Skip to content

Navigation Menu

Sign in
Sign up

commit f5ce6a1

abduznik edited this page May 23, 2026 · 1 revision

Feat/emulator expansion (#3)

Commit: f5ce6a18533e47a9c1f03ee59041f500320ca6e6

Author: abduznik

Date: 2026年03月24日

Message

  • feat: add RPCS3, PCSX2, Azahar, Cemu, Duckstation, Xemu, Xenia emulators with GitHub release downloads

  • v0.0.2 — Windows game support, 7z extraction, expanded save sync (PCSX2, RPCS3, Xenia)

  • fix: resolve all analyzer warnings and infos (unused imports, unused field, build context sync)

Why: Adds a new feature or capability to the application.

Files Changed

README.md | 81 +++++++--
 agent.md | 54 ++++--
 lib/core/downloader/download_service.dart | 27 ++-
 lib/core/emulator/emulator_download_service.dart | 80 +++++++-
 lib/core/emulator/emulator_registry_data.dart | 96 +++++++++-
 lib/core/emulator/github_release_service.dart | 50 +++++
 lib/core/emulator/strategies/azahar_strategy.dart | 43 +++++
 lib/core/emulator/strategies/cemu_strategy.dart | 40 ++++
 .../emulator/strategies/duckstation_strategy.dart | 40 ++++
 lib/core/emulator/strategies/pcsx2_strategy.dart | 40 ++++
 lib/core/emulator/strategies/rpcs3_strategy.dart | 40 ++++
 lib/core/emulator/strategies/windows_strategy.dart | 109 +++++++++++
 lib/core/emulator/strategies/xemu_strategy.dart | 40 ++++
 lib/core/emulator/strategies/xenia_strategy.dart | 40 ++++
 lib/core/emulator/strategy_registry.dart | 25 ++-
 lib/core/save/save_sync_service.dart | 45 ++++-
 lib/core/save/strategies/pcsx2_save_strategy.dart | 89 +++++++++
 lib/core/save/strategies/rpcs3_save_strategy.dart | 122 +++++++++++++
 .../save/strategies/windows_save_strategy.dart | 135 ++++++++++++++
 lib/core/save/strategies/xenia_save_strategy.dart | 109 +++++++++++
 lib/core/storage/directory_service.dart | 42 ++++-
 lib/core/windows/pcgamingwiki_service.dart | 202 +++++++++++++++++++++
 lib/core/windows/windows_game_service.dart | 73 ++++++++
 lib/providers/romm_provider.dart | 15 +-
 lib/ui/screens/library_screen.dart | 74 ++++++--
 lib/ui/widgets/windows_game_config_dialog.dart | 137 ++++++++++++++
 pubspec.yaml | 3 +-
 thirdparty/7zr.exe | Bin 0 -> 601088 bytes
 28 files changed, 1769 insertions(+), 82 deletions(-)
  • README.md
  • agent.md
  • lib/core/downloader/download_service.dart
  • lib/core/emulator/emulator_download_service.dart
  • lib/core/emulator/emulator_registry_data.dart
  • lib/core/emulator/github_release_service.dart
  • lib/core/emulator/strategies/azahar_strategy.dart
  • lib/core/emulator/strategies/cemu_strategy.dart
  • lib/core/emulator/strategies/duckstation_strategy.dart
  • lib/core/emulator/strategies/pcsx2_strategy.dart
  • lib/core/emulator/strategies/rpcs3_strategy.dart
  • lib/core/emulator/strategies/windows_strategy.dart
  • lib/core/emulator/strategies/xemu_strategy.dart
  • lib/core/emulator/strategies/xenia_strategy.dart
  • lib/core/emulator/strategy_registry.dart
  • lib/core/save/save_sync_service.dart
  • lib/core/save/strategies/pcsx2_save_strategy.dart
  • lib/core/save/strategies/rpcs3_save_strategy.dart
  • lib/core/save/strategies/windows_save_strategy.dart
  • lib/core/save/strategies/xenia_save_strategy.dart
  • lib/core/storage/directory_service.dart
  • lib/core/windows/pcgamingwiki_service.dart
  • lib/core/windows/windows_game_service.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart
  • lib/ui/widgets/windows_game_config_dialog.dart
  • pubspec.yaml
  • thirdparty/7zr.exe

Diff

diff --git a/README.md b/README.md
index 934e1b6..c8a824d 100644
--- a/README.md
+++ b/README.md
@@ -2,46 +2,87 @@
 
 A cross-platform Flutter app for browsing your RomM library, downloading ROMs, and launching games directly in emulators—all from one intuitive interface.
 
+## What's New in 0.0.2
+
+### Full Windows Native Game Support
+- Download Windows games (.zip and .7z) with automatic extraction
+- Auto-detection of game executables in extracted folders
+- Manual exe and save path configuration via long-press on any Windows game card
+- Crash detection on launch — if a game exits immediately, a clear error message explains likely causes (missing DirectX, Visual C++ redistributables, etc.)
+- Save sync for Windows games via PCGamingWiki automatic save path detection, with manual override support
+- All exe and save path overrides persist across app restarts
+
+### Expanded Save Sync
+- **PCSX2 (PS2)**: Syncs memory cards (Mcd001.ps2, Mcd002.ps2) and save states
+- **RPCS3 (PS3)**: Syncs save data folders by title ID
+- **Xenia Canary (Xbox 360)**: Syncs content save folders by title ID
+- **Windows games**: Full save directory packaging into zip for upload, auto-extraction on restore
+- All multi-file save strategies package saves as a single zip for clean cloud storage
+
+### 7-Zip Support
+- Bundled 7zr.exe for .7z extraction — no manual installation required
+- Automatically extracted to AppData on first run
+- Full .7z support for Windows game downloads
+
+### Quality of Life
+- Launch error messages now display for 8 seconds for better readability
+- Stale exe overrides (pointing to moved/deleted files) are automatically discarded and re-detected
+- ROM detection improved for Windows games — returns game folder instead of individual file
+
 ## Currently Working
 
-- **RomM Integration**: Browse and filter your entire RomM game library
-- **ROM Downloads**: Download games via HTTP from your RomM server (working)
+- **RomM Integration**: Browse and filter your entire RomM library by platform or search
+- **ROM Downloads**: Download games via HTTP from your RomM server with progress tracking
+- **Archive Extraction**: Automatic extraction of .zip and .7z archives on download
 - **Game Launching**: Launch games directly from the app using:
- - RetroArch (multi-system emulator)
+ - RetroArch (GBA, GBC, GB, NES, SNES, N64, NDS, PSX, PSP, Dreamcast, Megadrive, and more)
 - Dolphin (GameCube & Wii)
- - Eden (SNES/Genesis)
+ - Eden (Nintendo Switch)
+ - RPCS3 (PlayStation 3)
+ - PCSX2 (PlayStation 2)
+ - DuckStation (PlayStation 1)
+ - Azahar (Nintendo 3DS)
+ - Cemu (Wii U)
+ - Xemu (Xbox)
+ - Xenia Canary (Xbox 360)
+ - Windows Native (PC games)
+- **Emulator Downloads**: One-tap emulator download and installation from Settings
+- **Save Sync**: Two-way save synchronization with RomM cloud for:
+ - RetroArch (all supported platforms)
+ - Dolphin (GameCube/Wii)
+ - Eden (Switch)
+ - PCSX2 (PS2)
+ - RPCS3 (PS3)
+ - Xenia Canary (Xbox 360)
+ - Windows native games (via PCGamingWiki auto-detection)
 
 ## Roadmap
 
-### Near Term: Expanded Emulator Support
+### Near Term
 
-Expanding launcher support to cover more systems with download capabilities:
-- Dreamcast
-- RPCS3 (PlayStation 3)
-- RPCS2 (PlayStation 2)
-- Duckstation (PlayStation 1)
-- MelonDS (Nintendo DS)
-- Azure (Nintendo 3DS)
+- **Cemu save sync** — Wii U saves at mlc01/usr/save/
+- **Azahar save sync** — 3DS saves via SDMC folder
+- **MelonDS support** — better NDS emulation alternative
+- **macOS & Linux support** — platform detection and path resolution already structured for future expansion
 
 ### End-Game Features
 
-- Download emulators automatically with one tap
-- Download ROMs from RomM library (currently working)
-- Add custom ROMs for new platforms
-- Double-sided save synchronization: sync saves from RomM Cloud to device before playing, and sync back after you're done
+- Automatic emulator updates
+- Custom ROM platform tagging
+- Mobile companion app for on-the-go library browsing
 
 ### Cross-Platform Vision
 
-Freegosy is designed as a truly cross-platform experience, allowing you to manage your game library seamlessly across desktop, mobile, and tablet devices.
+Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, and Linux with platform-specific code isolated behind strategy patterns and service abstractions.
 
 ## Status
 
-Stay tuned for releases! Freegosy is actively under development with regular updates coming soon.
+Actively under development. Release 0.0.2 focuses on Windows game support and expanded save sync coverage.
 
 ## About RomM
 
-Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a modern ROM manager. It connects to your RomM instance to provide a lightweight, portable way to access and play your games on the go.
+Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a modern ROM manager. It connects to your RomM instance to provide a lightweight, portable way to access and play your games.
 
 ## Contributing
 
-We welcome contributions! Please check out our project structure in `agent.md` for guidelines on how to add new emulators or features.
+Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features.
\ No newline at end of file
diff --git a/agent.md b/agent.md
index 9e48305..de0be33 100644
--- a/agent.md
+++ b/agent.md
@@ -10,9 +10,11 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 - All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers.
 - All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI.
 - New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only.
+- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only.
 - New screen = new file in `ui/screens/`.
 - New reusable widget = new file in `ui/widgets/`.
 - Providers are thin — they call services, they do not contain business logic.
+- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks.
 
 ## File Map
 
@@ -26,43 +28,61 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 
 ### 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/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). Wires all strategies to RommService. Exposes windowsSaveStrategy for external access.
 - `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.
+- `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. Zips save folder for upload.
+- `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`).
+- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc.
+- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%\rpcs3\dev_hdd0\home00000001円\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution.
+- `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}00000001円\. Extracts title ID via 8-char hex regex. Zips save folder for upload.
 
 ### 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.
 - `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Methods: getStrategyForSlug(), getDefinition().
-- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy implementation.
-- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy implementation.
-- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy implementation.
-- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators.
+- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators. Supports direct URL and GitHub release types. Handles .zip and .7z extraction.
+- `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters.
+- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc.
+- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc.
+- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns.
+- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3.
+- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2.
+- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl.
+- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu.
+- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx.
+- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox.
+- `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla.
+- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`).
 
 ### Core — Downloader
-- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Exposes a Stream<DownloadProgress> for UI progress tracking. Updated to use DirectoryService.
+- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Handles .zip extraction via archive package and .7z via bundled 7zr.exe (resolved via DirectoryService.resolveSevenZipPath()). Windows games (.zip/.7z) always extracted regardless of isMultiFile flag.
 
 ### Core — Storage
-- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories, including persistence via SharedPreferences.
+- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets to %APPDATA%\Freegosy\thirdparty\ on first run. Uses defaultTargetPlatform for Windows check, Process.run for APPDATA resolution.
+
+### Core — Windows
+- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder (hint match then largest). Skips uninstall/setup/redist/etc. Launches via Process.start detached.
+- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations. Parses MediaWiki markup, expands environment variables (APPDATA, LOCALAPPDATA etc), returns resolved paths.
 
 ### Core — Updater
 - `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, EmulatorDownloadService, and SaveSyncService.
-- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search and filtering logic.
+- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry (loads WindowsStrategy persisted overrides on init), SaveSyncService (loads WindowsSaveStrategy persisted overrides on init).
+- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search, filtering, card aspect ratio, and RetroArch sync mode persistence.
 - `lib/providers/download_provider.dart` — Riverpod providers for active downloads and progress.
 
 ### UI — Screens
-- `lib/ui/screens/library_screen.dart` — Main screen. Shows platform filter bar, game grid, search bar, and game count display.
+- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, download, launch, save sync. Windows games support long-press to open config dialog and auto-show config on missing exe. Launch errors show for 8 seconds.
 - `lib/ui/screens/download_screen.dart` — Active downloads list with progress bars.
-- `lib/ui/screens/settings_screen.dart` — Storage and Emulator management section. Includes path selection, emulator installation status, and download buttons.
+- `lib/ui/screens/settings_screen.dart` — RomM server config, card aspect ratio, storage paths, RetroArch sync mode, emulator download/install status.
 
 ### UI — Widgets
-- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, platform. Tappable to launch or download.
+- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, download/launch/sync buttons. Green dot when downloaded.
 - `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar and cancel button.
 - `lib/ui/widgets/platform_filter_bar.dart` — Horizontal scrollable platform chip row with distinct styling for selected/unselected states.
+- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game exe path and save directory. Browse buttons for both. Returns `Map<String, String>` with keys `exe` and `save`. Only shown for Windows platform games.
 
 ## Key Contracts
 
@@ -107,7 +127,9 @@ class RomMConfig {
 - `flutter_riverpod` — state management
 - `dio` — HTTP client for API calls and downloads
 - `path_provider` — platform-safe file paths
-- `shared_preferences` — persist RomM config locally
+- `shared_preferences` — persist RomM config, card ratio, sync mode, Windows exe/save overrides
 - `package_info_plus` — read current app version for updater
-- `archive` — for zip extraction
-- `file_picker` — for directory selection
+- `archive` — zip extraction and creation (ZipDecoder, ZipFileEncoder)
+- `file_picker` — directory and file selection
+- `path` — path manipulation utilities
+- `thirdparty/7zr.exe` — bundled 7-Zip console executable for .7z extraction (Flutter asset)
\ No newline at end of file
diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart
index 927d303..b3e5a42 100644
--- a/lib/core/downloader/download_service.dart
+++ b/lib/core/downloader/download_service.dart
@@ -65,7 +65,10 @@ class DownloadService {
 deleteOnError: true,
 ).then((_) async {
 try {
- if (game.isMultiFile) {
+ final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
+ final isArchive = savePath.toLowerCase().endsWith('.zip') ||
+ savePath.toLowerCase().endsWith('.7z');
+ if (game.isMultiFile || (isWindowsGame && isArchive)) {
 controller.add(DownloadProgress(
 id: game.id,
 gameName: game.name,
@@ -112,11 +115,25 @@ class DownloadService {
 
 await Directory(extractDir).create(recursive: true);
 
- final bytes = await File(zipPath).readAsBytes();
- final archive = ZipDecoder().decodeBytes(bytes);
- extractArchiveToDisk(archive, extractDir);
+ if (zipPath.toLowerCase().endsWith('.7z')) {
+ final sevenZipExe = await directoryService.resolveSevenZipPath();
+ if (sevenZipExe == null) {
+ throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.');
+ }
+ final result = await Process.run(
+ sevenZipExe, ['x', zipPath, '-o$extractDir', '-y'],
+ runInShell: false,
+ );
+ if (result.exitCode != 0) {
+ throw Exception('7z extraction failed: ${result.stderr}');
+ }
+ } else {
+ final bytes = await File(zipPath).readAsBytes();
+ final archive = ZipDecoder().decodeBytes(bytes);
+ extractArchiveToDisk(archive, extractDir);
+ }
 
- // Delete the zip after extraction
+ // Delete the archive after extraction
 await File(zipPath).delete();
 
 // Log the largest file found (main ROM)
diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart
index e40b1a2..979da86 100644
--- a/lib/core/emulator/emulator_download_service.dart
+++ b/lib/core/emulator/emulator_download_service.dart
@@ -7,12 +7,16 @@ import 'package:archive/archive_io.dart';
 import '../downloader/download_service.dart';
 import '../storage/directory_service.dart';
 import 'emulator_registry_data.dart';
+import 'github_release_service.dart';
 
 class EmulatorDownloadService {
 final Dio _dio;
 final DirectoryService _directoryService;
+ late final GithubReleaseService _githubService;
 
- EmulatorDownloadService(this._dio, this._directoryService);
+ EmulatorDownloadService(this._dio, this._directoryService) {
+ _githubService = GithubReleaseService(_dio);
+ }
 
 Stream<DownloadProgress> downloadEmulator(String emulatorId) async* {
 final definition = kEmulatorDefinitions.firstWhere(
@@ -21,17 +25,53 @@ class EmulatorDownloadService {
 );
 
 if (definition.isEmpty) {
- yield DownloadProgress(id: emulatorId, gameName: emulatorId, error: 'Emulator definition not found');
+ yield DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorId,
+ error: 'Emulator definition not found',
+ );
 return;
 }
 
 final String emulatorName = definition['name'] as String? ?? emulatorId;
- final String? downloadUrl = Platform.isWindows
- ? definition['windows_url'] as String?
- : definition['linux_url'] as String?;
+ final String type = definition['type'] as String? ?? 'direct';
+
+ // Resolve download URL based on type
+ String? downloadUrl;
+ if (type == 'github') {
+ yield DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ status: 'Fetching latest release...',
+ );
+ final repo = definition['github_repo'] as String;
+ final required = List<String>.from(definition['github_asset_required'] ?? []);
+ final excluded = List<String>.from(definition['github_asset_excluded'] ?? []);
+ downloadUrl = await _githubService.getLatestReleaseUrl(
+ repo: repo,
+ required: required,
+ excluded: excluded,
+ );
+ if (downloadUrl == null) {
+ yield DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ error: 'No matching release asset found on GitHub',
+ );
+ return;
+ }
+ } else {
+ downloadUrl = Platform.isWindows
+ ? definition['windows_url'] as String?
+ : definition['linux_url'] as String?;
+ }
 
 if (downloadUrl == null) {
- yield DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'No download URL for this platform');
+ yield DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ error: 'No download URL for this platform',
+ );
 return;
 }
 
@@ -54,34 +94,54 @@ class EmulatorDownloadService {
 percent: received / total,
 bytesReceived: received,
 totalBytes: total,
+ status: 'Downloading...',
 ));
 }
 },
 deleteOnError: true,
 ).then((_) async {
 try {
+ controller.add(DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ percent: 1.0,
+ status: 'Extracting...',
+ ));
 await _extractArchive(tempFilePath, emulatorDir);
 controller.add(DownloadProgress(
 id: emulatorId,
 gameName: emulatorName,
 percent: 1.0,
 isComplete: true,
+ status: 'Done!',
 ));
 } catch (e) {
- controller.add(DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Extraction failed: $e'));
+ controller.add(DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ error: 'Extraction failed: $e',
+ ));
 } finally {
 controller.close();
 final f = File(tempFilePath);
 if (await f.exists()) await f.delete();
 }
 }).catchError((e) {
- controller.add(DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Download failed: $e'));
+ controller.add(DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ error: 'Download failed: $e',
+ ));
 controller.close();
 });
 
 yield* controller.stream;
 } catch (e) {
- yield DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Error: $e');
+ yield DownloadProgress(
+ id: emulatorId,
+ gameName: emulatorName,
+ error: 'Error: $e',
+ );
 }
 }
 
@@ -102,4 +162,4 @@ class EmulatorDownloadService {
 throw Exception('Unsupported archive format: $archivePath');
 }
 }
-}
+}
\ No newline at end of file
diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart
index e02b4c2..0aefadf 100644
--- a/lib/core/emulator/emulator_registry_data.dart
+++ b/lib/core/emulator/emulator_registry_data.dart
@@ -2,19 +2,21 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
 {
 'id': 'retroarch',
 'name': 'RetroArch',
+ 'type': 'direct',
 'windows_url': 'https://buildbot.libretro.com/stable/1.19.1/windows/x86_64/RetroArch.7z',
 'windows_executable': 'RetroArch.exe',
 'linux_executable': 'retroarch',
 'platform_slugs': [
- 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'psp',
- 'segacd', 'saturn', 'dreamcast', 'megadrive', 'genesis', 'gamegear',
- 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame',
+ 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'ps1', 'playstation',
+ 'psp', 'dc', 'dreamcast', 'segacd', 'saturn', 'megadrive', 'genesis', 'md',
+ 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame',
 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
 ],
 },
 {
 'id': 'dolphin',
 'name': 'Dolphin',
+ 'type': 'direct',
 'windows_url': 'https://dl.dolphin-emu.org/builds/dolphin-master-latest-x64.7z',
 'windows_executable': 'Dolphin.exe',
 'linux_executable': 'dolphin-emu',
@@ -23,9 +25,95 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
 {
 'id': 'eden',
 'name': 'Eden (Switch)',
+ 'type': 'direct',
 'windows_url': 'https://git.eden-emu.dev/eden-emu/eden/-/releases/permalink/latest/downloads/eden-windows-msvc-install.zip',
 'windows_executable': 'eden.exe',
 'linux_executable': 'eden',
 'platform_slugs': ['switch', 'nintendo-switch', 'ns'],
 },
-];
+ {
+ 'id': 'rpcs3',
+ 'name': 'RPCS3 (PlayStation 3)',
+ 'type': 'github',
+ 'github_repo': 'RPCS3/rpcs3',
+ 'github_asset_required': ['win64'],
+ 'github_asset_excluded': ['debug'],
+ 'windows_executable': 'rpcs3.exe',
+ 'linux_executable': 'rpcs3',
+ 'platform_slugs': ['ps3', 'playstation-3', 'playstation3'],
+ },
+ {
+ 'id': 'pcsx2',
+ 'name': 'PCSX2 (PlayStation 2)',
+ 'type': 'github',
+ 'github_repo': 'PCSX2/pcsx2',
+ 'github_asset_required': ['windows', 'Qt'],
+ 'github_asset_excluded': ['debug', 'sse4', 'arm64'],
+ 'windows_executable': 'pcsx2-qt.exe',
+ 'linux_executable': 'pcsx2-qt',
+ 'platform_slugs': ['ps2', 'playstation-2', 'playstation2'],
+ },
+ {
+ 'id': 'azahar',
+ 'name': 'Azahar (Nintendo 3DS)',
+ 'type': 'github',
+ 'github_repo': 'azahar-emu/azahar',
+ 'github_asset_required': ['windows', 'msys2'],
+ 'github_asset_excluded': ['debug', 'appimage'],
+ 'windows_executable': 'azahar.exe',
+ 'linux_executable': 'azahar',
+ 'platform_slugs': ['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'],
+ },
+ {
+ 'id': 'cemu',
+ 'name': 'Cemu (Wii U)',
+ 'type': 'github',
+ 'github_repo': 'cemu-project/Cemu',
+ 'github_asset_required': ['windows'],
+ 'github_asset_excluded': ['experimental', 'debug'],
+ 'windows_executable': 'Cemu.exe',
+ 'linux_executable': 'cemu',
+ 'platform_slugs': ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'],
+ },
+ {
+ 'id': 'xemu',
+ 'name': 'Xemu (Xbox)',
+ 'type': 'github',
+ 'github_repo': 'xemu-project/xemu',
+ 'github_asset_required': ['win', 'x86_64', 'release'],
+ 'github_asset_excluded': ['dbg', 'pdb', 'arm', 'macos', 'appimage', 'tar'],
+ 'windows_executable': 'xemu.exe',
+ 'linux_executable': 'xemu',
+ 'platform_slugs': ['xbox'],
+ },
+ {
+ 'id': 'xenia_canary',
+ 'name': 'Xenia Canary (Xbox 360)',
+ 'type': 'github',
+ 'github_repo': 'xenia-canary/xenia-canary',
+ 'github_asset_required': ['windows'],
+ 'github_asset_excluded': ['debugoptimized', 'debug', 'pdb'],
+ 'windows_executable': 'xenia_canary.exe',
+ 'linux_executable': 'xenia_canary',
+ 'platform_slugs': ['xbox360', 'xbla'],
+ },
+ {
+ 'id': 'duckstation',
+ 'name': 'DuckStation (PlayStation 1)',
+ 'type': 'github',
+ 'github_repo': 'stenzek/duckstation',
+ 'github_asset_required': ['windows', 'x64'],
+ 'github_asset_excluded': ['nogui', 'debug', 'arm64'],
+ 'windows_executable': 'duckstation-qt-x64-ReleaseLTCG.exe',
+ 'linux_executable': 'duckstation-qt',
+ 'platform_slugs': ['ps1', 'playstation', 'psx'],
+ },
+ {
+ 'id': 'windows_native',
+ 'name': 'Windows (Native)',
+ 'type': 'none',
+ 'windows_executable': '',
+ 'linux_executable': '',
+ 'platform_slugs': ['windows', 'pc', 'win'],
+ },
+];
\ No newline at end of file
diff --git a/lib/core/emulator/github_release_service.dart b/lib/core/emulator/github_release_service.dart
new file mode 100644
index 0000000..404a530
--- /dev/null
+++ b/lib/core/emulator/github_release_service.dart
@@ -0,0 +1,50 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
+
+class GithubReleaseService {
+ final Dio _dio;
+
+ GithubReleaseService(this._dio);
+
+ /// Fetches the latest release download URL from a GitHub repo
+ /// that matches all [required] filters and none of the [excluded] filters.
+ Future<String?> getLatestReleaseUrl({
+ required String repo,
+ required List<String> required,
+ List<String> excluded = const [],
+ }) async {
+ try {
+ final response = await _dio.get(
+ 'https://api.github.com/repos/$repo/releases/latest',
+ options: Options(headers: {'Accept': 'application/vnd.github.v3+json'}),
+ );
+
+ if (response.statusCode != 200) return null;
+
+ final assets = response.data['assets'] as List<dynamic>;
+ debugPrint('[GithubReleaseService] $repo — ${assets.length} assets found');
+
+ for (final asset in assets) {
+ final name = (asset['name'] as String).toLowerCase();
+ final url = asset['browser_download_url'] as String;
+
+ // Must match all required filters
+ final matchesRequired = required.every((f) => name.contains(f.toLowerCase()));
+ if (!matchesRequired) continue;
+
+ // Must not match any excluded filters
+ final matchesExcluded = excluded.any((f) => name.contains(f.toLowerCase()));
+ if (matchesExcluded) continue;
+
+ debugPrint('[GithubReleaseService] matched asset: $name');
+ return url;
+ }
+
+ debugPrint('[GithubReleaseService] no matching asset found for $repo');
+ return null;
+ } catch (e) {
+ debugPrint('[GithubReleaseService] error fetching $repo: $e');
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/azahar_strategy.dart b/lib/core/emulator/strategies/azahar_strategy.dart
new file mode 100644
index 0000000..9e7b673
--- /dev/null
+++ b/lib/core/emulator/strategies/azahar_strategy.dart
@@ -0,0 +1,43 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class AzaharStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ AzaharStrategy(this._directoryService);
+
+ @override
+ String get name => 'Azahar';
+
+ @override
+ String get emulatorId => 'azahar';
+
+ @override
+ List<String> get supportedSlugs => [
+ '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds',
+ 'new-nintendo-3ds', 'new-nintendo-3ds-xl',
+ ];
+
+ @override
+ String get windowsExecutable => 'azahar.exe';
+
+ @override
+ String get linuxExecutable => 'azahar';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/cemu_strategy.dart b/lib/core/emulator/strategies/cemu_strategy.dart
new file mode 100644
index 0000000..60732ea
--- /dev/null
+++ b/lib/core/emulator/strategies/cemu_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class CemuStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ CemuStrategy(this._directoryService);
+
+ @override
+ String get name => 'Cemu';
+
+ @override
+ String get emulatorId => 'cemu';
+
+ @override
+ List<String> get supportedSlugs => ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'];
+
+ @override
+ String get windowsExecutable => 'Cemu.exe';
+
+ @override
+ String get linuxExecutable => 'cemu';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart
new file mode 100644
index 0000000..74f2663
--- /dev/null
+++ b/lib/core/emulator/strategies/duckstation_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class DuckstationStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ DuckstationStrategy(this._directoryService);
+
+ @override
+ String get name => 'DuckStation';
+
+ @override
+ String get emulatorId => 'duckstation';
+
+ @override
+ List<String> get supportedSlugs => ['ps1', 'playstation', 'psx'];
+
+ @override
+ String get windowsExecutable => 'duckstation-qt-x64-ReleaseLTCG.exe';
+
+ @override
+ String get linuxExecutable => 'duckstation-qt';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, ['-batch', romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart
new file mode 100644
index 0000000..75ec8b1
--- /dev/null
+++ b/lib/core/emulator/strategies/pcsx2_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class Pcsx2Strategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ Pcsx2Strategy(this._directoryService);
+
+ @override
+ String get name => 'PCSX2';
+
+ @override
+ String get emulatorId => 'pcsx2';
+
+ @override
+ List<String> get supportedSlugs => ['ps2', 'playstation-2', 'playstation2'];
+
+ @override
+ String get windowsExecutable => 'pcsx2-qt.exe';
+
+ @override
+ String get linuxExecutable => 'pcsx2-qt';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/rpcs3_strategy.dart b/lib/core/emulator/strategies/rpcs3_strategy.dart
new file mode 100644
index 0000000..9828c67
--- /dev/null
+++ b/lib/core/emulator/strategies/rpcs3_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class Rpcs3Strategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ Rpcs3Strategy(this._directoryService);
+
+ @override
+ String get name => 'RPCS3';
+
+ @override
+ String get emulatorId => 'rpcs3';
+
+ @override
+ List<String> get supportedSlugs => ['ps3', 'playstation-3', 'playstation3'];
+
+ @override
+ String get windowsExecutable => 'rpcs3.exe';
+
+ @override
+ String get linuxExecutable => 'rpcs3';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/windows_strategy.dart b/lib/core/emulator/strategies/windows_strategy.dart
new file mode 100644
index 0000000..17cf244
--- /dev/null
+++ b/lib/core/emulator/strategies/windows_strategy.dart
@@ -0,0 +1,109 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+import 'package:freegosy/core/windows/windows_game_service.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class WindowsStrategy extends EmulatorStrategy {
+ final WindowsGameService _windowsGameService;
+
+ // Manual exe overrides per game id
+ final Map<String, String> _exeOverrides = {};
+
+ WindowsStrategy(DirectoryService directoryService)
+ : _windowsGameService = WindowsGameService();
+
+ @override
+ String get name => 'Windows';
+
+ @override
+ String get emulatorId => 'windows_native';
+
+ @override
+ List<String> get supportedSlugs => ['windows', 'pc', 'win'];
+
+ @override
+ String get windowsExecutable => '';
+
+ @override
+ String get linuxExecutable => '';
+
+ @override
+ bool get supportsSaveSync => true;
+
+ static const String _prefsPrefix = 'win_exe_';
+
+ Future<void> loadPersistedOverrides() async {
+ final prefs = await SharedPreferences.getInstance();
+ final keys = prefs.getKeys().where((k) => k.startsWith(_prefsPrefix));
+ for (final key in keys) {
+ final gameId = key.substring(_prefsPrefix.length);
+ final path = prefs.getString(key);
+ if (path != null && path.isNotEmpty) _exeOverrides[gameId] = path;
+ }
+ }
+
+ Future<void> setExeOverride(String gameId, String exePath) async {
+ _exeOverrides[gameId] = exePath;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('$_prefsPrefix$gameId', exePath);
+ }
+
+ String? getExeOverride(String gameId) => _exeOverrides[gameId];
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ // romPath for Windows games is the extracted game folder
+ String? exePath = _exeOverrides[game.id];
+
+ // If stored override no longer exists on disk, discard and auto-detect
+ if (exePath != null && exePath.isNotEmpty && !await File(exePath).exists()) {
+ debugPrint('[WindowsStrategy] override exe not found at $exePath, falling back to auto-detect');
+ exePath = null;
+ }
+
+ if (exePath == null || exePath.isEmpty) {
+ // Auto-detect exe in the game folder
+ final isDir = await Directory(romPath).exists();
+ final searchDir = isDir ? romPath : File(romPath).parent.path;
+ exePath = await _windowsGameService.findExecutable(
+ searchDir,
+ hint: game.name,
+ );
+ }
+
+ if (exePath == null) {
+ throw Exception(
+ 'No executable found for ${game.name}. '
+ 'Please set the exe path manually.',
+ );
+ }
+
+ debugPrint('[WindowsStrategy] launching: $exePath');
+ final process = await Process.start(
+ exePath,
+ [],
+ workingDirectory: File(exePath).parent.path,
+ );
+
+ // Wait up to 5 seconds — if process exits that fast it crashed
+ final exitCode = await process.exitCode
+ .timeout(const Duration(seconds: 5))
+ .catchError((_) => -99999); // timeout = still running = fine
+
+ if (exitCode != -99999 && exitCode != 0) {
+ debugPrint('[WindowsStrategy] ${game.name} exited with code $exitCode');
+ throw Exception(
+ '${game.name} crashed immediately (exit code $exitCode). '
+ 'This is likely due to missing DirectX, Visual C++ redistributables, or other dependencies.',
+ );
+ }
+
+ debugPrint('[WindowsStrategy] ${game.name} still running after 5s — OK');
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/xemu_strategy.dart b/lib/core/emulator/strategies/xemu_strategy.dart
new file mode 100644
index 0000000..8d433e8
--- /dev/null
+++ b/lib/core/emulator/strategies/xemu_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class XemuStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ XemuStrategy(this._directoryService);
+
+ @override
+ String get name => 'Xemu';
+
+ @override
+ String get emulatorId => 'xemu';
+
+ @override
+ List<String> get supportedSlugs => ['xbox'];
+
+ @override
+ String get windowsExecutable => 'xemu.exe';
+
+ @override
+ String get linuxExecutable => 'xemu';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/xenia_strategy.dart b/lib/core/emulator/strategies/xenia_strategy.dart
new file mode 100644
index 0000000..fac9475
--- /dev/null
+++ b/lib/core/emulator/strategies/xenia_strategy.dart
@@ -0,0 +1,40 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class XeniaStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ XeniaStrategy(this._directoryService);
+
+ @override
+ String get name => 'Xenia Canary';
+
+ @override
+ String get emulatorId => 'xenia_canary';
+
+ @override
+ List<String> get supportedSlugs => ['xbox360', 'xbla'];
+
+ @override
+ String get windowsExecutable => 'xenia_canary.exe';
+
+ @override
+ String get linuxExecutable => 'xenia_canary';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
\ No newline at end of file
diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart
index 57271de..8c46f8f 100644
--- a/lib/core/emulator/strategy_registry.dart
+++ b/lib/core/emulator/strategy_registry.dart
@@ -1,10 +1,18 @@
-import 'package:flutter/foundation.dart'; // For kIsWeb
+import 'package:flutter/foundation.dart';
 import 'package:freegosy/core/emulator/emulator_strategy.dart';
 import 'package:freegosy/core/emulator/strategies/retroarch_strategy.dart';
 import 'package:freegosy/core/emulator/strategies/dolphin_strategy.dart';
 import 'package:freegosy/core/emulator/strategies/eden_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/rpcs3_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/pcsx2_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/azahar_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/cemu_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/duckstation_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/xemu_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/xenia_strategy.dart';
 import 'package:freegosy/core/emulator/emulator_registry_data.dart';
 import 'package:freegosy/core/storage/directory_service.dart';
+import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
 
 class StrategyRegistry {
 final DirectoryService _directoryService;
@@ -15,14 +23,19 @@ class StrategyRegistry {
 RetroArchStrategy(_directoryService),
 DolphinStrategy(_directoryService),
 EdenStrategy(_directoryService),
+ Rpcs3Strategy(_directoryService),
+ Pcsx2Strategy(_directoryService),
+ AzaharStrategy(_directoryService),
+ CemuStrategy(_directoryService),
+ DuckstationStrategy(_directoryService),
+ XemuStrategy(_directoryService),
+ XeniaStrategy(_directoryService),
+ WindowsStrategy(_directoryService),
 ];
 }
 
 EmulatorStrategy? getStrategyForSlug(String platformSlug) {
- if (kIsWeb) {
- return null;
- }
-
+ if (kIsWeb) return null;
 for (final strategy in _strategies) {
 if (strategy.supportedSlugs.contains(platformSlug)) {
 return strategy;
@@ -38,4 +51,4 @@ class StrategyRegistry {
 return null;
 }
 }
-}
+}
\ No newline at end of file
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index f9d24d3..d7806f9 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -1,5 +1,5 @@
 import 'dart:io';
-import 'package:flutter/rendering.dart';
+import 'package:flutter/foundation.dart';
 import '../romm/romm_models.dart';
 import '../romm/romm_service.dart';
 import '../storage/directory_service.dart';
@@ -8,6 +8,10 @@ import 'strategies/retroarch_save_strategy.dart';
 import 'strategies/dolphin_save_strategy.dart';
 import 'strategies/eden_save_strategy.dart';
 import 'package:archive/archive_io.dart';
+import 'strategies/windows_save_strategy.dart';
+import 'strategies/pcsx2_save_strategy.dart';
+import 'strategies/rpcs3_save_strategy.dart';
+import 'strategies/xenia_save_strategy.dart';
 
 class SaveSyncService {
 final RommService _rommService;
@@ -16,11 +20,18 @@ class SaveSyncService {
 late final RetroArchSaveStrategy _retroarch;
 late final DolphinSaveStrategy _dolphin;
 late final EdenSaveStrategy _eden;
+ late final WindowsSaveStrategy _windows;
+ late final Pcsx2SaveStrategy _pcsx2;
+ late final Rpcs3SaveStrategy _rpcs3;
+ late final XeniaSaveStrategy _xenia;
 
 SaveSyncService(this._rommService, this._directoryService) {
 _retroarch = RetroArchSaveStrategy(_directoryService);
 _dolphin = DolphinSaveStrategy(_directoryService);
 _eden = EdenSaveStrategy();
+ _windows = WindowsSaveStrategy();
+ _pcsx2 = Pcsx2SaveStrategy(_directoryService);
+ _rpcs3 = Rpcs3SaveStrategy();
 }
 
 /// Returns the appropriate save strategy for [platformSlug], or null if unsupported.
@@ -43,15 +54,43 @@ class SaveSyncService {
 case 'genesis':
 case 'md':
 return _retroarch;
+ case 'gc':
 case 'ngc':
 case 'gamecube':
 case 'wii':
- case 'gc':
 return _dolphin;
 case 'switch':
 case 'nintendo-switch':
 case 'ns':
 return _eden;
+ case 'windows':
+ case 'pc':
+ case 'win':
+ return _windows;
+ // These emulators don't have save sync yet
+ case 'ps2':
+ case 'playstation-2':
+ case 'playstation2':
+ return _pcsx2;
+ case 'ps3':
+ case 'playstation-3':
+ case 'playstation3':
+ return _rpcs3;
+ case 'xbox360':
+ case 'xbla':
+ return _xenia;
+ case '3ds':
+ case 'n3ds':
+ case 'nintendo-3ds':
+ case 'nintendo3ds':
+ case 'new-nintendo-3ds':
+ case 'new-nintendo-3ds-xl':
+ case 'wiiu':
+ case 'wii-u':
+ case 'nintendo-wii-u':
+ case 'nintendo-wiiu':
+ case 'xbox':
+ return null;
 default:
 return null;
 }
@@ -150,4 +189,6 @@ class SaveSyncService {
 rethrow;
 }
 }
+
+ WindowsSaveStrategy get windowsSaveStrategy => _windows;
 }
diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart
new file mode 100644
index 0000000..a042690
--- /dev/null
+++ b/lib/core/save/strategies/pcsx2_save_strategy.dart
@@ -0,0 +1,89 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for PCSX2 (PlayStation 2).
+/// Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2
+/// States: {emulatorDir}/sstates/{stem}.000 etc.
+class Pcsx2SaveStrategy extends SaveStrategy {
+ final DirectoryService _directoryService;
+
+ Pcsx2SaveStrategy(this._directoryService);
+
+ @override
+ String get strategyId => 'pcsx2';
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ 'pcsx2', 'pcsx2-qt.exe');
+ if (exePath == null) return null;
+ return '${File(exePath).parent.path}/memcards';
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ 'pcsx2', 'pcsx2-qt.exe');
+ if (exePath == null) return [];
+ final exeDir = File(exePath).parent.path;
+
+ final candidates = <File>[];
+
+ // Memory cards — shared across all PS2 games
+ if (syncMode == 'saves' || syncMode == 'both') {
+ candidates.add(File('$exeDir/memcards/Mcd001.ps2'));
+ candidates.add(File('$exeDir/memcards/Mcd002.ps2'));
+ }
+
+ // Save states — named after ROM stem
+ if (syncMode == 'states' || syncMode == 'both') {
+ final stem = getRomStem(game);
+ final statesDir = '$exeDir/sstates';
+ for (int i = 0; i <= 9; i++) {
+ candidates.add(File('$statesDir/$stem.${'$i'.padLeft(3, '0')}'));
+ }
+ }
+
+ 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 exePath = await _directoryService.findEmulatorExecutable(
+ 'pcsx2', 'pcsx2-qt.exe');
+ if (exePath == null) return false;
+ final exeDir = File(exePath).parent.path;
+
+ final isState = filename.contains('.') &&
+ int.tryParse(filename.split('.').last) != null;
+ final targetDir = isState ? '$exeDir/sstates' : '$exeDir/memcards';
+
+ 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('[Pcsx2SaveStrategy] restored $filename to $targetPath');
+ return true;
+ } catch (e) {
+ debugPrint('[Pcsx2SaveStrategy] restoreSave error: $e');
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/core/save/strategies/rpcs3_save_strategy.dart b/lib/core/save/strategies/rpcs3_save_strategy.dart
new file mode 100644
index 0000000..3361212
--- /dev/null
+++ b/lib/core/save/strategies/rpcs3_save_strategy.dart
@@ -0,0 +1,122 @@
+import 'dart:io';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/foundation.dart';
+import '../../romm/romm_models.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for RPCS3 (PlayStation 3).
+/// Saves: %APPDATA%\rpcs3\dev_hdd0\home00000001円\savedata\{titleId}\
+class Rpcs3SaveStrategy extends SaveStrategy {
+ @override
+ String get strategyId => 'rpcs3';
+
+ Future<String?> _getRpcs3SaveRoot() 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\\rpcs3\\dev_hdd0\\home\00000001円\\savedata';
+ } catch (e) {
+ debugPrint('[Rpcs3SaveStrategy] failed to get APPDATA: $e');
+ return null;
+ }
+ }
+
+ /// PS3 title IDs are in the ROM folder name e.g. BLES01234
+ String? _extractTitleId(Game game) {
+ final name = game.fsName ?? game.fileName ?? game.name;
+ final match = RegExp(r'[A-Z]{4}\d{5}').firstMatch(name.toUpperCase());
+ return match?.group(0);
+ }
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final saveRoot = await _getRpcs3SaveRoot();
+ if (saveRoot == null) return null;
+ final titleId = _extractTitleId(game);
+ if (titleId == null) return null;
+ return '$saveRoot\\$titleId';
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final saveRoot = await _getRpcs3SaveRoot();
+ if (saveRoot == null) return [];
+
+ final titleId = _extractTitleId(game);
+ if (titleId == null) {
+ debugPrint('[Rpcs3SaveStrategy] could not extract title ID for ${game.name}');
+ return [];
+ }
+
+ final saveDir = Directory('$saveRoot\\$titleId');
+ if (!await saveDir.exists()) return [];
+
+ // Package entire save folder as zip (same approach as Windows saves)
+ bool hasFiles = false;
+ await for (final entity in saveDir.list(recursive: true)) {
+ if (entity is! File) continue;
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ hasFiles = true;
+ break;
+ }
+ if (!hasFiles) return [];
+
+ final zipPath = '${saveDir.path}.saves.zip';
+ final encoder = ZipFileEncoder();
+ encoder.create(zipPath);
+ await encoder.addDirectory(saveDir);
+ encoder.close();
+
+ debugPrint('[Rpcs3SaveStrategy] packaged saves to $zipPath');
+ return [File(zipPath)];
+ }
+
+ @override
+ Future<bool> restoreSave(
+ Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final saveRoot = await _getRpcs3SaveRoot();
+ if (saveRoot == null) return false;
+
+ final titleId = _extractTitleId(game);
+ if (titleId == null) return false;
+
+ final saveDir = '$saveRoot\\$titleId';
+ await Directory(saveDir).create(recursive: true);
+
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ final entryPath = '$saveDir\\${entry.name}';
+ if (entry.isFile) {
+ await backupSave(entryPath);
+ final outFile = File(entryPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ } else {
+ await Directory(entryPath).create(recursive: true);
+ }
+ }
+ debugPrint('[Rpcs3SaveStrategy] extracted $filename to $saveDir');
+ return true;
+ }
+
+ final targetPath = '$saveDir\\$filename';
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ debugPrint('[Rpcs3SaveStrategy] restored $filename to $targetPath');
+ return true;
+ } catch (e) {
+ debugPrint('[Rpcs3SaveStrategy] restoreSave error: $e');
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/core/save/strategies/windows_save_strategy.dart b/lib/core/save/strategies/windows_save_strategy.dart
new file mode 100644
index 0000000..68dfc3c
--- /dev/null
+++ b/lib/core/save/strategies/windows_save_strategy.dart
@@ -0,0 +1,135 @@
+import 'dart:io';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../../romm/romm_models.dart';
+import '../../windows/pcgamingwiki_service.dart';
+import '../save_strategy.dart';
+import 'package:dio/dio.dart';
+
+class WindowsSaveStrategy extends SaveStrategy {
+ final PcGamingWikiService _wikiService;
+
+ // Manual override paths set by user per game id
+ final Map<String, String> _manualOverrides = {};
+
+ WindowsSaveStrategy() : _wikiService = PcGamingWikiService(Dio());
+
+ @override
+ String get strategyId => 'windows';
+
+ /// Allows the user to manually set a save path for a game.
+ static const String _prefsPrefix = 'win_save_';
+
+ Future<void> loadPersistedOverrides() async {
+ final prefs = await SharedPreferences.getInstance();
+ final keys = prefs.getKeys().where((k) => k.startsWith(_prefsPrefix));
+ for (final key in keys) {
+ final gameId = key.substring(_prefsPrefix.length);
+ final path = prefs.getString(key);
+ if (path != null && path.isNotEmpty) _manualOverrides[gameId] = path;
+ }
+ }
+
+ /// Allows the user to manually set a save path for a game.
+ Future<void> setManualOverride(String gameId, String path) async {
+ _manualOverrides[gameId] = path;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('$_prefsPrefix$gameId', path);
+ }
+
+ String? getManualOverride(String gameId) => _manualOverrides[gameId];
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ // Manual override takes priority
+ final manual = _manualOverrides[game.id];
+ if (manual != null && manual.isNotEmpty) return manual;
+
+ // Try PCGamingWiki
+ try {
+ final locations = await _wikiService.getSaveLocations(game.name);
+ if (locations.isNotEmpty) {
+ debugPrint('[WindowsSaveStrategy] wiki found ${locations.length} locations for ${game.name}');
+ return locations.first['path'];
+ }
+ } catch (e) {
+ debugPrint('[WindowsSaveStrategy] wiki lookup failed: $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 [];
+
+ // Check if any files exist (respecting sessionStart)
+ bool hasFiles = false;
+ await for (final entity in dir.list(recursive: true)) {
+ if (entity is! File) continue;
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ hasFiles = true;
+ break;
+ }
+ if (!hasFiles) return [];
+
+ // Package the entire save directory as a single zip
+ final zipPath = '${dir.path}.saves.zip';
+ final encoder = ZipFileEncoder();
+ encoder.create(zipPath);
+ await encoder.addDirectory(dir);
+ encoder.close();
+
+ debugPrint('[WindowsSaveStrategy] packaged saves to $zipPath');
+ return [File(zipPath)];
+ }
+
+ @override
+ Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final saveDir = await getSaveDir(game, destPath);
+ if (saveDir == null) {
+ throw Exception('No save location found for ${game.name}. Set one manually in game settings.');
+ }
+
+ final dir = Directory(saveDir);
+ if (!await dir.exists()) await dir.create(recursive: true);
+
+ // Always extract zip into the save directory
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ final entryPath = '$saveDir/${entry.name}';
+ if (entry.isFile) {
+ await backupSave(entryPath);
+ final outFile = File(entryPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ } else {
+ await Directory(entryPath).create(recursive: true);
+ }
+ }
+ debugPrint('[WindowsSaveStrategy] extracted $filename to $saveDir');
+ return true;
+ }
+
+ // Fallback for non-zip (single file)
+ final targetPath = '$saveDir/$filename';
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ debugPrint('[WindowsSaveStrategy] restored $filename to $targetPath');
+ return true;
+ } catch (e) {
+ debugPrint('[WindowsSaveStrategy] restoreSave error: $e');
+ rethrow;
+ }
+ }
+}
diff --git a/lib/core/save/strategies/xenia_save_strategy.dart b/lib/core/save/strategies/xenia_save_strategy.dart
new file mode 100644
index 0000000..7958407
--- /dev/null
+++ b/lib/core/save/strategies/xenia_save_strategy.dart
@@ -0,0 +1,109 @@
+import 'dart:io';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/foundation.dart';
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for Xenia Canary (Xbox 360).
+/// Saves: {emulatorDir}\content\{titleId}00000001円\
+class XeniaSaveStrategy extends SaveStrategy {
+ final DirectoryService _directoryService;
+
+ XeniaSaveStrategy(this._directoryService);
+
+ @override
+ String get strategyId => 'xenia_canary';
+
+ /// Xbox 360 title IDs are 8 hex characters e.g. 4D5307E6
+ String? _extractTitleId(Game game) {
+ final name = game.fsName ?? game.fileName ?? game.name;
+ final match = RegExp(r'[0-9A-Fa-f]{8}').firstMatch(name);
+ return match?.group(0)?.toUpperCase();
+ }
+
+ Future<String?> _getContentDir() async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ 'xenia_canary', 'xenia_canary.exe');
+ if (exePath == null) return null;
+ return '${File(exePath).parent.path}\\content';
+ }
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final contentDir = await _getContentDir();
+ if (contentDir == null) return null;
+ final titleId = _extractTitleId(game);
+ if (titleId == null) return null;
+ return '$contentDir\\$titleId\00000001円';
+ }
+
+ @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 [];
+
+ bool hasFiles = false;
+ await for (final entity in dir.list(recursive: true)) {
+ if (entity is! File) continue;
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ hasFiles = true;
+ break;
+ }
+ if (!hasFiles) return [];
+
+ // Package entire save folder as zip
+ final zipPath = '${dir.path}.saves.zip';
+ final encoder = ZipFileEncoder();
+ encoder.create(zipPath);
+ await encoder.addDirectory(dir);
+ encoder.close();
+
+ debugPrint('[XeniaSaveStrategy] packaged saves to $zipPath');
+ return [File(zipPath)];
+ }
+
+ @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;
+
+ await Directory(saveDir).create(recursive: true);
+
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ final entryPath = '$saveDir\\${entry.name}';
+ if (entry.isFile) {
+ await backupSave(entryPath);
+ final outFile = File(entryPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ } else {
+ await Directory(entryPath).create(recursive: true);
+ }
+ }
+ debugPrint('[XeniaSaveStrategy] extracted $filename to $saveDir');
+ return true;
+ }
+
+ final targetPath = '$saveDir\\$filename';
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ debugPrint('[XeniaSaveStrategy] restored $filename to $targetPath');
+ return true;
+ } catch (e) {
+ debugPrint('[XeniaSaveStrategy] restoreSave error: $e');
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart
index 303cfc9..2ee7855 100644
--- a/lib/core/storage/directory_service.dart
+++ b/lib/core/storage/directory_service.dart
@@ -1,4 +1,6 @@
 import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:freegosy/core/romm/romm_models.dart';
 
@@ -95,7 +97,11 @@ class DirectoryService {
 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
+ // Windows games: return the folder itself so WindowsStrategy can find the exe
+ final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
+ if (isWindowsGame) return multiFileDir.path;
+
+ // Other platforms: find largest file inside — that's the main ROM
 File? largestFile;
 int largestSize = 0;
 await for (final entity in multiFileDir.list(recursive: true)) {
@@ -135,6 +141,40 @@ class DirectoryService {
 return null;
 }
 
+ Future<String?> resolveSevenZipPath() async {
+ String appData = '';
+
+ if (defaultTargetPlatform == TargetPlatform.windows) {
+ try {
+ final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false);
+ appData = result.stdout.toString().trim();
+ } catch (e) {
+ debugPrint('[DirectoryService] failed to get APPDATA: $e');
+ return null;
+ }
+ if (appData.isEmpty || appData.contains('%APPDATA%')) return null;
+ } else {
+ // macOS/Linux: no 7zr needed, system 7z is installable via package managers
+ // Return null for now — future: resolve via `which 7z`
+ return null;
+ }
+
+ final dest = File('$appData\\Freegosy\\thirdparty\7円zr.exe');
+
+ if (await dest.exists()) return dest.path;
+
+ try {
+ await dest.parent.create(recursive: true);
+ final byteData = await rootBundle.load('thirdparty/7zr.exe');
+ await dest.writeAsBytes(byteData.buffer.asUint8List());
+ debugPrint('[DirectoryService] 7zr.exe extracted to ${dest.path}');
+ return dest.path;
+ } catch (e) {
+ debugPrint('[DirectoryService] failed to extract 7zr.exe: $e');
+ return null;
+ }
+ }
+
 Future<String> getEmulatorDirectory(String emulatorId) async {
 final dirPath = '$emulatorsRootPath/$emulatorId';
 await _ensureDirectoryExists(dirPath);
diff --git a/lib/core/windows/pcgamingwiki_service.dart b/lib/core/windows/pcgamingwiki_service.dart
new file mode 100644
index 0000000..bb74ec2
--- /dev/null
+++ b/lib/core/windows/pcgamingwiki_service.dart
@@ -0,0 +1,202 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
+import 'dart:io';
+
+class PcGamingWikiService {
+ final Dio _dio;
+
+ PcGamingWikiService(this._dio);
+
+ static const String _apiUrl = 'https://www.pcgamingwiki.com/w/api.php';
+
+ /// Finds the PCGamingWiki page title for [gameTitle].
+ Future<String?> findPageTitle(String gameTitle) async {
+ try {
+ // Try exact match first
+ final exactResponse = await _dio.get(_apiUrl, queryParameters: {
+ 'action': 'query',
+ 'titles': gameTitle,
+ 'format': 'json',
+ });
+
+ if (exactResponse.statusCode == 200) {
+ final pages = exactResponse.data['query']['pages'] as Map;
+ for (final pageId in pages.keys) {
+ if (pageId != '-1') return pages[pageId]['title'] as String;
+ }
+ }
+
+ // Fall back to search
+ final searchResponse = await _dio.get(_apiUrl, queryParameters: {
+ 'action': 'query',
+ 'list': 'search',
+ 'srsearch': gameTitle,
+ 'format': 'json',
+ });
+
+ if (searchResponse.statusCode == 200) {
+ final results = searchResponse.data['query']['search'] as List;
+ if (results.isNotEmpty) return results.first['title'] as String;
+ }
+ } catch (e) {
+ debugPrint('[PcGamingWiki] findPageTitle error: $e');
+ }
+ return null;
+ }
+
+ /// Fetches the wikitext for [pageTitle].
+ Future<String?> getWikitext(String pageTitle) async {
+ try {
+ final response = await _dio.get(_apiUrl, queryParameters: {
+ 'action': 'parse',
+ 'page': pageTitle,
+ 'prop': 'wikitext',
+ 'format': 'json',
+ });
+ if (response.statusCode == 200) {
+ return response.data['parse']['wikitext']['*'] as String?;
+ }
+ } catch (e) {
+ debugPrint('[PcGamingWiki] getWikitext error: $e');
+ }
+ return null;
+ }
+
+ /// Returns expanded save locations for [gameTitle].
+ Future<List<Map<String, String>>> getSaveLocations(String gameTitle, {String gameDir = ''}) async {
+ try {
+ final pageTitle = await findPageTitle(gameTitle);
+ if (pageTitle == null) return [];
+
+ final wikitext = await getWikitext(pageTitle);
+ if (wikitext == null) return [];
+
+ return _parseSaveLocations(wikitext, gameTitle, gameDir);
+ } catch (e) {
+ debugPrint('[PcGamingWiki] getSaveLocations error: $e');
+ return [];
+ }
+ }
+
+ List<Map<String, String>> _parseSaveLocations(String wikitext, String gameTitle, String gameDir) {
+ final results = <Map<String, String>>[];
+ final seen = <String>{};
+
+ for (final line in wikitext.split('\n')) {
+ if (!line.contains('Game data/saves')) continue;
+ if (!line.contains('|Windows|')) continue;
+
+ try {
+ final after = line.split('|Windows|').last;
+ final cleaned = after.endsWith('}}') ? after.substring(0, after.length - 2) : after;
+ final paths = _safeSplitPaths(cleaned.trim());
+
+ for (final raw in paths) {
+ final trimmed = raw.trim();
+ if (trimmed.isEmpty) continue;
+
+ final lower = trimmed.toLowerCase();
+ if (_shouldSkipPath(lower)) continue;
+
+ final expanded = _expandWikiPath(trimmed, gameTitle, gameDir);
+ if (expanded == null) continue;
+
+ final normalizedLower = expanded.toLowerCase();
+ if (seen.contains(normalizedLower)) continue;
+ seen.add(normalizedLower);
+
+ results.add({
+ 'raw': trimmed,
+ 'path': expanded,
+ });
+ }
+ } catch (e) {
+ debugPrint('[PcGamingWiki] parse error on line: $e');
+ }
+ }
+ return results;
+ }
+
+ bool _shouldSkipPath(String lower) {
+ return lower.contains('steam') ||
+ lower.contains('linux') ||
+ lower.contains('wine') ||
+ lower.contains('{{p|uid}}') ||
+ lower.contains('{{p|hkcu}}') ||
+ lower.contains('{{p|osxhome}}') ||
+ lower.contains('{{p|xdg') ||
+ lower.contains('{{p|linux');
+ }
+
+ List<String> _safeSplitPaths(String s) {
+ final parts = <String>[];
+ int depth = 0;
+ final current = StringBuffer();
+ int i = 0;
+ while (i < s.length) {
+ if (i + 1 < s.length && s[i] == '{' && s[i + 1] == '{') {
+ depth++;
+ current.write('{{');
+ i += 2;
+ continue;
+ }
+ if (i + 1 < s.length && s[i] == '}' && s[i + 1] == '}') {
+ depth--;
+ current.write('}}');
+ i += 2;
+ continue;
+ }
+ if (s[i] == '|' && depth == 0) {
+ parts.add(current.toString().trim());
+ current.clear();
+ i++;
+ continue;
+ }
+ current.write(s[i]);
+ i++;
+ }
+ if (current.isNotEmpty) parts.add(current.toString().trim());
+ return parts.where((p) => p.isNotEmpty).toList();
+ }
+
+ String? _expandWikiPath(String path, String gameTitle, String gameDir) {
+ final appData = Platform.environment['APPDATA'] ?? '';
+ final localAppData = Platform.environment['LOCALAPPDATA'] ?? '';
+ final userProfile = Platform.environment['USERPROFILE'] ?? '';
+ final programData = Platform.environment['PROGRAMDATA'] ?? '';
+ final public = Platform.environment['PUBLIC'] ?? '';
+
+ final subs = <String, String>{
+ '{{p|appdata}}': appData,
+ '{{p|localappdata}}': localAppData,
+ '{{p|userprofile}}': userProfile,
+ '{{p|programdata}}': programData,
+ '{{p|public}}': public,
+ '{{p|game}}': gameDir.isNotEmpty ? '$gameDir/$gameTitle' : '',
+ };
+
+ String expanded = path;
+ for (final entry in subs.entries) {
+ if (entry.value.isEmpty && expanded.toLowerCase().contains(entry.key)) {
+ return null;
+ }
+ expanded = expanded.replaceAll(
+ RegExp(RegExp.escape(entry.key), caseSensitive: false),
+ entry.value,
+ );
+ }
+
+ // If any unresolved templates remain, skip
+ if (expanded.toLowerCase().contains('{{p|')) return null;
+
+ // Strip wildcard filenames e.g. \*.dat
+ expanded = expanded.replaceAll(RegExp(r'[/\\]\*\.[a-zA-Z0-9]+$'), '');
+
+ // Strip bare filenames with extension at end
+ if (RegExp(r'\.[a-zA-Z0-9]{2,4}$').hasMatch(expanded)) {
+ expanded = File(expanded).parent.path;
+ }
+
+ return expanded.replaceAll('/', '\\');
+ }
+}
\ No newline at end of file
diff --git a/lib/core/windows/windows_game_service.dart b/lib/core/windows/windows_game_service.dart
new file mode 100644
index 0000000..db76fb2
--- /dev/null
+++ b/lib/core/windows/windows_game_service.dart
@@ -0,0 +1,73 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:path/path.dart' as p;
+
+class WindowsGameService {
+ /// Finds the main executable in [gameDir].
+ /// First tries to match [hint] (game name), then falls back to largest .exe.
+ Future<String?> findExecutable(String gameDir, {String? hint}) async {
+ final dir = Directory(gameDir);
+ if (!await dir.exists()) return null;
+
+ final exeFiles = <File>[];
+ await for (final entity in dir.list(recursive: true)) {
+ if (entity is File && entity.path.toLowerCase().endsWith('.exe')) {
+ // Skip known non-game executables
+ final name = p.basename(entity.path).toLowerCase();
+ if (_shouldSkipExe(name)) continue;
+ exeFiles.add(entity);
+ }
+ }
+
+ if (exeFiles.isEmpty) return null;
+
+ // Try hint match first (game name similarity)
+ if (hint != null) {
+ final hintLower = hint.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
+ for (final exe in exeFiles) {
+ final exeName = p.basenameWithoutExtension(exe.path)
+ .toLowerCase()
+ .replaceAll(RegExp(r'[^a-z0-9]'), '');
+ if (exeName.contains(hintLower) || hintLower.contains(exeName)) {
+ debugPrint('[WindowsGameService] matched exe by hint: ${exe.path}');
+ return exe.path;
+ }
+ }
+ }
+
+ // Fall back to largest exe
+ File? largest;
+ int largestSize = 0;
+ for (final exe in exeFiles) {
+ final size = await exe.length();
+ if (size > largestSize) {
+ largestSize = size;
+ largest = exe;
+ }
+ }
+
+ debugPrint('[WindowsGameService] largest exe: ${largest?.path}');
+ return largest?.path;
+ }
+
+ bool _shouldSkipExe(String name) {
+ const skipList = [
+ 'uninstall', 'uninst', 'setup', 'install', 'redist',
+ 'vc_redist', 'vcredist', 'directx', 'dxsetup',
+ 'dotnet', 'crashreport', 'crashhandler', 'bugsplat',
+ 'upc', 'easyanticheat', 'battleye', 'launcher_helper',
+ ];
+ return skipList.any((s) => name.contains(s));
+ }
+
+ /// Launches the exe at [exePath].
+ Future<void> launch(String exePath) async {
+ final dir = File(exePath).parent.path;
+ await Process.start(
+ exePath,
+ [],
+ workingDirectory: dir,
+ mode: ProcessStartMode.detached,
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 6c14d0c..989865d 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -6,6 +6,8 @@ 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';
+import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
+
 
 // Provider for loading RomMConfig (including stored Bearer token)
 final rommConfigProvider = FutureProvider<RomMConfig>((ref) async {
@@ -44,7 +46,13 @@ final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) {
 final directoryService = ref.watch(directoryServiceProvider).value;
 if (directoryService != null) {
 try {
- return StrategyRegistry(directoryService);
+ final registry = StrategyRegistry(directoryService);
+ // Load persisted Windows exe overrides
+ final winStrategy = registry.getStrategyForSlug('windows');
+ if (winStrategy is WindowsStrategy) {
+ winStrategy.loadPersistedOverrides();
+ }
+ return registry;
 } catch (e) {
 return null;
 }
@@ -57,7 +65,10 @@ 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);
+ final service = SaveSyncService(rommService, directoryService);
+ // Load persisted Windows save path overrides
+ service.windowsSaveStrategy.loadPersistedOverrides();
+ return service;
 });
 
 // Simplified RommService provider
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 1888a83..25155c5 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -6,6 +6,8 @@ import '../../providers/romm_provider.dart';
 import '../../core/romm/romm_models.dart';
 import '../widgets/game_card.dart';
 import '../widgets/platform_filter_bar.dart';
+import '../widgets/windows_game_config_dialog.dart';
+import '../../core/emulator/strategies/windows_strategy.dart';
 import 'dart:convert';
 
 class LibraryScreen extends ConsumerWidget {
@@ -124,17 +126,52 @@ class LibraryScreen extends ConsumerWidget {
 await strategy.launch(game, existingRomPath);
 } catch (e) {
 if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Launch failed: $e')),
- );
+ final isWindows = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
+ final isMissingExe = e.toString().contains('No executable') || e.toString().contains('not found');
+ if (isWindows && isMissingExe) {
+ await _handleWindowsConfig(context, ref, game);
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Launch failed: $e'),
+ duration: const Duration(seconds: 8),
+ ),
+ );
+ }
 }
 }
 
+ Future<void> _handleWindowsConfig(BuildContext context, WidgetRef ref, Game game) async {
+ final registry = ref.read(strategyRegistryProvider);
+ final windowsStrategy = registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?;
+ final syncService = ref.read(saveSyncServiceProvider);
+
+ final result = await showDialog<Map<String, String>>(
+ context: context,
+ builder: (ctx) => WindowsGameConfigDialog(
+ game: game,
+ currentExePath: windowsStrategy?.getExeOverride(game.id),
+ currentSavePath: syncService?.windowsSaveStrategy.getManualOverride(game.id),
+ ),
+ );
+ if (result == null) return; // user cancelled
+
+ final exe = result['exe'] ?? '';
+ final save = result['save'] ?? '';
+
+ if (exe.isNotEmpty) await windowsStrategy?.setExeOverride(game.id, exe);
+ if (save.isNotEmpty) await syncService?.windowsSaveStrategy.setManualOverride(game.id, save);
+
+ if (!context.mounted) return;
+ await _handleLaunch(context, ref, game);
+ }
+
 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')),
+ const SnackBar(
+ content: Text('Save sync not available')),
 );
 return;
 }
@@ -276,23 +313,30 @@ class LibraryScreen extends ConsumerWidget {
 itemBuilder: (context, index) {
 final game = filteredGames[index];
 final dirService = directoryServiceAsync.asData?.value;
+ final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
 if (dirService == null) {
- return GameCard(
- game: game,
- onDownload: () => _startDownload(context, ref, game),
- onLaunch: () => _handleLaunch(context, ref, game),
- onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ return GestureDetector(
+ onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
+ child: GameCard(
+ game: game,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ ),
 );
 }
 return FutureBuilder<bool>(
 future: dirService.isRomDownloaded(game),
 builder: (context, snapshot) {
- return GameCard(
- game: game,
- isDownloaded: snapshot.data ?? false,
- onDownload: () => _startDownload(context, ref, game),
- onLaunch: () => _handleLaunch(context, ref, game),
- onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ return GestureDetector(
+ onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
+ child: GameCard(
+ game: game,
+ isDownloaded: snapshot.data ?? false,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ ),
 );
 },
 );
diff --git a/lib/ui/widgets/windows_game_config_dialog.dart b/lib/ui/widgets/windows_game_config_dialog.dart
new file mode 100644
index 0000000..e2863f8
--- /dev/null
+++ b/lib/ui/widgets/windows_game_config_dialog.dart
@@ -0,0 +1,137 @@
+import 'package:flutter/material.dart';
+import 'package:file_picker/file_picker.dart';
+import '../../core/romm/romm_models.dart';
+
+class WindowsGameConfigDialog extends StatefulWidget {
+ final Game game;
+ final String? currentExePath;
+ final String? currentSavePath;
+
+ const WindowsGameConfigDialog({
+ super.key,
+ required this.game,
+ this.currentExePath,
+ this.currentSavePath,
+ });
+
+ @override
+ State<WindowsGameConfigDialog> createState() => _WindowsGameConfigDialogState();
+}
+
+class _WindowsGameConfigDialogState extends State<WindowsGameConfigDialog> {
+ late TextEditingController _exeController;
+ late TextEditingController _saveController;
+
+ @override
+ void initState() {
+ super.initState();
+ _exeController = TextEditingController(text: widget.currentExePath ?? '');
+ _saveController = TextEditingController(text: widget.currentSavePath ?? '');
+ }
+
+ @override
+ void dispose() {
+ _exeController.dispose();
+ _saveController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: Text('Configure ${widget.game.name}'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Executable (.exe)',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _exeController,
+ decoration: const InputDecoration(
+ hintText: 'Auto-detect or browse...',
+ border: OutlineInputBorder(),
+ isDense: true,
+ ),
+ style: const TextStyle(fontSize: 12),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ElevatedButton(
+ onPressed: () async {
+ final result = await FilePicker.platform.pickFiles(
+ type: FileType.custom,
+ allowedExtensions: ['exe'],
+ );
+ if (result != null && result.files.single.path != null) {
+ setState(() {
+ _exeController.text = result.files.single.path!;
+ });
+ }
+ },
+ child: const Text('Browse'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ 'Save Directory',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 4),
+ const Text(
+ 'Leave empty to use PCGamingWiki auto-detection',
+ style: TextStyle(fontSize: 11, color: Colors.grey),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _saveController,
+ decoration: const InputDecoration(
+ hintText: 'Auto-detect or browse...',
+ border: OutlineInputBorder(),
+ isDense: true,
+ ),
+ style: const TextStyle(fontSize: 12),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ElevatedButton(
+ onPressed: () async {
+ final path = await FilePicker.platform.getDirectoryPath();
+ if (path != null) {
+ setState(() {
+ _saveController.text = path;
+ });
+ }
+ },
+ child: const Text('Browse'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(null),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.of(context).pop({
+ 'exe': _exeController.text.trim(),
+ 'save': _saveController.text.trim(),
+ }),
+ child: const Text('Save'),
+ ),
+ ],
+ );
+ }
+}
\ No newline at end of file
diff --git a/pubspec.yaml b/pubspec.yaml
index 8f7eef8..ebbfe54 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -59,7 +59,8 @@ dev_dependencies:
 
 # The following section is specific to Flutter packages.
 flutter:
-
+ assets:
+ - thirdparty/7zr.exe
 # The following line ensures that the Material Icons font is
 # included with your application, so that you can use the icons in
 # the material Icons class.
diff --git a/thirdparty/7zr.exe b/thirdparty/7zr.exe
new file mode 100644
index 0000000..2934156
Binary files /dev/null and b/thirdparty/7zr.exe differ

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /