Factorio Blueprint Decoder

Factorio Blueprint Decoder

Project Overview

Decoding Factorio blueprint strings to understand:

  • Base64 encoding/decoding
  • Zlib decompression
  • Stream-based I/O
  • Working with dynamic buffers
  • Navigating API changes in Zig 0.15.2

Code location: /home/wes/projects/game_of_zig/src/blueprint.zig

Blueprint String Format

0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJSKi5JTM7My0xOVbIqKSpN1VEqSCwCcZQqihJzU4Gy...
^
|
Version byte (0 = version 0)

Rest is: Base64(Zlib(JSON))

The format:

  1. Version byte (0)
  2. Base64-encoded data
  3. Which contains zlib-compressed JSON

Implementation Steps

Step 1: Strip Version Byte

const blueprint_string = "0eJyl19tuoz...";
const encoded_data = blueprint_string[1..];

Slice syntax:

  • [1..] - from index 1 to end
  • [0..3] - from 0 to 3 (exclusive)
  • Strings are just []const u8 slices!

Step 2: Base64 Decoding

const base64 = std.base64.standard;

// Calculate required buffer size
const decoded_size = try base64.Decoder.calcSizeForSlice(encoded_data);

// Allocate buffer
const decoded = try allocator.alloc(u8, decoded_size);
defer allocator.free(decoded);

// Decode
try base64.Decoder.decode(decoded, encoded_data);

Key points:

  • calcSizeForSlice returns !usize (error union), needs try
  • Must allocate exact size before decoding
  • defer ensures cleanup

Verification:

std.debug.print("First few bytes: ", .{});
for (decoded[0..@min(20, decoded.len)]) |byte| {
    std.debug.print("{x:0>2} ", .{byte});
}

Should see 78 9c ... (zlib header).

Step 3: Zlib Decompression (Detailed)

This is where it gets interesting!

Creating the Input Reader

var in_reader: std.Io.Reader = .fixed(decoded);

What’s happening:

  • std.Io.Reader is an interface type (vtable pattern)
  • .fixed(decoded) creates a reader that reads from a fixed buffer
  • Shorthand for std.Io.Reader.fixed(decoded)

Memory layout of a Reader:

struct {
    vtable: *const VTable,   // Function pointers
    buffer: []const u8,       // Points to 'decoded'
    pos: usize,               // Current position
}

The Decompression Window Buffer

var window_buffer: [flate.max_window_len]u8 = undefined;

Why this is needed:

  • Deflate uses a “sliding window” algorithm
  • Looks back up to 32KB to find repeated patterns
  • “The word ‘solar’ appeared 50 bytes ago” → copies it
  • max_window_len = 65536 bytes (32KB * 2)

Stack vs Heap:

var window: [65536]u8 = undefined;  // Stack - fast, auto-cleanup
// vs
var window = try alloc(u8, 65536);  // Heap - needs defer
defer free(window);

Stack allocation is faster and doesn’t need manual cleanup!

The undefined keyword:

  • Means “don’t initialize, I’ll fill it”
  • Optimization - skip zeroing 64KB
  • Safe because decompressor will write before reading

Initializing the Decompressor

var decompressor = flate.Decompress.init(&in_reader, .zlib, &window_buffer);

Parameters breakdown:

  1. &in_reader - Pointer to input reader

    • Decompressor calls in_reader.read() for compressed bytes
    • Needs pointer because it modifies internal position
  2. .zlib - Container format (enum)

    • Could be .raw, .gzip, or .zlib
    • Determines header/footer handling:
      .raw  = just deflate data
      .zlib = [2-byte header] + deflate + [4-byte checksum]
      .gzip = [10-byte header] + deflate + [8-byte footer]
  3. &window_buffer - Pointer to working memory

    • Stores recently decompressed data
    • Needed for “back-references” in compressed stream

What gets returned:

struct Decompress {
    input: *Reader,           // Input compressed data
    reader: Reader,           // Output reader (what we use!)
    container_metadata: ...,  // Tracks zlib state
    next_bits: usize,         // Bit buffer
    state: State,             // State machine
    // ... window buffer, etc.
}

The decompressor IS a reader that wraps another reader!

Creating the Output Buffer

var decompressed: std.ArrayList(u8) = .{};
defer decompressed.deinit(allocator);

Zig 0.15.2 ArrayList is “unmanaged”:

struct {
    items: []u8 = &[_]u8{},   // Empty initially
    capacity: usize = 0,       // No allocation yet
}

Why ArrayList?

  • Don’t know decompressed size (531 bytes → 3987 bytes!)
  • Grows automatically as needed
  • More efficient than repeated reallocation

The .{} syntax:

  • Type is known from annotation: std.ArrayList(u8)
  • .{} means “use default values”
  • Expands to .{ .items = &[_]u8{}, .capacity = 0 }

The Actual Decompression

try decompressor.reader.appendRemainingUnlimited(allocator, &decompressed);

What happens:

  1. decompressor.reader - The output reader

    • Reading from it triggers decompression
    • It’s wrapped around the input reader
  2. .appendRemainingUnlimited(allocator, &decompressed)

    • Reads until EOF
    • Appends all bytes to ArrayList
    • “Unlimited” = no max size limit

Internal process (pseudocode):

loop {
    // Ensure ArrayList has space
    try list.ensureUnusedCapacity(allocator, CHUNK_SIZE);

    // Read into ArrayList's buffer
    const bytes_read = try decompressor.reader.read(
        list.unusedCapacitySlice()
    );

    if (bytes_read == 0) break;  // EOF
    list.items.len += bytes_read;
}

The decompression state machine:

  1. appendRemainingUnlimited calls decompressor.reader.read()
  2. read() executes the deflate algorithm:
    • Reads compressed bits from in_reader
    • Decodes Huffman codes
    • Handles back-references using window_buffer
    • Writes decompressed bytes
  3. Bytes appended to decompressed.items
  4. Repeat until EOF

Accessing Results

std.debug.print("JSON: {s}\n", .{decompressed.items});

Why .items?

  • ArrayList separates “actual data” from “allocated space”
  • items: []u8 - the data slice (length = 3987)
  • capacity: usize - allocated space (might be 4096)

API Changes in Zig 0.15.2

1. Compression Module Reorganization

// Old (pre-0.15):
const zlib = std.compress.zlib;
var decompressor = zlib.decompressor(reader);

// New (0.15.2):
const flate = std.compress.flate;
var decompressor = flate.Decompress.init(&reader, .zlib, &buffer);

Zlib is now part of the flate module with explicit container types.

2. ArrayList Now Unmanaged

// Old:
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();  // Allocator stored in struct

// New:
var list: std.ArrayList(u8) = .{};
defer list.deinit(allocator);  // Pass allocator to methods!

try list.append(allocator, item);

3. Reader Creation

// New simple way:
var reader: std.Io.Reader = .fixed(buffer);

// Instead of:
var fbs = std.io.fixedBufferStream(buffer);
var reader = fbs.reader();

4. Reading into ArrayList

// New method:
try reader.appendRemainingUnlimited(allocator, &list);

// Old method (deprecated):
try reader.readAllArrayList(&list, max_size);

Complete Decompression Flow

Blueprint String

[Strip '0']

Base64 String
      ↓ (base64.Decoder.decode)
Compressed Bytes (zlib format)

std.Io.Reader.fixed(buffer)

flate.Decompress.init(&reader, .zlib, &window)

decompressor.reader (output reader)
      ↓ (appendRemainingUnlimited)
ArrayList growing dynamically

decompressed.items = JSON!

Output

{
  "blueprint": {
    "icons": [{"signal": {"type": "item", "name": "solar-panel"}, "index": 1}],
    "entities": [
      {"entity_number": 1, "name": "solar-panel", "position": {"x": 237.5, "y": -450.5}},
      {"entity_number": 2, "name": "solar-panel", "position": {"x": 240.5, "y": -450.5}},
      ...
    ],
    "item": "blueprint",
    "label": "solar tile",
    "version": 281479276920832
  }
}

Stats:

  • Original: 709 characters
  • Base64 decoded: 531 bytes
  • Decompressed: 3987 bytes
  • Compression ratio: ~7.5:1

Key Lessons

1. Reader/Writer Pattern

Composable I/O: buffer → reader → decompressor → reader → ArrayList

2. Stack Allocation for Large Buffers

If size is known at compile-time, stack is faster than heap.

3. Defer for Resource Management

RAII pattern without destructors - cleanup is guaranteed.

4. Unmanaged Collections

Pass allocator to methods rather than storing it - more explicit.

5. State Machines

The decompressor maintains complex internal state across read calls.

6. Type System Precision

*Reader vs *const Reader vs GenericReader - types tell you exactly what’s happening.

Running the Code

cd /home/wes/projects/game_of_zig
zig build run

Further Exploration

The next step would be parsing the JSON using std.json.parseFromSlice(), but the main learning objectives were accomplished:

  • Understanding streams and readers
  • Memory management patterns
  • Navigating Zig’s evolving APIs
  • Real-world data processing

This project demonstrates that Zig is suitable for everyday tasks (like Python), but with predictable performance and no runtime surprises.

Wesley Ray · blog · git · resume