-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit f5ce6a1
abduznik edited this page May 23, 2026
·
1 revision
Commit: f5ce6a18533e47a9c1f03ee59041f500320ca6e6
Author: abduznik
Date: 2026-03-24
-
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.
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.mdagent.mdlib/core/downloader/download_service.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/github_release_service.dartlib/core/emulator/strategies/azahar_strategy.dartlib/core/emulator/strategies/cemu_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/pcsx2_strategy.dartlib/core/emulator/strategies/rpcs3_strategy.dartlib/core/emulator/strategies/windows_strategy.dartlib/core/emulator/strategies/xemu_strategy.dartlib/core/emulator/strategies/xenia_strategy.dartlib/core/emulator/strategy_registry.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/rpcs3_save_strategy.dartlib/core/save/strategies/windows_save_strategy.dartlib/core/save/strategies/xenia_save_strategy.dartlib/core/storage/directory_service.dartlib/core/windows/pcgamingwiki_service.dartlib/core/windows/windows_game_service.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/widgets/windows_game_config_dialog.dartpubspec.yamlthirdparty/7zr.exe
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\home\00000001\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\home\00000001\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\\7zr.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