Skip to content

Instantly share code, notes, and snippets.

@ecnerwala
Last active August 13, 2022 18:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ecnerwala/f95daaeb55dc5459505e1a9f3c5b4eb6 to your computer and use it in GitHub Desktop.
Save ecnerwala/f95daaeb55dc5459505e1a9f3c5b4eb6 to your computer and use it in GitHub Desktop.
X'BPGH Save File Format
import sys
from enum import Enum, unique
from dataclasses import dataclass
from typing import Optional
import base64
import zlib
@unique
class CellType(Enum):
IGNORE = 0
SEED = 1
FLESH = 2
# NB: Note this transposition
FLESH_MUSCLE = 4
FLESH_HEART = 3
FLESH_FAT = 5
BONE = 6
BONE_SPINE = 7
SKIN = 8
SKIN_HAIR = 9
SKIN_EYE = 10
METAL = 11
ANY = 12
NONE = 13
@unique
class Direction(Enum):
RIGHT = 1
UP = 2
LEFT = 4
DOWN = 8
@unique
class Reaction(Enum):
IGNORE = 0
DIVIDE = 1
DIE = 4
FUSE = 3
SPECIALIZE = 2
@dataclass
class Coords:
x: int
y: int
@dataclass
class Rule:
target_type: CellType
neighbor_type: CellType
neighbor_dir: Direction
reaction: Reaction
divide_delta: Optional[Coords] = None
fuse_dir: Optional[Direction] = None
spec_type: Optional[CellType] = None
@dataclass
class Solution:
rules: list[Rule]
start_pos: Coords
metal_coords: list[Coords]
def parse_solution(dat: bytes) -> Solution:
"""parses a decompressed solution"""
def pop_int(b):
nonlocal dat
assert len(dat) >= b
res = int.from_bytes(dat[:b], "little", signed=True)
dat = dat[b:]
return res
# header (0xeb030000)
assert pop_int(4) == 1003
num_rules = pop_int(4)
assert num_rules == 16
rules = []
for _ in range(num_rules):
target_type = CellType(pop_int(4))
assert target_type not in {CellType.METAL, CellType.ANY, CellType.NONE}
neighbor_type = CellType(pop_int(4))
if target_type == CellType.IGNORE:
assert neighbor_type == CellType.IGNORE
neighbor_dir = Direction(pop_int(4))
reaction = Reaction(pop_int(1))
rule = Rule(target_type, neighbor_type, neighbor_dir, reaction)
if reaction == Reaction.DIVIDE:
delta_x = pop_int(4)
delta_y = pop_int(4)
assert (delta_x, delta_y) in {(1, 0), (0, 1), (-1, 0), (0, -1)}
rule.divide_delta = Coords(delta_x, delta_y)
elif reaction == Reaction.FUSE:
fuse_dir = Direction(pop_int(4))
rule.fuse_dir = fuse_dir
elif reaction == Reaction.SPECIALIZE:
spec_type = CellType(pop_int(4))
rule.spec_type = spec_type
rules.append(rule)
start_x = pop_int(4)
start_y = pop_int(4)
num_metal = pop_int(4)
metal_coords = []
for _ in range(num_metal):
x = pop_int(4)
y = pop_int(4)
metal_coords.append(Coords(x, y))
assert len(dat) == 0
return Solution(rules, Coords(start_x, start_y), metal_coords)
def main():
for line in sys.stdin:
if " = " in line:
key, val = line.split(" = ")
key = key.split(".")
if key[0] == "Toronto" and key[1] == "Solution":
level_id = int(key[2])
save_slot = int(key[3])
solution = parse_solution(zlib.decompress(base64.b64decode(val)))
print("Level", level_id, "Save", save_slot, solution)
if __name__ == "__main__":
main()

Save file

X'BPGH solutions are stored in the game's base save file, found at

Windows: %USERPROFILE%\Documents\My Games\Last Call BBS\<user-id>\save.dat
Linux: $HOME/.local/share/Last Call BBS/<user-id>/save.dat

The relevant lines of save.dat are of the form

Toronto.Solution.<LevelID>.<SaveSlot> = <SolutionString>

LevelID is the numeric ID of the level (see this post). SaveSlot is 0, 1, 2, or 3 (top-left, top-right, bottom-left, bottom-right). SolutionString is is the binary solution file, zlib compressed and base64 encoded.

Binary format

Decompressed solutions are variable-length encoded, using mostly little-endian (LE) 32-bit integers, and have the following high level structure

header: 4-byte LE int, always 1003 (0xEB 0x03 0x00 0x00)

num_rules: 4-byte LE int, always 16 (0x10 0x00 0x00 0x00)
rules: <num_rules = 16> rules in priority order, using a variable-length encoding, see below

start_coords: pair of 4-byte LE ints (x, y)

num_metal_coords: 4-byte LE int, always 0 except for level editor
metal_coords: <num_metal_coords> pairs of 4-byte LE ints (x, y)

All coordinates are 0-indexed and in (x, y) form, with x from left to right and y from bottom to top (so 0 <= x < 4 and 0 <= y < 5, origin at the bottom left). start_coords gives the coordinates of the starting seed cell, and metal_coords is a list of coordinates containing metal (only nonempty in the level editor, LevelID 16).

Rules

Rules are variable-length encoded, and each has length 13, 17, or 21 bytes. The rule format is:

  target_cell_type: 4-byte LE int, see cell types
neighbor_cell_type: 4-byte LE int, see cell types
neighbor_direction: 4-byte LE int, 1 = RIGHT, 2 = UP, 4 = LEFT, 8 = DOWN
     reaction_type: *1*-byte int, 0 = IGNORE, 1 = DIVIDE, 2 = SPECIALIZE, 3 = FUSE, 4 = DIE
     divide_coords: (for DIVIDE reactions only) pair of 4-byte LE ints (dx, dy) from -1 to +1, direction to divide
    fuse_direction: (for FUSE reactions only) 4-byte LE int, 1=R, 2=U, 4=L, 8=D, direction to fuse
   specialize_type: (for SPECIALIZE reactions only) 4-byte LE int, see cell types

The cell types are:

 0: IGNORE
 1: SEED
 2: FLESH
 3: FLESH_HEART
 4: FLESH_MUSCLE
 5: FLESH_FAT
 6: BONE
 7: BONE_SPINE
 8: SKIN
 9: SKIN_HAIR
10: SKIN_EYE
11: METAL
12: ANY
13: NONE

Note that FLESH_HEART and FLESH_MUSCLE are transposed compared to the game UI.

For example (written in left-to-right byte order):

0x01000000 0x0c000000 0x02000000 0x01 0xffffffff 0x00000000   SEED with ANY ABOVE should DIVIDE towards (-1, 0) (LEFT)
0x08000000 0x00000000 0x01000000 0x03 0x08000000              SKIN (IGNORE neighbor) should FUSE DOWN
0x06000000 0x05000000 0x01000000 0x02 0x07000000              BONE with FLESH_FAT to the RIGHT should SPECIALIZE into BONE_SPINE
0x0a000000 0x00000000 0x01000000 0x04                         SKIN_EYE (IGNORE neighbor) should DIE
0x04000000 0x00000000 0x01000000 0x00                         FLESH_MUSCLE (IGNORE neighbor) should IGNORE (rule is treated as empty)
0x00000000 0x00000000 0x01000000 0x00                         empty rule (has no effect)
@ecnerwala
Copy link
Author

This has been expanded into a full library+simulator at https://github.com/ecnerwala/xbpgh-sim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment