From 2d9fa8552816af003fbc4e8a7a8441d06fef27c2 Mon Sep 17 00:00:00 2001 From: Kasto M <99041020+kastomd@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:53:44 -0500 Subject: [PATCH 1/3] test 1 --- app_md/logic_port/__init__.py | 0 app_md/logic_port/char_port.py | 223 +++++++ app_md/logic_port/hud_port.py | 203 ++++++ app_md/logic_port/param_convert.py | 22 + app_md/logic_port/port_window.py | 453 +++++++++++++ app_md/logic_port/src/044_hud_1.ags | Bin 0 -> 1920 bytes app_md/logic_port/src/045_hud_1.rhg | Bin 0 -> 3328 bytes app_md/logic_port/src/046_hud_2.ags | Bin 0 -> 1920 bytes app_md/logic_port/src/047_hud_2.rhg | Bin 0 -> 3328 bytes app_md/logic_port/src/048_hud_clash.ags | Bin 0 -> 640 bytes app_md/logic_port/src/049_hud_clash.rhg | Bin 0 -> 17664 bytes app_md/logic_port/tex_compress.py | 356 +++++++++++ app_md/logic_swap/__init__.py | 1 + app_md/logic_swap/anim_converter.py | 204 ++++++ .../pmdl/bt3_face_parser - otra app.py | 96 +++ app_md/logic_swap/pmdl/bt3_face_parser.py | 96 +++ app_md/logic_swap/pmdl/bt3_tex_reader.py | 298 +++++++++ .../logic_swap/pmdl/bt3_to_ttt - otra app.py | 429 +++++++++++++ app_md/logic_swap/pmdl/bt3_to_ttt.py | 429 +++++++++++++ app_md/logic_swap/pmdl/file_detector.py | 14 + app_md/logic_swap/pmdl/optim_bone_ids.py | 40 ++ app_md/logic_swap/pmdl/parser.py | 139 ++++ app_md/logic_swap/pmdl/vfx_pmdl_port.py | 137 ++++ app_md/logic_swap/src/attack.txt | 108 ++++ app_md/logic_swap/swap_attacks.py | 399 ++++++++++++ app_md/logic_swap/swap_context.py | 51 ++ app_md/logic_swap/swap_data.py | 178 ++++++ app_md/logic_swap/swap_logic.py | 455 +++++++++++++ app_md/logic_swap/swap_vfx.py | 602 ++++++++++++++++++ app_md/windows/anim0.py | 95 +++ app_md/windows/extract_tool.py | 58 +- 31 files changed, 5085 insertions(+), 1 deletion(-) create mode 100644 app_md/logic_port/__init__.py create mode 100644 app_md/logic_port/char_port.py create mode 100644 app_md/logic_port/hud_port.py create mode 100644 app_md/logic_port/param_convert.py create mode 100644 app_md/logic_port/port_window.py create mode 100644 app_md/logic_port/src/044_hud_1.ags create mode 100644 app_md/logic_port/src/045_hud_1.rhg create mode 100644 app_md/logic_port/src/046_hud_2.ags create mode 100644 app_md/logic_port/src/047_hud_2.rhg create mode 100644 app_md/logic_port/src/048_hud_clash.ags create mode 100644 app_md/logic_port/src/049_hud_clash.rhg create mode 100644 app_md/logic_port/tex_compress.py create mode 100644 app_md/logic_swap/__init__.py create mode 100644 app_md/logic_swap/anim_converter.py create mode 100644 app_md/logic_swap/pmdl/bt3_face_parser - otra app.py create mode 100644 app_md/logic_swap/pmdl/bt3_face_parser.py create mode 100644 app_md/logic_swap/pmdl/bt3_tex_reader.py create mode 100644 app_md/logic_swap/pmdl/bt3_to_ttt - otra app.py create mode 100644 app_md/logic_swap/pmdl/bt3_to_ttt.py create mode 100644 app_md/logic_swap/pmdl/file_detector.py create mode 100644 app_md/logic_swap/pmdl/optim_bone_ids.py create mode 100644 app_md/logic_swap/pmdl/parser.py create mode 100644 app_md/logic_swap/pmdl/vfx_pmdl_port.py create mode 100644 app_md/logic_swap/src/attack.txt create mode 100644 app_md/logic_swap/swap_attacks.py create mode 100644 app_md/logic_swap/swap_context.py create mode 100644 app_md/logic_swap/swap_data.py create mode 100644 app_md/logic_swap/swap_logic.py create mode 100644 app_md/logic_swap/swap_vfx.py create mode 100644 app_md/windows/anim0.py diff --git a/app_md/logic_port/__init__.py b/app_md/logic_port/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app_md/logic_port/char_port.py b/app_md/logic_port/char_port.py new file mode 100644 index 0000000..bb88e2d --- /dev/null +++ b/app_md/logic_port/char_port.py @@ -0,0 +1,223 @@ +import struct +import tempfile +import os +from pathlib import Path + +from app_md.logic_port.param_convert import convert_param17, convert_skill_cameras_pak +from app_md.logic_swap.anim_converter import canm_to_tanm +from app_md.logic_swap.swap_vfx import ( + convert_vfx_bt3_to_ttt, parse_pak, + _pil_to_atex_data, build_atex +) +from app_md.logic_swap.pmdl.bt3_to_ttt import convert_bt3_to_ttt, convert_bt3_face_to_ttt + +_SIG_TTT = 0x000001F1 +_IDX_PARAM17 = 17 +_IDX_DBT = 11 +_IDX_1P_EMPTY = {12, 13, 14, 15, 16, 43} +_IDX_FACES = set(range(3, 11)) +_IDX_HUD_AGS = {44: "044_hud_1.ags", 46: "046_hud_2.ags", 48: "048_hud_clash.ags"} +_IDX_HUD_RHG = {45: "045_hud_1.rhg", 47: "047_hud_2.rhg", 49: "049_hud_clash.rhg"} +_EFF_COMMON_SLOT = 7 + + +def _parse_le_pak_exact(data: bytes): + count = struct.unpack_from(" offs[i] else b'') + for i in range(count)] + + +def _build_be_pak(entries: list) -> bytes: + count = len(entries) + index_raw = 4 + (count + 1) * 4 + pad = (16 - index_raw % 16) % 16 + first_off = index_raw + pad + cur = first_off + offsets = [] + for e in entries: + offsets.append(cur); cur += len(e) + offsets.append(cur) + out = bytearray() + out += struct.pack(">I", count) + for o in offsets: out += struct.pack(">I", o) + out += b'\x00' * pad + for e in entries: out += e + return bytes(out) + + +def _convert_effect_pak(bt3_data: bytes) -> bytes: + result, _ = convert_vfx_bt3_to_ttt(bt3_data) + return result + + +def _convert_effect_common(bt3_data: bytes) -> bytes: + entries = _parse_le_pak_exact(bt3_data) + out = [] + for i, s, e, raw in entries: + if not raw: + out.append(b'') + elif i == 0: + print(f"[port] effect_common[{i:02d}]: common_param copy 1:1") + out.append(raw) + elif i == _EFF_COMMON_SLOT: + print(f"[port] effect_common[{i:02d}]: skill_cameras LE→BE") + out.append(convert_skill_cameras_pak(raw)) + else: + print(f"[port] effect_common[{i:02d}]: converting effect pak ({len(raw):,}b)...") + converted, _ = convert_vfx_bt3_to_ttt(raw) + out.append(converted) + return _build_be_pak(out) + + +def _build_1p_slots(bt3_data, atlas_image, pack_result, tex_id_order, bt3_parts, face_blobs=None, face_tex_override=None): + from app_md.logic_port.hud_port import convert_hud_from_dbt + if face_blobs is None: face_blobs = {} + if face_tex_override is None: face_tex_override = {} + bt3_map = {idx: raw for idx, _, _, raw in _parse_le_pak_exact(bt3_data)} + uv_map = {tex_id_order[info["orig_index"]]: info for info in pack_result} + + print("[port] 1_p: converting pmdl BT3→TTT...") + pmdl_ttt = convert_bt3_to_ttt(bt3_map.get(2, b''), bt3_parts, uv_map=uv_map) + print(f"[port] 1_p: pmdl done ({len(pmdl_ttt):,} bytes)") + + print("[port] 1_p: building ATEX from atlas...") + idx_b, pal_b = _pil_to_atex_data(atlas_image.convert("RGBA"), 256, 256, flip=False) + atex = build_atex([(256, 256, idx_b, pal_b)]) + + print("[port] 1_p: converting HUDs...") + hud_data = {} + dbt_hud = bt3_map.get(0, b'') + if dbt_hud: + try: + hud_data = convert_hud_from_dbt(dbt_hud) + print(f"[port] 1_p: HUDs generated: {list(hud_data.keys())}") + except Exception as ex: + print(f"[port] 1_p: WARNING HUD failed ({ex}), slots 44-49 empty") + + from app_md.logic_swap.pmdl.bt3_to_ttt import _compute_bbox + pmdl_bbox = _compute_bbox(bt3_parts) + print(f"[port] 1_p: converting faces (received slots: {list(face_blobs.keys())}, bbox={pmdl_bbox})...") + face_ttt = {} + for slot in _IDX_FACES: + raw = face_blobs.get(slot, b'') + if not raw: + continue + try: + from app_md.logic_swap.pmdl.bt3_face_parser import get_face_tex_ids + face_tids = get_face_tex_ids(raw) + face_uv = dict(uv_map) + if slot in face_tex_override: + orig_tid, unique_tid = face_tex_override[slot] + if unique_tid in uv_map: + face_uv[orig_tid] = uv_map[unique_tid] + print(f"[port] face {slot}: remapped {orig_tid} → {unique_tid}") + result = convert_bt3_face_to_ttt(raw, uv_map=face_uv, bbox=pmdl_bbox) + if result: + face_ttt[slot] = result + print(f"[port] 1_p: face slot {slot} converted ({len(result):,}b)") + else: + print(f"[port] 1_p: face slot {slot} returned empty (no subparts parsed)") + except Exception as ex: + print(f"[port] 1_p: face slot {slot} failed ({ex})") + + slots = [] + for slot in range(50): + raw = bt3_map.get(slot, b'') + if slot == 0: + slots.append(b'') + elif slot == 1: + slots.append(raw) + elif slot in _IDX_FACES: + slots.append(face_ttt.get(slot, b'')) + elif slot in _IDX_1P_EMPTY: + slots.append(b'') + elif slot == 2: + slots.append(pmdl_ttt) + elif slot == _IDX_DBT: + slots.append(atex) + elif slot == _IDX_PARAM17: + slots.append(convert_param17(raw) if raw else b'') + elif slot in _IDX_HUD_AGS: + slots.append(hud_data.get(_IDX_HUD_AGS[slot], b'')) + elif slot in _IDX_HUD_RHG: + slots.append(hud_data.get(_IDX_HUD_RHG[slot], b'')) + else: + slots.append(raw) + + print(f"[port] 1_p: {len(slots)} slots ready") + return slots + + +def _build_anm_slots(bt3_data: bytes) -> list: + entries = _parse_le_pak_exact(bt3_data) + slots = [] + converted = 0 + for idx, s, e, raw in entries: + if raw: + try: + slots.append(canm_to_tanm(raw)); converted += 1 + except Exception: + slots.append(raw) + else: + slots.append(b'') + print(f"[port] 2_anm: {converted}/{len(entries)} animations converted canm→tanm") + return slots + + +def _build_eff_slots(bt3_data: bytes) -> list: + entries = _parse_le_pak_exact(bt3_data) + bt3_map = {idx: raw for idx, s, e, raw in entries} + slots = [] + for idx in range(7): + raw = bt3_map.get(idx, b'') + if not raw: + slots.append(b'') + elif idx == 5: + print(f"[port] 3_eff[{idx}]: converting effect_common...") + slots.append(_convert_effect_common(raw)) + elif idx == 6: + print(f"[port] 3_eff[{idx}]: patch_param (copy 1:1)") + slots.append(raw) + else: + print(f"[port] 3_eff[{idx}]: converting effect pak ({len(raw):,}b)...") + slots.append(_convert_effect_pak(raw)) + print(f"[port] 3_eff: {len(entries)} entries → {len(slots)} slots") + return slots + + +def port_character(bt3_1p: bytes, bt3_2anm: bytes, bt3_3eff: bytes, + atlas_image, pack_result, tex_id_order, bt3_parts, + face_blobs=None, face_tex_override=None) -> bytes: + + print("[port] --- 1_p ---") + slots_1p = _build_1p_slots(bt3_1p, atlas_image, pack_result, tex_id_order, bt3_parts, face_blobs=face_blobs or {}, face_tex_override=face_tex_override or {}) + + print("[port] --- 2_anm ---") + slots_anm = _build_anm_slots(bt3_2anm) + + print("[port] --- 3_eff ---") + slots_eff = _build_eff_slots(bt3_3eff) + + def _pad16(b): return b + b'\x00' * ((16 - len(b) % 16) % 16) + all_slots = [_pad16(e) for e in slots_1p + slots_anm + slots_eff] + print(f"[port] Assembling PCK1: {len(all_slots)} total slots (1p={len(slots_1p)}, anm={len(slots_anm)}, eff={len(slots_eff)})") + + count = len(all_slots) + index_raw = 4 + (count + 1) * 4 + first_off = 0x800 + pad = first_off - index_raw + cur = first_off + offsets = [] + for e in all_slots: + offsets.append(cur); cur += len(e) + offsets.append(cur) + + out = bytearray() + out += struct.pack(">I", _SIG_TTT) + for o in offsets: out += struct.pack(">I", o) + out += b'\x00' * pad + for e in all_slots: out += e + + print(f"[port] PCK1 built: {len(out):,} bytes") + return bytes(out) \ No newline at end of file diff --git a/app_md/logic_port/hud_port.py b/app_md/logic_port/hud_port.py new file mode 100644 index 0000000..147e782 --- /dev/null +++ b/app_md/logic_port/hud_port.py @@ -0,0 +1,203 @@ +import struct +import numpy as np +from pathlib import Path +from PIL import Image + +_SRC = Path(__file__).resolve().parent / "src" + +_SIG = bytes([0x51,0x00,0x00,0x00,0x00,0x00,0x00,0x00]) +_DBT_ENTRY_FMT = "> 20) & 0x3F +def _tex0_wh(g): return 1 << ((g >> 26) & 0xF), 1 << ((g >> 30) & 0xF) +def _ps2_alpha(a): return min(255, a * 2 - 1) if a > 0 else 0 + + +def _dbt_header(d): + v = struct.unpack_from(" 128 else None + if psm == 19: + indices = bytearray(_transform_to_bmp_order(bytearray(px_raw), w, h)) + pal_raw = _reorder_pal_data(bytearray(pl_raw)) + pal = [] + for i in range(256): + r,g2,b,a = pal_raw[i*4],pal_raw[i*4+1],pal_raw[i*4+2],_ps2_alpha(pal_raw[i*4+3]) + pal.extend([r,g2,b,a]) + img = Image.new("RGBA", (w, h)) + img.putdata([tuple(pal[indices[y*w+x]*4:(indices[y*w+x])*4+4]) + for y in range(h) for x in range(w)]) + return img.transpose(Image.FLIP_TOP_BOTTOM) + elif psm == 0: + img = Image.new("RGBA", (w, h)) + img.putdata([(px_raw[i*4],px_raw[i*4+1],px_raw[i*4+2],_ps2_alpha(px_raw[i*4+3])) + for i in range(w*h)]) + return img.transpose(Image.FLIP_TOP_BOTTOM) + except Exception: + return None + + +def _crop_solid(img, threshold=16): + arr = np.array(img.convert("RGBA")) + mask = arr[:,:,3] >= threshold + rows = np.any(mask, axis=1); cols = np.any(mask, axis=0) + if not rows.any(): return img + r0,r1 = np.where(rows)[0][[0,-1]]; c0,c1 = np.where(cols)[0][[0,-1]] + return img.crop((c0, r0, c1+1, r1+1)) + +def _fit_into(crop, cw, ch, margin_v): + sw, sh = crop.size + scale = min(cw/sw, (ch - margin_v*2)/sh) + nw, nh = max(1,round(sw*scale)), max(1,round(sh*scale)) + canvas = Image.new("RGBA", (cw, ch), (0,0,0,0)) + r = crop.resize((nw,nh), Image.LANCZOS) + canvas.paste(r, ((cw-nw)//2,(ch-nh)//2), r) + return canvas + +def _build_hud_images(src_img): + cropped = _crop_solid(src_img) + hud1 = _fit_into(cropped, 64, 32, 1) + hud2 = _fit_into(cropped, 64, 32, 0) + hud3 = Image.new("RGBA", (256, 64), (0,0,0,0)) + hud3.paste(cropped, (0, 0), cropped) + return hud1, hud2, hud3 + +def _img_to_rhg_data(img, w, h): + scaled = img.resize((w, h), Image.LANCZOS).convert("RGBA") + arr = np.array(scaled) + alpha_ch = arr[:,:,3].flatten() + quantized = scaled.convert("RGB").quantize(colors=255, method=Image.Quantize.MEDIANCUT) + pal_raw = quantized.getpalette() + while len(pal_raw) < 768: pal_raw.extend([0,0,0]) + raw_indices = np.array(quantized).flatten() + indices = (raw_indices + 1).tolist() + for i in range(len(indices)): + if alpha_ch[i] < 16: indices[i] = 0 + pal_bytes = bytearray(1024) + for i in range(255): + pal_bytes[(i+1)*4] = pal_raw[i*3] + pal_bytes[(i+1)*4+1] = pal_raw[i*3+1] + pal_bytes[(i+1)*4+2] = pal_raw[i*3+2] + pal_bytes[(i+1)*4+3] = 255 + tile_w, tile_h = 16, 8 + idx_bytes = bytearray(w * h); n = 0 + for ty in range(h // tile_h): + for tx in range(w // tile_w): + for py in range(tile_h): + for p in range(tile_w): + idx_bytes[n] = indices[(ty*tile_h+py)*w + (tx*tile_w+p)]; n += 1 + return bytes(idx_bytes), bytes(pal_bytes) + +def _patch_rhg(img, rhg_bytes): + w = struct.unpack_from(' tuple: + hd = _dbt_header(dbt_data) + tbl = hd["image_table_ptr"] * 4 + e = _dbt_entry(dbt_data, tbl) + img = _dbt_to_pil(dbt_data, e) + if img is None: + raise ValueError("Could not decode HUD texture from DBT") + + hud1, hud2, hud3 = _build_hud_images(img) + + specs = [ + ("045_hud_1.rhg", hud1), + ("047_hud_2.rhg", hud2), + ("049_hud_clash.rhg", hud3), + ] + results = {} + for name, hud_img in specs: + rhg_path = _SRC / name + if not rhg_path.exists(): + raise FileNotFoundError(f"RHG template not found: {rhg_path}") + rhg_orig = rhg_path.read_bytes() + results[name] = _patch_rhg(hud_img, rhg_orig) + + ags_names = ["044_hud_1.ags", "046_hud_2.ags", "048_hud_clash.ags"] + for name in ags_names: + ags_path = _SRC / name + if not ags_path.exists(): + raise FileNotFoundError(f"AGS template not found: {ags_path}") + results[name] = ags_path.read_bytes() + + return results diff --git a/app_md/logic_port/param_convert.py b/app_md/logic_port/param_convert.py new file mode 100644 index 0000000..10c7fba --- /dev/null +++ b/app_md/logic_port/param_convert.py @@ -0,0 +1,22 @@ +import struct + + +def convert_param17(bt3_data: bytes) -> bytes: + result = bytearray(bt3_data) + for pos, count in [(0xCA, 2), (0x98, 1), (0x88, 1), (0x12, 8)]: + result = result[:pos] + bytearray(count) + result[pos:] + return bytes(result[:len(bt3_data)]) + + +def convert_skill_cameras_pak(bt3_data: bytes) -> bytes: + count = struct.unpack_from("I", count) + for o in offsets_le: + out += struct.pack(">I", o) + out += bt3_data[index_size:first_offset] + out += bt3_data[first_offset:] + return bytes(out) \ No newline at end of file diff --git a/app_md/logic_port/port_window.py b/app_md/logic_port/port_window.py new file mode 100644 index 0000000..e43b1ec --- /dev/null +++ b/app_md/logic_port/port_window.py @@ -0,0 +1,453 @@ +import os +import struct +import tempfile +from pathlib import Path + +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QCheckBox, + QMessageBox, QFileDialog, QFrame, QApplication +) +from PyQt5.QtCore import Qt, QThreadPool, pyqtSignal +from PyQt5.QtGui import QDragEnterEvent, QDropEvent + +from app_md.logic_iso.worker import Worker + + +_SIG_1P = 0x000000FC +_SIG_ANM = 0x000001B8 +_SIG_EFF = 0x00000006 + +_ROLE_SIGS = {"1p": _SIG_1P, "anm": _SIG_ANM, "eff": _SIG_EFF} +_ROLE_LABELS = {"1p": "1_p", "anm": "2_anm", "eff": "3_eff"} +_ROLE_HINTS = { + "1p": "Drop or click · (name)_1p.pak", + "anm": "Drop or click · (name)_2_anm.pak", + "eff": "Drop or click · (name)_3_eff.pak", +} + + +def _read_sig(path: str) -> int: + try: + with open(path, "rb") as f: + return struct.unpack_from(" str: + stem = Path(path).stem + for suffix in ("_2_1p", "_1p", "_2_anm", "_2_eff", "_3_eff", "_anm", "_eff"): + if stem.lower().endswith(suffix): + return stem[: -len(suffix)] + return stem + + +def _sep(): + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + return line + + +def _center_on(dialog, parent): + if not parent: + return + pg = parent.geometry() + dialog.adjustSize() + x = pg.x() + (pg.width() - dialog.width()) // 2 + y = pg.y() + (pg.height() - dialog.height()) // 2 + dialog.move(x, y) + + +class PakDropPanel(QFrame): + file_selected = pyqtSignal(str) + + _IDLE = "border: 2px dashed #555; border-radius: 6px; background: transparent;" + _HOVER = "border: 2px dashed #5588cc; border-radius: 6px; background: transparent;" + _OK = "border: 2px solid #55aa55; border-radius: 6px; background: transparent;" + _ERR = "border: 2px solid #aa3333; border-radius: 6px; background: transparent;" + + def __init__(self, role: str, parent=None): + super().__init__(parent) + self.role = role + self._last_dir = "" + self.setAcceptDrops(True) + self.setCursor(Qt.PointingHandCursor) + self.setStyleSheet(self._IDLE) + + layout = QVBoxLayout(self) + layout.setSpacing(3) + layout.setContentsMargins(10, 8, 10, 8) + + self._lbl_role = QLabel(_ROLE_LABELS[role]) + self._lbl_role.setAlignment(Qt.AlignCenter) + self._lbl_role.setStyleSheet("font-weight: bold; font-size: 11px; color: #888; border: none;") + + self._lbl_icon = QLabel("⬇") + self._lbl_icon.setAlignment(Qt.AlignCenter) + self._lbl_icon.setStyleSheet("font-size: 18px; color: #555; border: none;") + + self._lbl_name = QLabel(_ROLE_HINTS[role]) + self._lbl_name.setAlignment(Qt.AlignCenter) + self._lbl_name.setWordWrap(True) + self._lbl_name.setStyleSheet("font-size: 11px; color: #666; border: none;") + self._lbl_name.setMinimumHeight(28) + + layout.addWidget(self._lbl_role) + layout.addWidget(self._lbl_icon) + layout.addWidget(self._lbl_name) + + def set_file(self, path: str, ok: bool): + icon = "✓" if ok else "✗" + color = "#55aa55" if ok else "#aa3333" + self._lbl_icon.setText(icon) + self._lbl_icon.setStyleSheet(f"font-size: 18px; color: {color}; border: none;") + self._lbl_name.setText(Path(path).name) + self._lbl_name.setStyleSheet(f"font-size: 11px; color: {color if not ok else ''}; border: none;") + self.setStyleSheet(self._OK if ok else self._ERR) + self._last_dir = str(Path(path).parent) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + path, _ = QFileDialog.getOpenFileName( + self.window(), f"Open {_ROLE_LABELS[self.role]}", + self._last_dir, "PAK files (*.pak *.unk);;All files (*)" + ) + if path: + self.file_selected.emit(path) + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + self.setStyleSheet(self._HOVER) + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self.setStyleSheet(self._OK if "✓" in self._lbl_icon.text() else self._IDLE) + + def dropEvent(self, event: QDropEvent): + urls = event.mimeData().urls() + if urls: + self.file_selected.emit(urls[0].toLocalFile()) + + + +_FACE_SLOTS = [ + (3, "Damage", True), + (4, "Talk 1", False), + (5, "Talk 2", False), + (6, "Talk 3", False), + (7, "Face 1", True), + (8, "Face 2", True), + (9, "Face 3", True), + (10, "Unused", False), +] +_FACE_SLOT_IDX = {s: (s-3) for s, _, _ in _FACE_SLOTS} + + +class FaceSelectDialog(QDialog): + def __init__(self, parent, detected: dict): + super().__init__(parent) + self.setWindowTitle("Select faces to port") + self.setModal(True) + self.setFixedWidth(260) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + if parent: + self.setWindowIcon(parent.windowIcon()) + self.setFont(parent.font()) + + self._checks = {} + layout = QVBoxLayout(self) + layout.setSpacing(6) + layout.setContentsMargins(16, 14, 16, 14) + + lbl = QLabel("Detected faces — select which to include:") + lbl.setWordWrap(True) + lbl.setStyleSheet("font-size: 11px; color: #aaa;") + layout.addWidget(lbl) + + for slot, label, default in _FACE_SLOTS: + if slot not in detected: + continue + chk = QCheckBox(label) + chk.setChecked(default) + chk.setFocusPolicy(Qt.NoFocus) + layout.addWidget(chk) + self._checks[slot] = chk + + btn = QPushButton("Continue") + btn.setFixedHeight(30) + btn.setFocusPolicy(Qt.NoFocus) + btn.clicked.connect(self.accept) + layout.addWidget(btn) + + _center_on(self, parent) + + def selected(self) -> set: + return {slot for slot, chk in self._checks.items() if chk.isChecked()} + + +class PortWindow(QDialog): + def __init__(self, parent_tool=None): + super().__init__(parent_tool) + self._parent_tool = parent_tool + self.setWindowTitle("BT3 → TTT Character Port") + self.setFixedWidth(430) + self.setWindowFlags(Qt.Window | Qt.WindowTitleHint | Qt.CustomizeWindowHint | + Qt.WindowCloseButtonHint) + + if parent_tool: + self.setWindowIcon(parent_tool.windowIcon()) + self.setFont(parent_tool.font()) + + self._paths = {"1p": None, "anm": None, "eff": None} + self._names = {"1p": None, "anm": None, "eff": None} + self._bt3_parts = None + self._bt3_blob = None + self._thread_pool = QThreadPool() + + self._build_ui() + _center_on(self, parent_tool) + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(10) + root.setContentsMargins(14, 12, 14, 14) + + self._lbl_status = QLabel("Open the 3 BT3 pak files with the same character name.") + self._lbl_status.setAlignment(Qt.AlignCenter) + self._lbl_status.setWordWrap(True) + self._lbl_status.setStyleSheet("font-size: 11px; color: #888;") + root.addWidget(self._lbl_status) + + self._panels = {} + for role in ("1p", "anm", "eff"): + panel = PakDropPanel(role) + panel.file_selected.connect(lambda p, r=role: self._on_file(r, p)) + self._panels[role] = panel + root.addWidget(panel) + + root.addWidget(_sep()) + + self._btn_process = QPushButton("Process") + self._btn_process.setFixedHeight(32) + self._btn_process.setEnabled(False) + self._btn_process.clicked.connect(self._on_process) + root.addWidget(self._btn_process) + + def _on_file(self, role: str, path: str): + sig = _read_sig(path) + expected = _ROLE_SIGS[role] + ok = (sig == expected) + self._panels[role].set_file(path, ok) + + if ok: + self._paths[role] = path + self._names[role] = _char_name(path) + else: + self._paths[role] = None + self._names[role] = None + QMessageBox.critical(self, "Invalid file", + f"Wrong signature for {_ROLE_LABELS[role]}.\n" + f"Expected: 0x{expected:08X} Got: 0x{sig:08X}") + self._update_state() + + def _update_state(self): + paths_ok = all(self._paths[r] for r in ("1p", "anm", "eff")) + names_ok = paths_ok and len({self._names[r] for r in ("1p", "anm", "eff")}) == 1 + if not paths_ok: + self._lbl_status.setText("Open the 3 BT3 pak files with the same character name.") + elif not names_ok: + ns = ", ".join(f"{_ROLE_LABELS[r]}: {self._names[r]}" for r in ("1p","anm","eff")) + self._lbl_status.setText(f"Name mismatch — {ns}") + else: + self._lbl_status.setText(f"Ready · {self._names['1p']}") + self._btn_process.setEnabled(paths_ok and names_ok) + + def _on_process(self): + print(f"[port] Starting BT3→TTT port for: {self._names['1p']}") + self._load_bt3_and_open_packer() + + def _load_bt3_and_open_packer(self): + from app_md.logic_swap.pmdl.parser import parse_bt3 + from app_md.logic_swap.pmdl.bt3_tex_reader import load_dbt, map_dbt_to_tex_ids + from app_md.logic_swap.swap_vfx import parse_pak + + print("[port] Reading 1_p pak...") + with open(self._paths["1p"], "rb") as f: + data_1p = f.read() + + entries_1p = parse_pak(data_1p) + e_map = {idx: raw for idx, _, _, raw in entries_1p} + + pmdl_blob = e_map.get(2, b'') + dbt_blob = e_map.get(11, b'') + + if not pmdl_blob: + QMessageBox.critical(self, "Error", "Could not find pmdl (slot 2) in 1_p.") + return + if not dbt_blob: + QMessageBox.critical(self, "Error", "Could not find dbt (slot 11) in 1_p.") + return + + print("[port] Parsing BT3 pmdl...") + try: + _, parts = parse_bt3(pmdl_blob) + except Exception as e: + QMessageBox.critical(self, "Error", f"Error parsing pmdl:\n{e}") + return + print(f"[port] pmdl parsed: {len(parts)} parts") + + tex_ids = [] + seen = set() + for p in parts: + for m in p.get("meshes", []): + tid = m.get("tex_id", "") + if tid and tid not in seen: + seen.add(tid); tex_ids.append(tid) + print(f"[port] Found {len(tex_ids)} unique tex_ids") + + print("[port] Loading DBT textures...") + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".dbt") + try: + tmp.write(dbt_blob); tmp.flush(); tmp.close() + entries_dbt, raw_data, tbl_offset = load_dbt(tmp.name) + finally: + try: os.unlink(tmp.name) + except: pass + + mapped = map_dbt_to_tex_ids(entries_dbt, tex_ids, + raw_data=raw_data, table_offset=tbl_offset) + tex_map = {tid: img for tid, img in (mapped or {}).items() if img is not None} + print(f"[port] Mapped {len(tex_map)}/{len(tex_ids)} textures from DBT") + + if not tex_map: + QMessageBox.critical(self, "Error", "Could not map any texture from the DBT.") + return + + self._bt3_blob = pmdl_blob + self._bt3_parts = parts + + # detect face blobs (slots 3-10 in pak index) + _FACE_OFFSETS = {3:0x10,4:0x14,5:0x18,6:0x1C,7:0x20,8:0x24,9:0x28,10:0x2C} + self._face_blobs = {} + self._face_dbt_tex = {} + for slot, off in _FACE_OFFSETS.items(): + raw = e_map.get(slot, b'') + if len(raw) >= 0x60 + 48: + self._face_blobs[slot] = raw + print(f"[port] Detected faces: {list(self._face_blobs.keys())}") + + if self._face_blobs: + dlg = FaceSelectDialog(self, self._face_blobs) + if dlg.exec_() != QDialog.Accepted: + return + self._face_selected = dlg.selected() + else: + self._face_selected = set() + + # load face extra DBTs only for selected faces + self._face_dbt_tex = {} + if self._face_selected: + from app_md.logic_swap.pmdl.bt3_tex_reader import load_dbt + from app_md.logic_swap.pmdl.bt3_face_parser import parse_bt3_face as _pf + _FACE_DBT_PAIR = {7: 13, 8: 14, 9: 15} + for face_slot, dbt_slot in _FACE_DBT_PAIR.items(): + if face_slot not in self._face_selected: + continue + raw_dbt = e_map.get(dbt_slot, b'') + if len(raw_dbt) < 256: + continue + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".dbt") + try: + tmp.write(raw_dbt); tmp.flush(); tmp.close() + entries_dbt, _, _ = load_dbt(tmp.name) + valid = [e for e in entries_dbt if e.get("image") is not None] + if not valid: + continue + img = valid[0]["image"] + subparts = _pf(self._face_blobs[face_slot]) + vert_count = {} + for sp in subparts: + t = sp['tex_id'] + vert_count[t] = vert_count.get(t, 0) + len(sp['verts']) + if not vert_count: + continue + face_tid = max(vert_count, key=vert_count.get) + unique_tid = f"FACE{face_slot:02d}_{face_tid}" + tex_map[unique_tid] = img + self._face_dbt_tex[face_slot] = (face_tid, unique_tid) + print(f"[port] Face slot {face_slot}: override {face_tid} → {unique_tid}") + except Exception as ex: + print(f"[port] Face slot {face_slot}: dbt load failed ({ex})") + finally: + try: os.unlink(tmp.name) + except: pass + + self._open_packer(tex_map) + + def _open_packer(self, tex_map: dict): + from app_md.logic_port.tex_compress import TexturePackerWindow + print("[port] Opening texture packer...") + packer = TexturePackerWindow(self, tex_map, self._run_port) + packer.exec_() + + def _run_port(self, atlas_image, pack_result, tex_id_order): + char_name = self._names["1p"] + out_path, _ = QFileDialog.getSaveFileName( + self, "Save PCK1", f"{char_name}.PCK1", "PCK1 files (*.PCK1);;All files (*)" + ) + if not out_path: + return + self.setEnabled(False) + QApplication.setOverrideCursor(Qt.WaitCursor) + + def task(): + from app_md.logic_port.char_port import port_character + + print(f"[port] === Starting full port: {char_name} ===") + + print("[port] Reading pak files...") + with open(self._paths["1p"], "rb") as f: d1p = f.read() + with open(self._paths["anm"], "rb") as f: danm = f.read() + with open(self._paths["eff"], "rb") as f: deff = f.read() + + face_blobs = {s: b for s, b in self._face_blobs.items() + if s in self._face_selected} + face_tex_override = {s: v for s, v in self._face_dbt_tex.items() + if s in self._face_selected} + pck1 = port_character( + d1p, danm, deff, + atlas_image, pack_result, tex_id_order, + self._bt3_parts, face_blobs=face_blobs, + face_tex_override=face_tex_override + ) + + print(f"[port] Writing {Path(out_path).name} ({len(pck1):,} bytes)...") + Path(out_path).write_bytes(pck1) + + print(f"[port] === Done: {char_name} ===") + return [f'Port complete · {char_name}', Path(out_path).parent] + + worker = Worker(task) + worker.signals.resultado.connect(self._on_done) + worker.signals.error.connect(self._on_error) + self._thread_pool.start(worker) + + def _on_done(self, result): + self.setEnabled(True) + QApplication.restoreOverrideCursor() + if self._parent_tool: + self._parent_tool.contenedor.success_dialog(result) + + def _on_error(self, msg): + self.setEnabled(True) + QApplication.restoreOverrideCursor() + if self._parent_tool: + self._parent_tool.contenedor.manejar_error(msg) + + def closeEvent(self, event): + if self._parent_tool: + self._parent_tool.setEnabled(True) + event.accept() \ No newline at end of file diff --git a/app_md/logic_port/src/044_hud_1.ags b/app_md/logic_port/src/044_hud_1.ags new file mode 100644 index 0000000000000000000000000000000000000000..ffb4d80b06eb4745c5d208c3bfa4a090cc9c4ea8 GIT binary patch literal 1920 zcmds0v2MaZ3_U1Fv=bv^Ce-n0S1Q63=JEkiRYf8aBo+KX|A0Ba(|_vR+R44jn2RpW z)><-tzUOy`FTQoMH3Ks?Y$NA&B7O!AhvJRUFuWIeOgIv`gcFy1k;!%7(7Bq2ZV5m! zCXdK{cIOuV`uRiET-W=*4fL4%E^ z0}*NVoH=u6^Ua&TU?Kma#Y>jX7GAq%?K+WBUO!KC!^VG5`rnwkf8wSlADO_DPgqXi zGiPmAY+C-`&7{>c7KlyXGIIuL){0G2cF2$f1ZBm=X3v$Am0$MrE-@LB-~wJX0l`_M zsk@i$72ust;*%BMCpcS9aJTvaA%WG4loyEU=?e!n1c2?a$m zg(9J(EIEH6RYi3tp{_AcQ$tIQraXU-jPjlh8@1_lZA~p5U1dIHektlw!oa|QZfK-7 zPx;XL!&1g3goTR1F+~YeDaqriawoWvS}uC`CGb z{=$TA{LkT{lk=tTHgVC3&E{T2V;1D(x)0ociHJ0HO-xFD5|)z6qHAjUr>UoV=jap!=oF^fYdSbMXlu|If0S2LmgrO! z(=@p!!!XoLO0FnRxnJn*V#w6gpwY}rVq?|F_PXKG+=;Y?H;oLsg}uz>C09hH+&zB1 z`WvCEOTBr^Pt^AKZO4GXJLm6)MY;P$nn&9%j``g&HZJ~QhIytxLtQD%jQljqI5zv5 zzdGIEnXhY65xMy3w&&R%+P<3F4sPi_WO5PZg>mei%jTLYwb6<`&U5Pw_Fdu!w05KgDFSQh$x(vWtNM%gnkejOF4|5El^cL!oQ3PBB<|N>Sbg zEpf(Mix^CD#<3WCLuO20-@Bpx!y{wmiM|i-K7JY=9_POXX^hOv>D=Qgrcf5gHxq=5 z?h>+unc3QP>+ITsxMK6@bbT-Jgqo1N_P3+CQThp;d3jx3UEMvOOX<~g<%NWVbX@#Q zUmow@e+U2a5CG9!et>A^>>@g9j}Qr=vP5621F5~%x}&4k2t5rZD9Jv6`tqabX);CU zOB#kc9q?h`9tPS@pzXyj^ww)*u-yaw?f22wiN^W}q{LlEbM*ELg}1lhjuTf`Zil=mZM2s7;eB^0S_-}3hlHK}g+^y)V(-Qa)~+q|Jgv-wzPyBe0{dG$T%@+gazLB5+XI{el^X`3q4 ze%8ga8#<_pv_QkNAiPXGhk{Tm)Ro4gr`!`m70y_`!yg0w!q_3mE7hA%L8`|F%v@=U zfa8+*G+2SR1+M5Qx(sP)OXQyy!l2hWYIDSPG^Z$H_^vQmdMcP9dHcHUs6Fmz#g$YeU8ev(F^&3 zrik>Rbi6B8YDF29+Ltb$+wxQ%L&ZuM%q2$-WGTwH9wRgPMa~m}Ea|{(_ce-L_ryuU l3PFUnvNq9@=Sp-|+$DOR$W7r}#UR#*UnFiYb`hih{+~xhCI|ok literal 0 HcmV?d00001 diff --git a/app_md/logic_port/src/046_hud_2.ags b/app_md/logic_port/src/046_hud_2.ags new file mode 100644 index 0000000000000000000000000000000000000000..9e92371dd90a4c17f79393df1768c704340dbfcc GIT binary patch literal 1920 zcmds0y$*sv3_kx7b#ioMGLERL2Ap)(2O!3fK!RZ81$+Z%UdMNG))uZwsSr>XwMpsM zpYQH^^p>lwYN(DvX=_a#_GxHbWm}AcaLjxgu)%x|yV|*f@HpYBePy0_a{^#(Py@6` z`a1aQ`!_{%b??7d(Z9SGe&2-oUg8+3TepsLWj{Q}F|9p>AYsa*kLzF#&nWY&^=1HJ zm=6O@Zf4WK$DgyM6l$>~Nn(*=1!1%qkNBB2O(#0KVQ#TH|A)VY99cNBPTr5q3;G)_ z^;brvyzYPEgwg4+3t0I-_b=zboI0K##-9IT|KIRFM{s8O{?JFoT+tcj{1w#z^#h^# XDqDi`&-QY?EzFs-52DyDV^_r=V9i{b literal 0 HcmV?d00001 diff --git a/app_md/logic_port/src/047_hud_2.rhg b/app_md/logic_port/src/047_hud_2.rhg new file mode 100644 index 0000000000000000000000000000000000000000..5774e3d28e24e79235f589c0df0d3e524e04546f GIT binary patch literal 3328 zcmdUvdpwj|7sscPTpHKuc8)|NIwvVk#w8<~F_gKGDUKmwDrQm|T?RSS&`dQ+DN!*c zk)f0{go%V)a|)G|N^(L5MRZ;7Gxd7krgQ%2ulMtL*XOf8Yk$}J?Y*97ul+o$T%1u8 zQ2#ZRg^6Eyrq5R|@c8N{{#){`c6^lsGBFs3@4S4SHZmNLVStSJoJ15#iTMP4AAORC!-C2U0|v+Re#FD1!hyGPS!HSSX$zAl&9#at76oqs%v1i9H!&+)a|f~ z9MS5^7@U^F50q4pXlQDwPgd5}(bdMF(bH6?W9F);D63&+Bdm!vnlo?iA_LV$28%Uk z&(_m4ondHhsj6aSZDYGsNzcyIbOzque7O~YXyru0De2>|B)qf2=05;4X3kPj8Jo;k zV2j@r5T&R_p>V}r#(Bri#oX`+ zopFx(!#_GC)X7`pYd&fZFE-9;uT_Z09Hbv=dT6J`Xk{%!^qiG)o`SXX^88)h6l>;9 zo@0sA#Mt@!d)i@4^#cNZJXK9K7a9bad)i^qj={RR7}eQWZU0bnPqfD3txP5xW3W@h zawW&yOvB9EhskCu8OLDE^*znaumNN;S(lB~F*35Y-s)&Q3LrR4*zmNlbGPj zK-$7)GN=sp{el9ocvmum$z(C8G^!xNW?PdjfzPK<7*swb*qbI`1{;$|n+`|QeU2n1 z9!(R)oH~6bB;9YjbHLe*bD8ejFJ4MGl$7l;m;WzTr zL=0WFzZ1^y8^8 zw(wbJ&Dnd;<(%||s}FY8W!yk`PcQFy9IvpiqN(Spc^j>@=HSk zvVri>KqlL#D=#q5FUOTnAzO#KGLR2c%BOI|o6>XK{X&Tp2JJdGu#3|bJkTZm`03MU z`21jifS$1<()$2FNuH2w!ja5~{!^2f0Z{`m#BwnX-CFJ2{I)+EUbVTyz!PtH{c;QRw9ugZA`xD{*a$<< z*MY2^4r2o`@cFF>-VPjv(Owpki-Mu<12EcC0i*qVIK9suTI-omcqs_3UtzN!iv!PE{Gg$X2A@VR!{|#dcsFz$YOV*sv1l4Rz8eB&3ud(; zN1-;-`-gH66Y6baJ;zGwIOVcntbl(qZV)MR@b%DhxgpBeDH(CD|Xw28*Hk z`c{CCQb3*qd;cnt(f4#Gq=n$%ZT}9qk;{SM&SDtuE`WiSqwpY=2AMIgkj8VQT|8g| z67fdpxpNvhhe~K^jDyE*CxE}%$fi7P4Rq8Vhp|_;;GL`y#6lV*@mI@h_>K)kEP8%} z3-yJ3=#a)jPjxI*#1WzBN;sT8;$rxyHSSXt@}4c1Ho}1|Rv^1AgnM-tV5k(Y@i22q zPu2+!sJjscp=-T94_#aObf5$eZ=`tWyKDn@iy}aFKN~&_-i4;>J)o>Kp-FZP-ykcp zgN|eK;faI;wbER;Rb2`R0p_qdE(EZ+h|ieW#9>)6zO46xHPoj32K5&s;eKU0a?JoD z&W3>avL8IXodYc;2ci334u~ZpD3Rnr=?Q=EAbNtnbuug@odjctbg){C|17&^ce$eo z4+RJ5P$CL~jx)GAlojiA-Rd6DCb|$+l;>fe4l9 zw$Lb;2XD`qfVzG-82sW7qYwBne8f0^uyQ%5V-G{lW^;H|83dkgYs+2=_3i(vaD*Ie|@<^>2OW<~{Ej&mt z0u!^X@S^%4v^ST6`|msYomr)%&W?Lv?Vau1m$d}S4ljhcOHV@tcO&Ux{!ZwqJOa&m z%b@?JBk4o4%j=KLE-T*MA;Cy3A!p>)a_fZ_OszN7%aK2jW8rY61GhS10Xo{(8TGt; O7plJ{6*bhJgZdYvFr*~_ literal 0 HcmV?d00001 diff --git a/app_md/logic_port/src/048_hud_clash.ags b/app_md/logic_port/src/048_hud_clash.ags new file mode 100644 index 0000000000000000000000000000000000000000..2a07767644bac50b3cc57f01426b33ec05543f02 GIT binary patch literal 640 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 X$QJ$RfU7EcUS)+?&UCbfeHi@@@>u^7 literal 0 HcmV?d00001 diff --git a/app_md/logic_port/tex_compress.py b/app_md/logic_port/tex_compress.py new file mode 100644 index 0000000..a3030f8 --- /dev/null +++ b/app_md/logic_port/tex_compress.py @@ -0,0 +1,356 @@ +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QWidget, QFrame, QFileDialog, QSpinBox +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap, QImage + +TARGET = 256 +PRIO_DEF = 3 +PREVIEW_PX = 400 +PRIO_WEIGHTS = {1: 0.4, 2: 0.7, 3: 1.0, 4: 1.4, 5: 1.8} +PRIO_LABELS = {1:"Min", 2:"Low", 3:"Normal", 4:"High", 5:"Max"} +PRIO_COLORS = {1:"#e84060", 2:"#e88040", 3:"#e8c840", 4:"#80d860", 5:"#40d890"} +SLOT_COLORS = [ + "#e8c840","#40c8e8","#e84060","#60e840", + "#c040e8","#e88040","#40e8c0","#e86020", + "#4080e8","#e840c0","#80e840","#e8e040", +] + + +class _R: + __slots__ = ('x','y','w','h') + def __init__(self,x,y,w,h): self.x,self.y,self.w,self.h=x,y,w,h + +class MaxRects: + def __init__(self,W,H): + self.free=[_R(0,0,W,H)]; self.placed=[] + + def _split(self,F,P): + if P.x>=F.x+F.w or P.x+P.w<=F.x or P.y>=F.y+F.h or P.y+P.h<=F.y: + return [F] + out=[] + if P.x>F.x: out.append(_R(F.x,F.y,P.x-F.x,F.h)) + if P.x+P.wF.y: out.append(_R(F.x,F.y,F.w,P.y-F.y)) + if P.y+P.h=a.x+a.w and b.y+b.h>=a.y+a.h + for j,b in enumerate(self.free)): + keep.append(a) + self.free=keep + + def insert(self,w,h,rid): + best=None; bs=float('inf') + for f in self.free: + if f.w>=w and f.h>=h: + s=min(f.w-w,f.h-h) + if s QPixmap: + img_rgba=img.convert("RGBA") + data=img_rgba.tobytes("raw","RGBA") + qimg=QImage(data,img_rgba.width,img_rgba.height,QImage.Format_RGBA8888) + return QPixmap.fromImage(qimg) + + +def _draw_labels(img: Image.Image, pack_result, scale) -> Image.Image: + overlay=img.copy().convert("RGBA") + draw=ImageDraw.Draw(overlay) + color_map={info["orig_index"]:i for i,info in enumerate(pack_result)} + for info in pack_result: + n=info["orig_index"]+1 + col=SLOT_COLORS[color_map[info["orig_index"]]%len(SLOT_COLORS)] + x=int(info["x"]*scale); y=int(info["y"]*scale) + w=int(info["w"]*scale); h=int(info["h"]*scale) + fs=max(9,min(w,h)//3) + try: font=ImageFont.truetype("arial.ttf",fs) + except: + try: font=ImageFont.truetype("DejaVuSans-Bold.ttf",fs) + except: font=ImageFont.load_default() + cx,cy=x+w//2,y+h//2 + for dx,dy in [(-1,-1),(1,-1),(-1,1),(1,1),(0,-1),(0,1),(-1,0),(1,0)]: + draw.text((cx+dx,cy+dy),str(n),font=font,fill=(0,0,0,210),anchor="mm") + draw.text((cx,cy),str(n),font=font,fill=col,anchor="mm") + return overlay + + +class TexturePackerWindow(QDialog): + def __init__(self, parent, tex_map: dict, on_proceed): + super().__init__(parent) + self.setWindowTitle("BT3 → TTT · Texture Packer") + self.setModal(True) + self.setMinimumSize(860, 620) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + if parent: + self.setWindowIcon(parent.windowIcon()) + self.setFont(parent.font()) + + self._on_proceed = on_proceed + self.tex_id_order = list(tex_map.keys()) + self.images = [tex_map[tid] for tid in self.tex_id_order] + self.priorities = [PRIO_DEF] * len(self.images) + self.pack_result = [] + self.composed = None + self._show_nums = True + self._row_widgets = [] + + self._build_ui() + self._center_on_parent(parent) + self.do_pack() + + def _center_on_parent(self, parent): + if not parent: return + pg=parent.geometry() + self.adjustSize() + self.move(pg.x()+(pg.width()-self.width())//2, + pg.y()+(pg.height()-self.height())//2) + + def _build_ui(self): + root=QHBoxLayout(self) + root.setSpacing(10) + root.setContentsMargins(12,12,12,12) + + left=QVBoxLayout(); left.setSpacing(6) + lbl=QLabel("ATLAS 256×256") + lbl.setStyleSheet("font-size: 10px; color: #555;") + lbl.setAlignment(Qt.AlignCenter) + left.addWidget(lbl) + + self._canvas=QLabel() + self._canvas.setFixedSize(PREVIEW_PX,PREVIEW_PX) + self._canvas.setStyleSheet("background: #060610; border: 1px solid #1a1a2e;") + self._canvas.setAlignment(Qt.AlignCenter) + left.addWidget(self._canvas) + + self._lbl_cov=QLabel("Coverage: —") + self._lbl_cov.setAlignment(Qt.AlignCenter) + self._lbl_cov.setStyleSheet("font-size: 11px; color: #e8c840;") + left.addWidget(self._lbl_cov) + + self._btn_nums=QPushButton("Numbers: ON") + self._btn_nums.setFixedHeight(26) + self._btn_nums.setCheckable(True) + self._btn_nums.setChecked(True) + self._btn_nums.setFocusPolicy(Qt.NoFocus) + self._btn_nums.clicked.connect(self._toggle_nums) + left.addWidget(self._btn_nums) + left.addStretch() + root.addLayout(left, 0) + + right=QVBoxLayout(); right.setSpacing(6) + + self._btn_ok=QPushButton("✔ Port PMDL") + self._btn_ok.setFixedHeight(36) + self._btn_ok.setStyleSheet("font-weight: bold; color: #80ff9a; background: #1e4d2a;") + self._btn_ok.setFocusPolicy(Qt.NoFocus) + self._btn_ok.clicked.connect(self._on_ok) + right.addWidget(self._btn_ok) + + btn_repack=QPushButton("▶ Recalculate") + btn_repack.setFixedHeight(28) + btn_repack.setFocusPolicy(Qt.NoFocus) + btn_repack.clicked.connect(self.do_pack) + right.addWidget(btn_repack) + + lbl_hint=QLabel("1 = Min 3 = Normal 5 = Max priority") + lbl_hint.setStyleSheet("font-size: 10px; color: #444;") + right.addWidget(lbl_hint) + + scroll=QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._list_container=QWidget() + self._list_layout=QVBoxLayout(self._list_container) + self._list_layout.setSpacing(4) + self._list_layout.setContentsMargins(4,4,4,4) + self._list_layout.addStretch() + scroll.setWidget(self._list_container) + right.addWidget(scroll, 1) + root.addLayout(right, 1) + self._rebuild_list() + + def _rebuild_list(self): + for w in self._row_widgets: w.setParent(None) + self._row_widgets.clear() + self._list_layout.takeAt(self._list_layout.count()-1) + + for i,(tid,img) in enumerate(zip(self.tex_id_order,self.images)): + info=next((p for p in self.pack_result if p["orig_index"]==i),None) + col=SLOT_COLORS[i%len(SLOT_COLORS)] + + row=QFrame() + row.setStyleSheet("background: #1a1a28; border-radius: 4px;") + rl=QHBoxLayout(row) + rl.setContentsMargins(6,4,6,4); rl.setSpacing(6) + + stripe=QFrame() + stripe.setFixedWidth(4) + stripe.setStyleSheet(f"background: {col}; border-radius: 2px;") + rl.addWidget(stripe) + + lbl_n=QLabel(str(i+1)) + lbl_n.setFixedWidth(20) + lbl_n.setAlignment(Qt.AlignCenter) + lbl_n.setStyleSheet(f"color: {col}; font-weight: bold; font-size: 12px;") + rl.addWidget(lbl_n) + + thumb=img.copy(); thumb.thumbnail((32,32),Image.LANCZOS) + lbl_t=QLabel() + lbl_t.setFixedSize(36,36) + lbl_t.setPixmap(_pil_to_qpixmap(thumb).scaled(36,36,Qt.KeepAspectRatio,Qt.SmoothTransformation)) + rl.addWidget(lbl_t) + + info_col=QVBoxLayout(); info_col.setSpacing(1) + lbl_tid=QLabel(tid[:24]) + lbl_tid.setStyleSheet("font-size: 11px; font-weight: bold; color: #ccc;") + info_col.addWidget(lbl_tid) + if info: + lbl_sz=QLabel(f"orig {img.width}×{img.height} → {info['w']}×{info['h']} @({info['x']},{info['y']})") + lbl_sz.setStyleSheet("font-size: 10px; color: #60c880;") + else: + lbl_sz=QLabel(f"orig {img.width}×{img.height} (no fit)") + lbl_sz.setStyleSheet("font-size: 10px; color: #cc4444;") + info_col.addWidget(lbl_sz) + rl.addLayout(info_col,1) + + spin=QSpinBox() + spin.setRange(1,5) + spin.setValue(self.priorities[i]) + spin.setFixedWidth(48) + spin.setFocusPolicy(Qt.NoFocus) + spin.valueChanged.connect(lambda v,idx=i: self._set_prio(idx,v)) + rl.addWidget(spin) + + self._list_layout.insertWidget(self._list_layout.count(),row) + self._row_widgets.append(row) + + self._list_layout.addStretch() + + def _set_prio(self,idx,val): self.priorities[idx]=val + + def _toggle_nums(self,checked): + self._show_nums=checked + self._btn_nums.setText("Numbers: ON" if checked else "Numbers: OFF") + self._refresh_canvas() + + def _refresh_canvas(self): + if not self.composed: + self._canvas.clear(); return + scale=PREVIEW_PX/TARGET + disp=self.composed.resize((PREVIEW_PX,PREVIEW_PX),Image.NEAREST) + if self._show_nums and self.pack_result: + disp=_draw_labels(disp,self.pack_result,scale) + self._canvas.setPixmap(_pil_to_qpixmap(disp)) + + def do_pack(self): + if not self.images: return + self.pack_result=smart_pack(self.images,self.priorities) + if not self.pack_result: + self._lbl_cov.setText("Pack failed"); return + self.composed=build_atlas(self.images,self.pack_result) + self._refresh_canvas(); self._rebuild_list() + cov=sum(p["w"]*p["h"] for p in self.pack_result)/TARGET**2 + self._lbl_cov.setText(f"Coverage: {cov*100:.1f}% ({len(self.pack_result)}/{len(self.images)})") + + def _on_ok(self): + if not self.composed or not self.pack_result: return + atlas=self.composed.copy() + result=list(self.pack_result) + order=list(self.tex_id_order) + self.accept() + self._on_proceed(atlas,result,order) diff --git a/app_md/logic_swap/__init__.py b/app_md/logic_swap/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app_md/logic_swap/__init__.py @@ -0,0 +1 @@ + diff --git a/app_md/logic_swap/anim_converter.py b/app_md/logic_swap/anim_converter.py new file mode 100644 index 0000000..2015e82 --- /dev/null +++ b/app_md/logic_swap/anim_converter.py @@ -0,0 +1,204 @@ +import os +import struct +import zlib +from pathlib import Path + + +BLOCKSIZE = 5000 +HASHSIZE = 4096 +MAXCHARS = 200 +THRESHOLD = 3 + + +def _bpe_lookup(lh, rh, ch, a, b): + i = (a ^ (b << 5)) & (HASHSIZE - 1) + while (lh[i] != a or rh[i] != b) and ch[i] != 0: + i = (i + 1) & (HASHSIZE - 1) + lh[i] = a; rh[i] = b + return i + + +def bpe_compress(data: bytes) -> bytes: + out = bytearray() + pos = 0 + n = len(data) + + while pos <= n: + ch = bytearray(HASHSIZE) + lh = bytearray(HASHSIZE) + rh = bytearray(HASHSIZE) + lc = bytearray(range(256)) + rc = bytearray(256) + buf = bytearray() + used = 0 + eof = pos >= n + + while len(buf) < BLOCKSIZE and used < MAXCHARS and pos < n: + c = data[pos]; pos += 1 + if buf: + idx = _bpe_lookup(lh, rh, ch, buf[-1], c) + if ch[idx] < 255: ch[idx] += 1 + buf.append(c) + if not rc[c]: + rc[c] = 1; used += 1 + + if not buf: + break + + size = len(buf) + code = 256 + + while True: + code -= 1 + while code >= 0: + if lc[code] == code and rc[code] == 0: break + code -= 1 + if code < 0: break + + best = THRESHOLD - 1; lch = rch = 0 + for i in range(HASHSIZE): + if ch[i] > best: + best = ch[i]; lch = lh[i]; rch = rh[i] + if best < THRESHOLD: break + + old = size - 1; w = r = 0 + while r < old: + if buf[r] == lch and buf[r+1] == rch: + if w > 0: + idx = _bpe_lookup(lh, rh, ch, buf[w-1], lch) + if ch[idx] > 1: ch[idx] -= 1 + idx = _bpe_lookup(lh, rh, ch, buf[w-1], code) + if ch[idx] < 255: ch[idx] += 1 + if r < old - 1: + idx = _bpe_lookup(lh, rh, ch, rch, buf[r+2]) + if ch[idx] > 1: ch[idx] -= 1 + idx = _bpe_lookup(lh, rh, ch, code, buf[r+2]) + if ch[idx] < 255: ch[idx] += 1 + buf[w] = code; w += 1; r += 2; size -= 1 + else: + buf[w] = buf[r]; w += 1; r += 1 + if r == old: buf[w] = buf[r] + + lc[code] = lch; rc[code] = rch + ch[_bpe_lookup(lh, rh, ch, lch, rch)] = 1 + + c = 0 + while c < 256: + if lc[c] == c: + ln = 1; c += 1 + while ln < 127 and c < 256 and lc[c] == c: ln += 1; c += 1 + out.append(ln + 127); ln = 0 + if c == 256: break + else: + ln = 0; c += 1 + while (ln < 127 and c < 256 and lc[c] != c) or \ + (ln < 125 and c < 254 and lc[c+1] != c+1): + ln += 1; c += 1 + out.append(ln); c -= ln + 1 + for _ in range(ln + 1): + out.append(lc[c]) + if c != lc[c]: out.append(rc[c]) + c += 1 + + out.append(size // 256); out.append(size % 256) + out += buf[:size] + if eof: break + + return bytes(out) + + +def bpe_decompress(data: bytes, out_size: int) -> bytes: + out = bytearray() + pos = 0 + + while pos < len(data) and len(out) < out_size: + left = bytearray(range(256)) + right = bytearray(256) + count = data[pos]; pos += 1 + c = 0 + + while True: + if count > 127: c += count - 127; count = 0 + if c == 256: break + for _ in range(count + 1): + if c >= 256: break + left[c] = data[pos]; pos += 1 + if c != left[c]: right[c] = data[pos]; pos += 1 + c += 1 + if c == 256: break + count = data[pos]; pos += 1 + + size = 256 * data[pos] + data[pos+1]; pos += 2 + stack = []; i = 0 + + while i < size: + if stack: + c = stack.pop() + else: + c = data[pos]; pos += 1; i += 1 + if c == left[c]: + out.append(c) + else: + stack.append(right[c]); stack.append(left[c]) + + return bytes(out) + + +def canm_to_anm(d: bytes) -> bytes: + size, zs = struct.unpack_from(' bytes: + c = bpe_compress(d) + return struct.pack(' bytes: + size, zs = struct.unpack_from('>II', d) + return zlib.decompress(d[8:8+zs]) + +def anm_to_tanm(d: bytes) -> bytes: + c = zlib.compress(d, 9) + return struct.pack('>II', len(d), len(c)) + c + +def canm_to_tanm(d: bytes) -> bytes: return anm_to_tanm(canm_to_anm(d)) +def tanm_to_canm(d: bytes) -> bytes: return anm_to_canm(tanm_to_anm(d)) + + +def encontrar_animacion(carpeta_anims, base_name, mode): + exts = [".tanm", ".anm"] if mode == "TTT" else [".canm", ".anm"] + for ext in exts: + p = os.path.join(carpeta_anims, base_name + ext) + if os.path.exists(p): + return p + p_noext = os.path.join(carpeta_anims, base_name) + if os.path.exists(p_noext): + return p_noext + for ext in [".tanm", ".canm", ".anm"]: + p = os.path.join(carpeta_anims, base_name + ext) + if os.path.exists(p): + return p + return None + + +def anim_ext_for_mode(mode): + return ".tanm" if mode == "TTT" else ".canm" + + +def get_dest_anim_path(dest_folder, dest_base, target_mode, prefer_ext=None): + for ext in [".tanm", ".canm", ".anm"]: + cand = os.path.join(dest_folder, dest_base + ext) + if os.path.exists(cand): + return cand + ext = prefer_ext if prefer_ext else anim_ext_for_mode(target_mode) + return os.path.join(dest_folder, dest_base + ext) + + +def convert_anim_between_modes(src_path: Path, from_mode: str, to_mode: str) -> bytes: + data = Path(src_path).read_bytes() + if from_mode == to_mode: + return data + if from_mode == "BT3" and to_mode == "TTT": + return canm_to_tanm(data) + if from_mode == "TTT" and to_mode == "BT3": + return tanm_to_canm(data) + return data \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/bt3_face_parser - otra app.py b/app_md/logic_swap/pmdl/bt3_face_parser - otra app.py new file mode 100644 index 0000000..d11f473 --- /dev/null +++ b/app_md/logic_swap/pmdl/bt3_face_parser - otra app.py @@ -0,0 +1,96 @@ +import struct + +_HEADER_SIZE = 0x60 +_STRIDE = 48 +_BONE_ID = 0x30 + +_TEX_ID_DEFAULT = "" + + +def _get_individual(part_blob, index): + if len(part_blob) <= 100 or index + 4 > len(part_blob): + return None, 0 + if part_blob[index + 1] != 0x80: + return None, 0 + num = part_blob[index + 2] * 16 + if index + 4 + num + 3 > len(part_blob): + return None, 0 + num2 = part_blob[index + 4 + num + 2] * 16 + num3 = 8 + num + num2 + 4 + if index + num3 > len(part_blob): + return None, 0 + return bytes(part_blob[index: index + num3]), index + num3 + + +def _read_verts(part_blob, v_start, vc): + verts = [] + for v in range(vc): + base = v_start + v * _STRIDE + if base + _STRIDE > len(part_blob): + break + x, y, z, w = struct.unpack_from('<4f', part_blob, base) + nx, ny, nz, _ = struct.unpack_from('<4f', part_blob, base + 0x10) + u, v2, _, _ = struct.unpack_from('<4f', part_blob, base + 0x20) + verts.append({'x': x, 'y': y, 'z': z, 'w': w, + 'nx': nx, 'ny': ny, 'nz': nz, + 'uvx': u, 'uvy': v2}) + return verts + + +def parse_bt3_face(blob: bytes) -> list: + # Face extra BT3: same mesh format as pmdl, starts at offset 8 + # (C# MeshStartOffset=8 for ExtraFace) + data = bytes(blob) + subparts = [] + off = 8 + + while True: + mesh_blob, new_off = _get_individual(data, off) + if mesh_blob is None: + break + + tex_id = mesh_blob[20:28].hex().upper() if len(mesh_blob) >= 28 else "" + num = data[off + 2] * 16 + vstart = 8 + num + vc_idx = vstart - 20 + vc = mesh_blob[vc_idx] if vc_idx < len(mesh_blob) else 0 + vs_abs = off + vstart + verts = _read_verts(data, vs_abs, vc) if vc > 0 else [] + + if verts: + subparts.append({ + 'bone_id': _BONE_ID, + 'tex_id': tex_id, + 'verts': verts, + }) + off = new_off + + return subparts + + +def face_to_bt3_parts(subparts: list) -> list: + meshes = [{'bone_id': _BONE_ID, 'tex_id': sp['tex_id'], 'vertices': sp['verts']} + for sp in subparts] + total_verts = sum(len(sp['verts']) for sp in subparts) + return [{'bone_id': _BONE_ID, 'meshes': meshes, + 'total_verts': total_verts, 'length': total_verts * _STRIDE, + 'is_face_extra': True}] + + +def get_face_tex_ids(blob: bytes) -> list: + subparts = parse_bt3_face(blob) + seen, result = set(), [] + for sp in subparts: + tid = sp['tex_id'] + if tid and tid not in seen: + seen.add(tid); result.append(tid) + return result + + +def face_tex_tbps(blob: bytes) -> set: + ids = get_face_tex_ids(blob) + tbps = set() + for tid in ids: + try: tbps.add(int.from_bytes(bytes.fromhex(tid[:4]), 'little') & 0x3FFF) + except: pass + return tbps \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/bt3_face_parser.py b/app_md/logic_swap/pmdl/bt3_face_parser.py new file mode 100644 index 0000000..d11f473 --- /dev/null +++ b/app_md/logic_swap/pmdl/bt3_face_parser.py @@ -0,0 +1,96 @@ +import struct + +_HEADER_SIZE = 0x60 +_STRIDE = 48 +_BONE_ID = 0x30 + +_TEX_ID_DEFAULT = "" + + +def _get_individual(part_blob, index): + if len(part_blob) <= 100 or index + 4 > len(part_blob): + return None, 0 + if part_blob[index + 1] != 0x80: + return None, 0 + num = part_blob[index + 2] * 16 + if index + 4 + num + 3 > len(part_blob): + return None, 0 + num2 = part_blob[index + 4 + num + 2] * 16 + num3 = 8 + num + num2 + 4 + if index + num3 > len(part_blob): + return None, 0 + return bytes(part_blob[index: index + num3]), index + num3 + + +def _read_verts(part_blob, v_start, vc): + verts = [] + for v in range(vc): + base = v_start + v * _STRIDE + if base + _STRIDE > len(part_blob): + break + x, y, z, w = struct.unpack_from('<4f', part_blob, base) + nx, ny, nz, _ = struct.unpack_from('<4f', part_blob, base + 0x10) + u, v2, _, _ = struct.unpack_from('<4f', part_blob, base + 0x20) + verts.append({'x': x, 'y': y, 'z': z, 'w': w, + 'nx': nx, 'ny': ny, 'nz': nz, + 'uvx': u, 'uvy': v2}) + return verts + + +def parse_bt3_face(blob: bytes) -> list: + # Face extra BT3: same mesh format as pmdl, starts at offset 8 + # (C# MeshStartOffset=8 for ExtraFace) + data = bytes(blob) + subparts = [] + off = 8 + + while True: + mesh_blob, new_off = _get_individual(data, off) + if mesh_blob is None: + break + + tex_id = mesh_blob[20:28].hex().upper() if len(mesh_blob) >= 28 else "" + num = data[off + 2] * 16 + vstart = 8 + num + vc_idx = vstart - 20 + vc = mesh_blob[vc_idx] if vc_idx < len(mesh_blob) else 0 + vs_abs = off + vstart + verts = _read_verts(data, vs_abs, vc) if vc > 0 else [] + + if verts: + subparts.append({ + 'bone_id': _BONE_ID, + 'tex_id': tex_id, + 'verts': verts, + }) + off = new_off + + return subparts + + +def face_to_bt3_parts(subparts: list) -> list: + meshes = [{'bone_id': _BONE_ID, 'tex_id': sp['tex_id'], 'vertices': sp['verts']} + for sp in subparts] + total_verts = sum(len(sp['verts']) for sp in subparts) + return [{'bone_id': _BONE_ID, 'meshes': meshes, + 'total_verts': total_verts, 'length': total_verts * _STRIDE, + 'is_face_extra': True}] + + +def get_face_tex_ids(blob: bytes) -> list: + subparts = parse_bt3_face(blob) + seen, result = set(), [] + for sp in subparts: + tid = sp['tex_id'] + if tid and tid not in seen: + seen.add(tid); result.append(tid) + return result + + +def face_tex_tbps(blob: bytes) -> set: + ids = get_face_tex_ids(blob) + tbps = set() + for tid in ids: + try: tbps.add(int.from_bytes(bytes.fromhex(tid[:4]), 'little') & 0x3FFF) + except: pass + return tbps \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/bt3_tex_reader.py b/app_md/logic_swap/pmdl/bt3_tex_reader.py new file mode 100644 index 0000000..1da57eb --- /dev/null +++ b/app_md/logic_swap/pmdl/bt3_tex_reader.py @@ -0,0 +1,298 @@ +""" +bt3_tex_reader.py +Lectura de archivos DBT (PS2 - DBZ Budokai Tenkaichi 3) y mapeo a tex_id del PMDL. +Extraído y adaptado de tex_viewer.py. +""" +import struct +from PIL import Image + +_DBT_ENTRY_FMT = "> 20) & 0x3F + +def _tbp0(gstex0): + return gstex0 & 0x3FFF + +def _tex0_wh(gstex0): + return 1 << ((gstex0 >> 26) & 0xF), 1 << ((gstex0 >> 30) & 0xF) + +def _ps2_alpha(a): + return min(255, (a * 2 - 1) if a * 2 != 0 else 0) + +def _dbt_parse_header(data): + v = struct.unpack_from(" data_len: return None + if pp < 0 or pp + pr > data_len: return None + return "psmt4" + elif psm == 19: + if tl <= 128 or pl <= 128: return None + if tp < 0 or tp + tr > data_len: return None + if pp < 0 or pp + pr > data_len: return None + return "psmt8" + elif psm == 0: + if tl <= 128: return None + if tp < 0 or tp + tr > data_len: return None + return "psmct32" + return None + +def _rarr(src, dst, o, n, loops): + for _ in range(loops): + dst[n] = src[o]; n += 1; o += 4 + return dst + +def _tbr8(s, d, o, n): + d = _rarr(s, d, o, n, 8) + d = _rarr(s, d, o+2, n+8, 8) + return d + +def _tbr4(s, d, o, n): + d = _rarr(s, d, o, n, 4); o -= 16 + d = _rarr(s, d, o, n+4, 4); o += 18 + d = _rarr(s, d, o, n+8, 4); o -= 16 + d = _rarr(s, d, o, n+12, 4) + return d + +def _transform_to_bmp_order(px, w, h): + out = bytearray(len(px)); red = w*2; o = 0; n = len(out)-w + for _ in range((w*h)//((w*4)*2)): + for _ in range(2): + for _ in range(w//16): out=_tbr8(px,out,o,n); n+=16; o+=32 + n -= red + o -= (red*2)-17 + for _ in range(2): + for _ in range(w//16): out=_tbr4(px,out,o,n); n+=16; o+=32 + n -= red + o -= 1 + for _ in range(w//16): out=_tbr4(px,out,o,n); n+=16; o+=32 + n -= red + for _ in range(w//16): out=_tbr4(px,out,o,n); n+=16; o+=32 + o -= (red*2)+15; n -= red + for _ in range(2): + for _ in range(w//16): out=_tbr8(px,out,o,n); n+=16; o+=32 + n -= red + o -= 1 + return bytes(out) + +def _interlacing(px): + b = bytearray(len(px)); n = o = 0 + for _ in range(len(px)//32): + for _ in range(2): + c = 0 + for _ in range(8): + for k in range(2): b[n]=px[o+c+k]; n+=1 + c += 4 + o += 2 + o += 28 + return bytes(b) + +def _tex_to_0x(px): + h = bytes(px).hex().upper() + chars = list(h) + parts = [] + for i in range(len(h)): + if i % 2 == 0: parts.append("0" + chars[i + 1]) + else: parts.append("0" + chars[i - 1]) + return bytes.fromhex("".join(parts)) + +def _reorder_pixel_data(px, width, height): + out = bytearray(len(px)) + n_ptr = o_ptr = base_offset = column_offset = 0 + cs = 32 + if width > 128: block_count=width//128; block_cols=4 + else: block_count=1; block_cols=width//32 + for block_row in range(height//16): + for _ in range(8): + bco = column_offset + for _ in range(block_count): + for _ in range(block_cols): + out[n_ptr:n_ptr+cs]=px[o_ptr:o_ptr+cs]; o_ptr+=width*16; n_ptr+=cs + bco += 256; o_ptr = bco + column_offset += width*2; o_ptr = column_offset + base_offset += cs; column_offset = base_offset; o_ptr = base_offset + if ((block_row+1)%8)==0: + base_offset=128*width*((block_row+1)//8)//2; column_offset=base_offset; o_ptr=base_offset + return bytes(out) + +def _reorder_pal_data(pl): + out = bytearray(len(pl)); bp = op = 0 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op=64 + for _ in range(7): + out[op:op+32]=pl[bp:bp+32]; bp+=32; op-=32 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op+=64 + out[op:op+64]=pl[bp:bp+64]; bp+=64; op+=96 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op-=32 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op+=32 + out[bp:bp+32]=pl[bp:bp+32] + return bytes(out) + +def _build_image(px_raw, pl_raw, w, h, mode): + if mode == "psmt4": + s1 = _reorder_pixel_data(bytearray(px_raw), w, h) + s2 = _interlacing(s1) + s3 = _tex_to_0x(s2) + s4 = _transform_to_bmp_order(bytearray(s3), w, h) + pal = [] + for i in range(len(pl_raw)//4): + b=i*4; pal.append((pl_raw[b],pl_raw[b+1],pl_raw[b+2],_ps2_alpha(pl_raw[b+3]))) + img = Image.new("RGBA",(w,h)); pix=img.load() + for y in range(h): + for x in range(w): + ci = s4[(h-1-y)*w + x] + pix[x, y] = pal[ci % len(pal)] + elif mode == "psmt8": + s = _transform_to_bmp_order(bytearray(px_raw), w, h) + pl = _reorder_pal_data(pl_raw) + pal = [] + for i in range(len(pl)//4): + b=i*4; pal.append((pl[b],pl[b+1],pl[b+2],_ps2_alpha(pl[b+3]))) + img = Image.new("RGBA",(w,h)); pix=img.load() + for y in range(h): + for x in range(w): + ci=s[y*w+x] + if ci < len(pal): pix[x, h-1-y]=pal[ci] + elif mode == "psmct32": + img = Image.new("RGBA",(w,h)); pix=img.load() + for y in range(h): + for x in range(w): + o=(y*w+x)*4 + if o+4 <= len(px_raw): + r,g,b,a=px_raw[o],px_raw[o+1],px_raw[o+2],px_raw[o+3] + pix[x,y]=(r,g,b,_ps2_alpha(a)) + else: + raise ValueError(f"modo desconocido: {mode}") + return img + + +def load_dbt(filepath): + """ + Carga un archivo DBT. Retorna (entries, raw_data, table_offset). + entries: lista de dicts con index, width, height, psm, tbp0, image, error. + raw_data + table_offset se usan para el matching exacto por material_id. + """ + with open(filepath, "rb") as f: + data = f.read() + + hd = _dbt_parse_header(data) + tbl = hd["image_table_ptr"] * 4 + results = [] + + for i in range(hd["image_count"]): + offset = tbl + i * _DBT_ENTRY_SIZE + e = _dbt_parse_entry(data, offset) + mode = _dbt_classify(e, len(data)) + + gstex = e["gstex0"] + psm = _psm(gstex) + tbp = _tbp0(gstex) + w, h = _tex0_wh(gstex) + psm_name = _PSM_NAMES.get(psm, f"PSM{psm}") + + if mode is None: + results.append({"index": i, "width": w, "height": h, + "psm": psm_name, "tbp0": tbp, + "image": None, "error": "entrada no soportada"}) + continue + try: + px = _dbt_extract(data, e["tex_data_ptr"], e["tex_data_length"]) + pl_raw = _dbt_extract(data, e["pal_data_ptr"], e["pal_data_length"]) if mode != "psmct32" else bytearray() + img = _build_image(px, pl_raw, w, h, mode) + results.append({"index": i, "width": w, "height": h, + "psm": psm_name, "tbp0": tbp, + "image": img, "error": None}) + except Exception as ex: + results.append({"index": i, "width": w, "height": h, + "psm": psm_name, "tbp0": tbp, + "image": None, "error": str(ex)}) + + return results, data, tbl + + +def material_id_from_entry(data: bytes, entry_offset: int, tex_count: int) -> str: + """ + Reconstruye el tex_id de 8 bytes de un entry DBT, igual que C# SetParameters. + Retorna el hex string en mayúsculas (mismo formato que el parser BT3). + entry_offset = PalStart (inicio del entry en la tabla). + tex_count = índice del entry (para calcular numero2). + """ + numero = struct.unpack_from("> 24) & 0xFF, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF] + + a2 = to_4bytes_be(numero) + a3 = to_4bytes_be(numero2) + + arr = [0] * 8 + arr[0] = a2[3] + arr[1] = (a2[2] + data[entry_offset + 45]) & 0xFF + arr[2] = data[entry_offset + 46] + arr[3] = data[entry_offset + 47] + arr[4] = a3[3] + arr[5] = a3[2] + arr[6] = data[entry_offset + 50] + arr[7] = data[entry_offset + 51] + + return bytes(arr).hex().upper() + + +def map_dbt_to_tex_ids(dbt_entries, tex_ids, raw_data: bytes = None, table_offset: int = 0): + """ + Mapea entradas DBT a tex_id del PMDL. + Si raw_data está disponible, usa matching exacto por material_id (igual que C#). + Si no, cae a matching ordinal como fallback. + tex_ids: lista de strings hex ordenados por TBP0. + Retorna dict {tex_id_hex: PIL.Image} + """ + valid = [e for e in dbt_entries if e.get("image") is not None] + + # Matching exacto por ID + if raw_data: + entry_size = 64 + id_to_img = {} + for i, e in enumerate(valid): + offset = table_offset + i * entry_size + try: + mid = material_id_from_entry(raw_data, offset, i) + id_to_img[mid] = e["image"] + except Exception: + pass + result = {} + for tid in tex_ids: + if tid.upper() in id_to_img: + result[tid] = id_to_img[tid.upper()] + if result: + return result + + # Fallback ordinal + result = {} + for i, tid in enumerate(tex_ids): + if i < len(valid): + result[tid] = valid[i]["image"] + return result \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/bt3_to_ttt - otra app.py b/app_md/logic_swap/pmdl/bt3_to_ttt - otra app.py new file mode 100644 index 0000000..e423cc8 --- /dev/null +++ b/app_md/logic_swap/pmdl/bt3_to_ttt - otra app.py @@ -0,0 +1,429 @@ +import math +import struct +from collections import defaultdict + + +_BONE_FLAG = {1: b'\x01\x43\x00\x12', 2: b'\x01\xC3\x00\x12', + 3: b'\x01\x43\x01\x12', 4: b'\x01\xC3\x01\x12'} + +_TTT_VERSION = 6 +_HEADER_SIZE = 0xA0 +_MAT_BLOCK_SIZE = 0x20 +_WEIRD_BLOCK_SIZE = 0x20 +_BONE_BLOCK_SIZE = 0xA0 +_PART_INDEX_ENTRY = 0x20 +_CAMERA_BYTES = bytes([0x00,0x00,0x20,0x42, 0x00,0x00,0xF0,0x42]) + + +def _compute_bbox(parts: list) -> tuple: + bx = by = bz = 0.0 + for part in parts: + for mesh in part.get("meshes", []): + for v in mesh.get("vertices", []): + ax, ay, az = abs(v["x"]), abs(v["y"]), abs(v["z"]) + if ax > bx: bx = ax + if ay > by: by = ay + if az > bz: bz = az + return (bx or 1.0, by or 1.0, bz or 1.0) + + +def _quant(val, bbox): + v = int(round(val * 32767.0 / bbox)) + return max(-32768, min(32767, v)) + + +# Huesos BT3 con flag especial en TTT +_SPECIAL_BONE_FLAGS = { + 0x30: 0x06, + 0x40: 0x01, + 0x41: 0x02, + 0x42: 0x07, + 0x43: 0x08, +} +_SPECIAL_BONES = set(_SPECIAL_BONE_FLAGS.keys()) | {0x33} + + +def _group_meshes(parts: list) -> list: + """ + Retorna lista de (group_key, flag, [(part, mesh), ...]). + Huesos especiales forman grupo propio por bone_id, ignorando material. + El resto se agrupa por material, excluyendo meshes de huesos especiales. + """ + special_groups = defaultdict(list) + material_groups = defaultdict(list) + + for part in parts: + bid = part.get("bone_id", 0) + for mesh in part.get("meshes", []): + if bid in _SPECIAL_BONES: + special_groups[bid].append((part, mesh)) + else: + material_groups[mesh.get("tex_id", "?")].append((part, mesh)) + + result = [] + for bid, meshes in special_groups.items(): + flag = _SPECIAL_BONE_FLAGS.get(bid, 0x00) + result.append((f"bone_{bid:#04x}", flag, meshes)) + for tid, meshes in material_groups.items(): + if meshes: + result.append((tid, 0x00, meshes)) + return result + + +_W_TABLE = { + 0.0: 0x8000, 0.1: 0x740c, 0.2: 0x6719, 0.3: 0x5a26, 0.4: 0x4d33, + 0.5: 0x4040, 0.6: 0x344c, 0.7: 0x2759, 0.8: 0x1a66, 0.9: 0x0d73, 1.0: 0x0080, +} + +def _w_to_peso(w: float) -> int: + level = round(max(0.0, min(1.0, w)) * 10) / 10.0 + return _W_TABLE.get(level, max(0x0080, round((1.0 - w) * 0x8000))) + + +def _uv_byte(uv_f: float, coord: int, size: int) -> int: + import math + frac = uv_f - math.floor(uv_f) + if frac == 0.0 and uv_f > 0.0: + frac = 1.0 + pixel = coord + frac * size + return max(coord, min(min(coord + size - 1, 255), round(pixel))) + + +def _build_ttt_part_entries(meshes_with_parts: list, bbox: tuple, + bt3_parts: list = None, + uv_map: dict = None) -> list: + sub_entries = [] + for part, mesh in meshes_with_parts: + verts = mesh.get("vertices", []) + if not verts: + continue + bone_id = part.get("bone_id", 0) + bone_ids = [bone_id, 0, 0, 0] + vb = bytearray() + for v in verts: + peso = _w_to_peso(v.get("w", 0.0)) + vb += struct.pack(" bytes: + """Ensambla el binario de una parte TTT desde la lista de subpartes.""" + if not sub_entries: + return b"" + + index_raw = 4 + len(sub_entries) * 0x10 + index_size = index_raw + (16 - index_raw % 16) % 16 + data_offset = index_size + idx = bytearray() + idx += struct.pack(" list: + huesos, pila = [], [] + for i, part in enumerate(bt3_parts): + bone_id = part.get("bone_id", 0) + anclaje = part.get("anclaje", 0) + bone_pos = part.get("bone_pos") or (0.0, 0.0, 0.0) + padre_idx = pila[-1] if pila else None + huesos.append({"idx": i, "bone_id": bone_id, "anclaje": anclaje, + "pos": bone_pos, "padre_idx": padre_idx, + "_blob": part.get("_blob", b"")}) + pila.append(i) + for _ in range(anclaje): + if pila: + pila.pop() + return huesos + + +def _padre_bone_id(bt3_parts: list, part_idx: int) -> int: + """Retorna el bone_id del padre de la parte en la jerarquía BT3.""" + pila = [] + for i, part in enumerate(bt3_parts): + padre = pila[-1] if pila else None + if i == part_idx: + if padre is not None: + return bt3_parts[padre].get("bone_id", 0) + return 0 + pila.append(i) + for _ in range(part.get("anclaje", 0)): + if pila: + pila.pop() + return 0 + + +def _get_depth(huesos, idx: int) -> int: + d, cur = 0, idx + while huesos[cur]["padre_idx"] is not None: + cur = huesos[cur]["padre_idx"] + d += 1 + if d > len(huesos): + break + return d + + +def _build_ttt_bones(bt3_parts: list) -> bytes: + print(" [huesos] Construyendo jerarquía desde partes BT3...") + huesos = _read_bones_from_parts(bt3_parts) + if not huesos: + print(" [huesos] Sin huesos.") + return b"" + print(f" [huesos] {len(huesos)} huesos. Calculando pop_levels...") + + depths = [_get_depth(huesos, i) for i in range(len(huesos))] + + out = bytearray() + for i, h in enumerate(huesos): + bone = bytearray(_BONE_BLOCK_SIZE) + + struct.pack_into("= 0x40: + bone[0x10:0x40] = blob[0x10:0x40] + if i != 0: + struct.pack_into("= 0x60: + bone[0x40:0x60] = blob[0x40:0x60] + else: + px, py, pz = h["pos"] + struct.pack_into("<4f", bone, 0x10, 0.0, px, py, pz) + if h["padre_idx"] is not None: + ppx, ppy, ppz = huesos[h["padre_idx"]]["pos"] + else: + ppx, ppy, ppz = 0.0, 0.0, 0.0 + struct.pack_into("<4f", bone, 0x20, 0.0, ppx, ppy, ppz) + struct.pack_into("<4f", bone, 0x30, 0.0, px-ppx, py-ppy, pz-ppz) + + out += bone + + print(f" [huesos] Bloque construido ({len(out)} bytes).") + return bytes(out) + + +def _build_ttt_header(bt3_blob, bone_count, num_materials, + bones_offset, mats_offset, weird_offset, + part_count, parts_index_offset, bbox): + hdr = bytearray(_HEADER_SIZE) + hdr[0:4] = b'pMdl' + struct.pack_into("= 0x28: + hdr[0x18:0x28] = bt3_blob[0x18:0x28] + struct.pack_into(" bytes: + from .optim_bone_ids import optimize_part_ids + + print("\n=== Iniciando conversión BT3 → TTT ===") + + print(f"[1/6] Calculando bounding box desde {sum(len(p.get('meshes',[])) for p in bt3_parts)} meshes...") + bbox = _compute_bbox(bt3_parts) + print(f" bbox = X:{bbox[0]:.4f} Y:{bbox[1]:.4f} Z:{bbox[2]:.4f}") + + print("[2/6] Agrupando meshes por material/hueso especial...") + grouped = _group_meshes(bt3_parts) + print(f" {len(grouped)} grupos") + + print("[3/6] Construyendo entradas de subpartes...") + all_part_entries = [] + part_flags = [] + for i, (key, flag, meshes) in enumerate(grouped): + total_v = sum(len(m.get("vertices", [])) for _, m in meshes) + print(f" Grupo {i+1}/{len(grouped)}: key={key} flag={flag:#04x} subpartes={len(meshes)} verts={total_v}") + entries = _build_ttt_part_entries(meshes, bbox, uv_map=uv_map) + all_part_entries.append(entries) + part_flags.append(flag) + + print(" Optimizando IDs de huesos (FF repeats)...") + all_part_entries = optimize_part_ids(all_part_entries) + + print(" Ensamblando partes TTT...") + assembled = [(p, f) for p, f in zip( + (_assemble_ttt_part(e) for e in all_part_entries), part_flags) if p] + ttt_parts = [p for p, _ in assembled] + ttt_flags = [f for _, f in assembled] + print(f" {len(ttt_parts)} partes TTT generadas.") + + print("[4/6] Construyendo bloque de huesos TTT...") + bones_blob = _build_ttt_bones(bt3_parts) + bone_count = len(bt3_parts) + + print("[5/6] Calculando offsets...") + num_mats = len(grouped) + mats_offset = _HEADER_SIZE + weird_offset = mats_offset + num_mats * _MAT_BLOCK_SIZE + bones_offset = weird_offset + _WEIRD_BLOCK_SIZE + parts_index_offset = bones_offset + len(bones_blob) + parts_index_size = len(ttt_parts) * _PART_INDEX_ENTRY + parts_start = parts_index_offset + parts_index_size + pad = (16 - parts_start % 16) % 16 + parts_start += pad + print(f" mats@{mats_offset:#x} weird@{weird_offset:#x} huesos@{bones_offset:#x} " + f"partsIdx@{parts_index_offset:#x} partes@{parts_start:#x}") + + print("[6/6] Ensamblando binario final...") + parts_data = [(p, 0xFFFF, i, f) for i, (p, f) in enumerate(zip(ttt_parts, ttt_flags))] + result = ( + _build_ttt_header(bt3_blob, bone_count, num_mats, + bones_offset, mats_offset, weird_offset, + len(ttt_parts), parts_index_offset, bbox) + + _build_materials_block(num_mats) + + _build_weird_block(bones_offset) + + bones_blob + + _build_parts_index(parts_data, parts_start) + + b'\x00' * pad + + b"".join(ttt_parts) + ) + print(f"=== Conversión completa: {len(result)} bytes ({len(result)/1024:.1f} KB) ===\n") + return result + +def _build_face_bone() -> bytes: + bone = bytearray(_BONE_BLOCK_SIZE) + struct.pack_into(" list: + from collections import defaultdict + mat_groups = defaultdict(list) + for part in parts: + for mesh in part.get("meshes", []): + mat_groups[mesh.get("tex_id", "?")].append((part, mesh)) + return [(tid, 0x06, meshes) for tid, meshes in mat_groups.items() if meshes] + + +def convert_bt3_face_to_ttt(face_blob: bytes, uv_map: dict = None, + bbox: tuple = None) -> bytes: + from .bt3_face_parser import parse_bt3_face + from .optim_bone_ids import optimize_part_ids + + subparts = parse_bt3_face(face_blob) + if not subparts: + return b'' + + fake_parts = [ + {'bone_id': 0x30, 'meshes': [{'tex_id': sp['tex_id'], 'vertices': sp['verts']}]} + for sp in subparts + ] + + if bbox is None: + bx = by = bz = 0.0 + for sp in subparts: + for v in sp['verts']: + bx = max(bx, abs(v['x'])); by = max(by, abs(v['y'])); bz = max(bz, abs(v['z'])) + bbox = (bx or 1.0, by or 1.0, bz or 1.0) + + grouped = _group_meshes_face(fake_parts) + all_entries = [_build_ttt_part_entries(m, bbox, uv_map=uv_map) for _, _, m in grouped] + flags = [f for _, f, _ in grouped] + all_entries = optimize_part_ids(all_entries) + + assembled = [(p, f) for p, f in zip((_assemble_ttt_part(e) for e in all_entries), flags) if p] + ttt_parts = [p for p, _ in assembled] + ttt_flags = [f for _, f in assembled] + + bones_blob = _build_face_bone() + num_mats = len(grouped) + mats_off = _HEADER_SIZE + weird_off = mats_off + num_mats * _MAT_BLOCK_SIZE + bones_off = weird_off + _WEIRD_BLOCK_SIZE + pi_off = bones_off + len(bones_blob) + ps = pi_off + len(ttt_parts) * _PART_INDEX_ENTRY + pad = (16 - ps % 16) % 16 + ps += pad + + parts_data = [(p, 0xFFFF, i, f) for i, (p, f) in enumerate(zip(ttt_parts, ttt_flags))] + hdr = bytearray(_build_ttt_header(face_blob, 1, num_mats, + bones_off, mats_off, weird_off, + len(ttt_parts), pi_off, bbox)) + hdr[0:4] = b'pMdF' + + return (bytes(hdr) + + _build_materials_block(num_mats) + + _build_weird_block(bones_off) + + bones_blob + + _build_parts_index(parts_data, ps) + + b'\x00' * pad + + b''.join(ttt_parts)) \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/bt3_to_ttt.py b/app_md/logic_swap/pmdl/bt3_to_ttt.py new file mode 100644 index 0000000..e423cc8 --- /dev/null +++ b/app_md/logic_swap/pmdl/bt3_to_ttt.py @@ -0,0 +1,429 @@ +import math +import struct +from collections import defaultdict + + +_BONE_FLAG = {1: b'\x01\x43\x00\x12', 2: b'\x01\xC3\x00\x12', + 3: b'\x01\x43\x01\x12', 4: b'\x01\xC3\x01\x12'} + +_TTT_VERSION = 6 +_HEADER_SIZE = 0xA0 +_MAT_BLOCK_SIZE = 0x20 +_WEIRD_BLOCK_SIZE = 0x20 +_BONE_BLOCK_SIZE = 0xA0 +_PART_INDEX_ENTRY = 0x20 +_CAMERA_BYTES = bytes([0x00,0x00,0x20,0x42, 0x00,0x00,0xF0,0x42]) + + +def _compute_bbox(parts: list) -> tuple: + bx = by = bz = 0.0 + for part in parts: + for mesh in part.get("meshes", []): + for v in mesh.get("vertices", []): + ax, ay, az = abs(v["x"]), abs(v["y"]), abs(v["z"]) + if ax > bx: bx = ax + if ay > by: by = ay + if az > bz: bz = az + return (bx or 1.0, by or 1.0, bz or 1.0) + + +def _quant(val, bbox): + v = int(round(val * 32767.0 / bbox)) + return max(-32768, min(32767, v)) + + +# Huesos BT3 con flag especial en TTT +_SPECIAL_BONE_FLAGS = { + 0x30: 0x06, + 0x40: 0x01, + 0x41: 0x02, + 0x42: 0x07, + 0x43: 0x08, +} +_SPECIAL_BONES = set(_SPECIAL_BONE_FLAGS.keys()) | {0x33} + + +def _group_meshes(parts: list) -> list: + """ + Retorna lista de (group_key, flag, [(part, mesh), ...]). + Huesos especiales forman grupo propio por bone_id, ignorando material. + El resto se agrupa por material, excluyendo meshes de huesos especiales. + """ + special_groups = defaultdict(list) + material_groups = defaultdict(list) + + for part in parts: + bid = part.get("bone_id", 0) + for mesh in part.get("meshes", []): + if bid in _SPECIAL_BONES: + special_groups[bid].append((part, mesh)) + else: + material_groups[mesh.get("tex_id", "?")].append((part, mesh)) + + result = [] + for bid, meshes in special_groups.items(): + flag = _SPECIAL_BONE_FLAGS.get(bid, 0x00) + result.append((f"bone_{bid:#04x}", flag, meshes)) + for tid, meshes in material_groups.items(): + if meshes: + result.append((tid, 0x00, meshes)) + return result + + +_W_TABLE = { + 0.0: 0x8000, 0.1: 0x740c, 0.2: 0x6719, 0.3: 0x5a26, 0.4: 0x4d33, + 0.5: 0x4040, 0.6: 0x344c, 0.7: 0x2759, 0.8: 0x1a66, 0.9: 0x0d73, 1.0: 0x0080, +} + +def _w_to_peso(w: float) -> int: + level = round(max(0.0, min(1.0, w)) * 10) / 10.0 + return _W_TABLE.get(level, max(0x0080, round((1.0 - w) * 0x8000))) + + +def _uv_byte(uv_f: float, coord: int, size: int) -> int: + import math + frac = uv_f - math.floor(uv_f) + if frac == 0.0 and uv_f > 0.0: + frac = 1.0 + pixel = coord + frac * size + return max(coord, min(min(coord + size - 1, 255), round(pixel))) + + +def _build_ttt_part_entries(meshes_with_parts: list, bbox: tuple, + bt3_parts: list = None, + uv_map: dict = None) -> list: + sub_entries = [] + for part, mesh in meshes_with_parts: + verts = mesh.get("vertices", []) + if not verts: + continue + bone_id = part.get("bone_id", 0) + bone_ids = [bone_id, 0, 0, 0] + vb = bytearray() + for v in verts: + peso = _w_to_peso(v.get("w", 0.0)) + vb += struct.pack(" bytes: + """Ensambla el binario de una parte TTT desde la lista de subpartes.""" + if not sub_entries: + return b"" + + index_raw = 4 + len(sub_entries) * 0x10 + index_size = index_raw + (16 - index_raw % 16) % 16 + data_offset = index_size + idx = bytearray() + idx += struct.pack(" list: + huesos, pila = [], [] + for i, part in enumerate(bt3_parts): + bone_id = part.get("bone_id", 0) + anclaje = part.get("anclaje", 0) + bone_pos = part.get("bone_pos") or (0.0, 0.0, 0.0) + padre_idx = pila[-1] if pila else None + huesos.append({"idx": i, "bone_id": bone_id, "anclaje": anclaje, + "pos": bone_pos, "padre_idx": padre_idx, + "_blob": part.get("_blob", b"")}) + pila.append(i) + for _ in range(anclaje): + if pila: + pila.pop() + return huesos + + +def _padre_bone_id(bt3_parts: list, part_idx: int) -> int: + """Retorna el bone_id del padre de la parte en la jerarquía BT3.""" + pila = [] + for i, part in enumerate(bt3_parts): + padre = pila[-1] if pila else None + if i == part_idx: + if padre is not None: + return bt3_parts[padre].get("bone_id", 0) + return 0 + pila.append(i) + for _ in range(part.get("anclaje", 0)): + if pila: + pila.pop() + return 0 + + +def _get_depth(huesos, idx: int) -> int: + d, cur = 0, idx + while huesos[cur]["padre_idx"] is not None: + cur = huesos[cur]["padre_idx"] + d += 1 + if d > len(huesos): + break + return d + + +def _build_ttt_bones(bt3_parts: list) -> bytes: + print(" [huesos] Construyendo jerarquía desde partes BT3...") + huesos = _read_bones_from_parts(bt3_parts) + if not huesos: + print(" [huesos] Sin huesos.") + return b"" + print(f" [huesos] {len(huesos)} huesos. Calculando pop_levels...") + + depths = [_get_depth(huesos, i) for i in range(len(huesos))] + + out = bytearray() + for i, h in enumerate(huesos): + bone = bytearray(_BONE_BLOCK_SIZE) + + struct.pack_into("= 0x40: + bone[0x10:0x40] = blob[0x10:0x40] + if i != 0: + struct.pack_into("= 0x60: + bone[0x40:0x60] = blob[0x40:0x60] + else: + px, py, pz = h["pos"] + struct.pack_into("<4f", bone, 0x10, 0.0, px, py, pz) + if h["padre_idx"] is not None: + ppx, ppy, ppz = huesos[h["padre_idx"]]["pos"] + else: + ppx, ppy, ppz = 0.0, 0.0, 0.0 + struct.pack_into("<4f", bone, 0x20, 0.0, ppx, ppy, ppz) + struct.pack_into("<4f", bone, 0x30, 0.0, px-ppx, py-ppy, pz-ppz) + + out += bone + + print(f" [huesos] Bloque construido ({len(out)} bytes).") + return bytes(out) + + +def _build_ttt_header(bt3_blob, bone_count, num_materials, + bones_offset, mats_offset, weird_offset, + part_count, parts_index_offset, bbox): + hdr = bytearray(_HEADER_SIZE) + hdr[0:4] = b'pMdl' + struct.pack_into("= 0x28: + hdr[0x18:0x28] = bt3_blob[0x18:0x28] + struct.pack_into(" bytes: + from .optim_bone_ids import optimize_part_ids + + print("\n=== Iniciando conversión BT3 → TTT ===") + + print(f"[1/6] Calculando bounding box desde {sum(len(p.get('meshes',[])) for p in bt3_parts)} meshes...") + bbox = _compute_bbox(bt3_parts) + print(f" bbox = X:{bbox[0]:.4f} Y:{bbox[1]:.4f} Z:{bbox[2]:.4f}") + + print("[2/6] Agrupando meshes por material/hueso especial...") + grouped = _group_meshes(bt3_parts) + print(f" {len(grouped)} grupos") + + print("[3/6] Construyendo entradas de subpartes...") + all_part_entries = [] + part_flags = [] + for i, (key, flag, meshes) in enumerate(grouped): + total_v = sum(len(m.get("vertices", [])) for _, m in meshes) + print(f" Grupo {i+1}/{len(grouped)}: key={key} flag={flag:#04x} subpartes={len(meshes)} verts={total_v}") + entries = _build_ttt_part_entries(meshes, bbox, uv_map=uv_map) + all_part_entries.append(entries) + part_flags.append(flag) + + print(" Optimizando IDs de huesos (FF repeats)...") + all_part_entries = optimize_part_ids(all_part_entries) + + print(" Ensamblando partes TTT...") + assembled = [(p, f) for p, f in zip( + (_assemble_ttt_part(e) for e in all_part_entries), part_flags) if p] + ttt_parts = [p for p, _ in assembled] + ttt_flags = [f for _, f in assembled] + print(f" {len(ttt_parts)} partes TTT generadas.") + + print("[4/6] Construyendo bloque de huesos TTT...") + bones_blob = _build_ttt_bones(bt3_parts) + bone_count = len(bt3_parts) + + print("[5/6] Calculando offsets...") + num_mats = len(grouped) + mats_offset = _HEADER_SIZE + weird_offset = mats_offset + num_mats * _MAT_BLOCK_SIZE + bones_offset = weird_offset + _WEIRD_BLOCK_SIZE + parts_index_offset = bones_offset + len(bones_blob) + parts_index_size = len(ttt_parts) * _PART_INDEX_ENTRY + parts_start = parts_index_offset + parts_index_size + pad = (16 - parts_start % 16) % 16 + parts_start += pad + print(f" mats@{mats_offset:#x} weird@{weird_offset:#x} huesos@{bones_offset:#x} " + f"partsIdx@{parts_index_offset:#x} partes@{parts_start:#x}") + + print("[6/6] Ensamblando binario final...") + parts_data = [(p, 0xFFFF, i, f) for i, (p, f) in enumerate(zip(ttt_parts, ttt_flags))] + result = ( + _build_ttt_header(bt3_blob, bone_count, num_mats, + bones_offset, mats_offset, weird_offset, + len(ttt_parts), parts_index_offset, bbox) + + _build_materials_block(num_mats) + + _build_weird_block(bones_offset) + + bones_blob + + _build_parts_index(parts_data, parts_start) + + b'\x00' * pad + + b"".join(ttt_parts) + ) + print(f"=== Conversión completa: {len(result)} bytes ({len(result)/1024:.1f} KB) ===\n") + return result + +def _build_face_bone() -> bytes: + bone = bytearray(_BONE_BLOCK_SIZE) + struct.pack_into(" list: + from collections import defaultdict + mat_groups = defaultdict(list) + for part in parts: + for mesh in part.get("meshes", []): + mat_groups[mesh.get("tex_id", "?")].append((part, mesh)) + return [(tid, 0x06, meshes) for tid, meshes in mat_groups.items() if meshes] + + +def convert_bt3_face_to_ttt(face_blob: bytes, uv_map: dict = None, + bbox: tuple = None) -> bytes: + from .bt3_face_parser import parse_bt3_face + from .optim_bone_ids import optimize_part_ids + + subparts = parse_bt3_face(face_blob) + if not subparts: + return b'' + + fake_parts = [ + {'bone_id': 0x30, 'meshes': [{'tex_id': sp['tex_id'], 'vertices': sp['verts']}]} + for sp in subparts + ] + + if bbox is None: + bx = by = bz = 0.0 + for sp in subparts: + for v in sp['verts']: + bx = max(bx, abs(v['x'])); by = max(by, abs(v['y'])); bz = max(bz, abs(v['z'])) + bbox = (bx or 1.0, by or 1.0, bz or 1.0) + + grouped = _group_meshes_face(fake_parts) + all_entries = [_build_ttt_part_entries(m, bbox, uv_map=uv_map) for _, _, m in grouped] + flags = [f for _, f, _ in grouped] + all_entries = optimize_part_ids(all_entries) + + assembled = [(p, f) for p, f in zip((_assemble_ttt_part(e) for e in all_entries), flags) if p] + ttt_parts = [p for p, _ in assembled] + ttt_flags = [f for _, f in assembled] + + bones_blob = _build_face_bone() + num_mats = len(grouped) + mats_off = _HEADER_SIZE + weird_off = mats_off + num_mats * _MAT_BLOCK_SIZE + bones_off = weird_off + _WEIRD_BLOCK_SIZE + pi_off = bones_off + len(bones_blob) + ps = pi_off + len(ttt_parts) * _PART_INDEX_ENTRY + pad = (16 - ps % 16) % 16 + ps += pad + + parts_data = [(p, 0xFFFF, i, f) for i, (p, f) in enumerate(zip(ttt_parts, ttt_flags))] + hdr = bytearray(_build_ttt_header(face_blob, 1, num_mats, + bones_off, mats_off, weird_off, + len(ttt_parts), pi_off, bbox)) + hdr[0:4] = b'pMdF' + + return (bytes(hdr) + + _build_materials_block(num_mats) + + _build_weird_block(bones_off) + + bones_blob + + _build_parts_index(parts_data, ps) + + b'\x00' * pad + + b''.join(ttt_parts)) \ No newline at end of file diff --git a/app_md/logic_swap/pmdl/file_detector.py b/app_md/logic_swap/pmdl/file_detector.py new file mode 100644 index 0000000..f8b724e --- /dev/null +++ b/app_md/logic_swap/pmdl/file_detector.py @@ -0,0 +1,14 @@ +def detect_format(blob: bytes) -> str: + """ + Devuelve 'ttt' o 'bt3' según la firma del archivo. + pMdl / pMdF (M upper) → TTT + pmdl (all lower) → BT3 + """ + if len(blob) < 4: + return 'ttt' + magic = blob[0:4] + if magic in (b'pMdl', b'pMdF'): + return 'ttt' + if magic == b'pmdl': + return 'bt3' + return 'bt3' if blob[1] == 0x6D else 'ttt' diff --git a/app_md/logic_swap/pmdl/optim_bone_ids.py b/app_md/logic_swap/pmdl/optim_bone_ids.py new file mode 100644 index 0000000..a5ada36 --- /dev/null +++ b/app_md/logic_swap/pmdl/optim_bone_ids.py @@ -0,0 +1,40 @@ +def _get_col_state(prev_col_ids: list, col: int) -> int: + """Último ID válido (!=0xFF, !=0x00) visto en esa columna, o None.""" + for part_cols in reversed(prev_col_ids): + if col < len(part_cols): + val = part_cols[col] + if val not in (0x00, 0xFF): + return val + return None + + +def optimize_part_ids(ttt_parts_entries: list) -> list: + # Estado de las 4 columnas: último ID válido visto + col_state = [None, None, None, None] + result = [] + + for part_entries in ttt_parts_entries: + new_part = [] + for sub in part_entries: + bone_ids = list(sub["bone_ids"]) # 4 bytes, padding 0x00 + bone_count = sub["bone_count"] + new_ids = list(bone_ids) + + for col in range(4): + raw = bone_ids[col] + if col >= bone_count: + # Slot vacío, no afecta estado ni se optimiza + new_ids[col] = 0x00 + continue + if raw == 0x00: + # Slot vacío activo (no debería pasar si bone_count es correcto) + continue + if col_state[col] is not None and raw == col_state[col]: + new_ids[col] = 0xFF + else: + col_state[col] = raw # actualizar estado con nuevo ID válido + + new_part.append({**sub, "bone_ids": new_ids}) + result.append(new_part) + + return result diff --git a/app_md/logic_swap/pmdl/parser.py b/app_md/logic_swap/pmdl/parser.py new file mode 100644 index 0000000..7b0f698 --- /dev/null +++ b/app_md/logic_swap/pmdl/parser.py @@ -0,0 +1,139 @@ +import struct +import os + +MESH_START_OFFSET = 0x68 +VERT_SIZE = 48 + + +def _ru8(b, o): return b[o] +def _ru32(b, o): return struct.unpack_from(" len(part_blob): + return None, 0 + if part_blob[index + 1] != 0x80: + return None, 0 + num = part_blob[index + 2] * 16 + if index + 4 + num + 3 > len(part_blob): + return None, 0 + num2 = part_blob[index + 4 + num + 2] * 16 + num3 = 8 + num + num2 + 4 + if index + num3 > len(part_blob): + return None, 0 + return bytes(part_blob[index: index + num3]), index + num3 + + +def _read_verts(part_blob, v_start, vc): + verts = [] + for v in range(vc): + base = v_start + v * VERT_SIZE + if base + VERT_SIZE > len(part_blob): + break + verts.append({ + "x": _rf32(part_blob, base), + "y": _rf32(part_blob, base + 4), + "z": _rf32(part_blob, base + 8), + "w": _rf32(part_blob, base + 12), + "nx": _rf32(part_blob, base + 16), + "ny": _rf32(part_blob, base + 20), + "nz": _rf32(part_blob, base + 24), + "uvx": _rf32(part_blob, base + 32), + "uvy": _rf32(part_blob, base + 36), + }) + return verts + + +def _parse_meshes(part_blob): + meshes = [] + index = MESH_START_OFFSET + + while True: + mesh_blob, new_index = _get_individual(part_blob, index) + if mesh_blob is None: + break + + tex_id = mesh_blob[20:28].hex().upper() if len(mesh_blob) >= 28 else "?" + raw_shader = struct.unpack_from("= 42 else 14 + shader = max(0, (raw_shader - 14) // 128) + refl = bool(mesh_blob[116]) if len(mesh_blob) > 116 else False + + num = part_blob[index + 2] * 16 + vstart = 8 + num + vc = mesh_blob[vstart - 20] if vstart >= 20 else 0 + vs_abs = index + vstart + verts = _read_verts(part_blob, vs_abs, vc) if vc > 0 else [] + + meshes.append({ + "index": len(meshes), + "offset": index, + "tex_id": tex_id, + "shader": shader, + "reflective": refl, + "vertex_count": vc, + "vertices": verts, + "strips": [verts] if verts else [], + "_raw_blob": mesh_blob, + }) + index = new_index + + return meshes + + +def parse_part(blob, offset, part_idx): + if offset + 16 > len(blob): + return None + length = _ru32(blob, offset) + if length == 0 or offset + length > len(blob): + return None + + anclaje = _ru8(blob, offset + 4) + bone_id = _ru8(blob, offset + 10) + refl = bool(blob[offset + 12]) + part_blob = blob[offset: offset + length] + bone_pos = (_rf32(part_blob, 0x14), _rf32(part_blob, 0x18), _rf32(part_blob, 0x1C)) if length >= 0x20 else None + + if length <= 64: + return { + "index": part_idx, "offset": offset, "length": length, + "bone_id": bone_id, "anclaje": anclaje, "reflective": refl, + "bone_pos": bone_pos, "meshes": [], "total_verts": 0, + "_blob": part_blob, + } + + meshes = _parse_meshes(part_blob) + total_verts = sum(m["vertex_count"] for m in meshes) + + return { + "index": part_idx, "offset": offset, "length": length, + "bone_id": bone_id, "anclaje": anclaje, "reflective": refl, + "bone_pos": bone_pos, "meshes": meshes, "total_verts": total_verts, + "_blob": part_blob, + } + + +def parse_bt3(blob: bytes) -> tuple: + """ + Parsea un blob BT3 completo. + Devuelve (header_dict, parts_list). + """ + magic = blob[0:4].decode("ascii", errors="replace") + + header = { + "magic": magic, + "bone_count": blob[0x08], + "parts_start": struct.unpack_from("= len(blob) - 16: + break + p = parse_part(blob, cursor, len(parts)) + if p is None: + break + parts.append(p) + cursor += p["length"] + + return header, parts diff --git a/app_md/logic_swap/pmdl/vfx_pmdl_port.py b/app_md/logic_swap/pmdl/vfx_pmdl_port.py new file mode 100644 index 0000000..758d980 --- /dev/null +++ b/app_md/logic_swap/pmdl/vfx_pmdl_port.py @@ -0,0 +1,137 @@ +import struct +from PIL import Image + +from .parser import parse_bt3 +from .file_detector import detect_format +from .bt3_tex_reader import load_dbt, map_dbt_to_tex_ids +from .bt3_to_ttt import convert_bt3_to_ttt + + +_INDEX_SIZE = 0x400 +_PMDL_IDX = 0x0C +_DBT_IDX = 0x30 +_TEX_SIZE = 256 +_PAK_SIG_TTT = 0x000000FA + + +def _read_le_entry(data: bytes, idx_off: int): + start = struct.unpack_from(" start else b"" + + +def _parse_vfx_subpak(data: bytes): + pmdl = _read_le_entry(data, _PMDL_IDX) + dbt = _read_le_entry(data, _DBT_IDX) + return pmdl, dbt + + +def _scale_to_256(img: Image.Image) -> Image.Image: + if img.size == (_TEX_SIZE, _TEX_SIZE): + return img + return img.resize((_TEX_SIZE, _TEX_SIZE), Image.NEAREST) + + +def _build_uv_map(tex_id: str) -> dict: + return {tex_id: {"x": 0, "y": 0, "w": _TEX_SIZE, "h": _TEX_SIZE}} + + +def _pil_to_atex_256(img: Image.Image) -> bytes: + from ..swap_vfx import build_atex, _pil_to_atex_data + idx_b, pal_b = _pil_to_atex_data(img.convert("RGBA"), _TEX_SIZE, _TEX_SIZE, flip=False) + return build_atex([(_TEX_SIZE, _TEX_SIZE, idx_b, pal_b)]) + + +def _build_ttt_subpak(pmdl_ttt: bytes, atex: bytes) -> bytes: + # offsets físicos: índice = 0x400 bytes, luego pmdl, luego atex + pmdl_start = _INDEX_SIZE + pmdl_end = pmdl_start + len(pmdl_ttt) + atex_start = pmdl_end + atex_end = atex_start + len(atex) + + idx = bytearray(_INDEX_SIZE) + + # firma BE + struct.pack_into(">I", idx, 0x00, _PAK_SIG_TTT) + + # 0x04 y 0x08: pmdl_start en BE + struct.pack_into(">I", idx, 0x04, pmdl_start) + struct.pack_into(">I", idx, 0x08, pmdl_start) + + # 0x0C: pmdl_start, 0x10: pmdl_end + struct.pack_into(">I", idx, 0x0C, pmdl_start) + struct.pack_into(">I", idx, 0x10, pmdl_end) + + # 0x14 a 0x2C: repetir pmdl_end (relleno entre pmdl y atex) + for off in range(0x14, 0x30, 4): + struct.pack_into(">I", idx, off, pmdl_end) + + # 0x30: atex_start, 0x34: atex_end + struct.pack_into(">I", idx, 0x30, atex_start) + struct.pack_into(">I", idx, 0x34, atex_end) + + # 0x38 a 0x3F0: repetir atex_end + for off in range(0x38, 0x3F0, 4): + struct.pack_into(">I", idx, off, atex_end) + + # 0x3F0 a 0x400: padding de 00 (ya está por defecto en bytearray) + + return bytes(idx) + pmdl_ttt + atex + + +def port_vfx_subpak_bt3_to_ttt(subpak_data: bytes) -> bytes: + pmdl_blob, dbt_blob = _parse_vfx_subpak(subpak_data) + + if not pmdl_blob or not dbt_blob: + raise ValueError("vfx subpak: could not read pmdl or dbt entries") + + if detect_format(pmdl_blob) != "bt3": + raise ValueError("vfx subpak: pmdl is not a valid BT3 model") + + _, parts = parse_bt3(pmdl_blob) + if not parts: + raise ValueError("vfx subpak: no parts found in pmdl") + + # extraer tex_id de la primera (y única) textura del modelo + tex_id = None + for part in parts: + for mesh in part.get("meshes", []): + tex_id = mesh.get("tex_id") + if tex_id: + break + if tex_id: + break + + if not tex_id: + raise ValueError("vfx subpak: no tex_id found in pmdl meshes") + + # leer DBT desde bytes usando archivo temporal + import tempfile, os + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".dbt") + try: + tmp.write(dbt_blob); tmp.flush(); tmp.close() + entries, raw_data, tbl_offset = load_dbt(tmp.name) + finally: + try: os.unlink(tmp.name) + except: pass + + mapped = map_dbt_to_tex_ids(entries, [tex_id], raw_data=raw_data, table_offset=tbl_offset) + tex_img = mapped.get(tex_id) + + if tex_img is None: + # fallback: tomar la primera entrada válida + for e in entries: + if e.get("image") is not None: + tex_img = e["image"] + break + + if tex_img is None: + raise ValueError("vfx subpak: could not extract texture from dbt") + + tex_img_256 = _scale_to_256(tex_img) + uv_map = _build_uv_map(tex_id) + + pmdl_ttt = convert_bt3_to_ttt(pmdl_blob, parts, uv_map=uv_map) + atex = _pil_to_atex_256(tex_img_256) + + return _build_ttt_subpak(pmdl_ttt, atex) \ No newline at end of file diff --git a/app_md/logic_swap/src/attack.txt b/app_md/logic_swap/src/attack.txt new file mode 100644 index 0000000..6611649 --- /dev/null +++ b/app_md/logic_swap/src/attack.txt @@ -0,0 +1,108 @@ +ATAQUE 1: +261_skill_003_f_in +262_skill_003_f_charge +263_skill_003_f_fire_in +264_skill_003_f_fire_loop +265_skill_003_f_close_up_in +266_skill_003_f_close_up_loop +267_skill_003_f_out +268_skill_003_u_in +269_skill_003_u_charge +270_skill_003_u_fire_in +271_skill_003_u_fire_loop +272_skill_003_u_close_up_in +273_skill_003_u_close_up_loop +274_skill_003_u_out +275_skill_003_d_in +276_skill_003_d_charge +277_skill_003_d_fire_in +278_skill_003_d_fire_loop +279_skill_003_d_close_up_in +280_skill_003_d_close_up_loop +281_skill_003_d_out +282_skill_003_22 +283_skill_003_23 +284_skill_003_event_1 +285_skill_003_event_2 +286_skill_003_event_3 +287_skill_003_event_4 +288_skill_003_event_5 +289_skill_003_enemy_1 +290_skill_003_enemy_2 +291_skill_003_enemy_3 +292_skill_003_enemy_4 +293_skill_003_enemy_5 +294_skill_003_enemy_assault + +ATAQUE 2: +295_skill_004_f_in +296_skill_004_f_charge +297_skill_004_f_fire_in +298_skill_004_f_fire_loop +299_skill_004_f_close_up_in +300_skill_004_f_close_up_loop +301_skill_004_f_out +302_skill_004_u_in +303_skill_004_u_charge +304_skill_004_u_fire_in +305_skill_004_u_fire_loop +306_skill_004_u_close_up_in +307_skill_004_u_close_up_loop +308_skill_004_u_out +309_skill_004_d_in +310_skill_004_d_charge +311_skill_004_d_fire_in +312_skill_004_d_fire_loop +313_skill_004_d_close_up_in +314_skill_004_d_close_up_loop +315_skill_004_d_out +316_skill_004_22 +317_skill_004_23 +318_skill_004_event_1 +319_skill_004_event_2 +320_skill_004_event_3 +321_skill_004_event_4 +322_skill_004_event_5 +323_skill_004_enemy_1 +324_skill_004_enemy_2 +325_skill_004_enemy_3 +326_skill_004_enemy_4 +327_skill_004_enemy_5 +328_skill_004_enemy_assault + +ATAQUE 3: +329_skill_005_f_in +330_skill_005_f_charge +331_skill_005_f_fire_in +332_skill_005_f_fire_loop +333_skill_005_f_close_up_in +334_skill_005_f_close_up_loop +335_skill_005_f_out +336_skill_005_u_in +337_skill_005_u_charge +338_skill_005_u_fire_in +339_skill_005_u_fire_loop +340_skill_005_u_close_up_in +341_skill_005_u_close_up_loop +342_skill_005_u_out +343_skill_005_d_in +344_skill_005_d_charge +345_skill_005_d_fire_in +346_skill_005_d_fire_loop +347_skill_005_d_close_up_in +348_skill_005_d_close_up_loop +349_skill_005_d_out +350_skill_005_22 +351_skill_005_23 +352_skill_005_event_1 +353_skill_005_event_2 +354_skill_005_event_3 +355_skill_005_event_4 +356_skill_005_event_5 +357_skill_005_enemy_1 +358_skill_005_enemy_2 +359_skill_005_enemy_3 +360_skill_005_enemy_4 +361_skill_005_enemy_5 +362_skill_005_enemy_assault +363_skill_005_start diff --git a/app_md/logic_swap/swap_attacks.py b/app_md/logic_swap/swap_attacks.py new file mode 100644 index 0000000..047d8dd --- /dev/null +++ b/app_md/logic_swap/swap_attacks.py @@ -0,0 +1,399 @@ +import os + +from PyQt5.QtWidgets import ( + QLabel, QPushButton, QFileDialog, QCheckBox, + QVBoxLayout, QHBoxLayout, QComboBox, QMessageBox, QDialog, QFrame +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QDragEnterEvent, QDropEvent + +from .swap_context import _resolver_ttt_ctx, _resolver_bt3_ctx +from .swap_logic import swap_habilidad, procesar_swap_ataques, procesar_swap_ataques_params_only + + +class VarHolder: + def __init__(self, val): + self.val = val + def get(self): return self.val + def set(self, val): self.val = val + + +class SelectPlatformDialog(QDialog): + def __init__(self, parent=None, title="Select platform"): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setFixedSize(340, 120) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.selected_mode = None + + layout = QVBoxLayout(self) + layout.setSpacing(12) + layout.setContentsMargins(20, 14, 20, 14) + + label = QLabel("What platform is the character from?") + label.setAlignment(Qt.AlignCenter) + label.setWordWrap(True) + layout.addWidget(label) + + btn_layout = QHBoxLayout() + btn_layout.setSpacing(12) + btn_ttt = QPushButton("TTT") + btn_ttt.setFixedHeight(32) + btn_ttt.clicked.connect(lambda: self._select("TTT")) + btn_bt3 = QPushButton("BT3") + btn_bt3.setFixedHeight(32) + btn_bt3.clicked.connect(lambda: self._select("BT3")) + btn_layout.addWidget(btn_ttt) + btn_layout.addWidget(btn_bt3) + layout.addLayout(btn_layout) + + def _select(self, mode): + self.selected_mode = mode + self.accept() + + +def _sep(): + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + return line + + +class CharDropPanel(QFrame): + char_selected = pyqtSignal(str, str) + + _STYLE_IDLE = "border: 2px dashed #555; border-radius: 6px; background: transparent;" + _STYLE_HOVER = "border: 2px dashed #5588cc; border-radius: 6px; background: transparent;" + _STYLE_FILLED = "border: 2px solid #5588cc; border-radius: 6px; background: transparent;" + + def __init__(self, role: str, parent=None): + super().__init__(parent) + self.role = role + self._last_dir = "" + self.setAcceptDrops(True) + self.setCursor(Qt.PointingHandCursor) + self.setStyleSheet(self._STYLE_IDLE) + + layout = QVBoxLayout(self) + layout.setSpacing(4) + layout.setContentsMargins(10, 10, 10, 10) + + self._lbl_role = QLabel(role) + self._lbl_role.setAlignment(Qt.AlignCenter) + self._lbl_role.setStyleSheet("font-weight: bold; font-size: 12px; color: #aaa; border: none;") + + self._lbl_icon = QLabel("⬇") + self._lbl_icon.setAlignment(Qt.AlignCenter) + self._lbl_icon.setStyleSheet("font-size: 20px; color: #555; border: none;") + + self._lbl_name = QLabel("Drop or click to select") + self._lbl_name.setAlignment(Qt.AlignCenter) + self._lbl_name.setWordWrap(True) + self._lbl_name.setStyleSheet("font-size: 12px; color: #777; border: none;") + self._lbl_name.setMinimumHeight(30) + + layout.addWidget(self._lbl_role) + layout.addWidget(self._lbl_icon) + layout.addWidget(self._lbl_name) + + def set_char(self, path, mode): + self._lbl_icon.setText("✓") + self._lbl_icon.setStyleSheet("font-size: 20px; color: #5588cc; border: none;") + self._lbl_name.setText(f"[{mode}] {os.path.basename(path)}") + self._lbl_name.setStyleSheet("font-size: 12px; border: none;") + self.setStyleSheet(self._STYLE_FILLED) + self._last_dir = os.path.dirname(path) if os.path.isfile(path) else path + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self._open_dialog() + + def _try_resolve(self, path): + if os.path.isdir(path): + ctx = _resolver_ttt_ctx(path) + if ctx: return ctx, "TTT", path + if path.lower().endswith(".pak"): + ctx = _resolver_bt3_ctx(path) + if ctx: return ctx, "BT3", path + return None, None, path + + def _open_dialog(self): + parent = self.window() + dialog = SelectPlatformDialog(parent, f"Select {self.role}") + if dialog.exec_() != QDialog.Accepted: + return + mode = dialog.selected_mode + if mode == "TTT": + path = QFileDialog.getExistingDirectory(parent, f"{self.role} folder (TTT)", self._last_dir) + if not path: return + ctx = _resolver_ttt_ctx(path) + if not ctx: + QMessageBox.critical(parent, "Error", "The folder does not contain 1_p, 2_anims and 3_effects.") + return + else: + path, _ = QFileDialog.getOpenFileName(parent, f"{self.role} .pak file (BT3)", self._last_dir, "PAK (*.pak)") + if not path: return + ctx = _resolver_bt3_ctx(path) + if not ctx: + QMessageBox.critical(parent, "Error", "Could not find '*_1p', '*_anm' and '*_eff' next to the .pak.") + return + self.set_char(path, mode) + self.char_selected.emit(path, mode) + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + self.setStyleSheet(self._STYLE_HOVER) + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event): + filled = "✓" in self._lbl_icon.text() + self.setStyleSheet(self._STYLE_FILLED if filled else self._STYLE_IDLE) + + def dropEvent(self, event: QDropEvent): + urls = event.mimeData().urls() + if not urls: return + path = urls[0].toLocalFile() + if not path: return + parent = self.window() + ctx, mode, resolved = self._try_resolve(path) + if ctx is None: + filled = "✓" in self._lbl_icon.text() + self.setStyleSheet(self._STYLE_FILLED if filled else self._STYLE_IDLE) + QMessageBox.critical(parent, "Error", + "Drop a TTT character folder or a BT3 .pak file.\n" + "TTT: folder containing 1_p, 2_anims and 3_effects.\n" + "BT3: .pak file next to *_1p, *_anm and *_eff folders.") + return + self.set_char(resolved, mode) + self.char_selected.emit(resolved, mode) + + +class SwapApp(QDialog): + def __init__(self, parent_tool=None): + super().__init__(parent_tool) + self._parent_tool = parent_tool + + self.setWindowTitle("Swap Attacks") + self.setFixedWidth(430) + self.setWindowFlags(Qt.Window | Qt.WindowTitleHint | Qt.CustomizeWindowHint | + Qt.WindowCloseButtonHint) + + if parent_tool: + self.setWindowIcon(parent_tool.windowIcon()) + self.setFont(parent_tool.font()) + + self._ctx_donor = {} + self._ctx_receptor = {} + + self._donor_var = VarHolder("Habilidad 1") + self._receptor_var = VarHolder("Habilidad 1") + self._atk_donor_var = VarHolder("Ataque 1") + self._atk_receptor_var = VarHolder("Ataque 1") + + self._build_ui() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(10) + root.setContentsMargins(14, 12, 14, 14) + + # ── Character panels ───────────────────────────────────────────────── + char_row = QHBoxLayout() + char_row.setSpacing(0) + + self._panel_receptor = CharDropPanel("RECEPTOR") + self._panel_receptor.char_selected.connect(self._on_receptor_selected) + + arrow_col = QVBoxLayout() + arrow_col.setAlignment(Qt.AlignCenter) + arrow_lbl = QLabel("←") + arrow_lbl.setFixedWidth(36) + arrow_lbl.setAlignment(Qt.AlignCenter) + arrow_lbl.setStyleSheet("font-size: 20px; font-weight: bold; color: #5588cc;") + arrow_sub = QLabel("receives") + arrow_sub.setFixedWidth(36) + arrow_sub.setAlignment(Qt.AlignCenter) + arrow_sub.setStyleSheet("font-size: 9px; color: #555;") + arrow_col.addStretch() + arrow_col.addWidget(arrow_lbl) + arrow_col.addWidget(arrow_sub) + arrow_col.addStretch() + + self._panel_donor = CharDropPanel("DONOR") + self._panel_donor.char_selected.connect(self._on_donor_selected) + + char_row.addWidget(self._panel_receptor, 5) + char_row.addLayout(arrow_col, 0) + char_row.addWidget(self._panel_donor, 5) + root.addLayout(char_row) + + # ── Options ────────────────────────────────────────────────────────── + opts_row = QHBoxLayout() + opts_row.setSpacing(16) + opts_row.setContentsMargins(4, 0, 4, 0) + + self.chk_effect = QCheckBox("Include effect") + self.chk_effect.setChecked(True) + self.chk_effect.setFocusPolicy(Qt.NoFocus) + + self.chk_cman = QCheckBox("Include cman") + self.chk_cman.setChecked(True) + self.chk_cman.setFocusPolicy(Qt.NoFocus) + + opts_row.addStretch() + opts_row.addWidget(self.chk_effect) + opts_row.addWidget(self.chk_cman) + opts_row.addStretch() + root.addLayout(opts_row) + + root.addWidget(_sep()) + + # ── Skills ─────────────────────────────────────────────────────────── + lbl_skill = QLabel("Skills") + lbl_skill.setStyleSheet("font-weight: bold;") + root.addWidget(lbl_skill) + + skill_row = QHBoxLayout() + skill_row.setSpacing(6) + + self.combo_receptor = QComboBox() + self.combo_receptor.addItems(["Skill 1", "Skill 2"]) + self.combo_receptor.currentTextChanged.connect(lambda v: self._receptor_var.set( + "Habilidad 1" if v == "Skill 1" else "Habilidad 2")) + + arr_skill = QLabel("←") + arr_skill.setFixedWidth(24) + arr_skill.setAlignment(Qt.AlignCenter) + arr_skill.setStyleSheet("font-size: 14px; color: #5588cc;") + + self.combo_donor = QComboBox() + self.combo_donor.addItems(["Skill 1", "Skill 2"]) + self.combo_donor.currentTextChanged.connect(lambda v: self._donor_var.set( + "Habilidad 1" if v == "Skill 1" else "Habilidad 2")) + + skill_row.addWidget(self.combo_receptor, 5) + skill_row.addWidget(arr_skill, 0) + skill_row.addWidget(self.combo_donor, 5) + root.addLayout(skill_row) + + skill_btns = QHBoxLayout() + skill_btns.setSpacing(6) + self.btn_swap_skill = QPushButton("Swap Skill") + self.btn_swap_skill.setFixedHeight(28) + self.btn_swap_skill.clicked.connect(lambda: self._run_swap_skill(False)) + self.btn_skill_params = QPushButton("Params only") + self.btn_skill_params.setFixedHeight(28) + self.btn_skill_params.clicked.connect(lambda: self._run_swap_skill(True)) + skill_btns.addWidget(self.btn_swap_skill, 3) + skill_btns.addWidget(self.btn_skill_params, 2) + root.addLayout(skill_btns) + + root.addWidget(_sep()) + + # ── Attacks ────────────────────────────────────────────────────────── + lbl_atk = QLabel("Attacks") + lbl_atk.setStyleSheet("font-weight: bold;") + root.addWidget(lbl_atk) + + atk_row = QHBoxLayout() + atk_row.setSpacing(6) + + self.combo_atk_receptor = QComboBox() + self.combo_atk_receptor.addItems(["Attack 1", "Attack 2", "Attack 3"]) + self.combo_atk_receptor.currentTextChanged.connect(lambda v: self._atk_receptor_var.set( + {"Attack 1": "Ataque 1", "Attack 2": "Ataque 2", "Attack 3": "Ataque 3"}[v])) + + arr_atk = QLabel("←") + arr_atk.setFixedWidth(24) + arr_atk.setAlignment(Qt.AlignCenter) + arr_atk.setStyleSheet("font-size: 14px; color: #5588cc;") + + self.combo_atk_donor = QComboBox() + self.combo_atk_donor.addItems(["Attack 1", "Attack 2", "Attack 3"]) + self.combo_atk_donor.currentTextChanged.connect(lambda v: self._atk_donor_var.set( + {"Attack 1": "Ataque 1", "Attack 2": "Ataque 2", "Attack 3": "Ataque 3"}[v])) + + atk_row.addWidget(self.combo_atk_receptor, 5) + atk_row.addWidget(arr_atk, 0) + atk_row.addWidget(self.combo_atk_donor, 5) + root.addLayout(atk_row) + + atk_btns = QHBoxLayout() + atk_btns.setSpacing(6) + self.btn_swap_atk = QPushButton("Swap Attack") + self.btn_swap_atk.setFixedHeight(28) + self.btn_swap_atk.clicked.connect(self._run_swap_attack) + self.btn_atk_params = QPushButton("Params only") + self.btn_atk_params.setFixedHeight(28) + self.btn_atk_params.clicked.connect(self._run_swap_attack_params) + atk_btns.addWidget(self.btn_swap_atk, 3) + atk_btns.addWidget(self.btn_atk_params, 2) + root.addLayout(atk_btns) + + # ── context slots ──────────────────────────────────────────────────────── + + def _on_donor_selected(self, path, mode): + self._ctx_donor = _resolver_ttt_ctx(path) if mode == "TTT" else _resolver_bt3_ctx(path) + + def _on_receptor_selected(self, path, mode): + self._ctx_receptor = _resolver_ttt_ctx(path) if mode == "TTT" else _resolver_bt3_ctx(path) + + # ── dialogs ────────────────────────────────────────────────────────────── + + def _ok(self, text): + QMessageBox.information(self, "Done", text) + + def _err(self, text): + QMessageBox.critical(self, "Error", text) + + # ── swap runners ───────────────────────────────────────────────────────── + + def _run_swap_skill(self, params_only): + if not self._ctx_donor or not self._ctx_receptor: + self._err("Select donor and receptor first.") + return + try: + result = swap_habilidad( + params_only, self._donor_var, self._receptor_var, + self._ctx_donor, self._ctx_receptor, + include_effect=self.chk_effect.isChecked() + ) + if result: self._ok(result) + except Exception as e: + self._err(str(e)) + + def _run_swap_attack(self): + if not self._ctx_donor or not self._ctx_receptor: + self._err("Select donor and receptor first.") + return + try: + result = procesar_swap_ataques( + self._atk_donor_var.get(), self._atk_receptor_var.get(), + self._ctx_donor, self._ctx_receptor, + include_effect=self.chk_effect.isChecked(), + include_cman=self.chk_cman.isChecked() + ) + if result: self._ok(result) + except Exception as e: + self._err(str(e)) + + def _run_swap_attack_params(self): + if not self._ctx_donor or not self._ctx_receptor: + self._err("Select donor and receptor first.") + return + try: + result = procesar_swap_ataques_params_only( + self._atk_donor_var.get(), self._atk_receptor_var.get(), + self._ctx_donor, self._ctx_receptor + ) + if result: self._ok(result) + except Exception as e: + self._err(str(e)) + + def closeEvent(self, event): + if self._parent_tool: + self._parent_tool.setEnabled(True) + event.accept() \ No newline at end of file diff --git a/app_md/logic_swap/swap_context.py b/app_md/logic_swap/swap_context.py new file mode 100644 index 0000000..44f6fcc --- /dev/null +++ b/app_md/logic_swap/swap_context.py @@ -0,0 +1,51 @@ +import os +from .swap_data import DIR_1P, DIR_ANIMS, DIR_EFF + + +def _resolver_ttt_ctx(carpeta_base): + p1 = os.path.join(carpeta_base, DIR_1P) + p2 = os.path.join(carpeta_base, DIR_ANIMS) + p3 = os.path.join(carpeta_base, DIR_EFF) + if all(os.path.isdir(p) for p in (p1, p2, p3)): + return {"mode": "TTT", "base": carpeta_base, "1_p": p1, "2_anims": p2, "3_effects": p3} + return {} + + +def _resolver_bt3_ctx(pak_path): + if not pak_path.lower().endswith(".pak"): + return {} + base_dir = os.path.dirname(pak_path) + c1p = c_anm = c_eff = None + try: + for entry in os.listdir(base_dir): + full = os.path.join(base_dir, entry) + if not os.path.isdir(full): + continue + low = entry.lower() + if low.endswith("_1p") and c1p is None: + c1p = full + elif low.endswith("_anm") and c_anm is None: + c_anm = full + elif low.endswith("_eff") and c_eff is None: + c_eff = full + except Exception: + return {} + + if all((c1p, c_anm, c_eff)): + return { + "mode": "BT3", + "base": base_dir, + "pak": pak_path, + "1_p": c1p, + "2_anims": c_anm, + "3_effects": c_eff, + } + return {} + + +def _formatear_label_ctx(titulo, ctx, seleccionado): + if not ctx: + return f"{titulo}: ---" + modo = ctx.get("mode", "?") + nombre = os.path.basename(seleccionado) + return f"{titulo} [{modo}]: {nombre}" diff --git a/app_md/logic_swap/swap_data.py b/app_md/logic_swap/swap_data.py new file mode 100644 index 0000000..f2db6e8 --- /dev/null +++ b/app_md/logic_swap/swap_data.py @@ -0,0 +1,178 @@ +from pathlib import Path + +BASE_DICC = Path(__file__).resolve().parent / "src" + +DIR_ANIMS = "2_anims" +DIR_EFF = "3_effects" +DIR_1P = "1_p" +DIR_COMMON = "05_effect_common" +DIR_1COMMON = "1_effect_common" +DIR_SKILL_CAM = "07_skill_cameras" +DIR_CMAN = "1_cman" + + +CAMARAS_POR_ATAQUE = { + "Ataque 1": "00_camera.cma", + "Ataque 2": "01_camera.cma", + "Ataque 3": "02_camera.cma" +} + +MINICAM_OFFSETS = { + "Ataque 1": 0x00, + "Ataque 2": 0x24, + "Ataque 3": 0x48, + "Ataque 3 extra": 0x6C +} +TAMANO_MINICAM = 0x24 + +NOMBRES_OFFSETS = { + "Habilidad 1": 0x00, + "Habilidad 2": 0x20, + "Ataque 1": 0x40, + "Ataque 2": 0x60, + "Ataque 3": 0x80 +} +TAMANO_NOMBRE = 0x20 + +DATOS_ATAQUES = { + "Ataque 1": { + "anims": list(range(261, 295)), + "effect": "02_skill_003.pak", + "offsets_4b": [ + 0x0, 0xC, 0x18, 0x24, 0x30, 0x3C, 0x48, 0x54, 0x60, 0x6C, + 0x78, 0x84, 0x16C, 0x178, 0x184, 0x190, 0x19C, 0x1A8, 0x1B4, + 0x1C0, 0x1CC, 0x1D8, 0x1E4, 0x1F0, 0x1FC, 0x214, 0x22C, 0x238 + ], + "offsets_2b": [0x208, 0x20E], + "offsets_1b": [ + 0x90, 0x93, 0x96, 0x99, 0x9C, 0x9F, 0xA2, 0xA5, 0xA8, 0xAB, + 0xAE, 0xB1, 0xB4, 0xB7, 0xBA, 0xBD, 0xC0, 0xC3, 0xC6, 0xC9, + 0xCC, 0xCF, 0x135, 0x138, 0x13B, 0x13E, 0x141, 0x144, 0x147, + 0x14A, 0x14D, 0x150, 0x153, 0x156, 0x159, 0x15C, 0x15F, 0x162, + 0x165, 0x168, 0x220, 0x223, 0x226, 0x229 + ] + }, + "Ataque 2": { + "anims": list(range(295, 329)), + "effect": "03_skill_004.pak", + "offsets_4b": [ + 0x4, 0x10, 0x1C, 0x28, 0x34, 0x40, 0x4C, 0x58, 0x64, 0x70, + 0x7C, 0x88, 0x170, 0x17C, 0x188, 0x194, 0x1A0, 0x1AC, 0x1B8, + 0x1C4, 0x1D0, 0x1DC, 0x1E8, 0x1F4, 0x200, 0x218, 0x230, 0x23C + ], + "offsets_2b": [0x20A, 0x210], + "offsets_1b": [ + 0x91, 0x94, 0x97, 0x9A, 0x9D, 0xA0, 0xA3, 0xA6, 0xA9, 0xAC, + 0xAF, 0xB2, 0xB5, 0xB8, 0xBB, 0xBE, 0xC1, 0xC4, 0xC7, 0xCA, + 0xCD, 0xD0, 0x136, 0x139, 0x13C, 0x13F, 0x142, 0x145, 0x148, + 0x14B, 0x14E, 0x151, 0x154, 0x157, 0x15A, 0x15D, 0x160, 0x163, + 0x166, 0x169, 0x221, 0x224, 0x227, 0x22A + ] + }, + "Ataque 3": { + "anims": list(range(329, 364)), + "effect": "04_skill_005.pak", + "offsets_4b": [ + 0x8, 0x14, 0x20, 0x2C, 0x38, 0x44, 0x50, 0x5C, 0x68, 0x74, + 0x80, 0x8C, 0x174, 0x180, 0x18C, 0x198, 0x1A4, 0x1B0, 0x1BC, + 0x1C8, 0x1D4, 0x1E0, 0x1EC, 0x1F8, 0x204, 0x21C, 0x234, 0x240 + ], + "offsets_2b": [0x20C, 0x212], + "offsets_1b": [ + 0x92, 0x95, 0x98, 0x9B, 0x9E, 0xA1, 0xA4, 0xA7, 0xAA, 0xAD, + 0xB0, 0xB3, 0xB6, 0xB9, 0xBC, 0xBF, 0xC2, 0xC5, 0xC8, 0xCB, + 0xCE, 0xD1, 0x137, 0x13A, 0x13D, 0x140, 0x143, 0x146, 0x149, + 0x14C, 0x14F, 0x152, 0x155, 0x158, 0x15B, 0x15E, 0x161, 0x164, + 0x167, 0x16A, 0x222, 0x225, 0x228, 0x22B + ] + } +} + +DATOS_ATAQUES_PARAMS_ONLY = { + "Ataque 1": { + "offsets_4b": [ + 0x0, 0xC, 0x18, 0x24, 0x30, 0x3C, 0x48, 0x54, 0x60, 0x6C, + 0x78, 0x84, 0x16C, 0x178, 0x184, 0x190, 0x19C, 0x1A8, 0x1B4, + 0x1C0, 0x1CC, 0x1D8, 0x1E4, 0x1F0, 0x1FC, 0x214, 0x22C, 0x238 + ], + "offsets_2b": [0x208, 0x20E], + "offsets_1b": [ + 0x90, 0x93, 0x96, 0x99, 0x9C, 0x9F, 0xA2, 0xA5, 0xA8, 0xAB, + 0xAE, 0xB1, 0xB4, 0xB7, 0xBA, 0xBD, 0xC0, 0xC3, 0xC6, 0xC9, + 0xCC, 0xCF, 0x135, 0x138, 0x13B, 0x13E, 0x141, 0x144, 0x147, + 0x14A, 0x14D, 0x150, 0x153, 0x156, 0x159, 0x15C, 0x15F, 0x162, + 0x165, 0x168, 0x220, 0x223, 0x226, 0x229 + ] + }, + "Ataque 2": { + "offsets_4b": [ + 0x4, 0x10, 0x1C, 0x28, 0x34, 0x40, 0x4C, 0x58, 0x64, 0x70, + 0x7C, 0x88, 0x170, 0x17C, 0x188, 0x194, 0x1A0, 0x1AC, 0x1B8, + 0x1C4, 0x1D0, 0x1DC, 0x1E8, 0x1F4, 0x200, 0x218, 0x230, 0x23C + ], + "offsets_2b": [0x20A, 0x210], + "offsets_1b": [ + 0x91, 0x94, 0x97, 0x9A, 0x9D, 0xA0, 0xA3, 0xA6, 0xA9, 0xAC, + 0xAF, 0xB2, 0xB5, 0xB8, 0xBB, 0xBE, 0xC1, 0xC4, 0xC7, 0xCA, + 0xCD, 0xD0, 0x136, 0x139, 0x13C, 0x13F, 0x142, 0x145, 0x148, + 0x14B, 0x14E, 0x151, 0x154, 0x157, 0x15A, 0x15D, 0x160, 0x163, + 0x166, 0x169, 0x221, 0x224, 0x227, 0x22A + ] + }, + "Ataque 3": { + "offsets_4b": [ + 0x8, 0x14, 0x20, 0x2C, 0x38, 0x44, 0x50, 0x5C, 0x68, 0x74, + 0x80, 0x8C, 0x174, 0x180, 0x18C, 0x198, 0x1A4, 0x1B0, 0x1BC, + 0x1C8, 0x1D4, 0x1E0, 0x1EC, 0x1F8, 0x204, 0x21C, 0x234, 0x240 + ], + "offsets_2b": [0x20C, 0x212], + "offsets_1b": [ + 0x92, 0x95, 0x98, 0x9B, 0x9E, 0xA1, 0xA4, 0xA7, 0xAA, 0xAD, + 0xB0, 0xB3, 0xB6, 0xB9, 0xBC, 0xBF, 0xC2, 0xC5, 0xC8, 0xCB, + 0xCE, 0xD1, 0x137, 0x13A, 0x13D, 0x140, 0x143, 0x146, 0x149, + 0x14C, 0x14F, 0x152, 0x155, 0x158, 0x15B, 0x15E, 0x161, 0x164, + 0x167, 0x16A, 0x222, 0x225, 0x228, 0x22B + ] + } +} + + +def obtener_datos_habilidad(nombre): + if nombre == "Habilidad 1": + return { + "anim": "259_skill_001", + "eff": "00_skill_001", + "offsets_4b": [0x08], + "offsets_1b": [ + 0x10, 0x30, 0x32, 0x34, 0x36, 0x38, 0x3A, 0x3C, 0x3E, 0x40, + 0x42, 0x44, 0x46, 0x48, 0x4A, 0x4C, 0x4E, 0x50, 0x52, 0x54, + 0x56, 0x58, 0x5A, 0x5C, 0x5E, 0x60, 0x62, 0x64, 0x66, 0x68, + 0x6A, 0x6C, 0x6E, 0x70, 0x72, 0x74, 0x76, 0x78, 0x7A, 0x7C, + 0x7E, 0x80, 0x82, 0x84, 0x86, 0x88, 0x8A, 0x8C, 0x8E, 0x90, + 0x92, 0x94, 0x96, 0x98, 0x9A, 0x9C, 0x9E, 0xA0, 0xA2, 0xA4, + 0xA6, 0xA8, 0xAA, 0xAC, 0xAE, 0xB0, 0xB2, 0xB4, 0xB6, 0xB8, + 0xBA, 0xBC, 0xBE, 0xC0, 0xC2, 0xC4, 0xC6, 0xC8, 0xCA, 0xCC, + 0xCE, 0xD0, 0xD2, 0xD4, 0xD6, 0xD8, 0xDA, 0xDC, 0xDE, 0xE0, + 0xE2, 0xE4, 0xE6, 0xE8, 0xEA, 0xEC, 0xEE, 0xF0, 0xF2, 0xF4, + 0xF6, 0xF8, 0xFA, 0xFC, 0xFE + ] + } + else: + return { + "anim": "260_skill_002", + "eff": "01_skill_002", + "offsets_4b": [0x0C], + "offsets_1b": [ + 0x12, 0x31, 0x33, 0x35, 0x37, 0x39, 0x3B, 0x3D, 0x3F, 0x41, + 0x43, 0x45, 0x47, 0x49, 0x4B, 0x4D, 0x4F, 0x51, 0x53, 0x55, + 0x57, 0x59, 0x5B, 0x5D, 0x5F, 0x61, 0x63, 0x65, 0x67, 0x69, + 0x6B, 0x6D, 0x6F, 0x71, 0x73, 0x75, 0x77, 0x79, 0x7B, 0x7D, + 0x7F, 0x81, 0x83, 0x85, 0x87, 0x89, 0x8B, 0x8D, 0x8F, 0x91, + 0x93, 0x95, 0x97, 0x99, 0x9B, 0x9D, 0x9F, 0xA1, 0xA3, 0xA5, + 0xA7, 0xA9, 0xAB, 0xAD, 0xAF, 0xB1, 0xB3, 0xB5, 0xB7, 0xB9, + 0xBB, 0xBD, 0xBF, 0xC1, 0xC3, 0xC5, 0xC7, 0xC9, 0xCB, 0xCD, + 0xCF, 0xD1, 0xD3, 0xD5, 0xD7, 0xD9, 0xDB, 0xDD, 0xDF, 0xE1, + 0xE3, 0xE5, 0xE7, 0xE9, 0xEB, 0xED, 0xEF, 0xF1, 0xF3, 0xF5, + 0xF7, 0xF9, 0xFB, 0xFD, 0xFF + ] + } \ No newline at end of file diff --git a/app_md/logic_swap/swap_logic.py b/app_md/logic_swap/swap_logic.py new file mode 100644 index 0000000..9275b6c --- /dev/null +++ b/app_md/logic_swap/swap_logic.py @@ -0,0 +1,455 @@ +import os +import shutil +from pathlib import Path + + +from .swap_data import ( + BASE_DICC, + CAMARAS_POR_ATAQUE, MINICAM_OFFSETS, TAMANO_MINICAM, + NOMBRES_OFFSETS, TAMANO_NOMBRE, + DATOS_ATAQUES, DATOS_ATAQUES_PARAMS_ONLY, + obtener_datos_habilidad +) +from .swap_vfx import convert_vfx_bt3_to_ttt, convert_vfx_ttt_to_bt3 +from .anim_converter import ( + encontrar_animacion, anim_ext_for_mode, get_dest_anim_path, + convert_anim_between_modes +) + + +def buscar_subcarpeta_fija(ruta_base, nombre): + ruta = os.path.join(ruta_base, nombre) + return ruta if os.path.isdir(ruta) else None + + +def get_cman_path(root_effects: str, mode: str, camera_filename: str): + if mode == "TTT": + return os.path.join(root_effects, "05_effect_common", "1_effect_common", "07_skill_cameras", "1_cman", camera_filename) + else: + return os.path.join(root_effects, "05_effect_common", "07_skill_cameras", camera_filename) + + +def encontrar_efecto(carpeta_eff, base_name): + ruta = os.path.join(carpeta_eff, base_name + ".pak") + return ruta if os.path.exists(ruta) else None + + +def cargar_animaciones_por_ataque(): + ruta = BASE_DICC / "attack.txt" + if not ruta.exists(): + return {} + + anim_dict = {"Ataque 1": [], "Ataque 2": [], "Ataque 3": []} + tipo_actual = None + + with open(ruta, "r", encoding="utf-8", errors="ignore") as f: + for linea in f: + linea = linea.strip() + if linea == "ATAQUE 1:": + tipo_actual = "Ataque 1" + elif linea == "ATAQUE 2:": + tipo_actual = "Ataque 2" + elif linea == "ATAQUE 3:": + tipo_actual = "Ataque 3" + elif tipo_actual and linea: + anim_dict[tipo_actual].append(linea) + + return anim_dict + + +def swap_habilidad(solo_params, donador_var, receptor_var, ctx_donador, ctx_receptor, include_effect=True): + if not ctx_donador or not ctx_receptor: + raise ValueError("Select donor and receptor first.") + + origen = obtener_datos_habilidad(donador_var.get()) + destino = obtener_datos_habilidad(receptor_var.get()) + + carpeta_d_anm = ctx_donador.get("2_anims") + carpeta_d_eff = ctx_donador.get("3_effects") + carpeta_d_1p = ctx_donador.get("1_p") + mode_d = ctx_donador.get("mode") + + carpeta_r_anm = ctx_receptor.get("2_anims") + carpeta_r_eff = ctx_receptor.get("3_effects") + carpeta_r_1p = ctx_receptor.get("1_p") + mode_r = ctx_receptor.get("mode") + + if not all([carpeta_d_anm, carpeta_d_eff, carpeta_d_1p, carpeta_r_anm, carpeta_r_eff, carpeta_r_1p]): + raise ValueError("Incomplete context (1_p, 2_anims, 3_effects).") + + cross_platform = (mode_d != mode_r) + errores = [] + + anim_src = encontrar_animacion(carpeta_d_anm, origen["anim"], mode_d) + anim_omitida = False + try: + if not solo_params: + if anim_src: + if cross_platform: + try: + converted_data = convert_anim_between_modes(Path(anim_src), mode_d, mode_r) + dest_anim = get_dest_anim_path(carpeta_r_anm, destino["anim"], mode_r, prefer_ext=anim_ext_for_mode(mode_r)) + Path(dest_anim).write_bytes(converted_data) + except Exception: + anim_omitida = True + else: + pref = os.path.splitext(anim_src)[1] + dest_anim = get_dest_anim_path(carpeta_r_anm, destino["anim"], mode_r, prefer_ext=pref) + shutil.copy2(anim_src, dest_anim) + else: + anim_omitida = True + except Exception: + anim_omitida = True + + eff_has_pmdl = False + if not solo_params and include_effect: + eff_src = encontrar_efecto(carpeta_d_eff, origen["eff"]) + eff_dst = os.path.join(carpeta_r_eff, destino["eff"] + ".pak") + if eff_src and os.path.exists(eff_src): + try: + if cross_platform: + data_eff = open(eff_src, "rb").read() + converted_eff, eff_has_pmdl = convert_vfx_bt3_to_ttt(data_eff) if mode_d == "BT3" else convert_vfx_ttt_to_bt3(data_eff) + open(eff_dst, "wb").write(converted_eff) + else: + shutil.copy2(eff_src, eff_dst) + except Exception as e: + errores.append(f"Error copying effect: {e}") + else: + errores.append("Effect file not found (skipped)") + + ruta_param_origen = os.path.join(carpeta_d_1p, "024_skill_param.dat") + ruta_param_destino = os.path.join(carpeta_r_1p, "024_skill_param.dat") + + if os.path.exists(ruta_param_origen) and os.path.exists(ruta_param_destino): + try: + with open(ruta_param_origen, "rb") as f: + bin_origen = f.read() + with open(ruta_param_destino, "rb") as f: + bin_destino = bytearray(f.read()) + + for o, d in zip(origen.get("offsets_4b", []), destino.get("offsets_4b", [])): + bin_destino[d:d+4] = bin_origen[o:o+4] + for o, d in zip(origen.get("offsets_2b", []), destino.get("offsets_2b", [])): + bin_destino[d:d+2] = bin_origen[o:o+2] + for o, d in zip(origen.get("offsets_1b", []), destino.get("offsets_1b", [])): + bin_destino[d] = bin_origen[o] + + with open(ruta_param_destino, "wb") as f: + f.write(bin_destino) + except Exception as e: + errores.append(f"Error in 024_skill_param: {e}") + else: + errores.append("024_skill_param.dat not found in donor or receptor") + + if (not solo_params) and (mode_d == "TTT") and (mode_r == "TTT"): + offset_origen = NOMBRES_OFFSETS[donador_var.get()] + offset_destino = NOMBRES_OFFSETS[receptor_var.get()] + nombre_dat = "043_move_list_name.dat" + + ruta_nombre_origen = os.path.join(carpeta_d_1p, nombre_dat) + ruta_nombre_destino = os.path.join(carpeta_r_1p, nombre_dat) + + if os.path.exists(ruta_nombre_origen) and os.path.exists(ruta_nombre_destino): + try: + with open(ruta_nombre_origen, "rb") as f: + datos_o = f.read() + with open(ruta_nombre_destino, "rb") as f: + datos_r = bytearray(f.read()) + + datos_r[offset_destino:offset_destino+TAMANO_NOMBRE] = datos_o[offset_origen:offset_origen+TAMANO_NOMBRE] + + with open(ruta_nombre_destino, "wb") as f: + f.write(datos_r) + except Exception as e: + errores.append(f"Error copying skill name: {e}") + else: + errores.append("043_move_list_name.dat not found in donor or receptor.") + + if errores: + raise ValueError("\n".join(errores)) + + if solo_params: + return "Skill parameters copied!" + + eff_info = "" + if not solo_params: + eff_src = encontrar_efecto(carpeta_d_eff, origen["eff"]) + eff_ok = eff_src and os.path.exists(eff_src) + if eff_ok: + eff_info = " (with effect + pmdl)" if eff_has_pmdl else " (with effect)" + elif not include_effect: + eff_info = " (effect skipped)" + else: + eff_info = " (no effect)" + msg = f"Skill swap done{eff_info}!" + if cross_platform and anim_omitida: + msg += "\n1 animation was skipped (missing or error)." + return msg + + +def procesar_swap_ataques(nombre_donador, nombre_receptor, ctx_donador, ctx_receptor, include_effect=True, include_cman=True): + errores = [] + + if not ctx_donador or not ctx_receptor: + return + + if not all([nombre_donador, nombre_receptor]): + return + + donador_data = DATOS_ATAQUES[nombre_donador] + receptor_data = DATOS_ATAQUES[nombre_receptor] + + carpeta_d_anm = ctx_donador.get("2_anims") + carpeta_d_eff = ctx_donador.get("3_effects") + carpeta_d_1p = ctx_donador.get("1_p") + mode_d = ctx_donador.get("mode") + + carpeta_r_anm = ctx_receptor.get("2_anims") + carpeta_r_eff = ctx_receptor.get("3_effects") + carpeta_r_1p = ctx_receptor.get("1_p") + mode_r = ctx_receptor.get("mode") + + if not all([carpeta_d_anm, carpeta_d_eff, carpeta_d_1p, carpeta_r_anm, carpeta_r_eff, carpeta_r_1p]): + return + + cross_platform = (mode_d != mode_r) + + animaciones_dict = cargar_animaciones_por_ataque() + anim_donador = animaciones_dict.get(nombre_donador, []) + anim_receptor = animaciones_dict.get(nombre_receptor, []) + + if not anim_donador or not anim_receptor: + return + + limite = 34 + if nombre_donador == "Ataque 3" and nombre_receptor == "Ataque 3": + limite = 35 + + anim_donador = anim_donador[:limite] + anim_receptor = anim_receptor[:limite] + + anim_omitidas = [] + camera_copiada = False + + for base_donador, base_receptor in zip(anim_donador, anim_receptor): + base_d = str(base_donador) + base_r = str(base_receptor) + src_anim = encontrar_animacion(carpeta_d_anm, base_d, mode_d) + if not src_anim: + anim_omitidas.append(base_d) + continue + try: + if cross_platform: + if os.path.getsize(src_anim) == 0: + dest_anim = get_dest_anim_path( + carpeta_r_anm, base_r, mode_r, + prefer_ext=anim_ext_for_mode(mode_r) + ) + open(dest_anim, "wb").close() + else: + conv_data = convert_anim_between_modes( + Path(src_anim), mode_d, mode_r + ) + dest_anim = get_dest_anim_path( + carpeta_r_anm, base_r, mode_r, + prefer_ext=anim_ext_for_mode(mode_r) + ) + Path(dest_anim).write_bytes(conv_data) + else: + pref = os.path.splitext(src_anim)[1] + dest_anim = get_dest_anim_path(carpeta_r_anm, base_r, mode_r, prefer_ext=pref) + shutil.copy2(src_anim, dest_anim) + except Exception: + anim_omitidas.append(base_d) + continue + + efecto_origen = os.path.join(carpeta_d_eff, donador_data["effect"]) + efecto_destino = os.path.join(carpeta_r_eff, receptor_data["effect"]) + atk_has_pmdl = False + if include_effect and os.path.exists(efecto_origen): + try: + if cross_platform: + data_eff = open(efecto_origen, "rb").read() + converted_eff, atk_has_pmdl = convert_vfx_bt3_to_ttt(data_eff) if mode_d == "BT3" else convert_vfx_ttt_to_bt3(data_eff) + open(efecto_destino, "wb").write(converted_eff) + else: + shutil.copy2(efecto_origen, efecto_destino) + except Exception as e: + errores.append(f"Error copying effect: {e}") + + param_archivo = "023_blast_param.dat" + ruta_d_param = os.path.join(carpeta_d_1p, param_archivo) + ruta_r_param = os.path.join(carpeta_r_1p, param_archivo) + + try: + with open(ruta_d_param, "rb") as f: + datos_d = bytearray(f.read()) + with open(ruta_r_param, "rb") as f: + datos_r = bytearray(f.read()) + except Exception: + errores.append("Could not open parameter files.") + return + + for offset_d, offset_r in zip(donador_data.get("offsets_4b", []), receptor_data.get("offsets_4b", [])): + datos_r[offset_r:offset_r+4] = datos_d[offset_d:offset_d+4] + for offset_d, offset_r in zip(donador_data.get("offsets_2b", []), receptor_data.get("offsets_2b", [])): + datos_r[offset_r:offset_r+2] = datos_d[offset_d:offset_d+2] + for offset_d, offset_r in zip(donador_data.get("offsets_1b", []), receptor_data.get("offsets_1b", [])): + datos_r[offset_r] = datos_d[offset_d] + + with open(ruta_r_param, "wb") as f: + f.write(datos_r) + + cam_param = "027_camera_param.dat" + ruta_minicam_d = os.path.join(carpeta_d_1p, cam_param) + ruta_minicam_r = os.path.join(carpeta_r_1p, cam_param) + + if os.path.exists(ruta_minicam_d) and os.path.exists(ruta_minicam_r): + try: + with open(ruta_minicam_d, "rb") as f: + data_d = f.read() + with open(ruta_minicam_r, "rb") as f: + data_r = bytearray(f.read()) + + offset_d = MINICAM_OFFSETS[nombre_donador] + offset_r = MINICAM_OFFSETS[nombre_receptor] + data_r[offset_r:offset_r+TAMANO_MINICAM] = data_d[offset_d:offset_d+TAMANO_MINICAM] + + if nombre_donador == "Ataque 3" and nombre_receptor == "Ataque 3": + extra_offset = MINICAM_OFFSETS["Ataque 3 extra"] + data_r[extra_offset:extra_offset+TAMANO_MINICAM] = data_d[extra_offset:extra_offset+TAMANO_MINICAM] + + with open(ruta_minicam_r, "wb") as f: + f.write(data_r) + except Exception as e: + errores.append(f"Error copying minicamera: {e}") + else: + errores.append("027_camera_param.dat not found in donor or receptor.") + + if (mode_d == "TTT") and (mode_r == "TTT"): + offset_origen = NOMBRES_OFFSETS[nombre_donador] + offset_destino = NOMBRES_OFFSETS[nombre_receptor] + nombre_dat = "043_move_list_name.dat" + + ruta_nombre_origen = os.path.join(carpeta_d_1p, nombre_dat) + ruta_nombre_destino = os.path.join(carpeta_r_1p, nombre_dat) + + if os.path.exists(ruta_nombre_origen) and os.path.exists(ruta_nombre_destino): + try: + with open(ruta_nombre_origen, "rb") as f: + datos_o = f.read() + with open(ruta_nombre_destino, "rb") as f: + datos_r = bytearray(f.read()) + + datos_r[offset_destino:offset_destino+TAMANO_NOMBRE] = datos_o[offset_origen:offset_origen+TAMANO_NOMBRE] + + with open(ruta_nombre_destino, "wb") as f: + f.write(datos_r) + except Exception as e: + errores.append(f"Error copying attack name: {e}") + else: + errores.append("043_move_list_name.dat not found in donor or receptor.") + + nombre_camara = CAMARAS_POR_ATAQUE.get(nombre_donador) + nombre_camara_destino = CAMARAS_POR_ATAQUE.get(nombre_receptor) + if include_cman and nombre_camara: + ruta_cman_donador = get_cman_path(carpeta_d_eff, mode_d, nombre_camara) + ruta_cman_receptor = get_cman_path(carpeta_r_eff, mode_r, nombre_camara_destino) + dst_dir = os.path.dirname(ruta_cman_receptor) + + if os.path.exists(dst_dir): + try: + donor_exists = os.path.exists(ruta_cman_donador) + donor_empty = donor_exists and os.path.getsize(ruta_cman_donador) == 0 + + if donor_exists and not donor_empty: + # donor has content: copy normally + shutil.copy2(ruta_cman_donador, ruta_cman_receptor) + camera_copiada = True + elif donor_empty: + # donor is empty (TTT empty cam): receptor must not have the file (BT3) or be empty (TTT) + if mode_r == "BT3": + if os.path.exists(ruta_cman_receptor): + os.remove(ruta_cman_receptor) + else: + open(ruta_cman_receptor, "wb").close() + camera_copiada = True + else: + # donor file does not exist (BT3 no cam): receptor gets empty file (TTT) or nothing (BT3) + if mode_r == "TTT": + open(ruta_cman_receptor, "wb").close() + elif os.path.exists(ruta_cman_receptor): + os.remove(ruta_cman_receptor) + camera_copiada = True + except Exception: + camera_copiada = False + else: + camera_copiada = False + else: + camera_copiada = False + + if errores: + raise ValueError("\n".join(errores)) + + if include_effect: + if os.path.exists(efecto_origen): + eff_info = "effect + pmdl" if atk_has_pmdl else "effect" + else: + eff_info = "no effect" + else: + eff_info = "effect skipped" + cam_info = "camera" if camera_copiada else ("cman skipped" if not include_cman else "no camera") + msg = f"Attack swap done ({eff_info}, {cam_info})!" + if anim_omitidas: + msg += f"\n{len(anim_omitidas)} animations skipped (missing or error)." + return msg + + +def procesar_swap_ataques_params_only(nombre_donador, nombre_receptor, ctx_donador, ctx_receptor): + errores = [] + + if not ctx_donador or not ctx_receptor: + return + + if not all([nombre_donador, nombre_receptor]): + return + + carpeta_d_1p = ctx_donador.get("1_p") + carpeta_r_1p = ctx_receptor.get("1_p") + if not all([carpeta_d_1p, carpeta_r_1p]): + return + + donador_data = DATOS_ATAQUES_PARAMS_ONLY.get(nombre_donador) + receptor_data = DATOS_ATAQUES_PARAMS_ONLY.get(nombre_receptor) + if not donador_data or not receptor_data: + return + + param_archivo = "023_blast_param.dat" + ruta_d_param = os.path.join(carpeta_d_1p, param_archivo) + ruta_r_param = os.path.join(carpeta_r_1p, param_archivo) + + if not (os.path.exists(ruta_d_param) and os.path.exists(ruta_r_param)): + errores.append("023_blast_param.dat not found in donor or receptor.") + else: + try: + with open(ruta_d_param, "rb") as f: + datos_d = bytearray(f.read()) + with open(ruta_r_param, "rb") as f: + datos_r = bytearray(f.read()) + + for offset_d, offset_r in zip(donador_data.get("offsets_4b", []), receptor_data.get("offsets_4b", [])): + datos_r[offset_r:offset_r+4] = datos_d[offset_d:offset_d+4] + for offset_d, offset_r in zip(donador_data.get("offsets_2b", []), receptor_data.get("offsets_2b", [])): + datos_r[offset_r:offset_r+2] = datos_d[offset_d:offset_d+2] + for offset_d, offset_r in zip(donador_data.get("offsets_1b", []), receptor_data.get("offsets_1b", [])): + datos_r[offset_r] = datos_d[offset_d] + + with open(ruta_r_param, "wb") as f: + f.write(datos_r) + except Exception as e: + errores.append(f"Error in 023_blast_param: {e}") + + if errores: + raise ValueError("\n".join(errores)) + return "Attack parameters copied!" \ No newline at end of file diff --git a/app_md/logic_swap/swap_vfx.py b/app_md/logic_swap/swap_vfx.py new file mode 100644 index 0000000..5a0e865 --- /dev/null +++ b/app_md/logic_swap/swap_vfx.py @@ -0,0 +1,602 @@ +import struct, math, shutil, os +import numpy as np +from PIL import Image + +RULE = { + 0x20400: 0x4400, 0x10400: 0x4400, 0x4400: 0x1400, 0x4040: 0x1040, + 0x02040: 0x1040, 0x1400: 0x0800, 0x1040: 0x0840, 0x0840: 0x0840, + 0x00800: 0x0500, 0x0500: 0x0500, 0x8040: 0x2040, 0x0840: 0x0240, +} +RULE_MAX_8BPP = (0x20400, 0x4400) +RULE_MAX_4BPP = (0x4040, 0x1040) + +_SIG = bytes([0x51, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) +_DBT_ENTRY_FMT = "I", data, 0)[0] + has_qrs = _QRS_SIG in data + if has_qrs and count_le < count_be and count_le * 4 + 8 < len(data): + return "BT3" + if not has_qrs and count_be * 4 + 8 < len(data): + return "TTT" + return "BT3" + + +def _psm(gstex0): return (gstex0 >> 20) & 0x3F +def _tex0_wh(gstex0): return 1 << ((gstex0 >> 26) & 0xF), 1 << ((gstex0 >> 30) & 0xF) +def _ps2_alpha(a): return min(255, a * 2 - 1) if a > 0 else 0 +def _p2(n): return 1 << max(0, (max(1, n) - 1).bit_length()) + + +def parse_pak(data): + count = struct.unpack_from(" offs[i] else b"") + for i in range(count)] + + +def parse_pak_be(data): + count = struct.unpack_from(">I", data, 0)[0] + offs = [struct.unpack_from(">I", data, 4 + i*4)[0] for i in range(count + 1)] + return [(i, offs[i], offs[i+1], data[offs[i]:offs[i+1]] if offs[i+1] > offs[i] else b"") + for i in range(count)] + + +def _is_dbt(data): + pos = data.find(_SIG) + while pos != -1: + if pos + 0x21 < len(data) and data[pos + 0x10] == 0x52 and data[pos + 0x20] == 0x53: + return True + pos = data.find(_SIG, pos + 1) + return False + + +def _is_atex(data): + if len(data) < 0x20: return False + count = struct.unpack_from(" 64: return False + if tbl_ptr != 8: return False + if h3 != count * 4: return False + return 0x20 + count * 0x60 <= len(data) + + +def _dbt_header(data): + v = struct.unpack_from(" 128: block_count=width//128; block_cols=4 + else: block_count=1; block_cols=width//32 + for block_row in range(height//16): + for _ in range(8): + bco = column_offset + for _ in range(block_count): + for _ in range(block_cols): + out[n_ptr:n_ptr+cs]=px[o_ptr:o_ptr+cs]; o_ptr+=width*16; n_ptr+=cs + bco += 256; o_ptr = bco + column_offset += width*2; o_ptr = column_offset + base_offset += cs; column_offset = base_offset; o_ptr = base_offset + if ((block_row+1)%8)==0: + base_offset=128*width*((block_row+1)//8)//2; column_offset=base_offset; o_ptr=base_offset + return bytes(out) + +def _reorder_pal_data(pl): + out = bytearray(len(pl)); bp = op = 0 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op=64 + for _ in range(7): + out[op:op+32]=pl[bp:bp+32]; bp+=32; op-=32 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op+=64 + out[op:op+64]=pl[bp:bp+64]; bp+=64; op+=96 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op-=32 + out[op:op+32]=pl[bp:bp+32]; bp+=32; op+=32 + out[bp:bp+32]=pl[bp:bp+32] + return bytes(out) + +def _dbt_to_pil(data, entry): + gstex0 = entry["gstex0"] + if not gstex0: return None + psm = _psm(gstex0) + w, h = _tex0_wh(gstex0) + tl = entry["tex_data_length"] + pl = entry["pal_data_length"] + if tl <= 128: return None + try: + px_raw = _dbt_extract_raw(data, entry["tex_data_ptr"], tl) + pl_raw = _dbt_extract_raw(data, entry["pal_data_ptr"], pl) if pl > 128 else None + if psm == 19: + indices = bytearray(_transform_to_bmp_order(bytearray(px_raw), w, h)) + pal_raw = _reorder_pal_data(bytearray(pl_raw)) + pal = [] + for i in range(256): + r,g,b,a = pal_raw[i*4],pal_raw[i*4+1],pal_raw[i*4+2],_ps2_alpha(pal_raw[i*4+3]) + pal.extend([r,g,b,a]) + img = Image.new("RGBA", (w, h)) + img.putdata([tuple(pal[indices[y*w+x]*4:(indices[y*w+x])*4+4]) + for y in range(h) for x in range(w)]) + return img.transpose(Image.FLIP_TOP_BOTTOM) + elif psm == 20: + s = _tex_to_0x(_interlacing(_reorder_pixel_data(bytearray(px_raw), w, h))) + indices = bytearray(_transform_to_bmp_order(bytearray(s), w, h)) + pal = [] + for i in range(16): + r,g,b,a = pl_raw[i*4],pl_raw[i*4+1],pl_raw[i*4+2],_ps2_alpha(pl_raw[i*4+3]) + pal.extend([r,g,b,a]) + img = Image.new("RGBA", (w, h)) + img.putdata([tuple(pal[indices[y*w+x]*4:indices[y*w+x]*4+4]) + for y in range(h) for x in range(w)]) + return img.transpose(Image.FLIP_TOP_BOTTOM) + elif psm == 0: + img = Image.new("RGBA", (w, h)) + img.putdata([(px_raw[i*4],px_raw[i*4+1],px_raw[i*4+2],_ps2_alpha(px_raw[i*4+3])) + for i in range(w*h)]) + return img.transpose(Image.FLIP_TOP_BOTTOM) + except Exception: + return None + + +def parse_dbt_textures(dbt_data): + hd = _dbt_header(dbt_data) + tbl_start = hd["image_table_ptr"] * 4 + textures = [] + for i in range(hd["image_count"]): + off = tbl_start + i * _DBT_ENTRY_SIZE + if off + _DBT_ENTRY_SIZE > len(dbt_data): break + e = _dbt_entry(dbt_data, off) + if not e["gstex0"] or e["tex_data_length"] <= 128: continue + w, h = _tex0_wh(e["gstex0"]) + idx_raw = e["tex_data_length"] - 128 if e["tex_data_length"] > 128 else 0 + pal_raw = e["pal_data_length"] - 128 if e["pal_data_length"] > 128 else 0 + raw_indices = raw_pal_bytes = None + n_colors = 0 + try: + psm_val = _psm(e["gstex0"]) + tl2, pl2 = e["tex_data_length"], e["pal_data_length"] + if psm_val == 19 and pl2 > 128: + px2 = _dbt_extract_raw(dbt_data, e["tex_data_ptr"], tl2) + raw_indices = bytearray(_transform_to_bmp_order(bytearray(px2), w, h)) + raw_pal_bytes = bytearray(_reorder_pal_data(_dbt_extract_raw(dbt_data, e["pal_data_ptr"], pl2))) + n_colors = 256 + elif psm_val == 20 and pl2 > 128: + px2 = _dbt_extract_raw(dbt_data, e["tex_data_ptr"], tl2) + s2 = _tex_to_0x(_interlacing(_reorder_pixel_data(bytearray(px2), w, h))) + raw_indices = bytearray(_transform_to_bmp_order(bytearray(s2), w, h)) + raw_pal_bytes = bytearray(_dbt_extract_raw(dbt_data, e["pal_data_ptr"], pl2)) + n_colors = 16 + except Exception: + pass + textures.append({ + "w": w, "h": h, + "psm": _psm(e["gstex0"]), + "idx_raw": idx_raw, "pal_raw": pal_raw, + "raw_indices": raw_indices, + "raw_pal_bytes": raw_pal_bytes, + "n_colors": n_colors, + "img": _dbt_to_pil(dbt_data, e), + }) + return textures + + +def _scale_dims(w, h, idx_raw, pal_raw): + key = idx_raw + pal_raw + if key not in RULE: + if pal_raw == 0x400 and key > RULE_MAX_8BPP[0]: + key = RULE_MAX_8BPP[0] + elif pal_raw == 0x40 and key > RULE_MAX_4BPP[0]: + key = RULE_MAX_4BPP[0] + else: + return w, h + ttt_total = RULE[key] + ttt_idx = ttt_total - pal_raw + if ttt_idx <= 0: return w, h + pixels_dst = ttt_idx * (2 if pal_raw == 0x40 else 1) + ratio = w / h if h else 1 + new_h = _p2(max(1, round(math.sqrt(pixels_dst / ratio)))) + new_w = _p2(max(1, pixels_dst // new_h)) + return new_w, new_h + + +def _indexed_to_atex_data(raw_indices, raw_pal, n_colors, w, h, new_w, new_h): + pal_psp = bytearray(1024) + for i in range(n_colors): + r, g, b, a_raw = raw_pal[i*4], raw_pal[i*4+1], raw_pal[i*4+2], raw_pal[i*4+3] + pal_psp[i*4] = r; pal_psp[i*4+1] = g; pal_psp[i*4+2] = b + pal_psp[i*4+3] = round((_ps2_alpha(a_raw) / 255) * 128) + arr = np.array(raw_indices, dtype='uint8').reshape(h, w) + scaled = list(Image.fromarray(arr, mode='L').transpose(Image.FLIP_TOP_BOTTOM).resize((new_w, new_h), Image.NEAREST).tobytes()) + tile_w, tile_h = 16, 8 + idx_bytes = bytearray(new_w * new_h); n = 0 + for ty in range(new_h // tile_h): + for tx in range(new_w // tile_w): + for py in range(tile_h): + for px in range(tile_w): + idx_bytes[n] = scaled[(ty*tile_h+py)*new_w + (tx*tile_w+px)]; n += 1 + return bytes(idx_bytes), bytes(pal_psp) + + +def _pil_to_atex_data(img, new_w, new_h, flip=True): + img_prep = img.transpose(Image.FLIP_TOP_BOTTOM) if flip else img + scaled = img_prep.resize((new_w, new_h), Image.NEAREST) + has_alpha = scaled.mode == "RGBA" + alpha_ch = list(scaled.split()[3].tobytes()) if has_alpha else None + if has_alpha: + px = list(scaled.getdata()) + premul_data = [(int(r*a/255), int(g*a/255), int(b*a/255)) for r,g,b,a in px] + premul = Image.new("RGB", (new_w, new_h)); premul.putdata(premul_data) + to_quantize = premul + else: + to_quantize = scaled.convert("RGB") + quantized = to_quantize.quantize(colors=256, method=Image.Quantize.MEDIANCUT) + pal_raw = quantized.getpalette() + while len(pal_raw) < 768: pal_raw.extend([0, 0, 0]) + indices = list(quantized.tobytes()) + alpha_max = [0] * 256 + if alpha_ch: + for i, idx in enumerate(indices): + if idx < 256 and alpha_ch[i] > alpha_max[idx]: + alpha_max[idx] = alpha_ch[i] + pal_bytes = bytearray(1024) + for i in range(256): + pal_bytes[i*4] = pal_raw[i*3]; pal_bytes[i*4+1] = pal_raw[i*3+1] + pal_bytes[i*4+2] = pal_raw[i*3+2] + pal_bytes[i*4+3] = round((alpha_max[i]/255)*128) if has_alpha else 128 + tile_w, tile_h = 16, 8 + idx_bytes = bytearray(new_w * new_h); n = 0 + for ty in range(new_h // tile_h): + for tx in range(new_w // tile_w): + for py in range(tile_h): + for px in range(tile_w): + idx_bytes[n] = indices[(ty*tile_h+py)*new_w + (tx*tile_w+px)]; n += 1 + return bytes(idx_bytes), bytes(pal_bytes) + + +def _atex_b40(idx_sz): return int(math.log2(max(idx_sz, 1))) // 2 + +def build_atex(tex_data_list): + count = len(tex_data_list) + header_bytes = 0x20 + count * 0x60 + idx_sizes = [w * h for w, h, _, _ in tex_data_list] + pal_sz = 1024 + field08 = sum(s // 64 for s in idx_sizes) + offsets = []; cur = header_bytes + for s in idx_sizes: + offsets.append((cur, cur + s)); cur += s + pal_sz + out = bytearray() + out += struct.pack("I", count) + for o in offsets: out += struct.pack(">I", o) + out += b"\x00" * (first_offset - len(out)) + for chunk in chunks: out += chunk + return bytes(out) + + +def _atex_untile(idx_tiled, w, h): + tiles_x = -(-w // 16); tiles_y = -(-h // 8) + out = bytearray(w * h); n = 0 + for ty in range(tiles_y): + for tx in range(tiles_x): + for py in range(8): + for px in range(16): + ay = ty*8+py; ax = tx*16+px + if ay < h and ax < w: out[ay*w+ax] = idx_tiled[n] + n += 1 + return bytes(out) + + +def parse_atex(data): + if not _is_atex(data): return [] + count = struct.unpack_from(" len(data): continue + if pal_sz > 0 and pal_off + pal_sz > len(data): continue + idx_tiled = data[idx_off:idx_off+idx_sz] + pal_bytes = data[pal_off:pal_off+pal_sz] if pal_sz > 0 else b"\x00"*1024 + textures.append({"w": w, "h": h, "idx_linear": _atex_untile(idx_tiled, w, h), "pal_bytes": pal_bytes}) + return textures + + +def _psp_to_ps2_alpha(a): + if a == 0: return 0 + if a >= 128: return 0x80 + return round(a / 128 * 127) + 1 + +def _make_gstex0(w, h, psm): + tw=w.bit_length()-1; th=h.bit_length()-1; tbw=0 if w<=64 else w//64 + lo=((th&3)<<30)|(tw<<26)|(psm<<20)|(tbw<<14); up=(1<<29)|((th>>2)&3) + return (up<<32)|lo + +def _make_gif_tex(w, h, psm, idx_raw, is_first): + tag=bytearray(96); b2D=idx_raw//4096; b2C=0x07|((idx_raw//16)&0xFF) + struct.pack_into(" tuple: + from .pmdl.vfx_pmdl_port import port_vfx_subpak_bt3_to_ttt + + entries = parse_pak(data) + atex_map = {} + has_pmdl = False + + for idx, start, end, raw in entries: + if idx == 0: + if raw: + try: + atex_map[idx] = port_vfx_subpak_bt3_to_ttt(raw) + has_pmdl = True + except Exception: + pass + continue + + if not _is_dbt(raw): + continue + atex_bytes = _process_dbt_to_atex_bytes(raw) + if atex_bytes: + atex_map[idx] = atex_bytes + + return _rebuild_pak_bt3_to_ttt(data, atex_map), has_pmdl + + +def convert_vfx_ttt_to_bt3(data: bytes) -> tuple: + entries = parse_pak_be(data) + replacements = {} + for idx, s, e, raw in entries: + if not _is_atex(raw): + continue + textures = parse_atex(raw) + if not textures: + continue + replacements[idx] = build_dbt(textures) + return _rebuild_pak_ttt_to_bt3(data, replacements), False \ No newline at end of file diff --git a/app_md/windows/anim0.py b/app_md/windows/anim0.py new file mode 100644 index 0000000..ecef81e --- /dev/null +++ b/app_md/windows/anim0.py @@ -0,0 +1,95 @@ +import os +from pathlib import Path + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QPushButton, + QFileDialog, QLabel, QMessageBox +) +from PyQt5.QtCore import Qt + +BASE_SCR = Path(__file__).parent / "scr" +ANIM_LIST_FILE = BASE_SCR / "anim0.txt" + + +class Anims0(QWidget): + def __init__(self, parent=None): + super().__init__(None) + self.setWindowFlags( + Qt.Window | + Qt.WindowTitleHint | + Qt.WindowCloseButtonHint | + Qt.WindowMinimizeButtonHint + ) + self.setWindowTitle("Anims0 - Vaciar animaciones") + self.setFixedSize(360, 180) + + self.anim_folder = None + + layout = QVBoxLayout() + layout.setSpacing(14) + + self.label = QLabel("No se ha seleccionado ninguna carpeta") + self.label.setAlignment(Qt.AlignCenter) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + btn_select = QPushButton("Seleccionar carpeta de Animaciones") + btn_select.setFixedHeight(36) + btn_select.clicked.connect(self.select_folder) + layout.addWidget(btn_select) + + btn_process = QPushButton("Procesar") + btn_process.setFixedHeight(36) + btn_process.clicked.connect(self.process_anims) + layout.addWidget(btn_process) + + self.setLayout(layout) + + def select_folder(self): + folder = QFileDialog.getExistingDirectory( + self, + "Selecciona la carpeta de animaciones" + ) + if not folder: + return + + self.anim_folder = Path(folder) + self.label.setText(f"Carpeta seleccionada:\n{folder}") + + def process_anims(self): + if not self.anim_folder: + QMessageBox.warning(self, "Aviso", "Selecciona primero una carpeta de animaciones.") + return + + if not ANIM_LIST_FILE.exists(): + QMessageBox.critical( + self, + "Error", + f"No se encontró el archivo:\n{ANIM_LIST_FILE}" + ) + return + + # Leer lista de animaciones + with open(ANIM_LIST_FILE, "r", encoding="utf-8", errors="ignore") as f: + names = [line.strip() for line in f if line.strip()] + + if not names: + QMessageBox.warning(self, "Aviso", "La lista anim0.txt está vacía.") + return + + processed = 0 + + for base_name in names: + for ext in (".anm", ".tanm", ".canm"): + anim_path = self.anim_folder / (base_name + ext) + if anim_path.exists() and anim_path.is_file(): + # Vaciar archivo → 0 bytes + open(anim_path, "wb").close() + processed += 1 + break + + QMessageBox.information( + self, + "Completado", + f"Proceso finalizado.\nAnimaciones procesadas: {processed}" + ) diff --git a/app_md/windows/extract_tool.py b/app_md/windows/extract_tool.py index a801509..094fbbd 100644 --- a/app_md/windows/extract_tool.py +++ b/app_md/windows/extract_tool.py @@ -62,11 +62,26 @@ def __init__(self, window=None): action_anm_tanm = QAction("Convert anm or tanm", self) action_anm_tanm.triggered.connect(self.process_anm) + action_swap = QAction("Swap Attacks (TTT)", self) + action_swap.setShortcut(QKeySequence("Ctrl+T")) + action_swap.triggered.connect(self.open_swap_attacks) + + action_anim0 = QAction("Anims0 (vaciar animaciones)", self) + action_anim0.setShortcut(QKeySequence("Ctrl+0")) + action_anim0.triggered.connect(self.open_anims0) + + action_port = QAction("BT3 → TTT Port", self) + action_port.setShortcut(QKeySequence("Ctrl+P")) + action_port.triggered.connect(self.open_port) + file_menu.addAction(action_close_fil) file_menu.addAction(action_salir) tool_menu.addAction(action_edit) tool_menu.addAction(action_edit_exr) tool_menu.addAction(action_anm_tanm) + tool_menu.addAction(action_swap) + tool_menu.addAction(action_anim0) + tool_menu.addAction(action_port) self.menu_bar.addMenu(file_menu) self.menu_bar.addMenu(tool_menu) @@ -395,7 +410,7 @@ def process_anm(self): self, "Choose files", "", - "anims files (*.anm);;tanms files (*.tanm);;All files (*.*)" + "Anim files (*.anm *.tanm);;anims files (*.anm);;tanms files (*.tanm);;All files (*.*)" ) if not files: @@ -426,6 +441,47 @@ def task_anm(self, files): return ["successfully converted"] + def open_swap_attacks(self): + try: + from app_md.logic_swap.swap_attacks import SwapApp + except Exception as e: + QMessageBox.critical(self, "Error", f"No se pudo importar Swap_Attacks: {e}") + return + + self.setEnabled(False) + swap = SwapApp(parent_tool=self) + swap.exec_() + + def open_anims0(self): + try: + from app_md.windows.anim0 import Anims0 + except Exception as e: + QMessageBox.critical(self, "Error", f"No se pudo abrir Anims0:\n{e}") + return + + if hasattr(self, "anims0_window") and self.anims0_window is not None: + try: + self.anims0_window.close() + except: + pass + + self.anims0_window = Anims0() + self.anims0_window.setWindowIcon(self.windowIcon()) + self.anims0_window.setFont(self.font()) + self.anims0_window.show() + self.anims0_window.raise_() + self.anims0_window.activateWindow() + + def open_port(self): + try: + from app_md.logic_port.port_window import PortWindow + except Exception as e: + QMessageBox.critical(self, "Error", f"Could not load Port tool: {e}") + return + self.setEnabled(False) + port = PortWindow(parent_tool=self) + port.exec_() + class ClickableFrame(QFrame): clicked = pyqtSignal() From 402bb598955d1a3f59666b74157c824bcae910c7 Mon Sep 17 00:00:00 2001 From: Kasto M <99041020+kastomd@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:15:54 -0500 Subject: [PATCH 2/3] tex tooltip cambiado --- app_md/base_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app_md/base_app.py b/app_md/base_app.py index 3148f00..c4162b5 100644 --- a/app_md/base_app.py +++ b/app_md/base_app.py @@ -263,7 +263,7 @@ def __init__(self, contenedor): self.edit_lbl_data_size = QLineEdit(self) self.edit_lbl_data_size.setText("0x38000") self.edit_lbl_data_size.setPlaceholderText("index size") - self.edit_lbl_data_size.setToolTip("Indicates the starting position of the first file in the ISO") + self.edit_lbl_data_size.setToolTip("Indicates the header size of the packfile") self.edit_lbl_files = QLineEdit(self) self.edit_lbl_files.setText("0x3711") From 5581304c60538693f5bfa59346e50ebf7957f919 Mon Sep 17 00:00:00 2001 From: Kasto M <99041020+kastomd@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:55:36 -0500 Subject: [PATCH 3/3] tester --- app_md/base_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app_md/base_app.py b/app_md/base_app.py index c4162b5..cae4282 100644 --- a/app_md/base_app.py +++ b/app_md/base_app.py @@ -31,7 +31,7 @@ class BaseApp: def __init__(self): self.path_iso = None - self.version = "1.20260421" + self.version = "1.20260423-test" #icono de la app self.icon = Path(__file__).resolve().parent / "images" / "icon.ico"