Add NoPayStation themes, DLC and minis support
This commit is contained in:
@@ -274,9 +274,14 @@ skipped — and you'll get a clear error if *everything* in the item is private.
|
|||||||
### 4. NoPayStation
|
### 4. NoPayStation
|
||||||
|
|
||||||
The **NoPayStation** source lists PSN content from
|
The **NoPayStation** source lists PSN content from
|
||||||
[NoPayStation](https://nopaystation.com/) — **PSP games** (`PSP_GAMES.tsv`) and
|
[NoPayStation](https://nopaystation.com/), selectable from the content-type
|
||||||
**PS1 Classics** (`PSX_GAMES.tsv`), selectable from the content-type dropdown
|
dropdown next to the source selector:
|
||||||
next to the source selector.
|
|
||||||
|
- **PSP Games** (`PSP_GAMES.tsv`) — includes **minis** (also listed separately
|
||||||
|
in the `Type` column), PC Engine and NeoGeo releases.
|
||||||
|
- **PS1 Classics** (`PSX_GAMES.tsv`)
|
||||||
|
- **PSP Themes** (`PSP_THEMES.tsv`)
|
||||||
|
- **PSP DLC** (`PSP_DLCS.tsv`)
|
||||||
|
|
||||||
Requirements & behaviour:
|
Requirements & behaviour:
|
||||||
|
|
||||||
@@ -286,12 +291,17 @@ Requirements & behaviour:
|
|||||||
only, and only runs if Python decryption fails and Wine is present.
|
only, and only runs if Python decryption fails and Wine is present.
|
||||||
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
||||||
`$PSP_STATE_DIR/nps`).
|
`$PSP_STATE_DIR/nps`).
|
||||||
3. Downloading a PSP game writes `PSP/<TITLE_ID>/EBOOT.PBP` (plus
|
3. Downloads land in the NPS folder and appear in the **Library**:
|
||||||
`PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic
|
- PSP game → `PSP/<TITLE_ID>/EBOOT.PBP` (`psp_kind="eboot"`).
|
||||||
writes `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). Both
|
- PS1 Classic → `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT`, `KEYS.BIN`).
|
||||||
then show up in the **Library** (PS1 Classics under **PS1**, PSP games
|
- Theme → `THEME/<TITLE_ID>/<content_id>.ptf` (the packaged PTF is
|
||||||
under **Games**) and can be copied to the PSP like any other item.
|
EDAT-unwrapped to a raw theme file).
|
||||||
4. The TSV lists are cached in `data/nps/` and refreshed on each store scan
|
- DLC → `DLC/<GAME_ID>/<content_id>.edat` (installed as-is into the owning
|
||||||
|
game's folder).
|
||||||
|
4. Copying to the PSP puts games/PS1 under `/PSP/GAME/` (optionally nested in
|
||||||
|
a Category Lite folder), themes under `/PSP/THEME/`, and DLC under
|
||||||
|
`/PSP/GAME/<GAME_ID>/`.
|
||||||
|
5. The TSV lists are cached in `data/nps/` and refreshed on each store scan
|
||||||
(drop the `.tsv` files there manually to work offline).
|
(drop the `.tsv` files there manually to work offline).
|
||||||
|
|
||||||
> **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the
|
> **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the
|
||||||
|
|||||||
+95
-11
@@ -1681,7 +1681,7 @@ class StorePage(QWidget):
|
|||||||
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||||
bar.addWidget(self.source_combo)
|
bar.addWidget(self.source_combo)
|
||||||
self.nps_type = QComboBox()
|
self.nps_type = QComboBox()
|
||||||
self.nps_type.addItems(["PSP Games", "PS1 Classics"])
|
self.nps_type.addItems(["PSP Games", "PS1 Classics", "PSP Themes", "PSP DLC"])
|
||||||
self.nps_type.setVisible(False)
|
self.nps_type.setVisible(False)
|
||||||
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
||||||
bar.addWidget(self.nps_type)
|
bar.addWidget(self.nps_type)
|
||||||
@@ -1788,7 +1788,7 @@ class StorePage(QWidget):
|
|||||||
if self.source_combo.currentIndex() == 1:
|
if self.source_combo.currentIndex() == 1:
|
||||||
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
|
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
|
||||||
if self.source_combo.currentIndex() == 3:
|
if self.source_combo.currentIndex() == 3:
|
||||||
ct = "psp" if self.nps_type.currentIndex() == 0 else "psx"
|
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
||||||
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
|
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
|
||||||
return transfers.LocalShareSource(self._store_dir)
|
return transfers.LocalShareSource(self._store_dir)
|
||||||
|
|
||||||
@@ -3266,11 +3266,13 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
|
|||||||
|
|
||||||
|
|
||||||
def _nps_library_items(nps_dir, overrides, dates):
|
def _nps_library_items(nps_dir, overrides, dates):
|
||||||
"""NoPayStation-downloaded PSP games and PS1 Classics, merged into the library.
|
"""NoPayStation-downloaded content, merged into the library.
|
||||||
|
|
||||||
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games) and ``<nps>/PSX/*/EBOOT.PBP``
|
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games), ``<nps>/PSX/*/EBOOT.PBP``
|
||||||
(PS1 Classics). PSP games are ``system="games"`` with ``psp_kind="eboot"``;
|
(PS1 Classics), ``<nps>/THEME/*/*.ptf`` (themes) and ``<nps>/DLC/*/*.edat``
|
||||||
PSX games are ``system="ps1"`` with ``ps1_kind="eboot"``.
|
(DLC). PSP games are ``system="games"`` with ``psp_kind="eboot"``; PSX games
|
||||||
|
are ``system="ps1"`` with ``ps1_kind="eboot"``; themes/DLC use
|
||||||
|
``psp_kind="theme"/"dlc"``.
|
||||||
"""
|
"""
|
||||||
if not nps_dir:
|
if not nps_dir:
|
||||||
return []
|
return []
|
||||||
@@ -3308,9 +3310,43 @@ def _nps_library_items(nps_dir, overrides, dates):
|
|||||||
else:
|
else:
|
||||||
item["psp_kind"] = "eboot"
|
item["psp_kind"] = "eboot"
|
||||||
out.append(item)
|
out.append(item)
|
||||||
|
|
||||||
|
# Themes: <nps>/THEME/<TITLE_ID>/<name>.ptf (extension case varies)
|
||||||
|
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))
|
||||||
|
|
||||||
|
# DLC: <nps>/DLC/<GAME_ID>/<name>.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))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _nps_extra_item(path, psp_kind, name):
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
rid = covers.stable_key(name)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"release_id": rid,
|
||||||
|
"system": psp_kind,
|
||||||
|
"canonical": name,
|
||||||
|
"serial": "",
|
||||||
|
"region": None,
|
||||||
|
"release_date": "",
|
||||||
|
"path": str(path),
|
||||||
|
"size": size,
|
||||||
|
"psp_kind": psp_kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LibraryWorker(QObject):
|
class LibraryWorker(QObject):
|
||||||
result = Signal(list)
|
result = Signal(list)
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
@@ -3957,7 +3993,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.library_search.textChanged.connect(self._apply_library_filter)
|
self.library_search.textChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_search, 1)
|
bar.addWidget(self.library_search, 1)
|
||||||
self.library_type = QComboBox()
|
self.library_type = QComboBox()
|
||||||
self.library_type.addItems(["All types", "Games", "Videos", "PS1"])
|
self.library_type.addItems(["All types", "Games", "Videos", "PS1", "Themes", "DLC"])
|
||||||
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_type)
|
bar.addWidget(self.library_type)
|
||||||
self.library_psp = QComboBox()
|
self.library_psp = QComboBox()
|
||||||
@@ -4227,6 +4263,10 @@ class MainWindow(QMainWindow):
|
|||||||
continue
|
continue
|
||||||
if typ == "PS1" and it["system"] != "ps1":
|
if typ == "PS1" and it["system"] != "ps1":
|
||||||
continue
|
continue
|
||||||
|
if typ == "Themes" and it["system"] != "theme":
|
||||||
|
continue
|
||||||
|
if typ == "DLC" and it["system"] != "dlc":
|
||||||
|
continue
|
||||||
if q and q not in (it["canonical"] or it["name"]).lower():
|
if q and q not in (it["canonical"] or it["name"]).lower():
|
||||||
continue
|
continue
|
||||||
if psp_f == "On PSP" and not it.get("on_psp"):
|
if psp_f == "On PSP" and not it.get("on_psp"):
|
||||||
@@ -4252,7 +4292,8 @@ class MainWindow(QMainWindow):
|
|||||||
title.setIcon(self._psp_dot_icon())
|
title.setIcon(self._psp_dot_icon())
|
||||||
title.setToolTip("Installed on PSP")
|
title.setToolTip("Installed on PSP")
|
||||||
self.library_table.setItem(row, 0, title)
|
self.library_table.setItem(row, 0, title)
|
||||||
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it["system"], "Game")
|
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1",
|
||||||
|
"theme": "Theme", "dlc": "DLC"}.get(it["system"], "Game")
|
||||||
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
|
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
|
||||||
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
|
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
|
||||||
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
||||||
@@ -4410,7 +4451,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._nps_dir:
|
if not self._nps_dir:
|
||||||
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
||||||
return
|
return
|
||||||
sub = "PSP" if src.content_type == "psp" else "PSX"
|
sub = {"psp": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(src.content_type, "PSP")
|
||||||
dest_dir = Path(self._nps_dir) / sub
|
dest_dir = Path(self._nps_dir) / sub
|
||||||
kind = "nps-download"
|
kind = "nps-download"
|
||||||
else:
|
else:
|
||||||
@@ -4457,6 +4498,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._maybe_auto_sync()
|
self._maybe_auto_sync()
|
||||||
|
|
||||||
def _on_psp(self, item):
|
def _on_psp(self, item):
|
||||||
|
psp_kind = item.get("psp_kind")
|
||||||
|
if psp_kind in ("theme", "dlc") and self._psp_root:
|
||||||
|
name = Path(item.get("path") or "").name
|
||||||
|
if psp_kind == "theme":
|
||||||
|
return (Path(self._psp_root) / "PSP" / "THEME" / name).exists()
|
||||||
|
game_id = Path(item.get("path") or "").parent.name
|
||||||
|
return (Path(self._psp_root) / "PSP" / "GAME" / game_id / name).exists()
|
||||||
if not self._psp_index:
|
if not self._psp_index:
|
||||||
return False
|
return False
|
||||||
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
||||||
@@ -4508,8 +4556,8 @@ class MainWindow(QMainWindow):
|
|||||||
return # UMD Video discs can't be played in PPSSPP
|
return # UMD Video discs can't be played in PPSSPP
|
||||||
if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot":
|
if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot":
|
||||||
return # raw PS1 discs are converted, not played
|
return # raw PS1 discs are converted, not played
|
||||||
if item.get("psp_kind") == "eboot":
|
if item.get("psp_kind") in ("eboot", "theme", "dlc"):
|
||||||
return # encrypted PSP PSN EBOOTs can't run in PPSSPP
|
return # encrypted PSN EBOOTs/themes/DLC can't run in PPSSPP
|
||||||
path = item.get("path")
|
path = item.get("path")
|
||||||
if not path or not Path(path).exists():
|
if not path or not Path(path).exists():
|
||||||
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
|
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
|
||||||
@@ -4582,6 +4630,23 @@ class MainWindow(QMainWindow):
|
|||||||
psp_root = Path(self._psp_root)
|
psp_root = Path(self._psp_root)
|
||||||
if system == "videos":
|
if system == "videos":
|
||||||
dest_dir = psp_root / "ISO" / "VIDEO"
|
dest_dir = psp_root / "ISO" / "VIDEO"
|
||||||
|
elif item.get("psp_kind") == "theme":
|
||||||
|
# Themes install as a single .ptf under /PSP/THEME.
|
||||||
|
dest_name = Path(item["path"]).name
|
||||||
|
dest_dir = psp_root / "PSP" / "THEME"
|
||||||
|
src = transfers.FileCopySource(item["path"])
|
||||||
|
log.info("copying theme to psp: %s -> %s", item["path"], dest_dir / dest_name)
|
||||||
|
self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install")
|
||||||
|
return True
|
||||||
|
elif item.get("psp_kind") == "dlc":
|
||||||
|
# DLC installs as a single .edat into /PSP/GAME/<GAME_ID>.
|
||||||
|
game_id = Path(item["path"]).parent.name
|
||||||
|
dest_name = Path(item["path"]).name
|
||||||
|
dest_dir = psp_root / "PSP" / "GAME" / game_id
|
||||||
|
src = transfers.FileCopySource(item["path"])
|
||||||
|
log.info("copying dlc to psp: %s -> %s", item["path"], dest_dir / dest_name)
|
||||||
|
self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install")
|
||||||
|
return True
|
||||||
elif system == "ps1" or item.get("psp_kind") == "eboot":
|
elif system == "ps1" or item.get("psp_kind") == "eboot":
|
||||||
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
|
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
|
||||||
# folder under /PSP/GAME (optionally nested in a Category Lite
|
# folder under /PSP/GAME (optionally nested in a Category Lite
|
||||||
@@ -4674,6 +4739,25 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(self, "Not on PSP", "That disc is not installed on the PSP.")
|
QMessageBox.information(self, "Not on PSP", "That disc is not installed on the PSP.")
|
||||||
return
|
return
|
||||||
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
||||||
|
psp_kind = item.get("psp_kind")
|
||||||
|
if psp_kind in ("theme", "dlc"):
|
||||||
|
name = Path(item.get("path") or "").name
|
||||||
|
target = (Path(self._psp_root) / "PSP" / "THEME" / name) if psp_kind == "theme" \
|
||||||
|
else (Path(self._psp_root) / "PSP" / "GAME" / Path(item.get("path") or "").parent.name / name)
|
||||||
|
if not target.exists():
|
||||||
|
QMessageBox.warning(self, "Not found", f"Could not find the installed copy on the PSP:\n{name}")
|
||||||
|
return
|
||||||
|
res = QMessageBox.question(
|
||||||
|
self, "Delete from PSP",
|
||||||
|
f"Delete this from the PSP?\n\n{name}\n\n{target}",
|
||||||
|
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if res != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
if not transfers.remove_path(target):
|
||||||
|
QMessageBox.warning(self, "Delete incomplete", f"Could not delete:\n{target}")
|
||||||
|
self._refresh_psp()
|
||||||
|
return
|
||||||
if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
|
if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
|
||||||
# PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder,
|
# PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder,
|
||||||
# matched by the source folder id (or title).
|
# matched by the source folder id (or title).
|
||||||
|
|||||||
+12
-6
@@ -22,7 +22,12 @@ from . import config
|
|||||||
|
|
||||||
log = logging.getLogger("nopaystation")
|
log = logging.getLogger("nopaystation")
|
||||||
|
|
||||||
_TSV_NAMES = {"psp": "PSP_GAMES.tsv", "psx": "PSX_GAMES.tsv"}
|
_TSV_NAMES = {
|
||||||
|
"psp": "PSP_GAMES.tsv",
|
||||||
|
"psx": "PSX_GAMES.tsv",
|
||||||
|
"theme": "PSP_THEMES.tsv",
|
||||||
|
"dlc": "PSP_DLCS.tsv",
|
||||||
|
}
|
||||||
|
|
||||||
# NoPayStation TSVs are tab-separated with a header row whose exact columns
|
# NoPayStation TSVs are tab-separated with a header row whose exact columns
|
||||||
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
|
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
|
||||||
@@ -45,7 +50,7 @@ _HEADER_ALIASES = {
|
|||||||
|
|
||||||
|
|
||||||
def tsv_url(content_type: str, base: str = "") -> str:
|
def tsv_url(content_type: str, base: str = "") -> str:
|
||||||
"""TSV URL for a content type (``psp`` / ``psx``)."""
|
"""TSV URL for a content type (``psp`` / ``psx`` / ``theme`` / ``dlc``)."""
|
||||||
base = (base or config.NPS_TSV_BASE).rstrip("/")
|
base = (base or config.NPS_TSV_BASE).rstrip("/")
|
||||||
return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}"
|
return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}"
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ def parse_tsv(text: str, content_type: str) -> List[dict]:
|
|||||||
``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``,
|
``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``,
|
||||||
``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX).
|
``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX).
|
||||||
"""
|
"""
|
||||||
system = "games" if content_type == "psp" else "ps1"
|
system = {"psp": "games", "psx": "ps1", "theme": "theme", "dlc": "dlc"}.get(content_type, "games")
|
||||||
items: List[dict] = []
|
items: List[dict] = []
|
||||||
header = None
|
header = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
@@ -187,7 +192,7 @@ def _ensure_keys_bin(folder, kind):
|
|||||||
|
|
||||||
|
|
||||||
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||||
progress_cb=None, cancel_event=None) -> Path:
|
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
|
||||||
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
||||||
|
|
||||||
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
||||||
@@ -199,12 +204,13 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
|||||||
|
|
||||||
pkg_path = Path(pkg_path)
|
pkg_path = Path(pkg_path)
|
||||||
dest = Path(dest_dir) / _safe_folder(title_id)
|
dest = Path(dest_dir) / _safe_folder(title_id)
|
||||||
if (dest / "EBOOT.PBP").exists():
|
if dest.is_dir() and any(dest.iterdir()):
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
# 1. Pure-Python decryptor (primary).
|
# 1. Pure-Python decryptor (primary).
|
||||||
try:
|
try:
|
||||||
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event)
|
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
|
||||||
|
progress_cb, cancel_event)
|
||||||
_ensure_keys_bin(folder, _kind)
|
_ensure_keys_bin(folder, _kind)
|
||||||
return folder
|
return folder
|
||||||
except TransferCancelled:
|
except TransferCancelled:
|
||||||
|
|||||||
+147
-30
@@ -1,10 +1,19 @@
|
|||||||
"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed).
|
"""Pure-Python PSP/PSX/theme/DLC PSN ``.pkg`` decryption (no Wine / pkg2zip).
|
||||||
|
|
||||||
Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip:
|
Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip:
|
||||||
parse the ``\\x7FPKG`` header, derive the PSP AES key, AES-CTR-decrypt the file
|
parse the ``\\x7FPKG`` header, derive the PSP/PS3 AES key, AES-CTR-decrypt the
|
||||||
entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The
|
file entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder.
|
||||||
resulting ``EBOOT.PBP`` keeps its DATA.PSP EDAT-encrypted, so it runs on CFW
|
|
||||||
with the ``npdrm_free`` plugin (ARK ships it) — same as pkgi-psp's install.
|
Supported content:
|
||||||
|
|
||||||
|
* PSP games / minis (``EBOOT.PBP``) — ``DATA.PSP`` stays EDAT-encrypted, so it
|
||||||
|
runs on CFW with ``npdrm_free`` (ARK ships it), same as pkgi-psp's install.
|
||||||
|
* PS1 Classics (``EBOOT.PBP`` + ``DOCUMENT.DAT``).
|
||||||
|
* PSP themes (``.ptf``) — the packaged PTF is EDAT-wrapped and is unwrapped here.
|
||||||
|
* PSP DLC (``.edat``) — extracted as-is into the owning game's folder.
|
||||||
|
|
||||||
|
Theme unwrapping needs ``pycryptodome`` (for AES-CMAC + ECB); everything else
|
||||||
|
works with either pycryptodome or the built-in pure-Python AES fallback.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +25,14 @@ PKG_MAGIC = 0x7F504B47
|
|||||||
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
||||||
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
||||||
|
|
||||||
|
# PSP KIRK/AMCTL keys used to unwrap theme PTF EDATs (pkgi-psp / libkirk).
|
||||||
|
_KIRK38 = bytes.fromhex("12468d7e1c42209bba5426835eb03303")
|
||||||
|
_KIRK39 = bytes.fromhex("c43bb6d653ee67493ea95fbc0ced6f8a")
|
||||||
|
_KIRK63 = bytes.fromhex("9c9b1372f8c640cf1c62f5d592ddb582")
|
||||||
|
_AMCTL3 = bytes.fromhex("e350ed1d910a1fd029bb1c3ef34077fb")
|
||||||
|
_AMCTL4 = bytes.fromhex("135fa47cab395ba476b8cca98f3a0445")
|
||||||
|
_AMCTL5 = bytes.fromhex("678d7fa32a9ca0d1508ad8385e4b017e")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
||||||
_HAVE_CRYPTO = True
|
_HAVE_CRYPTO = True
|
||||||
@@ -136,13 +153,86 @@ def _make_ctr_decryptor(key, iv, block_index):
|
|||||||
return decrypt
|
return decrypt
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# PSP theme PTF EDAT unwrapping (pkgi-psp's unpack_psp_edat)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _need_crypto():
|
||||||
|
if not _HAVE_CRYPTO:
|
||||||
|
raise RuntimeError("theme extraction needs pycryptodome (pip install pycryptodome)")
|
||||||
|
|
||||||
|
|
||||||
|
def _ecb_decrypt(key, block):
|
||||||
|
_need_crypto()
|
||||||
|
return _AES.new(key, _AES.MODE_ECB).decrypt(block)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmac(key, data):
|
||||||
|
_need_crypto()
|
||||||
|
from Crypto.Hash import CMAC
|
||||||
|
c = CMAC.new(key, ciphermod=_AES)
|
||||||
|
c.update(data)
|
||||||
|
return c.digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _kirk_iv(mac, key_header, offset2):
|
||||||
|
tmp = _ecb_decrypt(_KIRK38, bytes(key_header[0x70:0x80]))
|
||||||
|
iv = bytes(mac[i] ^ tmp[i] ^ key_header[offset2 + i] ^ _AMCTL3[i] ^ _AMCTL5[i] for i in range(16))
|
||||||
|
iv = _ecb_decrypt(_KIRK39, iv)
|
||||||
|
return bytes(b ^ _AMCTL4[i] for i, b in enumerate(iv))
|
||||||
|
|
||||||
|
|
||||||
|
def _kirk_cbc_decrypt(iv, index, data):
|
||||||
|
prev = bytearray(16) if index == 0 else bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||||
|
block = bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
v = int.from_bytes(block[12:16], "little") + 1
|
||||||
|
block[12:16] = v.to_bytes(4, "little")
|
||||||
|
o = _ecb_decrypt(_KIRK63, bytes(block))
|
||||||
|
out += bytes(b ^ prev[k] ^ o[k] for k, b in enumerate(data[i:i + 16]))
|
||||||
|
prev = bytes(block)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_theme_ptf(buf):
|
||||||
|
"""Unwrap a PSP theme PTF EDAT (``buf`` is the fully CTR-decrypted entry)."""
|
||||||
|
if len(buf) < 0x90 + 0xA0:
|
||||||
|
raise RuntimeError("theme EDAT is too short")
|
||||||
|
kh_off = buf[0xC]
|
||||||
|
key_header = bytearray(buf[kh_off:kh_off + 0xA0])
|
||||||
|
if bytes(key_header[:4]) != b"\x00PGD":
|
||||||
|
raise RuntimeError("theme EDAT: bad PGD header")
|
||||||
|
if int.from_bytes(key_header[4:8], "little") != 1 or int.from_bytes(key_header[8:12], "little") != 1:
|
||||||
|
raise RuntimeError("theme EDAT: unsupported key/drm type")
|
||||||
|
|
||||||
|
mac = _cmac(_KIRK38, bytes(key_header[:0x70]))
|
||||||
|
|
||||||
|
# Step 1: reveal the data size/offset fields (bytes 0x30..0x60).
|
||||||
|
key_header[0x30:0x60] = _kirk_cbc_decrypt(_kirk_iv(mac, key_header, 0x10), 0, bytes(key_header[0x30:0x60]))
|
||||||
|
data_size = int.from_bytes(key_header[0x44:0x48], "little")
|
||||||
|
data_offset = int.from_bytes(key_header[0x4c:0x50], "little")
|
||||||
|
if data_offset != 0x90:
|
||||||
|
raise RuntimeError("theme EDAT: unexpected data offset")
|
||||||
|
|
||||||
|
# Step 2: decrypt the PTF body.
|
||||||
|
iv = _kirk_iv(mac, key_header, 0x30)
|
||||||
|
out = bytearray()
|
||||||
|
nblocks = (data_size + 15) // 16
|
||||||
|
for i in range(nblocks):
|
||||||
|
chunk = buf[kh_off + data_offset + i * 16:kh_off + data_offset + (i + 1) * 16]
|
||||||
|
out += _kirk_cbc_decrypt(iv, i, chunk)
|
||||||
|
return bytes(out[:data_size])
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# PKG extraction
|
# PKG extraction
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
def extract(pkg_path, dest_dir, kind_hint=None, progress_cb=None, cancel_event=None):
|
||||||
"""Decrypt a PSP/PSX PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
"""Decrypt a PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
||||||
|
|
||||||
Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``.
|
Returns ``(kind, title_id, folder)`` where kind is ``"psp"``, ``"psx"``,
|
||||||
|
``"theme"`` or ``"dlc"``. ``kind_hint`` ("theme"/"dlc") overrides the
|
||||||
|
pkg-header content-type detection (NoPayStation DLC ships as two formats).
|
||||||
"""
|
"""
|
||||||
from .store import TransferCancelled
|
from .store import TransferCancelled
|
||||||
|
|
||||||
@@ -179,19 +269,30 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
|||||||
items_offset = int.from_bytes(m[8:12], "big")
|
items_offset = int.from_bytes(m[8:12], "big")
|
||||||
off += 8 + msize
|
off += 8 + msize
|
||||||
|
|
||||||
if content_type == 6:
|
if kind_hint in ("theme", "dlc"):
|
||||||
|
kind = kind_hint
|
||||||
|
elif content_type == 6:
|
||||||
kind = "psx"
|
kind = "psx"
|
||||||
elif content_type in (7, 0xE, 0xF, 0x10):
|
elif content_type in (7, 0xE, 0xF, 0x10):
|
||||||
kind = "psp"
|
kind = "psp"
|
||||||
|
elif content_type == 9:
|
||||||
|
kind = "theme"
|
||||||
|
elif content_type == 0xB:
|
||||||
|
kind = "dlc"
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
||||||
|
|
||||||
if key_type != 1:
|
# NoPayStation DLC is wrapped in a PS3-format pkg (key type 4 in the header
|
||||||
|
# but actually encrypted with the PS3 key); everything else uses the PSP key.
|
||||||
|
if content_type == 0xB:
|
||||||
|
main_key = PS3_KEY
|
||||||
|
ps3_key = PS3_KEY
|
||||||
|
elif key_type == 1:
|
||||||
|
main_key = PSP_KEY
|
||||||
|
ps3_key = PS3_KEY
|
||||||
|
else:
|
||||||
raise RuntimeError(f"unsupported PKG key type {key_type}")
|
raise RuntimeError(f"unsupported PKG key type {key_type}")
|
||||||
|
|
||||||
main_key = PSP_KEY
|
|
||||||
ps3_key = PS3_KEY
|
|
||||||
|
|
||||||
# Decrypt the whole item table in one shot.
|
# Decrypt the whole item table in one shot.
|
||||||
with open(pkg_path, "rb") as fh:
|
with open(pkg_path, "rb") as fh:
|
||||||
fh.seek(enc_offset + items_offset)
|
fh.seek(enc_offset + items_offset)
|
||||||
@@ -232,26 +333,42 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
|||||||
continue
|
continue
|
||||||
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
||||||
continue
|
continue
|
||||||
|
if kind == "theme" and not base.lower().endswith(".ptf"):
|
||||||
|
continue
|
||||||
|
if kind == "dlc" and not base.lower().endswith(".edat"):
|
||||||
|
continue
|
||||||
|
|
||||||
dest = folder / base
|
if kind == "theme":
|
||||||
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
|
# Read the (small) entry fully, CTR-decrypt, then unwrap the EDAT.
|
||||||
fh.seek(enc_offset + data_offset)
|
fh.seek(enc_offset + data_offset)
|
||||||
with open(dest, "wb") as out:
|
raw = _ctr_decrypt(item_key, iv, data_offset // 16, fh.read(data_size))
|
||||||
remaining = data_size
|
dest = folder / base
|
||||||
while remaining > 0:
|
dest.write_bytes(_unwrap_theme_ptf(raw))
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
done += data_size
|
||||||
raise TransferCancelled()
|
else:
|
||||||
chunk = fh.read(min(1 << 20, remaining))
|
dest = folder / base
|
||||||
if not chunk:
|
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
|
||||||
break
|
fh.seek(enc_offset + data_offset)
|
||||||
out.write(decrypt(chunk))
|
with open(dest, "wb") as out:
|
||||||
remaining -= len(chunk)
|
remaining = data_size
|
||||||
done += len(chunk)
|
while remaining > 0:
|
||||||
if progress_cb is not None:
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
progress_cb("Extracting", min(done / total, 1.0), "")
|
raise TransferCancelled()
|
||||||
|
chunk = fh.read(min(1 << 20, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(decrypt(chunk))
|
||||||
|
remaining -= len(chunk)
|
||||||
|
done += len(chunk)
|
||||||
|
if progress_cb is not None:
|
||||||
|
progress_cb("Extracting", min(done / total, 1.0), "")
|
||||||
written.append(dest)
|
written.append(dest)
|
||||||
|
|
||||||
if not (folder / "EBOOT.PBP").exists():
|
if kind in ("psp", "psx") and not (folder / "EBOOT.PBP").exists():
|
||||||
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
||||||
|
if kind == "theme" and not any(p.suffix.lower() == ".ptf" for p in written):
|
||||||
|
raise RuntimeError(f"no PTF found in {pkg_path.name}")
|
||||||
|
if kind == "dlc" and not written:
|
||||||
|
raise RuntimeError(f"no DLC EDAT found in {pkg_path.name}")
|
||||||
|
|
||||||
return kind, title_id, folder
|
return kind, title_id, folder
|
||||||
|
|||||||
+9
-6
@@ -468,9 +468,10 @@ class LocalShareSource(Source):
|
|||||||
class NoPayStationSource(Source):
|
class NoPayStationSource(Source):
|
||||||
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
|
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
|
||||||
|
|
||||||
``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv).
|
``content_type`` is ``"psp"`` (PSP_GAMES.tsv), ``"psx"`` (PSX_GAMES.tsv),
|
||||||
Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor
|
``"theme"`` (PSP_THEMES.tsv) or ``"dlc"`` (PSP_DLCS.tsv). Downloading
|
||||||
and writes a ``<TITLE_ID>/EBOOT.PBP`` game folder into ``dest_dir``.
|
decrypts the ``.pkg`` with the bundled pure-Python decryptor and writes a
|
||||||
|
``<TITLE_ID>/`` game folder into ``dest_dir``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
label = "NoPayStation"
|
label = "NoPayStation"
|
||||||
@@ -490,8 +491,9 @@ class NoPayStationSource(Source):
|
|||||||
dest_dir = Path(dest_dir)
|
dest_dir = Path(dest_dir)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# The bundled Python decryptor handles PSP/PSX directly; pkg2zip is
|
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly;
|
||||||
# only an optional fallback if a binary happens to be configured.
|
# pkg2zip is only an optional fallback if a binary happens to be
|
||||||
|
# configured (and only supports games/PSX).
|
||||||
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
|
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
|
||||||
title_id = item.get("title_id") or item.get("content_id") or ""
|
title_id = item.get("title_id") or item.get("content_id") or ""
|
||||||
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
||||||
@@ -501,8 +503,9 @@ class NoPayStationSource(Source):
|
|||||||
if progress_cb is not None:
|
if progress_cb is not None:
|
||||||
progress_cb("Downloading", 0.0, "")
|
progress_cb("Downloading", 0.0, "")
|
||||||
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
||||||
|
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,
|
||||||
progress_cb, cancel_event)
|
kind_hint, progress_cb, cancel_event)
|
||||||
return folder
|
return folder
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user