Created
March 1, 2026 12:31
-
-
Save Kattoor/f8283eb4864cd7a5ff98f296e443052d to your computer and use it in GitHub Desktop.
pak_onefile_edit.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| "use strict"; | |
| /** | |
| * Heaps PAK single-file extract + replace (repack) | |
| * Based on heaps: hxd/fmt/pak/Reader.hx, Writer.hx, Build.hx, FileSystem.hx | |
| * | |
| * Commands: | |
| * Extract one file: | |
| * node pak_onefile_edit.js extract <pakFile> "<path/in/pak.ext>" <outFile> | |
| * | |
| * Replace one file and write a new pak: | |
| * node pak_onefile_edit.js replace <pakFile> "<path/in/pak.ext>" <newFileBytes> <outPak> | |
| * | |
| * Options: | |
| * --overwrite allow overwriting output file(s) | |
| * | |
| * Notes: | |
| * - PAK stores a file tree in the header and raw bytes in the DATA section. | |
| * - Checksums are Adler-32 (haxe.crypto.Adler32.make). | |
| * - We infer original alignment from dataSize and per-file sizes; repack uses same align. | |
| */ | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| function die(msg) { | |
| console.error(msg); | |
| process.exit(1); | |
| } | |
| function hasFlag(argv, flag) { | |
| return argv.includes(flag); | |
| } | |
| function usage() { | |
| die( | |
| [ | |
| "Usage:", | |
| ' node pak_onefile_edit.js extract <pakFile> "<path/in/pak>" <outFile> [--overwrite]', | |
| ' node pak_onefile_edit.js replace <pakFile> "<path/in/pak>" <newFile> <outPak> [--overwrite]', | |
| "", | |
| "Examples:", | |
| ' node pak_onefile_edit.js extract game.pak "textures/ui/logo.png" ./logo.png', | |
| ' node pak_onefile_edit.js replace game.pak "textures/ui/logo.png" ./logo.png ./game_mod.pak', | |
| ].join("\n") | |
| ); | |
| } | |
| /** Minimal binary reader */ | |
| class BufReader { | |
| constructor(buf) { | |
| this.buf = buf; | |
| this.pos = 0; | |
| } | |
| ensure(n) { | |
| if (this.pos + n > this.buf.length) { | |
| throw new Error( | |
| `Unexpected EOF (need ${n} bytes at pos ${this.pos}, len ${this.buf.length})` | |
| ); | |
| } | |
| } | |
| readU8() { | |
| this.ensure(1); | |
| return this.buf.readUInt8(this.pos++); | |
| } | |
| readI32LE() { | |
| this.ensure(4); | |
| const v = this.buf.readInt32LE(this.pos); | |
| this.pos += 4; | |
| return v; | |
| } | |
| readF64LE() { | |
| this.ensure(8); | |
| const v = this.buf.readDoubleLE(this.pos); | |
| this.pos += 8; | |
| return v; | |
| } | |
| readBytes(n) { | |
| this.ensure(n); | |
| const s = this.buf.subarray(this.pos, this.pos + n); | |
| this.pos += n; | |
| return s; | |
| } | |
| readString(n) { | |
| return this.readBytes(n).toString("utf8"); | |
| } | |
| } | |
| /** Minimal binary writer */ | |
| class BufWriter { | |
| constructor() { | |
| this.chunks = []; | |
| this.length = 0; | |
| } | |
| writeU8(v) { | |
| const b = Buffer.allocUnsafe(1); | |
| b.writeUInt8(v & 0xff, 0); | |
| this._push(b); | |
| } | |
| writeI32LE(v) { | |
| const b = Buffer.allocUnsafe(4); | |
| b.writeInt32LE(v | 0, 0); | |
| this._push(b); | |
| } | |
| writeF64LE(v) { | |
| const b = Buffer.allocUnsafe(8); | |
| b.writeDoubleLE(v, 0); | |
| this._push(b); | |
| } | |
| writeStringAscii(s) { | |
| const b = Buffer.from(s, "utf8"); | |
| this._push(b); | |
| } | |
| writeBytes(buf) { | |
| this._push(Buffer.from(buf)); | |
| } | |
| padZeros(n) { | |
| if (n <= 0) return; | |
| this._push(Buffer.alloc(n, 0)); | |
| } | |
| _push(b) { | |
| this.chunks.push(b); | |
| this.length += b.length; | |
| } | |
| toBuffer() { | |
| return Buffer.concat(this.chunks, this.length); | |
| } | |
| } | |
| /** | |
| * Adler-32 (haxe.crypto.Adler32.make) | |
| * A=1,B=0. A=(A+byte)%65521, B=(B+A)%65521. Return (B<<16)|A. | |
| */ | |
| function adler32(buf) { | |
| const MOD = 65521; | |
| let a = 1; | |
| let b = 0; | |
| const CHUNK = 5552; | |
| let i = 0; | |
| while (i < buf.length) { | |
| const end = Math.min(i + CHUNK, buf.length); | |
| for (; i < end; i++) { | |
| a += buf[i]; | |
| b += a; | |
| } | |
| a %= MOD; | |
| b %= MOD; | |
| } | |
| return ((b << 16) | a) >>> 0; | |
| } | |
| function padLen(pos, align) { | |
| if (!align || align <= 1) return 0; | |
| const mod = pos % align; | |
| return mod === 0 ? 0 : align - mod; | |
| } | |
| function flattenFileNodes(root) { | |
| // Returns a list of { fullPath, node } | |
| const out = []; | |
| function rec(node, cur) { | |
| if (node.isDirectory) { | |
| for (const c of node.content || []) rec(c, cur ? `${cur}/${c.name}` : c.name); | |
| } else { | |
| out.push({ fullPath: cur, node }); | |
| } | |
| } | |
| if (root.isDirectory) { | |
| for (const c of root.content || []) rec(c, c.name); | |
| } else { | |
| rec(root, root.name); | |
| } | |
| return out; | |
| } | |
| function findNodeByPath(root, wantedPath) { | |
| const normWanted = wantedPath.replace(/\\/g, "/").replace(/^\/+/, ""); | |
| for (const { fullPath, node } of flattenFileNodes(root)) { | |
| if (fullPath === normWanted) return node; | |
| } | |
| return null; | |
| } | |
| function inferAlignFromDataSize(root, declaredDataSize) { | |
| const entries = flattenFileNodes(root).map((x) => x.node); | |
| const sizes = entries.map((n) => n.dataSize); | |
| const candidates = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]; | |
| for (const align of candidates) { | |
| let total = 0; | |
| for (const sz of sizes) { | |
| total += sz; | |
| if (align > 1) total += padLen(sz, align); | |
| } | |
| if (total === declaredDataSize) return align; | |
| } | |
| return 1; | |
| } | |
| function parsePak(pakPath) { | |
| const fd = fs.openSync(pakPath, "r"); | |
| try { | |
| const pre = Buffer.alloc(12); | |
| fs.readSync(fd, pre, 0, 12, 0); | |
| const pr = new BufReader(pre); | |
| const magic = pr.readString(3); | |
| if (magic !== "PAK") throw new Error("Invalid PAK (missing 'PAK')"); | |
| const version = pr.readU8(); | |
| const headerSize = pr.readI32LE(); | |
| const dataSize = pr.readI32LE(); | |
| const headerPayloadLen = headerSize - 16; | |
| if (headerPayloadLen < 0) throw new Error("Corrupted headerSize"); | |
| const headerPayload = Buffer.alloc(headerPayloadLen); | |
| fs.readSync(fd, headerPayload, 0, headerPayloadLen, 12); | |
| const dataTag = Buffer.alloc(4); | |
| fs.readSync(fd, dataTag, 0, 4, 12 + headerPayloadLen); | |
| if (dataTag.toString("utf8") !== "DATA") throw new Error("Corrupted header (missing 'DATA')"); | |
| const hr = new BufReader(headerPayload); | |
| function readNode() { | |
| const nameLen = hr.readU8(); | |
| const name = hr.readString(nameLen); | |
| const flags = hr.readU8(); | |
| const isDirectory = (flags & 1) !== 0; | |
| const isDoublePos = (flags & 2) !== 0; | |
| if (isDirectory) { | |
| const count = hr.readI32LE(); | |
| const content = []; | |
| for (let i = 0; i < count; i++) content.push(readNode()); | |
| return { name, isDirectory: true, content }; | |
| } else { | |
| const dataPosition = isDoublePos ? hr.readF64LE() : hr.readI32LE(); | |
| const dataSize2 = hr.readI32LE(); | |
| const checksum = hr.readI32LE() >>> 0; | |
| return { | |
| name, | |
| isDirectory: false, | |
| dataPosition, // may be offset or index (pakDiff); we repack as offset | |
| dataSize: dataSize2, | |
| checksum, | |
| _posWasDouble: isDoublePos, | |
| }; | |
| } | |
| } | |
| const root = readNode(); | |
| const dataStartOffset = headerSize; | |
| // Make a best-effort align inference (for clean repack) | |
| const align = inferAlignFromDataSize(root, dataSize); | |
| return { | |
| pakPath, | |
| fd, | |
| version, | |
| headerSize, | |
| dataSize, | |
| dataStartOffset, | |
| root, | |
| align, | |
| fileSize: fs.fstatSync(fd).size, | |
| }; | |
| } catch (e) { | |
| fs.closeSync(fd); | |
| throw e; | |
| } | |
| } | |
| function closePak(p) { | |
| if (p && p.fd != null) { | |
| try { fs.closeSync(p.fd); } catch {} | |
| p.fd = null; | |
| } | |
| } | |
| /** | |
| * Read file bytes from original pak. | |
| * Important: Heaps adds headerSize to each file's dataPosition when loading into filesystem. | |
| * That means header-stored dataPosition is relative to DATA, but actual bytes are at (headerSize + dataPosition) | |
| * | |
| * pakDiff note: | |
| * - Some paks can store dataPosition as an index. We don't reliably know original ordering without scanning. | |
| * - For extraction-by-path: if dataPosition looks like a small index, we try to reconstruct offsets by sorting all files by index | |
| * and using inferred align. | |
| */ | |
| function computeAbsoluteOffsets(pak) { | |
| const fileList = flattenFileNodes(pak.root).map(({ fullPath, node }) => ({ | |
| fullPath, | |
| node, | |
| pos: node.dataPosition, | |
| size: node.dataSize, | |
| })); | |
| const n = fileList.length; | |
| const allInt = fileList.every((e) => Number.isFinite(e.pos) && Math.floor(e.pos) === e.pos); | |
| const minPos = Math.min(...fileList.map((e) => e.pos)); | |
| const maxPos = Math.max(...fileList.map((e) => e.pos)); | |
| const uniq = new Set(fileList.map((e) => e.pos)).size; | |
| const looksLikeIndex = allInt && minPos >= 0 && maxPos <= Math.max(0, n - 1) && uniq === n; | |
| const map = new Map(); // fullPath -> absOffset | |
| if (!looksLikeIndex) { | |
| for (const e of fileList) map.set(e.fullPath, pak.dataStartOffset + e.pos); | |
| return { offsets: map, pakDiff: false }; | |
| } | |
| // pakDiff: order by index and compute byte offsets with inferred align | |
| const align = pak.align || 1; | |
| const sorted = [...fileList].sort((a, b) => a.pos - b.pos); | |
| let cursor = 0; | |
| for (const e of sorted) { | |
| map.set(e.fullPath, pak.dataStartOffset + cursor); | |
| cursor += e.size; | |
| if (align > 1) cursor += padLen(e.size, align); | |
| } | |
| return { offsets: map, pakDiff: true }; | |
| } | |
| function extractSingle(pakPath, inPakPath, outFile, overwrite) { | |
| const pak = parsePak(pakPath); | |
| try { | |
| const wanted = inPakPath.replace(/\\/g, "/").replace(/^\/+/, ""); | |
| const node = findNodeByPath(pak.root, wanted); | |
| if (!node || node.isDirectory) die(`Not found (or is directory): ${wanted}`); | |
| const { offsets } = computeAbsoluteOffsets(pak); | |
| const abs = offsets.get(wanted); | |
| if (abs == null) die(`Internal error: missing offset for ${wanted}`); | |
| const buf = Buffer.alloc(node.dataSize); | |
| const got = fs.readSync(pak.fd, buf, 0, node.dataSize, abs); | |
| if (got !== node.dataSize) die(`Unexpected EOF while reading ${wanted}`); | |
| if (!overwrite && fs.existsSync(outFile)) { | |
| die(`Refusing to overwrite ${outFile} (use --overwrite)`); | |
| } | |
| fs.mkdirSync(path.dirname(outFile), { recursive: true }); | |
| fs.writeFileSync(outFile, buf); | |
| console.log(`Extracted: ${wanted} -> ${outFile} (${buf.length} bytes)`); | |
| } finally { | |
| closePak(pak); | |
| } | |
| } | |
| /** | |
| * Repack: | |
| * - read all file bytes from original pak | |
| * - replace selected path with bytes from newFile | |
| * - recompute checksums + sizes | |
| * - assign new sequential offsets (like Heaps Writer) | |
| * - write a fresh .pak | |
| */ | |
| function replaceSingle(pakPath, inPakPath, newFilePath, outPakPath, overwrite) { | |
| const pak = parsePak(pakPath); | |
| try { | |
| const wanted = inPakPath.replace(/\\/g, "/").replace(/^\/+/, ""); | |
| const nodeToReplace = findNodeByPath(pak.root, wanted); | |
| if (!nodeToReplace || nodeToReplace.isDirectory) die(`Not found (or is directory): ${wanted}`); | |
| const newBytes = fs.readFileSync(newFilePath); | |
| // Load all file bytes from original pak | |
| const { offsets } = computeAbsoluteOffsets(pak); | |
| const allFiles = flattenFileNodes(pak.root); | |
| const contentByPath = new Map(); // fullPath -> Buffer | |
| for (const { fullPath, node } of allFiles) { | |
| const abs = offsets.get(fullPath); | |
| if (abs == null) die(`Internal error: missing offset for ${fullPath}`); | |
| const buf = Buffer.alloc(node.dataSize); | |
| const got = fs.readSync(pak.fd, buf, 0, node.dataSize, abs); | |
| if (got !== node.dataSize) die(`Unexpected EOF while reading ${fullPath}`); | |
| contentByPath.set(fullPath, buf); | |
| } | |
| // Replace target content | |
| contentByPath.set(wanted, Buffer.from(newBytes)); | |
| // Update node metadata (size + checksum) for all files (at least the changed one) | |
| for (const { fullPath, node } of allFiles) { | |
| const bytes = contentByPath.get(fullPath); | |
| node.dataSize = bytes.length; | |
| node.checksum = adler32(bytes); | |
| } | |
| // Assign new positions sequentially in tree order (DFS), like Build/Writer style | |
| // All positions are integer offsets within DATA. | |
| let cursor = 0; | |
| const align = pak.align || 1; | |
| function assignPositions(node, curPath) { | |
| if (node.isDirectory) { | |
| for (const c of node.content || []) { | |
| const p = curPath ? `${curPath}/${c.name}` : c.name; | |
| assignPositions(c, p); | |
| } | |
| } else { | |
| node.dataPosition = cursor; // offset within DATA | |
| node._posWasDouble = false; // we’ll set flags based on size/limits later | |
| cursor += node.dataSize; | |
| if (align > 1) cursor += padLen(node.dataSize, align); | |
| } | |
| } | |
| if (pak.root.isDirectory) { | |
| for (const c of pak.root.content || []) assignPositions(c, c.name); | |
| } else { | |
| assignPositions(pak.root, pak.root.name); | |
| } | |
| const newDataSize = cursor; | |
| // Build header bytes (tree) | |
| function writeNode(w, node) { | |
| const nameBuf = Buffer.from(node.name, "utf8"); | |
| if (nameBuf.length > 255) throw new Error(`Name too long: ${node.name}`); | |
| w.writeU8(nameBuf.length); | |
| w.writeBytes(nameBuf); | |
| if (node.isDirectory) { | |
| const flags = 1; // directory | |
| w.writeU8(flags); | |
| w.writeI32LE((node.content || []).length); | |
| for (const c of node.content || []) writeNode(w, c); | |
| } else { | |
| // Choose int32 or double encoding: | |
| // Heaps Writer uses double if position is not an int. | |
| // Our positions are ints, but if > 2^31-1 we can’t store safely as int32. | |
| let flags = 0; | |
| const pos = node.dataPosition; | |
| const needsDouble = pos > 0x7fffffff; // beyond int32 range | |
| if (needsDouble) flags |= 2; | |
| w.writeU8(flags); | |
| if (needsDouble) w.writeF64LE(pos); | |
| else w.writeI32LE(pos); | |
| w.writeI32LE(node.dataSize); | |
| // checksum stored as int32 (can be negative when read as signed, but bytes are the same) | |
| // We write it as signed int32 to match typical packing; buffer bytes are identical. | |
| w.writeI32LE(node.checksum | 0); | |
| } | |
| } | |
| const headerW = new BufWriter(); | |
| writeNode(headerW, pak.root); | |
| const headerBytes = headerW.toBuffer(); | |
| // Compute headerSize like Heaps Writer: | |
| // headerSize = headerLen + 16, then align so that (headerLen + 16) % align == 0 | |
| // and padding is inserted before "DATA" so that after writing "DATA" we’re at headerSize. | |
| const headerLen = headerBytes.length; | |
| let headerSize = headerLen + 16; | |
| if (align > 1) headerSize += padLen(headerSize, align); | |
| // Padding before DATA: make (headerLen + 16) aligned (this is exactly what Heaps addPadding(headerLen+16) does) | |
| const padBeforeData = align > 1 ? padLen(headerLen + 16, align) : 0; | |
| if (!overwrite && fs.existsSync(outPakPath)) { | |
| die(`Refusing to overwrite ${outPakPath} (use --overwrite)`); | |
| } | |
| // Write new pak file | |
| const outFd = fs.openSync(outPakPath, "w"); | |
| try { | |
| // "PAK" + version + headerSize + dataSize | |
| fs.writeSync(outFd, Buffer.from("PAK", "utf8")); | |
| fs.writeSync(outFd, Buffer.from([pak.version & 0xff])); | |
| { | |
| const b = Buffer.allocUnsafe(8); | |
| b.writeInt32LE(headerSize | 0, 0); | |
| b.writeInt32LE(newDataSize | 0, 4); | |
| fs.writeSync(outFd, b); | |
| } | |
| // header tree bytes | |
| fs.writeSync(outFd, headerBytes); | |
| // padding zeros before "DATA" | |
| if (padBeforeData > 0) fs.writeSync(outFd, Buffer.alloc(padBeforeData, 0)); | |
| // "DATA" | |
| fs.writeSync(outFd, Buffer.from("DATA", "utf8")); | |
| // write file blobs in the same traversal order as we assigned positions, inserting per-file padding | |
| function writeData(node, curPath) { | |
| if (node.isDirectory) { | |
| for (const c of node.content || []) { | |
| const p = curPath ? `${curPath}/${c.name}` : c.name; | |
| writeData(c, p); | |
| } | |
| } else { | |
| const bytes = contentByPath.get(curPath); | |
| if (!bytes) throw new Error(`Missing content for ${curPath}`); | |
| fs.writeSync(outFd, bytes); | |
| const p = align > 1 ? padLen(bytes.length, align) : 0; | |
| if (p > 0) fs.writeSync(outFd, Buffer.alloc(p, 0)); | |
| } | |
| } | |
| if (pak.root.isDirectory) { | |
| for (const c of pak.root.content || []) writeData(c, c.name); | |
| } else { | |
| writeData(pak.root, pak.root.name); | |
| } | |
| } finally { | |
| fs.closeSync(outFd); | |
| } | |
| console.log(`Repacked OK:`); | |
| console.log(` input : ${pakPath}`); | |
| console.log(` changed: ${wanted} <- ${newFilePath} (${newBytes.length} bytes)`); | |
| console.log(` output: ${outPakPath}`); | |
| console.log(` version=${pak.version} align=${align} headerSize=${headerSize} dataSize=${newDataSize}`); | |
| } finally { | |
| closePak(pak); | |
| } | |
| } | |
| (function main() { | |
| const argv = process.argv.slice(2); | |
| if (argv.length < 1) usage(); | |
| const overwrite = hasFlag(argv, "--overwrite"); | |
| const filtered = argv.filter((a) => a !== "--overwrite"); | |
| const cmd = filtered[0]; | |
| if (cmd === "extract") { | |
| if (filtered.length !== 4) usage(); | |
| const [, pakFile, inPakPath, outFile] = filtered; | |
| extractSingle(pakFile, inPakPath, outFile, overwrite); | |
| return; | |
| } | |
| if (cmd === "replace") { | |
| if (filtered.length !== 5) usage(); | |
| const [, pakFile, inPakPath, newFile, outPak] = filtered; | |
| replaceSingle(pakFile, inPakPath, newFile, outPak, overwrite); | |
| return; | |
| } | |
| usage(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment