diff --git a/Dockerfile b/Dockerfile index 61ceb260ef..d19c1876de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,11 @@ COPY frontend/package.json /app/frontend/ WORKDIR /app/frontend RUN npm install +# Install backend Node helpers (server-side ROM patching) +COPY backend/utils/rom_patcher/package.json /app/backend/utils/rom_patcher/ +WORKDIR /app/backend/utils/rom_patcher +RUN npm install + # Set working directory WORKDIR /app diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 1653e771a6..1ae6df18b0 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -5,7 +5,7 @@ from typing import NotRequired, TypedDict, get_type_hints from fastapi import Request -from pydantic import ConfigDict, computed_field, field_validator +from pydantic import ConfigDict, computed_field, field_validator, model_validator from endpoints.responses.assets import SaveSchema, ScreenshotSchema, StateSchema from handler.metadata.flashpoint_handler import FlashpointMetadata @@ -156,6 +156,7 @@ class RomFileSchema(BaseModel): file_path: str file_size_bytes: int full_path: str + is_top_level: bool created_at: UTCDatetime updated_at: UTCDatetime last_modified: UTCDatetime @@ -165,6 +166,12 @@ class RomFileSchema(BaseModel): ra_hash: str | None category: RomFileCategory | None + @model_validator(mode="after") + def default_category_for_non_nested(self) -> RomFileSchema: + if self.category is None and self.is_top_level: + self.category = RomFileCategory.GAME + return self + class RomMetadataSchema(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index d1d7be784e..d9850f861b 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -72,6 +72,7 @@ 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(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 0000000000..67ee2ff9bc --- /dev/null +++ b/backend/endpoints/roms/patch.py @@ -0,0 +1,147 @@ +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.rom_patcher import SUPPORTED_PATCH_EXTENSIONS, PatcherError, apply_patch +from utils.router import APIRouter + +router = APIRouter() + + +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" + ) + + 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", + ) + + 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", + ) + + 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))}", + ) + + rom_path = fs_rom_handler.validate_path(rom_file.full_path) + patch_path = fs_rom_handler.validate_path(patch_file.full_path) + + rom_ext = Path(rom_file.file_name).suffix + if patch_request.output_file_name: + output_file_name = f"{Path(patch_request.output_file_name).stem}{rom_ext}" + else: + rom_base = Path(rom_file.file_name).stem + patch_base = Path(patch_file.file_name).stem + 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)}" + ) + + tmp_dir = tempfile.mkdtemp(prefix="romm_patch_") + output_path = Path(tmp_dir) / output_file_name + + try: + await apply_patch(rom_path, patch_path, output_path) + except PatcherError as e: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + 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 + + 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 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), + ) diff --git a/backend/utils/rom_patcher/__init__.py b/backend/utils/rom_patcher/__init__.py new file mode 100644 index 0000000000..cd5dab18b5 --- /dev/null +++ b/backend/utils/rom_patcher/__init__.py @@ -0,0 +1,13 @@ +from .patcher import ( + PATCHER_SCRIPT, + SUPPORTED_PATCH_EXTENSIONS, + PatcherError, + apply_patch, +) + +__all__ = [ + "PATCHER_SCRIPT", + "SUPPORTED_PATCH_EXTENSIONS", + "PatcherError", + "apply_patch", +] diff --git a/backend/utils/rom_patcher/package-lock.json b/backend/utils/rom_patcher/package-lock.json new file mode 100644 index 0000000000..664850cff1 --- /dev/null +++ b/backend/utils/rom_patcher/package-lock.json @@ -0,0 +1,108 @@ +{ + "name": "romm-backend-rom-patcher", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "romm-backend-rom-patcher", + "license": "AGPL-3.0-only", + "dependencies": { + "rom-patcher": "github:marcrobledo/RomPatcher.js#v3.2.1" + }, + "engines": { + "node": "24" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rom-patcher": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/marcrobledo/RomPatcher.js.git#91e522e247f709e894761157ccba3189004d0859", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "commander": "^11.0.0" + }, + "bin": { + "RomPatcher": "index.js" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/backend/utils/rom_patcher/package.json b/backend/utils/rom_patcher/package.json new file mode 100644 index 0000000000..9c344f9185 --- /dev/null +++ b/backend/utils/rom_patcher/package.json @@ -0,0 +1,12 @@ +{ + "name": "romm-backend-rom-patcher", + "private": true, + "description": "Isolated RomPatcher.js install used by the backend server-side patch endpoint.", + "license": "AGPL-3.0-only", + "dependencies": { + "rom-patcher": "github:marcrobledo/RomPatcher.js#v3.2.1" + }, + "engines": { + "node": "24" + } +} diff --git a/backend/utils/rom_patcher/patcher.js b/backend/utils/rom_patcher/patcher.js new file mode 100644 index 0000000000..abd18f3e11 --- /dev/null +++ b/backend/utils/rom_patcher/patcher.js @@ -0,0 +1,102 @@ +#!/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 sibling node_modules (installed via package.json here). +const ROM_PATCHER_BASE = path.resolve(__dirname, "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); +} diff --git a/backend/utils/rom_patcher/patcher.py b/backend/utils/rom_patcher/patcher.py new file mode 100644 index 0000000000..5249afd2a0 --- /dev/null +++ b/backend/utils/rom_patcher/patcher.py @@ -0,0 +1,50 @@ +"""Server-side ROM patching helpers. + +Shells out to the sibling ``patcher.js`` (Node.js + RomPatcher.js) to apply +a patch file to a ROM file. +""" + +import asyncio +import json +from pathlib import Path + +PATCHER_SCRIPT = Path(__file__).parent / "patcher.js" + +SUPPORTED_PATCH_EXTENSIONS = frozenset( + (".ips", ".ups", ".bps", ".ppf", ".rup", ".aps", ".bdf", ".pmsr", ".vcdiff") +) + + +class PatcherError(Exception): + """Raised when the Node.js patcher script fails or produces no output.""" + + +async def apply_patch(rom_path: Path, patch_path: Path, output_path: Path) -> None: + """Apply ``patch_path`` to ``rom_path`` and write the result to ``output_path``. + + Raises :class:`PatcherError` if the subprocess exits non-zero or the output + file is missing. + """ + 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, + ) + _, stderr = await proc.communicate() + + if proc.returncode != 0: + message = "Patching failed" + try: + err_data = json.loads(stderr.decode()) + message = err_data.get("error", message) + except (json.JSONDecodeError, UnicodeDecodeError): + if stderr: + message = stderr.decode(errors="replace").strip() + raise PatcherError(message) + + if not output_path.exists(): + raise PatcherError("Patcher did not produce an output file") diff --git a/docker/Dockerfile b/docker/Dockerfile index f22a524e4d..af09eeb2e4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -39,6 +39,13 @@ COPY ./frontend ./ RUN npm run build +# BACKEND NODE HELPERS BUILD (server-side ROM patching via RomPatcher.js) +FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}@sha256:${NODE_ALPINE_SHA256} AS backend-node-build +WORKDIR /backend-node/rom_patcher +COPY ./backend/utils/rom_patcher/package*.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund + + # https://github.com/astral-sh/uv/pkgs/container/uv/452595714 FROM ghcr.io/astral-sh/uv:${UV_VERSION}-python${PYTHON_VERSION}-alpine@sha256:${UV_SHA256} AS uv-stage @@ -162,6 +169,7 @@ RUN apk add --no-cache \ libmagic \ mariadb-connector-c \ libpq \ + nodejs \ 7zip \ tzdata \ valkey @@ -180,6 +188,7 @@ COPY --from=python-alias /usr/local/lib/python${PYTHON_VERSION} /usr/local/lib/p COPY --from=rahasher-build /RALibretro/bin64/RAHasher /usr/bin/RAHasher COPY --from=nginx-build ./nginx/objs/ngx_http_zip_module.so /usr/lib/nginx/modules/ COPY --from=frontend-build /front/dist ${WEBSERVER_FOLDER} +COPY --from=backend-node-build /backend-node/rom_patcher/node_modules/rom-patcher/rom-patcher-js /backend/utils/rom_patcher/rom-patcher-js COPY ./frontend/assets ${WEBSERVER_FOLDER}/assets RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 83211587a6..4f19d39032 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,6 @@ "mitt": "^3.0.1", "pinia": "^3.0.1", "qrcode": "^1.5.4", - "rom-patcher": "github:marcrobledo/RomPatcher.js#v3.2.1", "semver": "^7.6.2", "socket.io-client": "^4.7.5", "tailwindcss": "^4.0.0", @@ -51,7 +50,6 @@ "vite": "^6.4.2", "vite-plugin-mkcert": "^1.17.8", "vite-plugin-pwa": "^0.21.1", - "vite-plugin-static-copy": "^3.2.0", "vite-plugin-vuetify": "^2.0.4", "vue-tsc": "^2.2.8" }, @@ -4484,33 +4482,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4701,19 +4672,6 @@ "node": ">=6.0.0" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -4743,19 +4701,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4880,60 +4825,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -5891,19 +5782,6 @@ "node": ">=10" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6293,15 +6171,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -6482,19 +6351,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -6677,16 +6533,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -7623,16 +7469,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7775,19 +7611,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -8039,32 +7862,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8264,27 +8061,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rom-patcher": { - "version": "3.0.0", - "resolved": "git+ssh://git@github.com/marcrobledo/RomPatcher.js.git#91e522e247f709e894761157ccba3189004d0859", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "commander": "^11.0.0" - }, - "bin": { - "RomPatcher": "index.js" - } - }, - "node_modules/rom-patcher/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -8805,18 +8581,6 @@ "node": ">=16" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8922,19 +8686,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -9402,29 +9153,6 @@ } } }, - "node_modules/vite-plugin-static-copy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.2.0.tgz", - "integrity": "sha512-g2k9z8B/1Bx7D4wnFjPLx9dyYGrqWMLTpwTtPHhcU+ElNZP2O4+4OsyaficiDClus0dzVhdGvoGFYMJxoXZ12Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.6.0", - "p-map": "^7.0.4", - "picocolors": "^1.1.1", - "tinyglobby": "^0.2.15" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/sapphi-red" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, "node_modules/vite-plugin-vuetify": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f6bbd6845a..b584076a13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,6 @@ "mitt": "^3.0.1", "pinia": "^3.0.1", "qrcode": "^1.5.4", - "rom-patcher": "github:marcrobledo/RomPatcher.js#v3.2.1", "semver": "^7.6.2", "socket.io-client": "^4.7.5", "tailwindcss": "^4.0.0", @@ -70,7 +69,6 @@ "vite": "^6.4.2", "vite-plugin-mkcert": "^1.17.8", "vite-plugin-pwa": "^0.21.1", - "vite-plugin-static-copy": "^3.2.0", "vite-plugin-vuetify": "^2.0.4", "vue-tsc": "^2.2.8" }, diff --git a/frontend/public/assets/patcherjs/patcher.worker.js b/frontend/public/assets/patcherjs/patcher.worker.js deleted file mode 100644 index 52a373c9e0..0000000000 --- a/frontend/public/assets/patcherjs/patcher.worker.js +++ /dev/null @@ -1,163 +0,0 @@ -/* eslint-disable no-undef */ - -/// - -// Load all patcher scripts -let scriptsLoaded = false; - -async function loadScripts() { - if (scriptsLoaded) return; - - self.BinFile = - self.IPS = - self.UPS = - self.APS = - self.APSGBA = - self.BPS = - self.RUP = - self.PPF = - self.BDF = - self.PMSR = - self.VCDIFF = - null; - - try { - importScripts( - "/rom-patcher/BinFile.js", - "/rom-patcher/HashCalculator.js", - "/rom-patcher/RomPatcher.format.aps_gba.js", - "/rom-patcher/RomPatcher.format.aps_n64.js", - "/rom-patcher/RomPatcher.format.bdf.js", - "/rom-patcher/RomPatcher.format.bps.js", - "/rom-patcher/RomPatcher.format.ips.js", - "/rom-patcher/RomPatcher.format.pmsr.js", - "/rom-patcher/RomPatcher.format.ppf.js", - "/rom-patcher/RomPatcher.format.rup.js", - "/rom-patcher/RomPatcher.format.ups.js", - "/rom-patcher/RomPatcher.format.vcdiff.js", - "/rom-patcher/RomPatcher.js", - ); - scriptsLoaded = true; - return true; - } catch (error) { - throw new Error(`Failed to load patcher scripts: ${error.message}`, { - cause: error, - }); - } -} - -// Handle messages from main thread -self.addEventListener("message", async (e) => { - const { - type, - romData, - patchData, - romFileName, - patchFileName, - customFileName, - } = e.data; - - if (type === "PATCH") { - try { - // Load scripts if not already loaded - self.postMessage({ - type: "STATUS", - message: "Loading patcher libraries...", - }); - await loadScripts(); - - // Extract patch name without extension for custom suffix - const patchNameWithoutExt = patchFileName.replace(/\.[^.]+$/, ""); - - // Try to create BinFile from Uint8Array - self.postMessage({ type: "STATUS", message: "Reading ROM file..." }); - const romUint8 = new Uint8Array(romData); - - const romBin = await new Promise((resolve, reject) => { - try { - new BinFile(romUint8, (bf) => { - if (bf) { - bf.fileName = romFileName; - resolve(bf); - } else { - reject(new Error("Failed to create ROM BinFile")); - } - }); - } catch (err) { - reject(err); - } - }); - - self.postMessage({ type: "STATUS", message: "Reading patch file..." }); - const patchUint8 = new Uint8Array(patchData); - - const patchBin = await new Promise((resolve, reject) => { - try { - new BinFile(patchUint8, (bf) => { - if (bf) { - bf.fileName = patchFileName; - resolve(bf); - } else { - reject(new Error("Failed to create patch BinFile")); - } - }); - } catch (err) { - reject(err); - } - }); - - // Parse patch - self.postMessage({ type: "STATUS", message: "Parsing patch format..." }); - const patch = RomPatcher.parsePatchFile(patchBin); - if (!patch) { - throw new Error("Unsupported or invalid patch format."); - } - - // Apply patch - self.postMessage({ - type: "STATUS", - message: "Applying patch (this may take a moment)...", - }); - const patched = RomPatcher.applyPatch(romBin, patch, { - requireValidation: false, - fixChecksum: false, - outputSuffix: false, // Don't add default suffix - }); - - // Extract the patched binary data - const patchedData = patched._u8array || patched.u8array || patched.data; - if (!patchedData) { - throw new Error("Failed to extract patched ROM data"); - } - - // Create custom filename with patch name - const romBaseName = romFileName.replace(/\.[^.]+$/, ""); - const romExtension = romFileName.match(/\.[^.]+$/)?.[0] || ""; - const defaultFileName = `${romBaseName} (patched-${patchNameWithoutExt})${romExtension}`; - - // If custom filename provided, strip any extension and add ROM extension - let finalFileName; - if (customFileName && customFileName.trim()) { - const customBase = customFileName.trim().replace(/\.[^.]+$/, ""); - finalFileName = `${customBase}${romExtension}`; - } else { - finalFileName = defaultFileName; - } - - // Send back the result - self.postMessage( - { - type: "SUCCESS", - patchedData: patchedData.buffer, - fileName: finalFileName, - }, - [patchedData.buffer], - ); // Transfer ownership of ArrayBuffer - } catch (error) { - self.postMessage({ - type: "ERROR", - error: error.message || String(error), - }); - } - } -}); diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 352bc0682b..9aaac690f5 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -81,6 +81,7 @@ export type { NetplayICEServer } from './models/NetplayICEServer'; export type { OIDCDict } from './models/OIDCDict'; export type { OIDCLogoutResponse } from './models/OIDCLogoutResponse'; export type { OrphanedResourcesCleanupStats } from './models/OrphanedResourcesCleanupStats'; +export type { PatchRequest } from './models/PatchRequest'; export type { PlatformBindingPayload } from './models/PlatformBindingPayload'; export type { PlatformSchema } from './models/PlatformSchema'; export type { PlaySessionEntry } from './models/PlaySessionEntry'; diff --git a/frontend/src/__generated__/models/PatchRequest.ts b/frontend/src/__generated__/models/PatchRequest.ts new file mode 100644 index 0000000000..e2baa85dd6 --- /dev/null +++ b/frontend/src/__generated__/models/PatchRequest.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type PatchRequest = { + /** + * ID of the patch file (RomFile) to apply. + */ + patch_file_id: number; + /** + * Custom output file name. If omitted, derived from ROM + patch names. + */ + output_file_name?: (string | null); +}; + diff --git a/frontend/src/__generated__/models/RomFileSchema.ts b/frontend/src/__generated__/models/RomFileSchema.ts index 0d88b24695..7cf0d9ef40 100644 --- a/frontend/src/__generated__/models/RomFileSchema.ts +++ b/frontend/src/__generated__/models/RomFileSchema.ts @@ -10,6 +10,7 @@ export type RomFileSchema = { file_path: string; file_size_bytes: number; full_path: string; + is_top_level: boolean; created_at: string; updated_at: string; last_modified: string; diff --git a/frontend/src/components/common/Game/AdminMenu.vue b/frontend/src/components/common/Game/AdminMenu.vue index 77cead37b1..7983d94cc4 100644 --- a/frontend/src/components/common/Game/AdminMenu.vue +++ b/frontend/src/components/common/Game/AdminMenu.vue @@ -1,6 +1,6 @@ - - diff --git a/frontend/src/locales/en_US/patcher.json b/frontend/src/locales/en_US/patcher.json index 845e341b6c..fcd6feede1 100644 --- a/frontend/src/locales/en_US/patcher.json +++ b/frontend/src/locales/en_US/patcher.json @@ -14,15 +14,23 @@ "error-no-platform": "Please select a platform to upload to.", "error-no-rom": "Please select a ROM file.", "error-upload-failed": "Unable to upload ROM: {error}", + "no-files": "This ROM has no files.", + "no-patch-files": "No supported patch files found on this ROM.", "output-filename": "Output filename (optional)", "patch-file": "Patch file", "powered-by": "Powered by patcherjs", "replace": "Replace", "rom-file": "ROM file", + "search-patch-library": "Search patches in library", + "search-rom-library": "Search ROMs in library", + "select-file": "Select file", + "select-patch-file": "Select patch file", + "start-typing": "Start typing to search...", "status-downloading": "Downloading patched ROM...", + "status-patching": "Applying patch on server...", "status-preparing": "Preparing files...", "status-uploading": "Uploading to RomM...", - "subtitle": "Choose a base ROM and a patch file, then apply to download the patched ROM.", + "subtitle": "Pick a base ROM and a patch from your library, then apply to download the patched ROM.", "success-downloaded": "downloaded", "success-message": "Patched ROM {actions} successfully!", "success-uploaded": "uploaded", diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index fef66afd77..17da75e0eb 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -184,6 +184,32 @@ const routes = [ name: ROUTES.RUFFLE, component: () => import("@/views/Player/RuffleRS/Base.vue"), }, + { + path: "rom/:rom/patch", + name: ROUTES.PATCHER, + meta: { + title: i18n.global.t("common.patcher"), + }, + component: () => import("@/views/Patcher.vue"), + beforeEnter: (async (to, _from, next) => { + const romsStore = storeRoms(); + + if ( + !romsStore.currentRom || + romsStore.currentRom.id !== parseInt(to.params.rom as string) + ) { + try { + const data = await romApi.getRom({ + romId: parseInt(to.params.rom as string), + }); + romsStore.setCurrentRom(data.data); + } catch (error) { + console.error(error); + } + } + next(); + }) as NavigationGuardWithThis, + }, { path: "april-fools", name: ROUTES.APRIL_FOOLS, @@ -197,14 +223,6 @@ const routes = [ }, component: () => import("@/views/Scan.vue"), }, - { - path: "patcher", - name: ROUTES.PATCHER, - meta: { - title: i18n.global.t("common.patcher"), - }, - component: () => import("@/views/Patcher.vue"), - }, { path: "user/:user", name: ROUTES.USER_PROFILE, diff --git a/frontend/src/stores/navigation.ts b/frontend/src/stores/navigation.ts index 57a1b20c82..3c5e2efe99 100644 --- a/frontend/src/stores/navigation.ts +++ b/frontend/src/stores/navigation.ts @@ -45,9 +45,9 @@ export default defineStore("navigation", { this.reset(); this.$router.push({ name: ROUTES.SCAN }); }, - goPatcher() { + goPatcher(romId: number) { this.reset(); - this.$router.push({ name: ROUTES.PATCHER }); + this.$router.push({ name: ROUTES.PATCHER, params: { rom: romId } }); }, goSearch() { this.reset(); diff --git a/frontend/src/types/rompatcher.d.ts b/frontend/src/types/rompatcher.d.ts deleted file mode 100644 index 6bd5b1db51..0000000000 --- a/frontend/src/types/rompatcher.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -declare module "rom-patcher/*" { - const value: any; - export default value; -} diff --git a/frontend/src/views/Patcher.vue b/frontend/src/views/Patcher.vue index 0b435546bd..aacd2ba309 100644 --- a/frontend/src/views/Patcher.vue +++ b/frontend/src/views/Patcher.vue @@ -1,64 +1,33 @@ - - diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b2157228b1..cdfa744af3 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,7 +4,6 @@ import { URL, fileURLToPath } from "node:url"; import { defineConfig, loadEnv } from "vite"; import mkcert from "vite-plugin-mkcert"; import { VitePWA } from "vite-plugin-pwa"; -import { viteStaticCopy } from "vite-plugin-static-copy"; import vuetify, { transformAssetUrls } from "vite-plugin-vuetify"; // Vuetify components to preoptimize for faster dev startup @@ -109,14 +108,6 @@ export default defineConfig(({ mode }) => { savePath: "/app/.vite-plugin-mkcert", hosts: ["localhost", "127.0.0.1", "romm.dev"], }), - viteStaticCopy({ - targets: [ - { - src: "node_modules/rom-patcher/rom-patcher-js/**/*.js", - dest: "rom-patcher", - }, - ], - }), ], define: { "process.env": {},