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.
- checksums.yaml +7 -0
- data/LICENSE +26 -0
- data/README.md +406 -0
- data/docs/api/README.md +167 -0
- data/docs/api/app.md +192 -0
- data/docs/api/assets.md +426 -0
- data/docs/api/audio.md +208 -0
- data/docs/api/components.md +321 -0
- data/docs/api/drawing.md +330 -0
- data/docs/api/game.md +99 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +179 -0
- data/docs/api/internals.md +110 -0
- data/docs/api/scene_graph.md +159 -0
- data/docs/api/signals.md +142 -0
- data/docs/api/systems.md +98 -0
- data/docs/api/text.md +116 -0
- data/docs/api/toolbox.md +240 -0
- data/docs/api/values.md +101 -0
- data/ext/README.md +225 -0
- data/ext/rgame_core/app/app.c +721 -0
- data/ext/rgame_core/app/app_gl.h +64 -0
- data/ext/rgame_core/app/frame_loop.c +42 -0
- data/ext/rgame_core/app/frame_loop.h +54 -0
- data/ext/rgame_core/audio/audio.c +466 -0
- data/ext/rgame_core/audio/audio_internal.h +45 -0
- data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
- data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
- data/ext/rgame_core/example.rb +188 -0
- data/ext/rgame_core/extconf.rb +167 -0
- data/ext/rgame_core/graphics/backend.c +52 -0
- data/ext/rgame_core/graphics/backend.h +64 -0
- data/ext/rgame_core/graphics/canvas.c +247 -0
- data/ext/rgame_core/graphics/canvas.h +143 -0
- data/ext/rgame_core/graphics/clip.c +87 -0
- data/ext/rgame_core/graphics/clip.h +89 -0
- data/ext/rgame_core/graphics/draw_queue.c +216 -0
- data/ext/rgame_core/graphics/draw_queue.h +174 -0
- data/ext/rgame_core/graphics/gl_backend.c +122 -0
- data/ext/rgame_core/graphics/gl_backend.h +43 -0
- data/ext/rgame_core/graphics/image.c +304 -0
- data/ext/rgame_core/graphics/image_internal.h +30 -0
- data/ext/rgame_core/graphics/primitives.c +189 -0
- data/ext/rgame_core/graphics/primitives.h +111 -0
- data/ext/rgame_core/graphics/recording.c +119 -0
- data/ext/rgame_core/graphics/recording.h +88 -0
- data/ext/rgame_core/graphics/texture.c +181 -0
- data/ext/rgame_core/graphics/texture.h +165 -0
- data/ext/rgame_core/graphics/transform.c +128 -0
- data/ext/rgame_core/graphics/transform.h +106 -0
- data/ext/rgame_core/include/rgame/core.h +577 -0
- data/ext/rgame_core/input/device_slots.c +103 -0
- data/ext/rgame_core/input/device_slots.h +93 -0
- data/ext/rgame_core/input/gamepad.c +145 -0
- data/ext/rgame_core/input/gamepad.h +63 -0
- data/ext/rgame_core/input/input.c +109 -0
- data/ext/rgame_core/input/input.h +99 -0
- data/ext/rgame_core/ruby/audio_ext.c +321 -0
- data/ext/rgame_core/ruby/core_ext.c +513 -0
- data/ext/rgame_core/ruby/core_ext.h +51 -0
- data/ext/rgame_core/ruby/font_ext.c +168 -0
- data/ext/rgame_core/ruby/image_ext.c +230 -0
- data/ext/rgame_core/ruby/recording_ext.c +186 -0
- data/ext/rgame_core/ruby/renderer_ext.c +376 -0
- data/ext/rgame_core/text/atlas.c +59 -0
- data/ext/rgame_core/text/atlas.h +85 -0
- data/ext/rgame_core/text/font.c +281 -0
- data/ext/rgame_core/text/font.h +139 -0
- data/ext/rgame_core/text/font_atlas.c +385 -0
- data/ext/rgame_core/text/font_internal.h +47 -0
- data/ext/rgame_core/text/glyph_cache.c +142 -0
- data/ext/rgame_core/text/glyph_cache.h +89 -0
- data/ext/rgame_core/vendor/README.md +159 -0
- data/ext/rgame_core/vendor/miniaudio.h +95864 -0
- data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
- data/ext/rgame_core/vendor/stb_image.h +7988 -0
- data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
- data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
- data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
- data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
- data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
- data/ext/rgame_util/color.c +19 -0
- data/ext/rgame_util/color.h +60 -0
- data/ext/rgame_util/color_ext.c +156 -0
- data/ext/rgame_util/extconf.rb +27 -0
- data/ext/rgame_util/tensor.c +186 -0
- data/ext/rgame_util/util_ext.c +27 -0
- data/ext/rgame_util/util_ext.h +16 -0
- data/lib/rgame/boot.rb +13 -0
- data/lib/rgame/core/app.rb +82 -0
- data/lib/rgame/core/asset_manager.rb +224 -0
- data/lib/rgame/core/audio.rb +124 -0
- data/lib/rgame/core/font.rb +49 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +77 -0
- data/lib/rgame/core/nine_slice.rb +163 -0
- data/lib/rgame/core/recording.rb +52 -0
- data/lib/rgame/core/renderer.rb +363 -0
- data/lib/rgame/core/sprite_sheet.rb +108 -0
- data/lib/rgame/core/tile_map_renderer.rb +160 -0
- data/lib/rgame/core/ui_atlas.rb +86 -0
- data/lib/rgame/core.rb +24 -0
- data/lib/rgame/engine/actor.rb +53 -0
- data/lib/rgame/engine/animation_set.rb +49 -0
- data/lib/rgame/engine/animator.rb +44 -0
- data/lib/rgame/engine/audio_bus.rb +24 -0
- data/lib/rgame/engine/audio_director.rb +29 -0
- data/lib/rgame/engine/body.rb +49 -0
- data/lib/rgame/engine/cached_label.rb +33 -0
- data/lib/rgame/engine/camera.rb +33 -0
- data/lib/rgame/engine/camera_view.rb +28 -0
- data/lib/rgame/engine/circle_collider.rb +32 -0
- data/lib/rgame/engine/collision_box.rb +34 -0
- data/lib/rgame/engine/collision_system.rb +44 -0
- data/lib/rgame/engine/component.rb +30 -0
- data/lib/rgame/engine/components/action_trigger.rb +41 -0
- data/lib/rgame/engine/components/animated_sprite.rb +63 -0
- data/lib/rgame/engine/components/character_body.rb +70 -0
- data/lib/rgame/engine/components/circle_collider.rb +44 -0
- data/lib/rgame/engine/components/collision_world.rb +103 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
- data/lib/rgame/engine/components/path_follow.rb +84 -0
- data/lib/rgame/engine/components/player_controller.rb +24 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +27 -0
- data/lib/rgame/engine/components/sprite.rb +31 -0
- data/lib/rgame/engine/components/targeting.rb +54 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +68 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +27 -0
- data/lib/rgame/engine/components/wander_controller.rb +60 -0
- data/lib/rgame/engine/debug_overlay.rb +106 -0
- data/lib/rgame/engine/i18n.rb +97 -0
- data/lib/rgame/engine/input/action_mapper.rb +46 -0
- data/lib/rgame/engine/input/actions.rb +41 -0
- data/lib/rgame/engine/input/player_controller.rb +14 -0
- data/lib/rgame/engine/matrix.rb +32 -0
- data/lib/rgame/engine/node2d.rb +271 -0
- data/lib/rgame/engine/path.rb +78 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/resettable.rb +67 -0
- data/lib/rgame/engine/scene/scene_stack.rb +65 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +71 -0
- data/lib/rgame/engine/tile_collision.rb +78 -0
- data/lib/rgame/engine/tile_map.rb +149 -0
- data/lib/rgame/engine/tileset.rb +101 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine.rb +68 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +129 -0
- data/lib/rgame/util/color.rb +27 -0
- data/lib/rgame/util/controls.rb +107 -0
- data/lib/rgame/util/tensor.rb +12 -0
- data/lib/rgame/util.rb +8 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +20 -0
- 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 */
|