Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DEVELOPER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ source .venv/bin/activate
uv sync --all-extras --dev
```

#### - Build and install sigil (optional)

```sh
# This is only required for title ID extraction; the feature no-ops without it
# Users on macOS can skip this step, like RAHasher
# Requires cmake, and RomM's Python 3.13 venv (created above) must be active
git clone --recurse-submodules https://github.com/rommforge/argosy-sigil.git ../argosy-sigil
cd ../argosy-sigil/bindings/python
uv pip install cffi setuptools
make build
uv pip install -e .
cd -
```

#### - Spin up the database and other services

```sh
Expand Down
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
make \
cmake \
gcc \
g++ \
libmariadb3 \
Expand Down Expand Up @@ -77,6 +78,24 @@ RUN uv sync --all-extras

ENV PATH="/app/.venv/bin:${PATH}"

# Build and install sigil (optional, for title ID extraction)
# Placed after `uv sync` because the extension is compiled with the venv's
# Python so the ABI matches.
ARG SIGIL_VERSION=98f3c626eecac48aa99209414d88a15ebe9b70c4
RUN git clone --recurse-submodules https://github.com/rommforge/argosy-sigil.git /tmp/argosy-sigil
WORKDIR /tmp/argosy-sigil
RUN git checkout "${SIGIL_VERSION}" \
&& git submodule update --init --recursive \
&& cmake -B ./build-python -S . -DSIGIL_BUILD_CLI=OFF -DSIGIL_BUILD_TESTS=OFF \
&& cmake --build ./build-python --target sigil
WORKDIR /tmp/argosy-sigil/bindings/python
RUN uv pip install --python /app/.venv/bin/python cffi setuptools \
&& /app/.venv/bin/python build_sigil.py \
&& mkdir -p /app/.venv/lib/python3.13/site-packages/sigil \
&& cp ./sigil/*.py ./sigil/_sigil.*.so /app/.venv/lib/python3.13/site-packages/sigil/
WORKDIR /app
RUN rm -rf /tmp/argosy-sigil

# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
Expand Down
99 changes: 99 additions & 0 deletions backend/adapters/services/sigil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Final

from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from logger.logger import log

try:
import sigil
except ImportError:
sigil = None # type: ignore[assignment]

SWITCH_SIGIL_SLUG: Final = "switch"

SIGIL_PLATFORM_SLUGS: Final[dict[UPS, str]] = {
UPS.PSP: "psp",
UPS.PSX: "psx",
UPS.PS2: "ps2",
UPS.PSVITA: "psvita",
UPS.SWITCH: SWITCH_SIGIL_SLUG,
UPS.SWITCH_2: SWITCH_SIGIL_SLUG,
UPS.N3DS: "3ds",
UPS.WII: "wii",
UPS.WIIU: "wiiu",
UPS.NGC: "gamecube",
}

# Errors that are expected for arbitrary library files (no title id present,
# format sigil can't parse, missing decryption keys). Logged at debug level.
ROUTINE_SIGIL_ERROR_CODES: Final = frozenset(
{"NOT_FOUND", "UNSUPPORTED", "UNSUPPORTED_FORMAT", "NEEDS_KEY"}
)

_missing_binding_logged = False


class SigilServiceError(Exception): ...


@dataclass(frozen=True)
class SigilExtractionResult:
title_id: str
save_id: str
usage: str
content_type: str | None = None
version: int | None = None


class SigilService:
"""Service to extract platform-native title ids from ROM binaries via the
optional `sigil` cffi binding."""

async def extract_title_id(
self,
platform_slug: UPS | str,
file_path: str,
prod_keys_path: str | None = None,
) -> SigilExtractionResult | None:
global _missing_binding_logged

if sigil is None:
if not _missing_binding_logged:
log.debug("sigil binding not installed, skipping title id extraction")
_missing_binding_logged = True
return None

sigil_slug = SIGIL_PLATFORM_SLUGS.get(platform_slug) # type: ignore[arg-type]
if sigil_slug is None:
return None

kwargs: dict[str, Any] = {"platform": sigil_slug, "filename_fallback": False}
if sigil_slug == SWITCH_SIGIL_SLUG:
kwargs["prod_keys_path"] = prod_keys_path

try:
result = await asyncio.to_thread(sigil.extract, file_path, **kwargs)
except Exception as exc:
code = getattr(exc, "code", None)
is_sigil_error = isinstance(exc, getattr(sigil, "SigilError", ()))
if is_sigil_error and code in ROUTINE_SIGIL_ERROR_CODES:
log.debug(f"Sigil found no title id for {file_path}: {code}")
else:
log.error(f"Sigil extraction failed for {file_path}: {exc}")
return None

raw_content_type = getattr(result, "switch_content_type", None)
content_type = (
raw_content_type if raw_content_type not in (None, "", "unknown") else None
)

return SigilExtractionResult(
title_id=result.title_id,
save_id=result.save_id,
usage=result.usage,
content_type=content_type,
# Version 0 is a valid base-game version, so keep the int as-is;
# a missing field (non-Switch, older binding) maps to None.
version=getattr(result, "title_version", None),
)
93 changes: 93 additions & 0 deletions backend/alembic/versions/0104_sigil_title_ids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Add sigil-extracted title id columns: per-file title_id/save_id on
rom_files, plus rom-level title_id/save_id/save_usage on roms.

Revision ID: 0104_sigil_title_ids
Revises: 0103_roms_facets_provider_ids
Create Date: 2026-07-23 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ENUM

from utils.database import is_postgresql

# revision identifiers, used by Alembic.
revision = "0104_sigil_title_ids"
down_revision = "0103_roms_facets_provider_ids"
branch_labels = None
depends_on = None

SAVE_USAGE_VALUES = ("FOLDER_EXACT", "FOLDER_PREFIX", "FILE_EXACT", "FILE_PREFIX")


def _save_usage_enum(connection) -> sa.Enum:
if is_postgresql(connection):
enum = ENUM(*SAVE_USAGE_VALUES, name="saveusage", create_type=False)
enum.create(connection, checkfirst=True)
return enum
return sa.Enum(*SAVE_USAGE_VALUES, name="saveusage")


def upgrade() -> None:
connection = op.get_bind()
save_usage_enum = _save_usage_enum(connection)

op.add_column(
"rom_files",
sa.Column("title_id", sa.String(length=100), nullable=True),
if_not_exists=True,
)
op.add_column(
"rom_files",
sa.Column("save_id", sa.String(length=100), nullable=True),
if_not_exists=True,
)
op.add_column(
"roms",
sa.Column("title_id", sa.String(length=100), nullable=True),
if_not_exists=True,
)
op.add_column(
"roms",
sa.Column("save_id", sa.String(length=100), nullable=True),
if_not_exists=True,
)
op.add_column(
"roms",
sa.Column("save_usage", save_usage_enum, nullable=True),
if_not_exists=True,
)

with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.create_index(
"idx_rom_files_title_id",
["title_id"],
unique=False,
if_not_exists=True,
)
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.create_index(
"idx_roms_title_id",
["title_id"],
unique=False,
if_not_exists=True,
)


def downgrade() -> None:
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_index("idx_roms_title_id", if_exists=True)
with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.drop_index("idx_rom_files_title_id", if_exists=True)

op.drop_column("roms", "save_usage", if_exists=True)
op.drop_column("roms", "save_id", if_exists=True)
op.drop_column("roms", "title_id", if_exists=True)
op.drop_column("rom_files", "save_id", if_exists=True)
op.drop_column("rom_files", "title_id", if_exists=True)

connection = op.get_bind()
if is_postgresql(connection):
ENUM(name="saveusage").drop(connection, checkfirst=True)
29 changes: 29 additions & 0 deletions backend/alembic/versions/0105_sigil_title_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add sigil-extracted title_version column to rom_files (Switch title
versions are u32, stored as BigInteger).

Revision ID: 0105_sigil_title_version
Revises: 0104_sigil_title_ids
Create Date: 2026-07-24 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0105_sigil_title_version"
down_revision = "0104_sigil_title_ids"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"rom_files",
sa.Column("title_version", sa.BigInteger(), nullable=True),
if_not_exists=True,
)


def downgrade() -> None:
op.drop_column("rom_files", "title_version", if_exists=True)
59 changes: 59 additions & 0 deletions backend/alembic/versions/0106_sigil_folder_split.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Add FOLDER_SPLIT to the saveusage enum (3DS saves nest the id across
two directory levels).

Revision ID: 0106_sigil_folder_split
Revises: 0105_sigil_title_version
Create Date: 2026-07-29 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

from utils.database import is_postgresql

# revision identifiers, used by Alembic.
revision = "0106_sigil_folder_split"
down_revision = "0105_sigil_title_version"
branch_labels = None
depends_on = None

SAVE_USAGE_VALUES = (
"FOLDER_EXACT",
"FOLDER_PREFIX",
"FILE_EXACT",
"FILE_PREFIX",
"FOLDER_SPLIT",
)


def upgrade() -> None:
connection = op.get_bind()

if is_postgresql(connection):
# ALTER TYPE ... ADD VALUE must run outside alembic's transaction.
with op.get_context().autocommit_block():
op.execute("ALTER TYPE saveusage ADD VALUE IF NOT EXISTS 'FOLDER_SPLIT'")
else:
save_usage_enum = sa.Enum(*SAVE_USAGE_VALUES, name="saveusage")
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.alter_column("save_usage", type_=save_usage_enum, nullable=True)


def downgrade() -> None:
# PostgreSQL cannot drop enum values; leave FOLDER_SPLIT in place. On
# MariaDB, narrow the enum back after clearing any rows that used it.
connection = op.get_bind()
if is_postgresql(connection):
return

op.execute("UPDATE roms SET save_usage = NULL WHERE save_usage = 'FOLDER_SPLIT'")
reverted_enum = sa.Enum(
"FOLDER_EXACT",
"FOLDER_PREFIX",
"FILE_EXACT",
"FILE_PREFIX",
name="saveusage",
)
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.alter_column("save_usage", type_=reverted_enum, nullable=True)
10 changes: 10 additions & 0 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ class Config:
ROMS_FOLDER_NAME: str
FIRMWARE_FOLDER_NAME: str
SKIP_HASH_CALCULATION: bool
SKIP_TITLE_ID_EXTRACTION: bool
EMBED_SWITCH_TITLE_IDS: bool
EJS_DEBUG: bool
EJS_CACHE_LIMIT: int | None
EJS_DISABLE_AUTO_UNLOAD: bool
Expand Down Expand Up @@ -437,6 +439,12 @@ def _parse_config(self):
SKIP_HASH_CALCULATION=pydash.get(
self._raw_config, "filesystem.skip_hash_calculation", False
),
SKIP_TITLE_ID_EXTRACTION=pydash.get(
self._raw_config, "filesystem.skip_title_id_extraction", False
),
EMBED_SWITCH_TITLE_IDS=pydash.get(
self._raw_config, "filesystem.embed_switch_title_ids", False
),
EJS_DEBUG=pydash.get(self._raw_config, "emulatorjs.debug", False),
EJS_CACHE_LIMIT=pydash.get(
self._raw_config, "emulatorjs.cache_limit", None
Expand Down Expand Up @@ -873,6 +881,8 @@ def _update_config_file(self) -> None:
"roms_folder": self.config.ROMS_FOLDER_NAME,
"firmware_folder": self.config.FIRMWARE_FOLDER_NAME,
"skip_hash_calculation": self.config.SKIP_HASH_CALCULATION,
"skip_title_id_extraction": self.config.SKIP_TITLE_ID_EXTRACTION,
"embed_switch_title_ids": self.config.EMBED_SWITCH_TITLE_IDS,
},
"system": {
"platforms": self.config.PLATFORMS_BINDING,
Expand Down
Loading
Loading