panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:26:41 -07:00
commit 295eb22826
2165 changed files with 301492 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 7DTD module ships shell scripts that run as the container entrypoint.
# A CRLF here breaks the container's /bin/bash interpreter line and
# `set -euo pipefail`. Force LF regardless of the editor's platform.
*.sh text eol=lf
entrypoint.sh text eol=lf
+299
View File
@@ -0,0 +1,299 @@
# 7 Days to Die — Cluster Canonical `.nim` ID Freeze
> How the panel stops a 7DTD cluster's **shared player saves** from corrupting on
> crossserver transfer (items "morphing" into other items, characters resetting
> to level 1 / naked). Read this before touching cluster `.nim` files, the 7dtd
> `entrypoint.sh` freeze guard, the canonical, or regenerating a clustered world.
>
> Author: deployed live 20260613 on cluster `cl_37e3a7f72cdc` (Refuge).
> Companion code: `modules/7dtd/entrypoint.sh` (the guard),
> `modules/7dtd/nim-freeze/rebuild.py` (regenerate the canonical), and
> `modules/7dtd/nim-freeze/remap_7dt.py` + `REMAP_RUNBOOK.md` (newworld deco remap).
---
## ⚠️ CORRECTION / SECOND HALF OF THE PROBLEM (20260613, later same day)
The freeze below fixes **inventory** (`.ttp`) transfers — correct and live. But it is
**NOT sufficient for a freshlygenerated DIFFERENT world**, and that's why insane/pvp
came up "online but unjoinable" with a `DecoManager.TryAddToOccupiedMap` NullReference.
Root cause (proven): a world also bakes its **decorations** into `decoration.7dt`
(and structures into `multiblocks.7dt`) **by numeric block id**, using the engine's
**native runtime ids at generation time**. The cluster's current runtime (RefugeBot
v1.2.39) assigns the treedeco band at ids **21895+**, while the season10anchored
canonical assigns the same trees at **24141+** (season10's world was baked *before* a
RefugeBot rebuild shifted the band). Stamping the canonical over a new world's `.nim`
makes its nativebaked deco ids resolve to a **null Block** → NRE → worldload
coroutine dies → never `GameStartDone`. season10/creative load clean only because their
deco was baked when runtime == canonical.
**Fix for a new world = a onetime perworld deco remap** (`remap_7dt.py`): translate
`decoration.7dt`/`multiblocks.7dt` block ids `native → name → canonical`, preserving the
high/rotation bits, then stamp the canonical. The engine **preserves** loaded deco ids on
save (only writes native at *generation*), so the remap is **stable across reboots**.
`.7dt` format: `u8 ver=6, u32 count, count×17B recs; id = u16@+12 & 0x7FFF`. The cluster
runtime native table is identical across all instances (one capture is authoritative).
**Full stepbystep: `nim-freeze/REMAP_RUNBOOK.md`.** insane (Yixacove) + pvp (Tuxeno)
were fixed this way and verified: `GameStartDone`, `NRE=0`, deco band 24141, on canon.
Permanent removal of the perworld remap step: pin block ids explicitly in a toppriority
modlet (so runtime native == canonical) or freeze the RefugeBot build season10 was baked
under. Until then, every NEW clustered world needs this one remap.
---
## TL;DR
* 7DTD stores a player's **inventory by numeric ID, not by name**. Each *world*
bakes its own `name <-> id` lookup tables: `itemmappings.nim` and
`blockmappings.nim` in that world's save dir.
* Our cluster **shares one player save folder** across servers that each run a
**different world**. The shared `.ttp` carries one world's numbers; every other
world decodes them through a **different** table → glass block reads as car
hood, etc. Severe mismatches fail to load → fresh level1 character, written
back over the shared save = **permanent wipe**.
* **Fix:** build ONE *complete, season10anchored* canonical id table and **freeze
it onto every world in the cluster**. Because it preserves season10's exact ids
and contains every block/item the mods can produce, every world decodes the
shared `.ttp` identically and the engine never appends → it stays frozen.
* A small **entrypoint guard** restamps the canonical on every boot, so it's
selfhealing and survives recreates/regens.
---
## The problem (root cause, proven by forensics)
1. A player's `.ttp` inventory region is a stream of **2byte numeric ids** — no
item/block name strings (names only appear later, for namekeyed progression
like perks/recipes, which is why **level/skills survive** a transfer but
**inventory/placedblocks do not**).
2. Those numbers mean something only relative to the **perworld** `.nim` tables.
The engine logs `INF Block IDs with mapping` / `INF ItemIDs from Mapping` on
every world load — it resolves stored ids → names **through that world's
`.nim`**.
3. The `.nim` files are **NOT shared** — only the `Player/` folder is symlinked to
`/cluster/Player`. Each world baked its table independently at genesis, so the
same id maps to a different name per world. Live proof on the cluster:
* block id **9243** = `steelShapes:cube` on season10 but `awningShapes:cube`
on the others.
* item id **66/67** = the inverse swap `meleeHandPlayer <-> meleeHandZombie01`.
4. **Why identical mods don't save you:** ids for unpinned/modded content are
**autoassigned at world genesis in encounter order and frozen into that
world's `.nim`**. Two worlds generated at different times/states freeze
*different* assignments even from byteidentical XML. So modsyncing, mod
symlinks, identical load order — all irrelevant. The divergence lives in the
**saves** volume, not the mods.
5. The "naked / level 1" variant: the destination's table is missing ids the
`.ttp` references (e.g. season10 had 1888 item ids; a fresh RWG world's table
had ~133). Unresolvable ids are dropped; enough drops and the character loads
empty/level 1. Because the save is shared and **written back**, the wipe is
permanent (`.ttp` *and* `.ttp.bak` both overwritten).
This is the unsupported corner of 7DTD: **The Fun Pimps disable crossworld
characters for exactly this reason.** Sharing one inventory across servers running
*different worlds* only works if every world uses an identical, frozen id table.
---
## The fix: a complete, frozen canonical table on every world
### What "canonical" means here
A single pair of `.nim` files that is:
* **Anchored to season10** — every id season10 currently uses is preserved
**byteforbyte**. season10 authored the 87 shared inventories and has the
richest/oldest table, so the existing saves already match it. This makes the
freeze **nondestructive to season10's world and all existing inventories**.
* **Complete** — contains every block and item the mods can ever produce:
* all named items + **all 237 `item_modifier` names** (these serialize into
`itemmappings.nim` when installed — the missing21 of these was the live
charwipe vector),
* all named blocks + the **entire `base:shape` crossproduct** (10 shapehelper
bases × every shape in `shapes.xml`) + the engine's authoritative block dump.
Because it's complete, the engine never meets a name not already in the table →
the **append branch never fires** → the file is never rewritten → **frozen**.
Verified live on creative/season10: the `.nim` sha256 is byteidentical before and
after boot + worldload.
### Final numbers (cluster `cl_37e3a7f72cdc`)
| file | count | sha256 (prefix) |
|---|---|---|
| `blockmappings.nim` | 38,626 blocks (ids 0..63004) | `0a7db915…` |
| `itemmappings.nim` | 2,719 items (ids 1..3513) | `fa4af1df…` |
u16 ceiling is 65,535 — headroom remains.
---
## Components & locations
| What | Where |
|---|---|
| **Deployed canonical** (the source of truth the guard reads) | `figaro:/home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/{blockmappings.nim,itemmappings.nim}` — this is the host side of the shared `/cluster` bind mount, so it appears as `/cluster/canonical/` inside every member container. |
| **The freeze guard** | `modules/7dtd/entrypoint.sh`, the `--- cluster canonical .nim freeze guard ---` block. Baked into `panel-7dtd:latest`. |
| **Perworld `.nim`** (what gets stamped) | `<savesvol>/.local/share/7DaysToDie/Saves/<World>/<GameName>/{block,item}mappings.nim` |
| **Rebuild tool** | `modules/7dtd/nim-freeze/rebuild.py` (reanchor) |
| **Build artifacts / name registries** (for a fromscratch rebuild) | `figaro:/home/refuge/nimcanon/``tool/`, `registry/` (`blocks_shapes_complete.txt`, `item_modifiers_all.txt`, `blocks_named_currentconfig.txt`, …), `canonical_final/`, `src/` (anchor copies). Persisted out of `/tmp` so a figaro reboot doesn't lose them. |
---
## The entrypoint guard (the automation)
On every boot, after the cluster Playersymlink setup, the entrypoint runs:
```sh
if [ -f /cluster/canonical/blockmappings.nim ] && [ -f /cluster/canonical/itemmappings.nim ] \
&& [ -d "$SAVE_BASE" ]; then
cp -f /cluster/canonical/itemmappings.nim "$SAVE_BASE/itemmappings.nim"
cp -f /cluster/canonical/blockmappings.nim "$SAVE_BASE/blockmappings.nim"
fi
```
* `SAVE_BASE` = `Saves/$CLUSTER_PLAYER_SAVE` (set by the Clustertab "Shared player
world" picker), or the literal `Saves/$GameWorld/$GameName` fallback.
* Only fires for cluster members that have a `/cluster/canonical/` present →
noncluster servers and other clusters are untouched.
* **Idempotent + selfhealing**: a complete canonical never triggers an append, so
restamping the same bytes every boot is a noop; if a stray append ever
happened, the next boot resets it.
You'll see this line in the boot log when it works:
```
[panel-7dtd] cluster <id>: stamped canonical id maps over .../<World>/<GameName> (frozen, divergence-proof)
```
---
## Is it automated? (what's handsoff vs. operator action)
* **Handsoff:** every existing frozen world stays frozen forever; every
boot/recreate/`/rebuild` reapplies the canonical automatically.
* **One operator step for a NEW world:** when a clustered server **regenerates an
RWG world** (new seed), the new world's folder name is **seedderived and
unknown until after gen**, so the guard can't target it until you set the
Clustertab **Shared player world** (`CLUSTER_PLAYER_SAVE`). Set that (and the
Region Medic `world`) to the new world and the guard freezes it on the next
boot. This is the same oneclick step a normal cluster *join* already needs.
* **Rebuild needed only if mods change** (see below).
> Future hardening (not yet done): make the guard/symlink **autodetect** the
> active world dir (newest `Saves/*/<GameName>/` with a `main.ttw`) so even RWG
> regens need zero manual settings. Deliberately left as an explicit Clustertab
> setting for now.
---
## Operating
### Add a clustered server / regenerate a clustered world (RWG)
Goal: fresh world, frozen on the canonical, sharing the player folder. (Fresh
worlds are clean — worldgen places vanilla blocks whose ids already match the
canonical, so **no morph**.)
1. **New map:** change the seed — `POST /api/instances/<id>/env-config` body
`{"updates":{"world_seed":"<new>"}}`. The engine gens a new seednamed world.
2. Find the new world dir (newest `Saves/*/<GameName>/main.ttw`).
3. **Point the cluster settings at it:**
* `POST .../env-config` `{"updates":{"CLUSTER_PLAYER_SAVE":"<NewWorld>/<GameName>"}}`
→ recreate → guard stamps it + symlinks `Player -> /cluster/Player`.
* Write `region-medic.json` `"world": "<NewWorld>/<GameName>"` into the saves
volume (keep `enabled`/`keep`/`discord_channel`).
4. Confirm the boot log shows the "stamped canonical" line and the world `.nim`
sha256 == the cluster canonical.
### After a mod change (new blocks/items added) — REBUILD the canonical
The canonical is a static snapshot of the merged config's name universe. New mods
add names it doesn't have, so those would append perworld again.
1. Reharvest the complete name set (the two ultracode workflows did this; the
essential outputs are in `~/nimcanon/registry/`):
* blocks: `<base>:<shape>` crossproduct from `shapes.xml` × the 10 shapehelper
bases, the engine's authoritative block dump (`exportcurrentconfigs` /
runtime `Block` list), named blocks.
* items: every `<item name=>` every `<item_modifier name=>`
(`grep -rhoE '<item_modifier name="[^"]+"' Data/Config/item_modifiers.xml Mods/*/Config/item_modifiers.xml`).
2. STOP season10, copy its current `.nim` as the **anchor**, run `rebuild.py`
(see its header) to produce a reanchored, extended, complete canonical.
3. Verify `GATE PASS: True` (0 anchor ids altered, no dup id/name, complete,
max id < 65536).
4. Copy into `…/<cluster_id>/canonical/` (chmod 644) and `/rebuild` each member.
### Verify a server is frozen
```sh
# .nim on disk == the cluster canonical:
docker run --rm -v panel-<id>-saves:/sv:ro debian:12-slim \
sha256sum "/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>/blockmappings.nim"
# vs:
sha256sum /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/blockmappings.nim
# and the boot log:
docker logs panel-<id> 2>&1 | grep "stamped canonical"
```
The **real** test is ingame: transfer a player between two members and confirm
glass stays glass, inventory intact, no level reset.
### Troubleshooting / gotchas
* **`/rebuild` left the server STOPPED.** If an instance was stopped *before*
`/rebuild`, the recreate suppresses autostart and leaves it `Created`/stopped.
Just **Start** it — the guard runs on boot.
* **Guard didn't stamp / wrong dir.** `CLUSTER_PLAYER_SAVE` is empty or stale →
for RWG the fallback `Saves/RWG/<GameName>` doesn't exist, so the guard skips.
Set the Clustertab Sharedplayerworld to the real (seedderived) world.
* **season10 grew between snapshot and stamp.** season10's `.nim` appends as
players place new shapes (we saw 12259 → 12263 in a few hours). **Always
reanchor (`rebuild.py`) against season10's CURRENT, STOPPED `.nim` immediately
before stamping season10.** Once season10 is frozen on the complete canonical it
stops growing.
* **Stamp only while the target is STOPPED**, as the runtime uid (`1000:1000`),
mode `0644`. An unreadable `.nim` makes the engine silently gen a fresh table =
full remap.
* Old orphaned worlds (e.g. `Tefasizi County`, `Kopaneke Territory`) are left in
the saves volumes after an RWG regen — safe to delete to reclaim space.
---
## If we need to pivot (alternatives, ranked)
If the freeze ever proves unworkable (e.g. a mod that registers ids at **runtime**
— none currently do; all 7DTD content here is static XML):
1. **One shared world** across the whole cluster (same GameWorld+seed+GameName).
One world → one `.nim` → impossible to diverge. Cost: every server is the SAME
map (defeats distinctexperience servers). Simplest and bulletproof.
2. **Don't carry numericid state across worlds.** Share only namekeyed
progression (level/skills/perks/recipes — these already transfer cleanly) and
**strip bag/belt/equipment** on join to a world that didn't author the
inventory (an admin/RefugeBot hook). Players keep their character, not carried
items. Preserves distinct worlds.
3. **Reencode on transfer** (true but fiddly): decode the `.ttp` inventory
through the SOURCE world's `.nim` and reencode through the DESTINATION's,
dropping ids the destination lacks. Must run while the player is offline.
4. The current approach (**freeze a complete canonical**) is strictly better than
"sync the `.nim` across different worlds" — never try that bare, because the
`.nim` is also the codebook for each world's OWN placed blocks: swapping it on
a world with existing modded builds **morphs that world's terrain** (that's why
insane/pvp got fresh worlds instead of an inplace stamp).
---
## Key facts to remember
* Inventory/placed blocks = **numeric id**; level/skills/perks/recipes =
**namekeyed** (the latter survive any transfer).
* `.nim` is written **only on table growth**, not per save — a complete table is
therefore frozen.
* The canonical must be **anchored to season10** (the authoring world) or existing
inventories morph.
* New RWG world folder names are **seedderived**; you must set
`CLUSTER_PLAYER_SAVE` + medic `world` to the real name after gen.
* The deployed canonical lives in the **shared cluster dir** (`/cluster/canonical`)
so one copy serves every member and the guard is purely local file ops.
+341
View File
@@ -0,0 +1,341 @@
# 7 Days to Die — Cluster Playbook
> The single operator + architecture doc for the Refuge 7DTD cluster: how shared
> characters/inventory work, how new servers auto-fix themselves on create, the
> one rule you must never break, and exactly what we do at the 3.0 wipe.
>
> Deep "why" lives in `CANONICAL_NIM_FREEZE.md` (root cause) and the manual
> fallback in `nim-freeze/REMAP_RUNBOOK.md`. **This doc is the one to read first.**
---
## 1. The one thing to understand
7DTD stores everything — your inventory, the blocks in the world, the trees —
by a **numeric id**, not by name. The engine hands out those numbers automatically
when it boots, and the assignment **drifts** whenever the *build* changes. "Build"
= the exact set of mods + their load order + the RefugeBot DLL version + the engine
version. Nothing else matters.
* **Same build everywhere → same numbers everywhere.** Inventory transfers cleanly,
worlds load. (Proven: two cluster servers on the same build produce byte-identical
id tables.)
* **Different builds → different numbers → "morph".** A glass block saved as id 21895
on one server reads as a car hood on another that put a different block at 21895.
Characters can even reset to level 1.
Everything below — the canonical table, the stamp, the decoration remap, the
auto-provision — exists for **one** reason: to bridge servers/worlds that were
baked under *different* builds so they all agree on the numbers.
---
## 2. The golden rule (non-negotiable)
> **Every server in the cluster must run the byte-identical build at all times.**
Same mods, same load order, same RefugeBot build, same game version — on **all**
members, always. Break this and inventory morphs. The build *can* evolve over time,
but only as a **coordinated, all-servers-at-once maintenance event** (see §6), never
one server at a time.
---
## 3. The two moving parts
| Part | What it is | Who maintains it |
|---|---|---|
| **The canonical** | One frozen `name ↔ id` table (`blockmappings.nim` + `itemmappings.nim`) every server stamps on, so the **shared player inventory** (`.ttp`) decodes identically everywhere. Lives at `/cluster/canonical/` (host: `…/7dtdcluster/<id>/canonical/`). | `nim-freeze/rebuild.py` (re-anchor / extend) |
| **The decoration remap** | A world bakes its **decorations** (`decoration.7dt` / `multiblocks.7dt`) with the engine's *native* ids at generation. Those must be rewritten to the canonical's ids or the world NREs on load. | `nim-freeze/remap_7dt.py`, now **automatic** (see §4) |
Why both: the canonical fixes **inventory**, the remap fixes **the world the
decorations live in**. The deep mechanics are in `CANONICAL_NIM_FREEZE.md`.
---
## 4. How it works now — auto-provision on create (the steady state)
You don't run any of the remap tooling by hand anymore. The game container's
entrypoint + the agent do it for you. A brand-new clustered world becomes
canon-compatible **on its own**, in two boots:
1. **Gen boot.** You create the server (cluster picked), Update (downloads the
game), and Start. The engine generates the world and bakes its decorations
with native ids. The world is up but *not yet aligned* to the cluster.
2. **Align boot (automatic).** The agent's auto-provision watcher notices the
fresh world's decorations are in the native band and **bounces the container
once**. On that boot, the entrypoint remaps `decoration.7dt` + `multiblocks.7dt`
native → canonical (using the world's own native table), drops a
`.canon-provisioned` marker, and stamps the canonical. The world comes back
**clean, on canon, joinable, transfer-safe.**
**What you see (visibility):** in the server's **Console**
`[panel] auto-provision: aligning the new world's decorations to the cluster…`
then `remapped decoration.7dt native->canonical` then
`decorations aligned to cluster canonical — world is now canon-compatible` then
`StartGame done`. The card briefly shows **"aligning decorations to cluster…"**.
**It is a strict no-op for everything else:** existing canon worlds
(season10/creative/insane/pvp) and non-cluster servers never enter the remap
branch (their decorations are already in the canon band), so recreating/restarting
them does nothing. The remap is **stable** — once a world is canon, it stays canon
across every save and reboot (the engine preserves loaded decoration ids; it only
writes native ids at *generation*).
**The backstop:** even if the auto-bounce is ever missed, the entrypoint aligns the
world on its *next* ordinary restart. The auto-bounce just makes it zero-touch.
### Files that implement it (for the next engineer)
* `modules/7dtd/entrypoint.sh` — the remap block (right before the canonical-stamp
guard) + the `.panel-save-base` writer. Gated on the **decoration band** only.
* `modules/7dtd/Dockerfile` — adds `python3-minimal` + the vendored remapper.
* `modules/7dtd/nim-freeze/remap_7dt.vendored.py` — self-contained in-container remapper.
* `agent/internal/dispatch/decoremap_provision.go` — the watcher (single-flight,
probes the running container, bounces once on a native-band world).
---
## 5. Adding a new server (operator steps)
1. **Add server** in the panel, module **7 Days to Die**, and **pick the cluster**
in the create form. (This wires the shared Player folder + the canonical mount.)
2. **Update** (installs the game) → **Start**. Let it generate the world.
3. **RWG worlds only:** once the world has generated, set the **"Shared player world"**
in the **Cluster tab** to the new (seed-named) world. The picker writes
`CLUSTER_PLAYER_SAVE` and triggers a recreate, so the entrypoint stamps + aligns the
right dir. **Also point Region Medic at the same `<World>/<GameName>`** (Cluster tab →
Region Medic card), or Medic keeps healing the old, now-deleted world. Until both are
set, alignment can't target the world.
4. That's it. The world auto-aligns to the cluster (watch the Console). When you see
`StartGame done` after the alignment bounce, it's joinable and transfer-safe.
> **Is the new world done? Don't let players join until all three pass:**
> 1. Console shows `StartGame done` *after* the `auto-provision: aligning…` line.
> 2. Save dir has a `.canon-provisioned` file.
> 3. Decorations read in the **24xxx** band — verify it yourself (read-only):
> ```sh
> docker run --rm -v panel-<inst>-saves:/sv:ro debian:12-slim sh -c \
> 'f="/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>/decoration.7dt"; \
> echo "first deco id = $(( $(od -An -tu2 -j17 -N2 "$f") & 0x7FFF ))"'
> ```
> `>= 24000` = canon (good). `< 24000` = still native (NOT aligned — do not let players in).
>
> **If after 2 boots there's still no `.canon-provisioned` / the band is native** (the
> §12 #3 case): do **one** more panel **Restart** — the entrypoint backstop aligns it on
> that boot — and re-check. Still native after that → manual repair via
> `nim-freeze/REMAP_RUNBOOK.md`.
---
## 6. Changing mods (a maintenance event, not a casual edit)
Because of the golden rule, a mod change is a **whole-cluster** operation:
1. Update **every** server's mods together (same set, same order, same RefugeBot
build). Never one server at a time.
2. **Rebuild the canonical** so it contains the new names (else they append per-world
again → morph). This is **two** steps — ⚠️ **`rebuild.py` does NOT discover new mod
content; it only re-anchors an existing name universe.** Doing 2b without 2a silently
produces an incomplete canonical and re-introduces the exact wipe bug this system
prevents.
* **2a — Re-harvest the complete name universe** (the hard part). Blocks = the
`<base>:<shape>` cross-product (`shapes.xml` × the 10 shape-helper bases) the
engine's authoritative block dump (telnet `exportcurrentconfigs`, or the runtime
`Block` list) every named block; items = every `<item name=>` every
`<item_modifier name=>`. The original harvest ran as two ultracode workflows; its
inputs/outputs live on figaro at `~/nimcanon/registry/` but **the generator is not
committed** — exact greps/commands are in `CANONICAL_NIM_FREEZE.md` → "After a mod
change". **Do not attempt a mod change without re-running this harvest.**
* **2b — Re-anchor** against season10's CURRENT, STOPPED `.nim` (the
`West Apeeni Mountains/MyGame` anchor — verify 38,626/2,719+ first, §13) with the new
universe: `nim-freeze/rebuild.py` preserves every existing id and appends new names
at fresh ids (so old worlds + the ~88 shared characters still decode). Verify
`GATE PASS: True`, then copy into `/cluster/canonical/` (chmod 644).
3. **Re-stamp every member** so they pick up the new canonical — a panel **Restart**
is enough (the entrypoint re-stamps from `/cluster/canonical` on every boot; season10
too, by Restart only — never Rebuild). New worlds genned after the change auto-align
(§4); existing worlds keep working (their content + the canonical's existing ids are
unchanged).
Adding content is safe (append). Removing/reordering content is where ids shift —
that's exactly what a **wipe** (§7) resets cleanly.
---
## 7. The 3.0 wipe roadmap (the easy button)
A version wipe is the moment to make the whole thing **bulletproof and remap-free**,
because you're discarding all the old worlds anyway. Plan:
1. **Freeze + hash the 3.0 build.** Lock the exact mod set + load order + RefugeBot DLL +
engine version, and record a hash of the artifact set (e.g. `tar c Mods/ | sha256sum`)
into a committed `3.0-build.lock`. That snapshot *is* the contract going forward.
2. **Wipe all worlds — through the panel, never docker CLI.** Per server: delete the old
`Saves/<World>/<GameName>` dirs (mount the saves volume, per the runbook's volume-access
pattern) and set a fresh `world_seed` via `env-config`, or delete-preserve-volumes +
recreate. Characters too — `.ttp` is build-dependent; a wipe is a fresh start for
inventories (carrying them across a build change needs the `.7rg`/`.ttp` codec work in §8).
3. **Re-anchor the canonical to the LIVE 3.0 runtime.** "Capture the current runtime's
table" = the `REMAP_RUNBOOK.md` **"CAPTURE the world's native table"** sub-procedure: on
a fresh 3.0 world, Stop → move the canonical aside (back it up first!) → write the 8-byte
empty `.nim` → Start (the engine writes the world's authoritative native table) → copy it
out → restore the canonical. Feed that as the new anchor to `rebuild.py` together with a
freshly-harvested 3.0 universe (§6 step 2a). Now **`canonical == runtime`** and the gap
that forces per-world remapping is gone.
4. **From then on, fresh maps just work with zero remap** — every world is born from the
same starting numbers. The auto-provision/stamp machinery stays in place as a harmless
safety net; it simply has nothing to fix.
5. Only a *future* mod change re-introduces the need to rebuild the canonical (§6).
> ⚠️ **The 3.0 wipe is irreversible and depends on the (currently uncommitted) §6 harvest
> workflow.** Do NOT execute it from this strategy alone — when 3.0 lands, write a dedicated,
> tested 3.0 runbook with the exact harvest + capture + wipe commands first. The plan above
> is sound; the runnable commands are the gap to close before you pull the trigger.
Net: **today** = same-mods rule + auto-provision handles new servers; **at 3.0** =
clean re-anchor and new maps need no special handling at all. It's a ~one-evening job
when 3.0 lands; nothing to prep now.
The truly permanent fix that removes even the maintenance events: pin every block/item
to an explicit `id=` in a top-priority modlet so the runtime can never drift from the
canonical (untested on this engine version — a worthwhile experiment), or freeze the
RefugeBot build the canonical was anchored under.
---
## 8. Known limits / frontier
* **RWG needs one pick.** A random-gen world's save folder name is seed-derived and
unknown until after gen, so the operator sets "Shared player world" once (§5 step 3).
Auto-align kicks in after that.
* **Regions + player `.ttp` are not remapped.** The codecs for `Region/*.7rg`
(explored chunk blocks) and `Player/*.ttp` (inventory) weren't cracked, so alignment
is done on a **fresh, 0-region world before exploration** (which is exactly when a
new server provisions). The 87 shared characters are fine (already canon-written).
Solving these codecs (decompile `Assembly-CSharp.dll`) is what would let you migrate
characters across a build change instead of wiping them.
---
## 9. Quick reference
| Want to… | Do this |
|---|---|
| Add a server | §5 (auto-aligns; RWG needs the one Cluster-tab pick) |
| Confirm a world is aligned | save dir has `.canon-provisioned`; decorations in 24xxx band |
| Change mods | §6 (all servers together + rebuild canonical) |
| Wipe for 3.0 | §7 (freeze build → wipe → re-anchor canonical to live runtime) |
| Manually fix a stuck world | `nim-freeze/REMAP_RUNBOOK.md` |
| Understand the root cause | `CANONICAL_NIM_FREEZE.md` |
| Re-anchor / extend the canonical | `nim-freeze/rebuild.py` |
| Deploy controller / agent / module | §10 (+ the `panel-deploy` skill) |
| Recover a wedged agent (config fields won't load / 504s) | restart `panel-agent` on figaro — §10 / §11 |
| Known issues & open work | §12 (season10 console, the "setting up" indicator, probe-path) |
| What's deployed vs. uncommitted | §13 |
---
## 10. Operational architecture & deploy (for the next engineer)
**Where it runs**
| Piece | Host | Notes |
|---|---|---|
| **Controller** (dashboard + gRPC) | kaiten `<user>@<controller-host>` | binary `/home/refuge/panel/bin/controller`, unit `panel-controller.service`, HTTP `:8180`, gRPC `:8443` |
| **Agent** (owns the docker game containers) | figaro `<user>@<agent-host>` | binary `/home/refuge/panel/bin/agent`, unit `panel-agent.service`, dials the controller |
| **Cluster shared dir** (the `/cluster` bind) | figaro `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/` | holds `canonical/` + `Player/`; bind-mounted into every member as `/cluster` |
| **Game containers** | figaro, image `panel-7dtd:latest` | named `panel-<instance>` (e.g. `panel-rg-season-10`) |
SSH everywhere with `~/.ssh/bubbly_ed25519`, user `refuge`. **The full deploy recipe is the `panel-deploy` skill — follow it.** The NOPASSWD sudoers is exact-match (single-service `systemctl` forms only).
**Panel access & conventions (read before driving anything)**
* **Dashboard:** `http://<controller-host>:8180` on the LAN (controller HTTP). Log in with your panel account. On kaiten itself the `http://127.0.0.1:8180/api/...` routes are unauthenticated — that's what the read-only diagnostic `curl`s in this doc use.
* **Server name → ids:** an instance's id is its slug. "Season 10" ⇒ instance id `rg-season-10` ⇒ container `panel-rg-season-10` ⇒ saves volume `panel-rg-season-10-saves`. List them all (with status) via `GET /api/instances`. Full live table in §13.
* **UI = API:** the `POST /api/instances/<id>/{env-config,start,stop,rebuild}` calls in these docs are exactly what the dashboard's Cluster-tab / Maintenance buttons fire. Use either — just target the right `<id>`.
* **"Stop / Start / Restart" always means the PANEL action** (agent-owned, clean shutdown). **NEVER `docker stop/start` a `panel-*` container** — the agent owns lifecycle + state reconciliation. Confirm a container is stopped via the grey card, or read-only `docker inspect -f '{{.State.Running}}' panel-<id>``false`. (Read-only `docker exec/logs/inspect` and `docker run` sidecars are fine.)
* **"Restart" vs "Rebuild" (load-bearing):** Restart = panel Stop then Start, **same image** — safe for any server *including season10*. **Rebuild/Recreate = adopts `panel-7dtd:latest`** — needed to pick up a module/image change, but **forbidden for `rg-season-10`** (it must stay on its old image; §13).
* **New entrypoint env vars MUST be declared in `module.yaml` `env:`** or the agent silently drops them (symptom: the var is empty at runtime even though you set it). `CLUSTER_ID` + `CLUSTER_PLAYER_SAVE` are already declared — add any new `CLUSTER_*` / provisioning var there too.
**Three deployable pieces**
1. **Controller** (Go + the dashboard HTML in `controller/cmd/controller/static/{new,index}.html`) — `GOOS=linux GOARCH=amd64 go build -o bin/controller-linux ./controller/cmd/controller`, scp to kaiten `bin/controller.new`, bak + swap + `systemctl restart panel-controller.service`.
* ⚠️ **After every controller deploy, HARD-refresh the browser (Ctrl-Shift-R).** A normal refresh serves the *old* cached JS against the new backend and the page half-breaks (fields won't load, console blank). It looks exactly like a real outage but is just stale cache — rule this out first.
2. **Agent** (Go) — `… go build -o bin/agent-linux ./agent/cmd/agent`, scp to figaro, bak + swap + `systemctl restart panel-agent.service`.
* Restarting the agent does **NOT** stop the game containers — it re-attaches/rehydrates them cleanly (stats + rcon + log streams re-open). Safe anytime; it is also the **recovery** for a wedged agent (§11).
3. **Module** (`modules/7dtd/{entrypoint.sh,Dockerfile,nim-freeze/remap_7dt.vendored.py}`) — sync the module dir Windows → kaiten → figaro, then build the image **on figaro** (where the containers run; kaiten holds the source-of-truth copy but builds nothing):
```sh
# from the Windows repo C:\Users\dbled\sources\panel :
scp -r modules/7dtd/ <user>@<controller-host>:/home/refuge/panel/modules/
ssh <user>@<controller-host> 'rsync -av /home/refuge/panel/modules/7dtd/ <user>@<agent-host>:/home/refuge/panel/modules/7dtd/'
ssh <user>@<agent-host> 'cd /home/refuge/panel/modules/7dtd && docker build -t panel-7dtd:latest .'
```
Existing containers **pin to the old image ID** — a container only adopts the new image via **Settings → Maintenance → Rebuild** (or delete-preserve-volumes + recreate).
* ⚠️ **Never Rebuild/recreate `rg-season-10`.** It is the live released server *and* the canonical anchor; it must stay on its current (old) image. `.Config.Image` shows the name `:latest` for every container — verify by image **ID**, not name.
**Do NOT `docker stop/start` a `panel-*` game container from the CLI** — let the panel do it (the agent owns lifecycle + state reconciliation). Read-only `docker exec`, `docker logs`, `docker inspect`, and `docker run` sidecars are fine.
---
## 11. Incident log & landmines
### 2026-06-13 — agent-wide hang (ExecCapture connection leak) ✅ FIXED + HARDENED
* **Symptom:** config fields wouldn't load on **any** server (the durable `serverconfig.xml` editor → `/api/instances/{id}/files/read` returned **HTTP 504 "timeout waiting for agent response"**); one server's console went blank; the agent was **idle (0% CPU) but unresponsive to file ops**; no panic.
* **Root cause:** the auto-provision watcher (`agent/internal/dispatch/decoremap_provision.go`) polls a running container with a docker exec (`DockerRuntime.ExecCapture`) every 20s. `ExecCapture` ran `stdcopy.StdCopy(…att.Reader)` on the hijacked exec stream **with no `ctx` enforcement on the Read**. When it caught the just-bounced `cluster-seed-test` mid-restart, the read blocked **forever** and leaked a docker SDK connection each cycle → the SDK client's connection pool exhausted → **every** later docker op (`CopyFileFromContainer` for config reads, a log-stream re-attach) hung. It is **not** a Go-mutex deadlock (the only mutex in `docker.go` — `hijackedStream.mu` — just guards the stdio-attach `Close()` and is unrelated); it's connection-pool starvation.
* **Recover:** `ssh <user>@<agent-host> 'sudo -n /usr/bin/systemctl restart panel-agent.service'` — clears the leaked connections; game containers keep running. **Confirm recovery:** re-open any server's Config tab (fields load within a few seconds), or on kaiten `curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8180/api/instances/rg-season-10/files/read?path=/game-saves/serverconfig.xml'` → expect **200**, not 504.
* **Permanent fix (shipped):** `ExecCapture` now runs `StdCopy` on a goroutine and `select`s on `ctx.Done()`; on timeout it `att.Close()`s to unblock the read, then drains the goroutine before reading the buffers (race-free). On the timeout path it returns the partial stdout/stderr with exit code `-1` and `exec copy: <ctx err>`, so callers can tell a wedged exec from a clean one. An exec can no longer hang the whole agent.
* **Landmine for the future:** *any* agent docker exec/stream read that ignores `ctx` can leak connections and wedge the entire agent the same way. If you add exec-based features, bound the read by `ctx`. (The log-stream demux at `docker.go:271` is a long-lived `Follow` stream — a different model, closes on the docker `rc`'s EOF/ctx — and is fine.)
* **Recognize it next time:** `files/read` 504s for *every* server + agent at 0% CPU + no crash ⇒ restart the agent first, then hunt for a new unbounded docker read.
---
## 12. Known issues & open work
1. **season10's panel console is blank** (separate from §11; pre-existing). season10's docker json-log has grown to **13 MB** and it is actively spamming exceptions: `No ItemClass entry for type 678xx` (×611) + `Log:Exception` / `ThreadManager:UpdateMainThreadTasks`. Docker's own `--follow --tail 400` serves season10 instantly and the other four servers stream fine, but the agent's SDK log-follow **stalls on season10's stream before forwarding a single line** (blocked-inside, not thrashing — no "log stream ended" retries). Re-attaching (two agent restarts) did **not** fix it. It will likely clear on a **season10 container restart** — a plain panel **Stop → Start**, which keeps season10 on its current image and is safe; this is **not** a Rebuild/recreate (that would adopt `panel-7dtd:latest` and break the canonical anchor — forbidden, see §10/§13). It may re-blank once the exception spam refills the log. **Durable fix:** resolve the `No ItemClass entry` item-config gap (kills the console stall, the log bloat, *and* the exceptions at once), **or** harden the agent log-follow — mechanism not fully pinned (the agent has no pprof endpoint, so no clean goroutine dump was possible). **Do NOT recreate season10 to fix this** (old-image constraint, §10).
2. **The "setting up" provisioning indicator doesn't surface.** The agent watcher emits `detail="Finishing cluster setup…"` and the dashboard renders a matching amber "setting up" pill — but the agent's own **state-reconciliation loop emits `detail="announced:running"` a beat later and overwrites it**, so the card flips green prematurely. The **Console line** (`auto-provision: aligning the new world's decorations to the cluster…`) *does* show and survives a refresh — that part works. Proper fix: a **dedicated provisioning state field** the reconciler respects, not the shared `detail` string. (The card-pill matcher is live but dormant in `new.html`/`index.html`; harmless.)
3. **Auto-provision watcher probe-path caveat (zero-touch isn't universal yet).** The watcher gates "gen finished" on `main.ttw` **only**; it reads `decoration.7dt` solely to detect the already-canon (≥24000) case. A *missing* `decoration.7dt` still yields `NEEDS_ALIGN` and a bounce — the graceful-stop save writes the native deco, which the entrypoint then remaps on the align boot. For some RWG worlds (e.g. Tuxeno on `cluster-seed-test`/pvp) that two-step doesn't always *complete* zero-touch, but the **entrypoint backstop still aligns the world on its next ordinary restart**, so it ends up correct. Worlds that bake deco at the save path at gen (e.g. refuge-mall) provision fully zero-touch. **Treat the entrypoint remap as the reliable path; the watcher bounce is the optimization.**
4. **`No ItemClass entry for type 678xx` on season10** (root of #1). A set of high-band item ids (mod / RefugeBot runtime items) the merged config references but doesn't define. Tracing the missing items cleans up the console stall, the 13 MB log bloat, and the on-server exceptions in one shot. Not urgent — season10 runs fine (20 FPS, players on).
---
## 13. Handoff status — what's deployed, what's uncommitted
**Cluster:** `cl_37e3a7f72cdc`. Shared `Player/` ≈ 88 `.ttp`. Live members:
| Server | Instance id | Container / saves volume | Active world (`CLUSTER_PLAYER_SAVE`) | Role |
|---|---|---|---|---|
| Season 10 | `rg-season-10` | `panel-rg-season-10` / `panel-rg-season-10-saves` | `West Apeeni Mountains/MyGame` *(canonical anchor)* | **master / anchor — DO NOT Rebuild** |
| Creative | `creative` | `panel-creative` / `panel-creative-saves` | `Tefasizi County/Refuge` | creative |
| Insane Survival | `insane-survival` | `panel-insane-survival` / `panel-insane-survival-saves` | `Yixacove Valley/Refuge` | survival |
| PvP | `cluster-seed-test` | `panel-cluster-seed-test` / `panel-cluster-seed-test-saves` | `Tuxeno Territory/RefugeTHREEPVP` | pvp |
| Mall | `refuge-mall` | `panel-refuge-mall` / `panel-refuge-mall-saves` | `New Vebape Territory/RefugeMall` | mall |
> **Canonical anchor — the single highest-blast-radius fact.** The complete frozen table (**38,626 blocks / 2,719 items**) lives ONLY at season10's `…/Saves/West Apeeni Mountains/MyGame/{block,item}mappings.nim`. season10's *other* save dirs are stale partials (e.g. `Navezgane/MyGame` = 3,356 blocks, `Iupu Territory/Troutdale` = 5,028) and its `CLUSTER_PLAYER_SAVE` env is **empty** (old image, predates the picker — so don't trust the env to find the anchor). Any re-anchor (§6 / §7) MUST copy from exactly `West Apeeni Mountains/MyGame` and **verify the counts read 38,626 / 2,719 (or higher — never lower)** before running `rebuild.py`. Anchoring against a partial silently wipes the ~88 shared characters.
**Deployed (live):**
* **Controller** on kaiten — has the create-form cluster note + the auto-provision "starting" chip + the (dormant) "setting up" card-pill code.
* **Agent** on figaro — has the auto-provision watcher **and** the hardened `ExecCapture` (§11).
* **Image `panel-7dtd:latest`** on figaro — has the entrypoint remap block + `python3-minimal` + the vendored `remap_7dt.py`. Running on creative / insane-survival / cluster-seed-test / refuge-mall.
* **`rg-season-10` deliberately on the OLD image** (no `.panel-save-base`, no remap block) — the live anchor; do not move it.
* **refuge-mall** — created via the panel this session and registered as a RefugeBot tenant (prod DB row Id=31 + `refugebot.json` in its saves volume), connected.
**⚠️ UNCOMMITTED — nothing from this work is in git.** `C:\Users\dbled\sources\panel` is a git working tree but all of the below is uncommitted: the controller dashboard edits, the agent Go (`decoremap_provision.go`, the `dispatch.go` hook + `provisionGuard`, the `ExecCapture` hardening), the module (`entrypoint.sh` remap block, `Dockerfile`, `nim-freeze/remap_7dt.vendored.py`), and these docs. **Rollback points** if a deploy goes bad: `controller.bak-<ts>` on kaiten, `agent.bak-<ts>` on figaro (newest = the binary just before the current one).
**If/when you commit,** suggested grouping: (1) canonical + deco freeze/remap, (2) auto-provision (entrypoint + watcher), (3) the `ExecCapture` hardening, (4) docs. Note in the message that the cluster is **live** so a reviewer treats it as load-bearing.
---
## 14. Disaster recovery — the three irreplaceable trees
These three live on figaro and **cannot be regenerated from the repo** — they *are* the source of truth. If figaro's disk dies, this is what hurts:
| Tree | Path on figaro | What it is | If lost |
|---|---|---|---|
| **The canonical** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/canonical/{block,item}mappings.nim` | the frozen stamp source every member uses | rebuildable from season10's `West Apeeni Mountains/MyGame` anchor via `rebuild.py` (needs `~/nimcanon`) |
| **Shared characters** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/Player/` (= `/cluster/Player`) | the ~88 player `.ttp` (every character + inventory) | **unrecoverable** — everyone resets to level 1 |
| **The rebuild toolchain** | `~/nimcanon/` (`tool/`, `registry/`, `canonical_final/`, `remap/`) | the only parser + name registry + capture tooling to rebuild any of the above | must re-harvest from scratch (the §6 harvest — currently uncommitted) |
> ⚠️ **Open risk — confirm these are actually backed up off-figaro.** `~/nimcanon` is persisted out of `/tmp` (survives a reboot) but a disk loss is unaddressed in these docs. **Recommendation:** snapshot all three trees to another host on a schedule (reuse the panel's backup system or a periodic `tar`). Restore order: canonical → Player → validate with an in-game transfer (CANONICAL_NIM_FREEZE "Verify a server is frozen"). Treat this as the **top open risk** until a backup is confirmed.
+71
View File
@@ -0,0 +1,71 @@
# panel-native 7 Days to Die runtime image.
#
# Unlike third-party images (vinanrra/LGSM, didstopia, etc.), this image does
# ONE thing: run a 7DTD dedicated server. Download + validate is handled by
# the panel's SteamCMD updater sidecar, which writes into the same Docker
# volume we mount read/write here. No LGSM, no tmux, no GitHub-fetch on
# start, no backup-before-start "surprises" — just the game.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed 7DTD binaries (panel volume)
# /game-saves — save + config data (panel volume)
# /entrypoint.sh — this image's entrypoint (renders serverconfig.xml and
# execs the game with stdout/stderr unbuffered)
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
# Runtime deps 7DTD's Linux build needs. lib32* for the 32-bit Steam bits,
# libxi/libxrandr for the server's Unity runtime probes, ca-certs so any
# outbound HTTPS (analytics, steam auth) resolves.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
python3-minimal \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Unprivileged user that owns /game. The updater sidecar writes as root, so
# at start time we chown once to panel (cheap since SteamCMD leaves a known
# set of files). This keeps the long-running game process non-root.
RUN groupadd -g 1000 panel \
&& useradd -u 1000 -g 1000 -m -s /bin/bash panel \
&& mkdir -p /game /game-saves \
&& chown -R panel:panel /game /game-saves
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Vendored nim-tools: the in-container decoration remapper used by the cluster
# auto-provision step in entrypoint.sh. Self-contained (nimtool.read_nim inlined),
# python3-minimal (above) is its only dependency. A new clustered world's native
# decorations are remapped to the cluster canonical here so it loads clean.
COPY nim-freeze/remap_7dt.vendored.py /opt/panel-nimtools/remap_7dt.py
# Panel-owned helper mod — exposes blood-moon status as a JSON file the
# scheduler reads to defer restarts through hordes. Lives in /opt so the
# entrypoint can copy it into /game/Mods/ on every boot (so a steam
# validate or operator overwrite re-seeds it).
COPY mod-source/PanelBloodMoonAgent/bin/Release/net48/PanelBloodMoonAgent.dll /opt/panel-mods/PanelBloodMoonAgent/PanelBloodMoonAgent.dll
COPY mod-source/PanelBloodMoonAgent/ModInfo.xml /opt/panel-mods/PanelBloodMoonAgent/ModInfo.xml
# Game UDP ports — published by the panel manifest at run time.
EXPOSE 26900/udp 26901/udp 26902/udp
# Telnet RCON
EXPOSE 8081/tcp
# tini reaps zombies + forwards signals cleanly so our SIGTERM → graceful stop
# actually lands on the 7DTD process (otherwise docker stop becomes kill -9).
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+22
View File
@@ -0,0 +1,22 @@
# 7 Days to Die module
Steam app id `294420`. Dedicated server runs on Windows and Linux. Configuration is a single `serverconfig.xml` with `<property name="X" value="Y"/>` entries — the panel's schema loader needs an XML-format adapter (not `.properties`).
## RCON surface
7DTD does not speak Source RCON. The admin interface is **telnet on port 8081**, line-oriented, with a password prompt on connect. The module sets `rcon.adapter: telnet`. Commands are plain strings (`lp`, `say "..."`, `kick`, `shutdown`).
## Log patterns
Join and leave patterns use single-quoted fields — `PlayerName='Bob'`. These are stable across A19A22. The panel's log tailer uses named regex groups; extracted fields map into `PlayerEvent.player_id`, `player_name`, etc.
## Ports
- `26900/udp` — game + Steam query
- `26901/udp`, `26902/udp` — peer-to-peer data
- `8080/tcp` — optional web admin
- `8081/tcp` — telnet admin (internal — should NOT be exposed outside the Target)
## Updates
Via SteamCMD, `app_update 294420`. Two branches are surfaced: `stable` and `latest_experimental` (the latter via SteamCMD `beta: latest_experimental`) — see `module.yaml` `update_providers`. The telnet password is auto-generated on first install **only if `TelnetPassword` is empty**, and stored plaintext at `/game-saves/.panel-telnet-password` (also surfaced in the Config tab). Read it there to telnet/RCON in manually during an incident.
+341
View File
@@ -0,0 +1,341 @@
#!/bin/bash
# panel-native 7DTD entrypoint.
#
# Contract:
# /game — SteamCMD-installed dedicated server (volume, populated by
# the panel's SteamCMD updater sidecar). If missing, exit
# with a helpful message telling the operator to run Update.
# /game-saves — save + config writable volume.
# $TELNET_PASSWORD — injected by the panel via a module secret; rendered
# into serverconfig.xml so telnet RCON matches.
#
# On every start we re-render serverconfig.xml from the bind-mounted
# template so operator edits to module values take effect without needing a
# Docker image rebuild. Saves and world data live in /game-saves so a steam
# validate won't ever touch them.
set -euo pipefail
log() { printf '[panel-7dtd] %s\n' "$*"; }
GAME_DIR=/game
SAVE_DIR=/game-saves
CONFIG=${SAVE_DIR}/serverconfig.xml
TEMPLATE=${SAVE_DIR}/serverconfig.xml.rendered
# Allocator-derived ports. Panel sets these via container env (manifest's
# ports[].env: + runtime.docker.env: declarations). Defaults mirror the
# canonical 7DTD numbers so single-instance setups still work without
# the panel allocator.
GAME_PORT="${GAME_PORT:-26900}"
TELNET_PORT="${TELNET_PORT:-8081}"
WEBDASH_PORT="${WEBDASH_PORT:-8080}"
# --- sanity: game files installed? ---
if [[ ! -x "${GAME_DIR}/startserver.sh" && ! -x "${GAME_DIR}/7DaysToDieServer.x86_64" ]]; then
log "ERROR: /game is empty."
log " Game files haven't been downloaded yet."
log " Run 'Update' on this server in the panel — that kicks off a"
log " SteamCMD sidecar which installs 7DTD into this volume."
exit 78 # EX_CONFIG
fi
# --- render serverconfig.xml ---
# The panel writes a pre-rendered serverconfig.xml.rendered into the save
# volume at create/update time (see module create hook). We symlink it into
# the game dir so the game binary finds it on the expected path.
if [[ -s "${TEMPLATE}" ]]; then
log "using panel-rendered config (${TEMPLATE})"
cp -f "${TEMPLATE}" "${CONFIG}"
elif [[ ! -s "${CONFIG}" ]]; then
log "no panel config found — using the shipped default in ${GAME_DIR}/serverconfig.xml"
if [[ -s "${GAME_DIR}/serverconfig.xml" ]]; then
cp -f "${GAME_DIR}/serverconfig.xml" "${CONFIG}"
else
log "ERROR: no default serverconfig.xml shipped with the game either — aborting"
exit 78
fi
fi
# Make sure the game reads OUR config (symlink from game dir → save dir).
ln -sf "${CONFIG}" "${GAME_DIR}/serverconfig.xml"
# --- auto-populate TelnetPassword if empty ---
#
# 7DTD is surprising here: if TelnetPassword="", the server *enables* telnet
# but binds it to the container's loopback only, so any host-mapped port is
# unreachable. The panel's RCON cannot connect → every "list players" call
# fails with `telnet auth: read password prompt: EOF`.
#
# Fix: on first boot, if TelnetPassword is empty we stamp a random one into
# the config + persist the plaintext to /game-saves/.panel-telnet-password
# so the Config tab can surface it to the operator. Once set, we never
# rotate it — operator owns it from then on.
PASS_FILE="${SAVE_DIR}/.panel-telnet-password"
if grep -Eq '<property name="TelnetPassword"[[:space:]]+value=""[[:space:]]*/>' "${CONFIG}" 2>/dev/null; then
if [[ -s "${PASS_FILE}" ]]; then
NEW_PW=$(cat "${PASS_FILE}")
log "using persisted TelnetPassword from ${PASS_FILE}"
else
NEW_PW=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
printf '%s' "${NEW_PW}" > "${PASS_FILE}"
chmod 600 "${PASS_FILE}" || true
log "generated random TelnetPassword (persisted to ${PASS_FILE})"
fi
# Replace the empty-password line in-place. Using | as sed delimiter so
# the replacement can contain / without escaping.
sed -i "s|<property name=\"TelnetPassword\"[[:space:]]*value=\"\"[[:space:]]*/>|<property name=\"TelnetPassword\" value=\"${NEW_PW}\"/>|" "${CONFIG}"
fi
# --- patch port properties from env on every boot ---
# Multi-instance safety: the panel allocator picks unique host ports per
# instance, the agent injects them as env vars, and we patch them into
# serverconfig.xml here so 7DTD actually binds them. Without this, two
# 7DTD instances on the same agent would both default to 26900/8081/8080
# and only one would launch successfully.
#
# 7DTD computes the LiteNetLib alt + p2p backup ports as ServerPort+1 and
# ServerPort+2 internally — only ServerPort needs to be patched. We DO
# allocate the +1/+2 slots in module.yaml so the panel knows they're taken.
log "patching ports: ServerPort=${GAME_PORT}, TelnetPort=${TELNET_PORT}, WebDashboardPort/ControlPanelPort=${WEBDASH_PORT}"
# NOTE: 7DTD 1.0 renamed the web port property "ControlPanelPort" → "WebDashboardPort".
# Both are patched so this works across versions; the legacy line is a harmless
# no-op on 1.0 configs (no ControlPanelPort property to match). Without the
# WebDashboardPort line every instance kept the template default 8080 and, on
# this host-network agent, all but the first to boot threw the Allocs WebServer
# "Address already in use" exception.
sed -i -E \
-e "s|(<property[[:space:]]+name=\"ServerPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${GAME_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"TelnetPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${TELNET_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"ControlPanelPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"WebDashboardPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
"${CONFIG}"
# --- point savegame / userdata into the save volume ---
# 7DTD writes saves + admin files under $HOME/.local/share/7DaysToDie by
# default. We redirect that whole tree into /game-saves so steam validate
# runs against /game can't stomp on world data.
mkdir -p "${SAVE_DIR}/.local/share/7DaysToDie"
export HOME=${SAVE_DIR}
# Surface the user data tree as /game/User so the file browser shows it
# alongside Data/, Mods/, steamapps/ — AMP-style flat root layout. The
# agent's lister uses `ls -lAL` which dereferences symlinks, so this
# renders as a real directory in the Files tab. Guard: only create if
# nothing's there OR what's there is already a symlink we can replace —
# never blow away a real directory an operator might have created.
if [[ ! -e "${GAME_DIR}/User" ]] || [[ -L "${GAME_DIR}/User" ]]; then
ln -sfn "${SAVE_DIR}/.local/share/7DaysToDie" "${GAME_DIR}/User"
else
log "WARN: ${GAME_DIR}/User exists as a real directory, leaving it alone"
fi
# --- cluster: share the Player save folder across cluster members ---
# When this instance is in a cluster, CLUSTER_ID is set and /cluster is a
# shared host bind mount (panel mount-override). We symlink this world's
# Player/ subfolder to /cluster/Player so every member reads/writes the same
# player .ttp files (shared progression). World data stays per-server.
if [[ -n "${CLUSTER_ID:-}" ]]; then
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
SAVES_ROOT="${SAVE_DIR}/.local/share/7DaysToDie/Saves"
# WHICH world's Player folder gets shared into /cluster/Player is chosen
# EXPLICITLY by the operator in the panel's Cluster tab ("Shared player
# world" picker). 7DTD names the save folder after the WORLD, and for
# random-gen worlds that name is seed-derived (e.g. "Tefasizi County", not
# the literal "RWG") and changes whenever a new map is generated — so a
# fixed GameWorld/GameName path silently orphans the symlink. The picker
# writes CLUSTER_PLAYER_SAVE = "<World>/<GameName>" and we use it verbatim.
# Until the operator picks one we fall back to the literal
# Saves/<GameWorld>/<GameName> (correct when GameWorld is already a concrete
# world name; an RWG server will just need a one-time pick after first boot).
if [[ -n "${CLUSTER_PLAYER_SAVE:-}" ]]; then
SAVE_BASE="${SAVES_ROOT}/${CLUSTER_PLAYER_SAVE}"
log "cluster ${CLUSTER_ID}: shared player world selected by operator -> ${CLUSTER_PLAYER_SAVE}"
elif [[ -n "${GW}" && -n "${GN}" ]]; then
SAVE_BASE="${SAVES_ROOT}/${GW}/${GN}"
log "cluster ${CLUSTER_ID}: shared player world = ${GW}/${GN} (literal; pick one in the Cluster tab if this server uses RWG)"
else
SAVE_BASE=""
log "WARN: CLUSTER_ID set but no CLUSTER_PLAYER_SAVE and GameWorld/GameName unparseable — skipping Player symlink"
fi
if [[ -n "${SAVE_BASE}" ]]; then
PLAYER_DIR="${SAVE_BASE}/Player"
mkdir -p "${SAVE_BASE}" /cluster/Player
# Record the resolved active-world save dir so the agent's auto-provision
# watcher can locate this world's decoration.7dt (to detect a fresh
# native-banded world that needs aligning to the cluster canonical).
printf '%s' "${SAVE_BASE}" > "${SAVE_DIR}/.panel-save-base" 2>/dev/null || true
# One-time migration: when this server has a real (non-symlink) Player
# dir from standalone play, seed the shared cluster dir from it so this
# server's existing progression becomes the cluster source of truth.
# Hardened for irreplaceable saves (e.g. Season 10):
# * only seed when the shared dir is still EMPTY (first member wins —
# never copy into an already-seeded cluster),
# * snapshot the local Player dir before touching it,
# * only delete the local copy once the seeding copy has SUCCEEDED, so
# a partial copy can never destroy the server's only Player files.
do_symlink=1
if [[ -d "${PLAYER_DIR}" && ! -L "${PLAYER_DIR}" ]]; then
if [[ -z "$(ls -A /cluster/Player 2>/dev/null)" ]]; then
snap="${SAVE_BASE}/Player.preseed-${CLUSTER_ID}"
log "cluster ${CLUSTER_ID}: seeding empty shared Player dir from local saves (snapshot -> ${snap})"
cp -a "${PLAYER_DIR}" "${snap}" 2>/dev/null || log "WARN: pre-seed snapshot failed (continuing)"
if cp -a "${PLAYER_DIR}/." /cluster/Player/ 2>/dev/null; then
rm -rf "${PLAYER_DIR}"
else
log "ERROR: failed to seed /cluster/Player from ${PLAYER_DIR}; KEEPING local Player, NOT symlinking (no data lost)"
do_symlink=0
fi
else
archive="${SAVE_BASE}/Player.local-pre-cluster"
log "cluster ${CLUSTER_ID}: shared Player dir already populated; archiving local Player -> ${archive}, adopting shared progression"
rm -rf "${archive}" 2>/dev/null || true
mv "${PLAYER_DIR}" "${archive}" 2>/dev/null || rm -rf "${PLAYER_DIR}"
fi
fi
if [[ "${do_symlink}" == "1" ]]; then
ln -sfn /cluster/Player "${PLAYER_DIR}"
chown -R 1000:1000 /cluster/Player 2>/dev/null || true
log "cluster ${CLUSTER_ID}: ${PLAYER_DIR} -> /cluster/Player"
fi
# --- auto-provision: align a NEW world's native decorations to canonical ---
# A freshly-generated cluster world bakes decoration.7dt / multiblocks.7dt by
# NUMERIC block id using the engine's NATIVE id table at generation. The stamp
# guard below then puts the CANONICAL .nim over the world's .nim, so those
# native-baked deco ids resolve to a null Block -> DecoManager NRE -> the world
# loads but never reaches GameStartDone ("online but unjoinable"). So BEFORE
# stamping, if this world's decorations are still in the NATIVE id band, remap
# them name-by-name to canonical using THIS WORLD'S OWN native blockmappings.nim
# (which the engine wrote at generation and is still on disk this boot, before
# the stamp). Tree-deco band: native < 24000 / canonical >= 24000 (verified).
# STRICT no-op for already-canon worlds (season10/creative/insane/pvp -> deco in
# canon band, never enters the branch), non-cluster servers (outside CLUSTER_ID),
# and steady state (post-remap deco is canon-band). multiblocks.7dt uses the same
# full native table (NOT a deco subset). See nim-freeze/REMAP_RUNBOOK.md.
DECO_F="${SAVE_BASE}/decoration.7dt"
CANON_B="/cluster/canonical/blockmappings.nim"
NAT_NIM="${SAVE_BASE}/blockmappings.nim"
REMAP_TOOL="/opt/panel-nimtools/remap_7dt.py"
MARK="${SAVE_BASE}/.canon-provisioned"
if [[ -f "${DECO_F}" && -f "${CANON_B}" && -f "${NAT_NIM}" && -f "${REMAP_TOOL}" ]] && command -v python3 >/dev/null 2>&1; then
# first decoration block id = u16 @ file offset 17 (hdr 5 + record id-offset 12); mask to 15 bits.
first_raw="$(od -An -tu2 -j17 -N2 "${DECO_F}" 2>/dev/null | tr -d ' ' || true)"
[[ "${first_raw}" =~ ^[0-9]+$ ]] || first_raw=0
first_deco=$(( first_raw & 0x7FFF ))
if [[ "${first_deco}" -gt 0 && "${first_deco}" -lt 24000 ]]; then
# Native-band deco present. Safety: confirm the on-disk .nim is the world's
# NATIVE table (count != canonical count), so we remap against the right map
# rather than a previously-stamped canonical (which would silently morph).
nat_cnt="$(od -An -tu4 -j4 -N4 "${NAT_NIM}" 2>/dev/null | tr -d ' ' || true)"; [[ "${nat_cnt}" =~ ^[0-9]+$ ]] || nat_cnt=0
canon_cnt="$(od -An -tu4 -j4 -N4 "${CANON_B}" 2>/dev/null | tr -d ' ' || true)"; [[ "${canon_cnt}" =~ ^[0-9]+$ ]] || canon_cnt=0
if [[ "${nat_cnt}" -gt 0 && "${nat_cnt}" != "${canon_cnt}" ]]; then
log "cluster ${CLUSTER_ID}: new world '${SAVE_BASE##*/Saves/}' has native-band decorations (first id ${first_deco}); aligning decorations to cluster (remap native->canonical)…"
remap_ok=1
for f in "${DECO_F}" "${SAVE_BASE}/multiblocks.7dt"; do
[[ -f "${f}" ]] || continue
if python3 "${REMAP_TOOL}" apply "${NAT_NIM}" "${CANON_B}" "${f}" "${f}.canon-tmp" > "/tmp/decoremap.$$" 2>&1; then
cp -f "${f}" "${f}.native-bak"
mv -f "${f}.canon-tmp" "${f}"
chown 1000:1000 "${f}" 2>/dev/null || true
log "cluster ${CLUSTER_ID}: remapped $(basename "${f}") native->canonical ($(grep -oE 'records_rewritten=[0-9]+' "/tmp/decoremap.$$" || true))"
else
log "ERROR: cluster ${CLUSTER_ID}: remap of $(basename "${f}") FAILED — refusing to start (would be unjoinable). remap output:"
while IFS= read -r _l; do log " ${_l}"; done < "/tmp/decoremap.$$"
rm -f "${f}.canon-tmp" "/tmp/decoremap.$$"
remap_ok=0
break
fi
done
rm -f "/tmp/decoremap.$$" 2>/dev/null || true
if [[ "${remap_ok}" == "1" ]]; then
sha256sum "${CANON_B}" 2>/dev/null | cut -d' ' -f1 > "${MARK}" || true
chown 1000:1000 "${MARK}" 2>/dev/null || true
log "cluster ${CLUSTER_ID}: decorations aligned to cluster canonical — world is now canon-compatible"
else
exit 1
fi
else
log "WARN: cluster ${CLUSTER_ID}: native-band decorations but world .nim count=${nat_cnt} == canonical (${canon_cnt}); cannot identify the native table to remap against — leaving as-is (may NRE; see nim-freeze/REMAP_RUNBOOK.md to fix manually)"
fi
fi
fi
# --- cluster canonical .nim freeze guard ---
# Stamp the cluster's canonical block/item id maps over THIS world's .nim
# on every boot, so every member resolves the shared player inventory
# through the SAME id table. This eliminates the per-world id divergence
# that otherwise morphs inventory items (glass block -> car hood) and wipes
# characters to level 1 on transfer: 7DTD stores inventory by NUMERIC id,
# each world bakes its own name<->id table (itemmappings.nim/blockmappings.nim),
# and a shared .ttp decoded through a different table resolves every id to a
# different name. The canonical is the season10-anchored, registry-COMPLETE
# table (every block incl. the full base:shape cross-product, every item +
# every item_modifier), so the engine never has to append a fresh id -> the
# table stays frozen. It lives ONCE in the shared cluster dir
# (/cluster/canonical) and is applied only when the world save dir already
# exists (a freshly-generated world is frozen on its FIRST reboot).
# Idempotent + self-healing: re-stamped every boot, so a recreate/regen or
# any stray append can never silently re-diverge the cluster.
if [[ -f /cluster/canonical/blockmappings.nim && -f /cluster/canonical/itemmappings.nim && -d "${SAVE_BASE}" ]]; then
cp -f /cluster/canonical/itemmappings.nim "${SAVE_BASE}/itemmappings.nim" || log "WARN: canonical item-id-map stamp failed"
cp -f /cluster/canonical/blockmappings.nim "${SAVE_BASE}/blockmappings.nim" || log "WARN: canonical block-id-map stamp failed"
log "cluster ${CLUSTER_ID}: stamped canonical id maps over ${SAVE_BASE} (frozen, divergence-proof)"
fi
fi
else
# Not clustered: if a stale symlink to /cluster lingers from a prior
# membership, drop it so 7DTD recreates a local Player dir.
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
if [[ -n "${GW}" && -n "${GN}" ]]; then
PLAYER_DIR="${SAVE_DIR}/.local/share/7DaysToDie/Saves/${GW}/${GN}/Player"
if [[ -L "${PLAYER_DIR}" ]]; then rm -f "${PLAYER_DIR}"; fi
fi
fi
# --- sync panel-owned mods into /game/Mods/ ---
# /opt/panel-mods/ is baked into the image. We rsync (well, cp -a) each
# subdir into /game/Mods/ on every boot so:
# - a fresh SteamCMD install picks up our mods automatically
# - operator-deleted mods are restored
# - image rebuilds (with newer mod DLLs) propagate without manual work
# We DO NOT remove other operator-installed mods — only overwrite
# panel-prefixed ones (named "Panel*").
if [[ -d /opt/panel-mods ]]; then
mkdir -p "${GAME_DIR}/Mods"
for src in /opt/panel-mods/*/; do
name=$(basename "${src}")
dest="${GAME_DIR}/Mods/${name}"
log "panel mod sync: ${name}${dest}"
rm -rf "${dest}"
cp -a "${src}" "${dest}"
done
fi
# --- go ---
cd "${GAME_DIR}"
log "starting 7 Days to Die dedicated server"
log " game dir: ${GAME_DIR}"
log " save dir: ${SAVE_DIR}"
log " config: ${CONFIG}"
log " game port: ${GAME_PORT}/udp (+1, +2 implicit)"
log " telnet port: ${TELNET_PORT}/tcp"
log " control port: ${WEBDASH_PORT}/tcp"
# IMPORTANT: we deliberately do NOT use ./startserver.sh — the shipped
# script hardcodes `-logfile <file>`, routing all server output to a
# filesystem log that the panel can't tail without extra bind-mount
# gymnastics. Invoking the binary directly with `-logfile -` streams
# every line to stdout, which docker logs picks up, which the panel
# Console tab can surface in real time.
#
# LD_LIBRARY_PATH is what startserver.sh set — the game loads local .so
# files, not system ones, so this is mandatory.
export LD_LIBRARY_PATH=.
exec stdbuf -oL -eL ./7DaysToDieServer.x86_64 \
-configfile=serverconfig.xml \
-logfile - \
-quit -batchmode -nographics -dedicated
@@ -0,0 +1,436 @@
// PanelBloodMoonAgent — writes a tiny status JSON the panel scheduler
// reads to decide whether to delay a restart through a blood-moon horde.
//
// Status file path: /game-saves/.panel-bloodmoon-status.json
// (lives next to .panel-telnet-password — same volume the entrypoint
// uses for panel-owned state)
//
// Shape:
// {
// "schema": 1,
// "written_at_unix": 1763500000,
// "in_game_day": 14,
// "in_game_hour": 22,
// "in_game_minute": 3,
// "bloodmoon_freq": 7,
// "bloodmoon_range": 0,
// "day_night_length": 60,
// "next_horde_day": 14,
// "active": true,
// "ends_at_unix": 1763500900, // best-effort wall-clock end (0 = unknown)
// "source": "ai_director" // or "math_fallback"
// }
//
// We bind IModApi + Mod at compile time so the 7DTD loader finds our entry
// point through its normal reflection probe. EVERY other game type
// (GamePrefs, World, AIDirector, AIDirectorBloodMoonComponent, GameStats,
// GameUtils) goes through string-keyed reflection — the refugebotserver
// Agent's 2026-04-29 outage taught us that a hard reference to a missing
// EnumGamePrefs member makes Mono refuse to load the whole assembly.
// Type-name lookup keeps us version-tolerant.
using System;
using System.IO;
using System.Reflection;
using System.Threading;
namespace Panel.BloodMoonAgent
{
// IModApi is in Assembly-CSharp. The 7DTD ModManager scans loaded mod
// assemblies for public, non-abstract types implementing this interface
// and instantiates the first one it finds, then calls InitMod(Mod).
public class BloodMoonAgent : IModApi
{
public void InitMod(Mod _modInstance)
{
try
{
if (_instance != null) return; // idempotent — Mod manager only calls once, but be safe
_instance = this;
Start();
TryLog("InitMod completed; status file: " + OutputPath);
}
catch (Exception ex)
{
TryLog("InitMod failed: " + ex.Message);
}
}
private static BloodMoonAgent? _instance;
// Reflection handles — looked up once, cached. Null means "not in
// this game build" and we silently skip that data source.
private Type? _enumPrefsT;
private Type? _gamePrefsT;
private Type? _gameManagerT;
private Type? _gameUtilsT;
private Type? _gameStatsT;
private Type? _enumGameStatsT;
private Thread? _worker;
private volatile bool _stopRequested;
private const string OutputPath = "/game-saves/.panel-bloodmoon-status.json";
private const string OutputPathTmp = "/game-saves/.panel-bloodmoon-status.json.tmp";
private const int TickSeconds = 5;
private void Start()
{
_enumPrefsT = ResolveType("EnumGamePrefs");
_gamePrefsT = ResolveType("GamePrefs");
_gameManagerT = ResolveType("GameManager");
_gameUtilsT = ResolveType("GameUtils");
_gameStatsT = ResolveType("GameStats");
_enumGameStatsT = ResolveType("EnumGameStats");
_worker = new Thread(WorkerLoop) { IsBackground = true, Name = "PanelBloodMoonAgent" };
_worker.Start();
}
private static Type? ResolveType(string name)
{
try
{
var t = Type.GetType(name + ", Assembly-CSharp");
if (t != null) return t;
t = Type.GetType(name);
if (t != null) return t;
// Fallback: scan loaded assemblies. Some 7DTD builds put types
// in unexpected assemblies after Unity merges.
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var hit = asm.GetType(name);
if (hit != null) return hit;
}
catch { }
}
}
catch { }
return null;
}
private void WorkerLoop()
{
// Wait briefly so GameManager.Instance.World has populated. World
// is null during early boot; writing "active=false" too early is
// fine (panel falls back to "treat unknown as not-bloodmoon" =
// restart proceeds normally) but we'd rather get real data ASAP.
while (!_stopRequested)
{
try
{
var status = Snapshot();
WriteStatus(status);
}
catch (Exception ex)
{
TryLog("tick failed: " + ex.Message);
}
for (int i = 0; i < TickSeconds * 10 && !_stopRequested; i++)
{
Thread.Sleep(100);
}
}
}
// ── Snapshot ───────────────────────────────────────────────────────
private Status Snapshot()
{
var s = new Status
{
Schema = 1,
WrittenAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Source = "math_fallback",
};
// Game prefs (string-keyed, safe on missing members)
s.BloodMoonFreq = PrefInt("BloodMoonFrequency", 7);
s.BloodMoonRange = PrefInt("BloodMoonRange", 0);
s.DayNightLength = PrefInt("DayNightLength", 60);
// World time → day/hour/minute via GameUtils.WorldTimeToDays/Hours
ulong worldTime = 0;
object? world = GetWorld();
if (world != null)
{
worldTime = (ulong)(GetFieldValue(world, "worldTime") ?? 0UL);
}
if (worldTime > 0 && _gameUtilsT != null)
{
s.InGameDay = InvokeStatic<int>(_gameUtilsT, "WorldTimeToDays", worldTime);
s.InGameHour = InvokeStatic<int>(_gameUtilsT, "WorldTimeToHours", worldTime);
s.InGameMinute = (int)((worldTime / 60UL) % 60UL);
}
// Authoritative: ask the AIDirector's BloodMoonComponent.
// Path: GameManager.Instance.World.aiDirector.BloodMoonComponent.BloodMoonActive
// GameStats.GetInt(EnumGameStats.BloodMoonDay) → next horde day
bool gotAuthoritative = false;
try
{
if (world != null)
{
var aiDirector = GetFieldOrProp(world, "aiDirector");
if (aiDirector != null)
{
var bmComponent = GetFieldOrProp(aiDirector, "BloodMoonComponent");
if (bmComponent != null)
{
var activeObj = GetFieldOrProp(bmComponent, "BloodMoonActive");
if (activeObj is bool active)
{
s.Active = active;
gotAuthoritative = true;
s.Source = "ai_director";
}
}
}
}
}
catch (Exception ex)
{
TryLog("authoritative bloodmoon lookup failed: " + ex.Message);
}
// Next horde day via GameStats.GetInt(EnumGameStats.BloodMoonDay)
try
{
if (_gameStatsT != null && _enumGameStatsT != null
&& Enum.IsDefined(_enumGameStatsT, "BloodMoonDay"))
{
var key = Enum.Parse(_enumGameStatsT, "BloodMoonDay");
var m = _gameStatsT.GetMethod("GetInt", new[] { _enumGameStatsT });
if (m != null)
{
var v = m.Invoke(null, new[] { key });
s.NextHordeDay = Convert.ToInt32(v ?? 0);
}
}
}
catch (Exception ex)
{
TryLog("GameStats.GetInt(BloodMoonDay) failed: " + ex.Message);
}
// Math fallback for Active if AIDirector wasn't available.
// Window is in-game 22:00 (bloodmoon day) → 04:00 (next day).
// This is wrong when BloodMoonRange > 0 — the authoritative path
// is the correct answer in that case. We treat math as best-effort.
if (!gotAuthoritative && s.BloodMoonFreq > 0 && s.InGameDay > 0)
{
int day = s.InGameDay;
int freq = s.BloodMoonFreq;
bool todayIsBM = (day % freq == 0);
bool yesterdayWasBM = (day > 1) && ((day - 1) % freq == 0);
if (todayIsBM && s.InGameHour >= 22) s.Active = true;
else if (yesterdayWasBM && s.InGameHour < 4) s.Active = true;
// Also derive NextHordeDay from math if we didn't get it from GameStats
if (s.NextHordeDay == 0)
{
int rem = freq - (day % freq);
s.NextHordeDay = day + ((rem == 0) ? freq : rem);
}
}
// Compute EndsAtUnix when active: time until in-game 04:00 of the
// morning after the bloodmoon day, scaled by DayNightLength.
// DayNightLength = real minutes per in-game 24h day.
if (s.Active && s.DayNightLength > 0 && worldTime > 0)
{
int gameMinutesLeft;
if (s.InGameHour >= 22)
{
// 22:00 → 28:00 (04:00 next day) = up to 6h = 360 game-minutes
gameMinutesLeft = ((28 - s.InGameHour) * 60) - s.InGameMinute;
}
else if (s.InGameHour < 4)
{
gameMinutesLeft = ((4 - s.InGameHour) * 60) - s.InGameMinute;
}
else
{
// Mid-day bloodmoon? Only possible if AIDirector says so
// outside the normal window. Conservative: assume 360 game-min.
gameMinutesLeft = 360;
}
if (gameMinutesLeft < 0) gameMinutesLeft = 0;
// 1440 game-minutes per in-game day → real seconds per game-min:
// realSecPerGameMin = (DayNightLength * 60) / 1440 = DayNightLength / 24
double realSecondsLeft = (double)gameMinutesLeft * s.DayNightLength / 24.0;
s.EndsAtUnix = s.WrittenAtUnix + (long)Math.Ceiling(realSecondsLeft);
}
return s;
}
// ── GamePrefs helpers (mirrors refugebotserver's pattern) ──────────
private int PrefInt(string name, int fallback)
{
try
{
if (_enumPrefsT == null || _gamePrefsT == null) return fallback;
if (!Enum.IsDefined(_enumPrefsT, name)) return fallback;
var key = Enum.Parse(_enumPrefsT, name);
var m = _gamePrefsT.GetMethod("GetInt", new[] { _enumPrefsT });
if (m == null) return fallback;
return Convert.ToInt32(m.Invoke(null, new[] { key }) ?? fallback);
}
catch { return fallback; }
}
// ── Reflection helpers ─────────────────────────────────────────────
private object? GetWorld()
{
try
{
if (_gameManagerT == null) return null;
// GameManager.Instance is a static FIELD (not a property) in
// current 7DTD builds. Try field first, then property as a
// safety net for older/newer builds.
object? instance = null;
var instF = _gameManagerT.GetField("Instance",
BindingFlags.Public | BindingFlags.Static);
if (instF != null) instance = instF.GetValue(null);
if (instance == null)
{
var instP = _gameManagerT.GetProperty("Instance",
BindingFlags.Public | BindingFlags.Static);
if (instP != null) instance = instP.GetValue(null);
}
if (instance == null) return null;
return GetFieldOrProp(instance, "World");
}
catch { return null; }
}
private static object? GetFieldOrProp(object target, string name)
{
if (target == null) return null;
var t = target.GetType();
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (f != null) return f.GetValue(target);
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null) return p.GetValue(target);
return null;
}
private static object? GetFieldValue(object target, string name)
{
return GetFieldOrProp(target, name);
}
private static T InvokeStatic<T>(Type t, string methodName, params object[] args)
{
try
{
var argTypes = new Type[args.Length];
for (int i = 0; i < args.Length; i++) argTypes[i] = args[i].GetType();
var m = t.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static, null, argTypes, null);
if (m == null) return default!;
var v = m.Invoke(null, args);
return v == null ? default! : (T)Convert.ChangeType(v, typeof(T));
}
catch { return default!; }
}
// ── Output ─────────────────────────────────────────────────────────
private void WriteStatus(Status s)
{
string json = s.ToJson();
try
{
// Atomic write: write to .tmp, then rename. Reader sees either
// the old file or the new one, never a torn write.
File.WriteAllText(OutputPathTmp, json);
// File.Replace fails on Mono if destination doesn't exist; use
// a plain Move+Delete fallback.
if (File.Exists(OutputPath))
{
File.Delete(OutputPath);
}
File.Move(OutputPathTmp, OutputPath);
}
catch (Exception ex)
{
TryLog("write status failed: " + ex.Message);
}
}
// ── Logging ────────────────────────────────────────────────────────
// 7DTD's Log.Out is in Assembly-CSharp. We do best-effort reflection
// so logs surface in the game console; fall back to stderr otherwise.
private static Type? _logT;
private static MethodInfo? _logOutM;
private static bool _logResolved;
private static void TryLog(string msg)
{
try
{
if (!_logResolved)
{
_logT = Type.GetType("Log, Assembly-CSharp") ?? Type.GetType("Log");
if (_logT != null)
{
_logOutM = _logT.GetMethod("Out", new[] { typeof(string) });
}
_logResolved = true;
}
var line = "[PanelBloodMoonAgent] " + msg;
if (_logOutM != null)
{
_logOutM.Invoke(null, new object[] { line });
}
else
{
Console.Error.WriteLine(line);
}
}
catch { }
}
// ── Status DTO ─────────────────────────────────────────────────────
// Hand-rolled JSON — keeps the mod dependency-free (no Newtonsoft
// load, no risk of clashing with 7DTD's bundled copy).
private class Status
{
public int Schema;
public long WrittenAtUnix;
public int InGameDay;
public int InGameHour;
public int InGameMinute;
public int BloodMoonFreq;
public int BloodMoonRange;
public int DayNightLength;
public int NextHordeDay;
public bool Active;
public long EndsAtUnix;
public string Source = "";
public string ToJson()
{
return "{"
+ "\"schema\":" + Schema
+ ",\"written_at_unix\":" + WrittenAtUnix
+ ",\"in_game_day\":" + InGameDay
+ ",\"in_game_hour\":" + InGameHour
+ ",\"in_game_minute\":" + InGameMinute
+ ",\"bloodmoon_freq\":" + BloodMoonFreq
+ ",\"bloodmoon_range\":" + BloodMoonRange
+ ",\"day_night_length\":" + DayNightLength
+ ",\"next_horde_day\":" + NextHordeDay
+ ",\"active\":" + (Active ? "true" : "false")
+ ",\"ends_at_unix\":" + EndsAtUnix
+ ",\"source\":\"" + JsonEscape(Source) + "\""
+ "}";
}
private static string JsonEscape(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xml>
<Name value="PanelBloodMoonAgent" />
<DisplayName value="Panel Blood-Moon Agent" />
<Description value="Writes a small JSON status file the panel scheduler reads to delay restarts during blood moons." />
<Author value="panel" />
<Version value="0.1.0" />
<Website value="" />
</xml>
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Panel.BloodMoonAgent</RootNamespace>
<AssemblyName>PanelBloodMoonAgent</AssemblyName>
<OutputType>Library</OutputType>
<!--
We reference Assembly-CSharp.dll ONLY to bind IModApi + Mod at compile
time so the 7DTD loader's reflection probe finds our entry point. All
game DATA access (GamePrefs, World, AIDirector, etc.) goes through
string-keyed reflection (see BloodMoonAgent.cs) to dodge the
EnumGamePrefs landmine where a missing enum member stops Mono from
loading the whole assembly. The two-type binding is stable across 7DTD
versions — IModApi has had the same shape since A19.
Private=false means we don't ship Assembly-CSharp.dll alongside our DLL;
it's resolved at runtime from the game's own Managed folder.
Override LocalAssemblyCSharp on the command line to point at a different
game install:
dotnet build -p:LocalAssemblyCSharp=D:\path\to\Assembly-CSharp.dll
-->
<LocalAssemblyCSharp Condition="'$(LocalAssemblyCSharp)' == ''">C:\7dtdserver\7DaysToDieServer_Data\Managed\Assembly-CSharp.dll</LocalAssemblyCSharp>
<NoWarn>$(NoWarn);CS8632</NoWarn>
</PropertyGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>$(LocalAssemblyCSharp)</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
+326
View File
@@ -0,0 +1,326 @@
# 7 Days to Die — panel-native dedicated server module.
#
# Unlike the earlier vinanrra/LGSM-based revision, this module owns the
# whole stack:
#
# 1. A minimal Debian runtime image (see ./Dockerfile) is built locally
# as panel-7dtd:latest. It contains ONLY the libraries the game binary
# needs — no LGSM, no tmux, no GitHub-fetch-on-start, no surprise
# backups. Just the server process.
# 2. Game files are installed + validated into a Docker volume by the
# panel's generic SteamCMD updater (`agent/internal/updater/steamcmd.go`).
# That sidecar uses the official steamcmd/steamcmd:latest image.
# 3. The entrypoint renders our serverconfig.xml template, drops into the
# `panel` user, and execs startserver.sh under tini with line-buffered
# stdout so progress streams live to the panel console.
#
# First-run flow for operators:
# - Create instance in the panel → container spec created, volumes empty
# - Click "Update" on the instance → SteamCMD sidecar downloads ~17 GB
# - Click "Start" → entrypoint launches 7DTD directly
#
# Subsequent starts skip SteamCMD entirely — instant launch.
id: 7dtd
name: "7 Days to Die"
version: 0.4.0
authors:
- panel contributors
supported_modes:
- docker
# Create the instance but DON'T auto-start it. The operator finishes setup
# (world/RWG settings, cluster join + mod-seed, RefugeBot) and starts it
# manually. This also removes the warm-seed-then-immediate-start race that
# could OOM the agent host. The first SteamCMD update / warm-seed still runs on
# create (auto_update_on_create stays default-true); only the START is
# suppressed — enforced by a shouldAutoStart() guard in the agent's
# onUpdateComplete (the warm-seed completion hook), since that path isn't
# otherwise gated by this flag.
auto_start_on_create: false
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-7dtd:latest modules/7dtd
image: panel-7dtd:latest
# The Files tab exposes TWO roots — operators can switch between them
# with a dropdown at the top of the tab:
#
# Saves & Configs (/game-saves) — world data, serverconfig.xml,
# admin files. Default view.
# Game Files (/game) — binaries + Mods/ folder + Data/.
# Where you drop a mod tarball to install it.
#
# `browseable_root` is kept for backward compat (single-root pre-
# feature clients) but `browseable_roots` wins if both are set.
# Host networking — the container shares the host's network namespace,
# so any port the game OR a mod binds is directly on the LAN. No
# `-p host:container` publishing required; mods like RefugeBot can
# port-hunt freely (the AMP model — drop the .dll, mod sniffs free
# port, LAN_IP:<port> just works).
#
# Tradeoff: every 7DTD on this agent shares lo. The panel's port
# allocator + the env-mapped ports below keep distinct instances
# from clobbering each other on the canonical 7DTD ports.
network_mode: host
browseable_root: /game
browseable_roots:
- name: "Game Files"
path: /game
hint: "AMP-style flat layout: Data/, Mods/, steamapps/, User/, serverconfig.xml"
- name: "Saves & Configs"
path: /game-saves
hint: "Underlying save volume — telnet password, rendered template, raw .local tree"
# Pre-declare the port env keys so the agent's resolve.go filter lets
# panel-allocated values flow through to the container env (only keys
# already present in docker.env get overridden by config_values).
# Defaults match the manifest port defaults below.
env:
GAME_PORT: "26900"
TELNET_PORT: "8081"
WEBDASH_PORT: "8080"
REFUGEBOT_PORT: "8082"
# Cluster membership marker. Empty for standalone servers. The
# controller's 7dtd cluster manager sets this (via config_values)
# when the server joins a cluster; the entrypoint reads it to
# symlink this world's Player save folder into /cluster/Player.
# Pre-declared here so resolve.go's filter lets the override flow
# through (only keys already present in docker.env get overridden).
CLUSTER_ID: ""
# Operator-selected world whose Player folder is shared into
# /cluster/Player ("<World>/<GameName>"). Set via the Cluster tab's
# "Shared player world" picker (config_values). Pre-declared here so
# resolve.go's env filter lets it flow through to the container; the
# entrypoint reads it to override the literal GameWorld/GameName symlink
# target — required for RWG worlds whose save folder is named after the
# generated seed (e.g. "North Tuxera Valley"), not the literal "RWG".
CLUSTER_PLAYER_SAVE: ""
volumes:
# Saves, configs, admin files. Never touched by SteamCMD validate.
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
# Game install — populated by SteamCMD sidecar. Differential updates
# on a named volume avoid Windows NTFS bind-mount misery.
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
# Cluster shared dir. Defaults to a private per-instance named volume
# so a standalone server has a harmless empty /cluster. The controller's
# 7dtd cluster manager OVERRIDES this (mount_overrides) to a shared host
# dir when the server joins a cluster, so every member's entrypoint can
# symlink its Player save folder into the same /cluster/Player.
- { type: volume, name: "panel-$INSTANCE_ID-cluster", container: "/cluster" }
# Bind logs to host so operators can tail / rotate.
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Bind the panel-rendered serverconfig.xml into the saves volume so
# the entrypoint picks it up. Without this, the entrypoint falls back
# to the stock serverconfig.xml shipped with the game (ServerName=
# "My Game Host" etc.) and the operator's config_values are ignored.
- { target: "$DATA_PATH/serverconfig.xml.rendered", container: "/game-saves/serverconfig.xml.rendered", read_only: true }
ports:
# 7DTD always binds three contiguous UDP ports starting at ServerPort:
# the game port, then game+1 (LiteNetLib alt) and game+2 (peer-to-peer
# backup). We only inject `game` into the container env (entrypoint
# patches ServerPort in serverconfig.xml). gameA/gameB are declared so
# the allocator records them as taken — preventing a sibling instance
# from grabbing game+1 or game+2 — but their env field is intentionally
# blank (the binary computes those from ServerPort, not env).
- { name: game, proto: udp, default: 26900, required: true, env: GAME_PORT }
- { name: gameA, proto: udp, default: 26901, required: true }
- { name: gameB, proto: udp, default: 26902, required: true }
- { name: telnet, proto: tcp, default: 8081, internal: true, env: TELNET_PORT }
# Vanilla 7DTD's built-in web dashboard. The TFP entrypoint binds
# this when WebDashboardEnabled=true in serverconfig.xml.
- { name: webdash, proto: tcp, default: 8080, required: false, env: WEBDASH_PORT }
# RefugeBot mod's web UI (Admin Panel + Player Shop). Not required —
# only listens when RefugeBot is installed via Settings → Bundled
# mods. Declared here so the panel allocates a unique host port for
# it (otherwise multiple 7DTDs would all collide on 8082) and Docker
# publishes the binding so LAN clients can reach LAN_IP:<host_port>.
# If a 7DTD instance doesn't have RefugeBot installed, the port is
# still bound but nothing answers — harmless.
- { name: refugebot, proto: tcp, default: 8082, required: false, env: REFUGEBOT_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# First-start config: the entrypoint copies /game/serverconfig.xml (the one
# shipped with the game install) into /game-saves on first run.
rcon:
adapter: telnet
host_port: telnet
auth: password_prompt
# Our entrypoint generates a random TelnetPassword on first boot (7DTD
# only listens on 0.0.0.0 when a password is set — otherwise it's
# container-loopback only and the agent can't reach it). The plaintext
# lands here inside the container for the agent to re-read on each RCON
# dial attempt.
password_from_file: /game-saves/.panel-telnet-password
commands:
list_players: "lp"
say: "say \"{msg}\""
kick: "kick {player} \"{reason}\""
ban: "ban add {player} {duration} \"{reason}\""
shutdown: "shutdown"
save: "saveworld"
state_sources:
# "lp" (list players) returns "Total of N in the game".
- type: rcon
command: "lp"
every: 30s
parse:
kind: regex
pattern: 'Total of (?P<players>\d+) in the game'
fields:
players_online: players
# 7DTD writes its real game log to /game/output_log_*.txt when invoked
# via startserver.sh. Our entrypoint bypasses that and streams to stdout
# (so docker logs → agent → panel console is the primary path). We still
# watch the log files as a belt-and-braces source for event parsing.
- type: log_tail
files:
- "$DATA_PATH/logs/*.log"
- "/game/output_log_*.txt"
events:
join:
pattern: "PlayerSpawnedInWorld \\(by .*?\\): PltfmId='(?P<platform_id>[^']+)', CrossId='(?P<cross_id>[^']*)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
kind: join
leave:
pattern: "Player disconnected: EntityID=\\d+, PltfmId='(?P<platform_id>[^']+)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
kind: leave
chat:
pattern: "Chat \\(from '.*?', entity id '\\d+', to '.*?'\\): (?:\\[.*?\\])?'(?P<name>[^']+)': (?P<msg>.*)"
kind: chat
death:
pattern: "GMSG: Player '(?P<name>[^']+)' died"
kind: death
# Render serverconfig.xml from templates/serverconfig.xml.tmpl on every
# (re)create, using the merged operator config_values + generated secrets
# as `.Values.*`. The output lands at $DATA_PATH/serverconfig.xml.rendered
# on the agent host and is bind-mounted into /game-saves/ where the
# entrypoint copies it over the live serverconfig.xml.
config_files:
- path: serverconfig.xml.rendered
format: xml
template: templates/serverconfig.xml.tmpl
# V3.0 "Dead Hot Summer" overhauls serverconfig.xml: ~29 gameplay
# properties are removed and folded into one encoded SandboxCode property
# (the 150 in-game Sandbox Options). A 3.0 server therefore needs a
# different template than ≤2.6. Selection is by the instance's normalized
# Steam branch (resolveCreateBranch): the 3.0 experimental branch normalizes
# to "latest_experimental", so it renders serverconfig.v3.xml.tmpl; every
# other branch (v2.6 default, public, historical) keeps the untouched 2.6
# template. When TFP cuts a frozen `v3.0`/`v3.x` branch near stable, add the
# matching normalized key(s) here — no code change needed (RenderForBranch /
# ConfigFile.TemplateForBranch are data-driven). See
# panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
template_by_branch:
latest_experimental: templates/serverconfig.v3.xml.tmpl
# 7DTD ships per-version LOCKED Steam branches on app 294420 alongside the
# moving `public` branch. Branch names + frozen buildids confirmed live via
# `steamcmd +app_info_print 294420` on 2026-06-14:
# public -> buildid 22422094 (MOVING default; becomes 3.0 on 2026-06-29)
# v2.6 -> buildid 22422094 ("Version 2.6 Stable" — frozen; == public today)
# v2.5/v2.4/v2.3/v2.0/v1.4/alpha21.2 ... -> older frozen stables
# (latest_experimental + the 3.0 experimental live under privatebranches)
#
# ORDER MATTERS. UpdateProviders[0] is the default the agent uses for
# warm-seed install-path resolution AND for the create-time auto-update when
# no provider_id is chosen (pickProviderFromConfigValues). We put the FROZEN
# `v2_6` branch FIRST so a server that doesn't explicitly pick a version stays
# locked to 2.6 and can NEVER drift onto the moving public branch when it flips
# to 3.0. Pinning a server to `-beta v2.6` freezes it at buildid 22422094
# permanently — the corruption guard the whole V3.0 readiness work is built on.
#
# Operators pick a version at create via the "Game version" dropdown (which
# writes config_values.provider_id); the chosen id maps to the matching
# provider here. See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md.
update_providers:
- id: v2_6 # DEFAULT — Version 2.6 Stable, frozen (buildid 22422094)
kind: steamcmd
app_id: "294420"
beta: "v2.6"
install_path: /game # goes directly into our /game volume
- id: latest_experimental # rolling experimental — V3.0 "Dead Hot Summer" lands here 2026-06-15
kind: steamcmd
app_id: "294420"
beta: latest_experimental
install_path: /game
- id: current # public/stable — MOVING. == 2.6 today; becomes 3.0 on 2026-06-29.
kind: steamcmd
app_id: "294420"
install_path: /game
- id: stable # back-compat alias for `current` (pre-2026-06-14 rows persisted provider_id=stable)
kind: steamcmd
app_id: "294420"
install_path: /game
# Historical version-locked branches — pin here to run an older client.
- id: v2_5
kind: steamcmd
app_id: "294420"
beta: "v2.5"
install_path: /game
- id: v2_4
kind: steamcmd
app_id: "294420"
beta: "v2.4"
install_path: /game
- id: v2_3
kind: steamcmd
app_id: "294420"
beta: "v2.3"
install_path: /game
- id: v2_0
kind: steamcmd
app_id: "294420"
beta: "v2.0"
install_path: /game
- id: v1_4
kind: steamcmd
app_id: "294420"
beta: "v1.4"
install_path: /game
# Backup scope — capture the whole user folder + server config.
#
# 7DTD writes saves, admin files, and titlestorage into the per-user
# tree at $HOME/.local/share/7DaysToDie/ (our entrypoint pins HOME to
# /game-saves so this becomes /game-saves/.local/share/7DaysToDie/).
# We grab that, the rendered serverconfig.xml, the persisted telnet
# password, and any 7DTD-side config under .config — that's everything
# an operator would lose if the volume vanished.
#
# Excluded paths:
# - logs/ — large, never useful for restore
# - Steam/ — Steam runtime cache, regenerated on next start
backup:
# Saves + admin files live in /game-saves, not /game. The file browser
# defaults to /game so operators can see the binary tree, but the
# backup must target /game-saves where the world data actually lives.
root: /game-saves
include:
- .local/share/7DaysToDie
- .config
- serverconfig.xml
- .panel-telnet-password
exclude:
- logs
- Steam
# --- Manifest-driven UI metadata (WI-07) --------------------------
# Consumed by the CONTROLLER/dashboard only (the controller loads its
# own ./modules copy at startup); the agent ignores these at runtime.
# ready_pattern is a JavaScript regex (slash-delimited when it needs
# flags) moved verbatim from the dashboard's READY_PATTERNS map.
ready_pattern: '/StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i'
appearance:
emoji: "🧟"
grad: "linear-gradient(135deg,#7b3b12,#d97706)"
art: "/game-art/7dtd.jpg"
steam: "251570"
+58
View File
@@ -0,0 +1,58 @@
# 7DTD Cluster — Canonical-Compatible New World Provisioning (RUNBOOK)
# Validated 2026-06-13. Tooling: this dir. Canonical: ~/nimcanon/canonical_final/{block,item}mappings.nim
## WHERE TO RUN (prerequisites — read first)
Run on **figaro** (`<user>@<agent-host>`) as user **refuge**. Requires `~/nimcanon/{tool,canonical_final,remap}` present (the `.nim` parser, the canonical, the cached native table). The repo `remap_7dt.py` hardcodes `sys.path.insert(0,'/home/refuge/nimcanon/tool')` and imports `nimtool` — it will **NOT** run on the Windows dev box or kaiten. (The in-container copy invoked automatically by the entrypoint is the self-contained `remap_7dt.vendored.py` — a different file.)
Saves live in a **docker volume**, not a host path — `$W` below is only reachable by mounting the volume. Run file ops inside a throwaway container, e.g.:
```sh
CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc
VOL=panel-<inst>-saves # e.g. panel-cluster-seed-test-saves
docker run --rm -v $VOL:/sv -v $CL:/cl debian:12-slim sh -c '
W="/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>"; <ops on "$W/decoration.7dt" etc.>'
```
STOP/START **only via the panel** (agent-owned; sends SIGTERM→tini→7DTD clean shutdown, which flushes the native `.nim`). A `docker kill`/`docker stop` would NOT flush and can corrupt the capture.
## CORRECTED ROOT CAUSE
7DTD V2.6(b14) auto-assigns block ids at config-load. The cluster's CURRENT runtime
(RefugeBot v1.2.39) puts the tree-decoration band at native ids 21895+, while the
season10-anchored CANONICAL puts the same trees at 24141+ (season10's world was baked
PRE-RefugeBot-rebuild). At world GENESIS the engine bakes decoration.7dt / multiblocks.7dt
BY NUMERIC BLOCK ID using native runtime ids. The entrypoint guard then stamps the
canonical .nim over the world's .nim every boot (so inventory/.ttp decode uniformly →
clean transfers). MISMATCH: native-baked deco (21895) read against stamped canonical
(24141) → null Block → DecoManager.TryAddToOccupiedMap NRE → world load hangs → never
GameStartDone. season10/creative load clean only because their deco was baked when the
runtime == canonical (pre-rebuild).
## KEY FACTS
- .nim: u32 ver=1, u32 count, recs[u16 id, u16 flag(block=0/item=1), u8 namelen, name]. Parser: ~/nimcanon/tool/nimtool.py
- decoration.7dt / multiblocks.7dt: u8 ver=6, u32 count, count*17B recs; block id = u16@+12 & 0x7FFF; high bit + bytes +14/+16 = rotation/placement (PRESERVE).
- Engine PRESERVES loaded deco ids on save; only writes native ids at GENERATION. So a canon-remapped decoration.7dt stays canon across reboots → STABLE.
- The cluster runtime native table is IDENTICAL across all instances (insane==pvp deco band 33/33). One captured native table is authoritative cluster-wide: ~/nimcanon/remap/cluster_native_deco.nim
- Remapper: remap_7dt.py (apply|analyze|decodecheck). Self-verifies byte-safety + 100% canon resolution.
## PROVISION A NEW CANONICAL-COMPATIBLE WORLD (do this on a FRESH/0-region world)
CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc ; VOL=panel-<inst>-saves ; W="<World>/<GameName>"
1. Gen the world (set seed, recreate/start). It bakes a NATIVE decoration.7dt.
2. CAPTURE the world's native table:
a. Stop the instance (panel Stop — NOT `docker stop`).
b. **First BACK UP the canonical** (`cp $CL/canonical/*.nim ~/canon-backup/`), THEN move the originals aside so the guard skips the stamp:
`mv $CL/canonical/*.nim $CL/canon_test_aside/`
⚠️ **The canonical is the SHARED stamp source for EVERY cluster member.** While it is moved aside, do **NOT** boot any other member — it would stamp nothing and could append/diverge (cluster-wide inventory risk). Keep this window short; restore in step 5 and confirm by `sha256sum` before starting anything else.
c. printf '\x01\x00\x00\x00\x00\x00\x00\x00' > "$W/blockmappings.nim" ; same for itemmappings.nim
d. Start instance → loads clean on native → engine writes the world's native .nim (covers its deco blocks). Graceful-stop to flush.
e. Copy out "$W/blockmappings.nim" as <native.nim>.
3. REMAP: python3 remap_7dt.py apply <native.nim> ~/nimcanon/canonical_final/blockmappings.nim "$W/decoration.7dt" /tmp/deco.canon.7dt
(repeat for multiblocks.7dt if present). Require: "byte-diff outside id field: 0" and "ids unresolved in canon: 0".
4. Backup native (cp decoration.7dt decoration.7dt.native-bak) then apply the remapped file; chown 1000:1000.
5. mv $CL/canon_test_aside/*.nim $CL/canonical/ # restore the stamp source
6. Start instance → guard stamps canon → loads canon-remapped deco → GameStartDone, NRE=0, on canon.
7. VERIFY: decoration.7dt id band = 24xxx ; GameStartDone ; NRE=0 ; .nim stamped 38626/2719 at boot.
## REPAIR AN ALREADY-STUCK WORLD (canon stamped, native deco, NRE) — same as above from step 2.
## CAVEATS
- Region/*.7rg voxel ids + Player/*.ttp item ids are NOT remapped (codecs unsolved). Do the remap on a FRESH 0-region world BEFORE exploration. Existing 87 shared .ttp are fine (written in canon by season10/creative).
- Deco block ids must be < 32768 (15-bit field); the tree-deco band is far under.
- PERMANENT fix (removes the per-world remap step): pin block ids explicitly in a high-priority modlet so the runtime native table == canonical, OR freeze the RefugeBot build that season10 was baked under. Until then each new world needs this one-time remap.
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Rebuild the 7DTD cluster canonical .nim (re-anchor to season10's CURRENT state).
WHAT THIS DOES
Produces a complete, season10-anchored canonical block/item id map. Every id
the anchor (season10) currently uses is preserved BYTE-FOR-BYTE; every other
name from the "universe" (a prior complete canonical) is appended at a fresh,
non-colliding id above the anchor's max. The result is a superset that decodes
season10's world + all shared inventories identically, and is COMPLETE so the
engine never appends => the table stays frozen forever.
WHEN TO RUN
* season10's live .nim grew (players placed new shapes) and you need to
re-stamp it — re-anchor so the canonical preserves the new ids.
* You just want to regenerate the deployed canonical from the anchor.
NOTE: if MODS CHANGED (new blocks/items added), the universe won't contain the
new names. Re-harvest first — see CANONICAL_NIM_FREEZE.md "Rebuilding after a
mod change". This script only RE-ANCHORS an existing complete name set.
USAGE
1. STOP season10 (panel Stop) so its .nim is final.
2. Copy season10's CURRENT blockmappings.nim + itemmappings.nim out (the ANCHOR):
docker run --rm -v panel-rg-season-10-saves:/sv:ro -v /tmp/x:/o debian:12-slim \
sh -c 'cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/blockmappings.nim" /o/anchor_block.nim;
cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/itemmappings.nim" /o/anchor_item.nim'
3. python3 rebuild.py anchor_block.nim anchor_item.nim \
UNIVERSE_block.nim UNIVERSE_item.nim OUT_DIR
(UNIVERSE = a prior complete canonical, e.g. ~/nimcanon/canonical_final/*.nim)
4. Confirm "GATE PASS: True". Then copy OUT_DIR/*.nim into the cluster canonical
dir and chmod 644:
cp OUT_DIR/*.nim /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/
chmod 644 /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/*.nim
5. Rebuild each cluster member (POST /api/instances/<id>/rebuild) — the entrypoint
guard re-stamps the new canonical on boot. (If a member was STOPPED, /rebuild
leaves it stopped — Start it after.)
.nim BINARY FORMAT (little-endian, no trailing bytes):
u32 version(=1), u32 count, then count records of:
u16 id, u16 flag (items=1, blocks=0), u8 namelen, <namelen> UTF-8 name bytes
Records are stored sorted by ascending id. Ids are SPARSE.
"""
import struct, sys, hashlib, os
def read_nim(p):
b = open(p, "rb").read()
ver, cnt = struct.unpack_from("<II", b, 0)
off = 8
recs = []
for _ in range(cnt):
i, fl, nl = struct.unpack_from("<HHB", b, off)
off += 5
nm = b[off:off + nl].decode("utf-8")
off += nl
recs.append((i, fl, nm))
assert off == len(b), f"trailing bytes parsing {p} ({off} != {len(b)})"
return recs
def write_nim(recs, p):
out = struct.pack("<II", 1, len(recs))
for i, fl, nm in recs:
nb = nm.encode("utf-8")
out += struct.pack("<HHB", i, fl, len(nb)) + nb
open(p, "wb").write(out)
return out
def build(anchor, universe, flag):
"""anchor ids are SACRED (verbatim). append every universe name not present."""
recs = list(anchor)
present = {nm for _, _, nm in anchor}
used = {i for i, _, _ in anchor}
nid = max(used) + 1
for nm in sorted({nm for _, _, nm in universe} - present):
while nid in used:
nid += 1
if nid >= 65536:
raise SystemExit("FATAL: ran out of u16 id space (>65535)")
recs.append((nid, flag, nm))
used.add(nid)
nid += 1
recs.sort(key=lambda r: r[0])
return recs
def main():
if len(sys.argv) != 6:
print(__doc__)
sys.exit(2)
ab, ai, ub, ui, outdir = sys.argv[1:6]
os.makedirs(outdir, exist_ok=True)
anchor_b, anchor_i = read_nim(ab), read_nim(ai)
univ_b, univ_i = read_nim(ub), read_nim(ui)
fb, fi = build(anchor_b, univ_b, 0), build(anchor_i, univ_i, 1)
db = write_nim(fb, os.path.join(outdir, "blockmappings.nim"))
di = write_nim(fi, os.path.join(outdir, "itemmappings.nim"))
fbm = {i: nm for i, _, nm in fb}
fim = {i: nm for i, _, nm in fi}
alt_b = sum(1 for i, _, nm in anchor_b if fbm.get(i) != nm)
alt_i = sum(1 for i, _, nm in anchor_i if fim.get(i) != nm)
bids = [i for i, _, _ in fb]
bnm = [nm for _, _, nm in fb]
iids = [i for i, _, _ in fi]
inm = [nm for _, _, nm in fi]
compl_b = {nm for _, _, nm in univ_b} <= set(bnm)
compl_i = {nm for _, _, nm in univ_i} <= set(inm)
print(f"blocks={len(fb)} maxid={max(bids)} | items={len(fi)} maxid={max(iids)}")
print(f"block sha256 = {hashlib.sha256(db).hexdigest()}")
print(f"item sha256 = {hashlib.sha256(di).hexdigest()}")
print(f"ANCHOR altered (MUST be 0): block={alt_b} item={alt_i}")
print(f"dup ids: block={len(bids)-len(set(bids))} item={len(iids)-len(set(iids))}"
f" | dup names: block={len(bnm)-len(set(bnm))} item={len(inm)-len(set(inm))}")
print(f"complete (universe names all present): block={compl_b} item={compl_i}")
ok = (alt_b == 0 and alt_i == 0
and len(bids) == len(set(bids)) and len(iids) == len(set(iids))
and len(bnm) == len(set(bnm)) and len(inm) == len(set(inm))
and compl_b and compl_i and max(bids) < 65536 and max(iids) < 65536)
print("GATE PASS:", ok)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.
7dt format (little-endian, no trailing bytes):
[0] u8 version (=6)
[1:5] u32 count
then count * 17-byte records; block id = u16 @ record offset +12,
low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
"""
import sys, struct
sys.path.insert(0, '/home/refuge/nimcanon/tool')
from nimtool import read_nim # -> (records[list of (id,flag,name)], version)
REC = 17; HDR = 5; IDOFF = 12
def load_id2name(path):
if path.endswith('.nim'):
recs, _ = read_nim(path)
return {i: n for i, _, n in recs}
d = {}
for line in open(path, encoding='utf-8', errors='replace'):
line = line.rstrip('\n')
if not line: continue
p = line.split('\t')
if len(p) >= 2 and p[0].isdigit():
d[int(p[0])] = p[1]
return d
def load_name2id(path):
recs, _ = read_nim(path)
return {n: i for i, _, n in recs}
def canon_idset(path):
recs, _ = read_nim(path)
return {i for i, _, n in recs}
def decode_ids(b):
ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
out = []
for r in range(count):
pos = HDR + r*REC + IDOFF
orig = struct.unpack_from('<H', b, pos)[0]
out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
return ver, count, out
def analyze(native, canon, infile):
b = open(infile, 'rb').read()
n2 = load_id2name(native); c2 = load_name2id(canon)
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
unmap_id = sorted(i for i in dist if i not in n2)
nm_for = {i: n2.get(i) for i in dist}
unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
inst_unmap_id = sum(dist[i] for i in unmap_id)
inst_unmap_nm = sum(dist[i] for i in dist if nm_for[i] in (None,) or (nm_for[i] is not None and nm_for[i] not in c2))
print(f" file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
print(f" distinct ids NOT in native table: {len(unmap_id)} (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
if unmap_id[:8]: print(f" e.g. {unmap_id[:8]}")
print(f" distinct names NOT in canon: {len(unmap_name)}")
if unmap_name[:8]: print(f" e.g. {unmap_name[:8]}")
nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
print(f" instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
print(f" >>> FULLY REMAPPABLE: {ok}")
return ok
def decodecheck(table, infile):
b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
miss = sum(dist[i] for i in dist if i not in ids)
print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]")
def apply(native, canon, infile, outfile):
src = open(infile, 'rb').read(); b = bytearray(src)
n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
ver, count, recs = decode_ids(b)
changed = 0; bad = []
for (pos, orig, bid, high) in recs:
name = n2.get(bid)
if name is None: bad.append(('noid', bid)); continue
cid = c2.get(name)
if cid is None: bad.append(('noname', name)); continue
new = high | (cid & 0x7FFF)
if new != orig:
struct.pack_into('<H', b, pos, new); changed += 1
if bad:
print(f" !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
open(outfile, 'wb').write(b)
# self-verify byte safety: same length, only +12/+13 differ, all new ids in canon
assert len(b) == len(src), "LENGTH CHANGED"
diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
ver2, count2, recs2 = decode_ids(bytes(b))
unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
print(f" wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
print(f" byte-diff outside id field: {len(badpos)} (MUST be 0) ids unresolved in canon after remap: {unresolved} (MUST be 0)")
return len(badpos) == 0 and unresolved == 0
if __name__ == '__main__':
cmd = sys.argv[1]
if cmd == 'analyze': analyze(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
elif cmd == 'apply': sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.
VENDORED, SELF-CONTAINED copy of nim-freeze/remap_7dt.py with nimtool.read_nim
inlined and the host sys.path dependency removed, so it runs inside the
panel-7dtd container (python3-minimal, no extra packages). Keep behaviour
byte-identical to remap_7dt.py — the entrypoint's auto-provision relies on
apply()'s exit code (0 = remapped + self-verified safe, 1 = refuse to start).
7dt format (little-endian, no trailing bytes):
[0] u8 version (=6)
[1:5] u32 count
then count * 17-byte records; block id = u16 @ record offset +12,
low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
"""
import sys, struct
REC = 17; HDR = 5; IDOFF = 12
# --- inlined from nimtool.py (read_nim / parse_nim_bytes), byte-faithful ---
def read_nim(path):
with open(path, "rb") as f:
data = f.read()
return parse_nim_bytes(data)
def parse_nim_bytes(data):
if len(data) < 8:
raise ValueError("file too short for header (%d bytes)" % len(data))
version, count = struct.unpack_from("<II", data, 0)
off = 8
records = []
for i in range(count):
if off + 5 > len(data):
raise ValueError("record %d: truncated header at offset %d (len=%d)" % (i, off, len(data)))
rid, flag = struct.unpack_from("<HH", data, off)
namelen = data[off + 4]
off += 5
if off + namelen > len(data):
raise ValueError("record %d (id=%d): name truncated at offset %d (len=%d)" % (i, rid, off, len(data)))
name = data[off:off + namelen].decode("utf-8")
off += namelen
records.append((rid, flag, name))
if off != len(data):
raise ValueError("trailing bytes: consumed %d of %d" % (off, len(data)))
return records, version
# --- end inlined ---
def load_id2name(path):
if path.endswith('.nim'):
recs, _ = read_nim(path)
return {i: n for i, _, n in recs}
d = {}
for line in open(path, encoding='utf-8', errors='replace'):
line = line.rstrip('\n')
if not line: continue
p = line.split('\t')
if len(p) >= 2 and p[0].isdigit():
d[int(p[0])] = p[1]
return d
def load_name2id(path):
recs, _ = read_nim(path)
return {n: i for i, _, n in recs}
def canon_idset(path):
recs, _ = read_nim(path)
return {i for i, _, n in recs}
def decode_ids(b):
ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
out = []
for r in range(count):
pos = HDR + r*REC + IDOFF
orig = struct.unpack_from('<H', b, pos)[0]
out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
return ver, count, out
def analyze(native, canon, infile):
b = open(infile, 'rb').read()
n2 = load_id2name(native); c2 = load_name2id(canon)
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
unmap_id = sorted(i for i in dist if i not in n2)
nm_for = {i: n2.get(i) for i in dist}
unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
inst_unmap_id = sum(dist[i] for i in unmap_id)
print(f" file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
print(f" distinct ids NOT in native table: {len(unmap_id)} (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
if unmap_id[:8]: print(f" e.g. {unmap_id[:8]}")
print(f" distinct names NOT in canon: {len(unmap_name)}")
if unmap_name[:8]: print(f" e.g. {unmap_name[:8]}")
nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
print(f" instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
print(f" >>> FULLY REMAPPABLE: {ok}")
return ok
def decodecheck(table, infile):
b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
miss = sum(dist[i] for i in dist if i not in ids)
print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]")
def apply(native, canon, infile, outfile):
src = open(infile, 'rb').read(); b = bytearray(src)
n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
ver, count, recs = decode_ids(b)
changed = 0; bad = []
for (pos, orig, bid, high) in recs:
name = n2.get(bid)
if name is None: bad.append(('noid', bid)); continue
cid = c2.get(name)
if cid is None: bad.append(('noname', name)); continue
new = high | (cid & 0x7FFF)
if new != orig:
struct.pack_into('<H', b, pos, new); changed += 1
if bad:
print(f" !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
open(outfile, 'wb').write(b)
assert len(b) == len(src), "LENGTH CHANGED"
diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
ver2, count2, recs2 = decode_ids(bytes(b))
unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
print(f" wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
print(f" byte-diff outside id field: {len(badpos)} (MUST be 0) ids unresolved in canon after remap: {unresolved} (MUST be 0)")
return len(badpos) == 0 and unresolved == 0
if __name__ == '__main__':
cmd = sys.argv[1]
if cmd == 'analyze': analyze(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
elif cmd == 'apply': sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# repin-existing-to-v2_6.sh — one-shot: pin EXISTING 7DTD instances to the
# frozen v2.6 Steam branch so the controller's version-lock gate protects them.
#
# WHY: instances created before the V3.0-readiness work have no provider_id in
# config_values. The lock-gate then treats them as "not explicitly pinned"
# (empty -> safe default of v2_6 on a plain Update, but NOT gated against a
# deliberate switch). Writing provider_id=v2_6 makes them fully locked: an
# Update re-validates 2.6, and switching versions needs a typed confirm.
#
# SAFETY: metadata-only. This writes a JSON key on the DB row. It does NOT
# restart containers, does NOT run SteamCMD, does NOT touch the running game.
# Only rows that do NOT already have a provider_id are changed (idempotent).
#
# ORDERING (important): run this AFTER the new agent + controller are deployed.
# The agent's module.yaml must contain the `v2_6` provider, otherwise an Update
# that resolves provider_id=v2_6 finds no match and falls back to
# UpdateProviders[0]. (Once the new module.yaml is live, UpdateProviders[0] is
# itself v2_6, so even the fallback is safe — but deploy first to be correct.)
#
# USAGE:
# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh # dry-run (shows what WOULD change)
# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh --apply # actually write
#
# DATABASE_URL must be the same connection string the controller uses (on the
# controller host — kaiten — read it from the controller's env/systemd unit).
# Requires `psql` on PATH.
set -euo pipefail
DB="${DATABASE_URL:-}"
if [[ -z "$DB" ]]; then
echo "ERROR: set DATABASE_URL (same string the controller uses)." >&2
exit 1
fi
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
echo "== Current 7DTD instances and their pinned version =="
psql "$DB" -P pager=off -c "
SELECT instance_id,
COALESCE(config_values->>'provider_id', '(none)') AS provider_id,
status
FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;"
echo
echo "== Rows that WOULD be re-pinned to v2_6 (no provider_id yet) =="
psql "$DB" -P pager=off -c "
SELECT instance_id
FROM instances
WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id')
ORDER BY instance_id;"
if [[ "$APPLY" -ne 1 ]]; then
echo
echo "DRY-RUN. Re-run with --apply to write provider_id=v2_6 to the rows above."
exit 0
fi
echo
echo "== Applying: setting provider_id=v2_6 on un-pinned 7DTD rows =="
psql "$DB" -P pager=off -c "
UPDATE instances
SET config_values = jsonb_set(COALESCE(config_values, '{}'::jsonb), '{provider_id}', '\"v2_6\"', true),
updated_at = NOW()
WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id');"
echo
echo "== Result =="
psql "$DB" -P pager=off -c "
SELECT instance_id, config_values->>'provider_id' AS provider_id
FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;"
echo
echo "Done. Existing 7DTD servers are now pinned to v2.6 (frozen)."
echo "No containers were restarted; nothing was downloaded."
@@ -0,0 +1,133 @@
<?xml version="1.0"?>
<!-- ============================================================ V3.0
serverconfig.xml rendered by panel for 7 Days to Die V3.0 "Dead Hot Summer".
RECONCILED 2026-06-15 against the REAL shipped 3.0 serverconfig.xml from the
experimental dedicated-server install (app 294420 -beta latest_experimental,
buildid 23705258). The kept/removed split below is the authoritative one
from that file — NOT a guess.
V3.0 removes 30 gameplay properties from serverconfig.xml and replaces them
with ONE encoded `SandboxCode` property carrying all 150 in-game Sandbox
Options. The panel generates SandboxCode from per-option settings via
pkg/sandbox (or accepts a pasted/preset code). The 30 removed properties:
GameDifficulty, BlockDamagePlayer, BlockDamageAI, BlockDamageAIBM,
XPMultiplier, DayNightLength, DayLightLength, BiomeProgression, StormFreq,
DeathPenalty, DropOnDeath, DropOnQuit, JarRefund, EnemySpawnMode,
EnemyDifficulty, ZombieFeralSense, ZombieMove, ZombieMoveNight,
ZombieFeralMove, ZombieBMMove, AISmellMode, BloodMoonFrequency,
BloodMoonRange, BloodMoonWarning, BloodMoonEnemyCount, LootAbundance,
LootRespawnDays, AirDropFrequency, AirDropMarker, QuestProgressionDailyLimit
(all now live inside SandboxCode).
Selection: an instance renders THIS template instead of serverconfig.xml.tmpl
when its normalized Steam branch is the 3.0 branch (latest_experimental;
add v3.x when TFP cuts a frozen branch). Wired via module.yaml
config_files[].template_by_branch + ConfigFile.TemplateForBranch /
module.RenderForBranch. 2.6 and earlier keep serverconfig.xml.tmpl untouched.
Manage values via the panel config UI, which populates `.Values.*`.
See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md.
============================================================ -->
<ServerSettings>
<!-- ===== Server representation ===== -->
<property name="ServerName" value="{{ or .Values.server_name "Refuge 7DTD" }}" />
<property name="ServerDescription" value="{{ .Values.server_description }}" />
<property name="ServerWebsiteURL" value="{{ .Values.server_website }}" />
<property name="ServerPassword" value="{{ .Values.server_password }}" />
<property name="ServerLoginConfirmationText" value="{{ .Values.login_confirmation_text }}" />
<property name="Region" value="{{ or .Values.region "NorthAmericaWest" }}" />
<property name="Language" value="{{ or .Values.language "English" }}" />
<!-- ===== Networking ===== -->
<property name="ServerPort" value="{{ or .Values.server_port "26900" }}" />
<property name="ServerVisibility" value="{{ or .Values.server_visibility "2" }}" />
<property name="ServerDisabledNetworkProtocols" value="{{ or .Values.disabled_network_protocols "SteamNetworking" }}" />
<property name="ServerMaxWorldTransferSpeedKiBs" value="{{ or .Values.max_world_transfer_speed "512" }}" />
<!-- ===== Slots ===== -->
<property name="ServerMaxPlayerCount" value="{{ or .Values.max_players "8" }}" />
<property name="ServerReservedSlots" value="{{ or .Values.reserved_slots "0" }}" />
<property name="ServerReservedSlotsPermission" value="{{ or .Values.reserved_slots_permission "100" }}" />
<property name="ServerAdminSlots" value="{{ or .Values.admin_slots "0" }}" />
<property name="ServerAdminSlotsPermission" value="{{ or .Values.admin_slots_permission "0" }}" />
<!-- ===== Admin interfaces ===== -->
<property name="WebDashboardEnabled" value="{{ or .Values.webdash_enabled "true" }}" />
<property name="WebDashboardPort" value="{{ or .Values.WEBDASH_PORT .Values.webdash_port "8080" }}" />
<property name="WebDashboardUrl" value="{{ .Values.webdash_url }}" />
<property name="EnableMapRendering" value="{{ or .Values.enable_map_rendering "true" }}" />
<property name="TelnetEnabled" value="{{ or .Values.telnet_enabled "true" }}" />
<property name="TelnetPort" value="{{ or .Values.telnet_port "8081" }}" />
<property name="TelnetPassword" value="{{ .Values.telnet_password }}" />
<property name="TelnetFailedLoginLimit" value="{{ or .Values.telnet_failed_login_limit "10" }}" />
<property name="TelnetFailedLoginsBlocktime" value="{{ or .Values.telnet_failed_login_blocktime "10" }}" />
<property name="TerminalWindowEnabled" value="{{ or .Values.terminal_window_enabled "true" }}" />
<!-- ===== Folder/file locations ===== -->
<property name="AdminFileName" value="serveradmin.xml" />
<!-- ===== Other technical settings ===== -->
<property name="ServerAllowCrossplay" value="{{ or .Values.allow_crossplay "false" }}" />
<property name="EACEnabled" value="{{ or .Values.eac "true" }}" />
<property name="IgnoreEOSSanctions" value="{{ or .Values.ignore_eos_sanctions "false" }}" />
<property name="HideCommandExecutionLog" value="{{ or .Values.hide_command_log "0" }}" />
<property name="MaxUncoveredMapChunksPerPlayer" value="{{ or .Values.max_uncovered_chunks "131072" }}" />
<property name="PersistentPlayerProfiles" value="{{ or .Values.persistent_player_profiles "false" }}" />
<property name="MaxChunkAge" value="{{ or .Values.max_chunk_age "-1" }}" />
<property name="SaveDataLimit" value="{{ or .Values.save_data_limit "-1" }}" />
<!-- ===== World generation ===== -->
<property name="GameWorld" value="{{ or .Values.world_name "RWG" }}" />
<property name="WorldGenSeed" value="{{ or .Values.world_seed "panel-default" }}" />
<property name="WorldGenSize" value="{{ or .Values.world_size "8192" }}" />
<property name="GameName" value="{{ or .Values.game_name "Refuge" }}" />
<property name="GameMode" value="{{ or .Values.game_mode "GameModeSurvival" }}" />
<!-- ===== Difficulty (KEPT in 3.0 — these survived the SandboxCode move) ===== -->
<property name="PlayerSafeZoneLevel" value="{{ or .Values.player_safe_zone_level "5" }}" />
<property name="PlayerSafeZoneHours" value="{{ or .Values.player_safe_zone_hours "5" }}" />
<!-- ===== Game rules (KEPT) ===== -->
<property name="BuildCreate" value="{{ or .Values.build_create "false" }}" />
<property name="BedrollDeadZoneSize" value="{{ or .Values.bedroll_dead_zone_size "15" }}" />
<property name="BedrollExpiryTime" value="{{ or .Values.bedroll_expiry_time "45" }}" />
<property name="AllowSpawnNearFriend" value="{{ or .Values.allow_spawn_near_friend "2" }}" />
<property name="CameraRestrictionMode" value="{{ or .Values.camera_restriction_mode "0" }}" />
<!-- ===== Performance (KEPT) ===== -->
<property name="MaxSpawnedZombies" value="{{ or .Values.max_spawned_zombies "64" }}" />
<property name="MaxSpawnedAnimals" value="{{ or .Values.max_spawned_animals "50" }}" />
<property name="ServerMaxAllowedViewDistance" value="{{ or .Values.max_view_distance "12" }}" />
<property name="MaxQueuedMeshLayers" value="{{ or .Values.max_queued_mesh_layers "1000" }}" />
<!-- ===== Multiplayer (KEPT) ===== -->
<property name="PartySharedKillRange" value="{{ or .Values.party_shared_kill_range "100" }}" />
<property name="PlayerKillingMode" value="{{ or .Values.pk_mode "3" }}" />
<!-- ===== Land claim options (KEPT) ===== -->
<property name="LandClaimCount" value="{{ or .Values.land_claim_count "5" }}" />
<property name="LandClaimSize" value="{{ or .Values.land_claim_size "41" }}" />
<property name="LandClaimDeadZone" value="{{ or .Values.land_claim_dead_zone "30" }}" />
<property name="LandClaimExpiryTime" value="{{ or .Values.land_claim_expiry_time "7" }}" />
<property name="LandClaimDecayMode" value="{{ or .Values.land_claim_decay_mode "0" }}" />
<property name="LandClaimOnlineDurabilityModifier" value="{{ or .Values.land_claim_online_durability_modifier "4" }}" />
<property name="LandClaimOfflineDurabilityModifier" value="{{ or .Values.land_claim_offline_durability_modifier "4" }}" />
<property name="LandClaimOfflineDelay" value="{{ or .Values.land_claim_offline_delay "0" }}" />
<!-- ===== Dynamic mesh (KEPT) ===== -->
<property name="DynamicMeshEnabled" value="{{ or .Values.dynamic_mesh_enabled "true" }}" />
<property name="DynamicMeshLandClaimOnly" value="{{ or .Values.dynamic_mesh_land_claim_only "true" }}" />
<property name="DynamicMeshLandClaimBuffer" value="{{ or .Values.dynamic_mesh_land_claim_buffer "3" }}" />
<property name="DynamicMeshMaxItemCache" value="{{ or .Values.dynamic_mesh_max_item_cache "3" }}" />
<!-- ===== Twitch (KEPT) ===== -->
<property name="TwitchServerPermission" value="{{ or .Values.twitch_server_permission "90" }}" />
<property name="TwitchBloodMoonAllowed" value="{{ or .Values.twitch_blood_moon_allowed "false" }}" />
<!-- ===== NEW in V3.0: the 150 sandbox options as one encoded string =====
Empty value = the game's built-in default sandbox. The panel writes
either a per-option-generated code (pkg/sandbox) or a pasted/preset code.
Default below = the game's Adventurer difficulty code. -->
<property name="SandboxCode" value="{{ or .Values.sandbox_code "AAAJABJACJADJARFBNC" }}" />
</ServerSettings>
@@ -0,0 +1,119 @@
<?xml version="1.0"?>
<!-- serverconfig.xml rendered by panel — do not edit by hand; changes will be
overwritten on the next instance (re)create. Manage values via the panel
config UI, which populates the `.Values.*` keys used below. -->
<ServerSettings>
<!-- GENERAL SERVER SETTINGS -->
<!-- Server representation -->
<property name="ServerName" value="{{ or .Values.server_name "Refuge 7DTD" }}" />
<property name="ServerDescription" value="{{ .Values.server_description }}" />
<property name="ServerWebsiteURL" value="{{ .Values.server_website }}" />
<property name="ServerPassword" value="{{ .Values.server_password }}" />
<property name="ServerLoginConfirmationText" value="{{ .Values.login_confirmation_text }}" />
<property name="Region" value="{{ or .Values.region "NorthAmericaWest" }}" />
<property name="Language" value="{{ or .Values.language "English" }}" />
<!-- Networking -->
<property name="ServerPort" value="{{ or .Values.server_port "26900" }}" />
<property name="ServerVisibility" value="{{ or .Values.server_visibility "2" }}" />
<property name="ServerDisabledNetworkProtocols" value="{{ or .Values.disabled_network_protocols "SteamNetworking" }}" />
<property name="ServerMaxWorldTransferSpeedKiBs" value="{{ or .Values.max_world_transfer_speed "1300" }}" />
<!-- Slots -->
<property name="ServerMaxPlayerCount" value="{{ or .Values.max_players "40" }}" />
<property name="ServerReservedSlots" value="{{ or .Values.reserved_slots "0" }}" />
<property name="ServerReservedSlotsPermission" value="{{ or .Values.reserved_slots_permission "100" }}" />
<property name="ServerAdminSlots" value="{{ or .Values.admin_slots "0" }}" />
<property name="ServerAdminSlotsPermission" value="{{ or .Values.admin_slots_permission "0" }}" />
<!-- Admin interfaces -->
<property name="WebDashboardEnabled" value="{{ or .Values.webdash_enabled "true" }}" />
<property name="WebDashboardPort" value="{{ or .Values.WEBDASH_PORT .Values.webdash_port "8080" }}" />
<property name="WebDashboardUrl" value="{{ .Values.webdash_url }}" />
<property name="EnableMapRendering" value="{{ or .Values.enable_map_rendering "true" }}" />
<property name="TelnetEnabled" value="{{ or .Values.telnet_enabled "true" }}" />
<property name="TelnetPort" value="{{ or .Values.telnet_port "8081" }}" />
<property name="TelnetPassword" value="{{ .Values.telnet_password }}" />
<property name="TelnetFailedLoginLimit" value="{{ or .Values.telnet_failed_login_limit "10" }}" />
<property name="TelnetFailedLoginsBlocktime" value="{{ or .Values.telnet_failed_login_blocktime "10" }}" />
<property name="TerminalWindowEnabled" value="{{ or .Values.terminal_window_enabled "true" }}" />
<!-- Folder and file locations -->
<property name="AdminFileName" value="serveradmin.xml" />
<!-- Other technical settings -->
<property name="ServerAllowCrossplay" value="{{ or .Values.allow_crossplay "false" }}" />
<property name="EACEnabled" value="{{ or .Values.eac "true" }}" />
<property name="IgnoreEOSSanctions" value="{{ or .Values.ignore_eos_sanctions "false" }}" />
<property name="HideCommandExecutionLog" value="{{ or .Values.hide_command_log "0" }}" />
<property name="MaxUncoveredMapChunksPerPlayer" value="{{ or .Values.max_uncovered_chunks "131072" }}" />
<property name="PersistentPlayerProfiles" value="{{ or .Values.persistent_player_profiles "true" }}" />
<property name="MaxChunkAge" value="{{ or .Values.max_chunk_age "-1" }}" />
<property name="SaveDataLimit" value="{{ or .Values.save_data_limit "-1" }}" />
<!-- GAMEPLAY -->
<!-- World -->
<property name="GameWorld" value="{{ or .Values.world_name "RWG" }}" />
<property name="WorldGenSeed" value="{{ or .Values.world_seed "panel-default" }}" />
<property name="WorldGenSize" value="{{ or .Values.world_size "8192" }}" />
<property name="GameName" value="{{ or .Values.game_name "Refuge" }}" />
<property name="GameMode" value="{{ or .Values.game_mode "GameModeSurvival" }}" />
<!-- Difficulty -->
<property name="GameDifficulty" value="{{ or .Values.difficulty "2" }}" />
<property name="BlockDamagePlayer" value="{{ or .Values.block_damage_player "100" }}" />
<property name="BlockDamageAI" value="{{ or .Values.block_damage_ai "100" }}" />
<property name="BlockDamageAIBM" value="{{ or .Values.block_damage_ai_bm "100" }}" />
<property name="XPMultiplier" value="{{ or .Values.xp_multiplier "60" }}" />
<property name="PlayerSafeZoneLevel" value="{{ or .Values.player_safe_zone_level "5" }}" />
<property name="PlayerSafeZoneHours" value="{{ or .Values.player_safe_zone_hours "5" }}" />
<!-- Game Rules -->
<property name="BuildCreate" value="{{ or .Values.build_create "false" }}" />
<property name="DayNightLength" value="{{ or .Values.day_length_minutes "60" }}" />
<property name="DayLightLength" value="{{ or .Values.daylight_hours "18" }}" />
<property name="BiomeProgression" value="{{ or .Values.biome_progression "true" }}" />
<property name="StormFreq" value="{{ or .Values.storm_freq "100" }}" />
<property name="DeathPenalty" value="{{ or .Values.death_penalty "1" }}" />
<property name="DropOnDeath" value="{{ or .Values.drop_on_death "3" }}" />
<property name="DropOnQuit" value="{{ or .Values.drop_on_quit "0" }}" />
<property name="BedrollDeadZoneSize" value="{{ or .Values.bedroll_dead_zone_size "15" }}" />
<property name="BedrollExpiryTime" value="{{ or .Values.bedroll_expiry_time "45" }}" />
<property name="AllowSpawnNearFriend" value="{{ or .Values.allow_spawn_near_friend "2" }}" />
<property name="CameraRestrictionMode" value="{{ or .Values.camera_restriction_mode "0" }}" />
<property name="JarRefund" value="{{ or .Values.jar_refund "50" }}" />
<!-- Performance related -->
<property name="MaxSpawnedZombies" value="{{ or .Values.max_spawned_zombies "80" }}" />
<property name="MaxSpawnedAnimals" value="{{ or .Values.max_spawned_animals "50" }}" />
<property name="ServerMaxAllowedViewDistance" value="{{ or .Values.max_view_distance "8" }}" />
<property name="MaxQueuedMeshLayers" value="{{ or .Values.max_queued_mesh_layers "1000" }}" />
<!-- Zombie settings -->
<property name="EnemySpawnMode" value="{{ or .Values.enemy_spawn_mode "true" }}" />
<property name="EnemyDifficulty" value="{{ or .Values.enemy_difficulty "0" }}" />
<property name="ZombieFeralSense" value="{{ or .Values.zombie_feral_sense "0" }}" />
<property name="ZombieMove" value="{{ or .Values.zombie_move "0" }}" />
<property name="ZombieMoveNight" value="{{ or .Values.zombie_move_night "3" }}" />
<property name="ZombieFeralMove" value="{{ or .Values.zombie_feral_move "3" }}" />
<property name="ZombieBMMove" value="{{ or .Values.zombie_bm_move "4" }}" />
<property name="AISmellMode" value="{{ or .Values.ai_smell_mode "3" }}" />
<property name="BloodMoonFrequency" value="{{ or .Values.blood_moon_frequency "7" }}" />
<property name="BloodMoonRange" value="{{ or .Values.blood_moon_range "0" }}" />
<property name="BloodMoonWarning" value="{{ or .Values.blood_moon_warning "8" }}" />
<property name="BloodMoonEnemyCount" value="{{ or .Values.blood_moon_enemy_count "6" }}" />
<!-- Loot -->
<property name="LootAbundance" value="{{ or .Values.loot_abundance "50" }}" />
<property name="LootRespawnDays" value="{{ or .Values.loot_respawn_days "2" }}" />
<property name="AirDropFrequency" value="{{ or .Values.airdrop_frequency "24" }}" />
<property name="AirDropMarker" value="{{ or .Values.airdrop_marker "true" }}" />
<!-- Multiplayer -->
<property name="PartySharedKillRange" value="{{ or .Values.party_shared_kill_range "200" }}" />
<property name="PlayerKillingMode" value="{{ or .Values.pk_mode "0" }}" />
<!-- Land claim options -->
<property name="LandClaimCount" value="{{ or .Values.land_claim_count "12" }}" />
<property name="LandClaimSize" value="{{ or .Values.land_claim_size "21" }}" />
<property name="LandClaimDeadZone" value="{{ or .Values.land_claim_dead_zone "5" }}" />
<property name="LandClaimExpiryTime" value="{{ or .Values.land_claim_expiry_time "30" }}" />
<property name="LandClaimDecayMode" value="{{ or .Values.land_claim_decay_mode "2" }}" />
<property name="LandClaimOnlineDurabilityModifier" value="{{ or .Values.land_claim_online_durability_modifier "0" }}" />
<property name="LandClaimOfflineDurabilityModifier" value="{{ or .Values.land_claim_offline_durability_modifier "0" }}" />
<property name="LandClaimOfflineDelay" value="{{ or .Values.land_claim_offline_delay "0" }}" />
<property name="DynamicMeshEnabled" value="{{ or .Values.dynamic_mesh_enabled "false" }}" />
<property name="DynamicMeshLandClaimOnly" value="{{ or .Values.dynamic_mesh_land_claim_only "false" }}" />
<property name="DynamicMeshLandClaimBuffer" value="{{ or .Values.dynamic_mesh_land_claim_buffer "3" }}" />
<property name="DynamicMeshMaxItemCache" value="{{ or .Values.dynamic_mesh_max_item_cache "3" }}" />
<property name="TwitchServerPermission" value="{{ or .Values.twitch_server_permission "90" }}" />
<property name="TwitchBloodMoonAllowed" value="{{ or .Values.twitch_blood_moon_allowed "false" }}" />
<property name="QuestProgressionDailyLimit" value="{{ or .Values.quest_daily_limit "15" }}" />
</ServerSettings>
+62
View File
@@ -0,0 +1,62 @@
# ARK: Survival Ascended — panel module
A panel-compatible module for running ARK: Survival Ascended (SA) dedicated
servers on any agent with Docker available.
## What this is
A thin manifest wrapper around `mschnitzer/ark-survival-ascended-server`
the most actively-maintained community image for ASA on Linux. That image
handles the Proton runtime (ASA has no native Linux server) + SteamCMD +
the Windows binary wrapper.
## First-run flow
1. **Create** the server in the panel (pick `ARK: Survival Ascended` from
the module picker).
2. Click **Update** on its card → our generic SteamCMD sidecar downloads
~18 GB into the instance's `install` volume.
3. Open **Config** → under *Identity*, set the server name and admin
password. Save.
4. Click **Start**. First map load takes 35 minutes — watch the **Console**
tab; when you see `Server started on port 7777`, it's ready.
## RAM
ASA is a hog. Plan for:
- **12 GB** minimum for `TheIsland_WP` with up to ~30 players
- **16 GB** comfortable
- **24 GB** if you're running a popular community
## Changing maps
Set `SERVER_MAP` in the container env (the mschnitzer image reads it at
boot). Valid values include:
- `TheIsland_WP`
- `TheCenter_WP`
- `ScorchedEarth_WP`
- `Aberration_WP`
- `Extinction_WP`
- `Astraeos_WP`
- `ClubArk_WP`
After changing the map, click **Stop → Start**.
## Using your own image
Replace the `image:` field in `module.yaml` with your Docker image. Keep
the volume mounts (`/home/steam/ASA/AppFiles` and
`/home/steam/ASA/AppFiles/ShooterGame/Saved`) so SteamCMD's output lands
where ASA expects and saves persist across updates.
## Known quirks
- **First start is slow** (35 min). This is Proton loading + ARK doing
its world init. Subsequent starts are ~90 seconds.
- **Windows file locks** — don't run Update while the server is running;
SteamCMD will conflict with ARK's open file handles.
- **EAC is enabled by default.** If you want to run mods that EAC blocks,
disable via `-NoBattlEye` in the server args (community image exposes
an env for this).
+379
View File
@@ -0,0 +1,379 @@
# ARK: Survival Ascended — dedicated server module.
#
# ASA has no native Linux server binary — it runs the Windows build under
# Proton. Rather than bundle our own 500 MB+ Wine/Proton image, this module
# uses `acekorneya/asa_server:latest`, the most actively-maintained
# community image for ASA on Linux. It packages:
#
# - Proton + Wine runtime
# - SteamCMD (handles install + auto-update on start)
# - An entrypoint that reads env vars and assembles the ASA command line
# - BattlEye / RCON / cluster flags
#
# So: the SteamCMD-style "download game via our sidecar" pattern we use for
# 7DTD doesn't apply here — the image downloads ASA itself on first boot
# via its internal SteamCMD. No external update provider needed, which is
# why this module has NO `update_providers:` section (the `Update` button
# won't do anything useful for ASA; just restart the server to pull game
# patches — the image auto-updates when UPDATE_SERVER=true is set).
#
# Operators who want to own the stack can publish their own image (Proton
# + SteamCMD + ASA) and swap `image:` below. Keep the env vars the same —
# the community entrypoint reads `SESSION_NAME`, `SERVER_ADMIN_PASSWORD`,
# `SERVER_MAP` etc.
#
# First-run flow:
# 1. Create the server in the panel → empty container + volume.
# 2. Click Start → the image's entrypoint runs SteamCMD inside the
# container and downloads ~18 GB of ASA into /home/pok/arkserver.
# First boot takes 1530 minutes depending on bandwidth.
# 3. After the install finishes the server actually starts. Open Console
# and wait for "Server started on port 7777".
#
# Subsequent starts are quick (<= 2 min) since the files are cached on the
# named volume.
id: ark-sa
name: "ARK: Survival Ascended"
version: 0.3.0
authors:
- panel contributors
supported_modes:
- docker
# The acekorneya image handles its own SteamCMD download on first Start
# (UPDATE_SERVER=TRUE env). Opt out of the panel's auto-update-on-create
# sidecar — its install path doesn't match the image's layout and would
# just waste ~20 minutes creating files the server won't use.
auto_update_on_create: false
# But DO auto-start after create — the image's entrypoint runs its own
# SteamCMD on first boot and then launches the server, so Create → Start
# is the one-gesture flow operators expect. (True is the default across
# all modules; declared here so the interaction with auto_update_on_create
# being false is explicit to anyone reading this file.)
auto_start_on_create: true
runtime:
docker:
image: acekorneya/asa_server:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
env:
TZ: "Etc/UTC"
# acekorneya's entrypoint expects these. They're defaults — operators
# override per-instance once the config-values layer is wired.
INSTANCE_NAME: "panel-asa"
SERVER_MAP: "TheIsland_WP"
SESSION_NAME: "Panel ARK SA Server"
SERVER_ADMIN_PASSWORD: "changeme-please"
SERVER_PASSWORD: ""
ASA_PORT: "7777"
# Steam query port. The agent rewrites this to the actually-allocated
# host port at create time (see resolve.go's port-env mapping) — for
# the first ARK on an agent this stays 27015; the second gets 27016
# or whatever the allocator picked. The acekorneya image translates
# this into the launch arg `?QueryPort=<n>` on its end.
QUERY_PORT: "27015"
RCON_PORT: "27020"
MAX_PLAYERS: "70"
# Pull any ASA patches from Steam on every start. Cheap when up to
# date (SteamCMD validate is a few seconds) and means the server is
# always current.
UPDATE_SERVER: "TRUE"
CHECK_FOR_UPDATE_INTERVAL: "24"
UPDATE_WINDOW_MINIMUM_TIME: "12:00 AM"
UPDATE_WINDOW_MAXIMUM_TIME: "11:59 PM"
RESTART_NOTICE_MINUTES: "30"
ENABLE_MOTD: "FALSE"
MOTD: "Welcome!"
MOTD_DURATION: "30"
MAP_NAME: "TheIsland"
NOTIFY_ADMIN_COMMANDS_IN_CHAT: "FALSE"
RANDOM_STARTUP_DELAY: "FALSE"
# BattlEye under Wine/Proton on Linux containers silently crashes the
# server ~2-3 minutes after "Server has successfully started!" (no
# log output between the success line and the process exit — classic
# UE5 silent death on BE handshake failure). Operators can enable it
# per-instance once BE for ASA/Linux gets sorted upstream; for now
# the default is off so servers actually stay up.
BATTLEEYE: "FALSE"
RCON_ENABLED: "TRUE"
DISPLAY_POK_MONITOR_MESSAGE: "FALSE"
MOD_IDS: ""
PASSIVE_MODS: ""
CUSTOM_SERVER_ARGS: ""
CLUSTER_ID: ""
# ----- EOS / session-browser stability knobs (see post_start) -----
# ASA reports the host's auto-detected adapter IP to Epic Online
# Services when it registers the server session for the in-game
# browser. Under Docker (host-networking, multiple bridges, IPv6
# scopes) EOS sometimes picks the wrong adapter on a re-registration
# and advertises an unreachable IP. The server stays up + RCON works
# + connect-by-IP works, but it vanishes from the browser. ASA
# exposes `-PublicIPForEpic=<ipv4>` to override the auto-detect.
# Leave empty to keep ASA's default behavior; set to the WAN IPv4
# players reach (the WG-edge VPS for this fleet) to pin EOS to it.
# Reference: forums.unrealengine.com EOS-DS browser-visibility thread.
PUBLIC_IP_FOR_EPIC: ""
# Run as UID/GID 1000 so writes land on the volume as a normal user.
PUID: "1000"
PGID: "1000"
# The acekorneya image runs ARK under Wine — the live install lives at
# the Wine-virtualized Windows path below. Early module versions mounted
# /home/pok/arkserver thinking it was a symlink; it isn't — that dir
# doesn't exist at image build time, so the volume was essentially
# unused, and saves/configs lived only in the container's ephemeral
# overlay FS. Fixed by mounting DIRECTLY at the Wine path, matching
# acekorneya's own docker-compose example. Saves, configs, logs, and
# admin lists all live under this Saved/ dir.
browseable_root: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved"
volumes:
# Whole ASA install (game binaries + ShooterGame/Saved/{configs,
# world data, logs}). One volume keeps things simple and mirrors
# the upstream docker-compose pattern.
- { type: volume, name: "panel-$INSTANCE_ID-server",
container: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server" }
# Cluster transfer dir — may be overridden at instance create time
# to point at a shared `panel-ark-cluster-<id>` volume for
# multi-server clustering (see the Cluster button in the ARK modal).
- { type: volume, name: "panel-$INSTANCE_ID-cluster",
container: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/clusters" }
ports:
# `env:` tells the agent which container env var to set with the
# allocated host port. The acekorneya entrypoint reads ASA_PORT /
# QUERY_PORT / RCON_PORT and assembles ARK's launch args from them
# (?Port=N ?QueryPort=N ?RCONPort=N). Without env-mirror, the second
# ARK instance on the same agent gets distinct host bindings but
# ARK still advertises 7777/27015 to Steam, so the browser shows
# only one of them.
- { name: game, proto: udp, default: 7777, required: true, env: ASA_PORT }
- { name: raw, proto: udp, default: 7778, required: true }
# Steam server-browser query port. ASA registers itself with the Steam
# master at this port, and `?QueryPort=<n>` on the ASA launch command
# tells the engine which port to bind + advertise. Without this entry
# in the manifest, the allocator can't pick distinct host ports for
# multiple instances on the same agent — they all clobber 27015 and
# only one shows up in the Steam server list.
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
- { name: rcon, proto: tcp, default: 27020, internal: true, env: RCON_PORT }
# post_start: shell commands executed inside the container by the agent
# after a successful Start. Best-effort — failures log a warning but don't
# fail the Start.
#
# Why this hook exists: ASA writes RCONPort=<n> under [ServerSettings] of
# GameUserSettings.ini at first boot, which is enough for the port to bind
# but the server silently refuses to respond to Source-RCON AUTH packets.
# Adding a duplicate RCONPort line under [SessionSettings] fixes it — a
# real, reproducible ASA quirk (hit with raw Python + the image's bundled
# rcon-cli, not panel-specific). Verified working on princess 2026-04-22.
#
# The hook: (a) waits up to 3 min for the ini to exist (cold first-boot
# SteamCMD install can take 15-30 min but the ini is written well before
# ASA launches), (b) bails out if the [SessionSettings] RCONPort already
# exists (idempotent — subsequent starts are no-ops), (c) injects the
# line, (d) signals the monitor_ark_server.sh watchdog to relaunch ASA so
# the server re-reads the patched config on its next boot cycle. The
# relaunch is the clean way — pkill is safe because the image's
# monitor_ark_server.sh is designed to re-spawn ArkAscendedServer.exe
# whenever it dies.
lifecycle:
post_start:
- |
set -e
LAUNCH="/usr/games/scripts/launch_ASA.sh"
INI="/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/Config/WindowsServer/GameUserSettings.ini"
WINEPREFIX_DIR="/usr/games/.wine"
CA_MARKER="$WINEPREFIX_DIR/.panel-amazon-roots-installed-v1"
changed=0
# ----- Fix 1: launch_ASA.sh URL is missing ?QueryPort= -----
# The acekorneya image hard-codes "?Port=${ASA_PORT}" in the launch
# URL but never appends "?QueryPort=", so every sibling ARK instance
# defaults to 27015 — only the first to bind wins, the rest have
# NO query port. Without a query port, EOS / Steam server browser
# can't advertise the server, so clients can't connect.
# Idempotent: a second run sees the marker comment and no-ops.
if [ -f "$LAUNCH" ] && ! grep -q 'QueryPort=\${QUERY_PORT' "$LAUNCH"; then
echo "[panel-hook] patching $LAUNCH to add ?QueryPort=\${QUERY_PORT}"
sed -i 's|?Port=${ASA_PORT}|?Port=${ASA_PORT}?QueryPort=${QUERY_PORT}|' "$LAUNCH"
changed=1
fi
# ----- Fix 2: GameUserSettings.ini RCONPort under [SessionSettings] -----
# ARK ASA's RCON server silently rejects AUTH unless RCONPort also
# appears under [SessionSettings] (verified on princess 2026-04-22).
# ASA accepts the launch-URL ?RCONPort=N for binding, but the
# adapter handshake also reads from this INI key.
for i in $(seq 1 180); do [ -f "$INI" ] && break; sleep 1; done
if [ -f "$INI" ] && ! awk '/^\[SessionSettings\]/{f=1; next} /^\[/{f=0} f && /^RCONPort=/{found=1} END{exit !found}' "$INI"; then
echo "[panel-hook] injecting RCONPort=${RCON_PORT:-27020} under [SessionSettings]"
awk -v port="${RCON_PORT:-27020}" '/^\[SessionSettings\]/{print; print "RCONPort=" port; next} {print}' "$INI" > "${INI}.new"
mv "${INI}.new" "$INI"
changed=1
fi
# ----- Fix 3: pin EOS-advertised IP via -PublicIPForEpic ----------------
# WHY: ASA's EOS session registration auto-detects the host's outbound
# IP each time it re-registers. Under Docker host-net with multiple
# interfaces (docker0, br-*, IPv6 link-local) EOS sometimes picks the
# wrong adapter on a re-register and advertises an unreachable IP to
# the in-game browser → "server invisible but RCON works" + "restart
# fixes it" pattern. ASA exposes -PublicIPForEpic=<ipv4> to override.
# Reference: Epic dev-forum confirmation (multihome → wrong-EOS-IP).
# Idempotent: marker on the wine binary line means we only inject once.
if [ -n "$PUBLIC_IP_FOR_EPIC" ] && [ -f "$LAUNCH" ] && ! grep -q -- '-PublicIPForEpic=' "$LAUNCH"; then
echo "[panel-hook] injecting -PublicIPForEpic=${PUBLIC_IP_FOR_EPIC} into $LAUNCH"
# Insert the flag right after the ArkAscendedServer.exe binary path.
# The launch line ends with a backslash continuation, so the next
# line (the URL + extra args) is unaffected.
sed -i 's|ArkAscendedServer.exe" \\$|ArkAscendedServer.exe" -PublicIPForEpic='"${PUBLIC_IP_FOR_EPIC}"' \\|' "$LAUNCH"
changed=1
fi
# ----- Fix 4: ensure system CA bundle is up-to-date (idempotent) -------
# WHY: EOS endpoints (*.epicgames.dev, *.on.epicgames.com) are on AWS
# CloudFront and chain to Amazon Root CA 1-4 + Starfield Services G2.
# Whether the EOS SDK under Wine uses Wine's HKLM root store or the
# statically-linked openssl/curl bundle inside the game, missing CAs
# in the system bundle have been correlated with hours-later EOS
# token-refresh failures (session ages out → server vanishes from
# in-game browser → container restart fixes by replaying cached auth).
# Reference: acekorneya/Ark-Survival-Ascended-Server#8 (open).
# The acekorneya image's Debian base lists the Amazon PEMs as enabled
# in /etc/ca-certificates.conf but may have a stale bundle if
# update-ca-certificates didn't run during image build. Marker file
# makes this once-per-prefix-lifetime — the run itself is ~1s if
# nothing needs rebuilding, so the marker is just for log noise.
if [ ! -f "$CA_MARKER" ]; then
echo "[panel-hook] running update-ca-certificates to refresh system CA bundle"
if update-ca-certificates >/dev/null 2>&1; then
echo "[panel-hook] system CA bundle refreshed"
touch "$CA_MARKER" 2>/dev/null || true
else
echo "[panel-hook] warn: update-ca-certificates failed (non-fatal)"
fi
fi
if [ "$changed" = "1" ]; then
# Relaunch ASA so it picks up the new launch script + INI. The
# image's monitor_ark_server.sh spots the missing PID within ~60s
# and respawns ArkAscendedServer.exe with the fixed config.
pkill -f ArkAscendedServer.exe 2>/dev/null || true
echo "[panel-hook] fixups applied; ASA will relaunch via image monitor"
else
echo "[panel-hook] all fixups already in place — no-op"
fi
resources:
min_ram_mb: 12288
recommended_ram_mb: 16384
# What gets archived when an operator clicks Back-up-now. Without this
# the agent tarballs the whole 18 GB ASA install (game binaries +
# Proton runtime + saves), which is wasteful — the only files that
# actually need preserving are under ShooterGame/Saved/. Cluster
# transfers live in a sibling clusters/ dir which IS bind-mounted to
# a shared volume on cluster setup, so we exclude that — restoring
# saves shouldn't clobber cluster data the other servers are using.
backup:
# Paths are RELATIVE to browseable_root (which is already
# ".../ShooterGame/Saved"), NOT relative to the container root. The
# earlier "ShooterGame/Saved/SavedArks" form pointed at a doubled path
# that doesn't exist, so every include filtered out and busybox tar
# got an empty stdin → exited non-zero → "sidecar exited 1".
include:
- "SavedArks" # world saves (the main thing)
- "Config" # GameUserSettings.ini, Game.ini
- "Logs" # tribe logs, server logs
exclude:
- "clusters" # shared volume; restoring it would clobber siblings
- "SavedArks/*/Mods" # mod cache; rebuilds on next start
- "Crashes" # crash dumps — never useful in a restore
notes: "Saves + configs + tribe logs (~250 MB typical). Game install + Proton runtime are preserved by SteamCMD on next start; no need to back them up."
rcon:
adapter: docker_exec_rcon
host_port: rcon
auth: password
# Source-of-truth precedence (highest first, per agent's resolveRCONAddress + tracker):
# 1. config_values["rcon_password"] — explicit override (rare)
# 2. config_values["SERVER_ADMIN_PASSWORD"] via password_secret —
# the env we set at create time. THIS is what actually works
# for the acekorneya image.
# 3. password_from_ini lazy-read at dial time — only effective
# AFTER the operator changes the password via the Config tab
# and the server has restarted to pick it up.
#
# Why both: the acekorneya image takes SERVER_ADMIN_PASSWORD and
# passes it on the launch command line as `?ServerAdminPassword=...`.
# ARK SA accepts the launch-arg version BUT does NOT write it back
# into GameUserSettings.ini's [ServerSettings]ServerAdminPassword
# — that key stays empty until the operator edits it. So the env
# value is the working credential. The INI fallback is for after
# the operator has changed the password via the Config tab (which
# writes to the INI but doesn't propagate back into the env).
password_secret: SERVER_ADMIN_PASSWORD
password_from_ini:
file: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/Config/WindowsServer/GameUserSettings.ini"
section: ServerSettings
key: ServerAdminPassword
commands:
list_players: "ListPlayers"
say: "ServerChat {msg}"
broadcast: "Broadcast {msg}"
kick: "KickPlayer {player}"
ban: "BanPlayer {player}"
save: "SaveWorld"
shutdown: "DoExit"
give_item: "GiveItemToPlayer {player} {item} {qty} 1 0"
set_time: "SetTimeOfDay {time}"
state_sources:
- type: rcon
command: "ListPlayers"
every: 30s
parse:
kind: regex
pattern: '^(?P<index>\d+)\.\s+(?P<name>.+?),\s+(?P<steamid>\d+)'
fields:
players_online: index
- type: log_tail
files:
- "$DATA_PATH/logs/*.log"
- "/home/pok/arkserver/ShooterGame/Saved/Logs/ShooterGame.log"
events:
join:
pattern: "(?P<name>[^ ]+) joined"
kind: join
leave:
pattern: "(?P<name>[^ ]+) left"
kind: leave
chat:
pattern: "Chat: \\[(?P<name>[^\\]]+)\\] (?P<msg>.+)"
kind: chat
# The panel manifest validator requires at least one update_provider. For
# ARK: SA the *real* update path is the image's built-in SteamCMD loop
# (env UPDATE_SERVER=TRUE). The stub below exists so the manifest loads;
# don't rely on it — the install path won't match the image's expected
# layout so running the panel's SteamCMD sidecar against it is a no-op at
# best. Click "Restart" instead; that triggers the image's update cycle.
update_providers:
- id: builtin
kind: steamcmd
app_id: "2430930"
install_path: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server"
# --- 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: '/Server has completed startup and is now advertising/i'
appearance:
emoji: "🦖"
grad: "linear-gradient(135deg,#3a2e1e,#d97706)"
art: "/game-art/ark-sa.jpg"
steam: "2430930"
+36
View File
@@ -0,0 +1,36 @@
# panel-native Barotrauma runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
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 \
libicu72 \
libssl3 \
zlib1g \
&& 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/*
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
EXPOSE 27015/udp 27016/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# panel-native Barotrauma entrypoint.
set -euo pipefail
log() { printf '[panel-barotrauma] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/DedicatedServer"
if [[ ! -f "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Barotrauma via SteamCMD."
exit 78
fi
[[ -x "${EXE}" ]] || chmod +x "${EXE}"
# ---- panel port wiring ----------------------------------------------------
# Barotrauma has no CLI port args; ports live as attributes on the root
# <serversettings> element. Seed a minimal file on first boot, then patch
# port=/queryport= from the panel-allocated env on EVERY boot so a panel
# port change is durable (the server rewrites the full file on exit).
GAME_PORT="${GAME_PORT:-27015}"
QUERY_PORT="${QUERY_PORT:-27016}"
SETTINGS="${SAVE_DIR}/serversettings.xml"
if [[ ! -f "${SETTINGS}" ]]; then
log "seeding serversettings.xml (name=${SERVER_NAME:-panel Barotrauma}, port=${GAME_PORT}, queryport=${QUERY_PORT})"
cat > "${SETTINGS}" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<serversettings name="${SERVER_NAME:-panel Barotrauma}" port="${GAME_PORT}" queryport="${QUERY_PORT}" MaxPlayers="${MAX_PLAYERS:-16}" password="${SERVER_PASSWORD:-}" IsPublic="${IS_PUBLIC:-True}" EnableUPnP="false" />
EOF
else
sed -i -E "s/\bport=\"[0-9]+\"/port=\"${GAME_PORT}\"/; s/\bqueryport=\"[0-9]+\"/queryport=\"${QUERY_PORT}\"/" "${SETTINGS}"
log "patched serversettings.xml ports (port=${GAME_PORT}, queryport=${QUERY_PORT})"
fi
# The server resolves config paths relative to the executable dir (/game),
# NOT the CWD — confirmed on first boot ("Could not find file
# '/game/serversettings.xml'"). Symlink the configs onto the save volume so
# they survive reinstalls and are browseable under Saves & Configs.
for f in serversettings.xml clientpermissions.xml; do
ln -sfn "${SAVE_DIR}/${f}" "${GAME_DIR}/${f}"
done
# Steamworks wants $HOME/.steam/sdk64/steamclient.so. The install ships a
# 64-bit copy at linux64/steamclient.so (the top-level one is 32-bit and
# fails with ELFCLASS32). Without this the server still boots (Lidgren),
# but the A2S query port never answers.
mkdir -p "${HOME}/.steam/sdk64"
ln -sfn "${GAME_DIR}/linux64/steamclient.so" "${HOME}/.steam/sdk64/steamclient.so"
# Barotrauma reads/writes serversettings.xml, clientpermissions.xml, saves/
# from CWD by default. Point CWD at save volume so nothing lives in /game.
cd "${SAVE_DIR}"
log "starting Barotrauma dedicated server"
export LD_LIBRARY_PATH="${GAME_DIR}:${LD_LIBRARY_PATH:-}"
exec stdbuf -oL -eL "${EXE}"
+93
View File
@@ -0,0 +1,93 @@
# Barotrauma — panel-native dedicated server module.
#
# Native Linux binary from SteamCMD app 1026340. NO RCON of any kind:
# verified 2026-07-14 — the server prints "Redirected input is detected but
# is not supported by this application. Input will be ignored." on boot, so
# even the stdio adapter can never deliver commands. Admin = in-game host
# console / clientpermissions.xml only.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (Barotrauma.kvp)
id: barotrauma
name: "Barotrauma"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-barotrauma:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "serversettings.xml + saves/ + clientpermissions.xml"
- name: "Game Files"
path: /game
hint: "Server binaries, Mods/"
env:
SERVER_NAME: "panel Barotrauma"
MAX_PLAYERS: "16"
SERVER_PASSWORD: ""
# Steam server-browser listing. ALSO gates the Steamworks A2S query
# port: with False the required query port never binds (verified).
# AMP defaults True too.
IS_PUBLIC: "True"
# Pre-declared so the agent's resolve.go filter lets panel-allocated
# ports flow through to the container env. Barotrauma has no CLI port
# args (AMP injects XML attributes too) — the entrypoint seeds/patches
# port= / queryport= in serversettings.xml from these on every boot.
GAME_PORT: "27015"
QUERY_PORT: "27016"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 27015, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 27016, required: true, env: QUERY_PORT }
resources:
min_ram_mb: 1024
recommended_ram_mb: 2048
# No rcon block: the dedicated server explicitly ignores redirected stdin
# (see header comment), and there is no network RCON in the game.
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (barotrauma.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '(?P<name>.+?) has joined the server\.$'
kind: join
leave:
pattern: '(?P<name>.+?) has left the server\.$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "1026340"
install_path: /game
# --- 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: '/Server started|Listening to \w+ connections/i'
appearance:
emoji: "🚢"
grad: "linear-gradient(135deg,#1e3a5f,#3b6e8c)"
art: "/game-art/barotrauma.jpg"
steam: "602960"
+80
View File
@@ -0,0 +1,80 @@
# panel-native Conan Exiles runtime image.
#
# Conan Exiles Dedicated Server is a Windows-only UE4 build. We bring just
# enough Wine + Xvfb to host it headlessly. SteamCMD is NOT installed here —
# the panel's generic updater sidecar uses the official steamcmd image and
# writes into our /game volume.
#
# Expected runtime layout inside the container:
# /game — SteamCMD install
# /game/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe — server binary
# /game/ConanSandbox/Saved/Config/WindowsServer/*.ini — config files
# /game/ConanSandbox/Mods/ — workshop paks + modlist.txt
# /game-saves — saves, configs, logs
# /entrypoint.sh — boot Xvfb, launch exe under Wine
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
# Wine pulls in ~500 MB of deps. Keep the 64-bit stack (dedicated binary is
# x86_64). cabextract + winbind satisfy Wine's helpers; procps for signal
# handling and pgrep/pkill debugging.
#
# The libfontconfig1 ... libasound2 block is the UE5 dedicated-server runtime
# dep set for the native Linux Conan Enhanced binary at
# /game/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping. Funcom's
# UE5 ELF dynamically links against these even in headless server mode (UE5
# pulls in font/X/SDL/audio plumbing during engine init). Kept alongside Wine
# so a single image can host either edition — Wine still drives the legacy
# UE4 (conan-exiles-legacy) Windows-only path.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
xvfb \
xauth \
wine64 \
wine \
winbind \
cabextract \
procps \
libfontconfig1 \
libfreetype6 \
libxcursor1 \
libxinerama1 \
libxi6 \
libxrandr2 \
libxxf86vm1 \
libgl1 \
libvulkan1 \
libsdl2-2.0-0 \
libpulse0 \
libasound2 \
&& 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/*
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 /home/panel
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Conan Exiles default ports (see module.yaml comment for reference).
EXPOSE 7777/tcp 7777/udp 7778/udp 27015/udp
EXPOSE 25575/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+55
View File
@@ -0,0 +1,55 @@
# Conan Exiles — panel-native module
Panel-native Conan Exiles dedicated server. **Enhanced (UE5)** runs as a native Linux binary; **Legacy (UE4, `conan-exiles-legacy` beta)** still runs under Wine + Xvfb. Both editions share one debian:12-slim image — the entrypoint picks launch mode from the `EDITION` config_value.
- **Image:** `panel-conan-exiles:latest` — built from `./Dockerfile`.
- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 443030) into the `panel-<id>-game` named volume. Edition matrix: **Enhanced = native Linux depot** (443032, ~4.27 GiB; `platform: linux` in `module.yaml`). **Legacy = Windows-only depot** under Wine (`platform: windows`, `beta: conan-exiles-legacy`).
- **Saves:** `panel-<id>-saves` at `/game-saves`; entrypoint moves `ConanSandbox/Saved/` from the game install into the save volume on first boot and symlinks back, so SteamCMD validate can't clobber world data.
- **Config:** Enhanced uses `ConanSandbox/Saved/Config/LinuxServer/`; Legacy uses `ConanSandbox/Saved/Config/WindowsServer/`. On the first native boot after Wine, the entrypoint auto-copies `ServerSettings.ini` / `Engine.ini` / `Game.ini` from `WindowsServer/` to `LinuxServer/` so operator-tuned values survive the cut-over. Edit via Files tab.
- **Mods:** drop Workshop pak files + `modlist.txt` into `ConanSandbox/Mods/` via the Files tab.
- **RCON:** real Source-RCON on `25575/tcp` (internal). Password `panel_rcon` (default) — editable in `module.yaml` `env.RCON_PASSWORD`. Tracker dials on start; our agent redials on EOF/broken-pipe automatically.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 7777 | TCP+UDP | Game traffic + client mod downloads |
| 7778 | UDP | Pinger (server-browser probe; `game + 1`) |
| 27015 | UDP | Steam query |
| 25575 | TCP | RCON (internal) |
## RCON commands wired
- `list``listplayers`
- `broadcast {msg}``broadcast {msg}`
- `kick {player}``kickplayer {player}`
- `ban {player}``banplayer {player}`
- `save``saveworld`
- `shutdown``shutdown`
## Log events captured
- **Join:** `LogNet: Join succeeded: <name>#<discriminator>`
- **Leave:** `LogNet: Player disconnected: <name>#<discriminator>`
- **Chat:** `ChatWindow: Character <name> said: <message>`
## Environment vars
| Var | Default | Notes |
|-----|---------|-------|
| `SERVER_NAME` | `panel Conan Exiles` | Displayed in server browser |
| `MAX_PLAYERS` | `40` | Hard cap (AMP also defaults 40) |
| `RCON_PASSWORD` | `panel_rcon` | Source RCON auth |
| `SERVER_MAP` | `/Game/Maps/ConanSandbox/ConanSandbox` | Exiled Lands. For Isle of Siptah: `/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah` |
| `STEAM_APP_ID` | `440900` | Steamworks client init context — required for Steam master-server listing. Exported as both `STEAM_APP_ID` and `SteamAppId` for cross-build compat. |
## Known gotchas
- Edition split: Enhanced (UE5) ships a real Linux depot since 2026-05-05 and runs native (no Wine). Legacy (UE4, `conan-exiles-legacy` beta) is Windows-only and stays on Wine.
- Wine under Xvfb (Legacy only) adds ~30 s to first boot (prefix init) and ~15 s to subsequent boots (X server + Wine cold start). Native Enhanced has no Wine overhead.
- Workshop mod downloads happen to `ConanSandbox/Mods/` — both the pak files AND `modlist.txt` need to be there for mods to load.
- Ready signal: `LogGameState: Match State Changed from WaitingToStart to InProgress`. Expect 13 min between docker-start and this line while UE4 loads the world.
## Reference
AMP template (CubeCoders): https://github.com/CubeCoders/AMPTemplates — `conan-exiles.kvp`, `conan-exilesports.json`, `conan-exilesupdates.json`, `conan-exilesconfig.json`, `conan-exilesmetaconfig.json`.
+399
View File
@@ -0,0 +1,399 @@
#!/bin/bash
# panel-native Conan Exiles entrypoint.
#
# Contract:
# /game — SteamCMD-installed Windows build of Conan Exiles Dedicated
# Server (populated by the panel's SteamCMD updater sidecar).
# If missing, exit with EX_CONFIG and a helpful message.
# /game-saves — ConanSandbox/Saved/ tree (world data) + Config/ INIs.
#
# Stage 1 runs as root: chown volumes (SteamCMD wrote as root), nuke stale
# X lock, symlink /Logs + ConanSandbox/Saved into the save volume, then drop
# to the `panel` user via setpriv. Wine refuses to run as root.
#
# Stage 2 (panel user): stamp config_values env vars into [ServerSettings]
# of ServerSettings.ini, then exec the dedicated server under Wine + Xvfb.
set -euo pipefail
log() { printf '[panel-conan] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
# UE4/UE5 under Wine writes to Z:\Logs (= /Logs). Pre-create as symlink
# into save volume so logs survive container recreate + show up in Files
# tab.
mkdir -p /game-saves/ConanLogs
chown -R panel:panel /game-saves/ConanLogs
if [[ ! -e /Logs || -L /Logs ]]; then
ln -sfn /game-saves/ConanLogs /Logs
fi
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
WINEPREFIX=/home/panel/.wine \
WINEDEBUG=-all \
STEAM_APP_ID="${STEAM_APP_ID:-440900}" \
SteamAppId="${STEAM_APP_ID:-440900}" \
SERVER_NAME="${SERVER_NAME:-panel Conan Exiles}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" \
RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \
MAX_PLAYERS="${MAX_PLAYERS:-40}" \
SERVER_REGION="${SERVER_REGION:-1}" \
SERVER_COMMUNITY="${SERVER_COMMUNITY:-0}" \
MAX_NUDITY="${MAX_NUDITY:-0}" \
SERVER_MAP="${SERVER_MAP:-/Game/Maps/ConanSandbox/ConanSandbox}" \
CUSTOM_MAP="${CUSTOM_MAP:-}" \
ENABLE_BATTLEYE="${ENABLE_BATTLEYE:-False}" \
ENABLE_VAC="${ENABLE_VAC:-True}" \
PVP_ENABLED="${PVP_ENABLED:-}" \
AVATARS_ENABLED="${AVATARS_ENABLED:-}" \
CONTAINERS_IGNORE_OWNERSHIP="${CONTAINERS_IGNORE_OWNERSHIP:-}" \
CAN_DAMAGE_PLAYER_OWNED_STRUCTURES="${CAN_DAMAGE_PLAYER_OWNED_STRUCTURES:-}" \
DYNAMIC_BUILDING_DAMAGE="${DYNAMIC_BUILDING_DAMAGE:-}" \
FRIENDLY_FIRE_DAMAGE="${FRIENDLY_FIRE_DAMAGE:-}" \
PLAYER_XP_RATE="${PLAYER_XP_RATE:-}" \
PLAYER_XP_KILL="${PLAYER_XP_KILL:-}" \
PLAYER_XP_HARVEST="${PLAYER_XP_HARVEST:-}" \
PLAYER_XP_CRAFT="${PLAYER_XP_CRAFT:-}" \
PLAYER_XP_TIME="${PLAYER_XP_TIME:-}" \
PLAYER_DAMAGE_MULTIPLIER="${PLAYER_DAMAGE_MULTIPLIER:-}" \
PLAYER_DAMAGE_TAKEN="${PLAYER_DAMAGE_TAKEN:-}" \
PLAYER_HEALTH_REGEN="${PLAYER_HEALTH_REGEN:-}" \
PLAYER_STAMINA_COST="${PLAYER_STAMINA_COST:-}" \
PLAYER_SPRINT_SPEED="${PLAYER_SPRINT_SPEED:-}" \
PLAYER_MOVEMENT_SPEED="${PLAYER_MOVEMENT_SPEED:-}" \
PLAYER_ENCUMBRANCE="${PLAYER_ENCUMBRANCE:-}" \
NPC_DAMAGE_MULTIPLIER="${NPC_DAMAGE_MULTIPLIER:-}" \
NPC_DAMAGE_TAKEN="${NPC_DAMAGE_TAKEN:-}" \
NPC_HEALTH_MULTIPLIER="${NPC_HEALTH_MULTIPLIER:-}" \
NPC_RESPAWN_MULTIPLIER="${NPC_RESPAWN_MULTIPLIER:-}" \
NPC_MAX_SPAWN_CAP="${NPC_MAX_SPAWN_CAP:-}" \
STRUCTURE_DAMAGE_MULTIPLIER="${STRUCTURE_DAMAGE_MULTIPLIER:-}" \
STRUCTURE_HEALTH_MULTIPLIER="${STRUCTURE_HEALTH_MULTIPLIER:-}" \
LANDCLAIM_RADIUS_MULTIPLIER="${LANDCLAIM_RADIUS_MULTIPLIER:-}" \
AVATAR_DOME_DURATION="${AVATAR_DOME_DURATION:-}" \
AVATAR_DOME_DAMAGE="${AVATAR_DOME_DAMAGE:-}" \
GAME_PORT="${GAME_PORT:-7777}" \
PINGER_PORT="${PINGER_PORT:-7778}" \
QUERY_PORT="${QUERY_PORT:-27015}" \
RCON_PORT="${RCON_PORT:-25575}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
# --- edition-aware launch mode ---
# Enhanced (UE5) is the native-Linux depot — direct exec, no Wine.
# Legacy (UE4) is Windows-only — Wine + Xvfb.
# Drives binary path, fallback find pattern, config subdir, prefix bootstrap,
# SIGTERM trap, and the launch line further below.
case "${EDITION:-enhanced}" in
legacy)
LAUNCH_MODE=wine
CANDIDATES=("${GAME_DIR}/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe")
CONFIG_SUBDIR=WindowsServer
;;
*)
LAUNCH_MODE=native
CANDIDATES=("${GAME_DIR}/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping")
CONFIG_SUBDIR=LinuxServer
;;
esac
BIN_BASENAME=$(basename "${CANDIDATES[0]}")
log "edition: ${EDITION:-enhanced} launch mode: ${LAUNCH_MODE} config subdir: ${CONFIG_SUBDIR}"
# --- locate the server binary ---
# Enhanced UE5 Linux depot ships the binary at
# /game/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping (ELF).
# Legacy UE4 Windows depot ships
# /game/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe (PE).
# Mod loading happens via ServerSettings.ini's ServerModList= key (populated
# by the panel's Mods tab) AND ConanSandbox/Mods/modlist.txt — Conan reads both.
DEDI_EXE=""
for c in "${CANDIDATES[@]}"; do
if [[ -f "$c" ]]; then
DEDI_EXE="$c"
break
fi
done
if [[ -z "${DEDI_EXE}" ]]; then
found=$(find "${GAME_DIR}" -maxdepth 6 -iname "${BIN_BASENAME}" -type f 2>/dev/null | head -1 || true)
if [[ -n "${found}" ]]; then
DEDI_EXE="${found}"
fi
fi
if [[ -z "${DEDI_EXE}" ]]; then
log "ERROR: ${BIN_BASENAME} not found under ${GAME_DIR}."
log " Run 'Update' on this server in the panel — that launches a"
log " SteamCMD sidecar which installs Conan Exiles into this volume"
log " for the selected edition (${EDITION:-enhanced})."
exit 78 # EX_CONFIG
fi
log "found server binary: ${DEDI_EXE}"
# --- persist ConanSandbox/Saved into /game-saves ---
# UE4/UE5 writes world saves + config INIs under <GameRoot>/ConanSandbox/Saved/.
# Move to save volume on first boot + symlink back, so SteamCMD validate
# can't wipe world data. Idempotent on subsequent boots.
mkdir -p "${SAVE_DIR}/ConanSandbox"
if [[ -d "${GAME_DIR}/ConanSandbox/Saved" && ! -L "${GAME_DIR}/ConanSandbox/Saved" ]]; then
if [[ ! -d "${SAVE_DIR}/ConanSandbox/Saved" ]]; then
mv "${GAME_DIR}/ConanSandbox/Saved" "${SAVE_DIR}/ConanSandbox/Saved"
else
rm -rf "${GAME_DIR}/ConanSandbox/Saved"
fi
fi
mkdir -p "${SAVE_DIR}/ConanSandbox/Saved/Config/${CONFIG_SUBDIR}"
ln -sfn "${SAVE_DIR}/ConanSandbox/Saved" "${GAME_DIR}/ConanSandbox/Saved"
# First-time native boot after Wine: if WindowsServer/ has config and
# LinuxServer/ does not yet, copy ServerSettings.ini + Engine.ini + Game.ini
# across so operator-tuned values survive the Wine -> native cut-over. Wine
# path is untouched; both dirs coexist.
if [[ "${LAUNCH_MODE}" = native ]]; then
WIN_CFG_DIR="${SAVE_DIR}/ConanSandbox/Saved/Config/WindowsServer"
LINUX_CFG_DIR="${SAVE_DIR}/ConanSandbox/Saved/Config/LinuxServer"
mkdir -p "${LINUX_CFG_DIR}"
for f in ServerSettings.ini Engine.ini Game.ini; do
if [[ -f "${WIN_CFG_DIR}/${f}" && ! -f "${LINUX_CFG_DIR}/${f}" ]]; then
log "migrating ${f} from WindowsServer/ to LinuxServer/ (first native boot)"
cp -a "${WIN_CFG_DIR}/${f}" "${LINUX_CFG_DIR}/${f}"
fi
done
fi
# OSS / Engine.ini: intentionally left alone.
#
# Empirically (see reference_conan_oss_steam_impossible.md): Conan Exiles
# Enhanced (UE5) native Linux dedi cannot be coerced into OSS=Steam without
# breaking the socket bind. AMP's Conan01 instance on figaro runs the same
# build with OSS=Null, no Engine.ini, no steam_appid.txt, and no
# ~/.steam/sdk64/steamclient.so symlink — and we now do the same. Engine
# default is Null, which binds IpNetDriver cleanly. Direct-IP join works;
# Steam server-browser listing is not achievable on this build.
#
# SteamAppId env var is already exported in stage 1 — that's enough for the
# Steamworks Game Server API to identify itself without a steam_appid.txt
# file on disk.
# --- stamp config_values env into ServerSettings.ini ---
# Each non-empty env value is written under [ServerSettings] (replace if the
# key already exists, append if not). Empty values are SKIPPED so operator
# manual edits to ServerSettings.ini via the Files tab survive boot. The
# panel UI's Config tab → Save → recreate-with-env path is the canonical
# way to mutate INI values; direct file edits are for fields the panel
# doesn't expose.
INI="${SAVE_DIR}/ConanSandbox/Saved/Config/${CONFIG_SUBDIR}/ServerSettings.ini"
if [[ ! -f "${INI}" ]]; then
log "ServerSettings.ini missing — initializing with [ServerSettings] header"
printf '[ServerSettings]\n' > "${INI}"
fi
if ! grep -qE '^\[ServerSettings\]' "${INI}"; then
log "ServerSettings.ini lacked [ServerSettings] header — prepending"
printf '[ServerSettings]\n' > "${INI}.new"
cat "${INI}" >> "${INI}.new"
mv "${INI}.new" "${INI}"
fi
# set_ini_kv KEY VALUE: write/replace KEY=VALUE under [ServerSettings].
# Skip on empty value. Idempotent. Tolerates `&` and `\` in values via the
# safe_value escape (sed RHS replacement metachars).
set_ini_kv() {
local key="$1" value="$2"
if [[ -z "${value}" ]]; then return 0; fi
local safe_value
safe_value=$(printf '%s' "${value}" | sed -e 's/[\\&|]/\\&/g')
if grep -qE "^${key}=" "${INI}"; then
sed -i "s|^${key}=.*|${key}=${safe_value}|" "${INI}"
else
sed -i "/^\[ServerSettings\]/a ${key}=${safe_value}" "${INI}"
fi
}
# Resolve the {{CUSTOM_MAP}} sentinel: if Map is set to "Custom" via the UI
# the panel passes SERVER_MAP="{{CUSTOM_MAP}}" or "{{CustomMap}}" — swap in
# the operator's CUSTOM_MAP value (or fall back to Exiled Lands if blank).
case "${SERVER_MAP}" in
"{{CUSTOM_MAP}}"|"{{CustomMap}}")
if [[ -n "${CUSTOM_MAP}" ]]; then
log "Map set to Custom; resolving to ${CUSTOM_MAP}"
SERVER_MAP="${CUSTOM_MAP}"
else
log "WARNING: Map set to Custom but CUSTOM_MAP is empty; falling back to Exiled Lands"
SERVER_MAP="/Game/Maps/ConanSandbox/ConanSandbox"
fi
;;
esac
log "stamping operator config_values into ${INI}"
# Identity
set_ini_kv "ServerName" "${SERVER_NAME}"
set_ini_kv "ServerPassword" "${SERVER_PASSWORD}"
set_ini_kv "AdminPassword" "${ADMIN_PASSWORD}"
set_ini_kv "MaxPlayers" "${MAX_PLAYERS}"
set_ini_kv "serverRegion" "${SERVER_REGION}"
set_ini_kv "ServerCommunity" "${SERVER_COMMUNITY}"
set_ini_kv "MaxNudity" "${MAX_NUDITY}"
# Anti-cheat
set_ini_kv "IsBattlEyeEnabled" "${ENABLE_BATTLEYE}"
set_ini_kv "IsVACEnabled" "${ENABLE_VAC}"
# PvP / world
set_ini_kv "PVPEnabled" "${PVP_ENABLED}"
set_ini_kv "AvatarsEnabled" "${AVATARS_ENABLED}"
set_ini_kv "ContainersIgnoreOwnership" "${CONTAINERS_IGNORE_OWNERSHIP}"
set_ini_kv "CanDamagePlayerOwnedStructures" "${CAN_DAMAGE_PLAYER_OWNED_STRUCTURES}"
set_ini_kv "DynamicBuildingDamage" "${DYNAMIC_BUILDING_DAMAGE}"
set_ini_kv "FriendlyFireDamageMultiplier" "${FRIENDLY_FIRE_DAMAGE}"
# XP rates
set_ini_kv "PlayerXPRateMultiplier" "${PLAYER_XP_RATE}"
set_ini_kv "PlayerXPKillMultiplier" "${PLAYER_XP_KILL}"
set_ini_kv "PlayerXPHarvestMultiplier" "${PLAYER_XP_HARVEST}"
set_ini_kv "PlayerXPCraftMultiplier" "${PLAYER_XP_CRAFT}"
set_ini_kv "PlayerXPTimeMultiplier" "${PLAYER_XP_TIME}"
# Player tuning
set_ini_kv "PlayerDamageMultiplier" "${PLAYER_DAMAGE_MULTIPLIER}"
set_ini_kv "PlayerDamageTakenMultiplier" "${PLAYER_DAMAGE_TAKEN}"
set_ini_kv "PlayerHealthRegenSpeedScale" "${PLAYER_HEALTH_REGEN}"
set_ini_kv "PlayerStaminaCostMultiplier" "${PLAYER_STAMINA_COST}"
set_ini_kv "PlayerSprintSpeedScale" "${PLAYER_SPRINT_SPEED}"
set_ini_kv "PlayerMovementSpeedScale" "${PLAYER_MOVEMENT_SPEED}"
set_ini_kv "PlayerEncumbranceMultiplier" "${PLAYER_ENCUMBRANCE}"
# NPC tuning
set_ini_kv "NPCDamageMultiplier" "${NPC_DAMAGE_MULTIPLIER}"
set_ini_kv "NPCDamageTakenMultiplier" "${NPC_DAMAGE_TAKEN}"
set_ini_kv "NPCHealthMultiplier" "${NPC_HEALTH_MULTIPLIER}"
set_ini_kv "NPCRespawnMultiplier" "${NPC_RESPAWN_MULTIPLIER}"
set_ini_kv "NPCMaxSpawnCapMultiplier" "${NPC_MAX_SPAWN_CAP}"
# Structures / land claim
set_ini_kv "StructureDamageMultiplier" "${STRUCTURE_DAMAGE_MULTIPLIER}"
set_ini_kv "StructureHealthMultiplier" "${STRUCTURE_HEALTH_MULTIPLIER}"
set_ini_kv "LandClaimRadiusMultiplier" "${LANDCLAIM_RADIUS_MULTIPLIER}"
set_ini_kv "AvatarDomeDurationMultiplier" "${AVATAR_DOME_DURATION}"
set_ini_kv "AvatarDomeDamageMultiplier" "${AVATAR_DOME_DAMAGE}"
# --- Steam OSS fix — INCOMPLETE, currently disabled ---
# Live experiment on conan-1 (2026-05-10) showed:
# 1. Adding DefaultPlatformService=Steam to Engine.ini alone is a no-op:
# Steam SDK reports "[STEAM] disabled" because the Windows binary can't
# locate steamclient64.dll (Funcom's dedi-server depot ships these at
# /game root, not under ConanSandbox/Binaries/Win64/).
# 2. Copying steamclient*.dll + tier0_s*.dll + vstdlib_s*.dll +
# steamwebrtc*.dll + a steam_appid.txt (440900) into the binary dir
# makes the SDK initialize successfully:
# LogOnline: STEAM: [AppId: 440900] Game Server API initialized 1
# LogOnline: OSS: Created online subsystem instance for: STEAM
# 3. BUT once Steam OSS comes up under Wine, the engine hangs the
# GameThread within ~80s, never binds the game port (8010 UDP),
# and LogServerStats reports "NetDriver is null". Pre-fix logs had
# none of these symptoms (server bound + responded to A2S fine, just
# couldn't accept real-client joins past encryption handshake).
# Net effect: enabling Steam OSS via Engine.ini + DLL drops trades one
# failure mode (handshake reject) for a worse one (no NetDriver at all).
# Leaving the original Engine.ini untouched until we find the correct
# NetDriverDefinitions / SteamSockets-disable knob that lets Steam OSS
# coexist with a working IpNetDriver under Wine.
#
# See session log 2026-05-10 for full investigation.
# --- Wine prefix bootstrap (first boot only, legacy/Wine path only). xvfb-run
# gives Wine a guaranteed-fresh DISPLAY without colliding with host Xvfb
# on :99. Native (enhanced) path skips this entirely. ---
if [[ "${LAUNCH_MODE}" = wine ]]; then
if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then
log "initializing Wine prefix at ${WINEPREFIX}"
xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true
wineserver -w || true
fi
fi
# --- go ---
# CLI-arg fallbacks: env values default to empty strings in module.yaml so
# the entrypoint's INI-stamper leaves operator INI edits alone. But Conan's
# launch CLI requires real values for ServerName, MaxPlayers, ports, and
# RconPassword. Resolve those here so empty config_values still produce a
# launchable server. These fallbacks should match the previous module.yaml
# defaults so behaviour is unchanged for fresh installs.
SERVER_NAME_LAUNCH="${SERVER_NAME:-panel Conan Exiles}"
MAX_PLAYERS_LAUNCH="${MAX_PLAYERS:-40}"
RCON_PASSWORD_LAUNCH="${RCON_PASSWORD:-panel_rcon}"
BIN_DIR="$(dirname "${DEDI_EXE}")"
cd "${BIN_DIR}"
log "starting Conan Exiles dedicated server"
log " game dir: ${GAME_DIR}"
log " save dir: ${SAVE_DIR}"
log " binary: ${DEDI_EXE}"
log " bin dir: ${BIN_DIR}"
log " map: ${SERVER_MAP}"
log " max: ${MAX_PLAYERS_LAUNCH}"
log " game port: ${GAME_PORT}/tcp+udp"
log " query: ${QUERY_PORT}/udp"
log " rcon: ${RCON_PORT}/tcp"
if [[ "${LAUNCH_MODE}" = wine ]]; then
trap 'log "SIGTERM received; stopping server"; wineserver -k || true; exit 0' TERM INT
else
trap 'log "SIGTERM received; stopping server"; kill -TERM "${SERVER_PID:-0}" 2>/dev/null || true; wait "${SERVER_PID:-0}" 2>/dev/null || true; exit 0' TERM INT
fi
# Launch flags. Conan reads most settings from ServerSettings.ini (which we
# just stamped) — CLI args set the boot-time essentials. Pinger port is
# `Port + 1` per AMP convention; setting only -Port is enough — Conan
# derives pinger from it automatically.
#
# Native (enhanced) — match Funcom's launcher convention: `ConanSandbox`
# first positional, then the map URL. No -log/-nosound/-SteamServerName
# (those are Wine-era flags). ServerName lives in [ServerSettings]
# ServerName= which our INI stamper already handles.
if [[ "${LAUNCH_MODE}" = native ]]; then
chmod +x "${DEDI_EXE}" || true
exec stdbuf -oL -eL "${DEDI_EXE}" \
ConanSandbox \
"${SERVER_MAP}" \
-stdout \
-FullStdOutLogOutput \
"-ServerName=${SERVER_NAME_LAUNCH}" \
"-MaxPlayers=${MAX_PLAYERS_LAUNCH}" \
"-Port=${GAME_PORT}" \
"-QueryPort=${QUERY_PORT}" \
-RconEnabled=1 \
"-RconPort=${RCON_PORT}" \
"-RconPassword=${RCON_PASSWORD_LAUNCH}"
fi
# Wine (legacy) — xvfb-run -a auto-allocates a free X display (host has its
# own Xvfb on :99 / :100 / :101 — see panel/memory/gotchas.md "network_mode:
# host Xvfb collision"). `ConanSandbox` first positional matches Funcom's
# launcher convention; was missing on the old Wine launch line.
exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \
wine "${DEDI_EXE}" \
ConanSandbox \
"${SERVER_MAP}" \
-log \
"-SteamServerName=${SERVER_NAME_LAUNCH}" \
"-ServerName=${SERVER_NAME_LAUNCH}" \
"-MaxPlayers=${MAX_PLAYERS_LAUNCH}" \
"-Port=${GAME_PORT}" \
"-QueryPort=${QUERY_PORT}" \
-RconEnabled=1 \
"-RconPort=${RCON_PORT}" \
"-RconPassword=${RCON_PASSWORD_LAUNCH}" \
-nosound \
-stdout \
-FullStdOutLogOutput
+573
View File
@@ -0,0 +1,573 @@
# Conan Exiles dedicated server module.
#
# Conan Exiles Dedicated Server (Steam app 443030) — Funcom rolled out the
# UE5 "Enhanced" remaster as the default branch on 2026-05-05. The UE4 build
# remains available indefinitely as Steam beta branch `conan-exiles-legacy`
# (description: "UE4 version of Conan Exiles Dedicated Server"). Both ride
# the same appid; only the branch differs. Default install pulls Enhanced.
#
# Hosting matrix as of 2026-05-10:
# Enhanced (UE5, public branch) — NATIVE LINUX. Funcom shipped a real Linux
# dedicated-server depot (443032, ~4.27 GiB) alongside the 2026-05-05
# Enhanced launch. Binary at
# ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping. We run
# it directly — no Wine, no Xvfb. Config dir is Config/LinuxServer/.
# Legacy (UE4, conan-exiles-legacy beta) — STILL WINDOWS-ONLY. No Linux
# depot exists for this branch; we keep the Wine + Xvfb path for it.
# Binary at ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe,
# config dir Config/WindowsServer/. Same pattern as Empyrion / V Rising /
# Sons of the Forest / Windrose.
# The container ships with both runtimes (Wine + UE5 Linux deps) so a single
# image can host either edition; entrypoint picks the launch mode from the
# EDITION config_value.
#
# Memory footprint: Enhanced UE5 runs ~2-4x heavier than UE4 Legacy on
# populated worlds. Defaults bumped accordingly (see `resources:` below).
#
# RCON is real Source-RCON on 25575/tcp — AMP's template, community
# servers, and the rcon plugin that ships with Conan all speak it. We
# surface it via our source_rcon adapter; the tracker's redial-on-EOF
# logic covers any idle disconnects (same fix landed for Palworld).
#
# Reference: https://github.com/CubeCoders/AMPTemplates (conan-exiles.kvp,
# conan-exilesports.json, conan-exilesupdates.json, conan-exilesconfig.json)
# Reference: https://low.ms/knowledgebase/conan-exiles-enhanced-update
id: conan-exiles
name: "Conan Exiles"
version: 0.3.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-conan-exiles:latest modules/conan-exiles
image: panel-conan-exiles:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "ServerSettings.ini, Game.ini, ConanSandbox/Saved/ world data"
- name: "Game Files"
path: /game
hint: "ConanSandbox/Binaries/Linux/ for Enhanced (UE5 native), ConanSandbox/Binaries/Win64/ for Legacy (UE4 under Wine). Engine/, ConanSandbox/Mods/ (Workshop pak drops). Workshop downloads land at /game/steamapps/workshop/content/440900/<id>/ via the shared volume mount."
# Pre-declare every env key that any config_value below maps to. The
# agent's resolve.go ONLY propagates config_values whose key is
# already present in `docker.env` — keys not listed here get silently
# filtered out. Defaults below are starting values for fresh instances;
# operator edits in the panel's Config tab override per-instance.
# Empty-string defaults mean "leave the INI alone" (entrypoint stamps
# only non-empty values, so unset fields fall back to game defaults
# or operator's manual ServerSettings.ini edits).
env:
# Steamworks client runtime init context — the dedicated-server app
# (443030) reports itself to Steam as Conan Exiles (440900). AMP
# sets this too; the server refuses to join the Steam master-server
# listing without it.
STEAM_APP_ID: "440900"
# EDITION — see config_values block below. Default `enhanced` (UE5).
# Pre-declared here so resolve.go propagates the operator's choice
# into the container env (the agent's docker.env filter only forwards
# config_values whose key already appears in this map). The entrypoint
# logs it so operators can grep boot output to confirm what's running.
EDITION: "enhanced"
# --- Identity ---
# All identity fields default to EMPTY. The entrypoint only stamps
# non-empty values into [ServerSettings] of ServerSettings.ini, so a
# recreate with empty env preserves whatever the operator set
# previously via the Files tab. CLI-launch args have hardcoded
# fallbacks in entrypoint.sh (e.g. ServerName falls back to
# "panel Conan Exiles" for the Steam server-name flag only when
# SERVER_NAME is genuinely unset).
SERVER_NAME: ""
SERVER_PASSWORD: ""
ADMIN_PASSWORD: ""
RCON_PASSWORD: ""
MAX_PLAYERS: ""
SERVER_REGION: ""
MAX_NUDITY: ""
SERVER_COMMUNITY: ""
# --- Map ---
# /Game/Maps/ConanSandbox/ConanSandbox = Exiled Lands (default)
# /Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah = Isle of Siptah
# SERVER_MAP keeps a default because Conan REQUIRES a map URL on the
# CLI; empty would mean "use last used", which is fine but surprising
# for new instances.
SERVER_MAP: "/Game/Maps/ConanSandbox/ConanSandbox"
CUSTOM_MAP: ""
# --- Anti-cheat ---
# Empty means "leave INI default" (entrypoint skips stamping). Conan's
# game default is BattlEye=False, VAC=True — same as ours used to be.
ENABLE_BATTLEYE: ""
ENABLE_VAC: ""
# --- Persistence / online ---
PVP_ENABLED: ""
AVATARS_ENABLED: ""
CONTAINERS_IGNORE_OWNERSHIP: ""
CAN_DAMAGE_PLAYER_OWNED_STRUCTURES: ""
DYNAMIC_BUILDING_DAMAGE: ""
# --- Gameplay multipliers (empty = use game default) ---
PLAYER_XP_RATE: ""
PLAYER_XP_KILL: ""
PLAYER_XP_HARVEST: ""
PLAYER_XP_CRAFT: ""
PLAYER_XP_TIME: ""
PLAYER_DAMAGE_MULTIPLIER: ""
PLAYER_DAMAGE_TAKEN: ""
PLAYER_HEALTH_REGEN: ""
PLAYER_STAMINA_COST: ""
PLAYER_SPRINT_SPEED: ""
PLAYER_MOVEMENT_SPEED: ""
PLAYER_ENCUMBRANCE: ""
NPC_DAMAGE_MULTIPLIER: ""
NPC_DAMAGE_TAKEN: ""
NPC_HEALTH_MULTIPLIER: ""
NPC_RESPAWN_MULTIPLIER: ""
NPC_MAX_SPAWN_CAP: ""
STRUCTURE_DAMAGE_MULTIPLIER: ""
STRUCTURE_HEALTH_MULTIPLIER: ""
LANDCLAIM_RADIUS_MULTIPLIER: ""
AVATAR_DOME_DURATION: ""
AVATAR_DOME_DAMAGE: ""
FRIENDLY_FIRE_DAMAGE: ""
# --- Pre-declared so allocator-derived ports survive resolve.go's filter. ---
GAME_PORT: "7777"
PINGER_PORT: "7778"
QUERY_PORT: "27015"
RCON_PORT: "25575"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Shared Steam-Workshop content volume — exact same pattern as DayZ
# (modules/dayz/module.yaml mounts panel-dayz-workshop at
# /game/steamapps/workshop). Volume's root contains a flat
# `content/<appid>/<id>/` tree (no extra prefix); mounting at
# /game/steamapps/workshop makes it appear as
# /game/steamapps/workshop/content/440900/<id>/<file>.pak
# — under the /game declared root, so FsList/FsSymlink work without
# extra browseable_roots. The SteamCMD sidecar uses a different deep
# path on its end (/conan-workshop/steamapps/workshop) so its
# +force_install_dir matches; see conanmods.go's
# runSteamCMDConanWorkshopDownloadOnceStreaming for the args. Both
# the sidecar and the game container are talking to the same volume,
# they just label the mount point differently.
- { type: volume, name: "panel-conan-workshop", container: "/game/steamapps/workshop" }
# Conan Exiles ports (matches AMP template):
# 7777/both (tcp+udp) — main game traffic + client mod downloads
# 7778/udp — pinger (game+1 offset; server browser probe)
# 27015/udp — Steam query
# 25575/tcp — RCON (internal)
ports:
# Conan binds the game socket on UDP `-Port=N` and the pinger
# (server-browser probe) on UDP N+1 — internally derived, no override
# flag exists. The panel allocator scans the agent window per-port; with
# no prior holes the first three rows land on consecutive numbers
# N, N+1, N+2 — so PINGER_PORT == GAME_PORT+1 and Conan's auto-derived
# pinger lands on the row we forward.
#
# `game` MUST be UDP — Conan's game socket is UDP-only. (AMP's template
# historically listed it as tcp+udp because of optional in-game mod
# downloads, but the dedicated-server depot never opens a TCP listener
# on -Port; the opnfwd orchestrator forwards by declared proto, so a
# tcp row here ends up forwarding a dead TCP port and leaves the real
# UDP game socket unforwarded — players can't join, server doesn't
# register on the master server.)
- { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT }
- { name: pinger, proto: udp, default: 7778, required: true, env: PINGER_PORT }
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
- { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT }
resources:
# Bumped for Enhanced UE5 (was 6144 / 12288 for UE4). Funcom + community
# benchmarks have populated Enhanced worlds at 2-4x the UE4 footprint —
# 8 GB minimum keeps a small server alive, 24 GB recommended for a
# populated long-running build-heavy server.
min_ram_mb: 8192
recommended_ram_mb: 24576
# Per-instance generated RCON secret — overrides env RCON_PASSWORD at
# create so every instance gets a unique password (see pkg/module
# GenerateSecrets). Operators can still override via the RCON Password
# config field.
secrets:
- name: RCON_PASSWORD
description: "Conan Exiles Source-RCON password — generated per instance"
generated: true
rcon:
adapter: source_rcon
host_port: rcon
auth: source_rcon_login
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances).
password_literal: "panel_rcon"
commands:
# Conan's RCON commands. `listplayers` is the roster query; `broadcast`
# sends an in-game message; `kick`/`ban` take a player # identifier.
list: "listplayers"
broadcast: "broadcast {msg}"
kick: "kickplayer {player}"
ban: "banplayer {player}"
save: "saveworld"
shutdown: "shutdown"
state_sources:
- type: rcon
command: "listplayers"
every: 60s
# Log-line event patterns. AMP's template is the source of these regexes —
# tested against real Conan Exiles boot output. Go regex = RE2; convert
# `(?<name>...)` → `(?P<name>...)`.
events:
join:
pattern: 'LogNet: Join succeeded: (?P<name>.+?#\d+)'
kind: join
leave:
pattern: 'LogNet: Player disconnected: (?P<name>.+?#\d+)'
kind: leave
chat:
pattern: 'ChatWindow: Character (?P<name>.+?) said: (?P<msg>.+)'
kind: chat
# Two update branches on the same appid. Operator picks via the Update
# button in the panel UI — `enhanced` is the default (UE5, public branch);
# `legacy` pins to UE4 via the `conan-exiles-legacy` Steam beta branch
# (Funcom's announced indefinite UE4 archive).
update_providers:
# `id` is also the user-facing label in the UI (no separate label field on
# this struct yet; see pkg/module/manifest.go UpdateProvider). Choose
# short ids that read well in the Update dropdown.
- id: enhanced
kind: steamcmd
app_id: "443030"
platform: linux # Enhanced (UE5) has a real Linux depot (443032, ~4.27 GiB) since 2026-05-05 — run native, no Wine.
install_path: /game
# Conan's `app_update 443030 validate` fails on empty volumes with
# "Missing configuration" (4/4 retries). Plain `app_update 443030`
# succeeds. Skip validate so first install just works; operators can
# trigger a manual Update later to re-validate existing files.
skip_validate: true
- id: legacy
kind: steamcmd
app_id: "443030"
beta: conan-exiles-legacy # confirmed branch name via app_info_print 2026-05-07
platform: windows # Legacy (UE4) has NO Linux depot — this is the only remaining Wine path in the module.
install_path: /game
skip_validate: true
# UI-driven configuration. Each `key` here maps 1:1 to an env key declared
# above; the entrypoint reads non-empty env values and stamps them into
# [ServerSettings] in ServerSettings.ini at every boot (idempotent — same
# value re-stamped is a no-op). Empty strings mean "leave the INI alone";
# operator edits via the Files tab to ServerSettings.ini are preserved for
# any field whose corresponding config_value is left blank.
#
# The most-tuned fields are at the top (no advanced flag); niche / tuning
# multipliers are tagged `advanced: true` so the basic Config tab stays
# scannable on a phone. AMP's official template surfaces ~11 fields; we
# expose a superset (~30) covering identity, anti-cheat, PvP, and the
# common gameplay knobs — beyond that, edit ServerSettings.ini directly.
config_values:
# ===== Edition (UE4 vs UE5) =====
# Pick at create time. Drives:
# 1. Which Steam branch downloads on first install (auto-update picks
# the matching update_provider via dispatch.go's edition-aware
# auto-update logic — `enhanced` → public branch, `legacy` →
# `conan-exiles-legacy` Steam beta).
# 2. Which Workshop mods the panel's Mods tab surfaces — Steam tags
# every published item as either "Enhanced" or "Legacy"; the panel's
# workshop search adds `requiredtags[]=Enhanced|Legacy` per the
# instance's edition. UE5 servers don't see UE4-only mods (they
# load silently as compatible-format files but don't actually run
# because the .pak ABI changed).
# Operators changing edition after creation: change this value, then
# click Update (which now uses the matching branch). Existing world
# data is preserved.
- key: EDITION
label: "Edition"
description: "Conan Exiles version. Enhanced (UE5, 2026-05+) is Funcom's current default. Legacy (UE4) is the indefinitely-archived prior build — pick this only if your players need UE4 mods that haven't been ported yet. Switching after creation requires clicking Update; saves are preserved across editions."
default: "enhanced"
options:
- { value: "enhanced", label: "Enhanced (UE5, default)" }
- { value: "legacy", label: "Legacy (UE4 — Steam beta branch)" }
# ===== Identity =====
- key: SERVER_NAME
label: "Server Name"
description: "Name shown in the server browser and steam community list."
default: "panel Conan Exiles"
- key: SERVER_PASSWORD
label: "Server Password"
description: "Password required to join the server. Leave blank for an open server."
default: ""
- key: ADMIN_PASSWORD
label: "Admin Password"
description: "Lets the operator open the in-game admin panel via 'Make Me Admin' on a logged-in client. Stamped into [ServerSettings] AdminPassword= on every restart. Leave blank to leave the existing INI value alone."
default: ""
- key: RCON_PASSWORD
label: "RCON Password"
description: "Source-RCON password used by the panel for player listing, broadcast, kick, ban, and saveworld. Avoid spaces or quotes. Leave blank to use the per-instance generated secret (recommended)."
default: ""
- key: MAX_PLAYERS
label: "Max Players"
description: "Player slot limit. Conan's official cap is 70; communities run 40-60 for most build densities."
default: "40"
- key: SERVER_REGION
label: "Server Region"
description: "Region tag used for server-browser filtering. Picks up the client's regional preferences."
default: "1"
options:
- { value: "0", label: "Europe" }
- { value: "1", label: "North America" }
- { value: "2", label: "Asia" }
- { value: "3", label: "Australia" }
- { value: "4", label: "South America" }
- { value: "5", label: "Japan" }
# ===== Map =====
- key: SERVER_MAP
label: "Map"
description: "Map loaded at server start. For a custom map, choose 'Custom' and fill in the path below."
default: "/Game/Maps/ConanSandbox/ConanSandbox"
options:
- { value: "/Game/Maps/ConanSandbox/ConanSandbox", label: "Exiled Lands (default)" }
- { value: "/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah", label: "Isle of Siptah" }
- { value: "{{CUSTOM_MAP}}", label: "Custom (use Custom Map below)" }
- key: CUSTOM_MAP
label: "Custom Map Path"
description: "Path to a custom map's UE asset. Only used when Map is set to 'Custom'. Example: /Game/Mods/Savage_Wilds/Savage_Wilds"
default: ""
# ===== Anti-cheat =====
- key: ENABLE_BATTLEYE
label: "Enable BattlEye"
description: "BattlEye anti-cheat. Off by default — works under Wine but adds a startup handshake that occasionally races on cold boots."
default: "False"
options:
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: ENABLE_VAC
label: "Enable Valve Anti-Cheat"
description: "Steam VAC. Generally safe to leave on for public servers."
default: "True"
options:
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
# ===== PvP / world =====
- key: PVP_ENABLED
label: "PvP Enabled"
description: "Master toggle for player-vs-player damage. Time-window restrictions are configured in ServerSettings.ini directly (PVPRestrict* keys); this just flips the global switch."
default: ""
options:
- { value: "", label: "(leave INI default)" }
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: AVATARS_ENABLED
label: "Avatars Enabled"
description: "Allow players to summon god avatars. Off in many private cluster setups to prevent griefing."
default: ""
options:
- { value: "", label: "(leave INI default)" }
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: CONTAINERS_IGNORE_OWNERSHIP
label: "Containers Ignore Ownership"
description: "If true, players can open any container regardless of owner. Useful for co-op-friendly servers; disable for strict PvP."
default: ""
options:
- { value: "", label: "(leave INI default)" }
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: CAN_DAMAGE_PLAYER_OWNED_STRUCTURES
label: "Allow Building Damage"
description: "Master toggle for player-owned structure damage. Combine with the dynamic decay setting for a softer PvP feel."
default: ""
options:
- { value: "", label: "(leave INI default)" }
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: DYNAMIC_BUILDING_DAMAGE
label: "Dynamic Building Damage"
description: "Decays vacant structures over time. Helps clean up abandoned bases on long-running servers."
default: ""
options:
- { value: "", label: "(leave INI default)" }
- { value: "True", label: "On" }
- { value: "False", label: "Off" }
- key: FRIENDLY_FIRE_DAMAGE
label: "Friendly Fire Damage Multiplier"
description: "0.01.0+ multiplier on damage between clan members. Default 0.25."
default: ""
# ===== Niche knobs (advanced) =====
- key: MAX_NUDITY
label: "Max Nudity"
description: "0=none, 1=partial, 2=full. Affects the default character render plus what skins players can wear."
default: "0"
advanced: true
options:
- { value: "0", label: "None" }
- { value: "1", label: "Partial" }
- { value: "2", label: "Full" }
- key: SERVER_COMMUNITY
label: "Server Community"
description: "0=Purist, 1=Relaxed, 2=Hard, 3=Hardcore, 4=Roleplaying, 5=Experimental. Just a tag in the server browser."
default: "0"
advanced: true
options:
- { value: "0", label: "Purist" }
- { value: "1", label: "Relaxed" }
- { value: "2", label: "Hard" }
- { value: "3", label: "Hardcore" }
- { value: "4", label: "Roleplaying" }
- { value: "5", label: "Experimental" }
# ===== XP rates (advanced) =====
- key: PLAYER_XP_RATE
label: "XP — Overall Rate Multiplier"
description: "Overall multiplier applied on top of every XP source. Default 1.0."
default: ""
advanced: true
- key: PLAYER_XP_KILL
label: "XP — Kill Multiplier"
description: "XP multiplier per kill. Default 1.0."
default: ""
advanced: true
- key: PLAYER_XP_HARVEST
label: "XP — Harvest Multiplier"
description: "XP multiplier per harvest tick. Default 1.0."
default: ""
advanced: true
- key: PLAYER_XP_CRAFT
label: "XP — Craft Multiplier"
description: "XP multiplier per crafted item. Default 1.0."
default: ""
advanced: true
- key: PLAYER_XP_TIME
label: "XP — Time Multiplier"
description: "Idle / time-based XP. Default 1.0."
default: ""
advanced: true
# ===== Player tuning (advanced) =====
- key: PLAYER_DAMAGE_MULTIPLIER
label: "Player Damage Output"
description: "Damage dealt by players. Default 1.0."
default: ""
advanced: true
- key: PLAYER_DAMAGE_TAKEN
label: "Player Damage Taken"
description: "Damage received by players. Lower = tougher players."
default: ""
advanced: true
- key: PLAYER_HEALTH_REGEN
label: "Player Health Regen Speed"
description: "Default 1.0."
default: ""
advanced: true
- key: PLAYER_STAMINA_COST
label: "Player Stamina Cost"
description: "Multiplier on stamina drained by sprint/jump/swim. Lower = forgiving."
default: ""
advanced: true
- key: PLAYER_SPRINT_SPEED
label: "Player Sprint Speed Scale"
description: "Default 1.0. Bumping past 1.5 makes ranged combat awkward."
default: ""
advanced: true
- key: PLAYER_MOVEMENT_SPEED
label: "Player Movement Speed Scale"
description: "Default 1.0."
default: ""
advanced: true
- key: PLAYER_ENCUMBRANCE
label: "Player Encumbrance Multiplier"
description: "Carry weight cap multiplier. Default 1.0."
default: ""
advanced: true
# ===== NPC tuning (advanced) =====
- key: NPC_DAMAGE_MULTIPLIER
label: "NPC Damage Output"
description: "Damage dealt by hostile NPCs. Default 1.0."
default: ""
advanced: true
- key: NPC_DAMAGE_TAKEN
label: "NPC Damage Taken"
description: "Damage NPCs receive. Lower = bullet-sponge enemies."
default: ""
advanced: true
- key: NPC_HEALTH_MULTIPLIER
label: "NPC Health Multiplier"
description: "Base HP scaling. Default 1.0."
default: ""
advanced: true
- key: NPC_RESPAWN_MULTIPLIER
label: "NPC Respawn Multiplier"
description: "How fast world spawns refill. >1.0 = faster respawn."
default: ""
advanced: true
- key: NPC_MAX_SPAWN_CAP
label: "NPC Max Spawn Cap Multiplier"
description: "Cap on simultaneous active world NPCs. Lower = less server CPU under load."
default: ""
advanced: true
# ===== Structures / land claim (advanced) =====
- key: STRUCTURE_DAMAGE_MULTIPLIER
label: "Structure Damage Multiplier"
description: "Damage dealt to player-built structures. Default 1.0."
default: ""
advanced: true
- key: STRUCTURE_HEALTH_MULTIPLIER
label: "Structure Health Multiplier"
description: "Base HP of placed pieces. >1.0 = sturdier bases."
default: ""
advanced: true
- key: LANDCLAIM_RADIUS_MULTIPLIER
label: "Landclaim Radius Multiplier"
description: "How big the no-build bubble around each placeable is. <1.0 lets settlements pack tighter."
default: ""
advanced: true
- key: AVATAR_DOME_DURATION
label: "Avatar Dome Duration Multiplier"
description: "How long avatar shields hold. Default 1.0."
default: ""
advanced: true
- key: AVATAR_DOME_DAMAGE
label: "Avatar Dome Damage Multiplier"
description: "Damage vs. avatar shields. Default 1.0."
default: ""
advanced: true
# --- 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: '/LogGameState: Match State Changed from WaitingToStart to InProgress/i'
appearance:
emoji: "⚔️"
grad: "linear-gradient(135deg,#5a1a00,#c2410c)"
art: "/game-art/conan-exiles.jpg"
steam: "440900"
+40
View File
@@ -0,0 +1,40 @@
# panel-native Core Keeper runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
DISPLAY=:99
# Core Keeper is Unity headless-on-Linux; Unity still needs an X display
# even with -nographics (some subsystem probes fail otherwise), so Xvfb.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
xvfb \
xauth \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
libxi6 \
libxrandr2 \
&& 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/*
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
EXPOSE 27015/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# panel-native Core Keeper entrypoint.
set -euo pipefail
log() { printf '[panel-core-keeper] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true
mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix && chown root:root /tmp/.X11-unix 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves DISPLAY=:99 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
GAME_ID="${GAME_ID:-}" \
WORLD_NAME="${WORLD_NAME:-panel}" \
WORLD_MODE="${WORLD_MODE:-0}" \
MAX_PLAYERS="${MAX_PLAYERS:-10}" \
GAME_PORT="${GAME_PORT:-27015}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/CoreKeeperServer"
if [[ ! -x "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Core Keeper via SteamCMD."
exit 78
fi
log "starting Xvfb on :99"
Xvfb :99 -screen 0 1024x768x24 -nolisten tcp &
XVFB_PID=$!
sleep 1
# Core Keeper writes saves under $HOME/.config/unity3d/Pugstorm/Core Keeper.
# HOME is /game-saves so these persist.
mkdir -p "${SAVE_DIR}/.config/unity3d/Pugstorm/Core Keeper"
trap 'log "SIGTERM"; kill "${XVFB_PID}" 2>/dev/null || true; exit 0' TERM INT
cd "${GAME_DIR}"
log "starting Core Keeper dedicated server"
log " world: ${WORLD_NAME} (mode ${WORLD_MODE}, max ${MAX_PLAYERS}), port ${GAME_PORT}"
# Steamworks needs the bundled linux64/steamclient.so and the GAME app id
# (1621690, not the dedicated-server app 1963720) — per AMP's
# core-keeper.kvp EnvironmentVariables.
export LD_LIBRARY_PATH="${GAME_DIR}/linux64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export SteamAppId=1621690
# -port makes the server open a direct UDP socket on the panel-allocated
# port (instead of Steam-relay-only). -datapath keeps worlds in the
# persistent save volume.
ARGS=(-batchmode -logfile -
-port "${GAME_PORT}"
-datapath "${SAVE_DIR}/DedicatedServer"
-worldname "${WORLD_NAME}" -worldmode "${WORLD_MODE}"
-maxplayers "${MAX_PLAYERS}")
if [[ -n "${GAME_ID}" ]]; then
ARGS+=(-gameid "${GAME_ID}")
fi
mkdir -p "${SAVE_DIR}/DedicatedServer"
exec stdbuf -oL -eL "${EXE}" "${ARGS[@]}"
+84
View File
@@ -0,0 +1,84 @@
# Core Keeper — panel-native dedicated server module.
#
# Native Linux (Unity headless) from SteamCMD app 1963720. Unity server
# still touches X so we need Xvfb (same pattern as Empyrion). No RCON —
# admin is via in-game console when you hold the founders key.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (CoreKeeperLinux.kvp)
id: core-keeper
name: "Core Keeper"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-core-keeper:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Worlds + server.json"
- name: "Game Files"
path: /game
hint: "Server binaries"
env:
GAME_ID: "" # leave empty to generate a new world; set to rejoin
WORLD_NAME: "panel"
WORLD_MODE: "0" # 0 = normal, 1 = hardmode, 2 = creative
MAX_PLAYERS: "10"
# Pre-declared so the agent's env filter lets the panel-allocated
# port flow through to the container (same trap as soulmask).
# The entrypoint passes it as -port; default matches the port default.
GAME_PORT: "27015"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 27015, required: true, env: GAME_PORT }
resources:
min_ram_mb: 2048
recommended_ram_mb: 4096
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (core-keeper.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '\[userid:(?P<id>[^\]]+)\] player (?P<name>.+?) connected'
kind: join
leave:
pattern: 'Disconnected from userid:(?P<id>\S+?) with reason'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "1963720"
install_path: /game
# --- 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.
# Verified on a live first boot 2026-07-14: the last line before the server
# accepts joins is "Started session with info: <ip>;<port>;<key1>;<key2>".
ready_pattern: '/Started session with info/i'
appearance:
emoji: "⛏️"
grad: "linear-gradient(135deg,#2e1a47,#6b3fa0)"
art: "/game-art/core-keeper.jpg"
steam: "1621690"
+51
View File
@@ -0,0 +1,51 @@
# panel-native DayZ runtime image.
#
# DayZ ships a native Linux binary (SteamCMD app 223350). No Wine. The
# binary is a static-ish x86_64 ELF that pulls in the usual i386 pile
# (steamclient.so is 32-bit) + a couple of Bohemia's own shared libs
# that live under <install>/.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed DayZ Server
# /game/DayZServer — server binary (Bohemia's launcher wrapper)
# /game/serverDZ.cfg — primary config file (symlinked to /game-saves)
# /game/battleye/ — BattlEye config + beserver.cfg (RCON port + pw)
# /game/profiles — logs, bans, RPT crash dumps (symlinked to /game-saves)
# /game-saves/ — operator data volume (configs, profiles, world DB)
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
libcurl4 \
libxml2 \
procps \
&& 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/*
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
EXPOSE 2302/udp 2303/udp 2304/udp 2305/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+82
View File
@@ -0,0 +1,82 @@
# DayZ — panel-native module
Panel-native DayZ dedicated server, native-Linux (no Wine). Steam app 223350.
## The Steam-login gotcha
**DayZ is paid content.** SteamCMD refuses `+login anonymous` with `No subscription`. The manifest sets `requires_steam_login: true` on the update provider, which tells the controller to gate install/update behind the Steam-login modal. Flow:
1. Create a DayZ instance in the panel. Auto-update is intentionally skipped (agent sees `requires_steam_login` on the provider and doesn't kick off the first Update).
2. Click **Update** on the instance.
3. Panel returns `{steam_login_required: true}` and the browser pops a login modal.
4. Enter your Steam username + password. If Steam Guard is enabled on your account, also enter the 5-char code from your email / Steam Mobile app.
5. Panel runs a one-shot `docker run steamcmd/steamcmd +login <user> <pass>` to verify, stores the password encrypted (AES-GCM, key HKDF-derived from the panel's CA private key), and caches the SSFN sentry file in a panel-wide volume (`panel-steamcmd-auth`) so future updates skip Steam Guard.
6. Retry Update — panel injects creds into the SteamCMD sidecar and downloads ~5 GB of DayZ server files.
Your Steam account must own the DayZ base game (app 221100). The dedicated-server app itself (223350) is a free tool; ownership of the base game is what grants download rights.
## Image + runtime
- `panel-dayz:latest` — debian:12-slim, 32-bit runtime libs (`libc6:i386`, `lib32gcc-s1`, `lib32stdc++6`) for `steamclient.so`, plus `libcurl4`/`libxml2`. No Wine.
- Binary: `/game/DayZServer` (ELF64, native).
- Config: `/game-saves/serverDZ.cfg` (symlinked to `/game/serverDZ.cfg`).
- Profiles (logs, bans, RPTs): `/game-saves/profiles/` (symlinked to `/game/profiles`).
- BattlEye config: `/game/battleye/beserver_x64.cfg` (port + password written on first boot from `BE_PASSWORD` env var).
## RCON — BattlEye protocol
DayZ uses BattlEye's proprietary UDP RCON, not Source RCON. The panel's `be_rcon` adapter handles:
- Login handshake (password auth → single-byte success/fail)
- Sequenced commands with per-seq ack tracking
- Multi-part response reassembly for long outputs (`players` on busy servers)
- Server-pushed events (chat/join/leave) ack'd automatically
- 30-second keep-alive ping (BE disconnects idle clients after ~45s)
Commands wired:
- `list``players`
- `broadcast {msg}``say -1 {msg}`
- `kick {player}``kick {player}`
- `ban {player}``ban {player}`
- `shutdown``#shutdown`
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 2302 | UDP | Game + Steam query (same port — Bohemia convention) |
| 2303 | UDP | Additional instance offset (+1) |
| 2304 | UDP | Additional instance offset (+2) |
| 2305 | UDP | Additional instance offset (+3) |
| 2310 | UDP | BattlEye RCON (internal) |
## Environment vars
| Var | Default | Notes |
|-----|---------|-------|
| `SERVER_NAME` | `panel DayZ` | Shown in the DayZ server browser |
| `SERVER_PORT` | `2302` | Game + Steam-query UDP port |
| `SERVER_PASSWORD` | *(empty)* | Optional client join password |
| `BE_PASSWORD` | `panel_be` | BattlEye RCON admin password (edit before exposing) |
| `CPU_COUNT` | `2` | `-cpuCount=` arg to the server binary |
## Log events
- **Join:** `Player "<name>" is connected`
- **Leave:** `Player "<name>" disconnected`
- **Chat:** `Chat("<name>"): <msg>`
## Known gotchas
- **Steam credentials are a one-time ceremony per panel.** Once stored, the cached SSFN blob means subsequent updates don't re-prompt. If Steam invalidates your session (machine change, long inactivity), the modal will pop again with `{need_guard: true}` — enter a fresh code.
- **BattlEye RCON port collides by convention.** 2310 is our default; if you run multiple DayZ instances on the same host, bump via the panel's Network Ports editor.
- **Modded servers need `@<ModName>` in the launch command.** The entrypoint doesn't wire this yet — drop mod pbos into `/game/` and add `-mod=@<ModName>` via a manual launcher edit (or wait for a follow-up that exposes `EXTRA_ARGS` as an env var).
- **`steamQueryPort` must equal the game port** in `serverDZ.cfg` or DayZ refuses to register with Steam's master server. Our seeded config follows this.
- **BattlEye can kick you for "kick #0 CreateVehicle Restriction..."** type messages on vanilla — these are server-side integrity checks. Tune `verifySignatures` and the `battleye/bans.txt` / `permissions.txt` files via the Files tab.
## References
- [Bohemia Interactive: Hosting a Linux Server (DayZ)](https://community.bistudio.com/wiki/DayZ:Hosting_a_Linux_Server)
- [BattlEye RCON Protocol](https://www.battleye.com/downloads/BERConProtocol.txt)
- [Steam — DayZ Server (app 223350)](https://steamdb.info/app/223350/)
+204
View File
@@ -0,0 +1,204 @@
#!/bin/bash
# panel-native DayZ entrypoint.
#
# Contract:
# /game — SteamCMD-installed DayZ Server (app 223350). Populated
# by the panel's SteamCMD updater sidecar, which uses the
# operator-supplied Steam credentials (the module manifest
# sets requires_steam_login: true).
# /game-saves — serverDZ.cfg + profiles/ (logs, bans, RPTs) + optional
# mpmissions/ override tree.
set -euo pipefail
log() { printf '[panel-dayz] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
SERVER_NAME="${SERVER_NAME:-panel DayZ}" \
SERVER_PORT="${SERVER_PORT:-2302}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
BE_PASSWORD="${BE_PASSWORD:-panel_be}" \
CPU_COUNT="${CPU_COUNT:-2}" \
LD_LIBRARY_PATH="/game:${LD_LIBRARY_PATH:-}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
DEDI_EXE="${GAME_DIR}/DayZServer"
if [[ ! -x "${DEDI_EXE}" ]]; then
log "ERROR: ${DEDI_EXE} not found or not executable."
log " Run 'Update' on this server in the panel — the modal will"
log " prompt for Steam credentials (DayZ is paid content; the"
log " anonymous SteamCMD login won't work)."
exit 78 # EX_CONFIG
fi
# --- persist profiles into /game-saves ---
# DayZ writes RPT crash logs, ADM admin logs, and BattlEye bans to
# <install>/profiles/. Move out to the save volume + symlink back on
# first boot so SteamCMD validate can't wipe them.
mkdir -p "${SAVE_DIR}/profiles"
if [[ -d "${GAME_DIR}/profiles" && ! -L "${GAME_DIR}/profiles" ]]; then
mv "${GAME_DIR}/profiles"/* "${SAVE_DIR}/profiles/" 2>/dev/null || true
rm -rf "${GAME_DIR}/profiles"
fi
ln -sfn "${SAVE_DIR}/profiles" "${GAME_DIR}/profiles"
# --- seed serverDZ.cfg on first boot ---
CFG="${SAVE_DIR}/serverDZ.cfg"
if [[ ! -s "${CFG}" ]]; then
log "seeding serverDZ.cfg (first boot)"
cat > "${CFG}" <<EOF
hostname = "${SERVER_NAME}";
password = "${SERVER_PASSWORD}";
passwordAdmin = "";
enableWhitelist = 0;
disableVoN = 0;
vonCodecQuality = 7;
disable3rdPerson = 0;
disableCrosshair = 0;
serverTime = "SystemTime";
serverTimeAcceleration = 12;
serverNightTimeAcceleration = 1;
serverTimePersistent = 0;
guaranteedUpdates = 1;
loginQueueConcurrentPlayers = 5;
loginQueueMaxPlayers = 500;
instanceId = 1;
storageAutoFix = 1;
verifySignatures = 2;
forceSameBuild = 1;
timeStampFormat = "Short";
logAverageFps = 1;
logMemory = 1;
logPlayers = 1;
logFile = "server_console.log";
adminLogPlayerHitsOnly = 0;
adminLogPlacement = 1;
adminLogBuildActions = 1;
adminLogPlayerList = 1;
disableBanlist = 0;
BattlEye = 1;
respawnTime = 5;
steamQueryPort = ${SERVER_PORT};
maxPing = 200;
MissionTemplate = "dayzOffline.chernarusplus";
class Missions
{
class DayZ
{
template = "dayzOffline.chernarusplus";
};
};
EOF
fi
# Point /game/serverDZ.cfg at the save-volume copy so the binary's default
# relative-path lookup finds it.
ln -sf "${CFG}" "${GAME_DIR}/serverDZ.cfg"
# --- BattlEye RCON config ---
# DayZ ships a `battleye/` dir that contains a NESTED `battleye/` subdir with
# a duplicate `beserver_x64.so`. The DayZ launcher (`-BEpath=battleye`) loads
# BE from there and expects `beserver_x64.cfg` to sit alongside the nested
# lib — i.e. `battleye/battleye/beserver_x64.cfg`, NOT `battleye/beserver_x64.cfg`.
# Writing to the outer dir (which is what most tutorials show) results in BE
# initializing but never binding the RCON port, with no error in the log.
#
# We unconditionally overwrite both locations so operator env changes take
# effect on the next boot. We also purge stale `beserver_x64_active_<token>.cfg`
# files BE writes after first handshake — if the password has changed, the
# active copy will mismatch and BE refuses incoming RCON packets.
#
# Reference: AMP's dayz-experimental.kvp uses `{{$FullBaseDir}}battleye` as
# the BE path and their PreStartStages purge the active file the same way.
BE_DIR="${GAME_DIR}/battleye"
for CFGPATH in "${BE_DIR}/beserver_x64.cfg" "${BE_DIR}/battleye/beserver_x64.cfg"; do
if [[ -d "$(dirname "${CFGPATH}")" ]]; then
log "writing ${CFGPATH} (RCON port 2305, pw from BE_PASSWORD)"
cat > "${CFGPATH}" <<EOF
RConPassword ${BE_PASSWORD}
RConPort 2305
RConIP 0.0.0.0
RestrictRCon 0
RConLog 1
UseLocationsToBan 0
LogFile 0
EOF
fi
done
# Purge stale active-session files (both locations).
rm -f "${BE_DIR}/beserver_x64_active_"*.cfg 2>/dev/null || true
rm -f "${BE_DIR}/battleye/beserver_x64_active_"*.cfg 2>/dev/null || true
cd "${GAME_DIR}"
log "starting DayZ dedicated server"
log " name: ${SERVER_NAME}"
log " game port: ${SERVER_PORT}/udp"
log " rcon port: 2305/udp (internal, BattlEye)"
log " cfg: ${CFG}"
trap 'log "SIGTERM received"; kill -TERM "${DZ_PID:-0}" 2>/dev/null || true; wait "${DZ_PID:-0}" 2>/dev/null || true; exit 0' TERM INT
# --- mod list dynamic build ---
# Panel's mod manager maintains two plain-text files on the save volume:
# /game-saves/panel-mods.txt — one @ModFolder per line → -mod=
# /game-saves/panel-servermods.txt — one @ModFolder per line → -servermod=
# Blank lines and lines starting with '#' are ignored. This lets the mod
# manager edit a human-readable file and have the server pick up the new
# load order on the next start without entrypoint code changes.
build_mod_arg() {
local file="$1" prefix="$2"
[[ -s "$file" ]] || { echo ""; return; }
local joined=""
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}" # strip trailing CR (Windows edits)
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -z "$joined" ]]; then joined="$line"
else joined="${joined};${line}"
fi
done < "$file"
if [[ -n "$joined" ]]; then
echo "${prefix}${joined}"
fi
}
MOD_ARG="$(build_mod_arg "${SAVE_DIR}/panel-mods.txt" '-mod=')"
SERVERMOD_ARG="$(build_mod_arg "${SAVE_DIR}/panel-servermods.txt" '-servermod=')"
[[ -n "${MOD_ARG}" ]] && log "loading mods: ${MOD_ARG}"
[[ -n "${SERVERMOD_ARG}" ]] && log "loading server-mods: ${SERVERMOD_ARG}"
# DayZ's documented launch command:
# ./DayZServer -config=serverDZ.cfg -port=2302 -BEpath=battleye \
# -profiles=profiles -dologs -adminlog -netlog \
# -freezecheck -doScriptLogs -cpuCount=2
# [-mod=@A;@B] [-servermod=@X]
DZ_ARGS=(
-config=serverDZ.cfg
-port="${SERVER_PORT}"
"-BEpath=${GAME_DIR}/battleye"
-profiles=profiles
-dologs
-adminlog
-netlog
-freezecheck
-doScriptLogs
"-cpuCount=${CPU_COUNT}"
)
[[ -n "${MOD_ARG}" ]] && DZ_ARGS+=("${MOD_ARG}")
[[ -n "${SERVERMOD_ARG}" ]] && DZ_ARGS+=("${SERVERMOD_ARG}")
exec stdbuf -oL -eL "${DEDI_EXE}" "${DZ_ARGS[@]}" &
DZ_PID=$!
wait "${DZ_PID}"
+144
View File
@@ -0,0 +1,144 @@
# DayZ dedicated server module.
#
# Native-Linux binary via SteamCMD app 223350 ("DayZ Server"). App is
# paid content — SteamCMD refuses `+login anonymous` with
# "No subscription". We set `requires_steam_login: true` which makes the
# controller gate updates behind the Steam-login modal; operators sign in
# once with a Steam account that owns DayZ (base game app 221100 — NOT
# the dedicated-server app itself — grants download entitlement).
#
# RCON is BattlEye's proprietary UDP protocol, not Source or WebSocket.
# The panel's new `be_rcon` adapter speaks it natively (MD5-challenge
# login, sequenced commands with ack packets, keep-alive pings).
#
# References:
# https://community.bistudio.com/wiki/DayZ:Hosting_a_Linux_Server
# https://www.battleye.com/downloads/BERConProtocol.txt
id: dayz
name: "DayZ"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-dayz:latest modules/dayz
image: panel-dayz:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "serverDZ.cfg + profiles/ (logs, bans, RPTs) + mpmissions/ world state"
- name: "Game Files"
path: /game
hint: "DayZServer binary + core keys/, shipped mpmissions, BattlEye/"
env:
SERVER_NAME: "panel DayZ"
SERVER_PORT: "2302"
BE_PASSWORD: "panel_be" # BattlEye RCON admin password
SERVER_PASSWORD: "" # optional client join password
CPU_COUNT: "2" # -cpuCount= for the server binary
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
# Shared DayZ Workshop cache — multiple DayZ instances download and use
# the same workshop content. Each instance creates @ModName symlinks in
# its own /game/ pointing at this shared tree, so disk space is saved
# and one download serves N instances.
- { type: volume, name: "panel-dayz-workshop", container: "/game/steamapps/workshop" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# DayZ ports:
# 2302/udp — primary game + query (Bohemia's convention: game port and
# Steam query are the SAME port, unlike Source-engine games)
# 2303/udp — additional-instance offset (+1)
# 2304/udp — additional-instance offset (+2)
# 2305/udp — additional-instance offset (+3)
# 27016/udp — alternate Steam-query port (some community servers expose)
# 2310/udp — BattlEye RCON (BE_PORT + 1 by convention; configurable)
ports:
- { name: game, proto: udp, default: 2302, required: true, env: SERVER_PORT } # entrypoint reads SERVER_PORT (declared in docker env); query shares this port
- { name: reserved, proto: udp, default: 2303, required: false }
- { name: battleye, proto: udp, default: 2304, required: false } # client BE anti-cheat traffic
- { name: rcon, proto: udp, default: 2305, internal: true } # BE RCON (AMP convention)
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# Per-instance generated RCON secret. The agent generates a random
# 24-byte token at create time (pkg/module GenerateSecrets), which
# overrides the env default for BE_PASSWORD below — so every instance
# gets a unique password. The weak env default only applies to
# containers launched outside the panel.
secrets:
- name: BE_PASSWORD
description: "DayZ BattlEye RCON password — generated per instance"
generated: true
rcon:
adapter: be_rcon
host_port: rcon
auth: password
password_secret: BE_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated BE_PASSWORD secret above.
password_literal: "panel_be"
commands:
list: "players"
broadcast: "say -1 {msg}"
kick: "kick {player}"
ban: "ban {player}"
save: "#shutdown" # DayZ has no "save" command; use shutdown as a placeholder
shutdown: "#shutdown"
state_sources:
- type: rcon
command: "players"
every: 60s
# Log-line event patterns (RE2). Patterns observed from a real DayZ
# 1.24 server log; tighten after smoke test if needed.
events:
join:
pattern: 'Player "(?P<name>[^"]+)" is connected'
kind: join
leave:
pattern: 'Player "(?P<name>[^"]+)" disconnected'
kind: leave
chat:
pattern: 'Chat\("(?P<name>[^"]+)"\): (?P<msg>.+)'
kind: chat
update_providers:
- id: stable
kind: steamcmd
app_id: "223350"
install_path: /game
# DayZ is paid content. Anonymous SteamCMD returns "No subscription".
# Operator logs in once via the panel's Steam-login modal; the cached
# SSFN blob (via the shared panel-steamcmd-auth volume) makes
# subsequent updates skip the 2FA prompt.
requires_steam_login: true
# --- 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: '/Mission read from bank|BattlEye Server: Initialized|Server World: Initialized|NetServer: (:\d+)?started|Creating a new world/i'
appearance:
emoji: "🧟"
grad: "linear-gradient(135deg,#2d3e2e,#6b7f56)"
art: "/game-art/dayz.jpg"
steam: "221100"
+56
View File
@@ -0,0 +1,56 @@
# Demo module — tiny alpine container used to validate panel end-to-end
# without pulling multi-GB game server images. The container just sleeps,
# so the runtime exercises pull / create / start / stop / logs / wait without
# needing RCON or any real game behaviour.
id: demo
name: "Demo (alpine sleeper)"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: alpine:3.21
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
command:
- sh
- -c
- |
echo "demo instance starting";
i=0;
while true; do
echo "tick $i $(date -Iseconds)";
i=$((i+1));
sleep 2;
done
ports:
- { name: dummy, proto: tcp, default: 14000 }
resources:
min_ram_mb: 32
recommended_ram_mb: 64
update_providers:
# Exercise the Direct provider with a small, stable URL. Running the
# update places the GPL-3.0 text at /data-ish/license.txt.
- id: gpl
kind: direct
url: "https://www.gnu.org/licenses/gpl-3.0.txt"
target_path: "license.txt"
# GitHub provider: grabs the latest `buf` CLI release note as a demo
# (asset_regex matches any tar.gz).
- id: buf-latest
kind: github
repo: "bufbuild/buf"
asset_regex: "buf-Linux-x86_64.tar.gz"
target_path: "downloads/buf.tar.gz"
# --- 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.
appearance:
emoji: "🧪"
grad: "linear-gradient(135deg,#4b5563,#9ca3af)"
+55
View File
@@ -0,0 +1,55 @@
# panel-native RuneScape: Dragonwilds runtime image.
#
# Dragonwilds ships a native-Linux UE5 build — no Wine. Minimal debian:12-slim
# + 32-bit runtime libs (steamclient.so dependency) + UE5 dlopen shim libs.
# Same scaffolding as the `rust` module; UE5 servers touch the same set of X/
# GL libs at boot even in headless mode.
#
# Expected runtime layout inside the container:
# /game — SteamCMD install (panel volume)
# /game/RSDragonwildsServer.sh — Jagex's launcher wrapper
# /game/RSDragonwilds/Binaries/Linux/RSDragonwildsServer-Linux-Shipping — real exe
# /game/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini — operator config
# /game-saves — save + logs volume
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
libgl1 \
libx11-6 \
libxcursor1 \
libxext6 \
libxi6 \
libxrandr2 \
libxrender1 \
procps \
&& 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/*
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
EXPOSE 7777/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+48
View File
@@ -0,0 +1,48 @@
# RuneScape: Dragonwilds — panel-native module
Panel-native Dragonwilds dedicated server, native-Linux (no Wine), debian:12-slim base.
- **Image:** `panel-dragonwilds:latest` — built from `./Dockerfile`.
- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 4019830) into the `panel-<id>-game` named volume. Dragonwilds is a multi-depot app (Windows 4019831 + Linux 3501791) — module declares `platform: linux` so SteamCMD's auto-detect doesn't mis-fire on Docker Desktop WSL2. Linux depot is ~1.6 GB compressed, ~5.8 GB on disk.
- **Saves:** `panel-<id>-saves` at `/game-saves`. Entrypoint migrates `RSDragonwilds/Saved/` into the save volume on first discovery and symlinks back, so SteamCMD validate can't wipe worlds or config.
- **RCON:** **None.** Jagex has not documented or implemented a remote admin protocol. The Players tab will stay empty until we wire log-tail join/leave regex from observing real player traffic.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 7777 | UDP | Game traffic |
Additional instances on the same host should use 7778, 7779, etc.
## Required configuration
Jagex requires 4 mandatory values in `RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini`:
- `ServerName`
- `DefaultWorldName`
- `OwnerID` — the Steam ID (or Jagex account ID?) of the server owner
- `AdminPassword`
The entrypoint seeds `DedicatedServer.ini` on first boot from env vars (`SERVER_NAME`, `DEFAULT_WORLD_NAME`, `OWNER_ID`, `ADMIN_PASSWORD`). Operators should set `OWNER_ID` and `ADMIN_PASSWORD` before the first launch or the server will reject client logins. Hand-edit via the Files tab after the first boot is also fine — the entrypoint only seeds when the file is missing.
## Environment vars
| Var | Default | Notes |
|-----|---------|-------|
| `SERVER_NAME` | `panel Dragonwilds` | Shown in the server browser |
| `SERVER_PORT` | `7777` | |
| `DEFAULT_WORLD_NAME` | `Gielinor` | World loaded on start |
| `OWNER_ID` | *(empty)* | Operator must set |
| `ADMIN_PASSWORD` | *(empty)* | Operator must set |
## Known gotchas
- Multi-depot SteamCMD install — without `platform: linux` in the manifest, SteamCMD exits 8 "Missing configuration" on Docker Desktop WSL2 (same class as Palworld).
- Linux launcher at `/game/RSDragonwildsServer.sh` is a shell wrapper around the real UE5 shipping binary under `RSDragonwilds/Binaries/Linux/`. The entrypoint probes for both and prefers the wrapper when available.
- No RCON → no admin/broadcast/kick from the panel. Use in-game commands via the server operator.
## Reference
- [Jagex dedicated server guide](https://dragonwilds.runescape.com/news/how-to-dedicated-servers)
- [RuneScape: Dragonwilds Wiki — Dedicated Servers / Linux](https://dragonwilds.runescape.wiki/w/Dedicated_Servers/Linux)
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# panel-native RuneScape: Dragonwilds entrypoint.
#
# Contract:
# /game — SteamCMD-installed Linux build of Dragonwilds Dedicated
# Server (app 4019830). Populated by the panel's SteamCMD
# updater sidecar.
# /game-saves — RSDragonwilds/Saved/ config + save tree + logs.
set -euo pipefail
log() { printf '[panel-dragonwilds] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
SERVER_NAME="${SERVER_NAME:-panel Dragonwilds}" \
SERVER_PORT="${SERVER_PORT:-7777}" \
OWNER_ID="${OWNER_ID:-}" \
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" \
DEFAULT_WORLD_NAME="${DEFAULT_WORLD_NAME:-Gielinor}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
# --- locate the launcher wrapper ---
# Jagex ships RSDragonwildsServer.sh at the install root; it execs the
# UE5 shipping binary under RSDragonwilds/Binaries/Linux/.
LAUNCHER="${GAME_DIR}/RSDragonwildsServer.sh"
FALLBACK="${GAME_DIR}/RSDragonwilds/Binaries/Linux/RSDragonwildsServer-Linux-Shipping"
if [[ ! -x "${LAUNCHER}" && ! -x "${FALLBACK}" ]]; then
log "ERROR: ${LAUNCHER} not found or not executable."
log " Run 'Update' on this server in the panel — that launches a"
log " SteamCMD sidecar which installs Dragonwilds (app 4019830)"
log " for Linux into this volume via +@sSteamCmdForcePlatformType linux."
exit 78 # EX_CONFIG
fi
# --- persist RSDragonwilds/Saved into /game-saves ---
# UE5 writes config + save data under <game>/RSDragonwilds/Saved/. Move
# it into the save volume on first discovery and symlink back so SteamCMD
# validate can't wipe world data. Idempotent on re-boot.
mkdir -p "${SAVE_DIR}/RSDragonwilds" "${GAME_DIR}/RSDragonwilds"
if [[ -d "${GAME_DIR}/RSDragonwilds/Saved" && ! -L "${GAME_DIR}/RSDragonwilds/Saved" ]]; then
if [[ ! -d "${SAVE_DIR}/RSDragonwilds/Saved" ]]; then
mv "${GAME_DIR}/RSDragonwilds/Saved" "${SAVE_DIR}/RSDragonwilds/Saved"
else
rm -rf "${GAME_DIR}/RSDragonwilds/Saved"
fi
fi
mkdir -p "${SAVE_DIR}/RSDragonwilds/Saved/Config/LinuxServer"
ln -sfn "${SAVE_DIR}/RSDragonwilds/Saved" "${GAME_DIR}/RSDragonwilds/Saved"
# --- seed DedicatedServer.ini from env on first boot ---
# Jagex requires OwnerId + Server Name + Default World Name + Admin Password
# to be set. Observed behavior on 0.11: Dragonwilds overwrites an absent
# DedicatedServer.ini on first boot with its OWN random-generated values
# under the `[/Script/Dominion.DedicatedServerSettings]` section ("Dominion"
# is the internal UE5 project name). So the best we can do for first-boot
# seeding is to pre-write the file — if it exists the server reads it and
# leaves it. We only touch the file when it doesn't already exist.
INI="${SAVE_DIR}/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini"
if [[ ! -s "${INI}" ]]; then
log "seeding DedicatedServer.ini (first boot)"
cat > "${INI}" <<EOF
;METADATA=(Diff=true, UseCommands=true)
[SectionsToSave]
bCanSaveAllSections=true
[/Script/Dominion.DedicatedServerSettings]
ServerName=${SERVER_NAME}
DefaultWorldName=${DEFAULT_WORLD_NAME}
OwnerId=${OWNER_ID}
AdminPassword=${ADMIN_PASSWORD}
WorldPassword=
EOF
fi
# Mirror logs to the bind-mounted logs dir for external access.
mkdir -p "${SAVE_DIR}/logs"
cd "${GAME_DIR}"
log "starting RuneScape: Dragonwilds dedicated server"
log " name: ${SERVER_NAME}"
log " default world: ${DEFAULT_WORLD_NAME}"
log " owner id: ${OWNER_ID:-(unset — server will refuse logins)}"
log " admin pw: ${ADMIN_PASSWORD:+<set>}"
log " port: ${SERVER_PORT}/udp"
trap 'log "SIGTERM received; stopping server"; kill -TERM "${SRV_PID:-0}" 2>/dev/null || true; wait "${SRV_PID:-0}" 2>/dev/null || true; exit 0' TERM INT
# Use the Jagex-shipped launcher when present; otherwise exec the shipping
# binary directly. `-log` routes UE5 output to stdout. Drop `-NewConsole`
# (Windows-only; harmless but noisy on Linux).
if [[ -x "${LAUNCHER}" ]]; then
exec stdbuf -oL -eL "${LAUNCHER}" -log "-Port=${SERVER_PORT}" &
else
exec stdbuf -oL -eL "${FALLBACK}" -log "-Port=${SERVER_PORT}" &
fi
SRV_PID=$!
wait "${SRV_PID}"
+99
View File
@@ -0,0 +1,99 @@
# RuneScape: Dragonwilds dedicated server module.
#
# Native-Linux UE5 binary via SteamCMD app 4019830 (free-to-download tool;
# Jagex made the dedicated server public on Steam in 2026 with Update 0.11).
# Multi-depot app (Windows 4019831 + Linux 3501791) — so `platform: linux`
# is required, same class of issue as Palworld on Docker Desktop WSL2.
#
# No native RCON protocol — Jagex hasn't documented one. The admin console
# is in-process only. Panel surfaces the UE log in the Console tab; Players
# tab stays empty until Jagex ships an admin protocol (or we wire a log-tail
# based join/leave regex when we observe real player traffic).
#
# References:
# https://dragonwilds.runescape.com/news/how-to-dedicated-servers
# https://dragonwilds.runescape.wiki/w/Dedicated_Servers/Linux
id: dragonwilds
name: "RuneScape: Dragonwilds"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-dragonwilds:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini + world saves"
- name: "Game Files"
path: /game
hint: "RSDragonwildsServer.sh, RSDragonwilds/Binaries/Linux/"
env:
SERVER_NAME: "panel Dragonwilds"
SERVER_PORT: "7777"
# Operators MUST supply these before the server will accept clients.
# First boot generates a stub DedicatedServer.ini if these are empty;
# the server still launches but refuses logins until populated.
OWNER_ID: ""
ADMIN_PASSWORD: ""
DEFAULT_WORLD_NAME: "Gielinor"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Dragonwilds only documents one port: 7777/udp (game). No Steam query
# or RCON ports in the public docs. Additional server instances on the
# same host are expected to increment the port (7778, 7779, ...).
ports:
- { name: game, proto: udp, default: 7777, required: true, env: SERVER_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (runescape-dragonwilds.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'LogDomMatcherSession:\s+Player\s+ADDED\s+to\s+session\s+\[[^\]]+\]-\[(?P<name>[^\]]+)\]$'
kind: join
leave:
pattern: 'LogDomMatcherSession:\s+Player\s+Removed\s+from\s+session\s+\[[^\]]+\]-\[(?P<name>[^\]]+)\]$'
kind: leave
chat:
pattern: '^\[[\d\.]+-[\d\.:]+\]\[[\d ]+\]LogChat: (?P<name>.+?): (?P<msg>.*)$'
kind: chat
update_providers:
- id: stable
kind: steamcmd
app_id: "4019830"
# Multi-depot — explicit platform required or SteamCMD auto-detect
# mis-fires on Docker Desktop WSL2 with "Missing configuration".
platform: linux
install_path: /game
# --- 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: '/LogInit: Display: Engine is initialized|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete|LogLoad: Game Engine Initialized|LogNet: GameNetDriver/i'
appearance:
emoji: "🐉"
grad: "linear-gradient(135deg,#4c1d95,#f59e0b)"
art: "/game-art/dragonwilds.jpg"
steam: "1374490"
+27
View File
@@ -0,0 +1,27 @@
# panel-empyrion-bridge — .NET 8 sidecar that proxies Empyrion's Mod API
# (TCP/protobuf-net via the EPM mod) to a small REST surface for the panel.
#
# Build:
# docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge
#
# Run:
# docker run --rm -p 8090:8090 panel-empyrion-bridge:latest
# then:
# curl 'http://localhost:8090/api/v1/factions?host=<your-server-ip>&port=7028'
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
COPY bridge/ ./bridge/
COPY lib/ ./lib/
RUN dotnet publish bridge/Bridge.csproj -c Release -o /out --no-self-contained \
-p:DebugType=embedded -p:PublishSingleFile=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
WORKDIR /app
COPY --from=build /out/ ./
ENV BRIDGE_URLS=http://0.0.0.0:8090 \
BRIDGE_REQUEST_TIMEOUT_S=8 \
DOTNET_RUNNING_IN_CONTAINER=true \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
EXPOSE 8090
USER 1000:1000
ENTRYPOINT ["dotnet", "panel-empyrion-bridge.dll"]
+97
View File
@@ -0,0 +1,97 @@
# panel-empyrion-bridge
A small .NET 8 ASP.NET Core sidecar that proxies Empyrion's native Mod API
(TCP/protobuf-net) into a JSON REST surface for the panel dashboard.
## Why this exists
Empyrion's only first-class admin tool is a Windows-only desktop app
(Empyrion Admin Helper / EAH) speaking protobuf-net to an in-process mod
called EPM. Re-implementing protobuf-net contract decoding in Go is a
losing battle — every Empyrion update could shift `[ProtoMember]` tags.
Instead, this bridge:
- References Eleon's `Mif.dll` directly (the official mod-API SDK).
- Re-uses `EPMConnector` (TCP framing + protobuf-net dispatch) by
compiling its source files in.
- Exposes a thin REST API the panel calls.
## Build
```
docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge
```
## Run
The bridge needs network reach to wherever EPM is listening on each
Empyrion instance. On a single-host setup with `network_mode: host`
empyrion containers, hostname `127.0.0.1` works:
```
docker run -d --name panel-bridge --network host \
-e BRIDGE_URLS=http://127.0.0.1:8090 \
panel-empyrion-bridge:latest
```
For multi-host setups, pass each Empyrion's LAN IP + EAH_API_PORT in the
query string per request.
## API (Phase 2 — read-only)
All endpoints take `?host=…&port=…` query parameters identifying the
Empyrion EPM instance.
| Method | Path | Returns |
|--------|--------------------------------------------|--------------------------------------------------|
| GET | `/healthz` | bridge health + version |
| GET | `/api/v1/factions` | `[{ origin, factionId, name, abbrev }]` |
| GET | `/api/v1/players` | `{ playerIds: [int], count }` |
| GET | `/api/v1/players/{id}` | full `PlayerInfo` (raw protobuf-net contract) |
| GET | `/api/v1/playfields` | `{ playfields: [name] }` |
| GET | `/api/v1/playfields/{name}/entities` | `{ entities: [...] }` |
| POST | `/api/v1/console` | fire-and-forget; body is the console line |
`POST /api/v1/console` is currently EXPERIMENTAL — see "Known issues" below.
## Known issues / Phase 3+ work
- **Console command cmd-byte truncation suspected.** Empyrion's `CmdId`
enum has grown past 255 entries; the legacy `EPMConnector.ModProtocol`
writes `cmd` as a single byte, which means commands with id > 255 alias
to other commands. Symptom seen: sending `Request_ConsoleCommand` shows
up on the EPM side as `Event_ChatMessageEx`. Fix: extend the protocol
encoding to use a UInt16 cmd OR build a tested mapping table from
Mif.dll's CmdId enum values. Tracked for Phase 3.
- **Authentication.** EPM has none. The bridge currently has none either.
Before exposing any port to the WAN, add a shared-secret bearer token
matching the panel's existing service-API pattern.
- **Streaming events.** Right now unsolicited Event_* packages (chat,
player join, etc) are dropped. A `GET /api/v1/stream` SSE endpoint will
fan these out to subscribers in Phase 4.
- **Connection persistence.** The pool reaps idle connections after 5
minutes. If Empyrion restarts, we don't yet auto-reconnect; the next
request opens a fresh connection but in-flight requests fail. Phase 5.
## Source layout
```
empyrion-bridge/
bridge/ ASP.NET Core minimal API project
Bridge.csproj
Program.cs endpoint definitions
BridgePool.cs per-(host,port) connection cache
BridgeConnection.cs request/response correlation by (seq, expectedCmd)
EPMConnector/ source files compiled in (from MichaelGoulding/sample-empyrion-mod, MIT)
lib/
Mif.dll Eleon Modding SDK reference
Dockerfile multi-stage build, alpine runtime
```
## Verified end-to-end on 2026-04-30
```
curl 'http://<agent-host>:8090/api/v1/factions?host=127.0.0.1&port=7028'
→ {"factions":[{"origin":1,"factionId":1,"name":"Human","abbrev":"Hum"}]}
```
@@ -0,0 +1,3 @@
bin/
obj/
*.user
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>EmpyrionBridge</RootNamespace>
<AssemblyName>panel-empyrion-bridge</AssemblyName>
<LangVersion>12</LangVersion>
<NoWarn>CS8981;CS0414;CS8632;CS8625;CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8619</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="protobuf-net" Version="3.2.30" />
</ItemGroup>
<ItemGroup>
<Reference Include="Mif">
<HintPath>..\lib\Mif.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,149 @@
using System.Collections.Concurrent;
using Eleon.Modding;
using EPMConnector;
namespace EmpyrionBridge;
// Wraps an EPMConnector.Client with a request/response correlation layer.
// EPM replies with the same seqNr we sent, so a TaskCompletionSource keyed
// by seqNr resolves on the matching Event_* package.
public class BridgeConnection : IDisposable
{
public string Host { get; }
public int Port { get; }
public bool IsConnected => _connected;
readonly Client _client;
readonly ConcurrentDictionary<ushort, Pending> _pending = new();
readonly ILogger _log;
int _seq;
volatile bool _connected;
// Subscribers receive every package not consumed by a pending request,
// i.e. EPM's broadcasts of player join/leave, chat, structure changes,
// PDA state, dedi stats heartbeats, etc. Used by the SSE fan-out.
readonly ConcurrentDictionary<int, Action<ModProtocol.Package>> _subscribers = new();
int _subscriberId;
record Pending(CmdId ExpectedResponse, TaskCompletionSource<ModProtocol.Package> Tcs);
public IDisposable Subscribe(Action<ModProtocol.Package> handler)
{
var id = Interlocked.Increment(ref _subscriberId);
_subscribers[id] = handler;
return new Subscription(this, id);
}
sealed class Subscription : IDisposable
{
readonly BridgeConnection _conn;
readonly int _id;
public Subscription(BridgeConnection c, int id) { _conn = c; _id = id; }
public void Dispose() => _conn._subscribers.TryRemove(_id, out _);
}
public BridgeConnection(string host, int port, ILogger log)
{
Host = host;
Port = port;
_log = log;
_client = new Client(clientId: 0);
_client.ClientMessages += msg => _log.LogDebug("EPM[{Host}:{Port}]: {Msg}", host, port, msg);
_client.OnConnected += () => { _connected = true; _log.LogInformation("EPM[{Host}:{Port}] connected", host, port); };
_client.GameEventReceived += OnPackage;
}
public Task ConnectAsync(TimeSpan timeout)
{
_client.Connect(Host, Port);
return WaitForConnect(timeout);
}
async Task WaitForConnect(TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!_connected && DateTime.UtcNow < deadline)
{
await Task.Delay(100);
}
if (!_connected) throw new TimeoutException($"EPM at {Host}:{Port} did not respond within {timeout.TotalSeconds:F0}s — is the mod loaded?");
}
public async Task<T> RequestAsync<T>(CmdId requestCmd, CmdId expectedResponse, object requestData, TimeSpan timeout) where T : class
{
if (!_connected) throw new InvalidOperationException("not connected");
var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF);
var tcs = new TaskCompletionSource<ModProtocol.Package>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[seq] = new Pending(expectedResponse, tcs);
try
{
_client.Send(requestCmd, seq, requestData);
var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout));
if (winner != tcs.Task)
{
throw new TimeoutException($"no {expectedResponse} response to {requestCmd} within {timeout.TotalSeconds:F0}s");
}
var pkg = await tcs.Task;
return pkg.data as T;
}
finally
{
_pending.TryRemove(seq, out _);
}
}
public void SendFireAndForget(CmdId cmd, object data)
{
if (!_connected) throw new InvalidOperationException("not connected");
var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF);
_client.Send(cmd, seq, data);
}
void OnPackage(ModProtocol.Package p)
{
_log.LogDebug("EPM[{Host}:{Port}] <== cmd={Cmd} seq={Seq} dataType={Type}",
Host, Port, p.cmd, p.seqNr, p.data?.GetType()?.Name ?? "null");
// Match a pending request by seqNr. Resolve on exact-cmd match
// (success) OR on Event_Error / Event_Ok (server-side failure /
// empty success); otherwise fall through to the SSE subscribers.
if (_pending.TryGetValue(p.seqNr, out var pending))
{
if (pending.ExpectedResponse == p.cmd)
{
_pending.TryRemove(p.seqNr, out _);
pending.Tcs.TrySetResult(p);
return;
}
if (p.cmd == CmdId.Event_Error || p.cmd == CmdId.Event_Ok)
{
// EPM sends Event_Error for invalid requests (e.g. asking
// for a non-existent player) AND for requests that have
// no data to return (e.g. Request_GlobalStructure_Update
// on a server with no structures). Either way, resolve
// the pending request with a null payload — caller's
// typed cast returns null and they render an empty list.
_pending.TryRemove(p.seqNr, out _);
pending.Tcs.TrySetResult(new ModProtocol.Package { cmd = pending.ExpectedResponse, seqNr = p.seqNr, data = null });
return;
}
}
// Broadcast to SSE subscribers. Catch failures per subscriber so a
// slow / failing one can't block the others.
foreach (var kv in _subscribers)
{
try { kv.Value(p); }
catch (Exception ex) { _log.LogWarning(ex, "subscriber {Id} threw", kv.Key); }
}
}
public void Dispose()
{
try { _client.Disconnect(); } catch { /* best effort */ }
foreach (var pending in _pending.Values) pending.Tcs.TrySetCanceled();
_pending.Clear();
}
}
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
namespace EmpyrionBridge;
// Lazy connection pool keyed by host:port. Reuses an EPM TCP connection
// across HTTP requests; drops it after IdleTtl with no traffic.
public class BridgePool : IAsyncDisposable
{
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(8);
public TimeSpan IdleTtl { get; init; } = TimeSpan.FromMinutes(5);
readonly ConcurrentDictionary<string, Entry> _entries = new();
readonly ILoggerFactory _logFactory;
readonly ILogger<BridgePool> _log;
readonly Timer _reaper;
public BridgePool(ILoggerFactory logFactory)
{
_logFactory = logFactory;
_log = logFactory.CreateLogger<BridgePool>();
_reaper = new Timer(Reap, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public async Task<BridgeConnection> GetAsync(string host, int port)
{
var key = $"{host}:{port}";
Entry e;
lock (_entries)
{
if (!_entries.TryGetValue(key, out e))
{
e = new Entry { Conn = new BridgeConnection(host, port, _logFactory.CreateLogger("EPM")) };
_entries[key] = e;
}
e.LastUsed = DateTime.UtcNow;
}
if (!e.Conn.IsConnected)
{
await e.ConnectGate.WaitAsync();
try
{
if (!e.Conn.IsConnected)
{
await e.Conn.ConnectAsync(ConnectTimeout);
}
}
finally
{
e.ConnectGate.Release();
}
}
return e.Conn;
}
void Reap(object _)
{
var cutoff = DateTime.UtcNow - IdleTtl;
foreach (var kv in _entries.ToArray())
{
if (kv.Value.LastUsed < cutoff)
{
if (_entries.TryRemove(kv.Key, out var e))
{
_log.LogInformation("reaping idle connection {Key}", kv.Key);
e.Conn.Dispose();
}
}
}
}
public async ValueTask DisposeAsync()
{
await _reaper.DisposeAsync();
foreach (var e in _entries.Values) e.Conn.Dispose();
_entries.Clear();
}
class Entry
{
public BridgeConnection Conn = null!;
public DateTime LastUsed = DateTime.UtcNow;
public SemaphoreSlim ConnectGate = new(1, 1);
}
}
@@ -0,0 +1,103 @@
using System;
using System.Net.Sockets;
using System.Threading;
using Eleon.Modding;
namespace EPMConnector
{
public class Client
{
ModThreadHelper.Info connectToServerThread;
public event Action OnConnected;
public event Action<String> ClientMessages;
public event Action<ModProtocol.Package> GameEventReceived;
volatile ModProtocol client;
int clientId;
string gameServerIp;
int gameServerPort;
public Client(int clientId)
{
this.clientId = clientId;
}
public void Connect(string ipAddress, int port)
{
this.gameServerIp = ipAddress;
this.gameServerPort = port;
connectToServerThread = ModThreadHelper.StartThread(ThreadConnectToServer, System.Threading.ThreadPriority.Lowest);
}
private void ThreadConnectToServer(ModThreadHelper.Info ti)
{
ClientMessages(string.Format("ModInterface: Started connection thread. Connecting to {0}:{1}", this.gameServerIp, this.gameServerPort));
while (!ti.eventRunning.WaitOne(0))
{
if (client == null)
{
try
{
TcpClient tcpClient = new TcpClient(this.gameServerIp, this.gameServerPort);
tcpClient.ReceiveBufferSize = 10 * 1024 * 1024;
tcpClient.SendBufferSize = 10 * 1024 * 1024;
client = new ModProtocol(tcpClient, PackageReceivedDelegate, DisconnectedDelegate);
ClientMessages("ModInterface: Connected with " + client + " over port " + this.gameServerPort);
OnConnected?.Invoke();
}
catch (SocketException)
{
// Ignore
}
catch (Exception e)
{
ClientMessages(e.GetType() + ": " + e.Message);
client = null;
}
}
Thread.Sleep(1000);
}
}
// Called in a thread!
private void PackageReceivedDelegate(ModProtocol con, ModProtocol.Package p)
{
GameEventReceived(p);
}
// Called in a thread!
private void DisconnectedDelegate(ModProtocol prot)
{
ClientMessages("DisconnectedDelegate called");
Disconnect();
}
public void Send(CmdId cmdId, ushort seqNr, object data)
{
//ClientMessages("Sending request event: c=" + cmdId + " sNr=" + seqNr + " d=" + data + " client="+client);
// Send events of the network
ModProtocol c = client;
if (c != null)
{
ModProtocol.Package p = new ModProtocol.Package(cmdId, clientId, seqNr, data);
c.AddToSendQueue(p);
}
}
public void Disconnect()
{
if(client != null)
{
client.Close();
client = null;
}
}
}
}
@@ -0,0 +1,92 @@
using System;
namespace EPMConnector
{
public class ModLoging
{
public static string Pfad = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + "\\";
public static string Datei = "Log.txt";
public enum eTyp
{
Information,
Warning,
Exception
}
public static Exception LastException = null;
public static void Log_Exception(Exception ex, string ZusatzInfo = "", string LogDatei = "")
{
Log("Error: " + ex.ToString() + ": " + (string.IsNullOrEmpty(ZusatzInfo) ? "" : "More Infos: " + ZusatzInfo + " - "), eTyp.Exception, LogDatei);
}
public static void Log(string Message, eTyp Typ = eTyp.Information, string LogDatei = "")
{
System.IO.FileStream stmFile = default(System.IO.FileStream);
System.IO.StreamWriter binWriter = default(System.IO.StreamWriter);
bool FileExists = false;
try
{
if (string.IsNullOrEmpty(LogDatei))
{
if (string.IsNullOrEmpty(Datei))
Datei = "Log.txt";
if (System.IO.Directory.Exists(Pfad) == false)
{
return;
}
LogDatei = Pfad + Datei;
}
else
{
if (System.IO.Directory.Exists(System.IO.Path.GetDirectoryName(LogDatei)) == false)
{
return;
}
}
FileExists = System.IO.File.Exists(LogDatei);
stmFile = new System.IO.FileStream(LogDatei, System.IO.FileMode.Append, System.IO.FileAccess.Write);
binWriter = new System.IO.StreamWriter(stmFile);
if (FileExists == false)
binWriter.WriteLine("Date" + "\t" + "Time " + "\t" + "Status" + "\t" + "Message");
binWriter.WriteLine(Get_LogString(Message, Typ));
binWriter.Flush();
binWriter.Close();
stmFile.Close();
}
catch (Exception)
{
}
}
public static string Get_LogString(string Message, eTyp Typ = eTyp.Information)
{
string functionReturnValue = null;
switch (Typ)
{
case eTyp.Information:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "I" + "\t" + Message;
break;
case eTyp.Warning:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "W" + "\t" + Message;
break;
case eTyp.Exception:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "E" + "\t" + Message;
break;
default:
functionReturnValue = "";
break;
}
return functionReturnValue;
}
}
}
@@ -0,0 +1,776 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using ProtoBuf;
using Eleon.Modding;
namespace EPMConnector
{
public class ModProtocol
{
public struct Package
{
public CmdId cmd;
public int clientId;
public object data;
public ushort seqNr;
public Package(CmdId cmdId, int nClientId, ushort nSeqNr, object nData)
{
cmd = cmdId;
clientId = nClientId;
seqNr = nSeqNr;
data = nData;
}
}
public delegate void DelegatePackageReceived(ModProtocol con, Package p);
private DelegatePackageReceived packageReceivedDelegate;
public delegate void DelegateDisconnected(ModProtocol con);
private DelegateDisconnected disconnectedDelegate;
public readonly TcpClient tcpClient;
private List<Package> writingPackages = new List<Package>();
ModThreadHelper.Info readerThread;
ModThreadHelper.Info writerThread;
volatile BinaryReader readerStream;
volatile BinaryWriter writerStream;
AutoResetEvent evWritePackages = new AutoResetEvent(false);
volatile bool bDisconnected = false;
public ModProtocol(TcpClient nTcpClient, DelegatePackageReceived nPackageReceivedDelegate, DelegateDisconnected nDisconnectedDelegate)
{
tcpClient = nTcpClient;
packageReceivedDelegate = nPackageReceivedDelegate;
disconnectedDelegate = nDisconnectedDelegate;
readerThread = ModThreadHelper.StartThread("Reader-" + tcpClient.Client.RemoteEndPoint, ReaderThread, ThreadPriority.Lowest);
writerThread = ModThreadHelper.StartThread("Writer-" + tcpClient.Client.RemoteEndPoint, WriterThread, ThreadPriority.Lowest);
}
public void AddToSendQueue(Package p)
{
lock (writingPackages)
{
writingPackages.Add(p);
}
evWritePackages.Set();
}
private void ReaderThread(ModThreadHelper.Info ti)
{
readerStream = new BinaryReader(tcpClient.GetStream());
try
{
while (!ti.eventRunning.WaitOne(0))
{
CmdId cmd = (CmdId)readerStream.ReadByte();
int clientId = readerStream.ReadInt32();
ushort seqNr = readerStream.ReadUInt16();
int len = readerStream.ReadInt32();
int bytesRead = 0;
byte[] data = null;
if (len > 0)
{
data = new byte[len]; // allocations are bad! maybe use pooled buffers?
do
{
bytesRead += readerStream.Read(data, bytesRead, (len - bytesRead));
} while (bytesRead < len && !readerThread.eventRunning.WaitOne(0));
}
object obj = null;
if (len > 0)
{
MemoryStream ms = new MemoryStream(data);
switch (cmd)
{
case Eleon.Modding.CmdId.Event_Player_Connected:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Disconnected:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfield>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Inventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdList>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Info:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayerInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_Info:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_GetInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_SetInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_AddItem:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdItemStack>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_Credits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_SetCredits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_AddCredits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Credits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_PosAndRot:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Entity_PosAndRot:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPositionRotation>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.DialogBoxData>(ms);
break;
case Eleon.Modding.CmdId.Event_DialogButtonIndex:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdAndIntValue>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Loaded:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldLoad>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Unloaded:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldLoad>(ms);
break;
case Eleon.Modding.CmdId.Request_Playfield_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldStats>(ms);
break;
case Eleon.Modding.CmdId.Event_Dedi_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.DediStats>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldList>(ms);
break;
case Eleon.Modding.CmdId.Event_GlobalStructure_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.GlobalStructureList>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Teleport:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPositionRotation>(ms);
break;
case Eleon.Modding.CmdId.Request_GlobalStructure_Update:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Faction_Changed:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.FactionChangeInfo>(ms);
break;
case CmdId.Request_Blueprint_Finish:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Blueprint_Resources:
obj = Serializer.Deserialize<BlueprintResources>(ms);
break;
case CmdId.Request_Player_ChangePlayerfield:
obj = Serializer.Deserialize<IdPlayfieldPositionRotation>(ms);
break;
case CmdId.Request_Entity_ChangePlayfield:
obj = Serializer.Deserialize<IdPlayfieldPositionRotation>(ms);
break;
case CmdId.Request_Entity_Destroy:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Player_ItemExchange:
case CmdId.Event_Player_ItemExchange:
obj = Serializer.Deserialize<ItemExchangeInfo>(ms);
break;
case CmdId.Event_Statistics:
obj = Serializer.Deserialize<StatisticsParam>(ms);
break;
case CmdId.Event_Error:
obj = Serializer.Deserialize<ErrorInfo>(ms);
break;
case CmdId.Request_Structure_Touch:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Get_Factions:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_Get_Factions:
obj = Serializer.Deserialize<FactionInfoList>(ms);
break;
case CmdId.Request_Player_SetPlayerInfo:
obj = Serializer.Deserialize<PlayerInfoSet>(ms);
break;
case CmdId.Event_NewEntityId:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Entity_Spawn:
obj = Serializer.Deserialize<EntitySpawnInfo>(ms);
break;
case CmdId.Event_Player_DisconnectedWaiting:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_ChatMessage:
obj = Serializer.Deserialize<ChatInfo>(ms);
break;
case CmdId.Request_ConsoleCommand:
obj = Serializer.Deserialize<PString>(ms);
break;
case CmdId.Request_Structure_BlockStatistics:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_Structure_BlockStatistics:
obj = Serializer.Deserialize<IdStructureBlockInfo>(ms);
break;
case CmdId.Event_AlliancesAll:
obj = Serializer.Deserialize<AlliancesTable>(ms);
break;
case CmdId.Request_AlliancesFaction:
obj = Serializer.Deserialize<AlliancesFaction>(ms);
break;
case CmdId.Event_AlliancesFaction:
obj = Serializer.Deserialize<AlliancesFaction>(ms);
break;
case CmdId.Event_BannedPlayers:
obj = Serializer.Deserialize<BannedPlayerData>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_Faction:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Event_TraderNPCItemSold:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.TraderNPCItemSoldInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Request_Playfield_Entity_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Entity_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldEntityList>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Destroy2:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfield>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Export:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.EntityExportInfo>(ms);
break;
case Eleon.Modding.CmdId.Event_ConsoleCommand:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.ConsoleCommandInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_SetName:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfieldName>(ms);
break;
case Eleon.Modding.CmdId.Event_PdaStateChange:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PdaStateInfo>(ms);
break;
case Eleon.Modding.CmdId.Event_GameEvent:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.GameEventData>(ms);
break;
}
}
if (packageReceivedDelegate != null && bytesRead == len)
{
packageReceivedDelegate(this, new Package(cmd, clientId, seqNr, obj));
}
}
}
catch (IOException e)
{
Console.WriteLine(string.Format("Connection closed while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (ObjectDisposedException e)
{
Console.WriteLine(string.Format("ObjectDisposed: Connection closed while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (Exception e)
{
Console.WriteLine(string.Format("Exception while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Exception while reading ({0})", e.Message), ModLoging.eTyp.Exception);
Console.WriteLine(e.GetType() + ": " + e.Message);
CloseConnection();
}
}
private void WriterThread(ModThreadHelper.Info ti)
{
writerStream = new BinaryWriter(tcpClient.GetStream());
List<Package> writingPackagesCopy = new List<Package>();
try
{
while (!ti.eventRunning.WaitOne(0))
{
evWritePackages.WaitOne();
// Do a copy of the list else we need to hold the lock when writing and this could block the main thread
writingPackagesCopy.Clear();
lock (writingPackages)
{
writingPackagesCopy.AddRange(writingPackages);
writingPackages.Clear();
}
for (int i = 0; i < writingPackagesCopy.Count; i++)
{
Package p = writingPackagesCopy[i];
MemoryStream ms = new MemoryStream();
switch (p.cmd)
{
case Eleon.Modding.CmdId.Event_Player_Connected:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Disconnected:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfield>(ms, (Eleon.Modding.IdPlayfield)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Inventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Info:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayerInfo>(ms, (Eleon.Modding.PlayerInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdList>(ms, (Eleon.Modding.IdList)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_Info:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_GetInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_SetInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_AddItem:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdItemStack>(ms, (Eleon.Modding.IdItemStack)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_Credits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_SetCredits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_AddCredits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Credits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_PosAndRot:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Entity_PosAndRot:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPositionRotation>(ms, (Eleon.Modding.IdPositionRotation)p.data);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer:
ProtoBuf.Serializer.Serialize<Eleon.Modding.DialogBoxData>(ms, (Eleon.Modding.DialogBoxData)p.data);
break;
case Eleon.Modding.CmdId.Event_DialogButtonIndex:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdAndIntValue>(ms, (Eleon.Modding.IdAndIntValue)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Loaded:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldLoad>(ms, (Eleon.Modding.PlayfieldLoad)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Unloaded:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldLoad>(ms, (Eleon.Modding.PlayfieldLoad)p.data);
break;
case Eleon.Modding.CmdId.Request_Playfield_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldStats>(ms, (Eleon.Modding.PlayfieldStats)p.data);
break;
case Eleon.Modding.CmdId.Event_Dedi_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.DediStats>(ms, (Eleon.Modding.DediStats)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldList>(ms, (Eleon.Modding.PlayfieldList)p.data);
break;
case Eleon.Modding.CmdId.Event_GlobalStructure_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.GlobalStructureList>(ms, (Eleon.Modding.GlobalStructureList)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Teleport:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPositionRotation>(ms, (Eleon.Modding.IdPositionRotation)p.data);
break;
case Eleon.Modding.CmdId.Request_GlobalStructure_Update:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Faction_Changed:
ProtoBuf.Serializer.Serialize<Eleon.Modding.FactionChangeInfo>(ms, (Eleon.Modding.FactionChangeInfo)p.data);
break;
case CmdId.Request_Blueprint_Finish:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Id)p.data);
break;
case CmdId.Request_Blueprint_Resources:
ProtoBuf.Serializer.Serialize<Eleon.Modding.BlueprintResources>(ms, (BlueprintResources)p.data);
break;
case CmdId.Request_Player_ChangePlayerfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldPositionRotation>(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data);
break;
case CmdId.Request_Entity_ChangePlayfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldPositionRotation>(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data);
break;
case CmdId.Request_Entity_Destroy:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Player_ItemExchange:
case CmdId.Event_Player_ItemExchange:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ItemExchangeInfo>(ms, (Eleon.Modding.ItemExchangeInfo)p.data);
break;
case CmdId.Event_Statistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.StatisticsParam>(ms, (Eleon.Modding.StatisticsParam)p.data);
break;
case CmdId.Event_Error:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ErrorInfo>(ms, (Eleon.Modding.ErrorInfo)p.data);
break;
case CmdId.Request_Structure_Touch:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Get_Factions:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Event_Get_Factions:
ProtoBuf.Serializer.Serialize<Eleon.Modding.FactionInfoList>(ms, (Eleon.Modding.FactionInfoList)p.data);
break;
case CmdId.Request_Player_SetPlayerInfo:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayerInfoSet>(ms, (Eleon.Modding.PlayerInfoSet)p.data);
break;
case CmdId.Event_NewEntityId:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Entity_Spawn:
ProtoBuf.Serializer.Serialize<Eleon.Modding.EntitySpawnInfo>(ms, (Eleon.Modding.EntitySpawnInfo)p.data);
break;
case CmdId.Event_Player_DisconnectedWaiting:
Serializer.Serialize(ms, (Id)p.data);
break;
case CmdId.Event_ChatMessage:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ChatInfo>(ms, (Eleon.Modding.ChatInfo)p.data);
break;
case CmdId.Request_ConsoleCommand:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case CmdId.Request_Structure_BlockStatistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Event_Structure_BlockStatistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdStructureBlockInfo>(ms, (Eleon.Modding.IdStructureBlockInfo)p.data);
break;
case CmdId.Event_AlliancesAll:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesTable>(ms, (Eleon.Modding.AlliancesTable)p.data);
break;
case CmdId.Request_AlliancesFaction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesFaction>(ms, (Eleon.Modding.AlliancesFaction)p.data);
break;
case CmdId.Event_AlliancesFaction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesFaction>(ms, (Eleon.Modding.AlliancesFaction)p.data);
break;
case CmdId.Event_BannedPlayers:
ProtoBuf.Serializer.Serialize<Eleon.Modding.BannedPlayerData>(ms, (Eleon.Modding.BannedPlayerData)p.data);
break;
case CmdId.Request_InGameMessage_Faction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Event_TraderNPCItemSold:
ProtoBuf.Serializer.Serialize<Eleon.Modding.TraderNPCItemSoldInfo>(ms, (Eleon.Modding.TraderNPCItemSoldInfo)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Request_Playfield_Entity_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Entity_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldEntityList>(ms, (Eleon.Modding.PlayfieldEntityList)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Destroy2:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfield>(ms, (Eleon.Modding.IdPlayfield)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Export:
ProtoBuf.Serializer.Serialize<Eleon.Modding.EntityExportInfo>(ms, (Eleon.Modding.EntityExportInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_ConsoleCommand:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ConsoleCommandInfo>(ms, (Eleon.Modding.ConsoleCommandInfo)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_SetName:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldName>(ms, (Eleon.Modding.IdPlayfieldName)p.data);
break;
case Eleon.Modding.CmdId.Event_PdaStateChange:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PdaStateInfo>(ms, (Eleon.Modding.PdaStateInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_GameEvent:
ProtoBuf.Serializer.Serialize<Eleon.Modding.GameEventData>(ms, (Eleon.Modding.GameEventData)p.data);
break;
default:
break;
}
writerStream.Write((byte)p.cmd);
writerStream.Write((Int32)p.clientId);
writerStream.Write((UInt16)p.seqNr);
byte[] data = ms.GetBuffer();
int len = (int)ms.Length;
writerStream.Write((Int32)len);
int offset = 0;
do
{
int sendLen = Math.Min(8192, len);
writerStream.Write(data, offset, sendLen);
len -= sendLen;
offset += sendLen;
} while (len > 0);
}
writerStream.Flush();
}
}
catch (IOException)
{
//Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message)); silent
CloseConnection();
}
catch (ObjectDisposedException e)
{
Console.WriteLine(string.Format("ObjectDisposed: Connection closed while writing ({0})", e.Message));
ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (Exception e)
{
Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception);
Console.WriteLine(e.GetType() + ": " + e.Message);
CloseConnection();
}
}
void CloseConnection()
{
try
{
if (bDisconnected)
{
return;
}
bDisconnected = true;
tcpClient.Close();
readerStream.Close();
readerThread.eventRunning.Set();
writerStream.Close();
writerThread.eventRunning.Set();
evWritePackages.Set();
if (disconnectedDelegate != null)
{
disconnectedDelegate(this);
}
}
catch (Exception e)
{
ModLoging.Log_Exception(e, "MDP: Close Connection");
}
//readerThread.WaitForEnd();
//writerThread.WaitForEnd();
}
public void Close()
{
try
{
tcpClient.Close();
readerStream.Close();
readerThread.eventRunning.Set();
writerStream.Close();
writerThread.eventRunning.Set();
evWritePackages.Set();
}
catch (Exception e)
{
ModLoging.Log_Exception(e, "MDP: Close");
}
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Threading;
public class ModThreadHelper
{
public class Info
{
public ThreadFunc threadDelegate;
public string name;
public Thread thread;
public ManualResetEvent eventRunning = new ManualResetEvent(false);
public void WaitForEnd()
{
eventRunning.Set();
}
}
public delegate void ThreadFunc(Info ti);
public static Dictionary<string, Info> RunningThreads = new Dictionary<string, Info>();
public static Info StartThread(ThreadFunc nThreadStart, System.Threading.ThreadPriority nThreadPriority)
{
return StartThread(nThreadStart.Method.Name, nThreadStart, nThreadPriority);
}
public static Info StartThread(string nName, ThreadFunc nThreadFunc, System.Threading.ThreadPriority nThreadPrio)
{
Thread t = new Thread(new ParameterizedThreadStart(ThreadInvoke));
t.Priority = nThreadPrio;
Info threadInfo = new Info();
threadInfo.threadDelegate = nThreadFunc;
threadInfo.thread = t;
lock (RunningThreads)
{
while (RunningThreads.ContainsKey(nName))
{
nName += "[new]";
}
threadInfo.name = nName;
RunningThreads.Add(nName, threadInfo);
}
ThreadPool.UnsafeQueueUserWorkItem(ThreadInvoke, threadInfo);
return threadInfo;
}
private static void ThreadInvoke(object ti)
{
Info threadInfo = (Info)ti;
try
{
threadInfo.threadDelegate(threadInfo);
}
catch (Exception e)
{
Console.WriteLine(string.Format("Thread {0} exception:", threadInfo.name));
Console.WriteLine(e.GetType() + ": " + e.Message);
EPMConnector.ModLoging.Log(string.Format("MTH: Thread {0} exception: {1}", threadInfo.name, e.GetType() + ": " + e.Message));
}
finally
{
//Console.WriteLine(string.Format("Thread {0} exited", threadInfo.name));
lock (RunningThreads)
{
RunningThreads.Remove(threadInfo.name);
}
}
}
}
+618
View File
@@ -0,0 +1,618 @@
using System.Reflection;
using Eleon.Modding;
namespace EmpyrionBridge;
// EAH-parity verbs grouped by the Empyrion Admin Helper tab they map to.
//
// Categories:
// - Players : kick, ban/unban, give-item, give-credits, teleport, info, inventory
// - Factions : list, alliances, message
// - Chat : broadcast (server-wide), faction chat, single-player whisper
// - Structures : list global, set-name, transfer-faction (via console)
// - Playfields : list, load, wipe (via console), entity list
// - Console : raw console line, save, restart-warning
//
// Most actions either go through a typed CmdId (Request_Player_AddItem) or
// fall back to Request_ConsoleCommand with a templated string. Console
// commands are documented at https://empyrion.fandom.com/wiki/Server_commands
// — the wiki is mostly accurate as of Empyrion 1.16.x.
public static class EndpointMaps
{
public static void MapAll(WebApplication app, BridgePool pool, TimeSpan timeout)
{
MapPlayers(app, pool, timeout);
MapFactions(app, pool, timeout);
MapChat(app, pool, timeout);
MapStructures(app, pool, timeout);
MapStructureOps(app, pool, timeout);
MapPlayfields(app, pool, timeout);
MapConsole(app, pool, timeout);
MapBans(app, pool, timeout);
MapEventStream(app, pool, timeout);
}
// ---------- SSE event stream ----------
// GET /api/v1/events?host=&port= — text/event-stream of unsolicited
// Event_* packages (chat, player join/leave, structure changes, etc).
// Each event line: data: { "cmd": "...", "seq": N, "data": {...} }\n\n
static void MapEventStream(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/events", async (HttpContext ctx) =>
{
var ep = Endpoint(ctx.Request);
if (ep is null) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("missing host or port"); return; }
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
ctx.Response.Headers["Content-Type"] = "text/event-stream";
ctx.Response.Headers["Cache-Control"] = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
var queue = new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false };
var ch = System.Threading.Channels.Channel.CreateUnbounded<string>(queue);
using var sub = conn.Subscribe(p =>
{
try
{
var payload = System.Text.Json.JsonSerializer.Serialize(new
{
cmd = p.cmd.ToString(),
cmdId = (int)p.cmd,
seq = (int)p.seqNr,
clientId = p.clientId,
data = p.data,
type = p.data?.GetType()?.Name,
});
ch.Writer.TryWrite(payload);
}
catch { /* don't let serialization errors take down the connection */ }
});
// Initial heartbeat so client knows the stream is live.
await ctx.Response.WriteAsync("event: ready\ndata: {}\n\n");
await ctx.Response.Body.FlushAsync();
var heartbeat = TimeSpan.FromSeconds(15);
using var hbCts = new CancellationTokenSource();
var hbTask = Task.Run(async () =>
{
try
{
while (!hbCts.IsCancellationRequested)
{
await Task.Delay(heartbeat, hbCts.Token);
ch.Writer.TryWrite(":heartbeat");
}
}
catch (TaskCanceledException) { }
});
try
{
await foreach (var msg in ch.Reader.ReadAllAsync(ctx.RequestAborted))
{
if (msg.StartsWith(":")) await ctx.Response.WriteAsync(msg + "\n\n");
else await ctx.Response.WriteAsync("data: " + msg + "\n\n");
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
}
}
catch (OperationCanceledException) { /* client went away */ }
finally
{
hbCts.Cancel();
ch.Writer.TryComplete();
}
});
}
static (string host, int port)? Endpoint(HttpRequest r)
{
var h = r.Query["host"].FirstOrDefault();
var p = r.Query["port"].FirstOrDefault();
if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
return (h, pi);
}
static async Task<(BridgeConnection conn, IResult? err)> Connect(BridgePool pool, HttpRequest r)
{
var ep = Endpoint(r);
if (ep is null) return (null!, Results.BadRequest(new { error = "missing host or port" }));
return (await pool.GetAsync(ep.Value.host, ep.Value.port), null);
}
// ---------- Players ----------
static void MapPlayers(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/players, /api/v1/players/{id}
app.MapPost("/api/v1/players/{id:int}/kick", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var reason = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(reason)) reason = "kicked by admin";
// Use console verb — there is no typed Request_Kick.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"kick {id} '{reason.Replace("'", "")}'"));
return Results.Json(new { ok = true, sent = $"kick {id} '{reason}'" });
});
app.MapPost("/api/v1/players/{id:int}/ban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Two duration shapes supported:
// ?dur=<string> — passed verbatim to Empyrion (e.g. "30s", "5m", "7h", "30d", "1y", "0" for permanent)
// ?days=N — legacy integer days, kept for back-compat
// dur takes precedence when both are set.
var rawDur = r.Query["dur"].FirstOrDefault();
string dur;
if (!string.IsNullOrWhiteSpace(rawDur))
{
// Allow only [0-9smhdy] to keep injection out of the console verb.
var clean = new string(rawDur.Where(c => char.IsDigit(c) || c is 's' or 'm' or 'h' or 'd' or 'y').ToArray());
if (string.IsNullOrEmpty(clean)) return Results.BadRequest(new { error = "dur must be like 30s / 5m / 7h / 30d / 1y / 0" });
dur = clean;
}
else
{
var days = int.TryParse(r.Query["days"].FirstOrDefault(), out var d) ? d : 1;
dur = $"{days}d";
}
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban add {id} {dur}"));
return Results.Json(new { ok = true, sent = $"ban add {id} {dur}" });
});
app.MapPost("/api/v1/players/{id:int}/unban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban remove {id}"));
return Results.Json(new { ok = true, sent = $"ban remove {id}" });
});
// Set Empyrion's per-player role. Wraps the
// `setrole "<playerName>" <role>` console verb — Empyrion's setrole
// looks up by name (or steam id), NOT by entity id, so we resolve
// the entity id → name via a quick PlayerInfo round-trip first.
// Sending `setrole <entityId> <role>` silently fails (Empyrion
// returns no error but the permission level never changes).
app.MapPost("/api/v1/players/{id:int}/set-role", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var role = (r.Query["role"].FirstOrDefault() ?? "").Trim().ToLowerInvariant();
if (role is not ("admin" or "gamemaster" or "moderator" or "player"))
return Results.BadRequest(new { error = "role must be one of: admin, gamemaster, moderator, player" });
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"setrole \"{name}\" {role}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, sent = cmd, id, playerName = name, role });
});
app.MapPost("/api/v1/players/{id:int}/give-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (credits to add; negative subtracts)" });
conn.SendFireAndForget(CmdId.Request_Player_AddCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
app.MapPost("/api/v1/players/{id:int}/set-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (absolute credits)" });
conn.SendFireAndForget(CmdId.Request_Player_SetCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
// Give an item to a player. We use Empyrion's `give "<name>" <itemId> <count>`
// console command rather than Request_Player_AddItem because the typed
// RPC requires a slotIdx — without one the API defaults to slot 0 in the
// toolbar, which is normally occupied (starter pickaxe etc), and Empyrion
// silently refuses the overwrite. The console command auto-places into
// the first free bag slot, the natural "give-me-this-item" behavior.
// Caller can still pass slotIdx explicitly to use the typed path.
app.MapPost("/api/v1/players/{id:int}/give-item", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "itemId": 4307, "count": 1, "slotIdx": 0|null, "ammo": 0, "decay": 0 }
var body = await JsonContent.ReadAsync<GiveItemReq>(r);
if (body is null || body.itemId <= 0) return Results.BadRequest(new { error = "itemId required" });
var count = body.count <= 0 ? 1 : body.count;
// Path A: caller pinned a specific slot — go through typed RPC.
if (body.slotIdx.HasValue)
{
var stack = new ItemStack
{
id = body.itemId,
count = count,
slotIdx = body.slotIdx.Value,
ammo = body.ammo,
decay = body.decay,
};
conn.SendFireAndForget(CmdId.Request_Player_AddItem, new IdItemStack { id = id, itemStack = stack });
return Results.Json(new { ok = true, id, item = stack, via = "typed" });
}
// Path B (default): auto-place via console — needs the player NAME.
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"give \"{name}\" {body.itemId} {count}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, id, playerName = name, itemId = body.itemId, count, sent = cmd, via = "console" });
});
app.MapGet("/api/v1/players/{id:int}/inventory", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<Inventory>(CmdId.Request_Player_GetInventory, CmdId.Event_Player_Inventory, new Id(id), timeout);
if (resp is null) return Results.NotFound(new { error = "no inventory for player", id });
return Results.Json(resp);
});
app.MapPost("/api/v1/players/{id:int}/teleport", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "x":0, "y":0, "z":0, "playfield":"optional" }
var body = await JsonContent.ReadAsync<TeleportReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Player_ChangePlayerfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
}
// ---------- Factions ----------
static void MapFactions(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/factions
app.MapGet("/api/v1/factions/alliances", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<AlliancesTable>(CmdId.Request_AlliancesAll, CmdId.Event_AlliancesAll, null, timeout);
return Results.Json(resp ?? new AlliancesTable());
});
// POST /api/v1/factions/alliance-state — set alliance state between two factions
// state mapping: ally=0, neutral=1, hostile=2 (Empyrion console verb values)
app.MapPost("/api/v1/factions/alliance-state", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<AllianceStateReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionA, factionB, and state required" });
int stateCode;
switch (body.state?.Trim().ToLowerInvariant())
{
case "ally": stateCode = 0; break;
case "neutral": stateCode = 1; break;
case "hostile": stateCode = 2; break;
default: return Results.BadRequest(new { error = "state must be one of: ally, neutral, hostile" });
}
var cmd = $"setalliancestate {body.factionA} {body.factionB} {stateCode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Chat ----------
static void MapChat(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapPost("/api/v1/chat/broadcast", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
// prio: 0=info, 1=warning, 2=alert. Default to alert for visibility.
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)2;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_AllPlayers, new IdMsgPrio { id = 0, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, msg, prio, time });
});
app.MapPost("/api/v1/chat/whisper/{playerId:int}", async (int playerId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)0;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_SinglePlayer, new IdMsgPrio { id = playerId, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, playerId, msg });
});
app.MapPost("/api/v1/chat/faction/{factionId:int}", async (int factionId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
conn.SendFireAndForget(CmdId.Request_InGameMessage_Faction, new IdMsgPrio { id = factionId, msg = msg, prio = 2, time = 8.0f });
return Results.Json(new { ok = true, factionId, msg });
});
}
// ---------- Structures ----------
static void MapStructures(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Empyrion's pattern: send Request_GlobalStructure_Update, the server
// pushes Event_GlobalStructure_List back. We correlate by expected cmd.
app.MapGet("/api/v1/structures", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<GlobalStructureList>(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout);
return Results.Json(new
{
playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty<string>(),
count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0,
structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List<GlobalStructureInfo>()).Select(gs => new
{
playfield = kv.Key,
id = gs.id,
name = gs.name,
type = gs.type,
pos = new[] { gs.pos.x, gs.pos.y, gs.pos.z },
factionId = gs.factionId,
factionGroup = gs.factionGroup,
coreType = gs.coreType,
classNr = gs.classNr,
blocks = gs.cntBlocks,
devices = gs.cntDevices,
triangles = gs.cntTriangles,
lights = gs.cntLights,
fuel = gs.fuel,
pilotId = gs.pilotId,
powered = gs.powered,
})) ?? Enumerable.Empty<object>(),
});
});
app.MapPost("/api/v1/structures/{id:int}/rename", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var newName = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(newName)) return Results.BadRequest(new { error = "empty name" });
// The Mod API has Request_Entity_SetName but it requires playfield+name, not just id.
// Easier path: console "rename ID NEW_NAME"
var safe = newName.Replace("\"", "");
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"rename '{id}' '{safe}'"));
return Results.Json(new { ok = true, id, name = newName });
});
app.MapPost("/api/v1/structures/{id:int}/destroy", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_Entity_Destroy, new Id(id));
return Results.Json(new { ok = true, id });
});
}
// ---------- Structure Operations ----------
static void MapStructureOps(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// POST /api/v1/structures/{id:int}/faction — transfer structure to a faction
app.MapPost("/api/v1/structures/{id:int}/faction", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<FactionReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionId required" });
var cmd = $"faction structure {id} {body.factionId}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/heal — heal structure to 100%
app.MapPost("/api/v1/structures/{id:int}/heal", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"heal {id} 100";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/refuel — refuel structure
app.MapPost("/api/v1/structures/{id:int}/refuel", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"refuel {id}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/set-core — set core type
app.MapPost("/api/v1/structures/{id:int}/set-core", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SetCoreReq>(r);
if (body is null || body.mode is null) return Results.BadRequest(new { error = "mode required (admin|player|hardcore)" });
var mode = body.mode.Trim().ToLowerInvariant();
if (mode is not ("admin" or "player" or "hardcore"))
return Results.BadRequest(new { error = "mode must be one of: admin, player, hardcore" });
var cmd = $"setadmincore {id} {mode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/move — move structure (same playfield or cross-playfield)
app.MapPost("/api/v1/structures/{id:int}/move", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<MoveStructureReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_ChangePlayfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
// POST /api/v1/structures/{id:int}/save-as-blueprint — save structure as blueprint
app.MapPost("/api/v1/structures/{id:int}/save-as-blueprint", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SaveBlueprintReq>(r);
var cmd = string.IsNullOrEmpty(body?.name) ? $"bp {id}" : $"bp {id} \"{body.name.Replace("\"", "")}\"";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/regenerate — regenerate a structure (optionally with faction override)
app.MapPost("/api/v1/structures/{id:int}/regenerate", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<RegenerateReq>(r);
var cmd = string.IsNullOrEmpty(body?.faction) ? $"regenerate {id}" : $"regenerate {id} {body.faction}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Playfields ----------
static void MapPlayfields(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/playfields, /api/v1/playfields/{name}/entities
app.MapGet("/api/v1/playfields/{name}/stats", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<PlayfieldStats>(CmdId.Request_Playfield_Stats, CmdId.Event_Playfield_Stats, new PString(name), timeout);
return Results.Json(resp ?? new PlayfieldStats { playfield = name });
});
app.MapPost("/api/v1/playfields/{name}/wipe", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// wipe playfield POI/all/terrain/deposit
var what = r.Query["what"].FirstOrDefault() ?? "poi";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"wipe '{name}' {what}"));
return Results.Json(new { ok = true, playfield = name, scope = what });
});
// POST /api/v1/playfields/{name}/load — load a playfield
app.MapPost("/api/v1/playfields/{name}/load", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"loadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/unload — unload a playfield
app.MapPost("/api/v1/playfields/{name}/unload", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"unloadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/reset — full playfield reset (aggressive)
app.MapPost("/api/v1/playfields/{name}/reset", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"resetplayfield '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Console ----------
static void MapConsole(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: POST /api/v1/console (raw command)
app.MapPost("/api/v1/save", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Empyrion's "saveandexit" command saves and shuts down — we don't want that.
// The "save" alias triggers an immediate save without exit; exists in 1.16+.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString("save"));
return Results.Json(new { ok = true, sent = "save" });
});
app.MapPost("/api/v1/dedi/stats", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<DediStats>(CmdId.Request_Dedi_Stats, CmdId.Event_Dedi_Stats, null, timeout);
return Results.Json(resp ?? new DediStats());
});
}
// ---------- Bans ----------
static void MapBans(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/banned", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<BannedPlayerData>(CmdId.Request_GetBannedPlayers, CmdId.Event_BannedPlayers, null, timeout);
return Results.Json(resp ?? new BannedPlayerData());
});
}
}
public class GiveItemReq { public int itemId { get; set; } public int count { get; set; } public byte? slotIdx { get; set; } public int ammo { get; set; } public int decay { get; set; } }
public class TeleportReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class FactionReq { public int factionId { get; set; } }
public class SetCoreReq { public string? mode { get; set; } }
public class MoveStructureReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class SaveBlueprintReq { public string? name { get; set; } }
public class AllianceStateReq { public int factionA { get; set; } public int factionB { get; set; } public string? state { get; set; } }
public class RegenerateReq { public string? faction { get; set; } }
internal static class JsonContent
{
public static async Task<T?> ReadAsync<T>(HttpRequest r) where T : class
{
try { return await r.ReadFromJsonAsync<T>(); }
catch { return null; }
}
}
+169
View File
@@ -0,0 +1,169 @@
using System.Reflection;
using Eleon.Modding;
using EmpyrionBridge;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.TimestampFormat = "HH:mm:ss ";
});
builder.Services.AddSingleton<BridgePool>();
builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("BRIDGE_URLS") ?? "http://0.0.0.0:8090");
// Eleon's protobuf-net contracts (PlayerInfo, FactionInfo, etc.) expose
// their data as PUBLIC FIELDS, not properties. System.Text.Json ignores
// public fields by default, which made `Results.Json(playerInfo)` emit
// `{}` even though the deserialized object had real data inside. Turning
// IncludeFields on (globally for every endpoint that doesn't project
// explicitly) fixes the player-list, player-info, dedi-stats, and any
// future endpoint that returns a raw mod-API DTO.
//
// AllowNamedFloatingPointLiterals lets us serialize `NaN` / `Infinity`
// as the JSON string forms. Empyrion's mod API hands those back for
// uninitialized float fields (heading, posY, etc.) and STJ will throw
// at serialization time without this — breaking the entire response.
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.IncludeFields = true;
o.SerializerOptions.NumberHandling =
System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals;
});
var app = builder.Build();
var pool = app.Services.GetRequiredService<BridgePool>();
var requestTimeout = TimeSpan.FromSeconds(double.TryParse(Environment.GetEnvironmentVariable("BRIDGE_REQUEST_TIMEOUT_S"), out var t) ? t : 8);
var apiToken = (Environment.GetEnvironmentVariable("BRIDGE_API_TOKEN") ?? "").Trim();
if (string.IsNullOrEmpty(apiToken))
{
app.Logger.LogWarning("BRIDGE_API_TOKEN is not set — auth is OFF. Anyone reaching the bridge can drive it. Suitable for loopback-only deployments.");
}
// Bearer-token middleware. Loopback (127.0.0.1, ::1) and unix sockets are
// always allowed without a token, matching the panel's existing pattern.
// Health endpoint always allowed.
app.Use(async (ctx, next) =>
{
if (string.IsNullOrEmpty(apiToken)) { await next(); return; }
if (ctx.Request.Path.StartsWithSegments("/healthz")) { await next(); return; }
var ip = ctx.Connection.RemoteIpAddress;
if (ip is not null && (System.Net.IPAddress.IsLoopback(ip))) { await next(); return; }
var auth = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "";
if (auth == $"Bearer {apiToken}") { await next(); return; }
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsJsonAsync(new { error = "unauthorized" });
});
string? Q(HttpRequest r, string k) => r.Query[k].FirstOrDefault();
(string host, int port)? Endpoint(HttpRequest r)
{
var h = Q(r, "host");
var p = Q(r, "port");
if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
return (h, pi);
}
app.MapGet("/healthz", () => Results.Json(new
{
ok = true,
version = typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
pid = Environment.ProcessId,
authMode = string.IsNullOrEmpty(apiToken) ? "none" : "bearer-token",
}));
app.MapGet("/api/v1/factions", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<FactionInfoList>(CmdId.Request_Get_Factions, CmdId.Event_Get_Factions, new Id(1), requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
factions = resp?.factions?.Select(f => new
{
origin = f.origin,
factionId = f.factionId,
name = f.name,
abbrev = f.abbrev,
}) ?? Enumerable.Empty<object>(),
});
});
app.MapGet("/api/v1/players", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<IdList>(CmdId.Request_Player_List, CmdId.Event_Player_List, null, requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playerIds = resp?.list ?? new List<int>(),
count = resp?.list?.Count ?? 0,
});
});
app.MapGet("/api/v1/players/{id:int}", async (int id, HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayerInfo>(CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), requestTimeout);
if (resp is null) return Results.NotFound(new { error = "no info for player", id });
return Results.Json(resp);
});
app.MapGet("/api/v1/playfields", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayfieldList>(CmdId.Request_Playfield_List, CmdId.Event_Playfield_List, null, requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playfields = resp?.playfields ?? new List<string>(),
});
});
app.MapGet("/api/v1/playfields/{name}/entities", async (string name, HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayfieldEntityList>(CmdId.Request_Playfield_Entity_List, CmdId.Event_Playfield_Entity_List, new PString(name), requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playfield = name,
entities = resp?.entities ?? new List<EntityInfo>(),
});
});
app.MapPost("/api/v1/console", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
using var sr = new StreamReader(r.Body);
var body = await sr.ReadToEndAsync();
var line = body.Trim();
if (string.IsNullOrEmpty(line)) return Results.BadRequest(new { error = "empty body" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(line));
return Results.Json(new { sent = line });
});
EndpointMaps.MapAll(app, pool, requestTimeout);
app.Logger.LogInformation("panel-empyrion-bridge listening; request_timeout={Timeout}s authMode={Mode}",
requestTimeout.TotalSeconds, string.IsNullOrEmpty(apiToken) ? "none" : "bearer");
app.Run();
public partial class Program { }
+68
View File
@@ -0,0 +1,68 @@
# panel-native Empyrion runtime image.
#
# Empyrion's dedicated server is a Windows-only Unity build, so we bring
# along just enough Wine + Xvfb to host it headlessly. SteamCMD is NOT
# installed here — the panel's generic updater sidecar uses the official
# steamcmd image and writes into our /game volume.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed Windows build (panel volume)
# /game/DedicatedServer/DedicatedServer64/
# EmpyrionDedicated.exe — the dedicated server binary
# /game-saves — saves, dedicated.yaml, admin files
# /entrypoint.sh — boot Xvfb, launch exe under Wine
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
# Wine pulls in ~500 MB of deps; keep to the 64-bit stack (Empyrion's
# dedicated binary is x86_64). winetricks isn't strictly required — the
# game's Mono runtime ships with the install — but it's 8 MB and saves
# future debugging headaches.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
xvfb \
xauth \
wine64 \
wine \
winbind \
cabextract \
procps \
&& 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 owning /game + /game-saves. The SteamCMD sidecar
# writes as root (official image runs as root); we chown once at start
# time so the long-running game process stays 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 /home/panel
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Pre-staged EPM mod (TCP relay for the Eleon Mod API). Entrypoint copies
# this into /game/Content/Mods/EPM/ on first boot so every empyrion
# instance comes EAH-bridge-ready out of the box. See modules/empyrion/epm-mod/.
COPY epm-mod/ /opt/panel-epm/
# Empyrion default ports (see module.yaml comment for reference).
EXPOSE 30000/udp 30001/udp 30002/udp 30003/udp
EXPOSE 30004/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+35
View File
@@ -0,0 +1,35 @@
# Empyrion - Galactic Survival
Panel-native dedicated server module for Empyrion.
## How it works
- Image: `panel-empyrion:latest` — built from `./Dockerfile` (Debian 12 + Wine + Xvfb + tini).
- Game files: downloaded by the panel's SteamCMD updater sidecar into the `panel-<id>-game` named volume. Because Empyrion's dedicated server ships only as a Windows binary, the updater uses `+@sSteamCmdForcePlatformType windows` (declared in `module.yaml` via `platform: windows`).
- Entrypoint: boots Xvfb, initializes a Wine prefix on first run, then runs `EmpyrionDedicated.exe` under `wine64` with `-batchmode -nographics -logFile -` so Unity pipes its log to stdout.
## First-run flow
1. Build the image (one-time):
```bash
docker build -t panel-empyrion:latest modules/empyrion
```
2. Create an instance in the panel (defaults are fine).
3. Click **Update** on the instance — the SteamCMD sidecar downloads ~3.5 GB of Windows build into the `game` volume.
4. Click **Start** — the entrypoint seeds `dedicated.yaml` from the shipped default, starts Xvfb, bootstraps Wine, and launches the server.
First boot is slow (Wine prefix init + Empyrion's own first-run map generation). Subsequent starts are 3060s.
## Ports
| Name | Proto | Default | Notes |
|--------|-------|---------|-----------------------------|
| game | UDP | 30000 | Srv_Port — main traffic |
| query | UDP | 30001 | Steam query |
| client | UDP | 30002 | Client connect |
| eac | UDP | 30003 | EasyAntiCheat |
| csw | TCP | 30004 | CSW API (internal for now) |
## No RCON (yet)
Empyrion has no native RCON protocol. The in-game `admin` console commands can only be issued from a connected client. Third-party tools (Empyrion Web Admin / EAH) fill this gap and can be layered on later via plugins — not wired into this module for v1.
+220
View File
@@ -0,0 +1,220 @@
#!/bin/bash
# panel-native Empyrion entrypoint.
#
# Contract:
# /game — SteamCMD-installed Windows build of Empyrion (volume,
# populated by the panel's SteamCMD updater sidecar).
# If missing, exit with EX_CONFIG and a helpful message.
# /game-saves — dedicated.yaml + Saves/ tree (volume).
#
# Runs as root for its opening stage (fix up ownership after the SteamCMD
# sidecar wrote as root, nuke any stale X lock from a previous crash),
# then drops to the `panel` user for Wine + Xvfb + the game. Wine refuses
# to run as root, so the drop is load-bearing.
#
# On every start we verify the binary is there, start Xvfb, boot a minimal
# Wine prefix if needed, then exec EmpyrionDedicated.exe under Wine with
# stdout/stderr line-buffered so docker logs → agent → panel console
# delivers live output.
set -euo pipefail
log() { printf '[panel-empyrion] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap (runs once per container start) -----
if [[ "$(id -u)" = "0" ]]; then
# SteamCMD writes as root in its sidecar; chown once so the panel user
# (UID 1000) can read/write the game dir without fiddling with perms.
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
# Nuke any stale Xvfb state from a previous boot. The container's /tmp
# persists across restarts (same writable layer), so /tmp/.X99-lock plus
# /tmp/.X11-unix/X99 left by a SIGKILL'd Xvfb make the next start die
# with "_XSERVTransMakeAllCOTSServerListeners: server already running".
# Single rm wasn't catching every variant (verified live 2026-04-29);
# nuking the whole dir + re-creating with the right mode is bulletproof.
pkill -9 Xvfb 2>/dev/null || true
rm -rf /tmp/.X11-unix /tmp/.X99-lock 2>/dev/null || true
mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix && chown root:root /tmp/.X11-unix 2>/dev/null || true
# Empyrion writes build-stamped logs to Z:\Logs\<buildnum> (= /Logs/...
# because Wine maps Z:\ to /). Without this symlink panel gets an
# UnauthorizedAccessException on first boot. Pointing it into the save
# volume keeps logs persistent + visible in the Files tab.
mkdir -p /game-saves/EmpyrionLogs
chown -R panel:panel /game-saves/EmpyrionLogs
if [[ ! -e /Logs || -L /Logs ]]; then
ln -sfn /game-saves/EmpyrionLogs /Logs
fi
# Re-exec as `panel` user. setpriv comes with util-linux in debian-slim.
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
WINEPREFIX=/home/panel/.wine \
WINEDEBUG=-all \
SRV_PORT="${SRV_PORT:-30000}" \
EAC_PORT="${EAC_PORT:-30003}" \
CSW_PORT="${CSW_PORT:-30004}" \
EAH_API_PORT="${EAH_API_PORT:-30007}" \
SCENARIO="${SCENARIO:-}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
DEDI_DIR="${GAME_DIR}/DedicatedServer"
DEDI_EXE="${DEDI_DIR}/EmpyrionDedicated.exe"
CONFIG="${SAVE_DIR}/dedicated.yaml"
# Empyrion ships its default dedicated.yaml at the game root.
SHIPPED_CONFIG="${GAME_DIR}/dedicated.yaml"
# --- sanity: Windows build installed? ---
if [[ ! -f "${DEDI_EXE}" ]]; then
log "ERROR: ${DEDI_EXE} not found."
log " Game files haven't been downloaded yet."
log " Run 'Update' on this server in the panel — that launches a"
log " SteamCMD sidecar which installs Empyrion (Windows build) into"
log " this volume via +@sSteamCmdForcePlatformType windows."
exit 78 # EX_CONFIG
fi
# --- dedicated.yaml: copy shipped default on first run ---
if [[ ! -s "${CONFIG}" && -s "${SHIPPED_CONFIG}" ]]; then
log "no panel config found — seeding ${CONFIG} from ${SHIPPED_CONFIG}"
cp -f "${SHIPPED_CONFIG}" "${CONFIG}"
fi
if [[ ! -s "${CONFIG}" ]]; then
log "WARN: no dedicated.yaml found in game install. Empyrion will create"
log " one with defaults on first boot. Edit via the Files tab."
fi
# Symlink so the binary finds the config at its expected relative path.
# Empyrion reads dedicated.yaml from the game root (same dir as the .exe's
# -dedicated argument resolves).
ln -sf "${CONFIG}" "${GAME_DIR}/dedicated.yaml"
# --- patch dedicated.yaml ports from env on every boot ---
# Empyrion reads Srv_Port / EAC_Port / Tel_Port from dedicated.yaml. Without
# this, multiple instances on the same agent all bind 30000-30004 and only
# one is reachable. SRV_PORT comes from the panel allocator via the
# manifest's port→env mapping (`env: SRV_PORT` in module.yaml). yaml is
# whitespace-sensitive but these keys are a flat top-level "Key: NNN" so
# sed is safe.
if [[ -s "${CONFIG}" ]]; then
log "patching dedicated.yaml (Srv_Port=${SRV_PORT}, EAC_Port=${EAC_PORT})"
sed -i -E \
-e "s|^([[:space:]]*Srv_Port:[[:space:]]*)[0-9]+|\1${SRV_PORT}|" \
-e "s|^([[:space:]]*EAC_Port:[[:space:]]*)[0-9]+|\1${EAC_PORT}|" \
"${CONFIG}"
fi
# --- scenario selection ---
# SCENARIO env names a folder under /game/Content/Scenarios/. The empyrion-
# shipped default is "Default Random". Reforged Eden / Reforged Eden 2 /
# Project Eden need to be present in /game/Content/Scenarios/<name>/ first
# (drop via the panel's Files tab, or future workshop-subscribe feature).
# This block keeps dedicated.yaml's CustomScenario in sync with the env.
SCENARIO="${SCENARIO:-}"
if [[ -n "${SCENARIO}" && -s "${CONFIG}" ]]; then
if [[ ! -d "${GAME_DIR}/Content/Scenarios/${SCENARIO}" ]]; then
log "WARN: scenario folder '${SCENARIO}' not found at ${GAME_DIR}/Content/Scenarios/${SCENARIO}"
log " The panel UI Files tab can drop scenario contents there. For"
log " Reforged Eden 2 (Workshop ID 3041847672), unzip into:"
log " /game/Content/Scenarios/Reforged Eden 2/"
log " Booting with Default for now."
else
log "selecting scenario: ${SCENARIO}"
if grep -qE '^[[:space:]]*CustomScenario:' "${CONFIG}"; then
sed -i -E "s|^([[:space:]]*CustomScenario:[[:space:]]*).*|\1${SCENARIO}|" "${CONFIG}"
else
# Insert under GameConfig: at the top, ahead of dedicated game block.
printf '\nGameConfig:\n CustomScenario: %s\n' "${SCENARIO}" >> "${CONFIG}"
fi
fi
fi
# --- stage EPM mod (TCP relay for Eleon Mod API) on first boot ---
# Pre-staged at /opt/panel-epm in the image. Copy into the live game
# Content/Mods/EPM/ if it isn't there yet. This makes the panel-empyrion-
# bridge sidecar work out-of-the-box: every new empyrion instance comes
# with EPM listening on EAH_API_PORT.
EPM_SRC=/opt/panel-epm
EPM_DST="${GAME_DIR}/Content/Mods/EPM"
if [[ -d "${EPM_SRC}" && ! -f "${EPM_DST}/EmpyrionNetworkRelayMod.dll" ]]; then
log "staging EPM mod into ${EPM_DST}"
mkdir -p "${EPM_DST}"
cp -a "${EPM_SRC}/." "${EPM_DST}/"
# If a panel-allocated EAH_API_PORT is set, patch EPM's Settings.yaml
# so its TCP listener binds the right port for this instance.
if [[ -n "${EAH_API_PORT:-}" && -f "${EPM_DST}/Settings.yaml" ]]; then
sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \
"${EPM_DST}/Settings.yaml"
fi
fi
# Re-patch port even if EPM was already staged, in case panel reallocated.
if [[ -f "${EPM_DST}/Settings.yaml" && -n "${EAH_API_PORT:-}" ]]; then
sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \
"${EPM_DST}/Settings.yaml"
fi
# --- prep saves dir (Empyrion writes under /game/Saves by default) ---
# Redirect Saves into the save volume via a symlink so steam validate
# can't clobber world data.
mkdir -p "${SAVE_DIR}/Saves"
if [[ -d "${GAME_DIR}/Saves" && ! -L "${GAME_DIR}/Saves" ]]; then
# Steam shipped an empty Saves dir; replace with symlink.
rm -rf "${GAME_DIR}/Saves"
fi
ln -sfn "${SAVE_DIR}/Saves" "${GAME_DIR}/Saves"
# --- Wine + Xvfb via xvfb-run (auto-allocates a free display) ---
#
# We previously hand-started Xvfb on :99. That broke once the host had
# its OWN Xvfb on :99 — the empyrion container uses network_mode: host,
# which shares the network namespace, and X server abstract Unix sockets
# (`@/tmp/.X11-unix/X99`) are namespaced by network ns. Result: container
# Xvfb fails with "_XSERVTransMakeAllCOTSServerListeners: server already
# running" and Wine then dies "Authorization required, but no auth
# protocol specified" → Unity exits "Failed to create batch mode window:
# Success." (verified 2026-04-29).
#
# `xvfb-run -a` finds a free display number, starts Xvfb with proper
# xauth set up, exports DISPLAY to its child, and cleans up on exit.
# Bulletproof against host's existing Xvfb instances.
# --- Wine prefix bootstrap (first boot only). xvfb-run handles the X
# server for the actual run; for prefix init we don't strictly need
# X but wineboot complains less when one is available. ---
if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then
log "initializing Wine prefix at ${WINEPREFIX}"
xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true
wineserver -w || true
fi
# --- go ---
cd "${GAME_DIR}"
log "starting Empyrion Dedicated Server"
log " game dir: ${GAME_DIR}"
log " save dir: ${SAVE_DIR}"
log " config: ${CONFIG}"
log " binary: ${DEDI_EXE}"
# Shutdown handler: forward SIGTERM (xvfb-run forwards to wine; wine's
# Unity hook persists state on quit).
trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; exit 0' TERM INT
# -batchmode / -nographics make Unity skip the renderer; -logFile - pipes
# the Unity log to our stdout so the panel console sees everything.
# `xvfb-run -a` picks a free display, sets up xauth, and exports DISPLAY.
# `--server-args="-screen 0 1024x768x24"` matches our previous Xvfb args.
exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \
wine "${DEDI_EXE}" \
-batchmode \
-nographics \
-logFile - \
-dedicated dedicated.yaml \
-startDedicated
@@ -0,0 +1,8 @@
# Empyrion mod manifest. Tells Empyrion to load this mod into the dedicated
# server process (Game_Start hook), where it opens a TCP listener on the
# port configured in Settings.yaml — 7028 in our case (panel-allocated).
Name: EPM
Description: Empyrion Network Relay - exposes Mod API over TCP for panel/EAH/etc
Author: W3DG (open-source via MichaelGoulding/sample-empyrion-mod)
Version: 1.0
ModTargets: Dedi
+43
View File
@@ -0,0 +1,43 @@
# EPM mod artifacts
Drop-in `Content/Mods/EPM/` for Empyrion. Loaded by the dedicated server's
mod loader; opens a TCP listener on `Settings.yaml.GameServerApiPort` (we
ship `0.0.0.0:7028`, panel-allocated to whatever `EAH_API_PORT` env says).
## Files
- `EmpyrionNetworkRelayMod.dll` — the mod (`ModTargets: Dedi`). Compiled
from `EmpyrionNetworkConnectedMods/sample-empyrion-mod/Empyrion Mod/`
with one tweak: `Configuration.cs` was rewritten to manually parse
`Settings.yaml` (key: value lines) instead of using `YamlDotNet`. Reason:
current Empyrion 1.16.x ships `YamlAssembly.dll` rather than the old
`Assembly-CSharp-firstpass.dll` that bundled `YamlDotNet`, so the
upstream binary fails with `TypeLoadException` at `Game_Start`.
- `EPMConnector.dll` — wire protocol library (TCP framing + protobuf-net
message dispatch). Linked by both EPM and any C# client.
- `EmpyrionNetworkRelayMod_Info.yaml` — manifest. Per-DLL naming is
required because there are two DLLs in the folder; with a single DLL,
`Info.yaml` works, but with multiple Empyrion's loader needs each main
DLL to have a sibling `<DllName>_Info.yaml`.
- `Settings.yaml` — host+port for the TCP listener. `0.0.0.0` is fine
inside the container (host networking limits exposure to the container
port; opnfwd handles WAN forwarding).
## Verified end-to-end on 2026-04-30
1. EPM loads under Wine: `Loaded Mod 'EmpyrionNetworkRelayMod': "EPM"`.
2. TCP listener opens: `Now listening on port 7028`.
3. C# probe (`/eah_probe/probe/Probe.cs`) connects via `EPMConnector.Client`
and gets typed protobuf-net responses for `Request_Get_Factions`,
`Request_Player_List`, `Request_Playfield_List`. Default Human
faction came back deserialized into `Eleon.Modding.FactionInfoList`.
## How to rebuild
1. Open `C:\Users\dbledeez\AppData\Local\Temp\eah_probe\EmpyrionNetworkConnectedMods\sample-empyrion-mod\Empyrion Mod\` (or re-clone from `MichaelGoulding/EmpyrionNetworkConnectedMods`).
2. Replace `Configuration.cs` with the YAML-free version (see
`feedback_empyrion_yaml_dotnet_drop.md` for the snippet).
3. `cd` to that folder and `csc.exe @build.rsp`. Output goes to
`bin\Release\EmpyrionNetworkRelayMod.dll`.
4. Copy `EmpyrionNetworkRelayMod.dll` + `EPMConnector.dll` + the two yamls
into this directory; commit.
+3
View File
@@ -0,0 +1,3 @@
---
GameServerIp: "0.0.0.0"
GameServerApiPort: 7028
+152
View File
@@ -0,0 +1,152 @@
# Empyrion — Galactic Survival dedicated server module.
#
# Same panel-native pattern as 7dtd: a minimal image we own
# (panel-empyrion:latest, Debian 12 + Wine + tini), with game files
# populated by the generic SteamCMD updater sidecar into a named volume.
#
# Empyrion's dedicated server is shipped as a Windows binary only, so the
# updater is told to pretend SteamCMD is running on Windows
# (`platform: windows` → +@sSteamCmdForcePlatformType windows), and the
# entrypoint runs the exe under Wine under Xvfb (Unity server still touches
# X even in -batchmode).
#
# First-run flow:
# - Create instance → container spec + empty volumes
# - Click "Update" → SteamCMD downloads ~3.5 GB Windows build
# - Click "Start" → entrypoint boots Wine + EmpyrionDedicated.exe
#
# Notes for operators:
# - The game server ports must be UDP; Empyrion uses a handful of them.
# - Main config file is dedicated.yaml, lives under /game-saves after
# first boot (entrypoint copies the shipped default on first run).
# - No in-game RCON: Empyrion's admin console is in-process only. The
# "Empyrion Web Admin" (EWA) third-party tool can be layered on top
# later via a plugin; not wired for v1.
id: empyrion
name: "Empyrion - Galactic Survival"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-empyrion:latest modules/empyrion
image: panel-empyrion:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Saves/, dedicated.yaml, admin files"
- name: "Game Files"
path: /game
hint: "Binaries, Content/, Mods/ — drop mod folders here"
# Pre-declare the port env keys so the agent's resolve filter lets the
# panel-allocated values flow through to the container env (resolve.go
# only overrides keys already present in docker.env). Defaults match
# the manifest port defaults below; agent overrides at create time.
env:
SRV_PORT: "30000"
QUERY_PORT: "30001"
CLIENT_PORT: "30002"
EAC_PORT: "30003"
CSW_PORT: "30004"
EAH_API_PORT: "30007" # Empyrion's Mod API TCP port (used by EAH/EPM and our future bridge)
# SCENARIO is declared empty here so the agent's resolver passes the
# config_values.SCENARIO override into the container env. Without
# this pre-declaration, resolve.go would silently drop the value
# (it only overrides keys already in docker.env), the entrypoint
# would see SCENARIO="" and skip the dedicated.yaml patch, and
# Empyrion would fall back to "Default Multiplayer" no matter what
# the operator picked in the dropdown.
SCENARIO: ""
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Empyrion port reference:
# Srv_Port (UDP) — main game traffic default 30000
# Srv_Port+1 (UDP) — Steam query default 30001
# Srv_Port+2 (UDP) — client connect default 30002
# EAC_Port (UDP) — EasyAntiCheat default 30003
# CSWApi_Port (TCP) — CSW API (remote admin) default 30004 (internal)
ports:
- { name: game, proto: udp, default: 30000, required: true, env: SRV_PORT }
- { name: query, proto: udp, default: 30001, required: true, env: QUERY_PORT } # Empyrion derives query from Srv_Port + 1; we still allocate it for tracking
- { name: client, proto: udp, default: 30002, required: true, env: CLIENT_PORT } # Srv_Port + 2 by convention
- { name: eac, proto: udp, default: 30003, required: true, env: EAC_PORT }
- { name: csw, proto: tcp, default: 30004, internal: true, env: CSW_PORT }
# Empyrion's native Mod API TCP port. EPM mod listens on this; EAH GUI
# and our future panel-empyrion-bridge will connect here. Public so
# remote EAH GUIs work; auth model not yet validated, see
# panel/memory/research_eah_integration.md before exposing externally.
- { name: eah-api, proto: tcp, default: 30007, required: true, env: EAH_API_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# Operator-tunable knobs. Panel renders these as a config form; the entrypoint
# reads them from env on every boot.
config_values:
- key: SCENARIO
label: "Scenario"
description: "Folder name under Content/Scenarios/. Leave blank for game default. Reforged Eden 2 needs the workshop content (ID 3041847672) dropped into Content/Scenarios/Reforged Eden 2/ first — use the Files tab."
default: ""
options:
- { value: "", label: "Default Random (vanilla)" }
- { value: "Reforged Eden 2", label: "Reforged Eden 2 (workshop)" }
- { value: "Reforged Eden", label: "Reforged Eden (legacy)" }
- { value: "Default Akua", label: "Default Akua (vanilla)" }
- { value: "Default Multiplayer Survival", label: "Default Multiplayer Survival" }
- key: EAH_API_PORT
label: "EAH/Mod API port"
description: "TCP port the EPM mod listens on for the panel-empyrion-bridge sidecar. Auto-allocated by panel; rarely needs manual override."
default: ""
advanced: true
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (empyrion-galactic-survival.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '-LOG-.*Player (?P<id>.+?)/''(?P<name>[^'']+)'' login ok'
kind: join
leave:
pattern: '-LOG-.*, (?P<id>.+?)/=/''(?P<name>[^'']+)'' disconnected$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "530870"
platform: windows # force Windows depot on Linux hosts — server is Windows-only
install_path: /game
- id: experimental
kind: steamcmd
app_id: "530870"
beta: experimental
platform: windows
install_path: /game
# --- 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: '/Server started listening on port|Dedi is ready|Ready for connections/i'
appearance:
emoji: "🚀"
grad: "linear-gradient(135deg,#0b2545,#5c7cfa)"
art: "/game-art/empyrion.jpg"
steam: "383120"
+74
View File
@@ -0,0 +1,74 @@
# panel-native Enshrouded runtime image.
#
# Enshrouded's dedicated server is WINDOWS-ONLY (SteamCMD app 2278520 has no
# Linux depot — "Invalid platform"; AMP runs the exe under Proton GE). We run
# it under Wine + Xvfb, same pattern as the verified windrose module.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed Windows build (panel volume)
# /game/enshrouded_server.exe — dedicated server binary
# /game-saves — enshrouded_server.json + savegame/ + logs/
# /entrypoint.sh — boot Xvfb, launch exe under Wine
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
# winehq-stable (Wine 9+) rather than Debian's wine 8.0~repack — matches the
# windrose module's rationale (IOCP fixes, and what CubeCoders' AMP wine
# images ship). cabextract + winbind are commonly needed by Wine helpers.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
gnupg \
xvfb \
xauth \
winbind \
cabextract \
procps \
&& mkdir -pm755 /etc/apt/keyrings \
&& curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \
| gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key \
&& curl -fsSL https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources \
-o /etc/apt/sources.list.d/winehq-bookworm.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends winehq-stable \
&& 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/*
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 /home/panel
# Pre-warm the Wine prefix during the image build (skip Mono/Gecko — the
# server doesn't need .NET/HTML). See windrose Dockerfile for the full
# rationale (interrupted first-boot wineboot leaves a broken prefix that
# crash-loops with kernel32 c0000135).
USER panel
ENV HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all
RUN Xvfb :99 -screen 0 1024x768x24 -nolisten tcp & XVFB_PID=$! \
&& sleep 2 \
&& DISPLAY=:99 WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init \
&& wineserver -w \
&& kill "$XVFB_PID" 2>/dev/null || true
USER root
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 15637/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# panel-native Enshrouded entrypoint (Windows server under Wine + Xvfb).
set -euo pipefail
log() { printf '[panel-enshrouded] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap, then drop to panel (Wine refuses root) -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/home/panel \
WINEPREFIX=/home/panel/.wine \
WINEDEBUG=-all \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel Enshrouded}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-letmein}" \
MAX_PLAYERS="${MAX_PLAYERS:-16}" \
QUERY_PORT="${QUERY_PORT:-15637}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/enshrouded_server.exe"
CONFIG="${SAVE_DIR}/enshrouded_server.json"
if [[ ! -f "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel — that launches a"
log " SteamCMD sidecar which installs the Windows build (app 2278520,"
log " +@sSteamCmdForcePlatformType windows) into this volume."
exit 78 # EX_CONFIG
fi
mkdir -p "${SAVE_DIR}/savegame" "${SAVE_DIR}/logs"
# Seed enshrouded_server.json on first boot. The Steam depot does not ship a
# config (the server generates one), so we write our own — deterministic, and
# the env->json patching below always has keys to patch. Paths are relative
# to the exe's directory; we symlink savegame/ + logs/ into the save volume.
if [[ ! -s "${CONFIG}" ]]; then
log "seeding default ${CONFIG}"
cat > "${CONFIG}" <<'EOF'
{
"name": "panel Enshrouded",
"password": "",
"saveDirectory": "./savegame",
"logDirectory": "./logs",
"ip": "0.0.0.0",
"queryPort": 15637,
"slotCount": 16
}
EOF
fi
# Basic env -> json patching. Enshrouded config is small + flat so a few
# seds are fine without pulling jq in.
sed -i "s|\"name\":\\s*\"[^\"]*\"|\"name\": \"${SERVER_NAME}\"|" "${CONFIG}"
sed -i "s|\"password\":\\s*\"[^\"]*\"|\"password\": \"${SERVER_PASSWORD}\"|" "${CONFIG}"
sed -i "s|\"slotCount\":\\s*[0-9]*|\"slotCount\": ${MAX_PLAYERS}|" "${CONFIG}"
# Panel-allocated port. Modern Enshrouded has a single UDP port (queryPort);
# gamePort no longer exists in the config schema.
sed -i "s|\"queryPort\":\\s*[0-9]*|\"queryPort\": ${QUERY_PORT}|" "${CONFIG}"
# Config + save/log dirs live in the save volume; symlink them next to the
# exe (the server resolves config + relative paths from its own directory).
ln -sf "${CONFIG}" "${GAME_DIR}/enshrouded_server.json"
for d in savegame logs; do
if [[ -d "${GAME_DIR}/${d}" && ! -L "${GAME_DIR}/${d}" ]]; then rm -rf "${GAME_DIR}/${d}"; fi
ln -sfn "${SAVE_DIR}/${d}" "${GAME_DIR}/${d}"
done
# --- Wine prefix bootstrap / repair (image pre-warms it; repair if broken) ---
if [[ ! -f "${WINEPREFIX}/system.reg" || ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then
log "initializing Wine prefix at ${WINEPREFIX}"
rm -rf "${WINEPREFIX}"
xvfb-run -a env WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init 2>&1 | sed 's/^/ /' || true
wineserver -w || true
fi
cd "${GAME_DIR}"
log "starting Enshrouded dedicated server (Wine)"
log " name: ${SERVER_NAME}"
log " slots: ${MAX_PLAYERS}"
log " port: ${QUERY_PORT}/udp"
# The server writes its real log to ./logs/enshrouded_server.log; mirror it
# to stdout so the panel's log follower + ready_pattern see it even if the
# Wine console output is sparse. tail -F copes with the file not existing yet.
tail -n +1 -F "${SAVE_DIR}/logs/enshrouded_server.log" 2>/dev/null &
TAIL_PID=$!
trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; kill "${TAIL_PID:-}" 2>/dev/null || true; exit 0' TERM INT
exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \
wine "${EXE}"
+101
View File
@@ -0,0 +1,101 @@
# Enshrouded — panel-native dedicated server module.
#
# The dedicated server is WINDOWS-ONLY (SteamCMD app 2278520 — no Linux
# depot; a plain Linux install fails with "Invalid platform", verified
# 2026-07-14). An earlier scaffold claimed "native Linux" — wrong. AMP runs
# the exe under Proton GE; we run it under Wine + Xvfb like windrose/empyrion
# (update provider `platform: windows`).
# No traditional RCON — server console is stdin-only.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (enshrouded.kvp)
id: enshrouded
name: "Enshrouded"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-enshrouded:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "enshrouded_server.json + savegame/"
- name: "Game Files"
path: /game
hint: "Server binaries"
env:
SERVER_NAME: "panel Enshrouded"
SERVER_PASSWORD: "letmein"
MAX_PLAYERS: "16"
# Pre-declared so the agent's resolve.go filter lets the
# panel-allocated port flow through to the container env (same
# pattern as soulmask). Modern Enshrouded (post "Melodies of the
# Mire", 2024) has a SINGLE UDP port — queryPort in
# enshrouded_server.json; gamePort was removed from the config.
QUERY_PORT: "15637"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
# Single UDP port — current Enshrouded merged game+query into one
# "queryPort" (AMP's enshroudedports.json declares only QueryPort).
- { name: query, proto: udp, default: 15637, required: true, env: QUERY_PORT }
resources:
min_ram_mb: 8192
recommended_ram_mb: 16384 # Enshrouded is memory-hungry
rcon:
# Enshrouded admin is stdin-only (no network RCON in-game).
adapter: stdio
commands:
save: "save"
shutdown: "shutdown"
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (enshrouded.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '\[server\] Player ''(?P<name>[^'']+)'' logged in with Permissions:'
kind: join
leave:
pattern: '\[server\] Remove Player ''(?P<name>[^'']+)''$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "2278520"
install_path: /game
platform: windows # server is Windows-only; no Linux depot
# --- 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.
# AMP's Console.AppReadyRegex: ^.*\[Session\] 'HostOnline' \(up\)!$
# VERIFIED live 2026-07-14 (agent01 smoke test): the exact ready line is
# [Session] 'HostOnline' (up)!
# Install 8.3 GB on disk; boot start->HostOnline ~25 s (warm Wine prefix);
# external A2S_INFO on the single UDP port answered off-host.
ready_pattern: "/\\[Session\\] 'HostOnline' \\(up\\)!/i"
appearance:
emoji: "🌫️"
grad: "linear-gradient(135deg,#4a2c2a,#a45e4a)"
art: "/game-art/enshrouded.jpg"
steam: "1203620"
+31
View File
@@ -0,0 +1,31 @@
# panel-native Factorio runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
xz-utils \
curl \
&& 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/*
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
EXPOSE 34197/udp 27015/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# panel-native Factorio entrypoint.
set -euo pipefail
log() { printf '[panel-factorio] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \
GAME_PORT="${GAME_PORT:-34197}" \
RCON_PORT="${RCON_PORT:-27015}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
# factorio.com's headless tarball extracts into a `factorio/` subdir. Our
# updater unpacks into /game, so we look for both.
BIN="${GAME_DIR}/factorio/bin/x64/factorio"
if [[ ! -x "${BIN}" ]]; then
BIN="${GAME_DIR}/bin/x64/factorio"
fi
SAVE="${SAVE_DIR}/saves/default.zip"
if [[ ! -x "${BIN}" ]]; then
log "ERROR: Factorio binary not found at /game/{factorio/,}bin/x64/factorio."
log " Run 'Update' in the panel to install Factorio (Direct provider)."
exit 78
fi
mkdir -p "${SAVE_DIR}/saves" "${SAVE_DIR}/mods" "${SAVE_DIR}/config" "${SAVE_DIR}/logs"
# Seed server-settings.json from Factorio's shipped example on first boot.
# Without this Factorio fails fast: "Hosting multiplayer game failed:
# filesystem error: file_size(p): unknown error: No such file or directory
# [/game-saves/config/server-settings.json]" — observed on smoke test.
# The example tree lives next to the binary at <bin>/../../data/.
DATA_DIR="$(dirname "${BIN}")/../../data"
if [[ ! -f "${SAVE_DIR}/config/server-settings.json" && -f "${DATA_DIR}/server-settings.example.json" ]]; then
log "seeding server-settings.json from example"
cp "${DATA_DIR}/server-settings.example.json" "${SAVE_DIR}/config/server-settings.json"
fi
# map-gen-settings + map-settings aren't required for boot, but having the
# examples in place means operators can edit them via the Files tab without
# having to dig into the binary tree.
for f in map-gen-settings.example.json map-settings.example.json; do
base="${f%.example.json}.json"
if [[ ! -f "${SAVE_DIR}/config/${base}" && -f "${DATA_DIR}/${f}" ]]; then
cp "${DATA_DIR}/${f}" "${SAVE_DIR}/config/${base}"
fi
done
# Create a default world on first boot — Factorio can't autostart from nothing.
if [[ ! -s "${SAVE}" ]]; then
log "no existing save — creating default map"
"${BIN}" --create "${SAVE}"
fi
cd "$(dirname "${BIN}")/../.."
GAME_PORT="${GAME_PORT:-34197}"
RCON_PORT="${RCON_PORT:-27015}"
log "starting Factorio headless server"
log " save: ${SAVE}"
log " settings: ${SAVE_DIR}/config/server-settings.json"
log " game port: ${GAME_PORT}/udp"
log " rcon port: ${RCON_PORT}/tcp"
exec stdbuf -oL -eL "${BIN}" \
--start-server "${SAVE}" \
--server-settings "${SAVE_DIR}/config/server-settings.json" \
--port "${GAME_PORT}" \
--rcon-port "${RCON_PORT}" \
--rcon-password "${RCON_PASSWORD}" \
--mod-directory "${SAVE_DIR}/mods" \
--console-log "${SAVE_DIR}/logs/factorio.log"
+125
View File
@@ -0,0 +1,125 @@
# Factorio — panel-native dedicated server module.
#
# Native Linux binary. Factorio has two install sources: SteamCMD app 427520
# (only if you own Factorio on Steam) OR direct download from factorio.com.
# We go direct: no Steam auth needed, and headless versions ship unzipped
# tar.xz archives. Users can swap to SteamCMD if they prefer.
#
# RCON: Factorio ships a source-compatible RCON server (same wire protocol
# as Valve's). Enabled via --rcon-port + --rcon-password flags.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (Factorio.kvp)
id: factorio
name: "Factorio"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-factorio:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Save games + server-settings.json + mod-list.json"
- name: "Game Files"
path: /game
hint: "Binaries, mods/, data/"
env:
RCON_PASSWORD: "panel_rcon"
FACTORIO_VERSION: "stable" # "stable" or "latest" (experimental)
# Pre-declared so the agent's resolve.go filter lets the panel-allocated
# port through to the container env (only keys ALREADY in docker.env get
# overridden by config_values).
GAME_PORT: "34197"
RCON_PORT: "27015"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 34197, required: true, env: GAME_PORT }
- { name: rcon, proto: tcp, default: 27015, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 1024
recommended_ram_mb: 4096 # mods + big bases
# Per-instance generated RCON secret. The agent generates a random
# 24-byte token at create time (pkg/module GenerateSecrets), which
# overrides the env default for RCON_PASSWORD below — so every instance
# gets a unique password. The weak env default only applies to
# containers launched outside the panel.
secrets:
- name: RCON_PASSWORD
description: "Factorio RCON password — generated per instance"
generated: true
rcon:
adapter: source_rcon
host_port: rcon
auth: source_rcon_login
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated RCON_PASSWORD secret above.
password_literal: "panel_rcon"
commands:
list: "/players online"
save: "/server-save"
say: "{msg}" # Factorio treats non-/ input as chat to all players
kick: "/kick {player} {reason}"
ban: "/ban {player} {reason}"
state_sources:
- type: rcon
command: "/players online count"
every: 60s
parse:
kind: regex
pattern: 'Online players \((?P<count>\d+)\)'
fields:
players_online: count
events:
join:
pattern: "\\[JOIN\\] (?P<name>\\S+) joined the game"
kind: join
leave:
pattern: "\\[LEAVE\\] (?P<name>\\S+) left the game"
kind: leave
chat:
pattern: "\\[CHAT\\] (?P<name>\\S+): (?P<msg>.*)"
kind: chat
update_providers:
- id: stable
kind: direct
url: "https://factorio.com/get-download/stable/headless/linux64"
target_path: /game
- id: experimental
kind: direct
url: "https://factorio.com/get-download/latest/headless/linux64"
target_path: /game
# --- 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: '/Hosting game at IP ADDR|changing state from\(CreatingGame\) to\(InGame\)/i'
appearance:
emoji: "🏭"
grad: "linear-gradient(135deg,#8c6239,#d4a373)"
art: "/game-art/factorio.jpg"
steam: "427520"
+142
View File
@@ -0,0 +1,142 @@
# Minecraft Bedrock Edition module.
#
# itzg/minecraft-bedrock-server is the de-facto community image for the
# Mojang-shipped Linux Bedrock binary. It auto-downloads the binary on
# first container start (license-accepted via EULA=TRUE) and re-pulls on
# version bumps via the VERSION env var.
#
# Bedrock is the iOS / Android / Console / Win10-Edition flavor of
# Minecraft. It speaks RakNet over UDP — TOTALLY DIFFERENT wire protocol
# from Java Edition. Bedrock clients cannot connect to Java servers and
# vice-versa. Run BOTH if your players are mixed-platform; or use the
# GeyserMC translation proxy (out of scope for this module).
#
# No RCON — Bedrock servers don't ship a remote-admin protocol. Admin
# commands go through the in-process console only. Players tab will be
# empty; state tracker has nothing to poll.
#
# Verified scaffolding pattern:
# - same `auto_update_on_create: false` + placeholder update_providers
# combination as minecraft-java (see panel/memory/gotchas.md
# "image-managed module" entry).
# - `env: SERVER_PORT` / `env: SERVER_PORT_V6` on each port wires the
# panel-allocated host ports through to the container env, then
# itzg's entrypoint passes them to the binary. Combined with
# `network_mode: host`, two Bedrock instances on the same agent
# allocate unique ports and bind them directly.
id: minecraft-bedrock
name: "Minecraft Bedrock Edition"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: itzg/minecraft-bedrock-server:latest
# Host networking — Bedrock's UDP RakNet protocol relies on the
# client speaking directly to the server's published port. Port
# publishing through Docker's bridge would NAT every Bedrock packet,
# which RakNet doesn't tolerate well. AMP-like host-net model keeps
# the packets clean. Multi-instance safety is the panel allocator
# picking distinct SERVER_PORT values per instance.
network_mode: host
browseable_root: /data
browseable_roots:
- name: "Server data"
path: /data
hint: "worlds/, allowlist.json, permissions.json, server.properties"
env:
EULA: "TRUE"
SERVER_NAME: "Refuge Bedrock"
GAMEMODE: "survival" # survival | creative | adventure
DIFFICULTY: "easy" # peaceful | easy | normal | hard
LEVEL_NAME: "level"
LEVEL_TYPE: "DEFAULT" # DEFAULT | FLAT | LEGACY
MAX_PLAYERS: "10"
ONLINE_MODE: "true"
ALLOW_CHEATS: "false"
VIEW_DISTANCE: "10"
TICK_DISTANCE: "4"
DEFAULT_PLAYER_PERMISSION_LEVEL: "member"
TEXTUREPACK_REQUIRED: "false"
VERSION: "LATEST"
# Pre-declared so the agent's resolve.go filter lets allocator-derived
# ports flow through to the container env. itzg honors both SERVER_PORT
# (IPv4) and SERVER_PORT_V6 (IPv6) — defaults match the manifest port
# defaults below; agent overrides at create time.
SERVER_PORT: "19132"
SERVER_PORT_V6: "19133"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-bedrock", container: "/data" }
- { target: "$DATA_PATH/logs", container: "/data/logs" }
# Bedrock binds two UDP ports — IPv4 and IPv6. Both are required for
# proper dual-stack listing in Bedrock's server browser. itzg defaults
# 19132 / 19133. The panel allocator picks unique ports from the agent's
# window (e.g. 7000-7999 on princess); the agent injects them as
# SERVER_PORT / SERVER_PORT_V6 env at container-create time.
ports:
- { name: game, proto: udp, default: 19132, required: true, env: SERVER_PORT }
- { name: game-v6, proto: udp, default: 19133, required: true, env: SERVER_PORT_V6 }
resources:
min_ram_mb: 1024
recommended_ram_mb: 2048
# itzg/minecraft-bedrock-server handles install + version pinning via the
# VERSION env var on first container start — no SteamCMD / GitHub / direct
# flow the panel needs to drive. Same shape as minecraft-java: keep a
# placeholder update_providers entry to satisfy the manifest validator,
# pair with auto_update_on_create:false so the agent doesn't actually run
# it (which would crash on the missing target_path — see
# panel/memory/gotchas.md "image-managed module" pattern).
auto_update_on_create: false
update_providers:
- id: image-managed
kind: direct
url: "https://hub.docker.com/r/itzg/minecraft-bedrock-server"
target_path: ".panel-image-managed-marker" # never written; auto-update is suppressed above
# itzg's bedrock image streams the server log to stdout. The format depends
# on the bundled binary's version — in 1.21+ the ready signal is
# "Server started." and join/leave use "Player connected/disconnected".
# Older builds use slightly different phrasing. We grep the broad shape;
# tighten once we observe the live format.
state_sources:
- type: log_tail
files:
- "$DATA_PATH/logs/*.log"
- "/data/logs/*.log"
events:
join:
pattern: 'Player connected: (?P<name>[^,]+), xuid: (?P<xuid>\S+)'
kind: join
leave:
pattern: 'Player disconnected: (?P<name>[^,]+), xuid: (?P<xuid>\S+)'
kind: leave
# Backup what's recoverable; everything else (binary, libs) is fetched
# fresh on next start.
backup:
include:
- "worlds" # world saves (one dir per LEVEL_NAME)
- "*.properties" # server.properties
- "*.json" # permissions.json, allowlist.json, etc.
exclude:
- "logs" # rotates infinitely
- "premium_cache" # auto-restored
- "behavior_packs" # auto-fetched (mods landing here are operator-managed via Files tab)
- "resource_packs" # same
notes: "World saves + permissions + server.properties (~10-100 MB typical). Bedrock binary fetched fresh by itzg on next start."
# --- 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.
appearance:
emoji: "🧱"
grad: "linear-gradient(135deg,#2d6929,#5a9d56)"
+140
View File
@@ -0,0 +1,140 @@
# Minecraft Java Edition module.
#
# Uses the itzg/minecraft-server community image — widely deployed, actively
# maintained, designed for panel/automation use. All configuration is driven
# by env vars (unlike 7DTD, which fights us). Boots in ~60-90s on first run
# (downloads the server jar) and ~10-15s on subsequent runs.
#
# RCON uses Source RCON (binary protocol), which our agent doesn't yet have
# an adapter for — so this module declares the RCON port/password for future
# use but leaves the `rcon:` section out for now. Adding a source_rcon adapter
# is ~200 lines in agent/internal/rcon/ when we get to it.
id: minecraft-java
name: "Minecraft Java Edition"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: itzg/minecraft-server:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
env:
EULA: "TRUE"
TYPE: "VANILLA"
VERSION: "LATEST"
MEMORY: "2G"
ENABLE_RCON: "true"
RCON_PASSWORD: "panel_rcon" # default only — a generated per-instance secret overrides this at create
MOTD: "Running on panel"
ONLINE_MODE: "TRUE"
DIFFICULTY: "normal"
VIEW_DISTANCE: "10"
# Pre-declared so the agent's resolve.go filter lets allocator-derived
# ports flow through to the container env. itzg honors both SERVER_PORT
# and RCON_PORT — defaults match the manifest port defaults below.
SERVER_PORT: "25565"
RCON_PORT: "25575"
volumes:
# /data holds the whole server including worlds, mods, configs.
# Named volume for speed; survives instance recreation unless operator
# explicitly removes it.
- { type: volume, name: "panel-$INSTANCE_ID-mc", container: "/data" }
ports:
- { name: game, proto: tcp, default: 25565, required: true, env: SERVER_PORT }
- { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT }
# Per-instance generated RCON secret — overrides the env default below
# at create so every instance gets a unique password.
secrets:
- name: RCON_PASSWORD
description: "Minecraft RCON password — generated per instance"
generated: true
rcon:
adapter: source_rcon
host_port: rcon
auth: source_rcon_login
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated RCON_PASSWORD secret above.
password_literal: "panel_rcon"
commands:
list: "list"
say: "say {msg}"
kick: "kick {player} {reason}"
stop: "stop"
save: "save-all"
state_sources:
- type: rcon
command: "list"
every: 60s
parse:
kind: regex
pattern: 'There are (?P<players>\d+) of a max of (?P<max>\d+) players online'
fields:
players_online: players
players_max: max
resources:
min_ram_mb: 2048
recommended_ram_mb: 4096
# /data has world saves mixed with downloadable libraries/, mods/ cache,
# and the server jar. Back up the player-state stuff; libraries +
# server jar regenerate from itzg's image on next start.
backup:
include:
- "world" # default world dir (overworld)
- "world_nether" # nether
- "world_the_end" # end
- "*.properties" # server.properties + others
- "*.json" # whitelist, ops, banned-players, banned-ips, usercache
- "plugins" # bukkit/spigot plugins (config + state inside each)
- "mods" # Forge/Fabric mod jars (mod configs in plugins/)
- "config" # mod config files
exclude:
- "logs" # rotates infinitely
- "crash-reports" # never useful in restore
- "libraries" # auto-downloaded by itzg
- "cache" # JVM/itzg fluff
- "tmp"
notes: "World data + plugins + mods + configs (~50-500 MB typical). Server jar + libraries fetched fresh by itzg on next start."
# itzg/minecraft-server handles install + version pinning via the VERSION env
# var on first container start — there's no SteamCMD / GitHub / direct flow
# the panel needs to drive.
#
# But: the manifest validator REQUIRES at least one update_provider. So we
# keep a placeholder. Without `auto_update_on_create: false` the agent kicks
# off this provider on first boot and direct.go errors out on the missing
# target_path — observed 2026-04-29. The combination here (placeholder +
# auto-update opt-out) is the right shape for any image-managed module.
auto_update_on_create: false
update_providers:
- id: image-managed
kind: direct
url: "https://hub.docker.com/r/itzg/minecraft-server"
target_path: ".panel-image-managed-marker" # never written; auto-update is suppressed above
events:
join:
pattern: "(?P<name>[A-Za-z0-9_]+) joined the game"
kind: join
leave:
pattern: "(?P<name>[A-Za-z0-9_]+) left the game"
kind: leave
chat:
pattern: "<(?P<name>[A-Za-z0-9_]+)> (?P<msg>.*)"
kind: chat
# --- 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: 'Done \([\d.]+s\)! For help'
appearance:
emoji: "🧱"
grad: "linear-gradient(135deg,#4c8b32,#84ce5c)"
art: "/game-art/minecraft-java.png"
+33
View File
@@ -0,0 +1,33 @@
# panel-native Palworld runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
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 \
&& 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/*
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
EXPOSE 8211/udp 27015/udp 25575/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# panel-native Palworld entrypoint.
set -euo pipefail
log() { printf '[panel-palworld] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel Palworld}" \
SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-A Palworld server}" \
MAX_PLAYERS="${MAX_PLAYERS:-32}" \
RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \
GAME_PORT="${GAME_PORT:-8211}" \
QUERY_PORT="${QUERY_PORT:-27015}" \
RCON_PORT="${RCON_PORT:-25575}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
PAL_SH="${GAME_DIR}/PalServer.sh"
SETTINGS_DIR="${GAME_DIR}/Pal/Saved/Config/LinuxServer"
SETTINGS="${SETTINGS_DIR}/PalWorldSettings.ini"
DEFAULT_SETTINGS="${GAME_DIR}/DefaultPalWorldSettings.ini"
if [[ ! -x "${PAL_SH}" ]]; then
log "ERROR: ${PAL_SH} not found. Run 'Update' in the panel to install Palworld via SteamCMD."
exit 78
fi
# Seed PalWorldSettings.ini on first boot from the shipped default, then
# patch RCON + server name/description/max-players into it. Palworld
# ignores these keys on re-save unless they live in PalWorldSettings.ini
# under [/Script/Pal.PalGameWorldSettings].
mkdir -p "${SETTINGS_DIR}"
if [[ ! -s "${SETTINGS}" && -s "${DEFAULT_SETTINGS}" ]]; then
log "seeding ${SETTINGS} from shipped default"
cp -f "${DEFAULT_SETTINGS}" "${SETTINGS}"
fi
# Patch the single OptionSettings=(…) tuple each boot so panel-managed
# values (RCON auth, server identity) track env vars. Uses | as sed
# delimiter since the tuple contains slashes.
if [[ -s "${SETTINGS}" ]]; then
log "patching ${SETTINGS} (RCON + identity + ports)"
sed -i -E \
-e "s|RCONEnabled=(True\|False)|RCONEnabled=True|" \
-e "s|RCONPort=[0-9]+|RCONPort=${RCON_PORT}|" \
-e "s|PublicPort=[0-9]+|PublicPort=${GAME_PORT}|" \
-e "s|AdminPassword=\"[^\"]*\"|AdminPassword=\"${RCON_PASSWORD}\"|" \
-e "s|ServerPlayerMaxNum=[0-9]+|ServerPlayerMaxNum=${MAX_PLAYERS}|" \
-e "s|ServerName=\"[^\"]*\"|ServerName=\"${SERVER_NAME}\"|" \
-e "s|ServerDescription=\"[^\"]*\"|ServerDescription=\"${SERVER_DESCRIPTION}\"|" \
"${SETTINGS}"
fi
# Redirect Pal saves into the save volume so SteamCMD validate can't clobber.
mkdir -p "${SAVE_DIR}/Pal"
if [[ -d "${GAME_DIR}/Pal/Saved" && ! -L "${GAME_DIR}/Pal/Saved" ]]; then
# Migrate any existing shipped saves once, then symlink.
if [[ ! -d "${SAVE_DIR}/Pal/Saved" ]]; then
mv "${GAME_DIR}/Pal/Saved" "${SAVE_DIR}/Pal/Saved"
else
rm -rf "${GAME_DIR}/Pal/Saved"
fi
fi
mkdir -p "${SAVE_DIR}/Pal/Saved"
ln -sfn "${SAVE_DIR}/Pal/Saved" "${GAME_DIR}/Pal/Saved"
cd "${GAME_DIR}"
log "starting Palworld dedicated server"
log " name: ${SERVER_NAME}"
log " max_players: ${MAX_PLAYERS}"
log " game port: ${GAME_PORT}/udp"
log " query port: ${QUERY_PORT}/udp"
log " rcon port: ${RCON_PORT}/tcp"
exec stdbuf -oL -eL "${PAL_SH}" \
-port="${GAME_PORT}" \
-queryport="${QUERY_PORT}" \
-players="${MAX_PLAYERS}" \
-useperfthreads \
-NoAsyncLoadingThread \
-UseMultithreadForDS \
"-servername=${SERVER_NAME}" \
"-adminpassword=${RCON_PASSWORD}" \
-RconEnabled=True \
-RconPort="${RCON_PORT}" \
"-ServerDescription=${SERVER_DESCRIPTION}"
+127
View File
@@ -0,0 +1,127 @@
# Palworld — panel-native dedicated server module.
#
# Native Linux binary from SteamCMD app 2394010. Native Source RCON on
# port 25575 when EnableRCON=True in PalWorldSettings.ini.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (PalworldLinux.kvp)
id: palworld
name: "Palworld"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-palworld:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "World save + PalWorldSettings.ini + admin files"
- name: "Game Files"
path: /game
hint: "PalServer install, binaries"
env:
SERVER_NAME: "panel Palworld"
SERVER_DESCRIPTION: "A Palworld server"
MAX_PLAYERS: "32"
RCON_PASSWORD: "panel_rcon"
# Pre-declared so the agent's resolve.go filter lets panel-allocated
# ports flow through to the container env. Defaults match port defaults.
GAME_PORT: "8211"
QUERY_PORT: "27015"
RCON_PORT: "25575"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 8211, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
- { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 6144
recommended_ram_mb: 16384 # Palworld is memory-hungry
# Per-instance generated RCON secret. The agent generates a random
# 24-byte token at create time (pkg/module GenerateSecrets), which
# overrides the env default for RCON_PASSWORD below — so every instance
# gets a unique password. The weak env default only applies to
# containers launched outside the panel.
secrets:
- name: RCON_PASSWORD
description: "Palworld RCON password — generated per instance"
generated: true
rcon:
adapter: source_rcon
host_port: rcon
auth: source_rcon_login
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated RCON_PASSWORD secret above.
password_literal: "panel_rcon"
commands:
list: "ShowPlayers"
save: "Save"
broadcast: "Broadcast {msg}"
kick: "KickPlayer {player}"
ban: "BanPlayer {player}"
shutdown: "Shutdown 30 \"Server going down\""
state_sources:
- type: rcon
command: "ShowPlayers"
every: 60s
parse:
kind: regex
pattern: '^(?P<count>\d+)\s*$' # Fallback; real parsing in playerList
fields:
players_online: count
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (palworld.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '(?P<name>.+?) \S+ connected the server\. \(User id: (?P<id>[^)]+)\)$'
kind: join
leave:
pattern: '(?P<name>.+?) left the server\. \(User id: (?P<id>[^)]+)\)$'
kind: leave
chat:
pattern: '^(?:\[[\d-]+ [\d:]+\] )?\[CHAT\] <(?P<name>.+?)> (?P<msg>.+)$'
kind: chat
update_providers:
- id: stable
kind: steamcmd
app_id: "2394010"
install_path: /game
# Force Linux depot — SteamCMD's auto-detect mis-fires in Docker
# Desktop on Windows (WSL2 backend), returning "Missing configuration"
# on anonymous app_update. Pinning platform fixes it.
platform: linux
# --- 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: '/Running Palworld dedicated server|Setting breakpad minidump AppID/i'
appearance:
emoji: "🐾"
grad: "linear-gradient(135deg,#006d77,#83c5be)"
art: "/game-art/palworld.jpg"
steam: "1623730"
+35
View File
@@ -0,0 +1,35 @@
# panel-native Project Zomboid runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
# PZ's start-server.sh expects Java 17+. Debian 12's default-jre is 17.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
default-jre-headless \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
&& 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/*
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
EXPOSE 16261/udp 16262/udp 27015/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# panel-native Project Zomboid entrypoint.
set -euo pipefail
log() { printf '[panel-pz] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel}" \
ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}" \
RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \
MEMORY="${MEMORY:-4096m}" \
GAME_PORT="${GAME_PORT:-16261}" \
GAME_PORT_B="${GAME_PORT_B:-16262}" \
RCON_PORT="${RCON_PORT:-27015}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
START_SH="${GAME_DIR}/start-server.sh"
if [[ ! -x "${START_SH}" ]]; then
log "ERROR: ${START_SH} not found. Run 'Update' in the panel to install Project Zomboid via SteamCMD."
exit 78
fi
# PZ writes world data + per-server config under $HOME/Zomboid (Linux) by
# default. HOME is already /game-saves via setpriv; pre-create subdirs.
mkdir -p "${SAVE_DIR}/Zomboid/Server" "${SAVE_DIR}/Zomboid/Saves"
# Per-server config at ~/Zomboid/Server/<SERVER_NAME>.ini. Seed on first
# boot, then ALWAYS enforce the panel-allocated ports + RCON creds so a
# recreate with new ports never leaves stale values in an existing ini.
CFG="${SAVE_DIR}/Zomboid/Server/${SERVER_NAME}.ini"
if [[ ! -s "${CFG}" ]]; then
log "seeding ${CFG}"
cat > "${CFG}" <<EOF
PublicName=${SERVER_NAME}
Public=false
MaxPlayers=16
PVP=true
Pause=false
PauseEmpty=true
AutoCreateUserInWhiteList=false
Open=true
ServerWelcomeMessage=Running on panel
UPnP=false
EOF
fi
# set_ini KEY VALUE — replace-or-append into ${CFG}.
set_ini() {
local key="$1" val="$2"
if grep -q "^${key}=" "${CFG}"; then
sed -i "s|^${key}=.*|${key}=${val}|" "${CFG}"
else
printf '%s=%s\n' "${key}" "${val}" >> "${CFG}"
fi
}
set_ini DefaultPort "${GAME_PORT}"
set_ini UDPPort "${GAME_PORT_B}"
set_ini RCONPort "${RCON_PORT}"
set_ini RCONPassword "${RCON_PASSWORD}"
set_ini UPnP "false"
# JVM heap: start-server.sh reads vmArgs from ProjectZomboid64.json (the
# MEMORY env is NOT read by the stock script) — patch the -Xmx entry.
PZ_JSON="${GAME_DIR}/ProjectZomboid64.json"
if [[ -f "${PZ_JSON}" ]]; then
sed -i "s|-Xmx[0-9]*[kmgKMG]\?|-Xmx${MEMORY}|" "${PZ_JSON}" || true
fi
cd "${GAME_DIR}"
log "starting Project Zomboid dedicated server"
log " servername: ${SERVER_NAME}"
log " heap: ${MEMORY}"
log " ports: game=${GAME_PORT}/udp direct=${GAME_PORT_B}/udp rcon=${RCON_PORT}/tcp"
# start-server.sh forwards program args to zombie.network.GameServer.
# -port/-udpport mirror the ini keys (AMP passes the same pair).
# -cachedir is REQUIRED: pzexe derives its data dir from the passwd home
# (/home/panel), NOT $HOME — without it the ini/saves silently land inside
# the container layer and the seeded ini above is never read (confirmed:
# 'cachedir set to "/home/panel/Zomboid"' on first smoke boot).
exec stdbuf -oL -eL "${START_SH}" \
"-cachedir=${SAVE_DIR}/Zomboid" \
-servername "${SERVER_NAME}" \
-adminpassword "${ADMIN_PASSWORD}" \
-port "${GAME_PORT}" \
-udpport "${GAME_PORT_B}"
+120
View File
@@ -0,0 +1,120 @@
# Project Zomboid — panel-native dedicated server module.
#
# Native Linux binary from SteamCMD app 380870. Java-heavy; the shipped
# start-server.sh wraps java with correct -cp and Dpzexe.* system props.
# Has Source-compatible RCON on port 27015 when enabled in server.ini.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (ProjectZomboid.kvp)
id: project-zomboid
name: "Project Zomboid"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-project-zomboid:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Server configs + world saves under ~/Zomboid"
- name: "Game Files"
path: /game
hint: "PZ install, binaries"
env:
SERVER_NAME: "panel" # used as name of server folder under ~/Zomboid/Server
ADMIN_PASSWORD: "changeme"
RCON_PASSWORD: "panel_rcon"
MEMORY: "4096m" # JVM heap
# Pre-declared so the agent's resolve.go filter lets panel-allocated
# ports flow through to the container env. The entrypoint passes
# -port/-udpport launch args AND enforces the matching ini keys;
# without these the server binds the hardcoded 16261/16262/27015 and
# ignores the panel allocation (breaks multi-instance + opnfwd).
GAME_PORT: "16261"
GAME_PORT_B: "16262"
RCON_PORT: "27015"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 16261, required: true, env: GAME_PORT }
- { name: gameB, proto: udp, default: 16262, required: true, env: GAME_PORT_B }
- { name: rcon, proto: tcp, default: 27015, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# Per-instance generated RCON secret. The agent generates a random
# 24-byte token at create time (pkg/module GenerateSecrets), which
# overrides the env default for RCON_PASSWORD below — so every instance
# gets a unique password. The weak env default only applies to
# containers launched outside the panel.
secrets:
- name: RCON_PASSWORD
description: "Project Zomboid RCON password — generated per instance"
generated: true
rcon:
adapter: source_rcon
host_port: rcon
auth: source_rcon_login
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated RCON_PASSWORD secret above.
password_literal: "panel_rcon"
commands:
list: "players"
save: "save"
kick: "kickuser {player}"
ban: "banuser {player}"
broadcast: "servermsg \"{msg}\""
state_sources:
- type: rcon
command: "players"
every: 60s
parse:
kind: regex
pattern: 'Players connected \((?P<count>\d+)\)'
fields:
players_online: count
events:
join:
pattern: "(?P<name>\\S+) connected with ip"
kind: join
leave:
pattern: "(?P<name>\\S+) disconnected"
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "380870"
install_path: /game
# --- 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.
# Confirmed on smoke boot 2026-07-14: 'LOG : Network ... > *** SERVER STARTED ****'
ready_pattern: '/\*+ SERVER STARTED \*+/'
appearance:
emoji: "🧟"
grad: "linear-gradient(135deg,#6a4c1e,#b89040)"
steam: "108600"
+61
View File
@@ -0,0 +1,61 @@
# panel-native Rust runtime image.
#
# Rust's dedicated server is a native Linux binary (SteamCMD app 258550),
# so no Wine. We need:
# - 32-bit runtime libs (RustDedicated is ELF64 but loads steamclient.so
# which pulls in 32-bit deps).
# - tini for signal forwarding (server does a graceful "Saving..." + exit
# on SIGTERM when its RCON is unreachable).
# - SDL/GL shims — Rust dedicated server does NOT need a display, but it
# dlopens libGL and a couple of X libs at startup; we ship the stubs.
#
# Expected runtime layout inside the container:
# /game — SteamCMD install (panel volume)
# /game/RustDedicated — server binary
# /game/server/<identity>/ — world + save data (symlinked to /game-saves)
# /game-saves — save + logs volume
# /entrypoint.sh — boot script (seeds cfg, launches binary)
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
libgdiplus \
libgl1 \
libx11-6 \
libxcursor1 \
libxext6 \
libxi6 \
libxrandr2 \
libxrender1 \
procps \
&& 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/*
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
# Rust default ports (see module.yaml comment for reference).
EXPOSE 28015/udp 28016/tcp 28082/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+52
View File
@@ -0,0 +1,52 @@
# Rust — panel-native module
Panel-native Rust dedicated server, native-Linux (no Wine), debian:12-slim base.
- **Image:** `panel-rust:latest` — built from `./Dockerfile`.
- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 258550) into the `panel-<id>-game` named volume. Fresh install is ~7 GB; first-ever boot additionally generates a ~500 MB map on the save volume depending on `SERVER_WORLD_SIZE`.
- **Saves:** `panel-<id>-saves` at `/game-saves`. Entrypoint symlinks `<game>/server` into `/game-saves/server/` so SteamCMD validate can't wipe worlds.
- **RCON:** **WebSocket RCON** (Facepunch dropped Source RCON years ago). Panel speaks this via the `websocket_rcon` adapter — `ws://<ip>:28016/<password>` with JSON-framed `{Identifier, Message, Name}` requests. Authenticated via the password in the URL path, not headers.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 28015 | UDP | Main game traffic |
| 28016 | TCP | WebSocket RCON (internal) |
| 28082 | TCP | Rust+ push notifications (optional — only needed for the companion app) |
## RCON commands wired
- `list``playerlist`
- `broadcast {msg}``say {msg}`
- `kick {player}``kick "{player}"`
- `ban {player}``banid {player}`
- `save``server.save`
- `shutdown``quit`
## Log events captured
- **Join:** `<name>[<pid>/<steamid>] has entered the game`
- **Leave:** `<steamid>/<name> disconnecting: <reason>`
## Environment vars
| Var | Default | Notes |
|-----|---------|-------|
| `SERVER_NAME` | `panel Rust` | Shown in the Rust server browser |
| `SERVER_DESCRIPTION` | `Powered by panel` | Browser description / tooltip |
| `SERVER_IDENTITY` | `panel-rust` | Save-tree subdirectory name |
| `SERVER_SEED` | `12345` | Procedural-gen seed |
| `SERVER_WORLD_SIZE` | `3000` | 10006000; smaller = faster |
| `MAX_PLAYERS` | `50` | |
| `RCON_PASSWORD` | `panel_rcon` | RCON auth (URL path) |
| `SERVER_LEVEL` | `Procedural Map` | Map preset |
| `SERVER_URL` | `` | Website URL shown in the browser |
| `SERVER_HEADER_IMAGE` | `` | 512×256 banner image URL |
## Known gotchas
- First-ever boot takes 25 min to generate the world. No players can connect until generation finishes (log: `Server startup complete` is the signal).
- RCON port is published on the container only (panel talks to it over the Docker bridge). Don't expose 28016 externally — password in URL means anyone who sees the URL has full server control.
- If you change `SERVER_WORLD_SIZE` or `SERVER_SEED` on a running server, the next boot wipes the existing world. Rust ties the world to a seed+size pair per identity.
- `libgl1` + a handful of X libs are installed in the image even though the server runs headless; Rust's Unity runtime `dlopen`s them at startup.
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# panel-native Rust entrypoint.
#
# Contract:
# /game — SteamCMD-installed Rust Dedicated Server (app 258550).
# Populated by the panel's SteamCMD updater sidecar.
# /game-saves — world/save data + logs volume.
#
# Stage 1 runs as root: chown volumes, symlink server/<identity> into the
# save volume, then drop to the `panel` user via setpriv.
set -euo pipefail
log() { printf '[panel-rust] %s\n' "$*"; }
# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
SERVER_NAME="${SERVER_NAME:-panel Rust}" \
SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-Powered by panel}" \
SERVER_IDENTITY="${SERVER_IDENTITY:-panel-rust}" \
SERVER_SEED="${SERVER_SEED:-12345}" \
SERVER_WORLD_SIZE="${SERVER_WORLD_SIZE:-3000}" \
MAX_PLAYERS="${MAX_PLAYERS:-50}" \
RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \
SERVER_URL="${SERVER_URL:-}" \
SERVER_HEADER_IMAGE="${SERVER_HEADER_IMAGE:-}" \
SERVER_LEVEL="${SERVER_LEVEL:-Procedural Map}" \
GAME_PORT="${GAME_PORT:-28015}" \
RCON_PORT="${RCON_PORT:-28016}" \
RUSTPLUS_PORT="${RUSTPLUS_PORT:-28082}" \
LD_LIBRARY_PATH="/game:/game/RustDedicated_Data/Plugins:/game/RustDedicated_Data/Plugins/x86_64:${LD_LIBRARY_PATH:-}" \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
DEDI_EXE="${GAME_DIR}/RustDedicated"
if [[ ! -x "${DEDI_EXE}" ]]; then
log "ERROR: ${DEDI_EXE} not found or not executable."
log " Run 'Update' on this server in the panel — that launches a"
log " SteamCMD sidecar which installs Rust (app 258550) here."
exit 78 # EX_CONFIG
fi
# --- persist server/<identity>/ into the save volume ---
# RustDedicated writes its world + save + cfg tree to <game>/server/<identity>.
# First boot moves it to /game-saves + symlinks back, so SteamCMD validate
# can't clobber world data. Idempotent on re-boot.
mkdir -p "${SAVE_DIR}/server"
if [[ -d "${GAME_DIR}/server" && ! -L "${GAME_DIR}/server" ]]; then
# On fresh install this dir doesn't exist; server creates it on first boot.
# If it does exist (e.g. from an earlier panel image without the symlink),
# migrate and replace.
if [[ ! -d "${SAVE_DIR}/server/${SERVER_IDENTITY}" ]]; then
mv "${GAME_DIR}/server"/* "${SAVE_DIR}/server/" 2>/dev/null || true
fi
rm -rf "${GAME_DIR}/server"
fi
ln -sfn "${SAVE_DIR}/server" "${GAME_DIR}/server"
# Mirror logs to the bind-mounted logs dir for external access.
mkdir -p "${SAVE_DIR}/logs"
cd "${GAME_DIR}"
log "starting Rust Dedicated Server"
log " name: ${SERVER_NAME}"
log " identity: ${SERVER_IDENTITY}"
log " level: ${SERVER_LEVEL}"
log " seed: ${SERVER_SEED}"
log " size: ${SERVER_WORLD_SIZE}"
log " max: ${MAX_PLAYERS}"
log " game port: ${GAME_PORT}/udp"
log " rcon port: ${RCON_PORT}/tcp (internal, websocket)"
log " rust+: ${RUSTPLUS_PORT}/tcp (push, optional)"
trap 'log "SIGTERM received; stopping server"; kill -TERM "${RUST_PID:-0}" 2>/dev/null || true; wait "${RUST_PID:-0}" 2>/dev/null || true; exit 0' TERM INT
# Rust's own flags — all `+` prefixed. `-batchmode` / `-nographics` come
# before the `+` convars. We intentionally omit `-logFile`: Unity defaults
# to stdout when absent, and passing `/dev/stdout` or `-` makes RustDedicated
# fail with "Unable to open log file, exiting." on Linux-container Unity.
exec stdbuf -oL -eL "${DEDI_EXE}" \
-batchmode \
-nographics \
+server.ip 0.0.0.0 \
+server.port "${GAME_PORT}" \
+server.tickrate 10 \
+server.hostname "${SERVER_NAME}" \
+server.description "${SERVER_DESCRIPTION}" \
+server.identity "${SERVER_IDENTITY}" \
+server.level "${SERVER_LEVEL}" \
+server.seed "${SERVER_SEED}" \
+server.worldsize "${SERVER_WORLD_SIZE}" \
+server.maxplayers "${MAX_PLAYERS}" \
+server.saveinterval 600 \
+server.url "${SERVER_URL}" \
+server.headerimage "${SERVER_HEADER_IMAGE}" \
+rcon.web 1 \
+rcon.ip 0.0.0.0 \
+rcon.port "${RCON_PORT}" \
+rcon.password "${RCON_PASSWORD}" \
+app.port "${RUSTPLUS_PORT}" &
RUST_PID=$!
wait "${RUST_PID}"
+131
View File
@@ -0,0 +1,131 @@
# Rust (Facepunch Studios) dedicated server module.
#
# Native Linux binary via SteamCMD app 258550. Uses WebSocket RCON (Facepunch
# dropped Source RCON years ago), which the panel speaks via the
# `websocket_rcon` adapter added in this module's first shipping session.
#
# Reference for flags + ports: Rust dedicated server wiki + widespread
# community configs. No AMP template exists for Rust in CubeCoders' public
# repo (Rust is in AMP's built-in module set, not community-contributed).
id: rust
name: "Rust"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-rust:latest modules/rust
image: panel-rust:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "server/<identity>/ save data, cfg/, user bans + mutes, oxide/"
- name: "Game Files"
path: /game
hint: "RustDedicated binary + Bundles/ + shipped configs"
env:
SERVER_NAME: "panel Rust"
SERVER_DESCRIPTION: "Powered by panel"
SERVER_IDENTITY: "panel-rust"
SERVER_SEED: "12345"
SERVER_WORLD_SIZE: "3000" # smaller = faster load; default 4000
MAX_PLAYERS: "50"
RCON_PASSWORD: "panel_rcon"
SERVER_URL: ""
SERVER_HEADER_IMAGE: ""
# Level selector: Procedural Map (default), Barren, HapisIsland, SavasIsland, CraggyIsland
SERVER_LEVEL: "Procedural Map"
# Pre-declared so allocator-derived ports survive resolve.go's filter.
GAME_PORT: "28015"
RCON_PORT: "28016"
RUSTPLUS_PORT: "28082"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Rust ports:
# 28015/udp — main game traffic (required)
# 28015/tcp — app port for some tooling (optional; not published by default)
# 28016/tcp — WebSocket RCON (internal; panel talks to it)
# 28082/tcp — Rust+ push notifications (optional)
ports:
- { name: game, proto: udp, default: 28015, required: true, env: GAME_PORT }
- { name: rcon, proto: tcp, default: 28016, internal: true, env: RCON_PORT }
- { name: rustplus, proto: tcp, default: 28082, required: false, env: RUSTPLUS_PORT }
resources:
min_ram_mb: 6144
recommended_ram_mb: 12288
# Per-instance generated RCON secret. The agent generates a random
# 24-byte token at create time (pkg/module GenerateSecrets), which
# overrides the env default for RCON_PASSWORD below — so every instance
# gets a unique password. The weak env default only applies to
# containers launched outside the panel.
secrets:
- name: RCON_PASSWORD
description: "Rust RCON password — generated per instance"
generated: true
rcon:
adapter: websocket_rcon
host_port: rcon
auth: password_in_url
password_secret: RCON_PASSWORD
# Legacy fallback only (pre-generated-secret instances). New instances
# always get the generated RCON_PASSWORD secret above.
password_literal: "panel_rcon"
commands:
list: "playerlist"
broadcast: "say {msg}"
kick: "kick \"{player}\""
ban: "banid {player}"
save: "server.save"
shutdown: "quit"
state_sources:
- type: rcon
command: "playerlist"
every: 60s
# Log-line event patterns (RE2; named groups use (?P<name>...)).
# Rust prints join/leave to both stdout and the save-side log; stdout is
# what the log pump tails.
events:
join:
pattern: '(?P<name>.+?)\[[0-9]+/(?P<id>\d{17})\] has entered the game'
kind: join
leave:
pattern: '(?P<id>\d{17})/(?P<name>.+?) disconnecting: (?P<reason>.+)$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "258550"
install_path: /game
# --- 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: '/Server startup complete|Listening on 0\.0\.0\.0:28015/i'
appearance:
emoji: "🔧"
grad: "linear-gradient(135deg,#8b4a2b,#d97706)"
art: "/game-art/rust.jpg"
steam: "252490"
+34
View File
@@ -0,0 +1,34 @@
# panel-native Satisfactory runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
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 \
libatomic1 \
&& 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/*
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
EXPOSE 7777/udp 15000/udp 7777/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# panel-native Satisfactory entrypoint.
set -euo pipefail
log() { printf '[panel-satisfactory] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
GAME_PORT="${GAME_PORT:-7777}" RELIABLE_PORT="${RELIABLE_PORT:-8888}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
START_SH="${GAME_DIR}/FactoryServer.sh"
if [[ ! -x "${START_SH}" ]]; then
log "ERROR: ${START_SH} not found. Run 'Update' in the panel to install Satisfactory via SteamCMD."
exit 78
fi
# Satisfactory writes SaveGames / Engine configs to
# $HOME/.config/Epic/FactoryGame. HOME is /game-saves → saves land in the
# save volume without further fiddling.
mkdir -p "${SAVE_DIR}/.config/Epic/FactoryGame/Saved/SaveGames"
GAME_PORT="${GAME_PORT:-7777}"
RELIABLE_PORT="${RELIABLE_PORT:-8888}"
cd "${GAME_DIR}"
log "starting Satisfactory dedicated server (game=${GAME_PORT}/udp+tcp reliable=${RELIABLE_PORT}/tcp)"
# -Port drives both the UDP game socket and the HTTPS API listener (they
# share the number). -ReliablePort is the TCP reliability channel (1.0+).
# -stdout -FullStdOutLogOutput route UE logs to stdout for `docker logs`.
exec stdbuf -oL -eL "${START_SH}" \
-Port="${GAME_PORT}" \
-ReliablePort="${RELIABLE_PORT}" \
-multihome=0.0.0.0 \
-stdout -FullStdOutLogOutput \
-unattended
+92
View File
@@ -0,0 +1,92 @@
# Satisfactory — panel-native dedicated server module.
#
# Native Linux binary from SteamCMD app 1690800. Admin is via Coffee Stain's
# HTTPS API on the game port (7777/tcp — separate from the UDP game traffic).
# No traditional RCON in v1; users admin via the in-game UI + API tokens.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (SatisfactoryLinux.kvp)
id: satisfactory
name: "Satisfactory"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-satisfactory:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "SaveGames/ + Engine/ + Game.ini"
- name: "Game Files"
path: /game
hint: "Server binaries"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Port env vars MUST be pre-declared here — the agent's ResolveDocker
# only lets ConfigValues override keys that already exist in this map,
# so the allocator's per-instance ports would otherwise be dropped.
env:
GAME_PORT: "7777"
RELIABLE_PORT: "8888"
# Satisfactory 1.0+ port model: ONE game port (UDP game traffic + the
# HTTPS admin API on the same number over TCP — the server binds both
# itself under host networking) plus a TCP "reliable" messaging port.
# The pre-1.0 beacon/query ports no longer exist.
ports:
- { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT }
- { name: reliable, proto: tcp, default: 8888, required: true, env: RELIABLE_PORT }
resources:
min_ram_mb: 6144
recommended_ram_mb: 12288
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (satisfactory.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'LogNet: Login request: \?ClientIdentity=.+\?EntryTicket=.+?EncryptionToken=.+\?Name=(?P<name>.+?) userId: .+\(ForeignId=\[Type=.+ Handle=.+ RepData=\[(?P<id>.+)\]\) platform: '
kind: join
leave:
pattern: 'LogNet: UChannel::Close: Sending CloseBunch\..+\[UNetConnection\] RemoteAddr: .+, Name: .+, Driver: .+, IsServer: YES, PC: .+, Owner: .+, UniqueId: .+\(ForeignId=\[Type=.+ Handle=.+ RepData=\[(?P<id>.+)\]\)'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "1690800"
install_path: /game
- id: experimental
kind: steamcmd
app_id: "1690800"
beta: experimental
install_path: /game
# --- 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.
# Verified against a real boot 2026-07-14 (v1.2.0-CL-495413): the net
# driver line is when the server becomes joinable (~5s after start).
# "Server startup time elapsed" follows at ~30s once level load settles.
ready_pattern: '/IpNetDriver listening on port \d+|Server startup time elapsed/i'
appearance:
emoji: "🏭"
grad: "linear-gradient(135deg,#d97706,#fbbf24)"
art: "/game-art/satisfactory.jpg"
steam: "526870"
+41
View File
@@ -0,0 +1,41 @@
# panel-native Sons Of The Forest runtime image (Wine).
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
xvfb \
xauth \
wine64 \
wine \
winbind \
cabextract \
procps \
&& 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/*
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 /home/panel
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8766/udp 27016/udp 9700/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# panel-native Sons Of The Forest entrypoint (Wine).
set -euo pipefail
log() { printf '[panel-sotf] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel Sons Of The Forest}" \
MAX_PLAYERS="${MAX_PLAYERS:-8}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
GAME_PORT="${GAME_PORT:-8766}" \
QUERY_PORT="${QUERY_PORT:-27016}" \
BLOBSYNC_PORT="${BLOBSYNC_PORT:-9700}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/SonsOfTheForestDS.exe"
# The server looks for dedicatedserver.cfg directly inside -userdatapath.
CONFIG="${SAVE_DIR}/dedicatedserver.cfg"
if [[ ! -f "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install SOTF (Windows build) via SteamCMD."
exit 78
fi
# Seed dedicatedserver.cfg (JSON-ish) on first boot from env. On subsequent
# boots, patch the port keys so panel-allocated ports flow into a config
# the operator may have hand-edited (preserves their other tweaks).
mkdir -p "${SAVE_DIR}"
if [[ ! -s "${CONFIG}" ]]; then
log "seeding ${CONFIG}"
cat > "${CONFIG}" <<EOF
{
"IpAddress": "0.0.0.0",
"ServerName": "${SERVER_NAME}",
"MaxPlayers": ${MAX_PLAYERS},
"Password": "${SERVER_PASSWORD}",
"GamePort": ${GAME_PORT},
"QueryPort": ${QUERY_PORT},
"BlobSyncPort": ${BLOBSYNC_PORT},
"LanOnly": false,
"SaveSlot": 1,
"SaveMode": "Continue",
"GameMode": "Normal",
"SaveInterval": 600,
"LogFilesEnabled": false,
"TimestampLogFilenames": true,
"TimestampLogEntries": true,
"SkipNetworkAccessibilityTest": true
}
EOF
else
log "patching ${CONFIG} (ports from env)"
# JSON int keys — sed targets `"Key": NNNN` allowing any current value.
sed -i -E \
-e "s|(\"GamePort\":[[:space:]]*)[0-9]+|\1${GAME_PORT}|" \
-e "s|(\"QueryPort\":[[:space:]]*)[0-9]+|\1${QUERY_PORT}|" \
-e "s|(\"BlobSyncPort\":[[:space:]]*)[0-9]+|\1${BLOBSYNC_PORT}|" \
"${CONFIG}"
fi
if [[ ! -f "${WINEPREFIX}/system.reg" ]]; then
log "initializing Wine prefix at ${WINEPREFIX}"
xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true
wineserver -w || true
fi
trap 'log "SIGTERM"; wineserver -k 2>/dev/null || true; exit 0' TERM INT
cd "${GAME_DIR}"
log "starting Sons Of The Forest dedicated server"
log " name: ${SERVER_NAME}"
log " game/query: ${GAME_PORT}/${QUERY_PORT}/udp"
log " blobsync: ${BLOBSYNC_PORT}/udp"
# xvfb-run -a auto-allocates a free X display (host has its own Xvfb on
# :99 / :100 / :101 — see panel/memory/gotchas.md).
# Args mirror AMPTemplates sons-of-the-forest.kvp App.CommandLineArgs:
# -dedicatedserver.* CLI overrides beat the cfg, so panel-allocated ports
# always win even if the operator hand-edits dedicatedserver.cfg.
exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \
wine "${EXE}" \
-dedicatedserver.IpAddress "0.0.0.0" \
-dedicatedserver.GamePort "${GAME_PORT}" \
-dedicatedserver.QueryPort "${QUERY_PORT}" \
-dedicatedserver.BlobSyncPort "${BLOBSYNC_PORT}" \
-userdatapath "${SAVE_DIR}"
+92
View File
@@ -0,0 +1,92 @@
# Sons Of The Forest — panel-native dedicated server module (Wine).
#
# Windows-only dedicated server. Same pattern as V Rising + Empyrion:
# Debian + Wine + Xvfb + tini, SteamCMD with platform:windows.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (SonsOfTheForest.kvp)
id: sons-of-the-forest
name: "Sons Of The Forest"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-sons-of-the-forest:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "dedicatedserver.cfg + Saves/"
- name: "Game Files"
path: /game
hint: "Server binaries"
env:
SERVER_NAME: "panel Sons Of The Forest"
MAX_PLAYERS: "8"
SERVER_PASSWORD: ""
# Pre-declared so allocator-derived ports survive resolve.go's filter.
GAME_PORT: "8766"
QUERY_PORT: "27016"
BLOBSYNC_PORT: "9700"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: udp, default: 8766, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 27016, required: true, env: QUERY_PORT }
# NOTE: name stays "join" (SOTF calls it BlobSync) — the running prod
# controller's in-memory manifest allocates ports by this name, and the
# agent maps allocation→env by matching the same name. Renaming it only
# works after a controller restart; the env var is what the game sees.
- { name: join, proto: udp, default: 9700, required: true, env: BLOBSYNC_PORT }
resources:
min_ram_mb: 8192
recommended_ram_mb: 16384
# No rcon: AMP's kvp claims AdminMethod=STDIO, but live smoke test
# (2026-07-14) showed the server ignores stdin entirely — `save`, `help`
# and `stop` all produced zero reaction under wine+xvfb-run. Admin is
# in-game only (like empyrion). Graceful stop = SIGTERM via entrypoint trap.
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (sons-of-the-forest.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'Steam auth successful for client \d+ with steam id (?P<id>\S+?)[.,] username (?P<name>.+)$'
kind: join
leave:
pattern: 'Unregistering client \d+ with steam id (?P<id>\S+?)[.,] username (?P<name>.+)$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "2465200"
platform: windows
install_path: /game
# --- 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: '/Dedicated server loaded|\[Self-Tests\] Please restart the server/i'
appearance:
emoji: "🌲"
grad: "linear-gradient(135deg,#14532d,#4d7c0f)"
art: "/game-art/sons-of-the-forest.jpg"
steam: "1326470"
+34
View File
@@ -0,0 +1,34 @@
# panel-native Soulmask runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
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 \
libatomic1 \
&& 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/*
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
EXPOSE 8777/udp 27015/udp 8888/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# panel-native Soulmask entrypoint.
set -euo pipefail
log() { printf '[panel-soulmask] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel Soulmask}" \
MAX_PLAYERS="${MAX_PLAYERS:-30}" \
ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}" \
GAME_PORT="${GAME_PORT:-8777}" \
QUERY_PORT="${QUERY_PORT:-27015}" \
GAME_MODE="${GAME_MODE:-pve}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
SAVING="${SAVING:-600}" \
BACKUP="${BACKUP:-900}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/WS/Binaries/Linux/WSServer-Linux-Shipping"
# Official launcher shipped inside app 3017300. It sets LD_LIBRARY_PATH to
# ./linux64 and passes the required leading "WS" project token before our
# args — WITHOUT it the engine inits then immediately EngineExit()s. Always
# launch through this wrapper, never exec the ELF directly.
SH="${GAME_DIR}/WSServer.sh"
if [[ ! -x "${EXE}" ]]; then
log "ERROR: Soulmask server binary not found at ${EXE}."
log " Run Update (SteamCMD app 3017300 — the native Linux server)."
exit 78
fi
# Saves live under WS/Saved — redirect into the save volume.
mkdir -p "${SAVE_DIR}/WS/Saved"
if [[ -d "${GAME_DIR}/WS/Saved" && ! -L "${GAME_DIR}/WS/Saved" ]]; then
if [[ ! -d "${SAVE_DIR}/WS/Saved" || -z "$(ls -A "${SAVE_DIR}/WS/Saved" 2>/dev/null)" ]]; then
cp -a "${GAME_DIR}/WS/Saved/." "${SAVE_DIR}/WS/Saved/" 2>/dev/null || true
fi
rm -rf "${GAME_DIR}/WS/Saved"
fi
ln -sfn "${SAVE_DIR}/WS/Saved" "${GAME_DIR}/WS/Saved"
# Deep gameplay settings: the panel renders GameXishu.json from config_values
# and bind-mounts it at /game-saves/GameXishu.json.rendered (read-only). Copy
# it into the path the server reads on boot. Confirmed load line:
# logGameXishu: UHGameXiShuGuanLiQi::LoadFromJsonFile File:.../WS/Saved/GameplaySettings/GameXishu.json Success.
RENDERED="${SAVE_DIR}/GameXishu.json.rendered"
if [[ -f "${RENDERED}" ]]; then
mkdir -p "${SAVE_DIR}/WS/Saved/GameplaySettings"
cp -f "${RENDERED}" "${SAVE_DIR}/WS/Saved/GameplaySettings/GameXishu.json"
log "applied rendered GameXishu.json (deep gameplay settings)"
else
log "no rendered GameXishu.json — server will use built-in defaults"
fi
cd "${GAME_DIR}"
GAME_PORT="${GAME_PORT:-8777}"
QUERY_PORT="${QUERY_PORT:-27015}"
GAME_MODE="${GAME_MODE:-pve}"
# Normalize game mode to the launch flag the server expects (-pve / -pvp).
case "${GAME_MODE,,}" in
pvp) MODE_FLAG="-pvp" ;;
*) MODE_FLAG="-pve" ;;
esac
# Optional join password (-PSW). Admin password is -adminpsw (NOT -PSW — an
# earlier version passed the admin pw as -PSW, which set the JOIN password to
# the admin secret and left admin unset).
PSW_ARG=()
if [[ -n "${SERVER_PASSWORD:-}" ]]; then
PSW_ARG=(-PSW="${SERVER_PASSWORD}")
fi
log "starting Soulmask dedicated server"
log " name: ${SERVER_NAME}"
log " slots: ${MAX_PLAYERS}"
log " ports: game=${GAME_PORT} query=${QUERY_PORT}"
log " mode: ${MODE_FLAG} save=${SAVING:-600}s backup=${BACKUP:-900}s"
# Launch through WSServer.sh (handles LD_LIBRARY_PATH + the leading "WS"
# token). -Port/-QueryPort honor panel-allocated ports; -adminpsw sets admin;
# -PSW (optional) sets the join password; -pve/-pvp + -saving/-backup are the
# server-level knobs. Confirmed boot: world up, match state WaitingToStart.
# IMPORTANT: server name + max players MUST be passed as the -SteamServerName
# and -MaxPlayers FLAGS, not as ?ServerName=?MaxPlayers= URL options on the
# level string. The URL-option form is silently ignored for Steam registration
# — the server registered as "UNNAMED_SERVER" / 0/20 slots. This flag format
# matches the proven jsknnr/soulmask-dedicated-server image. The level is bare
# (no ?listen? / ?ServerName= options); -server -online=Steam -forcepassthrough
# are required for correct dedicated-server + Steam behaviour.
exec stdbuf -oL -eL "${SH}" \
Level01_Main -server -SILENT \
-SteamServerName="${SERVER_NAME}" -MaxPlayers="${MAX_PLAYERS}" \
"${MODE_FLAG}" -saving="${SAVING:-600}" -backup="${BACKUP:-900}" \
-MULTIHOME=0.0.0.0 -Port="${GAME_PORT}" -QueryPort="${QUERY_PORT}" \
-online=Steam -forcepassthrough \
-adminpsw="${ADMIN_PASSWORD}" "${PSW_ARG[@]}" \
-log -UTF8Output -useperfthreads -NoAsyncLoadingThread
+115
View File
@@ -0,0 +1,115 @@
# Soulmask — panel-native dedicated server module.
#
# Native Linux dedicated server. The Linux server ships under SteamCMD app
# 3017300 (Steam labels it "Soulmask Dedicated Server", and it is the app the
# community jsknnr/soulmask-dedicated-server image installs). It produces a
# native ELF: WS/Binaries/Linux/WSServer-Linux-Shipping + WSServer.sh.
#
# NOTE: app 3017310 is the WINDOWS-only build ("Soulmask Dedicated Server For
# Windows" — verified: its depot has no Linux binaries, only WSServer.exe /
# Win64). Do NOT install it on Linux; it never boots. An earlier version of
# this module led with 3017310 and treated 3017300 as "content", which meant
# the server never got a runnable binary. Fixed 2026-07-08: 3017300 only.
#
# Reference: https://github.com/jsknnr/soulmask-dedicated-server (native Linux,
# no Proton) — installs STEAM_APP_ID 3017300, launches WSServer.sh.
id: soulmask
name: "Soulmask"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-soulmask:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "WS/Saved/ + GameUserSettings.ini"
- name: "Game Files"
path: /game
hint: "Server binaries, WS/Content/"
env:
SERVER_NAME: "panel Soulmask"
MAX_PLAYERS: "30"
ADMIN_PASSWORD: "changeme"
# Pre-declared so the agent's resolve.go filter lets panel-allocated
# ports flow through to the container env. The entrypoint passes these
# as -Port / -QueryPort; without them the server binds the hardcoded
# 8777/27015 and ignores the panel allocation (breaks multi-instance +
# the opnfwd forward). Defaults match the port defaults.
GAME_PORT: "8777"
QUERY_PORT: "27015"
# Server-level settings passed as launch args by the entrypoint.
GAME_MODE: "pve" # pve | pvp
SERVER_PASSWORD: "" # join password (-PSW); empty = open
SAVING: "600" # world save interval (seconds)
BACKUP: "900" # backup interval (seconds)
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Panel-rendered GameXishu.json (deep gameplay settings, all 3 sections).
# The entrypoint copies this into WS/Saved/GameplaySettings/ where the
# server reads it on boot (confirmed: logGameXishu LoadFromJsonFile
# Success). read_only so the template stays authoritative.
- { target: "$DATA_PATH/GameXishu.json.rendered", container: "/game-saves/GameXishu.json.rendered", read_only: true }
ports:
- { name: game, proto: udp, default: 8777, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
- { name: admin, proto: tcp, default: 8888, internal: true } # REST admin
resources:
min_ram_mb: 6144
recommended_ram_mb: 12288
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (soulmask.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'logStoreGamemode: player ready\. Addr:.*?, Netuid:(?P<id>\d+), Name:(?P<name>.+)$'
kind: join
leave:
pattern: 'logStoreGamemode: Display: player leave world\. (?P<id>\d+)$'
kind: leave
update_providers:
# 3017300 is the native Linux dedicated server (ELF WSServer-Linux-Shipping).
# This single app is the complete install — NOT 3017310 (Windows-only).
- id: stable
kind: steamcmd
app_id: "3017300"
install_path: /game
# Deep gameplay settings (harvest/xp/decay/damage/consumption ratios) are
# rendered from config_values into GameXishu.json — the 276-key × 3-section
# file the server reads on boot. Same durable-render path 7DTD uses; gamehost
# routes soulmask settings-saves through config-render (DurableConfig=true).
config_files:
- path: GameXishu.json.rendered
format: json
template: templates/GameXishu.json.tmpl
# --- 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: '/Starting up WSServer|Network connection saved/i'
appearance:
emoji: "🗿"
grad: "linear-gradient(135deg,#3a2416,#7a5230)"
art: "/game-art/soulmask.jpg"
steam: "2646460"
@@ -0,0 +1,836 @@
{
"0": {
"ExpRatio": {{ or .Values.gx_exp_ratio "1" }},
"ZuoWuDropRatio": 1,
"AddRenKeDuRatio": 5,
"ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }},
"BinSiKaiGuan": 1,
"JianZhuFuLanMul": 1,
"JianZhuXiuLiMul": 1,
"JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }},
"ZhiZuoTimeRatio": 5,
"DamageYeShengRatio": 1,
"BeDamageByYeShengRatio": 1,
"GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }},
"GameWorldTimePower": {{ or .Values.gx_day_length "24" }},
"BaoXiangDropRatio": 1,
"HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }},
"ZhiBeiChongShengRatio": 1,
"ChengZhangExpRatio": 1,
"MJExpRatio": 1,
"CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }},
"FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }},
"CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }},
"DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }},
"DongWuShiTiZhongYaoDiaoLuoRatio": 1,
"CaiJiShengChanJianZhuDiaoLuoRatio": 1,
"PuTongRenDiaoLuoRatio": 1,
"JingYingRenDiaoLuoRatio": 1,
"BossRenDiaoLuoRatio": 1,
"ShuLianDuExpRatio": 1,
"NaiJiuXiShu": 1,
"ReDuXiShu": 1,
"CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }},
"ZhiZuoExpRatio": 1,
"ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }},
"QiTaExpRatio": 1,
"BeiDongYiJiShuXingRatio": 1,
"ZhuDongYiJiShuXingRatio": 1,
"ErJiShuXingRatio": 1,
"MaRenBeiDongYiJiShuXingRatio": 1,
"MaRenZhuDongYiJiShuXingRatio": 1,
"MaRenErJiShuXingRatio": 1,
"DongWuBeiDongYiJiShuXingRatio": 1,
"DongWuZhuDongYiJiShuXingRatio": 1,
"DongWuErJiShuXingRatio": 1,
"ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }},
"ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }},
"QiXiXiaoHaoRatio": 1,
"ShengMingHuiFuRatio": 1,
"TiLiHuiFuRatio": 1,
"QiXiHuiFuRatio": 1,
"DongWuDamageRatio": 1,
"DongWuJianShangRatio": 1,
"MaRenDamageRatio": 1,
"ManRenJianShangRatio": 1,
"JiaSiHuiFuRatio": 1,
"CaiJiDamageRatio": 1,
"ZiYuanShengMingRatio": 1,
"RanLiaoXiaoHaoRatio": 1,
"DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }},
"FanZhiJianGeRatio": 1,
"DongWuShengChanJianGeRatio": 1,
"DongWuChanChuRatio": 1,
"DongWuXiaoHaoShiWuRatio": 1,
"DongWuXiaoHaoShuiRatio": 1,
"ZuoWuFeiLiaoXiaoHaoRatio": 1,
"ZuoWuShuiXiaoHaoRatio": 1,
"ZuoWuXiaoHuiRatio": 1,
"GongJiJianZhuDamageRatio": 1,
"DongWuPinZhiRatio": 1,
"ManRenPinZhiRatio": 1,
"WanJiaZiYuanJinShuaBanJing": 1,
"JianZhuZiYuanJinShuaBanJing": 1,
"WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }},
"WuPinXiaoHuiTime": 1,
"XiuLiXuYaoCaiLiaoRatio": 1,
"XiuLiJiangNaiJiuShangXianRatio": 1,
"HuiFuChuShiBodyData": 1,
"WanMeiChongSu": 1,
"FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }},
"JianZhuChuanSongMenPlusKaiGuan": 0,
"ShuaXinNPCKaiGuan": 0,
"SuiJiRuQinKaiGuan": 0,
"RuQinKaiGuan": 0,
"RuQinGuiMoXiShu": 1,
"RuQinQiangDuXiShu": 1,
"RuQinGuaiCountMin": 8,
"RuQinGuaiCountMax": 128,
"RuQinPerBoGuaiMin": 3,
"RuQinPerBoGuaiMax": 16,
"RuQinGuaiLevelXiShu": 1,
"TanChaMinuteLimit": 20,
"JinGongMinuteLimit": 90,
"LengQueMinuteLimit": 1440,
"ChongsuRatio": 1,
"RuQinMaxChangCiCount": 2,
"RuQinBeginHour": 0,
"RuQinEndHour": 24,
"RuQinShaoChengXiShu": 0.6000000238418579,
"RuQinTuShaXiShu": 0.30000001192092896,
"XiuMianDistance": 10000,
"HuanXingDistance": 9000,
"GongHuiMaxZhaoMuCount": 50,
"GeRenMaxZhaoMuCount": 6,
"GeRenMaxZhaoMuCount_Two": 10,
"GeRenMaxZhaoMuCount_Three": 15,
"XinQingZengZhang": 1,
"XinQingJianShao": 1,
"XiShuWeiLing": 0,
"YeShengHitJianZhuShangHaiRatio": 4,
"PVP_ShangHaiRatio_WithoutP2P_YouFang": 0,
"WanJiaHitJianZhuShangHaiRatio": 1,
"ShaGuaiExpShareRatio": 1,
"YunXuOtherDaKaiGongZuoTai": 0,
"YunXuOtherDaKaiXiangZi": 0,
"PVEOnlyTongGuiShuCanOpenKaiGuan": 1,
"PVP_ShangHaiRatio_JinZhan": 0.4000000059604645,
"PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645,
"GongHuiMaxDongWuCount": 50,
"GeRenMaxDongWuCount": 10,
"PanpaKaiGuan": 1,
"WanJiaBeiXiaoRenRatio": 0.800000011920929,
"WanJiaBeiXiaoTiRatio": 0.800000011920929,
"KaiQiJianZhuHuiXueBuilding": 1,
"PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1,
"PVP_GAPVPDamageRatio": 1,
"PlayerYouFangShangHaiKaiGuan": 1,
"YouFangShangHaiKaiGuan": 0,
"PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549,
"BaoXiangDiaoLuoDengJi": 0,
"KaiQiKuaFu": 0,
"FuHuaSpeed": 1,
"YingHuoRanShaoSuDuRatio": 1,
"HuDongExcludeBetweenCameraCharacter": 1,
"PVPTimeAsiaWorkStartTime": 11,
"PVPTimeAsiaWorkEndTime": 15,
"PVPTimeAsiaNoWorkStartTime": 11,
"PVPTimeAsiaNoWorkEndTime": 15,
"PVPTimeAmericaWorkStartTime": 1,
"PVPTimeAmericaWorkEndTime": 5,
"PVPTimeAmericaNoWorkStartTime": 1,
"PVPTimeAmericaNoWorkEndTime": 5,
"PVPTimeEuropeWorkStartTime": 18,
"PVPTimeEuropeWorkEndTime": 22,
"PVPTimeEuropeNoWorkStartTime": 18,
"PVPTimeEuropeNoWorkEndTime": 22,
"WuLiYouHuaDist": 6666,
"MovementYouHua": 1,
"XiuMianOfflineDays": 7,
"WuLiYouHuaKaiGuan": 1,
"TiaoWuLengQueTime": 4,
"MaxLevel": 60,
"XinXiLuRu": 5,
"TeShuDaoJuDropXiShuJiaChengKaiGuan": 0,
"MaxGenRenYingHuoNumber": 6,
"MaxGongHuiYingHuoNumber": 6,
"MaxChuanSongMenNumber": 10,
"JingShenNoXiaoHao": 0,
"MaxFuZhongRatio": 1,
"RoleBagCapacity": 60,
"JianZhuGaoDuLimit": 1,
"AIDengJi": 1,
"GeRenBiaoJiMaxCount": 20,
"GongHuiBiaoJiMaxCount": 20,
"ManRenChuZhanCount": 1,
"MakeUseAroundRongQiKaiGuan": 1,
"JianZhuBeDamageLimit": 1,
"JianZhuAroundNumLimit": 1,
"GongHuiMaxMember": 10,
"DynamicBossStats": 1,
"ZhaoHuanDisRatio": 1,
"DongWuChuZhanCount": 1,
"SuoDingKaiGuan": 1,
"ZuRenFuZhi": 1,
"TransDoorInterworkKaiGuan": 0,
"ConverPropsSpeedRatio": 5,
"MaxXiuMianCangCount": 50,
"RollingInvincibleTimeRatio": 1,
"MaxConvertCount": 3,
"AsiaWarTimeStart": 10,
"AsiaWarTimeEnd": 14,
"EuropeWarTimeStart": 17,
"EuropeWarTimeEnd": 21,
"AmericaWarTimeStart": 0,
"AmericaWarTimeEnd": 4,
"WarKaiGuan": 0,
"TribalExplorationKaiGuan": 1,
"RuinsExplorationKaiGuan": 1,
"TribalTransportSwitch": 1,
"SpecialBossSwitch": 1,
"RelicChestEventSwitch": 0,
"BossDeathEventSwitch": 0,
"KurmaFuZhongRatio": 1,
"GongHuiMaxSpecDongWuCount": 1,
"GeRenMaxSpecDongWuCount": 1,
"ProtectJianZhuInYingHuoSwitch": 0,
"SpecialEventConfigSwitch": 0,
"SpecialEventTriggerInterval": 3600,
"SpecialEventTriggerPercent": 50,
"SpecialEventTriggetLimitNum": 2,
"SpecialEventServerOpenDay": 1,
"SpecialEventAsiaStartTime": 10,
"SpecialEventAsiaEndTime": 16,
"SpecialEventEuropeStartTime": 17,
"SpecialEventEuropeEndTime": 23,
"SpecialEventAmericaStartTime": 23,
"SpecialEventAmericaEndTime": 5,
"SpecialEventGameDist": 0,
"MaskRepairUpgradeSwitch": 0,
"IsOpenGuideTask": 1,
"NormalEquipDropRatioCorrection": 0.10000000149011612,
"EliteEquipDropRatioCorrection": 0.20000000298023224,
"BossEquipDropRatioCorrection": 0.20000000298023224,
"NormalEquipDurabilityCorrection": 0.10000000149011612,
"EliteEquipDurabilityCorrection": 0.5,
"BossEquipDurabilityCorrection": 0.5,
"InitialDefaultAwarenessLevel": 1,
"FirstDayMaxAwarenessLevel": 29,
"SecondDayMaxAwarenessLevel": 34,
"ThirdDayMaxAwarenessLevel": 39,
"FourthDayMaxAwarenessLevel": 44,
"FifthDayMaxAwarenessLevel": 49,
"SixthDayMaxAwarenessLevel": 54,
"SeventhDayMaxAwarenessLevel": 59,
"EighthDayMaxAwarenessLevel": 60,
"NinthDayMaxAwarenessLevel": 60,
"TenthDayMaxAwarenessLevel": 60,
"MentalRecoveryRate": 1,
"PhysicalRecoveryIntervalRate": 1,
"DungeonReborn": 1,
"ManageModeRuQin": 1,
"ManageModeRuQinCountDownTimeRatio": 1,
"IsPlayBossAppearanceSequence": 1,
"PlayerDeathCantDropItemKaiGuan": 0,
"AnimalFollowerMaxCount": 1,
"DrawDebugDungeon": 0,
"RuQinSucceedPrizeTimes": 2,
"TrainingExpRatio": 1,
"BanGlider": 1,
"JianZhuMirageKaiGuan": 1,
"MaxConveyorCount": 1000,
"MaxDongLiKuangChangCount": 10,
"ManRenTenacityDamageRatio": 1,
"ManRenBossTiLiDamageRatio": 1,
"ManRenBossTenacityDamageRatio": 1,
"DongWuTiLiDamageRatio": 1,
"DongWuTenacityDamageRatio": 1,
"DongWuBossTiLiDamageRatio": 1,
"DongWuBossTenacityDamageRatio": 1,
"ManRenTiLiDamageRatio": 1,
"PingTaiBuildRangeLimit": 1,
"PingTaiJianZhuNumLimit": 1,
"GeRenMaxRaftSpaceCount": 2,
"GongHuiMaxRaftSpaceCount": 10,
"GeRenMaxSpecRaftSpaceCount": 1,
"GongHuiMaxSpecRaftSpaceCount": 2,
"MaxDiCiCount": 400,
"PingTaiAffectNavigation": 1,
"PlayerSweepRangeScale": 1,
"JiQiChuZhanKaiGuan": 0,
"ReboundDifficulty": 2,
"ZuRenDirectCunQu": 1,
"BagRepOptimizeSwitch": 1,
"JinJianQuKaiGuan": 1,
"ChuZhanZuRenShaGuaiExpShareRatio": 1,
"OtherShaGuaiExpShareRatio": 1,
"MaxPingTaiJianZhuNumMul": 3,
"CrewCountRatio": 1,
"IgnoreEnemyJianZhuInSelfYingHuo": 0,
"ShipBlueprintBuildConsumeSwitch": 1,
"ChestDropEquipmentMaxQualitySwitch": 0,
"CurProfInitRatio": 0,
"MainGunUseTimeCD": 2,
"RestartGameForceSpawnMonsterSwitch": 1,
"OpenEscMenuInfJianZao": 1,
"NewYingHuoTimeLenMul": 1,
"JianDuiRuQinKaiGuan": 0,
"ReleaseControlStatusCDRatio": 1
},
"1": {
"ExpRatio": {{ or .Values.gx_exp_ratio "1" }},
"ZuoWuDropRatio": 1.5,
"AddRenKeDuRatio": 5,
"ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }},
"BinSiKaiGuan": 1,
"JianZhuFuLanMul": 1,
"JianZhuXiuLiMul": 1,
"JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }},
"ZhiZuoTimeRatio": 5,
"DamageYeShengRatio": 1,
"BeDamageByYeShengRatio": 1,
"GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }},
"GameWorldTimePower": {{ or .Values.gx_day_length "24" }},
"BaoXiangDropRatio": 1.5,
"HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }},
"ZhiBeiChongShengRatio": 1,
"ChengZhangExpRatio": 1,
"MJExpRatio": 1,
"CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }},
"FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }},
"CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }},
"DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }},
"DongWuShiTiZhongYaoDiaoLuoRatio": 1.5,
"CaiJiShengChanJianZhuDiaoLuoRatio": 1.5,
"PuTongRenDiaoLuoRatio": 1.5,
"JingYingRenDiaoLuoRatio": 1.5,
"BossRenDiaoLuoRatio": 1.5,
"ShuLianDuExpRatio": 1.5,
"NaiJiuXiShu": 1,
"ReDuXiShu": 1,
"CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }},
"ZhiZuoExpRatio": 1,
"ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }},
"QiTaExpRatio": 1,
"BeiDongYiJiShuXingRatio": 1,
"ZhuDongYiJiShuXingRatio": 1,
"ErJiShuXingRatio": 1,
"MaRenBeiDongYiJiShuXingRatio": 1,
"MaRenZhuDongYiJiShuXingRatio": 1,
"MaRenErJiShuXingRatio": 1,
"DongWuBeiDongYiJiShuXingRatio": 1,
"DongWuZhuDongYiJiShuXingRatio": 1,
"DongWuErJiShuXingRatio": 1,
"ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }},
"ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }},
"QiXiXiaoHaoRatio": 1,
"ShengMingHuiFuRatio": 1,
"TiLiHuiFuRatio": 1,
"QiXiHuiFuRatio": 1,
"DongWuDamageRatio": 1,
"DongWuJianShangRatio": 1,
"MaRenDamageRatio": 1,
"ManRenJianShangRatio": 1,
"JiaSiHuiFuRatio": 1,
"CaiJiDamageRatio": 1,
"ZiYuanShengMingRatio": 1,
"RanLiaoXiaoHaoRatio": 1,
"DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }},
"FanZhiJianGeRatio": 1,
"DongWuShengChanJianGeRatio": 1.5,
"DongWuChanChuRatio": 1.5,
"DongWuXiaoHaoShiWuRatio": 1,
"DongWuXiaoHaoShuiRatio": 1,
"ZuoWuFeiLiaoXiaoHaoRatio": 1,
"ZuoWuShuiXiaoHaoRatio": 1,
"ZuoWuXiaoHuiRatio": 1,
"GongJiJianZhuDamageRatio": 1,
"DongWuPinZhiRatio": 1,
"ManRenPinZhiRatio": 1,
"WanJiaZiYuanJinShuaBanJing": 1,
"JianZhuZiYuanJinShuaBanJing": 1,
"WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }},
"WuPinXiaoHuiTime": 1,
"XiuLiXuYaoCaiLiaoRatio": 1,
"XiuLiJiangNaiJiuShangXianRatio": 1,
"HuiFuChuShiBodyData": 1,
"WanMeiChongSu": 1,
"FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }},
"JianZhuChuanSongMenPlusKaiGuan": 0,
"SuiJiRuQinKaiGuan": 0,
"RuQinKaiGuan": 0,
"ShuaXinNPCKaiGuan": 0,
"RuQinGuiMoXiShu": 1,
"RuQinQiangDuXiShu": 1,
"RuQinGuaiCountMin": 8,
"RuQinGuaiCountMax": 128,
"RuQinPerBoGuaiMin": 3,
"RuQinPerBoGuaiMax": 16,
"RuQinGuaiLevelXiShu": 1,
"TanChaMinuteLimit": 20,
"JinGongMinuteLimit": 90,
"LengQueMinuteLimit": 1440,
"ChongsuRatio": 1,
"RuQinMaxChangCiCount": 2,
"RuQinBeginHour": 0,
"RuQinEndHour": 24,
"RuQinShaoChengXiShu": 0.6000000238418579,
"RuQinTuShaXiShu": 0.30000001192092896,
"XiuMianDistance": 10000,
"HuanXingDistance": 9000,
"GongHuiMaxZhaoMuCount": 40,
"GeRenMaxZhaoMuCount": 6,
"GeRenMaxZhaoMuCount_Two": 10,
"GeRenMaxZhaoMuCount_Three": 15,
"XinQingZengZhang": 1,
"XinQingJianShao": 1,
"XiShuWeiLing": 0,
"YeShengHitJianZhuShangHaiRatio": 4,
"PVP_ShangHaiRatio_WithoutP2P_YouFang": 0,
"WanJiaHitJianZhuShangHaiRatio": 1,
"ShaGuaiExpShareRatio": 1,
"YunXuOtherDaKaiGongZuoTai": 0,
"YunXuOtherDaKaiXiangZi": 0,
"PVEOnlyTongGuiShuCanOpenKaiGuan": 1,
"PVP_ShangHaiRatio_JinZhan": 0.4000000059604645,
"PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645,
"GongHuiMaxDongWuCount": 50,
"GeRenMaxDongWuCount": 10,
"PanpaKaiGuan": 1,
"WanJiaBeiXiaoRenRatio": 0.800000011920929,
"WanJiaBeiXiaoTiRatio": 0.800000011920929,
"KaiQiJianZhuHuiXueBuilding": 1,
"PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1,
"PVP_GAPVPDamageRatio": 1,
"PlayerYouFangShangHaiKaiGuan": 1,
"YouFangShangHaiKaiGuan": 0,
"PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549,
"BaoXiangDiaoLuoDengJi": 0,
"KaiQiKuaFu": 0,
"FuHuaSpeed": 1,
"YingHuoRanShaoSuDuRatio": 1,
"HuDongExcludeBetweenCameraCharacter": 1,
"PVPTimeAsiaWorkStartTime": 11,
"PVPTimeAsiaWorkEndTime": 15,
"PVPTimeAsiaNoWorkStartTime": 11,
"PVPTimeAsiaNoWorkEndTime": 15,
"PVPTimeAmericaWorkStartTime": 1,
"PVPTimeAmericaWorkEndTime": 5,
"PVPTimeAmericaNoWorkStartTime": 1,
"PVPTimeAmericaNoWorkEndTime": 5,
"PVPTimeEuropeWorkStartTime": 18,
"PVPTimeEuropeWorkEndTime": 22,
"PVPTimeEuropeNoWorkStartTime": 18,
"PVPTimeEuropeNoWorkEndTime": 22,
"WuLiYouHuaDist": 6666,
"MovementYouHua": 1,
"XiuMianOfflineDays": 7,
"WuLiYouHuaKaiGuan": 1,
"TiaoWuLengQueTime": 4,
"MaxLevel": 60,
"XinXiLuRu": 5,
"TeShuDaoJuDropXiShuJiaChengKaiGuan": 0,
"MaxGenRenYingHuoNumber": 6,
"MaxGongHuiYingHuoNumber": 6,
"MaxChuanSongMenNumber": 10,
"JingShenNoXiaoHao": 0,
"MaxFuZhongRatio": 1,
"RoleBagCapacity": 60,
"JianZhuGaoDuLimit": 1,
"AIDengJi": 1,
"GeRenBiaoJiMaxCount": 20,
"GongHuiBiaoJiMaxCount": 20,
"ManRenChuZhanCount": 1,
"MakeUseAroundRongQiKaiGuan": 1,
"JianZhuBeDamageLimit": 1,
"JianZhuAroundNumLimit": 1,
"GongHuiMaxMember": 10,
"DynamicBossStats": 1,
"ZhaoHuanDisRatio": 1,
"DongWuChuZhanCount": 1,
"SuoDingKaiGuan": 1,
"ZuRenFuZhi": 1,
"TransDoorInterworkKaiGuan": 0,
"ConverPropsSpeedRatio": 5,
"MaxXiuMianCangCount": 50,
"RollingInvincibleTimeRatio": 1,
"MaxConvertCount": 3,
"AsiaWarTimeStart": 10,
"AsiaWarTimeEnd": 14,
"EuropeWarTimeStart": 17,
"EuropeWarTimeEnd": 21,
"AmericaWarTimeStart": 0,
"AmericaWarTimeEnd": 4,
"WarKaiGuan": 1,
"TribalExplorationKaiGuan": 1,
"RuinsExplorationKaiGuan": 1,
"TribalTransportSwitch": 1,
"SpecialBossSwitch": 1,
"RelicChestEventSwitch": 0,
"BossDeathEventSwitch": 0,
"KurmaFuZhongRatio": 1,
"GongHuiMaxSpecDongWuCount": 1,
"GeRenMaxSpecDongWuCount": 1,
"ProtectJianZhuInYingHuoSwitch": 0,
"SpecialEventConfigSwitch": 0,
"SpecialEventTriggerInterval": 3600,
"SpecialEventTriggerPercent": 50,
"SpecialEventTriggetLimitNum": 2,
"SpecialEventServerOpenDay": 1,
"SpecialEventAsiaStartTime": 10,
"SpecialEventAsiaEndTime": 16,
"SpecialEventEuropeStartTime": 17,
"SpecialEventEuropeEndTime": 23,
"SpecialEventAmericaStartTime": 23,
"SpecialEventAmericaEndTime": 5,
"SpecialEventGameDist": 0,
"MaskRepairUpgradeSwitch": 0,
"IsOpenGuideTask": 1,
"NormalEquipDropRatioCorrection": 0.10000000149011612,
"EliteEquipDropRatioCorrection": 0.20000000298023224,
"BossEquipDropRatioCorrection": 0.20000000298023224,
"NormalEquipDurabilityCorrection": 0.10000000149011612,
"EliteEquipDurabilityCorrection": 0.5,
"BossEquipDurabilityCorrection": 0.5,
"InitialDefaultAwarenessLevel": 1,
"FirstDayMaxAwarenessLevel": 29,
"SecondDayMaxAwarenessLevel": 34,
"ThirdDayMaxAwarenessLevel": 39,
"FourthDayMaxAwarenessLevel": 44,
"FifthDayMaxAwarenessLevel": 49,
"SixthDayMaxAwarenessLevel": 54,
"SeventhDayMaxAwarenessLevel": 59,
"EighthDayMaxAwarenessLevel": 60,
"NinthDayMaxAwarenessLevel": 60,
"TenthDayMaxAwarenessLevel": 60,
"MentalRecoveryRate": 1,
"PhysicalRecoveryIntervalRate": 1,
"DungeonReborn": 1,
"ManageModeRuQin": 0,
"ManageModeRuQinCountDownTimeRatio": 1,
"IsPlayBossAppearanceSequence": 1,
"PlayerDeathCantDropItemKaiGuan": 0,
"AnimalFollowerMaxCount": 1,
"DrawDebugDungeon": 0,
"RuQinSucceedPrizeTimes": 3,
"TrainingExpRatio": 5,
"BanGlider": 1,
"JianZhuMirageKaiGuan": 1,
"MaxConveyorCount": 1000,
"MaxDongLiKuangChangCount": 10,
"ManRenTenacityDamageRatio": 0.5,
"ManRenBossTiLiDamageRatio": 1,
"ManRenBossTenacityDamageRatio": 0.5,
"DongWuTiLiDamageRatio": 1,
"DongWuTenacityDamageRatio": 0.5,
"DongWuBossTiLiDamageRatio": 1,
"DongWuBossTenacityDamageRatio": 0.5,
"ManRenTiLiDamageRatio": 1,
"PingTaiBuildRangeLimit": 1,
"PingTaiJianZhuNumLimit": 1,
"GeRenMaxRaftSpaceCount": 2,
"GongHuiMaxRaftSpaceCount": 10,
"GeRenMaxSpecRaftSpaceCount": 1,
"GongHuiMaxSpecRaftSpaceCount": 2,
"MaxDiCiCount": 400,
"PingTaiAffectNavigation": 1,
"PlayerSweepRangeScale": 1,
"JiQiChuZhanKaiGuan": 0,
"ReboundDifficulty": 2,
"ZuRenDirectCunQu": 1,
"BagRepOptimizeSwitch": 1,
"JinJianQuKaiGuan": 1,
"ChuZhanZuRenShaGuaiExpShareRatio": 1,
"OtherShaGuaiExpShareRatio": 1,
"MaxPingTaiJianZhuNumMul": 0.10000000149011612,
"CrewCountRatio": 1,
"IgnoreEnemyJianZhuInSelfYingHuo": 0,
"ShipBlueprintBuildConsumeSwitch": 1,
"ChestDropEquipmentMaxQualitySwitch": 0,
"CurProfInitRatio": 0,
"MainGunUseTimeCD": 2,
"RestartGameForceSpawnMonsterSwitch": 1,
"OpenEscMenuInfJianZao": 0,
"NewYingHuoTimeLenMul": 1,
"JianDuiRuQinKaiGuan": 0,
"ReleaseControlStatusCDRatio": 1
},
"2": {
"ExpRatio": {{ or .Values.gx_exp_ratio "1" }},
"ZuoWuDropRatio": 1,
"AddRenKeDuRatio": 5,
"ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }},
"BinSiKaiGuan": 1,
"JianZhuFuLanMul": 1,
"JianZhuXiuLiMul": 1,
"JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }},
"ZhiZuoTimeRatio": 5,
"DamageYeShengRatio": 1,
"BeDamageByYeShengRatio": 1,
"GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }},
"GameWorldTimePower": {{ or .Values.gx_day_length "24" }},
"BaoXiangDropRatio": 1,
"HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }},
"ZhiBeiChongShengRatio": 1,
"ChengZhangExpRatio": 1,
"MJExpRatio": 1,
"CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }},
"FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }},
"CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }},
"DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }},
"DongWuShiTiZhongYaoDiaoLuoRatio": 1,
"CaiJiShengChanJianZhuDiaoLuoRatio": 1,
"PuTongRenDiaoLuoRatio": 1,
"JingYingRenDiaoLuoRatio": 1,
"BossRenDiaoLuoRatio": 1,
"ShuLianDuExpRatio": 1,
"NaiJiuXiShu": 1,
"ReDuXiShu": 1,
"CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }},
"ZhiZuoExpRatio": 1,
"ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }},
"QiTaExpRatio": 1,
"BeiDongYiJiShuXingRatio": 1,
"ZhuDongYiJiShuXingRatio": 1,
"ErJiShuXingRatio": 1,
"MaRenBeiDongYiJiShuXingRatio": 1,
"MaRenZhuDongYiJiShuXingRatio": 1,
"MaRenErJiShuXingRatio": 1,
"DongWuBeiDongYiJiShuXingRatio": 1,
"DongWuZhuDongYiJiShuXingRatio": 1,
"DongWuErJiShuXingRatio": 1,
"ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }},
"ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }},
"QiXiXiaoHaoRatio": 1,
"ShengMingHuiFuRatio": 1,
"TiLiHuiFuRatio": 1,
"QiXiHuiFuRatio": 1,
"DongWuDamageRatio": 1,
"DongWuJianShangRatio": 1,
"MaRenDamageRatio": 1,
"ManRenJianShangRatio": 1,
"JiaSiHuiFuRatio": 1,
"CaiJiDamageRatio": 1,
"ZiYuanShengMingRatio": 1,
"RanLiaoXiaoHaoRatio": 1,
"DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }},
"FanZhiJianGeRatio": 1,
"DongWuShengChanJianGeRatio": 1,
"DongWuChanChuRatio": 1,
"DongWuXiaoHaoShiWuRatio": 1,
"DongWuXiaoHaoShuiRatio": 1,
"ZuoWuFeiLiaoXiaoHaoRatio": 1,
"ZuoWuShuiXiaoHaoRatio": 1,
"ZuoWuXiaoHuiRatio": 1,
"GongJiJianZhuDamageRatio": 1,
"DongWuPinZhiRatio": 1,
"ManRenPinZhiRatio": 1,
"WanJiaZiYuanJinShuaBanJing": 1,
"JianZhuZiYuanJinShuaBanJing": 1,
"WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }},
"WuPinXiaoHuiTime": 1,
"XiuLiXuYaoCaiLiaoRatio": 1,
"XiuLiJiangNaiJiuShangXianRatio": 1,
"HuiFuChuShiBodyData": 1,
"WanMeiChongSu": 1,
"FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }},
"JianZhuChuanSongMenPlusKaiGuan": 0,
"ShuaXinNPCKaiGuan": 0,
"SuiJiRuQinKaiGuan": 0,
"RuQinKaiGuan": 0,
"RuQinGuiMoXiShu": 1,
"RuQinQiangDuXiShu": 1,
"RuQinGuaiCountMin": 8,
"RuQinGuaiCountMax": 128,
"RuQinPerBoGuaiMin": 3,
"RuQinPerBoGuaiMax": 16,
"RuQinGuaiLevelXiShu": 1,
"TanChaMinuteLimit": 20,
"JinGongMinuteLimit": 90,
"LengQueMinuteLimit": 1440,
"ChongsuRatio": 1,
"RuQinMaxChangCiCount": 2,
"RuQinBeginHour": 0,
"RuQinEndHour": 24,
"RuQinShaoChengXiShu": 0.6000000238418579,
"RuQinTuShaXiShu": 0.30000001192092896,
"XiuMianDistance": 10000,
"HuanXingDistance": 9000,
"GongHuiMaxZhaoMuCount": 50,
"GeRenMaxZhaoMuCount": 6,
"GeRenMaxZhaoMuCount_Two": 10,
"GeRenMaxZhaoMuCount_Three": 15,
"XinQingZengZhang": 1,
"XinQingJianShao": 1,
"XiShuWeiLing": 0,
"YeShengHitJianZhuShangHaiRatio": 4,
"PVP_ShangHaiRatio_WithoutP2P_YouFang": 0,
"WanJiaHitJianZhuShangHaiRatio": 1,
"ShaGuaiExpShareRatio": 1,
"YunXuOtherDaKaiGongZuoTai": 0,
"YunXuOtherDaKaiXiangZi": 0,
"PVEOnlyTongGuiShuCanOpenKaiGuan": 1,
"PVP_ShangHaiRatio_JinZhan": 0.4000000059604645,
"PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645,
"GongHuiMaxDongWuCount": 50,
"GeRenMaxDongWuCount": 10,
"PanpaKaiGuan": 1,
"WanJiaBeiXiaoRenRatio": 0.800000011920929,
"WanJiaBeiXiaoTiRatio": 0.800000011920929,
"KaiQiJianZhuHuiXueBuilding": 1,
"PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1,
"PVP_GAPVPDamageRatio": 1,
"PlayerYouFangShangHaiKaiGuan": 1,
"YouFangShangHaiKaiGuan": 0,
"PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549,
"BaoXiangDiaoLuoDengJi": 0,
"KaiQiKuaFu": 0,
"FuHuaSpeed": 1,
"YingHuoRanShaoSuDuRatio": 1,
"HuDongExcludeBetweenCameraCharacter": 1,
"PVPTimeAsiaWorkStartTime": 11,
"PVPTimeAsiaWorkEndTime": 15,
"PVPTimeAsiaNoWorkStartTime": 11,
"PVPTimeAsiaNoWorkEndTime": 15,
"PVPTimeAmericaWorkStartTime": 1,
"PVPTimeAmericaWorkEndTime": 5,
"PVPTimeAmericaNoWorkStartTime": 1,
"PVPTimeAmericaNoWorkEndTime": 5,
"PVPTimeEuropeWorkStartTime": 18,
"PVPTimeEuropeWorkEndTime": 22,
"PVPTimeEuropeNoWorkStartTime": 18,
"PVPTimeEuropeNoWorkEndTime": 22,
"WuLiYouHuaDist": 6666,
"MovementYouHua": 1,
"XiuMianOfflineDays": 7,
"WuLiYouHuaKaiGuan": 1,
"TiaoWuLengQueTime": 4,
"MaxLevel": 60,
"XinXiLuRu": 5,
"TeShuDaoJuDropXiShuJiaChengKaiGuan": 0,
"MaxGenRenYingHuoNumber": 6,
"MaxGongHuiYingHuoNumber": 6,
"MaxChuanSongMenNumber": 10,
"JingShenNoXiaoHao": 0,
"MaxFuZhongRatio": 1,
"RoleBagCapacity": 60,
"JianZhuGaoDuLimit": 1,
"AIDengJi": 1,
"GeRenBiaoJiMaxCount": 20,
"GongHuiBiaoJiMaxCount": 20,
"ManRenChuZhanCount": 1,
"MakeUseAroundRongQiKaiGuan": 1,
"JianZhuBeDamageLimit": 1,
"JianZhuAroundNumLimit": 1,
"GongHuiMaxMember": 10,
"DynamicBossStats": 1,
"ZhaoHuanDisRatio": 1,
"DongWuChuZhanCount": 1,
"SuoDingKaiGuan": 1,
"ZuRenFuZhi": 1,
"TransDoorInterworkKaiGuan": 0,
"ConverPropsSpeedRatio": 1,
"MaxXiuMianCangCount": 50,
"RollingInvincibleTimeRatio": 1,
"MaxConvertCount": 3,
"AsiaWarTimeStart": 10,
"AsiaWarTimeEnd": 14,
"EuropeWarTimeStart": 17,
"EuropeWarTimeEnd": 21,
"AmericaWarTimeStart": 0,
"AmericaWarTimeEnd": 4,
"WarKaiGuan": 1,
"TribalExplorationKaiGuan": 1,
"RuinsExplorationKaiGuan": 1,
"TribalTransportSwitch": 1,
"SpecialBossSwitch": 1,
"RelicChestEventSwitch": 0,
"BossDeathEventSwitch": 0,
"KurmaFuZhongRatio": 1,
"GongHuiMaxSpecDongWuCount": 1,
"GeRenMaxSpecDongWuCount": 1,
"ProtectJianZhuInYingHuoSwitch": 0,
"SpecialEventConfigSwitch": 0,
"SpecialEventTriggerInterval": 3600,
"SpecialEventTriggerPercent": 50,
"SpecialEventTriggetLimitNum": 2,
"SpecialEventServerOpenDay": 1,
"SpecialEventAsiaStartTime": 10,
"SpecialEventAsiaEndTime": 16,
"SpecialEventEuropeStartTime": 17,
"SpecialEventEuropeEndTime": 23,
"SpecialEventAmericaStartTime": 23,
"SpecialEventAmericaEndTime": 5,
"SpecialEventGameDist": 0,
"MaskRepairUpgradeSwitch": 0,
"IsOpenGuideTask": 1,
"NormalEquipDropRatioCorrection": 0.10000000149011612,
"EliteEquipDropRatioCorrection": 0.20000000298023224,
"BossEquipDropRatioCorrection": 0.20000000298023224,
"BossEquipDurabilityCorrection": 0.5,
"EliteEquipDurabilityCorrection": 0.5,
"NormalEquipDurabilityCorrection": 0.10000000149011612,
"InitialDefaultAwarenessLevel": 1,
"FirstDayMaxAwarenessLevel": 29,
"SecondDayMaxAwarenessLevel": 34,
"ThirdDayMaxAwarenessLevel": 39,
"FourthDayMaxAwarenessLevel": 44,
"FifthDayMaxAwarenessLevel": 49,
"SixthDayMaxAwarenessLevel": 54,
"SeventhDayMaxAwarenessLevel": 59,
"EighthDayMaxAwarenessLevel": 60,
"NinthDayMaxAwarenessLevel": 60,
"TenthDayMaxAwarenessLevel": 60,
"MentalRecoveryRate": 1,
"PhysicalRecoveryIntervalRate": 1,
"DungeonReborn": 1,
"ManageModeRuQin": 1,
"ManageModeRuQinCountDownTimeRatio": 1,
"IsPlayBossAppearanceSequence": 1,
"PlayerDeathCantDropItemKaiGuan": 0,
"AnimalFollowerMaxCount": 1,
"DrawDebugDungeon": 0,
"RuQinSucceedPrizeTimes": 2,
"TrainingExpRatio": 5,
"BanGlider": 1,
"JianZhuMirageKaiGuan": 1,
"MaxConveyorCount": 1000,
"MaxDongLiKuangChangCount": 10,
"ManRenTenacityDamageRatio": 1,
"ManRenBossTiLiDamageRatio": 1,
"ManRenBossTenacityDamageRatio": 1,
"DongWuTiLiDamageRatio": 1,
"DongWuTenacityDamageRatio": 1,
"DongWuBossTiLiDamageRatio": 1,
"DongWuBossTenacityDamageRatio": 1,
"ManRenTiLiDamageRatio": 1,
"PingTaiBuildRangeLimit": 1,
"PingTaiJianZhuNumLimit": 1,
"GeRenMaxRaftSpaceCount": 2,
"GongHuiMaxRaftSpaceCount": 10,
"GeRenMaxSpecRaftSpaceCount": 1,
"GongHuiMaxSpecRaftSpaceCount": 2,
"MaxDiCiCount": 400,
"PingTaiAffectNavigation": 1,
"PlayerSweepRangeScale": 1,
"JiQiChuZhanKaiGuan": 0,
"ReboundDifficulty": 2,
"ZuRenDirectCunQu": 1,
"BagRepOptimizeSwitch": 1,
"JinJianQuKaiGuan": 1,
"ChuZhanZuRenShaGuaiExpShareRatio": 1,
"OtherShaGuaiExpShareRatio": 1,
"MaxPingTaiJianZhuNumMul": 3,
"CrewCountRatio": 1,
"IgnoreEnemyJianZhuInSelfYingHuo": 0,
"ShipBlueprintBuildConsumeSwitch": 1,
"ChestDropEquipmentMaxQualitySwitch": 0,
"CurProfInitRatio": 0,
"MainGunUseTimeCD": 2,
"RestartGameForceSpawnMonsterSwitch": 1,
"OpenEscMenuInfJianZao": 1,
"NewYingHuoTimeLenMul": 1,
"JianDuiRuQinKaiGuan": 0,
"ReleaseControlStatusCDRatio": 1
}
}
+42
View File
@@ -0,0 +1,42 @@
# SteamCMD sidecar test module.
#
# Uses Half-Life 1 Dedicated Server (app_id=70, ~200MB) as a small-but-real
# SteamCMD download to verify the panel's sidecar updater end-to-end.
# The "main" container is just an alpine sleeper holding open the volume —
# we never need to start HLDS; we only care that the panel successfully
# spawns a steamcmd sidecar, mounts the shared volume, streams install
# progress, and exits cleanly.
id: steamcmd-test
name: "SteamCMD test (HLDS)"
version: 0.1.0
supported_modes:
- docker
runtime:
docker:
image: alpine:3.21
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
command: [sleep, "86400"]
browseable_root: /data
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-data", container: "/data" }
ports:
- { name: p, proto: tcp, default: 14000 }
resources:
min_ram_mb: 64
recommended_ram_mb: 128
update_providers:
- id: hlds
kind: steamcmd
app_id: "90"
install_path: "/data"
# --- 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.
appearance:
emoji: "⚙️"
grad: "linear-gradient(135deg,#1e3a8a,#3b82f6)"
+30
View File
@@ -0,0 +1,30 @@
# panel-native Terraria runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
unzip \
&& 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/*
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
EXPOSE 7777/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# panel-native Terraria entrypoint.
set -euo pipefail
log() { printf '[panel-terraria] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
WORLD_NAME="${WORLD_NAME:-DedicatedWorld}" \
WORLD_SIZE="${WORLD_SIZE:-2}" \
MAX_PLAYERS="${MAX_PLAYERS:-8}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
GAME_PORT="${GAME_PORT:-7777}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
# The panel's direct update provider drops the raw zip at
# /game-saves/terraria-server.zip (it does not extract archives).
# Unpack it into /game on boot, then delete the zip.
ZIP="${SAVE_DIR}/terraria-server.zip"
if [[ -s "${ZIP}" ]]; then
log "extracting $(basename "${ZIP}") into ${GAME_DIR}"
unzip -o -q "${ZIP}" -d "${GAME_DIR}"
rm -f "${ZIP}"
fi
# Terraria's zip extracts into /game/<version>/{Linux,Mac,Windows}. Find
# the Linux dir regardless of version folder name.
LINUX_DIR=$(find "${GAME_DIR}" -maxdepth 3 -type d -name Linux 2>/dev/null | head -n1 || true)
if [[ -z "${LINUX_DIR}" ]]; then
log "ERROR: Terraria Linux server binary not found under /game."
log " Run 'Update' in the panel to download Terraria headless server."
exit 78
fi
EXE="${LINUX_DIR}/TerrariaServer.bin.x86_64"
[[ -x "${EXE}" ]] || chmod +x "${EXE}" 2>/dev/null || true
# Terraria reads serverconfig.txt if -config is passed. Seed a minimal one
# with our env vars so the server starts without interactive prompts.
CONFIG="${SAVE_DIR}/serverconfig.txt"
if [[ ! -s "${CONFIG}" ]]; then
log "seeding ${CONFIG}"
{
echo "world=${SAVE_DIR}/Worlds/${WORLD_NAME}.wld"
echo "worldname=${WORLD_NAME}"
echo "autocreate=${WORLD_SIZE}"
echo "maxplayers=${MAX_PLAYERS}"
echo "port=${GAME_PORT:-7777}"
echo "motd=Running on panel"
echo "difficulty=1"
[[ -n "${SERVER_PASSWORD}" ]] && echo "password=${SERVER_PASSWORD}" || true
} > "${CONFIG}"
fi
mkdir -p "${SAVE_DIR}/Worlds"
cd "${LINUX_DIR}"
log "starting Terraria dedicated server"
log " world: ${WORLD_NAME} (size ${WORLD_SIZE})"
log " config: ${CONFIG}"
log " port: ${GAME_PORT:-7777}"
# -port on the command line overrides serverconfig.txt, so a panel port
# change takes effect even when a stale config was seeded on a prior boot.
exec stdbuf -oL -eL "${EXE}" -config "${CONFIG}" -port "${GAME_PORT:-7777}"
+104
View File
@@ -0,0 +1,104 @@
# Terraria — panel-native dedicated server module.
#
# Uses direct download from terraria.org (headless zip). Vanilla Terraria
# has no RCON — admin console is stdin. Ships without RCON/Players tab
# for v1 (same story as Empyrion / Barotrauma). Users running the
# tShock fork add Source RCON and can switch adapters later.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (Terraria.kvp)
id: terraria
name: "Terraria"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-terraria:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Worlds/, serverconfig.txt"
- name: "Game Files"
path: /game
hint: "Terraria binaries"
env:
WORLD_NAME: "DedicatedWorld"
WORLD_SIZE: "2" # 1=small, 2=medium, 3=large
MAX_PLAYERS: "8"
SERVER_PASSWORD: ""
# Pre-declared so the agent's resolve.go filter lets the panel-allocated
# port flow through to the container env. The entrypoint passes it as
# -port (and seeds serverconfig.txt); without it the server binds the
# hardcoded 7777 and ignores the panel allocation. Default matches the
# port default.
GAME_PORT: "7777"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
- { name: game, proto: tcp, default: 7777, required: true, env: GAME_PORT }
resources:
min_ram_mb: 1024
recommended_ram_mb: 2048
rcon:
# Vanilla Terraria admin is stdin-only. Commands like "say", "save",
# "exit" go straight to the server process.
adapter: stdio
commands:
save: "save"
say: "say {msg}"
kick: "kick {player}"
shutdown: "exit"
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (terraria.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: '^(?P<name>.+?) has joined\.$'
kind: join
leave:
pattern: '^(?P<name>.+?) has left\.$'
kind: leave
chat:
pattern: '^<(?P<name>.+?)> (?P<msg>.+)$'
kind: chat
update_providers:
# Terraria ships headless Linux server as a versioned zip from terraria.org.
# AMP hard-codes the latest-known URL; we take the same approach. Bump when
# Re-Logic cuts a new version. NOTE: the direct provider does NOT extract —
# it writes the raw bytes to BrowseableRoot+target_path (so this lands at
# /game-saves/terraria-server.zip). The entrypoint unzips it into /game on
# the next boot and deletes the zip.
- id: stable
kind: direct
url: "https://terraria.org/api/download/pc-dedicated-server/terraria-server-1449.zip"
target_path: /terraria-server.zip
# --- 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: '/Server started|Listening on port \d+/i'
appearance:
emoji: "🌳"
grad: "linear-gradient(135deg,#2d7141,#78c87a)"
art: "/game-art/terraria.jpg"
steam: "105600"
+41
View File
@@ -0,0 +1,41 @@
# panel-native V Rising runtime image (Wine).
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
xvfb \
xauth \
wine64 \
wine \
winbind \
cabextract \
procps \
&& 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/*
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 /home/panel
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 9876/udp 9877/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# panel-native V Rising entrypoint (Wine).
#
# CLI flags > JSON in V Rising's override precedence, so we drive every
# operator-facing knob via -flag args from env vars rather than rewriting
# ServerHostSettings.json on each boot. The shipped JSON in StreamingAssets/
# stays untouched; operators who want individual ServerGameSettings.json
# edits can ssh in / use the Files tab and edit the seeded copy in
# /game-saves/Settings — but only if GAME_SETTINGS_PRESET is empty (a
# non-empty preset overrides individual edits silently — Stunlock gotcha).
set -euo pipefail
log() { printf '[panel-v-rising] %s\n' "$*"; }
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
# Hand off to panel user. setpriv preserves env, so all the SERVER_*
# / GAME_* / RCON_* knobs the panel manifest sets reach the child.
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/VRisingServer.exe"
SETTINGS_SRC="${GAME_DIR}/VRisingServer_Data/StreamingAssets/Settings"
SETTINGS_DST="${SAVE_DIR}/Settings"
if [[ ! -f "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install V Rising (Windows build) via SteamCMD."
exit 78
fi
# Seed ServerHostSettings.json + ServerGameSettings.json into save volume
# the first time only. After that the operator can edit them by hand for
# settings the panel doesn't expose; CLI flags below still override the
# operator-facing keys (Name, Description, ports, password, max players).
mkdir -p "${SETTINGS_DST}"
if [[ ! -s "${SETTINGS_DST}/ServerHostSettings.json" && -d "${SETTINGS_SRC}" ]]; then
log "seeding ${SETTINGS_DST} from shipped defaults"
cp -r "${SETTINGS_SRC}/." "${SETTINGS_DST}/" || true
fi
if [[ ! -f "${WINEPREFIX}/system.reg" ]]; then
log "initializing Wine prefix at ${WINEPREFIX}"
xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true
wineserver -w || true
fi
trap 'log "SIGTERM"; wineserver -k 2>/dev/null || true; exit 0' TERM INT
# ---- Env defaults (mirrors module.yaml's env: block) ----
SERVER_NAME="${SERVER_NAME:-panel V Rising}"
SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-}"
SERVER_PASSWORD="${SERVER_PASSWORD:-}"
MAX_PLAYERS="${MAX_PLAYERS:-40}"
MAX_ADMINS="${MAX_ADMINS:-4}"
SAVE_NAME="${SAVE_NAME:-panel}"
LIST_ON_STEAM="${LIST_ON_STEAM:-true}"
LIST_ON_EOS="${LIST_ON_EOS:-true}"
SECURE="${SECURE:-true}"
SERVER_FPS="${SERVER_FPS:-30}"
LOWER_FPS_WHEN_EMPTY="${LOWER_FPS_WHEN_EMPTY:-true}"
GAME_SETTINGS_PRESET="${GAME_SETTINGS_PRESET:-}"
GAME_DIFFICULTY_PRESET="${GAME_DIFFICULTY_PRESET:-}"
GAME_PORT="${GAME_PORT:-9876}"
QUERY_PORT="${QUERY_PORT:-9877}"
RCON_ENABLED="${RCON_ENABLED:-false}"
RCON_PORT="${RCON_PORT:-25575}"
RCON_PASSWORD="${RCON_PASSWORD:-}"
# ---- Build CLI args ----
ARGS=(
-persistentDataPath "${SAVE_DIR}"
-saveName "${SAVE_NAME}"
-serverName "${SERVER_NAME}"
-gamePort "${GAME_PORT}"
-queryPort "${QUERY_PORT}"
-maxUsers "${MAX_PLAYERS}"
-maxAdmins "${MAX_ADMINS}"
-listOnSteam "${LIST_ON_STEAM}"
-listOnEOS "${LIST_ON_EOS}"
-secure "${SECURE}"
-targetFps "${SERVER_FPS}"
-lowerFPSWhenEmpty "${LOWER_FPS_WHEN_EMPTY}"
-logFile "-"
-batchmode -nographics
)
[[ -n "${SERVER_DESCRIPTION}" ]] && ARGS+=(-description "${SERVER_DESCRIPTION}")
[[ -n "${SERVER_PASSWORD}" ]] && ARGS+=(-password "${SERVER_PASSWORD}")
[[ -n "${GAME_SETTINGS_PRESET}" ]] && ARGS+=(-preset "${GAME_SETTINGS_PRESET}")
[[ -n "${GAME_DIFFICULTY_PRESET}" ]] && ARGS+=(-difficultyPreset "${GAME_DIFFICULTY_PRESET}")
if [[ "${RCON_ENABLED}" == "true" ]]; then
ARGS+=(-rconEnabled true -rconPort "${RCON_PORT}" -rconPassword "${RCON_PASSWORD}")
fi
cd "${GAME_DIR}"
log "starting V Rising dedicated server"
log " name: ${SERVER_NAME}"
log " ports: ${GAME_PORT}/${QUERY_PORT} (game/query)"
log " slots: ${MAX_PLAYERS}"
log " preset: ${GAME_SETTINGS_PRESET:-(none)}"
log " rcon: ${RCON_ENABLED} (port ${RCON_PORT})"
# xvfb-run -a auto-allocates a free display — host has its own Xvfb on
# :99 / :100 / :101, see panel/memory/gotchas.md.
exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \
wine "${EXE}" "${ARGS[@]}"
+121
View File
@@ -0,0 +1,121 @@
# V Rising — panel-native dedicated server module (Wine).
#
# Windows-only dedicated server. Same pattern as Empyrion:
# Debian + Wine + Xvfb + tini, SteamCMD with platform:windows.
#
# Reference: https://github.com/StunlockStudios/vrising-dedicated-server-instructions
# CLI/env-var flags override JSON, so the panel ships its config via env →
# entrypoint flags rather than rewriting ServerHostSettings.json. Avoids
# the per-boot JSON-rewrite race that bit ARK SA's INI flow.
id: v-rising
name: "V Rising"
version: 0.2.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-v-rising:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Settings/ServerHostSettings.json + Saves/"
- name: "Game Files"
path: /game
hint: "Server binaries"
env:
# ---- Host settings (ServerHostSettings.json equivalents) ----
SERVER_NAME: "panel V Rising"
SERVER_DESCRIPTION: "A V Rising server hosted by panel."
SERVER_PASSWORD: "" # blank = open server
MAX_PLAYERS: "40"
MAX_ADMINS: "4"
SAVE_NAME: "panel"
LIST_ON_STEAM: "true"
LIST_ON_EOS: "true"
SECURE: "true" # VAC
SERVER_FPS: "30"
LOWER_FPS_WHEN_EMPTY: "true"
# ---- Game-rule presets (set GAME_SETTINGS_PRESET to "" if you
# want to edit individual rules in ServerGameSettings.json
# manually — otherwise the preset wins silently) ----
GAME_SETTINGS_PRESET: "" # e.g. StandardPvP, StandardPvE, Duo_PvP, Solo_PvP, HardcorePvP
GAME_DIFFICULTY_PRESET: "" # e.g. (engine accepts files in StreamingAssets/GameSettingPresets/)
# ---- Port mappings (mirrored by the agent's port allocator) ----
GAME_PORT: "9876"
QUERY_PORT: "9877"
# ---- RCON (off by default — turn on per-instance once you need it) ----
RCON_ENABLED: "false"
RCON_PORT: "25575"
RCON_PASSWORD: ""
# ---- Legacy panel default; entrypoint reads the new keys above ----
ADMIN_PASSWORD: "changeme"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
ports:
# `env:` tells the agent which container env var to set with the
# allocated host port. The entrypoint uses these to assemble VRising's
# `-gamePort` / `-queryPort` launch flags so multiple instances on the
# same agent get distinct, non-colliding sockets.
- { name: game, proto: udp, default: 9876, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 9877, required: true, env: QUERY_PORT }
resources:
min_ram_mb: 6144
recommended_ram_mb: 12288
# What gets archived when an operator clicks Back-up-now. The save
# directory contains the world (Saves/) and the per-server settings
# (Settings/). The entrypoint regenerates Settings/ from defaults if
# absent, so technically only Saves/ has to be preserved — but operators
# expect their custom server name + password to come back too.
backup:
include:
- "Saves" # world data
- "Settings" # ServerHostSettings.json + ServerGameSettings.json
exclude:
- "logs" # rotates with operator + entrypoint
notes: "World saves + settings (~MB-scale). Game install regenerates from SteamCMD next start."
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (v-rising.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'User ''\{Steam (?P<id>\d+)\}'' ''\d+'', approvedUserIndex: \d+, Character: ''(?P<name>[^'']*)'' connected'
kind: join
leave:
pattern: 'User ''\{Steam (?P<id>\d+)\}'' disconnected\. approvedUserIndex: \d+ Reason: (?P<reason>.+)$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "1829350"
platform: windows
install_path: /game
# --- 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: '/\[Server\]\s*Startup Completed/i'
appearance:
emoji: "🧛"
grad: "linear-gradient(135deg,#3b0a0a,#991b1b)"
art: "/game-art/v-rising.jpg"
steam: "1604030"
+35
View File
@@ -0,0 +1,35 @@
# panel-native Valheim runtime image.
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
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 \
libpulse0 \
libatomic1 \
&& 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/*
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
EXPOSE 2456/udp 2457/udp 2458/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
# panel-native Valheim entrypoint.
set -euo pipefail
log() { printf '[panel-valheim] %s\n' "$*"; }
# Root-stage: chown (SteamCMD sidecar wrote as root) then drop to panel.
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SERVER_NAME="${SERVER_NAME:-panel Valheim}" \
SERVER_WORLD="${SERVER_WORLD:-Dedicated}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-secret12}" \
SERVER_PUBLIC="${SERVER_PUBLIC:-1}" \
/entrypoint.sh "$@"
fi
GAME_DIR=/game
SAVE_DIR=/game-saves
EXE="${GAME_DIR}/valheim_server.x86_64"
CFG="${SAVE_DIR}/server.cfg"
if [[ ! -x "${EXE}" ]]; then
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Valheim via SteamCMD."
exit 78
fi
# --- settings source of truth: PANEL ENV (config_values) ---
# The panel is the single editing surface (gamehost Settings page writes
# config_values → these env vars on recreate). The env values ALWAYS win.
#
# We REGENERATE /game-saves/server.cfg from the live env on every boot, purely
# as a human-readable reflection of the active settings — it is NOT sourced
# back in. The previous version seeded it once and then SOURCED it on every
# boot, so a stale server.cfg silently overrode every panel edit (a customer's
# renamed server / new password never took effect, and the Settings page showed
# values that disagreed with the running server). Fixed 2026-06-02.
cat > "${CFG}" <<EOF
# AUTO-GENERATED from the panel on every boot — DO NOT EDIT (changes are
# overwritten). Edit settings from the gamehost Settings page instead.
SERVER_NAME="${SERVER_NAME}"
SERVER_WORLD="${SERVER_WORLD}"
SERVER_PASSWORD="${SERVER_PASSWORD}"
SERVER_PUBLIC=${SERVER_PUBLIC}
SERVER_SAVEINTERVAL=${SERVER_SAVEINTERVAL:-1800}
SERVER_PRESET="${SERVER_PRESET:-}"
SERVER_MODIFIERS="${SERVER_MODIFIERS:-}"
EOF
# Valheim writes worlds + adminlist.txt under $HOME/.config/unity3d/IronGate/Valheim.
# HOME is already /game-saves via setpriv; pre-create so perms are right.
mkdir -p "${SAVE_DIR}/.config/unity3d/IronGate/Valheim"
export LD_LIBRARY_PATH="${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}"
export SteamAppId=892970 # as per Valheim docs (892970, not the dedicated app id)
# Port: bind the panel-allocated host port (host networking → the game listens
# directly on the LAN at this port). The panel injects PORT_GAME via the
# `env:` mapping on the `game` ports entry in module.yaml; fall back to 2456
# only if it's somehow unset (e.g. a hand-run container). Valheim opens
# ${GAME_PORT} for gameplay and ${GAME_PORT}+1 for the Steam server query, so
# the public chain must forward BOTH (panel allocates them contiguously).
GAME_PORT="${PORT_GAME:-2456}"
# --- optional flags: only appended when the corresponding env var is set, so
# an empty value never injects a broken flag. These are surfaced as curated
# fields in the gamehost Settings page.
EXTRA_ARGS=()
# Crossplay is intentionally DISABLED and not honoured. Valheim's -crossplay
# switches the server to PlayFab relay networking and opens NO local inbound
# listening port ("no inbound listening ports in Crossplay" — Iron Gate), so
# players can no longer join by IP:port. That breaks gamehost's entire connect
# model (we hand customers an IP:port). A customer enabling it bricked their
# direct-IP join on 2026-06-02. We never pass -crossplay regardless of any
# SERVER_CROSSPLAY value that might linger in an old config.
if [[ "${SERVER_CROSSPLAY:-0}" != "0" ]]; then
log "NOTE: SERVER_CROSSPLAY is set but crossplay is disabled on this host (it would break direct IP:port joining). Ignoring."
fi
# Save interval in seconds (Valheim default 1800). Only pass if numeric > 0.
if [[ "${SERVER_SAVEINTERVAL:-}" =~ ^[0-9]+$ ]] && [[ "${SERVER_SAVEINTERVAL}" -gt 0 ]]; then
EXTRA_ARGS+=(-saveinterval "${SERVER_SAVEINTERVAL}")
fi
# World preset: one of the vanilla presets (normal/casual/easy/hard/hardcore/
# immersive/hammer). Empty = leave at the world's existing settings.
if [[ -n "${SERVER_PRESET:-}" ]]; then
EXTRA_ARGS+=(-preset "${SERVER_PRESET}")
fi
# Per-key world modifiers: SERVER_MODIFIERS holds space-separated "key value"
# pairs, e.g. "combat hard deathpenalty casual resources more". Each pair
# becomes a "-modifier key value". Operator-facing fields write a clean string.
if [[ -n "${SERVER_MODIFIERS:-}" ]]; then
# shellcheck disable=SC2206
_mods=(${SERVER_MODIFIERS})
i=0
while [[ $i -lt ${#_mods[@]} ]]; do
key="${_mods[$i]}"; val="${_mods[$((i+1))]:-}"
if [[ -n "${key}" && -n "${val}" ]]; then
EXTRA_ARGS+=(-modifier "${key}" "${val}")
fi
i=$((i+2))
done
fi
# --- Mods: BepInEx (Doorstop) loader ---------------------------------------
# Valheim mods load through BepInEx, injected at launch by Unity Doorstop. The
# panel's mod manager installs BepInEx + plugins into /game/BepInEx (the
# Thunderstore/ValheimPlus zips extract their BepInEx/ tree + doorstop_libs +
# libdoorstop into /game). We enable injection ONLY when:
# - mods are turned on (VALHEIM_MODS_ENABLED=1, set by the panel), AND
# - the loader is actually present on disk (BepInEx/core/BepInEx.Preloader.dll)
# Otherwise we launch vanilla (no perf cost, no risk of a half-install bricking
# the server). cwd is already /game below so the relative paths resolve.
BEPINEX_PRELOADER="${GAME_DIR}/BepInEx/core/BepInEx.Preloader.dll"
MODS_ACTIVE=0
if [[ "${VALHEIM_MODS_ENABLED:-0}" != "0" && -f "${BEPINEX_PRELOADER}" ]]; then
MODS_ACTIVE=1
fi
# ValheimPlus config (valheim_plus.cfg) is NOT generated here. gamehost writes
# the full cfg directly into /game/BepInEx/config/valheim_plus.cfg via the file
# API whenever the customer saves V+ settings (it holds all 345 settings in
# config_values as VP_* and renders the cfg from them). Keeping cfg generation
# out of the container env avoids declaring 345 env vars per instance.
cd "${GAME_DIR}"
log "starting Valheim dedicated server"
log " name: ${SERVER_NAME}"
log " world: ${SERVER_WORLD}"
log " public: ${SERVER_PUBLIC}"
log " port: ${GAME_PORT} (+ ${GAME_PORT}+1 for query)"
log " extra: ${EXTRA_ARGS[*]:-(none)}"
if [[ "${MODS_ACTIVE}" = "1" ]]; then
log " mods: BepInEx ENABLED${VALHEIM_PLUS_ENABLED:+ (ValheimPlus)}"
# Doorstop env (from ValheimPlus/BepInEx start_server_bepinex.sh). Paths are
# relative to cwd (=/game).
export DOORSTOP_ENABLED=1
export DOORSTOP_ENABLE=TRUE
export DOORSTOP_TARGET_ASSEMBLY="./BepInEx/core/BepInEx.Preloader.dll"
export DOORSTOP_INVOKE_DLL_PATH="./BepInEx/core/BepInEx.Preloader.dll"
export LD_LIBRARY_PATH="./doorstop_libs:${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}"
export LD_PRELOAD="libdoorstop_x64.so:${LD_PRELOAD:-}"
else
log " mods: none (vanilla)"
fi
# --- Passwordless via ValheimPlus -----------------------------------------
# disableServerPassword=true in valheim_plus.cfg is NOT enough on its own:
# Valheim still enforces the password if the launch command passes -password,
# and it refuses a passwordless server unless it's also -public 0 (unlisted).
# So when V+ is active AND its cfg has disableServerPassword=true, we OMIT
# -password and force -public 0. (Confirmed: Iron Gate / ServerFlex / multiple
# V+ docker projects all hit exactly this.)
PW_ARGS=(-password "${SERVER_PASSWORD}")
PUBLIC_ARG="${SERVER_PUBLIC}"
VP_CFG="${GAME_DIR}/BepInEx/config/valheim_plus.cfg"
if [[ "${MODS_ACTIVE}" = "1" && "${VALHEIM_PLUS_ENABLED:-0}" != "0" && -f "${VP_CFG}" ]]; then
# Match `disableServerPassword=true` only inside the [Server] section.
if awk '
/^\[Server\]/ {inserver=1; next}
/^\[/ {inserver=0}
inserver && /^[[:space:]]*disableServerPassword[[:space:]]*=[[:space:]]*true[[:space:]]*$/ {found=1}
END {exit found?0:1}
' "${VP_CFG}"; then
log " password: DISABLED via ValheimPlus (omitting -password, forcing -public 0)"
PW_ARGS=()
PUBLIC_ARG="0"
fi
fi
exec stdbuf -oL -eL "${EXE}" \
-name "${SERVER_NAME}" \
-port "${GAME_PORT}" \
-world "${SERVER_WORLD}" \
"${PW_ARGS[@]}" \
-public "${PUBLIC_ARG}" \
-savedir "${SAVE_DIR}" \
"${EXTRA_ARGS[@]}" \
-nographics -batchmode
+117
View File
@@ -0,0 +1,117 @@
# Valheim — panel-native dedicated server module.
#
# Native Linux binary from SteamCMD app 896660. No RCON natively — the
# server reads admin commands from stdin and prints to stdout, which is
# a "STDIO adapter" thing we haven't shipped yet. So v1 runs without an
# RCON/Players tab, same story as Empyrion.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (Valheim.kvp)
id: valheim
name: "Valheim"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
image: panel-valheim:latest
# Host networking -- container shares the host net namespace, so
# any port the game/mod binds is directly on the LAN. AMP-like
# model. Multi-instance safety comes from env-driven per-instance
# ports (panel allocator + env: mapping on each ports entry).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Worlds + adminlist.txt + banlist.txt (via $HOME/.config/unity3d)"
- name: "Game Files"
path: /game
hint: "Binaries, plugins — drop BepInEx/Jotunn here"
env:
SERVER_NAME: "panel Valheim"
SERVER_WORLD: "Dedicated"
SERVER_PASSWORD: "secret12" # Valheim requires ≥5 chars + not part of name/world
SERVER_PUBLIC: "1" # 1 = listed, 0 = unlisted
# Optional curated knobs (surfaced as Settings fields in gamehost). Must
# be pre-declared here so the agent's config_values override is honoured
# by ResolveDocker (it only passes through keys already in this block).
SERVER_CROSSPLAY: "0" # 1 = enable PlayFab crossplay
SERVER_SAVEINTERVAL: "1800" # world autosave interval, seconds
SERVER_PRESET: "" # normal|casual|easy|hard|hardcore|immersive|hammer
SERVER_MODIFIERS: "" # "combat hard deathpenalty casual ..." pairs
VALHEIM_MODS_ENABLED: "0" # 1 = inject BepInEx/Doorstop at launch (mods installed)
VALHEIM_PLUS_ENABLED: "0" # 1 = ValheimPlus is the active mod (drives the V+ settings UI)
# Port env vars MUST be pre-declared here (with the manifest default) so
# the agent's per-port `env:` injection is allowed to override them at
# create time. ResolveDocker only honours config_values for keys that
# already exist in this static env block — otherwise the injected
# PORT_GAME is silently dropped and the entrypoint falls back to 2456.
PORT_GAME: "2456"
PORT_GAMEA: "2457"
PORT_QUERY: "2458"
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# The panel allocator assigns a per-instance host port for each entry and,
# thanks to the `env:` mapping, injects it into the container so the entrypoint
# binds the ALLOCATED port instead of the hardcoded default. Without this the
# game binds 2456/2457 regardless of allocation and every public forward (which
# targets the allocated port) misses — server unreachable / not in the browser.
# Valheim actually opens <port> and <port>+1 for play and <port>+1 for the
# Steam query/browser; we pass PORT_GAME and let the binary take port+1. The
# allocator hands out contiguous ports so gameA/query line up.
ports:
- { name: game, proto: udp, default: 2456, required: true, env: PORT_GAME }
- { name: gameA, proto: udp, default: 2457, required: true, env: PORT_GAMEA }
- { name: query, proto: udp, default: 2458, required: true, env: PORT_QUERY }
resources:
min_ram_mb: 2048
recommended_ram_mb: 4096
rcon:
# Valheim has no network RCON; admin commands go straight to the server's
# stdin console. The stdio adapter auto-enables OpenStdin on the container.
adapter: stdio
commands:
save: "save"
broadcast: "broadcast \"{msg}\""
kick: "kick {player}"
ban: "ban {player}"
shutdown: "shutdown"
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (valheim.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'Got character ZDOID from (?P<name>.+?) : (?P<id>-?\d+):\d+'
kind: join
leave:
pattern: 'Destroying abandoned non persistent zdo -?\d+:\d+ owner (?P<id>-?\d+)$'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "896660"
install_path: /game
# --- 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: '/Game server connected|Dedicated server started/i'
appearance:
emoji: "⚔️"
grad: "linear-gradient(135deg,#3b5998,#8da5d4)"
art: "/game-art/valheim.jpg"
steam: "892970"
+63
View File
@@ -0,0 +1,63 @@
# panel-native VEIN runtime image.
#
# UE4-class dedicated server. Per the AMP template the Linux server needs
# libasound2, libatomic1 and libpulse0 in addition to the usual 32-bit
# SteamCMD runtime libs. On debian:12 the ALSA package is `libasound2`
# (the `libasound2t64` rename only happened on Ubuntu 24.04 / Debian trixie).
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
libatomic1 \
libasound2 \
libpulse0 \
&& 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/*
# Bake the Steam client SDK into the image. VEIN's depot ships libsteam_api.so
# but NOT steamclient.so — the runtime SDK module SteamAPI_Init dlopen's from
# ~/.steam/sdk64/. The panel's SteamCMD sidecar only installs the GAME into the
# volume and is then removed, so steamclient.so must come from the image. We
# fetch steamcmd and run it once (+quit) to self-bootstrap, which downloads
# linux32/ + linux64/steamclient.so; we stash both at /opt/steamsdk for the
# entrypoint to symlink. (Without this the server runs but never registers with
# Steam — "Steam Dedicated Server API failed to initialize" + no heartbeat.)
RUN mkdir -p /opt/steamcmd /opt/steamsdk/linux64 /opt/steamsdk/linux32 \
&& curl -fsSL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar -xz -C /opt/steamcmd \
&& ( /opt/steamcmd/steamcmd.sh +quit || true ) \
&& for f in $(find /opt/steamcmd /root/.steam /root/.local/share/Steam -name steamclient.so 2>/dev/null); do \
echo "$f" | grep -qi linux32 && cp -f "$f" /opt/steamsdk/linux32/steamclient.so || cp -f "$f" /opt/steamsdk/linux64/steamclient.so ; \
done \
&& ls -la /opt/steamsdk/linux64/ /opt/steamsdk/linux32/ \
&& test -f /opt/steamsdk/linux64/steamclient.so
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 /opt/steamsdk
COPY entrypoint.sh /entrypoint.sh
# Strip any CR (\r) so a Windows-checkout CRLF entrypoint can't make the kernel
# look for "/bin/bash\r" → "exec /entrypoint.sh: No such file or directory"
# (exit 127 crash-loop). Belt-and-suspenders: the source is LF, but a future
# re-checkout on a CRLF-normalizing host shouldn't be able to break the image.
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
EXPOSE 7777/udp 27015/udp 7878/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+192
View File
@@ -0,0 +1,192 @@
#!/bin/bash
# panel-native VEIN entrypoint.
#
# Contract:
# /game — SteamCMD-installed VEIN Dedicated Server (app 2131400),
# populated by the panel's SteamCMD updater. If the server
# binary is missing, exit EX_CONFIG (78) with a helpful hint.
# /game-saves — Vein/Saved/ tree (savegame + Config/LinuxServer/Game.ini).
#
# Stage 1 (root): chown volumes (SteamCMD wrote as root), wire the Steam
# client SDK (~/.steam/sdk64/steamclient.so) so the Steamworks game-server
# API can register on the master server, then drop to the `panel` user.
# Stage 2 (panel): stamp config_values env into the CORRECT UE sections of
# Game.ini, redirect saves into the volume, then exec the server with the
# panel-allocated ports.
set -euo pipefail
log() { printf '[panel-vein] %s\n' "$*"; }
GAME_DIR=/game
SAVE_DIR=/game-saves
# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
log "dropping privileges to panel (UID 1000)"
exec setpriv --reuid=panel --regid=panel --clear-groups \
--inh-caps=-all --bounding-set=-all \
env \
HOME=/home/panel \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
STEAM_APP_ID="${STEAM_APP_ID:-1857950}" \
SteamAppId="${STEAM_APP_ID:-1857950}" \
GAME_PORT="${GAME_PORT:-7777}" \
QUERY_PORT="${QUERY_PORT:-27015}" \
RCON_PORT="${RCON_PORT:-7878}" \
SERVER_NAME="${SERVER_NAME:-}" \
SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-}" \
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
MAX_PLAYERS="${MAX_PLAYERS:-}" \
SERVER_PUBLIC="${SERVER_PUBLIC:-}" \
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-}" \
ENABLE_VAC="${ENABLE_VAC:-}" \
SHOW_SCOREBOARD_BADGES="${SHOW_SCOREBOARD_BADGES:-}" \
/entrypoint.sh "$@"
fi
# ----- stage 2: panel user -----
EXE="${GAME_DIR}/Vein/Binaries/Linux/VeinServer-Linux-Test"
# Fallbacks in case the depot ships a differently-suffixed binary
# (Shipping / DebugGame). The smoke test confirms the real name.
if [[ ! -x "${EXE}" ]]; then
for alt in VeinServer-Linux-Shipping VeinServer-Linux-DebugGame VeinServer-Linux; do
if [[ -x "${GAME_DIR}/Vein/Binaries/Linux/${alt}" ]]; then
EXE="${GAME_DIR}/Vein/Binaries/Linux/${alt}"
break
fi
done
fi
if [[ ! -x "${EXE}" ]]; then
log "ERROR: VEIN server binary not found under ${GAME_DIR}/Vein/Binaries/Linux/."
log " Run 'Update' in the panel to install VEIN via SteamCMD (app 2131400)."
exit 78
fi
# --- Steam client SDK: the Steamworks game-server API dlopen's steamclient.so
# from ~/.steam/sdk64/ (64-bit) and ~/.steam/sdk32/ (32-bit). VEIN's depot does
# NOT ship steamclient.so, and the panel's SteamCMD sidecar (which has it) is
# torn down after install — so we bake it into the image at /opt/steamsdk and
# symlink it here. Without this: "Steam Dedicated Server API failed to
# initialize" + endless "Failed to heartbeat" + the ready-regex never fires.
mkdir -p "${HOME}/.steam/sdk64" "${HOME}/.steam/sdk32"
for pair in "linux64:sdk64" "linux32:sdk32"; do
src="/opt/steamsdk/${pair%%:*}/steamclient.so"
dst="${HOME}/.steam/${pair##*:}/steamclient.so"
if [[ -f "${src}" ]]; then
ln -sf "${src}" "${dst}"
fi
done
# Fallback: if the baked SDK is somehow absent, scan known runtime locations.
if [[ ! -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then
alt="$(find /opt /game "${HOME}/.steam" "${HOME}/.local/share/Steam" -name steamclient.so 2>/dev/null | grep -i 'linux64\|sdk64' | head -1)"
[[ -z "${alt}" ]] && alt="$(find /opt /game "${HOME}/.steam" -name steamclient.so 2>/dev/null | head -1)"
[[ -n "${alt}" ]] && ln -sf "${alt}" "${HOME}/.steam/sdk64/steamclient.so"
fi
if [[ -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then
log "steamclient.so wired → $(readlink -f "${HOME}/.steam/sdk64/steamclient.so")"
else
log "WARNING: steamclient.so not found — Steam server registration will fail (server still LAN-joinable)."
fi
export LD_LIBRARY_PATH="${HOME}/.steam/sdk64:${GAME_DIR}/linux64:${GAME_DIR}/Vein/Binaries/Linux:${LD_LIBRARY_PATH:-}"
# --- Game.ini: section-aware env → INI stamping -------------------------
# VEIN's Game.ini is a multi-section UE config. Each tunable lives under a
# SPECIFIC [/Script/...] header — MaxPlayers under [/Script/Engine.GameSession],
# ServerName/Password under [/Script/Vein.VeinGameSession], etc. We stamp
# only NON-EMPTY env values, in place, into the right section — preserving
# operator hand-edits to other keys/sections via the Files tab.
CFG_DIR="${SAVE_DIR}/Vein/Saved/Config/LinuxServer"
INI="${CFG_DIR}/Game.ini"
mkdir -p "${CFG_DIR}"
[[ -f "${INI}" ]] || : > "${INI}"
# set_ini_kv "[Section]" KEY VALUE
# Replace KEY=... if it already exists *within* its section; else insert it
# immediately after the section header; create the section at EOF if absent.
# Skips on empty VALUE. Section-aware: a same-named key in another section
# is left untouched. Idempotent. Pure awk — no extra runtime deps.
set_ini_kv() {
local section="$1" key="$2" value="$3"
[[ -z "${value}" ]] && return 0
local tmp="${INI}.tmp.$$"
awk -v sec="${section}" -v key="${key}" -v val="${value}" '
function isheader(l) { return (l ~ /^[ \t]*\[.*\][ \t]*$/) }
function trim(l) { gsub(/^[ \t]+|[ \t]+$/, "", l); return l }
BEGIN { cur=""; replaced=0; hdr_line=0 }
{
lines[NR]=$0
t=trim($0)
if (isheader(t)) cur=t
section_of[NR]=cur
if (cur==sec && hdr_line==0 && t==sec) hdr_line=NR
# replace in-place when key matches inside the target section
if (cur==sec && (t ~ ("^" key "[ \t]*=") )) {
lines[NR]=key "=" val
replaced=1
}
}
END {
if (replaced) {
for (i=1;i<=NR;i++) print lines[i]
} else if (hdr_line>0) {
for (i=1;i<=NR;i++) {
print lines[i]
if (i==hdr_line) print key "=" val
}
} else {
for (i=1;i<=NR;i++) print lines[i]
if (NR>0 && trim(lines[NR])!="") print ""
print sec
print key "=" val
}
}
' "${INI}" > "${tmp}" && mv "${tmp}" "${INI}"
}
GS='[/Script/Engine.GameSession]'
VS='[/Script/Vein.VeinGameSession]'
OSS='[OnlineSubsystemSteam]'
SS='[/Script/Vein.ServerSettings]'
set_ini_kv "$GS" "MaxPlayers" "${MAX_PLAYERS}"
set_ini_kv "$VS" "ServerName" "${SERVER_NAME}"
set_ini_kv "$VS" "ServerDescription" "${SERVER_DESCRIPTION}"
set_ini_kv "$VS" "Password" "${SERVER_PASSWORD}"
set_ini_kv "$VS" "bPublic" "${SERVER_PUBLIC}"
set_ini_kv "$VS" "HeartbeatInterval" "${HEARTBEAT_INTERVAL}"
set_ini_kv "$OSS" "bVACEnabled" "${ENABLE_VAC}"
set_ini_kv "$SS" "GS_ShowScoreboardBadges" "${SHOW_SCOREBOARD_BADGES}"
# --- Saves: redirect Vein/Saved into the save volume via symlink so world
# data + configs survive container recreate and show in the Files tab.
mkdir -p "${SAVE_DIR}/Vein/Saved" "${SAVE_DIR}/logs"
if [[ -d "${GAME_DIR}/Vein/Saved" && ! -L "${GAME_DIR}/Vein/Saved" ]]; then
if [[ -z "$(ls -A "${SAVE_DIR}/Vein/Saved" 2>/dev/null)" ]]; then
cp -a "${GAME_DIR}/Vein/Saved/." "${SAVE_DIR}/Vein/Saved/" 2>/dev/null || true
fi
rm -rf "${GAME_DIR}/Vein/Saved"
fi
mkdir -p "${GAME_DIR}/Vein"
ln -sfn "${SAVE_DIR}/Vein/Saved" "${GAME_DIR}/Vein/Saved"
cd "${GAME_DIR}"
log "starting VEIN dedicated server"
log " name: ${SERVER_NAME:-<unset, Game.ini default>}"
log " slots: ${MAX_PLAYERS:-<unset, Game.ini default>}"
log " ports: game=${GAME_PORT} query=${QUERY_PORT}"
log " binary: ${EXE}"
# Launch args mirror the AMP template:
# Vein -Port=N -QueryPort=N -UseGameSaveDir -stdout -FullStdOutLogOutput
# -stdout + -FullStdOutLogOutput are LOAD-BEARING: they make the server log
# to stdout so the panel's Console live-tail (and the ready-regex) work, and
# so the stdio admin adapter's command echoes are visible.
exec stdbuf -oL -eL "${EXE}" Vein \
-Port="${GAME_PORT}" \
-QueryPort="${QUERY_PORT}" \
-UseGameSaveDir \
-stdout \
-FullStdOutLogOutput
+126
View File
@@ -0,0 +1,126 @@
# VEIN — panel-native dedicated server module.
#
# Native Linux dedicated server. UE4-class survival game. SteamCMD app
# 2131400 (the DEDICATED SERVER app id — distinct from the game app id
# 1857950, which the server reports itself as to Steam via SteamAppId).
# Anonymous Steam login works. Admin console is stdin-only (no network
# RCON), so the panel drives the console via the stdio adapter — same
# model as Enshrouded / Sons of the Forest.
#
# Reference: https://github.com/CubeCoders/AMPTemplates (vein.kvp)
id: vein
name: "VEIN"
version: 0.1.0
authors:
- panel contributors
supported_modes:
- docker
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-vein:latest modules/vein
image: panel-vein:latest
# Host networking -- container shares the host net namespace, so
# any port the game binds is directly on the LAN. AMP-like model.
# Multi-instance safety comes from env-driven per-instance ports
# (panel allocator + env: mapping on each ports entry below).
network_mode: host
browseable_root: /game-saves
browseable_roots:
- name: "Saves & Configs"
path: /game-saves
hint: "Vein/Saved/ savegame data + Config/LinuxServer/Game.ini"
- name: "Game Files"
path: /game
hint: "Vein/Binaries/Linux/ server binary + steamclient libs"
# Pre-declare every env key that any config_value below maps to. The
# agent's resolve.go ONLY propagates config_values whose key is already
# present in `docker.env` — keys not listed here get silently filtered
# out. Empty-string defaults mean "leave Game.ini alone" (the entrypoint
# stamps only non-empty values, so a recreate with empty env preserves
# whatever the operator set previously via the Config/Files tab).
env:
# Steamworks client runtime init context — the dedicated-server app
# (2131400) reports itself to Steam as VEIN (1857950). AMP sets this
# too; without it the server's Steam socket init / master-server
# listing fails.
STEAM_APP_ID: "1857950"
# --- Per-instance ports ---
# MUST be pre-declared here or resolve.go silently drops the allocator's
# injected values (it only overrides docker.env keys that already exist),
# and the entrypoint falls back to these defaults → two VEIN servers on
# one host both bind 7777 and collide. The `env:` on each ports entry
# below maps the panel-allocated HOST port into these vars at start.
GAME_PORT: "7777"
QUERY_PORT: "27015"
RCON_PORT: "7878"
# --- Identity & access (stamped into Game.ini sections) ---
SERVER_NAME: ""
SERVER_DESCRIPTION: ""
SERVER_PASSWORD: ""
MAX_PLAYERS: ""
SERVER_PUBLIC: "" # bPublic — list on the public browser
HEARTBEAT_INTERVAL: ""
ENABLE_VAC: "" # bVACEnabled under [OnlineSubsystemSteam]
SHOW_SCOREBOARD_BADGES: ""
volumes:
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Multi-instance safety: `env:` tells the agent which container env var to
# set with the allocated host port. The entrypoint reads these to build
# VEIN's `-Port` / `-QueryPort` launch flags so two VEIN servers on the
# same agent get distinct, non-colliding sockets (avoids the Valheim
# hardcoded-port collision class of bug).
ports:
- { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
# VEIN declares an RCON port but the admin path is STDIO; we expose it for
# completeness / firewalling but do NOT wire a network rcon adapter to it.
- { name: rcon, proto: udp, default: 7878, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
rcon:
# VEIN admin console is stdin-only (no network RCON). The stdio adapter
# auto-enables container OpenStdin (agent resolve.go) and the Console tab
# "Send" box writes commands to the server process's stdin. Output comes
# back via the docker-logs live tail, not a correlated reply.
adapter: stdio
# Log-derived player events. Regexes converted from CubeCoders'
# AMPTemplates (vein.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
# Sourced-from-template; not yet exercised against a live client.
events:
join:
pattern: 'LogNet: Login request: (?:\?Password=[^?]*)?\?Name=(?P<name>.+?)\?\?ID=(?P<id>\d+)\?Ticket='
kind: join
leave:
pattern: 'LogNet: UChannel::CleanUp: ChIndex == \d+\. Closing connection\..*RemoteAddr: (?P<id>\d+):\d+'
kind: leave
update_providers:
- id: stable
kind: steamcmd
app_id: "2131400"
install_path: /game
# --- 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: '/LogRamjetNetworking: Steamworks server initialized with SteamID \d+|LogRamjetNetworking: Heartbeating with IP /i'
appearance:
emoji: "🩸"
grad: "linear-gradient(135deg,#2a0a0a,#7a1010)"
art: "/game-art/vein.jpg"
steam: "1857950"
+97
View File
@@ -0,0 +1,97 @@
# panel-native Windrose runtime image.
#
# Windrose's dedicated server is a Windows-only UE5 build, so we bring along
# just enough Wine + Xvfb to host it headlessly. SteamCMD is NOT installed
# here — the panel's generic updater sidecar uses the official steamcmd
# image and writes into our /game volume.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed Windows build (panel volume)
# /game/WindroseServer.exe — dedicated server binary (preferred path)
# /game/R5/Binaries/Win64/WindroseServer.exe — fallback path (UE5 convention)
# /game-saves — ServerDescription.json, R5/Saved save tree
# /entrypoint.sh — boot Xvfb, launch exe under Wine
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
WINEDEBUG=-all \
WINEPREFIX=/home/panel/.wine \
DISPLAY=:99
# Wine: install winehq-stable from dl.winehq.org rather than Debian's
# wine 8.0~repack. Reason: Windrose's UE5 game uses gRPC for its R5 coop
# session. gRPC's Windows event engine asserts on partial-transfer IOCP
# completions ("ASSERTION FAILED: result.bytes_transferred ==
# buffer_->Length() at windows_endpoint.cc:355"). Wine 8.0's IOCP can
# return partial completions for WSARecv that the assertion rejects, so
# the server *crashes* the moment a player session reaches the gRPC
# transport layer (~12 seconds after a join). Wine 9+ on winehq's stable
# repo has the IOCP fix; matches what CubeCoders' AMP wine-stable image
# ships. cabextract + winbind are commonly needed by Wine's installer
# helpers even though we don't call winetricks directly.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
curl \
gnupg \
xvfb \
xauth \
winbind \
cabextract \
procps \
&& mkdir -pm755 /etc/apt/keyrings \
&& curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \
| gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key \
&& curl -fsSL https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources \
-o /etc/apt/sources.list.d/winehq-bookworm.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends winehq-stable \
&& 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/*
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 /home/panel
# Pre-warm the Wine prefix at /home/panel/.wine during the image build.
#
# Why: Wine 11's first-boot wineboot pops `appwiz.cpl install_mono` to
# install Wine Mono. In a headless xvfb container that step blocks
# indefinitely, and any interrupt (SIGINT, container restart) leaves the
# prefix half-built — kernel32.dll never finishes registering, so the
# next launch dies with "could not load kernel32.dll, status c0000135"
# in a tight crash-loop. UE5 dedicated servers don't need .NET, so we
# skip Mono entirely via WINEDLLOVERRIDES=mscoree= and the Gecko HTML
# engine via mshtml=. Result: first boot is fast and idempotent;
# entrypoint can skip wineboot since system.reg is already present.
USER panel
ENV HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all
# wineboot --init under xvfb-run hangs on Wine 11 because xvfb-run kills
# its X server the moment wineboot's main thread returns, but wineboot
# spawns explorer.exe / services.exe in the background that keep trying
# to reach :99 and emit "X connection broken" errors that kill the build.
# Run Xvfb directly, give it a moment, point DISPLAY at it, init the
# prefix, then `wineserver -w` to flush state and explicitly stop Xvfb.
RUN Xvfb :99 -screen 0 1024x768x24 -nolisten tcp & XVFB_PID=$! \
&& sleep 2 \
&& DISPLAY=:99 WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init \
&& wineserver -w \
&& kill "$XVFB_PID" 2>/dev/null || true
USER root
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 7777/udp 27015/udp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+25
View File
@@ -0,0 +1,25 @@
# Windrose — panel-native module
Panel-native Windrose dedicated server running under Wine + Xvfb.
- **Image:** `panel-windrose:latest` — built from `./Dockerfile` (Debian 12 + Wine + Xvfb + tini).
- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 4129620) into the `panel-<id>-game` named volume. Windrose ships only as a Windows build, so the updater uses `+@sSteamCmdForcePlatformType windows` (declared in `module.yaml` via `platform: windows`).
- **Saves:** `panel-<id>-saves` volume mounted at `/game-saves`; the entrypoint moves `R5/Saved/` from the game install into the save volume on first boot and symlinks it back, so SteamCMD `validate` can't clobber world data.
- **Config:** `ServerDescription.json` lives in the save volume (authoritative) and is symlinked back to the game install path the binary expects. Edit via the Files tab — JSON parsed in-browser; no dedicated Config-tab form yet.
- **RCON:** none. Windrose has no remote admin protocol; the admin console is in-process only. The Console tab streams the UE log.
## Ports
- **7777/udp** — Direct-connect game port (docs refer to this as `DirectConnectionServerPort`).
- **27015/udp** — Steam query (UE5 convention).
Most Windrose peer connectivity runs through Steam NAT punch-through, but the direct port must be reachable for LAN or public-IP clients.
## Updating
Click **Update** in the panel. The SteamCMD sidecar re-validates app 4129620 against the `/game` volume. First install is ~900 MB compressed.
## Known gotchas
- Server binary path isn't documented — the entrypoint probes `/game/WindroseServer.exe`, `/game/R5/Binaries/Win64/WindroseServer.exe`, and `/game/R5/Binaries/WinGDK/WindroseServer.exe`, then falls back to `find` if none match. Check logs on first boot.
- No RCON means the Players tab will stay empty; start/stop state comes from log-line ready-regex + container state.

Some files were not shown because too many files have changed in this diff Show More