======= D2RMT(Diablo 2 Resurrected Mod Toolkit) =======

[구글 드라이브 다운로드 링크]



오랜만에 시간이 나서 구상만 하고 있던 프로그램 만들어 공유합니다.

모드 백업 및 롤백, 모드 추가 등을 할 수 있는 툴킷입니다.

모드를 하나하나 백업해서 관리하고 롤백하는 게 꽤 번거로워 만든 프로그램입니다.




[주요 기능]

1. 현재 모드 백업 / 롤백 / 바닐라 기능

   현재 모드를 연월일시간을 초단위까지 기억해 백업합니다. 이렇게 백업된 모드는 내용을 제목으로 지정해
   두고 언제든지 롤백시킬 수 있습니다(하단 스크린샷 참조). 추가로 바닐라 버튼을 조작해 바닐라(순정) 
   상태로 모드를 되돌리는 기능도 있습니다(모드 테스트용).

   
   


2. 모드 불러오기 / 설치 기능

   인벤 애드온·모드 게시판에서 다운 받은 모더분들의 모드를 불러와 현재 모드에 설치 가능합니다.
   백업 / 롤백 기능과 연계하면 언제든지 원하는 스킨이나 기능이 포함된 모드를 불러올 수 있습니다.
   ※ 불러온 모드는 별도의 mod 폴더에 정리되어 다운로드 받은 모드를 지우더라도 언제든지 앱에서 
      설치/삭제가 가능합니다.


▲ 드래그로 위치 변경 가능


▲ 어떤 모드인지 직접 제목 설정하면 됩니다.


3. 모드 변경내역(로그) 기능

   모드별로 변경 내역을 저장해서 앱 최하단의 로그 보기를 눌러 언제든 이력 확인이 가능합니다. 
   롤백으로 모드를 불러올 때에도 각 모드별로 로그를 불러옵니다.







[사용법]

1. D2RMT_v0.1.exe를 원하는 경로에 폴더를 만들어 넣고 바로가기 생성

   
    ▲ 처음 다운로드 받으면 해당 파일 하나지만, 앱 실행 후 설정을 진행하면 설정파일과 모드폴더가 같은 
        경로에 생성 됩니다. 따라서 따로 폴더에 넣어두시고 바로가기를 만들어 사용하시면 깔끔합니다.


2. 현재 모드 폴더 경로지정

   디아블로2 레저렉션이 설치된 폴더/mods/모드폴더를 선택 - '폴더 선택' 클릭
   (예시 : 저의 경우 C:/Program files (x86)/Diablo II Resurrected/Mods/drag 폴더)




3. 현재 모드 백업

   1.에 따라 경로지정을 하셨다면 '백업' 버튼을 눌러 제목을 '원본'으로 백업해줍니다.



▲ 이제 언제든지 '롤백' 버튼을 이용해 방금 백업한 원본 상태로 되돌릴 수 있습니다.


4. 모드 불러오기 및 설치

   (1) 다운 받은 모드의 압축을 풉니다.

   (2) 앱에서 ③추가모드 항목의 '+모드 불러오기' 버튼 클릭

   (3) 압축풀어 나온 global, hd, local 폴더를 선택합니다

       data폴더에 바로 들어가는 global, hd, local 폴더만 해당합니다. 이외에는 에러 출력하도록 했습니다.
          만약 받으신 폴더가 이보다 하위 폴더일 경우 직접 상위폴더를 생성해줘야 합니다. 
          (예시 : 스킨을 받았는데 items 폴더만 있을 경우 hd 폴더 생성해서 안에 items 폴더 넣고 불러오기)

       ※ 다중 선택 기능은 안정성 이슈로 제외했고, 한 폴더를 선택하면 반복 팝업 되고 취소 누를 경우 제목 
          설정으로 넘어갑니다. (가령 적용하려는 모드가 hd와 global 폴더만 있다면 'hd폴더 선택 - 확인' 후 
          다시 뜨는 창에서 'global 폴더 선택 - 확인' 그리고 다시 뜨는 창에서 '취소'를 누르시면 됩니다.)





▲ 불러오기 및 제목 설정을 마친 다음, 설치 버튼을 누르면 현재 모드에 설치됩니다. 테스트 후 마음에 안들 
    경우 롤백하시면 되겠죠?

※ 모드들을 설치하신 후 주기적으로 백업 - 제목설정에 모드내용을 적어 두시면 언제든지 확인해 모드 전환 
   가능합니다!




보안 및 안전 안내
  • 100% 안전한 툴: 오직 모드 폴더의 백업/롤백과 외부모드 설치의 편의성만을 위해 제작된 도우미 프로그램입니다.
  • 소스코드 투명 공개: 혹시 모를 불안감이 있으신 분들을 위해 사용된 파이썬(Python) 소스코드를 하단에 그대로 첨부합니다. 의심스러우시다면 코드를 직접 검증하시거나 파이썬으로 직접 실행하셔도 좋습니다!
  • 미인증 개인 제작 파일 특성상 백신 프로그램이 오진할 수 있으나, 어떠한 악성 기능도 없는 청정 프로그램임을 보증합니다

▼ 클릭 시 펼쳐집니다.

Python Code 보기
"""
D2R Mod Manager - Diablo II Resurrected 모드 관리 툴
"""

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import os
import json
import shutil
import re
from datetime import datetime
from pathlib import Path

import sys

# PyInstaller로 빌드된 exe는 __file__이 임시 폴더를 가리키므로
# sys.executable 기준(exe 실제 위치)으로 저장 경로를 잡음
if getattr(sys, "frozen", False):
    _BASE_DIR = Path(sys.executable).parent
else:
    _BASE_DIR = Path(__file__).parent

SAVE_FILE = _BASE_DIR / "d2r_mod_manager_state.json"

APP_ICON = "app_icon.ico"

def _get_ico_path() -> Path | None:
    """번들/스크립트 환경 모두에서 app_icon.ico 경로를 반환한다."""
    if getattr(sys, "frozen", False):
        bundle_dir = Path(sys._MEIPASS)
    else:
        bundle_dir = Path(__file__).parent
    p = bundle_dir / APP_ICON
    return p if p.exists() else None


def _set_icon(win):
    """타이틀바·Alt+Tab 아이콘을 설정한다. (작업표시줄은 _set_taskbar_icon 담당)"""
    ico_path = _get_ico_path()
    if ico_path:
        try:
            win.iconbitmap(str(ico_path))
        except Exception:
            pass


def _set_taskbar_icon(win):
    """ctypes로 HICON을 직접 윈도우 핸들에 전송해 작업표시줄 아이콘을 교체한다.
    메인 Tk 창에만 호출하면 된다 (mainloop 진입 직후 after로 호출).
    wm_frame()으로 실제 최상위 데코레이션 윈도우 핸들을 획득하고,
    WS_EX_APPWINDOW를 명시적으로 설정해 작업표시줄 표시를 강제한다.
    """
    if sys.platform != "win32":
        return
    ico_path = _get_ico_path()
    if not ico_path:
        return
    try:
        import ctypes

        user32 = ctypes.windll.user32

        GWL_EXSTYLE      = -20
        WS_EX_APPWINDOW  = 0x00040000
        WS_EX_TOOLWINDOW = 0x00000080
        LR_LOADFROMFILE  = 0x10
        IMAGE_ICON       = 1
        WM_SETICON       = 0x0080
        ICON_SMALL       = 0
        ICON_BIG         = 1

        # winfo_id()는 내부 child 핸들을 반환할 수 있으므로
        # wm_frame()으로 실제 최상위(데코레이션 포함) 윈도우 핸들을 획득
        try:
            hwnd = int(win.wm_frame(), 16)
        except Exception:
            hwnd = win.winfo_id()

        # 큰 아이콘 (작업표시줄용)
        big_size = user32.GetSystemMetrics(11)   # SM_CXICON
        hicon_big = user32.LoadImageW(
            None, str(ico_path), IMAGE_ICON,
            big_size, big_size, LR_LOADFROMFILE
        )
        # 작은 아이콘 (타이틀바용)
        sm_size = user32.GetSystemMetrics(49)    # SM_CXSMICON
        hicon_sm = user32.LoadImageW(
            None, str(ico_path), IMAGE_ICON,
            sm_size, sm_size, LR_LOADFROMFILE
        )

        if hicon_big:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG,   hicon_big)
        if hicon_sm:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_sm)

        # WS_EX_APPWINDOW 명시적 설정 → 작업표시줄에 항상 표시
        ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
        ex_style = (ex_style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW
        user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)

    except Exception:
        pass


def _set_appid():
    """Windows 작업표시줄 아이콘을 exe 아이콘으로 고정시키기 위해
    AppUserModelID를 등록한다. 메인 창 생성 전에 한 번만 호출하면 된다.
    """
    if sys.platform != "win32":
        return
    try:
        import ctypes
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
            "D2RModManager.App.1"
        )
    except Exception:
        pass


# ──────────────────────────────────────────────
#  커스텀 텍스트 입력 다이얼로그
#  (simpledialog.askstring 대체 → 아이콘 적용 가능)
# ──────────────────────────────────────────────
class AskStringDialog(tk.Toplevel):
    """단일 텍스트 입력을 받는 커스텀 다이얼로그.
    result: 확인 시 입력 문자열, 취소/닫기 시 None.
    """
    def __init__(self, parent, title: str, prompt: str, initial: str = ""):
        super().__init__(parent)
        self.title(title)
        self.configure(bg=BG_DARK)
        self.resizable(False, False)
        self.result = None
        _set_icon(self)

        pw, ph = 440, 200
        self.update_idletasks()
        sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
        self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
        self.grab_set()

        tk.Label(self, text=prompt, bg=BG_DARK, fg=TEXT_MAIN,
                 font=FONT_SMALL, wraplength=340, justify="left",
                 padx=20, pady=14).pack(fill="x")

        self._var = tk.StringVar(value=initial)
        entry = tk.Entry(self, textvariable=self._var,
                         bg=BG_CARD, fg=TEXT_MAIN, insertbackground=TEXT_MAIN,
                         font=FONT_BODY, relief="flat",
                         highlightthickness=1, highlightcolor=BORDER_GOLD,
                         highlightbackground=BORDER)
        entry.pack(fill="x", padx=20)
        entry.icursor("end")
        entry.focus_set()

        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=14)
        styled_button(btn_frame, "확인", self._ok,
                      BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "취소", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")

        self.bind("<Return>", lambda e: self._ok())
        self.bind("<Escape>", lambda e: self.destroy())
        self.protocol("WM_DELETE_WINDOW", self.destroy)

    def _ok(self):
        self.result = self._var.get()
        self.destroy()

    @classmethod
    def ask(cls, parent, title: str, prompt: str, initial: str = ""):
        """블로킹 호출. 확인 → 입력값(str), 취소 → None 반환."""
        dlg = cls(parent, title, prompt, initial)
        parent.wait_window(dlg)
        return dlg.result

# 추가 모드를 영구 보관하는 폴더 (exe 옆 mod/ 디렉터리)
MOD_STORE_DIR = _BASE_DIR / "mod"


def _read_backup_labels(backup_root: Path) -> dict:
    """backup_root/backup_labels.json → {폴더명: 메모} dict"""
    label_file = backup_root / "backup_labels.json"
    if label_file.exists():
        try:
            return json.loads(label_file.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def _write_backup_labels(backup_root: Path, labels: dict):
    """labels dict를 backup_root/backup_labels.json 에 저장"""
    label_file = backup_root / "backup_labels.json"
    try:
        label_file.write_text(json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


# ──────────────────────────────────────────────
#  로그 파일 경로 helper
# ──────────────────────────────────────────────
def _log_path_for(mod_folder: Path) -> Path:
    """mod_folder 옆 BackUp/ 안의 <모드명>_activity_log.json 경로 (모드별 분리)"""
    mod_name = mod_folder.name
    return mod_folder.parent / "BackUp" / f"{mod_name}_activity_log.json"


def _read_log(mod_folder: Path) -> list:
    p = _log_path_for(mod_folder)
    if p.exists():
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            pass
    return []


def _write_log(mod_folder: Path, entries: list):
    backup_root = mod_folder.parent / "BackUp"
    backup_root.mkdir(exist_ok=True)
    p = _log_path_for(mod_folder)
    try:
        p.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


def _ts_now() -> str:
    """현재 시각을 'YY:MM:DD:HH:MM:SS' 표시용 문자열로 반환"""
    return datetime.now().strftime("%y:%m:%d %H:%M:%S")


# ──────────────────────────────────────────────
#  색상 & 폰트 테마 (Windows 11 네이티브 스타일)
# ──────────────────────────────────────────────
BG_DARK      = "#f3f3f3"   # 창 배경
BG_PANEL     = "#e8e8e8"   # 상태바 배경
BG_CARD      = "#ffffff"   # 카드 배경
BG_CARD_HOV  = "#f5f5f5"   # 카드 호버
BORDER       = "#d0d0d0"   # 일반 테두리
BORDER_GOLD  = "#0078d4"   # 강조 테두리 (Windows 블루)
ACCENT_GOLD  = "#0078d4"   # 강조색 (Windows 블루)
ACCENT_RED   = "#c42b1c"   # 위험 강조색
TEXT_MAIN    = "#1a1a1a"   # 기본 텍스트
TEXT_SUB     = "#5a5a5a"   # 보조 텍스트
TEXT_DIM     = "#999999"   # 흐린 텍스트
BTN_INSTALL  = "#0078d4"   # 설치 버튼 (Windows 블루)
BTN_INSTALL_H= "#006cbe"
BTN_REMOVE   = "#c42b1c"   # 제거 버튼 (빨간색)
BTN_REMOVE_H = "#ae2518"
BTN_BACKUP   = "#107c10"   # 백업 버튼 (초록색)
BTN_BACKUP_H = "#0e6b0e"
BTN_ROLL     = "#7a5af8"   # 롤백 버튼 (보라색)
BTN_ROLL_H   = "#6b4fe0"

FONT_TITLE   = ("Segoe UI", 18, "bold")
FONT_HEAD    = ("Segoe UI", 12, "bold")
FONT_BODY    = ("Segoe UI", 12)
FONT_SMALL   = ("Segoe UI", 11)
FONT_MONO    = ("Consolas", 11)

VALID_FOLDERS = {"global", "hd", "local"}


def styled_button(parent, text, command, bg, hover_bg,
                  fg="#ffffff", padx=10, pady=4, font=FONT_SMALL, **kw):
    btn = tk.Label(parent, text=text, bg=bg, fg=fg,
                   font=font, padx=padx, pady=pady,
                   cursor="hand2", relief="flat", **kw)
    btn.bind("<Button-1>", lambda e: command())
    btn.bind("<Enter>",  lambda e: btn.config(bg=hover_bg))
    btn.bind("<Leave>",  lambda e: btn.config(bg=bg))
    return btn


# ──────────────────────────────────────────────
#  활동 로그 다이얼로그
# ──────────────────────────────────────────────
class LogDialog(tk.Toplevel):
    def __init__(self, parent, entries: list, mod_name: str):
        super().__init__(parent)
        self.title("활동 로그")
        self.configure(bg=BG_DARK)
        self.resizable(True, True)
        _set_icon(self)

        pw, ph = 660, 520
        self.update_idletasks()
        sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
        self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
        self.grab_set()

        tk.Label(self, text=f"활동 로그 — {mod_name}",
                 bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD, pady=12).pack(fill="x")
        tk.Frame(self, bg=BORDER_GOLD, height=1).pack(fill="x", padx=20)

        frame = tk.Frame(self, bg=BG_DARK)
        frame.pack(fill="both", expand=True, padx=20, pady=10)

        sb = tk.Scrollbar(frame)
        sb.pack(side="right", fill="y")

        lb = tk.Listbox(
            frame,
            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
            selectbackground=BORDER_GOLD, selectforeground="#ffffff",
            relief="flat", bd=0,
            highlightthickness=1, highlightcolor=BORDER_GOLD,
            highlightbackground=BORDER_GOLD,
            yscrollcommand=sb.set, activestyle="none"
        )
        lb.pack(side="left", fill="both", expand=True)
        sb.config(command=lb.yview)

        if entries:
            for e in reversed(entries):   # 최신 항목이 위로
                lb.insert("end", f"  {e['ts']}   {e['msg']}")
        else:
            lb.insert("end", "  (기록 없음)")

        self.after(50, lb.focus_set)

        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=(0, 14))
        styled_button(btn_frame, "닫기", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")


# ──────────────────────────────────────────────
#  롤백 선택 다이얼로그
# ──────────────────────────────────────────────
class RollbackDialog(tk.Toplevel):
    def __init__(self, parent, backup_folder: Path, current_mod_name: str):
        super().__init__(parent)
        self.title("롤백 선택")
        self.configure(bg=BG_DARK)
        self.resizable(False, False)
        self.selected = None
        _set_icon(self)
        self.current_mod_name = current_mod_name
        self._backup_folder = backup_folder
        self._labels = _read_backup_labels(backup_folder) if backup_folder.exists() else {}

        # 화면 중앙 배치
        pw, ph = 560, 460
        self.update_idletasks()
        sw = self.winfo_screenwidth()
        sh = self.winfo_screenheight()
        x = max(0, (sw - pw) // 2)
        y = max(0, (sh - ph) // 2)
        self.geometry(f"{pw}x{ph}+{x}+{y}")
        self.grab_set()

        # 제목
        tk.Label(self, text="⟲  롤백 포인트 선택",
                 bg=BG_DARK, fg=ACCENT_GOLD,
                 font=FONT_HEAD, pady=14).pack(fill="x")

        sep = tk.Frame(self, bg=BORDER_GOLD, height=1)
        sep.pack(fill="x", padx=20)

        tk.Label(self, text="복원할 백업을 선택하세요.",
                 bg=BG_DARK, fg=TEXT_SUB, font=FONT_SMALL, pady=8).pack()

        # 리스트
        frame = tk.Frame(self, bg=BG_DARK)
        frame.pack(fill="both", expand=True, padx=20, pady=(0, 10))

        sb = tk.Scrollbar(frame)
        sb.pack(side="right", fill="y")

        self.listbox = tk.Listbox(
            frame,
            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
            selectbackground=BORDER_GOLD, selectforeground="#ffffff",
            relief="flat", bd=0,
            highlightthickness=1, highlightcolor=BORDER_GOLD,
            highlightbackground=BORDER_GOLD,
            yscrollcommand=sb.set,
            activestyle="none"
        )
        self.listbox.pack(side="left", fill="both", expand=True)
        sb.config(command=self.listbox.yview)

        # BackUp 폴더 내 .mpq 폴더 목록 수집
        self.entries = []
        if backup_folder.exists():
            pattern = re.compile(
                rf"^{re.escape(current_mod_name)}(d{{10,12}}).mpq$", re.IGNORECASE
            )
            for item in sorted(backup_folder.iterdir(), reverse=True):
                if item.is_dir() and item.name.lower().endswith(".mpq"):
                    m = pattern.match(item.name)
                    if m:
                        ts = m.group(1)          # 예: 2606112100
                        display = self._format_ts(ts, item.name)
                        self.entries.append((item.name, display))
                        self.listbox.insert("end", f"  {display}")

        if not self.entries:
            self.listbox.insert("end", "  (백업 없음)")

        self.after(50, self.listbox.focus_set)

        # 버튼
        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=(0, 16))

        styled_button(btn_frame, "✔  복원", self._confirm,
                      BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "🗑  제거", self._delete_selected,
                      BTN_REMOVE, BTN_REMOVE_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "✕  취소", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")

    def _format_ts(self, ts: str, raw_name: str) -> str:
        """260612091036 → 2026-06-12 09:10:36  |  파싱 실패 시 raw_name"""
        try:
            year   = int("20" + ts[0:2])
            month  = int(ts[2:4])
            day    = int(ts[4:6])
            hour   = int(ts[6:8])
            minute = int(ts[8:10])
            second = int(ts[10:12]) if len(ts) >= 12 else 0
            date_str = f"{year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
            memo = self._labels.get(raw_name, "")
            if memo:
                return f"[{memo}]  {date_str}"
            else:
                base = raw_name.replace(".mpq", "")
                mod_only = re.sub(r"d{10,12}$", "", base)
                return f"{mod_only}  ({date_str})"
        except Exception:
            return raw_name.replace(".mpq", "")

    def _confirm(self):
        sel = self.listbox.curselection()
        if not sel or not self.entries:
            messagebox.showwarning("선택 없음", "복원할 백업을 선택하세요.", parent=self)
            return
        self.selected = self.entries[sel[0]][0]
        self.destroy()

    def _delete_selected(self):
        sel = self.listbox.curselection()
        if not sel or not self.entries:
            messagebox.showwarning("선택 없음", "제거할 백업을 선택하세요.", parent=self)
            return
        folder_name, display = self.entries[sel[0]]
        ok = messagebox.askyesno(
            "백업 제거",
            f"선택한 백업을 영구 삭제할까요?nn{display}nn이 작업은 되돌릴 수 없습니다.",
            parent=self
        )
        if not ok:
            return
        target = self._backup_folder / folder_name
        try:
            shutil.rmtree(str(target))
        except Exception as e:
            messagebox.showerror("삭제 실패", str(e), parent=self)
            return
        # labels.json 에서도 제거
        labels = _read_backup_labels(self._backup_folder)
        if folder_name in labels:
            del labels[folder_name]
            _write_backup_labels(self._backup_folder, labels)
            self._labels = labels
        # 목록 갱신
        idx = sel[0]
        self.entries.pop(idx)
        self.listbox.delete(idx)
        if not self.entries:
            self.listbox.insert("end", "  (백업 없음)")


# ──────────────────────────────────────────────
#  메인 애플리케이션
# ──────────────────────────────────────────────
class D2RModManager(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("D2RMT v0.1")
        self.configure(bg=BG_DARK)
        self.minsize(820, 620)
        self.geometry("940x720")
        _set_icon(self)

        self.mod_folder: Path | None = None
        self.mpq_folder: Path | None = None
        self.mod_name: str = ""
        self.addon_mods: list[dict] = []
        self._activity_log: list[dict] = []   # 현재 mod_folder 세션 로그

        self._build_ui()
        self._load_state()

        # 창이 완전히 그려진 뒤 작업표시줄 아이콘 교체
        self.after(200, lambda: _set_taskbar_icon(self))

    # ── 상태 저장 / 불러오기 ─────────────────────────────────────────────
    # 추가 모드는 exe 옆 mod/<모드명>/ 폴더에 실제 파일을 복사해 두고,
    # JSON 에는 mod_folder 경로만 저장한다.
    # 재실행 시 mod/ 폴더를 스캔해 목록을 복원하므로 경로 소실 문제가 없다.

    def _save_state(self):
        """mod_folder 경로만 JSON 에 저장 (addon_mods 는 mod/ 폴더가 정보 원본)."""
        try:
            data = {
                "mod_folder": str(self.mod_folder) if self.mod_folder else None,
            }
            save_path = SAVE_FILE.resolve()
            save_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception as e:
            import traceback; traceback.print_exc()
            messagebox.showerror("저장 실패", f"상태 저장 중 오류가 발생했습니다:n{e}")

    def _load_state(self):
        """JSON 에서 mod_folder 복원, mod/ 폴더 스캔으로 addon_mods 복원."""
        # ── mod_folder 복원
        save_path = SAVE_FILE.resolve()
        if save_path.exists():
            try:
                data = json.loads(save_path.read_text(encoding="utf-8"))
                if data.get("mod_folder"):
                    p = Path(data["mod_folder"])
                    if p.exists():
                        mod_name = p.name
                        mpq_candidate = p / f"{mod_name}.mpq"
                        if not mpq_candidate.exists():
                            candidates = [x for x in p.iterdir()
                                          if x.name.lower().endswith(".mpq")]
                            mpq_candidate = candidates[0] if candidates else None
                        if mpq_candidate:
                            self.mod_folder = p
                            self.mpq_folder = mpq_candidate
                            self.mod_name = mpq_candidate.name.replace(".mpq", "")
                            self.path_var.set(str(p))
                            self._render_current_mod()
                            # 저장된 로그 복원
                            self._activity_log = _read_log(p)
            except Exception:
                import traceback; traceback.print_exc()

        # ── addon_mods 복원: mod/ 폴더 스캔
        try:
            if MOD_STORE_DIR.exists():
                for mod_dir in sorted(MOD_STORE_DIR.iterdir()):
                    if not mod_dir.is_dir():
                        continue
                    mod_name = mod_dir.name
                    # 하위 폴더(global / hd / local) 중 존재하는 것만 수집
                    folders = [mod_dir / fn for fn in ("global", "hd", "local")
                               if (mod_dir / fn).is_dir()]
                    if folders:
                        self.addon_mods.append({"name": mod_name, "folders": folders})
        except Exception:
            import traceback; traceback.print_exc()

        self._render_addon_list()

        # 재실행 후 상태바: mod_folder가 복원된 경우 로그 다시 읽어 최신 항목 표시
        # (mod_folder 복원 블록 안에서 _activity_log를 set했더라도 여기서 확정)
        if self.mod_folder:
            self._activity_log = _read_log(self.mod_folder)
        fa = [e for e in self._activity_log if e.get("fa")]
        if fa:
            latest = fa[-1]
            self.status_var.set(f"  {latest['ts']}  {latest['msg']}   ▲ 로그 보기")

    # ── UI 구성 ──────────────────────────────
    def _build_ui(self):
        # ── 하단 상태바를 먼저 pack(side=bottom) 해야
        #    outer(expand=True)가 남은 공간을 정확히 채움 → 스크롤바 상하 공백 없음
        self.status_var = tk.StringVar(value="  경로를 설정하여 시작하세요.")
        status_bar = tk.Frame(self, bg=BG_PANEL, pady=5)
        status_bar.pack(fill="x", side="bottom")

        self._status_lbl = tk.Label(
            status_bar, textvariable=self.status_var,
            bg=BG_PANEL, fg=TEXT_SUB, font=FONT_SMALL, padx=12,
            cursor="hand2", anchor="w", justify="left"
        )
        self._status_lbl.pack(side="left", fill="both", expand=True)
        self._status_lbl.bind("<Button-1>", lambda e: self._open_log_dialog())
        status_bar.bind("<Button-1>", lambda e: self._open_log_dialog())

        # ── 우측 상단 고정, 스크롤 영역 밖
        credit_bar = tk.Frame(self, bg=BG_DARK)
        credit_bar.pack(fill="x", side="top")
        tk.Label(
            credit_bar,
            text="디아블로2 인벤 애드온·모드 자료실  (닉네임 : 풍선 제작)",
            bg=BG_DARK, fg=TEXT_DIM, font=("Segoe UI", 9),
            anchor="e", padx=16, pady=2
        ).pack(side="right")

        # ── 스크롤 가능 메인 영역 (상태바 다음에 pack → 공백 없이 꽉 참)
        outer = tk.Frame(self, bg=BG_DARK, bd=0)
        outer.pack(fill="both", expand=True)

        canvas = tk.Canvas(outer, bg=BG_DARK, bd=0, highlightthickness=0)
        vscroll = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
        canvas.configure(yscrollcommand=vscroll.set)

        vscroll.pack(side="right", fill="y")
        canvas.pack(side="left", fill="both", expand=True)

        # scroll_frame 자체에 좌우 padding 24 적용 → 스크롤바는 창 끝에 붙음
        self.scroll_frame = tk.Frame(canvas, bg=BG_DARK, padx=24, pady=14)
        self.scroll_window = canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")

        def _update_scrollregion(e=None):
            canvas.update_idletasks()
            bbox = canvas.bbox("all")
            if bbox:
                # scrollregion 상단을 항상 0으로 고정 → 위로 스크롤 불가
                canvas.configure(scrollregion=(0, 0, bbox[2], bbox[3]))

        self.scroll_frame.bind("<Configure>", _update_scrollregion)

        # canvas 너비 변경 시 scroll_frame 너비 동기화
        def _on_canvas_configure(e):
            canvas.itemconfig(self.scroll_window, width=e.width)

        canvas.bind("<Configure>", _on_canvas_configure)

        self._main_canvas = canvas
        self._update_scrollregion = _update_scrollregion

        # ── 마우스 휠 스크롤: canvas 위에 포커스가 있을 때만 동작
        #    (RollbackDialog 등 별도 창 제외)
        def _on_mousewheel(e):
            # 이벤트 위젯이 이 창(self) 소속인지 확인
            try:
                widget = e.widget
                # toplevel 찾기
                top = widget.winfo_toplevel()
                if top is not self:
                    return  # 다른 창(RollbackDialog 등)이면 무시
            except Exception:
                return
            # scrollregion 크기가 canvas 높이보다 클 때만 스크롤
            scroll_region = canvas.cget("scrollregion")
            if not scroll_region:
                return
            try:
                _, _, _, content_h = map(float, str(scroll_region).split())
            except Exception:
                return
            if content_h <= canvas.winfo_height():
                return  # 내용이 창보다 작으면 스크롤 불필요
            canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")

        self.bind_all("<MouseWheel>", _on_mousewheel)

        self._build_path_section()
        self._build_current_mod_section()
        self._build_addon_section()

    def _section_label(self, parent, text):
        f = tk.Frame(parent, bg=BG_DARK)
        f.pack(fill="x", pady=(14, 4))
        tk.Label(f, text=text, bg=BG_DARK, fg=ACCENT_GOLD,
                 font=FONT_HEAD).pack(side="left")
        tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(10, 0))

    def _build_path_section(self):
        self._section_label(self.scroll_frame, "① 모드 폴더 경로 설정")

        card = tk.Frame(self.scroll_frame, bg=BG_CARD,
                        highlightbackground=BORDER, highlightthickness=1)
        card.pack(fill="x", pady=(0, 4))

        inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
        inner.pack(fill="x")

        tk.Label(inner, text="경로", bg=BG_CARD, fg=TEXT_SUB,
                 font=FONT_SMALL, width=6, anchor="w").pack(side="left")

        self.path_var = tk.StringVar(value="선택된 폴더 없음")
        path_lbl = tk.Label(inner, textvariable=self.path_var,
                            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
                            anchor="w")
        path_lbl.pack(side="left", fill="x", expand=True, padx=(0, 10))

        styled_button(inner, "📁  폴더 선택", self._select_mod_folder,
                      BORDER_GOLD, ACCENT_GOLD, fg="#ffffff",
                      font=FONT_SMALL, padx=14, pady=6).pack(side="right")

    def _build_current_mod_section(self):
        self._section_label(self.scroll_frame, "② 현재 모드")

        self.current_mod_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        self.current_mod_frame.pack(fill="x")

        self._render_current_mod()

    def _render_current_mod(self):
        for w in self.current_mod_frame.winfo_children():
            w.destroy()

        if not self.mpq_folder:
            card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
                            highlightbackground=BORDER, highlightthickness=1)
            card.pack(fill="x")
            tk.Label(card, text="모드 폴더를 먼저 선택하세요.",
                     bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL,
                     padx=14, pady=12).pack(anchor="w")
            return

        card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
                        highlightbackground=BORDER_GOLD, highlightthickness=1)
        card.pack(fill="x")

        inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
        inner.pack(fill="x")

        # 모드 아이콘 + 이름
        left = tk.Frame(inner, bg=BG_CARD)
        left.pack(side="left", fill="x", expand=True)

        tk.Label(left, text="🗂", bg=BG_CARD, fg=ACCENT_GOLD,
                 font=("Segoe UI Emoji", 18)).pack(side="left")
        tk.Label(left, text=self.mod_name,
                 bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
                 padx=8).pack(side="left")
        tk.Label(left, text=".mpq",
                 bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")

        # 버튼 우측 배치
        right = tk.Frame(inner, bg=BG_CARD)
        right.pack(side="right")

        styled_button(right, "💾  백업", self._backup_mod,
                      BTN_BACKUP, BTN_BACKUP_H, padx=14, pady=6).pack(side="left", padx=4)
        styled_button(right, "⟲  롤백", self._rollback_mod,
                      BTN_ROLL, BTN_ROLL_H, padx=14, pady=6).pack(side="left", padx=4)
        styled_button(right, "🛡  바닐라", self._restore_vanilla,
                      "#6b6b6b", "#555555", padx=14, pady=6).pack(side="left", padx=4)

        # 경로 표시
        path_frame = tk.Frame(card, bg=BG_CARD, padx=14, pady=(0, 8))
        path_frame.pack(fill="x")
        tk.Label(path_frame, text=str(self.mpq_folder),
                 bg=BG_CARD, fg=TEXT_DIM, font=FONT_MONO).pack(anchor="w")

    def _build_addon_section(self):
        self._section_label(self.scroll_frame, "③ 추가 모드 (스킨 / UI / 패치)")

        # 추가 버튼
        add_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        add_frame.pack(fill="x", pady=(0, 8))

        styled_button(add_frame, "+  모드 불러오기",
                      self._load_addon_mod,
                      BTN_INSTALL, BTN_INSTALL_H,
                      padx=18, pady=7, font=FONT_BODY).pack(side="left")

        tk.Label(add_frame,
                 text="  ※ global / hd / local 폴더만 허용",
                 bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")

        self.addon_list_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        self.addon_list_frame.pack(fill="x")

        self._render_addon_list()

    def _render_addon_list(self):
        for w in self.addon_list_frame.winfo_children():
            w.destroy()

        if not self.addon_mods:
            tk.Label(self.addon_list_frame,
                     text="불러온 추가 모드가 없습니다.",
                     bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL,
                     pady=8).pack(anchor="w")
            return

        for idx, mod in enumerate(self.addon_mods):
            self._render_addon_row(idx, mod)

    def _render_addon_row(self, idx: int, mod: dict):
        card = tk.Frame(self.addon_list_frame, bg=BG_CARD,
                        highlightbackground=BORDER, highlightthickness=1)
        card.pack(fill="x", pady=2)

        inner = tk.Frame(card, bg=BG_CARD, padx=8, pady=8)
        inner.pack(fill="x")

        # ── 드래그 핸들 (≡ 아이콘)
        handle = tk.Label(inner, text="≡", bg=BG_CARD, fg=TEXT_DIM,
                          font=("Segoe UI", 16), cursor="fleur", padx=6)
        handle.pack(side="left")

        # ── 모드명
        left = tk.Frame(inner, bg=BG_CARD)
        left.pack(side="left", fill="x", expand=True)

        tk.Label(left, text=mod["name"],
                 bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
                 padx=6).pack(side="left")

        # 포함 폴더 태그
        for folder_path in mod["folders"]:
            tag_color = {"global": "#d4edda",
                         "hd":     "#d0e8f8",
                         "local":  "#fce8d4"}.get(folder_path.name.lower(), BORDER)
            tk.Label(left, text=folder_path.name,
                     bg=tag_color, fg=TEXT_MAIN,
                     font=FONT_SMALL, padx=6, pady=2,
                     relief="flat").pack(side="left", padx=2)

        # ── 버튼 영역
        right = tk.Frame(inner, bg=BG_CARD)
        right.pack(side="right")

        styled_button(right, "설치",
                      lambda i=idx: self._install_addon(i),
                      BTN_BACKUP, BTN_BACKUP_H,
                      padx=14, pady=5).pack(side="left", padx=3)
        styled_button(right, "이름변경",
                      lambda i=idx: self._rename_addon(i),
                      "#5a6472", "#4a5462", fg="#ffffff",
                      padx=12, pady=5).pack(side="left", padx=3)
        styled_button(right, "제거",
                      lambda i=idx: self._remove_addon(i),
                      BTN_REMOVE, BTN_REMOVE_H,
                      padx=14, pady=5).pack(side="left", padx=3)

        # ── 드래그 & 드롭 순서 변경
        self._bind_drag(handle, card, idx)
        self._bind_drag(inner,  card, idx)

    # ── 동작 ─────────────────────────────────

    def _select_mod_folder(self):
        path = filedialog.askdirectory(title="mods 안의 모드 폴더 선택")
        if not path:
            return

        mod_folder = Path(path)
        # .mpq 폴더/파일 탐색 (폴더와 모드명이 일치해야 함)
        mod_name = mod_folder.name
        mpq_candidate = mod_folder / f"{mod_name}.mpq"

        if not mpq_candidate.exists():
            # .mpq 로 끝나는 것 아무거나 찾기
            candidates = [p for p in mod_folder.iterdir()
                          if p.name.lower().endswith(".mpq")]
            if not candidates:
                messagebox.showerror(
                    "오류",
                    f"'{mod_folder}' 안에 .mpq 폴더/파일이 없습니다.n"
                    "올바른 모드 폴더를 선택하세요."
                )
                return
            mpq_candidate = candidates[0]

        self.mod_folder  = mod_folder
        self.mpq_folder  = mpq_candidate
        self.mod_name    = mpq_candidate.name.replace(".mpq", "")

        # 새 모드 로드 → 로그 초기화 후 첫 항목 기록
        self._activity_log = _read_log(mod_folder)
        self.path_var.set(str(mod_folder))
        self._render_current_mod()
        self._add_log(f"모드 로드: {self.mod_name}", file_affecting=True)
        self._save_state()

    def _backup_mod(self):
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "유효한 .mpq 폴더가 없습니다.")
            return

        # 백업 메모 입력 (빈 값 허용 → 메모 없음)
        memo = AskStringDialog.ask(
            self,
            "백업 메모",
            "이 백업 상태를 설명하는 메모를 입력하세요.n(비워두면 날짜/시간만 표시됩니다)"
        )
        if memo is None:          # 취소 버튼
            return
        memo = memo.strip()

        mods_folder = self.mod_folder.parent
        backup_root = mods_folder / "BackUp"
        backup_root.mkdir(exist_ok=True)

        now = datetime.now()
        ts  = now.strftime("%y%m%d%H%M%S")        # 예: 260612091036
        dst_name = f"{self.mod_name}{ts}.mpq"
        dst_path = backup_root / dst_name

        if dst_path.exists():
            messagebox.showwarning("중복", f"같은 이름의 백업이 이미 존재합니다:n{dst_name}")
            return

        try:
            shutil.copytree(str(self.mpq_folder), str(dst_path))
        except Exception as e:
            messagebox.showerror("백업 실패", str(e))
            return

        # 메모가 있으면 backup_labels.json 에 저장
        if memo:
            labels = _read_backup_labels(backup_root)
            labels[dst_name] = memo
            _write_backup_labels(backup_root, labels)

        # 백업 시점 로그 항목 추가 후 스냅샷을 백업 이름으로 함께 저장
        label_str = f" [{memo}]" if memo else ""
        self._add_log(f"백업{label_str}{dst_name}", file_affecting=True)
        # 현재 로그를 이 백업 폴더에도 복사 (롤백 때 복원용)
        try:
            snap_path = dst_path / "_activity_log_snapshot.json"
            snap_path.write_text(
                json.dumps(self._activity_log, ensure_ascii=False, indent=2),
                encoding="utf-8"
            )
        except Exception:
            pass

        messagebox.showinfo("백업 완료", f"백업이 저장되었습니다:n{dst_path}")

    def _rollback_mod(self):
        if not self.mod_folder:
            messagebox.showerror("오류", "모드 폴더가 설정되지 않았습니다.")
            return

        mods_folder  = self.mod_folder.parent
        backup_root  = mods_folder / "BackUp"

        if not backup_root.exists() or not any(backup_root.iterdir()):
            messagebox.showinfo("백업 없음", "BackUp 폴더에 저장된 백업이 없습니다.")
            return

        dlg = RollbackDialog(self, backup_root, self.mod_name)
        self.wait_window(dlg)

        if not dlg.selected:
            return

        selected_path = backup_root / dlg.selected

        confirm = messagebox.askyesno(
            "롤백 확인",
            f"현재 모드 '{self.mod_name}.mpq'를 삭제하고n"
            f"'{dlg.selected}'으로 복원하시겠습니까?nn"
            "이 작업은 되돌릴 수 없습니다."
        )
        if not confirm:
            return

        try:
            # 현재 모드 삭제
            if self.mpq_folder.exists():
                shutil.rmtree(str(self.mpq_folder))

            # 백업 복사 (원래 이름으로)
            target = self.mod_folder / f"{self.mod_name}.mpq"
            shutil.copytree(str(selected_path), str(target))

            self.mpq_folder = target
        except Exception as e:
            messagebox.showerror("롤백 실패", str(e))
            return

        # 백업에 저장된 로그 스냅샷 복원
        snap = target / "_activity_log_snapshot.json"
        if snap.exists():
            try:
                self._activity_log = json.loads(snap.read_text(encoding="utf-8"))
                _write_log(self.mod_folder, self._activity_log)
            except Exception:
                pass
        self._add_log(f"롤백 완료 → {dlg.selected}", file_affecting=True)

        messagebox.showinfo("롤백 완료",
                            f"'{self.mod_name}.mpq'이 복원되었습니다.")
        self._render_current_mod()

    def _restore_vanilla(self):
        """현재 모드의 data 폴더 안 global / hd / local 폴더를 삭제해 바닐라 상태로 복원한다."""
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
            return

        data_folder = self.mpq_folder / "data"
        targets = [data_folder / name for name in ("global", "hd", "local")
                   if (data_folder / name).exists()]

        if not targets:
            messagebox.showinfo(
                "이미 바닐라 상태",
                f"'{data_folder}' 안에 global / hd / local 폴더가 없습니다.n"
                "이미 바닐라(순정) 상태입니다."
            )
            return

        folder_list = "n".join(f"  • {t.name}" for t in targets)
        confirmed = messagebox.askyesno(
            "⚠️  바닐라 복원 확인",
            f"아래 폴더를 영구적으로 삭제합니다.nn"
            f"{folder_list}nn"
            f"경로: {data_folder}nn"
            "삭제된 파일은 복구할 수 없습니다.n"
            "계속하시겠습니까?",
            icon="warning"
        )
        if not confirmed:
            return

        errors = []
        deleted = []
        for t in targets:
            try:
                shutil.rmtree(str(t))
                deleted.append(t.name)
            except Exception as e:
                errors.append(f"{t.name}: {e}")

        if errors:
            messagebox.showerror("삭제 오류", "n".join(errors))
        else:
            deleted_str = ", ".join(deleted)
            messagebox.showinfo(
                "바닐라 복원 완료",
                f"다음 폴더가 삭제되었습니다: {deleted_str}nn"
                "클라이언트가 순정(바닐라) 상태로 실행됩니다."
            )
            self._add_log(f"바닐라 복원 — 삭제: {deleted_str}", file_affecting=True)

    def _load_addon_mod(self):
        """폴더 다중 선택 → 유효성 검사 → 모드명 입력 → 등록"""
        folders: list[Path] = []
        while True:
            path = filedialog.askdirectory(
                title=f"모드 폴더 선택 (global/hd/local)  ―  현재 {len(folders)}개 선택됨 | 취소 시 완료"
            )
            if not path:
                break
            p = Path(path)
            if p.name.lower() not in VALID_FOLDERS:
                messagebox.showerror(
                    "잘못된 폴더",
                    f"'{p.name}' 폴더는 허용되지 않습니다.n"
                    "global, hd, local 폴더만 선택할 수 있습니다."
                )
                continue
            if p.name.lower() in ("global", "local") and p.parent.name.lower() == "hd":
                messagebox.showerror(
                    "경로 오류",
                    f"선택한 '{p.name}' 폴더의 상위가 'hd' 입니다.nn"
                    f"현재 경로: {p}nn"
                    "이 경로는 data/hd/global (또는 local) 로 설치됩니다.n"
                    "일반 모드에 필요한 경로는 data/global (또는 data/local) 이므로,n"
                    "hd 폴더 바깥의 올바른 global / local 폴더를 선택하세요."
                )
                continue
            if p not in folders:
                folders.append(p)

        if not folders:
            return

        mod_name = AskStringDialog.ask(
            self,
            "모드명 입력",
            "이 모드의 이름을 입력하세요:"
        )
        if not mod_name or not mod_name.strip():
            return

        name = mod_name.strip()

        # ── mod/<모드명>/ 폴더에 파일 복사 (영구 보관)
        store_dir = MOD_STORE_DIR / name
        if store_dir.exists():
            overwrite = messagebox.askyesno(
                "이미 존재",
                f"'{name}' 모드가 이미 저장되어 있습니다.n덮어쓸까요?"
            )
            if not overwrite:
                return
            shutil.rmtree(str(store_dir))

        try:
            store_dir.mkdir(parents=True, exist_ok=True)
            for src in folders:
                dst = store_dir / src.name
                shutil.copytree(str(src), str(dst))
        except Exception as e:
            messagebox.showerror("저장 실패", f"mod 폴더에 복사 중 오류:n{e}")
            return

        # ── addon_mods 에 등록 (folders는 mod/ 내부 경로)
        stored_folders = [store_dir / src.name for src in folders]
        self.addon_mods.append({"name": name, "folders": stored_folders})
        self._render_addon_list()
        self._add_log(f"추가 모드 등록: {name}")
        self._save_state()

    def _install_addon(self, idx: int):
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
            return

        mod  = self.addon_mods[idx]
        data_folder = self.mpq_folder / "data"

        if not data_folder.exists():
            confirm = messagebox.askyesno(
                "data 폴더 없음",
                f"'{data_folder}' 폴더가 없습니다. 생성하고 계속할까요?"
            )
            if not confirm:
                return
            data_folder.mkdir(parents=True)

        errors = []
        for src in mod["folders"]:
            dst = data_folder / src.name
            try:
                dst.mkdir(parents=True, exist_ok=True)
                shutil.copytree(str(src), str(dst), dirs_exist_ok=True)
            except Exception as e:
                errors.append(f"{src.name}: {e}")

        if errors:
            messagebox.showerror("설치 오류", "n".join(errors))
        else:
            messagebox.showinfo("설치 완료",
                                f"'{mod['name']}' 모드가 설치되었습니다.n"
                                f"→ {data_folder}")
            self._add_log(f"설치 완료: {mod['name']}", file_affecting=True)

    def _remove_addon(self, idx: int):
        mod = self.addon_mods[idx]
        confirm = messagebox.askyesno(
            "제거 확인",
            f"'{mod['name']}' 모드를 목록에서 제거할까요?nn"
            "저장된 mod 폴더의 파일도 함께 삭제됩니다."
        )
        if confirm:
            # mod/<모드명>/ 폴더 삭제
            store_dir = MOD_STORE_DIR / mod["name"]
            if store_dir.exists():
                try:
                    shutil.rmtree(str(store_dir))
                except Exception as e:
                    messagebox.showerror("삭제 실패", f"mod 폴더 삭제 중 오류:n{e}")
            self.addon_mods.pop(idx)
            self._render_addon_list()
            self._add_log(f"모드 제거: {mod['name']}")
            self._save_state()

    def _rename_addon(self, idx: int):
        mod = self.addon_mods[idx]
        new_name = AskStringDialog.ask(
            self,
            "이름 변경",
            f"'{mod['name']}' 의 새 이름을 입력하세요:",
            initial=mod["name"]
        )
        if not new_name or not new_name.strip():
            return
        new_name = new_name.strip()
        if new_name == mod["name"]:
            return

        # mod/ 폴더 이름 변경
        old_dir = MOD_STORE_DIR / mod["name"]
        new_dir = MOD_STORE_DIR / new_name
        if new_dir.exists():
            messagebox.showerror("이름 충돌", f"'{new_name}' 이름의 모드가 이미 존재합니다.")
            return
        try:
            if old_dir.exists():
                old_dir.rename(new_dir)
        except Exception as e:
            messagebox.showerror("이름 변경 실패", str(e))
            return

        # addon_mods 내부 경로도 새 이름으로 업데이트
        new_folders = [new_dir / f.name for f in mod["folders"]]
        self.addon_mods[idx] = {"name": new_name, "folders": new_folders}
        self._render_addon_list()
        self._add_log(f"이름 변경: {mod['name']}{new_name}")

    # ── 드래그 & 드롭 순서 변경 ─────────────────────────────────────────
    def _bind_drag(self, widget: tk.Widget, card: tk.Frame, idx: int):
        """widget 에 드래그 이벤트를 바인딩해 addon_mods 순서를 변경한다."""
        state = {}

        def on_press(e, i=idx):
            state["start_y"] = e.y_root
            state["idx"]     = i
            # 드래그 중인 카드 강조
            card.configure(highlightbackground=ACCENT_GOLD)

        def on_drag(e):
            pass  # 시각 피드백은 release 시 처리

        def on_release(e, i=idx):
            card.configure(highlightbackground=BORDER)
            if "start_y" not in state:
                return
            dy = e.y_root - state["start_y"]
            # 카드 높이를 동적으로 구함 (기본 ~42px)
            try:
                card_h = max(card.winfo_height(), 42)
            except Exception:
                card_h = 42
            steps = round(dy / card_h)
            if steps == 0:
                return
            src = i
            dst = max(0, min(len(self.addon_mods) - 1, src + steps))
            if src == dst:
                return
            # 순서 변경
            item = self.addon_mods.pop(src)
            self.addon_mods.insert(dst, item)
            self._render_addon_list()
            self._add_log(f"순서 변경: '{item['name']}' {src+1}번 → {dst+1}번")

        widget.bind("<ButtonPress-1>",   on_press)
        widget.bind("<B1-Motion>",       on_drag)
        widget.bind("<ButtonRelease-1>", on_release)

    # ── 로그 & 상태바 ────────────────────────────────────────────────────

    def _add_log(self, msg: str, file_affecting: bool = False):
        """로그 항목 추가, 상태바 갱신, 파일 저장.
        file_affecting=True 인 항목만 로그 다이얼로그에 표시된다.
        """
        entry = {"ts": _ts_now(), "msg": msg, "fa": file_affecting}
        self._activity_log.append(entry)
        # 상태바에는 file_affecting 항목만 표시 (없으면 마지막 항목)
        fa_entries = [e for e in self._activity_log if e.get("fa")]
        latest = fa_entries[-1] if fa_entries else entry
        self.status_var.set(f"  {latest['ts']}  {latest['msg']}   ▲ 로그 보기")
        # mod_folder 가 설정돼 있으면 즉시 파일에 저장
        if self.mod_folder:
            _write_log(self.mod_folder, self._activity_log)

    def _open_log_dialog(self):
        fa_entries = [e for e in self._activity_log if e.get("fa")]
        if not fa_entries:
            messagebox.showinfo("로그 없음",
                                "아직 기록된 파일 변경 이력이 없습니다.n"
                                "모드를 설치하거나 롤백해보세요.")
            return
        mod_name = self.mod_name or "—"
        LogDialog(self, fa_entries, mod_name)


# ──────────────────────────────────────────────
#  진입점
# ──────────────────────────────────────────────
if __name__ == "__main__":
    _set_appid()
    app = D2RModManager()
    app.mainloop()