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.
- checksums.yaml +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- metadata +234 -0
data/lib/gemba/config.rb
ADDED
|
@@ -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
|