Skip to content

Commit b3bab71

Browse files
authored
feat(domain): save sync v2 domain logic (#183) (#189)
Add four pure domain modules for save sync v2: - save_sync: unified sync action logic with v4.7 device sync awareness (is_current) and v4.6 fallback - save_path: RetroArch save directory layout resolution (sort_by_content, sort_by_core) - emulator_tag: RomM emulator tag construction from core names - save_extensions: per-platform save file extension configuration 60 new tests, all pure functions with no I/O.
1 parent ef1340e commit b3bab71

8 files changed

Lines changed: 803 additions & 0 deletions

File tree

py_modules/domain/emulator_tag.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Emulator tag construction for RomM save uploads.
2+
3+
The emulator tag determines the server-side folder path for saves:
4+
saves/{system}/{rom_id}/{emulator}/
5+
6+
Format: retroarch-{core} where core is the libretro core name
7+
without the _libretro suffix, lowercased.
8+
9+
No I/O, no service/adapter/lib imports. Pure functions only.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
_LIBRETRO_SUFFIX = "_libretro"
15+
_FALLBACK = "retroarch"
16+
17+
18+
def build_emulator_tag(core_so: str | None) -> str:
19+
"""Build a RomM emulator tag from a RetroArch core .so name.
20+
21+
Parameters
22+
----------
23+
core_so:
24+
The libretro core name as found in ES-DE config, e.g. "mgba_libretro",
25+
"snes9x_libretro", "swanstation_libretro".
26+
May be None if core resolution failed.
27+
28+
Returns
29+
-------
30+
str
31+
Emulator tag string, e.g. "retroarch-mgba", "retroarch-snes9x".
32+
Returns "retroarch" as fallback when core_so is None or empty.
33+
34+
Rules:
35+
- Strip "_libretro" suffix
36+
- Lowercase
37+
- Prepend "retroarch-"
38+
- Fallback to "retroarch" if core_so is None/empty
39+
"""
40+
if not core_so:
41+
return _FALLBACK
42+
core = core_so.lower()
43+
if core.endswith(_LIBRETRO_SUFFIX):
44+
core = core[: -len(_LIBRETRO_SUFFIX)]
45+
return f"retroarch-{core}"
46+
47+
48+
def detect_core_change(stored_core: str | None, active_core: str | None) -> bool:
49+
"""Detect if the active RetroArch core has changed since last sync.
50+
51+
Parameters
52+
----------
53+
stored_core:
54+
The core_so name recorded at last sync, or None if never synced.
55+
active_core:
56+
The currently active core_so name, or None if unresolved.
57+
58+
Returns
59+
-------
60+
bool
61+
True if the cores differ and both are non-None (actual change).
62+
False if either is None (can't determine) or they match.
63+
"""
64+
if stored_core is None or active_core is None:
65+
return False
66+
return stored_core != active_core
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Per-platform save file extension configuration.
2+
3+
Provides the list of save file extensions to look for when syncing saves.
4+
The default covers RetroArch's standard .srm and .rtc extensions.
5+
Platform-specific overrides can expand or replace this list.
6+
7+
Extension expansion (adding .sav, .mcr, .mcd, etc.) is tracked in #186.
8+
9+
No I/O, no service/adapter/lib imports. Pure functions only.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
_DEFAULT_EXTENSIONS: tuple[str, ...] = (".srm", ".rtc")
15+
16+
# Platform-specific overrides. Keys are RomM platform slugs.
17+
# Values completely replace the default list for that platform.
18+
# Populated incrementally as gameplay tests confirm actual extensions.
19+
_PLATFORM_OVERRIDES: dict[str, tuple[str, ...]] = {
20+
# Example (to be filled in #186):
21+
# "n64": (".srm", ".rtc", ".eep", ".sra", ".fla", ".mpk"),
22+
}
23+
24+
25+
def get_save_extensions(platform_slug: str | None = None) -> tuple[str, ...]:
26+
"""Return the save file extensions to search for a given platform.
27+
28+
Parameters
29+
----------
30+
platform_slug:
31+
RomM platform slug (e.g. "gba", "n64", "psx").
32+
If None or not in overrides, returns the default extensions.
33+
34+
Returns
35+
-------
36+
tuple[str, ...]
37+
Tuple of file extensions including the leading dot (e.g. (".srm", ".rtc")).
38+
"""
39+
if platform_slug is not None and platform_slug in _PLATFORM_OVERRIDES:
40+
return _PLATFORM_OVERRIDES[platform_slug]
41+
return _DEFAULT_EXTENSIONS
42+
43+
44+
def get_all_known_extensions() -> tuple[str, ...]:
45+
"""Return all unique extensions across defaults and all platform overrides.
46+
47+
Useful for broad file discovery or migration tooling.
48+
"""
49+
seen: set[str] = set()
50+
result: list[str] = []
51+
for ext in _DEFAULT_EXTENSIONS:
52+
if ext not in seen:
53+
seen.add(ext)
54+
result.append(ext)
55+
for exts in _PLATFORM_OVERRIDES.values():
56+
for ext in exts:
57+
if ext not in seen:
58+
seen.add(ext)
59+
result.append(ext)
60+
return tuple(result)

py_modules/domain/save_path.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Save file path resolution for RetroArch save directory layouts.
2+
3+
Handles the various save directory structures created by RetroArch's
4+
sort_savefiles_by_content_enable and sort_savefiles_enable settings.
5+
6+
No I/O, no service/adapter/lib imports. Pure functions only.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
13+
14+
def resolve_save_dir(
15+
rom_path: str,
16+
saves_base: str,
17+
system: str,
18+
*,
19+
sort_by_content: bool = True,
20+
sort_by_core: bool = False,
21+
core_name: str | None = None,
22+
) -> str:
23+
"""Resolve the save directory for a ROM.
24+
25+
Parameters
26+
----------
27+
rom_path:
28+
Relative ROM file path as stored in RomM (e.g. "gba/Game.gba" or
29+
"psx/Game (USA)/Game.m3u").
30+
saves_base:
31+
Absolute path to the RetroArch saves root directory.
32+
system:
33+
Platform/system slug (e.g. "gba", "psx"). Used as fallback subdir.
34+
sort_by_content:
35+
If True, save subdir = last folder component of rom_path.
36+
RetroDECK default: True.
37+
sort_by_core:
38+
If True, adds a core name subfolder inside the content dir.
39+
RetroDECK default: False. Requires core_name.
40+
core_name:
41+
RetroArch core name (e.g. "mgba_libretro"). Required when sort_by_core=True.
42+
43+
Returns
44+
-------
45+
str
46+
Absolute path to the directory where save files should be found/placed.
47+
"""
48+
parts: list[str] = [saves_base]
49+
50+
if sort_by_content:
51+
# Last folder component of the rom_path (i.e. the directory containing the ROM file)
52+
rom_dir = os.path.dirname(rom_path)
53+
content_dir = os.path.basename(rom_dir) if rom_dir else system
54+
parts.append(content_dir)
55+
56+
if sort_by_core and core_name:
57+
parts.append(core_name)
58+
59+
return os.path.join(*parts)
60+
61+
62+
def resolve_save_filename(rom_path: str, ext: str = ".srm") -> str:
63+
"""Derive the save filename from a ROM path.
64+
65+
Takes the ROM's basename, strips extension, appends save extension.
66+
E.g. "gba/Pokemon.gba" -> "Pokemon.srm"
67+
"""
68+
basename = os.path.basename(rom_path)
69+
name, _ = os.path.splitext(basename)
70+
return name + ext
71+
72+
73+
def detect_path_change(stored_path: str | None, resolved_path: str) -> bool:
74+
"""Detect if the save path has changed since last sync.
75+
76+
Useful for warning users when RetroArch settings change (e.g.
77+
sort_by_content toggled) which would move where saves are expected.
78+
79+
Returns True if paths differ (or stored_path is None -- first sync).
80+
"""
81+
if stored_path is None:
82+
return True
83+
return stored_path != resolved_path

py_modules/domain/save_sync.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Unified sync-action logic for save sync v2.
2+
3+
Extends the existing conflict detection (save_conflicts.py) with
4+
RomM v4.7 device sync awareness (is_current flag from device_syncs).
5+
6+
No I/O, no service/adapter/lib imports. Pure functions only.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from domain.save_conflicts import check_server_changes_fast, determine_action
12+
13+
14+
def check_server_changed_v47(device_sync_info: dict | None) -> bool | None:
15+
"""Check server change using v4.7 device_syncs info.
16+
17+
Parameters
18+
----------
19+
device_sync_info:
20+
A single device sync record from the server's device_syncs array,
21+
or None if not available (v4.6 / no device registered).
22+
23+
Returns
24+
-------
25+
bool | None
26+
True if server changed (is_current == False),
27+
False if server unchanged (is_current == True),
28+
None if indeterminate (no device_sync_info provided, or is_current is
29+
absent/None).
30+
"""
31+
if device_sync_info is None:
32+
return None
33+
is_current = device_sync_info.get("is_current")
34+
if is_current is None:
35+
return None
36+
return not is_current
37+
38+
39+
def determine_sync_action(
40+
local_changed: bool,
41+
server_save: dict | None,
42+
device_sync_info: dict | None = None,
43+
file_state: dict | None = None,
44+
) -> str:
45+
"""Determine the sync action combining local and server change detection.
46+
47+
Parameters
48+
----------
49+
local_changed:
50+
Whether the local file has changed since last sync (caller computes this).
51+
server_save:
52+
Server save metadata dict, or None if no server save exists.
53+
device_sync_info:
54+
v4.7 device sync record (has "is_current" key), or None for v4.6 fallback.
55+
file_state:
56+
Per-file sync state dict (used for v4.6 fallback via check_server_changes_fast).
57+
Only needed when device_sync_info is None.
58+
59+
Returns
60+
-------
61+
str
62+
One of "skip", "upload", "download", "conflict", "initial_upload",
63+
"initial_download".
64+
"""
65+
# No server save at all
66+
if server_save is None:
67+
return "initial_upload" if local_changed else "skip"
68+
69+
# Server save exists — determine whether server has changed
70+
server_changed: bool
71+
72+
# Priority 1: v4.7 device_sync_info
73+
v47_result = check_server_changed_v47(device_sync_info)
74+
if v47_result is not None:
75+
server_changed = v47_result
76+
else:
77+
# Priority 2: v4.6 fast-path using file_state
78+
if file_state is not None:
79+
v46_result = check_server_changes_fast(file_state, server_save)
80+
server_changed = v46_result if v46_result is not None else True
81+
else:
82+
# Priority 3: no info at all — safe default: assume server changed
83+
server_changed = True
84+
85+
return determine_action(local_changed, server_changed)

tests/test_emulator_tag.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Unit tests for domain.emulator_tag — pure emulator tag construction functions."""
2+
3+
from __future__ import annotations
4+
5+
from domain.emulator_tag import build_emulator_tag, detect_core_change
6+
7+
# ---------------------------------------------------------------------------
8+
# TestBuildEmulatorTag
9+
# ---------------------------------------------------------------------------
10+
11+
12+
class TestBuildEmulatorTag:
13+
def test_normal_core_strips_libretro_suffix(self):
14+
assert build_emulator_tag("mgba_libretro") == "retroarch-mgba"
15+
16+
def test_core_with_digits_strips_libretro_suffix(self):
17+
assert build_emulator_tag("snes9x_libretro") == "retroarch-snes9x"
18+
19+
def test_swanstation_core(self):
20+
assert build_emulator_tag("swanstation_libretro") == "retroarch-swanstation"
21+
22+
def test_none_input_returns_fallback(self):
23+
assert build_emulator_tag(None) == "retroarch"
24+
25+
def test_empty_string_returns_fallback(self):
26+
assert build_emulator_tag("") == "retroarch"
27+
28+
def test_core_without_libretro_suffix_passes_through(self):
29+
# Shouldn't happen in practice but must not crash
30+
assert build_emulator_tag("mgba") == "retroarch-mgba"
31+
32+
def test_uppercase_core_is_lowercased(self):
33+
assert build_emulator_tag("MGBA_libretro") == "retroarch-mgba"
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# TestDetectCoreChange
38+
# ---------------------------------------------------------------------------
39+
40+
41+
class TestDetectCoreChange:
42+
def test_same_core_returns_false(self):
43+
assert detect_core_change("mgba_libretro", "mgba_libretro") is False
44+
45+
def test_different_cores_returns_true(self):
46+
assert detect_core_change("mgba_libretro", "snes9x_libretro") is True
47+
48+
def test_stored_none_returns_false(self):
49+
# First sync — no prior core recorded, can't determine change
50+
assert detect_core_change(None, "mgba_libretro") is False
51+
52+
def test_active_none_returns_false(self):
53+
# Core unresolved — can't determine change
54+
assert detect_core_change("mgba_libretro", None) is False
55+
56+
def test_both_none_returns_false(self):
57+
assert detect_core_change(None, None) is False

0 commit comments

Comments
 (0)