Created
February 24, 2026 22:55
-
-
Save Kattoor/a7fa3c6491a4e7882c28313f636c66e2 to your computer and use it in GitHub Desktop.
farever-extractor.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"; | |
| /** | |
| * 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