From 9b0df32ad962690d52d3645186f704c4d9be2644 Mon Sep 17 00:00:00 2001 From: ApfelTeeSaft <91074565+ApfelTeeSaft@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:04:33 +0100 Subject: [PATCH] Initial Version --- README.md | 87 +++++ saga-resexplorer.py | 921 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1008 insertions(+) create mode 100644 README.md create mode 100644 saga-resexplorer.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..231279e --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# SAGA-ResExplorer + +A small Windows-focused GUI tool to explore, extract, preview, and **repack** SAGA engine `.RES` archives (tested with **I Have No Mouth, and I Must Scream** via ScummVM). + +## Features + +- Browse extracted `.RES` contents in a tree (`RES → records`) +- Guess file types (WAV/PNG/MP3/OGG/FLAC/…) +- Preview: + - PNG viewer + - Audio playback (WAV via Windows `winsound`, other formats via VLC) + - Hex preview for unknown files +- Extract all `.RES` files using ScummVM tools (`saga_unpack.exe`) +- Repack `.RES` files and override specific records (for fandubs / audio replacements) +- Optional: install patched `.RES` back into the game folder with automatic `.bak` backup + +## Requirements + +- **Windows** +- Python 3.10+ recommended +- ScummVM tools (Windows build) from: + - https://www.scummvm.org/downloads/#tools + +Optional (for MP3/OGG/FLAC playback in the inspector): +- VLC media player +- `python-vlc`: + ```bat + py -m pip install python-vlc + +> WAV playback works without VLC (uses the built-in `winsound` module). + +## Setup + +1. Download ScummVM tools from: + + * [https://www.scummvm.org/downloads/#tools](https://www.scummvm.org/downloads/#tools) + +2. Extract the tools archive somewhere on your PC. + +3. Place the Python script from this repo (`res_browser.py`) **in the same folder** as the ScummVM tools `.exe` files (so it can find `saga_unpack.exe`), e.g.: + + ``` + scummvm-tools-...\ + saga_unpack.exe + ... + res_browser.py + ``` + +## Running + +From the tools folder: + +```bat +py res_browser.py +``` + +## Usage (IHNM example) + +* Click **Choose Folder…** + + * Select your game folder (the one containing `VOICES*.RES`), **or** the `ScummVM` folder from the GOG layout (the app will try to detect `..\ihnm.ini` and use its `path=`). + +* Click **Extract All .RES** + + * Extracted files go to: `\extracted\\` + +* Find the line/record you want in the left tree and use **Play** in the inspector. + +* To replace a line: + + * Select the record (e.g. `123.bin`) + * Click **Override selected record…** + * A rebuilt file is written to: `\patched_res\.patched.res` + +* To install: + + * Click **Install patched RES…** + * The original `.RES` is backed up as `.bak`. + +## Notes / Tips + +* For safest compatibility, use **PCM WAV** (16-bit) for replacements. +* If you only want to replace one monologue/sequence, override just the relevant record(s) and install the patched `.RES`. + +## License + +This project is licensed under the **GNU General Public License v3.0 (GPL-3.0)**. diff --git a/saga-resexplorer.py b/saga-resexplorer.py new file mode 100644 index 0000000..ed139e3 --- /dev/null +++ b/saga-resexplorer.py @@ -0,0 +1,921 @@ +import os +import re +import struct +import shutil +import subprocess +import threading +import tempfile +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +try: + import winsound + HAS_WINSOUND = True +except Exception: + HAS_WINSOUND = False + +# VLC backend for mp3/ogg/flac/etc. +try: + import vlc # pip install python-vlc + HAS_VLC = True +except Exception: + HAS_VLC = False + vlc = None + +APP_TITLE = "SAGA .RES Browser / Extractor + Repacker (IHNM)" +EXTRACT_ROOT_NAME = "extracted" +PATCH_ROOT_NAME = "patched_res" # where we store rebuilt/patched .RES files + + +INDEX_RE = re.compile(r"^(\d+)\.(.+)$") + + +def parse_ini_value(path: str, section: str, key: str): + cur = None + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line or line.startswith(";") or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + cur = line[1:-1].strip().lower() + continue + if cur == section.lower(): + if "=" in line: + k, v = line.split("=", 1) + if k.strip().lower() == key.lower(): + return v.strip() + except Exception: + return None + return None + + +def try_detect_game_path_from_scummvm_folder(scummvm_folder: str): + r""" + If user selects ...\IHNMAIMS\ScummVM, this tries to read ..\ihnm.ini and find [ihnm] path=... + Returns game_path or None. + """ + ini = os.path.abspath(os.path.join(scummvm_folder, "..", "ihnm.ini")) + if not os.path.isfile(ini): + return None + game_path = parse_ini_value(ini, "ihnm", "path") + if game_path: + return game_path + return None + + +# ------------------------- +# File type guessing +# ------------------------- +def guess_type_and_ext(path: str): + try: + with open(path, "rb") as f: + head = f.read(64) + except Exception: + return ("unreadable", None) + + def starts(b: bytes) -> bool: + return head.startswith(b) + + # WAV: RIFF....WAVE + if len(head) >= 12 and head[0:4] == b"RIFF" and head[8:12] == b"WAVE": + return ("wav", ".wav") + + # PNG + if starts(b"\x89PNG\r\n\x1a\n"): + return ("png", ".png") + + # JPG + if starts(b"\xFF\xD8\xFF"): + return ("jpg", ".jpg") + + # GIF + if starts(b"GIF87a") or starts(b"GIF89a"): + return ("gif", ".gif") + + # BMP + if starts(b"BM"): + return ("bmp", ".bmp") + + # OGG + if starts(b"OggS"): + return ("ogg", ".ogg") + + # FLAC + if starts(b"fLaC"): + return ("flac", ".flac") + + # MP3 (ID3 tag) or frame sync + if starts(b"ID3"): + return ("mp3", ".mp3") + if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0: + return ("mp3", ".mp3") + + # ZIP + if starts(b"PK\x03\x04") or starts(b"PK\x05\x06") or starts(b"PK\x07\x08"): + return ("zip", ".zip") + + # PDF + if starts(b"%PDF"): + return ("pdf", ".pdf") + + # Rough text heuristic + stripped = head.lstrip() + if stripped.startswith(b"{") or stripped.startswith(b"["): + return ("json?", ".json") + + if all((c in b"\t\r\n" or 32 <= c < 127) for c in head[:32]) and len(head) > 0: + return ("text?", ".txt") + + return ("bin", None) + + +def human_size(n: int) -> str: + units = ["B", "KB", "MB", "GB", "TB"] + size = float(n) + for u in units: + if size < 1024.0: + return f"{size:.1f} {u}" if u != "B" else f"{int(size)} {u}" + size /= 1024.0 + return f"{size:.1f} PB" + + +def read_hex_preview(path: str, max_bytes=4096) -> str: + try: + with open(path, "rb") as f: + data = f.read(max_bytes) + except Exception as e: + return f"" + + lines = [] + for off in range(0, len(data), 16): + chunk = data[off:off + 16] + hexpart = " ".join(f"{b:02X}" for b in chunk) + ascpart = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{off:08X} {hexpart:<47} {ascpart}") + if len(data) == max_bytes: + lines.append("... (truncated)") + return "\n".join(lines) + + +# ------------------------- +# VLC wrapper +# ------------------------- +class VLCPlayer: + def __init__(self): + self.instance = None + self.player = None + if HAS_VLC: + try: + self.instance = vlc.Instance() + self.player = self.instance.media_player_new() + self.player.audio_set_volume(100) + except Exception: + self.instance = None + self.player = None + + def available(self): + return self.player is not None + + def load(self, path: str): + if not self.available(): + return False + try: + media = self.instance.media_new_path(path) + self.player.set_media(media) + self.player.audio_set_volume(100) + return True + except Exception: + return False + + def play(self): + if not self.available(): + return False + try: + self.player.play() + return True + except Exception: + return False + + def stop(self): + if not self.available(): + return False + try: + self.player.stop() + return True + except Exception: + return False + + +# ------------------------- +# RES repacker +# ------------------------- +def find_res_table(res_path: str, max_records=40000): + """ + Detect SAGA .RES layout: + [records blob...][8 bytes header][N * (offset,size)] + We brute-force N and verify: + - uint32 at table_offset equals N + - offsets/sizes point before table_offset + - first record offset == 0 + - offsets non-decreasing + Returns: (table_offset, n_records, header8_bytes) + """ + with open(res_path, "rb") as f: + data = f.read() + size = len(data) + + for n in range(1, max_records + 1): + table_offset = size - (8 + n * 8) + if table_offset < 0: + break + n_in_file = struct.unpack_from(" table_offset: + ok = False + break + if off + sz > table_offset: + ok = False + break + + if prev_off is not None and off < prev_off: + ok = False + break + prev_off = off + + if ok and first_off == 0: + return table_offset, n, header8 + + raise ValueError("Could not detect RES table layout (unsupported variant?)") + + +def build_index_to_file_map(extracted_dir: str): + """ + Accepts files like 0.bin, 0.wav, 1.bin, 12.mp3 ... + Returns dict[index] = filepath. If duplicates exist, prefers non-bin. + """ + candidates = {} + for name in os.listdir(extracted_dir): + full = os.path.join(extracted_dir, name) + if not os.path.isfile(full): + continue + m = INDEX_RE.match(name) + if not m: + continue + idx = int(m.group(1)) + ext = m.group(2).lower() + candidates.setdefault(idx, []).append((ext, full)) + + mapping = {} + for idx, lst in candidates.items(): + # prefer not-bin + lst_sorted = sorted(lst, key=lambda t: (t[0] == "bin", t[0])) + mapping[idx] = lst_sorted[0][1] + return mapping + + +def repack_res(original_res: str, extracted_dir: str, output_res: str, replacements=None): + """ + Rebuild .RES: payloads in record order + original 8-byte header + new (off,sz) table. + replacements: {index: filepath} + """ + replacements = replacements or {} + + table_offset, n, header8 = find_res_table(original_res) + index_map = build_index_to_file_map(extracted_dir) + + missing = [i for i in range(n) if (i not in index_map and i not in replacements)] + if missing: + raise FileNotFoundError( + f"Extracted folder missing {len(missing)} required indices.\n" + f"First missing: {missing[:20]}\n" + "Expected files like 0.bin, 1.bin, 2.bin..." + ) + + # Build payloads and table + offsets_sizes = [] + payload_parts = [] + cursor = 0 + + for i in range(n): + src = replacements.get(i) or index_map[i] + with open(src, "rb") as f: + b = f.read() + payload_parts.append(b) + offsets_sizes.append((cursor, len(b))) + cursor += len(b) + + os.makedirs(os.path.dirname(output_res) or ".", exist_ok=True) + with open(output_res, "wb") as out: + for b in payload_parts: + out.write(b) + + # preserve header from original + out.write(header8) + + # write entries + for off, sz in offsets_sizes: + out.write(struct.pack("", lambda e: self.rebuild_tree()) + + ttk.Button(mid, text="Save selected file as…", command=self.save_selected).pack(side=tk.LEFT) + ttk.Button(mid, text="Open selected externally", command=self.open_external).pack(side=tk.LEFT, padx=(8, 0)) + + self.path_lbl = ttk.Label(mid, text="Game folder: (none)") + self.path_lbl.pack(side=tk.RIGHT) + + paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) + paned.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8) + + left = ttk.Frame(paned) + paned.add(left, weight=3) + + self.tree = ttk.Treeview(left, columns=("type", "size", "relpath"), show="tree headings") + self.tree.heading("#0", text="Name (RES folders)", command=lambda: self.sort_by("name")) + self.tree.heading("type", text="Type", command=lambda: self.sort_by("type")) + self.tree.heading("size", text="Size", command=lambda: self.sort_by("size")) + self.tree.heading("relpath", text="Path", command=lambda: self.sort_by("relpath")) + + self.tree.column("#0", width=420) + self.tree.column("type", width=80, anchor=tk.CENTER) + self.tree.column("size", width=120, anchor=tk.E) + self.tree.column("relpath", width=650) + + self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + yscroll = ttk.Scrollbar(left, orient=tk.VERTICAL, command=self.tree.yview) + yscroll.pack(side=tk.RIGHT, fill=tk.Y) + self.tree.configure(yscrollcommand=yscroll.set) + + self.tree.bind("<>", lambda e: self.on_select()) + + right = ttk.Frame(paned) + paned.add(right, weight=2) + + self.inspector_title = ttk.Label(right, text="Inspector", font=("Segoe UI", 11, "bold")) + self.inspector_title.pack(side=tk.TOP, anchor="w", padx=6, pady=(6, 4)) + + self.inspector_info = ttk.Label(right, text="", wraplength=520, justify=tk.LEFT) + self.inspector_info.pack(side=tk.TOP, anchor="w", padx=6, pady=(0, 6)) + + self.preview_stack = ttk.Frame(right) + self.preview_stack.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=6, pady=6) + + # image preview + self.img_label = ttk.Label(self.preview_stack) + + # audio preview + self.audio_frame = ttk.Frame(self.preview_stack) + self.btn_play = ttk.Button(self.audio_frame, text="Play", command=self.play_audio) + self.btn_stop = ttk.Button(self.audio_frame, text="Stop", command=self.stop_audio) + self.btn_play.pack(side=tk.LEFT) + self.btn_stop.pack(side=tk.LEFT, padx=(8, 0)) + self.audio_backend_lbl = ttk.Label(self.audio_frame, text="") + self.audio_backend_lbl.pack(side=tk.LEFT, padx=12) + + # hex preview + self.hex_text = tk.Text(self.preview_stack, wrap="none", height=28) + self.hex_text.configure(state="disabled") + self.hex_scroll_y = ttk.Scrollbar(self.preview_stack, orient=tk.VERTICAL, command=self.hex_text.yview) + self.hex_text.configure(yscrollcommand=self.hex_scroll_y.set) + + def choose_folder(self): + folder = filedialog.askdirectory(title="Select game folder (or ScummVM folder)") + if not folder: + return + + self.selected_folder = folder + + # If user picked ScummVM folder, detect game folder from ..\ihnm.ini + detected = try_detect_game_path_from_scummvm_folder(folder) + if detected and os.path.isdir(detected): + self.game_folder = detected + else: + self.game_folder = folder + + self.extract_root = os.path.join(self.game_folder, EXTRACT_ROOT_NAME) + self.patch_root = os.path.join(self.game_folder, PATCH_ROOT_NAME) + os.makedirs(self.extract_root, exist_ok=True) + os.makedirs(self.patch_root, exist_ok=True) + + self.path_lbl.config(text=f"Game folder: {self.game_folder}") + self.status.config(text="Scanning…") + self.scan_existing() + self.status.config(text="Ready") + + def list_res_files(self): + if not self.game_folder: + return [] + return sorted( + os.path.join(self.game_folder, n) + for n in os.listdir(self.game_folder) + if n.lower().endswith(".res") + ) + + def extract_all(self): + if not self.game_folder: + messagebox.showinfo("No folder", "Choose a folder first.") + return + if not os.path.isfile(self.unpack_exe): + messagebox.showerror("Missing saga_unpack.exe", f"Not found:\n{self.unpack_exe}") + return + + res_files = self.list_res_files() + if not res_files: + messagebox.showinfo("No RES files", "No .RES files found in the game folder.") + return + + def worker(): + self._set_status("Extracting…") + for res_path in res_files: + res_name = os.path.splitext(os.path.basename(res_path))[0] + out_dir = os.path.join(self.extract_root, res_name) + os.makedirs(out_dir, exist_ok=True) + + try: + proc = subprocess.run( + [self.unpack_exe, res_path, out_dir], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=self.script_dir + ) + if proc.returncode != 0: + self._toast(f"Failed: {os.path.basename(res_path)}\n\n{proc.stdout[-1600:]}") + except Exception as e: + self._toast(f"Error running saga_unpack on {res_path}:\n{e}") + + self._set_status("Scanning extracted files…") + self.scan_existing() + self._set_status("Done.") + + threading.Thread(target=worker, daemon=True).start() + + def scan_existing(self): + self.items.clear() + + # Only show extracted records in UI (since that’s what you’re editing/listening to) + if self.extract_root and os.path.isdir(self.extract_root): + for res_name in sorted(os.listdir(self.extract_root)): + res_dir = os.path.join(self.extract_root, res_name) + if not os.path.isdir(res_dir): + continue + + def sort_key(s: str): + base = os.path.splitext(s)[0] + try: + return (0, int(base)) + except Exception: + return (1, s.lower()) + + for fname in sorted(os.listdir(res_dir), key=sort_key): + fpath = os.path.join(res_dir, fname) + if not os.path.isfile(fpath): + continue + + fsize = os.path.getsize(fpath) + ftype, ext = guess_type_and_ext(fpath) + relpath = os.path.relpath(fpath, self.game_folder) + + # derive index + idx = None + m = INDEX_RE.match(fname) + if m: + idx = int(m.group(1)) + + self.items.append({ + "name": fname, + "type": ftype, + "size": fsize, + "size_h": human_size(fsize), + "res": res_name, + "path": fpath, + "relpath": relpath, + "ext": ext, + "index": idx + }) + + self.rebuild_tree() + + def rebuild_tree(self): + self.stop_audio() + + q = (self.filter_var.get() or "").strip().lower() + items = self.items + + if q: + items = [ + it for it in items + if q in it["name"].lower() + or q in it["type"].lower() + or q in it["res"].lower() + or q in it["relpath"].lower() + ] + + key = self.sort_col + reverse = self.sort_desc + if key == "size": + items.sort(key=lambda it: it["size"], reverse=reverse) + else: + items.sort(key=lambda it: str(it.get(key, "")).lower(), reverse=reverse) + + self.tree.delete(*self.tree.get_children()) + + res_nodes = {} + for it in items: + res = it["res"] + if res not in res_nodes: + res_nodes[res] = self.tree.insert("", "end", text=res, values=("", "", "")) + + iid = it["path"] # unique + self.tree.insert( + res_nodes[res], + "end", + iid=iid, + text=it["name"], + values=(it["type"], it["size_h"], it["relpath"]) + ) + + for node in res_nodes.values(): + self.tree.item(node, open=True) + + self._set_status(f"{len(items)} files shown ({len(self.items)} total)") + + def sort_by(self, col: str): + if self.sort_col == col: + self.sort_desc = not self.sort_desc + else: + self.sort_col = col + self.sort_desc = False + self.rebuild_tree() + + def get_selected_item(self): + sel = self.tree.selection() + if not sel: + return None + iid = sel[0] + if not os.path.isfile(iid): + return None + for it in self.items: + if it["path"] == iid: + return it + return None + + def on_select(self): + it = self.get_selected_item() + self.current_item = it + self.stop_audio() + self._hide_previews() + + if not it: + self.inspector_title.config(text="Inspector") + self.inspector_info.config(text="") + return + + self.inspector_title.config(text=f"Inspector — {it['res']} / {it['name']}") + self.inspector_info.config( + text=f"Type: {it['type']}\nSize: {it['size_h']}\nRES: {it['res']}\nIndex: {it['index']}\nPath: {it['path']}" + ) + + if it["type"] == "png": + self.show_image(it["path"]) + elif it["type"] in self.AUDIO_TYPES: + self.show_audio_controls(it) + else: + self.show_hex(it["path"]) + + def _hide_previews(self): + self.img_label.pack_forget() + self._img_ref = None + + self.audio_frame.pack_forget() + + self.hex_text.pack_forget() + self.hex_scroll_y.pack_forget() + + def show_image(self, path: str): + try: + img = tk.PhotoImage(file=path) + self._img_ref = img + self.img_label.config(image=img) + self.img_label.pack(side=tk.TOP, anchor="nw") + except Exception as e: + self.show_hex(path) + self._toast(f"PNG preview failed (showing hex instead):\n{e}") + + def show_hex(self, path: str): + preview = read_hex_preview(path) + self.hex_text.configure(state="normal") + self.hex_text.delete("1.0", tk.END) + self.hex_text.insert("1.0", preview) + self.hex_text.configure(state="disabled") + self.hex_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.hex_scroll_y.pack(side=tk.RIGHT, fill=tk.Y) + + def show_audio_controls(self, it: dict): + self.audio_frame.pack(side=tk.TOP, fill=tk.X, anchor="nw") + backend = [] + if it["type"] == "wav" and HAS_WINSOUND: + backend.append("winsound (WAV)") + if HAS_VLC and self.vlc.available(): + backend.append("VLC (mp3/ogg/flac/…)") + if not backend: + backend_txt = "No audio backend (install VLC + python-vlc for non-WAV)" + else: + backend_txt = " | ".join(backend) + self.audio_backend_lbl.config(text=backend_txt) + + def _cleanup_temp_audio(self): + if self._temp_audio_file and os.path.isfile(self._temp_audio_file): + try: + os.remove(self._temp_audio_file) + except Exception: + pass + self._temp_audio_file = None + + def _make_temp_with_ext(self, src_path: str, ext: str) -> str: + self._cleanup_temp_audio() + fd, tmp = tempfile.mkstemp(suffix=ext) + os.close(fd) + shutil.copy2(src_path, tmp) + self._temp_audio_file = tmp + return tmp + + def play_audio(self): + it = self.current_item + if not it: + return + path = it["path"] + + # WAV via winsound + if it["type"] == "wav": + if not HAS_WINSOUND: + messagebox.showwarning("WAV playback unavailable", "winsound not available.") + return + play_path = path + if not path.lower().endswith(".wav"): + play_path = self._make_temp_with_ext(path, ".wav") + try: + winsound.PlaySound(play_path, winsound.SND_FILENAME | winsound.SND_ASYNC) + self._set_status("Playing (winsound)…") + return + except Exception as e: + messagebox.showerror("WAV playback failed", str(e)) + return + + # other audio via VLC + if self.vlc.available(): + ext = it.get("ext") or "" + play_path = path + if ext and not path.lower().endswith(ext): + play_path = self._make_temp_with_ext(path, ext) + if not self.vlc.load(play_path): + messagebox.showwarning("VLC load failed", "Could not load audio in VLC.") + return + self.vlc.play() + self._set_status("Playing (VLC)…") + return + + messagebox.showinfo("No backend", "Install VLC + python-vlc for MP3/OGG/FLAC playback.") + + def stop_audio(self): + if HAS_WINSOUND: + try: + winsound.PlaySound(None, winsound.SND_PURGE) + except Exception: + pass + if self.vlc.available(): + self.vlc.stop() + self._cleanup_temp_audio() + + def save_selected(self): + it = self.get_selected_item() + if not it: + messagebox.showinfo("No selection", "Select a file (not a folder) first.") + return + + suggested = it["name"] + ext = it.get("ext") + if ext and not suggested.lower().endswith(ext): + suggested = os.path.splitext(suggested)[0] + ext + + out = filedialog.asksaveasfilename(title="Save file as…", initialfile=suggested) + if not out: + return + try: + shutil.copy2(it["path"], out) + self._set_status(f"Saved: {out}") + except Exception as e: + messagebox.showerror("Save failed", str(e)) + + def open_external(self): + it = self.get_selected_item() + if not it: + return + try: + os.startfile(it["path"]) + except Exception as e: + messagebox.showerror("Open failed", str(e)) + + + def _selected_res_name(self): + it = self.current_item + if not it: + return None + return it["res"] + + def _paths_for_res(self, res_name: str): + """ + Returns original_res_path, extracted_dir + """ + original_res = os.path.join(self.game_folder, f"{res_name}.RES") + if not os.path.isfile(original_res): + # maybe lowercase + original_res = os.path.join(self.game_folder, f"{res_name}.res") + extracted_dir = os.path.join(self.extract_root, res_name) + return original_res, extracted_dir + + def repack_selected_full(self): + res_name = self._selected_res_name() + if not res_name: + messagebox.showinfo("No selection", "Select a record inside a RES first.") + return + + orig, extdir = self._paths_for_res(res_name) + if not os.path.isfile(orig): + messagebox.showerror("Missing original", f"Original RES not found:\n{orig}") + return + if not os.path.isdir(extdir): + messagebox.showerror("Missing extracted", f"Extracted dir not found:\n{extdir}") + return + + out = os.path.join(self.patch_root, f"{res_name}.patched.res") + + def worker(): + try: + self._set_status(f"Repacking {res_name}…") + repack_res(orig, extdir, out) + self._set_status(f"Repacked -> {out}") + self._toast(f"Repacked {res_name} to:\n{out}") + except Exception as e: + self._toast(f"Repack failed:\n{e}") + self._set_status("Error") + + threading.Thread(target=worker, daemon=True).start() + + def override_selected_record(self): + it = self.current_item + if not it or it.get("index") is None: + messagebox.showinfo("No record selected", "Select a specific extracted record first (e.g. 123.bin).") + return + + res_name = it["res"] + idx = it["index"] + + orig, extdir = self._paths_for_res(res_name) + if not os.path.isfile(orig): + messagebox.showerror("Missing original", f"Original RES not found:\n{orig}") + return + if not os.path.isdir(extdir): + messagebox.showerror("Missing extracted", f"Extracted dir not found:\n{extdir}") + return + + rep = filedialog.askopenfilename( + title=f"Choose replacement file for {res_name} record {idx}", + filetypes=[("All files", "*.*")] + ) + if not rep: + return + + out = os.path.join(self.patch_root, f"{res_name}.patched.res") + + def worker(): + try: + self._set_status(f"Repacking {res_name} with override {idx}…") + repack_res(orig, extdir, out, replacements={idx: rep}) + self._set_status(f"Patched -> {out}") + self._toast(f"Patched RES written to:\n{out}") + except Exception as e: + self._toast(f"Override/repack failed:\n{e}") + self._set_status("Error") + + threading.Thread(target=worker, daemon=True).start() + + def install_patched_res(self): + """ + Lets user pick a patched .res (defaults to patch_root) and installs over the game folder .RES + with .bak backup. + """ + if not self.game_folder: + messagebox.showinfo("No folder", "Choose a folder first.") + return + + p = filedialog.askopenfilename( + title="Select patched RES to install", + initialdir=self.patch_root if self.patch_root else None, + filetypes=[("RES files", "*.res"), ("All files", "*.*")] + ) + if not p: + return + + base = os.path.basename(p) + # Try to map "VOICES6.patched.res" -> "VOICES6.RES" + res_base = base.split(".patched", 1)[0] + target = os.path.join(self.game_folder, f"{res_base}.RES") + if not os.path.isfile(target): + target = os.path.join(self.game_folder, f"{res_base}.res") + + if not os.path.isfile(target): + messagebox.showerror("Target not found", f"Could not find original RES to replace:\n{target}") + return + + bak = target + ".bak" + try: + if not os.path.exists(bak): + shutil.copy2(target, bak) + shutil.copy2(p, target) + messagebox.showinfo("Installed", f"Installed:\n{target}\n\nBackup:\n{bak}") + except Exception as e: + messagebox.showerror("Install failed", str(e)) + + + + def _set_status(self, txt: str): + self.status.config(text=txt) + + def _toast(self, msg: str): + self.after(0, lambda: messagebox.showinfo("Info", msg)) + + +if __name__ == "__main__": + ResBrowserApp().mainloop()