diff --git a/.gitignore b/.gitignore index f31375e..858c330 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.exe -saves \ No newline at end of file +*.json +*.hg \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c2e53b..cbac569 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "deno.enable": true -} \ No newline at end of file + "deno.enable": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..92cec05 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# NMS-Save-Decoder + +I needed something to decode a .hg file, in case save editors aren't updated, or I have some other weirdly specific issue with a save. + +So this is the solution :shrug: + +This is all very hacky and barely works. It uses a [Python file by Robert Maupin](https://gist.github.com/Chase-san/16076aaa90429ea6170550926b70f48b) to compress and decompress the save, and my own Typescript Deno code to apply the mapping and write it to the disc. + +The two parts are glued together by a Windows Batch file. + +## Usage + +### Decoding a Save + +Drag'n'drop your save.hg file on the `decode.bat` file. + +### Encoding a Save + +Drag'n'drop your save.json file on the `encode.bat` file. + +## Build the TS Part from Source + +1. [Install Deno](https://docs.deno.com/runtime/manual/getting_started/installation) +2. Execute this command: + +```sh +deno compile --allow-read --allow-write --allow-net applyMapping.ts +``` diff --git a/applyMapping.ts b/applyMapping.ts new file mode 100644 index 0000000..494e956 --- /dev/null +++ b/applyMapping.ts @@ -0,0 +1,90 @@ +// deno-lint-ignore-file no-explicit-any +type Mapping = { Key: string; Value: string }; + +interface MappingObject { + libMBIN_version: string; + Mapping: Mapping[]; +} + +const [file, mappingFileName] = Deno.args; + +let mapping; + +if (mappingFileName) { + const mappingFileData = Deno.readTextFileSync(mappingFileName); + mapping = JSON.parse(mappingFileData); +} else { + mapping = await fetchMapping(); +} + +const fileData = Deno.readTextFileSync(file); + +let fileJson; +console.log("parsing file..."); +try { + fileJson = JSON.parse(fileData); +} catch { + fileJson = JSON.parse(fileData.slice(0, -1)); +} + +const isMapped = Boolean(fileJson.Version); + +const mappingFunction = isMapped ? reverseMapKeys : mapKeys; + +console.log("mapping..."); +const mappedSave = mappingFunction(fileJson, mapping); + +Deno.writeTextFileSync( + file, + JSON.stringify(mappedSave, null, isMapped ? undefined : 2), // minify when compressing +); +console.log("done!"); + +function mapKeys(json: any, mapping: Mapping[]): any { + if (Array.isArray(json)) { + return json.map((item) => mapKeys(item, mapping)); + } else if (typeof json === "object" && json !== null) { + const newJson: any = {}; + for (const key in json) { + const mappedKey = mapping.find((m) => m.Key === key)?.Value; + if (mappedKey) { + newJson[mappedKey] = mapKeys(json[key], mapping); + } else { + newJson[key] = mapKeys(json[key], mapping); + } + } + return newJson; + } else { + return json; + } +} + +function reverseMapKeys(json: any, mapping: Mapping[]): any { + if (Array.isArray(json)) { + return json.map((item) => reverseMapKeys(item, mapping)); + } else if (typeof json === "object" && json !== null) { + const newJson: any = {}; + for (const key in json) { + const originalKey = mapping.find((m) => m.Value === key)?.Key; + if (originalKey) { + newJson[originalKey] = reverseMapKeys(json[key], mapping); + } else { + newJson[key] = reverseMapKeys(json[key], mapping); + } + } + return newJson; + } else { + return json; + } +} + +async function fetchMapping() { + const mappingUrl = + "https://github.com/monkeyman192/MBINCompiler/releases/latest/download/mapping.json"; + console.log("downloading mapping..."); + const fetchedFile = await fetch(mappingUrl); + const fetchedJson: MappingObject = await fetchedFile.json(); + console.log("success!"); + + return fetchedJson.Mapping; +} diff --git a/decode.bat b/decode.bat new file mode 100644 index 0000000..d4f1279 --- /dev/null +++ b/decode.bat @@ -0,0 +1,4 @@ +@echo off +set "jsonfilename=%~n1.json" +python3 nmssavetool.py decompress %1 %jsonfilename% +applyMapping.exe %jsonfilename% diff --git a/encode.bat b/encode.bat new file mode 100644 index 0000000..4efba39 --- /dev/null +++ b/encode.bat @@ -0,0 +1,4 @@ +@echo off +set "compressedfilename=%~n1.hg" +applyMapping.exe %1 +python3 nmssavetool.py compress %1 %compressedfilename% diff --git a/main.ts b/main.ts deleted file mode 100644 index 54e90af..0000000 --- a/main.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Buffer } from "https://deno.land/std@0.141.0/node/buffer.ts"; -import lz4 from "npm:lz4"; - -function decompressSave(file: Uint8Array) { - const buf = Buffer.from(file); - - let index = 0; - const chunks = []; - - while (index < buf.length) { - const magic = buf.readUIntLE(index, 4); - index += 4; - - if (magic !== 0xfeeda1e5) { - console.error("Invalid Block assuming already decompressed"); - return buf.toString("binary"); - } - - const compressedSize = buf.readUIntLE(index, 4); - index += 4; - const uncompressedSize = buf.readUIntLE(index, 4); - index += 4; - - index += 4; // skip 4 bytes - - const output = Buffer.alloc(uncompressedSize); - lz4.decodeBlock(buf, output, index, index + compressedSize); - index += compressedSize; - - chunks.push(output); - } - - return Buffer.concat(chunks).toString("binary").slice(0, -1); -} - -const filePath = Deno.args[0]; - -const file = Deno.readFileSync(filePath); - -const decompressedFile = decompressSave(file); - -Deno.writeTextFileSync('result.json', decompressedFile); \ No newline at end of file diff --git a/nmssavetool.py b/nmssavetool.py new file mode 100644 index 0000000..ce824bc --- /dev/null +++ b/nmssavetool.py @@ -0,0 +1,103 @@ +#!/usr/bin/python3 + +# Copyright 2021, Robert Maupin +# Copying and distribution of this file, with or without modification, are +# permitted in any medium without royalty, provided the copyright notice and +# this notice are preserved. This file is offered as-is, without any warranty. + +import io +import os +import lz4.block +import math +import sys + +from glob import glob + +FILE_PATH = os.path.dirname(os.path.realpath(__file__)) + +def uint32(data: bytes) -> int: + """Convert 4 bytes to a little endian unsigned integer.""" + return int.from_bytes(data, byteorder='little', signed=False) & 0xffffffff + +def byte4(data: int): + """Convert unsigned 32 bit integer to 4 bytes.""" + return data.to_bytes(4, byteorder='little', signed=False) + +# The important part +def decompress(data): + """Decompresses the given save bytes.""" + size = len(data) + din = io.BytesIO(data) + out = bytearray() + while din.tell() < size: + magic = uint32(din.read(4)) + if magic != 0xfeeda1e5: + print("Invalid Block, bad file") + return bytes() # some unsupported format + compressedSize = uint32(din.read(4)) + uncompressedSize = uint32(din.read(4)) + din.seek(4, 1) # skip 4 bytes + out += lz4.block.decompress(din.read(compressedSize), uncompressed_size=uncompressedSize) + return out + +# The important part, part 2 +def compress(data): + """Compresses the given save bytes.""" + size = len(data) + din = io.BytesIO(data) + out = bytearray() + while din.tell() < size: + uncompressedSize = min([0x80000, size - din.tell()]) + block = lz4.block.compress(din.read(uncompressedSize), store_size=False) + out += byte4(0xfeeda1e5) + out += byte4(len(block)) + out += byte4(uncompressedSize) + out += byte4(0) + out += block + return out + +def readFile(path): + fin = open(path, "rb") + data = fin.read() + fin.close() + return data + +def writeFile(path, data): + fout = open(path, "wb") + fout.write(data) + fout.close() + + +def findSaveGames(): + """Finds all saves on windows.""" + return glob("C:\\Users\\*\\AppData\\Roaming\\HelloGames\\NMS\\*\\save*.hg") + +def usage(name): + print("No Man's Sky Save Tool v0.1") + print("Copyright (c) 2021 Robert Maupin. Permissive License.") + print("Usage: %s compress " % os.path.basename(name)) + print("Usage: %s decompress " % os.path.basename(name)) + print("Usage: %s list" % os.path.basename(name)) + print(" compress Decompress a given 'No Man's Sky' save file.") + print(" decompress Compress a given 'No Man's Sky' save file.") + print(" list Searchs for save files and outputs what files were found.") + +def main(): + args = sys.argv + if len(args) == 2: + if args[1] == "l" or args[1] == "list": + for file in findSaveGames(): + print(file) + return + if len(args) == 4: + if args[1] == "c" or args[1] == "compress": + writeFile(args[3], compress(readFile(args[2]))) + print("done") + return + if args[1] == "d" or args[1] == "decompress": + writeFile(args[3], decompress(readFile(args[2]))) + print("done") + return + usage(args[0]) + +main()