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,15 @@
1
+ #ifndef GEMBA_EXT_H
2
+ #define GEMBA_EXT_H
3
+
4
+ #include <ruby.h>
5
+ #include <mgba/core/core.h>
6
+ #include <mgba/core/config.h>
7
+ #include <mgba/core/directories.h>
8
+ #include <mgba/core/log.h>
9
+ #include <mgba-util/vfs.h>
10
+
11
+ extern VALUE mGemba;
12
+
13
+ void Init_gemba_ext(void);
14
+
15
+ #endif /* GEMBA_EXT_H */
data/gemba.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ require_relative "lib/gemba/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "gemba"
5
+ spec.version = Gemba::VERSION
6
+ spec.authors = ["James Cook"]
7
+ spec.email = ["jcook.rubyist@gmail.com"]
8
+
9
+ spec.summary = "GBA emulator frontend powered by teek and libmgba"
10
+ spec.description = "Wraps libmgba's mCore C API and provides a full-featured GBA player with SDL2 rendering, input, save states, and a Tk-based settings UI"
11
+ spec.homepage = "https://github.com/jamescook/gemba"
12
+ spec.licenses = ["MIT"]
13
+
14
+ spec.files = Dir.glob("{lib,ext,test,assets,bin}/**/*").select { |f|
15
+ File.file?(f) && f !~ /\.(bundle|so|o|log)$/ &&
16
+ !f.include?('.dSYM/') && File.basename(f) != 'Makefile'
17
+ } + %w[gemba.gemspec THIRD_PARTY_NOTICES]
18
+ spec.bindir = "bin"
19
+ spec.executables = ["gemba"]
20
+ spec.require_paths = ["lib"]
21
+ spec.extensions = ["ext/gemba/extconf.rb"]
22
+ spec.required_ruby_version = ">= 3.2"
23
+
24
+ spec.add_dependency "teek", ">= 0.1.2"
25
+ spec.add_dependency "teek-sdl2", ">= 0.1.3"
26
+
27
+ spec.add_development_dependency "rake", "~> 13.0"
28
+ spec.add_development_dependency "rake-compiler", "~> 1.0"
29
+ spec.add_development_dependency "minitest", "~> 6.0"
30
+ spec.add_development_dependency "method_source", "~> 1.0"
31
+ spec.add_development_dependency "simplecov", "~> 0.22"
32
+ spec.add_development_dependency "listen", "~> 3.0"
33
+
34
+ spec.requirements << "libmgba development headers"
35
+ spec.add_development_dependency "rubyzip", ">= 2.4"
36
+
37
+ spec.requirements << "rubyzip gem >= 2.4 (optional, for loading ROMs from .zip files)"
38
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Shared helpers for child windows (Settings, Save State Picker, ROM Info).
5
+ #
6
+ # Including classes must define a TOP constant and an @app instance variable.
7
+ # Modal windows (grab focus) use show_window/hide_window. Non-modal windows
8
+ # (like ROM Info) use show_window(modal: false)/hide_window(modal: false).
9
+ module ChildWindow
10
+ # Create and configure a toplevel window with standard boilerplate.
11
+ # Hides the window at the end — call show_window to reveal it.
12
+ #
13
+ # @param title [String]
14
+ # @param geometry [String, nil] e.g. '700x390'
15
+ def build_toplevel(title, geometry: nil)
16
+ top = self.class::TOP
17
+ @app.command(:toplevel, top)
18
+ @app.command(:wm, 'title', top, title)
19
+ @app.command(:wm, 'geometry', top, geometry) if geometry
20
+ @app.command(:wm, 'resizable', top, 0, 0)
21
+ @app.command(:wm, 'transient', top, '.')
22
+ # macOS has a single app-wide menu bar — share the parent's menubar
23
+ # so it doesn't revert to Tk's default "wish" menu when this window
24
+ # has focus. On other platforms each window has its own menu bar,
25
+ # so sharing creates a visible duplicate.
26
+ if Teek.platform.darwin?
27
+ parent_menu = @app.command('.', :cget, '-menu') rescue nil
28
+ @app.command(top, :configure, menu: parent_menu) if parent_menu && !parent_menu.empty?
29
+ end
30
+ @app.command(:wm, 'protocol', top, 'WM_DELETE_WINDOW', proc { hide })
31
+ yield if block_given?
32
+ @app.command(:wm, 'withdraw', top)
33
+ end
34
+
35
+ # Position this window to the right of the main application window.
36
+ def position_near_parent
37
+ top = self.class::TOP
38
+ x, y, w, _h = @app.interp.window_geometry('.')
39
+ @app.command(:wm, 'geometry', top, "+#{x + w + 12}+#{y}")
40
+ end
41
+
42
+ # Reveal the window, optionally grabbing focus (modal).
43
+ def show_window(modal: true)
44
+ top = self.class::TOP
45
+ position_near_parent
46
+ @app.command(:wm, 'deiconify', top)
47
+ @app.command(:raise, top)
48
+ if modal
49
+ @app.command(:grab, :set, top)
50
+ @app.command(:focus, top)
51
+ end
52
+ end
53
+
54
+ # Withdraw the window, release grab if modal, fire on_close callback.
55
+ def hide_window(modal: true)
56
+ top = self.class::TOP
57
+ @app.command(:grab, :release, top) if modal
58
+ @app.command(:wm, 'withdraw', top)
59
+ @callbacks[:on_close]&.call if defined?(@callbacks)
60
+ end
61
+ end
62
+ end
data/lib/gemba/cli.rb ADDED
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'version'
5
+
6
+ module Gemba
7
+ class CLI
8
+ SUBCOMMANDS = %w[record decode info].freeze
9
+
10
+ # Entry point: dispatch to subcommand or player.
11
+ # @param argv [Array<String>]
12
+ def self.run(argv = ARGV)
13
+ args = argv.dup
14
+
15
+ case args.first
16
+ when 'record'
17
+ args.shift
18
+ run_record(args)
19
+ when 'decode'
20
+ args.shift
21
+ run_decode(args)
22
+ when 'info'
23
+ args.shift
24
+ run_info(args)
25
+ else
26
+ run_player(args)
27
+ end
28
+ end
29
+
30
+ # Parse player (default) command options.
31
+ # @param argv [Array<String>]
32
+ # @return [Hash]
33
+ def self.parse(argv)
34
+ options = {}
35
+
36
+ parser = OptionParser.new do |o|
37
+ o.banner = "Usage: gemba [options] [ROM_FILE]"
38
+ o.separator ""
39
+ o.separator "GBA emulator powered by teek + libmgba"
40
+ o.separator ""
41
+ o.separator "Commands:"
42
+ o.separator " record Record video+audio to .grec (headless)"
43
+ o.separator " decode Encode .grec to video via ffmpeg"
44
+ o.separator " info Show .grec recording stats"
45
+ o.separator ""
46
+ o.separator "Player options:"
47
+
48
+ o.on("-s", "--scale N", Integer, "Window scale (1-4)") do |v|
49
+ options[:scale] = v.clamp(1, 4)
50
+ end
51
+
52
+ o.on("-v", "--volume N", Integer, "Volume (0-100)") do |v|
53
+ options[:volume] = v.clamp(0, 100)
54
+ end
55
+
56
+ o.on("-m", "--mute", "Start muted") do
57
+ options[:mute] = true
58
+ end
59
+
60
+ o.on("--no-sound", "Disable audio entirely") do
61
+ options[:sound] = false
62
+ end
63
+
64
+ o.on("-f", "--fullscreen", "Start in fullscreen") do
65
+ options[:fullscreen] = true
66
+ end
67
+
68
+ o.on("--show-fps", "Show FPS counter") do
69
+ options[:show_fps] = true
70
+ end
71
+
72
+ o.on("--turbo-speed N", Integer, "Fast-forward speed (0=uncapped, 2-4)") do |v|
73
+ options[:turbo_speed] = v.clamp(0, 4)
74
+ end
75
+
76
+ o.on("--locale LANG", "Language (en, ja, auto)") do |v|
77
+ options[:locale] = v
78
+ end
79
+
80
+ o.on("--headless", "Run without GUI (requires --frames and ROM)") do
81
+ options[:headless] = true
82
+ end
83
+
84
+ o.on("--frames N", Integer, "Run N frames then exit (requires ROM)") do |v|
85
+ options[:frames] = v
86
+ end
87
+
88
+ o.on("--reset-config", "Delete settings file and exit (keeps saves)") do
89
+ options[:reset_config] = true
90
+ end
91
+
92
+ o.on("-y", "--yes", "Skip confirmation prompts") do
93
+ options[:yes] = true
94
+ end
95
+
96
+ o.on("--version", "Show version") do
97
+ options[:version] = true
98
+ end
99
+
100
+ o.on("-h", "--help", "Show this help") do
101
+ options[:help] = true
102
+ end
103
+ end
104
+
105
+ parser.parse!(argv)
106
+ options[:rom] = File.expand_path(argv.first) if argv.first
107
+ options[:parser] = parser
108
+ options
109
+ end
110
+
111
+ # Apply parsed CLI options to the user config (session-only overrides).
112
+ # @param config [Gemba::Config]
113
+ # @param options [Hash]
114
+ def self.apply(config, options)
115
+ config.scale = options[:scale] if options[:scale]
116
+ config.volume = options[:volume] if options[:volume]
117
+ config.muted = true if options[:mute]
118
+ config.show_fps = true if options[:show_fps]
119
+ config.turbo_speed = options[:turbo_speed] if options[:turbo_speed]
120
+ config.locale = options[:locale] if options[:locale]
121
+ end
122
+
123
+ # --- Player (default command) ---
124
+
125
+ def self.run_player(argv)
126
+ options = parse(argv)
127
+
128
+ if options[:help]
129
+ puts options[:parser]
130
+ return
131
+ end
132
+
133
+ if options[:version]
134
+ puts "gemba #{Gemba::VERSION}"
135
+ return
136
+ end
137
+
138
+ require "gemba"
139
+
140
+ if options[:reset_config]
141
+ path = Config.default_path
142
+ unless File.exist?(path)
143
+ puts "No config file found at #{path}"
144
+ return
145
+ end
146
+ unless options[:yes]
147
+ print "Delete #{path}? [y/N] "
148
+ return unless $stdin.gets&.strip&.downcase == 'y'
149
+ end
150
+ Config.reset!(path: path)
151
+ puts "Deleted #{path}"
152
+ return
153
+ end
154
+
155
+ if options[:headless]
156
+ unless options[:frames] && options[:rom]
157
+ $stderr.puts "Error: --headless requires --frames N and a ROM file"
158
+ exit 1
159
+ end
160
+ require "gemba/headless"
161
+ HeadlessPlayer.open(options[:rom]) { |p| p.step(options[:frames]) }
162
+ return
163
+ end
164
+
165
+ if options[:frames] && !options[:rom]
166
+ $stderr.puts "Error: --frames requires a ROM file"
167
+ exit 1
168
+ end
169
+
170
+ apply(Gemba.user_config, options)
171
+ Gemba.load_locale if options[:locale]
172
+
173
+ sound = options.fetch(:sound, true)
174
+ Player.new(options[:rom], sound: sound, fullscreen: options[:fullscreen],
175
+ frames: options[:frames]).run
176
+ end
177
+ private_class_method :run_player
178
+
179
+ # --- record subcommand ---
180
+
181
+ def self.parse_record(argv)
182
+ options = {}
183
+
184
+ parser = OptionParser.new do |o|
185
+ o.banner = "Usage: gemba record [options] ROM_FILE"
186
+ o.separator ""
187
+ o.separator "Record video+audio to a .grec file (headless, no GUI)"
188
+ o.separator ""
189
+
190
+ o.on("--frames N", Integer, "Number of frames to record (required)") do |v|
191
+ options[:frames] = v
192
+ end
193
+
194
+ o.on("-o", "--output PATH", "Output .grec path (default: ROM_ID.grec)") do |v|
195
+ options[:output] = v
196
+ end
197
+
198
+ o.on("-c", "--compression N", Integer, "Zlib level 1-9 (default: 1, 6+ has diminishing returns)") do |v|
199
+ options[:compression] = v.clamp(1, 9)
200
+ end
201
+
202
+ o.on("--progress", "Show recording progress") do
203
+ options[:progress] = true
204
+ end
205
+
206
+ o.on("-h", "--help", "Show this help") do
207
+ options[:help] = true
208
+ end
209
+ end
210
+
211
+ parser.parse!(argv)
212
+ options[:rom] = File.expand_path(argv.first) if argv.first
213
+ options[:parser] = parser
214
+ options
215
+ end
216
+
217
+ def self.run_record(argv)
218
+ options = parse_record(argv)
219
+
220
+ if options[:help]
221
+ puts options[:parser]
222
+ return
223
+ end
224
+
225
+ unless options[:frames] && options[:rom]
226
+ $stderr.puts "Error: record requires --frames N and a ROM file"
227
+ $stderr.puts "Run 'gemba record --help' for usage"
228
+ exit 1
229
+ end
230
+
231
+ require "gemba/headless"
232
+
233
+ total = options[:frames]
234
+
235
+ HeadlessPlayer.open(options[:rom]) do |player|
236
+ rec_path = options[:output] ||
237
+ "#{Config.rom_id(player.game_code, player.checksum)}.grec"
238
+
239
+ rec_opts = {}
240
+ rec_opts[:compression] = options[:compression] if options[:compression]
241
+ player.start_recording(rec_path, **rec_opts)
242
+
243
+ if options[:progress]
244
+ last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
245
+ player.step(total) do |frame|
246
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ if frame == total || now - last_print >= 0.5
248
+ pct = frame * 100.0 / total
249
+ $stderr.print "\rRecording: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
250
+ last_print = now
251
+ end
252
+ end
253
+ $stderr.print "\r\e[K"
254
+ else
255
+ player.step(total)
256
+ end
257
+
258
+ player.stop_recording
259
+
260
+ info = RecorderDecoder.stats(rec_path)
261
+ puts "Recorded #{info[:frame_count]} frames to #{rec_path}"
262
+ puts " Duration: #{'%.1f' % info[:duration]}s"
263
+ puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
264
+ puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
265
+ puts " .grec size: #{format_size(File.size(rec_path))}"
266
+ end
267
+ end
268
+ private_class_method :run_record
269
+
270
+ # --- decode subcommand ---
271
+
272
+ def self.parse_decode(argv)
273
+ options = {}
274
+
275
+ parser = OptionParser.new do |o|
276
+ o.banner = "Usage: gemba decode [options] TREC_FILE [-- FFMPEG_ARGS...]"
277
+ o.separator ""
278
+ o.separator "Encode a .grec recording to a playable video via ffmpeg."
279
+ o.separator "Args after -- replace the default codec flags (-c:v, -c:a, etc)."
280
+ o.separator ""
281
+
282
+ o.on("-o", "--output PATH", "Output path (default: INPUT.mp4)") do |v|
283
+ options[:output] = v
284
+ end
285
+
286
+ o.on("--video-codec CODEC", "Video codec (default: libx264)") do |v|
287
+ options[:video_codec] = v
288
+ end
289
+
290
+ o.on("--audio-codec CODEC", "Audio codec (default: aac)") do |v|
291
+ options[:audio_codec] = v
292
+ end
293
+
294
+ o.on("-s", "--scale N", Integer, "Scale factor (default: native)") do |v|
295
+ options[:scale] = v.clamp(1, 10)
296
+ end
297
+
298
+ o.on("--no-progress", "Disable progress indicator") do
299
+ options[:progress] = false
300
+ end
301
+
302
+ o.on("-h", "--help", "Show this help") do
303
+ options[:help] = true
304
+ end
305
+ end
306
+
307
+ parser.parse!(argv)
308
+ options[:trec] = argv.shift
309
+ options[:ffmpeg_args] = argv unless argv.empty?
310
+ options[:parser] = parser
311
+ options
312
+ end
313
+
314
+ def self.run_decode(argv)
315
+ options = parse_decode(argv)
316
+
317
+ if options[:help]
318
+ puts options[:parser]
319
+ return
320
+ end
321
+
322
+ unless options[:trec]
323
+ $stderr.puts "Error: decode requires a .grec file"
324
+ $stderr.puts "Run 'gemba decode --help' for usage"
325
+ exit 1
326
+ end
327
+
328
+ require "gemba/headless"
329
+
330
+ trec_path = options[:trec]
331
+ output_path = options[:output] || trec_path.sub(/\.grec\z/, '') + '.mp4'
332
+ codec_opts = {}
333
+ codec_opts[:video_codec] = options[:video_codec] if options[:video_codec]
334
+ codec_opts[:audio_codec] = options[:audio_codec] if options[:audio_codec]
335
+ codec_opts[:scale] = options[:scale] if options[:scale]
336
+ codec_opts[:ffmpeg_args] = options[:ffmpeg_args] if options[:ffmpeg_args]
337
+ codec_opts[:progress] = options.fetch(:progress, true)
338
+
339
+ info = RecorderDecoder.decode(trec_path, output_path, **codec_opts)
340
+ puts "Encoded #{info[:frame_count]} frames " \
341
+ "(#{info[:width]}x#{info[:height]} @ #{'%.2f' % info[:fps]} fps, " \
342
+ "avg #{'%.1f' % info[:avg_change_pct]}% change/frame)"
343
+ puts "Output: #{info[:output_path]}"
344
+ end
345
+ private_class_method :run_decode
346
+
347
+ # --- info subcommand ---
348
+
349
+ def self.run_info(argv)
350
+ if argv.include?('--help') || argv.include?('-h') || argv.empty?
351
+ puts "Usage: gemba info TREC_FILE"
352
+ puts ""
353
+ puts "Show recording stats (no ffmpeg needed)"
354
+ return
355
+ end
356
+
357
+ require "gemba/headless"
358
+
359
+ trec_path = argv.first
360
+ info = RecorderDecoder.stats(trec_path)
361
+
362
+ puts "Recording: #{trec_path}"
363
+ puts " Frames: #{info[:frame_count]}"
364
+ puts " Resolution: #{info[:width]}x#{info[:height]}"
365
+ puts " FPS: #{'%.2f' % info[:fps]}"
366
+ puts " Duration: #{'%.1f' % info[:duration]}s"
367
+ puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
368
+ puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
369
+ puts " Audio: #{info[:audio_rate]} Hz, #{info[:audio_channels]}ch"
370
+ end
371
+ private_class_method :run_info
372
+
373
+ def self.format_size(bytes)
374
+ if bytes >= 1_073_741_824
375
+ "#{'%.1f' % (bytes / 1_073_741_824.0)} GB"
376
+ elsif bytes >= 1_048_576
377
+ "#{'%.1f' % (bytes / 1_048_576.0)} MB"
378
+ else
379
+ "#{'%.1f' % (bytes / 1024.0)} KB"
380
+ end
381
+ end
382
+ private_class_method :format_size
383
+ end
384
+ end