Skip to content

Instantly share code, notes, and snippets.

@Kattoor
Created February 24, 2026 22:55
Show Gist options
  • Select an option

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

Select an option

Save Kattoor/a7fa3c6491a4e7882c28313f636c66e2 to your computer and use it in GitHub Desktop.
farever-extractor.js
#!/usr/bin/env node
"use strict";
/**
* Extract all assets from a Heaps-style PAK file (like hxd.fmt.pak.Build -x),
* and convert DDS payloads to PNG using texconv.exe (must be in PATH).
*
* Usage:
* node extract-pak.js "C:\path\to\res.pak"
* node extract-pak.js "C:\path\to\res.pak" --out "C:\output\folder"
*
* Notes:
* - Works with very large .pak files (does NOT load the whole file into memory).
* - Reads only the header/tree, then streams file contents out in chunks.
* - Detects file format from magic bytes, not file extension.
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const cp = require("child_process");
class Reader {
constructor(buffer, offset = 0) {
this.buf = buffer;
this.pos = offset;
}
readByte() {
if (this.pos >= this.buf.length) throw new Error("Unexpected EOF (byte)");
return this.buf[this.pos++];
}
readString(len) {
if (this.pos + len > this.buf.length) throw new Error("Unexpected EOF (string)");
const s = this.buf.toString("utf8", this.pos, this.pos + len);
this.pos += len;
return s;
}
readInt32() {
if (this.pos + 4 > this.buf.length) throw new Error("Unexpected EOF (int32)");
const v = this.buf.readInt32LE(this.pos);
this.pos += 4;
return v;
}
readDouble() {
if (this.pos + 8 > this.buf.length) throw new Error("Unexpected EOF (double)");
const v = this.buf.readDoubleLE(this.pos);
this.pos += 8;
return v;
}
}
function readExactly(fd, length, position) {
const buf = Buffer.allocUnsafe(length);
let off = 0;
while (off < length) {
const n = fs.readSync(fd, buf, off, length - off, position + off);
if (n <= 0) throw new Error(`Unexpected EOF while reading ${length} bytes at ${position}`);
off += n;
}
return buf;
}
function readFileEntry(r) {
const nameLen = r.readByte();
const name = r.readString(nameLen);
const flags = r.readByte();
if ((flags & 1) !== 0) {
const count = r.readInt32();
const content = [];
for (let i = 0; i < count; i++) content.push(readFileEntry(r));
return { name, isDirectory: true, content };
}
const dataPosition = (flags & 2) !== 0 ? r.readDouble() : r.readInt32();
const dataSize = r.readInt32();
const checksum = r.readInt32();
return { name, isDirectory: false, dataPosition, dataSize, checksum };
}
function readPakHeaderFromFile(pakPath) {
const fd = fs.openSync(pakPath, "r");
try {
// "PAK"(3) + version(1) + headerSize(4) + dataSize(4)
const first12 = readExactly(fd, 12, 0);
const r0 = new Reader(first12);
const magic = r0.readString(3);
if (magic !== "PAK") throw new Error("Invalid PAK file (missing 'PAK')");
const version = r0.readByte();
const headerSize = r0.readInt32();
const dataSize = r0.readInt32();
if (headerSize < 16) throw new Error(`Invalid headerSize: ${headerSize}`);
const headerPayloadLen = headerSize - 16; // excludes "PAK"+ver+sizes and "DATA"
const headerPayload = readExactly(fd, headerPayloadLen, 12);
const hr = new Reader(headerPayload);
const root = readFileEntry(hr);
const dataMarker = readExactly(fd, 4, 12 + headerPayloadLen).toString("utf8");
if (dataMarker !== "DATA") throw new Error("Corrupted PAK header (missing 'DATA')");
return { version, headerSize, dataSize, root };
} finally {
fs.closeSync(fd);
}
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function extractFileChunked(srcFd, destPath, srcPos, size, chunkSize = 4 * 1024 * 1024) {
ensureDir(path.dirname(destPath));
const outFd = fs.openSync(destPath, "w");
try {
let remaining = size;
let readPos = srcPos;
const buf = Buffer.allocUnsafe(Math.min(chunkSize, Math.max(1, size)));
while (remaining > 0) {
const toRead = Math.min(buf.length, remaining);
const bytesRead = fs.readSync(srcFd, buf, 0, toRead, readPos);
if (bytesRead <= 0) {
throw new Error(`Unexpected EOF while extracting to ${destPath}`);
}
let written = 0;
while (written < bytesRead) {
const n = fs.writeSync(outFd, buf, written, bytesRead - written);
if (n <= 0) throw new Error(`Failed writing ${destPath}`);
written += n;
}
remaining -= bytesRead;
readPos += bytesRead;
}
} finally {
fs.closeSync(outFd);
}
}
function peekMagic4(srcFd, srcPos) {
const b = Buffer.allocUnsafe(4);
const n = fs.readSync(srcFd, b, 0, 4, srcPos);
if (n < 4) return null;
return b;
}
function isDDSMagic(buf4) {
return !!buf4 && buf4.length >= 4 &&
buf4[0] === 0x44 && buf4[1] === 0x44 && buf4[2] === 0x53 && buf4[3] === 0x20; // "DDS "
}
function isPNGMagic(buf4) {
return !!buf4 && buf4.length >= 4 &&
buf4[0] === 0x89 && buf4[1] === 0x50 && buf4[2] === 0x4E && buf4[3] === 0x47; // \x89PNG
}
function sanitizeName(name) {
if (name === "." || name === "..") throw new Error(`Unsafe entry name: ${name}`);
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
throw new Error(`Invalid entry name segment: ${name}`);
}
return name;
}
function runTexconv(ddsPath, outDir) {
// texconv writes to outDir using the input basename (with .png output)
const args = ["-ft", "PNG", "-y", "-o", outDir, ddsPath];
const result = cp.spawnSync("texconv", args, {
stdio: "pipe",
encoding: "utf8",
windowsHide: true,
});
if (result.error) {
throw new Error(
`Failed to launch texconv (is it in PATH?): ${result.error.message}`
);
}
if (result.status !== 0) {
throw new Error(
`texconv failed (${result.status})\n` +
`Command: texconv ${args.map(a => JSON.stringify(a)).join(" ")}\n` +
`stdout:\n${result.stdout || ""}\n` +
`stderr:\n${result.stderr || ""}`
);
}
}
function replaceExt(filePath, newExt) {
const parsed = path.parse(filePath);
return path.join(parsed.dir, parsed.name + newExt);
}
function extractRec(node, outDir, pakFd, headerSize, rel = "") {
if (node.isDirectory) {
const dirRel = node.name === ""
? rel
: (rel ? path.join(rel, sanitizeName(node.name)) : sanitizeName(node.name));
const absDir = path.join(outDir, dirRel);
ensureDir(absDir);
for (const child of node.content) {
extractRec(child, outDir, pakFd, headerSize, dirRel);
}
return;
}
const safeName = sanitizeName(node.name);
const fileRel = rel ? path.join(rel, safeName) : safeName;
const absPath = path.join(outDir, fileRel);
// Haxe extraction seeks to f.dataPosition + pak.headerSize
const srcPos = headerSize + node.dataPosition;
const magic = peekMagic4(pakFd, srcPos);
// DDS payload (even if filename says .png) -> convert to PNG via texconv
if (isDDSMagic(magic)) {
const pngOutPath = replaceExt(absPath, ".png");
const ddsTempPath = replaceExt(absPath, ".dds");
console.log(`Extracting DDS->PNG: ${fileRel} -> ${path.relative(outDir, pngOutPath)} (${node.dataSize} bytes)`);
// Write temp DDS
extractFileChunked(pakFd, ddsTempPath, srcPos, node.dataSize);
// Convert
runTexconv(ddsTempPath, path.dirname(ddsTempPath));
// Remove temp DDS
try {
fs.unlinkSync(ddsTempPath);
} catch (_) {
// non-fatal
}
return;
}
// Normal file (actual PNG, etc.) -> extract as-is
if (isPNGMagic(magic)) {
console.log(`Extracting PNG: ${fileRel} (${node.dataSize} bytes)`);
} else {
console.log(`Extracting: ${fileRel} (${node.dataSize} bytes)`);
}
extractFileChunked(pakFd, absPath, srcPos, node.dataSize);
}
function parseArgs(argv) {
const args = argv.slice(2);
let pakPath = null;
let outDir = null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--out" || a === "-o") {
if (i + 1 >= args.length) throw new Error("Missing value for --out");
outDir = args[++i];
continue;
}
if (!a.startsWith("-") && pakPath == null) {
pakPath = a;
continue;
}
throw new Error(`Unknown argument: ${a}`);
}
if (!pakPath) throw new Error('Usage: node extract-pak.js <file.pak> [--out "folder"]');
if (!outDir) {
// Match Haxe behavior: default base dir = pakFile minus ".pak"
const ext = path.extname(pakPath).toLowerCase();
outDir = ext === ".pak" ? pakPath.slice(0, -4) : pakPath + "_extracted";
}
return { pakPath, outDir };
}
function main() {
try {
const { pakPath, outDir } = parseArgs(process.argv);
const pak = readPakHeaderFromFile(pakPath);
console.log(`PAK: ${pakPath}`);
console.log(`Version: ${pak.version}`);
console.log(`Header size: ${pak.headerSize}`);
console.log(`Data size: ${pak.dataSize}`);
console.log(`Output: ${outDir}`);
console.log(`DDS conversion: enabled (texconv from PATH)`);
ensureDir(outDir);
const pakFd = fs.openSync(pakPath, "r");
try {
extractRec(pak.root, outDir, pakFd, pak.headerSize, "");
} finally {
fs.closeSync(pakFd);
}
console.log("Done.");
} catch (err) {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment