From f6dccbec244b8952b330590527e1aa5ac27497ed Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 7 Sep 2026 19:23:38 -0400 Subject: [PATCH] ps1: official-style ICON0/PIC0/PIC1 artwork with PSN templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the letterboxed 144x80 icon + cropped PIC1 with official-looking PS1 Classics art: - ICON0 (80x80): cover art composited into the bundled case frame (PS logo + 'PlayStation.' wordmark) from data/ps1_templates/ICON0.PNG. - PIC0 (310x180): a PSN-style info board drawn in code — title, IGDB description, ESRB badge (from IGDB age_ratings) and PS3/PSP badges. - PIC1 (480x272): the bundled launch background template. The converter now embeds PIC0, the PS1 tab previews all three images, and ps1_art pulls IGDB description/ESRB as best effort. Templates are git-ignored (user-supplied, like base.PBP) with a drawn-art fallback when missing. --- .gitignore | 4 + README.md | 17 ++- app/covers.py | 286 ++++++++++++++++++++++++++++++++++++++++----- app/gui.py | 70 ++++++----- app/ps1_convert.py | 12 +- app/transfers.py | 8 +- 6 files changed, 325 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index e53645e..fe91e67 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ session-transcript-2026-09-01.md # Copyrighted PSN EBOOTs (base.pbp) used only for local PS1 conversion testing data/*.PBP data/*.pbp + +# User-supplied PS1 Classics art templates (extracted from copyrighted PSN +# EBOOTs) — kept local only; the app falls back to drawn art when absent. +data/ps1_templates/ diff --git a/README.md b/README.md index 97515b2..22e1f2d 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ Discs are matched by hash first (most precise), then serial, then filename. + a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted). - `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS loader and NPDRM version key (from iPoPS, MIT). +- `data/ps1_templates/` — optional PS1 Classics art templates + (`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back + to drawn art when they're absent. - State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): - `library.sqlite` — the catalog + library database. - `covers/` — downloaded cover cache. @@ -203,10 +206,16 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab `STARTDAT` PGD), ready to copy to `/PSP/GAME//`. Compatible with CFW POPS (ARK-4/ARK-5). - **Artwork** — the converter fetches the game's real cover art (libretro / - ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and bakes - it into the EBOOT's `ICON0` (144×80 XMB icon) and `PIC1` (480×272 launch - background), so the PSP shows the actual game rather than the default PS1 - icon. Falls back to the base icon when no art is found. + ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and builds + official-style PS1 Classics art: + - `ICON0` (80×80) — the cover composited into the bundled case frame + (`data/ps1_templates/ICON0.PNG`) with the PlayStation logo + wordmark; + - `PIC0` (310×180) — a PSN-style info board with the game title, a + description from IGDB, an ESRB rating badge and PS3/PSP platform badges; + - `PIC1` (480×272) — the bundled launch background + (`data/ps1_templates/PIC1.PNG`). + If the template images are missing the app falls back to drawn art, and to + the base icon when no cover is found. Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc diff --git a/app/covers.py b/app/covers.py index d36a923..0181cd8 100644 --- a/app/covers.py +++ b/app/covers.py @@ -808,23 +808,63 @@ def compose_game_cover(key_art_bytes): return bytes(ba) -def make_ps1_assets(cover_bytes): - """Build ICON0.PNG (144x80) and PIC1.PNG (480x272) from PS1 cover art. +_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates" - Returns ``(icon0_bytes, pic1_bytes)``, or ``(None, None)`` when the art - can't be decoded (or Qt isn't available). ICON0 is the art letterboxed - inside the icon frame; PIC1 is the art cropped to fill the launch - background. + +def _ps1_template_bytes(name): + """Read a bundled PS1 art template (``ICON0.PNG`` / ``PIC1.PNG``).""" + try: + p = _PS1_TEMPLATE_DIR / name + if p.is_file(): + return p.read_bytes() + except OSError: + pass + return None + + +def _slot_rect(img): + """Find the fully-transparent cover slot in an icon template image. + + Returns ``(x, y, w, h)`` or ``None`` when the image has no transparent + region (e.g. a flattened template).""" + if img.isNull(): + return None + w, h = img.width(), img.height() + min_x = min_y = 1 << 30 + max_x = max_y = -1 + for y in range(h): + for x in range(w): + if img.pixelColor(x, y).alpha() < 10: + min_x, max_x = min(min_x, x), max(max_x, x) + min_y, max_y = min(min_y, y), max(max_y, y) + if max_x < min_x or max_y < min_y: + return None + return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1 + + +def make_ps1_assets(cover_bytes, title="", description="", esrb=None): + """Build official-style PS1 Classics art from the cover: (icon0, pic0, pic1). + + - ``ICON0`` (80x80): the cover composited into the bundled case template + (PS button logo, ``PlayStation.`` wordmark, gradient frame). + - ``PIC0`` (310x180): the PSN-style info board — title, description, + ESRB rating badge and PS3/PSP platform badges. + - ``PIC1`` (480x272): the bundled launch background (gradient + curve). + + Returns ``(None, None, None)`` when the art can't be decoded or Qt is + unavailable. """ try: - from PySide6.QtCore import QBuffer, QByteArray, Qt - from PySide6.QtGui import QImage, QPainter + from PySide6.QtCore import QBuffer, QByteArray, QPointF, QRectF, Qt + from PySide6.QtGui import (QColor, QFont, QFontMetrics, QImage, + QLinearGradient, QPainter, QPainterPath, + QPen, QPolygonF) except Exception: # noqa: BLE001 - headless API may lack PySide6 - return None, None + return None, None, None art = QImage.fromData(cover_bytes) if art.isNull(): - return None, None + return None, None, None art = art.convertToFormat(QImage.Format_ARGB32) def _save(img): @@ -835,43 +875,215 @@ def make_ps1_assets(cover_bytes): buf.close() return bytes(ba) - # ICON0: fit the cover inside 144x80 (transparent margins). - icon = QImage(144, 80, QImage.Format_ARGB32) - icon.fill(Qt.transparent) + # --- ICON0: cover art inside the case template --------------------------- + icon = QImage(80, 80, QImage.Format_ARGB32) + icon.fill(QColor(0, 0, 0)) p = QPainter(icon) - scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) - p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled) + p.setRenderHint(QPainter.SmoothPixmapTransform, True) + tmpl = QImage.fromData(_ps1_template_bytes("ICON0.PNG") or b"") + if not tmpl.isNull(): + tmpl = tmpl.convertToFormat(QImage.Format_ARGB32) + slot = _slot_rect(tmpl) or (0, 0, 80, 80) + sx, sy, sw, sh = slot + cover = art.scaled(sw, sh, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + p.drawImage(sx + (sw - cover.width()) // 2, sy + (sh - cover.height()) // 2, cover) + p.drawImage(0, 0, tmpl) + else: + cover = art.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) + p.drawImage((80 - cover.width()) // 2, (80 - cover.height()) // 2, cover) p.end() + icon0 = _save(icon) - # PIC1: crop the cover to fill 480x272. + # --- PIC0: PSN-style info board ----------------------------------------- + pic0 = _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, + QColor, QFont, QFontMetrics, QLinearGradient, QPen, + QPolygonF, QPointF, QRectF, Qt, _save) + + # --- PIC1: bundled launch background ------------------------------------ + pic1 = _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save) + + return icon0, pic0, pic1 + + +def _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save): pic = QImage(480, 272, QImage.Format_ARGB32) - pic.fill(Qt.transparent) + raw = _ps1_template_bytes("PIC1.PNG") + tmpl = QImage.fromData(raw) if raw else QImage() + if not tmpl.isNull(): + return _save(tmpl.convertToFormat(QImage.Format_ARGB32)) + # Fallback: a light lavender gradient with a subtle curve (same vibe as + # the official background) for when the template image is missing. + grad = QLinearGradient(0, 0, 0, 272) + grad.setColorAt(0.0, QColor(235, 232, 238)) + grad.setColorAt(1.0, QColor(205, 200, 215)) p = QPainter(pic) - scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled) + p.fillRect(0, 0, 480, 272, grad) + p.setPen(QPen(QColor(170, 160, 185, 120), 3)) + path = QPainterPath() + path.moveTo(-40, 300) + path.quadTo(240, 60, 520, 300) + p.drawPath(path) p.end() + return _save(pic) - return _save(icon), _save(pic) + +def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor, + QFont, QFontMetrics, QLinearGradient, QPen, QPolygonF, QPointF, + QRectF, Qt, _save): + W, H = 310, 180 + img = QImage(W, H, QImage.Format_ARGB32) + + # Dark gradient backdrop. + grad = QLinearGradient(0, 0, 0, H) + grad.setColorAt(0.0, QColor(38, 38, 40)) + grad.setColorAt(1.0, QColor(12, 12, 14)) + + p = QPainter(img) + p.setRenderHint(QPainter.Antialiasing, True) + p.setRenderHint(QPainter.TextAntialiasing, True) + p.fillRect(0, 0, W, H, grad) + + # Rounded border. + p.setPen(QPen(QColor(110, 110, 115), 1)) + p.setBrush(Qt.NoBrush) + p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9) + + # PlayStation button glyphs, top-right. + _draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF, + QPolygonF, QPen, Qt) + + # Title (bold, upper-left). + f_title = QFont("DejaVu Sans") + f_title.setPixelSize(19) + f_title.setBold(True) + p.setFont(f_title) + p.setPen(QColor(255, 255, 255)) + p.drawText(QRectF(18, 16, W - 64, 28), Qt.AlignLeft | Qt.AlignVCenter, + title or "") + + # Description (wrapped, below title). + if description: + f_desc = QFont("DejaVu Sans") + f_desc.setPixelSize(12) + p.setFont(f_desc) + p.setPen(QColor(215, 215, 215)) + text = description.strip() + fm = QFontMetrics(f_desc) + # Rough 3-line budget (~40 chars/line at 12px across 272px). + if fm.horizontalAdvance(text) > 3 * (W - 36): + text = text[:130].rstrip() + "\u2026" + p.drawText(QRectF(18, 46, W - 36, 74), + Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, text) + + # ESRB badge (bottom-left) + caption. + if esrb: + badge = esrb.strip() + if badge: + _draw_esrb(p, badge, 18, 140, QRectF, QColor, QFont, QFontMetrics, QPen, Qt) + + # Platform badges (bottom-right). + _draw_platform_badge(p, "PS3", W - 14 - 64, 146, QColor(94, 146, 210), + QRectF, QColor, QFont, QFontMetrics, Qt) + _draw_platform_badge(p, "PSP", W - 14 - 30, 146, QColor(212, 175, 55), + QRectF, QColor, QFont, QFontMetrics, Qt) + + p.end() + return _save(img) + + +def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt): + """Draw the four PlayStation face-button glyphs in a 2x2 grid.""" + cell = size / 2.0 + pen = QPen(color, max(1.0, size / 14.0)) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + # Triangle (top-left). + tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)]) + p.drawPolygon(tri) + # Circle (top-right). + p.drawEllipse(QRectF(x + cell, y, cell, cell)) + # Cross (bottom-left). + p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1)) + p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1)) + # Square (bottom-right). + p.drawRect(QRectF(x + cell, y + cell, cell, cell)) + + +def _draw_esrb(p, badge, x, y, QRectF, QColor, QFont, QFontMetrics, QPen, Qt): + f = QFont("DejaVu Sans") + f.setPixelSize(9) + f.setBold(True) + fm = QFontMetrics(f) + text = badge if badge != "EVERYONE" else "EVERYONE" + # Shrink until it fits the box (the official badge shows the word). + size = 9 + while size > 5 and fm.horizontalAdvance(text) > 46: + size -= 1 + f.setPixelSize(size) + fm = QFontMetrics(f) + rect = QRectF(x, y, 50, 24) + p.setPen(QPen(QColor(20, 20, 20), 1)) + p.setBrush(QColor(255, 255, 255)) + p.drawRoundedRect(rect, 3, 3) + p.setPen(QColor(20, 20, 20)) + p.setFont(f) + p.drawText(rect, Qt.AlignCenter, text) + # Caption. + fc = QFont("DejaVu Sans") + fc.setPixelSize(7) + p.setFont(fc) + p.setPen(QColor(150, 150, 155)) + p.drawText(QRectF(x + 56, y + 6, 120, 14), + Qt.AlignLeft | Qt.AlignVCenter, "ESRB CONTENT RATING") + + +def _draw_platform_badge(p, label, x, y, color, QRectF, QColor, QFont, + QFontMetrics, Qt): + f = QFont("DejaVu Sans") + f.setPixelSize(10) + f.setBold(True) + rect = QRectF(x, y, 30, 16) + p.setPen(Qt.NoPen) + p.setBrush(QColor(26, 26, 28)) + p.drawRoundedRect(rect, 4, 4) + p.setPen(color) + p.setFont(f) + p.drawText(rect, Qt.AlignCenter, label) def ps1_art(title, release_id, timeout=10): - """Fetch a PS1 game's cover and return ``(icon0, pic1)`` PNG bytes. + """Fetch a PS1 game's cover and build ``(icon0, pic0, pic1)`` PNG bytes. Reuses the local cover cache when available, otherwise searches the PS1 - sources (libretro, ScreenScraper, IGDB, Wikipedia). Returns - ``(None, None)`` when no art can be produced. + sources (libretro, ScreenScraper, IGDB, Wikipedia). The description and + ESRB rating for the PIC0 info board come from IGDB (best effort). + Returns ``(None, None, None)`` when no art can be produced. """ key = release_id or stable_key(title) try: path = fetch_cover(title, key, "ps1", timeout=timeout) except Exception: # noqa: BLE001 - return None, None + return None, None, None if path is None: - return None, None + return None, None, None + + description = "" + esrb = None try: - return make_ps1_assets(path.read_bytes()) + meta = get_game_metadata_igdb(title, timeout=timeout, system="ps1") + if meta: + description = meta.get("overview") or "" + esrb = meta.get("esrb") + except Exception: # noqa: BLE001 - metadata is best effort + pass + + try: + return make_ps1_assets(path.read_bytes(), title=title, + description=description, esrb=esrb) except OSError: - return None, None + return None, None, None # --------------------------------------------------------------------------- # @@ -1321,6 +1533,21 @@ def _igdb_date(ts): return "" +# IGDB ``age_ratings.rating`` values for ESRB (category 1). 6=RP, 7=EC, 8=E, +# 9=E10+, 10=T, 11=M, 12=AO. The badge uses the short letter form. +_ESRB_BADGE = {6: "RP", 7: "EC", 8: "EVERYONE", 9: "EVERYONE 10+", + 10: "TEEN", 11: "MATURE", 12: "ADULTS ONLY"} + + +def _igdb_esrb(g): + for ar in g.get("age_ratings") or []: + if not isinstance(ar, dict): + continue + if ar.get("category") == 1: # ESRB + return _ESRB_BADGE.get(ar.get("rating")) + return None + + def _igdb_game_meta(g): genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")] devs, pubs = [], [] @@ -1353,6 +1580,7 @@ def _igdb_game_meta(g): "genres": list(dict.fromkeys(genres)), "players": None, "rating": rating, + "esrb": _igdb_esrb(g), "youtube": yt, } @@ -1367,7 +1595,7 @@ def _igdb_search(query, limit=20, timeout=10, system="games"): f'search "{term}"; ' "fields name, cover.image_id, first_release_date, summary, genres.name, " "involved_companies.company.name, involved_companies.developer, involved_companies.publisher, " - "rating, videos.video_id; " + "rating, videos.video_id, age_ratings.category, age_ratings.rating; " f"where platforms = ({platform_id}); " f"limit {max(limit, 1)};" ) @@ -1426,7 +1654,7 @@ def game_metadata_by_id_igdb(game_id, timeout=10): q = ( "fields name, cover.image_id, first_release_date, summary, genres.name, " "involved_companies.company.name, involved_companies.developer, involved_companies.publisher, " - "rating, videos.video_id; " + "rating, videos.video_id, age_ratings.category, age_ratings.rating; " f"where id = {int(game_id)}; limit 1;" ) games = _igdb_post(q, timeout) or [] diff --git a/app/gui.py b/app/gui.py index 662fa07..fc835fa 100644 --- a/app/gui.py +++ b/app/gui.py @@ -3366,9 +3366,9 @@ class Ps1ScanWorker(QObject): class Ps1PreviewWorker(QObject): - """Fetch a disc's cover art and build the EBOOT icon/background preview.""" + """Fetch a disc's cover art and build the EBOOT art preview.""" - result = Signal(int, str, object, object) # gen, game_id, icon0 bytes, pic1 bytes + result = Signal(int, str, object, object, object) # gen, game_id, icon0, pic0, pic1 finished = Signal() def __init__(self, gen, title, game_id): @@ -3379,10 +3379,10 @@ class Ps1PreviewWorker(QObject): def run(self): try: - icon0, pic1 = covers.ps1_art(self.title, self.game_id) + icon0, pic0, pic1 = covers.ps1_art(self.title, self.game_id) except Exception: # noqa: BLE001 - icon0, pic1 = None, None - self.result.emit(self.gen, self.game_id, icon0, pic1) + icon0, pic0, pic1 = None, None, None + self.result.emit(self.gen, self.game_id, icon0, pic0, pic1) self.finished.emit() @@ -3427,8 +3427,12 @@ class Ps1Page(QWidget): self.preview_title.setStyleSheet("font-weight: bold;") self.preview_icon0 = QLabel() self.preview_icon0.setAlignment(Qt.AlignCenter) - self.preview_icon0.setFixedSize(288, 160) + self.preview_icon0.setFixedSize(160, 160) self.preview_icon0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;") + self.preview_pic0 = QLabel() + self.preview_pic0.setAlignment(Qt.AlignCenter) + self.preview_pic0.setFixedSize(310, 180) + self.preview_pic0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;") self.preview_pic1 = QLabel() self.preview_pic1.setAlignment(Qt.AlignCenter) self.preview_pic1.setFixedSize(480, 272) @@ -3439,16 +3443,24 @@ class Ps1Page(QWidget): pv.setContentsMargins(8, 0, 0, 0) pv.addWidget(QLabel("Preview")) pv.addWidget(self.preview_title) - pv.addWidget(QLabel("ICON0 — XMB icon")) + pv.addWidget(QLabel("ICON0 — XMB icon (80×80)")) pv.addWidget(self.preview_icon0) - pv.addWidget(QLabel("PIC1 — launch background")) + pv.addWidget(QLabel("PIC0 — info board (310×180)")) + pv.addWidget(self.preview_pic0) + pv.addWidget(QLabel("PIC1 — launch background (480×272)")) pv.addWidget(self.preview_pic1) pv.addStretch(1) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(preview) + scroll.setFrameShape(QScrollArea.NoFrame) + splitter = QSplitter(Qt.Horizontal) splitter.addWidget(self.table) - splitter.addWidget(preview) + splitter.addWidget(scroll) splitter.setStretchFactor(0, 1) + splitter.setSizes([640, 500]) layout.addWidget(splitter, 1) def _on_preview_changed(self): @@ -3466,6 +3478,8 @@ class Ps1Page(QWidget): self.preview_title.setText(e["name"]) self.preview_icon0.setPixmap(QPixmap()) self.preview_icon0.setText("Loading…") + self.preview_pic0.setPixmap(QPixmap()) + self.preview_pic0.setText("Loading…") self.preview_pic1.setPixmap(QPixmap()) self.preview_pic1.setText("Loading…") self._preview_gen += 1 @@ -3484,34 +3498,30 @@ class Ps1Page(QWidget): self._preview_threads.append(thread) self._preview_workers.append(worker) - def _on_preview_result(self, gen, game_id, icon0, pic1): + def _on_preview_result(self, gen, game_id, icon0, pic0, pic1): if gen != self._preview_gen: return self.preview_icon0.setText("") + self.preview_pic0.setText("") self.preview_pic1.setText("") - if icon0: - pm = QPixmap() - pm.loadFromData(icon0) - self.preview_icon0.setPixmap( - pm.scaled(self.preview_icon0.width(), self.preview_icon0.height(), - Qt.KeepAspectRatio, Qt.SmoothTransformation)) - else: - self.preview_icon0.setText("No art found") - if pic1: - pm = QPixmap() - pm.loadFromData(pic1) - self.preview_pic1.setPixmap( - pm.scaled(self.preview_pic1.width(), self.preview_pic1.height(), - Qt.KeepAspectRatio, Qt.SmoothTransformation)) - else: - self.preview_pic1.setText("No art found — the default icon will be used") + for label, data, fallback in ( + (self.preview_icon0, icon0, "No art found"), + (self.preview_pic0, pic0, "No art found"), + (self.preview_pic1, pic1, "No art found — the default icon will be used"), + ): + if data: + pm = QPixmap() + pm.loadFromData(data) + label.setPixmap(pm.scaled(label.width(), label.height(), + Qt.KeepAspectRatio, Qt.SmoothTransformation)) + else: + label.setText(fallback) def _reset_preview(self, message): self.preview_title.setText(message) - self.preview_icon0.setPixmap(QPixmap()) - self.preview_icon0.setText("") - self.preview_pic1.setPixmap(QPixmap()) - self.preview_pic1.setText("") + for label in (self.preview_icon0, self.preview_pic0, self.preview_pic1): + label.setPixmap(QPixmap()) + label.setText("") def set_dirs(self, ps1_dir, output_dir): self._ps1_dir = ps1_dir diff --git a/app/ps1_convert.py b/app/ps1_convert.py index 3fb1671..ddb0789 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -872,6 +872,7 @@ def convert( cancel_event=None, complevel: int = 1, icon0: Optional[bytes] = None, + pic0: Optional[bytes] = None, pic1: Optional[bytes] = None, ) -> Path: """Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``). @@ -880,9 +881,9 @@ def convert( ``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT. ``complevel`` is the DEFLATE level for the disc data (1-9; 0 = raw, which does not boot on real hardware and is only useful for diagnostics). - ``icon0`` (144x80 PNG) and ``pic1`` (480x272 PNG) override the generated - icon/background; when absent the base PBP's ICON0 is reused and PIC1 is - left empty. + ``icon0`` (80x80 PNG), ``pic0`` (310x180 PNG) and ``pic1`` (480x272 PNG) + override the generated art; when absent the base PBP's ICON0 is reused + and PIC0/PIC1 are left empty. """ if not base_pbp.is_file(): raise ConversionError(f"base.pbp not found: {base_pbp}") @@ -916,7 +917,7 @@ def convert( "sfo": sfo, "icon0": icon0, "icon1": b"", - "pic0": b"", + "pic0": pic0 or b"", "pic1": pic1 or b"", "snd0": b"", "data_psp": data_psp, @@ -986,6 +987,7 @@ def convert_to_folder( cancel_event=None, complevel: int = 1, icon0: Optional[bytes] = None, + pic0: Optional[bytes] = None, pic1: Optional[bytes] = None, ) -> Path: """Convert a PS1 disc into a game folder ready for /PSP/GAME. @@ -1000,7 +1002,7 @@ def convert_to_folder( convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP", progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel, - icon0=icon0, pic1=pic1) + icon0=icon0, pic0=pic0, pic1=pic1) return folder diff --git a/app/transfers.py b/app/transfers.py index 98f2192..018ef28 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -240,17 +240,17 @@ class Ps1ConvertSource(Source): from . import covers, ps1_convert # Pull the game's real cover art (libretro / ScreenScraper / IGDB / - # Wikipedia) and bake it into ICON0 + PIC1 so the XMB shows the actual - # game, not the default PS1 icon. Best effort: fall back to defaults. + # Wikipedia) and bake it into ICON0 + PIC0 + PIC1 so the XMB shows the + # official-style art, not the default PS1 icon. Best effort. if progress_cb is not None: progress_cb("Artwork", 0.0, "") - icon0, pic1 = covers.ps1_art(title, game_id) + icon0, pic0, pic1 = covers.ps1_art(title, game_id) try: ps1_convert.convert_to_folder( source, title, game_id, self.base_pbp, dest_dir, progress_cb=cb, cancel_event=cancel_event, - icon0=icon0, pic1=pic1, + icon0=icon0, pic0=pic0, pic1=pic1, ) except ps1_convert.Cancelled: raise TransferCancelled() from None