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,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "child_window"
4
+ require_relative "locale"
5
+
6
+ module Gemba
7
+ # Displays ROM metadata in a read-only window.
8
+ #
9
+ # Shown via View > ROM Info when a ROM is loaded. Contains a two-column
10
+ # grid of labels: field name on the left, value on the right.
11
+ class RomInfoWindow
12
+ include ChildWindow
13
+ include Locale::Translatable
14
+
15
+ TOP = ".mgba_rom_info"
16
+
17
+ # GBA maker/publisher codes (2-char ASCII → publisher name).
18
+ # Source: various community-maintained databases of GBA ROM headers.
19
+ MAKER_CODES = {
20
+ '01' => 'Nintendo',
21
+ '08' => 'Capcom',
22
+ '13' => 'Electronic Arts Japan',
23
+ '18' => 'Hudson Soft',
24
+ '20' => 'Destination Software / Zoo Digital',
25
+ '24' => 'PCM Complete',
26
+ '25' => 'San-X',
27
+ '28' => 'Kemco Japan',
28
+ '29' => 'SETA Corporation',
29
+ '2N' => 'Nowpro',
30
+ '30' => 'Viacom / Infogrames',
31
+ '34' => 'Konami',
32
+ '35' => 'Hector',
33
+ '36' => 'Codemasters',
34
+ '37' => 'GAGA Communications',
35
+ '38' => 'Laguna',
36
+ '41' => 'Ubisoft',
37
+ '42' => 'Sunsoft',
38
+ '47' => 'Spectrum Holobyte',
39
+ '49' => 'IREM',
40
+ '4D' => 'Malibu Games',
41
+ '4F' => 'Eidos / U.S. Gold',
42
+ '4Q' => 'Disney',
43
+ '4Z' => 'Crave Entertainment',
44
+ '50' => 'Absolute Entertainment',
45
+ '51' => 'Acclaim',
46
+ '52' => 'Activision',
47
+ '54' => 'GameTek',
48
+ '56' => 'LJN',
49
+ '58' => 'Mattel',
50
+ '5D' => 'Midway / Tradewest',
51
+ '5G' => 'Majesco',
52
+ '5H' => '3DO',
53
+ '5L' => 'NewKidCo',
54
+ '5S' => 'TDK Mediactive',
55
+ '60' => 'Titus',
56
+ '61' => 'Virgin',
57
+ '64' => 'LucasArts',
58
+ '67' => 'Ocean',
59
+ '69' => 'Electronic Arts',
60
+ '6E' => 'Elite Systems',
61
+ '6F' => 'Electro Brain',
62
+ '6L' => 'BAM! Entertainment',
63
+ '6S' => 'TDK Mediactive',
64
+ '70' => 'Infogrames',
65
+ '71' => 'Interplay',
66
+ '72' => 'JVC / Broderbund',
67
+ '73' => 'Sculptured Software',
68
+ '75' => 'The Sales Curve / SCi',
69
+ '78' => 'THQ',
70
+ '79' => 'Accolade',
71
+ '7A' => 'Triffix',
72
+ '7D' => 'Sierra / Universal Interactive',
73
+ '7F' => 'Kemco',
74
+ '7G' => 'Rage Software',
75
+ '7H' => 'Encore',
76
+ '7L' => 'Warped Productions',
77
+ '80' => 'Misawa',
78
+ '83' => 'LOZC / G.Amusements',
79
+ '86' => 'Tokuma Shoten',
80
+ '87' => 'Tsukuda Original',
81
+ '8B' => 'Bullet-Proof Software',
82
+ '8C' => 'Vic Tokai',
83
+ '8E' => 'Character Soft',
84
+ '8J' => 'General Entertainment',
85
+ '8N' => 'Success',
86
+ '91' => 'Chunsoft',
87
+ '92' => 'Video System',
88
+ '93' => 'BEC / Ocean / Acclaim',
89
+ '95' => 'Varie',
90
+ '97' => 'Kaneko',
91
+ '99' => 'Pack-In-Video',
92
+ '9B' => 'Tecmo',
93
+ '9C' => 'Imagineer',
94
+ '9H' => 'Bottom Up',
95
+ 'A0' => 'Telenet',
96
+ 'A1' => 'Hori',
97
+ 'A4' => 'Konami',
98
+ 'A7' => 'Takara',
99
+ 'A9' => 'Technos Japan',
100
+ 'AA' => 'JVC / Broderbund',
101
+ 'AC' => 'Toei Animation',
102
+ 'AD' => 'Toho',
103
+ 'AF' => 'Namco',
104
+ 'AG' => 'Media Rings',
105
+ 'AH' => 'J-Wing',
106
+ 'AK' => 'KID',
107
+ 'AL' => 'MediaFactory',
108
+ 'AP' => 'Infogrames Hudson',
109
+ 'AQ' => 'Kiratto Ludic',
110
+ 'AY' => 'Yacht Club Games',
111
+ 'B0' => 'Acclaim Japan / Nexsoft',
112
+ 'B1' => 'ASCII / Nexsoft',
113
+ 'B2' => 'Bandai',
114
+ 'B4' => 'Enix',
115
+ 'B6' => 'HAL Laboratory',
116
+ 'B7' => 'SNK',
117
+ 'B9' => 'Pony Canyon',
118
+ 'BA' => 'Culture Brain',
119
+ 'BB' => 'Sunsoft',
120
+ 'BD' => 'Sony Imagesoft',
121
+ 'BF' => 'Sammy',
122
+ 'BG' => 'Magical',
123
+ 'BJ' => 'Compile',
124
+ 'BL' => 'MTO',
125
+ 'BN' => 'Sunrise Interactive',
126
+ 'BP' => 'Global A Entertainment',
127
+ 'C0' => 'Taito',
128
+ 'C2' => 'Kemco',
129
+ 'C3' => 'Square Soft',
130
+ 'C4' => 'Tokuma Shoten',
131
+ 'C5' => 'Data East',
132
+ 'C6' => 'Tonkin House',
133
+ 'C8' => 'Koei',
134
+ 'CB' => 'Vap',
135
+ 'CC' => 'Use Corporation',
136
+ 'CD' => 'Meldac',
137
+ 'CE' => 'Pony Canyon / FCI',
138
+ 'CF' => 'Angel / Dtop',
139
+ 'CG' => 'Marvelous Entertainment',
140
+ 'CJ' => 'Boss Communication',
141
+ 'CK' => 'Axela / Crea-Tech',
142
+ 'CP' => 'Enterbrain',
143
+ 'D0' => 'Taito',
144
+ 'D1' => 'Sofel',
145
+ 'D2' => 'Quest',
146
+ 'D3' => 'Sigma Enterprises',
147
+ 'D4' => 'Ask Kodansha',
148
+ 'D6' => 'Naxat Soft',
149
+ 'D7' => 'Copya System',
150
+ 'D9' => 'Banpresto',
151
+ 'DA' => 'Tomy',
152
+ 'DB' => 'LJN Japan',
153
+ 'DD' => 'NCS',
154
+ 'DE' => 'Human',
155
+ 'DF' => 'Altron',
156
+ 'DH' => 'Gaps',
157
+ 'DK' => 'Kodansha',
158
+ 'DN' => 'ELF',
159
+ 'E2' => 'Yutaka',
160
+ 'E3' => 'Varie',
161
+ 'E5' => 'Epoch',
162
+ 'E7' => 'Athena',
163
+ 'E8' => 'Asmik / Asmik Ace',
164
+ 'E9' => 'Natsume',
165
+ 'EB' => 'Atlus',
166
+ 'EC' => 'Epic / Sony Records',
167
+ 'EE' => 'IGS',
168
+ 'EL' => 'Spike',
169
+ 'EM' => 'Konami Computer Entertainment Tokyo',
170
+ 'EP' => 'Sting',
171
+ 'ES' => 'Square Enix',
172
+ 'F0' => 'A-Wave',
173
+ 'G1' => 'PCCW',
174
+ 'G4' => 'KiKi',
175
+ 'G5' => 'Open Sesame',
176
+ 'G6' => 'Sims',
177
+ 'G7' => 'Broccoli',
178
+ 'G8' => 'Avex',
179
+ 'G9' => 'D3 Publisher',
180
+ 'GB' => 'Konami Computer Entertainment Japan',
181
+ 'GD' => 'Square Enix',
182
+ 'GE' => 'KSG',
183
+ 'GF' => 'Micott & Basara',
184
+ 'GH' => 'Orbital Media',
185
+ 'GN' => 'Nintendo',
186
+ 'GT' => '505 Games',
187
+ 'GY' => 'The Game Factory',
188
+ 'H1' => 'Treasure',
189
+ 'H2' => 'Aruze',
190
+ 'H3' => 'Ertain',
191
+ 'H4' => 'SNK Playmore',
192
+ 'HF' => 'Level-5',
193
+ 'HJ' => 'Genius Sonority',
194
+ 'HY' => 'Reef Entertainment',
195
+ 'IH' => 'Yojigen',
196
+ 'J9' => 'AQ Interactive',
197
+ 'JF' => 'Arc System Works',
198
+ 'K6' => 'Nihon System',
199
+ 'KB' => 'NexEntertainment',
200
+ 'KM' => 'Cybird',
201
+ 'KP' => 'Purple Hills',
202
+ 'LH' => 'Sekai Project',
203
+ 'LP' => 'Witchcraft',
204
+ 'LT' => 'Inti Creates',
205
+ 'LU' => 'XSEED Games',
206
+ 'MJ' => 'MumboJumbo',
207
+ 'MR' => 'Mindscape',
208
+ 'MS' => 'Mindscape / Red Orb',
209
+ 'MT' => 'Blast!',
210
+ 'N9' => 'Teyon',
211
+ 'NK' => 'Neko Entertainment',
212
+ 'NP' => 'Nobilis',
213
+ 'PL' => 'Playlogic',
214
+ 'RA' => 'Nordcurrent',
215
+ 'RS' => 'Warner Bros. Interactive',
216
+ 'SU' => 'Slitherine',
217
+ 'SV' => 'SevenOne Intermedia / dtp',
218
+ 'TR' => 'Tetris Online',
219
+ 'UG' => 'Metro3D / Data Design',
220
+ 'VN' => 'GameFly',
221
+ 'VP' => 'Virgin Play',
222
+ 'VZ' => 'Little Orbit',
223
+ 'WR' => 'Warner Bros. Interactive',
224
+ 'XJ' => 'XSEED Games',
225
+ 'XS' => 'Aksys Games',
226
+ 'YT' => 'Valcon Games',
227
+ 'Z4' => 'Ntreev Soft',
228
+ 'ZA' => 'WBA Interactive',
229
+ 'ZH' => 'Internal Engine',
230
+ 'ZS' => 'Zinkia',
231
+ 'ZW' => 'Judo Baby',
232
+ 'ZX' => 'TopWare Interactive',
233
+ }.freeze
234
+
235
+ # Look up a publisher name from a 2-char maker code.
236
+ # @param code [String] 2-character maker code
237
+ # @return [String] publisher name, or "Unknown (XX)"
238
+ def self.publisher_name(code)
239
+ MAKER_CODES[code] || "Unknown (#{code})"
240
+ end
241
+
242
+ def initialize(app, callbacks: {})
243
+ @app = app
244
+ @callbacks = callbacks
245
+ @built = false
246
+ end
247
+
248
+ # Show the ROM Info window, populating it with data from the given core.
249
+ # @param core [Gemba::Core]
250
+ # @param rom_path [String] path to the ROM file
251
+ # @param save_path [String] path to the .sav file
252
+ def show(core, rom_path:, save_path:)
253
+ build_ui unless @built
254
+ populate(core, rom_path, save_path)
255
+ show_window(modal: false)
256
+ end
257
+
258
+ def hide
259
+ hide_window(modal: false)
260
+ end
261
+
262
+ private
263
+
264
+ def build_ui
265
+ build_toplevel(translate('rom_info.title')) do
266
+ build_fields
267
+ end
268
+ @built = true
269
+ end
270
+
271
+ def build_fields
272
+ frame = "#{TOP}.f"
273
+ @app.command('ttk::frame', frame, padding: 12)
274
+ @app.command(:pack, frame, fill: :both, expand: 1)
275
+
276
+ @fields = {}
277
+ rows = %w[title game_code publisher platform rom_size checksum
278
+ rom_path save_path resolution]
279
+ labels = {
280
+ 'title' => translate('rom_info.field_title'),
281
+ 'game_code' => translate('rom_info.game_code'),
282
+ 'publisher' => translate('rom_info.publisher'),
283
+ 'platform' => translate('rom_info.platform'),
284
+ 'rom_size' => translate('rom_info.rom_size'),
285
+ 'checksum' => translate('rom_info.checksum'),
286
+ 'rom_path' => translate('rom_info.rom_file'),
287
+ 'save_path' => translate('rom_info.save_file'),
288
+ 'resolution' => translate('rom_info.resolution'),
289
+ }
290
+
291
+ rows.each_with_index do |key, i|
292
+ lbl = "#{frame}.lbl_#{key}"
293
+ val = "#{frame}.val_#{key}"
294
+
295
+ @app.command('ttk::label', lbl, text: labels[key], anchor: :e, width: 12)
296
+ @app.command(:grid, lbl, row: i, column: 0, sticky: :e, padx: [0, 8], pady: 3)
297
+
298
+ @app.command('ttk::label', val, text: '', anchor: :w)
299
+ @app.command(:grid, val, row: i, column: 1, sticky: :w, pady: 3)
300
+
301
+ @fields[key] = val
302
+ end
303
+
304
+ # Close button
305
+ btn = "#{frame}.close_btn"
306
+ @app.command('ttk::button', btn, text: translate('rom_info.close'), command: proc { hide })
307
+ @app.command(:grid, btn, row: rows.size, column: 0, columnspan: 2, pady: [12, 0])
308
+ end
309
+
310
+ def populate(core, rom_path, save_path)
311
+ set_field('title', core.title)
312
+
313
+ game_code = core.game_code
314
+ set_field('game_code', game_code)
315
+
316
+ maker = core.maker_code
317
+ na = translate('rom_info.na')
318
+ publisher = maker.empty? ? na : "#{self.class.publisher_name(maker)} (#{maker})"
319
+ set_field('publisher', publisher)
320
+
321
+ set_field('platform', core.platform)
322
+ set_field('rom_size', format_size(core.rom_size))
323
+ set_field('checksum', "0x%08X" % core.checksum)
324
+ set_field('rom_path', rom_path || na)
325
+ set_field('save_path', save_path || na)
326
+ set_field('resolution', "#{core.width}x#{core.height}")
327
+
328
+ @app.command(:wm, 'title', TOP, "#{translate('rom_info.title')} \u2014 #{core.title}")
329
+ end
330
+
331
+ def set_field(key, value)
332
+ widget = @fields[key]
333
+ @app.command(widget, 'configure', text: value.to_s) if widget
334
+ end
335
+
336
+ def format_size(bytes)
337
+ if bytes >= 1024 * 1024
338
+ "%.1f MB (%d bytes)" % [bytes / (1024.0 * 1024), bytes]
339
+ elsif bytes >= 1024
340
+ "%.1f KB (%d bytes)" % [bytes / 1024.0, bytes]
341
+ else
342
+ "#{bytes} bytes"
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gemba
6
+ # Resolves ROM paths for the player. Handles both bare ROM files
7
+ # and .zip archives containing a single ROM at the zip root.
8
+ #
9
+ # @example Load a bare ROM
10
+ # path = RomLoader.resolve("/path/to/game.gba")
11
+ # # => "/path/to/game.gba"
12
+ #
13
+ # @example Load from a zip
14
+ # path = RomLoader.resolve("/path/to/game.zip")
15
+ # # => "/Users/you/.config/gemba/tmp/game.gba"
16
+ class RomLoader
17
+ ROM_EXTENSIONS = %w[.gba .gb .gbc].freeze
18
+ ZIP_EXTENSIONS = %w[.zip].freeze
19
+ SUPPORTED_EXTENSIONS = (ROM_EXTENSIONS + ZIP_EXTENSIONS).freeze
20
+
21
+ class Error < StandardError; end
22
+ class NoRomInZip < Error; end
23
+ class MultipleRomsInZip < Error; end
24
+ class UnsupportedFormat < Error; end
25
+ class ZipReadError < Error; end
26
+
27
+ # Resolve a path to a loadable ROM file.
28
+ # For ROM files, returns the path unchanged.
29
+ # For ZIP files, extracts the single ROM inside to a temp directory.
30
+ #
31
+ # @param path [String] path to a ROM or ZIP file
32
+ # @return [String] path to a loadable ROM file
33
+ # @raise [NoRomInZip] if the ZIP contains no ROM files at the root
34
+ # @raise [MultipleRomsInZip] if the ZIP contains more than one ROM
35
+ # @raise [UnsupportedFormat] if the file extension is not supported
36
+ # @raise [ZipReadError] if the ZIP file cannot be read
37
+ def self.resolve(path)
38
+ ext = File.extname(path).downcase
39
+ if ROM_EXTENSIONS.include?(ext)
40
+ path
41
+ elsif ZIP_EXTENSIONS.include?(ext)
42
+ begin
43
+ require 'zip'
44
+ rescue LoadError
45
+ raise ZipReadError, "rubyzip gem not available (gem install rubyzip)"
46
+ end
47
+ extract_from_zip(path)
48
+ else
49
+ raise UnsupportedFormat, ext
50
+ end
51
+ end
52
+
53
+ # Remove previously extracted temp files.
54
+ def self.cleanup_temp
55
+ dir = tmp_dir
56
+ FileUtils.rm_rf(dir) if File.directory?(dir)
57
+ end
58
+
59
+ # @return [String] temp directory for extracted ROMs
60
+ def self.tmp_dir
61
+ File.join(Config.config_dir, 'tmp')
62
+ end
63
+
64
+ # Extract the single ROM from a ZIP file.
65
+ # Only considers entries at the zip root (no subdirectories).
66
+ # @param zip_path [String]
67
+ # @return [String] path to extracted ROM
68
+ def self.extract_from_zip(zip_path)
69
+ basename = File.basename(zip_path)
70
+ roms = []
71
+
72
+ Zip::File.open(zip_path) do |zip|
73
+ zip.each do |entry|
74
+ next if entry.directory?
75
+ next if entry.name.include?('/')
76
+ if ROM_EXTENSIONS.include?(File.extname(entry.name).downcase)
77
+ roms << entry
78
+ end
79
+ end
80
+
81
+ raise NoRomInZip, basename if roms.empty?
82
+ raise MultipleRomsInZip, basename if roms.length > 1
83
+
84
+ rom_entry = roms.first
85
+ dir = tmp_dir
86
+ FileUtils.mkdir_p(dir)
87
+ out_path = File.join(dir, File.basename(rom_entry.name))
88
+ File.binwrite(out_path, rom_entry.get_input_stream.read)
89
+ out_path
90
+ end
91
+ rescue NoRomInZip, MultipleRomsInZip
92
+ raise
93
+ rescue Zip::Error => e
94
+ raise ZipReadError, "#{basename}: #{e.message}"
95
+ rescue => e
96
+ raise ZipReadError, "#{basename}: #{e.message}"
97
+ end
98
+ private_class_method :extract_from_zip
99
+ end
100
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared runtime for gemba — loads the C extension, config, locale,
4
+ # core, and ROM loader. Both the full GUI and headless entry points
5
+ # require this.
6
+
7
+ require "teek/platform"
8
+ require "gemba_ext"
9
+ require_relative "version"
10
+ require_relative "config"
11
+ require_relative "locale"
12
+ require_relative "core"
13
+ require_relative "rom_loader"
14
+
15
+ module Gemba
16
+ ASSETS_DIR = File.expand_path('../../assets', __dir__).freeze
17
+
18
+ # Lazily loaded user config — shared across the application.
19
+ # @return [Gemba::Config]
20
+ def self.user_config
21
+ @user_config ||= Config.new
22
+ end
23
+
24
+ # Override the user config (useful for tests).
25
+ # @param config [Gemba::Config, nil] pass nil to reset to default
26
+ def self.user_config=(config)
27
+ @user_config = config
28
+ end
29
+
30
+ # Load translations based on the config locale setting.
31
+ def self.load_locale
32
+ lang = user_config.locale
33
+ lang = nil if lang == 'auto'
34
+ Locale.load(lang)
35
+ end
36
+
37
+ # Initialize locale on require
38
+ load_locale
39
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'locale'
5
+
6
+ module Gemba
7
+ # Manages save state persistence: save, load, screenshot capture,
8
+ # debounce, and backup rotation.
9
+ #
10
+ # All dependencies are injected via the constructor so the class can be
11
+ # tested with lightweight mocks (no real mGBA Core or Tk interpreter).
12
+ #
13
+ # @example Production usage (inside Player)
14
+ # @save_mgr = SaveStateManager.new(core: @core, config: @config, app: @app)
15
+ # success, msg = @save_mgr.save_state(1)
16
+ # show_toast(msg)
17
+ #
18
+ # @example Test usage (with mocks)
19
+ # mgr = SaveStateManager.new(core: mock_core, config: config, app: mock_app)
20
+ # success, msg = mgr.save_state(1)
21
+ # assert success
22
+ class SaveStateManager
23
+ include Locale::Translatable
24
+
25
+ GBA_W = 240
26
+ GBA_H = 160
27
+
28
+ def initialize(core:, config:, app:)
29
+ @core = core
30
+ @config = config
31
+ @app = app
32
+ @last_save_time = 0
33
+ @state_dir = nil
34
+ @quick_save_slot = config.quick_save_slot
35
+ @backup = config.save_state_backup?
36
+ end
37
+
38
+ # @return [Integer] quick save/load slot
39
+ attr_accessor :quick_save_slot
40
+
41
+ # @return [Boolean] whether to create .bak files
42
+ attr_accessor :backup
43
+
44
+ # @return [Core] the mGBA core (swappable for reset/ROM change)
45
+ attr_accessor :core
46
+
47
+ # Build per-ROM state directory path using game code + CRC32.
48
+ # e.g. states/AGB-BTKE-A1B2C3D4/
49
+ # @param core [Core, #game_code, #checksum] the emulator core
50
+ # @return [String] directory path
51
+ def state_dir_for_rom(core)
52
+ code = core.game_code.gsub(/[^a-zA-Z0-9_.-]/, '_')
53
+ crc = format('%08X', core.checksum)
54
+ File.join(@config.states_dir, "#{code}-#{crc}")
55
+ end
56
+
57
+ # Set the state directory (called after ROM load).
58
+ # @param dir [String]
59
+ attr_writer :state_dir
60
+
61
+ # @return [String, nil] current state directory
62
+ attr_reader :state_dir
63
+
64
+ # @param slot [Integer]
65
+ # @return [String] path to the save state file for this slot
66
+ def state_path(slot)
67
+ File.join(@state_dir, "state#{slot}.ss")
68
+ end
69
+
70
+ # @param slot [Integer]
71
+ # @return [String] path to the screenshot PNG for this slot
72
+ def screenshot_path(slot)
73
+ File.join(@state_dir, "state#{slot}.png")
74
+ end
75
+
76
+ # Save the emulator state to the given slot.
77
+ # @param slot [Integer]
78
+ # @return [Array(Boolean, String)] success flag and translated message
79
+ def save_state(slot)
80
+ return [false, nil] unless @core && !@core.destroyed?
81
+
82
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ if now - @last_save_time < @config.save_state_debounce
84
+ return [false, translate('toast.save_blocked')]
85
+ end
86
+
87
+ FileUtils.mkdir_p(@state_dir) unless File.directory?(@state_dir)
88
+
89
+ # Backup rotation: rename existing files → .bak
90
+ ss = state_path(slot)
91
+ png = screenshot_path(slot)
92
+ if @backup
93
+ File.rename(ss, "#{ss}.bak") if File.exist?(ss)
94
+ File.rename(png, "#{png}.bak") if File.exist?(png)
95
+ end
96
+
97
+ if @core.save_state_to_file(ss)
98
+ @last_save_time = now
99
+ save_screenshot(png)
100
+ [true, translate('toast.state_saved', slot: slot)]
101
+ else
102
+ [false, translate('toast.save_failed')]
103
+ end
104
+ end
105
+
106
+ # Load the emulator state from the given slot.
107
+ # @param slot [Integer]
108
+ # @return [Array(Boolean, String)] success flag and translated message
109
+ def load_state(slot)
110
+ return [false, nil] unless @core && !@core.destroyed?
111
+
112
+ ss = state_path(slot)
113
+ unless File.exist?(ss)
114
+ return [false, translate('toast.no_state', slot: slot)]
115
+ end
116
+
117
+ if @core.load_state_from_file(ss)
118
+ [true, translate('toast.state_loaded', slot: slot)]
119
+ else
120
+ [false, translate('toast.load_failed')]
121
+ end
122
+ end
123
+
124
+ # Save to the quick save slot.
125
+ # @return [Array(Boolean, String)]
126
+ def quick_save
127
+ save_state(@quick_save_slot)
128
+ end
129
+
130
+ # Load from the quick save slot.
131
+ # @return [Array(Boolean, String)]
132
+ def quick_load
133
+ load_state(@quick_save_slot)
134
+ end
135
+
136
+ # Save a PNG screenshot of the current frame via Tk photo image.
137
+ # Uses @app.command() to drive Tk's image subsystem.
138
+ # @param path [String] output PNG file path
139
+ def save_screenshot(path)
140
+ return unless @core && !@core.destroyed?
141
+
142
+ pixels = @core.video_buffer_argb
143
+ photo_name = "__gemba_ss_#{object_id}"
144
+
145
+ @app.command(:image, :create, :photo, photo_name,
146
+ width: GBA_W, height: GBA_H)
147
+ @app.interp.photo_put_block(photo_name, pixels, GBA_W, GBA_H, format: :argb)
148
+ @app.command(photo_name, :write, path, format: :png)
149
+ @app.command(:image, :delete, photo_name)
150
+ rescue StandardError => e
151
+ warn "gemba: screenshot failed for #{path}: #{e.message} (#{e.class})"
152
+ @app.command(:image, :delete, photo_name) rescue nil
153
+ end
154
+ end
155
+ end