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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "prism"
5
+
6
+ module Gemba
7
+ # @api private
8
+ # Transforms SimpleCov line coverage data into per-method coverage.
9
+ #
10
+ # Merges coverage data from all test suite result files, uses Prism to
11
+ # parse Ruby source files and find method definitions, then maps
12
+ # SimpleCov line coverage to calculate per-method percentages.
13
+ #
14
+ # @example
15
+ # service = MethodCoverageService.new(
16
+ # coverage_dir: "coverage",
17
+ # source_dirs: ["lib"]
18
+ # )
19
+ # service.call
20
+ # # => writes coverage/method_coverage.json
21
+ #
22
+ class MethodCoverageService
23
+ attr_reader :coverage_dir, :source_dirs, :output_path
24
+
25
+ def initialize(coverage_dir:, source_dirs: ["lib"], output_path: nil)
26
+ @coverage_dir = coverage_dir
27
+ @source_dirs = source_dirs
28
+ @output_path = output_path || File.join(coverage_dir, "method_coverage.json")
29
+ end
30
+
31
+ def call
32
+ coverage_files = Dir.glob(File.join(coverage_dir, "results", "*", "coverage.json"))
33
+ resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
34
+ if coverage_files.empty? && resultset_files.empty?
35
+ warn "No coverage files found in #{coverage_dir}/results/"
36
+ return nil
37
+ end
38
+
39
+ coverage_data = load_and_merge_coverage(coverage_files)
40
+ result = {}
41
+
42
+ # Collect all methods from all files
43
+ all_methods = []
44
+ source_files.each do |file|
45
+ all_methods.concat(extract_methods(file))
46
+ end
47
+
48
+ # Group by class path and calculate coverage
49
+ all_methods.group_by { |m| m[:class_path] }.each do |class_path, methods|
50
+ class_result = { "class_methods" => {}, "instance_methods" => {} }
51
+ total_covered = 0
52
+ total_relevant = 0
53
+
54
+ methods.each do |method|
55
+ file_coverage = coverage_data[method[:file]]
56
+ next unless file_coverage
57
+
58
+ cov = calculate_coverage(file_coverage, method[:start_line], method[:end_line])
59
+ next unless cov
60
+
61
+ # Store [percent, lines_string] - compact format
62
+ method_data = [cov[:percent], cov[:lines]]
63
+ if method[:scope] == :class
64
+ class_result["class_methods"][method[:name]] = method_data
65
+ else
66
+ class_result["instance_methods"][method[:name]] = method_data
67
+ end
68
+
69
+ total_covered += cov[:covered]
70
+ total_relevant += cov[:relevant]
71
+ end
72
+
73
+ next if class_result["class_methods"].empty? && class_result["instance_methods"].empty?
74
+
75
+ if total_relevant > 0
76
+ class_result["total"] = (total_covered.to_f / total_relevant * 100).round(1)
77
+ end
78
+
79
+ result[class_path] = class_result
80
+ end
81
+
82
+ File.write(output_path, JSON.pretty_generate(result))
83
+ puts "Generated method coverage: #{output_path} (#{result.size} classes/modules)"
84
+ result
85
+ end
86
+
87
+ private
88
+
89
+ def load_and_merge_coverage(coverage_files)
90
+ merged = {}
91
+
92
+ # Read from coverage.json files
93
+ coverage_files.each do |file|
94
+ data = JSON.parse(File.read(file))
95
+ merge_coverage_data(merged, data["coverage"])
96
+ end
97
+
98
+ # Also read from .resultset.json files (worker/subprocess results)
99
+ resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
100
+ resultset_files.each do |file|
101
+ data = JSON.parse(File.read(file))
102
+ # Resultsets are nested: { "suite_name" => { "coverage" => { ... } } }
103
+ data.each do |_suite_name, suite_data|
104
+ next unless suite_data.is_a?(Hash) && suite_data["coverage"]
105
+ merge_coverage_data(merged, suite_data["coverage"])
106
+ end
107
+ end
108
+
109
+ merged
110
+ end
111
+
112
+ def merge_coverage_data(merged, coverage_hash)
113
+ coverage_hash.each do |path, info|
114
+ # Normalize Docker /app/... paths to local paths
115
+ local_path = path.sub(%r{^/app/}, "")
116
+ lines = info["lines"]
117
+ if merged[local_path]
118
+ merged[local_path] = merge_line_coverage(merged[local_path], lines)
119
+ else
120
+ merged[local_path] = lines
121
+ end
122
+ end
123
+ end
124
+
125
+ def merge_line_coverage(lines_a, lines_b)
126
+ max_len = [lines_a.size, lines_b.size].max
127
+ (0...max_len).map do |i|
128
+ a = lines_a[i]
129
+ b = lines_b[i]
130
+ # nil means not relevant (comments, blank lines), "ignored" also not relevant
131
+ # If EITHER run says nil, treat as not relevant - a comment can't become executable
132
+ # This handles cases where different test suites have slightly different coverage metadata
133
+ if a.nil? || a == "ignored" || b.nil? || b == "ignored"
134
+ # Both nil -> nil, one nil -> nil (trust the nil, it means non-executable)
135
+ # Unless both are numeric, in which case take max
136
+ if (a.nil? || a == "ignored") && (b.nil? || b == "ignored")
137
+ nil
138
+ elsif a.nil? || a == "ignored"
139
+ # a is nil, b is numeric - but nil means not relevant, so prefer nil
140
+ # unless b > 0 (was actually executed, so must be real code)
141
+ b.to_i > 0 ? b : nil
142
+ else
143
+ # b is nil, a is numeric
144
+ a.to_i > 0 ? a : nil
145
+ end
146
+ else
147
+ [a.to_i, b.to_i].max
148
+ end
149
+ end
150
+ end
151
+
152
+ def source_files
153
+ source_dirs.flat_map { |dir| Dir.glob("#{dir}/**/*.rb") }
154
+ end
155
+
156
+ def extract_methods(file)
157
+ source = File.read(file)
158
+ result = Prism.parse(source)
159
+ methods = []
160
+
161
+ visitor = MethodVisitor.new(methods, file)
162
+ result.value.accept(visitor)
163
+
164
+ methods
165
+ rescue => e
166
+ warn "Failed to parse #{file}: #{e.message}"
167
+ []
168
+ end
169
+
170
+ def calculate_coverage(file_lines, start_line, end_line)
171
+ relevant = 0
172
+ covered = 0
173
+ lines_str = +"" # unfrozen string
174
+
175
+ # Skip first line (def) and last line (end) - only count method body
176
+ body_start = start_line + 1
177
+ body_end = end_line - 1
178
+
179
+ return nil if body_start > body_end # empty method body
180
+
181
+ (body_start..body_end).each do |line_num|
182
+ next if line_num < 1 || line_num > file_lines.size
183
+ line_cov = file_lines[line_num - 1] # array is 0-indexed
184
+ if line_cov.nil? || line_cov == "ignored"
185
+ lines_str << "-" # not relevant
186
+ elsif line_cov.to_i > 0
187
+ lines_str << "1" # covered
188
+ relevant += 1
189
+ covered += 1
190
+ else
191
+ lines_str << "0" # not covered
192
+ relevant += 1
193
+ end
194
+ end
195
+
196
+ return nil if relevant == 0
197
+ { covered: covered, relevant: relevant, percent: (covered.to_f / relevant * 100).round(1), lines: lines_str }
198
+ end
199
+
200
+ # Prism AST visitor to extract method definitions with class context
201
+ class MethodVisitor < Prism::Visitor
202
+ def initialize(methods, file)
203
+ @methods = methods
204
+ @file = file
205
+ @namespace_stack = [] # track current class/module nesting
206
+ @singleton_depth = 0 # track if we're inside class << self
207
+ end
208
+
209
+ def visit_class_node(node)
210
+ name = constant_path_to_string(node.constant_path)
211
+ @namespace_stack.push(name)
212
+ super
213
+ @namespace_stack.pop
214
+ end
215
+
216
+ def visit_module_node(node)
217
+ name = constant_path_to_string(node.constant_path)
218
+ @namespace_stack.push(name)
219
+ super
220
+ @namespace_stack.pop
221
+ end
222
+
223
+ def visit_singleton_class_node(node)
224
+ @singleton_depth += 1
225
+ super
226
+ @singleton_depth -= 1
227
+ end
228
+
229
+ def visit_def_node(node)
230
+ return super if @namespace_stack.empty? # skip top-level methods
231
+
232
+ scope = if node.receiver || @singleton_depth > 0
233
+ :class
234
+ else
235
+ :instance
236
+ end
237
+
238
+ @methods << {
239
+ name: node.name.to_s,
240
+ scope: scope,
241
+ start_line: node.location.start_line,
242
+ end_line: node.location.end_line,
243
+ class_path: @namespace_stack.join("::"),
244
+ file: @file
245
+ }
246
+
247
+ super
248
+ end
249
+
250
+ private
251
+
252
+ def constant_path_to_string(node)
253
+ case node
254
+ when Prism::ConstantReadNode
255
+ node.name.to_s
256
+ when Prism::ConstantPathNode
257
+ parent = node.parent ? constant_path_to_string(node.parent) + "::" : ""
258
+ parent + node.name.to_s
259
+ else
260
+ node.to_s
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Renders inverse-blend HUD overlays (FPS counter, fast-forward indicator)
5
+ # on top of the game viewport.
6
+ #
7
+ # White source pixels invert the destination, so the text is always
8
+ # readable regardless of the game's background color. Transparent
9
+ # regions pass through unchanged.
10
+ #
11
+ # @example
12
+ # hud = OverlayRenderer.new(font: font, blend_mode: inverse_blend)
13
+ # hud.set_fps("59.7 FPS")
14
+ # hud.set_ff_label(">> 2x")
15
+ # # inside render loop:
16
+ # hud.draw(r, dest_rect)
17
+ class OverlayRenderer
18
+ # @param font [Teek::SDL2::Font] used to render overlay text
19
+ # @param blend_mode [Integer] SDL blend mode (typically inverse blend)
20
+ def initialize(font:, blend_mode:)
21
+ @font = font
22
+ @blend_mode = blend_mode
23
+ @crop_h = compute_crop_h(font)
24
+ @fps_tex = nil
25
+ @ff_tex = nil
26
+ end
27
+
28
+ # Update the FPS counter text. Pass nil to hide.
29
+ # @param text [String, nil]
30
+ def set_fps(text)
31
+ @fps_tex&.destroy
32
+ @fps_tex = text ? build_tex(text) : nil
33
+ end
34
+
35
+ # Update the fast-forward indicator label. Pass nil to hide.
36
+ # @param text [String, nil]
37
+ def set_ff_label(text)
38
+ @ff_tex&.destroy
39
+ @ff_tex = text ? build_tex(text) : nil
40
+ end
41
+
42
+ # Whether the FPS overlay is currently showing.
43
+ # @return [Boolean]
44
+ def fps_visible?
45
+ !!@fps_tex
46
+ end
47
+
48
+ # Whether the fast-forward label is currently showing.
49
+ # @return [Boolean]
50
+ def ff_visible?
51
+ !!@ff_tex
52
+ end
53
+
54
+ # Draw all active overlays.
55
+ #
56
+ # FPS is positioned top-right; FF label is positioned top-left.
57
+ # Both are inset from the game area defined by +dest+.
58
+ #
59
+ # @param r [Teek::SDL2::Renderer]
60
+ # @param dest [Array(Integer,Integer,Integer,Integer), nil] game area rect
61
+ # @param show_fps [Boolean] whether to draw the FPS counter
62
+ # @param show_ff [Boolean] whether to draw the FF indicator
63
+ def draw(r, dest, show_fps: true, show_ff: false)
64
+ if show_ff && @ff_tex
65
+ ox = dest ? dest[0] : 0
66
+ oy = dest ? dest[1] : 0
67
+ draw_tex(r, @ff_tex, ox + 4, oy + 4)
68
+ end
69
+
70
+ if show_fps && @fps_tex
71
+ fx = (dest ? dest[0] + dest[2] : r.output_size[0]) - @fps_tex.width - 6
72
+ fy = (dest ? dest[1] : 0) + 4
73
+ draw_tex(r, @fps_tex, fx, fy)
74
+ end
75
+ end
76
+
77
+ # Free all textures.
78
+ def destroy
79
+ @fps_tex&.destroy
80
+ @fps_tex = nil
81
+ @ff_tex&.destroy
82
+ @ff_tex = nil
83
+ end
84
+
85
+ private
86
+
87
+ def build_tex(text)
88
+ return nil unless @font
89
+ tex = @font.render_text(text, 255, 255, 255)
90
+ tex.blend_mode = @blend_mode
91
+ tex
92
+ end
93
+
94
+ # Crop to ascent + partial descender to avoid alpha artifacts
95
+ # visible under inverse blending.
96
+ def draw_tex(r, tex, x, y)
97
+ tw = tex.width
98
+ th = @crop_h || tex.height
99
+ r.copy(tex, [0, 0, tw, th], [x, y, tw, th])
100
+ end
101
+
102
+ def compute_crop_h(font)
103
+ return nil unless font
104
+ ascent = font.ascent
105
+ full_h = font.measure('p')[1]
106
+ [ascent + (full_h - ascent) / 2, full_h - 1].min
107
+ end
108
+ end
109
+ end