mirage-engine 0.2.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
use core::time::Duration;
use std::future::Future;
use std::io;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::thread::JoinHandle;

use rayon::ThreadBuilder;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::window::{CursorGrabMode, Window, WindowAttributes};

use crate::platform::Platform;
use crate::platform::threads::{DisplayEnd, GameEnd, Paced, Starting};
use crate::{Config, Error, Game};

pub use std::time::Instant;

/// What this build runs behind: a window of the desktop's own.
pub(crate) const PLATFORM: Platform = Platform::Desktop;

/// What a player does after the device was lost, on this platform.
pub(crate) const AFTER_LOSS: &str =
    "Start the game again; if it happens again, update the GPU driver.";

/// What a player does after the device ran out of memory, on this platform.
pub(crate) const AFTER_OUT_OF_MEMORY: &str =
    "Close other programs that use the GPU, then start the game again.";

/// Reads the engine's log channel to the standard error stream, at `info`
/// unless `RUST_LOG` sets another level, so what the engine warns about
/// is shown without the game writing a line; one the game set first is
/// kept. `calloop` warns every frame about an event for a source `winit`
/// already dropped (upstream, `bevy#14904`), so its records are held to
/// errors here, in the seam that owns the `winit` loop.
pub(crate) fn install_diagnostics() {
    let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
        .filter_module("calloop", log::LevelFilter::Error)
        .try_init();
}

/// Blocks until `future` is done — platform code, where blocking belongs.
pub(crate) fn spawn(future: impl Future<Output = ()> + 'static) {
    pollster::block_on(future);
}

/// Reads one asset source relative to the working directory, one of the
/// engine's synchronous reads, all of which live in this file. The
/// browser's own read is not synchronous.
pub(crate) async fn read(source: &str) -> Result<Vec<u8>, Error> {
    std::fs::read(source)
        .map_err(|error| Error::msg(format!("the asset source `{source}` did not open: {error}")))
}

pub(crate) fn window_attributes(config: &Config) -> Result<WindowAttributes, Error> {
    let size = config.size();
    Ok(Window::default_attributes()
        .with_title(config.title())
        .with_inner_size(LogicalSize::new(size.x, size.y)))
}

/// Runs `app` on a window's event loop until the run ends.
pub(crate) fn run_app(mut app: impl ApplicationHandler + 'static) {
    let Some(event_loop) = crate::platform::event_loop() else {
        return;
    };

    if let Err(error) = event_loop.run_app(&mut app) {
        report_fatal(&Error::msg(format!("the event loop stopped: {error}")));
    }
}

/// The desktop states nothing of the adapter beyond what wgpu reads.
pub(crate) async fn adapter_facts() -> Option<String> {
    None
}

pub(crate) fn report_fatal(error: &Error) {
    eprintln!("mirage-engine: {error}");
}

/// The threads this machine states it runs at once, or `1` where it states
/// none.
pub(crate) fn hardware_threads() -> usize {
    std::thread::available_parallelism().map_or(1, NonZeroUsize::get)
}

/// Runs one worker of the engine's pool on a thread of the desktop's own,
/// which lasts as long as the run, under the name and the stack size the pool
/// states for it.
pub(crate) fn spawn_worker(worker: ThreadBuilder) -> io::Result<()> {
    let mut thread = std::thread::Builder::new();
    if let Some(name) = worker.name() {
        thread = thread.name(name.to_owned());
    }
    if let Some(stack_size) = worker.stack_size() {
        thread = thread.stack_size(stack_size);
    }
    thread.spawn(move || worker.run())?;

    Ok(())
}

/// Starts the game thread, which builds the game and then runs the ticks and
/// the frame of every sample the display thread hands, waiting there between
/// them.
///
/// A thread of the desktop's own always starts, so this states no error; the
/// browser's own start can fail.
pub(crate) fn start_game<G: Game>(
    end: GameEnd,
    starting: Starting<G>,
) -> Result<GameThreadHandle, Error> {
    Ok(GameThreadHandle(std::thread::spawn(move || {
        if let Some(mut paced) = Paced::started(&end, starting) {
            paced.run(&end);
        }
    })))
}

/// The handle the display thread keeps on the game thread: the thread it runs
/// on.
pub(crate) struct GameThreadHandle(JoinHandle<()>);

impl GameThreadHandle {
    /// `None`: a desktop thread states nothing while it runs, and
    /// [`Self::ended`] reports one that stopped before the run did.
    pub(crate) fn failed(&self) -> Option<Error> {
        None
    }

    /// Ends the run: dropping `end` ends the wait for the next sample, and
    /// this returns once the frame the thread was running is over.
    pub(crate) fn ended(self, end: DisplayEnd) {
        drop(end);
        if self.0.join().is_err() {
            report_fatal(&Error::msg("the game stopped before the run did"));
        }
    }
}

/// Nothing to do: the window closes with the loop.
pub(crate) fn close(_window: &Window, _config: &Config) {}

/// The hold a window has on the pointer: what the last frame set, and
/// whether the desktop took it.
pub(crate) struct PointerHold {
    set: bool,
    held: bool,
}

impl PointerHold {
    /// The hold a run starts with, which holds nothing.
    pub(crate) fn released() -> Self {
        Self {
            set: false,
            held: false,
        }
    }

    /// Holds `window`'s pointer in place where `held`, releases it where
    /// not, and reports whether it is held now.
    ///
    /// A desktop that takes the lock keeps the pointer in one place;
    /// `X11`, which has no lock, keeps it confined to the window instead.
    /// One that takes neither holds nothing, and the pointer keeps moving
    /// as it did.
    pub(crate) fn set(&mut self, window: &Window, held: bool) -> bool {
        if core::mem::replace(&mut self.set, held) == held {
            return self.held;
        }

        self.held = held
            && (grab(window, CursorGrabMode::Locked) || grab(window, CursorGrabMode::Confined));
        if !self.held {
            grab(window, CursorGrabMode::None);
        }
        self.held
    }

    /// Nothing to do: a desktop holds the pointer the frame a game sets
    /// the hold, with no gesture of the player's first.
    pub(crate) fn see_gesture(&mut self, _window: &Window) {}

    /// Drops the hold, which no window keeps past its focus: a desktop
    /// takes the hold back as the window loses focus. The first frame to
    /// set a hold after either edge of the focus takes it again.
    pub(crate) fn see_focus_change(&mut self) {
        self.set = false;
        self.held = false;
    }
}

/// Whether `window` took `mode` on its pointer; a desktop that has no such
/// hold states so in a debug log.
fn grab(window: &Window, mode: CursorGrabMode) -> bool {
    window
        .set_cursor_grab(mode)
        .inspect_err(|error| log::debug!("mirage-engine left the pointer as it was: {error}"))
        .is_ok()
}

/// Text the store called `name` kept, a synchronous read of the kind
/// this file alone may make. The browser keeps its own.
pub(crate) fn store_read(name: &str) -> Option<String> {
    read_text(store_path(name)?)
}

/// The text of `path`, or `None` where it did not open, with a debug log
/// of what stopped it.
fn read_text(path: PathBuf) -> Option<String> {
    std::fs::read_to_string(&path)
        .inspect_err(|error| {
            log::debug!(
                "mirage-engine read nothing from {}: {error}",
                path.display()
            );
        })
        .ok()
}

pub(crate) fn store_write(name: &str, text: &str) {
    let Some(path) = store_path(name) else {
        return;
    };
    let Some(folder) = path.parent() else {
        return;
    };
    if let Err(error) = std::fs::create_dir_all(folder).and_then(|()| std::fs::write(&path, text)) {
        log::debug!("mirage-engine kept nothing in {}: {error}", path.display());
    }
}

/// How long after a press a second one still counts as a double click, as
/// this desktop states it, or `None` where none of the files below states
/// one.
///
/// Read once at startup, in this order: `gtk-double-click-time` from
/// `gtk-4.0/settings.ini` and then `gtk-3.0/settings.ini`,
/// `DoubleClickInterval` under `[KDE]` in `kdeglobals`, each under the
/// settings directory, and last the `multiClickTime` line of
/// `~/.Xresources`.
#[cfg(target_os = "linux")]
pub(crate) fn double_click_interval() -> Option<Duration> {
    let settings = |file: &str| settings_dir().map(|dir| dir.join(file)).and_then(read_text);
    let gtk = |version: &str| settings(&format!("{version}/settings.ini"));
    let kde = || settings("kdeglobals");
    let x = || {
        home()
            .map(|home| home.join(".Xresources"))
            .and_then(read_text)
    };

    gtk("gtk-4.0")
        .and_then(|text| Settings(&text).gtk())
        .or_else(|| gtk("gtk-3.0").and_then(|text| Settings(&text).gtk()))
        .or_else(|| kde().and_then(|text| Settings(&text).kde()))
        .or_else(|| x().and_then(|text| Settings(&text).xresources()))
}

/// How long after a press a second one still counts as a double click,
/// which this desktop states through `GetDoubleClickTime`.
#[cfg(windows)]
pub(crate) fn double_click_interval() -> Option<Duration> {
    // SAFETY: the call takes nothing, reads one setting of this program's
    // own and returns a plain number, so it is sound at any time.
    let milliseconds =
        unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() };

    Some(Duration::from_millis(u64::from(milliseconds)))
}

/// `None`: no `winit` API reaches what this desktop states, so a run on it
/// counts by the engine's own interval.
#[cfg(not(any(target_os = "linux", windows)))]
pub(crate) fn double_click_interval() -> Option<Duration> {
    None
}

/// What a settings file holds, and the double click interval each kind of
/// file states in it.
#[cfg(target_os = "linux")]
struct Settings<'a>(&'a str);

#[cfg(target_os = "linux")]
impl Settings<'_> {
    /// What GTK's `gtk-double-click-time` states, in milliseconds.
    fn gtk(&self) -> Option<Duration> {
        self.keyed("gtk-double-click-time", None)
    }

    /// What `DoubleClickInterval` states under `[KDE]`, in milliseconds.
    fn kde(&self) -> Option<Duration> {
        self.keyed("DoubleClickInterval", Some("[KDE]"))
    }

    /// What the last `multiClickTime` line states, in milliseconds, spelled
    /// either way a machine spells it; the last is the one `xrdb` keeps.
    fn xresources(&self) -> Option<Duration> {
        self.0
            .lines()
            .filter_map(|line| line.split_once(':'))
            .rfind(|(name, _)| matches!(name.trim(), "*.multiClickTime" | "*multiClickTime"))
            .and_then(|(_, value)| Self::milliseconds(value))
    }

    /// What `key` states in milliseconds, under `section` where one is
    /// named and anywhere in the file where none is.
    fn keyed(&self, key: &str, section: Option<&str>) -> Option<Duration> {
        let mut inside = section.is_none();

        for line in self.0.lines().map(str::trim) {
            if line.starts_with('[') {
                inside = section.is_none_or(|named| named == line);
            } else if inside
                && let Some((name, value)) = line.split_once('=')
                && name.trim() == key
            {
                return Self::milliseconds(value);
            }
        }

        None
    }

    /// `text` as a length in milliseconds, or `None` where it states no
    /// whole number of them.
    fn milliseconds(text: &str) -> Option<Duration> {
        text.trim().parse().ok().map(Duration::from_millis)
    }
}

/// The file one kind of a game's kept text is written to, under wherever this
/// desktop keeps a program's settings.
fn store_path(name: &str) -> Option<PathBuf> {
    Some(
        settings_dir()?
            .join("mirage-engine")
            .join(format!("{name}.txt")),
    )
}

#[cfg(target_os = "windows")]
fn settings_dir() -> Option<PathBuf> {
    std::env::var_os("APPDATA").map(PathBuf::from)
}

#[cfg(target_os = "macos")]
fn settings_dir() -> Option<PathBuf> {
    home().map(|home| home.join("Library").join("Application Support"))
}

#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn settings_dir() -> Option<PathBuf> {
    std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|path| path.is_absolute())
        .or_else(|| home().map(|home| home.join(".config")))
}

#[cfg(not(target_os = "windows"))]
fn home() -> Option<PathBuf> {
    std::env::var_os("HOME").map(PathBuf::from)
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use super::*;

    #[test]
    fn each_kind_of_settings_file_states_its_own_interval() {
        assert_eq!(
            Settings("[Settings]\ngtk-double-click-time=250\n").gtk(),
            Some(Duration::from_millis(250))
        );
        assert_eq!(
            Settings("[General]\nDoubleClickInterval=999\n[KDE]\nDoubleClickInterval = 250\n")
                .kde(),
            Some(Duration::from_millis(250)),
            "under its own section, never another's"
        );
        assert_eq!(
            Settings("! what a machine is set to\n*.multiClickTime: 250\n").xresources(),
            Some(Duration::from_millis(250))
        );
        assert_eq!(
            Settings("*multiClickTime: 250\n").xresources(),
            Some(Duration::from_millis(250)),
            "spelled either way"
        );
        assert_eq!(
            Settings("*.multiClickTime: 250\n*.multiClickTime: 300\n").xresources(),
            Some(Duration::from_millis(300)),
            "and the last line is the one that counts"
        );
    }

    #[test]
    fn a_settings_file_stating_no_interval_states_nothing() {
        assert_eq!(Settings("").gtk(), None, "nothing at all");
        assert_eq!(
            Settings("[Settings]\ngtk-font-name=Sans 10\n").gtk(),
            None,
            "other settings"
        );
        assert_eq!(
            Settings("[General]\nDoubleClickInterval=250\n").kde(),
            None,
            "the key outside its own section"
        );
        assert_eq!(
            Settings("*.multiClickTime: soon\n").xresources(),
            None,
            "and a value that is no whole number of milliseconds"
        );
    }
}