fix(builder): SteamCMD is the only option for Linux Steam servers; settings must reach the server
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

This commit is contained in:
Greg Reed committed 2026-11-07 01:58:27 +00:00
1 parent 1fcf05cc27
commit 010d7a57a1
3 files changed
+70 -10

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)