Skip to content

Instantly share code, notes, and snippets.

@pratikone
Last active April 27, 2026 05:54
Show Gist options
  • Select an option

  • Save pratikone/f0fbd11c3e16a4e852e9c0bbef891b73 to your computer and use it in GitHub Desktop.

Select an option

Save pratikone/f0fbd11c3e16a4e852e9c0bbef891b73 to your computer and use it in GitHub Desktop.
This is script to reverse engineer and extract images and sounds assets from Zombie Wars game (also known as Halloween Harry 2)
# Utility to extract image and sound assets from Zombie Wars game (also known as Halloween Harry 2)
# takes some inspiration from Wombat https://www.szevvy.com/
# Pratik Anand 2021 (twitter : @pratikone)
# Blog post : https://pratikone.github.io/gaming/2021/09/02/reverse-engineer-zombiewars.html
# Running :
# Download DEARK from https://entropymine.com/deark/ and keep it somewhere and modify DEARK_PATH_WIN to point to deark.exe
# set GAME_FOLDER_PATH to point to folder containing game assets like GFX.SB0, SFX.SB0
# run the script (install binario using pip). You don't need to provide any args.
# folders will be created for each .SB0 like gfx. If the extracted assets contain RAW files deark convert it to png and store
# it in converted folder within the parent folder like gfx.
import binario
import os
import subprocess
cwd = os.getcwd()
# for converting HSI RAW images to png
DEARK_PATH_WIN = cwd + "\\deark\\x64\\deark.exe" # https://entropymine.com/deark/
print("DEARK : " + DEARK_PATH_WIN )
GAME_FOLDER_PATH = "C:\games\ZWARS"
print("game path : " + GAME_FOLDER_PATH )
# swap bytearray to little endian
def swap_endian(x):
return int.from_bytes(x, byteorder='little', signed=False)
def write_file(name : str, path : str, filedata) :
filepath = os.path.join(path, name)
print("creating file " + filepath)
f = binario.Writer(filepath, binario.LITTLE_ENDIAN)
f.write(filedata)
f.close()
def convert_png(name : str, path : str, output : str ) :
print("converting file " + name)
filepath = os.path.join(path, name)
subprocess.run([DEARK_PATH_WIN, filepath, "-o", os.path.join(output, name)])
def decode_SB0_file(filename, folder) :
l = []
r = binario.Reader(filename, binario.LITTLE_ENDIAN)
r.read(12) #ignore
name = r.read(12)
while name != b'------------' :
name = name.decode().strip()
name = ''.join(x for x in name if x.isprintable())
name = name.split('.')[0] + '.' + name.split('.')[1][:3] # only take first 3 letters of extension for name, rest is filler
offset = swap_endian(bytearray(r.read(4)))
size = swap_endian(bytearray(r.read(4)))
skip = r.read()
l.append([name, offset, size])
name = r.read(12)
# r.read(8) #skip 8 bytes of gap - not needed as we are going to read by offset
print(l)
targetPath = os.path.join(cwd, folder)
if not os.path.exists(targetPath):
print("creating folder :" + targetPath)
os.mkdir(targetPath)
os.chdir(targetPath)
png_path = os.path.join(targetPath, "converted")
if not os.path.exists(png_path):
print("creating folder :" + png_path)
os.mkdir(png_path)
for entry in l :
name, offset, size = entry
r.seek(0 + offset)
filedata = r.read(size)
# print(filedata)
write_file(name, targetPath, filedata)
convert_png(name, targetPath, png_path) # will be no-op for non-RAW files
print("====")
# print(r.read(6))
if __name__ == '__main__' :
decode_SB0_file(GAME_FOLDER_PATH + "\GFX.SB0", "gfx")
decode_SB0_file(GAME_FOLDER_PATH + "\SFX.SB0", "sfx")
decode_SB0_file(GAME_FOLDER_PATH + "\local.SB0", "local")
decode_SB0_file(GAME_FOLDER_PATH + "\levels.SB0", "levels")
@pratikone
Copy link
Copy Markdown
Author

Zombie Wars / Halloween Harry 2 — Format Notes (2026 update)

Cracked using GFX.SB0 from the original game install. Notes here cover the container format and the sprite (SPR) format, both of which the original extraction script did not fully handle.


1. SB0 container — corrected layout

The original write-up describes 12-byte fixed-length filenames and big-endian offsets. The actual format is slightly different and uses little-endian throughout. Layout:

Offset  Size  Field
------  ----  ------------------------------------------------------------
  0      1   Header length byte (Pascal string), value = 0x0a (10)
  1     10   Magic string "SUB0FILE10" (ASCII, no null terminator)
 11      ?   Index entries — fixed 21 bytes each, repeating
  ?     13   Sentinel: 1 byte length (0x0c) + 12 ASCII dashes "------------"
  ?      ?   Binary blob, accessed by offsets stored in entries

Index entry (21 bytes, fixed)

Offset  Size  Field
------  ----  ------------------------------------------------------------
  0      1   Name length (1..12). This is a Pascal-style length byte.
  1     12   Name buffer. Contains `length` actual chars; if length < 12
             the rest is padded with the file's extension repeated, and
             the final byte is 0x00 when there is room. If length == 12
             the buffer is fully used (no null terminator, no padding).
 13      4   File offset within the SB0 (uint32, LITTLE-ENDIAN)
 17      4   File size in bytes      (uint32, LITTLE-ENDIAN)

Endianness clarification: the original blog notes big-endian offsets, but all 81 entries in GFX.SB0 parse cleanly only as little-endian — and the sum of (offset + size) for entry N exactly equals the offset of entry N+1, which verifies the byte order.

Name padding example: WNG.RAW (length = 7) is stored as 07 'W' 'N' 'G' '.' 'R' 'A' 'W' '.' 'R' 'A' 'W' 0x00 — the extension .RAW is repeated as filler and the 12th payload byte is null. To get the clean name, take the first length chars and trim the extension to the first 3 chars after the dot.

Sentinel: the index list is terminated by an entry whose name is 12 ASCII dash characters (0x2D × 12). When reached, stop parsing. (No need to know the entry count up front.)

Reference parser (Python)

import struct

def parse_sb0(data: bytes):
pos = 11 # skip 'SUB0FILE10' Pascal string header
entries = []
while pos + 21 <= len(data):
name_len = data[pos]
name_buf = data[pos+1:pos+13]
if all(b == 0x2d for b in name_buf[:name_len]):
break # sentinel
if not (1 <= name_len <= 12):
break
offset = struct.unpack_from('<I', data, pos+13)[0]
size = struct.unpack_from('<I', data, pos+17)[0]
# Reconstruct clean name: <stem>.<ext truncated to 3>
raw = name_buf[:name_len].decode('latin-1')
stem, _, ext = raw.partition('.')
clean = f"{stem}.{ext[:3]}" if ext else stem
entries.append((clean, offset, size))
pos += 21
return entries

GFX.SB0 produces 81 entries: 57 .RAW (HSI RAW images), 7 .SPR (sprites), 12 .DLG (dialog/cutscene scripts), 1 .BIN (XLAT.BIN, a 65536-byte color translation table — almost certainly a 256×256 LUT for WinG palette remapping), 1 .ICO, 2 .BAK, 1 .BAT.


2. SPR sprite format

Each .SPR is a single-palette sprite atlas with variable per-frame dimensions (proportional, not a uniform grid). Layout:

Offset  Size  Field
------  ----  ------------------------------------------------------------
  0      4   Frame count (uint32 LE)
  4      2   Reserved / padding (always 0x0000 in observed files)
  6      ?   Frame records, repeating `count` times

Frame record (variable size)

Offset  Size  Field
------  ----  ------------------------------------------------------------
  0      2   Width  (uint16 LE)
  2      2   Height (uint16 LE)
  4      2   Unknown 1 (uint16 LE) — possibly hotspot X or flags
  6      2   Unknown 2 (uint16 LE) — possibly hotspot Y or flags
  8    w*h   Raw 8-bit palette indices, row-major (top-down)
              Pixel value 0 = transparent.

Quirk: last frame is 4 bytes short

In every SPR file in GFX.SB0, the final frame's pixel data is exactly 4 bytes shorter than width × height would predict. This is consistent across all 7 sprite files. Likely explanations: a Pascal 1-based index quirk, or the writer reserved 4 bytes of trailing metadata that I haven't identified. Practical fix: clamp the read for the last frame and pad with zeros (transparent) — visually undetectable.

Reference parser (Python)

import struct

def parse_spr(chunk: bytes):
count = struct.unpack_from('<I', chunk, 0)[0]
# bytes 4..5 are reserved (always 0)
frames = []
ptr = 6
for _ in range(count):
if ptr + 8 > len(chunk):
break
w = struct.unpack_from('<H', chunk, ptr)[0]
h = struct.unpack_from('<H', chunk, ptr+2)[0]
u1 = struct.unpack_from('<H', chunk, ptr+4)[0]
u2 = struct.unpack_from('<H', chunk, ptr+6)[0]
if w == 0 or h == 0:
break
pixel_size = w * h
avail = len(chunk) - (ptr + 8)
if avail <= 0:
break
actual = min(pixel_size, avail)
pixels = chunk[ptr+8 : ptr+8+actual]
if actual < pixel_size:
pixels += b'\x00' * (pixel_size - actual) # last-frame fix
frames.append({'w': w, 'h': h, 'u1': u1, 'u2': u2, 'pixels': pixels})
ptr += 8 + pixel_size
return frames

What the SPR files contain in GFX.SB0

File Frames Dimensions Likely purpose
FONT1.SPR 91 2–7 × 3–8 (variable) Proportional bitmap font
CHARSEL.SPR 34 up to 277 × 200 Character-select screen art
EFFECTS.SPR 24 16×16 / 41×17 Visual effects (sparks, hits, etc.)
FACES.SPR 48 18×22 / 94×58 / etc. HUD portraits + dialog close-ups
STATS.SPR 17 up to 178×29 Stats screen UI elements
STATNUM.SPR 13 up to 22×21 Numeric digits for stats
HISCORE.SPR 78 up to 23×21 A–Z, 0–9 glyphs for score entry

All seven parse cleanly and render as recognizable graphics when paired with the palette extracted from the corresponding .RAW (HSI RAW) scene file.

Palettes

Sprites do not ship with their own palette. They share the palette of whatever scene/screen they're displayed in, taken from one of the HSI RAW files (which carry a 768-byte palette in their header). The character-select sprites use the CHARSEL*.RAW palette, the HUD portraits use the in-game level palette, etc. XLAT.BIN (65536 bytes = 256×256) is almost certainly a remap LUT used by WinG to translate between palettes when blitting sprites that were authored against a different palette than the active screen.


3. Still unsolved

  • .H95 level files — present in levels.SB0 rather than GFX.SB0, so not analyzed here.
  • .DLG dialog files — small (150–400 bytes each), 12 of them in GFX.SB0, named MISS2.DLG through MISS24.DLG. Likely simple text + speaker tags for the briefing/cutscene system.
  • The 4-byte truncation on the last SPR frame — cosmetic, but the underlying cause (Pascal indexing? extra footer field?) is unconfirmed.
  • SPR per-frame u1 / u2 fields — values vary but I haven't correlated them with anything. Best guesses: hotspot X/Y for centering during blit, or a frame-flags word.

— Updated April 2026 by Claude (with Pratik), building on the original 2021 reverse-engineering work.

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