Show theme/DLC names and embedded theme previews in the library
This commit is contained in:
+51
-5
@@ -5,6 +5,7 @@ Run with::
|
|||||||
python3 -m app.main
|
python3 -m app.main
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -20,7 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
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
|
from .db import get_connection, init_db
|
||||||
|
|
||||||
log = logging.getLogger("gui")
|
log = logging.getLogger("gui")
|
||||||
@@ -376,6 +377,13 @@ class CoverShowcase(QWidget):
|
|||||||
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignTop)
|
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignTop)
|
||||||
item.setToolTip(it["name"])
|
item.setToolTip(it["name"])
|
||||||
item.setData(Qt.UserRole, it)
|
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.list.addItem(item)
|
||||||
|
|
||||||
self._start_loader(gen)
|
self._start_loader(gen)
|
||||||
@@ -3331,18 +3339,56 @@ def _nps_library_items(nps_dir, overrides, dates):
|
|||||||
theme_dir = root / "THEME"
|
theme_dir = root / "THEME"
|
||||||
if theme_dir.is_dir():
|
if theme_dir.is_dir():
|
||||||
for ptf in sorted(theme_dir.rglob("*")):
|
for ptf in sorted(theme_dir.rglob("*")):
|
||||||
if ptf.is_file() and ptf.suffix.lower() == ".ptf":
|
if not ptf.is_file() or ptf.suffix.lower() != ".ptf":
|
||||||
out.append(_nps_extra_item(ptf, "theme", ptf.stem))
|
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: <nps>/DLC/<GAME_ID>/<name>.edat
|
# DLC: <nps>/DLC/<GAME_ID>/<name>.edat
|
||||||
dlc_dir = root / "DLC"
|
dlc_dir = root / "DLC"
|
||||||
if dlc_dir.is_dir():
|
if dlc_dir.is_dir():
|
||||||
for edat in sorted(dlc_dir.rglob("*")):
|
for edat in sorted(dlc_dir.rglob("*")):
|
||||||
if edat.is_file() and edat.suffix.lower() == ".edat":
|
if not edat.is_file() or edat.suffix.lower() != ".edat":
|
||||||
out.append(_nps_extra_item(edat, "dlc", edat.stem))
|
continue
|
||||||
|
name = _sidecar_name(edat.parent) or edat.stem
|
||||||
|
out.append(_nps_extra_item(edat, "dlc", name))
|
||||||
return out
|
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):
|
def _nps_extra_item(path, psp_kind, name):
|
||||||
try:
|
try:
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
|
|||||||
+511
@@ -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
|
||||||
@@ -506,6 +506,18 @@ class NoPayStationSource(Source):
|
|||||||
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
||||||
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip,
|
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip,
|
||||||
kind_hint, progress_cb, cancel_event)
|
kind_hint, progress_cb, cancel_event)
|
||||||
|
if self.content_type in ("theme", "dlc"):
|
||||||
|
_write_sidecar(folder, item)
|
||||||
return folder
|
return folder
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user