Skip to content

commit 38510af

abduznik edited this page May 23, 2026 · 1 revision

Claude/implement save sync f gl cd (#2)

Commit: 38510af51b8484e23a5e553a4775dee6a961261c

Author: abduznik

Date: 2026-03-24

Message

  • Implement save sync — push/pull save files to/from RomM
  • Add SaveStrategy abstract base (backupSave rotation, getRomStem helper)
  • Add RetroArchSaveStrategy for GBA/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive
  • Add DolphinSaveStrategy for GC/Wii (region-aware Card A path)
  • Add EdenSaveStrategy for Switch (title ID via filename regex, XCI header, or folder scan)
  • Add SaveSyncService wiring strategies to RommService (pushSaves, pullSave)
  • Add uploadSave, getLatestSave, downloadSave to RommService
  • Add saveSyncServiceProvider to romm_provider.dart
  • Auto pull latest cloud save before launching a game in library_screen
  • Add cloud_upload sync button to GameCard via onSyncSaves callback
  • Update agent.md with all new files

https://claude.ai/code/session_012NdiCJkKvErnXToPrsMqo6

  • Fix eden_save_strategy: replace Platform.pathSeparator and Platform.environment
  • Platform.pathSeparator: replaced with replaceAll+split regex (handles both / and )
  • Platform.environment['APPDATA']: replaced with Process.run('cmd /c echo %APPDATA%') to avoid unresolved member errors in Flutter Windows build

https://claude.ai/code/session_012NdiCJkKvErnXToPrsMqo6

  • Extract zip when restoring Eden saves

RomM packages save files as .zip archives. EdenSaveStrategy.restoreSave now extracts the archive contents directly into the title ID folder instead of writing the zip as-is, so Eden can load the save files.

https://claude.ai/code/session_012NdiCJkKvErnXToPrsMqo6

  • Fixed downloads to use basic auth

  • feat: eden save sync - pull on launch with profile detection and user warning

  • feat: retroarch save states sync, psp/dc slug aliases, retroarch sync mode setting

  • feat: retroarch save states sync, settings sync mode, slug aliases, analyze fixes

  • feat: multi-file ROM download detection, extraction, and download status messages

  • fix print to debugprint


Co-authored-by: Claude noreply@anthropic.com

Why: General improvement or change to the codebase.

Files Changed

agent.md                                           |  11 +-
 lib/app.dart                                       |   1 +
 lib/core/downloader/download_service.dart          |  74 ++++++-
 .../emulator/strategies/retroarch_strategy.dart    |  15 +-
 lib/core/romm/romm_models.dart                     |   8 +
 lib/core/romm/romm_service.dart                    | 142 +++++++++++--
 lib/core/save/save_strategy.dart                   |  49 +++++
 lib/core/save/save_sync_service.dart               | 153 ++++++++++++++
 .../save/strategies/dolphin_save_strategy.dart     |  78 +++++++
 lib/core/save/strategies/eden_save_strategy.dart   | 235 +++++++++++++++++++++
 .../save/strategies/retroarch_save_strategy.dart   | 127 +++++++++++
 lib/core/storage/directory_service.dart            |  25 ++-
 lib/providers/library_provider.dart                |   8 +
 lib/providers/romm_provider.dart                   |  12 +-
 lib/ui/screens/library_screen.dart                 |  75 ++++++-
 lib/ui/screens/settings_screen.dart                |  35 ++-
 lib/ui/widgets/download_progress_card.dart         |   2 +-
 lib/ui/widgets/game_card.dart                      |  12 ++
 ls_upload.txt                                      | 167 ---------------
 pubspec.lock                                       |   2 +-
 20 files changed, 1016 insertions(+), 215 deletions(-)
  • agent.md
  • lib/app.dart
  • lib/core/downloader/download_service.dart
  • lib/core/emulator/strategies/retroarch_strategy.dart
  • lib/core/romm/romm_models.dart
  • lib/core/romm/romm_service.dart
  • lib/core/save/save_strategy.dart
  • lib/core/save/save_sync_service.dart
  • lib/core/save/strategies/dolphin_save_strategy.dart
  • lib/core/save/strategies/eden_save_strategy.dart
  • lib/core/save/strategies/retroarch_save_strategy.dart
  • lib/core/storage/directory_service.dart
  • lib/providers/library_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart
  • lib/ui/screens/settings_screen.dart
  • lib/ui/widgets/download_progress_card.dart
  • lib/ui/widgets/game_card.dart
  • ls_upload.txt
  • pubspec.lock

Diff

diff --git a/agent.md b/agent.md
index 3191783..9e48305 100644
--- a/agent.md
+++ b/agent.md
@@ -21,9 +21,16 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 - `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell.
 
 ### Core — RomM
-- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(). Returns typed models.
+- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(), uploadSave(), getLatestSave(), downloadSave(). Returns typed models.
 - `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig.
 
+### Core — Save Sync
+- `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() (3-version rotation), getRomStem().
+- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). Wires strategies to RommService.
+- `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto.
+- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci.
+- `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan.
+
 ### Core — Emulator
 - `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform().
 - `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions.
@@ -43,7 +50,7 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 - `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads new binary to temp, swaps, relaunches.
 
 ### Providers
-- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, and EmulatorDownloadService.
+- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, EmulatorDownloadService, and SaveSyncService.
 - `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search and filtering logic.
 - `lib/providers/download_provider.dart` — Riverpod providers for active downloads and progress.
 
diff --git a/lib/app.dart b/lib/app.dart
index 247e8ea..d08c16a 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -26,6 +26,7 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> {
     super.initState();
     // Eagerly load persisted card aspect ratio
     ref.read(cardAspectRatioLoaderProvider);
+    ref.read(retroarchSyncModeLoaderProvider);
   }
 
   @override
diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart
index a82edf0..927d303 100644
--- a/lib/core/downloader/download_service.dart
+++ b/lib/core/downloader/download_service.dart
@@ -1,6 +1,8 @@
 import 'package:dio/dio.dart';
 import 'dart:async';
 import 'dart:io';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/rendering.dart';
 import 'package:freegosy/core/storage/directory_service.dart';
 import 'package:freegosy/core/romm/romm_models.dart';
 
@@ -12,6 +14,7 @@ class DownloadProgress {
   final int totalBytes;
   final bool isComplete;
   final String? error;
+  final String status; // 'downloading', 'extracting', 'complete', 'error'
 
   DownloadProgress({
     required this.id,
@@ -21,6 +24,7 @@ class DownloadProgress {
     this.totalBytes = 0,
     this.isComplete = false,
     this.error,
+    this.status = 'Downloading...',
   });
 }
 
@@ -30,7 +34,6 @@ class DownloadService {
 
   DownloadService({required this.dio, required this.directoryService});
 
-  // Modified signature to accept downloadUrl
   Stream<DownloadProgress> download(Game game, String downloadUrl, {Map<String, String>? headers}) async* {
     if (await directoryService.isRomDownloaded(game)) {
       yield DownloadProgress(id: game.id, gameName: game.name, percent: 1.0, isComplete: true);
@@ -60,14 +63,33 @@ class DownloadService {
           }
         },
         deleteOnError: true,
-      ).then((_) {
-        controller.add(DownloadProgress(
-          id: game.id,
-          gameName: game.name,
-          percent: 1.0,
-          isComplete: true,
-        ));
-        controller.close();
+      ).then((_) async {
+        try {
+          if (game.isMultiFile) {
+            controller.add(DownloadProgress(
+              id: game.id,
+              gameName: game.name,
+              percent: 1.0,
+              status: 'Extracting...',
+            ));
+            await _extractMultiFile(game, savePath);
+          }
+          controller.add(DownloadProgress(
+            id: game.id,
+            gameName: game.name,
+            percent: 1.0,
+            isComplete: true,
+            status: 'Done!',
+          ));
+        } catch (e) {
+          controller.add(DownloadProgress(
+            id: game.id,
+            gameName: game.name,
+            error: 'Extraction failed: $e',
+          ));
+        } finally {
+          controller.close();
+        }
       }).catchError((e) {
         controller.add(DownloadProgress(id: game.id, gameName: game.name, error: 'Download failed: $e'));
         controller.close();
@@ -79,4 +101,36 @@ class DownloadService {
       if (await saveFile.exists()) await saveFile.delete();
     }
   }
-}
+
+  /// Extracts a multi-file zip into a folder named after the game,
+  /// then deletes the zip. Finds the main ROM by largest file size.
+  Future<void> _extractMultiFile(Game game, String zipPath) async {
+    final romDir = await directoryService.getRomDirectory(game);
+    // Sanitize game name for use as folder name
+    final folderName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
+    final extractDir = '$romDir/$folderName';
+
+    await Directory(extractDir).create(recursive: true);
+
+    final bytes = await File(zipPath).readAsBytes();
+    final archive = ZipDecoder().decodeBytes(bytes);
+    extractArchiveToDisk(archive, extractDir);
+
+    // Delete the zip after extraction
+    await File(zipPath).delete();
+
+    // Log the largest file found (main ROM)
+    File? largestFile;
+    int largestSize = 0;
+    await for (final entity in Directory(extractDir).list(recursive: true)) {
+      if (entity is File) {
+        final size = await entity.length();
+        if (size > largestSize) {
+          largestSize = size;
+          largestFile = entity;
+        }
+      }
+    }
+    debugPrint('[DownloadService] multi-file extracted to $extractDir, main ROM: ${largestFile?.path}');
+  }
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/retroarch_strategy.dart b/lib/core/emulator/strategies/retroarch_strategy.dart
index 0baa9dd..9950744 100644
--- a/lib/core/emulator/strategies/retroarch_strategy.dart
+++ b/lib/core/emulator/strategies/retroarch_strategy.dart
@@ -16,11 +16,15 @@ class RetroArchStrategy extends EmulatorStrategy {
 
   @override
   List<String> get supportedSlugs => [
-        'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'psp',
-        'segacd', 'saturn', 'dreamcast', 'megadrive', 'genesis', 'gamegear',
-        'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame',
-        'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
-      ];
+      'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 
+      'psx', 'ps1', 'playstation',
+      'psp',
+      'segacd', 'saturn', 
+      'dc', 'dreamcast',
+      'megadrive', 'genesis', 'md',
+      'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 
+      'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
+    ];
 
   @override
   String get windowsExecutable => 'RetroArch.exe';
@@ -40,6 +44,7 @@ class RetroArchStrategy extends EmulatorStrategy {
     'n64': 'mupen64plus_next_libretro.dll',
     'psx': 'pcsx_rearmed_libretro.dll',
     'psp': 'ppsspp_libretro.dll',
+    'dc': 'flycast_libretro.dll',
   };
 
   String? _getCoreForSlug(String? slug) {
diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart
index f5091dd..c9d6d00 100644
--- a/lib/core/romm/romm_models.dart
+++ b/lib/core/romm/romm_models.dart
@@ -11,6 +11,10 @@ class Game {
   final String? fileName; // Added: maps to 'file_name' in JSON
   final String? fsName; // Added: maps to 'fs_name' in JSON
   final int fileSize; // Kept
+  final String? multiFilePath; // maps to 'multi_file_path' in JSON
+  final bool hasMultipleFiles;
+
+  bool get isMultiFile => hasMultipleFiles;
 
   Game({
     required this.id,
@@ -25,6 +29,8 @@ class Game {
     this.fileName, // Added
     this.fsName, // Added
     required this.fileSize, // Kept
+    this.multiFilePath,
+    this.hasMultipleFiles = false,
   });
 
   factory Game.fromJson(Map<String, dynamic> json) {
@@ -41,6 +47,8 @@ class Game {
       fileName: json['file_name']?.toString(), // Mapped from JSON
       fsName: json['fs_name']?.toString(), // Mapped from JSON
       fileSize: json['file_size_bytes'] is int ? json['file_size_bytes'] : 0,
+      multiFilePath: json['multi_file_path']?.toString(),
+      hasMultipleFiles: json['has_multiple_files'] as bool? ?? false,
     );
   }
 }
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index 8bd0868..54fcc69 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -1,5 +1,8 @@
 import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
 import 'package:dio/dio.dart';
+import 'package:flutter/widgets.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'romm_models.dart';
 
@@ -16,22 +19,23 @@ class RommService {
           connectTimeout: const Duration(seconds: 10),
           receiveTimeout: const Duration(seconds: 15),
         )) {
-    print('[RommService] created — baseUrl=${_normalizeBaseUrl(config.baseUrl)} user=${config.username} hasToken=${config.token != null && config.token!.isNotEmpty}');
-    _dio.interceptors.add(LogInterceptor(
-      requestHeader: true,
-      responseHeader: false,
-      responseBody: true,
-      requestBody: true,
-      logPrint: (o) => print('[DIO] $o'),
-    ));
+    debugPrint('[RommService] created — baseUrl=${_normalizeBaseUrl(config.baseUrl)} user=${config.username} hasToken=${config.token != null && config.token!.isNotEmpty}');
+    // _dio.interceptors.add(LogInterceptor(
+    //   requestHeader: true,
+    //   responseHeader: false,
+    //   responseBody: true,
+    //   requestBody: true,
+    //   logPrint: (o) => debugPrint('[DIO] $o'),
+    // ));
     // If the server rejects the Bearer token with 403, retry once with Basic auth.
     _dio.interceptors.add(InterceptorsWrapper(
       onError: (DioException e, ErrorInterceptorHandler handler) async {
         if (e.response?.statusCode == 403 &&
-            e.requestOptions.extra['_basicRetry'] != true &&
-            config.token != null &&
-            config.token!.isNotEmpty) {
-          print('[RommService] Bearer got 403 — retrying with Basic auth');
+          e.requestOptions.extra['_basicRetry'] != true &&
+          e.requestOptions.data is! FormData &&
+          config.token != null &&
+          config.token!.isNotEmpty) {
+          debugPrint('[RommService] Bearer got 403 — retrying with Basic auth');
           final basic = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
           final opts = e.requestOptions
             ..headers['Authorization'] = basic
@@ -52,10 +56,10 @@ class RommService {
   Options get _authOptions {
     final token = config.token;
     if (token != null && token.isNotEmpty) {
-      print('[RommService] _authOptions using Bearer token');
+      debugPrint('[RommService] _authOptions using Bearer token');
       return Options(headers: {'Authorization': 'Bearer $token'});
     }
-    print('[RommService] _authOptions using Basic auth user=${config.username} passLen=${config.password.length}');
+    debugPrint('[RommService] _authOptions using Basic auth user=${config.username} passLen=${config.password.length}');
     final basic = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
     return Options(headers: {'Authorization': basic});
   }
@@ -64,26 +68,31 @@ class RommService {
   /// stores the Bearer token in SharedPreferences, and returns it.
   static Future<String> fetchToken(String baseUrl, String username, String password) async {
     final normalizedUrl = _normalizeBaseUrl(baseUrl);
-    print('[fetchToken] POST $normalizedUrl/api/token user=$username');
+    debugPrint('[fetchToken] POST $normalizedUrl/api/token user=$username');
     final dio = Dio(BaseOptions(
       baseUrl: normalizedUrl,
       connectTimeout: const Duration(seconds: 10),
       receiveTimeout: const Duration(seconds: 15),
     ));
-    dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true, logPrint: (o) => print('[DIO/token] $o')));
+    dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true, logPrint: (o) => debugPrint('[DIO/token] $o')));
     final response = await dio.post(
       '/api/token',
-      data: {'username': username, 'password': password, 'grant_type': 'password'},
+      data: {
+        'username': username,
+        'password': password,
+        'grant_type': 'password',
+        'scope': 'me.read me.write platforms.read roms.read assets.read assets.write roms.user.read roms.user.write',
+      },
       options: Options(contentType: 'application/x-www-form-urlencoded'),
     );
-    print('[fetchToken] response status=${response.statusCode} data=${response.data}');
+    debugPrint('[fetchToken] response status=${response.statusCode} data=${response.data}');
     final token = response.data['access_token'] as String?;
     if (token == null || token.isEmpty) {
       throw Exception('Login failed: no access_token in response');
     }
     final prefs = await SharedPreferences.getInstance();
     await prefs.setString('rommAuthToken', token);
-    print('[fetchToken] token stored OK');
+    debugPrint('[fetchToken] token stored OK');
     return token;
   }
 
@@ -148,6 +157,9 @@ class RommService {
         final Map<String, dynamic> data = response.data is Map ? response.data : {'items': response.data};
         final List<dynamic> items = data['items'] ?? [];
         total = data['total'] ?? items.length;
+        if (offset == 0 && items.isNotEmpty) {
+        debugPrint('[RommService] sample raw game: ${items.first}');
+        }
         allGames.addAll(items.map((item) => Game.fromJson(item)).toList());
         offset += limit;
       } else {
@@ -185,9 +197,14 @@ class RommService {
   }
 
   String getDownloadUrl(Game game) {
+    final baseUrl = config.baseUrl.endsWith('/') 
+        ? config.baseUrl.substring(0, config.baseUrl.length - 1) 
+        : config.baseUrl;
+    
+    debugPrint('[RommService] getDownloadUrl: name=${game.name} fileName=${game.fileName} fsName=${game.fsName} isMultiFile=${game.isMultiFile}');
+
     final name = game.fileName ?? game.fsName ?? game.name;
     final encoded = Uri.encodeComponent(name);
-    final baseUrl = config.baseUrl.endsWith('/') ? config.baseUrl.substring(0, config.baseUrl.length - 1) : config.baseUrl;
     return '$baseUrl/api/roms/${game.id}/content/$encoded';
   }
 
@@ -197,4 +214,89 @@ class RommService {
     if (token != null && token.isNotEmpty) return 'Bearer $token';
     return 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
   }
+
+  // ─── Save sync ─────────────────────────────────────────────────────────────
+
+  /// Uploads [saveFile] for [gameId] to RomM via POST /api/saves.
+  Future<bool> uploadSave(String gameId, File saveFile, {String slot = 'freegosy'}) async {
+    try {
+      final fileName = saveFile.uri.pathSegments.last;
+      final formData = FormData.fromMap({
+        'saveFile': await MultipartFile.fromFile(saveFile.path, filename: fileName),
+      });
+      final response = await _dio.post(
+        '/api/saves',
+        queryParameters: {'rom_id': gameId, 'emulator': 'freegosy', 'slot': slot},
+        data: formData,
+        options: _authOptions,
+      );
+      final ok = response.statusCode != null &&
+          response.statusCode! >= 200 &&
+          response.statusCode! < 300;
+      debugPrint('[RommService] uploadSave ${ok ? 'ok' : 'failed'} status=${response.statusCode} file=$fileName');
+      return ok;
+    } catch (e) {
+      debugPrint('[RommService] uploadSave error: $e');
+      return false;
+    }
+  }
+
+  /// Returns the most recently updated save object for [gameId], or null.
+  Future<Map<String, dynamic>?> getLatestSave(String gameId) async {
+    try {
+      final response = await _dio.get(
+        '/api/saves',
+        queryParameters: {'rom_id': gameId},
+        options: _authOptions,
+      );
+      if (response.statusCode != 200) return null;
+
+      final List<dynamic> items;
+      if (response.data is Map && response.data.containsKey('items')) {
+        items = response.data['items'] as List<dynamic>;
+      } else if (response.data is List) {
+        items = response.data as List<dynamic>;
+      } else {
+        return null;
+      }
+
+      if (items.isEmpty) return null;
+
+      // Sort by updated_at descending, return the most recent
+      final sorted = List<Map<String, dynamic>>.from(
+        items.whereType<Map<String, dynamic>>(),
+      );
+      sorted.sort((a, b) {
+        final ta = DateTime.tryParse(a['updated_at']?.toString() ?? '') ?? DateTime(0);
+        final tb = DateTime.tryParse(b['updated_at']?.toString() ?? '') ?? DateTime(0);
+        return tb.compareTo(ta);
+      });
+      return sorted.first;
+    } catch (e) {
+      debugPrint('[RommService] getLatestSave error: $e');
+      return null;
+    }
+  }
+
+  /// Downloads save bytes from [saveUrl]. Returns null on failure.
+  Future<Uint8List?> downloadSave(String saveUrl) async {
+    try {
+      // Resolve relative paths against the base URL
+      final url = saveUrl.startsWith('http')
+          ? saveUrl
+          : '${_normalizeBaseUrl(config.baseUrl)}$saveUrl';
+
+      final response = await _dio.get<List<int>>(
+        url,
+        options: _authOptions.copyWith(responseType: ResponseType.bytes),
+      );
+      if (response.statusCode == 200 && response.data != null) {
+        return Uint8List.fromList(response.data!);
+      }
+      return null;
+    } catch (e) {
+      debugPrint('[RommService] downloadSave error: $e');
+      return null;
+    }
+  }
 }
diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart
new file mode 100644
index 0000000..08a9265
--- /dev/null
+++ b/lib/core/save/save_strategy.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/widgets.dart';
+
+import '../romm/romm_models.dart';
+
+/// Abstract base for all save-file strategies.
+abstract class SaveStrategy {
+  String get strategyId;
+
+  /// Returns the local save directory for [game] given its [romPath].
+  Future<String?> getSaveDir(Game game, String romPath);
+
+  /// Returns all save files associated with [game].
+  /// If [sessionStart] is provided, only files modified after that time are returned.
+  Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'});
+
+  /// Restores save [data] named [filename] for [game] at [destPath].
+  Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename);
+
+  // ─── Shared helper: rotation backup ──────────────────────────────────────
+
+  /// Creates a .bak rotation (up to 3 versions) for the file at [path].
+  Future<void> backupSave(String path) async {
+    final file = File(path);
+    if (!await file.exists()) return;
+    try {
+      final bak2 = File('$path.bak2');
+      final bak1 = File('$path.bak1');
+      final bak = File('$path.bak');
+      if (await bak2.exists()) await bak2.delete();
+      if (await bak1.exists()) await bak1.rename('$path.bak2');
+      if (await bak.exists()) await bak.rename('$path.bak1');
+      await file.copy('$path.bak');
+    } catch (e) {
+      debugPrint('[SaveStrategy] backupSave error for $path: $e');
+    }
+  }
+
+  // ─── Shared helper: ROM stem ──────────────────────────────────────────────
+
+  /// Returns the base filename (without extension) used to identify save files.
+  String getRomStem(Game game) {
+    final name = game.fsName ?? game.name;
+    final dot = name.lastIndexOf('.');
+    if (dot > 0) return name.substring(0, dot);
+    return name;
+  }
+}
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
new file mode 100644
index 0000000..f9d24d3
--- /dev/null
+++ b/lib/core/save/save_sync_service.dart
@@ -0,0 +1,153 @@
+import 'dart:io';
+import 'package:flutter/rendering.dart';
+import '../romm/romm_models.dart';
+import '../romm/romm_service.dart';
+import '../storage/directory_service.dart';
+import 'save_strategy.dart';
+import 'strategies/retroarch_save_strategy.dart';
+import 'strategies/dolphin_save_strategy.dart';
+import 'strategies/eden_save_strategy.dart';
+import 'package:archive/archive_io.dart';
+
+class SaveSyncService {
+  final RommService _rommService;
+  final DirectoryService _directoryService;
+
+  late final RetroArchSaveStrategy _retroarch;
+  late final DolphinSaveStrategy _dolphin;
+  late final EdenSaveStrategy _eden;
+
+  SaveSyncService(this._rommService, this._directoryService) {
+    _retroarch = RetroArchSaveStrategy(_directoryService);
+    _dolphin = DolphinSaveStrategy(_directoryService);
+    _eden = EdenSaveStrategy();
+  }
+
+  /// Returns the appropriate save strategy for [platformSlug], or null if unsupported.
+  SaveStrategy? getStrategyForSlug(String? platformSlug) {
+    switch (platformSlug?.toLowerCase()) {
+      case 'gba':
+      case 'gbc':
+      case 'gb':
+      case 'snes':
+      case 'nes':
+      case 'n64':
+      case 'nds':
+      case 'psx':
+      case 'ps1':
+      case 'playstation':
+      case 'psp':
+      case 'dc':
+      case 'dreamcast':
+      case 'megadrive':
+      case 'genesis':
+      case 'md':
+        return _retroarch;
+      case 'ngc':
+      case 'gamecube':
+      case 'wii':
+      case 'gc':
+        return _dolphin;
+      case 'switch':
+      case 'nintendo-switch':
+      case 'ns':
+        return _eden;
+      default:
+        return null;
+    }
+  }
+
+  /// Uploads all local save files for [game] to RomM.
+  ///
+  /// If [sessionStart] is provided, only files modified after that time are uploaded.
+  /// Returns true if at least one file was uploaded successfully.
+  Future<bool> pushSaves(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+    try {
+      final strategy = getStrategyForSlug(game.platformSlug);
+      if (strategy == null) {
+        debugPrint('[SaveSyncService] no strategy for ${game.platformSlug}');
+        return false;
+      }
+
+      final files = await strategy.getSaveFiles(game, romPath, sessionStart: sessionStart, syncMode: syncMode);
+      if (files.isEmpty) {
+        debugPrint('[SaveSyncService] no save files found for ${game.name}');
+        return false;
+      }
+
+      int uploaded = 0;
+      for (final file in files) {
+        File uploadFile = file;
+        bool isTempZip = false;
+
+        // If it's a directory, zip it first
+        if (await FileSystemEntity.isDirectory(file.path)) {
+          final zipPath = '${file.path}.zip';
+          final encoder = ZipFileEncoder();
+          encoder.create(zipPath);
+          await encoder.addDirectory(Directory(file.path));
+          encoder.close();
+          uploadFile = File(zipPath);
+          isTempZip = true;
+        }
+
+  final ok = await _rommService.uploadSave(game.id, uploadFile);
+  if (ok) uploaded++;
+
+  // Clean up temp zip
+  if (isTempZip && await uploadFile.exists()) {
+    await uploadFile.delete();
+  }
+}
+
+      debugPrint('[SaveSyncService] pushed $uploaded/${files.length} saves for ${game.name}');
+      return uploaded > 0;
+    } catch (e) {
+      debugPrint('[SaveSyncService] pushSaves error: $e');
+      return false;
+    }
+  }
+
+  /// Downloads the latest save for [game] from RomM and restores it locally.
+  ///
+  /// Returns true on success.
+  Future<bool> pullSave(Game game, String romPath) async {
+    try {
+      final strategy = getStrategyForSlug(game.platformSlug);
+      if (strategy == null) {
+        debugPrint('[SaveSyncService] no strategy for ${game.platformSlug}');
+        return false;
+      }
+
+      final save = await _rommService.getLatestSave(game.id);
+      if (save == null) {
+        debugPrint('[SaveSyncService] no cloud save for ${game.name}');
+        return false;
+      }
+
+      final downloadUrl = save['download_path'] as String? ?? save['url'] as String?;
+      if (downloadUrl == null) {
+        debugPrint('[SaveSyncService] save has no download URL');
+        return false;
+      }
+
+      final bytes = await _rommService.downloadSave(downloadUrl);
+      if (bytes == null) {
+        debugPrint('[SaveSyncService] failed to download save bytes');
+        return false;
+      }
+
+      final filename = save['file_name'] as String? ??
+          downloadUrl.split('/').last;
+
+      debugPrint('[SaveSyncService] calling restoreSave: filename=$filename romPath=$romPath bytesLen=${bytes.length}');
+      final ok = await strategy.restoreSave(game, romPath, bytes, filename);
+      debugPrint('[SaveSyncService] restoreSave returned: $ok');
+      debugPrint('[SaveSyncService] pullSave ${ok ? 'ok' : 'failed'} for ${game.name}');
+      return ok;
+    } catch (e) {
+      debugPrint('[SaveSyncService] pullSave error: $e');
+      rethrow;
+    }
+  }
+}
diff --git a/lib/core/save/strategies/dolphin_save_strategy.dart b/lib/core/save/strategies/dolphin_save_strategy.dart
new file mode 100644
index 0000000..971cf22
--- /dev/null
+++ b/lib/core/save/strategies/dolphin_save_strategy.dart
@@ -0,0 +1,78 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/rendering.dart';
+
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for Dolphin emulator (GameCube / Wii).
+///
+/// Save files live in {emulatorDir}/User/GC/{region}/Card A/
+class DolphinSaveStrategy extends SaveStrategy {
+  final DirectoryService _directoryService;
+
+  DolphinSaveStrategy(this._directoryService);
+
+  @override
+  String get strategyId => 'dolphin';
+
+  /// Detects region code from [romPath].
+  String _detectRegion(String romPath) {
+    final upper = romPath.toUpperCase();
+    if (upper.contains('EUR') || upper.contains('PAL')) return 'EUR';
+    if (upper.contains('JAP') || upper.contains('JPN')) return 'JAP';
+    return 'USA';
+  }
+
+  @override
+  Future<String?> getSaveDir(Game game, String romPath) async {
+    final exePath = await _directoryService.findEmulatorExecutable('dolphin', 'Dolphin.exe');
+    if (exePath == null) return null;
+
+    final emulatorDir = File(exePath).parent.path;
+    final region = _detectRegion(romPath);
+    return '$emulatorDir/User/GC/$region/Card A';
+  }
+
+  @override
+Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+    final saveDir = await getSaveDir(game, romPath);
+    if (saveDir == null) return [];
+
+    final dir = Directory(saveDir);
+    if (!await dir.exists()) return [];
+
+    final result = <File>[];
+    await for (final entity in dir.list()) {
+      if (entity is! File) continue;
+      if (!entity.path.toLowerCase().endsWith('.gci')) continue;
+      if (sessionStart != null) {
+        final stat = await entity.stat();
+        if (stat.modified.isBefore(sessionStart)) continue;
+      }
+      result.add(entity);
+    }
+    return result;
+  }
+
+  @override
+  Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+    try {
+      final saveDir = await getSaveDir(game, destPath);
+      if (saveDir == null) return false;
+
+      final dir = Directory(saveDir);
+      if (!await dir.exists()) await dir.create(recursive: true);
+
+      final targetPath = '$saveDir/$filename';
+      await backupSave(targetPath);
+      await File(targetPath).writeAsBytes(data);
+      debugPrint('[DolphinSaveStrategy] restored $filename to $targetPath');
+      return true;
+    } catch (e) {
+      debugPrint('[DolphinSaveStrategy] restoreSave error: $e');
+      return false;
+    }
+  }
+}
diff --git a/lib/core/save/strategies/eden_save_strategy.dart b/lib/core/save/strategies/eden_save_strategy.dart
new file mode 100644
index 0000000..3a83909
--- /dev/null
+++ b/lib/core/save/strategies/eden_save_strategy.dart
@@ -0,0 +1,235 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:archive/archive.dart';
+import 'package:flutter/widgets.dart';
+import '../../romm/romm_models.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for Eden (Switch) emulator.
+///
+/// Save data is located in:
+///   AppData/Roaming/eden/nand/user/save/0000000000000000/{profile}/{titleId}/
+class EdenSaveStrategy extends SaveStrategy {
+  @override
+  String get strategyId => 'switch';
+
+  static final RegExp _titleIdRegex = RegExp(r'01[0-9A-Fa-f]{14}');
+
+  /// Attempt to extract a Switch title ID from [romPath] using three methods:
+  /// 1. Regex match in the filename
+  /// 2. Parse XCI header at offset 0x108 (8 bytes, reversed, hex)
+  /// 3. Scan eden save folders for the most recently modified folder
+  Future<String?> _resolveTitleId(String romPath, {DateTime? sessionStart}) async {
+    // Method 1: regex in filename
+    final fileName = romPath.replaceAll('\\', '/').split('/').last;
+    final match = _titleIdRegex.firstMatch(fileName.toUpperCase());
+    if (match != null) return match.group(0)!.toUpperCase();
+
+    // Method 2: XCI header
+    final titleFromHeader = await _parseTitleIdFromXci(romPath);
+    if (titleFromHeader != null) return titleFromHeader;
+
+    // Method 3: scan eden save folders
+    return await _scanEdenSaveFolders(sessionStart);
+  }
+
+  Future<String?> _parseTitleIdFromXci(String romPath) async {
+    if (!romPath.toLowerCase().endsWith('.xci')) return null;
+    try {
+      final file = File(romPath);
+      if (!await file.exists()) return null;
+      final raf = await file.open();
+      try {
+        await raf.setPosition(0x108);
+        final bytes = await raf.read(8);
+        if (bytes.length < 8) return null;
+        // Reverse bytes and convert to hex
+        final reversed = bytes.reversed.toList();
+        final hex = reversed.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase();
+        if (_titleIdRegex.hasMatch(hex)) return hex;
+        return null;
+      } finally {
+        await raf.close();
+      }
+    } catch (e) {
+      debugPrint('[EdenSaveStrategy] XCI parse error: $e');
+      return null;
+    }
+  }
+
+  Future<String?> _scanEdenSaveFolders(DateTime? sessionStart) async {
+    final edenSaveRoot = await _getEdenSaveRoot();
+    if (edenSaveRoot == null) return null;
+
+    final rootDir = Directory(edenSaveRoot);
+    if (!await rootDir.exists()) return null;
+
+    String? bestTitleId;
+    DateTime? bestTime;
+
+    try {
+      // Scan all profile directories
+      await for (final profileEntry in rootDir.list()) {
+        if (profileEntry is! Directory) continue;
+        await for (final titleEntry in profileEntry.list()) {
+          if (titleEntry is! Directory) continue;
+          final candidate = titleEntry.uri.pathSegments
+              .lastWhere((s) => s.isNotEmpty, orElse: () => '');
+          if (!_titleIdRegex.hasMatch(candidate.toUpperCase())) continue;
+          final stat = await titleEntry.stat();
+          if (sessionStart != null && stat.modified.isBefore(sessionStart)) continue;
+          if (bestTime == null || stat.modified.isAfter(bestTime)) {
+            bestTime = stat.modified;
+            bestTitleId = candidate.toUpperCase();
+          }
+        }
+      }
+    } catch (e) {
+      debugPrint('[EdenSaveStrategy] scan error: $e');
+    }
+
+    return bestTitleId;
+  }
+
+  Future<String?> _getEdenSaveRoot() async {
+    try {
+      final result = await Process.run(
+        'cmd', ['/c', 'echo %APPDATA%'],
+        runInShell: false,
+      );
+      final appData = result.stdout.toString().trim();
+      if (appData.isEmpty || appData.contains('%APPDATA%')) return null;
+      return '$appData/eden/nand/user/save/0000000000000000';
+    } catch (e) {
+      debugPrint('[EdenSaveStrategy] APPDATA lookup error: $e');
+      return null;
+    }
+  }
+
+  Future<String?> _getSaveDirForTitleId(String titleId) async {
+    final saveRoot = await _getEdenSaveRoot();
+    if (saveRoot == null) return null;
+
+    final rootDir = Directory(saveRoot);
+    if (!await rootDir.exists()) await rootDir.create(recursive: true);
+
+    try {
+      await for (final entry in rootDir.list()) {
+        if (entry is! Directory) continue;
+        final titleDir = Directory('${entry.path}/$titleId');
+        if (await titleDir.exists()) return titleDir.path;
+      }
+
+      final profiles = await rootDir.list().where((e) => e is Directory).toList();
+      if (profiles.isNotEmpty) {
+        final path = '${profiles.first.path}/$titleId';
+        await Directory(path).create(recursive: true);
+        return path;
+      }
+
+      throw Exception('Launch the game in Eden at least once before syncing saves');
+    } catch (e) {
+      rethrow;
+    }
+  }
+
+  @override
+  Future<String?> getSaveDir(Game game, String romPath) async {
+    final titleId = await _resolveTitleId(romPath);
+    if (titleId == null) return null;
+
+    final saveRoot = await _getEdenSaveRoot();
+    if (saveRoot == null) return null;
+
+    // Find the first profile directory that contains this title
+    final rootDir = Directory(saveRoot);
+    if (!await rootDir.exists()) return null;
+
+    try {
+      await for (final entry in rootDir.list()) {
+        if (entry is! Directory) continue;
+        final titleDir = Directory('${entry.path}/$titleId');
+        if (await titleDir.exists()) return titleDir.path;
+      }
+      // Default profile path (no profile folders yet)
+      final profileDirs = await rootDir.list().toList();
+      if (profileDirs.isNotEmpty && profileDirs.first is Directory) {
+        return '${profileDirs.first.path}/$titleId';
+      }
+    } catch (e) {
+      debugPrint('[EdenSaveStrategy] getSaveDir error: $e');
+    }
+    return null;
+  }
+
+  @override
+Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+    final saveDir = await getSaveDir(game, romPath);
+    if (saveDir == null) return [];
+    final dir = Directory(saveDir);
+    if (!await dir.exists()) return [];
+    // Return a dummy File pointing to the directory
+    // SaveSyncService will detect it's a directory and zip it
+    return [File(saveDir)];
+  }
+
+
+
+    @override
+    Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+      try {
+        debugPrint('[EdenSaveStrategy] restoreSave called: filename=$filename destPath=$destPath');
+        final titleMatch = _titleIdRegex.firstMatch(filename.toUpperCase());
+        debugPrint('[EdenSaveStrategy] titleMatch from filename: ${titleMatch?.group(0)}');
+        final saveDir = titleMatch != null
+            ? await _getSaveDirForTitleId(titleMatch.group(0)!)
+            : await getSaveDir(game, destPath);
+        debugPrint('[EdenSaveStrategy] saveDir resolved: $saveDir');
+        if (saveDir == null) return false;
+
+        final dir = Directory(saveDir);
+        if (!await dir.exists()) await dir.create(recursive: true);
+
+        // RomM packages saves as zips — extract into the title directory.
+        if (filename.toLowerCase().endsWith('.zip')) {
+          return await _extractZipSave(data, saveDir);
+        }
+
+        final targetPath = '$saveDir/$filename';
+        await backupSave(targetPath);
+        await File(targetPath).writeAsBytes(data);
+        debugPrint('[EdenSaveStrategy] restored $filename to $targetPath');
+        return true;
+      } catch (e) {
+        debugPrint('[EdenSaveStrategy] restoreSave error: $e');
+        rethrow;
+      }
+    }
+
+  Future<bool> _extractZipSave(Uint8List data, String destDir) async {
+    try {
+      final archive = ZipDecoder().decodeBytes(data);
+      for (final entry in archive) {
+        // Strip the leading titleId folder from zip entry names
+      final entryName = entry.name.contains('/')
+          ? entry.name.substring(entry.name.indexOf('/') + 1)
+          : entry.name;
+      if (entryName.isEmpty) continue;
+      final outPath = '$destDir/$entryName';
+        if (entry.isFile) {
+          await backupSave(outPath);
+          final outFile = File(outPath);
+          await outFile.parent.create(recursive: true);
+          await outFile.writeAsBytes(entry.content as List<int>);
+        } else {
+          await Directory(outPath).create(recursive: true);
+        }
+      }
+      debugPrint('[EdenSaveStrategy] extracted ${archive.length} entries to $destDir');
+      return true;
+    } catch (e) {
+      debugPrint('[EdenSaveStrategy] zip extraction error: $e');
+      return false;
+    }
+  }
+}
diff --git a/lib/core/save/strategies/retroarch_save_strategy.dart b/lib/core/save/strategies/retroarch_save_strategy.dart
new file mode 100644
index 0000000..50ac4cd
--- /dev/null
+++ b/lib/core/save/strategies/retroarch_save_strategy.dart
@@ -0,0 +1,127 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/rendering.dart';
+
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for RetroArch emulator.
+///
+/// Save files live next to RetroArch.exe in saves/{coreName}/.
+/// Core name mapping is derived from the platform slug.
+class RetroArchSaveStrategy extends SaveStrategy {
+  final DirectoryService _directoryService;
+
+  RetroArchSaveStrategy(this._directoryService);
+
+  @override
+  String get strategyId => 'retroarch';
+
+  static const Map<String, _CoreInfo> _coreMap = {
+    'gba':       _CoreInfo('mgba_libretro',            'mGBA',                'mGBA'),
+    'gbc':       _CoreInfo('mgba_libretro',            'mGBA',                'mGBA'),
+    'gb':        _CoreInfo('mgba_libretro',            'mGBA',                'mGBA'),
+    'snes':      _CoreInfo('snes9x_libretro',          'Snes9X',              'Snes9X'),
+    'nes':       _CoreInfo('fceumm_libretro',          'FCEUmm',              'FCEUmm'),
+    'n64':       _CoreInfo('mupen64plus_next_libretro', 'Mupen64Plus-Next',   'Mupen64Plus-Next'),
+    'nds':       _CoreInfo('desmume2015_libretro',     'DeSmuME 2015',        'DeSmuME 2015'),
+    'psx':       _CoreInfo('pcsx_rearmed_libretro',    'PCSX-ReARMed',        'PCSX-ReARMed'),
+    'psp':       _CoreInfo('ppsspp_libretro',          'PPSSPP/PSP/SAVEDATA', 'PPSSPP'),
+    'dreamcast': _CoreInfo('flycast_libretro',         'Flycast',             'Flycast'),
+    'megadrive': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX',    'Genesis Plus GX'),
+    'dc':        _CoreInfo('flycast_libretro', 'Flycast', 'Flycast'),
+    'ps1':       _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
+    'playstation': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
+    'md':        _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'),
+    'genesis':   _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'),
+  };
+
+  @override
+  Future<String?> getSaveDir(Game game, String romPath) async {
+    final slug = game.platformSlug?.toLowerCase() ?? '';
+    final coreInfo = _coreMap[slug];
+    if (coreInfo == null) return null;
+
+    final exePath = await _directoryService.findEmulatorExecutable('retroarch', 'RetroArch.exe');
+    if (exePath == null) return null;
+
+    final exeDir = File(exePath).parent.path;
+    return '$exeDir/saves/${coreInfo.saveFolder}';
+  }
+
+  @override
+  Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+    final slug = game.platformSlug?.toLowerCase() ?? '';
+    final coreInfo = _coreMap[slug];
+    if (coreInfo == null) return [];
+
+    final exePath = await _directoryService.findEmulatorExecutable('retroarch', 'RetroArch.exe');
+    if (exePath == null) return [];
+    final exeDir = File(exePath).parent.path;
+
+    final stem = getRomStem(game);
+    final candidates = <File>[];
+
+    if (syncMode == 'saves' || syncMode == 'both') {
+      final savesDir = '$exeDir/saves/${coreInfo.saveFolder}';
+      candidates.add(File('$savesDir/$stem.srm'));
+    }
+
+    if (syncMode == 'states' || syncMode == 'both') {
+      final statesDir = '$exeDir/states/${coreInfo.statesFolder}';
+      candidates.add(File('$statesDir/$stem.state.auto'));
+      for (int i = 0; i <= 9; i++) {
+        candidates.add(File('$statesDir/$stem.state$i'));
+      }
+    }
+
+    final result = <File>[];
+    for (final f in candidates) {
+      if (!await f.exists()) continue;
+      if (sessionStart != null) {
+        final stat = await f.stat();
+        if (stat.modified.isBefore(sessionStart)) continue;
+      }
+      result.add(f);
+    }
+    return result;
+  }
+
+  @override
+  Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+    try {
+      final slug = game.platformSlug?.toLowerCase() ?? '';
+      final coreInfo = _coreMap[slug];
+      if (coreInfo == null) return false;
+
+      final exePath = await _directoryService.findEmulatorExecutable('retroarch', 'RetroArch.exe');
+      if (exePath == null) return false;
+      final exeDir = File(exePath).parent.path;
+
+      final isState = filename.contains('.state');
+      final targetDir = isState
+          ? '$exeDir/states/${coreInfo.statesFolder}'
+          : '$exeDir/saves/${coreInfo.saveFolder}';
+
+      final dir = Directory(targetDir);
+      if (!await dir.exists()) await dir.create(recursive: true);
+
+      final targetPath = '$targetDir/$filename';
+      await backupSave(targetPath);
+      await File(targetPath).writeAsBytes(data);
+      debugPrint('[RetroArchSaveStrategy] restored $filename to $targetPath');
+      return true;
+    } catch (e) {
+      debugPrint('[RetroArchSaveStrategy] restoreSave error: $e');
+      return false;
+    }
+  }
+}
+
+class _CoreInfo {
+  final String coreName;
+  final String saveFolder;
+  final String statesFolder;
+  const _CoreInfo(this.coreName, this.saveFolder, this.statesFolder);
+}
\ No newline at end of file
diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart
index 82de655..303cfc9 100644
--- a/lib/core/storage/directory_service.dart
+++ b/lib/core/storage/directory_service.dart
@@ -91,17 +91,34 @@ class DirectoryService {
     if (await File(exactPath).exists()) return exactPath;
     if (await Directory(exactPath).exists()) return exactPath;
 
+    // Check multi-file folder named after game name
+    final folderName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
+    final multiFileDir = Directory('$romDir/$folderName');
+    if (await multiFileDir.exists()) {
+      // Find largest file inside — that's the main ROM
+      File? largestFile;
+      int largestSize = 0;
+      await for (final entity in multiFileDir.list(recursive: true)) {
+        if (entity is File) {
+          final size = await entity.length();
+          if (size > largestSize) {
+            largestSize = size;
+            largestFile = entity;
+          }
+        }
+      }
+      if (largestFile != null) return largestFile.path;
+    }
+
     // If baseName has no extension, try known extensions for this platform
     if (!baseName.contains('.')) {
       final extensions = _platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
       for (final ext in extensions) {
         final candidate = '$romDir/$baseName$ext';
-        if (await File(candidate).exists()) {
-          return candidate;
-        }
+        if (await File(candidate).exists()) return candidate;
       }
 
-      // Also scan the directory for any file starting with baseName
+      // Scan directory for any file starting with baseName
       final dir = Directory(romDir);
       if (await dir.exists()) {
         await for (final entity in dir.list()) {
diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart
index ea8dd52..aac1605 100644
--- a/lib/providers/library_provider.dart
+++ b/lib/providers/library_provider.dart
@@ -52,3 +52,11 @@ final filteredGamesProvider = Provider<List<Game>>((ref) {
   filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
   return filtered;
 });
+
+final retroarchSyncModeProvider = StateProvider<String>((ref) => 'both');
+
+final retroarchSyncModeLoaderProvider = FutureProvider<void>((ref) async {
+  final prefs = await SharedPreferences.getInstance();
+  final saved = prefs.getString('retroarch_sync_mode') ?? 'both';
+  ref.read(retroarchSyncModeProvider.notifier).state = saved;
+});
\ No newline at end of file
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 878ea7a..6c14d0c 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -1,9 +1,11 @@
+import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:freegosy/core/storage/directory_service.dart';
 import 'package:freegosy/core/romm/romm_models.dart';
 import 'package:freegosy/core/romm/romm_service.dart';
 import 'package:freegosy/core/emulator/strategy_registry.dart';
+import 'package:freegosy/core/save/save_sync_service.dart';
 
 // Provider for loading RomMConfig (including stored Bearer token)
 final rommConfigProvider = FutureProvider<RomMConfig>((ref) async {
@@ -13,7 +15,7 @@ final rommConfigProvider = FutureProvider<RomMConfig>((ref) async {
   final password = prefs.getString('rommPassword') ?? '';
   final token = prefs.getString('rommAuthToken');
 
-  print('[rommConfigProvider] loaded baseUrl=$baseUrl user=$username passLen=${password.length} hasToken=${token != null && token.isNotEmpty}');
+  debugPrint('[rommConfigProvider] loaded baseUrl=$baseUrl user=$username passLen=${password.length} hasToken=${token != null && token.isNotEmpty}');
   return RomMConfig(baseUrl: baseUrl, username: username, password: password, token: token);
 });
 
@@ -50,6 +52,14 @@ final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) {
   return null;
 });
 
+// SaveSyncService provider
+final saveSyncServiceProvider = Provider<SaveSyncService?>((ref) {
+  final rommService = ref.watch(rommServiceProvider);
+  final directoryService = ref.watch(directoryServiceProvider).asData?.value;
+  if (rommService == null || directoryService == null) return null;
+  return SaveSyncService(rommService, directoryService);
+});
+
 // Simplified RommService provider
 final rommServiceProvider = Provider<RommService?>((ref) {
   final rommConfigAsync = ref.watch(rommConfigProvider);
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 5633119..1888a83 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../providers/library_provider.dart';
 import '../../providers/download_provider.dart';
 import '../../providers/romm_provider.dart';
+import '../../core/romm/romm_models.dart';
 import '../widgets/game_card.dart';
 import '../widgets/platform_filter_bar.dart';
+import 'dart:convert';
 
 class LibraryScreen extends ConsumerWidget {
   const LibraryScreen({super.key});
@@ -79,6 +81,45 @@ class LibraryScreen extends ConsumerWidget {
       return;
     }
 
+    // Pull latest cloud save before launching
+    final syncService = ref.read(saveSyncServiceProvider);
+    if (syncService != null) {
+      // Push local saves first so nothing is lost, then pull cloud save
+      final syncMode = ref.read(retroarchSyncModeProvider);
+      await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
+      if (!context.mounted) return;
+      try {
+        final pulled = await syncService.pullSave(game, existingRomPath);
+        if (!context.mounted) return;
+        if (pulled) {
+          ScaffoldMessenger.of(context).showSnackBar(
+            const SnackBar(content: Text('Cloud save restored'), duration: Duration(seconds: 2)),
+          );
+        }
+      } catch (e) {
+        if (!context.mounted) return;
+        final shouldContinue = await showDialog<bool>(
+          context: context,
+          builder: (ctx) => AlertDialog(
+            title: const Text('Save Sync Warning'),
+            content: Text('${e.toString().replaceAll('Exception: ', '')}\n\nYou can still play, but your cloud save will not be restored. After playing once, exit the game and sync saves manually.'),
+            actions: [
+              TextButton(
+                onPressed: () => Navigator.of(ctx).pop(false),
+                child: const Text('Cancel'),
+              ),
+              ElevatedButton(
+                onPressed: () => Navigator.of(ctx).pop(true),
+                child: const Text('Play Anyway'),
+              ),
+            ],
+          ),
+        );
+        if (!context.mounted) return;
+        if (shouldContinue != true) return;
+      }
+    }
+
     try {
       await strategy.launch(game, existingRomPath);
     } catch (e) {
@@ -89,6 +130,35 @@ class LibraryScreen extends ConsumerWidget {
     }
   }
 
+  Future<void> _handleSyncSaves(BuildContext context, WidgetRef ref, Game game) async {
+    final syncService = ref.read(saveSyncServiceProvider);
+    if (syncService == null) {
+      ScaffoldMessenger.of(context).showSnackBar(
+        const SnackBar(content: Text('Save sync not available')),
+      );
+      return;
+    }
+
+    final dir = ref.read(directoryServiceProvider).asData?.value;
+    final romPath = dir != null ? await dir.getRomFilePath(game) : '';
+
+    if (!context.mounted) return;
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(content: Text('Syncing saves for ${game.name}...')),
+    );
+
+    final syncMode = ref.read(retroarchSyncModeProvider);
+    final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode);
+    if (!context.mounted) return;
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        content: Text(ok
+            ? 'Saves uploaded for ${game.name}'
+            : 'No saves found for ${game.name}'),
+      ),
+    );
+  }
+
   void _startDownload(BuildContext context, WidgetRef ref, game) {
     final service = ref.read(rommServiceProvider);
     if (service == null) {
@@ -98,7 +168,8 @@ class LibraryScreen extends ConsumerWidget {
       return;
     }
     final url = service.getDownloadUrl(game);
-    final headers = <String, String>{'Authorization': service.authHeader};
+    final basicAuth = 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}';
+    final headers = <String, String>{'Authorization': basicAuth};
     ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers);
     ScaffoldMessenger.of(context).showSnackBar(
       SnackBar(content: Text('Downloading ${game.name}...')),
@@ -210,6 +281,7 @@ class LibraryScreen extends ConsumerWidget {
                                     game: game,
                                     onDownload: () => _startDownload(context, ref, game),
                                     onLaunch: () => _handleLaunch(context, ref, game),
+                                    onSyncSaves: () => _handleSyncSaves(context, ref, game),
                                   );
                                 }
                                 return FutureBuilder<bool>(
@@ -220,6 +292,7 @@ class LibraryScreen extends ConsumerWidget {
                                       isDownloaded: snapshot.data ?? false,
                                       onDownload: () => _startDownload(context, ref, game),
                                       onLaunch: () => _handleLaunch(context, ref, game),
+                                      onSyncSaves: () => _handleSyncSaves(context, ref, game),
                                     );
                                   },
                                 );
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 5697e8a..7eefcb2 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -71,6 +71,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
                   const SizedBox(height: 24),
                   _buildStorageSection(directoryService),
                   const SizedBox(height: 24),
+                  _buildRetroArchSyncModeSection(context, ref),
+                  const SizedBox(height: 24),
                   _buildEmulatorsSection(directoryService),
                 ],
               );
@@ -85,6 +87,33 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
     );
   }
 
+  Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) {
+    final syncMode = ref.watch(retroarchSyncModeProvider);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        const Text('RetroArch Save Sync', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+        const SizedBox(height: 8),
+        const Text('What to sync with RomM cloud', style: TextStyle(color: Colors.grey)),
+        const SizedBox(height: 12),
+        SegmentedButton<String>(
+          segments: const [
+            ButtonSegment(value: 'saves', label: Text('Saves only')),
+            ButtonSegment(value: 'states', label: Text('States only')),
+            ButtonSegment(value: 'both', label: Text('Both')),
+          ],
+          selected: {syncMode},
+          onSelectionChanged: (selection) async {
+            final value = selection.first;
+            ref.read(retroarchSyncModeProvider.notifier).state = value;
+            final prefs = await SharedPreferences.getInstance();
+            await prefs.setString('retroarch_sync_mode', value);
+          },
+        ),
+      ],
+    );
+  }
+
   Widget _buildRommServerSection(BuildContext context, WidgetRef ref, rommService) {
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
@@ -162,16 +191,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
                       // it over HTTP or at all, fall back to Basic auth silently.
                       try {
                         await RommService.fetchToken(baseUrl, username, password);
-                        print('[Settings] fetchToken succeeded');
+                        debugPrint('[Settings] fetchToken succeeded');
                       } catch (e) {
-                        print('[Settings] fetchToken failed ($e), falling back to Basic auth');
+                        debugPrint('[Settings] fetchToken failed ($e), falling back to Basic auth');
                         // Clear any stale token so Basic auth is used instead.
                         final p = await SharedPreferences.getInstance();
                         await p.remove('rommAuthToken');
                       }
 
                       // Save credentials regardless of whether token fetch succeeded.
-                      print('[Settings] saving baseUrl=$baseUrl user=$username passLen=${password.length}');
+                      debugPrint('[Settings] saving baseUrl=$baseUrl user=$username passLen=${password.length}');
                       final prefs = await SharedPreferences.getInstance();
                       await prefs.setString('rommBaseUrl', baseUrl);
                       await prefs.setString('rommUsername', username);
diff --git a/lib/ui/widgets/download_progress_card.dart b/lib/ui/widgets/download_progress_card.dart
index 4c0bb04..aaaf46e 100644
--- a/lib/ui/widgets/download_progress_card.dart
+++ b/lib/ui/widgets/download_progress_card.dart
@@ -24,7 +24,7 @@ class DownloadProgressCard extends StatelessWidget {
           LinearProgressIndicator(value: progress.percent),
           const SizedBox(height: 4),
           Text(
-            '${(progress.percent * 100).toStringAsFixed(1)}% - '
+            '${progress.status} — ${(progress.percent * 100).toStringAsFixed(1)}% - '
             '${(progress.bytesReceived / 1024 / 1024).toStringAsFixed(1)} MB / '
             '${(progress.totalBytes / 1024 / 1024).toStringAsFixed(1)} MB',
             style: Theme.of(context).textTheme.bodySmall,
diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart
index dc7e311..a1966a5 100644
--- a/lib/ui/widgets/game_card.dart
+++ b/lib/ui/widgets/game_card.dart
@@ -7,6 +7,7 @@ class GameCard extends ConsumerWidget {
   final Game game;
   final VoidCallback onDownload;
   final VoidCallback onLaunch;
+  final VoidCallback? onSyncSaves;
   final bool isDownloaded;
 
   const GameCard({
@@ -14,6 +15,7 @@ class GameCard extends ConsumerWidget {
     required this.game,
     required this.onDownload,
     required this.onLaunch,
+    this.onSyncSaves,
     this.isDownloaded = false,
   });
 
@@ -100,6 +102,16 @@ class GameCard extends ConsumerWidget {
                         onPressed: onLaunch,
                         tooltip: 'Launch',
                       ),
+                      if (onSyncSaves != null)
+                        IconButton(
+                          visualDensity: VisualDensity.compact,
+                          iconSize: 24,
+                          padding: EdgeInsets.zero,
+                          constraints: const BoxConstraints(),
+                          icon: const Icon(Icons.cloud_upload),
+                          onPressed: onSyncSaves,
+                          tooltip: 'Sync saves',
+                        ),
                     ],
                   ),
                 ),
diff --git a/ls_upload.txt b/ls_upload.txt
deleted file mode 100644
index e17bbd5..0000000
--- a/ls_upload.txt
+++ /dev/null
@@ -1,167 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../providers/library_provider.dart'; // Ensure this import is correct
-import '../../providers/download_provider.dart';
-import '../../providers/romm_provider.dart';
-import '../widgets/game_card.dart';
-import '../widgets/platform_filter_bar.dart';
-import 'package:path_provider/path_provider.dart';
-import 'dart:convert'; // Ensure this import is present
-
-class LibraryScreen extends ConsumerWidget {
-  const LibraryScreen({super.key});
-
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    // Watch platforms to populate the filter bar
-    final platformsAsync = ref.watch(platformsProvider);
-    // Watch the selected platform ID
-    final selectedPlatformId = ref.watch(selectedPlatformIdProvider);
-    // Watch the search query
-    final searchQuery = ref.watch(searchQueryProvider);
-
-    // Watch allGamesProvider for the overall loading/error state of the initial fetch
-    final gamesAsync = ref.watch(allGamesProvider);
-    // Watch filteredGamesProvider for the actual list of games to display
-    final filteredGames = ref.watch(filteredGamesProvider);
-
-    // Get card aspect ratio from provider
-    final cardAspectRatio = ref.watch(cardAspectRatioProvider);
-
-    return Scaffold(
-      appBar: AppBar(title: const Text('Library')),
-      body: Column(
-        children: [
-          Padding(
-            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
-            child: TextField(
-              decoration: InputDecoration(
-                hintText: 'Search games...',
-                prefixIcon: const Icon(Icons.search),
-                border: OutlineInputBorder(
-                  borderRadius: BorderRadius.circular(8.0),
-                ),
-              ),
-              onChanged: (value) {
-                ref.read(searchQueryProvider.notifier).state = value;
-              },
-            ),
-          ),
-          platformsAsync.when(
-            data: (platforms) => PlatformFilterBar(
-              platforms: platforms,
-              selectedPlatformId: selectedPlatformId,
-              onSelected: (platform) {
-                ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
-              },
-            ),
-            loading: () => const LinearProgressIndicator(),
-            error: (e, s) => Text('Error loading platforms: $e'),
-          ),
-          Expanded(
-            child: gamesAsync.when(
-              loading: () => const Center(child: CircularProgressIndicator()),
-              error: (e, s) => Center(
-                child: Padding(
-                  padding: const EdgeInsets.all(16.0),
-                  child: Text(
-                    'Error loading games: $e',
-                    textAlign: TextAlign.center,
-                    style: const TextStyle(color: Colors.red),
-                  ),
-                ),
-              ),
-              data: (_) {
-                final gamesCount = filteredGames.length;
-                String countDisplayText;
-                if (selectedPlatformId == null && searchQuery.isEmpty) {
-                  countDisplayText = 'Showing all $gamesCount games';
-                } else {
-                  countDisplayText = 'Showing $gamesCount games';
-                }
-
-                return Column(
-                  children: [
-                    Padding(
-                      padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
-                      child: Align(
-                        alignment: Alignment.centerRight,
-                        child: Text(
-                          countDisplayText,
-                          style: const TextStyle(fontSize: 12, color: Colors.grey),
-                        ),
-                      ),
-                    ),
-                    Expanded(
-                      child: filteredGames.isEmpty
-                          ? const Center(child: Text('No games found'))
-                          : GridView.builder(
-                              padding: const EdgeInsets.all(12),
-                              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
-                                crossAxisCount: 4,
-                                childAspectRatio: cardAspectRatio,
-                                crossAxisSpacing: 8,
-                                mainAxisSpacing: 8,
-                              ),
-                              itemCount: filteredGames.length,
-                              itemBuilder: (context, index) {
-                                final game = filteredGames[index];
-                                return GameCard(
-                                  game: game,
-                                  onDownload: () async {
-                                    final service = ref.read(rommServiceProvider);
-                                    if (service == null) {
-                                      ScaffoldMessenger.of(context).showSnackBar(
-                                        const SnackBar(content: Text('Not connected to RomM')),
-                                      );
-                                      return;
-                                    }
-                                    final url = service.getDownloadUrl(game);
-                                    final u = service.config.username;
-                                    final p = service.config.password;
-                                    final token = 'Basic ${base64Encode(utf8.encode('$u:$p'))}';
-                                    final headers = <String, String>{'Authorization': token};
-                                    ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers);
-                                    ScaffoldMessenger.of(context).showSnackBar(
-                                      SnackBar(content: Text('Starting download: ${game.name}')),
-                                    );
-                                  },
-                                  onLaunch: () async { // Make callback async
-                                    final registry = ref.read(strategyRegistryProvider);
-                                    final strategy = registry?.getStrategyForSlug(game.platformSlug ?? '');
-                                    if (strategy == null) {
-                                      ScaffoldMessenger.of(context).showSnackBar( // Removed if (mounted)
-                                        SnackBar(content: Text('No emulator configured for ${game.platformDisplayName ?? game.platformSlug}')),
-                                      );
-                                      return;
-                                    }
-                                    final dir = await ref.read(directoryServiceProvider.future);
-                                    final romPath = await dir?.getRomFilePath(game) ?? '';
-                                    if (romPath.isEmpty) {
-                                      ScaffoldMessenger.of(context).showSnackBar( // Removed if (mounted)
-                                        const SnackBar(content: Text('Could not determine ROM path')),
-                                      );
-                                      return;
-                                    }
-                                    try {
-                                      await strategy.launch(game, romPath);
-                                    } catch (e) {
-                                      ScaffoldMessenger.of(context).showSnackBar( // Removed if (mounted)
-                                        SnackBar(content: Text('Launch failed: $e')),
-                                      );
-                                    }
-                                  },
-                                );
-                              },
-                            ),
-                    ),
-                  ],
-                );
-              },
-            ),
-          ),
-        ],
-      ),
-    );
-  }
-}
diff --git a/pubspec.lock b/pubspec.lock
index 3d743a0..5018c6d 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -305,7 +305,7 @@ packages:
     source: hosted
     version: "2.0.1"
   path:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: path
       sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"

Clone this wiki locally