diff --git a/CMakeLists.txt b/CMakeLists.txt index 63c819d..b612b11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,17 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_profile_tests COMMAND portablekit_profile_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 4f87ea4..90147fb 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -38,6 +38,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 @@ -50,6 +51,7 @@ set(PORTABLEKIT_HOST_SOURCES host/kernel/rtc_time.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 @@ -97,6 +99,8 @@ set(PORTABLEKIT_RENDERER_SOURCES host/gpu/vulkan_renderer.cpp 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 @@ -309,7 +313,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 43aa31e..9347fc5 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -16,6 +16,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) @@ -355,6 +356,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)); @@ -418,9 +432,14 @@ struct PadTuning { bool invert_x{}; bool invert_y{}; bool confirm_south{}; + settings::JumpButton jump{settings::JumpButton::Off}; 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; @@ -435,9 +454,9 @@ 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.jump = player.jump_button; value.trace = trace; return value; } @@ -447,9 +466,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); @@ -460,10 +495,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. @@ -826,6 +866,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; @@ -1128,6 +1197,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{}; @@ -1684,10 +1757,12 @@ 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 " - << (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") @@ -1723,6 +1798,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; @@ -2822,6 +2898,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); @@ -3087,6 +3164,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__) @@ -3143,6 +3470,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), @@ -3217,7 +3546,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 { @@ -4754,6 +5093,21 @@ 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), 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); + 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 = [] { @@ -4762,7 +5116,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. @@ -4824,7 +5178,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; } @@ -5742,6 +6100,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) { @@ -7331,6 +7699,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; @@ -7614,6 +7985,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/hle/hle_media.cpp b/host/hle/hle_media.cpp index 3b3727d..d983fe6 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" @@ -270,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) @@ -349,7 +361,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 +401,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 +560,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/hle/hle_system.cpp b/host/hle/hle_system.cpp index 7f2d097..b53ce8d 100644 --- a/host/hle/hle_system.cpp +++ b/host/hle/hle_system.cpp @@ -33,6 +33,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) { // The host has no data cache to keep coherent. Phantasy Star Portable 2 // Infinity (NPJH50332) uses WritebackInvalidateRange. @@ -243,7 +250,12 @@ void register_platform(HleRegistrar &hle) { log_once("npdrm-edata", "[npdrm] sceNpDrmEdataSetupKey (UNVERIFIED: no game traced yet)"); 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); @@ -283,6 +295,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); }); diff --git a/host/input/bindings.cpp b/host/input/bindings.cpp index e23565d..cccfe2a 100644 --- a/host/input/bindings.cpp +++ b/host/input/bindings.cpp @@ -48,18 +48,18 @@ 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"}, - {"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() { @@ -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; @@ -232,6 +248,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..6fdb98c 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); @@ -85,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. @@ -97,6 +105,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/input/motion.cpp b/host/input/motion.cpp new file mode 100644 index 0000000..7440576 --- /dev/null +++ b/host/input/motion.cpp @@ -0,0 +1,332 @@ +#include "../profile.hpp" +#include "input/motion.hpp" + +#include "input/steam_deck_imu.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; + // The Steam Deck's own sensors, read beside Steam Input. + bool deck{}; + + 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{}; + // 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{}; +}; + +State &state() { + static State value; + 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; +} + +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 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); + 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. A Steam + // Deck's own are read directly; another pad's are not. + const SDL_GamepadType type = SDL_GetGamepadType(pad); + 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; + } +} + +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); + steam_deck_imu::close(); + s.deck = 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 << " 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; + } + s.traced_direction = s.output.direction; + if (!down) return 0u; + return s.output.direction < 0 ? controls->left : controls->right; +} + +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(); + 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() { + 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.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"; + 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..91c07cf --- /dev/null +++ b/host/input/motion.hpp @@ -0,0 +1,55 @@ +#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), 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. +// +// 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(); + +// 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: +// 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/steam_deck_imu.cpp b/host/input/steam_deck_imu.cpp new file mode 100644 index 0000000..10332f4 --- /dev/null +++ b/host/input/steam_deck_imu.cpp @@ -0,0 +1,379 @@ +#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 +#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; + +// 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; + // Written by the thread, taken by read(). + bool have{}; // motion data arrived since active + tilt::Reading last; + float gyro_sum[3]{}; + int gyro_count{}; + std::uint64_t reports{}; +}; + +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; +} + +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()}; +} + +// /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))); +} + +// 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; + buffer[2] = 3; + buffer[3] = kSettingImuMode; + buffer[4] = static_cast(mode & 0xFF); + buffer[5] = static_cast(mode >> 8); + 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 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(); + { + 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 + +bool present() { + return !switched_off() && !find_device().empty(); +} + +bool open() { + Deck &d = deck(); + 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; + 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) { + say("[tilt] Steam Deck controller " + d.path + " cannot be read: " + std::strerror(errno)); + return false; + } + 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(); + 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.gyro_count = 0; + d.last.has_gyro = true; + } + reading = d.last; + return true; +} + +void close() { + Deck &d = deck(); + { + 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() { + 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"; +} + +#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..b8f79d4 --- /dev/null +++ b/host/input/steam_deck_imu.hpp @@ -0,0 +1,46 @@ +#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 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 +// 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/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..93cded3 --- /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 = 30.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/kernel/kernel.cpp b/host/kernel/kernel.cpp index 4c8cf07..a901f3a 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 @@ -417,6 +419,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"; @@ -437,6 +440,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 98f0897..d49fecc 100644 --- a/host/kernel/kernel.hpp +++ b/host/kernel/kernel.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -246,6 +247,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..7889c69 --- /dev/null +++ b/host/kernel/stall_watchdog.cpp @@ -0,0 +1,166 @@ +#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::function pump; + std::int64_t pumped_ms{0}; + 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 set_stalled_pump(std::function pump) { state().pump = std::move(pump); } + +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; + 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; + 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..aa7009c --- /dev/null +++ b/host/kernel/stall_watchdog.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#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); + +// 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 diff --git a/host/profile.hpp b/host/profile.hpp index 8ffdb28..09322ed 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 @@ -45,6 +47,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 { @@ -203,6 +220,15 @@ 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; + // 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 8f825b6..fb1e30e 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -109,6 +109,16 @@ 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::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. @@ -304,6 +314,41 @@ 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, rate, or level (also horizon)\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.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); + }, + [](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); }, @@ -437,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. @@ -461,6 +522,11 @@ 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; + // 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/host/settings/settings.hpp b/host/settings/settings.hpp index dc2d92b..17058ff 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 @@ -30,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 @@ -64,7 +68,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 @@ -97,6 +104,18 @@ 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 + // 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/input_script.cpp b/host/ui/input_script.cpp index b43d0c4..2b174f2 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; @@ -207,7 +236,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; @@ -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 b71f547..e422107 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" @@ -119,6 +120,7 @@ class Menu { void video(); void audio(); void controls(); + void tilt_controls(); void network(); void system(); bool confirm_dialog(); @@ -577,6 +579,124 @@ 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 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; + settings::save(); + } + } + const auto off = [&](RowOptions options) { + if (!s.tilt && !options.disabled) { + options.disabled = true; + options.note = "Tilt with motion is off"; + } + return options; + }; + { + // 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", name, + off(options_for("input.tilt_mode", + "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(); + } + } + 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(); + } + { + // 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 " + "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"); @@ -586,9 +706,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(); } @@ -709,6 +832,8 @@ void Menu::controls() { } } + tilt_controls(); + section(game().player_name_label); { const bool keyboard = s.name_entry == settings::NameEntry::Keyboard; @@ -782,13 +907,19 @@ 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"; 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(); } @@ -826,6 +957,13 @@ 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); + restore("input.jump_button", s.jump_button, d.jump_button); s.bindings = d.bindings; settings::save(); } 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 diff --git a/tests/input_tests.cpp b/tests/input_tests.cpp index 230fc67..f581396 100644 --- a/tests/input_tests.cpp +++ b/tests/input_tests.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace { @@ -111,6 +112,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() { @@ -123,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(); 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; +}