UNPKG

civ7-save-editor

Version:

Save editor for Civilization 7

522 lines (476 loc) 16.3 kB
#!/usr/bin/env node const fs = require('fs/promises'); const zlib = require('zlib'); const readline = require('readline'); const HEADER_TMP_FILE = '1-header.dat'; const BODY_TMP_FILE = '2-body.dat'; const FOOTER_TMP_FILE = '3-footer.dat'; const COMPRESSED_DATA_START = Buffer.from([0, 0, 1, 0, 0x78, 0x9c]); const GOLD_MARKER = Buffer.from([0x35, 0xcf, 0xc8, 0x6e]); const INFLUENCE_MARKER = Buffer.from([0x50, 0x3c, 0xa8, 0x4a]); const PLAYER_SLOT_MARKERS = [ Buffer.from([0xb8, 0x61, 0xf0, 0xf4]), // player slot #1 Buffer.from([0x2e, 0x51, 0xf7, 0x83]), // player slot #2 Buffer.from([0xd4, 0xab, 0x9f, 0x19]), // player slot #3 Buffer.from([0x02, 0x30, 0xf9, 0x6d]), // player slot #4 Buffer.from([0xa1, 0xa5, 0x9d, 0xf3]), // player slot #5 Buffer.from([0x37, 0x95, 0x9a, 0x84]), // player slot #6 Buffer.from([0x8d, 0xc4, 0x93, 0x1d]), // player slot #7 Buffer.from([0x1b, 0xf4, 0x94, 0x6a]), // player slot #8 ]; const LEADER_MARKER = Buffer.from([0x0f, 0xfb, 0x8c, 0xc1]); /** * @typedef {Object} Player * @property {string} leader The leader name. * @property {number} goldPos Buffer position for gold treasury. * @property {number} influencePos Buffer position for accumulated influence. */ /** * @typedef {Object} SaveFileData * @property {Buffer} header Header part of the save file. * @property {Buffer} body Uncompressed body part of the save file. * @property {Buffer} footer Footer part of the save file. * @property {Array.<Player>} players - Array of player data. */ /** * @typedef {Object} MenuOption * @template T * @property {string} label The display text. * @property {T} value The value to return when selected. */ /** * @typedef {Object} CommandOptions * @property {string} saveFile Path to the save file. * @property {boolean} extract Flag for extract mode. * @property {boolean} stitch Flag for stitch mode. */ /** * Helper function that wraps rl.question in a promise. * @param {readline.Interface} rl Readline interface. * @param {string} query The query to display. * @returns {Promise<string>} Promise that resolves with the user's input. */ function questionAsync(rl, query) { return new Promise((resolve) => rl.question(query, resolve)); } /** * Reads a Civ7Save file and returns a SaveFileData object. * @param {string} path Path to the save file. * @returns {Promise<SaveFileData>} */ async function readSaveFile(path) { const buffer = Buffer.from(await fs.readFile(path)); if (buffer.subarray(0, 4).toString() !== 'CIV7') { throw new Error('Not a Civilization 7 save file.'); } const compressedStart = buffer.indexOf(COMPRESSED_DATA_START); if (compressedStart === -1) { throw new Error('Invalid Civilization 7 save file format.'); } const header = buffer.subarray(0, compressedStart); const [body, bytesRead] = readCompressedData( buffer.subarray(compressedStart), ); const footer = buffer.subarray(compressedStart + bytesRead); const data = { header, body, footer }; data.players = parsePlayers(data); return data; } // Default chunk size used with compressed data let defaultChunkSize = 64 * 1024; /** * Reads compressed data from a buffer and returns the decompressed data and * number of bytes read. * @param {Buffer} buffer Buffer containing compressed data. * @returns {[Buffer, number]} */ function readCompressedData(buffer) { const chunks = []; let pos = 0; let chunkSize = buffer.readUInt32LE(pos); pos += 4; defaultChunkSize = chunkSize; // set default chunk size to this while (chunkSize > 1) { chunks.push(buffer.subarray(pos, pos + chunkSize)); pos += chunkSize; chunkSize = buffer.readUInt32LE(pos); pos += 4; } pos -= 4; // concatenate chunks and decompress const compressed = Buffer.concat(chunks); return [ zlib.inflateSync(compressed, { finishFlush: zlib.constants.Z_SYNC_FLUSH, }), pos, ]; } /** * Compresses the given data using the deflate algorithm. * @param {Buffer} data Data to compress. * @returns {Buffer} Compressed data with chunk headers. */ function compressData(data) { // use deflate algorithm const compressed = zlib.deflateSync(data, { finishFlush: zlib.constants.Z_SYNC_FLUSH, }); let pos = 0; const chunks = []; const addLengthBytes = (len) => { const buf = Buffer.alloc(4); buf.writeUInt32LE(len); chunks.push(buf); }; // add data in chunks while (pos + defaultChunkSize < compressed.length) { addLengthBytes(defaultChunkSize); chunks.push(compressed.subarray(pos, pos + defaultChunkSize)); pos += defaultChunkSize; } // add the last chunk addLengthBytes(compressed.length - pos); chunks.push(compressed.subarray(pos)); return Buffer.concat(chunks); } /** * Writes the combined header, compressed body, and footer to the specified * output file. * @param {{header: Buffer, body: Buffer, footer: Buffer}} data Save file data. * @param {string} output Output file path. * @returns {Promise<void>} */ async function writeFile(data, output) { const { header, body, footer } = data; const buffer = Buffer.concat([header, compressData(body), footer]); await fs.writeFile(output, buffer); console.log(`${output} rewritten.`); } /** * Reads a Civilization VII 24-bit value embedded in a 32-bit low-endian * integer. The value is stored in the 3 most significant bytes, with the least * significant byte used as a header for special cases. * * @param {Buffer} body The buffer that contains the data. * @param {number} pos The position in the buffer where the value is stored. * @returns {number} The extracted 24-bit integer. */ function readCiv24BitValue(body, pos) { // e.g. 00 FF FF 7F -> 0x7FFFFF const value = body.readUInt32LE(pos); const mainBody = value >> 8; // Civ VII custom format: first byte is FF when at max (i.e. +1 to the value). // e.g. FF FF FF 7F -> 0x800000 // Get first byte, check if it is FF, and add 1 to the main body if so. const header = value & 0xff; return header === 0xff ? mainBody + 1 : mainBody; } /** * Writes a 24-bit value back into the body at the specified position. The value * is stored in the 3 most significant bytes of a 32-bit low-endian integer. * * @param {Buffer} body The buffer that contains the data. * @param {number} pos The position in the buffer where the value will be written. * @param {number} value The 24-bit integer to write back. * @returns {void} */ function writeCiv24BitValue(body, pos, value) { // Throw if the value is out of bounds. if (value < 0 || value > 0x800000) { throw new Error( 'Value out of bounds. Must be between 0 and 8388608, inclusive.', ); } // For values less than 0x800000, we can write the value directly. if (value < 0x800000) { body.writeUInt32LE(value << 8, pos); return; } // For values at 0x800000, we need to write a value of 0x7fffff with a header // of 0xff. body.writeUInt32LE(0x7fffffff, pos); } /** * Finds positions by scanning the buffer for a given marker and adding an * offset. * @param {Buffer} body Buffer to scan. * @param {Buffer} marker Marker to search for. * @param {number} offset Number of bytes to skip after the marker. * @param {number} max Maximum number of positions to return. * @returns {number[]} Array of positions. */ function findPositions(body, marker, offset, max) { const positions = []; let pos = 0; while (pos < body.length && positions.length < max) { pos = body.indexOf(marker, pos); if (pos === -1) break; pos += offset; positions.push(pos); } return positions; } /** * Extracts the leader name from the header given a player slot marker. * @param {Buffer} header Header buffer. * @param {Buffer} playerMarker Marker identifying the player slot. * @returns {string|null} The leader name without the "LEADER_" prefix, or null. * if not found. */ function getPlayerLeader(header, playerMarker) { const pos = header.indexOf(playerMarker); if (pos === -1) return null; let leaderPos = header.indexOf(LEADER_MARKER, pos); if (leaderPos === -1) return null; leaderPos += 20; const end = header.indexOf(0, leaderPos); if (end === -1) return null; // Remove the "LEADER_" prefix. return header.subarray(leaderPos, end).toString().substring(7); } /** * Parses player data from the header and body portions of the save file. * @param {{header: Buffer, body: Buffer}} data Save file data. * @returns {Array.<Player>} Array of player objects. */ function parsePlayers({ header, body }) { const goldPositions = findPositions( body, GOLD_MARKER, 24, PLAYER_SLOT_MARKERS.length, ); const influencePositions = findPositions( body, INFLUENCE_MARKER, 24, PLAYER_SLOT_MARKERS.length, ); return PLAYER_SLOT_MARKERS.map((marker, idx) => { const leader = getPlayerLeader(header, marker); if (!leader) return null; return { leader, goldPos: goldPositions[idx], influencePos: influencePositions[idx], }; }).filter(Boolean); } /** * Displays a prompt menu, validates user input, and returns a promise that * resolves with the choice. * @param {readline.Interface} rl Readline interface. * @param {string} promptText Text to display as prompt. * @param {Array.<MenuOption>} options Array of menu option objects. * @param {boolean} allowBack If true, allows going back. * @returns {Promise<any>} The user's choice. */ async function promptMenu(rl, promptText, options, allowBack) { console.log('\n' + promptText); options.forEach((opt, index) => { console.log(` (${index + 1}) ${opt.label}`); }); if (allowBack) { console.log(' (b) Back'); } const input = await questionAsync(rl, 'Enter your choice: '); if (allowBack && input.toLowerCase() === 'b') { return 'back'; } const choice = parseInt(input, 10); if (isNaN(choice) || choice < 1 || choice > options.length) { console.error('Invalid selection. Please try again.'); return await promptMenu(rl, promptText, options, allowBack); } return options[choice - 1].value; } /** * Edits a numeric value (gold or influence) for a given player. * @param {readline.Interface} rl Readline interface. * @param {SaveFileData} data Save file data. * @param {string} type Type of value ('gold' or 'influence'). * @param {Player} player Player object containing the position key. * @returns {Promise<void>} */ async function editValue(rl, data, type, player) { /** * @type {{read: function(Buffer, number): number, write: function(Buffer, number, number), prompt: string, posKey: keyof Player}} */ const config = { gold: { read: readCiv24BitValue, write: writeCiv24BitValue, prompt: 'gold treasury', posKey: 'goldPos', }, influence: { read: readCiv24BitValue, write: writeCiv24BitValue, prompt: 'accumulated influence', posKey: 'influencePos', }, }[type]; const current = config.read(data.body, player[config.posKey]); const input = await questionAsync( rl, `Enter new amount for ${config.prompt} (${current}) between 0 and 8388608 (or 'b' to cancel): `, ); if (input.toLowerCase() === 'b') { return; } const newValue = parseInt(input, 10); if (isNaN(newValue) || newValue < 0 || newValue > 8388608) { console.error('Error: value must be a number between 0 and 8388608.'); return await editValue(rl, data, type, player); } config.write(data.body, player[config.posKey], newValue); console.log(`${config.prompt} updated to ${newValue} for ${player.leader}.`); } /** * Presents the player slot menu for editing a specific property. * @param {readline.Interface} rl Readline interface. * @param {SaveFileData} data Save file data. * @param {string} type Type of value to edit. * @returns {Promise<void>} */ async function playerMenu(rl, data, type) { const players = data.players; if (!players || players.length === 0) { console.error('No player data found.'); return; } const options = players.map((player, index) => ({ label: player.leader, value: index, })); const choice = await promptMenu( rl, `Select player slot to edit ${type}:`, options, true, ); if (choice === 'back') { return; } await editValue(rl, data, type, players[choice]); // After editing, show the same player menu again. await playerMenu(rl, data, type); } /** * Displays the main menu for editing options or exiting the program. * @param {readline.Interface} rl Readline interface. * @param {SaveFileData} data Save file data. * @param {string} saveFile Path to the save file. * @returns {Promise<void>} */ async function mainMenu(rl, data, saveFile) { const options = [ { label: 'Edit gold treasury', value: 'editGold' }, { label: 'Edit accumulated influence', value: 'editInfluence' }, { label: 'Save and exit', value: 'exit' }, { label: 'Exit without saving', value: 'exitNoSave' }, ]; const choice = await promptMenu( rl, 'Main Menu - Please select an option:', options, false, ); if (choice === 'exit') { await writeFile(data, saveFile); rl.close(); } else if (choice === 'exitNoSave') { const input = await questionAsync( rl, 'Are you sure you want to exit without saving? (y/n): ', ); if (input.toLowerCase() === 'y') { rl.close(); } else { await mainMenu(rl, data, saveFile); } } else if (choice === 'editGold') { await playerMenu(rl, data, 'gold'); await mainMenu(rl, data, saveFile); } else if (choice === 'editInfluence') { await playerMenu(rl, data, 'influence'); await mainMenu(rl, data, saveFile); } } /** * Displays usage and help information for the save editor. * @returns {void} */ function printHelp() { console.log(`Usage: civ7-save-editor [options] savefile Arguments: savefile Path to the Civ7Save file. Options: --extract Extracts data from the save file. This will write three files: '${HEADER_TMP_FILE}', '${BODY_TMP_FILE}' and '${FOOTER_TMP_FILE}' in the same directory. These files contain uncompressed save file data. --stitch Stitches the contents of '${HEADER_TMP_FILE}', '${BODY_TMP_FILE}' and '${FOOTER_TMP_FILE}' (generated by the --extract option) and rewrites given savefile with the combined content. This option requires the extracted files to exist in the same directory.`); } /** * Parses command line options and returns a CommandOptions object. * @returns {CommandOptions} */ function readOptions() { const args = process.argv.slice(2); const files = args.filter((arg) => !arg.startsWith('--')); if (files.length !== 1) { printHelp(); process.exit(1); } return { saveFile: files[0], extract: args.includes('--extract'), stitch: args.includes('--stitch'), }; } /** * Runs the save editor program with the provided options. * @param {CommandOptions} options - Options object from readOptions. * @returns {Promise<void>} */ async function run(options) { const { saveFile } = options; if (options.stitch) { // stitch mode, just combine files together const header = await fs.readFile(HEADER_TMP_FILE); const body = await fs.readFile(BODY_TMP_FILE); const footer = await fs.readFile(FOOTER_TMP_FILE); const data = { header, body, footer }; await writeFile(data, saveFile); return; } const data = await readSaveFile(saveFile); if (options.extract) { // extract mode await fs.writeFile(HEADER_TMP_FILE, data.header); await fs.writeFile(BODY_TMP_FILE, data.body); await fs.writeFile(FOOTER_TMP_FILE, data.footer); console.log( `Files extracted: ${HEADER_TMP_FILE} ${BODY_TMP_FILE} ${FOOTER_TMP_FILE}`, ); return; } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log('Create a backup of your save file before making any changes.'); await mainMenu(rl, data, saveFile); } console.log( 'Civ7 Save Editor v1.1.0 - https://github.com/iqqmut/civ7-save-editor', ); run(readOptions()).catch((err) => { console.error(err); });