From ca3dbab49d3d746ec2005e799a8fc7d5c59fd4f8 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:16:52 +0000 Subject: [PATCH 1/2] Unpatch saves started with save patching disabled Turning factorio.enable_save_patching off did nothing to a save that was patched before. Its control.lua still loaded every module, so the clusterio_api interface, player_sync's join handling and the other module event handlers kept running with no plugin on the host side. When patching is disabled the instance now restores the original control.lua that patching kept as scenario.lua and removes the module files, locale files included, as listed in clusterio.json. The patch info stays with an empty module list so the patch number keeps counting. Otherwise patching the save again would reuse the number stored in the game and on_server_startup would not be raised. Data the modules put in storage is left alone. PatchInfo.fromSave dropped the version read from the save, so the check against newer patch versions never fired. It now keeps it. Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 2 + docs/how-it-works.md | 1 + packages/host/src/Instance.ts | 5 +- packages/host/src/patch.ts | 165 ++++++++++++++++++++++++---------- test/host/patch.js | 86 ++++++++++++++++++ test/integration/instance.js | 19 ++++ 6 files changed, 229 insertions(+), 49 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 41683905..5d3b3768 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -639,6 +639,8 @@ Most plugins require the use of save patching to function. Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio. Plugins that need save patching can't be loaded on the instance while this is disabled. Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled. +Saves patched before are restored to their original control.lua and have the modules removed when started with this disabled. +Data the modules stored in the game is left in place. Defaults to true. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index cf99000d..d3736cad 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -27,6 +27,7 @@ The save patching is based on the built in [event_handler](https://github.com/wu Mods on the other hand work without modifications. This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function. +A save that was patched before has its modules removed again when it's started with save patching disabled. To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it. Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario. diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index a6a6c02c..d6cfe046 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -11,7 +11,7 @@ const execAsync = util.promisify(exec); import * as lib from "@clusterio/lib"; import { FactorioServer, checkForUpdates } from "./server.js"; -import { loadModules, patch } from "./patch.js"; +import { loadModules, patch, unpatch } from "./patch.js"; import { exportData } from "./export.js"; import type Host from "./Host.js"; import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js"; @@ -1082,6 +1082,9 @@ end`.replace(/\r?\n/g, " "); } if (!this.config.get("factorio.enable_save_patching")) { + if (await unpatch(this.path("saves", saveName))) { + this.logger.info("Removed Clusterio modules from save as save patching is disabled"); + } return saveName; } diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index dbf3022d..b23b01a3 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -227,6 +227,7 @@ export class PatchInfo { json.patch_number, lib.ModuleInfo.fromJSON(json.scenario), await Promise.all(json.modules.map(m => SaveModule.fromSave(m, zip, root))), + json.version, ); } } @@ -381,6 +382,72 @@ const knownScenarios: Record = { "3af547d2f4db3728b75ac5d1b3a4f47830dc48e7": new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]), }; +/** + * Names of the zip entries added by patching for the modules in the patch info + */ +function moduleEntries(zip: lib.ZipArchive, patchInfo: PatchInfo, root: string) { + const entries = new Set(); + if (patchInfo.version === 0) { + const modulesPath = path.posix.join(root, "modules/"); + for (const name of zip.entries.keys()) { + if (name.startsWith(modulesPath)) { + entries.add(name); + } + } + } else { + for (const module of patchInfo.modules) { + for (const filepath of module.files.keys()) { + entries.add(path.posix.join(root, filepath)); + } + } + } + return entries; +} + +/** + * Write a copy of the save next to it with entries removed and added + * + * Keeps the order of the entries in the original. + * + * @returns Path to the temporary file written. + */ +async function rewriteSave( + zip: lib.ZipArchive, savePath: string, removed: Set, added: Map +) { + added = new Map(added); + const output = new lib.ZipWriter(); + const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath); + const writing = pipeline(output.outputStream, writeStream); + // Awaited below, but a write error can reject it before then. + writing.catch(() => {}); + try { + for (const entry of zip.entries.values()) { + const name = entry.fileName; + if (removed.has(name)) { + continue; + } + const contents = added.get(name); + if (contents !== undefined) { + await output.addBuffer(contents, name); + added.delete(name); + } else { + await output.addEntry(zip, entry); + } + } + for (const [name, contents] of added) { + await output.addBuffer(contents, name); + } + await output.end(); + await writing; + } catch (err) { + output.outputStream.destroy(); + await writing.catch(() => {}); + await fs.rm(tempSavePath, { force: true }); + throw err; + } + return tempSavePath; +} + /** * Patch a save with the given modules * @@ -432,22 +499,8 @@ export async function patch(savePath: string, modules: SaveModule[]) { patchInfo.patchNumber = patchInfo.patchNumber + 1; // Remove any existing modules from the save - const removed = new Set(); - if (patchInfo.version === 0) { - const modulesPath = rootPath("modules/"); - for (const name of zip.entries.keys()) { - if (name.startsWith(modulesPath)) { - removed.add(name); - } - } - } else { - for (const module of patchInfo.modules) { - for (const filepath of module.files.keys()) { - removed.add(rootPath(filepath)); - } - } - patchInfo.modules = []; - } + const removed = moduleEntries(zip, patchInfo, root); + patchInfo.modules = []; reorderDependencies(modules); @@ -463,45 +516,61 @@ export async function patch(savePath: string, modules: SaveModule[]) { files.set("control.lua", Buffer.from(generateLoader(patchInfo), "utf8")); files.set("clusterio.json", Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8")); - // Write back the save, keeping the order of the entries in the original const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents])); - const output = new lib.ZipWriter(); - let writeStream; - [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath); - const writing = pipeline(output.outputStream, writeStream); - // Awaited below, but a write error can reject it before then. - writing.catch(() => {}); - try { - for (const entry of zip.entries.values()) { - const name = entry.fileName; - if (removed.has(name)) { - continue; - } - const contents = added.get(name); - if (contents !== undefined) { - await output.addBuffer(contents, name); - added.delete(name); - } else { - await output.addEntry(zip, entry); - } - } - for (const [name, contents] of added) { - await output.addBuffer(contents, name); - } - await output.end(); - await writing; - } catch (err) { - output.outputStream.destroy(); - await writing.catch(() => {}); - await fs.rm(tempSavePath, { force: true }); - throw err; - } + tempSavePath = await rewriteSave(zip, savePath, removed, added); } finally { zip.close(); } await fs.rename(tempSavePath, savePath); } +/** + * Remove the modules patched into a save + * + * Restores the original control.lua that patching kept as scenario.lua and + * removes the module files. The patch info is kept with no modules so that + * the patch number keeps counting if the save is patched again. + * + * @param savePath - Path to the Factorio save to unpatch. + * @returns true if the save was changed. + */ +export async function unpatch(savePath: string) { + const zip = await lib.ZipArchive.fromFile(savePath); + let tempSavePath: string; + try { + const root = lib.findRoot(zip); + const rootPath = (relativePath: string) => path.posix.join(root, relativePath); + const patchInfoContent = await zip.readFile(rootPath("clusterio.json")); + if (patchInfoContent === null) { + return false; + } + const patchInfo = await PatchInfo.fromSave(JSON.parse(patchInfoContent.toString("utf8")), zip, root); + if (patchInfo.version > PatchInfo.currentVersion) { + throw new Error( + `Save patch version ${patchInfo.version} is newer than the patch version this ` + + `version of Clusterio can unpatch (${PatchInfo.currentVersion})` + ); + } + if (patchInfo.version !== 0 && !patchInfo.modules.length) { + return false; + } + + const removed = moduleEntries(zip, patchInfo, root); + patchInfo.modules = []; + // Scenarios made for Clusterio have no scenario.lua, keep loading them without modules + const control = await zip.readFile(rootPath("scenario.lua")) + ?? Buffer.from(generateLoader(patchInfo), "utf8"); + tempSavePath = await rewriteSave(zip, savePath, removed, new Map([ + [rootPath("control.lua"), control], + [rootPath("clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8")], + ])); + } finally { + zip.close(); + } + await fs.rename(tempSavePath, savePath); + return true; +} + // For testing only export const _generateLoader = generateLoader; export const _reorderDependencies = reorderDependencies; diff --git a/test/host/patch.js b/test/host/patch.js index 7c3e7267..047e510e 100644 --- a/test/host/patch.js +++ b/test/host/patch.js @@ -271,4 +271,90 @@ describe("host/patch", function() { ); }); }); + + describe("unpatch()", function() { + const freeplayControl = "require('__base__/script/freeplay/control.lua')\n"; + const zipPath = path.join("temp", "test", "unpatch.zip"); + let testModule; + before(async function() { + testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0")); + await testModule.loadFiles("test/file/modules/test"); + }); + async function readSave() { + const zip = await lib.ZipArchive.fromFile(zipPath); + try { + const files = new Map(); + for (const name of zip.entries.keys()) { + files.set(name, (await zip.readFile(name)).toString("utf8")); + } + return files; + } finally { + zip.close(); + } + } + + it("should leave saves that are not patched untouched", async function() { + await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]])); + assert.equal(await patch.unpatch(zipPath), false); + assert.deepEqual([...(await readSave()).keys()], ["world/control.lua"]); + }); + it("should restore control.lua and remove module files", async function() { + await fs.writeFile(zipPath, await createZipBuffer([ + ["world/control.lua", freeplayControl], + ["world/level.dat0", "level"], + ])); + await patch.patch(zipPath, [testModule]); + assert.equal(await patch.unpatch(zipPath), true); + + const files = await readSave(); + assert.deepEqual( + [...files.keys()].sort(), + ["world/clusterio.json", "world/control.lua", "world/level.dat0", "world/scenario.lua"], + ); + assert.equal(files.get("world/control.lua"), freeplayControl); + const patchInfo = JSON.parse(files.get("world/clusterio.json")); + assert.equal(patchInfo.patch_number, 1); + assert.deepEqual(patchInfo.modules, []); + + assert.equal(await patch.unpatch(zipPath), false, "unpatched twice"); + }); + it("should throw on a newer patch version", async function() { + const scenario = new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]); + const patchInfo = { version: 2, patch_number: 1, scenario, modules: [] }; + await fs.writeFile(zipPath, await createZipBuffer([ + ["world/control.lua", freeplayControl], + ["world/clusterio.json", JSON.stringify(patchInfo)], + ])); + await assert.rejects(patch.unpatch(zipPath), /Save patch version 2 is newer/); + await assert.rejects(patch.patch(zipPath, []), /Save patch version 2 is newer/); + }); + it("should keep counting the patch number when patched again", async function() { + await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]])); + await patch.patch(zipPath, [testModule]); + await patch.unpatch(zipPath); + await patch.patch(zipPath, [testModule]); + + const files = await readSave(); + assert.equal(JSON.parse(files.get("world/clusterio.json")).patch_number, 2); + assert(files.has("world/modules/test/test.lua")); + }); + it("should keep the loader of scenarios made for Clusterio", async function() { + const scenario = new lib.ModuleInfo("custom", "1.0.0", [], ["custom"]); + const patchInfo = new patch.PatchInfo(3, scenario, [testModule]); + await fs.writeFile(zipPath, await createZipBuffer([ + ["world/control.lua", patch._generateLoader(patchInfo)], + ["world/clusterio.json", JSON.stringify(patchInfo)], + ["world/custom.lua", "-- custom\n"], + ...[...testModule.files].map(([name, content]) => [`world/${name}`, content]), + ])); + assert.equal(await patch.unpatch(zipPath), true); + + const files = await readSave(); + assert.deepEqual( + [...files.keys()].sort(), + ["world/clusterio.json", "world/control.lua", "world/custom.lua"], + ); + assert.equal(files.get("world/control.lua"), patch._generateLoader(new patch.PatchInfo(3, scenario, []))); + }); + }); }); diff --git a/test/integration/instance.js b/test/integration/instance.js index 708b43d6..d8fe5206 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -153,6 +153,25 @@ describe("Clusterio Instance", function() { assert.deepEqual(whitelist, ["test_user"]); }); }); + describe("save patching disabled", function() { + it("should remove the modules from a save patched before", async function() { + this.timeout(40000); + const hasApi = "/sc rcon.print(tostring(remote.interfaces.clusterio_api ~= nil))"; + await execCtl(`${instSetConfig} factorio.enable_save_patching true`); + await execCtl(`instance save create ${instName}`); + await execCtl(`instance start ${instName}`); + assert.equal(await sendRcon(instId, hasApi), "true\n"); + await execCtl(`instance stop ${instName}`); + + await execCtl(`${instSetConfig} factorio.enable_save_patching false`); + await execCtl(`instance start ${instName}`); + try { + assert.equal(await sendRcon(instId, hasApi), "false\n"); + } finally { + await execCtl(`instance stop ${instName}`); + } + }); + }); for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) { const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`; const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`; From 513d964a8b72c90e3b285b20603723ce6b12010b Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:21:19 +0000 Subject: [PATCH 2/2] Remove scenario.lua when unpatching a save Unpatching moves scenario.lua back to control.lua instead of copying it, so the save only keeps clusterio.json for the patch number. Patching such a save again copies control.lua back to scenario.lua. Co-Authored-By: Claude Opus 5.5 --- packages/host/src/patch.ts | 21 +++++++++++++++++---- test/host/patch.js | 4 +++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index b23b01a3..81e0f300 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -479,6 +479,16 @@ export async function patch(savePath: string, modules: SaveModule[]) { ); } + // Unpatching put the original control.lua back in place of scenario.lua + const scenarioRequired = [...patchInfo.scenario.load, ...patchInfo.scenario.require].includes("scenario"); + if (scenarioRequired && !zip.file(rootPath("scenario.lua"))) { + const control = await zip.readFile(rootPath("control.lua")); + if (control === null) { + throw new Error("Unable to patch save, missing control.lua file."); + } + files.set("scenario.lua", control); + } + // No info file present, try to detect if it's a known compatible scenario. } else { const controlFile = zip.file(rootPath("control.lua")); @@ -527,8 +537,8 @@ export async function patch(savePath: string, modules: SaveModule[]) { /** * Remove the modules patched into a save * - * Restores the original control.lua that patching kept as scenario.lua and - * removes the module files. The patch info is kept with no modules so that + * Moves the original control.lua that patching kept as scenario.lua back + * and removes the module files. The patch info is kept with no modules so that * the patch number keeps counting if the save is patched again. * * @param savePath - Path to the Factorio save to unpatch. @@ -557,9 +567,12 @@ export async function unpatch(savePath: string) { const removed = moduleEntries(zip, patchInfo, root); patchInfo.modules = []; + const scenario = await zip.readFile(rootPath("scenario.lua")); + if (scenario !== null) { + removed.add(rootPath("scenario.lua")); + } // Scenarios made for Clusterio have no scenario.lua, keep loading them without modules - const control = await zip.readFile(rootPath("scenario.lua")) - ?? Buffer.from(generateLoader(patchInfo), "utf8"); + const control = scenario ?? Buffer.from(generateLoader(patchInfo), "utf8"); tempSavePath = await rewriteSave(zip, savePath, removed, new Map([ [rootPath("control.lua"), control], [rootPath("clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8")], diff --git a/test/host/patch.js b/test/host/patch.js index 047e510e..e3f3ab3a 100644 --- a/test/host/patch.js +++ b/test/host/patch.js @@ -309,7 +309,7 @@ describe("host/patch", function() { const files = await readSave(); assert.deepEqual( [...files.keys()].sort(), - ["world/clusterio.json", "world/control.lua", "world/level.dat0", "world/scenario.lua"], + ["world/clusterio.json", "world/control.lua", "world/level.dat0"], ); assert.equal(files.get("world/control.lua"), freeplayControl); const patchInfo = JSON.parse(files.get("world/clusterio.json")); @@ -337,6 +337,8 @@ describe("host/patch", function() { const files = await readSave(); assert.equal(JSON.parse(files.get("world/clusterio.json")).patch_number, 2); assert(files.has("world/modules/test/test.lua")); + assert.equal(files.get("world/scenario.lua"), freeplayControl); + assert.match(files.get("world/control.lua"), /^-- Auto generated scenario module loader/); }); it("should keep the loader of scenarios made for Clusterio", async function() { const scenario = new lib.ModuleInfo("custom", "1.0.0", [], ["custom"]);