Compare commits

...
7 Commits
Author SHA1 Message Date
quoije 398c9f5dad Add PSP game Category Lite setting for NoPayStation installs 2026-09-07 23:00:04 -04:00
quoije 2ba24028a0 Port PSN pkg decryption to pure Python (drop Wine requirement) 2026-09-07 22:45:00 -04:00
quoije d458a9859c nps: bundle pkg2zip binary and run it via Wine
pkg2zip's prebuilt releases are Windows-only, so ship pkg2zip.exe in
data/bin/ (MIT, mmozeiko) and invoke it through Wine on Linux. find_pkg2zip
prefers the bundled binary, then a configured path, then a native pkg2zip on
PATH. Adds data/bin/ATTRIBUTION.md and README credit.
2026-09-07 22:14:14 -04:00
quoije 6837481f2b gui: narrow saves checkbox column + app window icon
- Saves checkbox column fixed at 28px.
- Add a PlayStation-style app icon (img/app-icon.png) and set it as the
  window icon on launch.
2026-09-07 22:04:27 -04:00
quoije 0ef7d05462 saves: bigger icons, per-row checkboxes, delete + export
- Save icon bumped to 56x40.
- Added a checkbox column for multi-select and a 'Delete selected' button.
- Right-click context menu on a row offers Export (copy the save folder to a
  chosen dir) and Delete (remove from all locations, with confirmation).
2026-09-07 21:48:41 -04:00
quoije de931f422a saves: show the save's ICON0.PNG in the table
The Save column now displays a small (40x32, letterboxed) icon from the
save folder's ICON0.PNG when present, using the first location (PSP,
PPSSPP, cloud) that has one.
2026-09-07 21:45:10 -04:00
quoije 4e0218a562 gui: stop scrape-all dialog from reappearing after cancel
Cancelling the store scrape now closes the dialog and drops its reference
immediately, so queued progress signals can't re-show it (QProgressDialog
re-shows on setValue while a value is pending). _scraping stays true until
the worker finishes to prevent a second concurrent scrape.
2026-09-07 21:41:53 -04:00
9 changed files with 560 additions and 47 deletions
+20 -11
View File
@@ -36,12 +36,14 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
## Requirements
- Linux + Python 3
- `PySide6` — the only Python dependency (`pip install -r requirements.txt`)
- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`)
- `7z` (p7zip) — used to peek/extract disc images from store archives
- `pkg2zip` (optional) — decrypts NoPayStation `.pkg` files (see
[NoPayStation](#4-nopaystation))
- PPSSPP (optional) — to launch discs from the details panel
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
decryptor** (`app/pkg.py`) — no Wine or `pkg2zip` required
(see [NoPayStation](#4-nopaystation)).
## Install
```bash
@@ -105,6 +107,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
to drawn art when they're absent.
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline).
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
only as an optional fallback decryptor; the app normally uses its own
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
See `data/bin/ATTRIBUTION.md`.
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
- `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache.
@@ -274,16 +280,17 @@ next to the source selector.
Requirements & behaviour:
1. Install [`pkg2zip`](https://github.com/mmozeiko/pkg2zip) (the same tool the
pkgi family uses) and either put it on `PATH` or set its path in
**Settings → Store**. NoPayStation `.pkg` files are encrypted PSN packages,
not ZIPs — `pkg2zip` decrypts them.
1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
`pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback
only, and only runs if Python decryption fails and Wine is present.
2. Set **NoPayStation output dir** in **Settings → Store** (default
`$PSP_STATE_DIR/nps`).
3. Downloading a PSP game writes `PSP/<TITLE_ID>/EBOOT.PBP`; a PS1 Classic
writes `PSX/<TITLE_ID>/EBOOT.PBP`. Both then show up in the **Library**
(PS1 Classics under **PS1**, PSP games under **Games**) and can be copied to
the PSP like any other item.
3. Downloading a PSP game writes `PSP/<TITLE_ID>/EBOOT.PBP` (plus
`PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic
writes `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). Both
then show up in the **Library** (PS1 Classics under **PS1**, PSP games
under **Games**) and can be copied to the PSP like any other item.
4. The TSV lists are cached in `data/nps/` and refreshed on each store scan
(drop the `.tsv` files there manually to work offline).
@@ -364,6 +371,8 @@ Connect a PSP over USB (or mount a UMD) and the **PSP Sync** tab detects it:
can dump the disc image into your library.
- **Sync from Library** — copy discs from your library onto the PSP, optionally
into a Category Lite category (set in **Settings → PSP → ISO category**).
NoPayStation PSP games install under `/PSP/GAME`, optionally nested in the
**PSP game category**; PS1 Classics use the **PS1 category**.
## Saves sync
+173 -15
View File
@@ -1075,7 +1075,7 @@ class SettingsPage(QWidget):
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
iso_category, ps1_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
nps_dir="", pkg2zip_path="", parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
@@ -1112,7 +1112,7 @@ class SettingsPage(QWidget):
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user)
self.store_edit = self._dir_row(form, "Local store dir:", store_dir)
self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir)
self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional):", pkg2zip_path)
self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path)
self.tabs.addTab(page, "Store")
# -- Saves ------------------------------------------------------- #
@@ -1133,6 +1133,9 @@ class SettingsPage(QWidget):
self.ps1_category_edit = QLineEdit(ps1_category)
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
form.addRow("PS1 category (Category Lite):", self.ps1_category_edit)
self.psp_category_edit = QLineEdit(psp_category)
self.psp_category_edit.setPlaceholderText("e.g. psp (blank = PSP/GAME root)")
form.addRow("PSP game category (Category Lite):", self.psp_category_edit)
self.tabs.addTab(page, "PSP")
# -- Cover sources ------------------------------------------------ #
@@ -1294,6 +1297,9 @@ class SettingsPage(QWidget):
def ps1_category(self):
return self.ps1_category_edit.text().strip()
def psp_category(self):
return self.psp_category_edit.text().strip()
def ps1_dir(self):
return self.ps1_edit.text().strip()
@@ -2320,7 +2326,7 @@ class StorePage(QWidget):
thread.started.connect(worker.run)
worker.progress.connect(self._on_scrape_progress)
worker.finished.connect(thread.quit)
dlg.canceled.connect(worker.cancel)
dlg.canceled.connect(self._on_scrape_cancelled)
worker.finished.connect(self._on_scrape_done)
self._scrape_thread = thread
self._scrape_worker = worker
@@ -2328,6 +2334,19 @@ class StorePage(QWidget):
self._workers.append(worker)
thread.start()
def _on_scrape_cancelled(self):
worker = self._scrape_worker
if worker is not None:
worker.cancel()
dlg = self._scrape_dialog
if dlg is not None:
dlg.close()
# Drop the dialog reference so queued progress signals can't re-show it
# (QProgressDialog re-appears on setValue while a value is pending).
# Keep _scraping True until the worker actually finishes (_on_scrape_done)
# so a re-click doesn't spawn a second scrape while this one winds down.
self._scrape_dialog = None
def _on_scrape_progress(self, done, total):
dlg = self._scrape_dialog
if dlg is not None:
@@ -3353,21 +3372,46 @@ class SavesPage(QWidget):
self.sync_btn = QPushButton("Sync now")
self.sync_btn.clicked.connect(lambda: self.sync_now(True))
bar.addWidget(self.sync_btn)
self.delete_btn = QPushButton("Delete selected")
self.delete_btn.setEnabled(False)
self.delete_btn.setStyleSheet("color: #e06c6c;")
self.delete_btn.clicked.connect(self._delete_checked)
bar.addWidget(self.delete_btn)
bar.addStretch(1)
layout.addLayout(bar)
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(["Save", "ID", "PSP", "PPSSPP", "Cloud"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table = QTableWidget(0, 6)
self.table.setHorizontalHeaderLabels(["", "Save", "ID", "PSP", "PPSSPP", "Cloud"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
self.table.setColumnWidth(0, 28)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSortingEnabled(True)
self.table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder)
self.table.setIconSize(QSize(56, 40))
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._on_context_menu)
self.table.itemChanged.connect(self._on_item_changed)
self.table.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
layout.addWidget(self.table)
self.status = QLabel("")
layout.addWidget(self.status)
self._data = {}
self._populating = False
def _save_icon(self, folder):
"""Return a small QIcon from a save folder's ICON0.PNG, or None."""
if not folder:
return None
p = Path(folder) / "ICON0.PNG"
if not p.is_file():
return None
pm = QPixmap(str(p))
if pm.isNull():
return None
return QIcon(_fit_pixmap(pm, 56, 40))
def set_locations(self, psp_dir, ppsspp_dir, cloud_dir):
self._psp_dir = psp_dir or ""
@@ -3378,21 +3422,122 @@ class SavesPage(QWidget):
def _locations(self):
return {"psp": self._psp_dir, "ppsspp": self._ppsspp_dir, "cloud": self._cloud_dir}
def _checked_ids(self):
ids = []
for row in range(self.table.rowCount()):
cb = self.table.item(row, 0)
if cb is not None and cb.checkState() == Qt.Checked:
sid = cb.data(Qt.UserRole)
if sid:
ids.append(sid)
return ids
def _on_item_changed(self, item):
if getattr(self, "_populating", False) or item.column() != 0:
return
self._update_delete_enabled()
def _update_delete_enabled(self):
self.delete_btn.setEnabled(bool(self._checked_ids()))
def _on_context_menu(self, pos):
item = self.table.itemAt(pos)
if item is None:
return
row = item.row()
cb = self.table.item(row, 0)
sid = cb.data(Qt.UserRole) if cb is not None else None
if not sid:
id_item = self.table.item(row, 2)
sid = id_item.text() if id_item is not None else None
if not sid:
return
menu = QMenu(self)
menu.addAction("Export…", lambda: self._export_save(sid))
menu.addAction("Delete…", lambda: self._delete_saves([sid]))
menu.exec(self.table.viewport().mapToGlobal(pos))
def _delete_checked(self):
ids = self._checked_ids()
if ids:
self._delete_saves(ids)
def _delete_saves(self, sids):
targets = []
seen = set()
for sid in sids:
for loc in ("psp", "ppsspp", "cloud"):
s = self._data.get(loc, {}).get(sid)
if not s:
continue
p = s.get("path")
if p and p not in seen:
seen.add(p)
targets.append(p)
if not targets:
QMessageBox.information(self, "Delete saves", "No local copies found.")
return
preview = "\n".join(targets[:8])
if len(targets) > 8:
preview += f"\n… (+{len(targets) - 8} more)"
res = QMessageBox.question(
self, "Delete saves",
f"Delete these save folders from disk?\n\n{preview}",
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
)
if res != QMessageBox.Yes:
return
failed = 0
for p in targets:
if not transfers.remove_path(Path(p)):
failed += 1
if failed:
QMessageBox.warning(self, "Delete incomplete", f"{failed} folder(s) could not be deleted.")
self.refresh()
def _export_save(self, sid):
src = None
for loc in ("psp", "ppsspp", "cloud"):
s = self._data.get(loc, {}).get(sid)
if s and Path(s.get("path", "")).is_dir():
src = s["path"]
break
if not src:
QMessageBox.information(self, "Export save", "No local copy found.")
return
dest = QFileDialog.getExistingDirectory(self, "Export save folder")
if not dest:
return
try:
transfers.copy_tree(Path(src), Path(dest) / sid)
except Exception as exc: # noqa: BLE001
QMessageBox.warning(self, "Export failed", str(exc))
return
QMessageBox.information(self, "Export save", f"Exported to:\n{Path(dest) / sid}")
def refresh(self):
locs = self._locations()
data = {name: {s["id"]: s for s in saves.list_saves(root)} for name, root in locs.items()}
self._data = data
all_ids = set()
for d in data.values():
all_ids.update(d)
sort_col = self.table.horizontalHeader().sortIndicatorSection()
sort_order = self.table.horizontalHeader().sortIndicatorOrder()
self.table.setSortingEnabled(False)
self._populating = True
self.table.setRowCount(0)
for sid in sorted(all_ids):
row = self.table.rowCount()
self.table.insertRow(row)
cb = QTableWidgetItem()
cb.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
cb.setCheckState(Qt.Unchecked)
cb.setData(Qt.UserRole, sid)
self.table.setItem(row, 0, cb)
title = sid
enc = False
icon = None
for loc in ("psp", "ppsspp", "cloud"):
s = data[loc].get(sid)
if s is None:
@@ -3401,13 +3546,17 @@ class SavesPage(QWidget):
title = s["title"]
if s.get("encrypted"):
enc = True
if icon is None:
icon = self._save_icon(s.get("path"))
if enc:
title += " \u26a0"
t = QTableWidgetItem(title)
t.setToolTip(sid)
self.table.setItem(row, 0, t)
self.table.setItem(row, 1, QTableWidgetItem(sid))
for col, loc in enumerate(("psp", "ppsspp", "cloud"), start=2):
if icon is not None:
t.setIcon(icon)
self.table.setItem(row, 1, t)
self.table.setItem(row, 2, QTableWidgetItem(sid))
for col, loc in enumerate(("psp", "ppsspp", "cloud"), start=3):
s = data[loc].get(sid)
if s is None:
self.table.setItem(row, col, QTableWidgetItem(""))
@@ -3416,9 +3565,11 @@ class SavesPage(QWidget):
item = QTableWidgetItem(stamp)
item.setToolTip(f"{s['size'] / 1024:.1f} KB")
self.table.setItem(row, col, item)
self._populating = False
self.table.setSortingEnabled(True)
self.table.sortByColumn(sort_col, sort_order)
self.status.setText(f"{len(all_ids)} save(s) across {sum(1 for r in locs.values() if r)} location(s)")
self._update_delete_enabled()
def sync_now(self, switch_tab=False):
if self._syncing:
@@ -3733,6 +3884,7 @@ class MainWindow(QMainWindow):
self._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "")
self._iso_category = str(self.settings.value("iso_category", "") or "")
self._ps1_category = str(self.settings.value("ps1_category", "") or "")
self._psp_category = str(self.settings.value("psp_category", "") or "")
self._ps1_dir = self._first_existing(config.PS1_ROOTS) or str(self.settings.value("ps1_dir", "") or "")
self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "")
self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "")
@@ -3947,7 +4099,7 @@ class MainWindow(QMainWindow):
self._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe,
self._store_server_url, self._store_token, self._store_archive_id,
self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_category,
self._ps1_category, self._psp_category,
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
self._nps_dir, self._pkg2zip)
self.settings_page.saved.connect(self._on_settings_saved)
@@ -3980,6 +4132,7 @@ class MainWindow(QMainWindow):
self._ppsspp_exe = self.settings_page.ppsspp_exe()
self._iso_category = self.settings_page.iso_category()
self._ps1_category = self.settings_page.ps1_category()
self._psp_category = self.settings_page.psp_category()
self._ps1_dir = self.settings_page.ps1_dir()
self._ps1_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp()
@@ -3997,6 +4150,7 @@ class MainWindow(QMainWindow):
self.settings.setValue("ppsspp_exe", self._ppsspp_exe)
self.settings.setValue("iso_category", self._iso_category)
self.settings.setValue("ps1_category", self._ps1_category)
self.settings.setValue("psp_category", self._psp_category)
self._update_saves_locations()
self._update_dir_status()
@@ -4430,15 +4584,16 @@ class MainWindow(QMainWindow):
dest_dir = psp_root / "ISO" / "VIDEO"
elif system == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
# folder under /PSP/GAME (optionally nested in a Category Lite folder
# for PS1).
# folder under /PSP/GAME (optionally nested in a Category Lite
# folder).
src_folder = Path(item["path"]).parent
if system == "ps1":
ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME")
else:
dest_dir = psp_root / "PSP" / "GAME"
log.info("copying ps1 game to psp: %s -> %s", src_folder, dest_dir / src_folder.name)
psp_cat = "".join(c for c in (self._psp_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "PSP" / "GAME" / psp_cat) if psp_cat else (psp_root / "PSP" / "GAME")
log.info("copying game folder to psp: %s -> %s", src_folder, dest_dir / src_folder.name)
self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir,
{"dest_name": src_folder.name}, item.get("name") or src_folder.name,
kind="psp-install")
@@ -4918,6 +5073,9 @@ def main():
init_db() # migrate legacy on-share DB/covers to local storage, then schema
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
app = QApplication(sys.argv)
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
window = MainWindow()
window.show()
log.info("main window ready")
+67 -14
View File
@@ -2,13 +2,14 @@
NoPayStation publishes per-platform TSV lists (``https://nopaystation.com/tsv/``)
with one row per downloadable PSN item. Each row links to an encrypted PSN
``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that must be decrypted with the
``pkg2zip`` tool before it can be installed as a ``PSP/GAME/<TITLE_ID>/``
folder.
``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that is decrypted by the bundled
pure-Python decryptor (``app/pkg.py``) into a ``PSP/GAME/<TITLE_ID>/``-style
folder — no Wine or ``pkg2zip`` required.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import urllib.parse
@@ -142,36 +143,85 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60) -> List[dic
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
"""Locate the pkg2zip binary (configured path, else PATH)."""
"""Locate pkg2zip: configured path, then the bundled binary, then PATH."""
if configured_path:
p = Path(configured_path)
return p if p.is_file() else None
bundled = config.DATA_DIR / "bin" / "pkg2zip.exe"
if bundled.is_file():
return bundled
found = shutil.which("pkg2zip")
return Path(found) if found else None
def _pkg2zip_cmd(pkg2zip: Path) -> List[str]:
"""Command prefix for pkg2zip, wrapping the bundled .exe in Wine on Linux."""
if pkg2zip.suffix.lower() == ".exe" and os.name != "nt":
wine = shutil.which("wine") or shutil.which("wine64")
if not wine:
raise RuntimeError(
"The bundled pkg2zip is a Windows binary and Wine is not installed — "
"install Wine (or point Settings → Store at a native pkg2zip)."
)
return [wine, str(pkg2zip)]
return [str(pkg2zip)]
def _safe_folder(value: str) -> str:
import re
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
return cleaned or "PKG"
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path,
progress_cb=None, cancel_event=None) -> Path:
"""Decrypt/extract a ``.pkg`` with pkg2zip into ``dest_dir/<TITLE_ID>/``.
def _ensure_keys_bin(folder, kind):
"""PS1 Classics need KEYS.BIN next to EBOOT.PBP to boot on CFW (npdrm)."""
if kind != "psx":
return
src = config.DATA_DIR / "loader" / "KEYS.BIN"
dest = Path(folder) / "KEYS.BIN"
if src.is_file() and not dest.exists():
try:
shutil.copy2(src, dest)
except OSError:
pass
pkg2zip extracts PSP/PSX content into a folder layout we then normalize:
the game's ``EBOOT.PBP`` (plus siblings) are copied into the destination
game folder. Returns the destination folder path.
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
progress_cb=None, cancel_event=None) -> Path:
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
back to pkg2zip only if that fails and a binary is available. Returns the
destination game folder path.
"""
from . import pkg as pkgmod
from .store import TransferCancelled
pkg_path = Path(pkg_path)
folder_name = _safe_folder(title_id)
dest = Path(dest_dir) / folder_name
dest = Path(dest_dir) / _safe_folder(title_id)
if (dest / "EBOOT.PBP").exists():
return dest
# 1. Pure-Python decryptor (primary).
try:
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event)
_ensure_keys_bin(folder, _kind)
return folder
except TransferCancelled:
raise
except Exception as exc: # noqa: BLE001
py_exc = exc
log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc)
# 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe).
if pkg2zip is None:
pkg2zip = find_pkg2zip()
if pkg2zip is None:
raise RuntimeError(
f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}"
)
folder_name = _safe_folder(title_id)
tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name)
if tmp.exists():
shutil.rmtree(tmp, ignore_errors=True)
@@ -180,10 +230,13 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path,
if progress_cb is not None:
progress_cb("Extracting", 0.0, "")
try:
cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)]
env = dict(os.environ)
env["WINEDEBUG"] = "-all" # silence Wine's console chatter
proc = subprocess.Popen(
[str(pkg2zip), "-x", str(pkg_path)],
cmd,
cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1,
text=True, bufsize=1, env=env,
)
assert proc.stdout is not None
for _line in proc.stdout:
+257
View File
@@ -0,0 +1,257 @@
"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed).
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
entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The
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.
"""
from __future__ import annotations
from pathlib import Path
PKG_MAGIC = 0x7F504B47
# Static PSN keys (key type 1 = PSP).
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
try:
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
_HAVE_CRYPTO = True
except ImportError: # noqa: BLE001
_HAVE_CRYPTO = False
# --------------------------------------------------------------------------- #
# Pure-Python AES-128 (fallback when pycryptodome is unavailable)
# --------------------------------------------------------------------------- #
_SBOX = bytes.fromhex(
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
)
_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
def _key_schedule(key):
w = list(key)
for i in range(4, 44):
t = list(w[(i - 1) * 4:(i - 1) * 4 + 4])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i // 4 - 1]
w += [w[(i - 4) * 4 + j] ^ t[j] for j in range(4)]
return [bytes(w[i * 16:(i + 1) * 16]) for i in range(11)]
def _shift_rows(s):
return [s[0], s[5], s[10], s[15], s[4], s[9], s[14], s[3],
s[8], s[13], s[2], s[7], s[12], s[1], s[6], s[11]]
def _xtime(a):
a <<= 1
return (a ^ 0x1B) & 0xFF if a & 0x100 else a & 0xFF
def _mix_columns(s):
out = [0] * 16
for c in range(4):
a0, a1, a2, a3 = s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]
out[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
out[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
out[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
out[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
return out
def _aes_encrypt_block(rk, block):
s = [block[i] ^ rk[0][i] for i in range(16)]
for rnd in range(1, 10):
s = [_SBOX[b] for b in s]
s = _shift_rows(s)
s = _mix_columns(s)
s = [s[i] ^ rk[rnd][i] for i in range(16)]
s = [_SBOX[b] for b in s]
s = _shift_rows(s)
s = [s[i] ^ rk[10][i] for i in range(16)]
return bytes(s)
def _add128(counter, n):
c = bytearray(counter)
for i in range(15, -1, -1):
n += c[i]
c[i] = n & 0xFF
n >>= 8
return bytes(c)
def _ctr_decrypt_py(key, iv, block_index, data):
"""AES-CTR decrypt (pure-Python): counter = iv + block_index, +1 per block."""
rk = _key_schedule(key)
counter = _add128(iv, block_index)
out = bytearray()
for i in range(0, len(data), 16):
ks = _aes_encrypt_block(rk, counter)
out += bytes(b ^ k for b, k in zip(data[i:i + 16], ks))
counter = _add128(counter, 1)
return bytes(out)
def _ctr_decrypt(key, iv, block_index, data):
"""AES-CTR decrypt: 128-bit big-endian counter at ``iv + block_index``."""
if _HAVE_CRYPTO:
nonce = iv[:8]
init = int.from_bytes(iv[8:16], "big") + block_index
return _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init).decrypt(data)
return _ctr_decrypt_py(key, iv, block_index, data)
def _make_ctr_decryptor(key, iv, block_index):
"""Return a streaming decryptor (callable: bytes -> bytes) for a data run."""
if _HAVE_CRYPTO:
nonce = iv[:8]
init = int.from_bytes(iv[8:16], "big") + block_index
cipher = _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init)
return cipher.decrypt
rk = _key_schedule(key)
counter = _add128(iv, block_index)
state = {"rk": rk, "counter": counter}
def decrypt(chunk):
out = bytearray()
for i in range(0, len(chunk), 16):
ks = _aes_encrypt_block(state["rk"], state["counter"])
out += bytes(b ^ k for b, k in zip(chunk[i:i + 16], ks))
state["counter"] = _add128(state["counter"], 1)
return bytes(out)
return decrypt
# --------------------------------------------------------------------------- #
# PKG extraction
# --------------------------------------------------------------------------- #
def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
"""Decrypt a PSP/PSX PSN pkg into ``dest_dir/<TITLE_ID>/``.
Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``.
"""
from .store import TransferCancelled
pkg_path = Path(pkg_path)
dest_dir = Path(dest_dir)
with open(pkg_path, "rb") as fh:
header = fh.read(0x100)
if len(header) < 0xE8 or int.from_bytes(header[0:4], "big") != PKG_MAGIC:
raise RuntimeError(f"not a PKG file: {pkg_path.name}")
meta_offset = int.from_bytes(header[8:12], "big")
meta_count = int.from_bytes(header[12:16], "big")
item_count = int.from_bytes(header[20:24], "big")
enc_offset = int.from_bytes(header[32:40], "big")
iv = header[0x70:0x80]
key_type = header[0xE7] & 7
title_id = header[0x37:0x40].decode("ascii", "replace").rstrip("\x00").strip() or "PKG"
content_type = 0
items_offset = 0
with open(pkg_path, "rb") as fh:
off = meta_offset
for _ in range(meta_count):
fh.seek(off)
m = fh.read(16)
if len(m) < 16:
break
mtype = int.from_bytes(m[0:4], "big")
msize = int.from_bytes(m[4:8], "big")
if mtype == 2:
content_type = int.from_bytes(m[8:12], "big")
elif mtype == 13:
items_offset = int.from_bytes(m[8:12], "big")
off += 8 + msize
if content_type == 6:
kind = "psx"
elif content_type in (7, 0xE, 0xF, 0x10):
kind = "psp"
else:
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
if key_type != 1:
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.
with open(pkg_path, "rb") as fh:
fh.seek(enc_offset + items_offset)
table = fh.read(item_count * 32)
table = _ctr_decrypt(main_key, iv, items_offset // 16, table)
entries = []
for i in range(item_count):
e = table[i * 32:(i + 1) * 32]
name_offset = int.from_bytes(e[0:4], "big")
name_size = int.from_bytes(e[4:8], "big")
data_offset = int.from_bytes(e[8:16], "big")
data_size = int.from_bytes(e[16:24], "big")
psp_type = e[24]
flags = e[27]
if flags in (4, 18) or data_size == 0:
continue
item_key = main_key if psp_type == 0x90 else ps3_key
entries.append((name_offset, name_size, data_offset, data_size, item_key))
folder = dest_dir / title_id
folder.mkdir(parents=True, exist_ok=True)
written = []
total = sum(ds for *_, ds, _ in entries) or 1
done = 0
with open(pkg_path, "rb") as fh:
for name_offset, name_size, data_offset, data_size, item_key in entries:
if cancel_event is not None and cancel_event.is_set():
raise TransferCancelled()
fh.seek(enc_offset + name_offset)
name = _ctr_decrypt(item_key, iv, name_offset // 16, fh.read(name_size))
name = name.split(b"\x00", 1)[0].decode("utf-8", "replace")
base = name.rsplit("/", 1)[-1]
if kind == "psx" and base not in ("EBOOT.PBP", "DOCUMENT.DAT"):
continue
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
continue
dest = folder / base
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
fh.seek(enc_offset + data_offset)
with open(dest, "wb") as out:
remaining = data_size
while remaining > 0:
if cancel_event is not None and cancel_event.is_set():
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)
if not (folder / "EBOOT.PBP").exists():
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
return kind, title_id, folder
+4 -7
View File
@@ -469,8 +469,8 @@ class NoPayStationSource(Source):
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv).
Downloading decrypts the ``.pkg`` with the ``pkg2zip`` binary and writes a
``<TITLE_ID>/EBOOT.PBP`` game folder into ``dest_dir``.
Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor
and writes a ``<TITLE_ID>/EBOOT.PBP`` game folder into ``dest_dir``.
"""
label = "NoPayStation"
@@ -490,12 +490,9 @@ class NoPayStationSource(Source):
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
# The bundled Python decryptor handles PSP/PSX directly; pkg2zip is
# only an optional fallback if a binary happens to be configured.
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
if pkg2zip is None:
raise RuntimeError(
"pkg2zip not found — install it (https://github.com/mmozeiko/pkg2zip) "
"or set its path in Settings → Store."
)
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"
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
+38
View File
@@ -0,0 +1,38 @@
# pkg2zip
The `pkg2zip.exe` binary in this folder is kept as an optional fallback for
decrypting NoPayStation `.pkg` files (PSP / PSX / PS3 / PSVita content). The
app normally uses its own pure-Python decryptor (`app/pkg.py`) and only runs
this binary if that fails.
- Project: <https://github.com/mmozeiko/pkg2zip>
- Author: Marcin Mikołajczak (mmozeiko)
- Version: v1.8 (bundled)
- License: MIT
The Windows binary is run through Wine on Linux/macOS (see
`app/nopaystation.py`).
## License
MIT License
Copyright (c) 2017 Marcin Mikołajczak (mmozeiko)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6
curl_cffi>=0.7
pycryptodome>=3.20