summaryrefslogtreecommitdiffstats
path: root/src/game/client/io
diff options
context:
space:
mode:
authoruntodesu <kirill@untode.su>2025-12-30 13:20:33 +0500
committeruntodesu <kirill@untode.su>2025-12-30 13:20:33 +0500
commita1c83d56f41e6f2e0ad86dcd76d1446bfc60a37c (patch)
tree5754f0cd6ef3678c2e9d9c31174ae435d463c8ed /src/game/client/io
parent49d3dff9e98e70e599dfd3059f85bb08ae247fe5 (diff)
downloadvoxelius-a1c83d56f41e6f2e0ad86dcd76d1446bfc60a37c.tar.bz2
voxelius-a1c83d56f41e6f2e0ad86dcd76d1446bfc60a37c.zip
begin working on qf ports (#21)
Diffstat (limited to 'src/game/client/io')
-rw-r--r--src/game/client/io/gamepad.cc72
-rw-r--r--src/game/client/io/gamepad.hh100
-rw-r--r--src/game/client/io/glfw.hh42
-rw-r--r--src/game/client/io/keyboard.cc29
-rw-r--r--src/game/client/io/keyboard.hh83
-rw-r--r--src/game/client/io/mouse.cc44
-rw-r--r--src/game/client/io/mouse.hh141
-rw-r--r--src/game/client/io/video.cc289
-rw-r--r--src/game/client/io/video.hh115
9 files changed, 821 insertions, 94 deletions
diff --git a/src/game/client/io/gamepad.cc b/src/game/client/io/gamepad.cc
index 768b18b..ba65599 100644
--- a/src/game/client/io/gamepad.cc
+++ b/src/game/client/io/gamepad.cc
@@ -9,12 +9,14 @@
#include "core/config/boolean.hh"
#include "core/config/number.hh"
+
#include "core/io/cmdline.hh"
#include "core/io/config_map.hh"
+#include "core/io/physfs.hh"
+
#include "core/math/constexpr.hh"
#include "client/gui/settings.hh"
-#include "client/io/glfw.hh"
#include "client/globals.hh"
#include "client/toggles.hh"
@@ -48,37 +50,37 @@ static void on_toggle_disable(const ToggleDisabledEvent& event)
}
}
-static void on_glfw_joystick_event(const GlfwJoystickEvent& event)
+static void on_joystick_glfw(int joystick_id, int event_type)
{
- if((event.event_type == GLFW_CONNECTED) && glfwJoystickIsGamepad(event.joystick_id) && (active_gamepad_id == INVALID_GAMEPAD_ID)) {
+ if((event_type == GLFW_CONNECTED) && glfwJoystickIsGamepad(joystick_id) && (active_gamepad_id == INVALID_GAMEPAD_ID)) {
gamepad::available = true;
- active_gamepad_id = event.joystick_id;
+ active_gamepad_id = joystick_id;
- for(int i = 0; i < NUM_AXES; gamepad::last_state.axes[i++] = 0.0f) {
- // empty
+ for(int i = 0; i < NUM_AXES; ++i) {
+ gamepad::last_state.axes[i] = 0.0f;
}
- for(int i = 0; i < NUM_BUTTONS; gamepad::last_state.buttons[i++] = GLFW_RELEASE) {
- // empty
+ for(int i = 0; i < NUM_BUTTONS; ++i) {
+ gamepad::last_state.buttons[i] = GLFW_RELEASE;
}
- spdlog::info("gamepad: detected gamepad: {}", glfwGetGamepadName(event.joystick_id));
+ spdlog::info("gamepad: detected gamepad: {}", glfwGetGamepadName(joystick_id));
return;
}
- if((event.event_type == GLFW_DISCONNECTED) && (active_gamepad_id == event.joystick_id)) {
+ if((event_type == GLFW_DISCONNECTED) && (active_gamepad_id == joystick_id)) {
gamepad::available = false;
active_gamepad_id = INVALID_GAMEPAD_ID;
- for(int i = 0; i < NUM_AXES; gamepad::last_state.axes[i++] = 0.0f) {
- // empty
+ for(int i = 0; i < NUM_AXES; ++i) {
+ gamepad::last_state.axes[i] = 0.0f;
}
- for(int i = 0; i < NUM_BUTTONS; gamepad::last_state.buttons[i++] = GLFW_RELEASE) {
- // empty
+ for(int i = 0; i < NUM_BUTTONS; ++i) {
+ gamepad::last_state.buttons[i] = GLFW_RELEASE;
}
spdlog::warn("gamepad: disconnected");
@@ -99,15 +101,12 @@ void gamepad::init(void)
settings::add_checkbox(0, gamepad::active, settings_location::GAMEPAD, "gamepad.active", true);
settings::add_slider(1, gamepad::deadzone, settings_location::GAMEPAD, "gamepad.deadzone", true, "%.03f");
- auto mappings_path = cmdline::get_cstr("gpmap", "misc/gamecontrollerdb.txt");
- auto mappings_file = PHYSFS_openRead(mappings_path);
+ std::string mappings_string;
+ std::string_view mappings_path(cmdline::get("gpmap", "misc/gamecontrollerdb.txt"));
- if(mappings_file) {
+ if(physfs::read_file(mappings_path, mappings_string)) {
spdlog::info("gamepad: using mappings from {}", mappings_path);
- auto mappings_string = std::string(PHYSFS_fileLength(mappings_file), char(0x00));
- PHYSFS_readBytes(mappings_file, mappings_string.data(), mappings_string.size());
glfwUpdateGamepadMappings(mappings_string.c_str());
- PHYSFS_close(mappings_file);
}
for(int joystick = 0; joystick <= GLFW_JOYSTICK_LAST; joystick += 1) {
@@ -140,7 +139,9 @@ void gamepad::init(void)
globals::dispatcher.sink<ToggleEnabledEvent>().connect<&on_toggle_enable>();
globals::dispatcher.sink<ToggleDisabledEvent>().connect<&on_toggle_disable>();
- globals::dispatcher.sink<GlfwJoystickEvent>().connect<&on_glfw_joystick_event>();
+
+ spdlog::info("gamepad: taking over device callbacks");
+ glfwSetJoystickCallback(&on_joystick_glfw);
}
void gamepad::update_late(void)
@@ -152,21 +153,21 @@ void gamepad::update_late(void)
if(glfwGetGamepadState(active_gamepad_id, &gamepad::state)) {
for(int i = 0; i < NUM_AXES; ++i) {
- if((glm::abs(gamepad::state.axes[i]) > GAMEPAD_AXIS_EVENT_THRESHOLD)
- && (glm::abs(gamepad::last_state.axes[i]) <= GAMEPAD_AXIS_EVENT_THRESHOLD)) {
- GamepadAxisEvent event;
- event.action = GLFW_PRESS;
- event.axis = i;
- globals::dispatcher.enqueue(event);
+ auto is_press = true;
+ is_press = is_press && glm::abs(gamepad::state.axes[i] > GAMEPAD_AXIS_EVENT_THRESHOLD);
+ is_press = is_press && glm::abs(gamepad::last_state.axes[i] <= GAMEPAD_AXIS_EVENT_THRESHOLD);
+
+ if(is_press) {
+ globals::dispatcher.enqueue(GamepadAxisEvent(i, GLFW_PRESS));
continue;
}
- if((glm::abs(gamepad::state.axes[i]) <= GAMEPAD_AXIS_EVENT_THRESHOLD)
- && (glm::abs(gamepad::last_state.axes[i]) > GAMEPAD_AXIS_EVENT_THRESHOLD)) {
- GamepadAxisEvent event;
- event.action = GLFW_RELEASE;
- event.axis = i;
- globals::dispatcher.enqueue(event);
+ auto is_release = true;
+ is_release = is_release && glm::abs(gamepad::state.axes[i]) <= GAMEPAD_AXIS_EVENT_THRESHOLD;
+ is_release = is_release && glm::abs(gamepad::last_state.axes[i]) > GAMEPAD_AXIS_EVENT_THRESHOLD;
+
+ if(is_release) {
+ globals::dispatcher.enqueue(GamepadAxisEvent(i, GLFW_RELEASE));
continue;
}
}
@@ -177,10 +178,7 @@ void gamepad::update_late(void)
continue;
}
- GamepadButtonEvent event;
- event.action = gamepad::state.buttons[i];
- event.button = i;
- globals::dispatcher.enqueue(event);
+ globals::dispatcher.enqueue(GamepadButtonEvent(i, gamepad::state.buttons[i]));
}
}
diff --git a/src/game/client/io/gamepad.hh b/src/game/client/io/gamepad.hh
index b506cc2..ff6af4a 100644
--- a/src/game/client/io/gamepad.hh
+++ b/src/game/client/io/gamepad.hh
@@ -10,6 +10,43 @@
constexpr static int INVALID_GAMEPAD_AXIS = INT_MAX;
constexpr static int INVALID_GAMEPAD_BUTTON = INT_MAX;
+// This simulates buttons using axes. When an axis
+// value exceeds 1.5 times the deadzone, the event is
+// queued with a GLFW_PRESS action, when it falls back
+// below the threshold, the event is queued with GLFW_RELEASE action
+class GamepadAxisEvent final {
+public:
+ constexpr explicit GamepadAxisEvent(int axis, int action);
+
+ constexpr int axis(void) const noexcept;
+ constexpr int action(void) const noexcept;
+
+ constexpr bool is_axis(int axis) const noexcept;
+ constexpr bool is_action(int action) const noexcept;
+
+private:
+ int m_axis;
+ int m_action;
+};
+
+// This smears GLFW event sugar over gamepad polling
+// system. Whenever it detects a state change, the event
+// is queued with an appropriate action
+class GamepadButtonEvent final {
+public:
+ constexpr explicit GamepadButtonEvent(int button, int action);
+
+ constexpr int button(void) const noexcept;
+ constexpr int action(void) const noexcept;
+
+ constexpr bool is_button(int button) const noexcept;
+ constexpr bool is_action(int action) const noexcept;
+
+private:
+ int m_action;
+ int m_button;
+};
+
namespace config
{
class Boolean;
@@ -33,21 +70,54 @@ void init(void);
void update_late(void);
} // namespace gamepad
-// This simulates buttons using axes. When an axis
-// value exceeds 1.5 times the deadzone, the event is
-// queued with a GLFW_PRESS action, when it falls back
-// below the threshold, the event is queued with GLFW_RELEASE action
-struct GamepadAxisEvent final {
- int action;
- int axis;
-};
+constexpr GamepadAxisEvent::GamepadAxisEvent(int axis, int action) : m_axis(axis), m_action(action)
+{
+ // empty
+}
-// This smears GLFW event sugar over gamepad polling
-// system. Whenever it detects a state change, the event
-// is queued with an appropriate action
-struct GamepadButtonEvent final {
- int action;
- int button;
-};
+constexpr int GamepadAxisEvent::axis(void) const noexcept
+{
+ return m_axis;
+}
+
+constexpr int GamepadAxisEvent::action(void) const noexcept
+{
+ return m_action;
+}
+
+constexpr bool GamepadAxisEvent::is_axis(int axis) const noexcept
+{
+ return m_axis == axis;
+}
+
+constexpr bool GamepadAxisEvent::is_action(int action) const noexcept
+{
+ return m_action == action;
+}
+
+constexpr GamepadButtonEvent::GamepadButtonEvent(int button, int action) : m_button(button), m_action(action)
+{
+ // empty
+}
+
+constexpr int GamepadButtonEvent::button(void) const noexcept
+{
+ return m_button;
+}
+
+constexpr int GamepadButtonEvent::action(void) const noexcept
+{
+ return m_action;
+}
+
+constexpr bool GamepadButtonEvent::is_button(int button) const noexcept
+{
+ return m_button == button;
+}
+
+constexpr bool GamepadButtonEvent::is_action(int action) const noexcept
+{
+ return m_action == action;
+}
#endif
diff --git a/src/game/client/io/glfw.hh b/src/game/client/io/glfw.hh
deleted file mode 100644
index 9da978b..0000000
--- a/src/game/client/io/glfw.hh
+++ /dev/null
@@ -1,42 +0,0 @@
-// SPDX-License-Identifier: BSD-2-Clause
-// Copyright (c) 2025 Kirill Dmitrievich
-// File: glfw.hh
-// Description: GLFW events passed through EnTT's signal system
-
-#ifndef CLIENT_IO_GLFW_HH
-#define CLIENT_IO_GLFW_HH
-#pragma once
-
-struct GlfwCursorPosEvent final {
- glm::fvec2 pos;
-};
-
-struct GlfwFramebufferSizeEvent final {
- glm::ivec2 size;
- float aspect;
-};
-
-struct GlfwJoystickEvent final {
- int joystick_id;
- int event_type;
-};
-
-struct GlfwKeyEvent final {
- int key { GLFW_KEY_UNKNOWN };
- int scancode;
- int action;
- int mods;
-};
-
-struct GlfwMouseButtonEvent final {
- int button { GLFW_KEY_UNKNOWN };
- int action;
- int mods;
-};
-
-struct GlfwScrollEvent final {
- float dx;
- float dy;
-};
-
-#endif
diff --git a/src/game/client/io/keyboard.cc b/src/game/client/io/keyboard.cc
new file mode 100644
index 0000000..71db460
--- /dev/null
+++ b/src/game/client/io/keyboard.cc
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: keyboard.cc; Created: Tue Dec 30 2025 12:27:50
+// Description: Keyboard handling
+
+#include "client/pch.hh"
+
+#include "client/io/keyboard.hh"
+
+#include "client/globals.hh"
+
+static void on_char_glfw(GLFWwindow* window, unsigned int codepoint)
+{
+ ImGui_ImplGlfw_CharCallback(window, codepoint);
+}
+
+static void on_key_glfw(GLFWwindow* window, int keycode, int scancode, int action, int modbits)
+{
+ globals::dispatcher.trigger(KeyEvent(keycode, scancode, action, modbits));
+
+ ImGui_ImplGlfw_KeyCallback(window, keycode, scancode, action, modbits);
+}
+
+void keyboard::init(void)
+{
+ spdlog::info("keyboard: taking over device events");
+ glfwSetCharCallback(globals::window, &on_char_glfw);
+ glfwSetKeyCallback(globals::window, &on_key_glfw);
+}
diff --git a/src/game/client/io/keyboard.hh b/src/game/client/io/keyboard.hh
new file mode 100644
index 0000000..566d38b
--- /dev/null
+++ b/src/game/client/io/keyboard.hh
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: keyboard.hh; Created: Tue Dec 30 2025 12:27:04
+// Description: Keyboard handling
+
+#ifndef CLIENT_IO_KEYBOARD_HH
+#define CLIENT_IO_KEYBOARD_HH
+#pragma once
+
+class KeyEvent final {
+public:
+ constexpr explicit KeyEvent(int keycode, int scancode, int action, int modbits);
+
+ constexpr int keycode(void) const noexcept;
+ constexpr int scancode(void) const noexcept;
+ constexpr int action(void) const noexcept;
+ constexpr int modbits(void) const noexcept;
+
+ constexpr bool is_keycode(int keycode) const noexcept;
+ constexpr bool is_action(int action) const noexcept;
+ constexpr bool has_modbits(int modbits) const noexcept;
+
+ constexpr bool is_valid(void) const noexcept;
+
+private:
+ int m_keycode;
+ int m_scancode;
+ int m_action;
+ int m_modbits;
+};
+
+namespace keyboard
+{
+void init(void);
+} // namespace keyboard
+
+constexpr KeyEvent::KeyEvent(int keycode, int scancode, int action, int modbits)
+ : m_keycode(keycode), m_scancode(scancode), m_action(action), m_modbits(modbits)
+{
+ // empty
+}
+
+constexpr int KeyEvent::keycode(void) const noexcept
+{
+ return m_keycode;
+}
+
+constexpr int KeyEvent::scancode(void) const noexcept
+{
+ return m_scancode;
+}
+
+constexpr int KeyEvent::action(void) const noexcept
+{
+ return m_action;
+}
+
+constexpr int KeyEvent::modbits(void) const noexcept
+{
+ return m_modbits;
+}
+
+constexpr bool KeyEvent::is_keycode(int keycode) const noexcept
+{
+ return m_keycode == keycode;
+}
+
+constexpr bool KeyEvent::is_action(int action) const noexcept
+{
+ return m_action == action;
+}
+
+constexpr bool KeyEvent::has_modbits(int modbits) const noexcept
+{
+ return static_cast<bool>(m_modbits & modbits);
+}
+
+constexpr bool KeyEvent::is_valid(void) const noexcept
+{
+ return m_keycode >= 0 && m_keycode <= GLFW_KEY_LAST;
+}
+
+#endif
diff --git a/src/game/client/io/mouse.cc b/src/game/client/io/mouse.cc
new file mode 100644
index 0000000..b79365c
--- /dev/null
+++ b/src/game/client/io/mouse.cc
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: mouse.cc; Created: Tue Dec 30 2025 12:39:32
+// Description: Mouse and scroll wheel handling
+
+#include "client/pch.hh"
+
+#include "client/io/mouse.hh"
+
+#include "client/globals.hh"
+
+static void on_cursor_enter_glfw(GLFWwindow* window, int entered)
+{
+ ImGui_ImplGlfw_CursorEnterCallback(window, entered);
+}
+static void on_cursor_pos_glfw(GLFWwindow* window, double xpos, double ypos)
+{
+ globals::dispatcher.trigger(CursorPosEvent(xpos, ypos));
+
+ ImGui_ImplGlfw_CursorPosCallback(window, xpos, ypos);
+}
+
+static void on_mouse_button_glfw(GLFWwindow* window, int button, int action, int modbits)
+{
+ globals::dispatcher.trigger(MouseButtonEvent(button, action, modbits));
+
+ ImGui_ImplGlfw_MouseButtonCallback(window, button, action, modbits);
+}
+
+static void on_scroll_glfw(GLFWwindow* window, double xoffset, double yoffset)
+{
+ globals::dispatcher.trigger(ScrollEvent(xoffset, yoffset));
+
+ ImGui_ImplGlfw_ScrollCallback(window, xoffset, yoffset);
+}
+
+void mouse::init(void)
+{
+ spdlog::info("mouse: taking over device events");
+ glfwSetCursorEnterCallback(globals::window, &on_cursor_enter_glfw);
+ glfwSetCursorPosCallback(globals::window, &on_cursor_pos_glfw);
+ glfwSetMouseButtonCallback(globals::window, &on_mouse_button_glfw);
+ glfwSetScrollCallback(globals::window, &on_scroll_glfw);
+}
diff --git a/src/game/client/io/mouse.hh b/src/game/client/io/mouse.hh
new file mode 100644
index 0000000..3a58dc7
--- /dev/null
+++ b/src/game/client/io/mouse.hh
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: mouse.hh; Created: Tue Dec 30 2025 12:33:48
+// Description: Mouse and scroll wheel handling
+
+#ifndef CLIENT_IO_MOUSE_HH
+#define CLIENT_IO_MOUSE_HH
+#pragma once
+
+class CursorPosEvent final {
+public:
+ constexpr explicit CursorPosEvent(double xpos, double ypos);
+
+ constexpr float xpos(void) const noexcept;
+ constexpr float ypos(void) const noexcept;
+ constexpr const glm::fvec2& position(void) const noexcept;
+
+private:
+ glm::fvec2 m_position;
+};
+
+class MouseButtonEvent final {
+public:
+ constexpr explicit MouseButtonEvent(int button, int action, int modbits);
+
+ constexpr int button(void) const noexcept;
+ constexpr int action(void) const noexcept;
+ constexpr int modbits(void) const noexcept;
+
+ constexpr bool is_button(int button) const noexcept;
+ constexpr bool is_action(int action) const noexcept;
+ constexpr bool has_modbits(int modbits) const noexcept;
+
+ constexpr bool is_valid(void) const noexcept;
+
+private:
+ int m_button;
+ int m_action;
+ int m_modbits;
+};
+
+class ScrollEvent final {
+public:
+ constexpr explicit ScrollEvent(double xoffset, double yoffset);
+
+ constexpr float xoffset(void) const noexcept;
+ constexpr float yoffset(void) const noexcept;
+ constexpr const glm::fvec2& offset(void) const noexcept;
+
+private:
+ glm::fvec2 m_offset;
+};
+
+namespace mouse
+{
+void init(void);
+} // namespace mouse
+
+constexpr CursorPosEvent::CursorPosEvent(double xpos, double ypos)
+{
+ m_position.x = static_cast<float>(xpos);
+ m_position.y = static_cast<float>(ypos);
+}
+
+constexpr float CursorPosEvent::xpos(void) const noexcept
+{
+ return m_position.x;
+}
+
+constexpr float CursorPosEvent::ypos(void) const noexcept
+{
+ return m_position.y;
+}
+
+constexpr const glm::fvec2& CursorPosEvent::position(void) const noexcept
+{
+ return m_position;
+}
+
+constexpr MouseButtonEvent::MouseButtonEvent(int button, int action, int modbits) : m_button(button), m_action(action), m_modbits(modbits)
+{
+ // empty
+}
+
+constexpr int MouseButtonEvent::button(void) const noexcept
+{
+ return m_button;
+}
+
+constexpr int MouseButtonEvent::action(void) const noexcept
+{
+ return m_action;
+}
+
+constexpr int MouseButtonEvent::modbits(void) const noexcept
+{
+ return m_modbits;
+}
+
+constexpr bool MouseButtonEvent::is_button(int button) const noexcept
+{
+ return m_button == button;
+}
+
+constexpr bool MouseButtonEvent::is_action(int action) const noexcept
+{
+ return m_action == action;
+}
+
+constexpr bool MouseButtonEvent::has_modbits(int modbits) const noexcept
+{
+ return static_cast<bool>(m_modbits & modbits);
+}
+
+constexpr bool MouseButtonEvent::is_valid(void) const noexcept
+{
+ return m_button >= 0 && m_button <= GLFW_MOUSE_BUTTON_LAST;
+}
+
+constexpr ScrollEvent::ScrollEvent(double xoffset, double yoffset)
+{
+ m_offset.x = static_cast<float>(xoffset);
+ m_offset.y = static_cast<float>(yoffset);
+}
+
+constexpr float ScrollEvent::xoffset(void) const noexcept
+{
+ return m_offset.x;
+}
+
+constexpr float ScrollEvent::yoffset(void) const noexcept
+{
+ return m_offset.y;
+}
+
+constexpr const glm::fvec2& ScrollEvent::offset(void) const noexcept
+{
+ return m_offset;
+}
+
+#endif
diff --git a/src/game/client/io/video.cc b/src/game/client/io/video.cc
new file mode 100644
index 0000000..361b167
--- /dev/null
+++ b/src/game/client/io/video.cc
@@ -0,0 +1,289 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: video.cc; Created: Tue Dec 30 2025 13:00:24
+// Description: Video mode handling
+
+#include "client/pch.hh"
+
+#include "client/io/video.hh"
+
+#include "core/config/boolean.hh"
+#include "core/config/string.hh"
+
+#include "core/io/cmdline.hh"
+#include "core/io/config_map.hh"
+
+#include "core/resource/image.hh"
+#include "core/resource/resource.hh"
+
+#include "core/version.hh"
+
+#include "shared/splash.hh"
+
+#include "client/gui/settings.hh"
+
+#include "client/const.hh"
+#include "client/globals.hh"
+
+static glm::ivec2 last_windowed_size;
+static config::Boolean enable_vsync(true);
+static config::String current_mode("windowed");
+
+static GLFWmonitor* fullscreen_monitor;
+static std::vector<VideoMode> fullscreen_modes;
+
+static void on_glfw_error(int error, const char* description)
+{
+ spdlog::error("video: GLFW error [{}]: {}", error, description);
+}
+
+static void on_glfw_framebuffer_size(GLFWwindow* window, int wide, int tall)
+{
+ globals::width = wide;
+ globals::height = tall;
+ globals::aspect = static_cast<float>(wide) / static_cast<float>(tall);
+
+ if(nullptr == glfwGetWindowMonitor(window)) {
+ last_windowed_size.x = wide;
+ last_windowed_size.y = tall;
+ }
+
+ globals::dispatcher.trigger(FramebufferSizeEvent(wide, tall));
+}
+
+static void on_glfw_window_close(GLFWwindow* window)
+{
+ // We don't really have a good way to
+ // pass "i want to quit" boolean to the main loop,
+ // so instead we just raise an external interrupt signal
+ // which handler latches an internal flag in the main loop
+ std::raise(SIGINT);
+}
+
+static void on_window_focus_glfw(GLFWwindow* window, int focused)
+{
+ ImGui_ImplGlfw_WindowFocusCallback(window, focused);
+}
+
+void video::init(void)
+{
+ last_windowed_size.x = BASE_WIDTH;
+ last_windowed_size.y = BASE_HEIGHT;
+
+ globals::client_config.add_value("video.enable_vsync", enable_vsync);
+ globals::client_config.add_value("video.current_mode", current_mode);
+
+#ifdef __unix__
+ // Wayland constantly throws random bullshit at me
+ // when I'm dealing with pretty much anything cross-platform
+ // on pretty much any kind of UNIX and Linux distribution
+ glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11);
+#endif
+
+ glfwSetErrorCallback(&on_glfw_error);
+
+ if(!glfwInit()) {
+ spdlog::critical("glfw: initialize failed");
+ std::terminate();
+ }
+
+ unsigned int monitor_index;
+ auto monitor_arg = cmdline::get("monitor");
+ auto monitor_check = std::from_chars(monitor_arg.data(), monitor_arg.data() + monitor_arg.size(), monitor_index);
+
+ if(monitor_check.ec == std::errc()) {
+ int monitor_count;
+ const auto monitors = glfwGetMonitors(&monitor_count);
+
+ if(monitor_index < static_cast<unsigned int>(monitor_count)) {
+ fullscreen_monitor = monitors[monitor_index];
+ }
+ else {
+ // If the user wants to, say run the game on a monitor
+ // number 2 and there are only 2 monitors (remember, zero-based index)
+ // it's a good idea to silently fall back to the primary monitor
+ fullscreen_monitor = glfwGetPrimaryMonitor();
+ }
+ }
+ else {
+ fullscreen_monitor = glfwGetPrimaryMonitor();
+ }
+
+ int video_mode_count;
+ const auto video_modes = glfwGetVideoModes(fullscreen_monitor, &video_mode_count);
+
+ fullscreen_modes.clear();
+ fullscreen_modes.reserve(video_mode_count);
+
+ for(int i = 0; i < video_mode_count; ++i) {
+ auto& mode = video_modes[i];
+
+ // Only allow video modes that are at least as large as the base resolution
+ // to be used by the video subsystem, otherwise we're going to end up with
+ // some bizzare UI scaling issues because there is no UI scale less than 1.0
+ if(mode.width >= BASE_WIDTH && mode.height >= BASE_HEIGHT) {
+ fullscreen_modes.push_back(VideoMode(mode));
+ }
+ }
+
+ // Setup GLFW window hints
+ glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API);
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
+ glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
+
+#if defined(__APPLE__)
+ // Enable forward compatibility because Apple
+ // just decided to be the autistic kid of the class
+ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
+#endif
+
+ globals::window = glfwCreateWindow(BASE_WIDTH, BASE_HEIGHT, "Client", nullptr, nullptr);
+
+ if(globals::window == nullptr) {
+ spdlog::critical("glfw: window creation failed");
+ std::terminate();
+ }
+
+ glfwSetWindowSizeLimits(globals::window, BASE_WIDTH, BASE_HEIGHT, GLFW_DONT_CARE, GLFW_DONT_CARE);
+ glfwMakeContextCurrent(globals::window);
+ glfwSwapInterval(1);
+
+ glfwSetFramebufferSizeCallback(globals::window, &on_glfw_framebuffer_size);
+ glfwSetWindowCloseCallback(globals::window, &on_glfw_window_close);
+ glfwSetWindowFocusCallback(globals::window, &on_window_focus_glfw);
+
+ if(auto image = resource::load<Image>("textures/gui/window_icon.png")) {
+ GLFWimage icon_image;
+ icon_image.width = image->size.x;
+ icon_image.height = image->size.y;
+ icon_image.pixels = reinterpret_cast<unsigned char*>(image->pixels);
+ glfwSetWindowIcon(globals::window, 1, &icon_image);
+ }
+
+ settings::add_video_mode_select(0, settings_location::VIDEO, "video.current_mode");
+ settings::add_checkbox(1, enable_vsync, settings_location::VIDEO, "video.enable_vsync", true);
+
+ update_window_title();
+}
+
+void video::init_late(void)
+{
+ std::string mode_string(current_mode.get_value());
+
+ if(0 == mode_string.compare("windowed")) {
+ request_windowed(last_windowed_size.x, last_windowed_size.y);
+ return;
+ }
+
+ int target_width;
+ int target_height;
+ int target_refresh_rate;
+
+ if(3 == std::sscanf(mode_string.c_str(), "%d:%d:%d", &target_width, &target_height, &target_refresh_rate)) {
+ request_fullscreen(target_width, target_height, target_refresh_rate);
+ return;
+ }
+
+ int current_width;
+ int current_height;
+ glfwGetFramebufferSize(globals::window, &current_width, &current_height);
+ on_glfw_framebuffer_size(globals::window, current_width, current_height);
+}
+
+void video::shutdown(void)
+{
+ glfwDestroyWindow(globals::window);
+ glfwTerminate();
+}
+
+void video::update(void)
+{
+ glfwGetFramebufferSize(globals::window, &globals::width, &globals::height);
+ globals::aspect = static_cast<float>(globals::width) / static_cast<float>(globals::height);
+}
+
+void video::update_late(void)
+{
+ glfwSwapInterval(enable_vsync.get_value() ? 1 : 0);
+}
+
+void video::query_current_mode(int& wide, int& tall) noexcept
+{
+ glfwGetFramebufferSize(globals::window, &wide, &tall);
+}
+
+void video::query_current_mode(int& wide, int& tall, bool& fullscreen) noexcept
+{
+ glfwGetFramebufferSize(globals::window, &wide, &tall);
+ fullscreen = static_cast<bool>(glfwGetWindowMonitor(globals::window));
+}
+
+const std::vector<VideoMode>& video::query_fullscreen_modes(void) noexcept
+{
+ return fullscreen_modes;
+}
+
+void video::request_fullscreen(int wide, int tall, int rate) noexcept
+{
+ assert(wide >= BASE_WIDTH);
+ assert(tall >= BASE_HEIGHT);
+
+ std::size_t best_index = 0;
+ auto best_score = std::numeric_limits<int>::max();
+
+ for(std::size_t i = 0; i < fullscreen_modes.size(); ++i) {
+ const auto& mode = fullscreen_modes[i];
+
+ auto score_width = std::abs(mode.wide() - wide);
+ auto score_height = std::abs(mode.tall() - tall);
+ auto score_refresh_rate = std::abs(mode.rate() - rate);
+ auto total_score = score_width + score_height + score_refresh_rate;
+
+ if(total_score < best_score) {
+ best_score = total_score;
+ best_index = i;
+ }
+ }
+
+ const auto& best_mode = fullscreen_modes[best_index];
+
+ glfwSetWindowMonitor(globals::window, fullscreen_monitor, 0, 0, best_mode.wide(), best_mode.tall(), best_mode.rate());
+ glfwSetWindowAttrib(globals::window, GLFW_AUTO_ICONIFY, GLFW_FALSE);
+ glfwSetWindowAttrib(globals::window, GLFW_RESIZABLE, GLFW_FALSE);
+
+ current_mode.set(std::format("{}:{}:{}", best_mode.wide(), best_mode.tall(), best_mode.rate()));
+
+ spdlog::debug("video: set mode to: {}x{} ({} Hz)", best_mode.wide(), best_mode.tall(), best_mode.rate());
+}
+
+void video::request_windowed(int wide, int tall) noexcept
+{
+ glfwSetWindowMonitor(globals::window, nullptr, 0, 0, wide, tall, GLFW_DONT_CARE);
+ glfwSetWindowAttrib(globals::window, GLFW_AUTO_ICONIFY, GLFW_FALSE);
+ glfwSetWindowAttrib(globals::window, GLFW_RESIZABLE, GLFW_TRUE);
+
+ int workarea_xpos, workarea_ypos;
+ int workarea_width, workarea_height;
+ glfwGetMonitorWorkarea(fullscreen_monitor, &workarea_xpos, &workarea_ypos, &workarea_width, &workarea_height);
+
+ auto center_x = workarea_xpos + (workarea_width - wide) / 2;
+ auto center_y = workarea_ypos + (workarea_height - tall) / 2;
+
+ glfwSetWindowPos(globals::window, center_x, center_y);
+
+ current_mode.set("windowed");
+
+ spdlog::debug("video: set mode to: windowed {}x{}", wide, tall);
+}
+
+void video::request_windowed(void) noexcept
+{
+ request_windowed(last_windowed_size.x, last_windowed_size.y);
+}
+
+void video::update_window_title(void)
+{
+ auto title = std::format("Voxelius {}: {}", version::triplet, splash::get());
+ glfwSetWindowTitle(globals::window, title.c_str());
+}
diff --git a/src/game/client/io/video.hh b/src/game/client/io/video.hh
new file mode 100644
index 0000000..858983f
--- /dev/null
+++ b/src/game/client/io/video.hh
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: BSD-2-Clause
+// Copyright (c) 2025 Kirill Dmitrievich
+// File: video.hh; Created: Tue Dec 30 2025 12:59:09
+// Description: Video mode handling
+
+#ifndef CLIENT_IO_VIDEO_HH
+#define CLIENT_IO_VIDEO_HH
+#pragma once
+
+class VideoMode final {
+public:
+ constexpr explicit VideoMode(const GLFWvidmode& mode) noexcept;
+ constexpr explicit VideoMode(int wide, int tall, int rate = GLFW_DONT_CARE) noexcept;
+
+ constexpr int wide(void) const noexcept;
+ constexpr int tall(void) const noexcept;
+ constexpr int rate(void) const noexcept;
+
+ constexpr bool operator==(const VideoMode& other) const noexcept;
+
+private:
+ int m_wide;
+ int m_tall;
+ int m_rate;
+};
+
+class FramebufferSizeEvent final {
+public:
+ constexpr explicit FramebufferSizeEvent(int wide, int tall) noexcept;
+ constexpr int wide(void) const noexcept;
+ constexpr int tall(void) const noexcept;
+
+private:
+ int m_wide;
+ int m_tall;
+};
+
+namespace video
+{
+void init(void);
+void init_late(void);
+void shutdown(void);
+void update(void);
+void update_late(void);
+} // namespace video
+
+namespace video
+{
+void query_current_mode(int& wide, int& tall) noexcept;
+void query_current_mode(int& wide, int& tall, bool& fullscreen) noexcept;
+const std::vector<VideoMode>& query_fullscreen_modes(void) noexcept;
+} // namespace video
+
+namespace video
+{
+void request_fullscreen(int wide, int tall, int rate) noexcept;
+void request_windowed(int wide, int tall) noexcept;
+void request_windowed(void) noexcept;
+} // namespace video
+
+namespace video
+{
+void update_window_title(void);
+} // namespace video
+
+constexpr VideoMode::VideoMode(int wide, int tall, int rate) noexcept : m_wide(wide), m_tall(tall), m_rate(rate)
+{
+ // empty
+}
+
+constexpr VideoMode::VideoMode(const GLFWvidmode& mode) noexcept : m_wide(mode.width), m_tall(mode.height), m_rate(mode.refreshRate)
+{
+ // empty
+}
+
+constexpr int VideoMode::wide(void) const noexcept
+{
+ return m_wide;
+}
+
+constexpr int VideoMode::tall(void) const noexcept
+{
+ return m_tall;
+}
+
+constexpr int VideoMode::rate(void) const noexcept
+{
+ return m_rate;
+}
+
+constexpr bool VideoMode::operator==(const VideoMode& other) const noexcept
+{
+ auto result = true;
+ result = result && (m_wide == other.m_wide || m_wide == GLFW_DONT_CARE || other.m_wide == GLFW_DONT_CARE);
+ result = result && (m_tall == other.m_tall || m_tall == GLFW_DONT_CARE || other.m_tall == GLFW_DONT_CARE);
+ result = result && (m_rate == other.m_rate || m_rate == GLFW_DONT_CARE || other.m_rate == GLFW_DONT_CARE);
+ return result;
+}
+
+constexpr FramebufferSizeEvent::FramebufferSizeEvent(int wide, int tall) noexcept : m_wide(wide), m_tall(tall)
+{
+ // empty
+}
+
+constexpr int FramebufferSizeEvent::wide(void) const noexcept
+{
+ return m_wide;
+}
+
+constexpr int FramebufferSizeEvent::tall(void) const noexcept
+{
+ return m_tall;
+}
+
+#endif