Compare commits

..

4 commits

Author SHA1 Message Date
d82895cc4d feat(rpgmaker): add scoped image patch preparation
- Patch highlighted editable images or all images when none are highlighted
- Skip unchanged images while preserving editable workspace copies
- Open the editable folder matching the current image or folder filter
2026-07-25 16:29:19 -05:00
3d82203496 feat(rpgmaker): add editable image workflow
- Decrypt MV/MZ images into a game-local editable workspace
- Re-encrypt edited images, back up originals, and add patch rules
- Add visual filtering, multi-selection, removal, and large-library paging
- Keep runtime image directories free of duplicate decrypted files
2026-07-25 16:01:46 -05:00
35d5d5968a Images Editor Pass 1 2026-07-25 15:03:10 -05:00
d1023178fe fix(rpgmaker): keep code 101 labels out of dialogue text
- Translate only the inner name while preserving display brackets
- Strip temporary speaker context before writing code 401 dialogue
- Cover malformed bracketed labels and reruns with a regression test
2026-07-25 14:34:51 -05:00
11 changed files with 2157 additions and 21 deletions

View file

@ -454,7 +454,7 @@ class BatchTab(QWidget):
tt.select_files_by_name(file_set)
# Switch to Translation page.
if hasattr(parent, "switch_page"):
page = getattr(parent, "PAGE_TRANSLATION", 2)
page = getattr(parent, "PAGE_TRANSLATION", 3)
parent.switch_page(page)
reply = QMessageBox.question(
self,

View file

@ -316,5 +316,5 @@ class GuideTab(QWidget):
def _open_config(self) -> None:
pw = self.parent_window
if pw is not None and hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_CONFIG", 5)
page = getattr(pw, "PAGE_CONFIG", 6)
pw.switch_page(page)

View file

@ -707,6 +707,7 @@ from gui.guide_tab import GuideTab
from gui.translation_tab import TranslationTab
from gui.workflow_tab import WorkflowTab
from gui.wolf_workflow_tab import WolfWorkflowTab
from gui.rpgmaker_image_manager import RPGMakerImageManager
from gui.skills_tab import SkillsTab
from gui.batch_tab import BatchTab
@ -715,10 +716,11 @@ class DazedMTLGUI(QMainWindow):
PAGE_GUIDE = 0
PAGE_WORKFLOW = 1
PAGE_TRANSLATION = 2
PAGE_BATCHES = 3
PAGE_SKILLS = 4
PAGE_CONFIG = 5
PAGE_IMAGES = 2
PAGE_TRANSLATION = 3
PAGE_BATCHES = 4
PAGE_SKILLS = 5
PAGE_CONFIG = 6
def __init__(self):
super().__init__()
@ -961,6 +963,13 @@ class DazedMTLGUI(QMainWindow):
sidebar_layout.addWidget(btn_workflow)
self.nav_buttons.append(btn_workflow)
# RPG Maker image decryption / patch selection
btn_images = self.create_nav_button("🖼", "Images")
btn_images.setToolTip("Images - decrypt, preview, encrypt, and add MV/MZ images to patches")
btn_images.clicked.connect(lambda: self.switch_page(self.PAGE_IMAGES))
sidebar_layout.addWidget(btn_images)
self.nav_buttons.append(btn_images)
# Translation
btn_translation = self.create_nav_button("🌐", "Translation")
btn_translation.clicked.connect(lambda: self.switch_page(self.PAGE_TRANSLATION))
@ -1027,6 +1036,8 @@ class DazedMTLGUI(QMainWindow):
def switch_page(self, index):
"""Switch to the specified page and update button states."""
if index == self.PAGE_IMAGES and hasattr(self, "image_manager_tab"):
self.image_manager_tab.refresh_game_root_from_settings()
self.content_stack.setCurrentIndex(index)
# Update button checked states
@ -1045,19 +1056,23 @@ class DazedMTLGUI(QMainWindow):
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
self.content_stack.addWidget(self._create_workflow_container())
# Translation Execution Tab (index 2)
# RPG Maker MV/MZ Image Manager (index 2)
self.image_manager_tab = RPGMakerImageManager(parent=self)
self.content_stack.addWidget(self.image_manager_tab)
# Translation Execution Tab (index 3)
self.translation_tab = TranslationTab(self)
self.content_stack.addWidget(self.translation_tab)
# Batch History Tab (index 3)
# Batch History Tab (index 4)
self.batch_tab = BatchTab(self)
self.content_stack.addWidget(self.batch_tab)
# Skills Tab (index 4)
# Skills Tab (index 5)
self.skills_tab = SkillsTab(self)
self.content_stack.addWidget(self.skills_tab)
# Configuration Tab (index 5)
# Configuration Tab (index 6)
self.config_tab = ConfigTab()
self.config_tab.config_changed.connect(self.on_config_changed)
self.content_stack.addWidget(self.config_tab)

View file

@ -0,0 +1,823 @@
"""Visual RPG Maker MV/MZ image decrypt/encrypt and patch manager."""
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import (
Qt,
QSize,
QThread,
pyqtSignal,
QSettings,
QUrl,
)
from PyQt5.QtGui import QColor, QDesktopServices, QIcon, QPixmap
from PyQt5.QtWidgets import (
QComboBox,
QAbstractItemView,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QVBoxLayout,
QWidget,
)
from util.paths import APP_NAME, ORG_NAME
from util.rpgmaker_images import (
ImageAsset,
clean_runtime_image_duplicates,
decrypt_assets,
editable_workspace_root,
ensure_editable_workspace,
migrate_legacy_editable_workspace,
prepare_assets_for_patch,
preview_png_bytes,
read_encryption_key,
remove_editable_assets,
resolve_content_root,
scan_image_assets,
thumbnail_png_bytes,
)
_ASSET_ID_ROLE = Qt.UserRole + 1
_PAGE_SIZE = 1000
class _UserSelectionList(QListWidget):
"""Report user selection changes without reacting to list rebuilding."""
userSelectionChanged = pyqtSignal()
deleteRequested = pyqtSignal()
def mousePressEvent(self, event) -> None:
super().mousePressEvent(event)
self.userSelectionChanged.emit()
def mouseReleaseEvent(self, event) -> None:
super().mouseReleaseEvent(event)
self.userSelectionChanged.emit()
def keyPressEvent(self, event) -> None:
if event.key() == Qt.Key_Delete:
self.deleteRequested.emit()
event.accept()
return
super().keyPressEvent(event)
self.userSelectionChanged.emit()
class _ImageScanWorker(QThread):
done = pyqtSignal(int, object)
error = pyqtSignal(int, str)
def __init__(self, generation: int, game_root: Path):
super().__init__()
self.generation = generation
self.game_root = game_root
def run(self) -> None:
try:
self.done.emit(self.generation, scan_image_assets(self.game_root))
except Exception as exc:
self.error.emit(self.generation, str(exc))
class _ThumbnailWorker(QThread):
done = pyqtSignal(int, object)
def __init__(self, generation: int, assets: list[ImageAsset], key: bytes | None):
super().__init__()
self.generation = generation
self.assets = assets
self.key = key
def run(self) -> None:
thumbnails: dict[str, bytes] = {}
for asset in self.assets:
if self.isInterruptionRequested():
return
try:
data = thumbnail_png_bytes(asset, self.key, size=112)
except Exception:
data = b""
thumbnails[asset.asset_id] = data
self.done.emit(self.generation, thumbnails)
class _ImageActionWorker(QThread):
status = pyqtSignal(str)
done = pyqtSignal(str, object)
error = pyqtSignal(str)
def __init__(
self,
action: str,
game_root: Path,
assets: list[ImageAsset],
key: bytes | None,
):
super().__init__()
self.action = action
self.game_root = game_root
self.assets = assets
self.key = key
def run(self) -> None:
try:
if self.action == "decrypt":
result = decrypt_assets(
self.assets,
self.key,
game_root=self.game_root,
overwrite=False,
progress=self.status.emit,
)
elif self.action == "remove":
result = remove_editable_assets(
self.game_root, self.assets, progress=self.status.emit
)
else:
result = prepare_assets_for_patch(
self.game_root, self.assets, self.key, progress=self.status.emit
)
self.done.emit(self.action, result)
except Exception as exc:
self.error.emit(str(exc))
class RPGMakerImageManager(QWidget):
"""Paginated contact sheet for projects containing thousands of images."""
def __init__(
self,
game_root: str | Path | None = None,
parent=None,
settings: QSettings | None = None,
):
super().__init__(parent)
self.settings = settings or QSettings(ORG_NAME, APP_NAME)
saved_root = str(self.settings.value("workflow/last_game_folder", "") or "")
initial_root = str(game_root or saved_root).strip()
self.game_root = Path(initial_root).expanduser().resolve() if initial_root else None
self.assets: list[ImageAsset] = []
self.assets_by_id: dict[str, ImageAsset] = {}
self.filtered_assets: list[ImageAsset] = []
self.selected_ids: set[str] = set()
self.page = 0
self.key: bytes | None = None
self._scan_worker: _ImageScanWorker | None = None
self._scan_workers: list[_ImageScanWorker] = []
self._scan_generation = 0
self._thumbnail_worker: _ThumbnailWorker | None = None
self._thumbnail_workers: list[_ThumbnailWorker] = []
self._action_worker: _ImageActionWorker | None = None
self._thumbnail_generation = 0
self._build_ui()
if self.game_root:
self.folder_edit.setText(str(self.game_root))
self._load_project()
else:
self.status_label.setText("Select an RPG Maker MV/MZ game folder to begin.")
self._set_actions_enabled(False)
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(18, 14, 18, 12)
title = QLabel("RPG Maker MV/MZ Image Manager")
title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;")
root.addWidget(title)
intro = QLabel(
"Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, "
"then highlight the finished images to patch only those, or clear highlights to "
"patch every editable image. "
"Pages keep very large games responsive."
)
intro.setWordWrap(True)
intro.setStyleSheet("color:#b8b8b8;font-size:13px;padding:2px 0 6px 0;")
root.addWidget(intro)
folder_row = QHBoxLayout()
folder_row.addWidget(QLabel("Game folder:"))
self.folder_edit = QLineEdit()
self.folder_edit.setPlaceholderText("Select an RPG Maker MV/MZ game folder…")
self.folder_edit.returnPressed.connect(self._load_project)
folder_row.addWidget(self.folder_edit, 1)
browse_button = QPushButton("Browse…")
browse_button.clicked.connect(self._browse_game_root)
folder_row.addWidget(browse_button)
load_button = QPushButton("Load")
load_button.clicked.connect(self._load_project)
folder_row.addWidget(load_button)
root.addLayout(folder_row)
filters = QHBoxLayout()
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Filter by any part of the folder or filename…")
self.search_edit.textChanged.connect(self._apply_filters)
filters.addWidget(self.search_edit, 2)
self.folder_combo = QComboBox()
self.folder_combo.addItem("All folders", "")
self.folder_combo.currentIndexChanged.connect(self._apply_filters)
filters.addWidget(self.folder_combo, 1)
self.state_combo = QComboBox()
self.state_combo.addItem("All images", "all")
self.state_combo.addItem("Editable images", "editable")
self.state_combo.currentIndexChanged.connect(self._apply_filters)
filters.addWidget(self.state_combo)
root.addLayout(filters)
splitter = QSplitter(Qt.Horizontal)
self.image_list = _UserSelectionList()
self.image_list.setViewMode(QListWidget.IconMode)
self.image_list.setResizeMode(QListWidget.Adjust)
self.image_list.setMovement(QListWidget.Static)
self.image_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.image_list.setIconSize(QSize(112, 112))
self.image_list.setGridSize(QSize(160, 160))
self.image_list.setUniformItemSizes(True)
self.image_list.setWordWrap(True)
self.image_list.setSpacing(4)
self.image_list.setObjectName("rpgImageList")
self.image_list.setStyleSheet(
"QListWidget#rpgImageList{background:#1e1e1e;color:#fff;"
"border:1px solid #555;padding:3px;outline:none;}"
"QListWidget#rpgImageList::item{border:1px solid transparent;"
"padding:3px;background:transparent;}"
"QListWidget#rpgImageList::item:selected{background:#264f78;"
"border-color:#4b8dc0;color:#fff;}"
"QListWidget#rpgImageList::item:hover:!selected{background:#333;"
"border-color:#555;}"
)
self.image_list.userSelectionChanged.connect(self._selection_changed)
self.image_list.deleteRequested.connect(self._remove_highlighted)
self.image_list.currentItemChanged.connect(self._show_preview)
self.image_list.setToolTip(
"Click highlights one image. Ctrl-click toggles individual images, Shift-click "
"selects a range, and Ctrl+A highlights the current page."
)
splitter.addWidget(self.image_list)
preview_host = QWidget()
preview_layout = QVBoxLayout(preview_host)
self.preview_label = QLabel("Select an image to preview it")
self.preview_label.setAlignment(Qt.AlignCenter)
self.preview_label.setMinimumSize(300, 300)
self.preview_label.setStyleSheet(
"background:#171717;border:1px solid #3c3c3c;color:#777;"
)
preview_layout.addWidget(self.preview_label, 1)
self.path_label = QLabel()
self.path_label.setWordWrap(True)
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.path_label.setStyleSheet("color:#c8c8c8;font-size:12px;padding:5px;")
preview_layout.addWidget(self.path_label)
splitter.addWidget(preview_host)
splitter.setSizes([850, 350])
root.addWidget(splitter, 1)
controls_row = QHBoxLayout()
controls_row.setSpacing(12)
action_row = QHBoxLayout()
action_row.setSpacing(8)
self.open_workspace_button = QPushButton("Open folder")
self.open_workspace_button.setToolTip(
"Open the highlighted image's editable folder, the chosen folder filter, or the "
"editable img root."
)
self.open_workspace_button.clicked.connect(self._open_editable_folder)
self.decrypt_selected_button = QPushButton("Decrypt highlighted")
self.decrypt_selected_button.clicked.connect(self._decrypt_checked)
self.decrypt_all_button = QPushButton("Decrypt all")
self.decrypt_all_button.clicked.connect(self._decrypt_all)
self.remove_button = QPushButton("Remove highlighted")
self.remove_button.setToolTip(
"Delete highlighted PNG copies from the editable folder. Runtime images remain "
"untouched and can be decrypted again. The Delete key does the same thing."
)
self.remove_button.clicked.connect(self._remove_highlighted)
self.prepare_button = QPushButton("Encrypt all + patch")
self.prepare_button.setStyleSheet(
"QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}"
"QPushButton:hover{background:#18352f;}"
)
self.prepare_button.setToolTip(
"With highlighted images, patch only their editable PNGs. With no highlights, patch "
"every editable PNG. Editable copies remain in .dazedtl/images until removed."
)
self.prepare_button.clicked.connect(self._prepare_checked)
action_buttons = (
self.open_workspace_button,
self.decrypt_selected_button,
self.decrypt_all_button,
self.remove_button,
self.prepare_button,
)
for button in action_buttons:
button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
action_row.addWidget(button, 1)
controls_row.addLayout(action_row, 1)
page_row = QHBoxLayout()
page_row.setSpacing(8)
self.previous_button = QPushButton("← Previous")
self.previous_button.clicked.connect(lambda: self._change_page(-1))
page_row.addWidget(self.previous_button)
self.page_label = QLabel("Page 0 / 0")
self.page_label.setAlignment(Qt.AlignCenter)
self.page_label.setMinimumWidth(210)
page_row.addWidget(self.page_label)
self.next_button = QPushButton("Next →")
self.next_button.clicked.connect(lambda: self._change_page(1))
page_row.addWidget(self.next_button)
nav_width = max(self.previous_button.sizeHint().width(), self.next_button.sizeHint().width())
self.previous_button.setFixedWidth(nav_width)
self.next_button.setFixedWidth(nav_width)
common_height = max(
40,
*(button.sizeHint().height() for button in (*action_buttons, self.previous_button, self.next_button)),
)
for button in (*action_buttons, self.previous_button, self.next_button):
button.setFixedHeight(common_height)
controls_row.addLayout(page_row)
root.addLayout(controls_row)
self.status_label = QLabel("Scanning image folders…")
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet("color:#8fbc8f;padding:4px 0;")
root.addWidget(self.status_label)
def _browse_game_root(self) -> None:
start = self.folder_edit.text().strip() or str(Path.home())
folder = QFileDialog.getExistingDirectory(self, "Select RPG Maker Game Folder", start)
if folder:
self.folder_edit.setText(folder)
self._load_project()
def _load_project(self) -> None:
raw_path = self.folder_edit.text().strip()
if not raw_path:
return
root = Path(raw_path).expanduser().resolve()
if not root.is_dir():
QMessageBox.warning(self, "Game Folder", f"Folder not found:\n{root}")
return
self.game_root = root
self.settings.setValue("workflow/last_game_folder", str(root))
try:
migrate_legacy_editable_workspace(root)
clean_runtime_image_duplicates(root)
except Exception as exc:
QMessageBox.warning(
self,
"Editable Image Cleanup",
f"Could not consolidate editable images under .dazedtl:\n{exc}",
)
self.selected_ids.clear()
self._load_key()
self._start_scan()
def refresh_game_root_from_settings(self) -> None:
"""Load a game newly selected in the RPG Maker Workflow tab."""
saved = str(self.settings.value("workflow/last_game_folder", "") or "").strip()
if not saved:
return
resolved = Path(saved).expanduser().resolve()
if resolved == self.game_root:
return
self.folder_edit.setText(str(resolved))
self._load_project()
def _load_key(self) -> None:
try:
self.key = read_encryption_key(self.game_root)
except Exception:
self.key = None
def _start_scan(self) -> None:
if self.game_root is None:
return
self._set_actions_enabled(False)
self.status_label.setText("Scanning image folders…")
self._scan_generation += 1
worker = _ImageScanWorker(self._scan_generation, self.game_root)
worker.done.connect(self._scan_done)
worker.error.connect(self._scan_error)
worker.finished.connect(lambda w=worker: self._forget_scan_worker(w))
self._scan_workers.append(worker)
self._scan_worker = worker
worker.start()
def _forget_scan_worker(self, worker: _ImageScanWorker) -> None:
if worker in self._scan_workers:
self._scan_workers.remove(worker)
def _scan_done(self, generation: int, assets: list[ImageAsset]) -> None:
if generation != self._scan_generation:
return
self._set_actions_enabled(True)
self.assets = assets
self.assets_by_id = {asset.asset_id: asset for asset in assets}
self.selected_ids.intersection_update(self.assets_by_id)
self.folder_combo.blockSignals(True)
current_folder = self.folder_combo.currentData() or ""
self.folder_combo.clear()
self.folder_combo.addItem("All folders", "")
folders = sorted(
{asset.relative_png.parent.as_posix() for asset in assets}, key=str.casefold
)
for folder in folders:
self.folder_combo.addItem(folder, folder)
index = self.folder_combo.findData(current_folder)
self.folder_combo.setCurrentIndex(max(0, index))
self.folder_combo.blockSignals(False)
encrypted = sum(asset.has_encrypted for asset in assets)
editable = sum(asset.has_plain for asset in assets)
self.status_label.setText(
f"Found {len(assets):,} images · {encrypted:,} encrypted · "
f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted"
)
self._update_prepare_scope()
self._apply_filters()
def _scan_error(self, generation: int, message: str) -> None:
if generation != self._scan_generation:
return
self._set_actions_enabled(False)
self.status_label.setText(f"Image scan failed: {message}")
QMessageBox.critical(self, "Image Scan Failed", message)
def _apply_filters(self) -> None:
query = self.search_edit.text().strip().casefold()
folder = self.folder_combo.currentData() or ""
state = self.state_combo.currentData()
filtered: list[ImageAsset] = []
for asset in self.assets:
if query and query not in asset.asset_id.casefold():
continue
if folder and asset.relative_png.parent.as_posix() != folder:
continue
if state == "editable" and not asset.has_plain:
continue
filtered.append(asset)
self.filtered_assets = filtered
self.page = 0
self._render_page()
def _page_assets(self) -> list[ImageAsset]:
start = self.page * _PAGE_SIZE
return self.filtered_assets[start:start + _PAGE_SIZE]
def _render_page(self) -> None:
if self._thumbnail_worker and self._thumbnail_worker.isRunning():
self._thumbnail_worker.requestInterruption()
page_count = max(1, (len(self.filtered_assets) + _PAGE_SIZE - 1) // _PAGE_SIZE)
self.page = min(self.page, page_count - 1)
page_assets = self._page_assets()
self.image_list.blockSignals(True)
self.image_list.clear()
placeholder = QPixmap(112, 112)
placeholder.fill(QColor("#292929"))
placeholder_icon = QIcon(placeholder)
for asset in page_assets:
label = asset.relative_png.name
item = QListWidgetItem(placeholder_icon, label)
item.setData(_ASSET_ID_ROLE, asset.asset_id)
kind = "encrypted + editable" if asset.has_encrypted and asset.has_plain else (
"encrypted" if asset.has_encrypted else "runtime PNG"
)
item.setToolTip(f"{asset.asset_id}\n{kind}")
self.image_list.addItem(item)
item.setSelected(asset.asset_id in self.selected_ids)
self.image_list.blockSignals(False)
self.page_label.setText(
f"Page {self.page + 1} / {page_count} · {len(self.filtered_assets):,} matches"
)
self.previous_button.setEnabled(self.page > 0)
self.next_button.setEnabled(self.page + 1 < page_count)
self._thumbnail_generation += 1
worker = _ThumbnailWorker(self._thumbnail_generation, page_assets, self.key)
worker.done.connect(self._thumbnails_ready)
worker.finished.connect(lambda w=worker: self._forget_thumbnail_worker(w))
self._thumbnail_workers.append(worker)
self._thumbnail_worker = worker
worker.start()
def _forget_thumbnail_worker(self, worker: _ThumbnailWorker) -> None:
if worker in self._thumbnail_workers:
self._thumbnail_workers.remove(worker)
def _thumbnails_ready(self, generation: int, thumbnails: dict[str, bytes]) -> None:
if generation != self._thumbnail_generation:
return
self.image_list.setUpdatesEnabled(False)
try:
for index in range(self.image_list.count()):
item = self.image_list.item(index)
data = thumbnails.get(item.data(_ASSET_ID_ROLE), b"")
if not data:
continue
pixmap = QPixmap()
if pixmap.loadFromData(data, "PNG"):
item.setIcon(QIcon(pixmap))
self.image_list.doItemsLayout()
finally:
self.image_list.setUpdatesEnabled(True)
self.image_list.viewport().update()
def _change_page(self, delta: int) -> None:
self.page += delta
self._render_page()
def _selection_changed(self) -> None:
page_ids = {
self.image_list.item(index).data(_ASSET_ID_ROLE)
for index in range(self.image_list.count())
}
selected_on_page = {
item.data(_ASSET_ID_ROLE) for item in self.image_list.selectedItems()
}
self.selected_ids.difference_update(page_ids)
self.selected_ids.update(selected_on_page)
self._update_selection_status()
def _update_selection_status(self) -> None:
self._update_prepare_scope()
self.status_label.setText(
f"{len(self.filtered_assets):,} matching images · "
f"{len(self.selected_ids):,} highlighted · "
f"{sum(asset.has_plain for asset in self.assets):,} editable"
)
def _update_prepare_scope(self) -> None:
if self.selected_ids:
self.prepare_button.setText("Encrypt highlighted + patch")
else:
self.prepare_button.setText("Encrypt all + patch")
def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None:
if current is None:
return
asset = self.assets_by_id.get(current.data(_ASSET_ID_ROLE))
if asset is None:
return
try:
raw = preview_png_bytes(asset, self.key)
pixmap = QPixmap()
if not pixmap.loadFromData(raw, "PNG"):
raise ValueError("Qt could not decode this PNG")
self.preview_label.setPixmap(
pixmap.scaled(
self.preview_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
)
except Exception as exc:
self.preview_label.setPixmap(QPixmap())
self.preview_label.setText(f"Preview unavailable\n{exc}")
details = [asset.asset_id]
if asset.has_encrypted:
details.append(f"Runtime encrypted: {asset.encrypted_path}")
elif asset.has_runtime_plain:
details.append(f"Runtime PNG: {asset.runtime_plain_path}")
details.append(
f"Editable PNG: {asset.plain_path}"
if asset.has_plain
else f"Editable folder target: {asset.plain_path} (not created)"
)
details.append(
"Highlighted: yes" if asset.asset_id in self.selected_ids
else "Highlighted: no"
)
self.path_label.setText("\n".join(details))
def _open_editable_folder(self) -> None:
if self.game_root is None:
return
try:
workspace = ensure_editable_workspace(self.game_root)
current = self.image_list.currentItem()
asset = (
self.assets_by_id.get(current.data(_ASSET_ID_ROLE))
if current is not None
else None
)
if asset is not None:
target = asset.plain_path.parent
else:
root = Path(self.game_root).expanduser().resolve()
content_relative = resolve_content_root(root).relative_to(root)
folder = self.folder_combo.currentData() or "img"
relative_folder = Path(folder)
if relative_folder.is_absolute() or ".." in relative_folder.parts:
raise ValueError(f"Invalid editable image folder: {folder}")
target = workspace / content_relative / relative_folder
target.resolve().relative_to(workspace.resolve())
target.mkdir(parents=True, exist_ok=True)
if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))):
raise RuntimeError("The system file manager could not open the folder.")
self.status_label.setText(f"Editable images: {target}")
except Exception as exc:
QMessageBox.warning(self, "Editable Image Folder", str(exc))
def _selected_assets(self) -> list[ImageAsset]:
return [self.assets_by_id[key] for key in self.selected_ids if key in self.assets_by_id]
def _editable_assets(self) -> list[ImageAsset]:
return [asset for asset in self.assets if asset.has_plain]
def _ensure_key(self) -> bool:
if self.key is not None:
return True
try:
self.key = read_encryption_key(self.game_root)
return True
except Exception as exc:
QMessageBox.warning(self, "Encryption Key Not Found", str(exc))
return False
def _decrypt_checked(self) -> None:
assets = [
asset
for asset in self._selected_assets()
if asset.has_encrypted and not asset.has_plain
]
if not assets:
QMessageBox.information(
self,
"Nothing to Decrypt",
"Highlight one or more encrypted images that are not already editable.",
)
return
if self._ensure_key():
self._start_action("decrypt", assets)
def _decrypt_all(self) -> None:
assets = [asset for asset in self.assets if asset.has_encrypted and not asset.has_plain]
if not assets:
QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.")
return
answer = QMessageBox.question(
self,
"Decrypt Every Image",
f"Decrypt all {len(assets):,} encrypted image(s) into .dazedtl/images?\n\n"
"Existing editable copies will not be overwritten.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer == QMessageBox.Yes and self._ensure_key():
self._start_action("decrypt", assets)
def _remove_highlighted(self) -> None:
assets = [asset for asset in self._selected_assets() if asset.has_plain]
if not assets:
QMessageBox.information(
self,
"No Editable Images Highlighted",
"Highlight one or more images from the Editable images filter first.",
)
return
answer = QMessageBox.question(
self,
"Remove Editable Images",
f"Remove {len(assets):,} highlighted image(s) from the editable folder?\n\n"
"Their editable PNG copies will be deleted. The original game images will not "
"be changed, and encrypted images can be decrypted again later.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer == QMessageBox.Yes:
self._start_action("remove", assets)
def _prepare_checked(self) -> None:
highlighted = bool(self.selected_ids)
assets = (
[asset for asset in self._selected_assets() if asset.has_plain]
if highlighted
else self._editable_assets()
)
if not assets:
message = (
"None of the highlighted images are in the editable folder. Decrypt them first "
"or clear the highlights to patch every editable image."
if highlighted
else "Decrypt images into the editable folder first."
)
QMessageBox.information(
self,
"No Editable Images",
message,
)
return
if any(asset.has_encrypted for asset in assets) and not self._ensure_key():
return
scope = (
f"the {len(assets):,} highlighted editable image(s)"
if highlighted
else f"all {len(assets):,} editable image(s)"
)
answer = QMessageBox.question(
self,
"Prepare Images for Patch",
f"Check {scope} and add changed images to the patch?\n\n"
"Unchanged editable copies will be skipped. Changed encrypted images will be rebuilt "
"from .dazedtl/images. Their original encrypted "
"files are backed up once under .dazedtl/image_backups. Exact allow-rules are then "
"added to the applicable .gitignore files. Editable PNG copies will remain available "
"for further changes until you remove them.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer == QMessageBox.Yes:
self._start_action("prepare", assets)
def _start_action(self, action: str, assets: list[ImageAsset]) -> None:
self._set_actions_enabled(False)
worker = _ImageActionWorker(action, self.game_root, assets, self.key)
worker.status.connect(self.status_label.setText)
worker.done.connect(self._action_done)
worker.error.connect(self._action_error)
self._action_worker = worker
worker.start()
def _action_done(self, action: str, result) -> None:
if action == "decrypt":
workspace = editable_workspace_root(self.game_root)
summary = (
f"Created {result.completed:,} editable image(s) in {workspace}; "
f"skipped {result.skipped:,}."
)
elif action == "remove":
self.selected_ids = {
asset_id
for asset_id in self.selected_ids
if self.assets_by_id.get(asset_id) is not None
and self.assets_by_id[asset_id].has_plain
}
summary = (
f"Removed {result.completed:,} editable image(s); "
f"skipped {result.skipped:,}. Runtime images were left unchanged."
)
else:
summary = (
f"Prepared {result.completed:,} image(s) for the patch and updated "
f"{len(result.gitignore_files):,} .gitignore file(s); "
f"skipped {result.skipped:,} unchanged image(s)."
)
if result.errors:
shown = "\n".join(result.errors[:12])
if len(result.errors) > 12:
shown += f"\n…and {len(result.errors) - 12} more"
QMessageBox.warning(self, "Image Action Completed with Errors", f"{summary}\n\n{shown}")
else:
QMessageBox.information(self, "Image Action Complete", summary)
self.status_label.setText(summary)
self._start_scan()
def _action_error(self, message: str) -> None:
self.status_label.setText(f"Image action failed: {message}")
self._set_actions_enabled(True)
QMessageBox.critical(self, "Image Action Failed", message)
def _set_actions_enabled(self, enabled: bool) -> None:
for button in (
self.open_workspace_button,
self.decrypt_selected_button,
self.decrypt_all_button,
self.remove_button,
self.prepare_button,
):
button.setEnabled(enabled)
def closeEvent(self, event) -> None:
running = [
worker for worker in (
*self._scan_workers, *self._thumbnail_workers, self._action_worker
)
if worker is not None and worker.isRunning()
]
action_running = self._action_worker is not None and self._action_worker.isRunning()
if action_running:
QMessageBox.information(
self, "Image Action Running", "Wait for the current image action to finish."
)
event.ignore()
return
for worker in running:
worker.requestInterruption()
worker.wait(1000)
if any(worker.isRunning() for worker in running):
self.status_label.setText("Finishing image scan/preview before closing…")
event.ignore()
return
super().closeEvent(event)

View file

@ -2281,13 +2281,13 @@ class WolfWorkflowTab(QWidget):
pass
if pw and hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_TRANSLATION", 2)
page = getattr(pw, "PAGE_TRANSLATION", 3)
pw.switch_page(page)
elif pw and hasattr(pw, "content_stack"):
pw.content_stack.setCurrentIndex(2)
pw.content_stack.setCurrentIndex(3)
if hasattr(pw, "nav_buttons"):
for i, btn in enumerate(pw.nav_buttons):
btn.setChecked(i == 2)
btn.setChecked(i == 3)
if auto_start:
QTimer.singleShot(

View file

@ -3700,13 +3700,13 @@ class WorkflowTab(QWidget):
# 5. Navigate to Translation tab
if hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_TRANSLATION", 2)
page = getattr(pw, "PAGE_TRANSLATION", 3)
pw.switch_page(page)
elif hasattr(pw, "content_stack"):
pw.content_stack.setCurrentIndex(2)
pw.content_stack.setCurrentIndex(3)
if hasattr(pw, "nav_buttons"):
for i, btn in enumerate(pw.nav_buttons):
btn.setChecked(i == 2)
btn.setChecked(i == 3)
# 6. Auto-start translation so the user doesn't need an extra click
if auto_start:

View file

@ -588,6 +588,22 @@ def _101_name_source(cmd, is_var: bool) -> str:
return ""
def _101_speaker_name(raw_name: str) -> str:
"""Return the translatable name inside a code-101 display wrapper.
Parameter 4 may use ``Name`` as its visible name-window styling. Those
brackets belong in the 101 field, not in the ``[Speaker]:`` transport prefix
temporarily attached to the following 401 dialogue.
"""
name = str(raw_name or "").strip()
name = re.sub(r"^(?:[\\]+[cC]\[\d+\]\s*)+", "", name)
bracketed = re.match(r"^【\s*([^】]+?)\s*】", name)
if bracketed:
return bracketed.group(1).strip()
plain = re.match(r"^([^\\]+)", name)
return plain.group(1).strip() if plain else ""
def _entry_orig(entry) -> dict:
"""Return _original dict on a database entry, or empty dict if absent."""
orig = entry.get("_original") if isinstance(entry, dict) else None
@ -3136,9 +3152,8 @@ def searchCodes(page, pbar, jobList, filename):
# Get Speaker
rawName = _101_name_source(codeList[i], isVar)
match = re.search(r"^(?:[\\]+[cC]\[\d+?\])?([^\\]+)", rawName)
if match:
sourceName = match.group(1)
sourceName = _101_speaker_name(rawName)
if sourceName:
response = getSpeaker(sourceName)
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]

View file

@ -112,7 +112,7 @@ def _resolve_case_command(page_list, entry, marked_cases=None):
return cmd
def _run_search_codes(page, *, preserve_original=True):
def _run_search_codes(page, *, preserve_original=True, speaker_fn=None):
"""Full Pass 1 -> mock translate -> Pass 2 cycle."""
captured = []
@ -121,7 +121,7 @@ def _run_search_codes(page, *, preserve_original=True):
return _mock_translate(text, history, batch)
def speaker(name):
return _mock_speaker(name)
return speaker_fn(name) if speaker_fn is not None else _mock_speaker(name)
orig_t = mvmz.translateAI
orig_s = mvmz.getSpeaker
@ -194,6 +194,45 @@ class TestMVMZSourceOriginal(unittest.TestCase):
self.assertEqual(c122["_original"], "変数テスト")
self.assertIn("EN_TRANSLATED", c122["parameters"][4])
def test_101_display_brackets_do_not_leak_into_401_dialogue(self):
speakers_seen = []
def speaker(name):
speakers_seen.append(name)
return ["Game Description", [0, 0]]
page = {
"list": [
{
"code": 101,
"indent": 0,
"parameters": ["", 0, 0, 2, "【[Game Description]】"],
"_original": "【ゲーム説明】",
},
{
"code": 401,
"indent": 0,
"parameters": ["ここから本編スタートとなります。"],
},
]
}
translated, captured = _run_search_codes(page, speaker_fn=speaker)
cmd101, cmd401 = translated["list"]
self.assertTrue(speakers_seen)
self.assertEqual(set(speakers_seen), {"ゲーム説明"})
self.assertEqual(cmd101["parameters"][4], "【Game Description】")
self.assertEqual(cmd401["parameters"][0], "EN_TRANSLATED")
self.assertNotIn("Game Description", cmd401["parameters"][0])
self.assertTrue(
any(
isinstance(item, str) and item.startswith("[Game Description]: ")
for payload in captured
for item in (payload if isinstance(payload, list) else [payload])
)
)
def test_rerun_uses_original_not_display_text(self):
page1, captured1 = _run_search_codes(_load_map_excerpt())
originals_snapshot = json.dumps(

View file

@ -0,0 +1,301 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PIL import Image
from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication, QAbstractItemView, QMessageBox
from gui.rpgmaker_image_manager import RPGMakerImageManager, _PAGE_SIZE
class RPGMakerImageManagerSelectionTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
root = Path(self.temp.name)
self.game_root = root / "Game"
image_dir = self.game_root / "img" / "pictures"
data_dir = self.game_root / "data"
image_dir.mkdir(parents=True)
data_dir.mkdir(parents=True)
data_dir.joinpath("System.json").write_text(
'{"encryptionKey":"00112233445566778899aabbccddeeff"}',
encoding="utf-8",
)
for index in range(4):
Image.new("RGBA", (24, 24), (index * 40, 10, 20, 255)).save(
image_dir / f"image{index}.png"
)
self.settings_path = root / "settings.ini"
settings = QSettings(str(self.settings_path), QSettings.IniFormat)
self.manager = RPGMakerImageManager(self.game_root, settings=settings)
self.manager.resize(1100, 760)
self.manager.show()
self.manager._scan_worker.wait(5000)
self.app.processEvents()
def tearDown(self):
for worker in list(self.manager._thumbnail_workers):
worker.wait(5000)
self.manager.close()
self.app.processEvents()
self.temp.cleanup()
def _click(self, index: int, modifiers=Qt.NoModifier):
item = self.manager.image_list.item(index)
rect = self.manager.image_list.visualItemRect(item)
QTest.mouseClick(
self.manager.image_list.viewport(),
Qt.LeftButton,
modifiers,
rect.center(),
)
self.app.processEvents()
def test_ctrl_shift_and_ctrl_a_use_standard_extended_selection(self):
image_list = self.manager.image_list
self.assertEqual(
image_list.selectionMode(), QAbstractItemView.ExtendedSelection
)
self._click(0)
self._click(2, Qt.ControlModifier)
self.assertEqual(len(image_list.selectedItems()), 2)
self.assertEqual(len(self.manager.selected_ids), 2)
self._click(3, Qt.ShiftModifier)
selected_names = {item.text() for item in image_list.selectedItems()}
self.assertIn("image2.png", selected_names)
self.assertIn("image3.png", selected_names)
image_list.clearSelection()
image_list.setFocus()
QTest.keyClick(image_list, Qt.Key_A, Qt.ControlModifier)
self.app.processEvents()
self.assertEqual(len(image_list.selectedItems()), 4)
self.assertEqual(len(self.manager.selected_ids), 4)
def test_plain_click_replaces_selection_and_ctrl_click_adds(self):
self._click(0)
self._click(1)
self.assertEqual(len(self.manager.image_list.selectedItems()), 1)
self.assertEqual(len(self.manager.selected_ids), 1)
self._click(2, Qt.ControlModifier)
self.assertEqual(len(self.manager.image_list.selectedItems()), 2)
self.assertEqual(len(self.manager.selected_ids), 2)
def test_workspace_images_are_selected_after_manager_reopens(self):
self._click(0)
self._click(2, Qt.ControlModifier)
expected = set(self.manager.selected_ids)
for asset_id in expected:
asset = self.manager.assets_by_id[asset_id]
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
manifest = self.game_root / ".dazedtl" / "image_selection.json"
manifest.write_text("not used", encoding="utf-8")
settings = QSettings(str(self.settings_path), QSettings.IniFormat)
reopened = RPGMakerImageManager(self.game_root, settings=settings)
reopened.resize(1100, 760)
reopened.show()
reopened._scan_worker.wait(5000)
self.app.processEvents()
try:
self.assertEqual(reopened.selected_ids, set())
self.assertEqual(reopened.image_list.selectedItems(), [])
self.assertEqual(manifest.read_text(encoding="utf-8"), "not used")
reopened.folder_combo.setCurrentIndex(
reopened.folder_combo.findData("img/pictures")
)
reopened.state_combo.setCurrentIndex(
reopened.state_combo.findData("editable")
)
self.app.processEvents()
self.assertEqual(reopened.image_list.count(), len(expected))
self.assertEqual(reopened.image_list.selectedItems(), [])
finally:
for worker in list(reopened._thumbnail_workers):
worker.wait(5000)
reopened.close()
self.app.processEvents()
def test_highlights_survive_filters_without_refreshing_editable_view(self):
self._click(1)
self._click(3, Qt.ControlModifier)
selected_before = set(self.manager.selected_ids)
self.assertEqual(len(selected_before), 2)
self.manager.search_edit.setText("image0")
self.app.processEvents()
self.manager.search_edit.clear()
self.app.processEvents()
self.assertEqual(self.manager.selected_ids, selected_before)
selected_ids = {
item.data(Qt.UserRole + 1)
for item in self.manager.image_list.selectedItems()
}
self.assertEqual(selected_ids, selected_before)
for asset_id in selected_before:
asset = self.manager.assets_by_id[asset_id]
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
editable_filter = self.manager.state_combo.findData("editable")
self.manager.state_combo.setCurrentIndex(editable_filter)
QTest.qWait(20)
self.app.processEvents()
self.assertEqual(self.manager.image_list.count(), 2)
self.assertEqual(self.manager.selected_ids, selected_before)
removed_id = self.manager.image_list.item(0).data(Qt.UserRole + 1)
self._click(0, Qt.ControlModifier)
self.app.processEvents()
self.assertNotIn(removed_id, self.manager.selected_ids)
self.assertEqual(self.manager.image_list.count(), 2)
def test_delete_key_removes_only_highlighted_workspace_copy(self):
asset = self.manager.assets[0]
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
edited = asset.runtime_plain_path.read_bytes()
asset.plain_path.write_bytes(edited)
self.manager.state_combo.setCurrentIndex(
self.manager.state_combo.findData("editable")
)
self.app.processEvents()
self.assertEqual(self.manager.image_list.count(), 1)
self._click(0)
with (
patch.object(QMessageBox, "question", return_value=QMessageBox.Yes),
patch.object(QMessageBox, "information"),
):
QTest.keyClick(self.manager.image_list, Qt.Key_Delete)
action_worker = self.manager._action_worker
self.assertIsNotNone(action_worker)
action_worker.wait(5000)
self.app.processEvents()
self.manager._scan_worker.wait(5000)
self.app.processEvents()
self.assertFalse(asset.plain_path.exists())
self.assertTrue(asset.runtime_plain_path.exists())
self.assertEqual(self.manager.image_list.count(), 0)
self.assertEqual(self.manager.remove_button.text(), "Remove highlighted")
def test_prepare_uses_only_highlighted_editable_images(self):
highlighted = self.manager.assets[0]
not_highlighted = self.manager.assets[1]
for asset in (highlighted, not_highlighted):
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
self._click(0)
with (
patch.object(QMessageBox, "question", return_value=QMessageBox.Yes),
patch.object(self.manager, "_start_action") as start_action,
):
self.manager._prepare_checked()
action, assets = start_action.call_args.args
self.assertEqual(action, "prepare")
self.assertEqual(assets, [highlighted])
self.assertTrue(highlighted.plain_path.exists())
self.assertTrue(not_highlighted.plain_path.exists())
self.assertEqual(
self.manager.prepare_button.text(), "Encrypt highlighted + patch"
)
def test_prepare_uses_all_editable_images_without_highlights(self):
editable = self.manager.assets[:2]
for asset in editable:
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
with (
patch.object(QMessageBox, "question", return_value=QMessageBox.Yes),
patch.object(self.manager, "_start_action") as start_action,
):
self.manager._prepare_checked()
action, assets = start_action.call_args.args
self.assertEqual(action, "prepare")
self.assertEqual(assets, editable)
self.assertEqual(self.manager.prepare_button.text(), "Encrypt all + patch")
def test_bottom_controls_share_one_row_and_action_width(self):
action_buttons = (
self.manager.open_workspace_button,
self.manager.decrypt_selected_button,
self.manager.decrypt_all_button,
self.manager.remove_button,
self.manager.prepare_button,
)
widths = [button.width() for button in action_buttons]
tops = [button.geometry().top() for button in action_buttons]
self.assertLessEqual(max(widths) - min(widths), 1)
self.assertEqual(len(set(tops)), 1)
self.assertEqual(
self.manager.previous_button.geometry().top(),
self.manager.open_workspace_button.geometry().top(),
)
def test_open_folder_uses_highlighted_images_editable_parent(self):
self._click(0)
asset_id = self.manager.image_list.currentItem().data(Qt.UserRole + 1)
expected = self.manager.assets_by_id[asset_id].plain_path.parent
with patch(
"gui.rpgmaker_image_manager.QDesktopServices.openUrl",
return_value=True,
) as open_url:
self.manager._open_editable_folder()
opened_url = open_url.call_args.args[0]
self.assertEqual(Path(opened_url.toLocalFile()), expected)
self.assertTrue(expected.is_dir())
def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self):
for worker in list(self.manager._thumbnail_workers):
worker.wait(5000)
self.app.processEvents()
image_list = self.manager.image_list
self.assertTrue(image_list.uniformItemSizes())
self.assertNotIn("border-bottom", image_list.styleSheet())
self.assertEqual(image_list.count(), 4)
self.assertTrue(
all(not image_list.item(index).icon().isNull() for index in range(4))
)
rects = [image_list.visualItemRect(image_list.item(index)) for index in range(4)]
self.assertEqual(len({(rect.x(), rect.y()) for rect in rects}), 4)
class RPGMakerImageManagerNavigationTests(unittest.TestCase):
def test_page_size_supports_large_image_sets(self):
self.assertEqual(_PAGE_SIZE, 1000)
def test_image_manager_is_a_dedicated_sidebar_page(self):
root = Path(__file__).resolve().parents[1]
main_source = (root / "gui" / "main.py").read_text(encoding="utf-8")
workflow_source = (root / "gui" / "workflow_tab.py").read_text(encoding="utf-8")
self.assertIn("PAGE_IMAGES = 2", main_source)
self.assertIn("self.image_manager_tab = RPGMakerImageManager", main_source)
self.assertIn('create_nav_button("🖼", "Images")', main_source)
self.assertNotIn("Open Image Manager", workflow_source)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,330 @@
import tempfile
import unittest
from io import BytesIO
from pathlib import Path
from PIL import Image
from util.rpgmaker_images import (
add_patch_exceptions,
clean_runtime_image_duplicates,
decrypt_assets,
decrypt_image_bytes,
editable_workspace_root,
encrypt_assets,
encrypt_image_bytes,
migrate_legacy_editable_workspace,
prepare_assets_for_patch,
read_encryption_key,
remove_editable_assets,
scan_image_assets,
)
KEY_HEX = "00112233445566778899aabbccddeeff"
KEY = bytes.fromhex(KEY_HEX)
def png_bytes(color: str) -> bytes:
output = BytesIO()
Image.new("RGBA", (12, 8), color).save(output, format="PNG")
return output.getvalue()
class RPGMakerImageTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "Game"
self.content = self.root / "www"
(self.content / "data").mkdir(parents=True)
(self.content / "img" / "pictures").mkdir(parents=True)
(self.content / "data" / "System.json").write_text(
'{"encryptionKey":"' + KEY_HEX + '"}', encoding="utf-8"
)
def tearDown(self):
self.temp.cleanup()
def _encrypted_asset(self, name: str = "001.rpgmvp", color: str = "red") -> Path:
path = self.content / "img" / "pictures" / name
path.write_bytes(encrypt_image_bytes(png_bytes(color), KEY))
return path
def test_scan_and_decrypt_mv_image_without_touching_encrypted_original(self):
encrypted = self._encrypted_asset()
original = encrypted.read_bytes()
assets = scan_image_assets(self.root)
self.assertEqual([asset.asset_id for asset in assets], ["img/pictures/001.png"])
self.assertFalse(assets[0].has_plain)
self.assertEqual(
assets[0].plain_path,
self.root / ".dazedtl" / "images" / "www" / "img" / "pictures" / "001.png",
)
result = decrypt_assets(
assets, read_encryption_key(self.root), game_root=self.root
)
self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, [])
self.assertEqual(assets[0].plain_path.read_bytes(), png_bytes("red"))
self.assertEqual(encrypted.read_bytes(), original)
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertIn("/.dazedtl/", ignore)
def test_existing_editable_png_is_not_overwritten_by_decrypt_all(self):
self._encrypted_asset()
asset = scan_image_assets(self.root)[0]
plain = asset.plain_path
plain.parent.mkdir(parents=True)
translated = png_bytes("blue")
plain.write_bytes(translated)
result = decrypt_assets(
scan_image_assets(self.root), KEY, game_root=self.root
)
self.assertEqual(result.completed, 0)
self.assertEqual(result.skipped, 1)
self.assertEqual(plain.read_bytes(), translated)
def test_remove_editable_image_deletes_only_workspace_copy(self):
self._encrypted_asset()
asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY, game_root=self.root)
edited = png_bytes("blue")
asset.plain_path.write_bytes(edited)
result = remove_editable_assets(self.root, [asset])
self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, [])
self.assertFalse(asset.plain_path.exists())
self.assertTrue(asset.encrypted_path.exists())
def test_prepare_reencrypts_selected_image_backs_up_original_and_updates_ignore(self):
encrypted = self._encrypted_asset()
original = encrypted.read_bytes()
asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY, game_root=self.root)
translated = png_bytes("blue")
asset.plain_path.write_bytes(translated)
self.root.joinpath(".gitignore").write_text("*.*\n", encoding="utf-8")
result = prepare_assets_for_patch(self.root, [asset], KEY)
self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, [])
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated)
self.assertEqual(asset.plain_path.read_bytes(), translated)
backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp"
self.assertEqual(backup.read_bytes(), original)
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertIn("/.dazedtl/", ignore)
self.assertIn("!/www/img/pictures/001.rpgmvp", ignore)
self.assertNotIn("!/www/img/pictures/001.png", ignore)
second = prepare_assets_for_patch(self.root, [asset], KEY)
again = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertEqual(second.completed, 0)
self.assertEqual(second.skipped, 1)
self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1)
self.assertEqual(backup.read_bytes(), original)
def test_prepare_skips_unchanged_decrypted_image(self):
encrypted = self._encrypted_asset()
original = encrypted.read_bytes()
asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY, game_root=self.root)
result = prepare_assets_for_patch(self.root, [asset], KEY)
self.assertEqual(result.completed, 0)
self.assertEqual(result.skipped, 1)
self.assertEqual(result.patch_files, [])
self.assertEqual(encrypted.read_bytes(), original)
self.assertFalse(
(self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp").exists()
)
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertNotIn("!/www/img/pictures/001.rpgmvp", ignore)
def test_prepare_skips_unchanged_unencrypted_workspace_copy(self):
runtime = self.content / "img" / "pictures" / "menu.png"
original = png_bytes("yellow")
runtime.write_bytes(original)
asset = scan_image_assets(self.root)[0]
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(original)
result = prepare_assets_for_patch(self.root, [asset], None)
self.assertEqual(result.completed, 0)
self.assertEqual(result.skipped, 1)
self.assertEqual(result.patch_files, [])
self.assertEqual(runtime.read_bytes(), original)
self.assertFalse((self.root / ".dazedtl" / "image_backups").exists())
def test_mz_png_uses_same_crypto_and_logical_png_name(self):
encrypted = self._encrypted_asset("title.png_", "green")
asset = scan_image_assets(self.root)[0]
self.assertEqual(asset.asset_id, "img/pictures/title.png")
self.assertEqual(asset.encrypted_path, encrypted)
result = decrypt_assets([asset], KEY, game_root=self.root)
self.assertEqual(result.completed, 1)
self.assertEqual(asset.plain_path.read_bytes(), png_bytes("green"))
def test_encrypt_only_rebuilds_runtime_file_without_changing_gitignore(self):
encrypted = self._encrypted_asset()
asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY, game_root=self.root)
translated = png_bytes("black")
asset.plain_path.write_bytes(translated)
ignore_before = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
result = encrypt_assets(self.root, [asset], KEY)
self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, [])
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated)
self.assertEqual(
self.root.joinpath(".gitignore").read_text(encoding="utf-8"),
ignore_before,
)
def test_nested_gitignore_receives_relative_exact_exception(self):
target = self.content / "img" / "pictures" / "menu image.png"
target.write_bytes(png_bytes("purple"))
nested = self.content / "img" / ".gitignore"
nested.write_text("*\n", encoding="utf-8")
changed = add_patch_exceptions(self.root, [target])
self.assertIn(self.root / ".gitignore", changed)
self.assertIn(nested, changed)
nested_text = nested.read_text(encoding="utf-8")
self.assertIn("!/pictures/menu\\ image.png", nested_text)
root_text = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertIn("!/www/img/pictures/menu\\ image.png", root_text)
def test_plain_selected_image_is_added_without_requiring_key(self):
plain = self.content / "img" / "pictures" / "menu.png"
plain.write_bytes(png_bytes("yellow"))
asset = scan_image_assets(self.root)[0]
result = prepare_assets_for_patch(self.root, [asset], None)
self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, [])
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertIn("!/www/img/pictures/menu.png", ignore)
def test_workspace_edit_of_plain_game_image_is_published_and_backed_up(self):
runtime = self.content / "img" / "pictures" / "menu.png"
original = png_bytes("yellow")
translated = png_bytes("blue")
runtime.write_bytes(original)
asset = scan_image_assets(self.root)[0]
asset.plain_path.parent.mkdir(parents=True)
asset.plain_path.write_bytes(translated)
result = prepare_assets_for_patch(self.root, [asset], None)
self.assertEqual(result.completed, 1)
self.assertEqual(runtime.read_bytes(), translated)
backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/menu.png"
self.assertEqual(backup.read_bytes(), original)
self.assertEqual(result.patch_files, [runtime])
def test_legacy_side_by_side_editable_png_is_migrated_to_workspace(self):
self._encrypted_asset()
legacy = self.content / "img" / "pictures" / "001.png"
translated = png_bytes("purple")
legacy.write_bytes(translated)
asset = scan_image_assets(self.root)[0]
result = decrypt_assets([asset], KEY, game_root=self.root)
self.assertEqual(result.completed, 1)
self.assertEqual(asset.plain_path.read_bytes(), translated)
self.assertFalse(legacy.exists())
def test_conflicting_runtime_duplicate_is_preserved_outside_game_images(self):
self._encrypted_asset()
runtime_duplicate = self.content / "img" / "pictures" / "001.png"
runtime_duplicate.write_bytes(png_bytes("blue"))
asset = scan_image_assets(self.root)[0]
asset.plain_path.parent.mkdir(parents=True)
workspace_edit = png_bytes("purple")
asset.plain_path.write_bytes(workspace_edit)
cleaned = clean_runtime_image_duplicates(self.root)
self.assertEqual(cleaned, 1)
self.assertFalse(runtime_duplicate.exists())
self.assertEqual(asset.plain_path.read_bytes(), workspace_edit)
conflict = (
self.root
/ ".dazedtl"
/ "runtime_plain_conflicts"
/ "www"
/ "img"
/ "pictures"
/ "001.png"
)
self.assertEqual(conflict.read_bytes(), png_bytes("blue"))
def test_mz_workspace_preserves_runtime_hierarchy(self):
mz_root = Path(self.temp.name) / "MZGame"
image_dir = mz_root / "img" / "pictures"
data_dir = mz_root / "data"
image_dir.mkdir(parents=True)
data_dir.mkdir(parents=True)
data_dir.joinpath("System.json").write_text(
'{"encryptionKey":"' + KEY_HEX + '"}', encoding="utf-8"
)
encrypted = image_dir / "title.png_"
encrypted.write_bytes(encrypt_image_bytes(png_bytes("green"), KEY))
asset = scan_image_assets(mz_root)[0]
result = decrypt_assets([asset], KEY, game_root=mz_root)
self.assertEqual(result.completed, 1)
self.assertEqual(
asset.plain_path,
editable_workspace_root(mz_root) / "img" / "pictures" / "title.png",
)
def test_legacy_editable_folder_is_moved_under_dazedtl(self):
legacy = self.root / "DazedTL_Images" / "www" / "img" / "pictures"
legacy.mkdir(parents=True)
translated = png_bytes("purple")
legacy.joinpath("001.png").write_bytes(translated)
moved = migrate_legacy_editable_workspace(self.root)
destination = (
self.root / ".dazedtl" / "images" / "www" / "img" / "pictures" / "001.png"
)
self.assertEqual(moved, 1)
self.assertEqual(destination.read_bytes(), translated)
self.assertFalse((self.root / "DazedTL_Images").exists())
def test_scan_uses_editable_workspace_as_the_edit_list(self):
self._encrypted_asset("001.rpgmvp")
self._encrypted_asset("002.rpgmvp", "blue")
assets = scan_image_assets(self.root)
assets[1].plain_path.parent.mkdir(parents=True, exist_ok=True)
assets[1].plain_path.write_bytes(png_bytes("purple"))
rescanned = scan_image_assets(self.root)
self.assertEqual(
{asset.asset_id for asset in rescanned if asset.has_plain},
{assets[1].asset_id},
)
if __name__ == "__main__":
unittest.main()

613
util/rpgmaker_images.py Normal file
View file

@ -0,0 +1,613 @@
"""RPG Maker MV/MZ image encryption and patch-selection helpers.
RPG Maker encrypts only the first 16 bytes of an asset, prefixes a fixed
16-byte header, and changes the file extension. This module intentionally
handles images only; audio assets use the same container but are outside the
image-translation workflow.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
import io
import json
import os
import re
import shutil
import tempfile
RPGMV_HEADER = bytes(
[0x52, 0x50, 0x47, 0x4D, 0x56, 0x00, 0x00, 0x00,
0x00, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]
)
KEY_LEN = 16
ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_")
EDITABLE_WORKSPACE_NAME = "images"
LEGACY_EDITABLE_WORKSPACE_NAME = "DazedTL_Images"
@dataclass(frozen=True)
class ImageAsset:
"""One logical image and its editable/encrypted representations."""
asset_id: str
relative_png: Path
plain_path: Path
encrypted_path: Path | None = None
runtime_plain_path: Path | None = None
@property
def has_plain(self) -> bool:
return self.plain_path.is_file()
@property
def has_encrypted(self) -> bool:
return self.encrypted_path is not None and self.encrypted_path.is_file()
@property
def has_runtime_plain(self) -> bool:
return self.runtime_plain_path is not None and self.runtime_plain_path.is_file()
@dataclass
class ImageActionResult:
completed: int = 0
skipped: int = 0
errors: list[str] | None = None
patch_files: list[Path] | None = None
gitignore_files: list[Path] | None = None
def __post_init__(self) -> None:
if self.errors is None:
self.errors = []
if self.patch_files is None:
self.patch_files = []
if self.gitignore_files is None:
self.gitignore_files = []
def resolve_content_root(game_root: str | Path) -> Path:
"""Return the directory containing ``data/`` and ``img/``."""
root = Path(game_root).expanduser().resolve()
for candidate in (root / "www", root):
if (candidate / "img").is_dir():
return candidate
raise FileNotFoundError(
f"Could not find an img folder under {root} or {root / 'www'}."
)
def read_encryption_key(game_root: str | Path) -> bytes:
"""Read and validate the 16-byte encryption key from System.json."""
content_root = resolve_content_root(game_root)
system_path = content_root / "data" / "System.json"
if not system_path.is_file():
raise FileNotFoundError(f"System.json not found: {system_path}")
text = system_path.read_text(encoding="utf-8-sig", errors="replace")
try:
raw_key = json.loads(text).get("encryptionKey", "")
except json.JSONDecodeError:
match = re.search(r'"encryptionKey"\s*:\s*"([a-fA-F0-9]{32})"', text)
raw_key = match.group(1) if match else ""
normalized = str(raw_key).strip().replace(" ", "")
if not re.fullmatch(r"[a-fA-F0-9]{32}", normalized):
raise ValueError(
"System.json does not contain a valid 32-character encryptionKey."
)
return bytes.fromhex(normalized)
def editable_workspace_root(game_root: str | Path) -> Path:
"""Return the DazedTL working folder used for editable image copies."""
return (
Path(game_root).expanduser().resolve()
/ ".dazedtl"
/ EDITABLE_WORKSPACE_NAME
)
def _logical_png(path: Path) -> Path:
lower = path.name.casefold()
if lower.endswith(".rpgmvp"):
return path.with_name(path.name[:-7] + ".png")
if lower.endswith(".png_"):
return path.with_name(path.name[:-5] + ".png")
return path
def scan_image_assets(game_root: str | Path) -> list[ImageAsset]:
"""Scan ``img/`` and combine matching PNG/encrypted files into records."""
root = Path(game_root).expanduser().resolve()
content_root = resolve_content_root(root)
image_root = content_root / "img"
by_id: dict[str, dict[str, Path]] = {}
for path in image_root.rglob("*"):
if not path.is_file():
continue
name = path.name.casefold()
if not (name.endswith(".png") or name.endswith(ENCRYPTED_IMAGE_EXTENSIONS)):
continue
logical = _logical_png(path)
relative = logical.relative_to(content_root)
asset_id = relative.as_posix()
entry = by_id.setdefault(
asset_id,
{
"relative": relative,
"workspace_relative": logical.relative_to(root),
},
)
if name.endswith(ENCRYPTED_IMAGE_EXTENSIONS):
entry["encrypted"] = path
else:
entry["runtime_plain"] = path
assets = [
ImageAsset(
asset_id=asset_id,
relative_png=entry["relative"],
plain_path=editable_workspace_root(root) / entry["workspace_relative"],
encrypted_path=entry.get("encrypted"),
runtime_plain_path=entry.get("runtime_plain"),
)
for asset_id, entry in by_id.items()
]
return sorted(assets, key=lambda asset: asset.asset_id.casefold())
def decrypt_image_bytes(data: bytes, key: bytes) -> bytes:
if len(key) != KEY_LEN:
raise ValueError("The RPG Maker encryption key must contain 16 bytes.")
if len(data) < len(RPGMV_HEADER) + KEY_LEN:
raise ValueError("Encrypted image is too small.")
if data[: len(RPGMV_HEADER)] != RPGMV_HEADER:
raise ValueError("Encrypted image has an invalid RPG Maker header.")
start = len(RPGMV_HEADER)
block = bytes(value ^ key[index] for index, value in enumerate(data[start:start + KEY_LEN]))
return block + data[start + KEY_LEN:]
def encrypt_image_bytes(data: bytes, key: bytes) -> bytes:
if len(key) != KEY_LEN:
raise ValueError("The RPG Maker encryption key must contain 16 bytes.")
if len(data) < KEY_LEN:
raise ValueError("PNG image is too small.")
block = bytes(value ^ key[index] for index, value in enumerate(data[:KEY_LEN]))
return RPGMV_HEADER + block + data[KEY_LEN:]
def preview_png_bytes(asset: ImageAsset, key: bytes | None) -> bytes:
"""Return displayable PNG bytes, decrypting in memory when necessary."""
if asset.has_plain:
return asset.plain_path.read_bytes()
if asset.has_runtime_plain:
return asset.runtime_plain_path.read_bytes()
if not asset.has_encrypted:
raise FileNotFoundError(asset.runtime_plain_path or asset.plain_path)
if key is None:
raise ValueError("The encryption key is required to preview this image.")
return decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
def thumbnail_png_bytes(asset: ImageAsset, key: bytes | None, size: int = 128) -> bytes:
"""Create a small PNG thumbnail without writing a decrypted working file."""
from PIL import Image
raw = preview_png_bytes(asset, key)
with Image.open(io.BytesIO(raw)) as image:
image.thumbnail((size, size))
if image.mode not in {"RGB", "RGBA"}:
image = image.convert("RGBA")
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def _atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
handle = tempfile.NamedTemporaryFile(
mode="wb", prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, delete=False
)
temp_path = Path(handle.name)
try:
with handle:
handle.write(data)
os.replace(temp_path, path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def _ensure_image_manager_ignores(game_root: str | Path) -> None:
root = Path(game_root).expanduser().resolve()
ignore_path = root / ".gitignore"
existing = (
ignore_path.read_text(encoding="utf-8", errors="surrogateescape")
if ignore_path.exists()
else ""
)
rules = ("/.dazedtl/",)
additions = [rule for rule in rules if rule not in existing.splitlines()]
if additions:
text = existing
if text and not text.endswith("\n"):
text += "\n"
if "# DazedTL image manager working files" not in text:
text += "\n# DazedTL image manager working files\n"
text += "\n".join(additions) + "\n"
_atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape"))
def ensure_editable_workspace(game_root: str | Path) -> Path:
"""Create the editable folder and keep working files out of patches."""
root = Path(game_root).expanduser().resolve()
workspace = editable_workspace_root(root)
workspace.mkdir(parents=True, exist_ok=True)
migrate_legacy_editable_workspace(root)
_ensure_image_manager_ignores(root)
return workspace
def migrate_legacy_editable_workspace(game_root: str | Path) -> int:
"""Move editable PNGs from the former top-level workspace into .dazedtl."""
root = Path(game_root).expanduser().resolve()
legacy = root / LEGACY_EDITABLE_WORKSPACE_NAME
if not legacy.is_dir():
return 0
destination_root = editable_workspace_root(root)
conflict_root = root / ".dazedtl" / "legacy_image_conflicts"
moved = 0
for source in sorted(path for path in legacy.rglob("*") if path.is_file()):
relative = source.relative_to(legacy)
destination = destination_root / relative
if destination.exists():
destination = conflict_root / relative
counter = 2
while destination.exists():
destination = conflict_root / relative.with_name(
f"{relative.stem}_{counter}{relative.suffix}"
)
counter += 1
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(destination))
moved += 1
directories = sorted(
(path for path in legacy.rglob("*") if path.is_dir()),
key=lambda path: len(path.parts),
reverse=True,
)
for directory in directories:
try:
directory.rmdir()
except OSError:
pass
try:
legacy.rmdir()
except OSError:
pass
_ensure_image_manager_ignores(root)
return moved
def clean_runtime_image_duplicates(game_root: str | Path) -> int:
"""Move PNGs that duplicate encrypted assets out of the runtime image tree."""
root = Path(game_root).expanduser().resolve()
cleaned = 0
for asset in scan_image_assets(root):
if not (asset.has_encrypted and asset.has_runtime_plain):
continue
source = asset.runtime_plain_path
destination = asset.plain_path
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists():
shutil.move(str(source), str(destination))
elif source.read_bytes() == destination.read_bytes():
source.unlink()
else:
relative = source.relative_to(root)
conflict_root = root / ".dazedtl" / "runtime_plain_conflicts"
conflict = conflict_root / relative
counter = 2
while conflict.exists():
conflict = (conflict_root / relative).with_name(
f"{relative.stem}_{counter}{relative.suffix}"
)
counter += 1
conflict.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(conflict))
cleaned += 1
if cleaned:
_ensure_image_manager_ignores(root)
return cleaned
def decrypt_assets(
assets: Iterable[ImageAsset],
key: bytes,
*,
game_root: str | Path | None = None,
overwrite: bool = False,
progress: Callable[[str], None] | None = None,
) -> ImageActionResult:
"""Decrypt images into the game-local editable image workspace."""
result = ImageActionResult()
assets = list(assets)
if game_root is not None:
ensure_editable_workspace(game_root)
for asset in assets:
if not asset.has_encrypted:
result.skipped += 1
continue
if asset.has_plain and not overwrite:
result.skipped += 1
continue
try:
# Older versions of the image manager placed the editable PNG
# beside its encrypted runtime file. Preserve any work done in
# that layout by migrating it instead of decrypting over it.
if asset.has_runtime_plain and not asset.has_plain and not overwrite:
if progress:
progress(f"Moving editable copy for {asset.asset_id}")
_atomic_write(asset.plain_path, asset.runtime_plain_path.read_bytes())
asset.runtime_plain_path.unlink()
result.completed += 1
continue
if progress:
progress(f"Decrypting {asset.asset_id}")
raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
_atomic_write(asset.plain_path, raw)
result.completed += 1
except Exception as exc: # continue a large batch and report all failures
result.errors.append(f"{asset.asset_id}: {exc}")
if game_root is not None:
try:
clean_runtime_image_duplicates(game_root)
except Exception as exc:
result.errors.append(f"Runtime image cleanup: {exc}")
return result
def _path_inside(root: Path, path: Path) -> Path:
try:
return path.resolve().relative_to(root.resolve())
except ValueError as exc:
raise ValueError(f"Image is outside the selected game folder: {path}") from exc
def remove_editable_assets(
game_root: str | Path,
assets: Iterable[ImageAsset],
*,
progress: Callable[[str], None] | None = None,
) -> ImageActionResult:
"""Delete editable copies while leaving their runtime assets untouched."""
root = Path(game_root).expanduser().resolve()
workspace = editable_workspace_root(root)
result = ImageActionResult()
for asset in assets:
try:
if not asset.has_plain:
result.skipped += 1
continue
_path_inside(workspace, asset.plain_path)
if progress:
progress(f"Removing {asset.asset_id} from editable images")
asset.plain_path.unlink()
result.completed += 1
parent = asset.plain_path.parent
while parent != workspace:
try:
parent.rmdir()
except OSError:
break
parent = parent.parent
except Exception as exc:
result.errors.append(f"{asset.asset_id}: {exc}")
return result
def _escape_gitignore_component(value: str) -> str:
if "\n" in value or "\r" in value:
raise ValueError("Image paths containing line breaks cannot be added to .gitignore.")
escaped = ""
for char in value:
if char in "\\*?[]!# " or char == "\t":
escaped += "\\"
escaped += char
return escaped
def _gitignore_pattern(relative: Path, *, directory: bool = False) -> str:
parts = [_escape_gitignore_component(part) for part in relative.parts]
pattern = "!/" + "/".join(parts)
if directory:
pattern += "/"
return pattern
def add_patch_exceptions(
game_root: str | Path, targets: Iterable[str | Path]
) -> list[Path]:
"""Append exact allow-rules to applicable .gitignore files.
Root and nested ignore files can both affect an asset. Adding a precise
exception to each existing file ensures a deeper rule cannot hide a chosen
image again. Existing content is preserved and entries are idempotent.
"""
root = Path(game_root).expanduser().resolve()
relative_targets = sorted(
{_path_inside(root, Path(target)) for target in targets},
key=lambda path: path.as_posix().casefold(),
)
if not relative_targets:
return []
ignore_to_targets: dict[Path, list[Path]] = {root / ".gitignore": relative_targets}
for relative in relative_targets:
parent = root
for part in relative.parts[:-1]:
parent /= part
nested = parent / ".gitignore"
if nested.is_file():
ignore_to_targets.setdefault(nested, []).append(
Path(*relative.parts[len(parent.relative_to(root).parts):])
)
changed: list[Path] = []
for ignore_path, entries in ignore_to_targets.items():
existing = (
ignore_path.read_text(encoding="utf-8", errors="surrogateescape")
if ignore_path.exists()
else ""
)
rules: list[str] = []
if ignore_path == root / ".gitignore":
rules.append("/.dazedtl/")
for relative in entries:
for depth in range(1, len(relative.parts)):
rules.append(_gitignore_pattern(Path(*relative.parts[:depth]), directory=True))
rules.append(_gitignore_pattern(relative))
additions = [rule for rule in dict.fromkeys(rules) if rule not in existing.splitlines()]
if not additions:
continue
text = existing
if text and not text.endswith("\n"):
text += "\n"
if "# DazedTL selected image patches" not in text:
text += "\n# DazedTL selected image patches\n"
text += "\n".join(additions) + "\n"
ignore_path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape"))
changed.append(ignore_path)
return changed
def prepare_assets_for_patch(
game_root: str | Path,
assets: Iterable[ImageAsset],
key: bytes | None,
*,
progress: Callable[[str], None] | None = None,
) -> ImageActionResult:
"""Re-encrypt edited PNGs and add only selected runtime assets to gitignore."""
root = Path(game_root).expanduser().resolve()
result = ImageActionResult()
targets: list[Path] = []
for asset in assets:
try:
if asset.has_encrypted:
if not asset.has_plain:
raise FileNotFoundError("decrypt this image before preparing it")
if key is None:
raise ValueError("the encryption key is required")
if progress:
progress(f"Checking {asset.asset_id}")
plain_bytes = asset.plain_path.read_bytes()
runtime_bytes = asset.encrypted_path.read_bytes()
if decrypt_image_bytes(runtime_bytes, key) == plain_bytes:
result.skipped += 1
continue
relative_encrypted = _path_inside(root, asset.encrypted_path)
backup = root / ".dazedtl" / "image_backups" / relative_encrypted
if not backup.exists():
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(asset.encrypted_path, backup)
if progress:
progress(f"Encrypting {asset.asset_id}")
encrypted = encrypt_image_bytes(plain_bytes, key)
_atomic_write(asset.encrypted_path, encrypted)
targets.append(asset.encrypted_path)
elif asset.has_runtime_plain:
# Unencrypted games load the PNG from the runtime image tree.
# If the user made a workspace copy, publish it back there and
# preserve the original once, just like encrypted assets.
if asset.has_plain:
if progress:
progress(f"Checking {asset.asset_id}")
plain_bytes = asset.plain_path.read_bytes()
if asset.runtime_plain_path.read_bytes() == plain_bytes:
result.skipped += 1
continue
relative_plain = _path_inside(root, asset.runtime_plain_path)
backup = root / ".dazedtl" / "image_backups" / relative_plain
if not backup.exists():
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(asset.runtime_plain_path, backup)
if progress:
progress(f"Publishing {asset.asset_id}")
_atomic_write(asset.runtime_plain_path, plain_bytes)
targets.append(asset.runtime_plain_path)
else:
raise FileNotFoundError("image file no longer exists")
result.completed += 1
except Exception as exc:
result.errors.append(f"{asset.asset_id}: {exc}")
if targets:
if progress:
progress("Updating .gitignore image allow-rules")
result.patch_files = targets
try:
result.gitignore_files = add_patch_exceptions(root, targets)
except Exception as exc:
result.errors.append(f".gitignore: {exc}")
try:
clean_runtime_image_duplicates(root)
except Exception as exc:
result.errors.append(f"Runtime image cleanup: {exc}")
return result
def encrypt_assets(
game_root: str | Path,
assets: Iterable[ImageAsset],
key: bytes,
*,
progress: Callable[[str], None] | None = None,
) -> ImageActionResult:
"""Re-encrypt workspace PNGs without changing gitignore files."""
root = Path(game_root).expanduser().resolve()
result = ImageActionResult()
for asset in assets:
try:
if not asset.has_encrypted:
raise FileNotFoundError("no matching encrypted runtime image exists")
if not asset.has_plain:
raise FileNotFoundError("decrypt this image before encrypting it")
relative_encrypted = _path_inside(root, asset.encrypted_path)
backup = root / ".dazedtl" / "image_backups" / relative_encrypted
if not backup.exists():
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(asset.encrypted_path, backup)
if progress:
progress(f"Encrypting {asset.asset_id}")
encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key)
_atomic_write(asset.encrypted_path, encrypted)
result.patch_files.append(asset.encrypted_path)
result.completed += 1
except Exception as exc:
result.errors.append(f"{asset.asset_id}: {exc}")
try:
clean_runtime_image_duplicates(root)
except Exception as exc:
result.errors.append(f"Runtime image cleanup: {exc}")
return result