MISC: Support importing Steam Cloud save file manually (#2583)

This commit is contained in:
catloversg
2026-03-22 12:23:43 +07:00
committed by GitHub
parent a9bb3f6d2f
commit 77cc7874ab
5 changed files with 80 additions and 10 deletions

View File

@@ -1 +1,4 @@
export declare const encodeBytesToBase64String: (bytes: Uint8Array<ArrayBuffer>) => string;
export declare const decodeBase64BytesToBytes: (bytes: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>;
export declare const isBinaryFormat: (saveData: string | Uint8Array<ArrayBuffer>) => boolean; export declare const isBinaryFormat: (saveData: string | Uint8Array<ArrayBuffer>) => boolean;
export declare const isSteamCloudFormat: (saveData: string | Uint8Array<ArrayBuffer>) => boolean;

View File

@@ -1,13 +1,68 @@
// The 2 magic bytes of the gzip header plus the mandatory compression type of DEFLATE // 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) { function isBinaryFormat(rawData) {
for (let i = 0; i < magicBytes.length; ++i) { for (let i = 0; i < magicBytesOfDeflateGzip.length; ++i) {
if (magicBytes[i] !== rawData[i]) { if (magicBytesOfDeflateGzip[i] !== rawData[i]) {
return false; return false;
} }
} }
return true; 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 };

View File

@@ -6,7 +6,7 @@ const fs = require("fs/promises");
const log = require("electron-log"); const log = require("electron-log");
const flatten = require("lodash/flatten"); const flatten = require("lodash/flatten");
const Store = require("electron-store"); const Store = require("electron-store");
const { isBinaryFormat } = require("./saveDataBinaryFormat"); const { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } = require("./saveDataBinaryFormat");
const store = new Store(); const store = new Store();
const { steamworksClient } = require("./steamworksUtils"); const { steamworksClient } = require("./steamworksUtils");
@@ -224,7 +224,7 @@ async function getSteamCloudSaveData() {
async function saveGameToDisk(window, electronGameData) { async function saveGameToDisk(window, electronGameData) {
const currentFolder = getSaveFolder(window); const currentFolder = getSaveFolder(window);
let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); 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; const remainingSpaceBytes = maxFolderSizeBytes - saveFolderSizeBytes;
log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`); log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`);
log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`); log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`);
@@ -282,6 +282,9 @@ async function loadFileFromDisk(path) {
if (isBinaryFormat(buffer)) { if (isBinaryFormat(buffer)) {
// Save file is in the binary format. // Save file is in the binary format.
content = buffer; content = buffer;
} else if (isSteamCloudFormat(buffer)) {
// Save file is in the Steam Cloud format.
content = decodeBase64BytesToBytes(buffer);
} else { } else {
// Save file is in the base64 format. // Save file is in the base64 format.
content = buffer.toString("utf8"); content = buffer.toString("utf8");

View File

@@ -23,7 +23,7 @@ import { pushGameSaved, pushImportResult } from "./Electron";
import { getGoSave, loadGo } from "./Go/SaveLoad"; import { getGoSave, loadGo } from "./Go/SaveLoad";
import { SaveData } from "./types"; import { SaveData } from "./types";
import { SaveDataError, canUseBinaryFormat, decodeSaveData, encodeJsonSaveString } from "./utils/SaveDataUtils"; 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 { downloadContentAsFile } from "./utils/FileUtils";
import { handleGetSaveDataInfoError } from "./utils/ErrorHandler"; import { handleGetSaveDataInfoError } from "./utils/ErrorHandler";
import { isObject, assertObject } from "./utils/TypeAssertion"; import { isObject, assertObject } from "./utils/TypeAssertion";
@@ -346,6 +346,9 @@ class BitburnerSaveObject implements BitburnerSaveObjectType {
if (isBinaryFormat(rawData)) { if (isBinaryFormat(rawData)) {
return rawData; return rawData;
} }
if (isSteamCloudFormat(rawData)) {
return decodeBase64BytesToBytes(rawData);
}
return new TextDecoder().decode(rawData); return new TextDecoder().decode(rawData);
} }

View File

@@ -1,15 +1,21 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const fs = require("fs").promises; const fs = require("fs").promises;
const path = require("path"); 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) { async function getSave(file) {
const data = await fs.readFile(file); const data = await fs.readFile(file);
let jsonSaveString; let jsonSaveString;
if (isBinaryFormat(data)) { if (isBinaryFormat(data)) {
const decompressedReadableStream = new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")); jsonSaveString = await decompress(data);
jsonSaveString = await new Response(decompressedReadableStream).text(); } else if (isSteamCloudFormat(data)) {
jsonSaveString = await decompress(decodeBase64BytesToBytes(data));
} else { } else {
jsonSaveString = decodeURIComponent(escape(atob(data))); jsonSaveString = decodeURIComponent(escape(atob(data)));
} }