From 1ef535fc32ef671e50804fff228bf34c97ca32b0 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:28:13 +0300 Subject: [PATCH 01/12] Let a profile say which console the game runs on, and add Patapon's small calls - GameProfile::system_language and confirm_button: what sceUtilityGetSystemParamInt and the new sceImposeGetLanguageMode report. The defaults stay a Japanese console (Japanese, circle), as before. - sceRtcGetAccumulativeTime, sceCtrlSetIdleCancelThreshold and scePowerSetClockFrequency, with their NIDs from the SDK's stubs. Co-Authored-By: Claude Opus 5.5 (1M context) --- configs/nids.csv | 4 ++++ host/hle/hle_media.cpp | 4 ++++ host/hle/hle_system.cpp | 16 ++++++++++++++-- host/profile.hpp | 8 ++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/configs/nids.csv b/configs/nids.csv index 6868215..94b097a 100644 --- a/configs/nids.csv +++ b/configs/nids.csv @@ -74,6 +74,7 @@ sceCtrl,0x6A2774F3,sceCtrlSetSamplingCycle sceCtrl,0x3A622550,sceCtrlPeekBufferPositive sceCtrl,0xB1D0E5CD,sceCtrlPeekLatch sceCtrl,0x0B588501,sceCtrlReadLatch +sceCtrl,0xA7144800,sceCtrlSetIdleCancelThreshold sceDisplay,0x0E20F177,sceDisplaySetMode sceDisplay,0x289D82FE,sceDisplaySetFrameBuf sceDisplay,0x46F186C3,sceDisplayWaitVblankStartCB @@ -125,6 +126,7 @@ sceHttp,0xF9D8EB63,sceHttpsEnd sceHttp,0xFCF8C055,sceHttpDeleteTemplate sceImpose,0x36AA6E91,sceImposeSetLanguageMode sceImpose,0x72189C48,sceImposeSetUMDPopup +sceImpose,0x24FD7BCF,sceImposeGetLanguageMode sceJpeg,0x67F0ED84,sceJpegCsc sceLibFont,0x099EF33C,sceFontFindOptimumFont sceLibFont,0x0DA7535E,sceFontGetFontInfo @@ -246,8 +248,10 @@ scePower,0x04B7766E,scePowerRegisterCallback scePower,0x469989AD,scePowerSetClockFrequency630 scePower,0xA85880D0,scePowerCheckWlanCoexistenceClock scePower,0xDFA8BAF8,scePowerUnregisterCallback +scePower,0x737486F2,scePowerSetClockFrequency sceRtc,0xE7C27D1B,sceRtcGetCurrentClockLocalTime sceRtc,0x3F7AD767,sceRtcGetCurrentTick +sceRtc,0x011F03C1,sceRtcGetAccumulativeTime sceSasCore,0x019B25EB,__sceSasSetADSR sceSasCore,0x07F58C24,__sceSasGetAllEnvelopeHeights sceSasCore,0x267A6DD2,__sceSasRevParam diff --git a/host/hle/hle_media.cpp b/host/hle/hle_media.cpp index 3dd3bda..4877467 100644 --- a/host/hle/hle_media.cpp +++ b/host/hle/hle_media.cpp @@ -549,6 +549,10 @@ void register_display_ctrl(HleRegistrar &hle) { kernel().finish(ctx, 0u); }); + // When pad input cancels the console's idle timer: there is none here. + hle.add("sceCtrl", "sceCtrlSetIdleCancelThreshold", [](Runtime &, AllegrexContext &ctx) { + kernel().finish(ctx, 0u); + }); hle.add("sceCtrl", "sceCtrlSetSamplingCycle", [](Runtime &, AllegrexContext &ctx) { const std::uint32_t previous = media().ctrl_cycle; media().ctrl_cycle = arg(ctx, 0); diff --git a/host/hle/hle_system.cpp b/host/hle/hle_system.cpp index 32d351a..261cc30 100644 --- a/host/hle/hle_system.cpp +++ b/host/hle/hle_system.cpp @@ -1,6 +1,7 @@ // UtilsForUser, LoadExecForUser, StdioForUser, ModuleMgrForUser, // InterruptManager, scePower, sceRtc, sceImpose, sceOpenPSID and the parameter // part of sceUtility. sceWlanDrv is with the ad hoc calls in hle_adhoc.cpp. +#include "../profile.hpp" #include "hle_common.hpp" #include "overlays.hpp" @@ -161,6 +162,7 @@ void register_platform(HleRegistrar &hle) { // reporting; the slot is simply forgotten. hle.add("scePower", "scePowerUnregisterCallback", success); hle.add("scePower", "scePowerSetClockFrequency630", success); + hle.add("scePower", "scePowerSetClockFrequency", success); hle.add("scePower", "scePowerCheckWlanCoexistenceClock", success); hle.add("sceRtc", "sceRtcGetCurrentClockLocalTime", [](Runtime &rt, AllegrexContext &ctx) { @@ -191,6 +193,16 @@ void register_platform(HleRegistrar &hle) { kernel().finish(ctx, 0u); }); hle.add("sceImpose", "sceImposeSetLanguageMode", success); + // (int *language, int *button): the console's, as GetSystemParamInt says. + hle.add("sceImpose", "sceImposeGetLanguageMode", [](Runtime &rt, AllegrexContext &ctx) { + if (arg(ctx, 0) != 0u) rt.memory().store32(arg(ctx, 0), portablekit::game().system_language); + if (arg(ctx, 1) != 0u) rt.memory().store32(arg(ctx, 1), portablekit::game().confirm_button); + kernel().finish(ctx, 0u); + }); + // Microseconds the console has been on, as the RTC counts them. + hle.add("sceRtc", "sceRtcGetAccumulativeTime", [](Runtime &, AllegrexContext &ctx) { + kernel().finish64(ctx, kernel().now_us()); + }); // The shell's "the disc has been ejected" popup. There is no shell and no // disc to eject, so whether the game wants it makes no difference. hle.add("sceImpose", "sceImposeSetUMDPopup", success); @@ -204,8 +216,8 @@ void register_platform(HleRegistrar &hle) { case 2u: value = 1u; break; // ad hoc channel: automatic case 4u: value = 0u; break; // date format YYYYMMDD case 5u: value = 0u; break; // 24-hour clock - case 8u: value = 0u; break; // language: Japanese - case 9u: value = 0u; break; // confirm button: circle + case 8u: value = portablekit::game().system_language; break; + case 9u: value = portablekit::game().confirm_button; break; default: break; } rt.memory().store32(arg(ctx, 1), value); diff --git a/host/profile.hpp b/host/profile.hpp index d691209..3cea4f3 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -220,6 +220,14 @@ struct GameProfile { // the emulation thread. Null: the framework's own guards decide alone. bool (*fast_loading_allowed)() = nullptr; + // --- The console's settings the game reads ---------------------------- + // What sceUtilityGetSystemParamInt and sceImposeGetLanguageMode report: the + // system language (0 Japanese, 1 English, 2 French, ...) and the confirm + // button (0 circle, 1 cross). A console of the release's region is what + // the game expects; the defaults are a Japanese one. + std::uint32_t system_language = 0u; + std::uint32_t confirm_button = 0u; + // --- Text ---------------------------------------------------------------- // Where the game caches the glyphs it has drawn. Null: a font changed while // the game runs applies to text the game has not drawn yet. From c0a4f4af743933d8448034db6258dbc93fc401f2 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:15:51 +0300 Subject: [PATCH 02/12] Trace the language and confirm button a game asks for and sets Under TRACE_KERNEL, "[sysparam]" lines show what sceUtilityGetSystemParamInt answers and what the game then hands sceImposeSetLanguageMode, so which confirm button a game ends up with is read, not guessed. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/hle/hle_system.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/host/hle/hle_system.cpp b/host/hle/hle_system.cpp index 261cc30..e8b44eb 100644 --- a/host/hle/hle_system.cpp +++ b/host/hle/hle_system.cpp @@ -29,6 +29,13 @@ std::uint64_t guest_unix_us() { return boot_unix_us() + kernel().now_us(); } void success(Runtime &, AllegrexContext &ctx) { kernel().finish(ctx, 0u); } +// The console settings a game asks for and tells the system, under +// TRACE_KERNEL: which language and which confirm button it ends up using. +bool trace_system_params() { + static const bool enabled = portablekit::env("TRACE_KERNEL") != nullptr; + return enabled; +} + void register_utils(HleRegistrar &hle) { for (const char *name : {"sceKernelDcacheWritebackAll", "sceKernelDcacheWritebackInvalidateAll", "sceKernelDcacheInvalidateRange", "sceKernelDcacheWritebackRange", "sceKernelSetGPO"}) @@ -192,7 +199,12 @@ void register_platform(HleRegistrar &hle) { if (arg(ctx, 0) != 0u) store64(rt.memory(), arg(ctx, 0), kUnixEpochTick + guest_unix_us()); kernel().finish(ctx, 0u); }); - hle.add("sceImpose", "sceImposeSetLanguageMode", success); + hle.add("sceImpose", "sceImposeSetLanguageMode", [](Runtime &, AllegrexContext &ctx) { + if (trace_system_params()) + std::cout << "[sysparam] sceImposeSetLanguageMode language=" << arg(ctx, 0) << " button=" << arg(ctx, 1) + << "\n"; + kernel().finish(ctx, 0u); + }); // (int *language, int *button): the console's, as GetSystemParamInt says. hle.add("sceImpose", "sceImposeGetLanguageMode", [](Runtime &rt, AllegrexContext &ctx) { if (arg(ctx, 0) != 0u) rt.memory().store32(arg(ctx, 0), portablekit::game().system_language); @@ -220,6 +232,8 @@ void register_platform(HleRegistrar &hle) { case 9u: value = portablekit::game().confirm_button; break; default: break; } + if (trace_system_params()) + std::cout << "[sysparam] sceUtilityGetSystemParamInt id=" << arg(ctx, 0) << " -> " << value << "\n"; rt.memory().store32(arg(ctx, 1), value); kernel().finish(ctx, 0u); }); From 2dfadc90f842276a4f7de94ead6bcc5c0e2e784b Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:15:58 +0300 Subject: [PATCH 03/12] Put the console's confirm button where the player's setting says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pad mapping assumed a Japanese console: "confirm on the south button" meant south presses circle. A game on a console where cross confirms (any non-Japanese release, once its profile says so) then went back on south and confirmed on east, the other way round from its own prompts. - The south and east buttons now press the game's confirm and back buttons, taken from GameProfile::confirm_button, so input.confirm names the physical button that confirms whatever the console. - input.confirm defaults to south where cross confirms and east where circle does; a value in settings.ini or PAD_FACE still wins. - AUTO_CONFIRM presses the console's confirm button. - The menu's confirm setting, the ○ and × key bindings' labels and the "[pad] ... connected" line say which button confirms on this console. A Japanese profile (confirm_button 0) behaves exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/gpu/vulkan_renderer.cpp | 27 ++++++++++++++++++--------- host/input/bindings.cpp | 4 ++-- host/settings/settings.cpp | 3 +++ host/settings/settings.hpp | 5 ++++- host/ui/input_script.cpp | 2 +- host/ui/menu.cpp | 16 ++++++++++++---- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index aec9aee..6f9e066 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -413,6 +413,10 @@ struct PadTuning { bool trace{false}; }; +// The SceCtrlButtons bit the game confirms with: the console's confirm +// button, as sceUtilityGetSystemParamInt reports it (0 circle, 1 cross). +std::uint32_t confirm_button_bit() { return portablekit::game().confirm_button == 1u ? 0x4000u : 0x2000u; } + // Read on every poll, so the in-game menu's changes apply at once. PadTuning pad_tuning() { static const bool trace = portablekit::env("TRACE_PAD") != nullptr; @@ -427,8 +431,7 @@ PadTuning pad_tuning() { value.right_stick_mode = player.right_stick; value.invert_x = player.invert_camera_x; value.invert_y = player.invert_camera_y; - // A PlayStation pad already carries the PSP's own face buttons, so the - // positional mapping puts confirm on circle where the prompts want it. + // Which face button presses the game's confirm button; see read_gamepad. value.confirm_south = player.confirm_south; value.trace = trace; return value; @@ -452,10 +455,15 @@ void read_gamepad(SDL_Gamepad *device, PadState &pad, int &analog_x, int &analog held(SDL_GAMEPAD_BUTTON_BACK, 0x0001u); held(SDL_GAMEPAD_BUTTON_NORTH, 0x1000u); held(SDL_GAMEPAD_BUTTON_WEST, 0x8000u); - // The game prompts "circle Enter / cross Back", and on a PlayStation pad - // those are the same two buttons in the same two places. - held(SDL_GAMEPAD_BUTTON_SOUTH, tuning.confirm_south ? 0x2000u : 0x4000u); - held(SDL_GAMEPAD_BUTTON_EAST, tuning.confirm_south ? 0x4000u : 0x2000u); + // The game confirms with the button the console's setting names (circle + // on a Japanese console, cross elsewhere) and goes back with the other. + // The player's setting says which of south and east confirms, so on a + // PlayStation pad the buttons land where the game's prompts show them + // whenever the setting matches the console. + const std::uint32_t confirm = confirm_button_bit(); + const std::uint32_t back = confirm ^ 0x6000u; + held(SDL_GAMEPAD_BUTTON_SOUTH, tuning.confirm_south ? confirm : back); + held(SDL_GAMEPAD_BUTTON_EAST, tuning.confirm_south ? back : confirm); // The PSP triggers are digital, and games hold L or R for long stretches, // so the analog triggers press the same bits as the shoulders. @@ -1673,8 +1681,9 @@ struct VulkanRenderer::Impl { gamepad_id = id; const char *name = SDL_GetGamepadName(device); const PadTuning tuning = pad_tuning(); - std::cout << "[pad] " << (name != nullptr ? name : "gamepad") << " connected; confirm on " - << (tuning.confirm_south ? "the south button" : "circle") << ", right stick " + std::cout << "[pad] " << (name != nullptr ? name : "gamepad") << " connected; confirm (" + << (confirm_button_bit() == 0x4000u ? "cross" : "circle") << ") on the " + << (tuning.confirm_south ? "south" : "east") << " button, right stick " << (tuning.right_stick_mode == settings::RightStick::DPad ? "as D-pad" : tuning.right_stick_mode == settings::RightStick::Camera ? "as camera" : "off") @@ -4749,7 +4758,7 @@ void VulkanRenderer::Impl::sample_pad(bool focused) { }(); if (auto_confirm != 0u) { const std::uint64_t phase = impl_->frames % auto_confirm; - if (phase < auto_confirm / 8u) pad.buttons |= 0x2000u; // circle + if (phase < auto_confirm / 8u) pad.buttons |= confirm_button_bit(); } // Buttons held when the game got its input back stay hidden from it until // they are released. diff --git a/host/input/bindings.cpp b/host/input/bindings.cpp index e23565d..1d1348f 100644 --- a/host/input/bindings.cpp +++ b/host/input/bindings.cpp @@ -48,8 +48,8 @@ constexpr const char *kSeparator = " / "; constexpr ActionInfo kInfo[kActions] = { {"stick_up", "Move forward"}, {"stick_left", "Move left"}, {"stick_down", "Move back"}, - {"stick_right", "Move right"}, {"triangle", "△"}, {"circle", "○ (confirm)"}, - {"cross", "× (back)"}, {"square", "□"}, {"l", "L"}, + {"stick_right", "Move right"}, {"triangle", "△"}, {"circle", "○"}, + {"cross", "×"}, {"square", "□"}, {"l", "L"}, {"r", "R"}, {"start", "START"}, {"select", "SELECT"}, {"dpad_up", "D-pad up"}, {"dpad_left", "D-pad left"}, {"dpad_down", "D-pad down"}, {"dpad_right", "D-pad right"}, {"camera_up", "Camera up"}, {"camera_left", "Camera left"}, diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index 8f825b6..2f44519 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -461,6 +461,9 @@ std::string game_default_name() { Settings defaults_for(Platform platform) { Settings values{}; + // Confirm where a console of the game's region has it: cross, the south + // button, outside Japan; circle, the east button, in Japan. + values.confirm_south = game().confirm_button == 1u; if (platform == Platform::Android) { values.aspect = Aspect::Fill; values.fullscreen = true; diff --git a/host/settings/settings.hpp b/host/settings/settings.hpp index dc2d92b..f1b89e2 100644 --- a/host/settings/settings.hpp +++ b/host/settings/settings.hpp @@ -64,7 +64,10 @@ struct Settings { bool mute{}; // Controls - bool confirm_south{}; // confirm (circle) on the south face button + // The game's confirm button on the south face button rather than the + // east one. The default follows the console the profile describes: south + // where cross confirms, east where circle does. + bool confirm_south{}; float dead_zone{0.15f}; float trigger{0.25f}; // What LT/RT (L2/R2) press past the trigger point: 0 is L and R like the diff --git a/host/ui/input_script.cpp b/host/ui/input_script.cpp index b43d0c4..bcc230f 100644 --- a/host/ui/input_script.cpp +++ b/host/ui/input_script.cpp @@ -207,7 +207,7 @@ void sort_steps(State &s) { } // Reads the lines appended to the live file since the last call. Their frames -// count from now, so a line `30:pad a` presses ○ half a second after it is read. +// count from now, so a line `30:pad a` presses the south button half a second after it is read. void read_live(State &s) { std::ifstream file(s.live_path, std::ios::binary); if (!file) return; diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index 6ba06e3..1561c89 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -585,9 +585,12 @@ void Menu::controls() { info_row("Connected", pad == nullptr ? "No gamepad; the keyboard and mouse drive the game" : (name != nullptr ? name : "Gamepad")); } - if (choice_row("Confirm button", s.confirm_south ? "Bottom (Western)" : "Right, ○ (Japanese)", - options_for("input.confirm", "Which face button confirms, in the game and in this menu. The " - "game's prompts show ○ to confirm and × to go back."))) { + const bool cross_confirms = portablekit::game().confirm_button == 1u; + if (choice_row("Confirm button", s.confirm_south ? "Bottom (Western)" : "Right (Japanese)", + options_for("input.confirm", + std::string("Which face button confirms, in the game and in this menu. The game's " + "prompts show ") + + (cross_confirms ? "× to confirm and ○ to go back." : "○ to confirm and × to go back.")))) { s.confirm_south = !s.confirm_south; settings::save(); } @@ -787,7 +790,12 @@ void Menu::controls() { const RowOptions o{false, {}, "Press a key or a mouse button to add it, or one it has already to remove it; Esc " "cancels. A key taken from another control leaves that one."}; - if (value_row(input::info(action).label, value, o) && !binding_) { + std::string label = input::info(action).label; + // The game's confirm and back buttons, as the console says. + const bool cross_confirms = portablekit::game().confirm_button == 1u; + if (action == input::Action::Circle) label += cross_confirms ? " (back)" : " (confirm)"; + if (action == input::Action::Cross) label += cross_confirms ? " (confirm)" : " (back)"; + if (value_row(label.c_str(), value, o) && !binding_) { binding_ = action; layer.begin_binding_capture(); } From e9e6faf9236b7cdf04a7ac7d97d81fe489408b36 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:21:19 +0300 Subject: [PATCH 04/12] Tilt a game's world by tilting the device A game played by tilting (LocoRoco tilts with L and R) can now be tilted with the motion sensors of a gamepad or of the phone itself. It is off by default and only for a game whose profile says how it tilts. - GameProfile::tilt names the buttons that tilt left and right, and whether the game eases its tilt towards the held side; then a small roll presses the button for part of the frames and tilts part way. - input/tilt: the roll from the accelerometer, fused with the gyroscope's rate; the dead zone with hysteresis, re-centring, inversion; three modes: Angle, Angle + level horizon, and Rate, where turning the device tilts and the tilt stays when the turning stops. Unit tested with synthetic readings (portablekit_tilt_tests). - input/motion: the sensors through SDL, a gamepad's first, else the device's own turned with the screen. The log says which were found, and why none were: Steam Input's virtual pad hides a Steam Deck's. - A tilt button the player presses takes over from the sensors, so a tilt never becomes both buttons at once. R3 alone re-centres, and so do the start and closing the menu. - Angle + level horizon turns the finished game picture against the device's roll and zooms it just enough to cover its rectangle at that angle (1:1 when level), capped at 10 degrees by default. The overlay and the interface are drawn after it and stay upright. - Controls > Tilt controls in the menu, settings.ini keys input.tilt*, and _TILT, _TILT_MODE, _TILT_FULL, _TILT_DEAD_ZONE. - The input script's virtual pad gets a gyroscope and an accelerometer when a script uses the new tilt and gyro steps, so all of it can be driven without hardware. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 11 ++ cmake/PortableKit.cmake | 6 +- docs/PROFILE_GUIDE.md | 2 + host/gpu/shaders/level.frag | 29 ++++ host/gpu/shaders/level.vert | 14 ++ host/gpu/vulkan_renderer.cpp | 316 ++++++++++++++++++++++++++++++++++- host/input/motion.cpp | 291 ++++++++++++++++++++++++++++++++ host/input/motion.hpp | 45 +++++ host/input/tilt.cpp | 128 ++++++++++++++ host/input/tilt.hpp | 135 +++++++++++++++ host/profile.hpp | 18 ++ host/settings/settings.cpp | 35 ++++ host/settings/settings.hpp | 10 ++ host/ui/input_script.cpp | 47 +++++- host/ui/input_script.hpp | 7 + host/ui/menu.cpp | 106 ++++++++++++ tests/tilt_tests.cpp | 221 ++++++++++++++++++++++++ 17 files changed, 1416 insertions(+), 5 deletions(-) create mode 100644 host/gpu/shaders/level.frag create mode 100644 host/gpu/shaders/level.vert create mode 100644 host/input/motion.cpp create mode 100644 host/input/motion.hpp create mode 100644 host/input/tilt.cpp create mode 100644 host/input/tilt.hpp create mode 100644 tests/tilt_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c9fec51..be99b62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,6 +247,17 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_touch_tests COMMAND portablekit_touch_tests) endif() +# Tilt controls: the device's roll from its motion sensors as a game's tilt +# buttons, with synthetic sensor readings. +if(PSPRECOMP_BUILD_TESTS) + add_executable(portablekit_tilt_tests + "${PORTABLEKIT_ROOT}/tests/tilt_tests.cpp" + "${PORTABLEKIT_ROOT}/host/input/tilt.cpp") + target_include_directories(portablekit_tilt_tests PRIVATE "${PORTABLEKIT_ROOT}/host") + set_target_properties(portablekit_tilt_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + add_test(NAME portablekit_tilt_tests COMMAND portablekit_tilt_tests) +endif() + # Texture pack import: finding a pack in the layouts packs come in, the # checks made before copying, and the copy, swap and backup; and the PSP's DXT # block layout. No game data. diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 2f58371..8bb9f14 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -36,6 +36,7 @@ set(PORTABLEKIT_HOST_SOURCES host/gpu/texture_decode.cpp host/input/bindings.cpp host/input/touch_controls.cpp + host/input/tilt.cpp host/gpu/texture_pack.cpp host/gpu/texture_pack_import.cpp host/perf/frame_stats.cpp @@ -86,6 +87,7 @@ set(PORTABLEKIT_RENDERER_SOURCES host/gpu/vulkan_renderer.cpp host/ui/file_browser.cpp host/ui/font_menu.cpp + host/input/motion.cpp host/ui/input_script.cpp host/ui/layer.cpp host/ui/menu.cpp @@ -178,7 +180,9 @@ function(portablekit_add_game target) "kGeVertexShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" "kGeRawVertexShader=GE_RAW_VERTICES@${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" "kGeCheckVertexShader=GE_RAW_VERTICES,GE_CHECK_DECODE@${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" - "kGeFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.frag") + "kGeFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.frag" + "kLevelVertexShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/level.vert" + "kLevelFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/level.frag") if(ANDROID) # Pre-rotation of the finished frame for a display turned sideways. list(APPEND shaders diff --git a/docs/PROFILE_GUIDE.md b/docs/PROFILE_GUIDE.md index 614630b..fe7dcab 100644 --- a/docs/PROFILE_GUIDE.md +++ b/docs/PROFILE_GUIDE.md @@ -63,6 +63,8 @@ Two more are for a port that goes further than the PSP did, and both are null un `trigger_profiles` are for how a game is played rather than how it is run: what the triggers may press instead of L and R, each with the key settings.ini keeps and the label the menu shows. Yakumo offers Bows (R on L2 to hold the aim, △ on R2 to shoot) and Bowguns (R and ○). Without any, the triggers press L and R and the menu has no row for them. +`tilt` is for a game played by tilting its world, as LocoRoco is with L and R: the buttons that tilt left and right, whether the game eases its tilt towards the held side (then a small roll of the device presses the button for part of the frames and tilts the world part of the way), and a sentence for the menu. With it, Controls gets a Tilt controls section, off by default, and the motion sensors of a gamepad (DualShock 4, DualSense, Switch Pro, a Steam Deck seen through SDL's own driver) or of the phone itself press those buttons (`host/input/tilt.hpp` for the arithmetic, `host/input/motion.hpp` for the sensors). Find the buttons and the easing with the game's pad trace before filling it in. Without it, nothing opens a sensor and the menu has no such section. + `player_name_label` and `default_player_name` are what the interface calls the name a game asks for and what it answers before the player sets one: "Player name" and "Player" unless a profile says otherwise. Yakumo says "Hunter name" and "Hunter". `glyph_cache` is for changing the font while a game runs. A game that caches the glyphs it has drawn keeps them until its atlas needs the cell, so a new font shows only in text it has not drawn yet. Where the game keeps that atlas, read off its own text code, lets the framework make it draw them again. Null for any game nobody has traced. diff --git a/host/gpu/shaders/level.frag b/host/gpu/shaders/level.frag new file mode 100644 index 0000000..3756666 --- /dev/null +++ b/host/gpu/shaders/level.frag @@ -0,0 +1,29 @@ +#version 450 + +// Level horizon: the game's picture turned about its centre and zoomed just +// enough to cover its rectangle, so no corner is ever left empty. + +layout(set = 0, binding = 0) uniform sampler2D frame; + +layout(push_constant) uniform Level { + // Where the picture lies in the frame image: offset and size, 0 to 1. + vec4 rect; + // cos and sin of the turn (anticlockwise on screen), the zoom, and the + // picture's width over its height. + vec4 turn; +} level; + +layout(location = 0) in vec2 picture; +layout(location = 0) out vec4 color; + +void main() { + // Centred, in units of the picture's height, y down as on screen. + vec2 point = (picture - 0.5) * vec2(level.turn.w, 1.0); + // The picture is turned anticlockwise as seen, which with y down is a + // clockwise turn of the coordinates: sample where this point came from. + float c = level.turn.x; + float s = level.turn.y; + vec2 source = vec2(c * point.x - s * point.y, s * point.x + c * point.y) / level.turn.z; + vec2 uv = source / vec2(level.turn.w, 1.0) + 0.5; + color = texture(frame, level.rect.xy + clamp(uv, 0.0, 1.0) * level.rect.zw); +} diff --git a/host/gpu/shaders/level.vert b/host/gpu/shaders/level.vert new file mode 100644 index 0000000..4858e45 --- /dev/null +++ b/host/gpu/shaders/level.vert @@ -0,0 +1,14 @@ +#version 450 + +// Level horizon (Angle + level horizon tilt controls): one triangle covering +// the picture's rectangle, which the viewport gives. The fragment shader +// turns and zooms the game's picture inside it. + +layout(location = 0) out vec2 picture; + +void main() { + // (0,0), (2,0), (0,2): the triangle that covers the whole viewport. + vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0); + picture = uv; +} diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index aec9aee..fc91e9e 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -15,6 +15,7 @@ #include "perf/frame_stats.hpp" #include "perf/perf_overlay.hpp" #include "input/bindings.hpp" +#include "input/motion.hpp" #include "settings/settings.hpp" #if defined(PORTABLEKIT_ANDROID_APP) @@ -818,6 +819,35 @@ struct VulkanRenderer::Impl { void destroy_rotation_pipeline(); void record_rotation(VkCommandBuffer commands, std::uint32_t image_index, VkImageLayout layout); #endif + // Angle + level horizon (input/motion.hpp): the game frame is copied into + // one of these per swapchain image, then drawn turned and zoomed into the + // image shown, under the performance overlay and the interface. Made the + // first time the picture is turned, and with every swapchain after that. + struct LevelImage { + VkImage image{}; + VkDeviceMemory memory{}; + VkImageView view{}; + VkDescriptorSet set{}; + VkFramebuffer framebuffer{}; + }; + std::vector level_images; + VkRenderPass level_render_pass{}; + VkDescriptorSetLayout level_set_layout{}; + VkDescriptorPool level_pool{}; + VkPipelineLayout level_layout{}; + VkPipeline level_pipeline{}; + VkSampler level_sampler{}; + VkShaderModule level_vertex{}; + VkShaderModule level_fragment{}; + bool level_failed{}; + // Where the last game blit put the picture in its target. + VkOffset3D picture_low{}; + VkOffset3D picture_high{}; + bool create_level_pipeline(std::string &error); + bool ensure_level_images(); + void destroy_level_images(); + void destroy_level_pipeline(); + void record_level(VkCommandBuffer commands, std::uint32_t image_index, float degrees); bool ui_ready{}; ImDrawData *ui_draw_data{}; std::function event_hook; @@ -1671,6 +1701,7 @@ struct VulkanRenderer::Impl { #endif gamepad = device; gamepad_id = id; + input::motion::gamepad_changed(device); const char *name = SDL_GetGamepadName(device); const PadTuning tuning = pad_tuning(); std::cout << "[pad] " << (name != nullptr ? name : "gamepad") << " connected; confirm on " @@ -1710,6 +1741,7 @@ struct VulkanRenderer::Impl { void close_gamepad(SDL_JoystickID id) { if (gamepad == nullptr || id != gamepad_id) return; + input::motion::gamepad_changed(nullptr); SDL_CloseGamepad(gamepad); gamepad = nullptr; gamepad_id = 0; @@ -2809,6 +2841,7 @@ bool VulkanRenderer::Impl::create_swapchain(std::string &error) { } void VulkanRenderer::Impl::destroy_swapchain_views() { + destroy_level_images(); for (VkFramebuffer framebuffer : ui_framebuffers) vkDestroyFramebuffer(device, framebuffer, nullptr); ui_framebuffers.clear(); for (VkImageView view : swapchain_views) vkDestroyImageView(device, view, nullptr); @@ -3074,6 +3107,256 @@ void VulkanRenderer::Impl::record_rotation(VkCommandBuffer commands, std::uint32 } #endif +// The pass that draws the game's picture turned for Angle + level horizon. +bool VulkanRenderer::Impl::create_level_pipeline(std::string &error) { + if (level_pipeline != VK_NULL_HANDLE) return true; + VkAttachmentDescription attachment{}; + attachment.format = swapchain_format; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + // Cleared: the bars beside a picture of the PSP's shape stay black. + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + // Left as the plain blit leaves it, for the overlay and the interface. + attachment.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + attachment.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + VkAttachmentReference reference{0u, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1u; + subpass.pColorAttachments = &reference; + VkRenderPassCreateInfo pass_info{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + pass_info.attachmentCount = 1u; + pass_info.pAttachments = &attachment; + pass_info.subpassCount = 1u; + pass_info.pSubpasses = &subpass; + if (!check(vkCreateRenderPass(device, &pass_info, nullptr, &level_render_pass), "vkCreateRenderPass", error)) + return false; + + VkSamplerCreateInfo sampler_info{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + sampler_info.magFilter = VK_FILTER_LINEAR; + sampler_info.minFilter = VK_FILTER_LINEAR; + sampler_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + if (!check(vkCreateSampler(device, &sampler_info, nullptr, &level_sampler), "vkCreateSampler", error)) return false; + + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0u; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1u; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo set_info{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + set_info.bindingCount = 1u; + set_info.pBindings = &binding; + if (!check(vkCreateDescriptorSetLayout(device, &set_info, nullptr, &level_set_layout), + "vkCreateDescriptorSetLayout", error)) + return false; + constexpr std::uint32_t kMaxImages = 8u; + VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, kMaxImages}; + VkDescriptorPoolCreateInfo pool_info{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + pool_info.maxSets = kMaxImages; + pool_info.poolSizeCount = 1u; + pool_info.pPoolSizes = &pool_size; + if (!check(vkCreateDescriptorPool(device, &pool_info, nullptr, &level_pool), "vkCreateDescriptorPool", error)) + return false; + + VkPushConstantRange push{VK_SHADER_STAGE_FRAGMENT_BIT, 0u, 8u * sizeof(float)}; + VkPipelineLayoutCreateInfo layout_info{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + layout_info.setLayoutCount = 1u; + layout_info.pSetLayouts = &level_set_layout; + layout_info.pushConstantRangeCount = 1u; + layout_info.pPushConstantRanges = &push; + if (!check(vkCreatePipelineLayout(device, &layout_info, nullptr, &level_layout), "vkCreatePipelineLayout", error)) + return false; + + const auto create_shader = [&](const std::uint32_t *code, std::size_t size, VkShaderModule &module) { + VkShaderModuleCreateInfo info{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + info.codeSize = size; + info.pCode = code; + return check(vkCreateShaderModule(device, &info, nullptr, &module), "vkCreateShaderModule", error); + }; + if (!create_shader(kLevelVertexShader, sizeof(kLevelVertexShader), level_vertex)) return false; + if (!create_shader(kLevelFragmentShader, sizeof(kLevelFragmentShader), level_fragment)) return false; + std::array stages{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = level_vertex; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = level_fragment; + stages[1].pName = "main"; + VkPipelineVertexInputStateCreateInfo vertex_input{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + VkPipelineInputAssemblyStateCreateInfo assembly{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO}; + assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo viewport{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; + viewport.viewportCount = 1u; + viewport.scissorCount = 1u; + VkPipelineRasterizationStateCreateInfo raster{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; + raster.polygonMode = VK_POLYGON_MODE_FILL; + raster.cullMode = VK_CULL_MODE_NONE; + raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + raster.lineWidth = 1.0f; + VkPipelineMultisampleStateCreateInfo multisample{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO}; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineColorBlendAttachmentState blend_attachment{}; + blend_attachment.colorWriteMask = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo blend{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO}; + blend.attachmentCount = 1u; + blend.pAttachments = &blend_attachment; + const std::array dynamic_states{VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dynamic{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO}; + dynamic.dynamicStateCount = static_cast(dynamic_states.size()); + dynamic.pDynamicStates = dynamic_states.data(); + VkGraphicsPipelineCreateInfo pipeline_info{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO}; + pipeline_info.stageCount = static_cast(stages.size()); + pipeline_info.pStages = stages.data(); + pipeline_info.pVertexInputState = &vertex_input; + pipeline_info.pInputAssemblyState = &assembly; + pipeline_info.pViewportState = &viewport; + pipeline_info.pRasterizationState = &raster; + pipeline_info.pMultisampleState = &multisample; + pipeline_info.pColorBlendState = &blend; + pipeline_info.pDynamicState = &dynamic; + pipeline_info.layout = level_layout; + pipeline_info.renderPass = level_render_pass; + return check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1u, &pipeline_info, nullptr, &level_pipeline), + "vkCreateGraphicsPipelines", error); +} + +// One copy of the frame per swapchain image, made for the swapchain in use. +// A failure is reported once, and the picture is then simply not turned. +bool VulkanRenderer::Impl::ensure_level_images() { + if (level_failed) return false; + if (!level_images.empty() && level_images.size() == swapchain_images.size()) return true; + std::string error; + const auto fail = [&] { + std::cout << "[render] cannot turn the picture for the level horizon: " << error << std::endl; + destroy_level_images(); + level_failed = true; + return false; + }; + if (!create_level_pipeline(error)) return fail(); + level_images.resize(swapchain_images.size()); + for (std::size_t i = 0; i < level_images.size(); ++i) { + LevelImage &level = level_images[i]; + if (!create_image(swapchain_extent.width, swapchain_extent.height, swapchain_format, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, level.image, level.memory, + level.view, VK_IMAGE_ASPECT_COLOR_BIT, error)) + return fail(); + VkDescriptorSetAllocateInfo allocate{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + allocate.descriptorPool = level_pool; + allocate.descriptorSetCount = 1u; + allocate.pSetLayouts = &level_set_layout; + if (!check(vkAllocateDescriptorSets(device, &allocate, &level.set), "vkAllocateDescriptorSets", error)) + return fail(); + VkDescriptorImageInfo image_info{level_sampler, level.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = level.set; + write.dstBinding = 0u; + write.descriptorCount = 1u; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &image_info; + vkUpdateDescriptorSets(device, 1u, &write, 0u, nullptr); + // The pass draws where the frame is finished: the swapchain image, or + // on a phone turned sideways its upright stand-in. + VkImageView target = swapchain_views[i]; +#if defined(__ANDROID__) + if (prerotated() && i < upright_images.size()) target = upright_images[i].view; +#endif + VkFramebufferCreateInfo framebuffer_info{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + framebuffer_info.renderPass = level_render_pass; + framebuffer_info.attachmentCount = 1u; + framebuffer_info.pAttachments = ⌖ + framebuffer_info.width = swapchain_extent.width; + framebuffer_info.height = swapchain_extent.height; + framebuffer_info.layers = 1u; + if (!check(vkCreateFramebuffer(device, &framebuffer_info, nullptr, &level.framebuffer), "vkCreateFramebuffer", + error)) + return fail(); + } + return true; +} + +void VulkanRenderer::Impl::destroy_level_images() { + for (LevelImage &level : level_images) { + if (level.framebuffer != VK_NULL_HANDLE) vkDestroyFramebuffer(device, level.framebuffer, nullptr); + if (level.set != VK_NULL_HANDLE) vkFreeDescriptorSets(device, level_pool, 1u, &level.set); + if (level.view != VK_NULL_HANDLE) vkDestroyImageView(device, level.view, nullptr); + if (level.image != VK_NULL_HANDLE) vkDestroyImage(device, level.image, nullptr); + if (level.memory != VK_NULL_HANDLE) vkFreeMemory(device, level.memory, nullptr); + } + level_images.clear(); +} + +void VulkanRenderer::Impl::destroy_level_pipeline() { + destroy_level_images(); + if (level_pipeline != VK_NULL_HANDLE) vkDestroyPipeline(device, level_pipeline, nullptr); + if (level_layout != VK_NULL_HANDLE) vkDestroyPipelineLayout(device, level_layout, nullptr); + if (level_pool != VK_NULL_HANDLE) vkDestroyDescriptorPool(device, level_pool, nullptr); + if (level_set_layout != VK_NULL_HANDLE) vkDestroyDescriptorSetLayout(device, level_set_layout, nullptr); + if (level_sampler != VK_NULL_HANDLE) vkDestroySampler(device, level_sampler, nullptr); + if (level_vertex != VK_NULL_HANDLE) vkDestroyShaderModule(device, level_vertex, nullptr); + if (level_fragment != VK_NULL_HANDLE) vkDestroyShaderModule(device, level_fragment, nullptr); + if (level_render_pass != VK_NULL_HANDLE) vkDestroyRenderPass(device, level_render_pass, nullptr); + level_pipeline = VK_NULL_HANDLE; + level_layout = VK_NULL_HANDLE; + level_pool = VK_NULL_HANDLE; + level_set_layout = VK_NULL_HANDLE; + level_sampler = VK_NULL_HANDLE; + level_vertex = VK_NULL_HANDLE; + level_fragment = VK_NULL_HANDLE; + level_render_pass = VK_NULL_HANDLE; +} + +// Draws the copied frame into the image shown, turned `degrees` +// anticlockwise about the picture's centre and zoomed just enough that the +// turned picture still covers its whole rectangle: for a W x H picture that +// is the larger of cos a + (H/W) sin a and cos a + (W/H) sin a, which is 1 +// when level and grows smoothly with the angle. +void VulkanRenderer::Impl::record_level(VkCommandBuffer commands, std::uint32_t image_index, float degrees) { + const LevelImage &level = level_images[image_index]; + transition(commands, level.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + const float width = static_cast(std::max(picture_high.x - picture_low.x, 1)); + const float height = static_cast(std::max(picture_high.y - picture_low.y, 1)); + const float radians = degrees * 3.14159265358979f / 180.0f; + const float c = std::cos(radians); + const float s = std::fabs(std::sin(radians)); + const float zoom = std::max(c + height / width * s, c + width / height * s); + const float push[8] = {static_cast(picture_low.x) / static_cast(swapchain_extent.width), + static_cast(picture_low.y) / static_cast(swapchain_extent.height), + width / static_cast(swapchain_extent.width), + height / static_cast(swapchain_extent.height), + c, + std::sin(radians), + zoom, + width / height}; + const VkClearValue black{{{0.0f, 0.0f, 0.0f, 1.0f}}}; + VkRenderPassBeginInfo pass{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + pass.renderPass = level_render_pass; + pass.framebuffer = level.framebuffer; + pass.renderArea = {{0, 0}, swapchain_extent}; + pass.clearValueCount = 1u; + pass.pClearValues = &black; + vkCmdBeginRenderPass(commands, &pass, VK_SUBPASS_CONTENTS_INLINE); + perf::count_render_pass(); + const VkViewport viewport{static_cast(picture_low.x), static_cast(picture_low.y), width, height, + 0.0f, 1.0f}; + const VkRect2D scissor{{picture_low.x, picture_low.y}, + {static_cast(width), static_cast(height)}}; + vkCmdSetViewport(commands, 0u, 1u, &viewport); + vkCmdSetScissor(commands, 0u, 1u, &scissor); + vkCmdBindPipeline(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, level_pipeline); + vkCmdBindDescriptorSets(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, level_layout, 0u, 1u, &level.set, 0u, nullptr); + vkCmdPushConstants(commands, level_layout, VK_SHADER_STAGE_FRAGMENT_BIT, 0u, sizeof(push), push); + vkCmdDraw(commands, 3u, 1u, 0u, 0u); + vkCmdEndRenderPass(commands); +} + bool VulkanRenderer::Impl::create_ui_framebuffers(std::string &error) { std::vector views = swapchain_views; #if defined(__ANDROID__) @@ -3130,6 +3413,8 @@ void VulkanRenderer::Impl::record_game_blit(VkCommandBuffer commands, VkImage so VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); } } + picture_low = low; + picture_high = high; VkImageBlit blit{}; blit.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0u, 0u, 1u}; blit.srcOffsets[1] = {static_cast(target_extent.width), @@ -3204,7 +3489,17 @@ void VulkanRenderer::Impl::submit_and_present(VkCommandBuffer commands, VkFence if (source != VK_NULL_HANDLE) { transition(commands, source, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); - record_game_blit(commands, source, target); + // Angle + level horizon turns the picture; everything drawn after + // it (the overlay, the interface) stays upright. + const float level = input::motion::level_degrees(); + if (std::fabs(level) > 0.01f && ensure_level_images() && image_index < level_images.size()) { + transition(commands, level_images[image_index].image, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + record_game_blit(commands, source, level_images[image_index].image); + record_level(commands, image_index, level); + } else { + record_game_blit(commands, source, target); + } transition(commands, source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); } else { @@ -4741,6 +5036,18 @@ void VulkanRenderer::Impl::sample_pad(bool focused) { pad.analog_x = static_cast(std::clamp(0x80 + analog_x, 0, 255)); pad.analog_y = static_cast(std::clamp(0x80 + analog_y, 0, 255)); + // Tilt controls (input/motion.hpp): the motion sensors press the game's + // tilt buttons. A tilt button the player presses takes over, so tilting + // one way while pressing the other shoulder never becomes both at once + // (LocoRoco's jump). R3 alone re-centres; L3 with it opens the menu. + if (const TiltControls *tilt = portablekit::game().tilt; tilt != nullptr) { + SDL_Gamepad *const device = impl_->gamepad; + const bool recenter = device != nullptr && SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_RIGHT_STICK) && + !SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_LEFT_STICK); + const std::uint32_t tilted = input::motion::sample(device, impl_->window, recenter); + if ((pad.buttons & (tilt->left | tilt->right)) == 0u) pad.buttons |= tilted; + } + // Unattended runs (overlay bootstrapping) press confirm periodically so the // game walks through title screens and dialogs on its own. static const std::uint64_t auto_confirm = [] { @@ -4811,7 +5118,11 @@ void VulkanRenderer::set_event_hook(std::function hook) void VulkanRenderer::set_game_input(bool enabled) { if (!impl_) return; - if (enabled && !impl_->game_input) impl_->suppress_held = true; + if (enabled && !impl_->game_input) { + impl_->suppress_held = true; + // Back from the menu, however the device is held now is level. + input::motion::recenter(); + } if (!enabled) impl_->touch.release_all(); impl_->game_input = enabled; } @@ -7566,6 +7877,7 @@ void VulkanRenderer::shutdown() { if (impl.ui_ready && ImGui::GetCurrentContext() != nullptr) ImGui_ImplVulkan_Shutdown(); impl.ui_ready = false; impl.destroy_swapchain_views(); + impl.destroy_level_pipeline(); #if defined(__ANDROID__) impl.destroy_rotation_pipeline(); #endif diff --git a/host/input/motion.cpp b/host/input/motion.cpp new file mode 100644 index 0000000..5d6eb70 --- /dev/null +++ b/host/input/motion.cpp @@ -0,0 +1,291 @@ +#include "../profile.hpp" +#include "input/motion.hpp" + +#include "input/tilt.hpp" +#include "settings/settings.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace portablekit::input::motion { +namespace { + +constexpr float kDegreesPerRadian = 57.29577951308232f; +// The tilt buttons change at most once per PSP frame when pressed part of the +// time (TiltControls::proportional). +constexpr std::uint64_t kPulseNs = 16'683'000u; + +struct State { + SDL_Gamepad *pad{}; + bool pad_accel{}; + bool pad_gyro{}; + bool pad_enabled{}; + std::string pad_name; + // The device's own sensors, opened the first time they are needed. + bool device_opened{}; + SDL_Sensor *device_accel{}; + SDL_Sensor *device_gyro{}; + std::string device_names; + + bool on{}; + bool reading{}; + bool recenter_down{}; + tilt::RollEstimator estimator; + tilt::Mapper mapper; + tilt::Pulse pulse; + tilt::Output output; + std::uint64_t last_ns{}; + std::uint64_t pulse_ns{}; + bool pulse_down{}; + int traced_direction{}; + std::uint64_t traced_ns{}; +}; + +State &state() { + static State value; + return value; +} + +bool trace() { + static const bool value = portablekit::env("TRACE_PAD") != nullptr || portablekit::env("TRACE_TILT") != nullptr; + return value; +} + +std::string pad_label(SDL_Gamepad *pad) { + const char *name = SDL_GetGamepadName(pad); + return name != nullptr ? name : "gamepad"; +} + +void enable_pad_sensors(State &s, bool enable) { + if (s.pad == nullptr || s.pad_enabled == enable) return; + if (s.pad_accel) SDL_SetGamepadSensorEnabled(s.pad, SDL_SENSOR_ACCEL, enable); + if (s.pad_gyro) SDL_SetGamepadSensorEnabled(s.pad, SDL_SENSOR_GYRO, enable); + s.pad_enabled = enable; +} + +// A phone's sensors, and a computer's where it has any. Opened once, when +// tilt controls are first on without a gamepad that has its own. +void open_device_sensors(State &s) { + if (s.device_opened) return; + s.device_opened = true; + if (!SDL_InitSubSystem(SDL_INIT_SENSOR)) { + std::cout << "[tilt] no sensor support: " << SDL_GetError() << std::endl; + return; + } + int count = 0; + SDL_SensorID *ids = SDL_GetSensors(&count); + for (int i = 0; ids != nullptr && i < count; ++i) { + const SDL_SensorType type = SDL_GetSensorTypeForID(ids[i]); + SDL_Sensor **slot = type == SDL_SENSOR_ACCEL ? &s.device_accel : type == SDL_SENSOR_GYRO ? &s.device_gyro + : nullptr; + if (slot == nullptr || *slot != nullptr) continue; + *slot = SDL_OpenSensor(ids[i]); + if (*slot == nullptr) { + std::cout << "[tilt] cannot open sensor " << ids[i] << ": " << SDL_GetError() << std::endl; + continue; + } + const char *name = SDL_GetSensorName(*slot); + if (!s.device_names.empty()) s.device_names += ", "; + s.device_names += std::string(name != nullptr ? name : "sensor") + + (type == SDL_SENSOR_ACCEL ? " (accelerometer)" : " (gyroscope)"); + } + SDL_free(ids); + if (s.device_names.empty()) + std::cout << "[tilt] this device has no accelerometer or gyroscope of its own" << std::endl; + else + std::cout << "[tilt] device sensors: " << s.device_names << std::endl; +} + +// Quarter turns anticlockwise of the display from the device's natural +// orientation, which is the frame a device's own sensors report in. +int display_quarter_turns(SDL_Window *window) { + if (window == nullptr) return 0; + const SDL_DisplayID display = SDL_GetDisplayForWindow(window); + if (display == 0) return 0; + const auto turns = [](SDL_DisplayOrientation orientation) { + switch (orientation) { + case SDL_ORIENTATION_LANDSCAPE: return 1; // right side up + case SDL_ORIENTATION_PORTRAIT_FLIPPED: return 2; + case SDL_ORIENTATION_LANDSCAPE_FLIPPED: return 3; // left side up + default: return 0; + } + }; + const SDL_DisplayOrientation current = SDL_GetCurrentDisplayOrientation(display); + if (current == SDL_ORIENTATION_UNKNOWN) return 0; + return (turns(current) - turns(SDL_GetNaturalDisplayOrientation(display)) + 4) % 4; +} + +// A vector in the device's natural axes, in the screen's. +void to_screen(float v[3], int quarter_turns) { + const float x = v[0]; + const float y = v[1]; + switch (quarter_turns) { + case 1: v[0] = -y; v[1] = x; break; + case 2: v[0] = -x; v[1] = -y; break; + case 3: v[0] = y; v[1] = -x; break; + default: break; + } +} + +// The pad's sensors when it has them, else the device's. +bool read_sensors(State &s, SDL_Window *window, tilt::Reading &reading) { + if (s.pad != nullptr && (s.pad_accel || s.pad_gyro)) { + reading.has_accel = s.pad_accel && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_ACCEL, reading.accel, 3); + reading.has_gyro = s.pad_gyro && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_GYRO, reading.gyro, 3); + return reading.has_accel || reading.has_gyro; + } + open_device_sensors(s); + if (s.device_accel == nullptr && s.device_gyro == nullptr) return false; + const int turns = display_quarter_turns(window); + if (s.device_accel != nullptr) { + reading.has_accel = SDL_GetSensorData(s.device_accel, reading.accel, 3); + if (reading.has_accel) to_screen(reading.accel, turns); + } + if (s.device_gyro != nullptr) { + reading.has_gyro = SDL_GetSensorData(s.device_gyro, reading.gyro, 3); + if (reading.has_gyro) to_screen(reading.gyro, turns); + } + return reading.has_accel || reading.has_gyro; +} + +tilt::Tuning tuning_now() { + const settings::Settings &player = settings::current(); + tilt::Tuning tuning; + tuning.mode = player.tilt_mode; + tuning.full_tilt = player.tilt_full; + tuning.dead_zone = player.tilt_dead_zone; + tuning.invert = player.tilt_invert; + tuning.level_limit = player.tilt_level_limit; + return tuning; +} + +} // namespace + +void gamepad_changed(SDL_Gamepad *pad) { + State &s = state(); + if (s.pad == pad) return; + s.pad = pad; + s.pad_enabled = false; + s.pad_accel = pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_ACCEL); + s.pad_gyro = pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO); + s.pad_name = pad != nullptr ? pad_label(pad) : std::string{}; + // Another sensor, another neutral. + s.estimator.reset(); + s.mapper.recenter(); + if (portablekit::game().tilt == nullptr || pad == nullptr) return; + if (s.pad_accel || s.pad_gyro) { + std::cout << "[tilt] " << s.pad_name << ": " + << (s.pad_gyro && s.pad_accel ? "gyroscope and accelerometer" + : s.pad_gyro ? "gyroscope only" + : "accelerometer only") + << " (" << SDL_GetGamepadSensorDataRate(pad, s.pad_gyro ? SDL_SENSOR_GYRO : SDL_SENSOR_ACCEL) + << " Hz)" << std::endl; + } else { + std::cout << "[tilt] " << s.pad_name << " has no motion sensors"; + // Steam Input hands the game a virtual pad without them, the Steam + // Deck's own included. + const SDL_GamepadType type = SDL_GetGamepadType(pad); + if (s.pad_name.find("Steam") != std::string::npos || type == SDL_GAMEPAD_TYPE_XBOX360) + std::cout << "; under Steam Input, turn Steam Input off for this game to use the pad's own gyroscope, " + "or map the gyroscope to a stick in Steam's controller settings"; + std::cout << std::endl; + } +} + +std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button) { + State &s = state(); + const TiltControls *controls = portablekit::game().tilt; + const bool on = controls != nullptr && settings::current().tilt; + if (pad != s.pad) gamepad_changed(pad); + if (!on) { + if (s.on) { + enable_pad_sensors(s, false); + s.on = false; + s.reading = false; + s.output = {}; + } + return 0u; + } + if (!s.on) { + s.on = true; + s.estimator.reset(); + s.mapper.recenter(); + s.last_ns = 0u; + } + enable_pad_sensors(s, true); + + if (recenter_button && !s.recenter_down) { + s.mapper.recenter(); + if (trace()) std::cout << "[tilt] re-centred by the gamepad" << std::endl; + } + s.recenter_down = recenter_button; + + const std::uint64_t now = SDL_GetTicksNS(); + const float seconds = s.last_ns == 0u ? 0.0f : static_cast(now - s.last_ns) * 1e-9f; + s.last_ns = now; + tilt::Reading reading; + s.reading = read_sensors(s, window, reading); + if (!s.reading) { + s.output = {}; + return 0u; + } + s.estimator.update(reading, seconds); + if (!s.estimator.valid()) return 0u; + const tilt::Tuning tuning = tuning_now(); + s.output = s.mapper.update(s.estimator.roll(), s.estimator.rate(), seconds, tuning); + + bool down = s.output.direction != 0; + if (controls->proportional) { + if (s.output.direction != s.traced_direction || now - s.pulse_ns >= kPulseNs) { + s.pulse_down = s.pulse.step(s.output.direction, s.output.strength, true); + s.pulse_ns = now; + } + down = s.pulse_down; + } + if (trace() && (s.output.direction != s.traced_direction || now - s.traced_ns >= 1'000'000'000u)) { + std::cout << "[tilt] roll " << std::lround(s.estimator.roll() * 10.0f) / 10.0f << " neutral " + << std::lround(s.mapper.neutral() * 10.0f) / 10.0f << " rate " << std::lround(s.estimator.rate()) + << " deg/s -> " + << (s.output.direction < 0 ? "left" : s.output.direction > 0 ? "right" : "none") + << " strength " << std::lround(s.output.strength * 100.0f) << "%"; + if (tuning.mode == tilt::Mode::AngleLevel) std::cout << " level " << s.output.level_degrees; + std::cout << std::endl; + s.traced_ns = now; + } + s.traced_direction = s.output.direction; + if (!down) return 0u; + return s.output.direction < 0 ? controls->left : controls->right; +} + +void recenter() { state().mapper.recenter(); } + +float level_degrees() { + const State &s = state(); + return s.on && s.reading ? s.output.level_degrees : 0.0f; +} + +std::string source() { + State &s = state(); + if (s.pad != nullptr && (s.pad_accel || s.pad_gyro)) + return s.pad_name + (s.pad_gyro && s.pad_accel ? ": gyroscope and accelerometer" + : s.pad_gyro ? ": gyroscope only" + : ": accelerometer only"); + if (!s.device_names.empty()) return "This device: " + s.device_names; + if (s.pad != nullptr) return s.pad_name + " has no motion sensors"; + if (s.device_opened) return "No motion sensors found"; + return "Not read yet"; +} + +bool reading(float &roll_from_neutral) { + const State &s = state(); + if (!s.on || !s.reading || !s.estimator.valid()) return false; + roll_from_neutral = s.estimator.roll() - s.mapper.neutral(); + return true; +} + +} // namespace portablekit::input::motion diff --git a/host/input/motion.hpp b/host/input/motion.hpp new file mode 100644 index 0000000..0e709af --- /dev/null +++ b/host/input/motion.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +struct SDL_Gamepad; +struct SDL_Window; + +// Motion sensors for tilt controls (input/tilt.hpp), through SDL: the +// gyroscope and accelerometer of the gamepad in use (DualShock 4, DualSense, +// Switch Pro, a Steam Deck seen through SDL's own driver), or else the +// device's own (a phone or a tablet). Only for a game with GameProfile::tilt, +// and only while the player has tilt controls on; otherwise the sensors stay +// closed. +// +// The main thread only, like the pad it is read with. +namespace portablekit::input::motion { + +// The renderer took a gamepad, or let it go (null). The log says which motion +// sensors it has, and why there are none when there are none. +void gamepad_changed(SDL_Gamepad *pad); + +// Reads the sensors and returns the game's tilt buttons (SceCtrlButtons bits) +// to press now: 0 while tilt controls are off, the game does not tilt, or no +// sensor can be read. `recenter_button` is the gamepad's re-centre button +// (R3); its press makes the current hold neutral. +[[nodiscard]] std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button); + +// The next reading becomes neutral. Called when the game gets its input back +// from the menu, and by the menu's Re-centre. +void recenter(); + +// Angle + level horizon: degrees to turn the game's picture anticlockwise so +// that its horizon stays level with the real one. 0 in every other case. +[[nodiscard]] float level_degrees(); + +// For the menu: where the tilt comes from ("DualSense Wireless Controller: +// gyroscope and accelerometer"), or why it cannot work. +[[nodiscard]] std::string source(); + +// For the menu: the roll from neutral now, in degrees, and whether any +// sensor is being read. +[[nodiscard]] bool reading(float &roll_from_neutral); + +} // namespace portablekit::input::motion diff --git a/host/input/tilt.cpp b/host/input/tilt.cpp new file mode 100644 index 0000000..2eba89b --- /dev/null +++ b/host/input/tilt.cpp @@ -0,0 +1,128 @@ +#include "input/tilt.hpp" + +#include +#include + +namespace portablekit::input::tilt { +namespace { + +constexpr float kDegrees = 57.29577951308232f; +constexpr float kGravity = 9.80665f; +// Seconds over which the accelerometer corrects the gyroscope's drift. +constexpr float kFusionSeconds = 0.25f; +// Seconds of smoothing on the accelerometer alone, when there is no gyroscope. +constexpr float kAccelOnlySeconds = 0.08f; + +float blend(float seconds, float time_constant) { + if (seconds <= 0.0f) return 0.0f; + return seconds / (time_constant + seconds); +} + +} // namespace + +void RollEstimator::update(const Reading &reading, float seconds) { + seconds = std::clamp(seconds, 0.0f, 0.25f); + bool have_accel_roll = false; + float accel_roll = 0.0f; + if (reading.has_accel) { + const float x = reading.accel[0]; + const float y = reading.accel[1]; + const float z = reading.accel[2]; + const float length = std::sqrt(x * x + y * y + z * z); + // Free fall or a sensor still starting says nothing about "up". + if (length > 0.3f * kGravity) { + up_[0] = x / length; + up_[1] = y / length; + up_[2] = z / length; + accel_roll = std::asin(std::clamp(-up_[0], -1.0f, 1.0f)) * kDegrees; + have_accel_roll = true; + } + } + bool have_gyro_rate = false; + float gyro_rate = 0.0f; + if (reading.has_gyro) { + // The X axis's height above the horizon is asin(x·up); up turns in + // the device's axes as -ω × up, so the height changes at + // -ω·(up × x) / cos(height), and roll is minus the height. + const float cosine = std::sqrt(std::max(1.0f - up_[0] * up_[0], 0.0f)); + if (cosine > 0.2f) { + gyro_rate = (reading.gyro[1] * up_[2] - reading.gyro[2] * up_[1]) / cosine * kDegrees; + have_gyro_rate = true; + } + } + if (!valid_) { + if (!have_accel_roll && !have_gyro_rate) return; + // Without an accelerometer the start is taken as level; re-centring + // makes it so anyway. + roll_ = have_accel_roll ? accel_roll : 0.0f; + rate_ = have_gyro_rate ? gyro_rate : 0.0f; + valid_ = true; + return; + } + const float previous = roll_; + if (have_gyro_rate) { + roll_ += gyro_rate * seconds; + if (have_accel_roll) roll_ += (accel_roll - roll_) * blend(seconds, kFusionSeconds); + rate_ = gyro_rate; + } else if (have_accel_roll) { + roll_ += (accel_roll - roll_) * blend(seconds, kAccelOnlySeconds); + rate_ = seconds > 0.0f ? (roll_ - previous) / seconds : 0.0f; + } + roll_ = std::clamp(roll_, -90.0f, 90.0f); +} + +Output Mapper::update(float roll, float rate, float seconds, const Tuning &tuning) { + const float full = std::clamp(tuning.full_tilt, kMinFullTilt, kMaxFullTilt); + const float dead = std::clamp(tuning.dead_zone, kMinDeadZone, std::min(kMaxDeadZone, full - 1.0f)); + if (recenter_) { + recenter_ = false; + neutral_ = roll; + accumulated_ = 0.0f; + held_ = 0; + } + Output out; + const float from_neutral = roll - neutral_; + float angle = from_neutral; + if (tuning.mode == Mode::Rate) { + // Only turning faster than holding still counts, by how much faster. + const float speed = std::fabs(rate); + if (speed > kRateThreshold) accumulated_ += std::copysign(speed - kRateThreshold, rate) * seconds; + if (seconds > 0.0f) accumulated_ *= std::exp(-seconds / kRateDecaySeconds); + // No further than a little past full, so tilting back acts at once. + accumulated_ = std::clamp(accumulated_, -full * 1.25f, full * 1.25f); + angle = accumulated_; + } else if (tuning.mode == Mode::AngleLevel) { + const float limit = std::clamp(tuning.level_limit, kMinLevelLimit, kMaxLevelLimit); + out.level_degrees = std::clamp(from_neutral, -limit, limit); + } + if (tuning.invert) angle = -angle; + + const float magnitude = std::fabs(angle); + const int side = angle > 0.0f ? 1 : -1; + const float release = std::max(dead - kHysteresis, dead * 0.5f); + if (held_ != 0 && (side != held_ || magnitude < release)) held_ = 0; + if (held_ == 0 && magnitude > dead) held_ = side; + out.direction = held_; + if (held_ != 0) out.strength = std::clamp((magnitude - dead) / (full - dead), 0.0f, 1.0f); + return out; +} + +bool Pulse::step(int direction, float strength, bool proportional) { + if (direction != direction_) { + direction_ = direction; + // A new press starts with the button down. + phase_ = 1.0f; + } + if (direction == 0) return false; + if (!proportional) return true; + // Just past the dead zone still presses one frame in five. + const float duty = std::clamp(0.2f + 0.8f * strength, 0.2f, 1.0f); + phase_ += duty; + if (phase_ >= 1.0f) { + phase_ -= 1.0f; + return true; + } + return false; +} + +} // namespace portablekit::input::tilt diff --git a/host/input/tilt.hpp b/host/input/tilt.hpp new file mode 100644 index 0000000..c1035c3 --- /dev/null +++ b/host/input/tilt.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include + +// Tilt controls: the device's roll, from its motion sensors, as a game's +// tilt buttons (LocoRoco tilts its world with L and R). Plain arithmetic, no +// SDL, so it is tested without hardware; input/motion.hpp reads the sensors. +// +// Axes are the screen's, as SDL gives them for a gamepad: +X to the right, +// +Y up, +Z towards the player. The accelerometer reads the reaction to +// gravity (a device at rest reads +9.8 m/s² straight up), the gyroscope +// radians a second, anticlockwise positive looking down each axis. +// +// Roll is the angle of the device's X axis below the horizon, in degrees: +// positive with the right side down. Measured that way it does not depend on +// how far the device is pitched towards the player, so a gamepad held flat +// and a phone or a Steam Deck held upright read the same. +namespace portablekit::input::tilt { + +enum class Mode { + // The roll from the calibrated neutral presses the tilt buttons. + Angle, + // As Angle, and the picture is turned against the roll so that the + // game's horizon stays level with the real one (presentation only). + AngleLevel, + // Turning the device tilts: its rate of roll is added up into a tilt that + // stays when the turning stops and fades back slowly. Turning slower than + // kRateThreshold adds nothing, so the device can be brought back to a + // comfortable hold without losing the tilt. + Rate, +}; + +struct Tuning { + Mode mode{Mode::Angle}; + // Roll, in degrees from neutral, at which the tilt is full. + float full_tilt{12.0f}; + // Roll, in degrees from neutral, before anything is pressed. + float dead_zone{4.0f}; + // Roll left presses right, and the other way round. + bool invert{}; + // Angle + level horizon: the picture is turned at most this far, in + // degrees; the further it turns, the more it is zoomed to cover the + // screen, so the cap keeps the crop small. + float level_limit{10.0f}; +}; + +inline constexpr float kMinFullTilt = 5.0f; +inline constexpr float kMaxFullTilt = 45.0f; +inline constexpr float kMinDeadZone = 1.0f; +inline constexpr float kMaxDeadZone = 15.0f; +// A pressed side is released only this far inside the dead zone, so a hand +// resting at its edge does not flicker the button. +inline constexpr float kHysteresis = 1.5f; +// Rate mode: degrees a second of roll that count as holding still. +inline constexpr float kRateThreshold = 20.0f; +// Rate mode: seconds for the added-up tilt to fall to a third by itself. +inline constexpr float kRateDecaySeconds = 3.0f; +inline constexpr float kMinLevelLimit = 2.0f; +inline constexpr float kMaxLevelLimit = 20.0f; + +// One reading of the sensors. Either may be missing: a pad with only a +// gyroscope, a phone with only an accelerometer. +struct Reading { + bool has_accel{}; + float accel[3]{}; // m/s², screen axes + bool has_gyro{}; + float gyro[3]{}; // rad/s, screen axes +}; + +// Fuses the accelerometer's roll, steady but shaken by every movement, with +// the gyroscope's rate of roll, smooth but drifting (a complementary filter). +class RollEstimator { +public: + // Takes a reading `seconds` after the previous one. + void update(const Reading &reading, float seconds); + void reset() { *this = RollEstimator{}; } + [[nodiscard]] bool valid() const { return valid_; } + // Degrees, right side down positive. + [[nodiscard]] float roll() const { return roll_; } + // Degrees a second, the same sign. + [[nodiscard]] float rate() const { return rate_; } + +private: + bool valid_{}; + float roll_{}; + float rate_{}; + // The last direction of "up" in the device's axes; straight up until an + // accelerometer says otherwise. + float up_[3]{0.0f, 1.0f, 0.0f}; +}; + +// What the roll asks of the game. +struct Output { + // -1 tilts left, +1 right, 0 neither. + int direction{}; + // How far past the dead zone, 0 to 1 (1 at full tilt). + float strength{}; + // For Angle + level horizon: degrees to turn the picture anticlockwise, + // which is the roll from neutral up to Tuning::level_limit. 0 in the + // other modes. + float level_degrees{}; +}; + +// The roll, from its neutral, as a direction to tilt: the dead zone, the +// hysteresis, inversion, re-centring and the rate mode. +class Mapper { +public: + // The next roll taken becomes neutral (and Rate's tilt drops to none). + void recenter() { recenter_ = true; } + [[nodiscard]] Output update(float roll, float rate, float seconds, const Tuning &tuning); + [[nodiscard]] float neutral() const { return neutral_; } + +private: + bool recenter_{true}; + float neutral_{}; + float accumulated_{}; + int held_{}; +}; + +// Presses one of the tilt buttons for a direction and a strength, a frame at +// a time. At full strength the button is held; below it the button is held +// for that share of the frames, spread evenly, so a game that eases its tilt +// towards the pressed side sees part of the tilt. Strength 0 with a +// direction still presses a little, the dead zone having been passed. +class Pulse { +public: + // True when the button is down this frame. + [[nodiscard]] bool step(int direction, float strength, bool proportional); + +private: + float phase_{}; + int direction_{}; +}; + +} // namespace portablekit::input::tilt diff --git a/host/profile.hpp b/host/profile.hpp index d691209..d85540d 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -44,6 +44,21 @@ struct TriggerProfile { std::uint32_t right; // and R2 }; +// A game played by tilting its world (LocoRoco tilts with L and R): what the +// device's roll presses when the player turns tilt controls on +// (input/tilt.hpp, input/motion.hpp). +struct TiltControls { + std::uint32_t left; // the PSP buttons held to tilt left, as SceCtrlButtons bits + std::uint32_t right; // and right + // True when the game eases its tilt towards the held side, so pressing + // a button part of the time tilts it part of the way: the roll between + // the dead zone and a full tilt then presses for that share of frames. + // False: past the dead zone the button is simply held. + bool proportional; + // What the menu adds about the game's own controls, a whole sentence. + const char *note; +}; + // Another release of the game than the one a port supports, and what the // installer tells a player who hands it that one. struct OtherRelease { @@ -176,6 +191,9 @@ struct GameProfile { // What the menu says about them, after "Standard: L and R, like the // shoulders." const char *trigger_profiles_note = nullptr; + // Tilt controls from motion sensors. Null: the game does not tilt, and + // the menu offers none. + const TiltControls *tilt = nullptr; // --- Hooks ------------------------------------------------------------- // Called after the framework has registered its own HLE, so a game can add diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index 8f825b6..c4d392c 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -109,6 +109,9 @@ const Names kPerfDisplays{{{PerfDisplay::Off, "off"}, {PerfDisplay::Overlay, "overlay"}, {PerfDisplay::OverlayAndLog, "overlay+log"}, {PerfDisplay::Log, "log"}}}; +const Names kTiltModes{{{input::tilt::Mode::Angle, "angle"}, + {input::tilt::Mode::AngleLevel, "level"}, + {input::tilt::Mode::Rate, "rate"}}}; const Names kRightSticks{ {{RightStick::Camera, "camera"}, {RightStick::DPad, "dpad"}, {RightStick::Off, "off"}}}; // A trigger profile by the key the game gives it, or "standard" for L and R. @@ -304,6 +307,38 @@ const std::vector &fields() { [](const Settings &s) { return format_float(s.touch_camera_speed); }, nullptr}, BOOL_FIELD("input.invert_mouse_x", invert_mouse_x), BOOL_FIELD("input.invert_mouse_y", invert_mouse_y), + {"input.tilt", "TILT", + [](Settings &s, const std::string &t) { return parse_bool(t, s.tilt); }, + [](const Settings &s) { return std::string(s.tilt ? "1" : "0"); }, + [](Settings &s, const char *t) { s.tilt = variable_flag(t); }}, + {"input.tilt_mode", "TILT_MODE", + [](Settings &s, const std::string &t) { return kTiltModes.parse(t, s.tilt_mode); }, + [](const Settings &s) { return kTiltModes.format(s.tilt_mode); }, + [](Settings &s, const char *t) { + if (!kTiltModes.parse(t, s.tilt_mode)) std::cerr << "[settings] " << env_name("TILT_MODE") << ": angle, level or rate\n"; + }}, + {"input.tilt_full", "TILT_FULL", + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinFullTilt, input::tilt::kMaxFullTilt, s.tilt_full); + }, + [](const Settings &s) { return format_float(s.tilt_full); }, + [](Settings &s, const char *t) { + s.tilt_full = variable_float(t, 12.0f, input::tilt::kMinFullTilt, input::tilt::kMaxFullTilt); + }}, + {"input.tilt_dead_zone", "TILT_DEAD_ZONE", + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinDeadZone, input::tilt::kMaxDeadZone, s.tilt_dead_zone); + }, + [](const Settings &s) { return format_float(s.tilt_dead_zone); }, + [](Settings &s, const char *t) { + s.tilt_dead_zone = variable_float(t, 4.0f, input::tilt::kMinDeadZone, input::tilt::kMaxDeadZone); + }}, + BOOL_FIELD("input.tilt_invert", tilt_invert), + {"input.tilt_level_limit", nullptr, + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinLevelLimit, input::tilt::kMaxLevelLimit, s.tilt_level_limit); + }, + [](const Settings &s) { return format_float(s.tilt_level_limit); }, nullptr}, {"input.name_entry", "OSK_MODE", [](Settings &s, const std::string &t) { return kNameEntries.parse(t, s.name_entry); }, [](const Settings &s) { return kNameEntries.format(s.name_entry); }, diff --git a/host/settings/settings.hpp b/host/settings/settings.hpp index dc2d92b..707affb 100644 --- a/host/settings/settings.hpp +++ b/host/settings/settings.hpp @@ -1,6 +1,7 @@ #pragma once #include "input/bindings.hpp" +#include "input/tilt.hpp" #include #include @@ -97,6 +98,15 @@ struct Settings { float touch_opacity{0.5f}; // 0.1-1 float touch_size{1.0f}; // 0.6-1.6 of the default size float touch_camera_speed{180.0f}; // degrees the camera turns for a drag across the screen's height + // Tilt controls, for a game that tilts (GameProfile::tilt): the roll of + // a gamepad with motion sensors, or of the phone itself, presses the + // game's tilt buttons (input/tilt.hpp). Off until the player turns it on. + bool tilt{}; + input::tilt::Mode tilt_mode{input::tilt::Mode::Angle}; + float tilt_full{12.0f}; // degrees of roll from neutral for a full tilt + float tilt_dead_zone{4.0f}; // degrees of roll from neutral before anything is pressed + bool tilt_invert{}; + float tilt_level_limit{10.0f}; // Angle + level horizon: degrees the picture turns at most NameEntry name_entry{NameEntry::Keyboard}; // on-screen keyboard, or the name below at once std::string name{game_default_name()}; // the fixed name diff --git a/host/ui/input_script.cpp b/host/ui/input_script.cpp index b43d0c4..5eab793 100644 --- a/host/ui/input_script.cpp +++ b/host/ui/input_script.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -43,6 +44,11 @@ struct State { std::uint8_t mouse_button{}; }; std::vector releases; + // The virtual pad's motion sensors (tilt, gyro), sent again every frame + // as a real pad streams them. + bool sensors{}; + float accel[3]{0.0f, 9.80665f, 0.0f}; + float gyro[3]{}; // Strings handed to SDL events must outlive them. std::deque strings; // _INPUT_LIVE: a file whose appended lines are read as they come. @@ -98,6 +104,8 @@ std::pair name_and_frames(const std::string &argumen bool mouse_step(const std::string &action) { return action == "mouse" || action == "click"; } +bool sensor_step(const std::string &action) { return action == "tilt" || action == "gyro"; } + void run(const Step &step) { State &s = state(); std::cout << "[script] frame " << s.frame << ": " << step.action << " " << step.argument << std::endl; @@ -162,6 +170,27 @@ void run(const Step &step) { return; } SDL_SetJoystickVirtualAxis(s.pad, axis, static_cast(std::clamp(value, -1.0f, 1.0f) * 32767.0f)); + } else if (step.action == "tilt") { + if (s.pad == nullptr) return; + float roll = 0.0f; + float pitch = 0.0f; + std::stringstream(step.argument) >> roll >> pitch; + constexpr float kRadians = 3.14159265358979f / 180.0f; + constexpr float kGravity = 9.80665f; + // What a pad at rest reads: the reaction to gravity, straight up. + s.accel[0] = -std::sin(roll * kRadians) * kGravity; + s.accel[1] = std::cos(roll * kRadians) * std::cos(pitch * kRadians) * kGravity; + s.accel[2] = std::cos(roll * kRadians) * std::sin(pitch * kRadians) * kGravity; + } else if (step.action == "gyro") { + if (s.pad == nullptr) return; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + std::stringstream(step.argument) >> x >> y >> z; + constexpr float kRadians = 3.14159265358979f / 180.0f; + s.gyro[0] = x * kRadians; + s.gyro[1] = y * kRadians; + s.gyro[2] = z * kRadians; } else if (step.action == "text") { SDL_Event event{}; event.type = SDL_EVENT_TEXT_INPUT; @@ -247,6 +276,7 @@ void attach() { if (file) s.live_offset = file.tellg(); uses_pad = true; uses_mouse = true; + s.sensors = true; std::cout << "[script] reading live input from " << s.live_path << std::endl; } if (const char *text = portablekit::env("INPUT_SCRIPT"); text != nullptr) { @@ -255,7 +285,8 @@ void attach() { while (std::getline(list, item, ';')) { Step step; if (!parse_step(item, 0u, step)) continue; - uses_pad = uses_pad || step.action == "pad" || step.action == "axis"; + uses_pad = uses_pad || step.action == "pad" || step.action == "axis" || sensor_step(step.action); + s.sensors = s.sensors || sensor_step(step.action); uses_mouse = uses_mouse || mouse_step(step.action); s.steps.push_back(step); } @@ -281,6 +312,13 @@ void attach_pad(State &s) { desc.button_mask = (1u << SDL_GAMEPAD_BUTTON_COUNT) - 1u; // The renderer gives the game this pad over a real one by its name. desc.name = kInputScriptName; + // A gyroscope and an accelerometer only for a script that moves them, so + // every other script's pad stays the plain one it always was. + static const SDL_VirtualJoystickSensorDesc kSensors[] = {{SDL_SENSOR_ACCEL, 60.0f}, {SDL_SENSOR_GYRO, 60.0f}}; + if (s.sensors) { + desc.nsensors = 2u; + desc.sensors = kSensors; + } const SDL_JoystickID id = SDL_AttachVirtualJoystick(&desc); if (id == 0) { std::cout << "[script] cannot attach a virtual gamepad: " << SDL_GetError() << std::endl; @@ -293,7 +331,7 @@ void attach_pad(State &s) { void tick() { State &s = state(); - if (s.steps.empty() && s.releases.empty() && s.live_path.empty()) return; + if (s.steps.empty() && s.releases.empty() && s.live_path.empty() && !s.sensors) return; ++s.frame; if (!s.live_path.empty() && s.frame % 10u == 0u) read_live(s); for (auto it = s.releases.begin(); it != s.releases.end();) { @@ -310,6 +348,11 @@ void tick() { run(s.steps.front()); s.steps.pop_front(); } + if (s.sensors && s.pad != nullptr) { + const Uint64 now = SDL_GetTicksNS(); + SDL_SendJoystickVirtualSensorData(s.pad, SDL_SENSOR_ACCEL, now, s.accel, 3); + SDL_SendJoystickVirtualSensorData(s.pad, SDL_SENSOR_GYRO, now, s.gyro, 3); + } } } // namespace portablekit::ui::script diff --git a/host/ui/input_script.hpp b/host/ui/input_script.hpp index 2d00338..b7be24d 100644 --- a/host/ui/input_script.hpp +++ b/host/ui/input_script.hpp @@ -18,6 +18,13 @@ // leftstick, rightstick, leftshoulder, dpup, dpdown, ...) // axis NAME VALUE hold an axis of the virtual gamepad at VALUE, -1 to 1 // (leftx, lefty, rightx, righty, lefttrigger, righttrigger) +// tilt ROLL [PITCH] hold the virtual gamepad's accelerometer where it reads +// with the pad rolled ROLL degrees (right side down +// positive) and pitched PITCH degrees back from flat, 0 by +// default: tilt controls without a pad that has sensors +// gyro X Y Z hold its gyroscope at X, Y, Z degrees a second about the +// screen's axes (right, up, towards the player); a turn +// clockwise as the player sees it is a negative Z // text STRING type text // drop PATH drop a file onto the window // shot NAME write the window image to _SCREENSHOT_DIR/NAME.bmp diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index 6ba06e3..ce1fcc6 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -22,6 +22,7 @@ #include "gpu/vulkan_renderer.hpp" #include "profile.hpp" #include "input/bindings.hpp" +#include "input/motion.hpp" #include "install/installer.hpp" #include "install/user_data.hpp" #include "perf/frame_stats.hpp" @@ -118,6 +119,7 @@ class Menu { void video(); void audio(); void controls(); + void tilt_controls(); void network(); void system(); bool confirm_dialog(); @@ -576,6 +578,102 @@ bool address_character(char32_t c) { c == U':' || c == U'-' || c == U'[' || c == U']'; } +// Only for a game that tilts (GameProfile::tilt). +void Menu::tilt_controls() { + const TiltControls *tilt = portablekit::game().tilt; + if (tilt == nullptr) return; + settings::Settings &s = settings::current(); + section("Tilt controls"); + { + std::string description = "Tilt the gamepad, or the phone itself, to tilt the game: its motion sensors press " + "the tilt buttons, and the buttons keep working. It needs a gamepad with a " + "gyroscope (DualShock 4, DualSense, Switch Pro, a Steam Deck with Steam Input off " + "for this game) or a phone."; + if (tilt->note != nullptr) description += std::string(" ") + tilt->note; + if (toggle_row("Tilt with motion", s.tilt, options_for("input.tilt", description))) { + s.tilt = !s.tilt; + settings::save(); + } + } + const auto off = [&](RowOptions options) { + if (!s.tilt && !options.disabled) { + options.disabled = true; + options.note = "Tilt with motion is off"; + } + return options; + }; + { + static const char *const kModes[] = {"Angle", "Angle + level horizon", "Rate"}; + const int current = static_cast(s.tilt_mode); + if (const int delta = choice_row( + "Gyro mode", kModes[current], + off(options_for("input.tilt_mode", + "Angle: how far the device is tilted from where it was re-centred tilts the game; " + "the picture stays as it is. Angle + level horizon (experimental): the same, and " + "the picture is turned against the device and zoomed a little so the game's " + "horizon stays level with the real one; turning pictures can cause motion " + "sickness. Rate: turning the device tilts, and the tilt stays when the turning " + "stops, so the device can go back to a comfortable hold.")))) { + s.tilt_mode = static_cast(cycle(current, delta, 3)); + settings::save(); + } + } + int full = static_cast(std::lround(s.tilt_full)); + if (slider_row("Full tilt at", full, static_cast(input::tilt::kMinFullTilt), + static_cast(input::tilt::kMaxFullTilt), 1, "%d deg", + off(options_for("input.tilt_full", "Sensitivity: how far the device is tilted for the game's " + "full tilt. Less is more sensitive.")))) { + s.tilt_full = static_cast(full); + s.tilt_dead_zone = std::min(s.tilt_dead_zone, s.tilt_full - 1.0f); + settings::save(); + } + int dead = static_cast(std::lround(s.tilt_dead_zone)); + if (slider_row("Tilt dead zone", dead, static_cast(input::tilt::kMinDeadZone), + static_cast(input::tilt::kMaxDeadZone), 1, "%d deg", + off(options_for("input.tilt_dead_zone", "How far the device is tilted before anything happens, " + "so a steady hand does not tilt the game.")))) { + s.tilt_dead_zone = static_cast(dead); + settings::save(); + } + if (toggle_row("Invert tilt", s.tilt_invert, + off(options_for("input.tilt_invert", "Tilting the device left tilts the game right.")))) { + s.tilt_invert = !s.tilt_invert; + settings::save(); + } + { + RowOptions o = off(options_for("input.tilt_level_limit", + "How far the picture may turn to keep the horizon level. The picture is " + "zoomed as it turns so no corner is left empty; a larger limit crops more.")); + if (s.tilt_mode != input::tilt::Mode::AngleLevel && !o.disabled) { + o.disabled = true; + o.note = "Only for Angle + level horizon"; + } + int limit = static_cast(std::lround(s.tilt_level_limit)); + if (slider_row("Level horizon limit", limit, static_cast(input::tilt::kMinLevelLimit), + static_cast(input::tilt::kMaxLevelLimit), 1, "%d deg", o)) { + s.tilt_level_limit = static_cast(limit); + settings::save(); + } + } + if (button_row("Re-centre tilt", + off({false, {}, "Makes however the device is held now the level position. This also happens " + "when the game starts, when this menu closes, and with R3 on a gamepad."}))) { + input::motion::recenter(); + } + if (s.tilt) { + // Read here too: the game's input is off while the menu is open, and + // the row below is how a player sees the sensors work. + (void)input::motion::sample(renderer().gamepad(), renderer().window(), false); + info_row("Motion from", input::motion::source()); + float roll = 0.0f; + if (input::motion::reading(roll)) { + char text[32]; + std::snprintf(text, sizeof(text), "%+.1f deg", static_cast(roll)); + info_row("Tilted now", text); + } + } +} + void Menu::controls() { settings::Settings &s = settings::current(); section("Gamepad"); @@ -708,6 +806,8 @@ void Menu::controls() { } } + tilt_controls(); + section(game().player_name_label); { const bool keyboard = s.name_entry == settings::NameEntry::Keyboard; @@ -825,6 +925,12 @@ void Menu::controls() { restore("input.touch_opacity", s.touch_opacity, d.touch_opacity); restore("input.touch_size", s.touch_size, d.touch_size); restore("input.touch_camera_speed", s.touch_camera_speed, d.touch_camera_speed); + restore("input.tilt", s.tilt, d.tilt); + restore("input.tilt_mode", s.tilt_mode, d.tilt_mode); + restore("input.tilt_full", s.tilt_full, d.tilt_full); + restore("input.tilt_dead_zone", s.tilt_dead_zone, d.tilt_dead_zone); + restore("input.tilt_invert", s.tilt_invert, d.tilt_invert); + restore("input.tilt_level_limit", s.tilt_level_limit, d.tilt_level_limit); s.bindings = d.bindings; settings::save(); } diff --git a/tests/tilt_tests.cpp b/tests/tilt_tests.cpp new file mode 100644 index 0000000..60b2cc8 --- /dev/null +++ b/tests/tilt_tests.cpp @@ -0,0 +1,221 @@ +// Tilt controls (input/tilt.hpp): the device's roll as tilt buttons, without +// SDL, sensors or game data. +#include "input/tilt.hpp" + +#include +#include + +namespace { +using namespace portablekit::input::tilt; +int failures{}; + +void check(bool condition, const char *message) { + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +constexpr float kPi = 3.14159265358979f; +constexpr float kG = 9.80665f; +constexpr float kFrame = 1.0f / 60.0f; + +// What the accelerometer reads with the device rolled `roll` degrees (right +// side down) and pitched `pitch` degrees back from flat towards the player. +Reading accel_at(float roll, float pitch) { + const float r = roll * kPi / 180.0f; + const float p = pitch * kPi / 180.0f; + Reading reading; + reading.has_accel = true; + // "Up" in the device's axes: the X axis dips by the roll, and what is + // left of up is shared between Y and Z by the pitch. + reading.accel[0] = -std::sin(r) * kG; + reading.accel[1] = std::cos(r) * std::cos(p) * kG; + reading.accel[2] = std::cos(r) * std::sin(p) * kG; + return reading; +} + +void test_roll_from_accelerometer() { + for (const float pitch : {0.0f, 30.0f, 60.0f, 85.0f}) { + RollEstimator estimator; + estimator.update(accel_at(10.0f, pitch), kFrame); + check(estimator.valid(), "one accelerometer reading is enough"); + check(std::fabs(estimator.roll() - 10.0f) < 0.01f, "roll does not depend on the pitch"); + } + RollEstimator estimator; + estimator.update(accel_at(-8.0f, 45.0f), kFrame); + check(estimator.roll() < -7.9f && estimator.roll() > -8.1f, "left side down is negative"); + RollEstimator falling; + Reading nothing; + nothing.has_accel = true; + falling.update(nothing, kFrame); + check(!falling.valid(), "a zero reading says nothing"); +} + +void test_roll_follows_accelerometer() { + RollEstimator estimator; + estimator.update(accel_at(0.0f, 40.0f), kFrame); + for (int i = 0; i < 60; ++i) estimator.update(accel_at(12.0f, 40.0f), kFrame); + check(std::fabs(estimator.roll() - 12.0f) < 0.5f, "a steady roll is reached within a second"); + check(estimator.rate() > -1.0f && estimator.rate() < 1.0f, "and its rate settles"); +} + +void test_gyro_rate() { + // Flat gamepad (up is +Y): turning clockwise as the player sees it is a + // negative rate about +Z and rolls the right side down. + RollEstimator estimator; + Reading reading = accel_at(0.0f, 0.0f); + estimator.update(reading, kFrame); + reading.has_gyro = true; + reading.has_accel = false; + reading.gyro[2] = -30.0f * kPi / 180.0f; + for (int i = 0; i < 30; ++i) estimator.update(reading, kFrame); + check(std::fabs(estimator.rate() - 30.0f) < 0.1f, "clockwise about the player's view is positive roll"); + check(std::fabs(estimator.roll() - 15.0f) < 0.5f, "half a second at 30 deg/s rolls 15 degrees"); + + // Held upright (up is +Z-ish, a phone or a Deck at 70 degrees): the same + // turn about the screen's normal is still roll. + RollEstimator upright; + Reading tilted = accel_at(0.0f, 70.0f); + upright.update(tilted, kFrame); + const float p = 70.0f * kPi / 180.0f; + // A turn about the axis towards the player, which is pitched with the + // device: its components in the device's axes. + tilted.has_gyro = true; + tilted.has_accel = false; + tilted.gyro[1] = 0.0f; + tilted.gyro[2] = -30.0f * kPi / 180.0f; + upright.update(tilted, kFrame); + check(upright.rate() > 30.0f * std::cos(p) * 0.99f, "a turn about the screen's normal rolls when upright"); +} + +void test_fusion_rejects_shake() { + // A shake that the gyroscope says is not a turn barely moves the roll. + RollEstimator estimator; + Reading still = accel_at(0.0f, 30.0f); + still.has_gyro = true; + estimator.update(still, kFrame); + Reading shaken = still; + shaken.accel[0] = -0.5f * kG; // a sideways jolt: 30 degrees if taken as roll + estimator.update(shaken, kFrame); + check(std::fabs(estimator.roll()) < 3.0f, "one jolt moves the fused roll only a little"); +} + +void test_dead_zone_and_hysteresis() { + Mapper mapper; + const Tuning tuning{Mode::Angle, 12.0f, 4.0f, false}; + check(mapper.update(0.0f, 0.0f, kFrame, tuning).direction == 0, "neutral presses nothing"); + check(mapper.update(3.9f, 0.0f, kFrame, tuning).direction == 0, "inside the dead zone presses nothing"); + Output out = mapper.update(4.5f, 0.0f, kFrame, tuning); + check(out.direction == 1, "past the dead zone to the right presses right"); + check(out.strength > 0.0f && out.strength < 0.1f, "just past it is a weak tilt"); + check(mapper.update(3.0f, 0.0f, kFrame, tuning).direction == 1, "a little back inside still holds"); + check(mapper.update(2.4f, 0.0f, kFrame, tuning).direction == 0, "past the hysteresis it lets go"); + check(mapper.update(3.9f, 0.0f, kFrame, tuning).direction == 0, "and needs the dead zone again"); + out = mapper.update(-20.0f, 0.0f, kFrame, tuning); + check(out.direction == -1 && out.strength == 1.0f, "far to the left is full left"); + check(mapper.update(5.0f, 0.0f, kFrame, tuning).direction == 1, "crossing over changes side at once"); + // A dead zone smaller than the hysteresis still lets go. + Mapper small; + const Tuning narrow{Mode::Angle, 12.0f, 1.0f, false}; + check(small.update(0.0f, 0.0f, kFrame, narrow).direction == 0, "neutral"); + check(small.update(1.2f, 0.0f, kFrame, narrow).direction == 1, "a narrow dead zone presses"); + check(small.update(0.2f, 0.0f, kFrame, narrow).direction == 0, "and lets go near neutral"); +} + +void test_recenter_and_invert() { + Mapper mapper; + Tuning tuning{Mode::Angle, 12.0f, 4.0f, false}; + // The first roll seen is neutral: a player holding the device at 20 + // degrees presses nothing. + check(mapper.update(20.0f, 0.0f, kFrame, tuning).direction == 0, "the first roll is neutral"); + check(std::fabs(mapper.neutral() - 20.0f) < 0.001f, "and remembered"); + check(mapper.update(26.0f, 0.0f, kFrame, tuning).direction == 1, "rolls count from it"); + mapper.recenter(); + check(mapper.update(26.0f, 0.0f, kFrame, tuning).direction == 0, "re-centring makes the hold neutral"); + tuning.invert = true; + check(mapper.update(31.0f, 0.0f, kFrame, tuning).direction == -1, "inverted, right presses left"); +} + +void test_level_horizon() { + Mapper mapper; + const Tuning level{Mode::AngleLevel, 12.0f, 4.0f, false}; + (void)mapper.update(10.0f, 0.0f, kFrame, level); + Output out = mapper.update(17.0f, 0.0f, kFrame, level); + check(std::fabs(out.level_degrees - 7.0f) < 0.001f, "the picture turns by the roll from neutral"); + check(out.direction == 1, "and the tilt still presses"); + out = mapper.update(80.0f, 0.0f, kFrame, level); + check(out.level_degrees == 10.0f, "never further than the limit, 10 degrees by default"); + Tuning wider = level; + wider.level_limit = 15.0f; + check(mapper.update(80.0f, 0.0f, kFrame, wider).level_degrees == 15.0f, "the limit is tunable"); + wider.level_limit = 90.0f; + check(mapper.update(80.0f, 0.0f, kFrame, wider).level_degrees == kMaxLevelLimit, "within its own bounds"); + Mapper plain; + const Tuning angle{Mode::Angle, 12.0f, 4.0f, false}; + (void)plain.update(0.0f, 0.0f, kFrame, angle); + check(plain.update(10.0f, 0.0f, kFrame, angle).level_degrees == 0.0f, "Angle alone leaves the picture"); +} + +void test_rate_mode() { + Mapper mapper; + const Tuning rate{Mode::Rate, 12.0f, 4.0f, false}; + (void)mapper.update(0.0f, 0.0f, kFrame, rate); + // A quick turn: 0.25 s at 80 deg/s. + Output out; + for (int i = 0; i < 15; ++i) out = mapper.update(0.0f, 80.0f, kFrame, rate); + check(out.direction == 1, "a quick turn to the right tilts right"); + // Bringing the device back slowly adds nothing. + for (int i = 0; i < 60; ++i) out = mapper.update(0.0f, -15.0f, kFrame, rate); + check(out.direction == 1, "a slow return keeps the tilt"); + // Held still, it fades. + for (int i = 0; i < 60 * 8; ++i) out = mapper.update(0.0f, 0.0f, kFrame, rate); + check(out.direction == 0, "held still, the tilt fades"); + // A quick turn the other way tilts left at once. + for (int i = 0; i < 15; ++i) out = mapper.update(0.0f, -80.0f, kFrame, rate); + check(out.direction == -1, "a quick turn to the left tilts left"); + mapper.recenter(); + check(mapper.update(0.0f, 0.0f, kFrame, rate).direction == 0, "re-centring drops the tilt"); + // Slow drift alone never tilts. + Mapper drift; + for (int i = 0; i < 600; ++i) out = drift.update(0.0f, 19.0f, kFrame, rate); + check(out.direction == 0, "turning slower than the threshold never tilts"); +} + +void test_pulse() { + Pulse pulse; + int down = 0; + for (int i = 0; i < 100; ++i) down += pulse.step(1, 1.0f, true) ? 1 : 0; + check(down == 100, "full strength holds the button"); + Pulse half; + down = 0; + for (int i = 0; i < 100; ++i) down += half.step(1, 0.5f, true) ? 1 : 0; + check(down >= 58 && down <= 62, "half strength presses about 60 frames in 100"); + Pulse fresh; + check(fresh.step(-1, 0.0f, true), "a new press starts down"); + check(!fresh.step(0, 0.0f, true), "no direction presses nothing"); + Pulse digital; + down = 0; + for (int i = 0; i < 100; ++i) down += digital.step(1, 0.1f, false) ? 1 : 0; + check(down == 100, "not proportional, any tilt holds the button"); +} + +} // namespace + +int main() { + test_roll_from_accelerometer(); + test_roll_follows_accelerometer(); + test_gyro_rate(); + test_fusion_rejects_shake(); + test_dead_zone_and_hysteresis(); + test_recenter_and_invert(); + test_level_horizon(); + test_rate_mode(); + test_pulse(); + if (failures != 0) { + std::cerr << failures << " tilt test(s) failed\n"; + return 1; + } + std::cout << "tilt tests passed\n"; + return 0; +} From 7a94d1fbac084260c7e3cab4132920f5e9840d34 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:08:27 +0300 Subject: [PATCH 05/12] Read a Steam Deck's gyroscope beside Steam Input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A game started from Steam on a Steam Deck gets the Deck's controls through Steam Input: SDL is told to ignore the real controller (28de:1205) and sees only Steam's virtual pad, which has no motion sensors, and tilt controls did nothing even with Steam Input turned off for the game, because the Deck's built-in controls stay under Steam Input. input/steam_deck_imu reads the controller's state reports from its hidraw node beside Steam, on Linux, when the pad in use has no sensors of its own. Only the motion fields are used; buttons and sticks keep coming through Steam's pad, so nothing is pressed twice. Measured on a Deck (SteamOS, Game Mode): - The controller's vendor interface (report descriptor starting with usage page 0xFFFF) sends 64-byte reports 01 00 09 40 at 250 a second; the accelerometer is int16 X, Y, Z at 24, the gyroscope at 30. - While a game whose Steam layout leaves the gyroscope unused is running, every motion field is zero: Steam has the motion unit off. In Steam's own interface it is on. - Setting 48 (the motion unit's mode) to 0x18 through feature message 0x87 turns it on and data arrive at once; 0 turns it off again. - At rest on a table the accelerometer read (0.4, 9.78, -1.1) m/s² in SDL's gamepad axes, +Y up out of the face, as SDL's own Deck driver gives them. So when the motion fields stay empty for 0.3 s the mode is set, again once a second if Steam turns it back off (logged), and set back off when tilt controls are turned off or the game exits, if it was set here. _TILT_DECK=0 leaves the controller alone. The gyroscope is averaged over the reports of each frame. The log names the source ("[tilt] Steam Deck controller /dev/hidrawN: reading its gyroscope and accelerometer beside Steam Input"), the menu shows "Steam Deck: gyroscope and accelerometer", and the advice to turn Steam Input off is given only for pads other than a Deck's own. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmake/PortableKit.cmake | 1 + host/input/motion.cpp | 23 ++- host/input/motion.hpp | 3 +- host/input/steam_deck_imu.cpp | 286 ++++++++++++++++++++++++++++++++++ host/input/steam_deck_imu.hpp | 44 ++++++ host/ui/menu.cpp | 4 +- 6 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 host/input/steam_deck_imu.cpp create mode 100644 host/input/steam_deck_imu.hpp diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 8bb9f14..5656466 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -88,6 +88,7 @@ set(PORTABLEKIT_RENDERER_SOURCES host/ui/file_browser.cpp host/ui/font_menu.cpp host/input/motion.cpp + host/input/steam_deck_imu.cpp host/ui/input_script.cpp host/ui/layer.cpp host/ui/menu.cpp diff --git a/host/input/motion.cpp b/host/input/motion.cpp index 5d6eb70..7aafe36 100644 --- a/host/input/motion.cpp +++ b/host/input/motion.cpp @@ -1,6 +1,7 @@ #include "../profile.hpp" #include "input/motion.hpp" +#include "input/steam_deck_imu.hpp" #include "input/tilt.hpp" #include "settings/settings.hpp" @@ -31,6 +32,8 @@ struct State { SDL_Sensor *device_accel{}; SDL_Sensor *device_gyro{}; std::string device_names; + // The Steam Deck's own sensors, read beside Steam Input. + bool deck{}; bool on{}; bool reading{}; @@ -132,13 +135,20 @@ void to_screen(float v[3], int quarter_turns) { } } -// The pad's sensors when it has them, else the device's. +// The pad's sensors when it has them, else a Steam Deck's read directly, +// else the device's. bool read_sensors(State &s, SDL_Window *window, tilt::Reading &reading) { if (s.pad != nullptr && (s.pad_accel || s.pad_gyro)) { + if (s.deck) { + steam_deck_imu::close(); + s.deck = false; + } reading.has_accel = s.pad_accel && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_ACCEL, reading.accel, 3); reading.has_gyro = s.pad_gyro && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_GYRO, reading.gyro, 3); return reading.has_accel || reading.has_gyro; } + s.deck = steam_deck_imu::open(); + if (s.deck) return steam_deck_imu::read(reading); open_device_sensors(s); if (s.device_accel == nullptr && s.device_gyro == nullptr) return false; const int turns = display_quarter_turns(window); @@ -187,10 +197,12 @@ void gamepad_changed(SDL_Gamepad *pad) { << " Hz)" << std::endl; } else { std::cout << "[tilt] " << s.pad_name << " has no motion sensors"; - // Steam Input hands the game a virtual pad without them, the Steam - // Deck's own included. + // Steam Input hands the game a virtual pad without them. A Steam + // Deck's own are read directly; another pad's are not. const SDL_GamepadType type = SDL_GetGamepadType(pad); - if (s.pad_name.find("Steam") != std::string::npos || type == SDL_GAMEPAD_TYPE_XBOX360) + if (steam_deck_imu::present()) + std::cout << "; the Steam Deck's own are read directly"; + else if (s.pad_name.find("Steam") != std::string::npos || type == SDL_GAMEPAD_TYPE_XBOX360) std::cout << "; under Steam Input, turn Steam Input off for this game to use the pad's own gyroscope, " "or map the gyroscope to a stick in Steam's controller settings"; std::cout << std::endl; @@ -205,6 +217,8 @@ std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button) if (!on) { if (s.on) { enable_pad_sensors(s, false); + steam_deck_imu::close(); + s.deck = false; s.on = false; s.reading = false; s.output = {}; @@ -275,6 +289,7 @@ std::string source() { return s.pad_name + (s.pad_gyro && s.pad_accel ? ": gyroscope and accelerometer" : s.pad_gyro ? ": gyroscope only" : ": accelerometer only"); + if (s.deck) return steam_deck_imu::describe(); if (!s.device_names.empty()) return "This device: " + s.device_names; if (s.pad != nullptr) return s.pad_name + " has no motion sensors"; if (s.device_opened) return "No motion sensors found"; diff --git a/host/input/motion.hpp b/host/input/motion.hpp index 0e709af..c020e9c 100644 --- a/host/input/motion.hpp +++ b/host/input/motion.hpp @@ -8,7 +8,8 @@ struct SDL_Window; // Motion sensors for tilt controls (input/tilt.hpp), through SDL: the // gyroscope and accelerometer of the gamepad in use (DualShock 4, DualSense, -// Switch Pro, a Steam Deck seen through SDL's own driver), or else the +// Switch Pro, a Steam Deck seen through SDL's own driver), else a Steam +// Deck's read beside Steam Input (input/steam_deck_imu.hpp), or else the // device's own (a phone or a tablet). Only for a game with GameProfile::tilt, // and only while the player has tilt controls on; otherwise the sensors stay // closed. diff --git a/host/input/steam_deck_imu.cpp b/host/input/steam_deck_imu.cpp new file mode 100644 index 0000000..3fbbb4a --- /dev/null +++ b/host/input/steam_deck_imu.cpp @@ -0,0 +1,286 @@ +#include "../profile.hpp" +#include "input/steam_deck_imu.hpp" + +#include +#include +#include +#include +#include + +#if defined(__linux__) && !defined(__ANDROID__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace portablekit::input::steam_deck_imu { +namespace { + +[[maybe_unused]] bool switched_off() { + const char *value = portablekit::env("TILT_DECK"); + return value != nullptr && (std::strcmp(value, "0") == 0 || std::strcmp(value, "off") == 0); +} + +} // namespace + +#if defined(__linux__) && !defined(__ANDROID__) +namespace { + +// The Deck's built-in controller, and the interface of it that sends the +// controller's state: the one whose report descriptor starts with the +// vendor-defined usage page 0xFFFF (the other two are its keyboard and mouse). +constexpr const char *kDeckHidId = "000028DE:00001205"; + +// Its state report, as read on a Deck: a 4-byte header (01 00, type 09, +// length 0x40), then the packet number (u32) at 4, the buttons (u64) at 8, +// the two trackpads at 16, then little-endian int16 accelerometer X, Y, Z +// at 24, gyroscope X, Y, Z at 30 and an orientation quaternion at 36. 250 +// reports a second. +constexpr std::size_t kReportBytes = 64; +constexpr std::uint8_t kStateReport = 0x09; +constexpr std::size_t kAccel = 24; +constexpr std::size_t kGyro = 30; +constexpr std::size_t kMotionEnd = 44; +// Full scale: ±2 g and ±2000 degrees a second over the int16 range. +constexpr float kAccelScale = 2.0f * 9.80665f / 32768.0f; +constexpr float kGyroScale = 2000.0f / 32768.0f * 0.017453292519943295f; + +// The controller's settings: feature message 0x87 with (setting u8, value +// u16) triples; setting 48 is the motion unit's mode, and 0x18 asks for raw +// accelerometer (0x08) and raw gyroscope (0x10) data. +constexpr std::uint8_t kSetSettings = 0x87; +constexpr std::uint8_t kSettingImuMode = 48; +constexpr std::uint16_t kImuRaw = 0x18; +constexpr std::uint16_t kImuOff = 0x00; + +// How long the motion fields may stay empty before the unit is switched on, +// and between two attempts. +constexpr auto kSilenceBeforeSwitch = std::chrono::milliseconds(300); +constexpr auto kSwitchInterval = std::chrono::seconds(1); + +using Clock = std::chrono::steady_clock; + +struct Deck { + bool tried{}; + int fd{-1}; + bool writable{}; + std::string path; + bool switched_on{}; // the mode was set here and not yet set back + int switches{}; + bool have{}; // a report with motion data has been read + std::uint64_t reports{}; + tilt::Reading last; + Clock::time_point since_data{}; + Clock::time_point last_switch{}; +}; + +Deck &deck() { + static Deck value; + return value; +} + +bool trace() { + static const bool value = portablekit::env("TRACE_PAD") != nullptr || portablekit::env("TRACE_TILT") != nullptr; + return value; +} + +std::string read_file(const std::string &path) { + std::ifstream in(path, std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +// /dev/hidrawN of the Deck controller's state interface, or empty. +std::string find_device() { + DIR *dir = opendir("/sys/class/hidraw"); + if (dir == nullptr) return {}; + std::string found; + while (dirent *entry = readdir(dir)) { + const std::string name = entry->d_name; + if (name.rfind("hidraw", 0) != 0) continue; + const std::string base = "/sys/class/hidraw/" + name + "/device/"; + std::string uevent = read_file(base + "uevent"); + for (char &c : uevent) c = static_cast(std::toupper(static_cast(c))); + if (uevent.find(kDeckHidId) == std::string::npos) continue; + const std::string descriptor = read_file(base + "report_descriptor"); + if (descriptor.size() < 3 || static_cast(descriptor[0]) != 0x06 || + static_cast(descriptor[1]) != 0xFF || static_cast(descriptor[2]) != 0xFF) + continue; + found = "/dev/" + name; + break; + } + closedir(dir); + return found; +} + +std::int16_t le16(const std::uint8_t *p) { + return static_cast(static_cast(p[0] | (p[1] << 8))); +} + +bool set_imu_mode(Deck &d, std::uint16_t mode) { + if (d.fd < 0 || !d.writable) return false; + // Report ID 0 (the device numbers none), then the message. + std::uint8_t buffer[kReportBytes + 1]{}; + buffer[1] = kSetSettings; + buffer[2] = 3; + buffer[3] = kSettingImuMode; + buffer[4] = static_cast(mode & 0xFF); + buffer[5] = static_cast(mode >> 8); + if (ioctl(d.fd, HIDIOCSFEATURE(sizeof(buffer)), buffer) < 0) { + std::cout << "[tilt] Steam Deck: cannot switch its motion sensors " << (mode != kImuOff ? "on" : "off") + << ": " << std::strerror(errno) << std::endl; + return false; + } + return true; +} + +void restore_at_exit() { + Deck &d = deck(); + if (d.switched_on) set_imu_mode(d, kImuOff); +} + +} // namespace + +bool present() { + return !switched_off() && !find_device().empty(); +} + +bool open() { + Deck &d = deck(); + if (d.fd >= 0) return true; + if (d.tried) return false; + d.tried = true; + if (switched_off()) return false; + d.path = find_device(); + if (d.path.empty()) return false; + d.fd = ::open(d.path.c_str(), O_RDWR | O_NONBLOCK | O_CLOEXEC); + d.writable = d.fd >= 0; + if (d.fd < 0) d.fd = ::open(d.path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (d.fd < 0) { + std::cout << "[tilt] Steam Deck controller " << d.path << " cannot be read: " << std::strerror(errno) + << std::endl; + return false; + } + d.have = false; + d.reports = 0; + d.since_data = Clock::now(); + d.last_switch = {}; + static bool registered = false; + if (!registered) { + registered = true; + std::atexit(restore_at_exit); + } + std::cout << "[tilt] Steam Deck controller " << d.path << ": reading its gyroscope and accelerometer beside " + << "Steam Input" << (d.writable ? "" : " (read only; the motion unit cannot be switched on)") + << std::endl; + return true; +} + +bool read(tilt::Reading &reading) { + Deck &d = deck(); + if (d.fd < 0) return false; + std::uint8_t report[kReportBytes]; + float gyro[3]{}; + int gyros = 0; + bool accel = false; + for (;;) { + const ssize_t n = ::read(d.fd, report, sizeof(report)); + if (n < 0) { + if (errno == EINTR) continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) { + std::cout << "[tilt] Steam Deck controller: " << std::strerror(errno) << "; stopped reading it" + << std::endl; + close(); + return false; + } + break; + } + if (static_cast(n) < kMotionEnd || report[0] != 0x01 || report[2] != kStateReport) continue; + ++d.reports; + bool empty = true; + for (std::size_t i = kAccel; i < kMotionEnd; ++i) empty = empty && report[i] == 0; + if (empty) continue; + // The controller's X, Y, Z in SDL's gamepad axes: X right, the + // controller's Z as up out of its face, its -Y towards the player. + const std::uint8_t *a = report + kAccel; + const std::uint8_t *g = report + kGyro; + d.last.accel[0] = le16(a) * kAccelScale; + d.last.accel[1] = le16(a + 4) * kAccelScale; + d.last.accel[2] = -le16(a + 2) * kAccelScale; + accel = true; + gyro[0] += le16(g) * kGyroScale; + gyro[1] += le16(g + 4) * kGyroScale; + gyro[2] += -le16(g + 2) * kGyroScale; + ++gyros; + } + const Clock::time_point now = Clock::now(); + if (accel) { + if (!d.have) { + std::cout << "[tilt] Steam Deck: motion data arriving" << std::endl; + if (trace()) + std::cout << "[tilt] Steam Deck accel " << d.last.accel[0] << ' ' << d.last.accel[1] << ' ' + << d.last.accel[2] << " m/s2" << std::endl; + } + d.have = true; + d.since_data = now; + d.last.has_accel = true; + d.last.has_gyro = gyros > 0; + for (int i = 0; i < 3; ++i) d.last.gyro[i] = gyros > 0 ? gyro[i] / static_cast(gyros) : 0.0f; + } else if (now - d.since_data >= kSilenceBeforeSwitch && d.writable && + (d.last_switch == Clock::time_point{} || now - d.last_switch >= kSwitchInterval)) { + // Reports arrive with empty motion fields: Steam has the motion + // unit off. Once, and again whenever Steam turns it back off. + d.last_switch = now; + if (set_imu_mode(d, kImuRaw)) { + d.switched_on = true; + ++d.switches; + if (d.switches <= 3 || trace()) + std::cout << "[tilt] Steam Deck: its motion sensors were off; switched them on" + << (d.switches > 1 ? " again (" + std::to_string(d.switches) + ")" : std::string{}) + << std::endl; + } + d.have = false; + } + if (!d.have) return false; + reading = d.last; + return true; +} + +void close() { + Deck &d = deck(); + if (d.fd < 0) return; + if (d.switched_on && set_imu_mode(d, kImuOff)) + std::cout << "[tilt] Steam Deck: switched its motion sensors back off" << std::endl; + d.switched_on = false; + ::close(d.fd); + d.fd = -1; + d.have = false; + // Opened again when tilt controls come back on. + d.tried = false; +} + +std::string describe() { + const Deck &d = deck(); + if (d.fd < 0) return {}; + if (d.have) return "Steam Deck: gyroscope and accelerometer"; + return d.writable ? "Steam Deck: waiting for its motion sensors" : "Steam Deck: its motion sensors are off"; +} + +#else + +bool present() { return false; } +bool open() { return false; } +bool read(tilt::Reading &) { return false; } +void close() {} +std::string describe() { return {}; } + +#endif + +} // namespace portablekit::input::steam_deck_imu diff --git a/host/input/steam_deck_imu.hpp b/host/input/steam_deck_imu.hpp new file mode 100644 index 0000000..c8363f4 --- /dev/null +++ b/host/input/steam_deck_imu.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "input/tilt.hpp" + +#include + +// The Steam Deck's built-in gyroscope and accelerometer, read straight from +// its controller's HID reports (Linux only). +// +// A game started from Steam gets the Deck's controls through Steam Input: +// SDL is told to ignore the real controller and sees only Steam's virtual +// pad, which has no motion sensors, and Steam keeps the controller's motion +// unit switched off unless the player's layout uses the gyroscope. This reads +// the controller's reports beside Steam, without taking it over: the buttons +// and sticks keep coming through Steam's pad, and only the motion fields are +// used here. When the motion fields stay empty, the one setting that switches +// the motion unit on is sent, again whenever Steam switches it back off, and +// it is switched off again when tilt controls are turned off. +// +// _TILT_DECK=0 leaves the Deck's controller alone. +// +// The main thread only. +namespace portablekit::input::steam_deck_imu { + +// Opens the Deck's controller the first time it is called; true while it is +// open. False on any other device, on other systems, and when switched off. +[[nodiscard]] bool open(); + +// Reads every report that arrived since the last call. The accelerometer is +// the latest, the gyroscope the mean over them, both in the axes SDL gives a +// gamepad's sensors. False while no report with motion data has arrived. +[[nodiscard]] bool read(tilt::Reading &reading); + +// Stops reading, and switches the motion unit back off if it was switched on +// here. +void close(); + +// Whether the Deck's controller is there to be read, without opening it. +[[nodiscard]] bool present(); + +// For the menu and the log: what is read, or what is being waited for. +[[nodiscard]] std::string describe(); + +} // namespace portablekit::input::steam_deck_imu diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index ce1fcc6..1a5be20 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -587,8 +587,8 @@ void Menu::tilt_controls() { { std::string description = "Tilt the gamepad, or the phone itself, to tilt the game: its motion sensors press " "the tilt buttons, and the buttons keep working. It needs a gamepad with a " - "gyroscope (DualShock 4, DualSense, Switch Pro, a Steam Deck with Steam Input off " - "for this game) or a phone."; + "gyroscope (DualShock 4, DualSense, Switch Pro, a Steam Deck, with or without " + "Steam Input) or a phone."; if (tilt->note != nullptr) description += std::string(" ") + tilt->note; if (toggle_row("Tilt with motion", s.tilt, options_for("input.tilt", description))) { s.tilt = !s.tilt; From 5d9f45c7681aeed7edaf5de29d789fe2d99b4a38 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:22:19 +0300 Subject: [PATCH 06/12] Read the Steam Deck's motion sensors on a thread of their own The Deck's controller was read on the main thread, from the frame's pad sampling: non-blocking reads until the queue was empty, and the feature report that switches the motion unit on sent from there too. The kernel sends a feature report as a USB control transfer and waits for it, so a controller slow to answer could have held up a frame. Now a thread owns the device: it waits in poll() with a 100 ms timeout, reads at most 256 reports per wake, and sends the motion unit's setting itself. The main thread only takes the latest accelerometer reading and the mean gyroscope rate under a mutex. Turning tilt controls off parks the thread (after switching the unit back off if it switched it on) without waiting for it; at exit it is stopped and joined. Measured on a Deck with a probe calling open, read and describe once a frame: the slowest call took 234 us (the first, which scans sysfs and starts the thread); closing took 6 us. Switching on after 0.3 s of empty motion fields, switching off when closed, and on again when reopened all worked. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/input/steam_deck_imu.cpp | 297 ++++++++++++++++++++++------------ host/input/steam_deck_imu.hpp | 4 +- 2 files changed, 198 insertions(+), 103 deletions(-) diff --git a/host/input/steam_deck_imu.cpp b/host/input/steam_deck_imu.cpp index 3fbbb4a..10332f4 100644 --- a/host/input/steam_deck_imu.cpp +++ b/host/input/steam_deck_imu.cpp @@ -18,7 +18,10 @@ #include #include #include -#include +#include +#include +#include +#include #endif namespace portablekit::input::steam_deck_imu { @@ -68,18 +71,28 @@ constexpr auto kSwitchInterval = std::chrono::seconds(1); using Clock = std::chrono::steady_clock; +// Everything that touches the device runs on its own thread, so nothing here +// can hold up a frame: reads wait in poll() with a timeout, and the feature +// report that switches the motion unit (a USB control transfer the kernel may +// take a while over) is sent from there too. The main thread only takes the +// latest values under the mutex. struct Deck { + std::mutex mutex; + std::condition_variable wake; + std::thread thread; bool tried{}; + bool opened{}; // the thread runs and owns fd + bool active{}; // tilt controls want readings + bool quit{}; int fd{-1}; bool writable{}; std::string path; - bool switched_on{}; // the mode was set here and not yet set back - int switches{}; - bool have{}; // a report with motion data has been read - std::uint64_t reports{}; + // Written by the thread, taken by read(). + bool have{}; // motion data arrived since active tilt::Reading last; - Clock::time_point since_data{}; - Clock::time_point last_switch{}; + float gyro_sum[3]{}; + int gyro_count{}; + std::uint64_t reports{}; }; Deck &deck() { @@ -92,6 +105,11 @@ bool trace() { return value; } +void say(const std::string &line) { + // One write per line, as the thread logs beside the main thread. + std::cout << (line + "\n") << std::flush; +} + std::string read_file(const std::string &path) { std::ifstream in(path, std::ios::binary); return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; @@ -124,8 +142,8 @@ std::int16_t le16(const std::uint8_t *p) { return static_cast(static_cast(p[0] | (p[1] << 8))); } -bool set_imu_mode(Deck &d, std::uint16_t mode) { - if (d.fd < 0 || !d.writable) return false; +// The reader thread only. +bool set_imu_mode(int fd, std::uint16_t mode) { // Report ID 0 (the device numbers none), then the message. std::uint8_t buffer[kReportBytes + 1]{}; buffer[1] = kSetSettings; @@ -133,17 +151,141 @@ bool set_imu_mode(Deck &d, std::uint16_t mode) { buffer[3] = kSettingImuMode; buffer[4] = static_cast(mode & 0xFF); buffer[5] = static_cast(mode >> 8); - if (ioctl(d.fd, HIDIOCSFEATURE(sizeof(buffer)), buffer) < 0) { - std::cout << "[tilt] Steam Deck: cannot switch its motion sensors " << (mode != kImuOff ? "on" : "off") - << ": " << std::strerror(errno) << std::endl; + if (ioctl(fd, HIDIOCSFEATURE(sizeof(buffer)), buffer) < 0) { + say(std::string("[tilt] Steam Deck: cannot switch its motion sensors ") + (mode != kImuOff ? "on" : "off") + + ": " + std::strerror(errno)); return false; } return true; } -void restore_at_exit() { +void run(Deck &d) { + const int fd = d.fd; + const bool writable = d.writable; + bool switched_on = false; // the mode was set here and not yet set back + int switches = 0; + bool was_active = false; + bool announced = false; + Clock::time_point since_data{}; + Clock::time_point last_switch{}; + std::uint8_t report[kReportBytes]; + for (;;) { + bool active; + { + std::unique_lock lock(d.mutex); + if (!d.active && !d.quit) { + // Idle: switch the unit back off if it was switched on here, + // then wait without touching the device. + if (switched_on) { + lock.unlock(); + if (set_imu_mode(fd, kImuOff)) say("[tilt] Steam Deck: switched its motion sensors back off"); + switched_on = false; + lock.lock(); + } + d.wake.wait(lock, [&] { return d.active || d.quit; }); + } + if (d.quit) break; + active = d.active; + } + const Clock::time_point now = Clock::now(); + if (active && !was_active) { + // Drop what queued while idle. + for (int i = 0; i < 256 && ::read(fd, report, sizeof(report)) > 0; ++i) {} + since_data = now; + last_switch = {}; + announced = false; + } + was_active = active; + + pollfd waiting{fd, POLLIN, 0}; + const int ready = ::poll(&waiting, 1, 100); + if (ready < 0 && errno != EINTR) { + say(std::string("[tilt] Steam Deck controller: ") + std::strerror(errno) + "; stopped reading it"); + break; + } + if (ready > 0 && (waiting.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + say("[tilt] Steam Deck controller went away; stopped reading it"); + break; + } + float gyro[3]{}; + float accel[3]{}; + int count = 0; + // Bounded: the kernel keeps at most 64 reports, 250 arrive a second. + for (int i = 0; ready > 0 && i < 256; ++i) { + const ssize_t n = ::read(fd, report, sizeof(report)); + if (n <= 0) break; + if (static_cast(n) < kMotionEnd || report[0] != 0x01 || report[2] != kStateReport) continue; + bool empty = true; + for (std::size_t j = kAccel; j < kMotionEnd; ++j) empty = empty && report[j] == 0; + if (empty) continue; + // The controller's X, Y, Z in SDL's gamepad axes: X right, the + // controller's Z as up out of its face, its -Y towards the player. + const std::uint8_t *a = report + kAccel; + const std::uint8_t *g = report + kGyro; + accel[0] = le16(a) * kAccelScale; + accel[1] = le16(a + 4) * kAccelScale; + accel[2] = -le16(a + 2) * kAccelScale; + gyro[0] += le16(g) * kGyroScale; + gyro[1] += le16(g + 4) * kGyroScale; + gyro[2] += -le16(g + 2) * kGyroScale; + ++count; + } + const Clock::time_point after = Clock::now(); + if (count > 0) { + since_data = after; + if (!announced) { + announced = true; + std::string line = "[tilt] Steam Deck: motion data arriving"; + if (trace()) + line += " (accel " + std::to_string(accel[0]) + ' ' + std::to_string(accel[1]) + ' ' + + std::to_string(accel[2]) + " m/s2)"; + say(line); + } + std::lock_guard lock(d.mutex); + d.have = true; + d.last.has_accel = true; + std::memcpy(d.last.accel, accel, sizeof(accel)); + for (int i = 0; i < 3; ++i) d.gyro_sum[i] += gyro[i]; + d.gyro_count += count; + d.reports += static_cast(count); + } else if (after - since_data >= kSilenceBeforeSwitch) { + { + std::lock_guard lock(d.mutex); + d.have = false; + } + // Reports arrive with empty motion fields, or none at all: Steam + // has the motion unit off. Once, and again whenever Steam turns + // it back off. + if (writable && (last_switch == Clock::time_point{} || after - last_switch >= kSwitchInterval)) { + last_switch = after; + if (set_imu_mode(fd, kImuRaw)) { + switched_on = true; + ++switches; + announced = false; + if (switches <= 3 || trace()) + say(std::string("[tilt] Steam Deck: its motion sensors were off; switched them on") + + (switches > 1 ? " again (" + std::to_string(switches) + ")" : std::string{})); + } + } + } + } + if (switched_on) set_imu_mode(fd, kImuOff); + ::close(fd); + std::lock_guard lock(d.mutex); + d.have = false; + d.fd = -1; +} + +void stop_at_exit() { Deck &d = deck(); - if (d.switched_on) set_imu_mode(d, kImuOff); + { + std::lock_guard lock(d.mutex); + d.quit = true; + } + d.wake.notify_all(); + // Within a poll timeout, and the unit switched back off if it was + // switched on here. + if (d.thread.joinable()) d.thread.join(); } } // namespace @@ -154,7 +296,16 @@ bool present() { bool open() { Deck &d = deck(); - if (d.fd >= 0) return true; + if (d.opened) { + { + std::lock_guard lock(d.mutex); + if (d.fd < 0) return false; // the thread stopped: the device went away + if (d.active) return true; + d.active = true; + } + d.wake.notify_all(); + return true; + } if (d.tried) return false; d.tried = true; if (switched_off()) return false; @@ -164,111 +315,53 @@ bool open() { d.writable = d.fd >= 0; if (d.fd < 0) d.fd = ::open(d.path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); if (d.fd < 0) { - std::cout << "[tilt] Steam Deck controller " << d.path << " cannot be read: " << std::strerror(errno) - << std::endl; + say("[tilt] Steam Deck controller " + d.path + " cannot be read: " + std::strerror(errno)); return false; } - d.have = false; - d.reports = 0; - d.since_data = Clock::now(); - d.last_switch = {}; - static bool registered = false; - if (!registered) { - registered = true; - std::atexit(restore_at_exit); - } - std::cout << "[tilt] Steam Deck controller " << d.path << ": reading its gyroscope and accelerometer beside " - << "Steam Input" << (d.writable ? "" : " (read only; the motion unit cannot be switched on)") - << std::endl; + d.active = true; + d.opened = true; + d.thread = std::thread(run, std::ref(d)); + std::atexit(stop_at_exit); + say("[tilt] Steam Deck controller " + d.path + ": reading its gyroscope and accelerometer beside Steam Input" + + (d.writable ? "" : " (read only; the motion unit cannot be switched on)")); return true; } bool read(tilt::Reading &reading) { Deck &d = deck(); - if (d.fd < 0) return false; - std::uint8_t report[kReportBytes]; - float gyro[3]{}; - int gyros = 0; - bool accel = false; - for (;;) { - const ssize_t n = ::read(d.fd, report, sizeof(report)); - if (n < 0) { - if (errno == EINTR) continue; - if (errno != EAGAIN && errno != EWOULDBLOCK) { - std::cout << "[tilt] Steam Deck controller: " << std::strerror(errno) << "; stopped reading it" - << std::endl; - close(); - return false; - } - break; - } - if (static_cast(n) < kMotionEnd || report[0] != 0x01 || report[2] != kStateReport) continue; - ++d.reports; - bool empty = true; - for (std::size_t i = kAccel; i < kMotionEnd; ++i) empty = empty && report[i] == 0; - if (empty) continue; - // The controller's X, Y, Z in SDL's gamepad axes: X right, the - // controller's Z as up out of its face, its -Y towards the player. - const std::uint8_t *a = report + kAccel; - const std::uint8_t *g = report + kGyro; - d.last.accel[0] = le16(a) * kAccelScale; - d.last.accel[1] = le16(a + 4) * kAccelScale; - d.last.accel[2] = -le16(a + 2) * kAccelScale; - accel = true; - gyro[0] += le16(g) * kGyroScale; - gyro[1] += le16(g + 4) * kGyroScale; - gyro[2] += -le16(g + 2) * kGyroScale; - ++gyros; - } - const Clock::time_point now = Clock::now(); - if (accel) { - if (!d.have) { - std::cout << "[tilt] Steam Deck: motion data arriving" << std::endl; - if (trace()) - std::cout << "[tilt] Steam Deck accel " << d.last.accel[0] << ' ' << d.last.accel[1] << ' ' - << d.last.accel[2] << " m/s2" << std::endl; - } - d.have = true; - d.since_data = now; - d.last.has_accel = true; - d.last.has_gyro = gyros > 0; - for (int i = 0; i < 3; ++i) d.last.gyro[i] = gyros > 0 ? gyro[i] / static_cast(gyros) : 0.0f; - } else if (now - d.since_data >= kSilenceBeforeSwitch && d.writable && - (d.last_switch == Clock::time_point{} || now - d.last_switch >= kSwitchInterval)) { - // Reports arrive with empty motion fields: Steam has the motion - // unit off. Once, and again whenever Steam turns it back off. - d.last_switch = now; - if (set_imu_mode(d, kImuRaw)) { - d.switched_on = true; - ++d.switches; - if (d.switches <= 3 || trace()) - std::cout << "[tilt] Steam Deck: its motion sensors were off; switched them on" - << (d.switches > 1 ? " again (" + std::to_string(d.switches) + ")" : std::string{}) - << std::endl; + std::lock_guard lock(d.mutex); + if (d.fd < 0 || !d.active || !d.have) return false; + // The mean rate over the reports since the last call; the last one again + // when none arrived in between. + if (d.gyro_count > 0) { + for (int i = 0; i < 3; ++i) { + d.last.gyro[i] = d.gyro_sum[i] / static_cast(d.gyro_count); + d.gyro_sum[i] = 0.0f; } - d.have = false; + d.gyro_count = 0; + d.last.has_gyro = true; } - if (!d.have) return false; reading = d.last; return true; } void close() { Deck &d = deck(); - if (d.fd < 0) return; - if (d.switched_on && set_imu_mode(d, kImuOff)) - std::cout << "[tilt] Steam Deck: switched its motion sensors back off" << std::endl; - d.switched_on = false; - ::close(d.fd); - d.fd = -1; - d.have = false; - // Opened again when tilt controls come back on. - d.tried = false; + { + std::lock_guard lock(d.mutex); + if (!d.active) return; + d.active = false; + d.have = false; + d.gyro_count = 0; + for (float &v : d.gyro_sum) v = 0.0f; + } + d.wake.notify_all(); } std::string describe() { - const Deck &d = deck(); - if (d.fd < 0) return {}; + Deck &d = deck(); + std::lock_guard lock(d.mutex); + if (d.fd < 0 || !d.active) return {}; if (d.have) return "Steam Deck: gyroscope and accelerometer"; return d.writable ? "Steam Deck: waiting for its motion sensors" : "Steam Deck: its motion sensors are off"; } diff --git a/host/input/steam_deck_imu.hpp b/host/input/steam_deck_imu.hpp index c8363f4..b8f79d4 100644 --- a/host/input/steam_deck_imu.hpp +++ b/host/input/steam_deck_imu.hpp @@ -19,7 +19,9 @@ // // _TILT_DECK=0 leaves the Deck's controller alone. // -// The main thread only. +// The device is read, and the setting sent, on a thread of its own, so a +// slow device cannot hold up a frame; these calls only take a mutex. Called +// from the main thread. namespace portablekit::input::steam_deck_imu { // Opens the Deck's controller the first time it is called; true while it is From ab97b1c7a47414eb2f0cf8196c7551345d05387c Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:01:59 +0300 Subject: [PATCH 07/12] Hold a game's ground still in Angle + level horizon Angle + level horizon turned the picture against the device's roll, so the game's horizon stayed level with the real one. What the player wants from a tilting game is the opposite: the ground fixed to the screen, so the device held tilted is the tilted world and the character rolls. How LocoRoco 2 turns its world, traced with _TRACE_CAMERA and a temporary dump of each frame's matrices while R, then L, was held: the view and world matrices stay unturned; the projection of its transformed draws turns, as scale x turn. At rest its first two rows are (1/120, 0) and (0, 1/68); held R eased over about half a second to (7.2253e-3, 4.1715e-3) and (-7.3615e-3, 1.2750e-2), that is exactly -30 degrees with the scales unchanged, and held L to +30. It eases back to 0 on release. - The renderer takes the turn of the projection of each frame's biggest transformed draw (projection_roll: each row divided by its length, so the aspect drops out) and hands it to input::motion::set_game_roll. - level_degrees() turns the picture back by that much, within the level limit, while tilt controls are on in Angle + level horizon. The zoom that keeps the corners filled is unchanged. - The level limit may now go to 30 degrees, LocoRoco's full tilt; below it the ground turns on the screen again past the limit. - _TILT_LEVEL_FROM=device turns the picture against the device's roll as before. - _TRACE_TILT prints the game's roll and the level turn. Not yet seen on a screen: the turn is derived from traced matrices only. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/gpu/vulkan_renderer.cpp | 30 ++++++++++++++++++++++++++++++ host/input/motion.cpp | 30 ++++++++++++++++++++++++++++-- host/input/motion.hpp | 13 +++++++++++-- host/input/tilt.hpp | 2 +- host/ui/menu.cpp | 11 ++++++----- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index fc91e9e..5bd6e1a 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -348,6 +348,19 @@ std::array multiply(const std::array &a, const std::array< return result; } +// How far a projection turns the picture, in degrees anticlockwise as clip +// space (y up) sees it. A projection that turns a 2D world is a scale times a +// turn: its first two rows are (sx cos, -sx sin) and (sy sin, sy cos), each +// row's length is its scale, so dividing it out leaves the turn whatever the +// aspect. Measured on LocoRoco 2, which turns its world by up to 30 degrees +// this way while L or R is held. +float projection_roll(const std::array &m) { + const float sx = std::hypot(m[0], m[4]); + const float sy = std::hypot(m[1], m[5]); + if (sx <= 0.0f || sy <= 0.0f) return 0.0f; + return std::atan2(m[1] / sy, m[0] / sx) * 57.29577951308232f; +} + bool check(VkResult result, const char *what, std::string &error) { if (result == VK_SUCCESS) return true; error = std::string(what) + " failed with VkResult " + std::to_string(static_cast(result)); @@ -1150,6 +1163,10 @@ struct VulkanRenderer::Impl { // Per-frame tally, so "no 3D" can be told from "3D drawn somewhere else". std::uint32_t frame_through_draws{}; std::uint32_t frame_transformed_draws{}; + // The projection's turn in the frame's biggest transformed draw, in + // degrees, and that draw's vertices. + float frame_roll{}; + std::uint32_t frame_roll_vertices{}; std::uint32_t frame_transformed_vertices{}; std::uint32_t frame_onscreen_vertices{}; std::uint32_t frame_behind_camera{}; @@ -6018,6 +6035,16 @@ void VulkanRenderer::submit(const DrawCall &call, const GuestMemory &memory) { ++impl.frame_through_draws; } else { ++impl.frame_transformed_draws; + { + // The game's own turn of its world, from the projection of the + // frame's biggest transformed draw (input::motion::set_game_roll). + const auto vertices = + static_cast(direct ? impl.direct_indices.size() : impl.scratch.size()); + if (vertices > impl.frame_roll_vertices) { + impl.frame_roll_vertices = vertices; + impl.frame_roll = projection_roll(call.projection); + } + } // Only _TRACE_3D reads it: a map insert per draw otherwise. if (trace3d) ++impl.frame_transformed_targets[call.target.color_address]; if (watch_camera) { @@ -7594,6 +7621,9 @@ bool VulkanRenderer::present(std::uint32_t display_address, } } impl.frame_views.clear(); + input::motion::set_game_roll(impl.frame_roll_vertices != 0u, impl.frame_roll); + impl.frame_roll_vertices = 0u; + impl.frame_roll = 0.0f; impl.frame_through_draws = 0u; impl.frame_transformed_draws = 0u; impl.frame_transformed_vertices = 0u; diff --git a/host/input/motion.cpp b/host/input/motion.cpp index 7aafe36..7440576 100644 --- a/host/input/motion.cpp +++ b/host/input/motion.cpp @@ -45,6 +45,9 @@ struct State { std::uint64_t last_ns{}; std::uint64_t pulse_ns{}; bool pulse_down{}; + // The game's own turn of its world, from the renderer. + bool game_roll_valid{}; + float game_roll{}; int traced_direction{}; std::uint64_t traced_ns{}; }; @@ -54,6 +57,16 @@ State &state() { return value; } +// _TILT_LEVEL_FROM=device: Angle + level horizon turns the picture +// against the device, as it first did, rather than against the game. +bool level_from_device() { + static const bool value = [] { + const char *from = portablekit::env("TILT_LEVEL_FROM"); + return from != nullptr && std::string(from) == "device"; + }(); + return value; +} + bool trace() { static const bool value = portablekit::env("TRACE_PAD") != nullptr || portablekit::env("TRACE_TILT") != nullptr; return value; @@ -267,7 +280,9 @@ std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button) << " deg/s -> " << (s.output.direction < 0 ? "left" : s.output.direction > 0 ? "right" : "none") << " strength " << std::lround(s.output.strength * 100.0f) << "%"; - if (tuning.mode == tilt::Mode::AngleLevel) std::cout << " level " << s.output.level_degrees; + if (tuning.mode == tilt::Mode::AngleLevel) + std::cout << " game roll " << std::lround(s.game_roll * 10.0f) / 10.0f << " level " + << std::lround(level_degrees() * 10.0f) / 10.0f; std::cout << std::endl; s.traced_ns = now; } @@ -278,9 +293,20 @@ std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button) void recenter() { state().mapper.recenter(); } +void set_game_roll(bool valid, float degrees) { + State &s = state(); + s.game_roll_valid = valid; + s.game_roll = valid ? degrees : 0.0f; +} + float level_degrees() { const State &s = state(); - return s.on && s.reading ? s.output.level_degrees : 0.0f; + if (!s.on) return 0.0f; + if (level_from_device()) return s.reading ? s.output.level_degrees : 0.0f; + const settings::Settings &player = settings::current(); + if (player.tilt_mode != tilt::Mode::AngleLevel || !s.game_roll_valid) return 0.0f; + const float limit = std::clamp(player.tilt_level_limit, tilt::kMinLevelLimit, tilt::kMaxLevelLimit); + return std::clamp(-s.game_roll, -limit, limit); } std::string source() { diff --git a/host/input/motion.hpp b/host/input/motion.hpp index c020e9c..91c07cf 100644 --- a/host/input/motion.hpp +++ b/host/input/motion.hpp @@ -31,8 +31,17 @@ void gamepad_changed(SDL_Gamepad *pad); // from the menu, and by the menu's Re-centre. void recenter(); -// Angle + level horizon: degrees to turn the game's picture anticlockwise so -// that its horizon stays level with the real one. 0 in every other case. +// The renderer, once a frame: how far the game turned its own world in the +// frame just drawn, in degrees anticlockwise (the turn of its projection; +// false when it drew nothing transformed). +void set_game_roll(bool valid, float degrees); + +// Angle + level horizon: degrees to turn the game's picture anticlockwise. +// Against the game's own turn of its world, so its ground stays still on the +// screen and the device held tilted is the tilted world; with +// _TILT_LEVEL_FROM=device, against the device's roll instead, so the +// game's horizon stays level with the real one. Within the level limit +// either way. 0 in every other mode and while tilt controls are off. [[nodiscard]] float level_degrees(); // For the menu: where the tilt comes from ("DualSense Wireless Controller: diff --git a/host/input/tilt.hpp b/host/input/tilt.hpp index c1035c3..93cded3 100644 --- a/host/input/tilt.hpp +++ b/host/input/tilt.hpp @@ -56,7 +56,7 @@ inline constexpr float kRateThreshold = 20.0f; // Rate mode: seconds for the added-up tilt to fall to a third by itself. inline constexpr float kRateDecaySeconds = 3.0f; inline constexpr float kMinLevelLimit = 2.0f; -inline constexpr float kMaxLevelLimit = 20.0f; +inline constexpr float kMaxLevelLimit = 30.0f; // One reading of the sensors. Either may be missing: a pad with only a // gyroscope, a phone with only an accelerometer. diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index 1a5be20..b05cfad 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -610,9 +610,9 @@ void Menu::tilt_controls() { off(options_for("input.tilt_mode", "Angle: how far the device is tilted from where it was re-centred tilts the game; " "the picture stays as it is. Angle + level horizon (experimental): the same, and " - "the picture is turned against the device and zoomed a little so the game's " - "horizon stays level with the real one; turning pictures can cause motion " - "sickness. Rate: turning the device tilts, and the tilt stays when the turning " + "the picture is turned back against the game's own tilt and zoomed to fill the " + "screen, so the ground stays still on the screen and the device held tilted is " + "the tilted world; turning pictures can cause motion sickness. Rate: turning the device tilts, and the tilt stays when the turning " "stops, so the device can go back to a comfortable hold.")))) { s.tilt_mode = static_cast(cycle(current, delta, 3)); settings::save(); @@ -642,8 +642,9 @@ void Menu::tilt_controls() { } { RowOptions o = off(options_for("input.tilt_level_limit", - "How far the picture may turn to keep the horizon level. The picture is " - "zoomed as it turns so no corner is left empty; a larger limit crops more.")); + "How far the picture may turn back against the game's tilt; past it the " + "ground turns on the screen again. The picture is zoomed as it turns so no " + "corner is left empty; a larger limit crops more.")); if (s.tilt_mode != input::tilt::Mode::AngleLevel && !o.disabled) { o.disabled = true; o.note = "Only for Angle + level horizon"; From 569559987e730d5d679cecc896edf597ee2f06e7 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:16:53 +0300 Subject: [PATCH 08/12] Jump with one button beside tilt controls, and keep the level horizon off the menu LocoRoco jumps with L and R together. With tilt controls on: - L and R pressed together reach the game as both, as before: a tilt button the player presses takes over from the motion, so the motion is not added at all while either is held. A single shoulder tilts that way alone, over the motion. - A jump button presses both tilt buttons while held, whatever the device's roll (input.jump_button: off, l3, south, east, west, north). The default is L3: the PSP has no stick click, so it takes nothing from the game, while every face button is read by LocoRoco 2 somewhere (circle next to L and R in its play code, cross as its confirm button, triangle and square with START and SELECT). A face button chosen instead no longer presses its own button. - The keyboard gets a Jump action (Keyboard and mouse), unbound by default, listed only for a game that tilts. Angle + level horizon did not play well on a Steam Deck and is no longer offered in the menu. It is still honoured from settings.ini or _TILT_MODE (level, or horizon), shown by name, and leaves for Angle or Rate from the menu; its limit row only shows while it is in use. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/gpu/vulkan_renderer.cpp | 23 ++++++++++++++++++- host/input/bindings.cpp | 5 +++-- host/input/bindings.hpp | 4 ++++ host/settings/settings.cpp | 12 +++++++++- host/settings/settings.hpp | 6 +++++ host/ui/menu.cpp | 43 +++++++++++++++++++++++++++--------- tests/input_tests.cpp | 5 +++++ 7 files changed, 84 insertions(+), 14 deletions(-) diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index 5bd6e1a..cc73885 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -424,6 +424,7 @@ struct PadTuning { bool invert_x{}; bool invert_y{}; bool confirm_south{}; + settings::JumpButton jump{settings::JumpButton::Off}; bool trace{false}; }; @@ -444,6 +445,7 @@ PadTuning pad_tuning() { // A PlayStation pad already carries the PSP's own face buttons, so the // positional mapping puts confirm on circle where the prompts want it. value.confirm_south = player.confirm_south; + value.jump = player.jump_button; value.trace = trace; return value; } @@ -453,9 +455,25 @@ PadTuning pad_tuning() { void read_gamepad(SDL_Gamepad *device, PadState &pad, int &analog_x, int &analog_y) { const PadTuning tuning = pad_tuning(); std::uint32_t &buttons = pad.buttons; + // A tilting game's jump button (settings::JumpButton) presses both tilt + // buttons instead of its own. + const TiltControls *const tilt = portablekit::game().tilt; + SDL_GamepadButton jump = SDL_GAMEPAD_BUTTON_INVALID; + if (tilt != nullptr) { + switch (tuning.jump) { + case settings::JumpButton::LeftStick: jump = SDL_GAMEPAD_BUTTON_LEFT_STICK; break; + case settings::JumpButton::South: jump = SDL_GAMEPAD_BUTTON_SOUTH; break; + case settings::JumpButton::East: jump = SDL_GAMEPAD_BUTTON_EAST; break; + case settings::JumpButton::West: jump = SDL_GAMEPAD_BUTTON_WEST; break; + case settings::JumpButton::North: jump = SDL_GAMEPAD_BUTTON_NORTH; break; + case settings::JumpButton::Off: break; + } + } const auto held = [&](SDL_GamepadButton button, std::uint32_t bit) { + if (button == jump) return; if (SDL_GetGamepadButton(device, button)) buttons |= bit; }; + if (jump != SDL_GAMEPAD_BUTTON_INVALID && SDL_GetGamepadButton(device, jump)) buttons |= tilt->left | tilt->right; held(SDL_GAMEPAD_BUTTON_DPAD_UP, 0x0010u); held(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, 0x0020u); held(SDL_GAMEPAD_BUTTON_DPAD_DOWN, 0x0040u); @@ -5056,8 +5074,11 @@ void VulkanRenderer::Impl::sample_pad(bool focused) { // Tilt controls (input/motion.hpp): the motion sensors press the game's // tilt buttons. A tilt button the player presses takes over, so tilting // one way while pressing the other shoulder never becomes both at once - // (LocoRoco's jump). R3 alone re-centres; L3 with it opens the menu. + // (LocoRoco's jump), and both pressed, by the shoulders, the jump button + // or the keyboard's Jump, reach the game as both whatever the device's + // roll: a jump always wins. R3 alone re-centres; L3 with it opens the menu. if (const TiltControls *tilt = portablekit::game().tilt; tilt != nullptr) { + if (typed.jump) pad.buttons |= tilt->left | tilt->right; SDL_Gamepad *const device = impl_->gamepad; const bool recenter = device != nullptr && SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_RIGHT_STICK) && !SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_LEFT_STICK); diff --git a/host/input/bindings.cpp b/host/input/bindings.cpp index e23565d..de556d4 100644 --- a/host/input/bindings.cpp +++ b/host/input/bindings.cpp @@ -53,13 +53,13 @@ constexpr ActionInfo kInfo[kActions] = { {"r", "R"}, {"start", "START"}, {"select", "SELECT"}, {"dpad_up", "D-pad up"}, {"dpad_left", "D-pad left"}, {"dpad_down", "D-pad down"}, {"dpad_right", "D-pad right"}, {"camera_up", "Camera up"}, {"camera_left", "Camera left"}, - {"camera_down", "Camera down"}, {"camera_right", "Camera right"}, + {"camera_down", "Camera down"}, {"camera_right", "Camera right"}, {"jump", "Jump (L and R together)"}, }; // SceCtrlButtons for the actions that are buttons. constexpr std::uint32_t kButtonBits[kActions] = { 0u, 0u, 0u, 0u, 0x1000u, 0x2000u, 0x4000u, 0x8000u, 0x0100u, 0x0200u, 0x0008u, 0x0001u, - 0x0010u, 0x0080u, 0x0040u, 0x0020u, 0u, 0u, 0u, 0u, + 0x0010u, 0x0080u, 0x0040u, 0x0020u, 0u, 0u, 0u, 0u, 0u, }; Bindings make_default() { @@ -232,6 +232,7 @@ PadInput read(const Bindings &bindings, const std::function &held pad.stick_y = axis(Action::StickUp, Action::StickDown); pad.camera_x = axis(Action::CameraLeft, Action::CameraRight); pad.camera_y = axis(Action::CameraUp, Action::CameraDown); + pad.jump = on[static_cast(Action::Jump)]; return pad; } diff --git a/host/input/bindings.hpp b/host/input/bindings.hpp index 5c18176..bf4f0e2 100644 --- a/host/input/bindings.hpp +++ b/host/input/bindings.hpp @@ -38,6 +38,9 @@ enum class Action : std::uint8_t { CameraLeft, CameraDown, CameraRight, + // A game that tilts (GameProfile::tilt): both tilt buttons at once, which + // is LocoRoco's jump. Only listed for such a game. + Jump, Count }; inline constexpr std::size_t kActions = static_cast(Action::Count); @@ -97,6 +100,7 @@ struct PadInput { int stick_y{}; int camera_x{}; // the second stick int camera_y{}; + bool jump{}; // Action::Jump held }; [[nodiscard]] PadInput read(const Bindings &bindings, const std::function &held); diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index c4d392c..97353d4 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -111,7 +111,14 @@ const Names kPerfDisplays{{{PerfDisplay::Off, "off"}, {PerfDisplay::Log, "log"}}}; const Names kTiltModes{{{input::tilt::Mode::Angle, "angle"}, {input::tilt::Mode::AngleLevel, "level"}, + {input::tilt::Mode::AngleLevel, "horizon"}, {input::tilt::Mode::Rate, "rate"}}}; +const Names kJumpButtons{{{JumpButton::Off, "off"}, + {JumpButton::LeftStick, "l3"}, + {JumpButton::South, "south"}, + {JumpButton::East, "east"}, + {JumpButton::West, "west"}, + {JumpButton::North, "north"}}}; const Names kRightSticks{ {{RightStick::Camera, "camera"}, {RightStick::DPad, "dpad"}, {RightStick::Off, "off"}}}; // A trigger profile by the key the game gives it, or "standard" for L and R. @@ -315,7 +322,7 @@ const std::vector &fields() { [](Settings &s, const std::string &t) { return kTiltModes.parse(t, s.tilt_mode); }, [](const Settings &s) { return kTiltModes.format(s.tilt_mode); }, [](Settings &s, const char *t) { - if (!kTiltModes.parse(t, s.tilt_mode)) std::cerr << "[settings] " << env_name("TILT_MODE") << ": angle, level or rate\n"; + if (!kTiltModes.parse(t, s.tilt_mode)) std::cerr << "[settings] " << env_name("TILT_MODE") << ": angle, rate, or level (also horizon)\n"; }}, {"input.tilt_full", "TILT_FULL", [](Settings &s, const std::string &t) { @@ -334,6 +341,9 @@ const std::vector &fields() { s.tilt_dead_zone = variable_float(t, 4.0f, input::tilt::kMinDeadZone, input::tilt::kMaxDeadZone); }}, BOOL_FIELD("input.tilt_invert", tilt_invert), + {"input.jump_button", nullptr, + [](Settings &s, const std::string &t) { return kJumpButtons.parse(t, s.jump_button); }, + [](const Settings &s) { return kJumpButtons.format(s.jump_button); }, nullptr}, {"input.tilt_level_limit", nullptr, [](Settings &s, const std::string &t) { return parse_float(t, input::tilt::kMinLevelLimit, input::tilt::kMaxLevelLimit, s.tilt_level_limit); diff --git a/host/settings/settings.hpp b/host/settings/settings.hpp index 707affb..3f47ee8 100644 --- a/host/settings/settings.hpp +++ b/host/settings/settings.hpp @@ -31,6 +31,9 @@ enum class PresentMode { Fifo, Mailbox, Immediate }; enum class Aspect { Original, Stretch, Fill }; enum class PerfDisplay { Off, Overlay, OverlayAndLog, Log }; enum class RightStick { Camera, DPad, Off }; +// The gamepad button that presses a tilting game's both tilt buttons at once +// (LocoRoco's jump), instead of what it presses otherwise. +enum class JumpButton { Off, LeftStick, South, East, West, North }; // What answers the game when it asks for text such as the player's name. enum class NameEntry { Keyboard, Fixed }; // Presents per second. The game makes 30 frames a second; the faster rates @@ -107,6 +110,9 @@ struct Settings { float tilt_dead_zone{4.0f}; // degrees of roll from neutral before anything is pressed bool tilt_invert{}; float tilt_level_limit{10.0f}; // Angle + level horizon: degrees the picture turns at most + // For a game that tilts: the gamepad button that jumps. L3 by default, + // which no PSP game can read, so it takes nothing from the game. + JumpButton jump_button{JumpButton::LeftStick}; NameEntry name_entry{NameEntry::Keyboard}; // on-screen keyboard, or the name below at once std::string name{game_default_name()}; // the fixed name diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index b05cfad..1fe6230 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -603,18 +603,23 @@ void Menu::tilt_controls() { return options; }; { - static const char *const kModes[] = {"Angle", "Angle + level horizon", "Rate"}; - const int current = static_cast(s.tilt_mode); + // Angle + level horizon is not offered: it did not play well. It is + // still honoured when settings.ini or _TILT_MODE asks for it + // (level or horizon), shown by name, and left for Angle or Rate here. + using input::tilt::Mode; + static const Mode kOffered[] = {Mode::Angle, Mode::Rate}; + const int current = s.tilt_mode == Mode::Rate ? 1 : 0; + const char *const name = s.tilt_mode == Mode::AngleLevel ? "Angle + level horizon" + : s.tilt_mode == Mode::Rate ? "Rate" + : "Angle"; if (const int delta = choice_row( - "Gyro mode", kModes[current], + "Gyro mode", name, off(options_for("input.tilt_mode", - "Angle: how far the device is tilted from where it was re-centred tilts the game; " - "the picture stays as it is. Angle + level horizon (experimental): the same, and " - "the picture is turned back against the game's own tilt and zoomed to fill the " - "screen, so the ground stays still on the screen and the device held tilted is " - "the tilted world; turning pictures can cause motion sickness. Rate: turning the device tilts, and the tilt stays when the turning " - "stops, so the device can go back to a comfortable hold.")))) { - s.tilt_mode = static_cast(cycle(current, delta, 3)); + "Angle: how far the device is tilted from where it was re-centred tilts the game. " + "Rate: turning the device tilts, and the tilt stays when the turning stops, so the " + "device can go back to a comfortable hold.")))) { + s.tilt_mode = s.tilt_mode == Mode::AngleLevel ? (delta > 0 ? Mode::Rate : Mode::Angle) + : kOffered[cycle(current, delta, 2)]; settings::save(); } } @@ -641,6 +646,22 @@ void Menu::tilt_controls() { settings::save(); } { + // Not greyed out with tilt controls off: the button jumps either way. + static const char *const kJumpNames[] = {"Off", "L3 (left stick click)", "South face button", + "East face button", "West face button", "North face button"}; + const int current = static_cast(s.jump_button); + if (const int delta = choice_row( + "Jump button", kJumpNames[current], + options_for("input.jump_button", + "One gamepad button that presses L and R together, to jump while one hand holds the " + "device level. Pressing L and R together still jumps, and a jump always wins over the " + "motion. L3 takes nothing from the game; a face button no longer presses its own " + "button. On the keyboard, bind Jump under Keyboard and mouse."))) { + s.jump_button = static_cast(cycle(current, delta, 6)); + settings::save(); + } + } + if (s.tilt_mode == input::tilt::Mode::AngleLevel) { RowOptions o = off(options_for("input.tilt_level_limit", "How far the picture may turn back against the game's tilt; past it the " "ground turns on the screen again. The picture is zoomed as it turns so no " @@ -882,6 +903,7 @@ void Menu::controls() { } for (std::size_t i = 0; i < input::kActions; ++i) { const auto action = static_cast(i); + if (action == input::Action::Jump && portablekit::game().tilt == nullptr) continue; std::string value = input::format(s.bindings[i]); if (binding_ == action) value = "Press a key or a mouse button"; else if (value.empty()) value = "None"; @@ -932,6 +954,7 @@ void Menu::controls() { restore("input.tilt_dead_zone", s.tilt_dead_zone, d.tilt_dead_zone); restore("input.tilt_invert", s.tilt_invert, d.tilt_invert); restore("input.tilt_level_limit", s.tilt_level_limit, d.tilt_level_limit); + restore("input.jump_button", s.jump_button, d.jump_button); s.bindings = d.bindings; settings::save(); } diff --git a/tests/input_tests.cpp b/tests/input_tests.cpp index 230fc67..4c11bbc 100644 --- a/tests/input_tests.cpp +++ b/tests/input_tests.cpp @@ -111,6 +111,11 @@ void test_read() { check(pad.camera_x == -127 && pad.camera_y == 127 && pad.buttons == 0u, "camera keys push the second stick"); held = {from_name("Up"), from_name("Right"), from_name("Enter"), from_name("Backspace")}; check(read_held().buttons == (0x0010u | 0x0020u | 0x0008u | 0x0001u), "D-pad, START and SELECT"); + check(d[static_cast(Action::Jump)][0] == kNone, "Jump is unbound by default"); + Bindings with_jump = d; + with_jump[static_cast(Action::Jump)] = {from_name("V"), kNone}; + pad = read(with_jump, [&](Binding b) { return b == from_name("V"); }); + check(pad.jump && pad.buttons == 0u, "Jump is reported as itself, the tilt buttons are the renderer's to add"); } void test_mouse_turn() { From fe86456dc8886a04e8027b7e7a5ebda636db6710 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:48:55 +0300 Subject: [PATCH 09/12] Say what the emulated PSP is doing when a game stops drawing A Steam Deck build hung twice a minute or two into play: the log stopped, the main thread slept in a loop, and the process could not be attached to (ptrace_scope 1, no root). The stall watchdog is how such a stall can be read afterwards. When the game has flipped no framebuffer for five seconds outside the paused menu: - the scheduler's idle loop, where the main thread waits when every guest thread is blocked, writes Kernel::dump_state to stderr and flushes it: the clock, vblanks, idle vblanks, the current thread, interrupts, the pacing and fast loading, and every thread with its state, priority, pc, ra, sp, what it waits for, its deadline, and the semaphore, event flag, mutex or mailbox it waits on. Once, and again every 30 seconds while the stall lasts; - a thread of its own, for a stall anywhere else, says how long since the last frame and when the scheduler last went idle; - both add the last 16 changes of the pad word handed to the game, with their age. _STALL_WATCHDOG=0 turns it off; =N waits N seconds. Tried on macOS without a window with a limit of a millisecond: the dump lists LocoRoco 2's threads (LocoRocoMain waiting on a delay, its sound threads on their semaphores). Co-Authored-By: Claude Opus 5.5 (1M context) --- cmake/PortableKit.cmake | 1 + host/hle/hle_media.cpp | 5 ++ host/kernel/kernel.cpp | 62 +++++++++++++ host/kernel/kernel.hpp | 4 + host/kernel/stall_watchdog.cpp | 158 +++++++++++++++++++++++++++++++++ host/kernel/stall_watchdog.hpp | 34 +++++++ 6 files changed, 264 insertions(+) create mode 100644 host/kernel/stall_watchdog.cpp create mode 100644 host/kernel/stall_watchdog.hpp diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 5656466..cc1af63 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -46,6 +46,7 @@ set(PORTABLEKIT_HOST_SOURCES host/kernel/kernel.cpp host/kernel/load_trace.cpp host/kernel/fast_loading.cpp + host/kernel/stall_watchdog.cpp host/kernel/load_detector.cpp host/kernel/iso_image.cpp host/hle/hle_common.cpp diff --git a/host/hle/hle_media.cpp b/host/hle/hle_media.cpp index 3dd3bda..f1e7e88 100644 --- a/host/hle/hle_media.cpp +++ b/host/hle/hle_media.cpp @@ -4,6 +4,7 @@ // mixing themselves live under gpu/ and audio/. #include "../profile.hpp" #include "hle_common.hpp" +#include "kernel/stall_watchdog.hpp" #include "kernel/fast_loading.hpp" #include "kernel/load_trace.hpp" @@ -349,7 +350,9 @@ void present_frame(Runtime &rt) { // still while it runs in here, and the device stops playing. renderer.pause_interpolation(); audio::AudioSink::instance().set_paused(true); + stall_watchdog::note_menu(true); const bool keep_playing = ui::run_menu(); + stall_watchdog::note_menu(false); audio::AudioSink::instance().set_paused(false); // Resume at normal speed rather than racing to make up the pause, // and keep the pause out of the frame statistics. @@ -387,6 +390,7 @@ CtrlSample sample_ctrl() { if (!at_flip) media().renderer->sample_pad(); const gpu::PadState pad = media().renderer->pad(); sample.buttons = pad.buttons; + stall_watchdog::note_pad(sample.buttons); fast_loading::note_buttons(sample.buttons != 0u); sample.analog_x = pad.analog_x; sample.analog_y = pad.analog_y; @@ -545,6 +549,7 @@ void register_display_ctrl(HleRegistrar &hle) { media().display.buffer_width = arg(ctx, 1); media().display.pixel_format = arg(ctx, 2); trace_pacing("sceDisplaySetFrameBuf"); + stall_watchdog::note_frame(); present_frame(rt); kernel().finish(ctx, 0u); }); diff --git a/host/kernel/kernel.cpp b/host/kernel/kernel.cpp index e0126c8..1d3fa81 100644 --- a/host/kernel/kernel.cpp +++ b/host/kernel/kernel.cpp @@ -2,6 +2,7 @@ #include "kernel.hpp" #include "kernel/fast_loading.hpp" +#include "kernel/stall_watchdog.hpp" #include "kernel/load_trace.hpp" #include "perf/frame_stats.hpp" #include "settings/settings.hpp" @@ -9,6 +10,7 @@ #include #include +#include #include #include #include @@ -415,6 +417,7 @@ void Kernel::schedule(AllegrexContext &ctx) { return; } + stall_watchdog::idle(*this); const auto next = next_event_us(); if (!next || idle_vblanks_ > kIdleVBlankLimit) { std::string report = "PSP scheduler deadlock: no runnable thread"; @@ -435,6 +438,65 @@ void Kernel::schedule(AllegrexContext &ctx) { } } +void Kernel::dump_state(std::ostream &out) const { + const auto hex = [](std::uint32_t value) { + char text[16]; + std::snprintf(text, sizeof(text), "0x%08X", value); + return std::string(text); + }; + const auto real_ms = std::chrono::duration(std::chrono::steady_clock::now() - + pacing_real_base_) + .count(); + out << "[stall] clock " << now_us_ << " us, next vblank " << next_vblank_us_ << " us, vblanks " << vblank_count_ + << ", idle vblanks " << idle_vblanks_ << ", current thread " << current_uid_ << ", interrupts " + << (interrupts_enabled_ ? "on" : "off") << " (" << pending_interrupts_.size() << " pending" + << (interrupt_active_ ? ", one running" : "") << "), dispatch " << (dispatch_enabled_ ? "on" : "off") + << "\n[stall] pacing " << (pacing_fast_ ? "fast loading" : "normal"); + if (pacing_started_) + out << ", " << (now_us_ - pacing_virtual_base_) / 1000.0 << " ms emulated against " << real_ms + << " ms real since its base"; + else + out << ", not started (unpaced, or just resynchronised)"; + out << "\n"; + for (const auto &[uid, thread] : threads_) { + out << "[stall] thread " << uid << " '" << thread->name << "' " << status_name(thread->status) << " prio " + << thread->priority << " pc " << hex(thread->context.pc) << " ra " << hex(thread->context.gpr[31]) + << " sp " << hex(thread->context.gpr[29]); + if (thread->status == ThreadStatus::Waiting) { + const WaitState &wait = thread->wait; + out << " waits " << wait_name(wait.type) << " object " << wait.object << " value " << hex(wait.value) + << " mode " << wait.mode; + if (wait.deadline_us) + out << " deadline " << (*wait.deadline_us >= now_us_ ? "+" : "-") + << (*wait.deadline_us >= now_us_ ? *wait.deadline_us - now_us_ : now_us_ - *wait.deadline_us) + << " us"; + switch (wait.type) { + case WaitType::Semaphore: + if (const auto it = semaphores.find(wait.object); it != semaphores.end()) + out << " (sema '" << it->second.name << "' count " << it->second.count << "/" + << it->second.max_count << ", " << it->second.waiters.size() << " waiting)"; + break; + case WaitType::EventFlag: + if (const auto it = event_flags.find(wait.object); it != event_flags.end()) + out << " (flag '" << it->second.name << "' pattern " << hex(it->second.pattern) << ", " + << it->second.waiters.size() << " waiting)"; + break; + case WaitType::Mutex: + if (const auto it = mutexes.find(wait.object); it != mutexes.end()) + out << " (mutex '" << it->second.name << "' owner " << it->second.owner << " count " + << it->second.lock_count << ")"; + break; + case WaitType::Mailbox: + if (const auto it = mailboxes.find(wait.object); it != mailboxes.end()) + out << " (mailbox '" << it->second.name << "' " << it->second.messages.size() << " messages)"; + break; + default: break; + } + } + out << "\n"; + } +} + void Kernel::advance_clock(std::uint64_t target_us) { if (target_us <= now_us_) return; const std::uint64_t step_us = target_us - now_us_; diff --git a/host/kernel/kernel.hpp b/host/kernel/kernel.hpp index d534663..4867bb9 100644 --- a/host/kernel/kernel.hpp +++ b/host/kernel/kernel.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -228,6 +229,9 @@ class Kernel { // after the game was paused, so it resumes at normal speed rather than // racing to make up the pause. void resync_real_time() noexcept { pacing_started_ = false; } + // Every thread, what it waits for and where, the objects waited on, the + // clock and the pacing, one line each (kernel/stall_watchdog.hpp). + void dump_state(std::ostream &out) const; // The moment of real time the hold to real time maps `virtual_us` of // emulated time to; empty while emulated time is not held to real time, // a load running fast included (kernel/fast_loading.hpp). diff --git a/host/kernel/stall_watchdog.cpp b/host/kernel/stall_watchdog.cpp new file mode 100644 index 0000000..0a6cf59 --- /dev/null +++ b/host/kernel/stall_watchdog.cpp @@ -0,0 +1,158 @@ +#include "kernel/stall_watchdog.hpp" + +#include "kernel/kernel.hpp" +#include "profile.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace portablekit::stall_watchdog { +namespace { + +using Clock = std::chrono::steady_clock; +constexpr std::int64_t kRepeatMs = 30000; +constexpr std::size_t kPadHistory = 16u; + +std::int64_t now_ms() { + return std::chrono::duration_cast(Clock::now().time_since_epoch()).count(); +} + +// _STALL_WATCHDOG: 0 or off turns it off; a number of seconds +// replaces the five. +std::int64_t stall_limit_ms() { + static const std::int64_t value = [] { + const char *text = portablekit::env("STALL_WATCHDOG"); + if (text == nullptr || *text == '\0') return std::int64_t{5000}; + if (std::strcmp(text, "off") == 0) return std::int64_t{0}; + const double seconds = std::atof(text); + return seconds > 0.0 ? static_cast(seconds * 1000.0) : std::int64_t{0}; + }(); + return value; +} + +bool enabled() { return stall_limit_ms() > 0; } + +struct State { + std::atomic last_frame_ms{0}; + std::atomic frames{0}; + std::atomic menu{false}; + std::atomic last_idle_ms{0}; + // Pad words and when they were handed over, written by the main thread + // only; the watchdog thread's reads may be torn, which a dump can bear. + std::array, kPadHistory> pad{}; + std::array, kPadHistory> pad_ms{}; + std::atomic pads{0}; + std::atomic last_pad{0xFFFFFFFFu}; + // Main thread only. + std::int64_t dumped_ms{0}; + std::uint64_t dumped_frames{~0ull}; + // The watchdog thread's. + std::int64_t reported_ms{0}; + std::uint64_t reported_frames{~0ull}; +}; + +State &state() { + static State value; + return value; +} + +// Frames are what the watchdog waits for; a stall is time without one. +std::int64_t stalled_ms(const State &s, std::int64_t now) { + if (s.menu.load() || s.frames.load() == 0u) return 0; + return now - s.last_frame_ms.load(); +} + +std::string pad_history(const State &s, std::int64_t now) { + std::ostringstream out; + const std::uint64_t count = s.pads.load(); + out << "[stall] last pad words handed to the game (newest first; only changes are kept):"; + for (std::uint64_t i = 0; i < kPadHistory && i < count; ++i) { + const std::size_t slot = static_cast((count - 1u - i) % kPadHistory); + out << " 0x" << std::hex << s.pad[slot].load() << std::dec << "@-" << (now - s.pad_ms[slot].load()) << "ms"; + } + return out.str(); +} + +void watch() { + State &s = state(); + for (;;) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + const std::int64_t now = now_ms(); + const std::int64_t stalled = stalled_ms(s, now); + if (stalled < stall_limit_ms()) continue; + const std::uint64_t frames = s.frames.load(); + if (frames == s.reported_frames && now - s.reported_ms < kRepeatMs) continue; + s.reported_frames = frames; + s.reported_ms = now; + std::ostringstream out; + out << "[stall] no frame for " << stalled / 1000.0 << " s after " << frames << " frames; the scheduler " + << (s.last_idle_ms.load() == 0 ? std::string("never went idle") + : "last went idle " + std::to_string(now - s.last_idle_ms.load()) + + " ms ago") + << "\n" + << pad_history(s, now) << "\n"; + std::cerr << out.str() << std::flush; + } +} + +void start_thread() { + static const bool started = [] { + std::thread(watch).detach(); + return true; + }(); + (void)started; +} + +} // namespace + +void note_frame() { + if (!enabled()) return; + State &s = state(); + s.last_frame_ms.store(now_ms()); + if (s.frames.fetch_add(1u) == 0u) start_thread(); +} + +void note_menu(bool open) { + if (!enabled()) return; + State &s = state(); + s.menu.store(open); + s.last_frame_ms.store(now_ms()); +} + +void note_pad(std::uint32_t buttons) { + if (!enabled()) return; + State &s = state(); + if (s.last_pad.exchange(buttons) == buttons) return; + const std::uint64_t index = s.pads.load(); + const std::size_t slot = static_cast(index % kPadHistory); + s.pad[slot].store(buttons); + s.pad_ms[slot].store(now_ms()); + s.pads.store(index + 1u); +} + +void idle(const Kernel &kernel) { + if (!enabled()) return; + State &s = state(); + const std::int64_t now = now_ms(); + s.last_idle_ms.store(now); + const std::int64_t stalled = stalled_ms(s, now); + if (stalled < stall_limit_ms()) return; + const std::uint64_t frames = s.frames.load(); + if (frames == s.dumped_frames && now - s.dumped_ms < kRepeatMs) return; + s.dumped_frames = frames; + s.dumped_ms = now; + std::ostringstream out; + out << "[stall] no frame for " << stalled / 1000.0 << " s after " << frames + << " frames, and no guest thread can run. The emulated PSP:\n"; + kernel.dump_state(out); + out << pad_history(s, now) << "\n"; + std::cerr << out.str() << std::flush; +} + +} // namespace portablekit::stall_watchdog diff --git a/host/kernel/stall_watchdog.hpp b/host/kernel/stall_watchdog.hpp new file mode 100644 index 0000000..48a8baa --- /dev/null +++ b/host/kernel/stall_watchdog.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace portablekit { +class Kernel; +} + +// The stall watchdog: when the game has shown no new frame for five seconds +// outside the paused menu, says what the emulated PSP is doing, on stderr +// and flushed, since a stalled game on a handheld cannot be attached to. +// +// - From the scheduler's idle loop, which is where the main thread waits +// when every guest thread is blocked: every thread's state, what it waits +// for, its pc and ra, the objects waited on, the clock and the pacing +// (Kernel::dump_state). Once when the stall is noticed and again every 30 +// seconds while it lasts. +// - From a thread of its own, for a stall anywhere else: how long since the +// last frame, when the scheduler last went idle, and the last pad words +// handed to the game. +// +// _STALL_WATCHDOG=0 turns it off; =N waits N seconds instead of five. +namespace portablekit::stall_watchdog { + +// The game flipped its framebuffer. +void note_frame(); +// The paused menu opened (true) or closed; no frames come meanwhile. +void note_menu(bool open); +// A pad sample handed to the game. +void note_pad(std::uint32_t buttons); +// The scheduler found nothing to run. On the main thread. +void idle(const Kernel &kernel); + +} // namespace portablekit::stall_watchdog From 56cb885d9976cd0c2153b8217cfceaea3a32b0c4 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:51:31 +0300 Subject: [PATCH 10/12] Answer closing the window and SIGTERM while a game stalls SDL turns SIGTERM into a quit event, and the window's events are only read when the game flips its framebuffer; a game that stopped flipping ignored both, which is why the stalled Steam Deck build needed SIGKILL. While the stall watchdog sees a stall from the scheduler's idle loop it now reads the window's events ten times a second and stops the game when the window is closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/hle/hle_media.cpp | 11 +++++++++++ host/kernel/stall_watchdog.cpp | 8 ++++++++ host/kernel/stall_watchdog.hpp | 7 +++++++ 3 files changed, 26 insertions(+) diff --git a/host/hle/hle_media.cpp b/host/hle/hle_media.cpp index f1e7e88..5c150a8 100644 --- a/host/hle/hle_media.cpp +++ b/host/hle/hle_media.cpp @@ -271,6 +271,17 @@ void present_frame(Runtime &rt) { return; } gpu::VulkanRenderer &renderer = *media().renderer; + static const bool pump_set = [&rt] { + // A stalled game still answers closing the window and SIGTERM. + stall_watchdog::set_stalled_pump([&rt] { + if (media().renderer && !media().renderer->pump_events()) { + std::cerr << "[stall] the window was closed while the game stalled; stopping" << std::endl; + rt.stop("window closed"); + } + }); + return true; + }(); + (void)pump_set; // The guest passes a VRAM offset when the high byte is zero. const std::uint32_t address = (media().display.framebuffer & 0xFF000000u) == 0u ? (media().display.framebuffer | 0x04000000u) diff --git a/host/kernel/stall_watchdog.cpp b/host/kernel/stall_watchdog.cpp index 0a6cf59..7889c69 100644 --- a/host/kernel/stall_watchdog.cpp +++ b/host/kernel/stall_watchdog.cpp @@ -50,6 +50,8 @@ struct State { std::atomic pads{0}; std::atomic last_pad{0xFFFFFFFFu}; // Main thread only. + std::function pump; + std::int64_t pumped_ms{0}; std::int64_t dumped_ms{0}; std::uint64_t dumped_frames{~0ull}; // The watchdog thread's. @@ -136,6 +138,8 @@ void note_pad(std::uint32_t buttons) { s.pads.store(index + 1u); } +void set_stalled_pump(std::function pump) { state().pump = std::move(pump); } + void idle(const Kernel &kernel) { if (!enabled()) return; State &s = state(); @@ -143,6 +147,10 @@ void idle(const Kernel &kernel) { s.last_idle_ms.store(now); const std::int64_t stalled = stalled_ms(s, now); if (stalled < stall_limit_ms()) return; + if (s.pump && now - s.pumped_ms >= 100) { + s.pumped_ms = now; + s.pump(); + } const std::uint64_t frames = s.frames.load(); if (frames == s.dumped_frames && now - s.dumped_ms < kRepeatMs) return; s.dumped_frames = frames; diff --git a/host/kernel/stall_watchdog.hpp b/host/kernel/stall_watchdog.hpp index 48a8baa..aa7009c 100644 --- a/host/kernel/stall_watchdog.hpp +++ b/host/kernel/stall_watchdog.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include namespace portablekit { class Kernel; @@ -31,4 +32,10 @@ void note_pad(std::uint32_t buttons); // The scheduler found nothing to run. On the main thread. void idle(const Kernel &kernel); +// Called from idle() about ten times a second while a stall lasts: the +// window's events are otherwise only read when the game flips, so a stalled +// game would ignore closing the window and SIGTERM (which SDL turns into a +// quit event). Set once by whoever owns the window. +void set_stalled_pump(std::function pump); + } // namespace portablekit::stall_watchdog From 388b344f7943c1dae30313b94418f890ccfeb869 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:56:59 +0300 Subject: [PATCH 11/12] Let a game set the keyboard it starts with A game played otherwise than the framework's defaults suppose (W A S D to move, the mouse for a camera) can now give its own keyboard and mouse defaults: GameProfile::keyboard_defaults gets the framework's bindings and mouse setting and changes what it needs. defaults_for() applies it before a phone's own changes, so "Restore control defaults" returns to it too. A player's own bindings stay: only actions settings.ini does not name take the defaults, and those lose any key or button a saved action already has (input::keep_saved), so a default added later, such as Jump on Space where an older file put Space on cross, cannot press two controls at once. _TRACE_PAD prints the keyboard as loaded, marking the defaults. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/input/bindings.cpp | 16 ++++++++++++++++ host/input/bindings.hpp | 5 +++++ host/profile.hpp | 8 ++++++++ host/settings/settings.cpp | 18 ++++++++++++++++++ tests/input_tests.cpp | 27 +++++++++++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/host/input/bindings.cpp b/host/input/bindings.cpp index de556d4..d61a625 100644 --- a/host/input/bindings.cpp +++ b/host/input/bindings.cpp @@ -132,6 +132,22 @@ std::string_view trim(std::string_view text) { } // namespace +void keep_saved(Bindings &bindings, const std::array &saved) { + for (std::size_t i = 0; i < kActions; ++i) { + if (saved[i]) continue; + Slots &slots = bindings[i]; + for (Binding &binding : slots) { + if (binding == kNone) continue; + for (std::size_t j = 0; j < kActions; ++j) { + if (!saved[j]) continue; + for (const Binding other : bindings[j]) + if (other == binding) binding = kNone; + } + } + if (slots[0] == kNone) std::swap(slots[0], slots[1]); + } +} + const Bindings &default_bindings() { static const Bindings value = make_default(); return value; diff --git a/host/input/bindings.hpp b/host/input/bindings.hpp index bf4f0e2..6fdb98c 100644 --- a/host/input/bindings.hpp +++ b/host/input/bindings.hpp @@ -88,6 +88,11 @@ struct ActionInfo { // False, leaving `slots` alone, if a name is not known. bool parse(std::string_view text, Slots &slots); +// After settings.ini is read: the actions it held keep what it said, and +// the others, left at the defaults, lose any key or button one of those +// already has, so a default added later cannot press two controls at once. +void keep_saved(Bindings &bindings, const std::array &saved); + // The menu's way of changing a binding: pressing what is already bound to // the action removes it; anything else is taken from any other action and // added, replacing the second slot when both are full. diff --git a/host/profile.hpp b/host/profile.hpp index d85540d..b79da20 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -11,6 +11,8 @@ // derive from the executable it was given. Anything the ELF already says (its // entry point, its segments, its imports) is read from the ELF, not declared. +#include "input/bindings.hpp" + #include #include #include @@ -194,6 +196,12 @@ struct GameProfile { // Tilt controls from motion sensors. Null: the game does not tilt, and // the menu offers none. const TiltControls *tilt = nullptr; + // The keyboard and mouse this game starts with, for a game played + // otherwise than the framework's defaults suppose (WASD and the mouse + // for a camera): gets the framework's bindings and mouse setting and + // changes what it needs. Only what settings.ini does not hold is taken + // from here, so a player's own bindings stay. Null: the framework's. + void (*keyboard_defaults)(input::Bindings &bindings, bool &mouse) = nullptr; // --- Hooks ------------------------------------------------------------- // Called after the framework has registered its own HLE, so a game can add diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index 97353d4..5f4c4c0 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -482,6 +482,22 @@ void load(State &s) { field.parse_variable(s.values, text); s.overrides[field.key] = env_name(field.variable); } + // Bindings settings.ini did not hold are defaults, which must not take a + // key it gave another control. + std::array saved{}; + bool any_saved = false; + for (std::size_t i = 0; i < input::kActions; ++i) { + saved[i] = s.file.count(std::string("input.bind.") + input::info(static_cast(i)).key) != 0u; + any_saved = any_saved || saved[i]; + } + if (any_saved) input::keep_saved(s.values.bindings, saved); + if (portablekit::env("TRACE_PAD") != nullptr) { + std::cout << "[pad] keyboard:"; + for (std::size_t i = 0; i < input::kActions; ++i) + std::cout << " " << input::info(static_cast(i)).key << "=" + << input::format(s.values.bindings[i]) << (saved[i] ? "" : "*"); + std::cout << " (* the game's default); mouse " << (s.values.mouse ? "on" : "off") << std::endl; + } // A fixed name in the environment is meant for unattended runs, which // nobody is there to type in, so it also answers at once unless // the OSK_MODE variable says otherwise. @@ -506,6 +522,8 @@ std::string game_default_name() { Settings defaults_for(Platform platform) { Settings values{}; + // The game's keyboard first; a phone then turns the mouse off either way. + if (const auto keyboard = game().keyboard_defaults) keyboard(values.bindings, values.mouse); if (platform == Platform::Android) { values.aspect = Aspect::Fill; values.fullscreen = true; diff --git a/tests/input_tests.cpp b/tests/input_tests.cpp index 4c11bbc..f581396 100644 --- a/tests/input_tests.cpp +++ b/tests/input_tests.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace { @@ -128,7 +129,33 @@ void test_mouse_turn() { } // namespace +void test_keep_saved() { + // A file from before Jump existed: it holds Space on ×, and Jump's new + // default of Space must not press both. + Bindings b = default_bindings(); + b[static_cast(Action::Jump)] = {from_name("Space"), from_name("V")}; + b[static_cast(Action::Cross)] = {from_name("Space"), kNone}; + std::array saved{}; + saved.fill(true); + saved[static_cast(Action::Jump)] = false; + keep_saved(b, saved); + check(b[static_cast(Action::Cross)][0] == from_name("Space"), "the saved binding stays"); + check(b[static_cast(Action::Jump)][0] == from_name("V") && + b[static_cast(Action::Jump)][1] == kNone, + "a default another control holds is dropped, and the rest moves up"); + Bindings untouched = default_bindings(); + std::array none{}; + keep_saved(untouched, none); + check(untouched == default_bindings(), "nothing saved, nothing changes"); + const char *names[] = {"Left", "Right", "A", "D", "Space", "Left Shift", "Right Shift", "Enter", "Z", "C", + "X", "Tab", "P", "Backspace", "Up", "Down", "W", "S", "Q", "E"}; + bool all = true; + for (const char *name : names) all = all && from_name(name) != kNone; + check(all, "every key a game's keyboard defaults name by name is known"); +} + int main() { + test_keep_saved(); test_names(); test_settings_spelling(); test_layouts(); From 8c243d61810f2678ff1647107d287392fce98418 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:10:57 +0300 Subject: [PATCH 12/12] Package Android games without overlays and include license texts --- packaging/android/build_apk.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packaging/android/build_apk.sh b/packaging/android/build_apk.sh index de91ad3..755092f 100755 --- a/packaging/android/build_apk.sh +++ b/packaging/android/build_apk.sh @@ -66,6 +66,15 @@ echo "linking resources" # The licences of what the APK carries: the third-party notices, SDL's and # FFmpeg's (with where its source is), and the font's when it is packed. mkdir -p "$work/assets/licenses" +kit="$(cd "$here/../.." && pwd)" +cp "$kit/LICENSE" "$work/assets/licenses/PortableKit-LICENSE.txt" +cp "$kit/third_party/imgui/LICENSE.txt" "$work/assets/licenses/DearImGui-LICENSE.txt" +cp "$kit/third_party/tiny_aes/UNLICENSE" "$work/assets/licenses/tiny-AES-c-UNLICENSE.txt" +cp "$kit/third_party/xxhash/LICENSE" "$work/assets/licenses/xxHash-LICENSE.txt" +game_repo="${GAME_REPO:-$(git -C "$game_res" rev-parse --show-toplevel 2>/dev/null || echo "$game_res")}" +if [[ -f "$game_repo/LICENSE" ]]; then + cp "$game_repo/LICENSE" "$work/assets/licenses/Game-LICENSE.txt" +fi if [[ -n "${NOTICES:-}" ]]; then cp "$NOTICES" "$work/assets/licenses/"; fi cp "$sdl_dir/LICENSE.txt" "$work/assets/licenses/SDL3-LICENSE.txt" cp "$build_dir"/bin/lib/FFmpeg-COPYING.LGPLv2.1.txt "$build_dir"/bin/lib/FFmpeg-SOURCE.txt "$work/assets/licenses/" @@ -93,9 +102,10 @@ lib="$work/lib/arm64-v8a" cp "$sdl_lib" "$lib/libSDL3.so" cp "$build_dir/bin/libmain.so" "$lib/" cp "$build_dir"/bin/lib/libavcodec.so "$build_dir"/bin/lib/libavutil.so "$lib/" +shopt -s nullglob overlays=("$build_dir"/bin/overlays/libovl*.so) if [[ -n "$overlay_limit" ]]; then overlays=("${overlays[@]:0:$overlay_limit}"); fi -cp "${overlays[@]}" "$lib/" +if [[ ${#overlays[@]} -gt 0 ]]; then cp "${overlays[@]}" "$lib/"; fi strip="$(ls -d "$ANDROID_NDK"/toolchains/llvm/prebuilt/*/bin/llvm-strip | head -1)" "$strip" --strip-unneeded "$lib"/*.so # Every function a packed library imports must be in Android $min_sdk's system