From c03f4619e6e2166ce525539023615a37293204a2 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 8 Sep 2026 17:32:11 -0400 Subject: [PATCH] Show theme/DLC names and embedded theme previews in the library --- app/gui.py | 56 +++++- app/theme.py | 511 +++++++++++++++++++++++++++++++++++++++++++++++ app/transfers.py | 12 ++ 3 files changed, 574 insertions(+), 5 deletions(-) create mode 100644 app/theme.py diff --git a/app/gui.py b/app/gui.py index ba5874f..2c9c814 100644 --- a/app/gui.py +++ b/app/gui.py @@ -5,6 +5,7 @@ Run with:: python3 -m app.main """ import datetime +import json import logging import sys import threading @@ -20,7 +21,7 @@ from PySide6.QtWidgets import ( QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) -from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, transfers +from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, theme, transfers from .db import get_connection, init_db log = logging.getLogger("gui") @@ -376,6 +377,13 @@ class CoverShowcase(QWidget): item.setTextAlignment(Qt.AlignHCenter | Qt.AlignTop) item.setToolTip(it["name"]) item.setData(Qt.UserRole, it) + # Themes/DLC show their locally-extracted preview (no scraping). + if not covers.is_scrapable(it.get("system")): + rid = it.get("release_id") + system = it.get("system") + path = covers.cover_path(rid, system) + if path is not None and path.exists() and covers.file_is_image(path): + item.setIcon(self._fit_icon(QPixmap(str(path)))) self.list.addItem(item) self._start_loader(gen) @@ -3331,18 +3339,56 @@ def _nps_library_items(nps_dir, overrides, dates): theme_dir = root / "THEME" if theme_dir.is_dir(): for ptf in sorted(theme_dir.rglob("*")): - if ptf.is_file() and ptf.suffix.lower() == ".ptf": - out.append(_nps_extra_item(ptf, "theme", ptf.stem)) + if not ptf.is_file() or ptf.suffix.lower() != ".ptf": + continue + folder = ptf.parent + name = _sidecar_name(folder) or theme.ptf_name(ptf) or ptf.stem + item = _nps_extra_item(ptf, "theme", name) + item["release_id"] = covers.stable_key(name) + _write_theme_cover(ptf, item["release_id"]) + out.append(item) # DLC: /DLC//.edat dlc_dir = root / "DLC" if dlc_dir.is_dir(): for edat in sorted(dlc_dir.rglob("*")): - if edat.is_file() and edat.suffix.lower() == ".edat": - out.append(_nps_extra_item(edat, "dlc", edat.stem)) + if not edat.is_file() or edat.suffix.lower() != ".edat": + continue + name = _sidecar_name(edat.parent) or edat.stem + out.append(_nps_extra_item(edat, "dlc", name)) return out +def _sidecar_name(folder): + """Read a display name saved next to a downloaded NPS item, if present.""" + try: + meta = json.loads((Path(folder) / "meta.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return "" + return (meta.get("name") or "").strip() + + +def _write_theme_cover(ptf_path, rid): + """Extract a theme's embedded preview into the cover cache (idempotent).""" + path = covers.cover_path(rid, "theme") + if path.exists() and covers.file_is_image(path): + return + try: + res = theme.theme_preview_rgba(ptf_path) + except Exception: # noqa: BLE001 + res = None + if not res: + return + w, h, rgba = res + img = QImage(rgba, w, h, w * 4, QImage.Format_RGBA8888) + if img.isNull(): + return + try: + img.save(str(path), "PNG") + except OSError: + pass + + def _nps_extra_item(path, psp_kind, name): try: size = path.stat().st_size diff --git a/app/theme.py b/app/theme.py new file mode 100644 index 0000000..ba9814a --- /dev/null +++ b/app/theme.py @@ -0,0 +1,511 @@ +"""PSP theme (``.ptf``) introspection: name + preview image. + +PTF themes embed a readable name in their header and a preview image (a GIM +bitmap) in object 0. This module decodes both without any external service: + +* the name lives at offset 0x08 (128 UTF-8 bytes); +* the preview is object 0's largest image item, stored as a GIM that may be + Sony-LZR compressed. + +GIM decoding covers the common RGBA formats (5650/5551/4444/8888) and 8-bit +palettes; anything exotic simply yields no preview. +""" +from __future__ import annotations + +import zlib +from pathlib import Path + +PTF_MAGIC = b"\x00PTF" + +# --------------------------------------------------------------------------- # +# Sony LZR/RLZ decompression (sceLzrDecompress), ported from PTF-Studio. +# --------------------------------------------------------------------------- # +_LZR_PROBABILITY_SLOTS = 2800 +_LZR_INITIAL_PROBABILITY = 0x80 +_LZR_RENORM_LIMIT = 0x00FFFFFF +_LZR_U32_MOD = 0x100000000 + + +def _lzr_decompress(inp, max_output, offset=0): + inp = bytes(inp) + end = offset + len(inp) - offset + out = bytearray(max_output) + probs = [_LZR_INITIAL_PROBABILITY] * _LZR_PROBABILITY_SLOTS + out_pos = 0 + in_pos = offset + 5 + code = int.from_bytes(inp[offset + 1:offset + 5], "big") + range_ = 0xFFFFFFFF + spec = 0 + corrupt = False + buf_off = 0 + last_byte = 0 + last_number_flag = 0 + type_ = inp[offset] + if type_ >= 0x80: + type_ -= 0x100 + + def next_byte(): + nonlocal in_pos, corrupt + if in_pos >= end: + corrupt = True + return 0 + b = inp[in_pos] + in_pos += 1 + return b + + if type_ > 7: + raise ValueError("unsupported LZR literal-context mode") + if type_ < 0: + stored = code + if stored > len(out) or in_pos + stored > end: + raise ValueError("malformed stored LZR block") + return inp[in_pos:in_pos + stored] + + def renormalize(): + nonlocal code, range_ + if range_ <= _LZR_RENORM_LIMIT: + code = (code * 256 + next_byte()) % _LZR_U32_MOD + range_ = (range_ * 256) % _LZR_U32_MOD + + def decode_bit(prob_index): + nonlocal code, range_, corrupt + if prob_index < 0 or prob_index >= _LZR_PROBABILITY_SLOTS: + corrupt = True + return 0 + renormalize() + if corrupt: + return 0 + p = probs[prob_index] + bound = (range_ // 256) * p + probs[prob_index] = p - (p // 8) + if code < bound: + range_ = bound + probs[prob_index] += 31 + return 1 + code -= bound + range_ -= bound + return 0 + + def decode_bit_speculative(prob_index): + nonlocal code, range_, spec, corrupt + if prob_index < 0 or prob_index >= _LZR_PROBABILITY_SLOTS: + corrupt = True + return 0 + if spec <= _LZR_RENORM_LIMIT: + code = (code * 256 + next_byte()) % _LZR_U32_MOD + range_ = (spec * 256) % _LZR_U32_MOD + if corrupt: + return 0 + p = probs[prob_index] + bound = (range_ // 256) * p + spec = bound + probs[prob_index] = p - (p // 8) + if code < bound: + range_ = bound + probs[prob_index] += 31 + return 1 + code -= bound + range_ -= bound + return 0 + + def decode_number(bits, base, step): + nonlocal code, range_, corrupt, last_number_flag + number = 1 + if bits >= 3: + number = number * 2 + decode_bit(base + 3 * step) + if bits >= 4: + number = number * 2 + decode_bit(base + 3 * step) + if bits >= 5: + renormalize() + remaining = bits + while remaining >= 5: + if range_ == 0: + corrupt = True + return 0 + number *= 2 + range_ = range_ // 2 + if code < range_: + number += 1 + else: + code -= range_ + remaining -= 1 + last_number_flag = decode_bit(base) + number = number * 2 + last_number_flag + if bits >= 1: + number = number * 2 + decode_bit(base + step) + if bits >= 2: + number = number * 2 + decode_bit(base + 2 * step) + return number + + def selector_context(): + return buf_off + 2488 + + def decode_literal(shift): + nonlocal out_pos, last_byte, corrupt, buf_off + if buf_off > 0: + buf_off -= 1 + if out_pos == len(out): + return False + context = ((((out_pos & 7) << 8) + last_byte) // (2 ** shift)) & 7 + tree_base = context * 0xFF - 1 + node = 1 + while node <= 0xFF: + node = node * 2 + decode_bit(tree_base + node) + if corrupt: + return False + out[out_pos] = node & 0xFF + last_byte = node & 0xFF + out_pos += 1 + return True + + def decode_match(): + nonlocal out_pos, last_byte, buf_off, spec, code, range_, corrupt, last_number_flag + slot = selector_context() + spec = range_ + length_class = -1 + bit = 0 + while True: + slot += 8 + bit = decode_bit_speculative(slot) + if corrupt: + return -1 + length_class += bit + if bit == 0 or length_class >= 6: + break + offset_group = length_class + 2033 + offset_bias = 64 + if bit != 0 or length_class >= 0: + length_context = (length_class << 5) + (((out_pos * (2 ** length_class)) & 3) << 3) + buf_off + 2552 + match_length = decode_number(length_class, length_context, 8) + if corrupt: + return -1 + if match_length == 0xFF: + return 1 + if last_number_flag != 0 or length_class > 0: + offset_group += 56 + offset_bias = 352 + else: + match_length = 1 + index = 1 + offset_class = 0 + while True: + offset_class = (index << 4) - offset_bias + prob_index = offset_group + (index << 3) + if prob_index >= _LZR_PROBABILITY_SLOTS: + return -1 + bit = decode_bit(prob_index) + if corrupt: + return -1 + index = (index << 1) | bit + if offset_class >= 0: + break + if bit != 0 or offset_class > 0: + if bit == 0: + offset_class -= 8 + base = offset_class + 2344 + if base < 0 or base + 3 >= _LZR_PROBABILITY_SLOTS: + return -1 + back_offset = decode_number(offset_class // 8, base, 1) + if corrupt: + return -1 + else: + back_offset = 1 + if back_offset > out_pos: + return -1 + copy_end = out_pos + match_length + 1 + if copy_end > len(out): + return -1 + buf_off = ((copy_end + 1) & 1) + 6 + from_ = out_pos - back_offset + while out_pos < copy_end: + out[out_pos] = out[from_] + out_pos += 1 + from_ += 1 + last_byte = out[out_pos - 1] + return 0 + + token_budget = max(1024, max_output * 2 + 1024) + while True: + token_budget -= 1 + if token_budget < 0: + raise ValueError("malformed LZR stream did not terminate") + token_is_match = decode_bit(selector_context()) + if corrupt: + raise ValueError("truncated LZR stream") + if token_is_match == 0: + if not decode_literal(type_): + raise ValueError("LZR stream exceeds declared output size") + else: + result = decode_match() + if result == 1: + return bytes(out[:out_pos]) + if result < 0: + raise ValueError("malformed LZR back-reference") + + +# --------------------------------------------------------------------------- # +# PTF parsing +# --------------------------------------------------------------------------- # +def _decompress_item(blob, comp, ucsize): + if comp == 2 and len(blob) < ucsize: + return zlib.decompress(blob) + if comp == 1 and len(blob) < ucsize: + return _lzr_decompress(blob, ucsize) + return blob + + +def parse_ptf(data): + """Return ``(name, items)`` where items are decompressed item blobs.""" + if not isinstance(data, (bytes, bytearray)): + data = data.read_bytes() if hasattr(data, "read_bytes") else bytes(data) + data = bytes(data) + if data[:4] != PTF_MAGIC: + raise ValueError("not a PTF file") + + raw_name = data[8:8 + 128] + name = raw_name.split(b"\x00", 1)[0].decode("utf-8", "replace").strip() + + def u16(o): + return int.from_bytes(data[o:o + 2], "little") + + def u32(o): + return int.from_bytes(data[o:o + 4], "little") + + items = [] + pos = 0x100 + while True: + offset = u32(pos) + pos += 4 + if offset == 0 or offset >= len(data): + break + count = u16(offset + 2) + data_off = u32(offset + 8) + p = data_off + for _ in range(count): + file_type = u16(p + 4) + comp = u16(p + 6) + size = u32(p + 8) + ucsize = u32(p + 12) + blob = data[p + 0x20:p + 0x20 + size] + items.append((file_type, _decompress_item(blob, comp, ucsize))) + p += 0x20 + size + return name, items + + +def ptf_name(path): + """Theme display name embedded in a ``.ptf`` file.""" + try: + with open(path, "rb") as fh: + head = fh.read(8 + 128) + except OSError: + return "" + if head[:4] != PTF_MAGIC: + return "" + name = head[8:8 + 128].split(b"\x00", 1)[0].decode("utf-8", "replace").strip() + return name or Path(path).stem + + +# --------------------------------------------------------------------------- # +# GIM decoding (RGBA formats only) +# --------------------------------------------------------------------------- # +def _overscan(value, tile): + return value if value % tile == 0 else value + (tile - (value % tile)) + + +def _untile(pixels, w, h, tile_w, tile_h): + over_w = _overscan(w, tile_w) + over_h = _overscan(h, tile_h) + out = [0] * (w * h) + tile_pos = 0 + origin_x = 0 + origin_y = 0 + for y in range(over_h): + for x in range(over_w): + dx = origin_x + (tile_pos % tile_w) + dy = origin_y + (tile_pos // tile_w) + if dx < w and dy < h: + out[dy * w + dx] = pixels[y * over_w + x] + tile_pos += 1 + if tile_pos == tile_w * tile_h: + tile_pos = 0 + origin_x += tile_w + if origin_x >= over_w: + origin_x = 0 + origin_y += tile_h + return out + + +def _gim_to_rgba(data): + """Decode a GIM to ``(width, height, rgba_bytes)`` or None if unsupported.""" + data = bytes(data) + if len(data) < 0x10: + return None + + # Detect byte order from the magic. Sony GIMs store the magic as the byte + # string "MIG.00.1PSP" with little-endian data (LibPSPThemes semantics). + if data[:4] == b"MIG.": + byteorder = "little" + elif data[:4] == b".GIM": + byteorder = "big" + else: + return None + + def rd(off, size): + return int.from_bytes(data[off:off + size], byteorder) + + image = None + palette = None + pos = 0x10 + for _ in range(32): + if pos + 16 > len(data): + break + btype = rd(pos, 2) + next_block = rd(pos + 8, 4) + if btype == 4 or btype == 5: + h = pos + 0x10 + fmt = rd(h + 4, 2) + pixel_order = rd(h + 6, 2) + width = rd(h + 8, 2) + height = rd(h + 10, 2) + bpp = rd(h + 12, 2) + next_index = rd(h + 24, 4) + frame_data_end = rd(h + 32, 4) + frame_offset = rd(pos + 0x10 + next_index, 4) + block_content = pos + 0x10 + start = block_content + frame_offset + end = block_content + frame_data_end + raw = data[start:end] + pixels = _decode_pixels(raw, fmt, bpp, width, height, pixel_order) + if pixels is None: + return None + if btype == 4: + image = (width, height, pixels) + else: + palette = (fmt, width, height, pixels) + if next_block == 0: + break + pos += next_block + + if image is None: + return None + width, height, pixels = image + if palette is not None: + _, pw, ph, pvals = palette + if pw * ph > 0: + pixels = [pvals[p] if p < len(pvals) else 0 for p in pixels] + + rgba = bytearray(width * height * 4) + for i, v in enumerate(pixels): + # pixels are packed 0xAABBGGRR + o = i * 4 + rgba[o] = v & 0xFF + rgba[o + 1] = (v >> 8) & 0xFF + rgba[o + 2] = (v >> 16) & 0xFF + rgba[o + 3] = (v >> 24) & 0xFF + return width, height, bytes(rgba) + + +def _decode_pixels(raw, fmt, bpp, width, height, pixel_order): + """Decode a GIM frame into a list of packed 0xAABBGGRR ints.""" + if fmt in (0, 1, 2, 3, 5): + pass + else: + return None # indexed-16 / DXT etc. not supported + + if fmt == 5: # p8 indexed + bytes_per_pixel = 1 + tile_w = 0x80 // max(bpp, 8) + else: + bytes_per_pixel = max(1, (bpp + 7) // 8) + tile_w = 0x80 // max(bpp, 1) + tile_h = 8 + + if pixel_order == 1: + w = _overscan(width, tile_w) + h = _overscan(height, tile_h) + else: + w, h = width, height + + count = w * h + values = [] + pos = 0 + for _ in range(count): + v = int.from_bytes(raw[pos:pos + bytes_per_pixel], "little") + pos += bytes_per_pixel + values.append(_expand(fmt, bpp, v)) + if pos >= len(raw): + # pad missing trailing pixels with transparent + values.extend([0] * (count - len(values))) + break + + if pixel_order == 1: + values = _untile(values, width, height, tile_w, tile_h) + return values[:width * height] + + +def _expand(fmt, bpp, v): + """Expand a raw GIM pixel value into packed 0xAABBGGRR.""" + if fmt == 3: # rgba8888 + r = v & 0xFF + g = (v >> 8) & 0xFF + b = (v >> 16) & 0xFF + a = (v >> 24) & 0xFF + return (a << 24) | (b << 16) | (g << 8) | r + if fmt == 0: # rgba5650 + r = (v >> 11) & 0x1F + g = (v >> 5) & 0x3F + b = v & 0x1F + r = (r << 3) | (r >> 2) + g = (g << 2) | (g >> 4) + b = (b << 3) | (b >> 2) + return (0xFF << 24) | (b << 16) | (g << 8) | r + if fmt == 1: # rgba5551 + r = (v >> 11) & 0x1F + g = (v >> 6) & 0x1F + b = (v >> 1) & 0x1F + a = 0xFF if v & 1 else 0 + r = (r << 3) | (r >> 2) + g = (g << 3) | (g >> 2) + b = (b << 3) | (b >> 2) + return (a << 24) | (b << 16) | (g << 8) | r + if fmt == 2: # rgba4444 + r = (v >> 12) & 0xF + g = (v >> 8) & 0xF + b = (v >> 4) & 0xF + a = v & 0xF + r = (r << 4) | r + g = (g << 4) | g + b = (b << 4) | b + a = (a << 4) | a + return (a << 24) | (b << 16) | (g << 8) | r + if fmt == 5: # p8 — value is a palette index (expanded later) + return v & 0xFF + return 0 + + +# --------------------------------------------------------------------------- # +# Public API +# --------------------------------------------------------------------------- # +def theme_preview_rgba(ptf_path): + """Decode the preview image of a theme PTF. + + Returns ``(width, height, rgba_bytes)`` or None if it can't be decoded. + """ + try: + data = Path(ptf_path).read_bytes() + _, items = parse_ptf(data) + except (OSError, ValueError): + return None + + # The preview image is the largest decodable GIM item. + best = None + for file_type, blob in items: + if file_type != 5: + continue + decoded = _gim_to_rgba(blob) + if decoded is None: + continue + w, h, rgba = decoded + if best is None or w * h > best[0] * best[1]: + best = (w, h, rgba) + return best diff --git a/app/transfers.py b/app/transfers.py index 3700485..06175aa 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -506,6 +506,18 @@ class NoPayStationSource(Source): kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip, kind_hint, progress_cb, cancel_event) + if self.content_type in ("theme", "dlc"): + _write_sidecar(folder, item) return folder finally: shutil.rmtree(tmp, ignore_errors=True) + + +def _write_sidecar(folder, item): + """Save the store display name beside a downloaded theme/DLC (for the library).""" + try: + meta = {"name": item.get("name") or "", "title_id": item.get("title_id") or "", + "content_id": item.get("content_id") or ""} + (Path(folder) / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + except OSError: + pass