Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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=45fcd54c1e6e18b63f83a70b8d86a4f93fb53340
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)
10 changes: 10 additions & 0 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,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 @@ -431,6 +433,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 @@ -851,6 +859,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
15 changes: 14 additions & 1 deletion backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
from handler.metadata.ra_handler import RAMetadata
from handler.metadata.ss_handler import SSMetadata
from models.collection import Collection
from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus
from models.rom import (
Rom,
RomArchiveMember,
RomFile,
RomFileCategory,
RomUserStatus,
SaveUsage,
)

from .base import BaseModel, UTCDatetime

Expand Down Expand Up @@ -191,6 +198,9 @@ class RomFileSchema(BaseModel):
sha1_hash: str | None
ra_hash: str | None
chd_sha1_hash: str | None
title_id: str | None
save_id: str | None
title_version: int | None
archive_members: list[RomArchiveMember] | None
category: RomFileCategory | None
track_meta: TrackMetaSchema | None = None
Expand Down Expand Up @@ -331,6 +341,9 @@ class RomSchema(BaseModel):
md5_hash: str | None
sha1_hash: str | None
ra_hash: str | None
title_id: str | None
save_id: str | None
save_usage: SaveUsage | None

has_simple_single_file: bool
has_nested_single_file: bool
Expand Down
Loading
Loading