Files
kq4-decompile/extract_sci_pic.py
2026-02-20 14:00:40 -08:00

567 lines
23 KiB
Python

#!/usr/bin/env python3
"""
SCI Picture Resource Extractor
Extracts and renders background images (PIC resources) from Sierra's Creative Interpreter (SCI) games.
Supports SCI0 format (King's Quest IV).
"""
import os
import sys
from pathlib import Path
from typing import Tuple, Optional
from collections import deque
import numpy as np
from PIL import Image
PIC_OP_SET_COLOR = 0xf0
PIC_OP_DISABLE_VISUAL = 0xf1
PIC_OP_SET_PRIORITY = 0xf2
PIC_OP_DISABLE_PRIORITY = 0xf3
PIC_OP_RELATIVE_PATTERNS = 0xf4
PIC_OP_RELATIVE_MEDIUM_LINES = 0xf5
PIC_OP_RELATIVE_LONG_LINES = 0xf6
PIC_OP_RELATIVE_SHORT_LINES = 0xf7
PIC_OP_FILL = 0xf8
PIC_OP_SET_PATTERN = 0xf9
PIC_OP_ABSOLUTE_PATTERNS = 0xfa
PIC_OP_SET_CONTROL = 0xfb
PIC_OP_DISABLE_CONTROL = 0xfc
PIC_OP_RELATIVE_MEDIUM_PATTERNS = 0xfd
PIC_OP_OPX = 0xfe
PIC_OP_END = 0xff
WIDTH, HEIGHT = 320, 190
PATTERN_FLAG_RECTANGLE = 0x10
PATTERN_FLAG_USE_PATTERN = 0x20
CIRCLES = [
[0x80],
[0x4e, 0x40],
[0x73, 0xef, 0xbe, 0x70],
[0x38, 0x7c, 0xfe, 0xfe, 0xfe, 0x7c, 0x38, 0x00],
[0x1c, 0x1f, 0xcf, 0xfb, 0xfe, 0xff, 0xbf, 0xef, 0xf9, 0xfc, 0x1c],
[0x0e, 0x03, 0xf8, 0x7f, 0xc7, 0xfc, 0xff, 0xef, 0xfe, 0xff, 0xe7, 0xfc, 0x7f, 0xc3, 0xf8, 0x1f, 0x00],
[0x0f, 0x80, 0xff, 0x87, 0xff, 0x1f, 0xfc, 0xff, 0xfb, 0xff, 0xef, 0xff, 0xbf, 0xfe, 0xff, 0xf9, 0xff, 0xc7, 0xff, 0x0f, 0xf8, 0x0f, 0x80],
[0x07, 0xc0, 0x1f, 0xf0, 0x3f, 0xf8, 0x7f, 0xfc, 0x7f, 0xfc, 0xff, 0xfe, 0xff, 0xfe, 0xff, 0xfe, 0xff, 0xfe, 0xff, 0xfe, 0x7f, 0xfc, 0x7f, 0xfc, 0x3f, 0xf8, 0x1f, 0xf0, 0x07, 0xc0],
]
JUNQ = [0x20, 0x94, 0x02, 0x24, 0x90, 0x82, 0xa4, 0xa2, 0x82, 0x09, 0x0a, 0x22, 0x12, 0x10, 0x42, 0x14, 0x91, 0x4a, 0x91, 0x11, 0x08, 0x12, 0x25, 0x10, 0x22, 0xa8, 0x14, 0x24, 0x00, 0x50, 0x24, 0x04]
JUNQINDEX = [0x00, 0x18, 0x30, 0xc4, 0xdc, 0x65, 0xeb, 0x48, 0x60, 0xbd, 0x89, 0x05, 0x0a, 0xf4, 0x7d, 0x7d, 0x85, 0xb0, 0x8e, 0x95, 0x1f, 0x22, 0x0d, 0xdf, 0x2a, 0x78, 0xd5, 0x73, 0x1c, 0xb4, 0x40, 0xa1, 0xb9, 0x3c, 0xca, 0x58, 0x92, 0x34, 0xcc, 0xce, 0xd7, 0x42, 0x90, 0x0f, 0x8b, 0x7f, 0x32, 0xed, 0x5c, 0x9d, 0xc8, 0x99, 0xad, 0x4e, 0x56, 0xa6, 0xf7, 0x68, 0xb7, 0x25, 0x82, 0x37, 0x3a, 0x51, 0x69, 0x26, 0x38, 0x52, 0x9e, 0x9a, 0x4f, 0xa7, 0x43, 0x10, 0x80, 0xee, 0x3d, 0x59, 0x35, 0xcf, 0x79, 0x74, 0xb5, 0xa2, 0xb1, 0x96, 0x23, 0xe0, 0xbe, 0x05, 0xf5, 0x6e, 0x19, 0xc5, 0x66, 0x49, 0xf0, 0xd1, 0x54, 0xa9, 0x70, 0x4b, 0xa4, 0xe2, 0xe6, 0xe5, 0xab, 0xe4, 0xd2, 0xaa, 0x4c, 0xe3, 0x06, 0x6f, 0xc6, 0x4a, 0xa4, 0x75, 0x97, 0xe1]
EGA_COLORS = [
(0x00, 0x00, 0x00), (0x00, 0x00, 0xA0), (0x00, 0xA0, 0x00), (0x00, 0xA0, 0xA0),
(0xA0, 0x00, 0x00), (0xA0, 0x00, 0xA0), (0xA0, 0x50, 0x00), (0xA0, 0xA0, 0xA0),
(0x50, 0x50, 0x50), (0x50, 0x50, 0xFF), (0x00, 0xFF, 0x50), (0x50, 0xFF, 0xFF),
(0xFF, 0x50, 0x50), (0xFF, 0x50, 0xFF), (0xFF, 0xFF, 0x50), (0xFF, 0xFF, 0xFF),
]
DEFAULT_PALETTE = [
(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7),
(8, 8), (9, 9), (10, 10), (11, 11), (12, 12), (13, 13), (14, 14), (8, 8),
(8, 8), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (8, 8),
(8, 8), (15, 9), (15, 10), (15, 11), (15, 12), (15, 13), (15, 14), (15, 15),
(0, 8), (9, 1), (2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (8, 8),
]
class PictureRenderer:
VISUAL = 1
PRIORITY = 2
CONTROL = 4
def __init__(self):
self.visual = np.full((HEIGHT, WIDTH), 0x0f, dtype=np.uint8)
self.priority = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
self.control = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
self.aux = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
self.palettes = [list(DEFAULT_PALETTE) for _ in range(4)]
self.locked = [0] * 40
self.reset_state()
def reset_state(self):
self.palette_number = 0
self.palette_offset = 0
self.ega_color = (0, 0)
self.current_priority = 0
self.current_control = 0
self.draw_enable = self.VISUAL
self.palette_to_draw = 0
self.pattern_size = 0
self.pattern_nr = 0
self.pattern_use_pattern = False
self.pattern_is_rect = False
def is_white(self):
return self.ega_color[0] == 15 and self.ega_color[1] == 15
def get_aux_set(self):
aux = self.draw_enable
if self.ega_color[0] == 15 and self.ega_color[1] == 15:
aux &= ~self.VISUAL
if self.current_priority == 0:
aux &= ~self.PRIORITY
if self.current_control == 0:
aux &= ~self.CONTROL
return aux
def plot_pixel(self, x: int, y: int):
if 0 <= x < WIDTH and 0 <= y < HEIGHT:
aux_set = self.get_aux_set()
if self.draw_enable & self.VISUAL:
# EGA dithering: color1 on odd pixels, color2 on even pixels
color_idx = self.ega_color[0] if ((x ^ y) & 1) else self.ega_color[1]
self.visual[y, x] = color_idx
if self.draw_enable & self.PRIORITY:
self.priority[y, x] = self.current_priority
if self.draw_enable & self.CONTROL:
self.control[y, x] = self.current_control
self.aux[y, x] |= aux_set
def draw_line(self, x1: int, y1: int, x2: int, y2: int):
dx = abs(x2 - x1)
dy = abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx - dy
while True:
self.plot_pixel(x1, y1)
if x1 == x2 and y1 == y2:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x1 += sx
if e2 < dx:
err += dx
y1 += sy
def draw_pattern(self, x: int, y: int, size: int, pattern_nr: int, use_pattern: bool, is_rect: bool):
wsize = size
x_max, y_max = WIDTH - 1, HEIGHT - 1
if x < wsize:
x = wsize
if x + wsize > x_max:
x = x_max - wsize
if y < wsize:
y = wsize
if y + wsize > y_max:
y = y_max - wsize
if pattern_nr >= len(JUNQINDEX):
return
junqbit = JUNQINDEX[pattern_nr]
if is_rect:
for ly in range(y - wsize, y + wsize + 1):
for lx in range(x - wsize, x + wsize + 2):
if use_pattern:
if (JUNQ[junqbit >> 3] >> (7 - (junqbit & 7))) & 1:
self.plot_pixel(lx, ly)
junqbit = (junqbit + 1) & 0xFF
else:
self.plot_pixel(lx, ly)
else:
circlebit = 0
circle_data = CIRCLES[size]
circle_data_bits = len(circle_data) * 8
for ly in range(y - wsize, y + wsize + 1):
for lx in range(x - wsize, x + wsize + 2):
circle_on = False
if circlebit < circle_data_bits:
circle_on = bool((circle_data[circlebit >> 3] >> (7 - (circlebit & 7))) & 1)
if circle_on:
if use_pattern:
if (JUNQ[junqbit >> 3] >> (7 - (junqbit & 7))) & 1:
self.plot_pixel(lx, ly)
junqbit = (junqbit + 1) & 0xFF
else:
self.plot_pixel(lx, ly)
circlebit += 1
def _fill_bounds(self, x: int, y: int, draw_enable: int) -> bool:
"""Check if (x, y) is a fill boundary, using the given draw_enable flags.
Returns True if the pixel is a boundary (should NOT be filled).
Matches SCICompanion's FILL_BOUNDS macro:
(dwDrawEnable & aux) && !(visual_enabled && pixel_is_white)
"""
aux = self.aux[y, x]
overlap = draw_enable & aux
if overlap:
# There IS something drawn here that overlaps with what we want to draw.
# But if visual is enabled and the pixel is white, treat it as empty (not a boundary).
if (draw_enable & self.VISUAL) and (self.visual[y, x] == 0x0f):
return False
return True
return False
def _ok_to_fill(self, x: int, y: int, draw_enable: int) -> bool:
if not (0 <= x < WIDTH and 0 <= y < HEIGHT):
return False
return not self._fill_bounds(x, y, draw_enable)
def flood_fill(self, start_x: int, start_y: int):
if not (0 <= start_x < WIDTH and 0 <= start_y < HEIGHT):
return
# Compute auxSet from the color/priority/control and draw_enable.
# Then replace draw_enable with auxSet (matching SCICompanion behavior).
aux_set = self.get_aux_set()
draw_enable = aux_set
# Guard against filling with pure white (would hang/infinite loop).
if self.is_white():
return
# If no screen is being drawn to, bail.
if draw_enable == 0:
return
# Check if starting point is already a boundary.
if self._fill_bounds(start_x, start_y, draw_enable):
return
# Use a stack (LIFO) to match SCICompanion's qstore/qretrieve behavior.
stack = [(start_x, start_y)]
while stack:
x, y = stack.pop()
if not self._ok_to_fill(x, y, draw_enable):
continue
# Plot the pixel
if draw_enable & self.VISUAL:
self.visual[y, x] = self.ega_color[0] if ((x ^ y) & 1) else self.ega_color[1]
if draw_enable & self.PRIORITY:
self.priority[y, x] = self.current_priority
if draw_enable & self.CONTROL:
self.control[y, x] = self.current_control
self.aux[y, x] |= aux_set
if y > 0 and self._ok_to_fill(x, y - 1, draw_enable):
stack.append((x, y - 1))
if x > 0 and self._ok_to_fill(x - 1, y, draw_enable):
stack.append((x - 1, y))
if x < WIDTH - 1 and self._ok_to_fill(x + 1, y, draw_enable):
stack.append((x + 1, y))
if y < HEIGHT - 1 and self._ok_to_fill(x, y + 1, draw_enable):
stack.append((x, y + 1))
def read_abs_coords(self, data: bytes, i: int) -> Tuple[int, int, int]:
prefix = data[i]
x = data[i + 1] | ((prefix & 0xF0) << 4)
y = data[i + 2] | ((prefix & 0x0F) << 8)
return x, y, i + 3
def render(self, data: bytes) -> Tuple[Image.Image, Image.Image, Image.Image]:
self.visual.fill(0x0f)
self.priority.fill(0)
self.control.fill(0)
self.aux.fill(0)
self.palettes = [list(DEFAULT_PALETTE) for _ in range(4)]
self.locked = [0] * 40
self.reset_state()
i = 0
x, y = 0, 0
while i < len(data):
opcode = data[i]
if opcode >= 0xF0:
i += 1
if opcode == PIC_OP_END:
break
elif opcode == PIC_OP_SET_COLOR:
color_code = data[i]
i += 1
# Palette number and offset within palette
palette_num = color_code // 40
palette_offset = color_code % 40
# If palette_num is 0, use the global palette_to_draw instead
palette_to_use = palette_num if palette_num != 0 else self.palette_to_draw
# Store for UI/state tracking
self.palette_number = palette_num
self.palette_offset = palette_offset
# Look up the actual color from the palette
if self.locked[palette_offset]:
self.ega_color = self.palettes[0][palette_offset]
else:
self.ega_color = self.palettes[palette_to_use][palette_offset]
self.draw_enable |= self.VISUAL
elif opcode == PIC_OP_DISABLE_VISUAL:
self.draw_enable &= ~self.VISUAL
elif opcode == PIC_OP_SET_PRIORITY:
self.current_priority = data[i] & 0x0F
i += 1
self.draw_enable |= self.PRIORITY
elif opcode == PIC_OP_DISABLE_PRIORITY:
self.draw_enable &= ~self.PRIORITY
elif opcode == PIC_OP_SET_CONTROL:
self.current_control = data[i]
i += 1
self.draw_enable |= self.CONTROL
elif opcode == PIC_OP_DISABLE_CONTROL:
self.draw_enable &= ~self.CONTROL
elif opcode == PIC_OP_RELATIVE_MEDIUM_LINES:
x, y, i = self.read_abs_coords(data, i)
while i < len(data) and data[i] < 0xF0:
by = data[i]
bx = data[i + 1]
i += 2
dy = (by & 0x7F) if not (by & 0x80) else -(by & 0x7F)
dx = bx if bx < 128 else bx - 256
self.draw_line(x, y, x + dx, y + dy)
x += dx
y += dy
elif opcode == PIC_OP_RELATIVE_LONG_LINES:
x, y, i = self.read_abs_coords(data, i)
while i < len(data) and data[i] < 0xF0:
x2, y2, i = self.read_abs_coords(data, i)
self.draw_line(x, y, x2, y2)
x, y = x2, y2
elif opcode == PIC_OP_RELATIVE_SHORT_LINES:
x, y, i = self.read_abs_coords(data, i)
while i < len(data) and data[i] < 0xF0:
b = data[i]
i += 1
dx = ((b >> 4) & 0x7) if not (b & 0x80) else -((b >> 4) & 0x7)
dy = (b & 0x7) if not (b & 0x08) else -(b & 0x7)
self.draw_line(x, y, x + dx, y + dy)
x += dx
y += dy
elif opcode == PIC_OP_FILL:
while i < len(data) and data[i] < 0xF0:
fx, fy, i = self.read_abs_coords(data, i)
self.flood_fill(fx, fy)
elif opcode == PIC_OP_RELATIVE_PATTERNS:
pattern_nr_byte = 0
if self.pattern_use_pattern and i < len(data) and data[i] < 0xF0:
pattern_nr_byte = data[i]
i += 1
x, y, i = self.read_abs_coords(data, i)
self.draw_pattern(x, y, self.pattern_size, (pattern_nr_byte >> 1) & 0x7f, self.pattern_use_pattern, self.pattern_is_rect)
while i < len(data) and data[i] < 0xF0:
pattern_nr_byte = 0
if self.pattern_use_pattern and i < len(data) and data[i] < 0xF0:
pattern_nr_byte = data[i]
i += 1
# Relative short coords for pattern
b = data[i]
i += 1
dx = ((b >> 4) & 0x7) if not (b & 0x80) else -((b >> 4) & 0x7)
dy = (b & 0x7) if not (b & 0x08) else -(b & 0x7)
x += dx
y += dy
self.draw_pattern(x, y, self.pattern_size, (pattern_nr_byte >> 1) & 0x7f, self.pattern_use_pattern, self.pattern_is_rect)
elif opcode == PIC_OP_ABSOLUTE_PATTERNS:
while i < len(data) and data[i] < 0xF0:
pattern_nr_byte = 0
if self.pattern_use_pattern and i < len(data) and data[i] < 0xF0:
pattern_nr_byte = data[i]
i += 1
x, y, i = self.read_abs_coords(data, i)
self.draw_pattern(x, y, self.pattern_size, (pattern_nr_byte >> 1) & 0x7f, self.pattern_use_pattern, self.pattern_is_rect)
elif opcode == PIC_OP_RELATIVE_MEDIUM_PATTERNS:
pattern_nr_byte = 0
if self.pattern_use_pattern and i < len(data) and data[i] < 0xF0:
pattern_nr_byte = data[i]
i += 1
x, y, i = self.read_abs_coords(data, i)
self.draw_pattern(x, y, self.pattern_size, (pattern_nr_byte >> 1) & 0x7f, self.pattern_use_pattern, self.pattern_is_rect)
while i < len(data) and data[i] < 0xF0:
pattern_nr_byte = 0
if self.pattern_use_pattern and i < len(data) and data[i] < 0xF0:
pattern_nr_byte = data[i]
i += 1
by = data[i]
bx = data[i + 1]
i += 2
dy = (by & 0x7F) if not (by & 0x80) else -(by & 0x7F)
dx = bx if bx < 128 else bx - 256
x += dx
y += dy
self.draw_pattern(x, y, self.pattern_size, (pattern_nr_byte >> 1) & 0x7f, self.pattern_use_pattern, self.pattern_is_rect)
elif opcode == PIC_OP_SET_PATTERN:
if i < len(data) and data[i] < 0xF0:
val = data[i]
i += 1
val &= 0x37 # Mask to valid bits as SCICompanion does
self.pattern_size = val & 0x07
self.pattern_is_rect = bool(val & PATTERN_FLAG_RECTANGLE)
self.pattern_use_pattern = bool(val & PATTERN_FLAG_USE_PATTERN)
elif opcode == PIC_OP_OPX:
if i >= len(data):
break
ext = data[i]
i += 1
if ext == 0x00:
# PIC_OPX_SET_PALETTE_ENTRY: variable length pairs of (index, color)
while i < len(data) and data[i] < 0xF0:
b_index = data[i]
b_color = data[i + 1]
i += 2
pal_num = b_index // 40
pal_offset = b_index % 40
if pal_num < 4:
# color1 is high nibble, color2 is low nibble
color1 = (b_color >> 4) & 0x0f
color2 = b_color & 0x0f
self.palettes[pal_num][pal_offset] = (color1, color2)
if pal_num == 0:
self.locked[pal_offset] = 0xff
elif ext == 0x01:
# PIC_OPX_SET_PALETTE: 1 byte palette number + 40 color bytes
pal_num = data[i]
i += 1
for j in range(40):
b_color = data[i]
i += 1
color1 = (b_color >> 4) & 0x0f
color2 = b_color & 0x0f
self.palettes[pal_num][j] = (color1, color2)
# If this palette is the one currently in use, update egaColor
if pal_num == self.palette_number:
self.ega_color = self.palettes[pal_num][self.palette_offset]
elif ext == 0x08:
# PIC_OPX_SET_PRIORITY_TABLE: 14 bytes
i += 14
else:
# Unknown extended opcode, try to skip gracefully
pass
else:
break
visual_img = Image.new('RGB', (WIDTH, HEIGHT))
for py in range(HEIGHT):
for px in range(WIDTH):
visual_img.putpixel((px, py), EGA_COLORS[self.visual[py, px]])
priority_img = Image.fromarray(self.priority * 17, mode='L')
control_img = Image.fromarray(self.control * 17, mode='L')
return visual_img, priority_img, control_img
class SCIPictureExtractor:
def __init__(self, game_dir: str):
self.game_dir = Path(game_dir)
import importlib.util
spec = importlib.util.spec_from_file_location(
"extract_sci_text",
self.game_dir.parent.parent / "extract_sci_text.py"
)
if spec and spec.loader:
self.extractor_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.extractor_module)
self.extractor = self.extractor_module.SCIResourceExtractor(game_dir)
else:
raise ImportError("Could not load extract_sci_text module")
def extract_pic(self, pic_number: int, output_dir: str = ".") -> Optional[Tuple[str, str, str]]:
resources = self.extractor.read_resource_map()
pic_resources = [r for r in resources if r['type'] == 1 and r['number'] == pic_number]
if not pic_resources:
print(f"PIC {pic_number} not found")
return None
res = pic_resources[0]
print(f"Extracting PIC {pic_number} from package {res['package']}...")
header = self.extractor.read_resource_header(res['package'], res['offset'])
if not header:
print(f"Failed to read header for PIC {pic_number}")
return None
print(f" Compression: {header['method_name']}, Size: {header['decompressed_size']} bytes")
data = self.extractor.extract_resource_data(
res['package'],
res['offset'],
header['compressed_size'],
header['decompressed_size'],
header['method']
)
if not data:
print(f"Failed to extract data for PIC {pic_number}")
return None
renderer = PictureRenderer()
visual_img, priority_img, control_img = renderer.render(data)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
base_name = f"pic_{pic_number:03d}"
visual_path = output_path / f"{base_name}_visual.png"
priority_path = output_path / f"{base_name}_priority.png"
control_path = output_path / f"{base_name}_control.png"
visual_img.save(visual_path)
priority_img.save(priority_path)
control_img.save(control_path)
print(f" Saved: {visual_path}")
return (str(visual_path), str(priority_path), str(control_path))
def main():
import argparse
parser = argparse.ArgumentParser(description="Extract SCI picture resources")
parser.add_argument("--pic", type=int, help="Specific PIC number to extract")
parser.add_argument("--output", "-o", default="pics", help="Output directory")
parser.add_argument("--game-dir", "-g", default="King's Quest IV - The Perils of Rosella (1988)/KQ4",
help="Game directory")
args = parser.parse_args()
if not os.path.exists(args.game_dir):
print(f"Error: Game directory not found: {args.game_dir}")
sys.exit(1)
print(f"Extracting pictures from: {args.game_dir}")
extractor = SCIPictureExtractor(args.game_dir)
if args.pic is not None:
extractor.extract_pic(args.pic, args.output)
else:
resources = extractor.extractor.read_resource_map()
pic_resources = sorted(set(r['number'] for r in resources if r['type'] == 1))
print(f"Found {len(pic_resources)} PIC resources")
for pic_num in pic_resources:
extractor.extract_pic(pic_num, args.output)
print(f"\nExtraction complete! Images saved to {args.output}/")
if __name__ == "__main__":
main()