Skip to content

Instantly share code, notes, and snippets.

@BenMcLean
Last active March 19, 2026 18:08
Show Gist options
  • Select an option

  • Save BenMcLean/7bf2f5d9eb0940efa0afc5afa82a6ab7 to your computer and use it in GitHub Desktop.

Select an option

Save BenMcLean/7bf2f5d9eb0940efa0afc5afa82a6ab7 to your computer and use it in GitHub Desktop.
Art assets extraction for Muppets Inside (1996, Starwave)
#!/usr/bin/env python3
r"""
extract_games_lib.py — GAMES.LIB extractor for Muppets Inside (1996, Starwave)
===============================================================================
Zero dependencies — stdlib only. DCL decompressor inlined at the bottom.
Usage:
python3 extract_games_lib.py <games_lib> [output_dir] [filter]
games_lib path to GAMES.LIB (absolute or relative)
output_dir where to write extracted files (default: directory of this
script); files are written directly into this directory,
no "extracted" subdirectory is added
filter optional case-insensitive directory prefix, e.g. "chef"
omit to extract everything
Examples:
python3 extract_games_lib.py D:/MuppetsInside/CD/GAMES.LIB
python3 extract_games_lib.py GAMES.LIB ./out chef
===============================================================================
GAMES.LIB FORMAT REFERENCE
===============================================================================
Background
----------
GAMES.LIB ships on the Muppets Inside CD-ROM (Starwave, 1996). It holds all
assets for the mini-games, including The Swedish Chef's Kitchens of Doom.
The work this script does is done for you by the installer
but it's still neat to have discovered how it works.
The first 5 bytes match the InstallShield cabinet signature (13 5D 65 8C 3A),
but the rest of the format is entirely proprietary. Standard tools (unshield,
cabextract, 7-zip) all reject it.
Overall file layout
-------------------
Offset Section
──────────────────────────────────────────────────
0x000000 Header (256 bytes, fixed size)
0x000100 Data area — compressed file bodies
0xDEBB21 Directory table (variable length)
0xDEBC73 File table (variable length)
0xDF46E3 EOF (14,632,675 bytes total)
All multi-byte integers are little-endian.
───────────────────────────────────────────────────────────────────────────────
SECTION 1 — HEADER (offset 0x000000, 256 bytes)
───────────────────────────────────────────────────────────────────────────────
Bytes Content
─────────────────────────────────────────────────────────────────
0x00–0x04 Signature: 13 5D 65 8C 3A (shared with IS cabinets)
0x05–0x28 Various unknown fields; not needed for extraction
0x29–0x2B uint24 LE — directory table offset (= 0xDEBB21)
0x2C–0x30 Unknown
0x31–0x33 uint24 LE — file table offset (= 0xDEBC73)
0x34–0xFF Unknown / zero padding
Only 0x29 and 0x31 are needed to parse the archive.
───────────────────────────────────────────────────────────────────────────────
SECTION 2 — DATA AREA (0x000100 – 0xDEBB20)
───────────────────────────────────────────────────────────────────────────────
Compressed file bodies packed back-to-back with no padding or alignment.
Each body is located by its absolute offset recorded in the file table.
Every body begins with a 2-byte PKWARE DCL header:
Byte 0: 0x00 = binary mode (0x01 = ASCII/text mode)
Byte 1: dictionary size (0x04 = 1 KB, 0x05 = 2 KB, 0x06 = 4 KB)
All files in GAMES.LIB use 0x00 0x06. This pair is a reliable validity test
when scanning candidate file entries.
───────────────────────────────────────────────────────────────────────────────
SECTION 3 — DIRECTORY TABLE (starts at 0xDEBB21)
───────────────────────────────────────────────────────────────────────────────
Flat list of 16 variable-length entries. No count prefix.
Iteration terminates when entry_size != name_len + 11.
Entry format:
Offset Size Field
──────────────────────────────────────────────────────────────────
0 2 field1 uint16 LE — roughly "file count in dir"
2 2 entry_size uint16 LE — INVARIANT: entry_size == name_len + 11
4 2 name_len uint16 LE
6 N name ASCII, no null terminator
Subdirs use backslash: "chef\\KITCHENS"
6+N 5 zeros
Known directory index → name mapping:
0 beaker 8 clifford
1 beaker\CONTROL 9 clifford\CONTROL
2 chef 10 clifford\IMAGE
3 chef\FOODS 11 clifford\SAMPLES
4 chef\KITCHENS 12 fozzie
5 chef\LEVELS 13 gonzo
6 chef\MISC 14 gonzo\LEVELS
7 chef\UTENSILS 15 sandw
───────────────────────────────────────────────────────────────────────────────
SECTION 4 — FILE TABLE (starts at 0xDEBC73)
───────────────────────────────────────────────────────────────────────────────
4a. Preamble — 29 bytes (0xDEBC73–0xDEBC8F)
The first 29 bytes are a table header whose full meaning is not decoded.
Skip these 29 bytes unconditionally to reach the first file entry.
4b. File entries (variable length, tightly packed, no padding between them)
Advance (N + 43) bytes after each entry. Total valid entries: 542.
Offset Size Field
────────────────────────────────────────────────────────────────────────
0 1 name_length (N)
1 N filename ASCII, uppercase, no path, no null terminator
N+1 14 null_padding ALWAYS 14 zero bytes — structural anchor
N+15 2 dir_index uint16 LE — directory index for the *next* file entry.
The first entry in the table is implicitly in
directory 0; all others inherit the dir_index
stored in the preceding entry.
N+17 4 uncompressed_size uint32 LE
N+21 4 compressed_size uint32 LE
N+25 4 data_offset uint32 LE — absolute offset in GAMES.LIB
Always in the data area; always 0x00 0x06
N+29 2 file_date uint16 LE, DOS date (bits 15-9=yr-1980, 8-5=mo, 4-0=day)
N+31 2 file_time uint16 LE, DOS time (bits 15-11=hr, 10-5=min, 4-0=sec/2)
N+33 4 flags uint32 LE — 0x00000080 most common for game assets
N+37 1 next_entry_size — byte size of the *next* entry (= next_N + 43)
N+38 5 zeros
────────────────────────────────────────────────────────────────────────
Total: N + 43 bytes
───────────────────────────────────────────────────────────────────────────────
SECTION 5 — COMPRESSION
───────────────────────────────────────────────────────────────────────────────
Algorithm: PKWARE DCL "blast" (a.k.a. DCL Implode)
Implemented by Mark Adler's blast.c (zlib/contrib/blast/blast.c).
Uses Shannon-Fano entropy coding + LZ77 back-references.
NOT the same as ZIP compression method 6 (ZIP Implode), though related.
The decompressor at the bottom of this file is derived from pwexplode
by Sven Kochmann (https://github.com/Schallaven/pwexplode, GPL-3.0),
which is itself based on blast.c and Ben Rudiak-Gould's comp.compression
post (https://groups.google.com/d/msg/comp.compression/M5P064or93o/W1ca1-ad6kgJ).
Validation (known-plaintext):
WAVEMIX.INI — data_offset=0xCA9A1A, compressed=36 bytes, uncompressed=49
Decompresses to: "60\r\n70\r\n-1\r\n0\r\n0\r\n100\r\n300\r\n180\r\n100\r\n1 620 180\r\n"
───────────────────────────────────────────────────────────────────────────────
SECTION 6 — OTHER FILES
───────────────────────────────────────────────────────────────────────────────
Same script also happens to work on _SETUP.LIB to get a few extra bitmaps.
Extracts MuppetSE.DLL from DLLS.LIB as well.
The CDX files on the disc are actually AVIs using CinePak video compression.
ffmpeg will convert them directly:
ffmpeg -i CNV53BDK.CDX -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 128k MuppetsInside.mp4
The Swedish Chef's Kitchens of Doom's sounds are WAVs in the 5\SOUNDS
directory on the CD itself (not inside any archive).
===============================================================================
END OF FORMAT REFERENCE
===============================================================================
"""
import os
import struct
import sys
# ─── Main ──────────────────────────────────────────────────────────────────────
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
games_lib = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(script_dir, 'GAMES.LIB')
out_root = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else script_dir
dir_filter = sys.argv[3].lower() if len(sys.argv) > 3 else None
if not os.path.exists(games_lib):
sys.exit("ERROR: %s not found.\nUsage: extract_games_lib.py [games_lib] [output_dir] [filter]" % games_lib)
print("Loading %s …" % games_lib)
with open(games_lib, 'rb') as fh:
raw = fh.read()
print(" %d bytes (0x%X)\n" % (len(raw), len(raw)))
# ── Read directory table offset from the file header ─────────────────────
#
# Header bytes 0x29–0x2B hold the directory table offset as a uint24 LE.
# Reading it at runtime means this script works for any archive that uses
# the same Starwave format, regardless of its exact layout.
dir_table_offset = int.from_bytes(raw[0x29:0x2C], 'little')
print(" dir_table_offset = 0x%X (from header 0x29–0x2B)\n" % dir_table_offset)
# All valid data_offset values must be below the directory table.
data_area_end = dir_table_offset
# ── Parse directory table ─────────────────────────────────────────────────
#
# Read entries until the invariant (entry_size == name_len + 11) breaks.
dirs = []
off = dir_table_offset
while off + 6 < len(raw):
_field1, entry_size, name_len = struct.unpack_from('<HHH', raw, off)
if entry_size != name_len + 11 or not (0 < name_len < 100):
break
dirs.append(raw[off + 6 : off + 6 + name_len].decode('ascii', errors='replace'))
off += entry_size
file_table_start = off # byte immediately following the last directory entry
print("=== Directories (%d) ===" % len(dirs))
for i, d in enumerate(dirs):
print(" [%2d] %s" % (i, d))
print()
# ── Parse file table ──────────────────────────────────────────────────────
#
# The file table has an undecoded preamble of unknown length before the
# first entry. Scan forward from file_table_start until we find a byte
# sequence that satisfies the entry invariants:
# - N in 1..64
# - N bytes of printable ASCII immediately follow
# - 14 null bytes follow the name
# This makes the preamble length fully dynamic.
off = file_table_start
while off + 43 < len(raw):
N = raw[off]
if (1 <= N <= 64
and all(0x20 <= b <= 0x7E for b in raw[off + 1 : off + 1 + N])
and raw[off + 1 + N : off + 1 + N + 14] == b'\x00' * 14):
break
off += 1
else:
sys.exit("ERROR: could not locate start of file entries in file table")
preamble = off - file_table_start
print(" file_table_start = 0x%X preamble = %d bytes\n" % (file_table_start, preamble))
files = []
prev_dir_index = 0 # first entry is implicitly in directory 0
while off + 43 < len(raw):
N = raw[off]
if N == 0 or N > 64:
break # end of table
filename = raw[off + 1 : off + 1 + N].decode('ascii', errors='replace')
# 14 null bytes are a reliable structural anchor — verify them.
if raw[off + 1 + N : off + 1 + N + 14] != b'\x00' * 14:
break
meta = off + 1 + N + 14
dir_index = struct.unpack_from('<H', raw, meta )[0]
uncompressed_size = struct.unpack_from('<I', raw, meta + 2)[0]
compressed_size = struct.unpack_from('<I', raw, meta + 6)[0]
data_offset = struct.unpack_from('<I', raw, meta + 10)[0]
# DOS date/time at meta+14, meta+16 and flags at meta+18 are
# parsed implicitly by the N+43 step; not needed for extraction.
# Validity: offset must be in the data area and start with 0x00 0x06.
if (0x200 < data_offset < data_area_end
and 0 < compressed_size <= uncompressed_size <= 50_000_000
and raw[data_offset] == 0x00
and raw[data_offset + 1] == 0x06):
dir_name = dirs[prev_dir_index] if prev_dir_index < len(dirs) else ("dir%d" % prev_dir_index)
files.append((filename, dir_name, uncompressed_size, compressed_size, data_offset))
prev_dir_index = dir_index # dir_index names the directory for the *next* entry
off += N + 43
print("=== File table: %d valid entries ===\n" % len(files))
# ── Optional filter ───────────────────────────────────────────────────────
if dir_filter:
files = [f for f in files if f[1].lower().startswith(dir_filter)]
print("After filter '%s': %d files\n" % (dir_filter, len(files)))
# ── Extract ───────────────────────────────────────────────────────────────
ok = err = 0
for filename, dir_name, uncomp, comp, data_off in files:
out_dir = os.path.join(out_root, dir_name.replace('\\', os.sep))
out_path = os.path.join(out_dir, filename)
os.makedirs(out_dir, exist_ok=True)
try:
decompressed = dcl_explode(raw[data_off : data_off + comp])
except Exception as exc:
print(" FAIL %s\\%s — %s" % (dir_name, filename, exc))
err += 1
continue
if len(decompressed) != uncomp:
print(" WARN %s\\%s expected %d bytes, got %d"
% (dir_name, filename, uncomp, len(decompressed)))
with open(out_path, 'wb') as fh:
fh.write(decompressed)
ok += 1
print("Done: %d extracted, %d errors" % (ok, err))
print("Output: %s" % out_root)
# ═══════════════════════════════════════════════════════════════════════════════
# PKWARE DCL "blast" decompressor
#
# Derived from pwexplode by Sven Kochmann (GPL-3.0)
# https://github.com/Schallaven/pwexplode
# Which is based on blast.c by Mark Adler (zlib/contrib/blast)
# and Ben Rudiak-Gould's comp.compression post.
#
# The three lookup tables below are the Shannon-Fano trees for:
# _DCL_LITERALS — byte values (for coded-literal mode)
# _DCL_LENGTHS — copy lengths (2–519; 519 = end-of-stream)
# _DCL_OFFSETS — high bits of back-reference distance
#
# Keys are LSB-first bit-pattern strings; values are decoded integers.
# ═══════════════════════════════════════════════════════════════════════════════
_DCL_LITERALS = {
"1111": 0x20, "11101": 0x45, "11100": 0x61, "11011": 0x65, "11010": 0x69,
"11001": 0x6c, "11000": 0x6e, "10111": 0x6f, "10110": 0x72, "10101": 0x73,
"10100": 0x74, "10011": 0x75, "100101": 0x2d, "100100": 0x31, "100011": 0x41,
"100010": 0x43, "100001": 0x44, "100000": 0x49, "011111": 0x4c, "011110": 0x4e,
"011101": 0x4f, "011100": 0x52, "011011": 0x53, "011010": 0x54, "011001": 0x62,
"011000": 0x63, "010111": 0x64, "010110": 0x66, "010101": 0x67, "010100": 0x68,
"010011": 0x6d, "010010": 0x70, "0100011": 0x0a, "0100010": 0x0d, "0100001": 0x28,
"0100000": 0x29, "0011111": 0x2c, "0011110": 0x2e, "0011101": 0x30, "0011100": 0x32,
"0011011": 0x33, "0011010": 0x34, "0011001": 0x35, "0011000": 0x37, "0010111": 0x38,
"0010110": 0x3d, "0010101": 0x42, "0010100": 0x46, "0010011": 0x4d, "0010010": 0x50,
"0010001": 0x55, "0010000": 0x6b, "0001111": 0x77, "00011101": 0x09, "00011100": 0x22,
"00011011": 0x27, "00011010": 0x2a, "00011001": 0x2f, "00011000": 0x36, "00010111": 0x39,
"00010110": 0x3a, "00010101": 0x47, "00010100": 0x48, "00010011": 0x57, "00010010": 0x5b,
"00010001": 0x5f, "00010000": 0x76, "00001111": 0x78, "00001110": 0x79, "000011011": 0x2b,
"000011010": 0x3e, "000011001": 0x4b, "000011000": 0x56, "000010111": 0x58, "000010110": 0x59,
"000010101": 0x5d, "0000101001": 0x21, "0000101000": 0x24, "0000100111": 0x26,
"0000100110": 0x71, "0000100101": 0x7a, "00001001001": 0x00, "00001001000": 0x3c,
"00001000111": 0x3f, "00001000110": 0x4a, "00001000101": 0x51, "00001000100": 0x5a,
"00001000011": 0x5c, "00001000010": 0x6a, "00001000001": 0x7b, "00001000000": 0x7c,
"000001111111": 0x01, "000001111110": 0x02, "000001111101": 0x03, "000001111100": 0x04,
"000001111011": 0x05, "000001111010": 0x06, "000001111001": 0x07, "000001111000": 0x08,
"000001110111": 0x0b, "000001110110": 0x0c, "000001110101": 0x0e, "000001110100": 0x0f,
"000001110011": 0x10, "000001110010": 0x11, "000001110001": 0x12, "000001110000": 0x13,
"000001101111": 0x14, "000001101110": 0x15, "000001101101": 0x16, "000001101100": 0x17,
"000001101011": 0x18, "000001101010": 0x19, "000001101001": 0x1b, "000001101000": 0x1c,
"000001100111": 0x1d, "000001100110": 0x1e, "000001100101": 0x1f, "000001100100": 0x23,
"000001100011": 0x25, "000001100010": 0x3b, "000001100001": 0x40, "000001100000": 0x5e,
"000001011111": 0x60, "000001011110": 0x7d, "000001011101": 0x7e, "000001011100": 0x7f,
"000001011011": 0xb0, "000001011010": 0xb1, "000001011001": 0xb2, "000001011000": 0xb3,
"000001010111": 0xb4, "000001010110": 0xb5, "000001010101": 0xb6, "000001010100": 0xb7,
"000001010011": 0xb8, "000001010010": 0xb9, "000001010001": 0xba, "000001010000": 0xbb,
"000001001111": 0xbc, "000001001110": 0xbd, "000001001101": 0xbe, "000001001100": 0xbf,
"000001001011": 0xc0, "000001001010": 0xc1, "000001001001": 0xc2, "000001001000": 0xc3,
"000001000111": 0xc4, "000001000110": 0xc5, "000001000101": 0xc6, "000001000100": 0xc7,
"000001000011": 0xc8, "000001000010": 0xc9, "000001000001": 0xca, "000001000000": 0xcb,
"000000111111": 0xcc, "000000111110": 0xcd, "000000111101": 0xce, "000000111100": 0xcf,
"000000111011": 0xd0, "000000111010": 0xd1, "000000111001": 0xd2, "000000111000": 0xd3,
"000000110111": 0xd4, "000000110110": 0xd5, "000000110101": 0xd6, "000000110100": 0xd7,
"000000110011": 0xd8, "000000110010": 0xd9, "000000110001": 0xda, "000000110000": 0xdb,
"000000101111": 0xdc, "000000101110": 0xdd, "000000101101": 0xde, "000000101100": 0xdf,
"000000101011": 0xe1, "000000101010": 0xe5, "000000101001": 0xe9, "000000101000": 0xee,
"000000100111": 0xf2, "000000100110": 0xf3, "000000100101": 0xf4, "0000001001001": 0x1a,
"0000001001000": 0x80, "0000001000111": 0x81, "0000001000110": 0x82, "0000001000101": 0x83,
"0000001000100": 0x84, "0000001000011": 0x85, "0000001000010": 0x86, "0000001000001": 0x87,
"0000001000000": 0x88, "0000000111111": 0x89, "0000000111110": 0x8a, "0000000111101": 0x8b,
"0000000111100": 0x8c, "0000000111011": 0x8d, "0000000111010": 0x8e, "0000000111001": 0x8f,
"0000000111000": 0x90, "0000000110111": 0x91, "0000000110110": 0x92, "0000000110101": 0x93,
"0000000110100": 0x94, "0000000110011": 0x95, "0000000110010": 0x96, "0000000110001": 0x97,
"0000000110000": 0x98, "0000000101111": 0x99, "0000000101110": 0x9a, "0000000101101": 0x9b,
"0000000101100": 0x9c, "0000000101011": 0x9d, "0000000101010": 0x9e, "0000000101001": 0x9f,
"0000000101000": 0xa0, "0000000100111": 0xa1, "0000000100110": 0xa2, "0000000100101": 0xa3,
"0000000100100": 0xa4, "0000000100011": 0xa5, "0000000100010": 0xa6, "0000000100001": 0xa7,
"0000000100000": 0xa8, "0000000011111": 0xa9, "0000000011110": 0xaa, "0000000011101": 0xab,
"0000000011100": 0xac, "0000000011011": 0xad, "0000000011010": 0xae, "0000000011001": 0xaf,
"0000000011000": 0xe0, "0000000010111": 0xe2, "0000000010110": 0xe3, "0000000010101": 0xe4,
"0000000010100": 0xe6, "0000000010011": 0xe7, "0000000010010": 0xe8, "0000000010001": 0xea,
"0000000010000": 0xeb, "0000000001111": 0xec, "0000000001110": 0xed, "0000000001101": 0xef,
"0000000001100": 0xf0, "0000000001011": 0xf1, "0000000001010": 0xf5, "0000000001001": 0xf6,
"0000000001000": 0xf7, "0000000000111": 0xf8, "0000000000110": 0xf9, "0000000000101": 0xfa,
"0000000000100": 0xfb, "0000000000011": 0xfc, "0000000000010": 0xfd, "0000000000001": 0xfe,
"0000000000000": 0xff,
}
_DCL_LENGTHS = {
# Source: pwexplode by Sven Kochmann (https://github.com/Schallaven/pwexplode)
# These are the actual Shannon-Fano codes from the PKWARE DCL spec.
# Keys are LSB-first bit-pattern strings; values are copy lengths.
# Length 519 is the end-of-stream sentinel.
"11": 3, "101": 2, "100": 4, "011": 5, "0101": 6, "0100": 7, "0011": 8,
"00101": 9, "001001": 11, "001000": 10, "0001111": 15, "0001110": 13,
"0001101": 14, "0001100": 12, "00010111": 23, "00010110": 19, "00010101": 21,
"00010100": 17, "00010011": 22, "00010010": 18, "00010001": 20, "00010000": 16,
"0000111111": 39, "0000111110": 31, "0000111101": 35, "0000111100": 27,
"0000111011": 37, "0000111010": 29, "0000111001": 33, "0000111000": 25,
"0000110111": 38, "0000110110": 30, "0000110101": 34, "0000110100": 26,
"0000110011": 36, "0000110010": 28, "0000110001": 32, "0000110000": 24,
"00001011111": 71, "00001011110": 55, "00001011101": 63, "00001011100": 47,
"00001011011": 67, "00001011010": 51, "00001011001": 59, "00001011000": 43,
"00001010111": 69, "00001010110": 53, "00001010101": 61, "00001010100": 45,
"00001010011": 65, "00001010010": 49, "00001010001": 57, "00001010000": 41,
"00001001111": 70, "00001001110": 54, "00001001101": 62, "00001001100": 46,
"00001001011": 66, "00001001010": 50, "00001001001": 58, "00001001000": 42,
"00001000111": 68, "00001000110": 52, "00001000101": 60, "00001000100": 44,
"00001000011": 64, "00001000010": 48, "00001000001": 56, "00001000000": 40,
"000001111111": 135, "000001111110": 103, "000001111101": 119, "000001111100": 87,
"000001111011": 127, "000001111010": 95, "000001111001": 111, "000001111000": 79,
"000001110111": 131, "000001110110": 99, "000001110101": 115, "000001110100": 83,
"000001110011": 123, "000001110010": 91, "000001110001": 107, "000001110000": 75,
"000001101111": 133, "000001101110": 101, "000001101101": 117, "000001101100": 85,
"000001101011": 125, "000001101010": 93, "000001101001": 109, "000001101000": 77,
"000001100111": 129, "000001100110": 97, "000001100101": 113, "000001100100": 81,
"000001100011": 121, "000001100010": 89, "000001100001": 105, "000001100000": 73,
"000001011111": 134, "000001011110": 102, "000001011101": 118, "000001011100": 86,
"000001011011": 126, "000001011010": 94, "000001011001": 110, "000001011000": 78,
"000001010111": 130, "000001010110": 98, "000001010101": 114, "000001010100": 82,
"000001010011": 122, "000001010010": 90, "000001010001": 106, "000001010000": 74,
"000001001111": 132, "000001001110": 100, "000001001101": 116, "000001001100": 84,
"000001001011": 124, "000001001010": 92, "000001001001": 108, "000001001000": 76,
"000001000111": 128, "000001000110": 96, "000001000101": 112, "000001000100": 80,
"000001000011": 120, "000001000010": 88, "000001000001": 104, "000001000000": 72,
"00000011111111": 263, "00000011111110": 199, "00000011111101": 231,
"00000011111100": 167, "00000011111011": 247, "00000011111010": 183,
"00000011111001": 215, "00000011111000": 151, "00000011110111": 255,
"00000011110110": 191, "00000011110101": 223, "00000011110100": 159,
"00000011110011": 239, "00000011110010": 175, "00000011110001": 207,
"00000011110000": 143, "00000011101111": 259, "00000011101110": 195,
"00000011101101": 227, "00000011101100": 163, "00000011101011": 243,
"00000011101010": 179, "00000011101001": 211, "00000011101000": 147,
"00000011100111": 251, "00000011100110": 187, "00000011100101": 219,
"00000011100100": 155, "00000011100011": 235, "00000011100010": 171,
"00000011100001": 203, "00000011100000": 139, "00000011011111": 261,
"00000011011110": 197, "00000011011101": 229, "00000011011100": 165,
"00000011011011": 245, "00000011011010": 181, "00000011011001": 213,
"00000011011000": 149, "00000011010111": 253, "00000011010110": 189,
"00000011010101": 221, "00000011010100": 157, "00000011010011": 237,
"00000011010010": 173, "00000011010001": 205, "00000011010000": 141,
"00000011001111": 257, "00000011001110": 193, "00000011001101": 225,
"00000011001100": 161, "00000011001011": 241, "00000011001010": 177,
"00000011001001": 209, "00000011001000": 145, "00000011000111": 249,
"00000011000110": 185, "00000011000101": 217, "00000011000100": 153,
"00000011000011": 233, "00000011000010": 169, "00000011000001": 201,
"00000011000000": 137, "00000010111111": 262, "00000010111110": 198,
"00000010111101": 230, "00000010111100": 166, "00000010111011": 246,
"00000010111010": 182, "00000010111001": 214, "00000010111000": 150,
"00000010110111": 254, "00000010110110": 190, "00000010110101": 222,
"00000010110100": 158, "00000010110011": 238, "00000010110010": 174,
"00000010110001": 206, "00000010110000": 142, "00000010101111": 258,
"00000010101110": 194, "00000010101101": 226, "00000010101100": 162,
"00000010101011": 242, "00000010101010": 178, "00000010101001": 210,
"00000010101000": 146, "00000010100111": 250, "00000010100110": 186,
"00000010100101": 218, "00000010100100": 154, "00000010100011": 234,
"00000010100010": 170, "00000010100001": 202, "00000010100000": 138,
"00000010011111": 260, "00000010011110": 196, "00000010011101": 228,
"00000010011100": 164, "00000010011011": 244, "00000010011010": 180,
"00000010011001": 212, "00000010011000": 148, "00000010010111": 252,
"00000010010110": 188, "00000010010101": 220, "00000010010100": 156,
"00000010010011": 236, "00000010010010": 172, "00000010010001": 204,
"00000010010000": 140, "00000010001111": 256, "00000010001110": 192,
"00000010001101": 224, "00000010001100": 160, "00000010001011": 240,
"00000010001010": 176, "00000010001001": 208, "00000010001000": 144,
"00000010000111": 248, "00000010000110": 184, "00000010000101": 216,
"00000010000100": 152, "00000010000011": 232, "00000010000010": 168,
"00000010000001": 200, "00000010000000": 136, "000000011111111": 519,
"000000011111110": 391, "000000011111101": 455, "000000011111100": 327,
"000000011111011": 487, "000000011111010": 359, "000000011111001": 423,
"000000011111000": 295, "000000011110111": 503, "000000011110110": 375,
"000000011110101": 439, "000000011110100": 311, "000000011110011": 471,
"000000011110010": 343, "000000011110001": 407, "000000011110000": 279,
"000000011101111": 511, "000000011101110": 383, "000000011101101": 447,
"000000011101100": 319, "000000011101011": 479, "000000011101010": 351,
"000000011101001": 415, "000000011101000": 287, "000000011100111": 495,
"000000011100110": 367, "000000011100101": 431, "000000011100100": 303,
"000000011100011": 463, "000000011100010": 335, "000000011100001": 399,
"000000011100000": 271, "000000011011111": 515, "000000011011110": 387,
"000000011011101": 451, "000000011011100": 323, "000000011011011": 483,
"000000011011010": 355, "000000011011001": 419, "000000011011000": 291,
"000000011010111": 499, "000000011010110": 371, "000000011010101": 435,
"000000011010100": 307, "000000011010011": 467, "000000011010010": 339,
"000000011010001": 403, "000000011010000": 275, "000000011001111": 507,
"000000011001110": 379, "000000011001101": 443, "000000011001100": 315,
"000000011001011": 475, "000000011001010": 347, "000000011001001": 411,
"000000011001000": 283, "000000011000111": 491, "000000011000110": 363,
"000000011000101": 427, "000000011000100": 299, "000000011000011": 459,
"000000011000010": 331, "000000011000001": 395, "000000011000000": 267,
"000000010111111": 517, "000000010111110": 389, "000000010111101": 453,
"000000010111100": 325, "000000010111011": 485, "000000010111010": 357,
"000000010111001": 421, "000000010111000": 293, "000000010110111": 501,
"000000010110110": 373, "000000010110101": 437, "000000010110100": 309,
"000000010110011": 469, "000000010110010": 341, "000000010110001": 405,
"000000010110000": 277, "000000010101111": 509, "000000010101110": 381,
"000000010101101": 445, "000000010101100": 317, "000000010101011": 477,
"000000010101010": 349, "000000010101001": 413, "000000010101000": 285,
"000000010100111": 493, "000000010100110": 365, "000000010100101": 429,
"000000010100100": 301, "000000010100011": 461, "000000010100010": 333,
"000000010100001": 397, "000000010100000": 269, "000000010011111": 513,
"000000010011110": 385, "000000010011101": 449, "000000010011100": 321,
"000000010011011": 481, "000000010011010": 353, "000000010011001": 417,
"000000010011000": 289, "000000010010111": 497, "000000010010110": 369,
"000000010010101": 433, "000000010010100": 305, "000000010010011": 465,
"000000010010010": 337, "000000010010001": 401, "000000010010000": 273,
"000000010001111": 505, "000000010001110": 377, "000000010001101": 441,
"000000010001100": 313, "000000010001011": 473, "000000010001010": 345,
"000000010001001": 409, "000000010001000": 281, "000000010000111": 489,
"000000010000110": 361, "000000010000101": 425, "000000010000100": 297,
"000000010000011": 457, "000000010000010": 329, "000000010000001": 393,
"000000010000000": 265, "000000001111111": 518, "000000001111110": 390,
"000000001111101": 454, "000000001111100": 326, "000000001111011": 486,
"000000001111010": 358, "000000001111001": 422, "000000001111000": 294,
"000000001110111": 502, "000000001110110": 374, "000000001110101": 438,
"000000001110100": 310, "000000001110011": 470, "000000001110010": 342,
"000000001110001": 406, "000000001110000": 278, "000000001101111": 510,
"000000001101110": 382, "000000001101101": 446, "000000001101100": 318,
"000000001101011": 478, "000000001101010": 350, "000000001101001": 414,
"000000001101000": 286, "000000001100111": 494, "000000001100110": 366,
"000000001100101": 430, "000000001100100": 302, "000000001100011": 462,
"000000001100010": 334, "000000001100001": 398, "000000001100000": 270,
"000000001011111": 514, "000000001011110": 386, "000000001011101": 450,
"000000001011100": 322, "000000001011011": 482, "000000001011010": 354,
"000000001011001": 418, "000000001011000": 290, "000000001010111": 498,
"000000001010110": 370, "000000001010101": 434, "000000001010100": 306,
"000000001010011": 466, "000000001010010": 338, "000000001010001": 402,
"000000001010000": 274, "000000001001111": 506, "000000001001110": 378,
"000000001001101": 442, "000000001001100": 314, "000000001001011": 474,
"000000001001010": 346, "000000001001001": 410, "000000001001000": 282,
"000000001000111": 490, "000000001000110": 362, "000000001000101": 426,
"000000001000100": 298, "000000001000011": 458, "000000001000010": 330,
"000000001000001": 394, "000000001000000": 266, "000000000111111": 516,
"000000000111110": 388, "000000000111101": 452, "000000000111100": 324,
"000000000111011": 484, "000000000111010": 356, "000000000111001": 420,
"000000000111000": 292, "000000000110111": 500, "000000000110110": 372,
"000000000110101": 436, "000000000110100": 308, "000000000110011": 468,
"000000000110010": 340, "000000000110001": 404, "000000000110000": 276,
"000000000101111": 508, "000000000101110": 380, "000000000101101": 444,
"000000000101100": 316, "000000000101011": 476, "000000000101010": 348,
"000000000101001": 412, "000000000101000": 284, "000000000100111": 492,
"000000000100110": 364, "000000000100101": 428, "000000000100100": 300,
"000000000100011": 460, "000000000100010": 332, "000000000100001": 396,
"000000000100000": 268, "000000000011111": 512, "000000000011110": 384,
"000000000011101": 448, "000000000011100": 320, "000000000011011": 480,
"000000000011010": 352, "000000000011001": 416, "000000000011000": 288,
"000000000010111": 496, "000000000010110": 368, "000000000010101": 432,
"000000000010100": 304, "000000000010011": 464, "000000000010010": 336,
"000000000010001": 400, "000000000010000": 272, "000000000001111": 504,
"000000000001110": 376, "000000000001101": 440, "000000000001100": 312,
"000000000001011": 472, "000000000001010": 344, "000000000001001": 408,
"000000000001000": 280, "000000000000111": 488, "000000000000110": 360,
"000000000000101": 424, "000000000000100": 296, "000000000000011": 456,
"000000000000010": 328, "000000000000001": 392, "000000000000000": 264,
}
_DCL_OFFSETS = {
"11": 0x00, "1011": 0x01, "1010": 0x02, "10011": 0x03, "10010": 0x04,
"10001": 0x05, "10000": 0x06, "011111": 0x07, "011110": 0x08, "011101": 0x09,
"011100": 0x0a, "011011": 0x0b, "011010": 0x0c, "011001": 0x0d, "011000": 0x0e,
"010111": 0x0f, "010110": 0x10, "010101": 0x11, "010100": 0x12, "010011": 0x13,
"010010": 0x14, "010001": 0x15, "0100001": 0x16, "0100000": 0x17, "0011111": 0x18,
"0011110": 0x19, "0011101": 0x1a, "0011100": 0x1b, "0011011": 0x1c, "0011010": 0x1d,
"0011001": 0x1e, "0011000": 0x1f, "0010111": 0x20, "0010110": 0x21, "0010101": 0x22,
"0010100": 0x23, "0010011": 0x24, "0010010": 0x25, "0010001": 0x26, "0010000": 0x27,
"0001111": 0x28, "0001110": 0x29, "0001101": 0x2a, "0001100": 0x2b, "0001011": 0x2c,
"0001010": 0x2d, "0001001": 0x2e, "0001000": 0x2f, "00001111": 0x30, "00001110": 0x31,
"00001101": 0x32, "00001100": 0x33, "00001011": 0x34, "00001010": 0x35, "00001001": 0x36,
"00001000": 0x37, "00000111": 0x38, "00000110": 0x39, "00000101": 0x3a, "00000100": 0x3b,
"00000011": 0x3c, "00000010": 0x3d, "00000001": 0x3e, "00000000": 0x3f,
}
def dcl_explode(data):
"""Decompress a PKWARE DCL 'blast' byte string. Returns decompressed bytes.
Header (2 bytes):
data[0]: literal mode — 0=raw 8-bit, 1=Shannon-Fano coded
data[1]: dict size — 4=1KB, 5=2KB, 6=4KB sliding window
Bitstream is LSB-first within each byte. After the 2-byte header, tokens
alternate between:
bit=0 → literal (8 raw bits, or variable-width coded bits)
bit=1 → back-reference: length (from _DCL_LENGTHS) + distance prefix
(from _DCL_OFFSETS) + low bits determined by dict size
Length 519 is the end-of-stream sentinel.
"""
coded = data[0] # 0 = raw literals, 1 = coded literals
dictbits = data[1] # low-bit count for distance = 4, 5, or 6
if dictbits not in (4, 5, 6):
raise ValueError("DCL: unsupported dict size byte %d" % dictbits)
# Expand all bytes into an LSB-first bit string, skip the 2-byte header.
bits = "".join("{:08b}".format(b)[::-1] for b in data)[16:]
blen = len(bits)
out = bytearray()
pos = 0
while pos < blen:
kind = bits[pos]; pos += 1
if kind == '0':
# ── Literal ──────────────────────────────────────────────────────
if coded:
# Grow window 1 bit at a time until we match the SF tree.
buf = bits[pos : pos + 4]
while len(buf) <= 13:
if buf in _DCL_LITERALS:
out.append(_DCL_LITERALS[buf])
pos += len(buf)
break
buf = bits[pos : pos + len(buf) + 1]
else:
raise RuntimeError("DCL: coded literal not found")
else:
# Raw: next 8 bits are the byte value (LSB first).
out.append(int(bits[pos : pos + 8][::-1], 2))
pos += 8
else:
# ── Back-reference ────────────────────────────────────────────────
# Step 1: decode copy length.
buf = bits[pos : pos + 2]
while len(buf) <= 15:
if buf in _DCL_LENGTHS:
length = _DCL_LENGTHS[buf]; pos += len(buf)
break
buf = bits[pos : pos + len(buf) + 1]
else:
raise RuntimeError("DCL: length code not found")
if length == 519:
break # end-of-stream sentinel
# Step 2: decode distance prefix (high bits).
buf = bits[pos : pos + 2]
while len(buf) <= 8:
if buf in _DCL_OFFSETS:
dist_hi = _DCL_OFFSETS[buf]; pos += len(buf)
break
buf = bits[pos : pos + len(buf) + 1]
else:
raise RuntimeError("DCL: offset code not found")
# Step 3: read low bits of distance.
# length==2 always uses 2 low bits; longer copies use dictbits.
low_n = 2 if length == 2 else dictbits
low = int(bits[pos : pos + low_n][::-1], 2)
pos += low_n
dist = (dist_hi << low_n) | low
# Step 4: copy `length` bytes from output[-(dist+1)].
# Byte-by-byte to handle overlapping RLE-style runs correctly.
src = len(out) - dist - 1
for _ in range(length):
out.append(out[src]); src += 1
return bytes(out)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
extract_mcd1.py — Extract all images from Muppets Inside (1996) MCD1.CD
========================================================================
No dependencies beyond the Python standard library.
Usage:
python extract_mcd1.py [MCD1.CD] [pn10ni11.dll] [output_dir]
All three arguments are optional and default to the same directory as
this script:
MCD1.CD — copy from the root of the game disc
pn10ni11.dll — copy from the SYSTEM\ folder of the installed game
output_dir — same directory as this script
Examples:
python extract_mcd1.py
python extract_mcd1.py /mnt/cdrom/MCD1.CD
python extract_mcd1.py MCD1.CD pn10ni11.dll ./out
Where to find the required files:
MCD1.CD lives in the root of the Muppets Inside CD-ROM.
It is the main asset container for all game artwork.
pn10ni11.dll is NOT on the disc. It is installed by the game's
setup program into the SYSTEM\ subfolder of the install
directory (e.g. "C:\Program Files\Muppets Inside\SYSTEM\").
Copy it next to this script before running.
Output:
<output_dir>/
bmp/ — small 8-bpp BMP UI sprites, written as original .bmp files
pic/ — JPEG "PIC" images (title screens, panel art, game images),
written as .jpg files with the missing DQT marker injected;
no re-encoding — pixel data is bit-for-bit identical to the
original compressed stream
========================================================================
BACKGROUND — WHY THIS SCRIPT EXISTS
========================================================================
Muppets Inside ships with a proprietary JPEG codec called Pegasus Imaging
(pn10ni11.dll, ~180 KB, compiled with WATCOM C++). The game stores all of
its large artwork as JFIF JPEG files inside the container MCD1.CD, but
with one crucial piece stripped out: the DQT marker (FF DB), which holds
the quantization tables that the JPEG decoder needs in order to reconstruct
the image correctly.
The Pegasus codec supplies those tables itself from its own hardcoded data,
so the on-disc files are intentionally incomplete — they cannot be opened
by any standard viewer (Windows Photo Viewer, GIMP, Photoshop, etc.) as-is.
This script reverse-engineers that process:
1. Reads the standard libjpeg base quantization tables out of pn10ni11.dll.
2. Derives the correct quality factor from each image's own APP1 header
using the formula Q = 100 - 6 * (param2 - param1).
3. Injects a well-formed DQT marker into each stripped JPEG and writes it
as a standard .jpg file. No re-encoding — the compressed pixel data
is copied verbatim.
========================================================================
MCD1.CD FORMAT
========================================================================
MCD1.CD is a Starwave RIFF container:
Offset 0x0000 : RIFF header (5004 bytes)
"RIFF" <size> "STWV"
RSRC chunks — HEAD / DPND / DATA sub-chunks
Maps numeric resource IDs ↔ (offset, size) pairs
in the raw data stream below.
NOTE: IDs are opaque integers; filenames live only
in MUPPET.ASP (FDIR block), which stores an external
flag (FF FF FF FF) instead of literal offsets, so
the ID↔filename mapping is not resolved here.
Offset 0x138C : Raw resource data stream (~29.2 MB)
BMP and JPEG files packed back-to-back, no padding.
We ignore the RIFF header entirely and scan the raw stream directly for
magic bytes:
FF D8 FF — JPEG SOI
42 4D — BMP signature ("BM")
This is robust: every image is found regardless of the ID mapping.
------------------------------------------------------------------------
BMP files (indices 0–46 in the stream, starting at 0x138C)
------------------------------------------------------------------------
46 small 8-bpp palettised Windows BMP files used as UI sprites and
buttons. Sizes range from 24×19 to ~90×70 pixels. A tiny 1×1 sentinel
BMP sits at 0x138C itself.
Each BMP's size is given in bytes 2–5 of its header (little-endian uint32),
so the extent is fully self-describing.
------------------------------------------------------------------------
JPEG "PIC" files
------------------------------------------------------------------------
The game's artwork. Every PIC file has this structure:
FF D8 SOI
FF E0 00 10 APP0 — JFIF marker
4A 46 49 46 00 "JFIF\0"
02 01 Version 2.1 (non-standard; treat as 1.1)
02 Density units: pixels/cm
00 1C 00 1C Xdensity=28, Ydensity=28
00 00 No thumbnail
FF E1 00 0A APP1 — Starwave "PIC" marker (10 payload bytes)
50 49 43 00 "PIC\0"
01 version = 1
?? param1 \
?? param2 > quality hint (see formula below)
01 flags = 1
FF C0 ... SOF0 — baseline DCT
3 components, 4:2:0 YCbCr subsampling
FF C4 ... DHT — Huffman tables (present and valid)
FF DA ... SOS — scan header + entropy-coded data
FF D9 EOI
Missing: FF DB DQT. We inject it (see below).
------------------------------------------------------------------------
Quantization tables in pn10ni11.dll
------------------------------------------------------------------------
The standard libjpeg base quantization tables (Q=50 reference tables)
are stored in zigzag order inside pn10ni11.dll at:
Luma (table 0): offset 0x27540, 64 bytes
Chroma (table 1): offset 0x27580, 64 bytes
These are the ANNEX K tables from the JPEG spec, identical to those
used by Independent JPEG Group's libjpeg. Scaled to quality Q using:
scale = 5000 // Q if Q < 50
scale = 200 - 2*Q if Q >= 50
entry = clamp(1, 255, (base_value * scale + 50) // 100)
Quality is NOT hardcoded — it is derived from the APP1 param bytes:
Q = 100 - 6 * (param2 - param1)
Verified against two distinct (param1, param2) pairs found in MCD1.CD:
param1=0x06, param2=0x08 -> 100 - 6*(8-6) = 88 (82 images)
param1=0x0C, param2=0x0F -> 100 - 6*(15-12) = 82 (1 image)
Both values confirmed correct by visual inspection.
Example: at Q=88, scale = 200 - 176 = 24.
The DQT marker injected before SOF0:
FF DB marker
00 84 length = 2 + 1 + 64 + 1 + 64 = 132
00 <64 luma bytes> table-id=0, luma entries (zigzag order)
01 <64 chroma bytes> table-id=1, chroma entries (zigzag order)
========================================================================
KNOWN IMAGE INVENTORY
========================================================================
The script finds and extracts all images automatically; this table is
provided for reference only.
Offset Raw size Image size Notes
0x660B6 63,419 B 640×480 Title screen candidate 1
0x75CB3 40,154 B 432×173 Strip / banner
0x7FDCF 30,727 B 432×173 Strip / banner
0x87A18 36,405 B 432×173 Strip / banner
0x90C8F 33,003 B 432×173 Strip / banner
0x991BC 32,336 B 432×173 Strip / banner
0xA144E 31,774 B 432×173 Strip / banner
0xA94AE 32,691 B 432×173 Strip / banner
0xB18A3 31,121 B 432×173 Strip / banner
0xB9676 37,301 B 432×173 Strip / banner
0xC2C6D 33,304 B 432×173 Strip / banner
0xCB64D 67,372 B 640×480 Title screen candidate 2
0x23E3CA 149,122 B 640×480 Large title screen
0x325263 80,511 B 640×480 Title screen
0x3662E6 79,399 B 640×480 Title screen
...plus additional images throughout the file
Named PIC resources (from MUPPET.ASP FDIR block — ID↔offset not mapped):
D_SwedishChefTitle.pic D_SuperGonzoTitle.pic
D_MuppetSquaresTitle.pic D_FozzieTitle.pic
D_BeakersWorldTitle.pic D_FinaleTitle.pic
D_G6_01.pic … D_G6_10.pic
D_ChangeGears_001–003.pic D_FO_Open.pic
LPanel.pic RPanel.pic D_Roadmap.pic
D_Session01C.pic D_Session03.pic D_Session06B.pic D_Session10.pic
D_Fozzie2.pic D_GonzoPiggy.pic D_StatlerWaldorf.pic
========================================================================
"""
import os
import struct
import sys
# ── Default paths ──────────────────────────────────────────────────────────────
#
# Both input files are expected next to this script by default.
# MCD1.CD comes from the game disc; pn10ni11.dll comes from the installed game.
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_DEFAULT_MCD1 = os.path.join(_SCRIPT_DIR, 'MCD1.CD')
_DEFAULT_DLL = os.path.join(_SCRIPT_DIR, 'pn10ni11.dll')
_DEFAULT_OUTDIR = _SCRIPT_DIR
# Offset in MCD1.CD where the raw resource data stream starts (immediately
# after the 5004-byte RIFF header).
_DATA_START = 0x138C
# Maximum plausible JPEG size (5 MB). Any apparent JPEG larger than this
# is treated as a false positive and skipped.
_MAX_JPEG_SIZE = 5_000_000
# ── Quantization table helpers ─────────────────────────────────────────────────
def _load_qtables(dll_bytes):
"""Read the Q=50 base quantization tables from pn10ni11.dll.
The tables are stored in JPEG zigzag order at fixed offsets:
Luma (table 0): 0x27540
Chroma (table 1): 0x27580
Returns (luma_zigzag, chroma_zigzag) as lists of 64 ints.
"""
if len(dll_bytes) < 0x27580 + 64:
raise ValueError(
"pn10ni11.dll is too small — expected at least 0x275C0 bytes, "
f"got {len(dll_bytes)}. Wrong file?"
)
luma = list(dll_bytes[0x27540 : 0x27540 + 64])
chroma = list(dll_bytes[0x27580 : 0x27580 + 64])
return luma, chroma
def _make_dqt_marker(quality, luma_zigzag, chroma_zigzag):
"""Build a JPEG DQT marker for the given quality factor.
Uses the standard libjpeg scaling formula:
scale = 5000 // quality if quality < 50
= 200 - 2 * quality if quality >= 50
entry = clamp(1, 255, (base * scale + 50) // 100)
The marker contains both table 0 (luma) and table 1 (chroma), each
preceded by their one-byte table-id, giving a total payload of
2 + 65 + 65 = 132 bytes (including the length field itself).
"""
scale = 5000 // quality if quality < 50 else 200 - 2 * quality
def _scale(table):
return bytes(max(1, min(255, (v * scale + 50) // 100)) for v in table)
payload = bytes([0]) + _scale(luma_zigzag) + bytes([1]) + _scale(chroma_zigzag)
length = len(payload) + 2 # +2 for the length field itself
return b'\xff\xdb' + length.to_bytes(2, 'big') + payload
def _quality_from_pic_header(jpeg_bytes):
"""Extract the JPEG quality factor from the Starwave APP1 'PIC' header.
APP1 layout (offsets from SOI):
+20 FF E1 APP1 marker
+22 00 0A length (10, includes self)
+24 50 49 43 00 "PIC\\0"
+28 01 version (always 1)
+29 param1 \\
+30 param2 > quality hint
+31 01 flags
Formula derived empirically from two confirmed (param1, param2) pairs:
param1=0x06, param2=0x08 -> Q=88
param1=0x0C, param2=0x0F -> Q=82
Q = 100 - 6 * (param2 - param1)
"""
param1, param2 = jpeg_bytes[29], jpeg_bytes[30]
return 100 - 6 * (param2 - param1)
def _inject_dqt(jpeg_bytes, luma_zigzag, chroma_zigzag):
"""Inject a DQT marker into jpeg_bytes immediately before SOF0 (FF C0).
Quality is read from the image's own APP1 PIC header via
_quality_from_pic_header(), so every image self-describes its tables.
The DQT must appear before SOF0 so the decoder knows the quantization
step before it processes the frame header. All other markers (APP0,
APP1, DHT, etc.) are left untouched.
Raises ValueError if no SOF0 is found.
"""
quality = _quality_from_pic_header(jpeg_bytes)
dqt = _make_dqt_marker(quality, luma_zigzag, chroma_zigzag)
sof0 = jpeg_bytes.find(b'\xff\xc0')
if sof0 < 0:
raise ValueError("No SOF0 marker (FF C0) found in JPEG data")
return jpeg_bytes[:sof0] + dqt + jpeg_bytes[sof0:], quality
# ── MCD1.CD scanner ────────────────────────────────────────────────────────────
def _scan_images(data, start=_DATA_START):
"""Scan the raw data stream of MCD1.CD for BMP and JPEG files.
Strategy:
- BMP: magic bytes 42 4D ("BM") at current position. The file size
is stored as a little-endian uint32 at bytes 2–5 of the BMP header,
so the extent is self-describing.
- JPEG: magic bytes FF D8 FF at current position. Scan forward for
the EOI marker (FF D9) to find the end. Cap at _MAX_JPEG_SIZE to
avoid false positives.
Yields (kind, offset, raw_bytes) where kind is 'bmp' or 'jpeg'.
"""
pos = start
end = len(data)
while pos < end - 4:
# ── BMP ──────────────────────────────────────────────────────────────
if data[pos:pos+2] == b'BM':
bmp_size = struct.unpack_from('<I', data, pos + 2)[0]
if 54 <= bmp_size <= 10_000_000 and pos + bmp_size <= end:
yield 'bmp', pos, data[pos : pos + bmp_size]
pos += bmp_size
continue
# ── JPEG ─────────────────────────────────────────────────────────────
# Only accept Starwave "PIC" format JPEGs. These are identified by
# the APP1 marker (FF E1) immediately after the APP0 block, with a
# payload starting with "PIC\0". The APP0 block is always 20 bytes
# (FF E0 00 10 + 16-byte JFIF header), so APP1 starts at offset 20
# from the SOI and "PIC\0" is at offset 24.
#
# Vanilla JPEG fragments, EXIF thumbnails, and other JFIF files that
# happen to start with FF D8 FF do not have this marker and are
# skipped. This eliminates hundreds of false positives.
if data[pos:pos+3] == b'\xff\xd8\xff':
if data[pos+24:pos+28] == b'PIC\x00':
eoi = data.find(b'\xff\xd9', pos + 2)
if 0 < eoi - pos < _MAX_JPEG_SIZE and eoi + 2 <= end:
yield 'jpeg', pos, data[pos : eoi + 2]
pos = eoi + 2
continue
pos += 1
# ── Extraction ─────────────────────────────────────────────────────────────────
def extract(mcd1_path=_DEFAULT_MCD1, dll_path=_DEFAULT_DLL, out_dir=_DEFAULT_OUTDIR):
"""Extract all images from MCD1.CD.
BMP files are written out as-is (they are already valid Windows BMP files).
JPEG "PIC" files have the missing DQT marker injected and are written as
.jpg files. No re-encoding takes place — the compressed pixel data is
copied verbatim from MCD1.CD.
Parameters
----------
mcd1_path : str
Path to MCD1.CD (the Starwave RIFF container).
dll_path : str
Path to pn10ni11.dll (the Pegasus Imaging JPEG codec).
out_dir : str
Root output directory. Sub-directories 'bmp/' and 'pic/' are
created automatically.
"""
# ── Validate inputs ───────────────────────────────────────────────────────
for path, label, hint in [
(mcd1_path, 'MCD1.CD', 'copy it from the root of the game disc'),
(dll_path, 'pn10ni11.dll', 'copy it from SYSTEM\\ in the installed game'),
]:
if not os.path.isfile(path):
sys.exit(f"ERROR: {label} not found at: {path}\n"
f" Hint: {hint}\n"
f"Usage: python extract_mcd1.py [MCD1.CD] [pn10ni11.dll] [output_dir]")
# ── Load files ────────────────────────────────────────────────────────────
print(f"Reading {mcd1_path} …")
with open(mcd1_path, 'rb') as fh:
mcd1 = fh.read()
print(f" {len(mcd1):,} bytes")
print(f"Reading {dll_path} …")
with open(dll_path, 'rb') as fh:
dll = fh.read()
# ── Load quantization base tables from the codec ──────────────────────────
luma_zigzag, chroma_zigzag = _load_qtables(dll)
print(f" Q-table base tables loaded (luma @ 0x27540, chroma @ 0x27580)\n")
# ── Prepare output directories ────────────────────────────────────────────
bmp_dir = os.path.join(out_dir, 'bmp')
pic_dir = os.path.join(out_dir, 'pic')
os.makedirs(bmp_dir, exist_ok=True)
os.makedirs(pic_dir, exist_ok=True)
# ── Scan and extract ──────────────────────────────────────────────────────
bmp_count = jpeg_count = err_count = 0
for kind, offset, raw in _scan_images(mcd1):
if kind == 'bmp':
bmp_count += 1
fname = f'bmp_{bmp_count:03d}_@{offset:07X}.bmp'
out_path = os.path.join(bmp_dir, fname)
try:
width = struct.unpack_from('<i', raw, 18)[0]
height = abs(struct.unpack_from('<i', raw, 22)[0])
with open(out_path, 'wb') as fh:
fh.write(raw)
print(f" BMP [{bmp_count:3d}] 0x{offset:07X} "
f"{width}x{height} -> {out_path}")
except Exception as exc:
err_count += 1
print(f" BMP [{bmp_count:3d}] 0x{offset:07X} FAILED: {exc}")
elif kind == 'jpeg':
jpeg_count += 1
try:
patched, quality = _inject_dqt(raw, luma_zigzag, chroma_zigzag)
# Read dimensions directly from the SOF0 marker rather than
# decoding the image. SOF0 (FF C0) payload: 1-byte precision,
# then 2-byte height, 2-byte width (all big-endian).
sof0 = patched.find(b'\xff\xc0')
height, width = struct.unpack_from('>HH', patched, sof0 + 5)
fname = f'pic_{jpeg_count:03d}_@{offset:07X}_{width}x{height}.jpg'
out_path = os.path.join(pic_dir, fname)
with open(out_path, 'wb') as fh:
fh.write(patched)
print(f" PIC [{jpeg_count:3d}] 0x{offset:07X} "
f"{width}x{height} Q={quality} {len(raw):,} B -> {out_path}")
except Exception as exc:
err_count += 1
print(f" PIC [{jpeg_count:3d}] 0x{offset:07X} "
f"{len(raw):,} B FAILED: {exc}")
# ── Summary ───────────────────────────────────────────────────────────────
print()
print(f"Done.")
print(f" BMP images : {bmp_count}")
print(f" PIC images : {jpeg_count}")
print(f" Errors : {err_count}")
print(f" Output : {os.path.abspath(out_dir)}")
# ── Entry point ────────────────────────────────────────────────────────────────
if __name__ == '__main__':
args = sys.argv[1:]
extract(
mcd1_path = args[0] if len(args) > 0 else _DEFAULT_MCD1,
dll_path = args[1] if len(args) > 1 else _DEFAULT_DLL,
out_dir = args[2] if len(args) > 2 else _DEFAULT_OUTDIR,
)

Muppets Inside (1996) — The Swedish Chef's Kitchens of Doom

Level Format Technical Specification

  • Game: Muppets Inside, Starwave, 1996
  • Minigame: The Swedish Chef's Kitchens of Doom
  • Engine: I3D Tool Kit 2.1, copyright 1993–94 Jim O'Keane, probably behind https://sites.google.com/view/disintegrator/home
  • Authored by: Ben McLean, reverse engineering of installed game files

Citation Key

All facts in this document are sourced from one of:

Tag Source
[hex] Direct hex dump via xxd of the named file
[strings] Output of printable-string extraction from bork.exe
[blk:01] Plain-text content of LEVELS/LEVEL01.BLK (and similarly for other levels)
[decode] Result of running the PCX RLE decoder and analyzing the 256×256 grid
[pcxspec] Standard PCX file format specification (widely documented, e.g. ZSoft PCX Technical Reference)
[user] Playing the game

1. Engine

[strings] The string I3D Tool Kit 2.1 (c)1993-94 Jim O'Keane was found literally in bork.exe (the minigame executable). The string ?I3D 2.1 Demo also appears in the same binary. The .BLK files begin with the header line I3D DEMO block database file version: 2.0 [blk:01].

This is a Wolfenstein-3D–style raycasting engine with a grid-based world. Geometry is defined as axis-aligned cubes (blocks). The player navigates a 2D tile grid that is rendered in first-person 3D.


2. File Organization

Muppets Inside/chef/
├── bork.exe                    ← minigame executable
├── LEVELS/
│   ├── LEVEL01.PCX             ← map grid for level 1
│   ├── LEVEL01.BLK             ← block/entity definitions for level 1
│   ├── LEVEL02.PCX
│   ├── LEVEL02.BLK
│   ...
│   ├── LEVEL10.PCX
│   └── LEVEL10.BLK
├── KITCHENS/                   ← wall/floor/ceiling textures
│   ├── FIFTS_00.PCX ... FIFTS_15.PCX   (Fifties theme)
│   ├── MEDIV_00.PCX ... MEDIV_16.PCX   (Medieval theme)
│   ├── TECHY_00.PCX ... TECHY_13.PCX   (Techy theme)
│   └── WOODY_00.PCX ... WOODY_14.PCX   (Woody theme)
├── FOODS/                      ← enemy sprite sheets
├── UTENSILS/                   ← item sprite sheets
└── MISC/                       ← UI/HUD images

[strings] The file naming pattern LEVELS\%s%02d appears in bork.exe error messages, confirming the level numbering scheme. The range is 1–10: Error - game level must be between 1 and 10.

Each level is exactly one .PCX file paired with one .BLK file, both sharing the same base name. They must be loaded together; neither is independently meaningful.


3. The PCX Map File (.PCX)

3.1 It Is a Standard PCX File

[hex:LEVEL01.PCX] The file begins with bytes 0A 05 01 08 — the standard ZSoft PCX magic. Despite the extension being used unconventionally (the file encodes a map grid, not a photographic image), it is a fully valid, standards-compliant PCX file that any PCX decoder will correctly expand.

3.2 PCX Header (128 bytes)

All multi-byte integers are little-endian. [hex:LEVEL01.PCX] [pcxspec]

Offset Size Value (all levels) Meaning
0 1 0x0A PCX manufacturer magic
1 1 0x05 Version 5.0 (supports VGA 256-color palette)
2 1 0x01 Encoding: 1 = Run-Length Encoded (RLE)
3 1 0x08 Bits per pixel: 8
4–5 2 0x0000 Xmin = 0
6–7 2 0x0000 Ymin = 0
8–9 2 0x00FF = 255 Xmax = 255
10–11 2 0x00FF = 255 Ymax = 255
12–13 2 0x0096 = 150 Horizontal DPI (not semantically meaningful here)
14–15 2 0x0096 = 150 Vertical DPI (not semantically meaningful here)
16–63 48 varies 16-color EGA palette (not used; VGA palette is at file end)
64 1 0x00 Reserved, always 0
65 1 0x01 Color planes = 1
66–67 2 0x0100 = 256 Bytes per scan line
68–69 2 0x0001 = 1 Palette type: 1 = color
70–71 2 0x0000 Horizontal screen size (unused)
72–73 2 0x0000 Vertical screen size (unused)
74–127 54 0x00… Filler (all zeroes)

[decode] All 10 level PCX files have identical header field values. The map is always 256 columns × 256 rows, one byte per cell.

3.3 RLE-Compressed Pixel Data (bytes 128 to EOF−769)

[pcxspec] PCX RLE encoding: read one byte at a time starting at offset 128.

if (byte & 0xC0) == 0xC0:
	count = byte & 0x3F      # upper 2 bits set → run; count in lower 6 bits
	value = next_byte()      # the repeated value
	emit count copies of value
else:
	emit 1 copy of byte      # literal byte (top 2 bits both clear)

Expand until you have accumulated 256 × 256 = 65536 bytes. Lay them out row-major: the first 256 bytes are row 0 (Y=0), bytes 256–511 are row 1 (Y=1), etc.

[decode] Observed compressed sizes range from 17,694 bytes (level 1) to roughly 20,000 bytes across all 10 levels.

The decompressed result is the map grid: grid[y][x] is a block ID (0–255) at column x, row y.

3.4 VGA 256-Color Palette (last 769 bytes)

[decode] For all 10 level files, the byte at offset −769 (i.e., file_size − 769) is 0x0C — the standard PCX Version 5 VGA palette marker. [pcxspec] Following it are 768 bytes = 256 × 3 RGB triplets, 8 bits per channel (0–255). This palette defines the visual color of each block ID for the level editor; it is not needed for map parsing in a modern engine. [decode] The palette varies across levels (4 distinct palettes observed, corresponding to the 4 kitchen themes).


4. The Map Grid Layout (256 × 256)

[decode] The 256×256 cell grid is divided into three regions. Only the playable maze is needed for a game engine import.

  Column 0                    ~53        54                         255
Row 0   ┌──────────────────────┬─────────────────────────────────────┐
		│                      │                                     │
		│    PLAYABLE MAZE     │     LEVEL EDITOR TILE PALETTE       │
		│                      │    (block type examples in rooms)   │
		│   (actual game map)  │                                     │
~Row 127│                      │                                     │
		├──────────────────────┤                                     │
		│  TILE PREVIEW ROWS   │                                     │
		│  (palette, rows of   │                                     │
		│   solid walls with   │                                     │
		│   sample IDs inset)  │                                     │
Row 199 └──────────────────────┴─────────────────────────────────────┘
Row 200–255: all void (0xFF)

4.1 Playable Maze Region

[decode] The playable maze occupies the left portion of the grid (columns 0–~53) at a Y position determined by the player start in the BLK file. It is not always at row 0:

Level Player Start (x,y) Approximate Maze Row Range
01 (2, 2) rows 0–19
02 (2, 2) rows 0–15
03 (3, 33) rows 8–35
04 (18, 7) rows 0–28
05 (16, 18) rows 0–36
06 (3, 35) rows 0–42
07 (46, 3) rows 0–34
08 (37, 32) rows 0–33
09 (3, 3) rows 0–47
10 (2, 9) rows 0–28

[blk:01–10] Player start coordinates come from the Player start x and Player start y fields in the BLK file header. To extract only the maze, use a flood-fill from the player start position (see Section 7).

4.2 Level Editor Tile Palette (columns ~54–255)

[decode] Columns 54–255 contain the level editor's tile palette: every defined block type is displayed as a small rectangular room (walls on all sides, with the block ID of interest filling the interior). These cells are never reachable by the player's flood-fill and should be ignored entirely for game engine import. Block IDs in this region of every level file each appear exactly 21 times (a 3-column × 7-row room per block type).

4.3 Reserved/Palette Lower Rows (rows ~128–199, columns 0–53)

[decode] Below the playable maze and its surrounding empty space (cells filled with 0x00 = open floor), starting around row 128, the left columns contain another region used by the level editor: solid walls (id=1) forming a large block with small insets showing variant block IDs. The final rows of this region (rows 188–199 in level 1) contain a solid fill of the next level's primary wall block ID [decode] — seemingly a preview. This region is also not reachable by flood-fill from the player start and should be ignored for game import.

4.4 Special Cell Values

Block ID Meaning Source
0x00 (0) Open floor (passable, no geometry) [blk:01] block 0 { open block }shape = empty; set trans;
0xFF (255) Void / outside the map (never rendered) [decode] Fills the right palette area exterior and unused rows
All others Block or thing ID, defined in the paired .BLK file [blk:01–10]

5. The BLK Block Database File (.BLK)

5.1 File Encoding

[decode] Line endings are CRLF (0x0D 0x0A). The file begins with a bare 0x0D 0x0A before the version line (i.e., the first line is blank). All 10 BLK files exhibit this. Encoding is 7-bit ASCII.

5.2 Top-Level Structure

\r\n
I3D DEMO block database file version: 2.0\r\n
Background color index: N \r\n
Shadow color index    : N \r\n
Highlight color index : N \r\n
Floor color index     : N \r\n
Ceiling color index   : N \r\n
Player start x        :  N\r\n
Player start y        :  N\r\n
Player start heading  :  N \r\n
\r\n
{ comment block }\r\n
block N ( { optional inline comment }\r\n
	property = value;\r\n
	...\r\n
)\r\n
\r\n
thing N ( { optional inline comment }\r\n
	...\r\n
)\r\n
\r\n
action N ( { optional inline comment }\r\n
	...\r\n
)\r\n
\r\n
anim N ( { optional inline comment }\r\n
	...\r\n
)\r\n

[blk:01] The first non-blank line is always I3D DEMO block database file version: 2.0. Everything else is order-independent within each section.

5.3 Header Fields

[blk:01] Parsed from the header lines above the first block/thing definition:

Field Key string Type Notes
Background color index Background color index: integer Index into VGA palette (not needed for import)
Shadow color index Shadow color index : integer
Highlight color index Highlight color index : integer
Floor color index Floor color index : integer Default floor color for unspecified cells
Ceiling color index Ceiling color index : integer Default ceiling color
Player start X Player start x : integer Map column (0-based)
Player start Y Player start y : integer Map row (0-based)
Player start heading Player start heading : integer Spawn direction (see Section 6.2)

Parsing: split on :, strip whitespace, parse as integer. The value may have trailing whitespace.

5.4 Entry Types

[blk:01–10] Four entry types are defined. All use the same syntax:

keyword ID ( { optional comment }
	property = value;
	set flag;
	...
)
  • keyword is one of: block, thing, action, anim
  • ID is a decimal integer
  • The body is delimited by ( and ) (not braces — the braces are used only for comments)
  • Body lines are tab-indented
  • Property assignments end with ;
  • set flagname; sets a boolean flag on the entry
  • Comments use { curly braces } and may appear inline anywhere on a line; they do not nest

Important: [blk:01] The integer IDs are not globally unique across types. For example, block 1 (walls) and action 01 (cabbage action) both use ID 1 in LEVEL01.BLK. The engine uses type context to disambiguate. In the PCX map grid, only block and thing IDs appear as pixel values. When the same integer ID appears for both a block/thing and an action/anim, the block or thing definition is the one relevant to the map.

5.5 block Entry Properties

[blk:01–10] All properties observed across all 10 level files:

Property Example Value Meaning
shape empty, cube, horz Geometry type. empty = no geometry (floor tile). cube = solid cube. horz = horizontal half-wall (passable from N/S, solid from E/W — used for goal door markers).
set wall (flag) Cell is solid and blocks movement
set trans (flag) Cell is transparent / passable (no collision)
set hitable (flag) Cell can receive damage (used on thing entries)
n_wall kitchens\fifts_00 Texture for north face. Path is relative to chef/, backslash-separated, no extension — the file is a PCX.
e_wall kitchens\fifts_01 Texture for east face
s_wall kitchens\fifts_02 Texture for south face
w_wall kitchens\fifts_03 Texture for west face
ceil kitchens\fifts_06 Ceiling texture
floor kitchens\fifts_05 Floor texture
t_width 256 Texture width in pixels (typically 256)
t_height 256 Texture height in pixels (typically 256)
obstacle kitchens\fifts_07 (Commented out in all observed files; purpose unclear)

[blk:01] For blocks with shape = empty, the n_wall/e_wall/s_wall/w_wall properties are absent. Only ceil and floor are specified.

5.6 thing Entry Properties

[blk:01–10] Things are entities placed on the floor (enemies, pickups). They always have set trans (passable — the player can walk into the cell and trigger the entity).

Property Example Meaning
set trans (flag) Always set; thing is passable
set wall (flag) Sometimes set; implies the sprite blocks sight
set hitable (flag) Sometimes set
panel utensils\uten_wsk Sprite sheet PCX path (relative to chef/, no extension)
t_width 256 Sprite width hint
t_height 128 Sprite height hint
action 10 Links to an action entry by ID (defines enemy behavior)

5.7 action Entry Properties

[blk:01–10] Actions define enemy combat stats. These are linked from thing entries via the action property. Not directly relevant for geometry import.

Property Example Meaning
name carrot Display name
radius 0.92 Collision radius in cells
walk_anim 10 ID of walking animation (links to anim entry)
throw_anim 11 ID of attack animation
talk_anim 12 ID of idle/talk animation
die_anim 13 ID of death animation
dead_anim 14 ID of dead (corpse) animation
speed 75 Movement speed (0=still, 100=fast)
wisk 256 86 64 Damage thresholds for whisk weapon (3 integers)
egg_beater 256 86 64 Damage thresholds for egg-beater weapon
rolling_pin 256 128 86 Damage thresholds for rolling-pin weapon
food_processor 256 256 128 Damage thresholds for food-processor weapon
pastry_gun 256 256 256 Damage thresholds for pastry-gun weapon

[blk:01] Damage thresholds appear to be 3 integers representing thresholds for different difficulty settings. The comment { 4 hits required } in the file clarifies: 256 = requires the maximum hits (one hit = floor(255/threshold) damage).

5.8 anim Entry Properties

[blk:01] Animation sheet definitions, linked from action entries.

Property Example Meaning
panel foods\ca_wlk Base path of sprite sheet PCX(s)
frames 3 Number of animation frames
views 3 Number of view angles (1 = single sprite, 3 or 8 = directional)

6. Coordinate and Heading System

6.1 Cell Coordinates

[decode] The PCX grid uses standard 2D raster coordinates:

  • X increases left → right (west → east)
  • Y increases top → bottom (north → south)
  • (0, 0) is the top-left cell

[strings] In bork.exe, runtime position is logged as (%d:%3d, %d:%3d) — a fixed-point format where the integer part is the cell index and the fractional part is a sub-cell offset (0–999, i.e., 1/1000 of a cell). Example: (2:000, 2:000) = cell (2,2) at the center. This is the in-engine runtime format; the BLK file stores only the integer cell coordinates.

6.2 Heading (Player Spawn Orientation)

[blk:01–10] The Player start heading field stores an integer angle. [decode] Observed values: 0, 32, 64, 96, 144, 192, 250. The values 0, 32, 64, 96, 144, 192 are all multiples of 32 (= 256/8), strongly indicating a 256-unit-per-revolution binary angle measurement (BAM) system, where the full circle maps to 0–255.

Under the assumption that 0 = North and the angle increases clockwise (consistent with 2D game conventions where Y increases downward):

Heading value Degrees Cardinal
0 North
32 45° NE
64 90° East
96 135° SE
128 180° South
144 ~202° SSW
192 270° West
224 315° NW
250 ~352° NNW

Caveat: The axis convention (which direction is 0) is inferred from the BAM structure and reasonable raycasting defaults. It has not been confirmed by running the game and observing player spawn direction. The value 144 (used in level 4) does not fall on a standard 45° boundary, which suggests the engine may use true continuous angles rather than cardinal snapping.

[strings] bork.exe contains the token inc_angle_tok, suggesting a concept of angular increments confirming that heading is indeed a continuous angle, not an enumerated direction.


7. Extracting the Playable Maze

The 256×256 grid contains editor metadata beyond the playable maze. Use the following algorithm to isolate the maze for import.

7.1 Algorithm: Flood Fill from Player Start

def extract_maze(grid, blk):
	px = blk.player_x
	py = blk.player_y
	defs = blk.definitions   # dict: id -> {type, shape, is_wall, is_thing, ...}

	def is_passable(val):
		if val == 0:   return True   # open floor [blk:01 block 0]
		if val == 255: return False  # void
		d = defs.get(val)
		if d is None: return False
		# things (set trans) and shape=empty blocks are passable [blk:01]
		return d['type'] == 'thing' or d.get('shape') == 'empty' or d.get('is_thing')

	# BFS from player start
	from collections import deque
	visited = set()
	q = deque([(px, py)])
	visited.add((px, py))
	while q:
		x, y = q.popleft()
		for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)):
			nx, ny = x+dx, y+dy
			if 0 <= nx < 256 and 0 <= ny < 256:
				if (nx, ny) not in visited and is_passable(grid[ny][nx]):
					visited.add((nx, ny))
					q.append((nx, ny))

	# Bounding box of reachable cells + 1 border row/col to include surrounding walls
	xs = [p[0] for p in visited]
	ys = [p[1] for p in visited]
	x0 = max(0, min(xs) - 1)
	y0 = max(0, min(ys) - 1)
	x1 = min(255, max(xs) + 1)
	y1 = min(255, max(ys) + 1)

	maze = [[grid[y][x] for x in range(x0, x1+1)] for y in range(y0, y1+1)]
	player_local_x = px - x0
	player_local_y = py - y0
	return maze, player_local_x, player_local_y

[decode] This algorithm correctly isolates all 10 playable mazes. The editor palette region (columns 54+) and the tile preview rows are never reachable from the player start because they are separated by void (0xFF) or solid wall cells.

7.2 Maze Cell Semantics for Import

After extraction, each cell in the maze sub-grid has one of these semantic roles:

Value Role Notes
0 Open floor Passable, render floor + ceiling texture
255 Void Outside map bounds; treat as impassable, do not render
1 Generic wall Solid, render all 4 faces
2 Horizontal goal door shape = horz [blk:01]: passable from N/S only (half-wall marking a level exit)
3 Vertical goal door shape = cube with exit textures on E/W faces
49 (varies) Weapon pickup Thing at floor level; passable. Which weapon ID maps to which pickup is defined per-level in the BLK — see Section 13
10, 19, etc. Enemy spawn point Thing at floor level; passable by player on spawn
2024, 6065, etc. Textured wall variants Solid; use n_wall/e_wall/s_wall/w_wall for per-face textures
86, 87, 88, 89 Exit/easy-out markers [blk:01] described as "easy out north-south/east-west"; shape = cube, set wall. Mark passable exit cells that end the level

For a Wolfenstein-style import, the minimum viable classification is:

  • 0 or any cell where shape == empty or set transfloor (empty cell)
  • 255void (skip)
  • All others → wall (solid), with face textures from the BLK definition

8. Kitchen Tileset Themes

[blk:01–10] Each level's BLK file references textures from one of four kitchen theme directories under KITCHENS/. The theme is identified by the texture path prefix:

Prefix Theme Levels
fifts_ Fifties kitchen 1, 5
techy_ Techy/modern kitchen 2, 7, 9
woody_ Woody/cabin kitchen 3, 8
mediv_ Medieval kitchen 4, 6, 10

Texture paths in the BLK use Windows backslash as a path separator and have no file extension. The actual files on disk are PCX format with uppercase names (e.g., KITCHENS\FIFTS_00.PCX). [strings] The executable references both .PCX and .pcx case variants when loading, suggesting case-insensitive path resolution.


9. Enemy and Item Types (All Levels)

[blk:01–10] All food enemies and item types observed across the 10 BLK files:

Name (in BLK name field or comment) Map symbol Levels
carrot thing 1, 8, 10
cabbage thing 1, 10
lemon thing 2, 10
watermellon (sic) thing 2, 10
coliflower (sic, = cauliflower) thing 3, 10
cucumber thing 3, 10
tomato thing 4, 5, 6, 7, 8, 9, 10
potato thing 5, 7, 10
cheese thing 6, 10
egg thing 6, 7, 10
whisk (utensil/pickup) thing 1, 2, ...

[blk:01–10] Level 10 contains all food enemy types simultaneously, making it the final/hardest level.


10. Weapons and Damage System

[blk:01] The action entries define five named weapons and three damage-resistance thresholds per weapon (values are out of 255 total health):

Weapon Threshold values (typical) Hits to kill (typical)
wisk 256 86 64 4 hits
egg_beater 256 86 64 4 hits
rolling_pin 256 128 86 3 hits
food_processor 256 256 128 2 hits
pastry_gun 256 256 256 1 hit

[blk:01] The comment { out of 255 health points, so 4 hits required } appears inline. A threshold value of 256 means a single hit does not kill (> 255 HP), so multiple hits are needed.


11. Parsing Pseudocode (Language-Agnostic)

11.1 Parse the PCX Map

function parse_pcx_map(bytes):
	# Header
	assert bytes[0] == 0x0A            # PCX magic
	assert bytes[1] == 0x05            # Version 5
	assert bytes[2] == 0x01            # RLE encoding
	assert bytes[3] == 0x08            # 8 bpp
	xmax = read_u16_le(bytes, 8)       # always 255
	ymax = read_u16_le(bytes, 10)      # always 255
	width  = xmax + 1                  # always 256
	height = ymax + 1                  # always 256
	bytes_per_line = read_u16_le(bytes, 66)  # always 256

	# Decode RLE starting at byte 128
	pixels = []
	pos = 128
	target = height * bytes_per_line   # 65536
	while len(pixels) < target:
		b = bytes[pos]; pos++
		if (b & 0xC0) == 0xC0:
			count = b & 0x3F
			value = bytes[pos]; pos++
			repeat value, count times into pixels
		else:
			append b to pixels

	# Build 2D grid [row][col]
	grid = new array[height][width]
	for y in 0..height-1:
		for x in 0..width-1:
			grid[y][x] = pixels[y * bytes_per_line + x]

	# Optional: read VGA palette (not needed for map import)
	assert bytes[len(bytes) - 769] == 0x0C   # palette marker
	palette = bytes[len(bytes)-768 .. len(bytes)-1]  # 256 * RGB

	return grid

11.2 Parse the BLK File

function parse_blk(text):
	result = {
		player_x: 2, player_y: 2, player_heading: 0,
		floor_color: 0, ceiling_color: 0,
		blocks: {}, things: {}, actions: {}, anims: {}
	}

	lines = split text by CRLF
	i = 0
	while i < len(lines):
		line = trim(lines[i])

		# Header fields (parse until first block/thing/action/anim keyword)
		if line starts with "Player start x": result.player_x = int(after ":")
		if line starts with "Player start y": result.player_y = int(after ":")
		if line starts with "Player start heading": result.player_heading = int(after ":")
		if line starts with "Floor color index": result.floor_color = int(after ":")
		if line starts with "Ceiling color index": result.ceiling_color = int(after ":")

		# Entry definitions
		keyword, id, comment = try_parse_entry_header(line)
		# entry header format: "keyword N ( { optional comment }"
		if keyword matched:
			entry = {id: id, comment: comment, shape: "cube",
					 is_wall: false, is_thing: false, properties: {}}
			i++
			while i < len(lines):
				body_line = trim(lines[i])
				if body_line starts with ")": break
				if body_line == "set wall;": entry.is_wall = true
				if body_line == "set trans;": entry.is_thing = true
				if body_line starts with "shape":
					entry.shape = value_of(body_line)  # empty/cube/horz
				# Parse "key = value;" — strip inline { comments } first
				key, value = parse_property(strip_comments(body_line))
				if key: entry.properties[key] = value
				i++
			# Store in appropriate namespace
			if keyword == "block": result.blocks[id] = entry
			if keyword == "thing": result.things[id] = entry
			if keyword == "action": result.actions[id] = entry
			if keyword == "anim":  result.anims[id]   = entry

		i++

	return result

Note on ID collision: [blk:01] In the same BLK file, block 0 (open floor), thing 10 (carrot), and action 10 (carrot behavior) can coexist with the same integer ID. When looking up a map cell value, always check blocks first, then things. action and anim entries are not stored as map cells.

11.3 Resolve a Cell to Geometry

function cell_to_geometry(cell_value, blk):
	if cell_value == 0:   return FLOOR
	if cell_value == 255: return VOID

	block = blk.blocks.get(cell_value)
	if block:
		if block.shape == "empty": return FLOOR
		if block.is_wall:
			return WALL(
				faces: {
					N: resolve_texture(block.n_wall),
					E: resolve_texture(block.e_wall),
					S: resolve_texture(block.s_wall),
					W: resolve_texture(block.w_wall),
				},
				floor: resolve_texture(block.floor),
				ceil:  resolve_texture(block.ceil)
			)

	thing = blk.things.get(cell_value)
	if thing:
		return ENTITY(
			sprite: resolve_texture(thing.panel),
			action_id: thing.properties.get("action")
		)

	return UNKNOWN  # ID present in map but not defined in this BLK

11.4 Resolve a Texture Path

function resolve_texture(blk_path):
	# blk_path example: "kitchens\fifts_00"
	# Replace backslash with platform separator, append ".PCX"
	return base_dir + "/" + blk_path.replace("\\", "/") + ".PCX"
	# On disk: chef/KITCHENS/FIFTS_00.PCX (files are uppercase)

12. Known Unknowns

The following were observed but not fully determined by this analysis:

  1. Blocks 86–89 ("easy out" markers): [blk:01] Defined as shape = cube; set wall; with descriptors like "easy out north-south". Appear at level exits in the map. Their exact runtime behavior (teleport? level-end trigger?) is not confirmed from static file analysis alone.

  2. Heading axis convention: [decode] The heading unit (256/revolution) is confirmed, but whether 0 = North or 0 = East was inferred, not directly observed.

  3. Multiple undefined block IDs: [decode] Cells with IDs not defined in the paired BLK (e.g., ID 86 in LEVEL05.PCX where LEVEL05.BLK lacks a block 86 entry) appear to be valid map data. These may reference a built-in default the engine knows about (the executable likely has hardcoded fallback definitions for certain IDs).

  4. obstacle property: [blk:01] Appears in commented-out form in LEVEL01.BLK: {obstacle = kitchens\fifts_07;}. Purpose unknown (possibly an AI navigation blocker).

  5. background color index / shadow / highlight palette indices: [blk:01] Specified in the BLK header. Likely used for ambient lighting or fog effects at runtime. Not needed for static geometry import.

  6. VGA palette: [decode] The 256-color palette at the end of the PCX is not needed to interpret map cell IDs. It was used by the level editor to render the map preview.


13. Weapons and Pickups

13.1 The Five Weapons

[utensils/] The UTENSILS/ directory contains exactly five floor-pickup sprites — one per weapon — plus three sets of in-hand animation sprite sheets. The five weapons, identified by their file name stems:

Stem Weapon name Floor pickup file
WSK Whisk UTENSILS/UTEN_WSK.PCX
BTR Egg Beater UTENSILS/UTEN_BTR.PCX
PIN Rolling Pin UTENSILS/UTEN_PIN.PCX
PRO Food Processor UTENSILS/UTEN_PRO.PCX
GUN Pastry Gun UTENSILS/UTEN_GUN.PCX

These correspond exactly to the five weapon names in the action damage table (Section 10): wisk, egg_beater, rolling_pin, food_processor, pastry_gun.

13.2 In-Hand Weapon Animation Sprite Sheets

[utensils/] Each weapon also has three sets of animation frames, distinguished by a size prefix:

Prefix Meaning (inferred) Frame count
SM_ Small (weapon at rest / lowered) 7 frames (00–06)
MD_ Medium (weapon mid-raise) 7 frames (00–06)
LG_ Large (weapon raised / firing) 7 frames (00–06); 16 frames for GUN (00–15)

Examples: SM_WSK00.PCXSM_WSK06.PCX, LG_GUP00.PCXLG_GUP15.PCX.

[utensils/] One additional file, MD_WSKFL.PCX, exists only for the whisk. The FL suffix likely means "flash" (muzzle/hit flash frame). This is the only weapon with a named flash frame; the others may encode the flash as one of the numbered frames.

Note: The SM/MD/LG distinction and the exact engine animation state that triggers each set are inferred from the file naming. The I3D engine likely cycles through the numbered frames during the attack animation and selects the size based on a game state variable (e.g., weapon charge or bob phase).

13.3 Thing IDs Are Per-Level, Not Global

[blk:01–10] Weapon pickup thing IDs are not fixed across levels. The mapping from thing ID to weapon sprite is defined individually in each level's BLK file via the panel property. The same weapon can use a different thing ID in different levels, and different weapons can share the same ID across levels.

The BLK thing entry for a weapon pickup has these characteristics (distinguishing it from an enemy thing):

  • Has a panel property pointing to utensils\uten_xxx
  • Has no action property (enemies always have action = N)
  • Has set trans (passable)
  • Observed IDs used for weapon pickups: 4, 5, 6, 7, 8, 9

13.4 Per-Level Pickup Mapping Table

[blk:01–10] The complete mapping of thing ID → weapon for each level, and how many of each pickup are placed in the playable maze:

Level Thing ID Weapon Count in maze
01 4 WSK (Whisk) 1
02 8 PRO (Food Processor) 1
02 9 GUN (Pastry Gun) 1
03 5 PIN (Rolling Pin) 1
03 8 PRO (Food Processor) 1
04 4 WSK (Whisk) — (defined but 0 in maze)
04 5 PIN (Rolling Pin) 2
04 6 BTR (Egg Beater) 1
05 4 WSK (Whisk) 1
05 7 PIN (Rolling Pin) 1
05 8 PRO (Food Processor) 1
05 9 GUN (Pastry Gun) 1
06 4 WSK (Whisk) 1
06 5 PIN (Rolling Pin) — (defined but 0 in maze)
06 6 BTR (Egg Beater) 1
07 8 PRO (Food Processor) 1
07 9 GUN (Pastry Gun) 1
08 6 BTR (Egg Beater) 1
08 8 PRO (Food Processor) 1
08 9 GUN (Pastry Gun) 1
09 9 GUN (Pastry Gun) 1
10 4 WSK (Whisk) 1
10 5 PIN (Rolling Pin) 1
10 6 BTR (Egg Beater) 1
10 7 PIN (Rolling Pin) 1 (second rolling pin — distinct ID, same sprite)
10 8 PRO (Food Processor) 1
10 9 GUN (Pastry Gun) 1

[blk:10] Level 10 defines both thing 5 and thing 7 as uten_pin (rolling pin) — two separate pickup entities with different IDs but the same weapon sprite, allowing two rolling pins to be placed with independent pickup tracking.

13.5 Weapon Progression Pattern

[blk:01–10] The pickup selection across levels follows a clear escalation:

  • Level 1: Whisk only — the player's starting weapon, weakest
  • Levels 2–3: Mid-tier weapons appear (food processor, rolling pin)
  • Levels 4–6: Egg beater introduced; whisk re-appears in some levels
  • Levels 7–8: Consistently high-tier (food processor + pastry gun)
  • Level 9: Pastry gun only — single most powerful weapon as the only pickup
  • Level 10: All five weapons placed — one of each available in the final level

13.6 Resolving a Pickup Cell

For a game engine import, to identify whether a cell contains a weapon pickup:

function is_weapon_pickup(cell_value, blk):
	thing = blk.things.get(cell_value)
	if thing is None: return false
	panel = thing.properties.get("panel", "")
	return panel.startswith("utensils") and "action" not in thing.properties

function get_weapon_sprite(cell_value, blk):
	thing = blk.things.get(cell_value)
	panel = thing.properties["panel"]          # e.g. "utensils\uten_wsk"
	floor_sprite = resolve_texture(panel)       # UTENSILS/UTEN_WSK.PCX
	return floor_sprite

14. Combat System — Health, Damage, and Hits to Kill

14.1 Enemy Hit Points

[blk:01] Every enemy action entry includes an inline comment:

wisk = 256 86 64;  { out of 255 health points, so 4 hits required }

This explicitly states that all enemies have 255 hit points. HP is not stored in the map or BLK file as a variable — it is a constant embedded in the engine.

14.2 Weapon Damage Values (Per-Hit, Per Skill Level)

[blk:01–10] Each action entry specifies per-weapon damage delivered per hit, at three skill levels:

weapon_name = skill1_damage  skill2_damage  skill3_damage;
  • Skill 1 = easiest difficulty (highest damage → fewest hits to kill)
  • Skill 3 = hardest difficulty (lowest damage → most hits to kill)
  • A damage value of 256 exceeds the enemy's 255 HP, guaranteeing a one-hit kill

The following table lists the raw per-hit damage values for each enemy, for each weapon, at each skill level:

Enemy WSK BTR PIN PRO GUN
Carrot 256 / 86 / 64 256 / 86 / 64 256 / 128 / 86 256 / 256 / 128 256 / 256 / 256
Cabbage 256 / 86 / 64 256 / 86 / 64 256 / 128 / 86 256 / 128 / 86 256 / 256 / 256
Lemon 128 / 86 / 64 128 / 86 / 64 128 / 86 / 86 128 / 128 / 128 256 / 256 / 256
Watermelon 128 / 64 / 52 128 / 64 / 64 128 / 64 / 64 128 / 64 / 64 256 / 256 / 256
Cauliflower 256 / 64 / 52 256 / 64 / 52 256 / 128 / 86 256 / 128 / 86 256 / 256 / 256
Cucumber 256 / 128 / 86 256 / 128 / 86 256 / 128 / 86 256 / 128 / 86 256 / 256 / 256
Tomato 128 / 52 / 43 128 / 52 / 43 128 / 52 / 43 128 / 64 / 52 256 / 256 / 256
Potato 128 / 86 / 64 128 / 86 / 64 128 / 86 / 64 128 / 86 / 64 256 / 256 / 256
Cheese 256 / 128 / 86 256 / 128 / 86 256 / 86 / 64 256 / 86 / 64 256 / 256 / 256
Egg 256 / 128 / 86 256 / 256 / 128 128 / 128 / 86 128 / 128 / 86 256 / 256 / 256

14.3 Hits to Kill (ceil(255 / damage))

The following table gives the number of hits required to kill each enemy, derived from the damage values above using ceil(255 / damage). Format: skill1 / skill2 / skill3.

Enemy WSK BTR PIN PRO GUN
Carrot 1/3/4 1/3/4 1/2/3 1/1/2 1/1/1
Cabbage 1/3/4 1/3/4 1/2/3 1/2/3 1/1/1
Lemon 2/3/4 2/3/4 2/3/3 2/2/2 1/1/1
Watermelon 2/4/5 2/4/4 2/4/4 2/4/4 1/1/1
Cauliflower 1/4/5 1/4/5 1/2/3 1/2/3 1/1/1
Cucumber 1/2/3 1/2/3 1/2/3 1/2/3 1/1/1
Tomato 2/5/6 2/5/6 2/5/6 2/4/5 1/1/1
Potato 2/3/4 2/3/4 2/3/4 2/3/4 1/1/1
Cheese 1/2/3 1/2/3 1/3/4 1/3/4 1/1/1
Egg 1/2/3 1/1/2 2/2/3 2/2/3 1/1/1

Notable anomalies:

  • [blk:06] Egg takes fewer hits with the Egg Beater than with the Rolling Pin or Food Processor — the egg is specifically vulnerable to being beaten (egg_beater skill3=128 vs rolling_pin skill3=86). Unusual because BTR is weaker than PIN/PRO against most enemies.
  • [blk:06] Cheese takes fewer hits with WSK/BTR than with PIN/PRO — the rolling pin and food processor are actually less effective against cheese, suggesting a flavor-based design choice.
  • Cauliflower at skill 2/3 takes 4–5 wisk/egg-beater hits but only 2–3 rolling pin hits — a strong per-weapon vulnerability.
  • Tomato is the toughest enemy at skill 3: up to 6 hits with wisk, egg beater, or rolling pin. Combined with its many appearances (levels 4, 8, 9), it is the most punishing single-type level (level 9).
  • Pastry Gun one-hit-kills every enemy at every skill level. It is always placed as the rarest pickup.

14.4 Player Health

[hex] The MISC/CHEF_C/L/R/T 00–04.PCX files establish that the player HUD uses the same 4-direction × 5-state health face design as Wolfenstein 3D: directions C (center), L (left), R (right), T (top) indicate the direction the player was last hit, and states 00–04 represent increasing damage (00 = full health, 04 = near death).

The exact maximum player HP value is not confirmed from the files analyzed. The 255 HP figure for enemies is explicitly stated in BLK comments; player HP is likely also byte-range (0–255) but is hardcoded in the engine and not represented in any level file.

Player damage taken (enemy projectile → player HP reduction) is also hardcoded in the engine and not found in the BLK files.

14.5 Food-Enemy-to-Consumable Mechanic

[strings] When an enemy's HP reaches zero, it transitions through a unique two-phase death sequence not present in Wolfenstein 3D or Doom:

  1. DYING — death animation plays (die_anim)
  2. DEAD — enemy lies on the floor; dead_anim sprite plays (looping idle). The enemy is now a consumable food item on the ground.
  3. EATEN — triggered when the player walks over the dead enemy's cell. The food item is consumed.

After the EATEN transition:

  • snd_burp() is called (plays sounds\snd_bl00.wav or similar from the burp sound pool)
  • snd_really_burp() is called for a second, more emphatic burp
  • The enemy entity is removed from the map

This mechanic replaces the separate pickup-drop system used in most Wolf3D-style games: the enemy body itself is the health pickup.

[strings] The SPLAT state also appears in the AI state machine strings in bork.exe. It is associated with the function snd_splat_chef() and may represent the player's death animation (the chef being flattened) rather than an enemy state.


15. Enemy AI State Machine

[strings] The following state names appear as literal strings in bork.exe in sequential order, indicating a state machine with these transitions:

WAITING → WANDER → TALK → CHASE → DYING → DEAD → EATEN
												 ↘ SPLAT (player death)
State Description
WAITING Enemy is idle, not yet aware of player
WANDER Enemy patrols or moves randomly through the maze
TALK Enemy has spotted the player; voice line plays; transition to chase
CHASE Enemy actively pursues the player and attacks
DYING Enemy HP reached zero; death animation (die_anim) plays
DEAD Enemy lies on floor as a food item; dead_anim plays
EATEN Player walked over the dead enemy; burp sounds play; entity removed
SPLAT Player death state; snd_splat_chef() plays

[strings] The function talk_actor(%lx) is called on transition to TALK state. The enemy voice line (INT_XX00.WAV) plays during this state. A debug toggle Toggle vegetable speech is present in bork.exe, confirming that voice lines are a discrete, toggleable feature.


16. Sound System

16.1 Sound File Location

[cd] All audio files are located on the game CD at 5\SOUNDS\. The installed game references them via the path prefix sounds\ (relative to the chef\ directory). File format: PCM WAV.

16.2 Enemy Voice Lines (Sighting)

[cd] Each enemy type has exactly one sighting voice line, played when entering the TALK state (enemy spots the player):

File Enemy
INT_CA00.WAV Carrot
INT_CB00.WAV Cabbage
INT_CO00.WAV Cauliflower (spelled "coliflower" in BLK)
INT_CS00.WAV Cheese
INT_CU00.WAV Cucumber
INT_EG00.WAV Egg
INT_LE00.WAV Lemon
INT_PO00.WAV Potato
INT_TO00.WAV Tomato
INT_WM00.WAV Watermelon

The INT_ prefix likely stands for "intercept" (enemy intercepting / spotting the player). Each file has only a 00 variant (no multiple takes per enemy type).

16.3 Per-Enemy Chase/Ambient Sounds

[strings] The pattern sounds\snd_%s.wav in bork.exe loads per-enemy sounds using the same two-letter abbreviation codes as the INT files. These are likely played during the CHASE state (enemy movement/attack sounds):

File Enemy
SND_CA.WAV Carrot
SND_CB.WAV Cabbage
SND_CO.WAV Cauliflower
SND_CS.WAV Cheese
SND_CU.WAV Cucumber
SND_EG.WAV Egg
SND_LE.WAV Lemon
SND_PO.WAV Potato
SND_TO.WAV Tomato
SND_WM.WAV Watermelon

16.4 Weapon Fire Sounds

[cd] One WAV file per weapon, played on each shot fired:

File Weapon
SND_WSK.WAV Whisk
SND_BTR.WAV Egg Beater
SND_PIN.WAV Rolling Pin
SND_PRO.WAV Food Processor
SND_GUP.WAV Pastry Gun

Note: The pastry gun file uses abbreviation GUP rather than GUN, which is also how it is referenced in bork.exe sprite path strings (lg_gup00.pcx etc.).

16.5 Hit and Projectile Sounds

[strings] SND_BONK.WAV — played by snd_bonk() when a projectile hits a wall or enemy.

16.6 Player State Sounds

File Trigger
SND_OOF.WAV Player takes damage (hurt sound)
SPLAT00.WAV / SPLAT_00.WAV Player death; played by snd_splat_chef()
SND_BL00.WAV Player eats a dead food enemy (burp); called via snd_burp()
SND_BL01.WAV Emphatic burp after eating; called via snd_really_burp()

[strings] The pattern sounds\snd_bl%02d.wav is used to load burp sounds by index.

16.7 UI and Menu Sounds

File Trigger
SND_WON.WAV Level completed (win)
SND_LOST.WAV Game over (player died)
SND_CHNG.WAV Weapon switched
SND_CHIM.WAV Chime (likely played on reaching the exit/goal)
SND_QUIT.WAV Quit game
TITLE0.WAV Title screen audio

16.8 Chef Voice Lines

[strings] The pattern sounds\chtlk%02d.wav loads Swedish Chef dialogue. Eight files exist (CHTLK00.WAV through CHTLK07.WAV). These are likely triggered when the player fires a weapon or at other gameplay events (separate from enemy sighting lines, which use INT_XX00.WAV).

16.9 Music

[strings] The pattern sounds\music%02d.wav loads background music tracks. Four tracks exist: MUSIC00.WAV through MUSIC03.WAV.

[user] All four tracks are sections of the same song (the Swedish Chef theme). Playback is randomized: when a track finishes, the next track is chosen at random from the four, with replacement (the same track can repeat). There is no per-level or per-theme track assignment. This design reflects that Kitchens of Doom was intended as a short minigame within the larger Muppets Inside CD-ROM experience, not a standalone game to be played for extended sessions.

16.10 Enemy Abbreviation Reference

For implementing the sound system, the two-letter enemy abbreviations used in both INT_XX00.WAV and SND_XX.WAV filenames:

Code Enemy BLK name
CA Carrot carrot
CB Cabbage cabbage
CO Cauliflower coliflower
CS Cheese cheese
CU Cucumber cucumber
EG Egg egg
LE Lemon lemon
PO Potato potato
TO Tomato tomato
WM Watermelon watermellon (note: double-l in BLK)

17. Enemy Movement and Collision

17.1 Enemy Speed

[blk:01–10] Each action entry includes a speed property with an inline scale comment:

speed = 75; { 0 is still, 25 is very slow, 50 is ok, 100 is fast }

The scale is: 0 = stationary · 25 = very slow · 50 = moderate · 100 = fast. This is an opaque integer unit whose exact mapping to tiles/second is hardcoded in the engine.

Enemy Speed Relative pace
Tomato 100 Fastest
Carrot 75 Fast
Cucumber 75 Fast
Cheese 75 Fast
Egg 75 Fast
Potato 75 Fast
Cabbage 50 Moderate
Lemon 50 Moderate
Cauliflower 25 Very slow
Watermelon 25 Very slow

Design note: Tomato is simultaneously the fastest enemy (speed=100), the toughest at high difficulty (up to 6 wisk hits at skill 3), and the only weapon that always kills it in 1 hit is the Pastry Gun. Level 9 contains only Tomatoes and only a Pastry Gun pickup — a deliberate difficulty spike.

Player speed is not present in the BLK file format; it is hardcoded in bork.exe.

17.2 Enemy Collision Radius

[blk:01–10] Each action entry includes a radius property (a floating-point value in cell units where 1.0 = full cell width). This is the radius of the enemy's collision circle used for both wall collision and player-proximity checks.

Enemy Radius (standard)
Cauliflower 1.00
Watermelon 0.99
Tomato 0.98
Potato 0.97
Cheese 0.96
Lemon 0.95
Egg 0.94
Cabbage 0.93
Cucumber 0.93
Carrot 0.92

[blk:07] Level 7 exception: In LEVEL07.BLK, the Egg has radius = 0.40 and the Potato has radius = 0.70 — significantly smaller than their standard values in all other levels. This makes both enemies much harder to hit with projectiles in Level 7. This appears to be an intentional per-level difficulty adjustment, as all other properties (speed, HP, anims) remain unchanged.

17.3 Collision Flags (Thing Entry)

[blk:01] Enemy thing entries carry three flags that govern different aspects of collision:

Flag Present on enemies Present on weapon pickups Meaning
set hitable Yes No Can be targeted and hit by the player's projectile weapons
set wall Yes No Solid obstacle: blocks player movement using radius-based circle collision
set trans Yes Yes Sprite uses palette transparency; also marks the thing cell as traversable for map-parsing purposes (see Section 8)

Weapon pickup thing entries carry only set trans. The player collects a pickup by walking into its cell — no separate interaction is needed, and the pickup has no collision circle.


18. Sprite Animation System

18.1 Anim Entry Format (Full Detail)

[blk:01–10] Each anim entry defines one animation clip. The three properties are:

Property Meaning
panel Base filename path (relative to chef/, no extension, no frame/view digits)
frames Number of sequential frames in the animation
views Number of viewing-angle variants (1 = omnidirectional; 3 = directional)

18.2 Sprite File Naming Convention

[foods/] Sprite files are named by appending a two-digit suffix to the panel base name:

{panel}{view_digit}{frame_digit}.PCX
  • view_digit is 0 to (views − 1)
  • frame_digit is 0 to (frames − 1)

Examples for Carrot walk anim (panel = foods\ca_wlk, frames=3, views=3):

CA_WLK00.PCX   (view 0, frame 0)
CA_WLK01.PCX   (view 0, frame 1)
CA_WLK02.PCX   (view 0, frame 2)
CA_WLK10.PCX   (view 1, frame 0)
CA_WLK11.PCX   (view 1, frame 1)
CA_WLK12.PCX   (view 1, frame 2)
CA_WLK20.PCX   (view 2, frame 0)
CA_WLK21.PCX   (view 2, frame 1)
CA_WLK22.PCX   (view 2, frame 2)

Examples for Carrot throw anim (panel = foods\ca_thr, frames=3, views=1):

CA_THR00.PCX   (view 0, frame 0)
CA_THR01.PCX   (view 0, frame 1)
CA_THR02.PCX   (view 0, frame 2)

For views=1 animations, the view digit is always 0. The engine selects the view digit at runtime based on the angle between the camera and the enemy; which view index maps to which angle range is hardcoded in bork.exe (not represented in the BLK format).

[blk:01] The thing entry for an enemy uses panel = foods\{code}_wlk00 — this is the static map-editor/palette sprite, hardcoded to view 0, frame 0 of the walk animation.

18.3 AI State to Animation Mapping

[blk:01–10] The action entry bridges AI states to anim IDs via five named properties:

Property AI State(s) Views Notes
walk_anim WANDER, CHASE 3 Directional walking; view selected by camera angle
throw_anim CHASE (attacking) 1 Played during an attack; not directional
talk_anim TALK 1 Plays when enemy spots the player; sighting voice line plays concurrently
die_anim DYING 1 Death sequence; plays once through
dead_anim DEAD 1 Single-frame idle displayed while enemy is an edible food item on the floor

There is no dedicated idle_anim; the WAITING state presumably displays the first frame of walk_anim (view 0, frame 0) as a static sprite. This is consistent with the thing entry using _wlk00 as its panel.

The EATEN state removes the entity entirely — no sprite is displayed.

18.4 Per-Enemy Animation Frame Counts

[blk:01–10] [foods/] Frame counts vary per enemy and per animation type. All walk animations use 3 views × 3 frames = 9 PCX files. All dead_anim (FNL) entries use 1 frame × 1 view = 1 PCX file.

Enemy WLK files (v×f) THR frames TLK frames DIE frames
Carrot 3×3 = 9 3 3 5
Cabbage 3×3 = 9 3 3 5
Cauliflower 3×3 = 9 3 3 3
Cucumber 3×3 = 9 3 3 3
Cheese 3×3 = 9 3 3 5
Egg 3×3 = 9 4 5 5
Lemon 3×3 = 9 3 3 3
Potato 3×3 = 9 3 3 3
Tomato 3×3 = 9 3 3 3
Watermelon 3×3 = 9 3 3 3

[foods/] The Egg is the only enemy with non-standard frame counts: 4 throw frames and 5 talk frames. An additional unnumbered file EG_THR.PCX also exists in FOODS/ alongside the numbered frames; its purpose is unknown (possibly a debug or legacy file). The DIE animation length (3 vs 5 frames) may correspond to animation complexity: carrot, cabbage, cheese, cucumber, and egg have 5-frame deaths; the remaining five enemies have 3-frame deaths.

Palette Architecture — Muppets Inside / Swedish Chef's Kitchens of Doom

Overview

All game graphics under chef/ use a palette-swap model with exactly 4 palettes — one per kitchen theme. No separate sprite palette exists; sprites were designed to share the same palette as whichever kitchen is active.

Every palette is 256 colors (768 bytes of raw RGB triplets, 3 bytes per entry, no header). Each PCX file embeds its own copy of the applicable palette in the last 768 bytes of the file, preceded by the standard PCX palette marker byte 0x0C at offset −769.


Palette Structure

Each of the 4 palettes shares the same two-zone layout:

Index range Content Same across all 4 palettes?
0 Transparent / background color No, but irrelevant — never rendered
1 – 89 Sprite colors (also referenced by kitchen textures — identical in all 4 palettes) Yes — bit-for-bit identical
90 – 255 Kitchen-exclusive texture colors No — unique per kitchen

Sprites (food enemies and held utensils) use only pixel indices within the range 1–89. Kitchen texture tiles use indices from the full range 1–255 — they reference the sprite range as well as their own exclusive range. This means any of the 4 kitchen palettes can be loaded and sprites will render correctly without remapping.


The 4 Palettes

FIFTS — Fifties Kitchen

  • Reference file: chef/KITCHENS/FIFTS_00.PCX
  • Used by levels: 1, 5
  • Also the palette embedded in all chef/FOODS/ and chef/UTENSILS/ sprite files (the "main game palette")

TECHY — Techy / Modern Kitchen

  • Reference file: chef/KITCHENS/TECHY_00.PCX
  • Used by levels: 2, 7, 9

WOODY — Woody Kitchen

  • Reference file: chef/KITCHENS/WOODY_00.PCX
  • Used by levels: 3, 8

MEDIV — Medieval Kitchen

  • Reference file: chef/KITCHENS/MEDIV_00.PCX
  • Used by levels: 4, 6, 10

To extract any palette as a raw 768-byte binary, read the last 768 bytes of the reference PCX (i.e., bytes at offset filesize − 768).


How Each Graphic Type Uses the Palette

KITCHENS (wall/floor/ceiling textures)

Files: chef/KITCHENS/FIFTS_*.PCX, TECHY_*.PCX, WOODY_*.PCX, MEDIV_*.PCX

Each file embeds its own kitchen's palette. Pixel indices span the full range 1–255, drawing on both the shared 1–89 zone and the kitchen-exclusive 90–255 zone.

FOODS (enemy sprites — carrots, tomatoes, etc.)

Files: chef/FOODS/*.PCX

All embed the FIFTS/main palette. Pixel indices used: 1–89 only. These sprites render correctly under any of the 4 kitchen palettes without remapping.

UTENSILS (held weapon sprites — whisk, batter gun, etc.)

Files: chef/UTENSILS/SM_*.PCX, MD_*.PCX, LG_*.PCX, UTEN_*.PCX

Same as FOODS: all embed the FIFTS/main palette and use only pixel indices 1–89.

MISC

Files: chef/MISC/*.PCX

Various UI elements (font, number glyphs, info bar). These use their own independent palettes and are not subject to the kitchen palette-swap model.

LEVELS

Files: chef/LEVELS/LEVEL*.PCX

Level map files used by the level editor — not rendered textures. Each embeds a completely unrelated palette used for color-coded map visualization. Not relevant to in-game rendering.


Anomalous File: UTENSILS/MD_WSKFL.PCX

This file should be disregarded:

  • Dimensions: 128×64 — does not match any size tier (SM_=64×64, MD_=96×96, LG_=128×128)
  • Content: Appears to be a batter gun image, unrelated to the whisk its name implies
  • Palette: Completely unique — shares almost no colors with any kitchen or sprite palette, and uses pixel indices throughout 0–255 rather than staying within 1–89
  • Status: Almost certainly an unused leftover from development; no evidence it is referenced by bork.exe

This file is the sole exception to the palette-swap model and can be safely excluded from any port or conversion.


Summary: Deriving the Palettes

To obtain all 4 palettes, read the last 768 bytes of these four files:

chef/KITCHENS/FIFTS_00.PCX   -> FIFTS palette (also the main sprite palette)
chef/KITCHENS/TECHY_00.PCX   -> TECHY palette
chef/KITCHENS/WOODY_00.PCX   -> WOODY palette
chef/KITCHENS/MEDIV_00.PCX   -> MEDIV palette

Any _00.PCX kitchen file works as a reference; the palette is consistent across all files belonging to the same kitchen theme. The _00 tile is simply the most convenient anchor point.

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