Skip to content

commit 9728917

abduznik edited this page May 23, 2026 · 1 revision

feat: implement experimental Custom Emulator support with flexible save sync (v0.4.0)

Commit: 97289171ba125b9f5d5b2d1d466b6072c8f1b835

Author: abduznik

Date: 2026-04-18

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

Files Changed

lib/core/emulator/custom_emulator_config.dart      |  46 +++++
 .../strategies/custom_emulator_strategy.dart       |  71 ++++++++
 lib/core/emulator/strategy_registry.dart           |   7 +-
 lib/providers/custom_emulators_provider.dart       |  45 +++++
 lib/providers/romm_provider.dart                   |   5 +-
 .../screens/settings_custom_emulators_section.dart | 195 +++++++++++++++++++++
 lib/ui/screens/settings_screen.dart                |   3 +
 pubspec.lock                                       |   2 +-
 pubspec.yaml                                       |   4 +-
 9 files changed, 374 insertions(+), 4 deletions(-)
  • lib/core/emulator/custom_emulator_config.dart
  • lib/core/emulator/strategies/custom_emulator_strategy.dart
  • lib/core/emulator/strategy_registry.dart
  • lib/providers/custom_emulators_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/settings_custom_emulators_section.dart
  • lib/ui/screens/settings_screen.dart
  • pubspec.lock
  • pubspec.yaml

Diff

diff --git a/lib/core/emulator/custom_emulator_config.dart b/lib/core/emulator/custom_emulator_config.dart
new file mode 100644
index 0000000..4f65670
--- /dev/null
+++ b/lib/core/emulator/custom_emulator_config.dart
@@ -0,0 +1,46 @@
+import 'dart:convert';
+
+enum CustomSaveMethod {
+  file,
+  folder,
+}
+
+class CustomEmulatorConfig {
+  final String id;
+  final String name;
+  final List<String> platforms;
+  final String executablePath;
+  final CustomSaveMethod saveMethod;
+  final String savePath;
+  final String? savePattern; // e.g. "*.srm" for file-based
+
+  CustomEmulatorConfig({
+    required this.id,
+    required this.name,
+    required this.platforms,
+    required this.executablePath,
+    required this.saveMethod,
+    required this.savePath,
+    this.savePattern,
+  });
+
+  Map<String, dynamic> toJson() => {
+    'id': id,
+    'name': name,
+    'platforms': platforms,
+    'executablePath': executablePath,
+    'saveMethod': saveMethod.name,
+    'savePath': savePath,
+    'savePattern': savePattern,
+  };
+
+  factory CustomEmulatorConfig.fromJson(Map<String, dynamic> json) => CustomEmulatorConfig(
+    id: json['id'],
+    name: json['name'],
+    platforms: List<String>.from(json['platforms']),
+    executablePath: json['executablePath'],
+    saveMethod: CustomSaveMethod.values.byName(json['saveMethod']),
+    savePath: json['savePath'],
+    savePattern: json['savePattern'],
+  );
+}
diff --git a/lib/core/emulator/strategies/custom_emulator_strategy.dart b/lib/core/emulator/strategies/custom_emulator_strategy.dart
new file mode 100644
index 0000000..b7cdf1d
--- /dev/null
+++ b/lib/core/emulator/strategies/custom_emulator_strategy.dart
@@ -0,0 +1,71 @@
+import 'dart:io' as io;
+import 'package:path/path.dart' as p;
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+import '../emulator_strategy.dart';
+import '../custom_emulator_config.dart';
+
+class CustomEmulatorStrategy extends EmulatorStrategy {
+  final CustomEmulatorConfig config;
+  @override
+  final DirectoryService directoryService;
+
+  CustomEmulatorStrategy(this.config, this.directoryService);
+
+  @override
+  String get name => config.name;
+
+  @override
+  String get emulatorId => config.id;
+
+  @override
+  List<String> get supportedSlugs => config.platforms;
+
+  @override
+  String get windowsExecutable => config.executablePath;
+
+  @override
+  String get linuxExecutable => config.executablePath;
+
+  @override
+  bool get supportsSaveSync => true;
+
+  @override
+  Future<String?> findExecutable() async {
+    // For custom emulators, the user provides the absolute path.
+    if (await io.File(config.executablePath).exists()) {
+      return config.executablePath;
+    }
+    return null;
+  }
+
+  @override
+  String resolveSavePath(Game game) {
+    if (config.saveMethod == CustomSaveMethod.file) {
+      final pattern = config.savePattern ?? '';
+      if (pattern.contains('*')) {
+        final ext = pattern.replaceAll('*', '');
+        return p.join(config.savePath, '${game.displayName}$ext');
+      } else if (pattern.isNotEmpty) {
+        return p.join(config.savePath, pattern);
+      } else {
+        // Fallback: just game name
+        return p.join(config.savePath, game.displayName);
+      }
+    } else {
+      // Folder based
+      return p.join(config.savePath, game.displayName);
+    }
+  }
+
+  @override
+  Future<void> launch(Game game, String romPath) async {
+    final exePath = await findExecutable();
+    if (exePath == null) throw Exception('Custom emulator executable not found at: ${config.executablePath}');
+
+    final normalizedRomPath = p.absolute(p.normalize(romPath));
+    
+    // We use a raw process start because custom emulators might not be in the standard EmuDeck structure
+    await io.Process.run(exePath, [normalizedRomPath], runInShell: true);
+  }
+}
diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart
index a9cb600..4976dfd 100644
--- a/lib/core/emulator/strategy_registry.dart
+++ b/lib/core/emulator/strategy_registry.dart
@@ -20,13 +20,17 @@ 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';
+import 'package:freegosy/core/emulator/custom_emulator_config.dart';
+import 'package:freegosy/core/emulator/strategies/custom_emulator_strategy.dart';
 
 class StrategyRegistry {
   final DirectoryService _directoryService;
   late final List<EmulatorStrategy> _strategies;
+  final List<CustomEmulatorConfig> _customEmulatorConfigs;
   final Map<String, String> _slugPreferences = {};
 
-  StrategyRegistry(this._directoryService) {
+  StrategyRegistry(this._directoryService, {List<CustomEmulatorConfig> customEmulators = const []}) 
+    : _customEmulatorConfigs = customEmulators {
     final List<EmulatorStrategy> allPossibleStrategies = [
       RetroArchStrategy(_directoryService),
       DolphinStrategy(_directoryService),
@@ -44,6 +48,7 @@ class StrategyRegistry {
       XemuStrategy(_directoryService),
       XeniaStrategy(_directoryService),
       WindowsStrategy(_directoryService),
+      ..._customEmulatorConfigs.map((config) => CustomEmulatorStrategy(config, _directoryService)),
     ];
 
     _strategies = allPossibleStrategies.where((strategy) {
diff --git a/lib/providers/custom_emulators_provider.dart b/lib/providers/custom_emulators_provider.dart
new file mode 100644
index 0000000..6a70047
--- /dev/null
+++ b/lib/providers/custom_emulators_provider.dart
@@ -0,0 +1,45 @@
+import 'dart:convert';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../core/emulator/custom_emulator_config.dart';
+
+class CustomEmulatorsNotifier extends StateNotifier<List<CustomEmulatorConfig>> {
+  static const String _storageKey = 'custom_emulators_config';
+
+  CustomEmulatorsNotifier() : super([]) {
+    _load();
+  }
+
+  Future<void> _load() async {
+    final prefs = await SharedPreferences.getInstance();
+    final jsonStr = prefs.getString(_storageKey);
+    if (jsonStr != null) {
+      try {
+        final List<dynamic> list = json.decode(jsonStr);
+        state = list.map((item) => CustomEmulatorConfig.fromJson(item)).toList();
+      } catch (e) {
+        state = [];
+      }
+    }
+  }
+
+  Future<void> _save() async {
+    final prefs = await SharedPreferences.getInstance();
+    final jsonStr = json.encode(state.map((e) => e.toJson()).toList());
+    await prefs.setString(_storageKey, jsonStr);
+  }
+
+  Future<void> addEmulator(CustomEmulatorConfig config) async {
+    state = [...state, config];
+    await _save();
+  }
+
+  Future<void> removeEmulator(String id) async {
+    state = state.where((e) => e.id != id).toList();
+    await _save();
+  }
+}
+
+final customEmulatorsProvider = StateNotifierProvider<CustomEmulatorsNotifier, List<CustomEmulatorConfig>>((ref) {
+  return CustomEmulatorsNotifier();
+});
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index f26fbdb..699dff4 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -13,6 +13,7 @@ import 'package:freegosy/core/storage/download_cache_service.dart';
 import 'package:freegosy/core/storage/metadata_cache_service.dart';
 import 'package:freegosy/core/storage/rom_mapping_service.dart';
 import 'package:freegosy/core/romm/rom_scanner_service.dart';
+import 'package:freegosy/providers/custom_emulators_provider.dart';
 
 import 'package:freegosy/core/emulator/firmware_service.dart';
 
@@ -107,9 +108,11 @@ final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async {
 // Provider for StrategyRegistry
 final strategyRegistryProvider = FutureProvider<StrategyRegistry?>((ref) async {
   final directoryService = ref.watch(directoryServiceProvider).value;
+  final customEmulators = ref.watch(customEmulatorsProvider);
+  
   if (directoryService != null) {
     try {
-      final registry = StrategyRegistry(directoryService);
+      final registry = StrategyRegistry(directoryService, customEmulators: customEmulators);
       await registry.loadPreferences(); // Await preferences loading
       // Load persisted Windows exe overrides
       final winStrategy = registry.getStrategyForSlug('windows');
diff --git a/lib/ui/screens/settings_custom_emulators_section.dart b/lib/ui/screens/settings_custom_emulators_section.dart
new file mode 100644
index 0000000..26a2a72
--- /dev/null
+++ b/lib/ui/screens/settings_custom_emulators_section.dart
@@ -0,0 +1,195 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:uuid/uuid.dart';
+import '../../core/emulator/custom_emulator_config.dart';
+import '../../providers/custom_emulators_provider.dart';
+
+class SettingsCustomEmulatorsSection extends ConsumerWidget {
+  const SettingsCustomEmulatorsSection({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final customEmulators = ref.watch(customEmulatorsProvider);
+    final colorScheme = Theme.of(context).colorScheme;
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          children: [
+            const Text('Custom Emulators', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+            const SizedBox(width: 8),
+            Container(
+              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+              decoration: BoxDecoration(
+                color: Colors.orange.withValues(alpha: 0.2),
+                borderRadius: BorderRadius.circular(4),
+                border: Border.all(color: Colors.orange.withValues(alpha: 0.5)),
+              ),
+              child: const Text(
+                'EXPERIMENTAL',
+                style: TextStyle(color: Colors.orange, fontSize: 10, fontWeight: FontWeight.bold),
+              ),
+            ),
+          ],
+        ),
+        const SizedBox(height: 8),
+        const Text(
+          'Add your own emulators. Use comma-separated platform names (e.g. ps1,ps2).',
+          style: TextStyle(color: Colors.grey, fontSize: 13),
+        ),
+        const SizedBox(height: 16),
+        if (customEmulators.isEmpty)
+          const Center(
+            child: Padding(
+              padding: EdgeInsets.all(16.0),
+              child: Text('No custom emulators added yet.', style: TextStyle(color: Colors.grey)),
+            ),
+          ),
+        ...customEmulators.map((emu) => Card(
+              margin: const EdgeInsets.only(bottom: 8),
+              child: ListTile(
+                title: Text(emu.name),
+                subtitle: Text('${emu.platforms.join(", ")}\n${emu.executablePath}'),
+                trailing: IconButton(
+                  icon: const Icon(Icons.delete_outline, color: Colors.red),
+                  onPressed: () => ref.read(customEmulatorsProvider.notifier).removeEmulator(emu.id),
+                ),
+                isThreeLine: true,
+              ),
+            )),
+        const SizedBox(height: 8),
+        SizedBox(
+          width: double.infinity,
+          child: ElevatedButton.icon(
+            onPressed: () => _showAddEmulatorDialog(context, ref),
+            icon: const Icon(Icons.add),
+            label: const Text('Add Custom Emulator'),
+          ),
+        ),
+      ],
+    );
+  }
+
+  void _showAddEmulatorDialog(BuildContext context, WidgetRef ref) {
+    final nameController = TextEditingController();
+    final platformsController = TextEditingController();
+    final exeController = TextEditingController();
+    final savePathController = TextEditingController();
+    final patternController = TextEditingController();
+    CustomSaveMethod saveMethod = CustomSaveMethod.file;
+
+    showDialog(
+      context: context,
+      builder: (context) => StatefulBuilder(
+        builder: (context, setDialogState) => AlertDialog(
+          title: const Text('Add Custom Emulator'),
+          content: SingleChildScrollView(
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                TextField(
+                  controller: nameController,
+                  decoration: const InputDecoration(labelText: 'Emulator Name (e.g. PCSX2 Nightly)'),
+                ),
+                TextField(
+                  controller: platformsController,
+                  decoration: const InputDecoration(labelText: 'Platform Slugs (e.g. ps1,ps2)'),
+                ),
+                const SizedBox(height: 16),
+                Row(
+                  children: [
+                    Expanded(
+                      child: TextField(
+                        controller: exeController,
+                        decoration: const InputDecoration(labelText: 'Executable Path'),
+                        readOnly: true,
+                      ),
+                    ),
+                    IconButton(
+                      icon: const Icon(Icons.folder_open),
+                      onPressed: () async {
+                        FilePickerResult? result = await FilePicker.platform.pickFiles();
+                        if (result != null) {
+                          exeController.text = result.files.single.path ?? '';
+                        }
+                      },
+                    ),
+                  ],
+                ),
+                const SizedBox(height: 16),
+                const Divider(),
+                const Text('Save Sync Settings', style: TextStyle(fontWeight: FontWeight.bold)),
+                const SizedBox(height: 8),
+                DropdownButtonFormField<CustomSaveMethod>(
+                  value: saveMethod,
+                  items: const [
+                    DropdownMenuItem(value: CustomSaveMethod.file, child: Text('File Based')),
+                    DropdownMenuItem(value: CustomSaveMethod.folder, child: Text('Folder Based')),
+                  ],
+                  onChanged: (val) => setDialogState(() => saveMethod = val!),
+                  decoration: const InputDecoration(labelText: 'Save Method'),
+                ),
+                const SizedBox(height: 16),
+                Row(
+                  children: [
+                    Expanded(
+                      child: TextField(
+                        controller: savePathController,
+                        decoration: const InputDecoration(labelText: 'Save Directory'),
+                        readOnly: true,
+                      ),
+                    ),
+                    IconButton(
+                      icon: const Icon(Icons.folder_open),
+                      onPressed: () async {
+                        String? path = await FilePicker.platform.getDirectoryPath();
+                        if (path != null) {
+                          savePathController.text = path;
+                        }
+                      },
+                    ),
+                  ],
+                ),
+                if (saveMethod == CustomSaveMethod.file)
+                  TextField(
+                    controller: patternController,
+                    decoration: const InputDecoration(
+                      labelText: 'Save Pattern (e.g. *.srm or specific name)',
+                      hintText: '*.srm',
+                    ),
+                  ),
+              ],
+            ),
+          ),
+          actions: [
+            TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+            ElevatedButton(
+              onPressed: () {
+                if (nameController.text.isEmpty || exeController.text.isEmpty || platformsController.text.isEmpty) {
+                  return;
+                }
+                final platforms = platformsController.text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
+                
+                final config = CustomEmulatorConfig(
+                  id: const Uuid().v4(),
+                  name: nameController.text.trim(),
+                  platforms: platforms,
+                  executablePath: exeController.text.trim(),
+                  saveMethod: saveMethod,
+                  savePath: savePathController.text.trim(),
+                  savePattern: patternController.text.trim().isEmpty ? null : patternController.text.trim(),
+                );
+
+                ref.read(customEmulatorsProvider.notifier).addEmulator(config);
+                Navigator.pop(context);
+              },
+              child: const Text('Add'),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 0802244..3914fe1 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -16,6 +16,7 @@ import '../../core/romm/romm_service.dart';
 import '../../core/romm/romm_models.dart';
 import 'settings_emulators_section.dart';
 import 'settings_display_section.dart';
+import 'settings_custom_emulators_section.dart';
 
 class SettingsScreen extends ConsumerStatefulWidget {
   const SettingsScreen({super.key});
@@ -155,6 +156,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
                       ),
                       error: (e, s) => Center(child: Text('Error loading emulators: $e')),
                     ),
+                    const SizedBox(height: 24),
+                    const SettingsCustomEmulatorsSection(),
                     if (strategyRegistry != null) ...[
                       const SizedBox(height: 24),
                       // Call the extracted conflicts section function
diff --git a/pubspec.lock b/pubspec.lock
index 8b2297d..4b7a492 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1086,7 +1086,7 @@ packages:
     source: hosted
     version: "3.1.5"
   uuid:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: uuid
       sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
diff --git a/pubspec.yaml b/pubspec.yaml
index 8272c4b..28e5a4c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 # In Windows, build-name is used as the major, minor, and patch parts
 # of the product and file versions while build-number is used as the build suffix.
-version: 0.3.2+1
+version: 0.4.0+1
+
 
 environment:
   sdk: ^3.11.1
@@ -51,6 +52,7 @@ dependencies:
   hive: ^2.2.3
   hive_flutter: ^1.1.0
   synchronized: ^3.1.0+1
+  uuid: ^4.5.1
 
 dev_dependencies:
   flutter_test:

Clone this wiki locally