Skip to main content

gasm_host/
window.rs

1//! The windowed runner: winit window, wgpu surface, cpal audio, gilrs gamepads,
2//! keyboard layouts, fixed-timestep frames with catch-up, and the hold-Escape quit.
3
4use std::collections::HashSet;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use gilrs::{Button, Gilrs};
9use winit::application::ApplicationHandler;
10use winit::dpi::LogicalSize;
11use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent};
12use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
13use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
14use winit::window::{CursorGrabMode, Window, WindowId};
15
16use crate::audio::{AudioOut, AudioSink, AudioStream};
17use crate::angle::NativeWindow;
18use crate::gfx::Gfx;
19use crate::host::{self, Game, Gamepad, KEY_STATE_BYTES, Pointer, RawInput, Stop};
20use crate::keymap;
21use crate::present::Present;
22use crate::session::Session;
23
24pub struct Options {
25    /// initial window size in logical pixels
26    pub size: (u32, u32),
27    pub keymap: keymap::Keymap,
28    pub mute: bool,
29    /// how 2D frames are shown
30    pub present: Present,
31    /// write frame N as the window shows it and quit (gasm:gl games; for tests)
32    pub screenshot: Option<(u64, String)>,
33}
34
35/// The platform window handle ANGLE draws into.
36fn native_window(window: &Window) -> Result<NativeWindow, String> {
37    use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle};
38    let w = window.window_handle().map_err(|e| e.to_string())?.as_raw();
39    let d = window.display_handle().map_err(|e| e.to_string())?.as_raw();
40    Ok(match (w, d) {
41        (RawWindowHandle::AppKit(h), _) => NativeWindow::AppKit(h.ns_view.as_ptr()),
42        (RawWindowHandle::Win32(h), _) => NativeWindow::Win32(h.hwnd.get() as *mut std::ffi::c_void),
43        (RawWindowHandle::Xlib(h), RawDisplayHandle::Xlib(d)) => {
44            NativeWindow::Xlib(d.display.map_or(std::ptr::null_mut(), |p| p.as_ptr()), h.window)
45        }
46        (RawWindowHandle::Wayland(_), RawDisplayHandle::Wayland(d)) => NativeWindow::Wayland(d.display.as_ptr()),
47        _ => return Err("gasm:gl: this kind of window isn't supported".into()),
48    })
49}
50
51/// Open a window and play until the guest exits, traps or the player quits.
52/// Returns the guest's exit code (0 when the player quit).
53pub fn run(session: Session, opts: Options) -> Result<i32, String> {
54    let mut builder = EventLoop::builder();
55    // gasm:gl draws with ANGLE, which needs an X11 window here (XWayland on Wayland desktops)
56    #[cfg(all(unix, not(target_os = "macos")))]
57    if session.uses_gl() {
58        use winit::platform::x11::EventLoopBuilderExtX11;
59        builder.with_x11();
60    }
61    let event_loop = builder.build().map_err(|e| e.to_string())?;
62    // default title: the module file name without .wasm (set_title replaces it)
63    let default_title = host::static_title(&session.wasm).unwrap_or_else(|| {
64        std::path::Path::new(&session.name).file_stem().map_or(session.name.clone(), |s| s.to_string_lossy().into_owned())
65    });
66    let mut app = App {
67        title: default_title.clone(),
68        default_title,
69        fps: 0,
70        opts,
71        session: Some(session),
72        window: None,
73        game: None,
74        keys: HashSet::new(),
75        typed: String::new(),
76        key_events: Vec::new(),
77        esc_down: None,
78        mouse: MouseState::default(),
79        applied_mode: 0,
80        pointer_flags: 0,
81        gilrs: Gilrs::new().ok(),
82        next: Instant::now(),
83        fps_t: Instant::now(),
84        fps_n: 0,
85        result: Ok(0),
86        frames_run: 0,
87        audio_stream: None,
88    };
89    event_loop.run_app(&mut app).map_err(|e| e.to_string())?;
90    app.result
91}
92
93/// Gamepads in connection order, plus how many are connected.
94fn gamepad_pads(gilrs: &mut Gilrs) -> ([u32; 4], usize) {
95    const MAP: &[(Button, u32)] = &[
96        (Button::East, 1 << 0),
97        (Button::South, 1 << 1),
98        (Button::North, 1 << 2),
99        (Button::West, 1 << 3),
100        (Button::LeftTrigger, 1 << 4),
101        (Button::RightTrigger, 1 << 5),
102        (Button::Select, 1 << 6),
103        (Button::Start, 1 << 7),
104        (Button::DPadUp, 1 << 8),
105        (Button::DPadDown, 1 << 9),
106        (Button::DPadLeft, 1 << 10),
107        (Button::DPadRight, 1 << 11),
108    ];
109    while gilrs.next_event().is_some() {}
110    let mut pads = [0u32; 4];
111    let connected = gilrs.gamepads().count();
112    for (i, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
113        pads[i] = MAP.iter().filter(|(b, _)| pad.is_pressed(*b)).fold(0, |m, (_, bit)| m | bit);
114        let (x, y) = (pad.value(gilrs::Axis::LeftStickX), pad.value(gilrs::Axis::LeftStickY));
115        if x < -0.5 { pads[i] |= 1 << 10 }
116        if x > 0.5 { pads[i] |= 1 << 11 }
117        if y > 0.5 { pads[i] |= 1 << 8 }
118        if y < -0.5 { pads[i] |= 1 << 9 }
119    }
120    (pads, connected)
121}
122
123/// Mouse input collected between frames (window pixels).
124#[derive(Default)]
125struct MouseState {
126    x: f32,
127    y: f32,
128    inside: bool,
129    buttons: u32,
130    /// buttons that went down / up since the last frame (a click inside one frame counts)
131    pressed: u32,
132    released: u32,
133    dx: f32,
134    dy: f32,
135    wheel_x: f32,
136    wheel_y: f32,
137}
138
139/// Hold Escape this long to quit (a tap goes to the game).
140const ESC_HOLD: Duration = Duration::from_millis(1000);
141
142fn mouse_bit(b: MouseButton) -> u32 {
143    match b {
144        MouseButton::Left => 1,
145        MouseButton::Right => 2,
146        MouseButton::Middle => 4,
147        MouseButton::Back => 8,
148        MouseButton::Forward => 16,
149        MouseButton::Other(_) => 0,
150    }
151}
152
153/// Raw gamepads (first 4 connected, the same order as the pads). Known
154/// controllers in the W3C standard layout; others raw, ordered by code.
155fn gamepads_raw(gilrs: &Gilrs) -> [Gamepad; 4] {
156    use gilrs::{Axis, MappingSource};
157    const BUTTONS: [Button; 17] = [
158        Button::South, Button::East, Button::West, Button::North, Button::LeftTrigger, Button::RightTrigger,
159        Button::LeftTrigger2, Button::RightTrigger2, Button::Select, Button::Start, Button::LeftThumb,
160        Button::RightThumb, Button::DPadUp, Button::DPadDown, Button::DPadLeft, Button::DPadRight, Button::Mode,
161    ];
162    let mut out: [Gamepad; 4] = Default::default();
163    for (slot, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
164        let g = &mut out[slot];
165        g.connected = true;
166        g.name = pad.name().to_owned();
167        if pad.mapping_source() != MappingSource::None {
168            g.standard = true;
169            g.buttons = BUTTONS.iter().map(|b| pad.button_data(*b).map_or(0.0, |d| d.value())).collect();
170            // W3C: y axes point down
171            g.axes = vec![pad.value(Axis::LeftStickX), -pad.value(Axis::LeftStickY), pad.value(Axis::RightStickX), -pad.value(Axis::RightStickY)];
172        } else {
173            let mut b: Vec<_> = pad.state().buttons().map(|(c, d)| (c.into_u32(), d.value())).collect();
174            let mut a: Vec<_> = pad.state().axes().map(|(c, d)| (c.into_u32(), d.value())).collect();
175            b.sort_by_key(|x| x.0);
176            a.sort_by_key(|x| x.0);
177            g.buttons = b.into_iter().map(|x| x.1).take(host::GAMEPAD_BUTTONS).collect();
178            g.axes = a.into_iter().map(|x| x.1).take(host::GAMEPAD_AXES).collect();
179        }
180    }
181    out
182}
183
184struct App {
185    /// frames run so far (--window-screenshot)
186    frames_run: u64,
187    /// what the window shows before " — gasm": set_title, else `default_title`
188    title: String,
189    default_title: String,
190    /// guest frames in the last second
191    fps: u32,
192    opts: Options,
193    /// taken when the window opens
194    session: Option<Session>,
195    window: Option<Arc<Window>>,
196    game: Option<Game>,
197    keys: HashSet<KeyCode>,
198    /// text typed since the last frame (for text_input)
199    typed: String,
200    /// raw key presses/releases since the last frame
201    key_events: Vec<(u16, bool)>,
202    /// Escape: a tap goes to the game, holding it quits
203    esc_down: Option<Instant>,
204    mouse: MouseState,
205    /// cursor mode currently applied to the window (GASM_INPUT_POINTER_*)
206    applied_mode: u32,
207    /// GASM_POINTER_IS_HIDDEN / IS_LOCKED as actually achieved
208    pointer_flags: u32,
209    gilrs: Option<Gilrs>,
210    next: Instant,
211    fps_t: Instant,
212    fps_n: u32,
213    result: Result<i32, String>,
214    /// the audio device stream (kept alive while the game plays)
215    audio_stream: Option<AudioStream>,
216}
217
218impl App {
219    fn stop(&mut self, el: &ActiveEventLoop, result: Result<i32, String>) {
220        self.result = result;
221        // the event loop can tick again before it ends: never call into a guest
222        // that has exited or trapped
223        self.game = None;
224        el.exit();
225    }
226
227    /// Hide / lock the cursor as the guest asked (input_mode), when it changes.
228    /// The window title: the game's (set_title or the file name), then the runner's own part,
229    /// which the guest can't change.
230    fn show_title(&self) {
231        if let Some(w) = &self.window {
232            w.set_title(&format!("{} — gasm — {} fps", self.title, self.fps));
233        }
234    }
235
236    fn apply_input_mode(&mut self) {
237        let (Some(game), Some(w)) = (&mut self.game, &self.window) else { return };
238        let want = game.with_host(|h| h.input_mode) & 6;
239        if want == self.applied_mode {
240            return;
241        }
242        self.applied_mode = want;
243        let locked = want & 4 != 0
244            && w.set_cursor_grab(CursorGrabMode::Locked).or_else(|_| w.set_cursor_grab(CursorGrabMode::Confined)).is_ok();
245        if want & 4 == 0 {
246            let _ = w.set_cursor_grab(CursorGrabMode::None);
247        }
248        // locked implies hidden
249        w.set_cursor_visible(want == 0);
250        self.pointer_flags = if want != 0 { 2 } else { 0 } | if locked { 4 } else { 0 };
251    }
252
253    /// The player closed the window / held Esc: let the game save, then stop.
254    fn quit(&mut self, el: &ActiveEventLoop) {
255        if let Some(g) = &mut self.game {
256            g.exit();
257        }
258        self.stop(el, Ok(0));
259    }
260
261    fn tick(&mut self, el: &ActiveEventLoop) {
262        let Some(game) = &mut self.game else { return };
263        let period = Duration::from_secs_f64(1.0 / game.with_host(|h| h.frame_rate));
264        let now = Instant::now();
265        if now >= self.next {
266            // Fixed timestep: catch up at most 4 frames, only the last one is shown.
267            let behind = ((now - self.next).as_secs_f64() / period.as_secs_f64()) as u32 + 1;
268            let steps = behind.min(4);
269            // Escape held long enough: quit (a short tap went to the game as a key)
270            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
271                return self.quit(el);
272            }
273            let mut keys = [0u8; KEY_STATE_BYTES];
274            for &c in &self.keys {
275                let k = keymap::gasm_key(c);
276                if k != 0 {
277                    keys[k as usize / 8] |= 1 << (k % 8);
278                }
279            }
280            // gamepads are sampled once per tick: pads and raw state agree within a batch
281            let (mut pads, gamepads) = self.gilrs.as_mut().map(gamepad_pads).unwrap_or_default();
282            let raw_pads = self.gilrs.as_ref().map(gamepads_raw).unwrap_or_default();
283            // a guest that reads the keyboard itself (KEYS_RAW) gets no keymap pads
284            if game.with_host(|h| h.input_mode) & 1 == 0 {
285                let kb = keymap::pads(&self.opts.keymap, &self.keys, gamepads);
286                for (p, k) in pads.iter_mut().zip(kb) {
287                    *p |= k;
288                }
289            }
290            let drawable = game.with_host(|h| h.gl.size().unwrap_or_else(|| h.gfx.size()));
291            for k in 0..steps {
292                game.with_host(|host| {
293                host.pads = pads;
294                // typed text, key events and relative motion go to the first frame of a catch-up batch
295                let first = k == 0;
296                host.text = Some(if first { std::mem::take(&mut self.typed) } else { String::new() });
297                let m = &mut self.mouse;
298                let pointer = Pointer {
299                    x: m.x,
300                    y: m.y,
301                    dx: if first { m.dx } else { 0.0 },
302                    dy: if first { m.dy } else { 0.0 },
303                    wheel_x: if first { m.wheel_x } else { 0.0 },
304                    wheel_y: if first { m.wheel_y } else { 0.0 },
305                    buttons: m.buttons,
306                    pressed: if first { m.pressed } else { 0 },
307                    released: if first { m.released } else { 0 },
308                    flags: m.inside as u32 | self.pointer_flags,
309                    drawable: (drawable.0 as f32, drawable.1 as f32),
310                    integer_scale: self.opts.present.integer_scale,
311                };
312                if first {
313                    (m.dx, m.dy, m.wheel_x, m.wheel_y, m.pressed, m.released) = (0.0, 0.0, 0.0, 0.0, 0, 0);
314                }
315                if first {
316                    host.input = RawInput { keys: Some(keys), key_events: std::mem::take(&mut self.key_events), pointer: Some(pointer), gamepads: Some(raw_pads.clone()) };
317                } else {
318                    host.input.key_events.clear();
319                    host.input.pointer = Some(pointer);
320                }
321                host.show_frame = k + 1 == steps;
322                host.gfx.used = false;
323                });
324                let r = game.frame();
325                self.frames_run += 1;
326                let shot = self.opts.screenshot.as_ref().filter(|(n, _)| *n == self.frames_run).map(|(_, p)| p.clone());
327                let img = game.with_host(|h| {
328                    let img = shot.as_ref().and_then(|_| h.gl.read_frame());
329                    let show = h.show_frame;
330                    h.gl.end_frame(show);
331                    img
332                });
333                if let Some(path) = shot {
334                    let r = match img {
335                        Some((w, h, rgba)) => crate::headless::write_png(&path, w, h, &rgba).map(|_| eprintln!("[gasm] wrote {path}")),
336                        None => Err("--window-screenshot: only gasm:gl games".into()),
337                    };
338                    return match r {
339                        Ok(()) => self.quit(el),
340                        Err(e) => self.stop(el, Err(e)),
341                    };
342                }
343                match r {
344                    Ok(()) => {}
345                    Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
346                    Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
347                }
348                self.next += period;
349                self.fps_n += 1;
350            }
351            if behind > 4 {
352                self.next = Instant::now() + period;
353            }
354            // 2D guests: show the last video_present frame.
355            let title = game.with_host(|host| {
356                if !host.gfx.used && host.width > 0 {
357                    let (w, h) = (host.width as u32, host.height as u32);
358                    let rgba = std::mem::take(&mut host.rgba);
359                    host.gfx.present_video(&rgba, w, h, host.aspect);
360                    host.rgba = rgba;
361                }
362                std::mem::take(&mut host.title_changed).then(|| host.title.clone())
363            });
364            if let Some(t) = title {
365                self.title = t.unwrap_or_else(|| self.default_title.clone());
366                self.show_title();
367            }
368        }
369        self.apply_input_mode();
370        if self.fps_t.elapsed() >= Duration::from_secs(1) {
371            self.fps = self.fps_n;
372            self.show_title();
373            self.fps_n = 0;
374            self.fps_t = Instant::now();
375        }
376        el.set_control_flow(ControlFlow::WaitUntil(self.next));
377    }
378}
379
380impl ApplicationHandler for App {
381    fn resumed(&mut self, el: &ActiveEventLoop) {
382        if self.window.is_some() {
383            return;
384        }
385        let attrs = Window::default_attributes()
386            .with_title(format!("{} — gasm", self.title))
387            .with_inner_size(LogicalSize::new(self.opts.size.0, self.opts.size.1));
388        let window = match el.create_window(attrs) {
389            Ok(w) => Arc::new(w),
390            Err(e) => return self.stop(el, Err(format!("cannot create window: {e}"))),
391        };
392        // gasm:gl games draw with ANGLE into the window; everything else with wgpu
393        let uses_gl = self.session.as_ref().is_some_and(|s| s.uses_gl());
394        let mut gl = None;
395        let gfx = if uses_gl {
396            let native = match native_window(&window) {
397                Ok(n) => n,
398                Err(e) => return self.stop(el, Err(e)),
399            };
400            match self.session.as_ref().map(|s| s.open_gl(Some(native), (0, 0))) {
401                Some(Ok(a)) => gl = Some(a),
402                Some(Err(e)) => return self.stop(el, Err(format!("cannot initialise OpenGL ES (gasm:gl): {e}"))),
403                None => return,
404            }
405            Gfx::null()
406        } else {
407            match Gfx::for_window(window.clone()) {
408                Ok(mut g) => {
409                    g.present = self.opts.present;
410                    g
411                }
412                Err(e) => return self.stop(el, Err(format!("cannot initialise GPU: {e}"))),
413            }
414        };
415        let audio: Option<Box<dyn AudioOut>> = if self.opts.mute {
416            None
417        } else {
418            match AudioSink::open() {
419                Ok((a, stream)) => {
420                    eprintln!("[gasm] audio: {} Hz", a.device_rate());
421                    self.audio_stream = Some(stream);
422                    Some(Box::new(a))
423                }
424                Err(e) => {
425                    eprintln!("[gasm] audio disabled: {e}");
426                    None
427                }
428            }
429        };
430        let Some(session) = self.session.take() else { return };
431        match session.start(audio, gfx, gl, false, false) {
432            Ok(g) => self.game = Some(g),
433            Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
434            Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
435        }
436        self.window = Some(window);
437        self.next = Instant::now();
438        el.set_control_flow(ControlFlow::WaitUntil(self.next));
439    }
440
441    fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
442        match event {
443            WindowEvent::CloseRequested => self.quit(el),
444            WindowEvent::Resized(size) => {
445                if let Some(g) = &mut self.game {
446                    g.with_host(|h| h.gfx.resize(size.width, size.height));
447                }
448            }
449            WindowEvent::Focused(false) => {
450                // nothing stays held while another app has the input
451                for c in self.keys.drain() {
452                    let k = keymap::gasm_key(c);
453                    if k != 0 {
454                        self.key_events.push((k, false));
455                    }
456                }
457                self.esc_down = None;
458                self.mouse.inside = false;
459                self.mouse.released |= self.mouse.buttons;
460                self.mouse.buttons = 0;
461            }
462            WindowEvent::CursorMoved { position, .. } => {
463                self.mouse.x = position.x as f32;
464                self.mouse.y = position.y as f32;
465                self.mouse.inside = true;
466            }
467            WindowEvent::CursorEntered { .. } => self.mouse.inside = true,
468            WindowEvent::CursorLeft { .. } => self.mouse.inside = false,
469            WindowEvent::MouseInput { state, button, .. } => {
470                let bit = mouse_bit(button);
471                match state {
472                    ElementState::Pressed => {
473                        self.mouse.buttons |= bit;
474                        self.mouse.pressed |= bit;
475                    }
476                    ElementState::Released => {
477                        self.mouse.buttons &= !bit;
478                        self.mouse.released |= bit;
479                    }
480                }
481            }
482            WindowEvent::MouseWheel { delta, .. } => {
483                // gasm: about 1 per wheel notch, y > 0 = down (W3C sign); winit's is the opposite
484                let (x, y) = match delta {
485                    MouseScrollDelta::LineDelta(x, y) => (x, y),
486                    MouseScrollDelta::PixelDelta(p) => (p.x as f32 / 100.0, p.y as f32 / 100.0),
487                };
488                self.mouse.wheel_x -= x;
489                self.mouse.wheel_y -= y;
490            }
491            WindowEvent::KeyboardInput { event, .. } => {
492                if let PhysicalKey::Code(code) = event.physical_key {
493                    if code == KeyCode::Escape && !event.repeat {
494                        self.esc_down = (event.state == ElementState::Pressed).then(Instant::now);
495                    }
496                    let k = keymap::gasm_key(code);
497                    if !event.repeat && k != 0 {
498                        self.key_events.push((k, event.state == ElementState::Pressed));
499                    }
500                    match event.state {
501                        ElementState::Pressed => self.keys.insert(code),
502                        ElementState::Released => self.keys.remove(&code),
503                    };
504                }
505                if event.state == ElementState::Pressed {
506                    match &event.logical_key {
507                        Key::Named(NamedKey::Enter) => self.typed.push('\n'),
508                        Key::Named(NamedKey::Backspace) => self.typed.push('\u{8}'),
509                        _ => {
510                            if let Some(t) = &event.text {
511                                self.typed.extend(t.chars().filter(|c| !c.is_control()));
512                            }
513                        }
514                    }
515                }
516            }
517            WindowEvent::RedrawRequested => self.tick(el),
518            _ => {}
519        }
520    }
521
522    fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
523        // raw (unaccelerated) motion, also while the cursor is locked
524        if let DeviceEvent::MouseMotion { delta } = event
525            && self.window.as_ref().is_some_and(|w| w.has_focus())
526        {
527            self.mouse.dx += delta.0 as f32;
528            self.mouse.dy += delta.1 as f32;
529        }
530    }
531
532    fn about_to_wait(&mut self, el: &ActiveEventLoop) {
533        self.tick(el);
534    }
535}
536