1 Commits

Author SHA1 Message Date
the_minisip 9de19fceb3 Update README.md
Updating markdown
2026-05-10 18:57:40 -03:00
12 changed files with 125 additions and 514 deletions
+125 -277
View File
@@ -1,400 +1,248 @@
Windrose Dedicated Server — TrueNAS SCALE Deployment Guide
# Windrose Dedicated Server — TrueNAS SCALE Deployment Guide
A fully working configuration for running the Windrose Dedicated Server (Windows build) inside a TrueNAS SCALE container using Wine + DXVK, with persistent saves, explicit world selection, and fixed hostname resolution for ICE/P2P networking.
This README documents the exact setup currently running in production.
---
## Table of Contents
Table of Contents
- [Overview](#overview)
- [Directory Layout](#directory-layout)
- [Environment Variables](#environment-variables)
- [Hostname Resolution (ICE/P2P Fix)](#hostname-resolution-icep2p-fix)
- [DXVK Behavior](#dxvk-behavior)
- [Server Launch Command](#server-launch-command)
- [Entrypoint Behavior Summary](#entrypoint-behavior-summary)
- [Networking Requirements](#networking-requirements)
- [Troubleshooting](#troubleshooting)
- [Future Enhancements](#future-enhancements)
Overview
---
Directory Layout
Environment Variables
Hostname Resolution (ICE/P2P Fix)
DXVK Behavior
Server Launch Command
Entrypoint Behavior Summary
Networking Requirements
Troubleshooting
Future Enhancements
Overview
## Overview
This deployment runs:
Windrose Dedicated Server (Windows build)
Inside a TrueNAS SCALE App container
Using Wine + DXVK
With persistent world saves
With explicit WORLD\_ID selection
With hostname resolution fixes for ICE/P2P
With nonfatal DXVK installation fallback
With stable LAN/WAN connectivity
- Windrose Dedicated Server (Windows build)
- Inside a TrueNAS SCALE App container
- Using Wine + DXVK
- With persistent world saves
- With explicit WORLD_ID selection
- With hostname resolution fixes for ICE/P2P
- With nonfatal DXVK installation fallback
- With stable LAN/WAN connectivity
This configuration is confirmed working as of:
**May 10, 2026**
---
May 10, 2026
## Directory Layout
Directory Layout
Persistent Storage
### Persistent Storage
Mounted into the container:
Code
```
/data/Saved
```
Windrose stores all world data here:
```
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD_ID>/
```
### Game Installation Directory
Code
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD\_ID>/
Game Installation Directory
Code
```
/server
/server/R5
```
This is where SteamCMD installs the Windrose server files.
---
Environment Variables
## Environment Variables
Set these in the TrueNAS SCALE App → Environment Variables.
### Required
| Variable | Description |
|----------|-------------|
| WORLD_ID | Folder name of the world to load. Example: `352359B474804C2A4D83D84AADC7579D` |
Required
### Optional
Variable Description
| Variable | Default | Description |
|--------------|---------------------------|-------------|
| WINEPREFIX | /data/wine/wineprefix | Wine prefix location |
| SAVED_DIR | /data/Saved | Persistent save directory |
WORLD\_ID Folder name of the world to load. Example: 352359B474804C2A4D83D84AADC7579D
---
Optional
Variable Default Description
WINEPREFIX /data/wine/wineprefix Wine prefix location
SAVED\_DIR /data/Saved Persistent save directory
Hostname Resolution (ICE/P2P Fix)
## Hostname Resolution (ICE/P2P Fix)
Windrose internally tries to resolve the hostname:
Code
```
windrose
```
Your deployment requires:
Code
```
windrose.theminisip.ca
```
Both must resolve to the LAN IP of the TrueNAS host:
Code
```
192.168.3.41
```
The entrypoint injects this automatically:
bash
```
192.168.3.41 windrose windrose.theminisip.ca
```
This prevents ICE errors such as:
Code
```
Cannot resolve addresses for host windrose.
Error on getting local ICE candidates
P2pClient is broken
```
DXVK Behavior
---
## DXVK Behavior
DXVK is required even in headless mode.
Your container includes:
Code
/opt/dxvk/x32/\*.dll
/opt/dxvk/x64/\*.dll
```
/opt/dxvk/x32/*.dll
/opt/dxvk/x64/*.dll
```
The entrypoint:
Runs setup-dxvk.sh if present
Falls back to manual DLL copy
Logs exit codes
Never crashes if DXVK is missing or incomplete
- Runs setup-dxvk.sh if present
- Falls back to manual DLL copy
- Logs exit codes
- Never crashes if DXVK is missing or incomplete
This ensures Wine initializes correctly.
---
Server Launch Command
## Server Launch Command
The server is launched with:
```
wine WindroseServer-Win64-Shipping.exe \
-log \
-unattended \
-nosteam \
-savedir "Saved" \
-saveprofile Default \
-world "$WORLD_ID"
```
### Notes
bash
- `"Saved"` resolves to `/server/R5/Saved`
- World data still loads from `/data/Saved` via RocksDB
- `-nosteam` avoids Steam API issues
- `-unattended` prevents UI prompts
wine WindroseServer-Win64-Shipping.exe \\
---
&#x20; -log \\
&#x20; -unattended \\
&#x20; -nosteam \\
&#x20; -savedir "Saved" \\
&#x20; -saveprofile Default \\
&#x20; -world "$WORLD\_ID"
Notes:
"Saved" resolves to /server/R5/Saved
World data still loads from /data/Saved via RocksDB
\-nosteam avoids Steam API issues
\-unattended prevents UI prompts
Entrypoint Behavior Summary
## Entrypoint Behavior Summary
The entrypoint:
Initializes Wine prefix
Installs DXVK (nonfatal)
Injects hostname resolution
Validates WORLD\_ID
Launches the server
1. Initializes Wine prefix
2. Installs DXVK (nonfatal)
3. Injects hostname resolution
4. Validates WORLD_ID
5. Launches the server
This is the exact working version currently deployed.
---
## Networking Requirements
Networking Requirements
LAN
### LAN
Works out of the box.
WAN
### WAN
Forward these ports:
Port Protocol Purpose
7777 UDP Game traffic
15000 UDP ICE / P2P
27015 UDP Query port
| Port | Protocol | Purpose |
|------|----------|----------|
| 7777 | UDP | Game traffic |
| 15000 | UDP | ICE / P2P |
| 27015 | UDP | Query port |
Ensure Cloudflare DNS for:
Code
```
windrose.theminisip.ca
```
points to your public IP.
---
## Troubleshooting
Troubleshooting
ICE Errors
### ICE Errors
If you see:
Code
```
Cannot resolve addresses for host windrose
```
Check:
- /etc/hosts mapping
- DNS for windrose.theminisip.ca
- Correct WORLD_ID
/etc/hosts mapping
DNS for windrose.theminisip.ca
Correct WORLD\_ID
World Not Loading
### World Not Loading
Ensure:
Code
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD\_ID>/
```
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD_ID>/
```
exists.
DXVK Errors
### DXVK Errors
Nonfatal — server will still run.
---
## Future Enhancements
- Node.js web manager (deployment, config editing, restarts)
- Multiinstance orchestration
- World backups & restores
- Metrics dashboard
- Log streaming
-16
View File
@@ -37,19 +37,3 @@ services:
tty: true
stdin_open: true
manager:
build: ./manager
container_name: windrose-manager
environment:
- ADMIN_PASSWORD=changeme
ports:
- "3000:3000"
volumes:
# Manager needs access to real config files
- ./data:/data:rw
# Manager needs access to Docker to restart windrose container
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
-14
View File
@@ -1,14 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
ENV PORT=3000
CMD ["npm", "start"]
-21
View File
@@ -1,21 +0,0 @@
{
"name": "windrose-manager",
"version": "1.0.0",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"dockerode": "^4.0.0",
"express": "^4.19.0",
"yaml": "^2.4.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.0"
}
}
-37
View File
@@ -1,37 +0,0 @@
import { Router } from "express";
import { getInstance } from "../core/config";
import { readJson, writeJson, listWorldDescriptions } from "../core/files";
const router = Router();
router.get("/:id/server", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
res.json(readJson(inst.configPaths.serverDescription));
});
router.put("/:id/server", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
writeJson(inst.configPaths.serverDescription, req.body);
res.json({ status: "updated" });
});
router.post("/:id/worlds/update-all", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
const files = listWorldDescriptions(inst.configPaths.worldsRoot);
for (const file of files) {
const data = readJson(file);
const updated = { ...data, ...req.body };
writeJson(file, updated);
}
res.json({ status: "worlds-updated", count: files.length });
});
export default router;
-23
View File
@@ -1,23 +0,0 @@
import { Router } from "express";
import { getInstance } from "../core/config";
import { restartInstance, getInstanceStatus } from "../core/docker";
const router = Router();
router.post("/:id/restart", async (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
await restartInstance(inst);
res.json({ status: "restarted" });
});
router.get("/:id/status", async (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
const status = await getInstanceStatus(inst);
res.json(status);
});
export default router;
-21
View File
@@ -1,21 +0,0 @@
import { Request, Response, NextFunction } from "express";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Basic ")) {
res.setHeader("WWW-Authenticate", "Basic");
return res.status(401).send("Authentication required");
}
const decoded = Buffer.from(header.replace("Basic ", ""), "base64").toString();
const [user, pass] = decoded.split(":");
if (pass !== ADMIN_PASSWORD) {
return res.status(403).send("Forbidden");
}
next();
}
-30
View File
@@ -1,30 +0,0 @@
import fs from "fs";
import path from "path";
import YAML from "yaml";
export interface InstanceConfig {
id: string;
containerName: string;
image: string;
savedDir: string;
configPaths: {
serverDescription: string;
worldsRoot: string;
};
}
const CONFIG_PATH = path.join(__dirname, "..", "..", "config", "instances.yml");
let cache: { instances: InstanceConfig[] };
export function loadConfig() {
if (!cache) {
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
cache = YAML.parse(raw);
}
return cache;
}
export function getInstance(id: string) {
return loadConfig().instances.find(i => i.id === id);
}
-22
View File
@@ -1,22 +0,0 @@
import Docker from "dockerode";
import { InstanceConfig } from "./config";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export async function restartInstance(inst: InstanceConfig) {
const container = docker.getContainer(inst.containerName);
await container.restart();
}
export async function getInstanceStatus(inst: InstanceConfig) {
try {
const container = docker.getContainer(inst.containerName);
const info = await container.inspect();
return {
running: info.State.Running,
status: info.State.Status
};
} catch {
return { running: false, status: "not found" };
}
}
-27
View File
@@ -1,27 +0,0 @@
import fs from "fs";
import path from "path";
export function readJson(file: string) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
export function writeJson(file: string, data: any) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
}
export function listWorldDescriptions(worldsRoot: string) {
const versions = fs.readdirSync(worldsRoot);
const files: string[] = [];
for (const v of versions) {
const worldsDir = path.join(worldsRoot, v, "Worlds");
if (!fs.existsSync(worldsDir)) continue;
for (const worldId of fs.readdirSync(worldsDir)) {
const file = path.join(worldsDir, worldId, "WorldDescription.json");
if (fs.existsSync(file)) files.push(file);
}
}
return files;
}
-16
View File
@@ -1,16 +0,0 @@
import express from "express";
import { requireAuth } from "./auth";
import instanceApi from "./api/instance";
import configApi from "./api/config";
const app = express();
app.use(express.json());
// Protect everything
app.use(requireAuth);
app.use("/instance", instanceApi);
app.use("/config", configApi);
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Windrose Manager running on port ${port}`));
-10
View File
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"strict": false
}
}