ps1: embed real game art (ICON0 + PIC1) in converted EBOOTs

Fetch the game's cover art (libretro / ScreenScraper / IGDB / Wikipedia,
reusing the local cover cache) and bake it into the EBOOT's ICON0 (144x80
XMB icon) and PIC1 (480x272 launch background) instead of the default PS1
icon. Best-effort: falls back to the base icon/empty background when no art
is found. convert()/convert_to_folder() gain optional icon0/pic1 params.
This commit is contained in:
2026-09-07 18:39:29 -04:00
parent f9d9d639ed
commit fb2a98dd21
4 changed files with 93 additions and 8 deletions
+5
View File
@@ -202,6 +202,11 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab
compressed PSAR) plus a `KEYS.BIN` (NPDRM version key used to decrypt the compressed PSAR) plus a `KEYS.BIN` (NPDRM version key used to decrypt the
`STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible `STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible
with CFW POPS (ARK-4/ARK-5). 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.
Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
+65
View File
@@ -808,6 +808,71 @@ def compose_game_cover(key_art_bytes):
return bytes(ba) return bytes(ba)
def make_ps1_assets(cover_bytes):
"""Build ICON0.PNG (144x80) and PIC1.PNG (480x272) from PS1 cover art.
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.
"""
try:
from PySide6.QtCore import QBuffer, QByteArray, Qt
from PySide6.QtGui import QImage, QPainter
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None, None
art = QImage.fromData(cover_bytes)
if art.isNull():
return None, None
art = art.convertToFormat(QImage.Format_ARGB32)
def _save(img):
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QBuffer.WriteOnly)
img.save(buf, "PNG")
buf.close()
return bytes(ba)
# ICON0: fit the cover inside 144x80 (transparent margins).
icon = QImage(144, 80, QImage.Format_ARGB32)
icon.fill(Qt.transparent)
p = QPainter(icon)
scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled)
p.end()
# PIC1: crop the cover to fill 480x272.
pic = QImage(480, 272, QImage.Format_ARGB32)
pic.fill(Qt.transparent)
p = QPainter(pic)
scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled)
p.end()
return _save(icon), _save(pic)
def ps1_art(title, release_id, timeout=10):
"""Fetch a PS1 game's cover and return ``(icon0, 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.
"""
try:
path = fetch_cover(title, release_id, "ps1", timeout=timeout)
except Exception: # noqa: BLE001
return None, None
if path is None:
return None, None
try:
return make_ps1_assets(path.read_bytes())
except OSError:
return None, None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Public entry point # Public entry point
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+14 -7
View File
@@ -871,6 +871,8 @@ def convert(
progress_cb=None, progress_cb=None,
cancel_event=None, cancel_event=None,
complevel: int = 1, complevel: int = 1,
icon0: Optional[bytes] = None,
pic1: Optional[bytes] = None,
) -> Path: ) -> Path:
"""Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``). """Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``).
@@ -878,6 +880,9 @@ def convert(
``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT. ``.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 ``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). 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.
""" """
if not base_pbp.is_file(): if not base_pbp.is_file():
raise ConversionError(f"base.pbp not found: {base_pbp}") raise ConversionError(f"base.pbp not found: {base_pbp}")
@@ -901,11 +906,10 @@ def convert(
# Reuse the base PBP's SFO, patched with our title + game id (the # Reuse the base PBP's SFO, patched with our title + game id (the
# popstation approach), rather than rebuilding it from scratch. # popstation approach), rather than rebuilding it from scratch.
sfo = patch_sfo(extract_sfo(base_pbp), title, game_id) sfo = patch_sfo(extract_sfo(base_pbp), title, game_id)
# Reuse the base PBP's ICON0 too. The XMB icon parser is picky about PNG # Reuse the base PBP's ICON0 when no custom icon was supplied (the XMB
# variants, and the base icon is guaranteed to decode; the other image # icon parser is picky about PNG variants, and the base icon is guaranteed
# sections are left empty exactly like popstation when no assets are # to decode). PIC1 is the launch background; empty when no art is given.
# supplied alongside the conversion. icon0 = icon0 or extract_icon0(base_pbp)
icon0 = extract_icon0(base_pbp)
# Sections in header order; empty sections keep the previous offset. # Sections in header order; empty sections keep the previous offset.
sections = { sections = {
@@ -913,7 +917,7 @@ def convert(
"icon0": icon0, "icon0": icon0,
"icon1": b"", "icon1": b"",
"pic0": b"", "pic0": b"",
"pic1": b"", "pic1": pic1 or b"",
"snd0": b"", "snd0": b"",
"data_psp": data_psp, "data_psp": data_psp,
# psar written separately, sized after everything else. # psar written separately, sized after everything else.
@@ -981,6 +985,8 @@ def convert_to_folder(
progress_cb=None, progress_cb=None,
cancel_event=None, cancel_event=None,
complevel: int = 1, complevel: int = 1,
icon0: Optional[bytes] = None,
pic1: Optional[bytes] = None,
) -> Path: ) -> Path:
"""Convert a PS1 disc into a game folder ready for /PSP/GAME. """Convert a PS1 disc into a game folder ready for /PSP/GAME.
@@ -993,7 +999,8 @@ def convert_to_folder(
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP", convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP",
progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel) progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel,
icon0=icon0, pic1=pic1)
return folder return folder
+9 -1
View File
@@ -237,12 +237,20 @@ class Ps1ConvertSource(Source):
if progress_cb is not None and total: if progress_cb is not None and total:
progress_cb("Converting", min(done / total, 1.0), "") progress_cb("Converting", min(done / total, 1.0), "")
from . import ps1_convert 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.
if progress_cb is not None:
progress_cb("Artwork", 0.0, "")
icon0, pic1 = covers.ps1_art(title, game_id)
try: try:
ps1_convert.convert_to_folder( ps1_convert.convert_to_folder(
source, title, game_id, self.base_pbp, dest_dir, source, title, game_id, self.base_pbp, dest_dir,
progress_cb=cb, cancel_event=cancel_event, progress_cb=cb, cancel_event=cancel_event,
icon0=icon0, pic1=pic1,
) )
except ps1_convert.Cancelled: except ps1_convert.Cancelled:
raise TransferCancelled() from None raise TransferCancelled() from None