Merge pull request 'fix(satisfactory): official SteamCMD server, and builder uses SteamCMD only for Linux Steam servers' (#27) from fix/satisfactory-steamcmd into main
Validate game templates / validate (push) Successful in 23s
Builder image / publish (push) Successful in 25s
Validate game templates / boot-test (push) Successful in 4m39s
Validate game templates / publish (push) Successful in 18s

This commit was merged in pull request #27.
This commit is contained in:
git_pipeline committed 2026-11-07 02:20:48 +00:00
commit ac3febf109
5 files changed
+159 -164

No files matched your search

+30
View File
@@ -39,10 +39,26 @@ Know this before writing anything:
- No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a - No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a
player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name. player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name.
### The image: SteamCMD first
- If the game's dedicated server is on Steam and ships a Linux build, the template is
`defaultDockerImage: cm2network/steamcmd` with `installMethod: STEAMCMD` and the **server's** app id
(not the game's). Never a community image of that server (`wolveix/...`, `lloesche/...` and the like):
they keep game files and saves in their own folders (`/config`, `/home/<user>`), outside `/server`, so
everything is lost on the next start, and no setting can move them.
- Community images are only for servers that aren't on Steam for Linux: Windows-only servers that need
Proton or Wine, and games not on Steam.
- Many Linux servers save under `$HOME` (Unreal Engine games: `~/.config/Epic/<Game>/Saved`). In
`cm2network/steamcmd`, `$HOME` is `/home/steam`, outside the volume. Start the command with
`export HOME=/server;` so those saves land in the volume, and list them in `backupPaths` relative to
`/server` (e.g. `.config/Epic/<Game>/Saved/SaveGames`).
### Ports ### Ports
- `defaultPorts` lists **only what players connect to** (game and query ports). RCON, REST and admin - `defaultPorts` lists **only what players connect to** (game and query ports). RCON, REST and admin
ports are **never** published. ports are **never** published.
- Use `"7777/udp"` or `"8211:8211/udp"`. Every port needs its protocol. - Use `"7777/udp"` or `"8211:8211/udp"`. Every port needs its protocol.
- A port the game uses over both protocols is listed twice: `"7777/udp"` and `"7777/tcp"`.
- A game that tells players a second port (a reliable-messaging or query port it announces) needs it as
`{PUBLIC_PORT_<port>}` passed to the game's "external port" option, since nodes may publish it elsewhere.
- Nodes always publish these container ports (only the host side moves when servers share a node). - Nodes always publish these container ports (only the host side moves when servers share a node).
So the game's own port is **not a setting**: write it into the startup command or config file. A So the game's own port is **not a setting**: write it into the startup command or config file. A
game that must announce the port players use gets `{PUBLIC_PORT_<port>}`. game that must announce the port players use gets `{PUBLIC_PORT_<port>}`.
@@ -77,6 +93,20 @@ Know this before writing anything:
- Take keys and default values from what the server really writes: run - Take keys and default values from what the server really writes: run
`gbrgames first-boot -game <id>` and read the settings in its report. `gbrgames first-boot -game <id>` and read the settings in its report.
- Every key in `defaultConfig` has a `configSchema` property, and the other way round. - Every key in `defaultConfig` has a `configSchema` property, and the other way round.
- **Every setting must reach the server.** Settings only arrive as environment variables (and in
`configFilePath`, if set). A server that doesn't read environment variables (most official servers,
every Unreal Engine server) ignores a setting unless the template delivers it: in `configFilePath`, as a
launch option the startup command builds from the variable, or written into the server's own config
file by the startup command before it starts. A setting that nothing reads looks editable in the panel
and does nothing: leave it out instead.
- `configFilePath` writes **every** setting into **one** file. When a server reads its settings from
several files (Unreal: `Game.ini`, `Engine.ini`, `GameUserSettings.ini` under
`<Game>/Saved/Config/LinuxServer/`), leave `configFilePath` out and write each file from the startup
command before the server starts:
`printf '[/Script/Engine.GameSession]\nMaxPlayers=%s\n' "${MAX_PLAYERS:-4}" > "$cfg/Game.ini";`.
The server may rewrite these files when it stops; writing them on every start keeps the panel in charge.
- Never carry over a community image's variable names (`MAXPLAYERS`, `AUTOSAVENUM`, `PUID`, `SKIPUPDATE`...)
into a SteamCMD template: only that image's own scripts read them. Use the game's real setting names.
- Every property has a `title`, a short `description` for a player, a `type`, a `default` equal to the - Every property has a `title`, a short `description` for a player, a `type`, a `default` equal to the
`defaultConfig` value, and a `category`: one of `Security & Access`, `Gameplay & Rules`, `defaultConfig` value, and a `category`: one of `Security & Access`, `Gameplay & Rules`,
`World & Environment`, `System & General`. Mark the few that matter when deploying with `World & Environment`, `System & General`. Mark the few that matter when deploying with
+25 -7
View File
@@ -174,9 +174,10 @@ const researchPrompt = "You research how to host a game's dedicated server for G
"the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " + "the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " +
"crossplay builds, or notably different server images. For each variant give:\n" + "crossplay builds, or notably different server images. For each variant give:\n" +
"- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " + "- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " +
"cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " + "cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) is its only image, and " +
"variant is the recommended one. Then Docker Hub images that appear in the pages and run as a non-root user " + "there is no separate variant for a community image of the same server: those keep their saves outside /server. " +
"(needed for servers that only exist for Windows).\n" + "Docker Hub images that appear in the pages and run as a non-root user are only for servers that aren't on Steam " +
"for Linux (Windows-only servers that need Proton or Wine, games not on Steam).\n" +
"- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" + "- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" +
"- startupHint: the command that starts the server from its install folder /server, with the launch options " + "- startupHint: the command that starts the server from its install folder /server, with the launch options " +
"for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" + "for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" +
@@ -311,7 +312,7 @@ func verifyVariants(res *researchResult) {
} }
} }
apps := steamApps(appIDs) apps := steamApps(appIDs)
anyRunnable := false anyRunnable, linuxSteam := false, false
for i := range res.Variants { for i := range res.Variants {
v := &res.Variants[i] v := &res.Variants[i]
var images []string var images []string
@@ -333,7 +334,8 @@ func verifyVariants(res *researchResult) {
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name)) v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name))
v.Images = withSteamCMD(v.Images, app.linux) v.Images = withSteamCMD(v.Images, app.linux)
if app.linux { if app.linux {
v.Verified = append(v.Verified, "it ships a Linux server, so the SteamCMD option (installing it into /server) is recommended") linuxSteam = true
v.Verified = append(v.Verified, "it ships a Linux server, so it is installed with SteamCMD into /server")
} }
} else { } else {
v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD")
@@ -352,10 +354,26 @@ func verifyVariants(res *researchResult) {
res.Reason = "unknown" res.Reason = "unknown"
res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message
} }
if linuxSteam {
res.Variants = steamCMDOnly(res.Variants)
}
res.Variants = oneOptionPerImage(res.Variants) res.Variants = oneOptionPerImage(res.Variants)
recommend(res.Variants) recommend(res.Variants)
} }
// steamCMDOnly keeps only the SteamCMD options once the game has a Linux server on Steam. Ready-made images
// of such servers keep their files and saves in their own folders (/config, /home/...), outside /server,
// and read settings from their own variables, so they can't be made or repaired into a working template.
func steamCMDOnly(variants []variant) []variant {
var out []variant
for _, v := range variants {
if len(v.Images) > 0 && isSteamCMDImage(v.Images[0]) && v.SteamAppID != "" {
out = append(out, v)
}
}
return out
}
// recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux // recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux
// server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need. // server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need.
func recommend(variants []variant) { func recommend(variants []variant) {
@@ -422,7 +440,7 @@ func steamApps(apps []string) map[string]steamApp {
} }
// withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server // withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server
// it goes first: it installs into /server, which nodes keep, while ready-made images often keep their // it is the only route: it installs into /server, which nodes keep, while ready-made images keep their
// files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine. // files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine.
func withSteamCMD(images []string, linux bool) []string { func withSteamCMD(images []string, linux bool) []string {
var others []string var others []string
@@ -432,7 +450,7 @@ func withSteamCMD(images []string, linux bool) []string {
} }
} }
if linux { if linux {
return append([]string{"cm2network/steamcmd"}, others...) return []string{"cm2network/steamcmd"}
} }
return append(others, "cm2network/steamcmd") return append(others, "cm2network/steamcmd")
} }
+15 -3
View File
@@ -59,10 +59,10 @@ func TestParseAppInfoFindsLinuxServers(t *testing.T) {
} }
} }
func TestWithSteamCMDGoesFirstOnlyForLinuxServers(t *testing.T) { func TestWithSteamCMDIsTheOnlyRouteForLinuxServers(t *testing.T) {
images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"} images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"}
got := withSteamCMD(images, true) got := withSteamCMD(images, true)
if len(got) != 3 || got[0] != "cm2network/steamcmd" || got[1] != "someone/example-server" { if len(got) != 1 || got[0] != "cm2network/steamcmd" {
t.Errorf("linux: %v", got) t.Errorf("linux: %v", got)
} }
got = withSteamCMD([]string{"someone/proton-example"}, false) got = withSteamCMD([]string{"someone/proton-example"}, false)
@@ -89,10 +89,22 @@ func TestRecommendPrefersTheSteamCMDVariant(t *testing.T) {
} }
} }
func TestSteamCMDOnlyDropsImageVariantsOfLinuxSteamGames(t *testing.T) {
// A Linux Steam server researched as two variants: SteamCMD, and a community image under its own title
got := steamCMDOnly([]variant{
{ID: "dedicated", SteamAppID: "1000000", Images: []string{"cm2network/steamcmd"}},
{ID: "vanilla-stable", Images: []string{"someone/example-server"}},
})
if len(got) != 1 || got[0].ID != "dedicated" {
t.Errorf("got %+v", got)
}
}
func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) { func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) {
// A variant runnable two ways becomes one option per way, SteamCMD recommended
v := oneOptionPerImage([]variant{{ v := oneOptionPerImage([]variant{{
ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000", ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000",
Images: withSteamCMD([]string{"someone/example-server"}, true), Images: []string{"cm2network/steamcmd", "someone/example-server"},
Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""}, Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""},
}}) }})
recommend(v) recommend(v)
+87 -153
View File
@@ -1,165 +1,114 @@
# yaml-language-server: $schema=../../schema/game.schema.json # yaml-language-server: $schema=../../schema/game.schema.json
# The official dedicated server (Steam app 1690800), installed by the node with anonymous SteamCMD.
# Satisfactory keeps its saves under $HOME/.config/Epic/FactoryGame, so HOME is pointed at the server
# volume; otherwise they would land in /home/steam and be lost on the next start.
# The server reads no environment variables, so the startup command writes the settings into its
# Game.ini and Engine.ini before every start, and passes the rest as launch options.
id: satisfactory-vanilla-stable id: satisfactory-vanilla-stable
name: Satisfactory (Vanilla Stable) name: Satisfactory
defaultDockerImage: wolveix/satisfactory-server:latest defaultDockerImage: cm2network/steamcmd
defaultStartupCommand: >-
export HOME=/server;
cfg=FactoryGame/Saved/Config/LinuxServer;
mkdir -p "$HOME/.steam/sdk64" "$cfg";
cp -f /home/steam/steamcmd/linux64/steamclient.so "$HOME/.steam/sdk64/" 2>/dev/null;
case "${SERVER_STREAMING:-true}" in true|True|1) ss=1;; *) ss=0;; esac;
printf '[/Script/Engine.GameSession]\nMaxPlayers=%s\n' "${MAX_PLAYERS:-4}" > "$cfg/Game.ini";
printf '[/Script/FactoryGame.FGSaveSession]\nmNumRotatingAutosaves=%s\n\n[/Script/OnlineSubsystemUtils.IpNetDriver]\nNetServerMaxTickRate=%s\nConnectionTimeout=%s\nInitialConnectTimeout=%s\n\n[/Script/Engine.GarbageCollectionSettings]\ngc.MaxObjectsInEditor=%s\ngc.MaxObjectsInGame=%s\n\n[ConsoleVariables]\nwp.Runtime.EnableServerStreaming=%s\n'
"${AUTOSAVE_NUM:-5}" "${MAX_TICK_RATE:-30}" "${CONNECTION_TIMEOUT:-30}" "${CONNECTION_TIMEOUT:-30}"
"${MAX_OBJECTS:-2162688}" "${MAX_OBJECTS:-2162688}" "$ss" > "$cfg/Engine.ini";
args=(-Port=7777 -ReliablePort=8888 -ExternalReliablePort="${EXTERNAL_RELIABLE_PORT:-8888}" -log -unattended);
case "${DISABLE_SEASONAL_EVENTS:-false}" in true|True|1) args+=(-DisableSeasonalEvents);; esac;
[ -n "${MULTIHOME:-}" ] && args+=(-multihome="$MULTIHOME");
exec ./FactoryServer.sh "${args[@]}"
requiredMemoryMb: 8192 requiredMemoryMb: 8192
requiredDiskMb: 16384 requiredDiskMb: 20480
defaultPorts: defaultPorts:
- 7777/tcp
- 7777/udp - 7777/udp
- 7777/tcp
- 8888/tcp - 8888/tcp
installMethod: NONE installMethod: STEAMCMD
installConfig:
appId: "1690800"
defaultConfig: defaultConfig:
AUTOSAVENUM: 5 MaxPlayers: 4
DEBUG: false AutosaveNum: 5
DISABLESEASONALEVENTS: false MaxTickRate: 30
LOG: false ConnectionTimeout: 30
MAXOBJECTS: 2162688 MaxObjects: 2162688
MAXPLAYERS: 4 ServerStreaming: true
MAXTICKRATE: 30 DisableSeasonalEvents: false
MULTIHOME: '::' Multihome: ""
PGID: 1000 ExternalReliablePort: '{PUBLIC_PORT_8888}'
PUID: 1000
SERVERGAMEPORT: 7777
SERVERMESSAGINGPORT: 8888
SERVERSTREAMING: true
SKIPUPDATE: false
STEAMBETA: false
STEAMBETAID: ""
STEAMBETAKEY: ""
TIMEOUT: 30
VMOVERRIDE: false
configSchema: configSchema:
$schema: http://json-schema.org/draft-07/schema# $schema: http://json-schema.org/draft-07/schema#
type: object
properties: properties:
AUTOSAVENUM: MaxPlayers:
title: Max Players
description: How many players can be on the server at once. Set by your plan.
type: integer
category: Gameplay & Rules
default: 4
AutosaveNum:
title: Autosave Rotation Count
description: How many rotating autosave files to keep in the save folder.
type: integer
minimum: 1
category: World & Environment category: World & Environment
default: 5 default: 5
description: The number of rotating autosave files to keep in the save folder. MaxTickRate:
title: Autosave Rotation Count title: Max Tick Rate
description: The highest simulation tick rate the server runs at, in Hz.
type: integer type: integer
DEBUG: minimum: 10
maximum: 120
category: System & General category: System & General
default: false default: 30
description: Prints diagnostic environment and system details on startup, then stops. ConnectionTimeout:
title: Debug Mode title: Connection Timeout
type: boolean description: Seconds before a player who stops responding is disconnected.
DISABLESEASONALEVENTS: type: integer
category: Gameplay & Rules minimum: 5
default: false
description: Disables seasonal in-game events such as FICSMAS holiday events.
title: Disable Seasonal Events
type: boolean
LOG:
category: System & General category: System & General
default: false default: 30
description: Disables automatic pruning of previous Satisfactory server log files on container startup. MaxObjects:
title: Retain Old Logs title: Max Objects Limit
type: boolean description: Unreal Engine object limit. Raise it for very large late-game factories.
MAXOBJECTS: type: integer
category: World & Environment category: World & Environment
default: 2162688 default: 2162688
description: Unreal Engine garbage collector UObject limit to support large late-game factories. ServerStreaming:
title: Max Objects Limit title: Server Streaming
type: integer description: Lets the server stream world data in and out instead of keeping it all loaded.
MAXPLAYERS: type: boolean
category: System & General
default: 4
description: Sets the maximum number of concurrent players allowed on the server.
title: Max Players
type: integer
MAXTICKRATE:
category: System & General
default: 30
description: Sets the maximum simulation tick rate for the server in Hz.
title: Max Tick Rate
type: integer
MULTIHOME:
category: System & General
default: '::'
description: 'Network interface binding IP address (defaults to :: for dual-stack IPv4/IPv6).'
title: Multihome Address
type: string
PGID:
category: System & General
default: 1000
description: Sets the Linux group ID under which the dedicated server process runs.
title: Process Group ID
type: integer
PUID:
category: System & General
default: 1000
description: Sets the Linux user ID under which the dedicated server process runs.
title: Process User ID
type: integer
SERVERGAMEPORT:
category: System & General
default: 7777
description: Primary game port for UDP and TCP network communication.
title: Server Game Port
type: integer
SERVERMESSAGINGPORT:
category: System & General
default: 8888
description: Port used for reliable server messaging and beacon network traffic.
title: Server Messaging Port
type: integer
SERVERSTREAMING:
category: System & General category: System & General
default: true default: true
description: Toggles whether the game server utilizes Unreal Engine World Partition asset streaming. DisableSeasonalEvents:
title: Server Streaming title: Disable Seasonal Events
description: Turns off seasonal events such as FICSMAS.
type: boolean type: boolean
SKIPUPDATE: category: Gameplay & Rules
category: System & General
default: false default: false
description: Skips automatic SteamCMD game file update checks on container startup. Multihome:
title: Skip Update title: Multihome Address
type: boolean description: Bind the server to one IP address instead of all of them. Leave empty unless you know you need it.
STEAMBETA: type: string
category: System & General
default: false
description: When enabled, updates and runs the Experimental branch instead of the stable release.
title: Steam Beta Branch
type: boolean
STEAMBETAID:
category: System & General category: System & General
default: "" default: ""
description: Specific beta branch identifier to download via SteamCMD. ExternalReliablePort:
title: Steam Beta Branch Name title: Reliable Messaging Port (public)
description: The port players' games use for reliable messaging. Filled in for you; only change it if you know you need to.
type: string type: string
STEAMBETAKEY:
category: Security & Access
default: ""
description: Password required to access a private Steam beta branch.
title: Steam Beta Password
type: string
TIMEOUT:
category: System & General category: System & General
default: 30 default: '{PUBLIC_PORT_8888}'
description: Client network connection timeout threshold in seconds.
title: Connection Timeout
type: integer
VMOVERRIDE:
category: System & General
default: false
description: Skips CPU model checking on hypervisors to prevent crash warnings on KVM/QEMU hosts.
title: VM CPU Check Override
type: boolean
required: [] required: []
type: object maxPlayersConfigKey: MaxPlayers
configFileFormat: INI logFilePath: FactoryGame/Saved/Logs/FactoryGame.log
configFormatOptions:
assignment: =
falseValue: "false"
footer: ""
header: ""
quoteStrings: false
separator: "\n"
trueValue: "true"
unquotedKeys: []
uppercaseKeys: false
agnosticCommandConfig: agnosticCommandConfig:
queryType: LOG_ONLY
playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)'
playerLeaveRegex: 'UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\]'
actions: actions:
Ban: Ban:
type: UNSUPPORTED type: UNSUPPORTED
@@ -169,22 +118,7 @@ agnosticCommandConfig:
type: UNSUPPORTED type: UNSUPPORTED
Save: Save:
type: UNSUPPORTED type: UNSUPPORTED
pingPort: null
pingPortKey: null
playerJoinRegex: '.*Join succeeded: (\S+).*'
playerLeaveRegex: .*UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\].*
playersCommand: null
playersFormat: null
playersRegex: null
queryPortKey: null
queryType: LOG_ONLY
rconPasswordKey: null
rconPortKey: null
restEndpoint: null
restPasswordKey: null
restPortKey: null
restUser: null
backupPaths: backupPaths:
- saved - .config/Epic/FactoryGame/Saved/SaveGames
maxBackupSizeMb: 1024 - FactoryGame/Saved/Config
maxPlayersConfigKey: MAXPLAYERS maxBackupSizeMb: 2048
+2 -1
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=../../schema/test.schema.json # yaml-language-server: $schema=../../schema/test.schema.json
# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.
timeoutSec: 900 # SteamCMD install of ~8 GB plus first start
timeoutSec: 2400