#!/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()