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
|
@@ -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
|