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 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
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 };

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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)));
}