mirror of
https://github.com/ApfelTeeSaft/SAGA-ResExplorer.git
synced 2026-08-26 19:33:33 +00:00
Initial Version
This commit is contained in:
@@ -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)**.
|
||||
@@ -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 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()
|
||||
Reference in New Issue
Block a user