Reverse-engineering an Unreal Engine 5 binary format, compiling to WebAssembly without
wasm-bindgen, and the one mistake I made with the main thread.
Every Palworld breeding calculator out there has the same problem: you have to manually search and select every Pal you own. If your Palbox has 80 Pals, that's 80 dropdown selections. The moment you want to try a different target, you start over.
So I built something that skips all of that. You drag your level.sav into palbreedingcalc.com, and it extracts every Pal you own — species, gender, passive skills, everything — in under a second. The entire breeding tree is computed from your actual inventory.
There's no server. The parsing happens client-side, in Rust compiled to WebAssembly. Here's how I built it, what I broke, and what I'd do differently.
The Format: PlZ Wrapper, GVAS Payload, UE5 Property Tree
The first thing you learn when you open a Palworld .sav file in a hex editor is that it's not JSON. It's not text at all.
The file starts with 3 bytes: PlZ. That's Pocketpair's custom compressed save format. It wraps a standard Unreal Engine GVAS (Game Versioned Asset Serialization) blob — UE5's native save game format. Getting from those 3 bytes to structured character data takes three layers of unwrapping.
Layer 1: zlib (with a twist)
After the PlZ magic, there's a header with uncompressed/compressed lengths and a save type byte. Type 0x31 means one pass of zlib. Type 0x32 means the data is zlib-compressed twice — decompress the outer layer, then decompress the inner layer from what you got back.
Some files also have a CNK sub-header at byte 8 instead of jumping straight to PlZ. This one little difference means you can't just hardcode data[8..11] — you have to read the header structure to find where the magic bytes actually are.
I used miniz_oxide for the zlib layer. Pure Rust, no C FFI on the zlib side, which matters later when you're trying to compile to WASM without pulling in Emscripten.
const MAGIC: &[u8; 3] = b"PlZ";
// Check for CNK header, which shifts the entire layout by 12 bytes
let data_start = if data.len() > 12 && &data[8..11] == b"CNK" {
24 // CNK adds 12 extra bytes before PlZ
} else {
12
};
// Type 0x32 = double-compressed
if save_type == 0x32 {
uncompressed = decompress_to_vec_zlib(&uncompressed)?;
}
Layer 2: GVAS — The Unreal Engine Save Game Container
Once decompressed, you're looking at a GVAS file. The magic number is 0x53415647 ("GVAS" in little-endian). The header gives you the engine version, custom version GUIDs, and — critically — the save game class name: /Script/Pal.PalWorldSaveGame.
The GVAS spec says version must be 3. If it's anything else, you're looking at a different UE version and your parser is about to make bad assumptions about the byte layout downstream.
Layer 3: FArchive — UE's Binary Property Tree
This is where things get tedious. GVAS payloads are serialized using FArchive, Unreal's binary serialization format. It's a recursive tree of typed properties — StructProperty, MapProperty, ArrayProperty, FloatProperty, BoolProperty, and about a dozen others.
Each property has a name, a type tag, a size, and then its value. MapProperty entries are key-value pairs. StructProperty contains nested property lists. ArrayProperty is a counted sequence. You read the tag, switch on the type, recurse or collect, and move on.
The 1.0 Palworld save format nests character data inside a chain like this:
StructProperty("worldSaveData")
└── StructProperty("CharacterSaveParameterMap")
└── MapProperty
└── MapProperty entries
├── key: StructProperty (FGuid instance_id)
└── value: StructProperty
├── StructProperty("RawData")
│ └── ArrayProperty of bytes (UE object serialization)
└── BoolProperty("IsPlayer")
Each character — player or Pal — sits inside one of those MapProperty entries. For Pals, the RawData byte blob gets deserialized further into:
-
CharacterID— the Pal species internal name (e.g., "Anubis", "JetDragon") -
Gender— enum value -
Level— integer -
PassiveSkillList— array of passive skill internal names -
NickName— if the player renamed the Pal -
OwnerPlayerUID— who caught or hatched it
I won't lie: figuring out the exact property tree structure took several hours of staring at hex dumps and cross-referencing FArchive field names with known UE5 property serialization behavior. If Pocketpair ever publishes their save format spec, I'll owe someone a beer.
Why Rust? (And Why No wasm-bindgen?)
Three reasons:
Zero dependencies at runtime. The parser only needs
miniz_oxidefor zlib. No allocator libraries, no C runtime. This matters for WASM binary size — the final.wasmis under 200KB.The borrow checker is genuinely useful for binary parsing. When you're reading a byte buffer with a position cursor, Rust makes it very hard to accidentally read past the buffer. Every
FArchiveReadermethod checks bounds before advancing:
pub fn read_bytes(&mut self, count: usize) -> Result<&[u8], String> {
if self.pos + count > self.data.len() {
return Err(format!(
"EOF: tried to read {} bytes at offset {} (buffer has {} bytes)",
count, self.pos, self.data.len()
));
}
let slice = &self.data[self.pos..self.pos + count];
self.pos += count;
Ok(slice)
}
No silent buffer overflows. No segfaults in the browser.
-
I deliberately avoided
wasm-bindgen. Thewasm-bindgenCLI is great when it works, but it adds a build step, generates JS glue code, and ties you to a specific toolchain version. The Rust-WASM ABI is simple enough that you don't need it.
My WASM exports use raw extern "C" functions:
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
core::mem::forget(buf);
ptr
}
#[no_mangle]
pub extern "C" fn parse_sav(ptr: *const u8, len: usize) -> *mut c_char {
let data = unsafe { slice::from_raw_parts(ptr, len) };
let result = match parse_sav_bytes(data) {
Ok(r) => format_json(&r),
Err(e) => format!("{{\"error\":\"{}\"}}", e),
};
CString::new(result).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
if !ptr.is_null() {
unsafe { let _ = CString::from_raw(ptr); }
}
}
On the JS side, calling it is just:
const wasm = await WebAssembly.instantiate(bytes, {});
const inputPtr = wasm.exports.alloc(fileBuffer.length);
new Uint8Array(wasm.exports.memory.buffer, inputPtr, fileBuffer.length).set(fileBuffer);
const resultPtr = wasm.exports.parse_sav(inputPtr, fileBuffer.length);
const json = readCString(wasm.exports.memory.buffer, resultPtr);
wasm.exports.free_string(resultPtr);
No glue code. No codegen step. Just four functions on a WASM export table. The entire JS integration layer is 40 lines.
The Main Thread Problem I Didn't See Coming
Here's the thing I got wrong on my first attempt.
I was so focused on getting the parser working that I didn't think about when it runs. My initial approach was: user drops file → file.arrayBuffer() → call WASM → parse → render results. All on the main thread.
This works fine for small saves (under 1MB). But Palworld saves from long-running worlds can hit 9MB compressed, expanding to 50MB+ after decompression. The zlib pass alone takes 200-400ms on a desktop CPU. On a mid-range phone? The browser tab freezes for 2-3 seconds. The upload button stops responding to hover. The page looks broken.
I fixed it by doing three things:
-
Lazy WASM loading. The parser isn't loaded at page load. It's loaded via
requestIdleCallback— the browser only fetches the.wasmwhen the main thread is idle. If the user immediately clicks something, the parser load is deprioritized.
useEffect(() => {
const id = requestIdleCallback(() => {
import('@/lib/save-parser').then(m => m.initParser())
})
return () => clearTimeout(id)
}, [])
Loading state with a real progress animation. Not a spinner. A full-screen overlay with a pulsating progress bar and the filename. If the user has to wait, they should know exactly what's happening and why.
The real fix: Web Worker. I haven't shipped this yet, but the architecture is clear. The parsing needs to move off the main thread entirely. WASM instantiation and parsing happen in a Worker. The main thread gets progress events via
postMessage. This is the correct architecture for any WASM workload over 50ms.
What I'd Do Differently
Don't hand-write JSON serialization. I wrote a manual JSON formatter to avoid pulling in serde (which adds ~100KB to the WASM binary). It works. It's also the buggiest code in the project. String escaping, nested object formatting, trailing comma handling — all things serde solves perfectly. The 100KB is worth it. Don't be like me.
Test with real, large save files early. My first 20 test runs were on a tiny 50KB Level.sav test file. Everything was instant. Then I ran it on a real 9MB save from a 200-hour world and discovered that double zlib decompression takes real time. Test with production data from day one.
Add a parser_version() export immediately. Having the WASM module report its own version string is a 3-line function that saves hours of debugging when you're wondering "is the browser running the stale cached binary or the new one?"
The Result
The parser handles Palworld saves spanning versions 0.2.0.6 through 1.0, with file sizes from 50KB to 20MB. It extracts character data — species, gender, level, passive skills, ownership — in a few hundred milliseconds on desktop and returns structured JSON.
On top of the parser, the breeding engine runs a BFS pathfinding algorithm that computes optimal multi-generation breeding trees from your actual Palbox inventory. Try it at palbreedingcalc.com — drop your save file and get an instant roadmap. No more guessing which Pals can breed together. No more spreadsheets.
The whole thing is a static Next.js site deployed on Vercel. No database. No API routes at runtime. Zero server cost.
The Rust parser and breeding engine are open source at github.com/gewenwu716/palworld-save-parser. PRs welcome — especially if you have weird save files the parser chokes on. Edge cases are where all the interesting bugs live.
Top comments (0)