mirror of
https://github.com/steredbits/pelagia.git
synced 2026-10-24 22:26:19 +02:00
562 lines
17 KiB
C++
562 lines
17 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 steredbits and Pelagia contributors
|
|
// SDL2 platform layer shared by Linux and the PS5 (rendering, audio, input,
|
|
// time). Differences go through sdl_hooks.h (platform/linux,
|
|
// platform/ps5); files are in platform/posix.
|
|
// Also works without a display or sound card with the dummy drivers:
|
|
// SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
|
|
// On PS5, the renderer is "software" (CPU display): validated on the console,
|
|
// IYUV 1080p texture at 60 fps for ~26% of one core.
|
|
|
|
#include "platform.h"
|
|
|
|
#include "controller_set.h"
|
|
#include "input_map.h"
|
|
#include "letterbox.h"
|
|
#include "sdl_hooks.h"
|
|
#include "text_input.h"
|
|
#include "util/log.h"
|
|
|
|
#include <SDL.h>
|
|
|
|
#include <chrono>
|
|
#include <deque>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
namespace platform {
|
|
|
|
namespace {
|
|
|
|
SDL_Window* g_window = nullptr;
|
|
SDL_Renderer* g_renderer = nullptr;
|
|
SDL_Texture* g_texture = nullptr;
|
|
int g_texture_w = 0;
|
|
int g_texture_h = 0;
|
|
SDL_AudioDeviceID g_audio_device = 0;
|
|
platform_sdl::ControllerSet<SDL_GameController*> g_controllers;
|
|
|
|
struct TextureEntry {
|
|
SDL_Texture* texture = nullptr;
|
|
PixelFormat format = PixelFormat::Rgba8;
|
|
int w = 0;
|
|
int h = 0;
|
|
};
|
|
std::unordered_map<TextureId, TextureEntry> g_textures;
|
|
TextureId g_next_texture = 1;
|
|
std::vector<uint8_t> g_convert; // Alpha8 -> RGBA (SDL2 has no A8 texture)
|
|
|
|
bool g_text_mode = false;
|
|
std::deque<Input> g_pending;
|
|
platform_sdl::RepeatTracker g_repeat;
|
|
// Axes: triggers (long seeks) and left stick (directions).
|
|
platform_sdl::AxisEdge g_trigger_left;
|
|
platform_sdl::AxisEdge g_trigger_right;
|
|
platform_sdl::AxisEdge g_stick_x;
|
|
platform_sdl::AxisEdge g_stick_y;
|
|
|
|
void push(InputEvent ev, uint32_t codepoint = 0) {
|
|
if (ev == InputEvent::None) {
|
|
return;
|
|
}
|
|
Input in;
|
|
in.event = ev;
|
|
in.codepoint = codepoint;
|
|
g_pending.push_back(in);
|
|
}
|
|
|
|
// Opens the gamepad with SDL index `index`, unless it is already open.
|
|
void open_controller(int index) {
|
|
if (g_controllers.contains(SDL_JoystickGetDeviceInstanceID(index))) {
|
|
return;
|
|
}
|
|
if (!SDL_IsGameController(index)) {
|
|
LOG_WARN("Joystick ignored (no SDL gamepad mapping): %s",
|
|
SDL_JoystickNameForIndex(index));
|
|
return;
|
|
}
|
|
SDL_GameController* pad = SDL_GameControllerOpen(index);
|
|
if (!pad) {
|
|
LOG_WARN("Cannot open the gamepad: %s", SDL_GetError());
|
|
return;
|
|
}
|
|
const SDL_GameControllerType type = SDL_GameControllerGetType(pad);
|
|
const bool playstation = type == SDL_CONTROLLER_TYPE_PS3 ||
|
|
type == SDL_CONTROLLER_TYPE_PS4 || type == SDL_CONTROLLER_TYPE_PS5;
|
|
const SDL_JoystickID id = SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(pad));
|
|
g_controllers.add(id, pad, playstation);
|
|
LOG_INFO("Manette n°%d : %s (disposition %s), %zu ouverte(s)", static_cast<int>(id),
|
|
SDL_GameControllerName(pad), playstation ? "PlayStation" : "standard",
|
|
g_controllers.size());
|
|
}
|
|
|
|
void close_controller(SDL_JoystickID id) {
|
|
SDL_GameController* pad = nullptr;
|
|
if (g_controllers.remove(id, &pad)) {
|
|
SDL_GameControllerClose(pad);
|
|
g_repeat.release(g_repeat.held());
|
|
LOG_INFO("Gamepad no. %d removed, %zu open", static_cast<int>(id), g_controllers.size());
|
|
}
|
|
}
|
|
|
|
// SDL internal messages: through util/log like the rest (usable PS5 logs).
|
|
void sdl_log_output(void*, int, SDL_LogPriority priority, const char* message) {
|
|
const util::LogLevel level = priority >= SDL_LOG_PRIORITY_ERROR ? util::LogLevel::Error
|
|
: priority >= SDL_LOG_PRIORITY_WARN ? util::LogLevel::Warn
|
|
: priority >= SDL_LOG_PRIORITY_INFO ? util::LogLevel::Info
|
|
: util::LogLevel::Debug;
|
|
util::log_write(level, "SDL : %s", message);
|
|
}
|
|
|
|
// Direction of a stick: press/release like the d-pad (repetition included).
|
|
void stick_direction(int fired, int state, InputEvent negative, InputEvent positive) {
|
|
if (fired != 0) {
|
|
const InputEvent ev = fired > 0 ? positive : negative;
|
|
push(ev);
|
|
g_repeat.press(ev, ticks_ms());
|
|
} else if (state == 0) {
|
|
g_repeat.release(negative);
|
|
g_repeat.release(positive);
|
|
}
|
|
}
|
|
|
|
void handle_axis(const SDL_ControllerAxisEvent& axis) {
|
|
switch (axis.axis) {
|
|
case SDL_CONTROLLER_AXIS_TRIGGERLEFT:
|
|
if (g_trigger_left.update(axis.value) > 0) push(InputEvent::SeekBackLong);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_TRIGGERRIGHT:
|
|
if (g_trigger_right.update(axis.value) > 0) push(InputEvent::SeekFwdLong);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_LEFTX: {
|
|
const int fired = g_stick_x.update(axis.value);
|
|
stick_direction(fired, g_stick_x.state(), InputEvent::Left, InputEvent::Right);
|
|
break;
|
|
}
|
|
case SDL_CONTROLLER_AXIS_LEFTY: {
|
|
const int fired = g_stick_y.update(axis.value);
|
|
stick_direction(fired, g_stick_y.state(), InputEvent::Up, InputEvent::Down);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void handle_text(const char* text) {
|
|
const std::string s(text);
|
|
for (size_t pos = 0; pos < s.size();) {
|
|
// Minimal UTF-8 decoding (SDL provides valid UTF-8).
|
|
const unsigned char c = static_cast<unsigned char>(s[pos]);
|
|
int len = c < 0x80 ? 1 : (c >> 5) == 6 ? 2 : (c >> 4) == 14 ? 3 : 4;
|
|
if (pos + len > s.size()) {
|
|
break;
|
|
}
|
|
uint32_t cp = len == 1 ? c : (c & (0x7F >> len));
|
|
for (int k = 1; k < len; ++k) {
|
|
cp = (cp << 6) | (static_cast<unsigned char>(s[pos + k]) & 0x3F);
|
|
}
|
|
pos += len;
|
|
if (cp >= 0x20 && cp != 0x7F) {
|
|
push(InputEvent::Text, cp);
|
|
}
|
|
}
|
|
}
|
|
|
|
void handle_event(const SDL_Event& event) {
|
|
switch (event.type) {
|
|
case SDL_QUIT:
|
|
push(InputEvent::Quit);
|
|
break;
|
|
case SDL_KEYDOWN: {
|
|
const bool ctrl = (event.key.keysym.mod & KMOD_CTRL) != 0;
|
|
push(platform_sdl::map_key(event.key.keysym.sym, ctrl, g_text_mode,
|
|
event.key.repeat != 0));
|
|
break;
|
|
}
|
|
case SDL_TEXTINPUT:
|
|
if (g_text_mode) {
|
|
handle_text(event.text.text);
|
|
}
|
|
break;
|
|
case SDL_CONTROLLERBUTTONDOWN: {
|
|
const InputEvent ev = platform_sdl::map_button(
|
|
event.cbutton.button, g_controllers.playstation(event.cbutton.which));
|
|
push(ev);
|
|
if (platform_sdl::is_direction(ev)) {
|
|
g_repeat.press(ev, ticks_ms());
|
|
}
|
|
break;
|
|
}
|
|
case SDL_CONTROLLERBUTTONUP:
|
|
g_repeat.release(platform_sdl::map_button(event.cbutton.button,
|
|
g_controllers.playstation(event.cbutton.which)));
|
|
break;
|
|
case SDL_CONTROLLERAXISMOTION:
|
|
handle_axis(event.caxis);
|
|
break;
|
|
case SDL_CONTROLLERDEVICEADDED:
|
|
open_controller(event.cdevice.which);
|
|
break;
|
|
case SDL_CONTROLLERDEVICEREMOVED:
|
|
close_controller(event.cdevice.which);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool init(const Config& requested) {
|
|
platform_sdl::before_init();
|
|
SDL_LogSetOutputFunction(&sdl_log_output, nullptr);
|
|
const Config config = platform_sdl::adjust_config(requested);
|
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
|
|
LOG_ERROR("SDL_Init failed: %s", SDL_GetError());
|
|
return false;
|
|
}
|
|
g_window = SDL_CreateWindow(config.window_title, SDL_WINDOWPOS_CENTERED,
|
|
SDL_WINDOWPOS_CENTERED, config.window_width,
|
|
config.window_height,
|
|
SDL_WINDOW_RESIZABLE |
|
|
(config.fullscreen ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0));
|
|
if (!g_window) {
|
|
LOG_ERROR("SDL_CreateWindow failed: %s", SDL_GetError());
|
|
return false;
|
|
}
|
|
// Linear filtering of all textures (posters, scaling from
|
|
// the logical size to the window).
|
|
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
|
|
g_renderer = SDL_CreateRenderer(g_window, -1, SDL_RENDERER_ACCELERATED);
|
|
if (!g_renderer) {
|
|
g_renderer = SDL_CreateRenderer(g_window, -1, SDL_RENDERER_SOFTWARE);
|
|
}
|
|
if (!g_renderer) {
|
|
LOG_ERROR("SDL_CreateRenderer failed: %s", SDL_GetError());
|
|
return false;
|
|
}
|
|
SDL_RendererInfo info{};
|
|
SDL_GetRendererInfo(g_renderer, &info);
|
|
LOG_INFO("SDL platform: video driver %s, renderer %s", SDL_GetCurrentVideoDriver(),
|
|
info.name);
|
|
// Logical coordinates 1920x1080 whatever the window size.
|
|
SDL_RenderSetLogicalSize(g_renderer, kLogicalWidth, kLogicalHeight);
|
|
SDL_SetRenderDrawBlendMode(g_renderer, SDL_BLENDMODE_BLEND);
|
|
// All gamepads present (see controller_set.h).
|
|
for (int i = 0; i < SDL_NumJoysticks(); ++i) {
|
|
open_controller(i);
|
|
}
|
|
// No text input as long as no field has focus.
|
|
SDL_StopTextInput();
|
|
return true;
|
|
}
|
|
|
|
// Each step is logged: on PS5, the last "Shutdown: ..." line of the
|
|
// log shows the step where a possible crash happened.
|
|
void shutdown() {
|
|
LOG_INFO("Shutdown: SDL platform, %zu gamepad(s), %zu remaining texture(s)",
|
|
g_controllers.size(), g_textures.size());
|
|
for (const auto& entry : g_controllers.entries()) {
|
|
SDL_GameControllerClose(entry.handle);
|
|
}
|
|
g_controllers.clear();
|
|
LOG_INFO("Shutdown: gamepads closed");
|
|
video_clear();
|
|
for (auto& entry : g_textures) {
|
|
SDL_DestroyTexture(entry.second.texture);
|
|
}
|
|
g_textures.clear();
|
|
LOG_INFO("Shutdown: textures destroyed");
|
|
if (g_renderer) {
|
|
SDL_DestroyRenderer(g_renderer);
|
|
g_renderer = nullptr;
|
|
}
|
|
LOG_INFO("Shutdown: renderer destroyed");
|
|
if (g_window) {
|
|
SDL_DestroyWindow(g_window);
|
|
g_window = nullptr;
|
|
}
|
|
LOG_INFO("Shutdown: window destroyed");
|
|
audio_close();
|
|
LOG_INFO("Shutdown: SDL_Quit");
|
|
SDL_Quit();
|
|
LOG_INFO("Shutdown: SDL_Quit done");
|
|
}
|
|
|
|
void finish_process(int status) { platform_sdl::finish_process(status); }
|
|
|
|
void render_begin() {
|
|
if (!g_renderer) {
|
|
return;
|
|
}
|
|
SDL_SetRenderDrawColor(g_renderer, 0, 0, 0, 255);
|
|
SDL_RenderClear(g_renderer);
|
|
}
|
|
|
|
void render_present() {
|
|
if (g_renderer) {
|
|
SDL_RenderPresent(g_renderer);
|
|
}
|
|
}
|
|
|
|
TextureId texture_create(int w, int h, PixelFormat format) {
|
|
if (!g_renderer || w <= 0 || h <= 0) {
|
|
return 0;
|
|
}
|
|
SDL_Texture* texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_RGBA32,
|
|
SDL_TEXTUREACCESS_STATIC, w, h);
|
|
if (!texture) {
|
|
LOG_ERROR("SDL_CreateTexture(%dx%d) failed: %s", w, h, SDL_GetError());
|
|
return 0;
|
|
}
|
|
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
|
|
SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear);
|
|
const TextureId id = g_next_texture++;
|
|
TextureEntry entry;
|
|
entry.texture = texture;
|
|
entry.format = format;
|
|
entry.w = w;
|
|
entry.h = h;
|
|
g_textures[id] = entry;
|
|
return id;
|
|
}
|
|
|
|
bool texture_update(TextureId id, int x, int y, int w, int h, const void* pixels,
|
|
int pitch) {
|
|
auto it = g_textures.find(id);
|
|
if (it == g_textures.end() || !pixels || w <= 0 || h <= 0) {
|
|
return false;
|
|
}
|
|
const SDL_Rect area{x, y, w, h};
|
|
if (it->second.format == PixelFormat::Rgba8) {
|
|
return SDL_UpdateTexture(it->second.texture, &area, pixels, pitch) == 0;
|
|
}
|
|
// Alpha8: white + coverage as alpha; the tint comes from the color mod.
|
|
g_convert.resize(static_cast<size_t>(w) * h * 4);
|
|
const uint8_t* src = static_cast<const uint8_t*>(pixels);
|
|
for (int row = 0; row < h; ++row) {
|
|
uint8_t* dst = g_convert.data() + static_cast<size_t>(row) * w * 4;
|
|
const uint8_t* line = src + static_cast<size_t>(row) * pitch;
|
|
for (int col = 0; col < w; ++col) {
|
|
dst[col * 4 + 0] = 255;
|
|
dst[col * 4 + 1] = 255;
|
|
dst[col * 4 + 2] = 255;
|
|
dst[col * 4 + 3] = line[col];
|
|
}
|
|
}
|
|
return SDL_UpdateTexture(it->second.texture, &area, g_convert.data(), w * 4) == 0;
|
|
}
|
|
|
|
void texture_destroy(TextureId id) {
|
|
auto it = g_textures.find(id);
|
|
if (it != g_textures.end()) {
|
|
SDL_DestroyTexture(it->second.texture);
|
|
g_textures.erase(it);
|
|
}
|
|
}
|
|
|
|
void draw_rect(const Rect& rect, Color color) {
|
|
if (!g_renderer || rect.w <= 0 || rect.h <= 0) {
|
|
return;
|
|
}
|
|
SDL_SetRenderDrawColor(g_renderer, color.r, color.g, color.b, color.a);
|
|
const SDL_Rect r{rect.x, rect.y, rect.w, rect.h};
|
|
SDL_RenderFillRect(g_renderer, &r);
|
|
}
|
|
|
|
void draw_texture(TextureId id, const Rect& src, const Rect& dst, Color tint) {
|
|
auto it = g_textures.find(id);
|
|
if (!g_renderer || it == g_textures.end() || dst.w <= 0 || dst.h <= 0) {
|
|
return;
|
|
}
|
|
SDL_SetTextureColorMod(it->second.texture, tint.r, tint.g, tint.b);
|
|
SDL_SetTextureAlphaMod(it->second.texture, tint.a);
|
|
const SDL_Rect s{src.x, src.y, src.w, src.h};
|
|
const SDL_Rect d{dst.x, dst.y, dst.w, dst.h};
|
|
SDL_RenderCopy(g_renderer, it->second.texture, &s, &d);
|
|
}
|
|
|
|
void set_clip(const Rect* clip) {
|
|
if (!g_renderer) {
|
|
return;
|
|
}
|
|
if (!clip) {
|
|
SDL_RenderSetClipRect(g_renderer, nullptr);
|
|
return;
|
|
}
|
|
const SDL_Rect r{clip->x, clip->y, clip->w, clip->h};
|
|
SDL_RenderSetClipRect(g_renderer, &r);
|
|
}
|
|
|
|
void draw_video(const Rect& area) {
|
|
if (!g_renderer || !g_texture) {
|
|
return;
|
|
}
|
|
// Aspect ratio kept, bars left to the background (black after render_begin);
|
|
// recomputed at each frame to follow resizing.
|
|
const platform_sdl::FitRect fit =
|
|
platform_sdl::fit_rect(g_texture_w, g_texture_h, area.w, area.h);
|
|
const SDL_Rect dst{area.x + fit.x, area.y + fit.y, fit.w, fit.h};
|
|
SDL_RenderCopy(g_renderer, g_texture, nullptr, &dst);
|
|
}
|
|
|
|
void video_clear() {
|
|
if (g_texture) {
|
|
SDL_DestroyTexture(g_texture);
|
|
g_texture = nullptr;
|
|
g_texture_w = 0;
|
|
g_texture_h = 0;
|
|
}
|
|
}
|
|
|
|
bool video_submit_frame_yuv(const uint8_t* y, const uint8_t* u, const uint8_t* v,
|
|
int stride_y, int stride_u, int stride_v,
|
|
int w, int h) {
|
|
if (!g_renderer || !y || !u || !v || w <= 0 || h <= 0) {
|
|
return false;
|
|
}
|
|
if (!g_texture || g_texture_w != w || g_texture_h != h) {
|
|
if (g_texture) {
|
|
SDL_DestroyTexture(g_texture);
|
|
}
|
|
g_texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_IYUV,
|
|
SDL_TEXTUREACCESS_STREAMING, w, h);
|
|
if (!g_texture) {
|
|
LOG_ERROR("SDL_CreateTexture (video %dx%d) failed: %s", w, h, SDL_GetError());
|
|
return false;
|
|
}
|
|
// Linear filtering: without it SDL scales with nearest neighbor
|
|
// (grainy image when the window is smaller than the video).
|
|
if (SDL_SetTextureScaleMode(g_texture, SDL_ScaleModeLinear) != 0) {
|
|
LOG_WARN("SDL_SetTextureScaleMode(linear) failed: %s", SDL_GetError());
|
|
}
|
|
g_texture_w = w;
|
|
g_texture_h = h;
|
|
}
|
|
return SDL_UpdateYUVTexture(g_texture, nullptr, y, stride_y, u, stride_u, v,
|
|
stride_v) == 0;
|
|
}
|
|
|
|
bool audio_open(const AudioSpec& spec) {
|
|
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
|
|
LOG_ERROR("SDL_InitSubSystem(audio) failed: %s", SDL_GetError());
|
|
return false;
|
|
}
|
|
SDL_AudioSpec want{};
|
|
want.freq = spec.sample_rate;
|
|
want.format = AUDIO_S16SYS;
|
|
want.channels = static_cast<Uint8>(spec.channels);
|
|
want.samples = 1024;
|
|
g_audio_device = SDL_OpenAudioDevice(nullptr, 0, &want, nullptr, 0);
|
|
if (g_audio_device == 0) {
|
|
LOG_ERROR("SDL_OpenAudioDevice failed: %s", SDL_GetError());
|
|
return false;
|
|
}
|
|
SDL_PauseAudioDevice(g_audio_device, 0);
|
|
return true;
|
|
}
|
|
|
|
void audio_close() {
|
|
if (g_audio_device != 0) {
|
|
SDL_CloseAudioDevice(g_audio_device);
|
|
g_audio_device = 0;
|
|
}
|
|
}
|
|
|
|
size_t audio_queue(const void* samples, size_t bytes) {
|
|
if (g_audio_device == 0) {
|
|
return 0;
|
|
}
|
|
if (SDL_QueueAudio(g_audio_device, samples, static_cast<Uint32>(bytes)) != 0) {
|
|
return 0;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
size_t audio_queued_bytes() {
|
|
if (g_audio_device == 0) {
|
|
return 0;
|
|
}
|
|
return SDL_GetQueuedAudioSize(g_audio_device);
|
|
}
|
|
|
|
void audio_flush() {
|
|
if (g_audio_device != 0) {
|
|
SDL_ClearQueuedAudio(g_audio_device);
|
|
}
|
|
}
|
|
|
|
void audio_pause(bool paused) {
|
|
if (g_audio_device != 0) {
|
|
SDL_PauseAudioDevice(g_audio_device, paused ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
Input poll_input() {
|
|
if (g_pending.empty()) {
|
|
SDL_Event event;
|
|
while (g_pending.empty() && SDL_PollEvent(&event)) {
|
|
handle_event(event);
|
|
}
|
|
}
|
|
if (g_pending.empty()) {
|
|
push(g_repeat.poll(ticks_ms()));
|
|
}
|
|
if (g_pending.empty()) {
|
|
return Input{};
|
|
}
|
|
const Input in = g_pending.front();
|
|
g_pending.pop_front();
|
|
return in;
|
|
}
|
|
|
|
void set_text_input(bool enabled) {
|
|
if (enabled == g_text_mode) {
|
|
return;
|
|
}
|
|
g_text_mode = enabled;
|
|
// PS5: never SDL's IME, the application's on-screen keyboard is enough.
|
|
if (!platform_sdl::should_start_sdl_text_input(enabled, platform_sdl::system_text_input())) {
|
|
return;
|
|
}
|
|
if (enabled) {
|
|
SDL_StartTextInput();
|
|
} else {
|
|
SDL_StopTextInput();
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> preferred_locales() {
|
|
std::vector<std::string> out;
|
|
// SDL >= 2.0.14. Nothing more is attempted when it fails or returns an empty list.
|
|
SDL_Locale* locales = SDL_GetPreferredLocales();
|
|
if (!locales) {
|
|
return out;
|
|
}
|
|
for (const SDL_Locale* l = locales; l->language; ++l) {
|
|
std::string code = l->language;
|
|
if (l->country && l->country[0] != '\0') {
|
|
code += "_";
|
|
code += l->country;
|
|
}
|
|
out.push_back(std::move(code));
|
|
}
|
|
SDL_free(locales);
|
|
return out;
|
|
}
|
|
|
|
uint64_t ticks_ms() {
|
|
const auto now = std::chrono::steady_clock::now().time_since_epoch();
|
|
return static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(now).count());
|
|
}
|
|
|
|
void sleep_ms(uint32_t ms) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
|
}
|
|
|
|
} // namespace platform
|