<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: bruce.ge</title>
    <description>The latest articles on DEV Community by bruce.ge (@gewenwu716).</description>
    <link>https://dev.to/gewenwu716</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4044914%2F1c3f8d94-8257-4505-b01e-77bed6c05b85.png</url>
      <title>DEV Community: bruce.ge</title>
      <link>https://dev.to/gewenwu716</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gewenwu716"/>
    <language>en</language>
    <item>
      <title>How I Reverse-Engineered Palworld's Save Format and Built a Rust Parser That Runs in Your Browser</title>
      <dc:creator>bruce.ge</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:29:55 +0000</pubDate>
      <link>https://dev.to/gewenwu716/how-i-reverse-engineered-palworlds-save-format-and-built-a-rust-parser-that-runs-in-your-browser-3p3e</link>
      <guid>https://dev.to/gewenwu716/how-i-reverse-engineered-palworlds-save-format-and-built-a-rust-parser-that-runs-in-your-browser-3p3e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Reverse-engineering an Unreal Engine 5 binary format, compiling to WebAssembly without &lt;code&gt;wasm-bindgen&lt;/code&gt;, and the one mistake I made with the main thread.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I built something that skips all of that. You drag your &lt;code&gt;level.sav&lt;/code&gt; into &lt;a href="https://www.palbreedingcalc.com" rel="noopener noreferrer"&gt;palbreedingcalc.com&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Format: PlZ Wrapper, GVAS Payload, UE5 Property Tree
&lt;/h2&gt;

&lt;p&gt;The first thing you learn when you open a Palworld &lt;code&gt;.sav&lt;/code&gt; file in a hex editor is that it's not JSON. It's not text at all.&lt;/p&gt;

&lt;p&gt;The file starts with 3 bytes: &lt;code&gt;PlZ&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: zlib (with a twist)
&lt;/h3&gt;

&lt;p&gt;After the &lt;code&gt;PlZ&lt;/code&gt; magic, there's a header with uncompressed/compressed lengths and a save type byte. Type &lt;code&gt;0x31&lt;/code&gt; means one pass of zlib. Type &lt;code&gt;0x32&lt;/code&gt; means the data is zlib-compressed &lt;em&gt;twice&lt;/em&gt; — decompress the outer layer, then decompress the inner layer from what you got back.&lt;/p&gt;

&lt;p&gt;Some files also have a &lt;code&gt;CNK&lt;/code&gt; sub-header at byte 8 instead of jumping straight to &lt;code&gt;PlZ&lt;/code&gt;. This one little difference means you can't just hardcode &lt;code&gt;data[8..11]&lt;/code&gt; — you have to read the header structure to find where the magic bytes actually are.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;miniz_oxide&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAGIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"PlZ"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Check for CNK header, which shifts the entire layout by 12 bytes&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;b"CNK"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="mi"&gt;24&lt;/span&gt;  &lt;span class="c1"&gt;// CNK adds 12 extra bytes before PlZ&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Type 0x32 = double-compressed&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;save_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0x32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;uncompressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decompress_to_vec_zlib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;uncompressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 2: GVAS — The Unreal Engine Save Game Container
&lt;/h3&gt;

&lt;p&gt;Once decompressed, you're looking at a GVAS file. The magic number is &lt;code&gt;0x53415647&lt;/code&gt; ("GVAS" in little-endian). The header gives you the engine version, custom version GUIDs, and — critically — the save game class name: &lt;code&gt;/Script/Pal.PalWorldSaveGame&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: FArchive — UE's Binary Property Tree
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The 1.0 Palworld save format nests character data inside a chain like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;StructProperty("worldSaveData")
  └── StructProperty("CharacterSaveParameterMap")
        └── MapProperty
              └── MapProperty entries
                    ├── key: StructProperty (FGuid instance_id)
                    └── value: StructProperty
                          ├── StructProperty("RawData")
                          │     └── ArrayProperty of bytes (UE object serialization)
                          └── BoolProperty("IsPlayer")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each character — player or Pal — sits inside one of those MapProperty entries. For Pals, the &lt;code&gt;RawData&lt;/code&gt; byte blob gets deserialized further into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CharacterID&lt;/code&gt; — the Pal species internal name (e.g., "Anubis", "JetDragon")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Gender&lt;/code&gt; — enum value&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Level&lt;/code&gt; — integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PassiveSkillList&lt;/code&gt; — array of passive skill internal names&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NickName&lt;/code&gt; — if the player renamed the Pal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OwnerPlayerUID&lt;/code&gt; — who caught or hatched it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rust? (And Why No &lt;code&gt;wasm-bindgen&lt;/code&gt;?)
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero dependencies at runtime.&lt;/strong&gt; The parser only needs &lt;code&gt;miniz_oxide&lt;/code&gt; for zlib. No allocator libraries, no C runtime. This matters for WASM binary size — the final &lt;code&gt;.wasm&lt;/code&gt; is under 200KB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The borrow checker is genuinely useful for binary parsing.&lt;/strong&gt; When you're reading a byte buffer with a position cursor, Rust makes it very hard to accidentally read past the buffer. Every &lt;code&gt;FArchiveReader&lt;/code&gt; method checks bounds before advancing:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"EOF: tried to read {} bytes at offset {} (buffer has {} bytes)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No silent buffer overflows. No segfaults in the browser.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;I deliberately avoided &lt;code&gt;wasm-bindgen&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;wasm-bindgen&lt;/code&gt; CLI 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My WASM exports use raw &lt;code&gt;extern "C"&lt;/code&gt; functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[no_mangle]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ptr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.as_mut_ptr&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nn"&gt;core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;ptr&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[no_mangle]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;parse_sav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;c_char&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_raw_parts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;parse_sav_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;format_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nn"&gt;CString&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.into_raw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[no_mangle]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;free_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;c_char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="nf"&gt;.is_null&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;CString&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the JS side, calling it is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;WebAssembly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instantiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputPtr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputPtr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultPtr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_sav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputPtr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readCString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resultPtr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resultPtr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No glue code. No codegen step. Just four functions on a WASM export table. The entire JS integration layer is 40 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Main Thread Problem I Didn't See Coming
&lt;/h2&gt;

&lt;p&gt;Here's the thing I got wrong on my first attempt.&lt;/p&gt;

&lt;p&gt;I was so focused on getting the parser working that I didn't think about &lt;em&gt;when&lt;/em&gt; it runs. My initial approach was: user drops file → &lt;code&gt;file.arrayBuffer()&lt;/code&gt; → call WASM → parse → render results. All on the main thread.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I fixed it by doing three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lazy WASM loading.&lt;/strong&gt; The parser isn't loaded at page load. It's loaded via &lt;code&gt;requestIdleCallback&lt;/code&gt; — the browser only fetches the &lt;code&gt;.wasm&lt;/code&gt; when the main thread is idle. If the user immediately clicks something, the parser load is deprioritized.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/save-parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initParser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Loading state with a real progress animation.&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The real fix: Web Worker.&lt;/strong&gt; 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 &lt;code&gt;postMessage&lt;/code&gt;. This is the correct architecture for any WASM workload over 50ms.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't hand-write JSON serialization.&lt;/strong&gt; I wrote a manual JSON formatter to avoid pulling in &lt;code&gt;serde&lt;/code&gt; (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 &lt;code&gt;serde&lt;/code&gt; solves perfectly. The 100KB is worth it. Don't be like me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with real, large save files early.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a &lt;code&gt;parser_version()&lt;/code&gt; export immediately.&lt;/strong&gt; 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?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://www.palbreedingcalc.com" rel="noopener noreferrer"&gt;palbreedingcalc.com&lt;/a&gt; — drop your save file and get an instant roadmap. No more guessing which Pals can breed together. No more spreadsheets.&lt;/p&gt;

&lt;p&gt;The whole thing is a static Next.js site deployed on Vercel. No database. No API routes at runtime. Zero server cost.&lt;/p&gt;

&lt;p&gt;The Rust parser and breeding engine are open source at &lt;a href="https://github.com/gewenwu716/palworld-save-parser" rel="noopener noreferrer"&gt;github.com/gewenwu716/palworld-save-parser&lt;/a&gt;. PRs welcome — especially if you have weird save files the parser chokes on. Edge cases are where all the interesting bugs live.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>webassembly</category>
      <category>gamedev</category>
      <category>reverseengineering</category>
    </item>
  </channel>
</rss>
