gemba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,621 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'set'
6
+ require 'teek/platform'
7
+
8
+ module Gemba
9
+ # Persists mGBA Player settings to a JSON file in the platform-appropriate
10
+ # config directory.
11
+ #
12
+ # Config file location:
13
+ # macOS: ~/Library/Application Support/gemba/settings.json
14
+ # Linux: $XDG_CONFIG_HOME/gemba/settings.json (~/.config/gemba/)
15
+ # Windows: %APPDATA%/gemba/settings.json
16
+ #
17
+ # Gamepad mappings are keyed by SDL GUID (identifies controller model/type),
18
+ # so different controller types keep separate configs.
19
+ #
20
+ # Per-game settings: when enabled, a subset of settings (video, audio,
21
+ # save state) can be overridden per ROM. Game-specific files are stored
22
+ # under config_dir/games/<rom_id>/settings.json. The PerGameProxy class
23
+ # transparently routes reads/writes so callers don't need conditionals.
24
+ class Config
25
+ APP_NAME = 'gemba'
26
+ FILENAME = 'settings.json'
27
+
28
+ GLOBAL_DEFAULTS = {
29
+ 'scale' => 3,
30
+ 'volume' => 100,
31
+ 'muted' => false,
32
+ 'turbo_speed' => 2,
33
+ 'turbo_volume_pct' => 25,
34
+ 'keep_aspect_ratio' => true,
35
+ 'show_fps' => true,
36
+ 'toast_duration' => 1.5,
37
+ 'save_state_debounce' => 3.0,
38
+ 'quick_save_slot' => 1,
39
+ 'save_state_backup' => true,
40
+ 'locale' => 'auto',
41
+ 'pixel_filter' => 'nearest',
42
+ 'integer_scale' => false,
43
+ 'color_correction' => false,
44
+ 'frame_blending' => false,
45
+ 'rewind_enabled' => true,
46
+ 'rewind_seconds' => 10,
47
+ 'per_game_settings' => false,
48
+ 'tip_dismiss_ms' => 4000,
49
+ 'recording_compression' => 1,
50
+ 'pause_on_focus_loss' => true,
51
+ }.freeze
52
+
53
+ # Settings that can be overridden per ROM. Maps config key → locale key.
54
+ # This is the single source of truth for which keys are per-game eligible.
55
+ PER_GAME_SETTINGS = {
56
+ 'scale' => 'settings.window_scale',
57
+ 'pixel_filter' => 'settings.pixel_filter',
58
+ 'integer_scale' => 'settings.integer_scale',
59
+ 'color_correction' => 'settings.color_correction',
60
+ 'frame_blending' => 'settings.frame_blending',
61
+ 'volume' => 'settings.volume',
62
+ 'muted' => 'settings.mute',
63
+ 'turbo_speed' => 'settings.turbo_speed',
64
+ 'quick_save_slot' => 'settings.quick_save_slot',
65
+ 'save_state_backup' => 'settings.keep_backup',
66
+ }.freeze
67
+
68
+ PER_GAME_KEYS = PER_GAME_SETTINGS.keys.to_set.freeze
69
+
70
+ # Transparent proxy that routes per-game keys to a game-specific hash
71
+ # and everything else to the base (global) hash. Config getters/setters
72
+ # call global['key'] — this intercepts those calls so no other code
73
+ # needs to know whether per-game settings are active.
74
+ class PerGameProxy
75
+ def initialize(base, game_data, per_game_keys)
76
+ @base = base
77
+ @game_data = game_data
78
+ @per_game_keys = per_game_keys
79
+ end
80
+
81
+ def [](key)
82
+ if @per_game_keys.include?(key) && @game_data.key?(key)
83
+ @game_data[key]
84
+ else
85
+ @base[key]
86
+ end
87
+ end
88
+
89
+ def []=(key, val)
90
+ if @per_game_keys.include?(key)
91
+ @game_data[key] = val
92
+ else
93
+ @base[key] = val
94
+ end
95
+ end
96
+ end
97
+
98
+ GAMEPAD_DEFAULTS = {
99
+ 'dead_zone' => 25,
100
+ 'mappings' => {
101
+ 'a' => 'a', 'b' => 'b',
102
+ 'l' => 'left_shoulder', 'r' => 'right_shoulder',
103
+ 'up' => 'dpad_up', 'down' => 'dpad_down',
104
+ 'left' => 'dpad_left', 'right' => 'dpad_right',
105
+ 'start' => 'start', 'select' => 'back',
106
+ },
107
+ }.freeze
108
+
109
+ # Sentinel GUID for keyboard bindings — stored alongside real gamepad GUIDs.
110
+ KEYBOARD_GUID = 'keyboard'
111
+
112
+ MAX_RECENT_ROMS = 5
113
+
114
+ KEYBOARD_DEFAULTS = {
115
+ 'dead_zone' => 0,
116
+ 'mappings' => {
117
+ 'a' => 'z', 'b' => 'x',
118
+ 'l' => 'a', 'r' => 's',
119
+ 'up' => 'Up', 'down' => 'Down',
120
+ 'left' => 'Left', 'right' => 'Right',
121
+ 'start' => 'Return', 'select' => 'BackSpace',
122
+ },
123
+ }.freeze
124
+
125
+ def initialize(path: nil)
126
+ @path = path || self.class.default_path
127
+ @data = load_file
128
+ end
129
+
130
+ # @return [String] path to the config file
131
+ attr_accessor :path
132
+
133
+ # -- Global settings ---------------------------------------------------
134
+
135
+ def scale
136
+ global['scale']
137
+ end
138
+
139
+ def scale=(val)
140
+ global['scale'] = val.to_i.clamp(1, 4)
141
+ end
142
+
143
+ def volume
144
+ global['volume']
145
+ end
146
+
147
+ def volume=(val)
148
+ global['volume'] = val.to_i.clamp(0, 100)
149
+ end
150
+
151
+ def muted?
152
+ global['muted']
153
+ end
154
+
155
+ def muted=(val)
156
+ global['muted'] = !!val
157
+ end
158
+
159
+ # @return [Integer] turbo speed multiplier (2, 3, 4, or 0 for uncapped)
160
+ def turbo_speed
161
+ global['turbo_speed']
162
+ end
163
+
164
+ def turbo_speed=(val)
165
+ global['turbo_speed'] = val.to_i
166
+ end
167
+
168
+ # @return [Integer] volume percentage during fast-forward (0-100, hidden setting)
169
+ def turbo_volume_pct
170
+ global['turbo_volume_pct']
171
+ end
172
+
173
+ def turbo_volume_pct=(val)
174
+ global['turbo_volume_pct'] = val.to_i.clamp(0, 100)
175
+ end
176
+
177
+ def keep_aspect_ratio?
178
+ global['keep_aspect_ratio']
179
+ end
180
+
181
+ def keep_aspect_ratio=(val)
182
+ global['keep_aspect_ratio'] = !!val
183
+ end
184
+
185
+ def show_fps?
186
+ global['show_fps']
187
+ end
188
+
189
+ def show_fps=(val)
190
+ global['show_fps'] = !!val
191
+ end
192
+
193
+ # @return [String] pixel filter mode ('nearest' or 'linear')
194
+ def pixel_filter
195
+ global['pixel_filter']
196
+ end
197
+
198
+ def pixel_filter=(val)
199
+ global['pixel_filter'] = %w[nearest linear].include?(val.to_s) ? val.to_s : 'nearest'
200
+ end
201
+
202
+ def integer_scale?
203
+ global['integer_scale']
204
+ end
205
+
206
+ def integer_scale=(val)
207
+ global['integer_scale'] = !!val
208
+ end
209
+
210
+ def color_correction?
211
+ global['color_correction']
212
+ end
213
+
214
+ def color_correction=(val)
215
+ global['color_correction'] = !!val
216
+ end
217
+
218
+ def frame_blending?
219
+ global['frame_blending']
220
+ end
221
+
222
+ def frame_blending=(val)
223
+ global['frame_blending'] = !!val
224
+ end
225
+
226
+ def rewind_enabled?
227
+ global['rewind_enabled']
228
+ end
229
+
230
+ def rewind_enabled=(val)
231
+ global['rewind_enabled'] = !!val
232
+ end
233
+
234
+ # @return [Integer] rewind buffer duration in seconds (1-60)
235
+ def rewind_seconds
236
+ global['rewind_seconds']
237
+ end
238
+
239
+ def rewind_seconds=(val)
240
+ global['rewind_seconds'] = val.to_i.clamp(1, 60)
241
+ end
242
+
243
+ # -- Per-game settings ---------------------------------------------------
244
+
245
+ # @return [Boolean] whether per-game settings are enabled
246
+ def per_game_settings?
247
+ !!global_base['per_game_settings']
248
+ end
249
+
250
+ def per_game_settings=(val)
251
+ global_base['per_game_settings'] = !!val
252
+ end
253
+
254
+ # @return [String, nil] the active ROM ID, or nil if no ROM loaded
255
+ attr_reader :active_rom_id
256
+
257
+ # Activate per-game config for the given ROM. If per_game_settings? is
258
+ # true, reads/writes to PER_GAME_KEYS will go through the game file.
259
+ # @param rom_id [String] e.g. "AGB_BTKE-DEADBEEF"
260
+ def activate_game(rom_id)
261
+ @active_rom_id = rom_id
262
+ if per_game_settings?
263
+ @game_data = load_game_file(rom_id)
264
+ @proxy = PerGameProxy.new(global_base, @game_data, PER_GAME_KEYS)
265
+ else
266
+ @game_data = nil
267
+ @proxy = nil
268
+ end
269
+ end
270
+
271
+ # Deactivate per-game settings (e.g. when ROM is unloaded).
272
+ def deactivate_game
273
+ @active_rom_id = nil
274
+ @game_data = nil
275
+ @proxy = nil
276
+ end
277
+
278
+ # Enable per-game settings for the currently loaded ROM.
279
+ # Copies current global values to game file on first enable.
280
+ def enable_per_game
281
+ raise "No ROM loaded" unless @active_rom_id
282
+ self.per_game_settings = true
283
+ @game_data = load_game_file(@active_rom_id)
284
+ if @game_data.empty?
285
+ PER_GAME_KEYS.each { |key| @game_data[key] = global_base[key] }
286
+ end
287
+ @proxy = PerGameProxy.new(global_base, @game_data, PER_GAME_KEYS)
288
+ end
289
+
290
+ # Disable per-game settings. Reverts to global values.
291
+ # Does NOT delete the game-specific file on disk.
292
+ def disable_per_game
293
+ self.per_game_settings = false
294
+ @proxy = nil
295
+ end
296
+
297
+ # Build a ROM identifier from game code and CRC32 checksum.
298
+ # Uses the same sanitization as SaveStateManager#state_dir_for_rom.
299
+ # @return [String] e.g. "AGB_BTKE-DEADBEEF"
300
+ def self.rom_id(game_code, checksum)
301
+ code = game_code.gsub(/[^a-zA-Z0-9_.-]/, '_')
302
+ crc = format('%08X', checksum)
303
+ "#{code}-#{crc}"
304
+ end
305
+
306
+ # @return [String] path to the per-game settings file
307
+ def self.game_config_path(rom_id)
308
+ File.join(config_dir, 'games', rom_id, 'settings.json')
309
+ end
310
+
311
+ # @return [Float] toast notification duration in seconds
312
+ def toast_duration
313
+ global['toast_duration'].to_f
314
+ end
315
+
316
+ def toast_duration=(val)
317
+ val = val.to_f
318
+ raise ArgumentError, "toast_duration must be positive" if val <= 0
319
+ global['toast_duration'] = val.clamp(0.1, 10.0)
320
+ end
321
+
322
+ # @return [String] directory for game save files (.sav)
323
+ def saves_dir
324
+ global['saves_dir'] || self.class.default_saves_dir
325
+ end
326
+
327
+ def saves_dir=(val)
328
+ global['saves_dir'] = val.to_s
329
+ end
330
+
331
+ # @return [String] directory for save state files (.ss1, .ss2, etc.)
332
+ def states_dir
333
+ global['states_dir'] || self.class.default_states_dir
334
+ end
335
+
336
+ def states_dir=(val)
337
+ global['states_dir'] = val.to_s
338
+ end
339
+
340
+ # @return [Integer] tooltip auto-dismiss delay in milliseconds (hidden setting)
341
+ def tip_dismiss_ms
342
+ global['tip_dismiss_ms']
343
+ end
344
+
345
+ def tip_dismiss_ms=(val)
346
+ global['tip_dismiss_ms'] = val.to_i.clamp(1000, 30_000)
347
+ end
348
+
349
+ # @return [Float] debounce interval in seconds between save state operations (hidden setting)
350
+ def save_state_debounce
351
+ global['save_state_debounce'].to_f
352
+ end
353
+
354
+ def save_state_debounce=(val)
355
+ global['save_state_debounce'] = val.to_f.clamp(0.0, 30.0)
356
+ end
357
+
358
+ # @return [Integer] quick save/load slot (1-10)
359
+ def quick_save_slot
360
+ global['quick_save_slot']
361
+ end
362
+
363
+ def quick_save_slot=(val)
364
+ global['quick_save_slot'] = val.to_i.clamp(1, 10)
365
+ end
366
+
367
+ # @return [Boolean] whether to create .bak files when overwriting save states
368
+ def save_state_backup?
369
+ global['save_state_backup']
370
+ end
371
+
372
+ def save_state_backup=(val)
373
+ global['save_state_backup'] = !!val
374
+ end
375
+
376
+ # @return [String] locale code ('auto', 'en', 'ja', etc.)
377
+ def locale
378
+ global['locale']
379
+ end
380
+
381
+ def locale=(val)
382
+ global['locale'] = val.to_s
383
+ end
384
+
385
+ # @return [Integer] zlib compression level for .grec recordings (1-9)
386
+ def recording_compression
387
+ global['recording_compression']
388
+ end
389
+
390
+ def recording_compression=(val)
391
+ global['recording_compression'] = val.to_i.clamp(1, 9)
392
+ end
393
+
394
+ # @return [Boolean] whether to pause emulation when window loses focus
395
+ def pause_on_focus_loss?
396
+ global['pause_on_focus_loss']
397
+ end
398
+
399
+ def pause_on_focus_loss=(val)
400
+ global['pause_on_focus_loss'] = !!val
401
+ end
402
+
403
+ # @return [String] directory for .grec recording files
404
+ def recordings_dir
405
+ global['recordings_dir'] || self.class.default_recordings_dir
406
+ end
407
+
408
+ def recordings_dir=(val)
409
+ global['recordings_dir'] = val.to_s
410
+ end
411
+
412
+ # -- Recent ROMs -------------------------------------------------------
413
+
414
+ # @return [Array<String>] ROM paths, newest first
415
+ def recent_roms
416
+ @data['recent_roms'] ||= []
417
+ end
418
+
419
+ # Add a ROM path to the front of the recent list (deduplicates).
420
+ # @param path [String] absolute path to the ROM file
421
+ def add_recent_rom(path)
422
+ list = recent_roms
423
+ list.delete(path)
424
+ list.unshift(path)
425
+ list.pop while list.size > MAX_RECENT_ROMS
426
+ end
427
+
428
+ # Remove a specific ROM path from the recent list.
429
+ # @param path [String]
430
+ def remove_recent_rom(path)
431
+ recent_roms.delete(path)
432
+ end
433
+
434
+ def clear_recent_roms
435
+ @data['recent_roms'] = []
436
+ end
437
+
438
+ # -- Hotkeys -------------------------------------------------------------
439
+
440
+ # @return [Hash] action (String) → keysym (String)
441
+ def hotkeys
442
+ @data['hotkeys'] ||= {}
443
+ end
444
+
445
+ # @param action [Symbol, String] e.g. :quit, 'pause'
446
+ # @param hk [String, Array] e.g. 'q', 'F5', or ['Control', 's']
447
+ def set_hotkey(action, hk)
448
+ hotkeys[action.to_s] = hk
449
+ end
450
+
451
+ def reset_hotkeys
452
+ @data['hotkeys'] = {}
453
+ end
454
+
455
+ # -- Per-gamepad settings ----------------------------------------------
456
+
457
+ # @param guid [String] SDL joystick GUID, or KEYBOARD_GUID for keyboard bindings
458
+ # @param name [String] human-readable controller name (stored for reference)
459
+ # @return [Hash] gamepad config (dead_zone, mappings)
460
+ def gamepad(guid, name: nil)
461
+ defaults = guid == KEYBOARD_GUID ? KEYBOARD_DEFAULTS : GAMEPAD_DEFAULTS
462
+ gp = gamepads[guid] ||= deep_dup(defaults)
463
+ gp['name'] = name if name
464
+ gp
465
+ end
466
+
467
+ # @param guid [String]
468
+ # @return [Integer] dead zone percentage (0-50)
469
+ def dead_zone(guid)
470
+ gamepad(guid)['dead_zone']
471
+ end
472
+
473
+ # @param guid [String]
474
+ # @param val [Integer] percentage (0-50)
475
+ def set_dead_zone(guid, val)
476
+ gamepad(guid)['dead_zone'] = val.to_i.clamp(0, 50)
477
+ end
478
+
479
+ # @param guid [String]
480
+ # @return [Hash] GBA button (String) → gamepad button (String)
481
+ def mappings(guid)
482
+ gamepad(guid)['mappings']
483
+ end
484
+
485
+ # @param guid [String]
486
+ # @param gba_btn [Symbol, String] e.g. :a, "a"
487
+ # @param gp_btn [Symbol, String] e.g. :x, "dpad_up"
488
+ def set_mapping(guid, gba_btn, gp_btn)
489
+ m = gamepad(guid)['mappings']
490
+ m.delete_if { |_, v| v == gp_btn.to_s }
491
+ m[gba_btn.to_s] = gp_btn.to_s
492
+ end
493
+
494
+ # @param guid [String]
495
+ def reset_gamepad(guid)
496
+ defaults = guid == KEYBOARD_GUID ? KEYBOARD_DEFAULTS : GAMEPAD_DEFAULTS
497
+ gamepads[guid] = deep_dup(defaults)
498
+ end
499
+
500
+ # -- Persistence -------------------------------------------------------
501
+
502
+ def save!
503
+ @data['meta'] = {
504
+ 'teek_version' => (defined?(Teek::VERSION) && Teek::VERSION) || 'unknown',
505
+ 'gemba_version' => (defined?(Gemba::VERSION) && Gemba::VERSION) || 'unknown',
506
+ 'saved_at' => Time.now.iso8601,
507
+ }
508
+ dir = File.dirname(@path)
509
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
510
+ File.write(@path, JSON.pretty_generate(@data))
511
+
512
+ save_game_file! if @game_data && @active_rom_id
513
+ end
514
+
515
+ def reload!
516
+ @data = load_file
517
+ activate_game(@active_rom_id) if @active_rom_id
518
+ end
519
+
520
+ # -- Platform paths ----------------------------------------------------
521
+
522
+ def self.default_path
523
+ File.join(config_dir, FILENAME)
524
+ end
525
+
526
+ # Delete the settings file at the given path (or the default).
527
+ # @return [String, nil] the path deleted, or nil if no file existed
528
+ def self.reset!(path: default_path)
529
+ if File.exist?(path)
530
+ File.delete(path)
531
+ path
532
+ end
533
+ end
534
+
535
+ def self.config_dir
536
+ return ENV['GEMBA_CONFIG_DIR'] if ENV['GEMBA_CONFIG_DIR']
537
+
538
+ p = Teek.platform
539
+ if p.darwin?
540
+ File.join(Dir.home, 'Library', 'Application Support', APP_NAME)
541
+ elsif p.windows?
542
+ File.join(ENV.fetch('APPDATA', File.join(Dir.home, 'AppData', 'Roaming')), APP_NAME)
543
+ else
544
+ # Linux / other Unix — XDG Base Directory Specification
545
+ base = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
546
+ File.join(base, APP_NAME)
547
+ end
548
+ end
549
+
550
+ # @return [String] default directory for game save files (.sav)
551
+ def self.default_saves_dir
552
+ File.join(config_dir, 'saves')
553
+ end
554
+
555
+ # @return [String] default directory for save state files
556
+ def self.default_states_dir
557
+ File.join(config_dir, 'states')
558
+ end
559
+
560
+ # @return [String] default directory for screenshots
561
+ def self.default_screenshots_dir
562
+ File.join(config_dir, 'screenshots')
563
+ end
564
+
565
+ # @return [String] default directory for .grec recordings
566
+ def self.default_recordings_dir
567
+ File.join(config_dir, 'recordings')
568
+ end
569
+
570
+ private
571
+
572
+ def global
573
+ @proxy || global_base
574
+ end
575
+
576
+ def global_base
577
+ @data['global'] ||= deep_dup(GLOBAL_DEFAULTS)
578
+ end
579
+
580
+ def gamepads
581
+ @data['gamepads'] ||= {}
582
+ end
583
+
584
+ def load_file
585
+ return default_data unless File.exist?(@path)
586
+
587
+ data = JSON.parse(File.read(@path))
588
+ data['global'] = GLOBAL_DEFAULTS.merge(data['global'] || {})
589
+ data['gamepads'] ||= {}
590
+ data['recent_roms'] ||= []
591
+ data
592
+ rescue JSON::ParserError => e
593
+ warn "gemba: corrupt config file #{@path}: #{e.message} — using defaults"
594
+ default_data
595
+ end
596
+
597
+ def default_data
598
+ { 'global' => deep_dup(GLOBAL_DEFAULTS), 'gamepads' => {}, 'recent_roms' => [] }
599
+ end
600
+
601
+ def deep_dup(hash)
602
+ JSON.parse(JSON.generate(hash))
603
+ end
604
+
605
+ def load_game_file(rom_id)
606
+ path = self.class.game_config_path(rom_id)
607
+ return {} unless File.exist?(path)
608
+ JSON.parse(File.read(path))
609
+ rescue JSON::ParserError => e
610
+ warn "gemba: corrupt game config #{path}: #{e.message} — using global"
611
+ {}
612
+ end
613
+
614
+ def save_game_file!
615
+ path = self.class.game_config_path(@active_rom_id)
616
+ dir = File.dirname(path)
617
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
618
+ File.write(path, JSON.pretty_generate(@game_data))
619
+ end
620
+ end
621
+ end