Skip to content

Instantly share code, notes, and snippets.

@AndywinXp
Last active September 21, 2023 10:58
Show Gist options
  • Save AndywinXp/7e42a8b107177f783c7286feeee090b8 to your computer and use it in GitHub Desktop.
Save AndywinXp/7e42a8b107177f783c7286feeee090b8 to your computer and use it in GitHub Desktop.
My SWORDRES.RIF analyzer
import tkinter as tk
from tkinter import ttk, font
from tkinter import filedialog
import struct
import os
# Make the application DPI-aware (for Windows, jeez...)
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
is_windows = True
except:
is_windows = False
pass
class ResMan:
def __init__(self):
self._prj = {}
self.palette = None
def get_data(self):
return self._prj
def load_clu_descript(self, file_name):
# Store the directory of the .rif file for later use...
self.base_directory = os.path.dirname(file_name)
# Constants
MAX_LABEL_SIZE = 32
# Let's process the file!
with open(file_name, 'rb') as file:
# Read number of clusters.
self._prj['noClu'] = struct.unpack('<I', file.read(4))[0]
self._prj['clu'] = [{} for _ in range(self._prj['noClu'])]
# Read cluster index...
clu_index = struct.unpack('<' + 'I' * self._prj['noClu'], file.read(4 * self._prj['noClu']))
# Process clusters...
for clus_cnt, index in enumerate(clu_index):
if index:
cluster = self._prj['clu'][clus_cnt]
cluster['label'] = file.read(MAX_LABEL_SIZE).decode('utf-8').rstrip('\0')
cluster['noGrp'] = struct.unpack('<I', file.read(4))[0]
cluster['grp'] = [{} for _ in range(cluster['noGrp'])]
# Read group index...
grp_index = struct.unpack('<' + 'I' * cluster['noGrp'], file.read(4 * cluster['noGrp']))
# Process groups...
for grp_cnt, g_index in enumerate(grp_index):
if g_index:
group = cluster['grp'][grp_cnt]
group['noRes'] = struct.unpack('<I', file.read(4))[0]
group['offset'] = []
group['length'] = []
# Read resource ID index...
res_id_idx = struct.unpack('<' + 'I' * group['noRes'], file.read(4 * group['noRes']))
# Process resources...
for res_idx in res_id_idx:
if res_idx:
group['offset'].append(struct.unpack('<I', file.read(4))[0])
group['length'].append(struct.unpack('<I', file.read(4))[0])
else:
group['offset'].append(0xFFFFFFFF)
group['length'].append(0)
# This saves a description of the RIF file to text file
def save_to_file(self, output_file):
with open(output_file, 'w') as out:
for cluster in self._prj['clu']:
out.write(f"Cluster: {cluster['label']} (noRes: {len(cluster['grp'])}):\n")
for idx, group in enumerate(cluster['grp']):
out.write(f" Group {idx} (noRes: {group['noRes']}):\n")
for offset, length in zip(group['offset'], group['length']):
out.write(f" Offset: 0x{offset:08x}\n Length: {length}\n\n")
out.write("\n\n")
def read_cluster_file(self, cluster_label, offset, length):
# Generate the path to the cluster file
clu_path = os.path.join(self.base_directory, f"{cluster_label}.CLU")
with open(clu_path, 'rb') as file:
file.seek(offset)
data = file.read(length)
# HACK! Palette resources have absolutely no indication
# that they are what they are, except from the size, which is
# 256 * 3
# Basically if we select a palette resource, and then we select
# a sprite near it, we can show the correct paletted image
if length == 768:
file.seek(offset)
self.palette = file.read(length)
return data
def rle0_decompress(self, source, compsize):
dest = []
idx = 0
while idx < compsize:
color = source[idx]
idx += 1
# Check if it's a single pixel or a color run
if color != 0:
# Single pixel
dest.append(color)
else:
# Skip run
skip = source[idx]
idx += 1
dest.extend([0] * (skip))
return dest
def rle7_decompress(self, source, compsize):
dest = []
idx = 0
while idx < compsize:
colourlength = source[idx]
# Check if it's a single pixel or a colour run
if colourlength > 127 or colourlength == 0:
# Single pixel
dest.append(colourlength)
idx += 1
else:
# Colour run
idx += 1
color = source[idx]
dest.extend([color] * (colourlength + 1))
idx += 1
return dest
def tony_decompress(self, source, compsize):
dest = []
source_idx = 0
data_end = compsize
while source_idx < data_end:
num_flat = source[source_idx]
source_idx += 1
if num_flat:
# Repeat the next byte num_flat times
value = source[source_idx]
source_idx += 1
dest.extend([value] * num_flat)
if source_idx < data_end:
num_no_flat = source[source_idx]
source_idx += 1
# Copy the next num_no_flat bytes directly
dest.extend(source[source_idx:source_idx + num_no_flat])
source_idx += num_no_flat
return dest
class ResManApp:
def __init__(self, root):
self.root = root
self.root.title("Broken Sword Resource Viewer")
self.res_man = ResMan()
self.mode = "None"
# Dynamically determine initial size
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Set a reasonable fraction of the screen size (e.g., 1/2 of screen width and 2/3 of screen height)
self.root.geometry(f"{(int)(screen_width//2*1.5)}x{(int)(screen_height*2//3*1.5)}")
# Set up a Menu Bar
self.menubar = tk.Menu(root)
self.file_menu = tk.Menu(self.menubar, tearoff=0)
self.file_menu.add_command(label="Open File", command=self.open_file)
self.file_menu.add_separator()
self.file_menu.add_command(label="Exit", command=root.quit)
self.menubar.add_cascade(label="File", menu=self.file_menu)
self.root.config(menu=self.menubar)
# PanedWindow for TreeView and Details
self.paned_window = tk.PanedWindow(root, orient=tk.HORIZONTAL, sashrelief=tk.RAISED, sashwidth=20)
self.paned_window.pack(fill=tk.BOTH, expand=True)
# Left panel: TreeView with scrollbar
self.tree_frame = ttk.Frame(self.paned_window)
self.tree = ttk.Treeview(self.tree_frame)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, ipadx=70, expand=True)
self.tree.heading("#0", text="Clusters & Groups")
self.tree.bind("<<TreeviewSelect>>", self.on_tree_select)
self.tree_scroll = ttk.Scrollbar(self.tree_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.configure(yscrollcommand=self.tree_scroll.set)
self.paned_window.add(self.tree_frame)
# Right panel: Split into two (upper and lower)
self.right_paned_window = tk.PanedWindow(self.paned_window, width=screen_width//4, orient=tk.VERTICAL)
self.sprite_label = tk.Label(self.right_paned_window, text="Element at offset:")
self.sprite_label.pack(side=tk.LEFT, padx=(0, 5))
self.paned_window.add(self.right_paned_window)
# Upper right: Resource info table
self.top_frame = ttk.Frame(self.right_paned_window)
self.right_paned_window.add(self.top_frame)
# Define the table to display header content
self.header_table = ttk.Treeview(self.top_frame, columns=('Field', 'Value'), show='headings')
self.header_table.heading('Field', text='Field')
self.header_table.heading('Value', text='Value')
self.header_table.pack(fill=tk.BOTH, expand=True)
# Lower right: Content display
self.bottom_frame = None
self.build_empty_panel()
# Configure Treeview Style for more vertical padding
style = ttk.Style()
if is_windows:
style.configure("Treeview", rowheight=int(3.5 * default_font_size))
def build_empty_panel(self):
if self.bottom_frame is not None:
self.bottom_frame.destroy()
self.bottom_frame = ttk.Frame(self.right_paned_window)
self.empty_display_frame = tk.Frame(self.bottom_frame)
#self.empty_display_frame.pack(pady=10, fill=tk.BOTH, expand=True)
self.right_paned_window.add(self.bottom_frame)
def build_sprite_panel(self):
if self.bottom_frame is not None:
self.bottom_frame.destroy()
self.bottom_frame = ttk.Frame(self.right_paned_window)
# Define the dropdown for element offsets
self.sprite_label = tk.Label(self.bottom_frame, text="Element at offset:")
self.sprite_label.pack(padx=(0, 5))
self.element_dropdown = ttk.Combobox(self.bottom_frame, state="readonly")
self.element_dropdown.bind("<<ComboboxSelected>>", self.on_element_selected)
self.element_dropdown.pack(pady=10)
# Frame to hold the canvas and the scrollbars
self.sprite_display_frame = tk.Frame(self.bottom_frame)
self.sprite_display_frame.pack(fill=tk.BOTH, expand=True)
# Vertical Scrollbar for canvas
self.v_scroll = ttk.Scrollbar(self.sprite_display_frame, orient=tk.VERTICAL)
self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y)
# Horizontal Scrollbar for canvas
self.h_scroll = ttk.Scrollbar(self.sprite_display_frame, orient=tk.HORIZONTAL)
self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
# Canvas to display the sprite
self.sprite_canvas = tk.Canvas(self.sprite_display_frame,
#bg='white',
yscrollcommand=self.v_scroll.set,
xscrollcommand=self.h_scroll.set)
self.sprite_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Configure the scrollbars to work with the canvas
self.v_scroll.config(command=self.sprite_canvas.yview)
self.h_scroll.config(command=self.sprite_canvas.xview)
self.right_paned_window.add(self.bottom_frame)
def build_text_panel(self):
if self.bottom_frame is not None:
self.bottom_frame.destroy()
self.bottom_frame = ttk.Frame(self.right_paned_window)
# Define the table to display header content
self.text_table = ttk.Treeview(self.bottom_frame, columns=('Text Id', 'Text Line'), show='headings')
self.text_table.column('Text Id',stretch=tk.NO, width=100)
self.text_table.heading('Text Id', text='Text Id')
self.text_table.heading('Text Line', text='Text Line')
self.text_table.pack(fill=tk.BOTH, expand=True)
# Horizontal Scrollbar
self.h_scroll = ttk.Scrollbar(self.bottom_frame, orient=tk.HORIZONTAL)
self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
# Configure the scrollbars to work with the table
self.h_scroll.config(command=self.text_table.xview)
self.right_paned_window.add(self.bottom_frame)
def open_file(self):
file_path = filedialog.askopenfilename(title="Select swordres.rif", filetypes=[("RIF files", "*.rif"), ("All files", "*.*")])
if not file_path:
return
self.res_man.load_clu_descript(file_path)
self.populate_tree()
def populate_tree(self):
for cluster in self.res_man.get_data()['clu']:
cluster_id = self.tree.insert("", "end", text=f"Cluster: {cluster['label']} | noRes: {len(cluster['grp'])}")
for group in cluster['grp']:
group_id = self.tree.insert(cluster_id, "end", text=f"Group (noRes: {group['noRes']}):")
for offset, length in zip(group['offset'], group['length']):
self.tree.insert(group_id, "end", text=f"Offset: 0x{offset:08x}, Length: {length}")
def display_resource_data(self, data):
# Clear existing data
for row in self.header_table.get_children():
self.header_table.delete(row)
self.current_element_data = data
# Sprites!
try:
if data[:6].decode() == 'Sprite':
self.build_sprite_panel()
self.mode = "Sprite"
header_type = data[:6].decode('utf-8')
header_version = struct.unpack('H', data[6:6+2])
header_comp_len = struct.unpack('I', data[8:8+4])
header_comp_tag = data[12:12+4].decode('utf-8')
header_decomp_len = struct.unpack('I', data[16:16+4])
sprite_total_sprites = struct.unpack('i', data[20:20+4])[0]
values = [header_type, header_version, header_comp_len, header_comp_tag, header_decomp_len, sprite_total_sprites]
fields = ["Type", "Version", "Comp. Length", "Compression", "Decomp. Length", "Total Sprites"]
for field, value in zip(fields, values):
self.header_table.insert('', 'end', values=(field, value))
# Extract sprite offsets and populate the dropdown
start_index = 24 # Starting from after sprite_total_sprites
sprite_offsets = []
for _ in range(sprite_total_sprites):
offset = struct.unpack('I', data[start_index:start_index+4])[0]
sprite_offsets.append(offset)
start_index += 4
element_dropdown_values = [offset for offset in sprite_offsets]
self.element_dropdown['values'] = element_dropdown_values
if sprite_offsets:
self.element_dropdown.current(0) # Set the first item as default
# Text files!
elif data[:6].decode() == 'ChrTxt':
self.build_text_panel()
self.mode = "ChrTxt"
header_type = data[:6].decode('utf-8')
header_version = struct.unpack('H', data[6:6+2])
header_comp_len = struct.unpack('I', data[8:8+4])
header_comp_tag = data[12:12+4].decode('utf-8')
header_decomp_len = struct.unpack('I', data[16:16+4])
num_elements = struct.unpack('i', data[20:20+4])
values = [header_type, header_version, header_comp_len, header_comp_tag, header_decomp_len, num_elements]
fields = ["Type", "Version", "Comp. Length", "Compression", "Decomp. Length", "Total Elements"]
for field, value in zip(fields, values):
self.header_table.insert('', 'end', values=(field, value))
# Extract text offsets
start_index = 24 # Starting from after num_elements
txt_offsets = []
for _ in range(num_elements[0]):
offset = struct.unpack('I', data[start_index:start_index+4])[0]
txt_offsets.append(offset)
start_index += 4
txt_lines = []
for pos, off in enumerate(txt_offsets):
if off == 0:
txt_lines.append("")
continue
else:
off += 20
if len(txt_offsets) > pos + 1:
# Adjust the header, and other two bytes because I don't know
next_offset = txt_offsets[pos + 1] + 20
text_data = self.current_element_data[off:next_offset]
else:
text_data = self.current_element_data[off:]
txt_lines.append(text_data.decode('latin1'))
for field, value in zip(txt_offsets, txt_lines):
self.text_table.insert('', 'end', values=(field, value))
# Default case!
else:
self.build_empty_panel()
self.mode = "None"
print("Unknown resource:",data[:32])
except Exception as e:
print(e)
self.build_empty_panel()
self.mode = "None"
print("Unknown resource:",data[:32])
def on_element_selected(self, event=None):
if self.mode == "Sprite":
self.on_sprite_selected(event=event)
else:
pass
# On sprite selected, trigger the (embarassingly slow) drawing routine
def on_sprite_selected(self, event=None):
selected = self.element_dropdown.current()
selected_offset = int(self.element_dropdown['values'][selected]) # Extract offset from tuple
data = self.current_element_data[selected_offset:]
palette = self.res_man.palette
f_runtime_comp = data[:4].decode('utf-8')
f_comp_size = struct.unpack('I', data[4:4+4])[0]
f_width = struct.unpack('H', data[8:8+2])[0]
f_height = struct.unpack('H', data[10:10+2])[0]
f_offset_x = struct.unpack('h', data[12:12+2])[0]
f_offset_y = struct.unpack('h', data[14:14+2])[0]
fields = ["Frame Compression", "Frame Comp. Length", "Frame Dimensions", "Frame Offsets"]
values = [f_runtime_comp, f_comp_size, f"({f_width}, {f_height})", f"({f_offset_x}, {f_offset_y})"]
for field, value in zip(fields, values):
self.header_table.insert('', 'end', values=(field, value))
print(f"Selected: {selected}, runtimecomp: {f_runtime_comp}, compsize: {f_comp_size}, w: {f_width}, h: {f_height}")
#self.scale_image_on_canvas(2)
print(f"offsets ({f_offset_x}, {f_offset_y})")
self.draw_sprite_on_canvas(f_runtime_comp, f_comp_size, f_width, f_height, 10, 10, data[16:], palette)
#self.scale_image_on_canvas(2)
def scale_image_on_canvas(self, scale_factor):
# Apply scale to the canvas
self.sprite_canvas.scale("all", 0, 0, scale_factor, scale_factor)
# Adjust canvas scroll region (this may be needed based on your setup)
self.sprite_canvas.config(scrollregion=self.sprite_canvas.bbox("all"))
def blit_sprite(self, photo, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette=None):
# Iterate over the sprite data to set each pixel
for y in range(f_height):
for x in range(f_width):
if palette == None:
pixel_value = sprite_data[y * f_width + x]
color = "#{0:02x}{0:02x}{0:02x}".format(pixel_value)
photo.put(color, (x, y))
else:
palette_index = sprite_data[y * f_width + x]
# Check for the transparency index
if palette_index == 0:
continue
# Extract the RGB values from the palette for the given index
r, g, b = palette[palette_index * 3], palette[palette_index * 3 + 1], palette[palette_index * 3 + 2]
# Palette is 6-bit, convert it to 8-bit!
r = r << 2
g = g << 2
b = b << 2
# Convert the RGB values to a color string
color = "#{:02x}{:02x}{:02x}".format(r, g, b)
# Update the pixel color in the PhotoImage
photo.put(color, (x, y))
def draw_sprite_on_canvas(self, f_runtime_comp, f_comp_size, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette=None):
# Clear the previous canvas
self.sprite_canvas.delete("all")
# Create a blank PhotoImage
photo = tk.PhotoImage(width=f_width, height=f_height)
y_range = range(f_height)
if f_runtime_comp == "JIM ":
sprite_data = self.res_man.tony_decompress(sprite_data, f_comp_size)
self.blit_sprite(photo, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette)
elif f_runtime_comp == "RLE0":
sprite_data = self.res_man.rle0_decompress(sprite_data, f_comp_size)
self.blit_sprite(photo, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette)
elif f_runtime_comp == "RLE7":
sprite_data = self.res_man.rle7_decompress(sprite_data, f_comp_size)
self.blit_sprite(photo, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette)
elif f_runtime_comp == "NONE":
self.blit_sprite(photo, f_width, f_height, f_offset_x, f_offset_y, sprite_data, palette)
# Display the PhotoImage on the canvas
self.sprite_canvas.create_image(f_offset_x, f_offset_y, image=photo, anchor=tk.NW)
# This is needed to prevent garbage collection of the image
self.sprite_canvas._photo = photo
def on_tree_select(self, event):
item = self.tree.selection()[0]
parent = self.tree.parent(item)
grandparent = self.tree.parent(parent)
#print(self.tree.item(item, 'text'))
#print(self.tree.item(parent, 'text'))
#print(self.tree.item(grandparent, 'text'))
if grandparent: # Ensures we are at the 'Resource' level in the tree hierarchy.
offset_hex, length = self.tree.item(item, 'text').split(", ")
offset = int(offset_hex.split(": ")[1], 16)
length = int(length.split(": ")[1])
cluster_label = self.tree.item(grandparent, 'text').split("|")[0].split(": ")[1].strip()
# Fetch the data from the appropriate cluster file
data = self.res_man.read_cluster_file(cluster_label, offset, length)
self.display_resource_data(data)
if __name__ == "__main__":
root = tk.Tk()
# Set scaling
default_font_size = tk.font.nametofont("TkDefaultFont").actual()["size"]
scale_factor = root.winfo_fpixels(f"{default_font_size}p") / default_font_size
root.tk.call('tk', 'scaling', scale_factor)
app = ResManApp(root)
root.mainloop()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment