567 lines
23 KiB
Python
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()
|