Skip to content

Instantly share code, notes, and snippets.

@mikerudolph
Last active February 19, 2026 04:46
Show Gist options
  • Select an option

  • Save mikerudolph/b0cf31714dc1dfc835ee6a4ca35060c0 to your computer and use it in GitHub Desktop.

Select an option

Save mikerudolph/b0cf31714dc1dfc835ee6a4ca35060c0 to your computer and use it in GitHub Desktop.
Usage: npx tsx palworld-xgp-to-steam.ts --help npx tsx palworld-xgp-to-steam.ts --list # discover & list XGP saves npx tsx palworld-xgp-to-steam.ts --dry-run --verbose # parse without writing, show details npx tsx palworld-xgp-to-steam.ts # interactive: auto-detect Steam ID, prompt to confirm npx tsx palworld-xgp-to-steam.ts --steam-id 765611980…
/**
* palworld-xgp-to-steam.ts
*
* Converts Palworld Xbox GamePass (XGP) saves from the WGS container format
* to Steam's plain .sav file structure. Non-destructive: only reads from XGP,
* writes copies to the Steam directory.
*
* Usage: npx tsx palworld-xgp-to-steam.ts [options]
*
* Options:
* --steam-id <id> Steam user ID (auto-detected if possible)
* --output <dir> Output directory (overrides default Steam save path)
* --yes Skip confirmation prompts
* --dry-run Parse and report without writing files
* --list List discovered XGP saves and exit
* --verbose Show detailed output
* --help Show this help message
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline/promises";
import { pipeline } from "node:stream/promises";
// ─── Types ──────────────────────────────────────────────────────────────────────
interface ContainerFileEntry {
filename: string;
blobGuid: string;
blobGuidRaw: string;
altGuid: string;
altGuidRaw: string;
}
interface ContainerInfo {
name: string;
containerNumber: number;
guid: string;
guidRaw: string;
modifiedDate: Date;
files: ContainerFileEntry[];
containerDir: string | null;
}
interface IndexHeader {
version: number;
storeName: string;
creationDate: Date;
containers: ContainerInfo[];
}
interface SaveFileMapping {
savePath: string;
blobPath: string;
size: number;
containerName: string;
}
interface ExtractionResult {
profileDir: string;
storeName: string;
creationDate: Date;
files: SaveFileMapping[];
warnings: string[];
}
interface CliArgs {
steamId: string | null;
output: string | null;
yes: boolean;
dryRun: boolean;
list: boolean;
verbose: boolean;
help: boolean;
}
// ─── Constants ──────────────────────────────────────────────────────────────────
const PACKAGE_NAME = "PocketpairInc.Palworld_ad4psfrxyesvt";
const FILETIME_EPOCH_OFFSET = 116444736000000000n;
const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
const USAGE = `
palworld-xgp-to-steam - Convert Palworld XGP saves to Steam format
USAGE
npx tsx palworld-xgp-to-steam.ts [options]
OPTIONS
--steam-id <id> Steam user ID (auto-detected if possible)
--output <dir> Output directory (overrides default Steam path)
--yes Skip confirmation prompts
--dry-run Parse and report without writing files
--list List discovered XGP saves and exit
--verbose Show detailed output
--help Show this help message
DESCRIPTION
Extracts Palworld save files from the Xbox GamePass WGS container
format and organizes them into Steam's expected directory structure.
Original GamePass saves are never modified -- all data is copied.
`.trim();
// ─── BufferReader ───────────────────────────────────────────────────────────────
class BufferReader {
private buf: Buffer;
private pos: number;
private filePath: string;
constructor(buf: Buffer, filePath: string = "<buffer>") {
this.buf = buf;
this.pos = 0;
this.filePath = filePath;
}
private check(n: number): void {
if (this.pos + n > this.buf.length) {
throw new Error(
`Unexpected end of data in ${this.filePath} at offset 0x${this.pos.toString(16)} ` +
`(need ${n} bytes, have ${this.buf.length - this.pos})`
);
}
}
readInt32LE(): number {
this.check(4);
const val = this.buf.readInt32LE(this.pos);
this.pos += 4;
return val;
}
readUInt32LE(): number {
this.check(4);
const val = this.buf.readUInt32LE(this.pos);
this.pos += 4;
return val;
}
readUInt8(): number {
this.check(1);
const val = this.buf.readUInt8(this.pos);
this.pos += 1;
return val;
}
readBigUInt64LE(): bigint {
this.check(8);
const val = this.buf.readBigUInt64LE(this.pos);
this.pos += 8;
return val;
}
readBytes(n: number): Buffer {
this.check(n);
const slice = this.buf.subarray(this.pos, this.pos + n);
this.pos += n;
return slice;
}
skip(n: number): void {
this.check(n);
this.pos += n;
}
remaining(): number {
return this.buf.length - this.pos;
}
offset(): number {
return this.pos;
}
}
// ─── Binary Parsing Utilities ───────────────────────────────────────────────────
function readUtf16String(reader: BufferReader, fixedCharCount?: number): string {
const charCount = fixedCharCount ?? reader.readInt32LE();
if (charCount < 0 || charCount > 1_000_000) {
throw new Error(`Invalid string char count: ${charCount} at offset 0x${(reader.offset() - 4).toString(16)}`);
}
const bytes = reader.readBytes(charCount * 2);
return Buffer.from(bytes).toString("utf16le").replace(/\0+$/, "");
}
function readFileTime(reader: BufferReader): Date {
const ft = reader.readBigUInt64LE();
if (ft === 0n) return new Date(0);
const ms = Number((ft - FILETIME_EPOCH_OFFSET) / 10000n);
return new Date(ms);
}
function guidBytesToMixedEndian(bytes: Buffer): string {
const d1 = bytes.readUInt32LE(0).toString(16).padStart(8, "0");
const d2 = bytes.readUInt16LE(4).toString(16).padStart(4, "0");
const d3 = bytes.readUInt16LE(6).toString(16).padStart(4, "0");
let d4 = "";
for (let i = 8; i < 16; i++) d4 += bytes[i].toString(16).padStart(2, "0");
return (d1 + d2 + d3 + d4).toUpperCase();
}
function guidBytesToRaw(bytes: Buffer): string {
let hex = "";
for (let i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, "0");
return hex.toUpperCase();
}
function readGuid(reader: BufferReader): { guid: string; raw: string } {
const bytes = reader.readBytes(16);
return {
guid: guidBytesToMixedEndian(bytes),
raw: guidBytesToRaw(bytes),
};
}
// ─── Index & Container Parsing ──────────────────────────────────────────────────
function dumpHex(buf: Buffer, offset: number, length: number): string {
const end = Math.min(offset + length, buf.length);
const lines: string[] = [];
for (let i = offset; i < end; i += 16) {
const hex: string[] = [];
const ascii: string[] = [];
for (let j = i; j < Math.min(i + 16, end); j++) {
hex.push(buf[j].toString(16).padStart(2, "0"));
ascii.push(buf[j] >= 0x20 && buf[j] < 0x7f ? String.fromCharCode(buf[j]) : ".");
}
lines.push(` ${i.toString(16).padStart(6, "0")} ${hex.join(" ").padEnd(48)} ${ascii.join("")}`);
}
return lines.join("\n");
}
function parseContainersIndex(buf: Buffer, filePath: string, verbose: boolean): IndexHeader {
const reader = new BufferReader(buf, filePath);
if (verbose) log(` Raw data (first 512 bytes):\n${dumpHex(buf, 0, 512)}`);
const version = reader.readUInt32LE();
if (verbose) log(` containers.index version: ${version} (0x${version.toString(16)})`);
if (version > 100) {
warn(`Unexpected containers.index version ${version} -- format may differ`);
}
const containerCount = reader.readInt32LE();
if (containerCount < 0 || containerCount > 10000) {
throw new Error(`Invalid container count ${containerCount} in ${filePath}`);
}
if (verbose) log(` Container count: ${containerCount}`);
// Unknown u32 (observed as 0)
reader.skip(4);
// Store / container package name
const storeName = readUtf16String(reader);
if (verbose) log(` Store name: ${storeName}`);
// Creation timestamp
const creationDate = readFileTime(reader);
if (verbose) log(` Creation date: ${creationDate.toISOString()}`);
// Number of extra strings per container (after contentId + 8-byte gap)
const extraStringCount = reader.readUInt32LE();
if (verbose) log(` Extra strings per container: ${extraStringCount}`);
// Content/profile ID — one-time header field, not per-container
const contentId = readUtf16String(reader);
if (verbose) log(` Content ID: "${contentId}"`);
reader.skip(8); // unknown metadata associated with content ID
if (verbose) log(` Containers start at offset 0x${reader.offset().toString(16)}`);
const containers: ContainerInfo[] = [];
for (let i = 0; i < containerCount; i++) {
if (verbose) {
log(` --- Container ${i} at offset 0x${reader.offset().toString(16)} ---`);
log(dumpHex(buf, reader.offset(), 256));
}
// Extra strings: first one is the container name, rest are skipped
let name = "";
for (let s = 0; s < extraStringCount; s++) {
const str = readUtf16String(reader);
if (s === 0) {
name = str; // container name (e.g. "1F095563450BBBFB10AB5B8AE4705090-LocalData")
}
if (verbose) log(` string[${s}]: "${str}"`);
}
// 4. Container file number (u8) — selects container.N file
const containerNumber = reader.readUInt8();
// 5. Unknown u32
reader.skip(4);
// 6. Container GUID (16 bytes, mixed-endian)
const { guid, raw: guidRaw } = readGuid(reader);
// 7. Modification timestamp
const modifiedDate = readFileTime(reader);
// 8. Second FILETIME (skip)
reader.skip(8);
// 9. Unknown u64 (skip)
reader.skip(8);
if (verbose) {
log(` Container ${i}: "${name}" number=${containerNumber} guid=${guid} modified=${modifiedDate.toISOString()}`);
}
containers.push({
name,
containerNumber,
guid,
guidRaw,
modifiedDate,
files: [],
containerDir: null,
});
}
if (verbose && reader.remaining() > 0) {
log(` ${reader.remaining()} bytes remaining after parsing containers.index`);
}
return { version, storeName, creationDate, containers };
}
function parseContainerFile(buf: Buffer, filePath: string, verbose: boolean): ContainerFileEntry[] {
const reader = new BufferReader(buf, filePath);
const header = reader.readUInt32LE();
if (verbose) log(` container file header: ${header} (0x${header.toString(16)})`);
const fileCount = reader.readInt32LE();
if (fileCount < 0 || fileCount > 100000) {
throw new Error(`Invalid file count ${fileCount} in ${filePath}`);
}
if (verbose) log(` File count: ${fileCount}`);
const entries: ContainerFileEntry[] = [];
for (let i = 0; i < fileCount; i++) {
// Each entry: 128 bytes filename (64 UTF-16LE chars) + 16 bytes blob GUID + 16 bytes alt GUID = 160 bytes
const filename = readUtf16String(reader, 64);
const { guid: blobGuid, raw: blobGuidRaw } = readGuid(reader);
const { guid: altGuid, raw: altGuidRaw } = readGuid(reader);
if (verbose) {
log(` File ${i}: "${filename}" blob=${blobGuid} alt=${altGuid}`);
}
entries.push({ filename, blobGuid, blobGuidRaw, altGuid, altGuidRaw });
}
return entries;
}
// ─── Filesystem Operations ──────────────────────────────────────────────────────
function normalizeHex(s: string): string {
return s.replace(/-/g, "").toLowerCase();
}
function findEntryInDir(parentDir: string, target: string, wantDir: boolean): string | null {
const norm = normalizeHex(target);
try {
const entries = fs.readdirSync(parentDir, { withFileTypes: true });
for (const entry of entries) {
const isMatch = wantDir ? entry.isDirectory() : entry.isFile();
if (isMatch && normalizeHex(entry.name) === norm) {
return path.join(parentDir, entry.name);
}
}
} catch {
// Directory doesn't exist or can't be read
}
return null;
}
function resolveContainerDir(profileDir: string, guid: string, guidRaw: string): string | null {
return findEntryInDir(profileDir, guid, true) ?? findEntryInDir(profileDir, guidRaw, true);
}
function resolveFileBlob(containerDir: string, entry: ContainerFileEntry): string | null {
return (
findEntryInDir(containerDir, entry.blobGuid, false) ??
findEntryInDir(containerDir, entry.blobGuidRaw, false) ??
findEntryInDir(containerDir, entry.altGuid, false) ??
findEntryInDir(containerDir, entry.altGuidRaw, false)
);
}
function discoverWgsSaves(verbose: boolean): string[] {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
throw new Error("LOCALAPPDATA environment variable not set -- is this Windows?");
}
const wgsDir = path.join(localAppData, "Packages", PACKAGE_NAME, "SystemAppData", "wgs");
if (!fs.existsSync(wgsDir)) {
throw new Error(
`Palworld XGP save directory not found:\n ${wgsDir}\n\n` +
"Make sure Palworld was installed via Xbox GamePass and has been played at least once."
);
}
if (verbose) log(`Scanning WGS directory: ${wgsDir}`);
const profiles: string[] = [];
const entries = fs.readdirSync(wgsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const profileDir = path.join(wgsDir, entry.name);
const indexFile = path.join(profileDir, "containers.index");
if (fs.existsSync(indexFile)) {
profiles.push(profileDir);
if (verbose) log(` Found profile: ${entry.name}`);
}
}
if (profiles.length === 0) {
throw new Error(
`No valid save profiles found in:\n ${wgsDir}\n\n` +
"Expected subdirectories containing containers.index files."
);
}
return profiles;
}
// ─── Extraction ─────────────────────────────────────────────────────────────────
function extractContainers(profileDir: string, verbose: boolean): ExtractionResult {
const warnings: string[] = [];
// Parse containers.index
const indexPath = path.join(profileDir, "containers.index");
if (verbose) log(`\nParsing ${indexPath}`);
let indexBuf: Buffer;
try {
indexBuf = fs.readFileSync(indexPath);
} catch (err: any) {
if (err.code === "EBUSY" || err.code === "EPERM") {
throw new Error(
`Cannot read ${indexPath} -- file is locked.\n` +
"Close Palworld and any Xbox app before running this tool."
);
}
throw err;
}
const index = parseContainersIndex(indexBuf, indexPath, verbose);
// Parse each container's file list
for (const container of index.containers) {
const containerDir = resolveContainerDir(profileDir, container.guid, container.guidRaw);
if (!containerDir) {
const msg = `Container directory not found for "${container.name}" (guid=${container.guid}, raw=${container.guidRaw})`;
warnings.push(msg);
if (verbose) warn(msg);
continue;
}
container.containerDir = containerDir;
const containerFilePath = path.join(containerDir, `container.${container.containerNumber}`);
if (!fs.existsSync(containerFilePath)) {
const msg = `Container file not found: ${containerFilePath}`;
warnings.push(msg);
if (verbose) warn(msg);
continue;
}
if (verbose) log(`\n Parsing ${containerFilePath}`);
let containerBuf: Buffer;
try {
containerBuf = fs.readFileSync(containerFilePath);
} catch (err: any) {
if (err.code === "EBUSY" || err.code === "EPERM") {
throw new Error(
`Cannot read ${containerFilePath} -- file is locked.\n` +
"Close Palworld and any Xbox app before running this tool."
);
}
throw err;
}
container.files = parseContainerFile(containerBuf, containerFilePath, verbose);
}
// Build save file map
const files = buildPalworldSaveMap(index.containers, warnings, verbose);
return {
profileDir,
storeName: index.storeName,
creationDate: index.creationDate,
files,
warnings,
};
}
function containerNameToSavePath(containerName: string): string {
// Container names use hyphens as path separators, e.g.:
// "GUID-LocalData" -> "GUID/LocalData.sav"
// "GUID-Level-01" -> "GUID/Level/01.sav"
// "GUID-Players-{GUID}" -> "GUID/Players/{GUID}.sav"
// All hyphens become path separators, matching the reference implementation.
return containerName.replace(/-/g, path.sep) + ".sav";
}
function identifyMainWorldGuid(containers: ContainerInfo[]): string | null {
// The main world GUID is the one that has a top-level Level container
// (e.g. "GUID-Level-01" without a Slot prefix like "GUID-Slot1-Level-01").
// A top-level Level container name has exactly 2 hyphens: GUID-Level-NN.
const levelContainers: { guid: string; container: ContainerInfo }[] = [];
for (const c of containers) {
const match = c.name.match(/^([A-F0-9]{32})-Level-\d+$/);
if (match) {
levelContainers.push({ guid: match[1], container: c });
}
}
if (levelContainers.length === 0) return null;
// Group by GUID
const guidMap = new Map<string, ContainerInfo[]>();
for (const { guid, container } of levelContainers) {
const existing = guidMap.get(guid) ?? [];
existing.push(container);
guidMap.set(guid, existing);
}
if (guidMap.size === 1) return guidMap.keys().next().value!;
// Multiple world GUIDs with Level containers: pick the one with most recent modification
let bestGuid = "";
let bestDate = new Date(0);
for (const [guid, containers] of guidMap) {
for (const c of containers) {
if (c.modifiedDate > bestDate) {
bestDate = c.modifiedDate;
bestGuid = guid;
}
}
}
return bestGuid;
}
function buildPalworldSaveMap(
containers: ContainerInfo[],
warnings: string[],
verbose: boolean
): SaveFileMapping[] {
const files: SaveFileMapping[] = [];
// Identify the main world GUID and filter to only its containers
const mainGuid = identifyMainWorldGuid(containers);
if (mainGuid) {
if (verbose) log(` Main world GUID: ${mainGuid}`);
} else {
warn("Could not identify main world GUID (no Level container found)");
}
for (const container of containers) {
if (!container.containerDir) continue;
// Filter: only include containers belonging to the main world
if (mainGuid && !container.name.startsWith(mainGuid)) {
if (verbose) log(` Skipping non-world container: "${container.name}"`);
continue;
}
if (container.files.length === 0) {
const msg = `Container "${container.name}" has no file entries`;
warnings.push(msg);
if (verbose) warn(msg);
continue;
}
if (container.files.length > 1 && verbose) {
log(` Container "${container.name}" has ${container.files.length} files -- using first`);
}
const fileEntry = container.files[0];
const blobPath = resolveFileBlob(container.containerDir, fileEntry);
if (!blobPath) {
const msg =
`Blob file not found for "${container.name}" ` +
`(blob=${fileEntry.blobGuid}, raw=${fileEntry.blobGuidRaw}, ` +
`alt=${fileEntry.altGuid}, altRaw=${fileEntry.altGuidRaw}) ` +
`in ${container.containerDir}`;
warnings.push(msg);
if (verbose) warn(msg);
continue;
}
let size: number;
try {
size = fs.statSync(blobPath).size;
} catch {
const msg = `Cannot stat blob file: ${blobPath}`;
warnings.push(msg);
continue;
}
const savePath = containerNameToSavePath(container.name);
if (verbose) {
log(` Mapped: "${container.name}" -> ${savePath} (${formatSize(size)}) from ${blobPath}`);
}
files.push({ savePath, blobPath, size, containerName: container.name });
}
// Create duplicate Level.sav entries from Level/01.sav entries.
// The reference implementation outputs both GUID/Level/01.sav AND GUID/Level.sav
// (same blob data). Scan for */Level/01.sav and add a sibling */Level.sav.
const levelDuplicates: SaveFileMapping[] = [];
for (const file of files) {
// Match patterns like "GUID/Level/01.sav" or "GUID/Slot1/Level/01.sav"
const levelMatch = file.savePath.match(
new RegExp(`(.*[\\\/])Level[\\\/]\\d+\\.sav$`)
);
if (levelMatch) {
const parentDir = levelMatch[1]; // e.g. "GUID/" or "GUID/Slot1/"
const dupPath = parentDir + "Level.sav";
// Only add if not already present
if (!files.some((f) => f.savePath === dupPath)) {
if (verbose) {
log(` Adding duplicate: ${file.savePath} -> ${dupPath}`);
}
levelDuplicates.push({
savePath: dupPath,
blobPath: file.blobPath,
size: file.size,
containerName: file.containerName,
});
}
}
}
files.push(...levelDuplicates);
return files;
}
// ─── File Copying ───────────────────────────────────────────────────────────────
async function copySaveFiles(
extraction: ExtractionResult,
outputDir: string,
verbose: boolean
): Promise<{ copied: number; failed: string[] }> {
const failed: string[] = [];
let copied = 0;
fs.mkdirSync(outputDir, { recursive: true });
for (const file of extraction.files) {
const destPath = path.join(outputDir, file.savePath);
const destDir = path.dirname(destPath);
fs.mkdirSync(destDir, { recursive: true });
try {
if (file.size >= LARGE_FILE_THRESHOLD) {
await streamCopy(file.blobPath, destPath, file.savePath, file.size);
} else {
process.stderr.write(` Copying ${file.savePath} (${formatSize(file.size)})...`);
fs.copyFileSync(file.blobPath, destPath);
process.stderr.write(" done\n");
}
copied++;
} catch (err: any) {
process.stderr.write("\n");
const reason =
err.code === "EBUSY" || err.code === "EPERM"
? "file is locked -- close Palworld and any Xbox app"
: err.message;
const msg = `Failed to copy ${file.savePath}: ${reason}`;
failed.push(msg);
warn(msg);
// Level save files are critical
if (
/Level[\/\\]\d+\.sav$/.test(file.savePath) ||
file.savePath.endsWith(path.sep + "Level.sav") ||
file.savePath === "Level.sav"
) {
throw new Error(
`Failed to copy Level save -- this is the main world save and is required.\n` +
`Reason: ${reason}`
);
}
}
}
return { copied, failed };
}
async function streamCopy(
src: string,
dst: string,
label: string,
totalSize: number
): Promise<void> {
const readStream = fs.createReadStream(src);
const writeStream = fs.createWriteStream(dst);
let bytesCopied = 0;
let lastPct = -1;
readStream.on("data", (chunk: Buffer) => {
bytesCopied += chunk.length;
const pct = Math.floor((bytesCopied / totalSize) * 100);
if (pct !== lastPct) {
lastPct = pct;
process.stderr.write(
`\r Copying ${label} (${formatSize(totalSize)})... ${pct}%`
);
}
});
await pipeline(readStream, writeStream);
process.stderr.write(
`\r Copying ${label} (${formatSize(totalSize)})... done\n`
);
}
// ─── CLI ────────────────────────────────────────────────────────────────────────
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const result: CliArgs = {
steamId: null,
output: null,
yes: false,
dryRun: false,
list: false,
verbose: false,
help: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--steam-id":
result.steamId = args[++i];
if (!result.steamId) fatal("--steam-id requires a value");
break;
case "--output":
result.output = args[++i];
if (!result.output) fatal("--output requires a value");
break;
case "--yes":
case "-y":
result.yes = true;
break;
case "--dry-run":
result.dryRun = true;
break;
case "--list":
result.list = true;
break;
case "--verbose":
case "-v":
result.verbose = true;
break;
case "--help":
case "-h":
result.help = true;
break;
default:
fatal(`Unknown option: ${args[i]}\nRun with --help for usage.`);
}
}
return result;
}
function detectSteamIds(): string[] {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) return [];
const saveGamesDir = path.join(localAppData, "Pal", "Saved", "SaveGames");
if (!fs.existsSync(saveGamesDir)) return [];
try {
return fs
.readdirSync(saveGamesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d{10,20}$/.test(e.name))
.map((e) => e.name);
} catch {
return [];
}
}
async function promptLine(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
try {
const answer = await rl.question(question);
return answer.trim();
} finally {
rl.close();
}
}
async function confirmOrExit(message: string): Promise<void> {
const answer = await promptLine(`${message} [y/N] `);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
log("Aborted.");
process.exit(0);
}
}
async function resolveSteamId(args: CliArgs): Promise<string> {
if (args.steamId) return args.steamId;
const detected = detectSteamIds();
if (detected.length === 1) {
log(`Detected Steam ID: ${detected[0]}`);
return detected[0];
}
if (detected.length > 1) {
log("Multiple Steam IDs detected:");
detected.forEach((id, i) => log(` ${i + 1}. ${id}`));
const answer = await promptLine("Enter the number or Steam ID to use: ");
const num = parseInt(answer, 10);
if (num >= 1 && num <= detected.length) return detected[num - 1];
if (/^\d{10,20}$/.test(answer)) return answer;
fatal("Invalid Steam ID selection.");
}
// No detected IDs
const answer = await promptLine("Enter your Steam ID (numeric, ~17 digits): ");
if (!/^\d{10,20}$/.test(answer)) {
fatal("Invalid Steam ID. Expected a numeric ID like 76561198012345678.");
}
return answer;
}
async function selectProfile(profiles: string[], args: CliArgs): Promise<string> {
if (profiles.length === 1) return profiles[0];
log("Multiple XGP save profiles found:");
profiles.forEach((p, i) => log(` ${i + 1}. ${path.basename(p)}`));
if (args.yes) {
log("Using first profile (--yes).");
return profiles[0];
}
const answer = await promptLine("Select profile number: ");
const num = parseInt(answer, 10);
if (num >= 1 && num <= profiles.length) return profiles[num - 1];
fatal("Invalid profile selection.");
return ""; // unreachable
}
// ─── Output Helpers ─────────────────────────────────────────────────────────────
function log(msg: string): void {
process.stderr.write(msg + "\n");
}
function warn(msg: string): void {
process.stderr.write(`WARNING: ${msg}\n`);
}
function fatal(msg: string): never {
process.stderr.write(`ERROR: ${msg}\n`);
process.exit(1);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function printSummary(extraction: ExtractionResult): void {
log(`\nSave profile: ${path.basename(extraction.profileDir)}`);
log(`Store name: ${extraction.storeName}`);
log(`Created: ${extraction.creationDate.toISOString()}`);
log(`Files found: ${extraction.files.length}`);
log("");
const totalSize = extraction.files.reduce((sum, f) => sum + f.size, 0);
for (const file of extraction.files) {
log(` ${file.savePath.padEnd(50)} ${formatSize(file.size).padStart(12)}`);
}
log(` ${"".padEnd(50)} ${"─".repeat(12)}`);
log(` ${"Total".padEnd(50)} ${formatSize(totalSize).padStart(12)}`);
}
// ─── Main ───────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = parseArgs();
if (args.help) {
log(USAGE);
process.exit(0);
}
// Platform check
if (process.platform !== "win32") {
fatal(
"This tool only works on Windows.\n" +
"Xbox GamePass saves are stored in a Windows-specific location."
);
}
if (!process.env.LOCALAPPDATA) {
fatal("LOCALAPPDATA environment variable not set.");
}
// Discover saves
log("Searching for Palworld XGP saves...");
const profiles = discoverWgsSaves(args.verbose);
log(`Found ${profiles.length} save profile(s).`);
// --list mode: show all saves and exit
if (args.list) {
for (const profileDir of profiles) {
try {
const extraction = extractContainers(profileDir, args.verbose);
printSummary(extraction);
if (extraction.warnings.length > 0) {
log(`\n Warnings: ${extraction.warnings.length}`);
for (const w of extraction.warnings) log(` - ${w}`);
}
} catch (err: any) {
warn(`Failed to parse ${path.basename(profileDir)}: ${err.message}`);
}
log("");
}
process.exit(0);
}
// Select profile
const profileDir = await selectProfile(profiles, args);
// Extract
log("\nParsing save data...");
const extraction = extractContainers(profileDir, args.verbose);
if (extraction.files.length === 0) {
fatal("No save files could be extracted. Check warnings above.");
}
// Check for Level save (either Level/01.sav or the duplicate Level.sav)
const hasLevel = extraction.files.some(
(f) =>
/Level[\/\\]\d+\.sav$/.test(f.savePath) ||
f.savePath.endsWith(path.sep + "Level.sav") ||
f.savePath === "Level.sav"
);
if (!hasLevel) {
fatal(
"Level.sav not found in extracted data.\n" +
"The save may be incomplete or the WGS format may have changed."
);
}
printSummary(extraction);
if (extraction.warnings.length > 0) {
log(`\nWarnings (${extraction.warnings.length}):`);
for (const w of extraction.warnings) log(` - ${w}`);
}
// Determine output directory
let outputDir: string;
if (args.output) {
outputDir = path.resolve(args.output);
} else {
const steamId = await resolveSteamId(args);
// World ID is embedded in container names (e.g. "GUID-Level" -> "GUID/Level.sav"),
// so the output base is just <SaveGames>/<steamId>/
outputDir = path.join(
process.env.LOCALAPPDATA!,
"Pal",
"Saved",
"SaveGames",
steamId
);
}
log(`Output directory: ${outputDir}`);
// Dry run
if (args.dryRun) {
log("\n--dry-run: No files will be written.");
log("\nFiles that would be created:");
for (const file of extraction.files) {
log(` ${path.join(outputDir, file.savePath)}`);
}
process.exit(0);
}
// Check for existing files
if (fs.existsSync(outputDir)) {
const existing = fs.readdirSync(outputDir);
if (existing.length > 0) {
warn(`Output directory is not empty: ${outputDir}`);
if (!args.yes) {
await confirmOrExit("Existing files may be overwritten. Continue?");
}
}
}
// Confirm
if (!args.yes) {
await confirmOrExit(`\nCopy ${extraction.files.length} save files to the above directory?`);
}
// Copy
log("\nCopying save files...");
const { copied, failed } = await copySaveFiles(extraction, outputDir, args.verbose);
// Summary
log(`\nDone! ${copied} file(s) copied successfully.`);
if (failed.length > 0) {
warn(`${failed.length} file(s) failed to copy:`);
for (const f of failed) log(` - ${f}`);
}
log(`\nSave files written to:\n ${outputDir}`);
log(
"\nTo use in Steam Palworld, launch the game and the imported world\n" +
"should appear in your world list."
);
}
main().catch((err: Error) => {
process.stderr.write(`\nFATAL: ${err.message}\n`);
if (process.argv.includes("--verbose") || process.argv.includes("-v")) {
process.stderr.write(`\n${err.stack}\n`);
}
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment