Last active
September 21, 2023 10:58
-
-
Save AndywinXp/7e42a8b107177f783c7286feeee090b8 to your computer and use it in GitHub Desktop.
My SWORDRES.RIF analyzer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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