Skip to content

Instantly share code, notes, and snippets.

@Kattoor
Created March 1, 2026 12:31
Show Gist options
  • Select an option

  • Save Kattoor/f8283eb4864cd7a5ff98f296e443052d to your computer and use it in GitHub Desktop.

Select an option

Save Kattoor/f8283eb4864cd7a5ff98f296e443052d to your computer and use it in GitHub Desktop.
pak_onefile_edit.js
#!/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