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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'thread'
5
+
6
+ module Gemba
7
+ # Records emulator video + audio to a .grec file.
8
+ #
9
+ # Video is delta-compressed (XOR with previous frame) then zlib level 1.
10
+ # Audio is stored as raw PCM. A background thread handles disk I/O so
11
+ # the frame loop stays fast.
12
+ #
13
+ # @example
14
+ # recorder = Recorder.new("output.grec", width: 240, height: 160)
15
+ # recorder.start
16
+ # loop do
17
+ # core.run_frame
18
+ # recorder.capture(core.video_buffer_argb, core.audio_buffer)
19
+ # end
20
+ # recorder.stop
21
+ class Recorder
22
+ MAGIC = "GEMBAREC"
23
+ FOOTER_MAGIC = "GEND"
24
+ VERSION = 1
25
+ FLUSH_INTERVAL = 60 # frames between queue flushes (~1s)
26
+
27
+ # @param path [String] output .grec file path
28
+ # @param width [Integer] video width (240 for GBA)
29
+ # @param height [Integer] video height (160 for GBA)
30
+ # @param audio_rate [Integer] audio sample rate (default 44100)
31
+ # @param audio_channels [Integer] audio channels (default 2)
32
+ # @param compression [Integer] zlib compression level 1-9 (default 1 = fastest)
33
+ def initialize(path, width:, height:, audio_rate: 44100, audio_channels: 2,
34
+ compression: Zlib::BEST_SPEED)
35
+ @path = path
36
+ @width = width
37
+ @height = height
38
+ @audio_rate = audio_rate
39
+ @audio_channels = audio_channels
40
+ @compression = compression
41
+ @frame_size = width * height * 4
42
+ @recording = false
43
+ end
44
+
45
+ # Start recording. Writes header and spawns writer thread.
46
+ def start
47
+ raise "Already recording" if @recording
48
+ @recording = true
49
+ @frame_count = 0
50
+ @prev_frame = ("\0" * @frame_size).b
51
+ @batch = []
52
+ @queue = Thread::Queue.new
53
+ @writer = Thread.new { writer_loop }
54
+ @queue.push(build_header)
55
+ end
56
+
57
+ # Capture one frame of video + audio.
58
+ # @param video_argb [String] raw ARGB8888 pixel data
59
+ # @param audio_pcm [String] raw s16le stereo PCM data
60
+ def capture(video_argb, audio_pcm)
61
+ return unless @recording
62
+
63
+ delta = Gemba.xor_delta(video_argb, @prev_frame)
64
+ @prev_frame = video_argb.dup
65
+
66
+ changed = Gemba.count_changed_pixels(delta)
67
+ total = @width * @height
68
+ change_pct = total > 0 ? (changed * 100 / total).clamp(0, 100) : 0
69
+
70
+ compressed = Zlib::Deflate.deflate(delta, @compression)
71
+
72
+ @batch << [change_pct, compressed, audio_pcm || "".b]
73
+ @frame_count += 1
74
+
75
+ if @batch.length >= FLUSH_INTERVAL
76
+ flush_batch
77
+ end
78
+ end
79
+
80
+ # Stop recording. Flushes remaining data, writes footer, closes file.
81
+ def stop
82
+ return unless @recording
83
+ @recording = false
84
+ flush_batch unless @batch.empty?
85
+ @queue.push(build_footer)
86
+ @queue.push(:done)
87
+ @writer.join
88
+ @writer = nil
89
+ @queue = nil
90
+ @batch = nil
91
+ @prev_frame = nil
92
+ end
93
+
94
+ # @return [Boolean] true if currently recording
95
+ def recording?
96
+ @recording
97
+ end
98
+
99
+ # @return [Integer] number of frames captured so far
100
+ def frame_count
101
+ @frame_count || 0
102
+ end
103
+
104
+ private
105
+
106
+ def flush_batch
107
+ data = encode_batch(@batch)
108
+ @queue.push(data)
109
+ @batch = []
110
+ end
111
+
112
+ def encode_batch(frames)
113
+ buf = String.new(encoding: Encoding::BINARY, capacity: frames.length * 1024)
114
+ frames.each do |change_pct, compressed_video, audio_pcm|
115
+ buf << [change_pct].pack('C')
116
+ buf << [compressed_video.bytesize].pack('V')
117
+ buf << compressed_video
118
+ buf << [audio_pcm.bytesize].pack('V')
119
+ buf << audio_pcm
120
+ end
121
+ buf
122
+ end
123
+
124
+ def build_header
125
+ # 32-byte header
126
+ h = String.new(encoding: Encoding::BINARY, capacity: 32)
127
+ h << MAGIC # 8 bytes
128
+ h << [VERSION].pack('C') # 1 byte
129
+ h << [@width, @height].pack('v2') # 4 bytes
130
+ h << [262_144, 4389].pack('V2') # 8 bytes (fps = 262144/4389 ≈ 59.7272)
131
+ h << [@audio_rate].pack('V') # 4 bytes
132
+ h << [@audio_channels, 16].pack('C2') # 2 bytes
133
+ h << ("\0" * 5) # 5 bytes reserved
134
+ h
135
+ end
136
+
137
+ def build_footer
138
+ footer = String.new(encoding: Encoding::BINARY, capacity: 8)
139
+ footer << [@frame_count].pack('V') # 4 bytes
140
+ footer << FOOTER_MAGIC # 4 bytes
141
+ footer
142
+ end
143
+
144
+ def writer_loop
145
+ File.open(@path, 'wb') do |f|
146
+ loop do
147
+ chunk = @queue.pop
148
+ break if chunk == :done
149
+ f.write(chunk)
150
+ end
151
+ end
152
+ rescue => e
153
+ warn "gemba: recorder write error: #{e.message}"
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'open3'
5
+ require 'tempfile'
6
+
7
+ module Gemba
8
+ # Decodes a .grec file and encodes it to a playable video via ffmpeg.
9
+ #
10
+ # Two-pass approach to avoid writing massive intermediate files:
11
+ # Pass 1: Extract audio to a small tempfile (~10MB/min), count frames,
12
+ # collect per-frame change percentages.
13
+ # Pass 2: Decode video frames one at a time and pipe to ffmpeg's stdin.
14
+ #
15
+ # Only one decoded video frame is in memory at a time, so RAM usage stays
16
+ # constant regardless of recording length.
17
+ #
18
+ # @example
19
+ # info = RecorderDecoder.decode("recording.grec", "output.mp4")
20
+ # puts "Encoded #{info[:frame_count]} frames to #{info[:output_path]}"
21
+ #
22
+ # @example Quick stats without encoding
23
+ # stats = RecorderDecoder.stats("recording.grec")
24
+ # puts "#{stats[:frame_count]} frames, avg #{stats[:avg_change_pct].round(1)}% change"
25
+ class RecorderDecoder
26
+ class FormatError < StandardError; end
27
+ class FfmpegNotFound < StandardError; end
28
+
29
+ DEFAULT_VIDEO_CODEC = 'libx264'
30
+ DEFAULT_AUDIO_CODEC = 'aac'
31
+
32
+ # Quick scan of a .grec file — no ffmpeg needed.
33
+ # Reads header + per-frame change bytes, skips video/audio data.
34
+ # @param trec_path [String]
35
+ # @return [Hash] :frame_count, :width, :height, :fps, :duration,
36
+ # :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels
37
+ def self.stats(trec_path)
38
+ new(trec_path, nil).stats
39
+ end
40
+
41
+ # Decode a .grec file and encode to a playable video file.
42
+ # @param trec_path [String] path to .grec file
43
+ # @param output_path [String] output video path (e.g. "out.mp4", "out.mkv")
44
+ # @param video_codec [String] ffmpeg video codec (default: libx264)
45
+ # @param audio_codec [String] ffmpeg audio codec (default: aac)
46
+ # @param scale [Integer, nil] output scale factor (nil = native)
47
+ # @param ffmpeg_args [Array<String>, nil] raw ffmpeg output args (overrides codecs)
48
+ # @param progress [Boolean] show encoding progress (default: true)
49
+ # @return [Hash] :output_path, :frame_count, :width, :height, :fps,
50
+ # :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels
51
+ def self.decode(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
52
+ audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
53
+ ffmpeg_args: nil, progress: true)
54
+ new(trec_path, output_path,
55
+ video_codec: video_codec, audio_codec: audio_codec,
56
+ scale: scale, ffmpeg_args: ffmpeg_args, progress: progress).decode
57
+ end
58
+
59
+ def initialize(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
60
+ audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
61
+ ffmpeg_args: nil, progress: true)
62
+ @trec_path = trec_path
63
+ @output_path = output_path
64
+ @video_codec = video_codec
65
+ @audio_codec = audio_codec
66
+ @scale = scale
67
+ @ffmpeg_args = ffmpeg_args
68
+ @progress = progress
69
+ end
70
+
71
+ # Quick stats scan — reads only header + 1 byte per frame.
72
+ def stats
73
+ header = nil
74
+ frame_count = 0
75
+ total_change = 0
76
+
77
+ File.open(@trec_path, 'rb') do |f|
78
+ header = read_header(f)
79
+
80
+ until f.eof?
81
+ break if at_footer?(f)
82
+
83
+ change_pct = f.read(1)&.unpack1('C') or break
84
+ total_change += change_pct
85
+
86
+ video_len = read_u32(f) or break
87
+ f.seek(video_len, IO::SEEK_CUR)
88
+
89
+ audio_len = read_u32(f) or break
90
+ f.seek(audio_len, IO::SEEK_CUR)
91
+
92
+ frame_count += 1
93
+ end
94
+ end
95
+
96
+ fps = header[:fps_num].to_f / header[:fps_den]
97
+ frame_size = header[:width] * header[:height] * 4
98
+
99
+ {
100
+ frame_count: frame_count,
101
+ width: header[:width],
102
+ height: header[:height],
103
+ fps: fps,
104
+ duration: frame_count / fps,
105
+ avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
106
+ raw_video_size: frame_count * frame_size,
107
+ audio_rate: header[:audio_rate],
108
+ audio_channels: header[:audio_channels],
109
+ }
110
+ end
111
+
112
+ def decode
113
+ check_ffmpeg!
114
+
115
+ header = nil
116
+ frame_count = 0
117
+ total_change = 0
118
+
119
+ # Pass 1: parse header, extract audio to tempfile, count frames.
120
+ # Video chunks are skipped (seek, not read) to keep this fast.
121
+ audio_tmp = Tempfile.new(['trec_audio', '.raw'])
122
+ audio_tmp.binmode
123
+
124
+ File.open(@trec_path, 'rb') do |f|
125
+ header = read_header(f)
126
+
127
+ until f.eof?
128
+ break if at_footer?(f)
129
+
130
+ change_pct = f.read(1)&.unpack1('C') or break
131
+ total_change += change_pct
132
+
133
+ video_len = read_u32(f) or break
134
+ f.seek(video_len, IO::SEEK_CUR)
135
+
136
+ audio_len = read_u32(f) or break
137
+ audio_tmp.write(f.read(audio_len)) if audio_len > 0
138
+
139
+ frame_count += 1
140
+ end
141
+ end
142
+
143
+ audio_tmp.flush
144
+ fps = header[:fps_num].to_f / header[:fps_den]
145
+ frame_size = header[:width] * header[:height] * 4
146
+
147
+ # Pass 2: decode video frames and pipe to ffmpeg.
148
+ encode(header, fps, audio_tmp.path, frame_count)
149
+
150
+ {
151
+ output_path: @output_path,
152
+ frame_count: frame_count,
153
+ width: header[:width],
154
+ height: header[:height],
155
+ fps: fps,
156
+ avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
157
+ raw_video_size: frame_count * frame_size,
158
+ audio_rate: header[:audio_rate],
159
+ audio_channels: header[:audio_channels],
160
+ }
161
+ ensure
162
+ audio_tmp&.close!
163
+ end
164
+
165
+ private
166
+
167
+ def check_ffmpeg!
168
+ Open3.capture2e('ffmpeg', '-version')
169
+ rescue Errno::ENOENT
170
+ raise FfmpegNotFound, "ffmpeg not found in PATH"
171
+ end
172
+
173
+ # Pipe decoded video frames to ffmpeg stdin while ffmpeg reads audio
174
+ # from the tempfile. One frame in memory at a time.
175
+ def encode(header, fps, audio_path, total_frames)
176
+ frame_size = header[:width] * header[:height] * 4
177
+ prev_frame = ("\0" * frame_size).b
178
+ cmd = build_ffmpeg_cmd(header, fps, audio_path)
179
+
180
+ Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
181
+ err_reader = Thread.new { stderr.read }
182
+ progress_reader = @progress ? start_progress_reader(stdout, total_frames) : nil
183
+
184
+ File.open(@trec_path, 'rb') do |f|
185
+ f.seek(32) # skip header
186
+
187
+ until f.eof?
188
+ break if at_footer?(f)
189
+
190
+ f.seek(1, IO::SEEK_CUR) # skip change_pct byte
191
+
192
+ # Decode one video frame
193
+ video_len = read_u32(f) or break
194
+ compressed = f.read(video_len)
195
+ delta = Zlib::Inflate.inflate(compressed)
196
+ frame = Gemba.xor_delta(delta, prev_frame)
197
+ prev_frame = frame
198
+ stdin.write(frame)
199
+
200
+ # Skip audio (already extracted in pass 1)
201
+ audio_len = read_u32(f) or break
202
+ f.seek(audio_len, IO::SEEK_CUR) if audio_len > 0
203
+ end
204
+ end
205
+
206
+ stdin.close
207
+ progress_reader&.join
208
+ $stderr.print "\r\e[K" if @progress
209
+ status = wait_thr.value
210
+ unless status.success?
211
+ raise "ffmpeg failed (exit #{status.exitstatus}): #{err_reader.value}"
212
+ end
213
+ end
214
+ end
215
+
216
+ # Read ffmpeg's -progress output on stdout and print a progress line.
217
+ def start_progress_reader(stdout, total_frames)
218
+ Thread.new do
219
+ current_frame = 0
220
+ encode_fps = 0.0
221
+ last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
222
+
223
+ stdout.each_line do |line|
224
+ case line
225
+ when /\Aframe=(\d+)/
226
+ current_frame = $1.to_i
227
+ when /\Afps=([\d.]+)/
228
+ encode_fps = $1.to_f
229
+ when /\Aprogress=/
230
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
231
+ is_done = line.start_with?('progress=end')
232
+ if is_done || now - last_print >= 0.5
233
+ pct = total_frames > 0 ? (current_frame * 100.0 / total_frames) : 0
234
+ fps_str = encode_fps > 0 ? " @ #{'%.1f' % encode_fps} fps" : ""
235
+ $stderr.print "\rEncoding: #{current_frame}/#{total_frames} " \
236
+ "(#{'%.1f' % pct}%)#{fps_str}\e[K"
237
+ last_print = now
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ # Build the ffmpeg argument list.
245
+ #
246
+ # Video input (pipe:0): raw frames in BGRA pixel order.
247
+ # mGBA's video_buffer_argb stores each pixel as a uint32 0xAARRGGBB;
248
+ # on little-endian systems the byte layout is B-G-R-A, so ffmpeg
249
+ # must be told -pix_fmt bgra (not argb).
250
+ #
251
+ # Audio input: raw PCM, signed 16-bit little-endian, stereo interleaved
252
+ # (LRLRLR...) at the GBA's native sample rate (44100 Hz).
253
+ #
254
+ # -pix_fmt yuv420p on output ensures broad player/browser compatibility.
255
+ def build_ffmpeg_cmd(header, fps, audio_path)
256
+ cmd = %W[
257
+ ffmpeg -y -loglevel error
258
+ -f rawvideo -pix_fmt bgra
259
+ -s #{header[:width]}x#{header[:height]}
260
+ -r #{format('%.4f', fps)}
261
+ -i pipe:0
262
+ -f s16le -ar #{header[:audio_rate]}
263
+ -ac #{header[:audio_channels]}
264
+ -i #{audio_path}
265
+ ]
266
+ if @scale && @scale > 1
267
+ w = header[:width] * @scale
268
+ h = header[:height] * @scale
269
+ # Use -sws_flags instead of scale=W:H:flags=neighbor — the inline
270
+ # flags= syntax produced vertically-oriented output on ffmpeg 7.1.1.
271
+ cmd.push('-vf', "scale=#{w}:#{h}",
272
+ '-sws_flags', 'neighbor')
273
+ end
274
+ if @ffmpeg_args
275
+ cmd.concat(@ffmpeg_args)
276
+ else
277
+ cmd.push('-c:v', @video_codec, '-pix_fmt', 'yuv420p',
278
+ '-c:a', @audio_codec)
279
+ end
280
+ cmd.push('-progress', 'pipe:1') if @progress
281
+ cmd.push(@output_path)
282
+ cmd
283
+ end
284
+
285
+ def read_header(f)
286
+ raw = f.read(32)
287
+ raise FormatError, "File too small for header" unless raw && raw.bytesize == 32
288
+
289
+ magic = raw[0, 8]
290
+ raise FormatError, "Invalid magic: #{magic.inspect}" unless magic == Recorder::MAGIC
291
+
292
+ version = raw[8].unpack1('C')
293
+ raise FormatError, "Unsupported version: #{version}" unless version == Recorder::VERSION
294
+
295
+ width, height = raw[9, 4].unpack('v2')
296
+ fps_num, fps_den = raw[13, 8].unpack('V2')
297
+ audio_rate = raw[21, 4].unpack1('V')
298
+ audio_channels, audio_bits = raw[25, 2].unpack('C2')
299
+
300
+ { width: width, height: height,
301
+ fps_num: fps_num, fps_den: fps_den,
302
+ audio_rate: audio_rate,
303
+ audio_channels: audio_channels,
304
+ audio_bits: audio_bits }
305
+ end
306
+
307
+ # Check if we're at the 8-byte footer. If not, rewind to where we were.
308
+ def at_footer?(f)
309
+ pos = f.pos
310
+ marker = f.read(8)
311
+ if marker && marker.bytesize == 8
312
+ _, magic = marker.unpack('Va4')
313
+ return true if magic == Recorder::FOOTER_MAGIC
314
+ end
315
+ f.seek(pos)
316
+ false
317
+ end
318
+
319
+ def read_u32(f)
320
+ raw = f.read(4)
321
+ return nil unless raw && raw.bytesize == 4
322
+ raw.unpack1('V')
323
+ end
324
+ end
325
+ end