From 77cc7874abcea170f134a8b676a7a4f1b6aa57d7 Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:23:43 +0700 Subject: [PATCH] MISC: Support importing Steam Cloud save file manually (#2583) --- electron/saveDataBinaryFormat.d.ts | 3 ++ electron/saveDataBinaryFormat.js | 63 ++++++++++++++++++++++++++++-- electron/storage.js | 7 +++- src/SaveObject.ts | 5 ++- tools/pretty-save.js | 12 ++++-- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/electron/saveDataBinaryFormat.d.ts b/electron/saveDataBinaryFormat.d.ts index 747b50f12..fa7274fd2 100644 --- a/electron/saveDataBinaryFormat.d.ts +++ b/electron/saveDataBinaryFormat.d.ts @@ -1 +1,4 @@ +export declare const encodeBytesToBase64String: (bytes: Uint8Array) => string; +export declare const decodeBase64BytesToBytes: (bytes: Uint8Array) => Uint8Array; export declare const isBinaryFormat: (saveData: string | Uint8Array) => boolean; +export declare const isSteamCloudFormat: (saveData: string | Uint8Array) => boolean; diff --git a/electron/saveDataBinaryFormat.js b/electron/saveDataBinaryFormat.js index ea7f3ea4c..6f787fa8d 100644 --- a/electron/saveDataBinaryFormat.js +++ b/electron/saveDataBinaryFormat.js @@ -1,13 +1,68 @@ // The 2 magic bytes of the gzip header plus the mandatory compression type of DEFLATE -const magicBytes = [0x1f, 0x8b, 0x08]; +const magicBytesOfDeflateGzip = new Uint8Array([0x1f, 0x8b, 0x08]); +// Base64-encoded string of magicBytesOfDeflateGzip +const base64EncodingOfMagicBytes = encodeBytesToBase64String(magicBytesOfDeflateGzip); +// Convert the base64-encoded string to a byte array +const byteArrayOfBase64EncodingOfMagicBytes = Uint8Array.from(base64EncodingOfMagicBytes, (c) => c.charCodeAt(0)); +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +function encodeBytesToBase64String(bytes) { + let binaryString = ""; + for (let i = 0; i < bytes.length; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + return btoa(binaryString); +} + +/** + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function decodeBase64BytesToBytes(bytes) { + let base64String = ""; + for (let i = 0; i < bytes.length; i++) { + base64String += String.fromCharCode(bytes[i]); + } + const decodedBinaryString = atob(base64String); + const result = new Uint8Array(decodedBinaryString.length); + for (let i = 0; i < decodedBinaryString.length; i++) { + result[i] = decodedBinaryString.charCodeAt(i); + } + return result; +} + +/** + * @param {string | Uint8Array} rawData + * @returns {boolean} + */ function isBinaryFormat(rawData) { - for (let i = 0; i < magicBytes.length; ++i) { - if (magicBytes[i] !== rawData[i]) { + for (let i = 0; i < magicBytesOfDeflateGzip.length; ++i) { + if (magicBytesOfDeflateGzip[i] !== rawData[i]) { return false; } } return true; } -module.exports = { isBinaryFormat }; +/** + * The Steam Cloud save file is a base64-encoded gz file. + * + * @param {string | Uint8Array} rawData + * @returns {boolean} + */ +function isSteamCloudFormat(rawData) { + if (typeof rawData === "string") { + return rawData.startsWith(base64EncodingOfMagicBytes); + } + for (let i = 0; i < byteArrayOfBase64EncodingOfMagicBytes.length; ++i) { + if (byteArrayOfBase64EncodingOfMagicBytes[i] !== rawData[i]) { + return false; + } + } + return true; +} + +module.exports = { encodeBytesToBase64String, decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat }; diff --git a/electron/storage.js b/electron/storage.js index 7b9c2ac98..47304eefb 100644 --- a/electron/storage.js +++ b/electron/storage.js @@ -6,7 +6,7 @@ const fs = require("fs/promises"); const log = require("electron-log"); const flatten = require("lodash/flatten"); const Store = require("electron-store"); -const { isBinaryFormat } = require("./saveDataBinaryFormat"); +const { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } = require("./saveDataBinaryFormat"); const store = new Store(); const { steamworksClient } = require("./steamworksUtils"); @@ -224,7 +224,7 @@ async function getSteamCloudSaveData() { async function saveGameToDisk(window, electronGameData) { const currentFolder = getSaveFolder(window); let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); - const maxFolderSizeBytes = store.get("autosave-quota", 1e8); // 100Mb per playerIndentifier + const maxFolderSizeBytes = store.get("autosave-quota", 1e8); // 100Mb const remainingSpaceBytes = maxFolderSizeBytes - saveFolderSizeBytes; log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`); log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`); @@ -282,6 +282,9 @@ async function loadFileFromDisk(path) { if (isBinaryFormat(buffer)) { // Save file is in the binary format. content = buffer; + } else if (isSteamCloudFormat(buffer)) { + // Save file is in the Steam Cloud format. + content = decodeBase64BytesToBytes(buffer); } else { // Save file is in the base64 format. content = buffer.toString("utf8"); diff --git a/src/SaveObject.ts b/src/SaveObject.ts index 809977925..95e463b19 100644 --- a/src/SaveObject.ts +++ b/src/SaveObject.ts @@ -23,7 +23,7 @@ import { pushGameSaved, pushImportResult } from "./Electron"; import { getGoSave, loadGo } from "./Go/SaveLoad"; import { SaveData } from "./types"; import { SaveDataError, canUseBinaryFormat, decodeSaveData, encodeJsonSaveString } from "./utils/SaveDataUtils"; -import { isBinaryFormat } from "../electron/saveDataBinaryFormat"; +import { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } from "../electron/saveDataBinaryFormat"; import { downloadContentAsFile } from "./utils/FileUtils"; import { handleGetSaveDataInfoError } from "./utils/ErrorHandler"; import { isObject, assertObject } from "./utils/TypeAssertion"; @@ -346,6 +346,9 @@ class BitburnerSaveObject implements BitburnerSaveObjectType { if (isBinaryFormat(rawData)) { return rawData; } + if (isSteamCloudFormat(rawData)) { + return decodeBase64BytesToBytes(rawData); + } return new TextDecoder().decode(rawData); } diff --git a/tools/pretty-save.js b/tools/pretty-save.js index 2a818206a..d3ff44ba1 100644 --- a/tools/pretty-save.js +++ b/tools/pretty-save.js @@ -1,15 +1,21 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const fs = require("fs").promises; const path = require("path"); -const { isBinaryFormat } = require("../electron/saveDataBinaryFormat"); +const { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } = require("../electron/saveDataBinaryFormat"); + +async function decompress(data) { + const decompressedReadableStream = new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")); + return await new Response(decompressedReadableStream).text(); +} async function getSave(file) { const data = await fs.readFile(file); let jsonSaveString; if (isBinaryFormat(data)) { - const decompressedReadableStream = new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")); - jsonSaveString = await new Response(decompressedReadableStream).text(); + jsonSaveString = await decompress(data); + } else if (isSteamCloudFormat(data)) { + jsonSaveString = await decompress(decodeBase64BytesToBytes(data)); } else { jsonSaveString = decodeURIComponent(escape(atob(data))); }