Initial Version

This commit is contained in:
ApfelTeeSaft
2025-12-17 14:04:33 +01:00
parent 6f11570d91
commit 9b0df32ad9
2 changed files with 1008 additions and 0 deletions
+87
View File
@@ -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: `<game folder>\extracted\<RES name>\`
* 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: `<game folder>\patched_res\<RES name>.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)**.
+921
View File
@@ -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"<unable to read: {e}>"
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("<I", data, table_offset)[0]
if n_in_file != n:
continue
header8 = data[table_offset:table_offset + 8]
ok = True
prev_off = None
first_off = None
for i in range(n):
off, sz = struct.unpack_from("<II", data, table_offset + 8 + i * 8)
if first_off is None:
first_off = off
if off > 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("<II", off, sz))
class ResBrowserApp(tk.Tk):
AUDIO_TYPES = {"wav", "mp3", "ogg", "flac", "aac", "m4a", "wma"}
def __init__(self):
super().__init__()
self.title(APP_TITLE)
self.geometry("1450x820")
self.script_dir = os.path.dirname(os.path.abspath(__file__))
self.unpack_exe = os.path.join(self.script_dir, "saga_unpack.exe")
self.selected_folder = None # user-chosen folder (game folder OR scummvm folder)
self.game_folder = None # where VOICES*.RES live (detected or same as selected)
self.extract_root = None # game_folder\extracted
self.patch_root = None # game_folder\patched_res
self.items = []
self.sort_col = "name"
self.sort_desc = False
self.current_item = None
self._img_ref = None
self._temp_audio_file = None
self.vlc = VLCPlayer()
self._build_ui()
if not os.path.isfile(self.unpack_exe):
messagebox.showwarning("Missing tool", f"Could not find saga_unpack.exe next to:\n{self.unpack_exe}")
def _build_ui(self):
top = ttk.Frame(self, padding=8)
top.pack(side=tk.TOP, fill=tk.X)
ttk.Button(top, text="Choose Folder…", command=self.choose_folder).pack(side=tk.LEFT)
ttk.Button(top, text="Extract All .RES", command=self.extract_all).pack(side=tk.LEFT, padx=(8, 0))
ttk.Separator(top, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
ttk.Button(top, text="Repack selected RES (full)", command=self.repack_selected_full).pack(side=tk.LEFT)
ttk.Button(top, text="Override selected record…", command=self.override_selected_record).pack(side=tk.LEFT, padx=(8, 0))
ttk.Button(top, text="Install patched RES…", command=self.install_patched_res).pack(side=tk.LEFT, padx=(8, 0))
self.status = ttk.Label(top, text="Ready")
self.status.pack(side=tk.LEFT, padx=12)
mid = ttk.Frame(self, padding=(8, 0, 8, 8))
mid.pack(side=tk.TOP, fill=tk.X)
ttk.Label(mid, text="Filter:").pack(side=tk.LEFT)
self.filter_var = tk.StringVar()
ent = ttk.Entry(mid, textvariable=self.filter_var, width=55)
ent.pack(side=tk.LEFT, padx=(6, 10))
ent.bind("<KeyRelease>", 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("<<TreeviewSelect>>", 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 thats what youre 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()