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