Find a full install in host.factorio_directory instead of a setting

Per review, drop host.factorio_graphics_directory. findVersion takes a
headless option that filters installs by build, telling them apart by
data/base/graphics/icons. When the instance runs the headless build the
export looks for a full install of the same major.minor version in the
host's factorio directory. Server startup is unchanged.

If none is found the export still runs, and the reason is included in
the missing icons warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-11-01 20:53:05 +00:00
1 parent 46c6153201
commit 37d74ca2a6
10 files changed
+132 -65

No files matched your search

+1 -10
View File
@@ -354,20 +354,11 @@ Defaults to a random 31 bit number.
Directory to look for the Factorio server(s) in.
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
The headless build has no graphics, so when exporting data from an instance running it the base game's icons are read from a full install of the same major and minor version in this directory instead, if there is one.
Defaults to "factorio".
### host.factorio_graphics_directory
Directory to read the base game's icons from when exporting data.
The headless build of Factorio has no graphics, so instances running it export none of the base game's icons unless this points to a full install of Factorio.
Like `host.factorio_directory` this can either point to a Factorio directory, or to a directory containing multiple versions, in which case the latest one matching the major and minor version of the instance is used.
If there is no install matching the instance, a warning is logged and the data is exported without it.
Defaults to null.
### host.mods_directory
Directory the host stores mods downloaded from the controller in.
+1 -1
View File
@@ -1479,7 +1479,7 @@ end`.replace(/\r?\n/g, " ");
this.logger.info("Exporting data .....");
await this.syncMods();
let zip = await exportData(this.server, this._host.config.get("host.factorio_graphics_directory"));
let zip = await exportData(this.server);
let url = new URL(this._host.config.get("host.controller_url"));
url.pathname += "api/upload-export";
+25 -27
View File
@@ -10,7 +10,7 @@ import yazl from "yazl";
import * as lib from "@clusterio/lib";
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js";
import { findVersion, type FactorioServer } from "./server.js";
import { findVersion, isHeadless, type FactorioServer } from "./server.js";
import { definesPrototypesFallback } from "./export_fallback.js";
interface SimpleIconSpecification {
@@ -100,6 +100,8 @@ interface ExportFiles {
zipCache: ZipCache;
/** Data dir of a full install to fall back to for builtin mod files. */
graphicsDataDir?: string;
/** Why no full install was found for a headless server. */
graphicsError?: string;
}
function closeZipCache(zipCache: ZipCache) {
@@ -524,43 +526,36 @@ async function exportLocale(
}
/**
* Find the data dir to read builtin mod graphics from
* Find a full install to read builtin mod graphics from
*
* @param server - The server the export runs on.
* @param graphicsDirectory - Full Factorio install, or directory of installs.
* @returns
* Data dir of the install matching the major and minor version of the
* server, or undefined if there is none.
* @param files - Updated with the data dir found, or why there is none.
* @internal
*/
async function findGraphicsDataDir(server: FactorioServer, graphicsDirectory: string) {
async function findGraphicsDataDir(server: FactorioServer, files: ExportFiles) {
const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion;
try {
const [dataDir] = await findVersion(graphicsDirectory, majorMinor);
return dataDir;
[files.graphicsDataDir] = await findVersion(server._factorioDir, majorMinor, { headless: false });
} catch (err: any) {
// Without it the export still has everything but the base game icons
server._logger.warn(
`Unable to use host.factorio_graphics_directory for Factorio ${majorMinor}, exporting without it: ` +
`${err.message}`
);
return undefined;
files.graphicsError = err.message;
}
}
/**
* Export locale, icons and metadata for the given factorio server
*
* If the server runs the headless build, builtin mod graphics are read
* from a full install of the same major and minor version found next to
* it, if there is one.
*
* @param server - The server to export the data from.
* @param graphicsDirectory -
* Full Factorio install, or directory of installs, to read builtin mod
* graphics from when the server's install lacks them.
* @returns zip file with exported data.
*/
export async function exportData(server: FactorioServer, graphicsDirectory?: string | null) {
export async function exportData(server: FactorioServer) {
const files: ExportFiles = { zipCache: new Map() };
if (graphicsDirectory) {
files.graphicsDataDir = await findGraphicsDataDir(server, graphicsDirectory);
if (await isHeadless(server.dataPath())) {
await findGraphicsDataDir(server, files);
}
try {
return await buildExport(server, files);
@@ -592,13 +587,16 @@ function warnMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, ico
}
const dirs = [server.dataPath(), files.graphicsDataDir].filter(Boolean).join(" or ");
const hint = files.graphicsDataDir
? "Check that host.factorio_graphics_directory points to a full Factorio install"
: "Set host.factorio_graphics_directory to a full Factorio install to read them from there";
server._logger.warn(
`${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported. ` +
`The headless build of Factorio has no graphics. ${hint}, or export the data on a host with a full install.`
);
let message =
`${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported.`;
if (files.graphicsError !== undefined) {
const majorMinor = server.version!.replace(/\.\d+$/, "");
message +=
` The headless build of Factorio has no graphics and no full install was found (${files.graphicsError}).` +
` Add a full install of Factorio ${majorMinor} to host.factorio_directory to read them from there,` +
" or export the data on a host with a full install.";
}
server._logger.warn(message);
}
async function buildExport(server: FactorioServer, files: ExportFiles) {
+41 -3
View File
@@ -262,15 +262,34 @@ export async function checkForUpdates(
* @param targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
* @param options - Optional parameters.
* @param options.headless -
* Only consider headless installs if true, or only full installs if
* false.
* @returns Array with path to data dir and version found.
*/
export async function findVersion(
factorioDir: string,
targetVersion: lib.TargetVersion,
options: { headless?: boolean } = {},
): Promise<[string, lib.FullVersion]> {
let kind = "";
if (options.headless !== undefined) {
kind = options.headless ? "headless " : "full ";
}
const wrongKind = async (dir: string) => (
options.headless !== undefined && await isHeadless(path.join(dir, "data")) !== options.headless
);
// Check if this is a direct installation dir
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
if (await wrongKind(factorioDir)) {
throw new Error(
`Unable to find a ${kind}install of Factorio ${targetVersion}: ${factorioDir} is a direct ` +
`(single-version) install of the ${options.headless ? "full" : "headless"} build.`
);
}
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
}
@@ -287,7 +306,7 @@ export async function findVersion(
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
if (version === null || await wrongKind(fullPath)) {
continue;
}
@@ -299,7 +318,7 @@ export async function findVersion(
}
if (!versions.size) {
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
throw new Error(`Unable to find any ${kind}Factorio install in ${factorioDir}`);
}
if (lib.isPartialVersion(targetVersion)) {
@@ -315,7 +334,26 @@ export async function findVersion(
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
throw new Error(`Unable to find ${kind}Factorio version ${targetVersion}`);
}
/**
* Check if a Factorio install is the headless build
*
* The headless build has no icons, which is what tells it apart.
*
* @param dataDir - Path to the data dir of the install.
*/
export async function isHeadless(dataDir: string) {
try {
await fs.access(path.join(dataDir, "base", "graphics", "icons"));
return false;
} catch (err: any) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return true;
}
throw err;
}
}
/**
-9
View File
@@ -340,7 +340,6 @@ export interface HostConfigFields {
"host.name": string;
"host.id": number;
"host.factorio_directory": string;
"host.factorio_graphics_directory": string | null;
"host.mods_directory": string;
"host.instances_directory": string;
"host.controller_url": string;
@@ -389,14 +388,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
initialValue: "factorio",
validator: validators.filePath,
},
"host.factorio_graphics_directory": {
description:
"Path to a full Factorio install, or directory of installs, to read base game icons from when " +
"exporting data. Needed when running the headless build, which has no graphics.",
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"host.mods_directory": {
title: "Mods Directory",
description: "Path to directory where mods for instances are cached.",
File renamed without changes.

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 206 B

@@ -0,0 +1,3 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.2
About: Dummy changelog.txt for FactorioServer tests
@@ -0,0 +1,3 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.2
About: Dummy changelog.txt for FactorioServer tests
+31 -15
View File
@@ -74,7 +74,7 @@ describe("host/src/export", function() {
});
// Stands in for the data dir of a full install
const graphicsDataDir = path.join("test", "file", "factorio-graphics");
const graphicsDataDir = path.join("test", "file", "factorio-mixed", "full", "data");
describe("loadSimpleIcon()", function() {
it("defaults icon_size to 64", async function() {
const icon = await _loadSimpleIcon(
@@ -124,21 +124,29 @@ describe("host/src/export", function() {
});
describe("findGraphicsDataDir()", function() {
const installs = path.join("test", "file", "factorio");
it("returns the data dir of the install matching the server version", async function() {
const dataDir = await _findGraphicsDataDir(testServer, installs);
assert.equal(dataDir, path.join(installs, "0.1.2", "data"));
assert.deepEqual(warnings, []);
const withInstalls = (factorioDir, version = testServer.version) => Object.create(testServer, {
_factorioDir: { value: factorioDir }, version: { value: version },
});
it("warns and returns undefined when no install matches the server version", async function() {
const server = Object.create(testServer, { version: { value: "0.2.5" } });
assert.equal(await _findGraphicsDataDir(server, installs), undefined);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.2, /);
it("finds a full install matching the major and minor version of the server", async function() {
const files = { zipCache: new Map() };
await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio"), "0.1.1"), files);
assert.deepEqual(files, { zipCache: new Map(), graphicsDataDir: path.join(installs, "0.1.2", "data") });
});
it("warns and returns undefined when the directory does not exist", async function() {
assert.equal(await _findGraphicsDataDir(testServer, path.join("test", "file", "missing")), undefined);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.1, .*ENOENT/);
it("skips headless installs of the same version", async function() {
const files = { zipCache: new Map() };
await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio-mixed")), files);
assert.equal(files.graphicsDataDir, graphicsDataDir);
});
it("records why when there is no full install", async function() {
const files = { zipCache: new Map() };
await _findGraphicsDataDir(withInstalls(installs, "0.2.5"), files);
assert.equal(files.graphicsDataDir, undefined);
assert.equal(files.graphicsError, "Unable to find full Factorio version 0.2");
});
it("records why when the directory does not exist", async function() {
const files = { zipCache: new Map() };
await _findGraphicsDataDir(withInstalls(path.join("test", "file", "missing")), files);
assert.match(files.graphicsError, /ENOENT/);
});
});
describe("warnMissingBuiltinIcons()", function() {
@@ -151,7 +159,15 @@ describe("host/src/export", function() {
]));
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^2 icon files of the base game were not found/);
assert.match(warnings[0], /headless/);
assert.doesNotMatch(warnings[0], /headless/);
});
it("explains the headless build when no full install was found", function() {
_warnMissingBuiltinIcons(testServer, { zipCache: new Map(), graphicsError: "Not found" }, new Map([
["__base__/graphics/icons/a.png", null],
]));
assert.equal(warnings.length, 1);
assert.match(warnings[0], /has no graphics and no full install was found \(Not found\)/);
assert.match(warnings[0], /Add a full install of Factorio 0\.1 to host\.factorio_directory/);
});
it("does not warn when no builtin files are missing", function() {
_warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([
+27
View File
@@ -105,6 +105,33 @@ describe("host/server", function() {
);
});
});
describe("headless option", function() {
const mixedDir = path.join("test", "file", "factorio-mixed");
it("should find only full installs when false", async function() {
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: false });
assert.equal(dir, path.join(mixedDir, "full", "data"));
});
it("should find only headless installs when true", async function() {
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: true });
assert.equal(dir, path.join(mixedDir, "headless", "data"));
});
it("should reject a direct install of the other build", async function() {
const installDir = path.join(mixedDir, "headless");
await assert.rejects(
hostServer.findVersion(installDir, "0.1.2", { headless: false }),
new Error(
`Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` +
"(single-version) install of the headless build."
)
);
});
it("should reject if no install of that build has the version", async function() {
await assert.rejects(
hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", { headless: true }),
new Error("Unable to find headless Factorio version 0.1.2")
);
});
});
});
describe("_listFactorioVersions()", function() {