From 1398aa09cf4f8d88f884e43572f11810c8bbb5f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 22:22:40 +0000 Subject: [PATCH] Add server-side ROM patching endpoint using RomPatcher.js Introduces POST /api/roms/{id}/patch that applies patch files to ROM files server-side, enabling third-party apps to request ROM patching using games and patches already in the library without needing client-side JS support. - backend/utils/patcher.js: Node.js helper that loads RomPatcher.js and applies a patch file to a ROM, writing the result to an output path - backend/endpoints/roms/patch.py: FastAPI endpoint that looks up ROM and patch files by ID, invokes the Node.js patcher via subprocess, and streams the patched ROM back as a download - Supports all 9 patch formats: IPS, UPS, BPS, PPF, RUP, APS, BDF, PMSR, VCDIFF - Requires roms.read scope for authentication - Temp files are cleaned up via Starlette BackgroundTask after response https://claude.ai/code/session_01HS6ZvAiBjmLPVB3gGw8eEt --- backend/endpoints/roms/__init__.py | 2 + backend/endpoints/roms/patch.py | 197 +++++++++++++++++++++++++++++ backend/utils/patcher.js | 94 ++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 backend/endpoints/roms/patch.py create mode 100644 backend/utils/patcher.js diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 914b1aead..5c159fa28 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -72,6 +72,7 @@ from utils.validation import ValidationError from .files import router as files_router from .manual import router as manual_router from .notes import router as notes_router +from .patch import router as patch_router from .upload import router as upload_router router = APIRouter( @@ -82,6 +83,7 @@ router.include_router(upload_router) router.include_router(files_router) router.include_router(manual_router) router.include_router(notes_router) +router.include_router(patch_router) def safe_int_or_none(value: Any) -> int | None: diff --git a/backend/endpoints/roms/patch.py b/backend/endpoints/roms/patch.py new file mode 100644 index 000000000..dbb0ef0ee --- /dev/null +++ b/backend/endpoints/roms/patch.py @@ -0,0 +1,197 @@ +import asyncio +import json +import shutil +import tempfile +from pathlib import Path +from typing import Annotated +from urllib.parse import quote + +from fastapi import Body, HTTPException +from fastapi import Path as PathVar +from fastapi import Request, status +from pydantic import BaseModel, Field +from starlette.background import BackgroundTask +from starlette.responses import FileResponse + +from decorators.auth import protected_route +from handler.auth.constants import Scope +from handler.database import db_rom_handler +from handler.filesystem import fs_rom_handler +from logger.formatter import BLUE +from logger.formatter import highlight as hl +from logger.logger import log +from utils.router import APIRouter + +router = APIRouter() + +PATCHER_SCRIPT = Path(__file__).resolve().parent.parent / "utils" / "patcher.js" + +SUPPORTED_PATCH_EXTENSIONS = frozenset( + (".ips", ".ups", ".bps", ".ppf", ".rup", ".aps", ".bdf", ".pmsr", ".vcdiff") +) + + +class PatchRequest(BaseModel): + patch_file_id: int = Field( + description="ID of the patch file (RomFile) to apply." + ) + output_file_name: str | None = Field( + default=None, + description="Custom output file name. If omitted, derived from ROM + patch names.", + ) + + +class PatchResponse(BaseModel): + message: str + output_file_name: str + output_file_size: int + + +@protected_route( + router.post, + "/{id}/patch", + [Scope.ROMS_READ], + responses={ + status.HTTP_400_BAD_REQUEST: {}, + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {}, + }, +) +async def patch_rom( + request: Request, + id: Annotated[int, PathVar(description="ROM file ID (the base game file).", ge=1)], + patch_request: PatchRequest = Body(...), +): + """Apply a patch to a ROM file server-side and return the patched file. + + Both the ROM file and the patch file must already exist in the library. + The patched ROM is streamed back as a download. + """ + + current_username = ( + request.user.username if request.user.is_authenticated else "unknown" + ) + + # Look up ROM file + rom_file = db_rom_handler.get_rom_file_by_id(id) + if not rom_file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ROM file with id {id} not found", + ) + + if rom_file.missing_from_fs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ROM file '{rom_file.file_name}' is missing from filesystem", + ) + + # Look up patch file + patch_file = db_rom_handler.get_rom_file_by_id(patch_request.patch_file_id) + if not patch_file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Patch file with id {patch_request.patch_file_id} not found", + ) + + if patch_file.missing_from_fs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Patch file '{patch_file.file_name}' is missing from filesystem", + ) + + # Validate the patch file extension + patch_ext = Path(patch_file.file_name).suffix.lower() + if patch_ext not in SUPPORTED_PATCH_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported patch format '{patch_ext}'. Supported: {', '.join(sorted(SUPPORTED_PATCH_EXTENSIONS))}", + ) + + # Resolve filesystem paths + rom_path = fs_rom_handler.validate_path(rom_file.full_path) + patch_path = fs_rom_handler.validate_path(patch_file.full_path) + + # Build output filename + if patch_request.output_file_name: + # Strip any extension from custom name and use ROM's extension + custom_base = Path(patch_request.output_file_name).stem + rom_ext = Path(rom_file.file_name).suffix + output_file_name = f"{custom_base}{rom_ext}" + else: + rom_base = Path(rom_file.file_name).stem + patch_base = Path(patch_file.file_name).stem + rom_ext = Path(rom_file.file_name).suffix + output_file_name = f"{rom_base} (patched-{patch_base}){rom_ext}" + + log.info( + f"User {hl(current_username, color=BLUE)} is patching " + f"ROM file {hl(rom_file.file_name)} with patch {hl(patch_file.file_name)}" + ) + + # Create temp directory for the patched output + tmp_dir = tempfile.mkdtemp(prefix="romm_patch_") + output_path = Path(tmp_dir) / output_file_name + + try: + # Run the Node.js patcher script + proc = await asyncio.create_subprocess_exec( + "node", + str(PATCHER_SCRIPT), + str(rom_path), + str(patch_path), + str(output_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + error_msg = "Patching failed" + try: + err_data = json.loads(stderr.decode()) + error_msg = err_data.get("error", error_msg) + except (json.JSONDecodeError, UnicodeDecodeError): + if stderr: + error_msg = stderr.decode(errors="replace").strip() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=error_msg, + ) + + if not output_path.exists(): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Patcher did not produce an output file", + ) + + output_size = output_path.stat().st_size + + log.info( + f"Successfully patched ROM for user {hl(current_username, color=BLUE)}: " + f"{hl(output_file_name)} ({output_size} bytes)" + ) + + # Return the patched file as a download, cleaning up the temp + # directory after the response body has been sent. + return FileResponse( + path=str(output_path), + filename=output_file_name, + media_type="application/octet-stream", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{quote(output_file_name)}; filename=\"{quote(output_file_name)}\"", + "Content-Length": str(output_size), + }, + background=BackgroundTask(shutil.rmtree, tmp_dir, True), + ) + + except HTTPException: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise + except Exception as e: + shutil.rmtree(tmp_dir, ignore_errors=True) + log.error(f"Patching error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unexpected patching error: {e}", + ) from e diff --git a/backend/utils/patcher.js b/backend/utils/patcher.js new file mode 100644 index 000000000..960ad8041 --- /dev/null +++ b/backend/utils/patcher.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +/** + * Server-side ROM patcher helper script. + * + * Uses RomPatcher.js (https://github.com/marcrobledo/RomPatcher.js/) to apply + * a patch file to a ROM file, writing the result to an output path. + * + * Usage: + * node patcher.js + * + * Exit codes: + * 0 - success + * 1 - usage / argument error + * 2 - patching error + */ + +const path = require("path"); +const fs = require("fs"); + +// Resolve RomPatcher.js from the frontend node_modules +const ROM_PATCHER_BASE = path.resolve( + __dirname, + "../../frontend/node_modules/rom-patcher/rom-patcher-js" +); + +// Load the library (sets globals that RomPatcher.js expects) +require(path.join(ROM_PATCHER_BASE, "modules", "BinFile.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "HashCalculator.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_gba.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_n64.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bdf.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bps.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ips.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.pmsr.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ppf.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.rup.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ups.js")); +require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.vcdiff.js")); +const RomPatcher = require(path.join(ROM_PATCHER_BASE, "RomPatcher.js")); + +const args = process.argv.slice(2); +if (args.length !== 3) { + console.error("Usage: node patcher.js "); + process.exit(1); +} + +const [romPath, patchPath, outputPath] = args; + +try { + // Validate input files exist + if (!fs.existsSync(romPath)) { + throw new Error(`ROM file not found: ${romPath}`); + } + if (!fs.existsSync(patchPath)) { + throw new Error(`Patch file not found: ${patchPath}`); + } + + // Load files using BinFile (Node.js mode accepts file paths) + const romFile = new BinFile(romPath); + const patchFile = new BinFile(patchPath); + + // Parse the patch format + const patch = RomPatcher.parsePatchFile(patchFile); + if (!patch) { + throw new Error("Unsupported or invalid patch format"); + } + + // Apply patch + const patchedRom = RomPatcher.applyPatch(romFile, patch, { + requireValidation: false, + fixChecksum: false, + outputSuffix: false, + }); + + // Extract binary data and write to output + const data = patchedRom._u8array || patchedRom.u8array || patchedRom.data; + if (!data) { + throw new Error("Failed to extract patched ROM data"); + } + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputPath, Buffer.from(data.buffer, data.byteOffset, data.byteLength)); + console.log(JSON.stringify({ success: true, output: outputPath, size: data.byteLength })); + process.exit(0); +} catch (err) { + console.error(JSON.stringify({ success: false, error: err.message || String(err) })); + process.exit(2); +}