rgame 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 (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. metadata +215 -0
@@ -0,0 +1,64 @@
1
+ #ifndef RGAME_APP_GL_H
2
+ #define RGAME_APP_GL_H
3
+
4
+ #include "rgame/core.h"
5
+
6
+ /*
7
+ * The one thing other engine files need from inside `struct rgame_app`: its GL
8
+ * context. Private to the implementation — this header is not under include/,
9
+ * so nothing outside ext/rgame_core/ can reach it, and the public API stays a
10
+ * single opaque handle.
11
+ */
12
+
13
+ /*
14
+ * Whatever was current before a switch, so it can be put back. Opaque handles;
15
+ * the header still names no SDL types.
16
+ */
17
+ typedef struct {
18
+ void *window;
19
+ void *context;
20
+ } rgame_gl_context_save;
21
+
22
+ /*
23
+ * Makes this app's GL context current on the calling thread. Returns 1 on
24
+ * success, 0 if SDL refused — which includes the case of an app whose window
25
+ * has already been destroyed, so this doubles as "is there still a context
26
+ * here?".
27
+ *
28
+ * GL has no notion of "which window" per call — every call acts on whatever
29
+ * context is current. With one window that is already the right one, but
30
+ * uploading a texture while a *second* window's context happened to be current
31
+ * would put the texture on the wrong GPU context and draw nothing, with no
32
+ * error anywhere. So the calls that own resources say which app they mean, and
33
+ * this is how they honour it.
34
+ *
35
+ * **Pass `saved` and restore it afterwards.** Switching contexts and leaving
36
+ * them switched is worse than not switching at all: freeing an image belonging
37
+ * to one window in the middle of *another* window's frame would leave that
38
+ * frame to be submitted into the wrong context, and it would come out blank.
39
+ * That is not hypothetical — a garbage collector picks the moment. `saved` may
40
+ * be NULL only where the caller genuinely does not care what was current.
41
+ */
42
+ int rgame_app_gl_make_current(rgame_app *app, rgame_gl_context_save *saved);
43
+
44
+ /* Puts back what `make_current` displaced. Safe with NULL and with a save that
45
+ * captured "no context current". */
46
+ void rgame_app_gl_restore(const rgame_gl_context_save *saved);
47
+
48
+ /*
49
+ * Keeps the app *struct* alive while something else still points at it.
50
+ *
51
+ * This is not a way to keep the window open — `rgame_app_destroy` closes the
52
+ * window and context immediately however many references are outstanding. It
53
+ * only delays freeing the memory, so that a holder which outlives the app
54
+ * finds a valid pointer whose context is gone (make_current answers 0) instead
55
+ * of reading freed memory.
56
+ *
57
+ * Every retain needs exactly one release. image.c is the only caller: a Ruby
58
+ * image and its app can become garbage in the same collection, and nothing
59
+ * says which gets swept first.
60
+ */
61
+ void rgame_app_gl_retain(rgame_app *app);
62
+ void rgame_app_gl_release(rgame_app *app);
63
+
64
+ #endif /* RGAME_APP_GL_H */
@@ -0,0 +1,42 @@
1
+ #include "app/frame_loop.h"
2
+
3
+ void rgame_frame_loop_init(rgame_frame_loop *loop) {
4
+ loop->accumulated_seconds = 0.0;
5
+ }
6
+
7
+ int rgame_frame_loop_advance(rgame_frame_loop *loop, double elapsed_seconds,
8
+ double tick_seconds, int max_ticks_per_frame) {
9
+ loop->accumulated_seconds += elapsed_seconds;
10
+
11
+ int ticks = 0;
12
+ while (loop->accumulated_seconds >= tick_seconds && ticks < max_ticks_per_frame) {
13
+ loop->accumulated_seconds -= tick_seconds;
14
+ ticks++;
15
+ }
16
+
17
+ if (ticks == max_ticks_per_frame) {
18
+ /* Hit the cap: we're behind. Drop the backlog so we don't spiral. */
19
+ loop->accumulated_seconds = 0.0;
20
+ }
21
+
22
+ return ticks;
23
+ }
24
+
25
+ void rgame_fps_counter_init(rgame_fps_counter *counter) {
26
+ counter->frame_count = 0;
27
+ counter->window_elapsed_seconds = 0.0;
28
+ counter->fps = 0.0;
29
+ }
30
+
31
+ void rgame_fps_counter_tick(rgame_fps_counter *counter, double dt_seconds) {
32
+ counter->frame_count++;
33
+ counter->window_elapsed_seconds += dt_seconds;
34
+
35
+ if (counter->window_elapsed_seconds >= 1.0) {
36
+ /* Divide by the real window length, not a hardcoded 1.0, since the
37
+ * window usually overshoots slightly past 1 second. */
38
+ counter->fps = counter->frame_count / counter->window_elapsed_seconds;
39
+ counter->frame_count = 0;
40
+ counter->window_elapsed_seconds = 0.0;
41
+ }
42
+ }
@@ -0,0 +1,54 @@
1
+ #ifndef RGAME_FRAME_LOOP_H
2
+ #define RGAME_FRAME_LOOP_H
3
+
4
+ /*
5
+ * Pure fixed-timestep + FPS logic — no SDL, no GL, no I/O.
6
+ *
7
+ * This is "layer 1" per CLAUDE.md's abstraction strategy: the tricky
8
+ * time-accounting decisions live here as plain arithmetic on plain structs,
9
+ * so they can be exercised by the Check suite without a window or clock.
10
+ * core.c owns the real loop and feeds this real elapsed time from SDL.
11
+ */
12
+
13
+ /*
14
+ * Fixed-timestep accumulator. Real elapsed time is poured in each frame via
15
+ * rgame_frame_loop_advance, which reports how many whole simulation ticks are
16
+ * now due. Leftover sub-tick time is retained for next frame, so simulation
17
+ * stays decoupled from a variable/vsync render rate.
18
+ */
19
+ typedef struct {
20
+ double accumulated_seconds;
21
+ } rgame_frame_loop;
22
+
23
+ void rgame_frame_loop_init(rgame_frame_loop *loop);
24
+
25
+ /*
26
+ * Adds elapsed_seconds to the accumulator and returns how many ticks of
27
+ * tick_seconds are now due, draining that much time from the accumulator.
28
+ *
29
+ * max_ticks_per_frame caps the ticks returned from a single call. If that cap
30
+ * is hit, the remaining backlog is dropped (accumulator reset to 0) rather than
31
+ * carried forward — this is the "spiral of death" guard: if a frame is so slow
32
+ * the sim can't catch up within the cap, we let time slow down instead of
33
+ * accumulating an ever-growing debt that makes every subsequent frame worse.
34
+ */
35
+ int rgame_frame_loop_advance(rgame_frame_loop *loop, double elapsed_seconds,
36
+ double tick_seconds, int max_ticks_per_frame);
37
+
38
+ /*
39
+ * Rolling frames-per-second counter. Counts frames over a ~1 second window and
40
+ * republishes `fps` at the end of each window. For a debug readout, not
41
+ * gameplay logic (see docs/c_engine_feature_specs.md section 1).
42
+ */
43
+ typedef struct {
44
+ int frame_count;
45
+ double window_elapsed_seconds;
46
+ double fps;
47
+ } rgame_fps_counter;
48
+
49
+ void rgame_fps_counter_init(rgame_fps_counter *counter);
50
+
51
+ /* Records one frame of dt_seconds; updates `fps` once the 1s window closes. */
52
+ void rgame_fps_counter_tick(rgame_fps_counter *counter, double dt_seconds);
53
+
54
+ #endif /* RGAME_FRAME_LOOP_H */
@@ -0,0 +1,466 @@
1
+ /*
2
+ * audio.c — the sound device, and the two kinds of sound.
3
+ *
4
+ * Layer 3 in CLAUDE.md's scheme, and by far the thinnest of the three: the
5
+ * mixing, the voices, the resampling and the device thread are all miniaudio's,
6
+ * and Ogg Vorbis is vorbis_decoder.c's. What is left here is opening the
7
+ * device, loading two kinds of file, and the small amount of policy the engine
8
+ * has an opinion about.
9
+ *
10
+ * Unusually for a layer-3 file it is properly *tested* rather than merely
11
+ * looked at, because miniaudio's null backend is a real device that consumes
12
+ * frames on a timer and produces silence. `test/test_audio.c` drives all of
13
+ * this with no sound card — see the note on the fallback in
14
+ * `rgame_audio_create`.
15
+ *
16
+ * ---------------------------------------------------------------------------
17
+ * Why samples and songs are different types
18
+ * ---------------------------------------------------------------------------
19
+ *
20
+ * A footstep is played fifty times a minute and must overlap itself; a music
21
+ * track plays once, loops, and gets stopped. Those want opposite things from
22
+ * the mixer:
23
+ *
24
+ * sample decoded into memory once, then a fresh voice per play, each one
25
+ * cleaning itself up. No handle, so nothing to stop or query.
26
+ * song one long-lived voice reading from disk, which can be started,
27
+ * stopped and asked whether it is playing.
28
+ *
29
+ * One type with a flag would have to answer `playing?` for a sound that has
30
+ * five voices or none, and the honest answer is that the question does not
31
+ * apply. Two types, and the question cannot be asked.
32
+ */
33
+
34
+ #include "rgame/core.h"
35
+
36
+ #include "audio/audio_internal.h"
37
+ #include "vendor/miniaudio.h"
38
+ #include "audio/vorbis_decoder.h"
39
+
40
+ #include <stdio.h>
41
+ #include <stdlib.h>
42
+ #include <string.h>
43
+
44
+ struct rgame_audio {
45
+ ma_resource_manager resources;
46
+ ma_engine engine;
47
+ };
48
+
49
+ struct rgame_sample {
50
+ rgame_audio *audio;
51
+ /* The resource manager is keyed by path, so the path *is* the handle for
52
+ * playback. */
53
+ char *path;
54
+ /*
55
+ * A voice that is never started. Its only jobs are to fail loudly at load
56
+ * time if the file is not a sound, and to hold the resource manager's
57
+ * decoded copy alive for as long as this sample exists — the manager
58
+ * reference counts by path, so every `play` below finds the data already
59
+ * decoded instead of decoding again.
60
+ */
61
+ ma_sound decoded;
62
+ /*
63
+ * Every voice of this sample plays into this group, which exists so that
64
+ * volume has somewhere to live. A fire-and-forget voice hands back no
65
+ * handle to set anything on, so per-*play* volume is not available; per
66
+ * sample is, and it reaches voices already sounding.
67
+ */
68
+ ma_sound_group group;
69
+ };
70
+
71
+ struct rgame_song {
72
+ rgame_audio *audio;
73
+ ma_sound sound;
74
+ };
75
+
76
+ /* See rgame_audio_live_sounds. */
77
+ static long live_sounds = 0;
78
+
79
+ long rgame_audio_live_sounds(void) {
80
+ return live_sounds;
81
+ }
82
+
83
+ static void set_error(char *err, size_t err_size, const char *format, const char *detail) {
84
+ if (err && err_size > 0) {
85
+ snprintf(err, err_size, format, detail);
86
+ }
87
+ }
88
+
89
+ /* Volume is a gain, so above 1.0 amplifies and is the caller's business; below
90
+ * zero is not a volume at all. */
91
+ static float clamp_volume(float volume) {
92
+ return volume < 0.0f ? 0.0f : volume;
93
+ }
94
+
95
+ /* ------------------------------------------------------------------------- *
96
+ * The device
97
+ * ------------------------------------------------------------------------- */
98
+
99
+ /* Shared by the real constructor and the offline one below, which differ only
100
+ * in whether a device is opened. */
101
+ static rgame_audio *create_audio(int offline, unsigned int sample_rate, char *err,
102
+ size_t err_size) {
103
+ rgame_audio *audio = calloc(1, sizeof(rgame_audio));
104
+ if (!audio) {
105
+ set_error(err, err_size, "%s", "out of memory");
106
+ return NULL;
107
+ }
108
+
109
+ /*
110
+ * The resource manager is where the vorbis backend gets registered, and
111
+ * registering it here is what makes every .ogg in the engine readable —
112
+ * miniaudio cannot read one otherwise. Everything that loads a file goes
113
+ * through this manager.
114
+ */
115
+ static ma_decoding_backend_vtable *decoders[] = { &rgame_vorbis_decoding_backend };
116
+
117
+ ma_resource_manager_config resources = ma_resource_manager_config_init();
118
+ resources.ppCustomDecodingBackendVTables = decoders;
119
+ resources.customDecodingBackendCount = 1;
120
+
121
+
122
+ if (ma_resource_manager_init(&resources, &audio->resources) != MA_SUCCESS) {
123
+ free(audio);
124
+ set_error(err, err_size, "%s", "could not start the audio resource manager");
125
+ return NULL;
126
+ }
127
+
128
+ /*
129
+ * No explicit backend list: miniaudio tries the real ones in order and
130
+ * falls back to a null device when none of them can open. So a machine with
131
+ * no sound card — a build server, a container — gets a working engine that
132
+ * plays silence rather than an error a game has to handle. Verified: with
133
+ * ALSA and PulseAudio unavailable, the chosen backend is "Null".
134
+ */
135
+ ma_engine_config engine = ma_engine_config_init();
136
+ engine.pResourceManager = &audio->resources;
137
+ if (offline) {
138
+ /* No device at all: the caller pumps the mixer. Channels and rate have
139
+ * to be stated, because without a device there is nothing to ask. */
140
+ engine.noDevice = MA_TRUE;
141
+ engine.channels = 2;
142
+ engine.sampleRate = sample_rate;
143
+ }
144
+
145
+ if (ma_engine_init(&engine, &audio->engine) != MA_SUCCESS) {
146
+ ma_resource_manager_uninit(&audio->resources);
147
+ free(audio);
148
+ set_error(err, err_size, "%s", "could not start the audio engine");
149
+ return NULL;
150
+ }
151
+
152
+ return audio;
153
+ }
154
+
155
+ rgame_audio *rgame_audio_create(char *err, size_t err_size) {
156
+ return create_audio(0, 0, err, err_size);
157
+ }
158
+
159
+ rgame_audio *rgame_audio_create_offline(unsigned int sample_rate, char *err, size_t err_size) {
160
+ return create_audio(1, sample_rate, err, err_size);
161
+ }
162
+
163
+ unsigned int rgame_audio_read(rgame_audio *audio, float *out, unsigned int frames) {
164
+ if (!audio || !out) {
165
+ return 0;
166
+ }
167
+
168
+ ma_uint64 read = 0;
169
+ ma_engine_read_pcm_frames(&audio->engine, out, frames, &read);
170
+ return (unsigned int)read;
171
+ }
172
+
173
+ void rgame_audio_destroy(rgame_audio *audio) {
174
+ if (!audio) {
175
+ return;
176
+ }
177
+
178
+ /* The engine first: it owns the device thread, and the resource manager
179
+ * underneath is still holding the data that thread may be reading. */
180
+ ma_engine_uninit(&audio->engine);
181
+ ma_resource_manager_uninit(&audio->resources);
182
+ free(audio);
183
+ }
184
+
185
+ void rgame_audio_set_volume(rgame_audio *audio, float volume) {
186
+ if (audio) {
187
+ ma_engine_set_volume(&audio->engine, clamp_volume(volume));
188
+ }
189
+ }
190
+
191
+ float rgame_audio_volume(const rgame_audio *audio) {
192
+ /* ma_engine_get_volume takes a non-const pointer for no reason this call
193
+ * can honour; the cast keeps the query const for callers. */
194
+ return audio ? ma_engine_get_volume((ma_engine *)&audio->engine) : 0.0f;
195
+ }
196
+
197
+ const char *rgame_audio_backend(const rgame_audio *audio) {
198
+ if (!audio) {
199
+ return "none";
200
+ }
201
+
202
+ ma_device *device = ma_engine_get_device((ma_engine *)&audio->engine);
203
+ return device ? ma_get_backend_name(device->pContext->backend) : "none";
204
+ }
205
+
206
+ /*
207
+ * Is this a file the engine can actually play?
208
+ *
209
+ * Asked *before* handing the path to the resource manager, and the reason is a
210
+ * bug in miniaudio 0.11.25: when a load fails, `ma_resource_manager_data_buffer_node_acquire`
211
+ * frees the node and then reads a field of it a few lines later
212
+ * (miniaudio.h:70918 and :70926). Every rejected file is therefore a
213
+ * use-after-free — found by running these tests under AddressSanitizer, and
214
+ * reachable from anything that loads a decoded sound, including
215
+ * `ma_sound_init_from_file`.
216
+ *
217
+ * Checking first means the failing path is never entered: a file that is not a
218
+ * sound is turned away here, and one that is loads successfully. Patching the
219
+ * vendored copy would work until the next update silently dropped the fix, so
220
+ * this goes around it instead — worth re-testing when miniaudio is bumped.
221
+ *
222
+ * The check is not merely a formality either: it uses the same decoders the
223
+ * real load will, so "readable here" and "readable there" cannot disagree.
224
+ */
225
+ static int file_is_playable(const char *path) {
226
+ static ma_decoding_backend_vtable *decoders[] = { &rgame_vorbis_decoding_backend };
227
+
228
+ ma_decoder_config config = ma_decoder_config_init_default();
229
+ config.ppCustomBackendVTables = decoders;
230
+ config.customBackendCount = 1;
231
+
232
+ ma_decoder decoder;
233
+ if (ma_decoder_init_file(path, &config, &decoder) != MA_SUCCESS) {
234
+ return 0;
235
+ }
236
+
237
+ ma_decoder_uninit(&decoder);
238
+ return 1;
239
+ }
240
+
241
+ /* ------------------------------------------------------------------------- *
242
+ * Samples
243
+ * ------------------------------------------------------------------------- */
244
+
245
+ rgame_sample *rgame_sample_load(rgame_audio *audio, const char *path, char *err,
246
+ size_t err_size) {
247
+ if (!audio || !path) {
248
+ set_error(err, err_size, "%s", "no audio device or path given");
249
+ return NULL;
250
+ }
251
+
252
+ rgame_sample *sample = calloc(1, sizeof(rgame_sample));
253
+ if (!sample) {
254
+ set_error(err, err_size, "%s", "out of memory");
255
+ return NULL;
256
+ }
257
+
258
+ /* Turned away before the resource manager ever sees it — see
259
+ * file_is_playable for why that order matters. */
260
+ if (!file_is_playable(path)) {
261
+ free(sample);
262
+ set_error(err, err_size, "could not load %s", path);
263
+ return NULL;
264
+ }
265
+
266
+ /*
267
+ * Decoded now, synchronously, rather than on first play: the decode then
268
+ * does not happen during a frame, and the sound below holds the resource
269
+ * manager's copy alive so every `play` finds it already done.
270
+ */
271
+ if (ma_sound_init_from_file(&audio->engine, path, MA_SOUND_FLAG_DECODE, NULL, NULL,
272
+ &sample->decoded) != MA_SUCCESS) {
273
+ free(sample);
274
+ set_error(err, err_size, "could not load %s", path);
275
+ return NULL;
276
+ }
277
+
278
+ if (ma_sound_group_init(&audio->engine, 0, NULL, &sample->group) != MA_SUCCESS) {
279
+ ma_sound_uninit(&sample->decoded);
280
+ free(sample);
281
+ set_error(err, err_size, "%s", "could not create a mixer group for the sample");
282
+ return NULL;
283
+ }
284
+
285
+ sample->path = malloc(strlen(path) + 1);
286
+ if (!sample->path) {
287
+ ma_sound_group_uninit(&sample->group);
288
+ ma_sound_uninit(&sample->decoded);
289
+ free(sample);
290
+ set_error(err, err_size, "%s", "out of memory");
291
+ return NULL;
292
+ }
293
+ strcpy(sample->path, path);
294
+
295
+ sample->audio = audio;
296
+ live_sounds++;
297
+ return sample;
298
+ }
299
+
300
+ void rgame_sample_destroy(rgame_sample *sample) {
301
+ if (!sample) {
302
+ return;
303
+ }
304
+
305
+ /*
306
+ * Stop the group before tearing it down: its voices are nodes attached to
307
+ * it, and a voice still sounding when its group goes is reading through
308
+ * something being freed on another thread.
309
+ *
310
+ * `ma_sound_group_uninit` turns out to handle that itself, so removing the
311
+ * stop survives the suite and the sanitizer. Kept anyway: "the thing is
312
+ * silent before it is freed" is one line, and relying on a teardown
313
+ * function to also be a stop function is the kind of assumption that holds
314
+ * until a version bump.
315
+ */
316
+ ma_sound_group_stop(&sample->group);
317
+ ma_sound_group_uninit(&sample->group);
318
+
319
+ /* Releases this sample's claim on the decoded data. Other samples of the
320
+ * same file hold their own, so the last one out frees it. */
321
+ ma_sound_uninit(&sample->decoded);
322
+ free(sample->path);
323
+ free(sample);
324
+ live_sounds--;
325
+ }
326
+
327
+ void rgame_sample_play(rgame_sample *sample) {
328
+ if (!sample) {
329
+ return;
330
+ }
331
+
332
+ /*
333
+ * Fire and forget: the engine spawns a voice from the already-decoded data,
334
+ * plays it, and cleans it up itself. Calling this again while the last one
335
+ * is still sounding gives a second voice rather than restarting the first —
336
+ * which is the whole difference between a sample and a song.
337
+ *
338
+ * The return value is ignored deliberately. Running out of voices is not
339
+ * something a game can do anything useful about mid-frame, and a footstep
340
+ * that does not play is better than an exception during `update`.
341
+ */
342
+ ma_engine_play_sound(&sample->audio->engine, sample->path, &sample->group);
343
+ }
344
+
345
+ void rgame_sample_set_volume(rgame_sample *sample, float volume) {
346
+ if (sample) {
347
+ ma_sound_group_set_volume(&sample->group, clamp_volume(volume));
348
+ }
349
+ }
350
+
351
+ float rgame_sample_volume(const rgame_sample *sample) {
352
+ return sample ? ma_sound_group_get_volume((ma_sound_group *)&sample->group) : 0.0f;
353
+ }
354
+
355
+ /* ------------------------------------------------------------------------- *
356
+ * Songs
357
+ * ------------------------------------------------------------------------- */
358
+
359
+ rgame_song *rgame_song_load(rgame_audio *audio, const char *path, char *err, size_t err_size) {
360
+ if (!audio || !path) {
361
+ set_error(err, err_size, "%s", "no audio device or path given");
362
+ return NULL;
363
+ }
364
+
365
+ rgame_song *song = calloc(1, sizeof(rgame_song));
366
+ if (!song) {
367
+ set_error(err, err_size, "%s", "out of memory");
368
+ return NULL;
369
+ }
370
+
371
+ /* Checked first, as samples are. Streaming happens to take a different path
372
+ * through miniaudio and does not trip the bug described above, but "some
373
+ * loads validate first and others do not" is a distinction nobody should
374
+ * have to remember. */
375
+ if (!file_is_playable(path)) {
376
+ free(song);
377
+ set_error(err, err_size, "could not load %s", path);
378
+ return NULL;
379
+ }
380
+
381
+ /*
382
+ * Streamed, so a long track costs a buffer rather than its whole decoded
383
+ * length in memory — thirty megabytes for three minutes of CD-quality
384
+ * stereo, measured, if it were decoded.
385
+ *
386
+ * No test can see the difference: with a quarter-second fixture the two
387
+ * behave identically, and the mutation to MA_SOUND_FLAG_DECODE survives.
388
+ * What it protects is a number nobody measures until a player's machine
389
+ * starts swapping.
390
+ */
391
+ if (ma_sound_init_from_file(&audio->engine, path, MA_SOUND_FLAG_STREAM, NULL, NULL,
392
+ &song->sound) != MA_SUCCESS) {
393
+ free(song);
394
+ set_error(err, err_size, "could not load %s", path);
395
+ return NULL;
396
+ }
397
+
398
+ song->audio = audio;
399
+ live_sounds++;
400
+ return song;
401
+ }
402
+
403
+ void rgame_song_destroy(rgame_song *song) {
404
+ if (!song) {
405
+ return;
406
+ }
407
+
408
+ ma_sound_uninit(&song->sound);
409
+ free(song);
410
+ live_sounds--;
411
+ }
412
+
413
+ void rgame_song_play(rgame_song *song, int looping) {
414
+ if (!song) {
415
+ return;
416
+ }
417
+
418
+ ma_sound_set_looping(&song->sound, looping ? MA_TRUE : MA_FALSE);
419
+
420
+ /*
421
+ * Rewind first. A song that played to its end sits at the end, and starting
422
+ * it again without seeking produces nothing at all — the failure being
423
+ * "the music stopped working after the first time", which is a miserable
424
+ * one to track down. It also makes `stop` then `play` mean "from the
425
+ * beginning" rather than "resume", which is what the layer being replaced
426
+ * did.
427
+ *
428
+ * Not covered by a test, and the mutation that removes it survives.
429
+ * Observing it needs a song played nearly to its end, and a streamed sound
430
+ * cannot be driven that far by the offline test device — it refills its
431
+ * buffers on a thread that a tight pumping loop starves. Checking it would
432
+ * mean exposing a playback-position query that nothing else wants. Verified
433
+ * by ear instead: `ruby ext/rgame_core/example.rb <a sound file>` binds
434
+ * Return to stop and start a song, so pressing it twice is the check. The
435
+ * C driver has no audio in it — a sound file is a thing you bring.
436
+ */
437
+ ma_sound_seek_to_pcm_frame(&song->sound, 0);
438
+ ma_sound_start(&song->sound);
439
+ }
440
+
441
+ void rgame_song_stop(rgame_song *song) {
442
+ if (song) {
443
+ /* Stop leaves the playhead where it is; `play` rewinds, so together
444
+ * they mean "start from the beginning" rather than "resume". That is
445
+ * what the layer being replaced did. */
446
+ ma_sound_stop(&song->sound);
447
+ }
448
+ }
449
+
450
+ int rgame_song_playing(const rgame_song *song) {
451
+ return song && ma_sound_is_playing((ma_sound *)&song->sound) ? 1 : 0;
452
+ }
453
+
454
+ int rgame_song_looping(const rgame_song *song) {
455
+ return song && ma_sound_is_looping((ma_sound *)&song->sound) ? 1 : 0;
456
+ }
457
+
458
+ void rgame_song_set_volume(rgame_song *song, float volume) {
459
+ if (song) {
460
+ ma_sound_set_volume(&song->sound, clamp_volume(volume));
461
+ }
462
+ }
463
+
464
+ float rgame_song_volume(const rgame_song *song) {
465
+ return song ? ma_sound_get_volume((ma_sound *)&song->sound) : 0.0f;
466
+ }
@@ -0,0 +1,45 @@
1
+ #ifndef RGAME_AUDIO_INTERNAL_H
2
+ #define RGAME_AUDIO_INTERNAL_H
3
+
4
+ #include <stddef.h>
5
+
6
+ #include "rgame/core.h"
7
+
8
+ /*
9
+ * The counter tests assert against, kept out of the public header the same way
10
+ * the texture and font-page counters are.
11
+ *
12
+ * A leaked sound is invisible: nothing sounds wrong, nothing gets slower, and
13
+ * memory grows over an hour of play. Counting the live ones makes "the sound
14
+ * went away when the game dropped it" something a spec can state.
15
+ *
16
+ * Single-threaded, like the rest of the engine's bookkeeping.
17
+ */
18
+ long rgame_audio_live_sounds(void);
19
+
20
+ /*
21
+ * An audio device with no device behind it: nothing is sent anywhere, and the
22
+ * caller pumps the mixer by hand and gets the samples back.
23
+ *
24
+ * This is the audio equivalent of reading the framebuffer, and it exists for
25
+ * the same reason. Everything else a test can check about sound is a
26
+ * transition — it says it is playing, it says it stopped — and a stack that
27
+ * reported all of those correctly while emitting silence would pass every one
28
+ * of them. Silence is also exactly what a broken audio stack produces, so
29
+ * "assert on what actually comes out" is worth more here than almost anywhere.
30
+ *
31
+ * Test-only, hence its place in this header rather than in core.h. A game wants
32
+ * the real constructor, which opens a real device.
33
+ */
34
+ rgame_audio *rgame_audio_create_offline(unsigned int sample_rate, char *err, size_t err_size);
35
+
36
+ /*
37
+ * Mixes the next `frames` frames of stereo output into `out`, which must have
38
+ * room for `frames * 2` floats. Returns the number of frames written.
39
+ *
40
+ * Only meaningful on a device made by rgame_audio_create_offline; a real device
41
+ * is pumped by its own thread and there is nothing here to read.
42
+ */
43
+ unsigned int rgame_audio_read(rgame_audio *audio, float *out, unsigned int frames);
44
+
45
+ #endif /* RGAME_AUDIO_INTERNAL_H */