Add cross-platform Python asset pipeline GUI

This commit is contained in:
Kelsi 2026-02-23 19:11:29 -08:00
parent aaab2115d1
commit 20dd5ed63b
3 changed files with 842 additions and 0 deletions

View file

@ -106,6 +106,9 @@ This project requires WoW client data that you extract from your own legally obt
Wowee loads assets via an extracted loose-file tree indexed by `manifest.json` (it does not read MPQs at runtime).
For a cross-platform GUI workflow (extraction + texture pack management + active override state), see:
- [Asset Pipeline GUI](docs/asset-pipeline-gui.md)
#### 1) Extract MPQs into `./Data/`
```bash
@ -196,6 +199,7 @@ make -j$(nproc)
- [Project Status](docs/status.md) -- Current code state, limitations, and near-term direction
- [Quick Start](docs/quickstart.md) -- Installation and first steps
- [Build Instructions](BUILD_INSTRUCTIONS.md) -- Detailed dependency, build, and run guide
- [Asset Pipeline GUI](docs/asset-pipeline-gui.md) -- Python GUI for extraction, pack installs, ordering, and override rebuilds
### Technical Documentation
- [Architecture](docs/architecture.md) -- System design and module overview

View file

@ -0,0 +1,56 @@
# Asset Pipeline GUI
WoWee includes a Python GUI for extraction and texture-pack management:
```bash
python3 tools/asset_pipeline_gui.py
```
## Supported Platforms
- Linux
- macOS
- Windows
The app uses Python's built-in `tkinter` module. If `tkinter` is missing, install the platform package:
- Linux (Debian/Ubuntu): `sudo apt install python3-tk`
- Fedora: `sudo dnf install python3-tkinter`
- Arch: `sudo pacman -S tk`
- macOS: use the official Python.org installer (includes Tk)
- Windows: use the official Python installer and enable Tcl/Tk support
## What It Does
- Runs `asset_extract` (or `extract_assets.sh` fallback on non-Windows)
- Saves extraction config in `asset_pipeline/state.json`
- Installs texture packs from ZIP or folders
- Lets users activate/deactivate packs and reorder active pack priority
- Rebuilds `Data/override` from active pack order
- Shows current data state (`manifest.json`, entry count, override file count, last runs)
## Pack Format
Supported pack layouts:
1. `PackName/Data/...`
2. `PackName/data/...`
3. `PackName/...` where top folders include game folders (`Interface`, `World`, `Character`, `Textures`, `Sound`)
When multiple active packs contain the same file path, **later packs in active order win**.
## State Files and Folders
- Pipeline state: `asset_pipeline/state.json`
- Installed packs: `asset_pipeline/packs/<pack-id>/`
- Active merged override output: `<Output Data>/override/`
## Typical Workflow
1. Open the GUI.
2. Set WoW MPQ Data source and output Data path.
3. Run extraction.
4. Install texture packs.
5. Activate and order packs.
6. Click **Rebuild Override**.
7. Launch wowee with `WOW_DATA_PATH` pointing at your output Data path if needed.

782
tools/asset_pipeline_gui.py Normal file
View file

@ -0,0 +1,782 @@
#!/usr/bin/env python3
"""WoWee Asset Pipeline GUI.
Cross-platform Tkinter app for running asset extraction and managing texture packs
that are merged into Data/override in deterministic order.
"""
from __future__ import annotations
import json
import platform
import queue
import shutil
import subprocess
import threading
import time
import zipfile
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinter.scrolledtext import ScrolledText
ROOT_DIR = Path(__file__).resolve().parents[1]
PIPELINE_DIR = ROOT_DIR / "asset_pipeline"
STATE_FILE = PIPELINE_DIR / "state.json"
@dataclass
class PackInfo:
pack_id: str
name: str
source: str
installed_dir: str
installed_at: str
file_count: int = 0
@dataclass
class AppState:
wow_data_dir: str = ""
output_data_dir: str = str(ROOT_DIR / "Data")
extractor_path: str = ""
expansion: str = "auto"
locale: str = "auto"
skip_dbc: bool = False
dbc_csv: bool = False
verify: bool = False
verbose: bool = False
threads: int = 0
packs: list[PackInfo] = field(default_factory=list)
active_pack_ids: list[str] = field(default_factory=list)
last_extract_at: str = ""
last_extract_ok: bool = False
last_extract_command: str = ""
last_override_build_at: str = ""
class PipelineManager:
def __init__(self) -> None:
PIPELINE_DIR.mkdir(parents=True, exist_ok=True)
(PIPELINE_DIR / "packs").mkdir(parents=True, exist_ok=True)
self.state = self._load_state()
def _default_state(self) -> AppState:
return AppState()
def _load_state(self) -> AppState:
if not STATE_FILE.exists():
return self._default_state()
try:
doc = json.loads(STATE_FILE.read_text(encoding="utf-8"))
packs = [PackInfo(**item) for item in doc.get("packs", [])]
doc["packs"] = packs
state = AppState(**doc)
return state
except (OSError, ValueError, TypeError):
return self._default_state()
def save_state(self) -> None:
serializable = asdict(self.state)
STATE_FILE.write_text(json.dumps(serializable, indent=2), encoding="utf-8")
def now_str(self) -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _normalize_id(self, name: str) -> str:
raw = "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
base = raw or "pack"
return f"{base}-{int(time.time())}"
def _pack_dir(self, pack_id: str) -> Path:
return PIPELINE_DIR / "packs" / pack_id
def _looks_like_data_root(self, path: Path) -> bool:
markers = {"interface", "world", "character", "textures", "sound"}
names = {p.name.lower() for p in path.iterdir() if p.is_dir()} if path.is_dir() else set()
return bool(markers.intersection(names))
def find_data_root(self, pack_path: Path) -> Path:
direct_data = pack_path / "Data"
if direct_data.is_dir():
return direct_data
lower_data = pack_path / "data"
if lower_data.is_dir():
return lower_data
if self._looks_like_data_root(pack_path):
return pack_path
# Common zip layout: one wrapper directory.
children = [p for p in pack_path.iterdir() if p.is_dir()] if pack_path.is_dir() else []
if len(children) == 1:
child = children[0]
child_data = child / "Data"
if child_data.is_dir():
return child_data
if self._looks_like_data_root(child):
return child
return pack_path
def _count_files(self, root: Path) -> int:
if not root.exists():
return 0
return sum(1 for p in root.rglob("*") if p.is_file())
def install_pack_from_zip(self, zip_path: Path) -> PackInfo:
pack_name = zip_path.stem
pack_id = self._normalize_id(pack_name)
target = self._pack_dir(pack_id)
target.mkdir(parents=True, exist_ok=False)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(target)
data_root = self.find_data_root(target)
info = PackInfo(
pack_id=pack_id,
name=pack_name,
source=str(zip_path),
installed_dir=str(target),
installed_at=self.now_str(),
file_count=self._count_files(data_root),
)
self.state.packs.append(info)
self.save_state()
return info
def install_pack_from_folder(self, folder_path: Path) -> PackInfo:
pack_name = folder_path.name
pack_id = self._normalize_id(pack_name)
target = self._pack_dir(pack_id)
shutil.copytree(folder_path, target)
data_root = self.find_data_root(target)
info = PackInfo(
pack_id=pack_id,
name=pack_name,
source=str(folder_path),
installed_dir=str(target),
installed_at=self.now_str(),
file_count=self._count_files(data_root),
)
self.state.packs.append(info)
self.save_state()
return info
def uninstall_pack(self, pack_id: str) -> None:
self.state.packs = [p for p in self.state.packs if p.pack_id != pack_id]
self.state.active_pack_ids = [pid for pid in self.state.active_pack_ids if pid != pack_id]
target = self._pack_dir(pack_id)
if target.exists():
shutil.rmtree(target)
self.save_state()
def set_pack_active(self, pack_id: str, active: bool) -> None:
if active:
if pack_id not in self.state.active_pack_ids:
self.state.active_pack_ids.append(pack_id)
else:
self.state.active_pack_ids = [pid for pid in self.state.active_pack_ids if pid != pack_id]
self.save_state()
def move_active_pack(self, pack_id: str, delta: int) -> None:
ids = self.state.active_pack_ids
if pack_id not in ids:
return
idx = ids.index(pack_id)
nidx = idx + delta
if nidx < 0 or nidx >= len(ids):
return
ids[idx], ids[nidx] = ids[nidx], ids[idx]
self.state.active_pack_ids = ids
self.save_state()
def rebuild_override(self) -> dict[str, int]:
out_dir = Path(self.state.output_data_dir)
override_dir = out_dir / "override"
override_dir.mkdir(parents=True, exist_ok=True)
if override_dir.exists():
shutil.rmtree(override_dir)
override_dir.mkdir(parents=True, exist_ok=True)
copied = 0
replaced = 0
active_map = {p.pack_id: p for p in self.state.packs}
for pack_id in self.state.active_pack_ids:
info = active_map.get(pack_id)
if info is None:
continue
pack_dir = Path(info.installed_dir)
if not pack_dir.exists():
continue
data_root = self.find_data_root(pack_dir)
for source in data_root.rglob("*"):
if not source.is_file():
continue
rel = source.relative_to(data_root)
target = override_dir / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
replaced += 1
shutil.copy2(source, target)
copied += 1
self.state.last_override_build_at = self.now_str()
self.save_state()
return {"copied": copied, "replaced": replaced}
def _resolve_extractor(self) -> list[str] | None:
configured = self.state.extractor_path.strip()
if configured:
path = Path(configured)
if path.exists() and path.is_file():
return [str(path)]
ext = ".exe" if platform.system().lower().startswith("win") else ""
for candidate in [
ROOT_DIR / "build" / "bin" / f"asset_extract{ext}",
ROOT_DIR / "bin" / f"asset_extract{ext}",
]:
if candidate.exists():
return [str(candidate)]
if platform.system().lower().startswith("win"):
return None
shell_script = ROOT_DIR / "extract_assets.sh"
if shell_script.exists():
return ["bash", str(shell_script)]
return None
def build_extract_command(self) -> list[str]:
mpq_dir = self.state.wow_data_dir.strip()
output_dir = self.state.output_data_dir.strip()
if not mpq_dir or not output_dir:
raise ValueError("Both WoW Data directory and output directory are required.")
extractor = self._resolve_extractor()
if extractor is None:
raise ValueError(
"No extractor found. Build asset_extract first or set the extractor path in Configuration."
)
if extractor[0].endswith("extract_assets.sh") or extractor[-1].endswith("extract_assets.sh"):
cmd = [*extractor, mpq_dir]
if self.state.expansion and self.state.expansion != "auto":
cmd.append(self.state.expansion)
return cmd
cmd = [*extractor, "--mpq-dir", mpq_dir, "--output", output_dir]
if self.state.expansion and self.state.expansion != "auto":
cmd.extend(["--expansion", self.state.expansion])
if self.state.locale and self.state.locale != "auto":
cmd.extend(["--locale", self.state.locale])
if self.state.skip_dbc:
cmd.append("--skip-dbc")
if self.state.dbc_csv:
cmd.append("--dbc-csv")
if self.state.verify:
cmd.append("--verify")
if self.state.verbose:
cmd.append("--verbose")
if self.state.threads > 0:
cmd.extend(["--threads", str(self.state.threads)])
return cmd
def summarize_state(self) -> dict[str, Any]:
output_dir = Path(self.state.output_data_dir)
manifest_path = output_dir / "manifest.json"
override_dir = output_dir / "override"
summary: dict[str, Any] = {
"output_dir": str(output_dir),
"output_exists": output_dir.exists(),
"manifest_exists": manifest_path.exists(),
"manifest_entries": 0,
"override_exists": override_dir.exists(),
"override_files": self._count_files(override_dir),
"packs_installed": len(self.state.packs),
"packs_active": len(self.state.active_pack_ids),
"last_extract_at": self.state.last_extract_at or "never",
"last_extract_ok": self.state.last_extract_ok,
"last_override_build_at": self.state.last_override_build_at or "never",
}
if manifest_path.exists():
try:
doc = json.loads(manifest_path.read_text(encoding="utf-8"))
entries = doc.get("entries", {})
if isinstance(entries, dict):
summary["manifest_entries"] = len(entries)
except (OSError, ValueError, TypeError):
summary["manifest_entries"] = -1
return summary
class AssetPipelineGUI:
def __init__(self, root: tk.Tk) -> None:
self.root = root
self.manager = PipelineManager()
self.log_queue: queue.Queue[str] = queue.Queue()
self.proc_thread: threading.Thread | None = None
self.proc_running = False
self.root.title("WoWee Asset Pipeline")
self.root.geometry("1120x760")
self.status_var = tk.StringVar(value="Ready")
self._build_ui()
self._load_vars_from_state()
self.refresh_pack_list()
self.refresh_state_view()
self.root.after(120, self._poll_logs)
def _build_ui(self) -> None:
top = ttk.Frame(self.root, padding=10)
top.pack(fill="both", expand=True)
status = ttk.Label(top, textvariable=self.status_var, anchor="w")
status.pack(fill="x", pady=(0, 8))
self.notebook = ttk.Notebook(top)
self.notebook.pack(fill="both", expand=True)
self.cfg_tab = ttk.Frame(self.notebook, padding=10)
self.packs_tab = ttk.Frame(self.notebook, padding=10)
self.state_tab = ttk.Frame(self.notebook, padding=10)
self.logs_tab = ttk.Frame(self.notebook, padding=10)
self.notebook.add(self.cfg_tab, text="Configuration")
self.notebook.add(self.packs_tab, text="Texture Packs")
self.notebook.add(self.state_tab, text="Current State")
self.notebook.add(self.logs_tab, text="Logs")
self._build_config_tab()
self._build_packs_tab()
self._build_state_tab()
self._build_logs_tab()
def _build_config_tab(self) -> None:
self.var_wow_data = tk.StringVar()
self.var_output_data = tk.StringVar()
self.var_extractor = tk.StringVar()
self.var_expansion = tk.StringVar(value="auto")
self.var_locale = tk.StringVar(value="auto")
self.var_skip_dbc = tk.BooleanVar(value=False)
self.var_dbc_csv = tk.BooleanVar(value=False)
self.var_verify = tk.BooleanVar(value=False)
self.var_verbose = tk.BooleanVar(value=False)
self.var_threads = tk.IntVar(value=0)
frame = self.cfg_tab
self._path_row(frame, 0, "WoW Data (MPQ source)", self.var_wow_data, self._pick_wow_data_dir)
self._path_row(frame, 1, "Output Data directory", self.var_output_data, self._pick_output_dir)
self._path_row(frame, 2, "Extractor binary/script (optional)", self.var_extractor, self._pick_extractor)
ttk.Label(frame, text="Expansion").grid(row=3, column=0, sticky="w", pady=6)
exp_combo = ttk.Combobox(
frame,
textvariable=self.var_expansion,
values=["auto", "classic", "turtle", "tbc", "wotlk"],
state="readonly",
width=18,
)
exp_combo.grid(row=3, column=1, sticky="w", pady=6)
ttk.Label(frame, text="Locale").grid(row=3, column=2, sticky="w", pady=6)
loc_combo = ttk.Combobox(
frame,
textvariable=self.var_locale,
values=["auto", "enUS", "enGB", "deDE", "frFR", "esES", "esMX", "ruRU", "koKR", "zhCN", "zhTW"],
state="normal",
width=12,
)
loc_combo.grid(row=3, column=3, sticky="w", pady=6)
ttk.Label(frame, text="Threads (0 = auto)").grid(row=4, column=0, sticky="w", pady=6)
ttk.Spinbox(frame, from_=0, to=256, textvariable=self.var_threads, width=8).grid(
row=4, column=1, sticky="w", pady=6
)
opts = ttk.Frame(frame)
opts.grid(row=5, column=0, columnspan=4, sticky="w", pady=6)
ttk.Checkbutton(opts, text="Skip DBC extraction", variable=self.var_skip_dbc).pack(side="left", padx=(0, 12))
ttk.Checkbutton(opts, text="Generate DBC CSV", variable=self.var_dbc_csv).pack(side="left", padx=(0, 12))
ttk.Checkbutton(opts, text="Verify CRC", variable=self.var_verify).pack(side="left", padx=(0, 12))
ttk.Checkbutton(opts, text="Verbose output", variable=self.var_verbose).pack(side="left", padx=(0, 12))
buttons = ttk.Frame(frame)
buttons.grid(row=6, column=0, columnspan=4, sticky="w", pady=12)
ttk.Button(buttons, text="Save Configuration", command=self.save_config).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Run Extraction", command=self.run_extraction).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Refresh State", command=self.refresh_state_view).pack(side="left")
tip = (
"Texture packs are merged into <Output Data>/override in active order. "
"Later packs override earlier packs file-by-file."
)
ttk.Label(frame, text=tip, foreground="#444").grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
frame.columnconfigure(1, weight=1)
def _build_packs_tab(self) -> None:
left = ttk.Frame(self.packs_tab)
left.pack(side="left", fill="both", expand=True)
right = ttk.Frame(self.packs_tab)
right.pack(side="right", fill="y", padx=(12, 0))
self.pack_list = tk.Listbox(left, height=22)
self.pack_list.pack(fill="both", expand=True)
self.pack_list.bind("<<ListboxSelect>>", lambda _evt: self._refresh_pack_detail())
self.pack_detail = ScrolledText(left, height=10, wrap="word", state="disabled")
self.pack_detail.pack(fill="both", expand=False, pady=(10, 0))
ttk.Button(right, text="Install ZIP", width=22, command=self.install_zip).pack(pady=4)
ttk.Button(right, text="Install Folder", width=22, command=self.install_folder).pack(pady=4)
ttk.Separator(right, orient="horizontal").pack(fill="x", pady=8)
ttk.Button(right, text="Activate", width=22, command=self.activate_selected_pack).pack(pady=4)
ttk.Button(right, text="Deactivate", width=22, command=self.deactivate_selected_pack).pack(pady=4)
ttk.Button(right, text="Move Up", width=22, command=lambda: self.move_selected_pack(-1)).pack(pady=4)
ttk.Button(right, text="Move Down", width=22, command=lambda: self.move_selected_pack(1)).pack(pady=4)
ttk.Separator(right, orient="horizontal").pack(fill="x", pady=8)
ttk.Button(right, text="Rebuild Override", width=22, command=self.rebuild_override).pack(pady=4)
ttk.Button(right, text="Uninstall", width=22, command=self.uninstall_selected_pack).pack(pady=4)
def _build_state_tab(self) -> None:
actions = ttk.Frame(self.state_tab)
actions.pack(fill="x")
ttk.Button(actions, text="Refresh", command=self.refresh_state_view).pack(side="left")
self.state_text = ScrolledText(self.state_tab, wrap="word", state="disabled")
self.state_text.pack(fill="both", expand=True, pady=(10, 0))
def _build_logs_tab(self) -> None:
actions = ttk.Frame(self.logs_tab)
actions.pack(fill="x")
ttk.Button(actions, text="Clear Logs", command=self.clear_logs).pack(side="left")
self.log_text = ScrolledText(self.logs_tab, wrap="none", state="disabled")
self.log_text.pack(fill="both", expand=True, pady=(10, 0))
def _path_row(self, frame: ttk.Frame, row: int, label: str, variable: tk.StringVar, browse_cmd) -> None:
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=6)
ttk.Entry(frame, textvariable=variable).grid(row=row, column=1, columnspan=2, sticky="ew", pady=6)
ttk.Button(frame, text="Browse", command=browse_cmd).grid(row=row, column=3, sticky="e", pady=6)
def _pick_wow_data_dir(self) -> None:
picked = filedialog.askdirectory(title="Select WoW Data directory")
if picked:
self.var_wow_data.set(picked)
def _pick_output_dir(self) -> None:
picked = filedialog.askdirectory(title="Select output Data directory")
if picked:
self.var_output_data.set(picked)
def _pick_extractor(self) -> None:
picked = filedialog.askopenfilename(title="Select extractor binary or script")
if picked:
self.var_extractor.set(picked)
def _load_vars_from_state(self) -> None:
st = self.manager.state
self.var_wow_data.set(st.wow_data_dir)
self.var_output_data.set(st.output_data_dir)
self.var_extractor.set(st.extractor_path)
self.var_expansion.set(st.expansion)
self.var_locale.set(st.locale)
self.var_skip_dbc.set(st.skip_dbc)
self.var_dbc_csv.set(st.dbc_csv)
self.var_verify.set(st.verify)
self.var_verbose.set(st.verbose)
self.var_threads.set(st.threads)
def save_config(self) -> None:
st = self.manager.state
st.wow_data_dir = self.var_wow_data.get().strip()
st.output_data_dir = self.var_output_data.get().strip()
st.extractor_path = self.var_extractor.get().strip()
st.expansion = self.var_expansion.get().strip() or "auto"
st.locale = self.var_locale.get().strip() or "auto"
st.skip_dbc = bool(self.var_skip_dbc.get())
st.dbc_csv = bool(self.var_dbc_csv.get())
st.verify = bool(self.var_verify.get())
st.verbose = bool(self.var_verbose.get())
st.threads = int(self.var_threads.get())
self.manager.save_state()
self.status_var.set("Configuration saved")
def _selected_pack(self) -> PackInfo | None:
sel = self.pack_list.curselection()
if not sel:
return None
idx = int(sel[0])
if idx < 0 or idx >= len(self.manager.state.packs):
return None
return self.manager.state.packs[idx]
def refresh_pack_list(self) -> None:
active = self.manager.state.active_pack_ids
self.pack_list.delete(0, tk.END)
for pack in self.manager.state.packs:
marker = ""
if pack.pack_id in active:
marker = f"[active #{active.index(pack.pack_id) + 1}] "
self.pack_list.insert(tk.END, f"{marker}{pack.name}")
self._refresh_pack_detail()
def _refresh_pack_detail(self) -> None:
pack = self._selected_pack()
self.pack_detail.configure(state="normal")
self.pack_detail.delete("1.0", tk.END)
if pack is None:
self.pack_detail.insert(tk.END, "Select a texture pack to see details.")
self.pack_detail.configure(state="disabled")
return
active = "yes" if pack.pack_id in self.manager.state.active_pack_ids else "no"
order = "-"
if pack.pack_id in self.manager.state.active_pack_ids:
order = str(self.manager.state.active_pack_ids.index(pack.pack_id) + 1)
lines = [
f"Name: {pack.name}",
f"Active: {active}",
f"Order: {order}",
f"Files: {pack.file_count}",
f"Installed at: {pack.installed_at}",
f"Installed dir: {pack.installed_dir}",
f"Source: {pack.source}",
]
self.pack_detail.insert(tk.END, "\n".join(lines))
self.pack_detail.configure(state="disabled")
def install_zip(self) -> None:
zip_path = filedialog.askopenfilename(
title="Choose texture pack ZIP",
filetypes=[("ZIP archives", "*.zip"), ("All files", "*.*")],
)
if not zip_path:
return
try:
info = self.manager.install_pack_from_zip(Path(zip_path))
except Exception as exc: # pylint: disable=broad-except
messagebox.showerror("Install failed", str(exc))
return
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Installed pack: {info.name}")
def install_folder(self) -> None:
folder = filedialog.askdirectory(title="Choose texture pack folder")
if not folder:
return
try:
info = self.manager.install_pack_from_folder(Path(folder))
except Exception as exc: # pylint: disable=broad-except
messagebox.showerror("Install failed", str(exc))
return
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Installed pack: {info.name}")
def activate_selected_pack(self) -> None:
pack = self._selected_pack()
if pack is None:
return
self.manager.set_pack_active(pack.pack_id, True)
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Activated pack: {pack.name}")
def deactivate_selected_pack(self) -> None:
pack = self._selected_pack()
if pack is None:
return
self.manager.set_pack_active(pack.pack_id, False)
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Deactivated pack: {pack.name}")
def move_selected_pack(self, delta: int) -> None:
pack = self._selected_pack()
if pack is None:
return
self.manager.move_active_pack(pack.pack_id, delta)
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Reordered active pack: {pack.name}")
def uninstall_selected_pack(self) -> None:
pack = self._selected_pack()
if pack is None:
return
ok = messagebox.askyesno("Confirm uninstall", f"Uninstall texture pack '{pack.name}'?")
if not ok:
return
self.manager.uninstall_pack(pack.pack_id)
self.refresh_pack_list()
self.refresh_state_view()
self.status_var.set(f"Uninstalled pack: {pack.name}")
def rebuild_override(self) -> None:
try:
report = self.manager.rebuild_override()
except Exception as exc: # pylint: disable=broad-except
messagebox.showerror("Override rebuild failed", str(exc))
return
self.refresh_state_view()
self.status_var.set(
f"Override rebuilt: {report['copied']} files copied, {report['replaced']} replaced"
)
self._append_log(
f"[{self.manager.now_str()}] Override rebuild complete: {report['copied']} copied, {report['replaced']} replaced"
)
def clear_logs(self) -> None:
self.log_text.configure(state="normal")
self.log_text.delete("1.0", tk.END)
self.log_text.configure(state="disabled")
def _append_log(self, line: str) -> None:
self.log_text.configure(state="normal")
self.log_text.insert(tk.END, line + "\n")
self.log_text.see(tk.END)
self.log_text.configure(state="disabled")
def _poll_logs(self) -> None:
while True:
try:
line = self.log_queue.get_nowait()
except queue.Empty:
break
self._append_log(line)
self.root.after(120, self._poll_logs)
def run_extraction(self) -> None:
if self.proc_running:
messagebox.showinfo("Extraction running", "An extraction is already running.")
return
self.save_config()
try:
cmd = self.manager.build_extract_command()
except ValueError as exc:
messagebox.showerror("Cannot run extraction", str(exc))
return
def worker() -> None:
self.proc_running = True
started = self.manager.now_str()
self.log_queue.put(f"[{started}] Running: {' '.join(cmd)}")
self.root.after(0, lambda: self.status_var.set("Extraction running..."))
ok = False
try:
process = subprocess.Popen(
cmd,
cwd=str(ROOT_DIR),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
self.log_queue.put(line.rstrip())
rc = process.wait()
ok = rc == 0
if not ok:
self.log_queue.put(f"Extractor exited with status {rc}")
except Exception as exc: # pylint: disable=broad-except
self.log_queue.put(f"Extraction error: {exc}")
finally:
self.manager.state.last_extract_at = self.manager.now_str()
self.manager.state.last_extract_ok = ok
self.manager.state.last_extract_command = " ".join(cmd)
self.manager.save_state()
self.proc_running = False
self.root.after(0, self.refresh_state_view)
self.root.after(
0, lambda: self.status_var.set("Extraction complete" if ok else "Extraction failed")
)
self.proc_thread = threading.Thread(target=worker, daemon=True)
self.proc_thread.start()
def refresh_state_view(self) -> None:
summary = self.manager.summarize_state()
active_names = []
pack_map = {p.pack_id: p.name for p in self.manager.state.packs}
for pid in self.manager.state.active_pack_ids:
active_names.append(pack_map.get(pid, f"<missing {pid}>"))
lines = [
"WoWee Asset Pipeline State",
"",
f"Output directory: {summary['output_dir']}",
f"Output exists: {summary['output_exists']}",
f"manifest.json present: {summary['manifest_exists']}",
f"Manifest entries: {summary['manifest_entries']}",
"",
f"Override folder present: {summary['override_exists']}",
f"Override file count: {summary['override_files']}",
f"Last override build: {summary['last_override_build_at']}",
"",
f"Installed texture packs: {summary['packs_installed']}",
f"Active texture packs: {summary['packs_active']}",
"Active order:",
]
if active_names:
for i, name in enumerate(active_names, start=1):
lines.append(f" {i}. {name}")
else:
lines.append(" (none)")
lines.extend(
[
"",
f"Last extraction time: {summary['last_extract_at']}",
f"Last extraction success: {summary['last_extract_ok']}",
f"Last extraction command: {self.manager.state.last_extract_command or '(none)'}",
"",
"Pipeline files:",
f" State file: {STATE_FILE}",
f" Packs dir: {PIPELINE_DIR / 'packs'}",
]
)
self.state_text.configure(state="normal")
self.state_text.delete("1.0", tk.END)
self.state_text.insert(tk.END, "\n".join(lines))
self.state_text.configure(state="disabled")
def main() -> None:
root = tk.Tk()
AssetPipelineGUI(root)
root.mainloop()
if __name__ == "__main__":
main()