Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ccf43ed
Add binary title ID extraction via sigil during scans
tmgast Jul 23, 2026
c753997
Surface title and save IDs in the v2 game details
tmgast Jul 23, 2026
2f1d69f
Build sigil into Docker images as an optional dependency
tmgast Jul 23, 2026
8fa9bc5
Bump sigil pin for the Linux large-CHD fix
tmgast Jul 23, 2026
c902605
Bump sigil pin for PS2 folder-prefix save_id
tmgast Jul 23, 2026
fe0b4de
Bump sigil pin for Switch CNMT content type and version
tmgast Jul 23, 2026
4e696b3
Extract per-file Switch content type and version via CNMT
tmgast Jul 23, 2026
aba14c0
Add opt-in Switch title ID filename embedding
tmgast Jul 23, 2026
4aaf6d6
Show Switch title version in the file row
tmgast Jul 23, 2026
dbab44c
Persist title_version through the RomFile rebuild
tmgast Jul 23, 2026
6042a1c
Merge upstream master into feature/sigil-title-ids
tmgast Jul 23, 2026
3f91f0d
Merge upstream master into feature/sigil-title-ids
tmgast Jul 29, 2026
0c88dfd
Extract 3DS save location via sigil folder-split usage
tmgast Jul 29, 2026
158977c
Merge upstream master into feature/sigil-title-ids
tmgast Jul 30, 2026
0cf06f2
Bump sigil pin for 3DS z3ds extraction fix
tmgast Aug 16, 2026
95f30b4
Merge upstream master into feature/sigil-title-ids
tmgast Aug 16, 2026
b9261cd
fix(scan): store 3DS, Wii and Wii U save ids in the emulator's case
tmgast Aug 26, 2026
9d9ce97
feat(scan): extract Dreamcast title ids via sigil
tmgast Aug 26, 2026
5425a22
Merge upstream master into feature/sigil-title-ids
tmgast Aug 26, 2026
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=a294ade6ef5043fd825ec5a866fe5aa3b6aeeb77
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
100 changes: 100 additions & 0 deletions backend/adapters/services/sigil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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",
UPS.DC: "dreamcast",
}

# 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/0111_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: 0111_sigil_title_ids
Revises: 0110_walkthrough_docs
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 = "0111_sigil_title_ids"
down_revision = "0110_walkthrough_docs"
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/0112_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: 0112_sigil_title_version
Revises: 0111_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 = "0112_sigil_title_version"
down_revision = "0111_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/0113_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: 0113_sigil_folder_split
Revises: 0112_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 = "0113_sigil_folder_split"
down_revision = "0112_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)
Loading
Loading