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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user