You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

437 lines
16 KiB

#!/usr/bin/env python3
"""
MPQ Inspector - A GUI tool to inspect MPQ archives
Uses tkinter for the GUI and PyStorm for MPQ operations
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from pathlib import Path
import sys
from typing import Optional
try:
from pystorm import MPQArchive, StormLibError
except ImportError:
print("Error: PyStorm not installed. Please run: pip install -e .")
sys.exit(1)
class MPQInspectorApp:
"""Main application window for MPQ Inspector"""
def __init__(self, root):
self.root = root
self.root.title("MPQ Inspector - PyStorm Demo")
self.root.geometry("900x700")
self.current_archive: Optional[MPQArchive] = None
self.current_path: Optional[str] = None
self.setup_ui()
def setup_ui(self):
"""Setup the user interface"""
# Menu bar
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open MPQ...", command=self.open_archive, accelerator="Ctrl+O")
file_menu.add_command(label="Close Archive", command=self.close_archive, accelerator="Ctrl+W")
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.root.quit, accelerator="Ctrl+Q")
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="About", command=self.show_about)
# Bind keyboard shortcuts
self.root.bind('<Control-o>', lambda e: self.open_archive())
self.root.bind('<Control-w>', lambda e: self.close_archive())
self.root.bind('<Control-q>', lambda e: self.root.quit())
# Main container
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(2, weight=1)
# Top bar - File selection
top_frame = ttk.LabelFrame(main_frame, text="Archive", padding="5")
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
top_frame.columnconfigure(1, weight=1)
ttk.Label(top_frame, text="File:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5))
self.file_path_var = tk.StringVar(value="No archive loaded")
file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, state='readonly')
file_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
ttk.Button(top_frame, text="Browse...", command=self.open_archive).grid(row=0, column=2)
# Stats bar
stats_frame = ttk.LabelFrame(main_frame, text="Statistics", padding="5")
stats_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
self.stats_text = tk.StringVar(value="No archive loaded")
ttk.Label(stats_frame, textvariable=self.stats_text).grid(row=0, column=0, sticky=tk.W)
# File list frame
list_frame = ttk.LabelFrame(main_frame, text="Files in Archive", padding="5")
list_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
# Create treeview for file list
columns = ('size', 'compressed', 'ratio', 'flags')
self.tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
# Define headings
self.tree.heading('#0', text='Filename')
self.tree.heading('size', text='Size')
self.tree.heading('compressed', text='Compressed')
self.tree.heading('ratio', text='Ratio')
self.tree.heading('flags', text='Flags')
# Define column widths
self.tree.column('#0', width=400)
self.tree.column('size', width=100, anchor='e')
self.tree.column('compressed', width=100, anchor='e')
self.tree.column('ratio', width=80, anchor='e')
self.tree.column('flags', width=80, anchor='center')
# Scrollbars
vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(list_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.E, tk.W))
vsb.grid(row=0, column=1, sticky=(tk.N, tk.S))
hsb.grid(row=1, column=0, sticky=(tk.E, tk.W))
# Context menu for tree
self.tree_menu = tk.Menu(self.root, tearoff=0)
self.tree_menu.add_command(label="Extract File...", command=self.extract_selected_file)
self.tree_menu.add_command(label="View File Info", command=self.show_file_info)
self.tree.bind('<Button-3>', self.show_context_menu)
self.tree.bind('<Double-1>', self.show_file_info)
# Bottom buttons
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=(10, 0))
ttk.Button(button_frame, text="Extract All...", command=self.extract_all_files).pack(side=tk.LEFT, padx=(0, 5))
ttk.Button(button_frame, text="Refresh", command=self.refresh_file_list).pack(side=tk.LEFT, padx=(0, 5))
ttk.Button(button_frame, text="Verify Archive", command=self.verify_archive).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Close", command=self.root.quit).pack(side=tk.RIGHT)
def format_size(self, size_bytes):
"""Format size in bytes to human-readable format"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def open_archive(self):
"""Open an MPQ archive"""
filename = filedialog.askopenfilename(
title="Select MPQ Archive",
filetypes=[
("MPQ Archives", "*.mpq"),
("SC2 Archives", "*.SC2Data *.SC2Map *.SC2Mod"),
("All Files", "*.*")
]
)
if not filename:
return
try:
# Close current archive if any
self.close_archive()
# Open new archive
self.current_archive = MPQArchive(filename)
self.current_path = filename
# Update UI
self.file_path_var.set(filename)
self.refresh_file_list()
except StormLibError as e:
messagebox.showerror("Error", f"Failed to open archive:\n{str(e)}")
except Exception as e:
messagebox.showerror("Error", f"Unexpected error:\n{str(e)}")
def close_archive(self):
"""Close the current archive"""
if self.current_archive:
try:
self.current_archive.close()
except:
pass
self.current_archive = None
self.current_path = None
self.file_path_var.set("No archive loaded")
self.stats_text.set("No archive loaded")
# Clear tree
for item in self.tree.get_children():
self.tree.delete(item)
def refresh_file_list(self):
"""Refresh the file list from the current archive"""
if not self.current_archive:
return
# Clear existing items
for item in self.tree.get_children():
self.tree.delete(item)
try:
# Get all files
files = self.current_archive.find_files("*")
# Update stats
total_size = sum(f['size'] for f in files)
total_compressed = sum(f['compressed_size'] for f in files)
if total_size > 0:
ratio = (1 - total_compressed / total_size) * 100
else:
ratio = 0
self.stats_text.set(
f"Files: {len(files)} | "
f"Total Size: {self.format_size(total_size)} | "
f"Compressed: {self.format_size(total_compressed)} | "
f"Ratio: {ratio:.1f}%"
)
# Add files to tree
for file_info in sorted(files, key=lambda x: x['name']):
name = file_info['name']
size = file_info['size']
compressed = file_info['compressed_size']
flags = file_info['flags']
# Calculate compression ratio
if size > 0:
file_ratio = (1 - compressed / size) * 100
else:
file_ratio = 0
# Format flags
flag_str = []
if flags & 0x00000200: # MPQ_FILE_COMPRESS
flag_str.append("C")
if flags & 0x00010000: # MPQ_FILE_ENCRYPTED
flag_str.append("E")
if flags & 0x01000000: # MPQ_FILE_SINGLE_UNIT
flag_str.append("S")
flag_display = "".join(flag_str) or "-"
self.tree.insert('', 'end', text=name, values=(
self.format_size(size),
self.format_size(compressed),
f"{file_ratio:.1f}%",
flag_display
))
except Exception as e:
messagebox.showerror("Error", f"Failed to read archive:\n{str(e)}")
def show_context_menu(self, event):
"""Show context menu for tree item"""
if not self.current_archive:
return
item = self.tree.identify_row(event.y)
if item:
self.tree.selection_set(item)
self.tree_menu.post(event.x_root, event.y_root)
def extract_selected_file(self):
"""Extract the selected file"""
if not self.current_archive:
return
selection = self.tree.selection()
if not selection:
messagebox.showwarning("Warning", "Please select a file to extract")
return
item = selection[0]
filename = self.tree.item(item, 'text')
# Ask where to save
output_path = filedialog.asksaveasfilename(
title="Save File As",
initialfile=Path(filename).name,
defaultextension="*",
filetypes=[("All Files", "*.*")]
)
if not output_path:
return
try:
self.current_archive.extract_file(filename, output_path)
messagebox.showinfo("Success", f"File extracted to:\n{output_path}")
except Exception as e:
messagebox.showerror("Error", f"Failed to extract file:\n{str(e)}")
def extract_all_files(self):
"""Extract all files from the archive"""
if not self.current_archive:
messagebox.showwarning("Warning", "No archive loaded")
return
# Ask for output directory
output_dir = filedialog.askdirectory(title="Select Output Directory")
if not output_dir:
return
output_dir = Path(output_dir)
try:
files = self.current_archive.find_files("*")
total = len(files)
if total == 0:
messagebox.showinfo("Info", "No files to extract")
return
# Create progress window
progress_window = tk.Toplevel(self.root)
progress_window.title("Extracting Files")
progress_window.geometry("400x120")
progress_window.transient(self.root)
progress_window.grab_set()
ttk.Label(progress_window, text="Extracting files...").pack(pady=(10, 5))
progress_var = tk.StringVar(value="0 / 0")
ttk.Label(progress_window, textvariable=progress_var).pack()
progress = ttk.Progressbar(progress_window, length=350, mode='determinate', maximum=total)
progress.pack(pady=10)
extracted = 0
failed = 0
for i, file_info in enumerate(files, 1):
filename = file_info['name']
output_path = output_dir / filename
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
self.current_archive.extract_file(filename, str(output_path))
extracted += 1
except:
failed += 1
progress['value'] = i
progress_var.set(f"{i} / {total}")
progress_window.update()
progress_window.destroy()
messagebox.showinfo(
"Extraction Complete",
f"Extracted: {extracted}\nFailed: {failed}\n\nOutput directory:\n{output_dir}"
)
except Exception as e:
messagebox.showerror("Error", f"Extraction failed:\n{str(e)}")
def show_file_info(self, event=None):
"""Show detailed information about selected file"""
if not self.current_archive:
return
selection = self.tree.selection()
if not selection:
return
item = selection[0]
filename = self.tree.item(item, 'text')
values = self.tree.item(item, 'values')
# Create info window
info_window = tk.Toplevel(self.root)
info_window.title(f"File Info - {Path(filename).name}")
info_window.geometry("500x300")
info_window.transient(self.root)
# Create text widget
text = scrolledtext.ScrolledText(info_window, wrap=tk.WORD, font=('Courier', 10))
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Add info
info_text = f"""File Information
{'='*60}
Filename: {filename}
Size: {values[0]}
Compressed Size: {values[1]}
Compression Ratio: {values[2]}
Flags: {values[3]}
Archive: {self.current_path}
"""
text.insert('1.0', info_text)
text.config(state=tk.DISABLED)
# Add close button
ttk.Button(info_window, text="Close", command=info_window.destroy).pack(pady=(0, 10))
def verify_archive(self):
"""Verify the archive integrity"""
if not self.current_archive:
messagebox.showwarning("Warning", "No archive loaded")
return
try:
result = self.current_archive.verify()
if result == 0:
messagebox.showinfo("Verification", "Archive verification successful!\n\nThe archive is valid.")
else:
messagebox.showwarning("Verification", f"Archive verification failed!\n\nError code: {result}")
except Exception as e:
messagebox.showerror("Error", f"Verification failed:\n{str(e)}")
def show_about(self):
"""Show about dialog"""
about_text = """MPQ Inspector
Version 1.0.0
A GUI tool for inspecting MPQ archives using PyStorm.
PyStorm: Python bindings for StormLib
StormLib: Created by Ladislav Zezula
Licensed under MIT License
"""
messagebox.showinfo("About MPQ Inspector", about_text)
def main():
"""Main entry point"""
root = tk.Tk()
app = MPQInspectorApp(root)
root.mainloop()
if __name__ == "__main__":
main()