diff --git a/CMakePresets.json b/CMakePresets.json index b64ca36a8..8630c1f33 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -165,6 +165,79 @@ "CMAKE_BUILD_TYPE": "Release", "CMAKE_INTERPROCEDURAL_OPTIMIZATION": true } + }, + { + "name": "ios-simulator-debug", + "displayName": "iOS Simulator Debug", + "generator": "Ninja", + "binaryDir": "${sourceDir}/out/build/${presetName}", + "installDir": "${sourceDir}/out/install/${presetName}", + "cacheVariables": { + "CMAKE_SYSTEM_NAME": "iOS", + "CMAKE_OSX_SYSROOT": "iphonesimulator", + "CMAKE_OSX_ARCHITECTURES": "arm64", + "CMAKE_BUILD_TYPE": "Debug", + "UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES": true, + "UNLEASHED_RECOMP_HOST_TOOLS_DIR": "${sourceDir}/out/build/macos-release", + "CMAKE_TOOLCHAIN_FILE": { + "value": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "type": "FILEPATH" + }, + "VCPKG_TARGET_TRIPLET": { + "value": "arm64-ios-simulator", + "type": "STRING" + } + }, + "environment": { + "VCPKG_ROOT": "${sourceDir}/thirdparty/vcpkg" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + }, + { + "name": "ios-device-release", + "displayName": "iOS Device Release (MoltenVK)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/out/build/${presetName}", + "installDir": "${sourceDir}/out/install/${presetName}", + "cacheVariables": { + "CMAKE_SYSTEM_NAME": "iOS", + "CMAKE_OSX_SYSROOT": "iphoneos", + "CMAKE_OSX_ARCHITECTURES": "arm64", + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_INTERPROCEDURAL_OPTIMIZATION": true, + "UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES": true, + "UNLEASHED_RECOMP_HOST_TOOLS_DIR": "${sourceDir}/out/build/macos-release", + "CMAKE_TOOLCHAIN_FILE": { + "value": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "type": "FILEPATH" + }, + "VCPKG_TARGET_TRIPLET": { + "value": "arm64-ios", + "type": "STRING" + } + }, + "environment": { + "VCPKG_ROOT": "${sourceDir}/thirdparty/vcpkg" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + }, + { + "name": "ios-native-metal-release", + "displayName": "iOS Native Metal Release", + "inherits": "ios-device-release", + "binaryDir": "${sourceDir}/out/build/${presetName}", + "installDir": "${sourceDir}/out/install/${presetName}", + "cacheVariables": { + "UNLEASHED_RECOMP_IOS_NATIVE_METAL": true + } } ] } diff --git a/UnleashedRecomp/CMakeLists.txt b/UnleashedRecomp/CMakeLists.txt index 01e040ab8..2a5d7858d 100644 --- a/UnleashedRecomp/CMakeLists.txt +++ b/UnleashedRecomp/CMakeLists.txt @@ -1,5 +1,19 @@ project("UnleashedRecomp") +set(UNLEASHED_RECOMP_HOST_TOOLS_DIR "" CACHE PATH "Directory containing host-built recompilation tools.") + +function(unleashed_recomp_resolve_tool OUT_VAR TARGET_NAME RELATIVE_PATH) + if (UNLEASHED_RECOMP_HOST_TOOLS_DIR) + set(${OUT_VAR} "${UNLEASHED_RECOMP_HOST_TOOLS_DIR}/${RELATIVE_PATH}" PARENT_SCOPE) + elseif (TARGET ${TARGET_NAME}) + set(${OUT_VAR} "$" PARENT_SCOPE) + else() + message(FATAL_ERROR "Tool ${TARGET_NAME} is not available. Set UNLEASHED_RECOMP_HOST_TOOLS_DIR.") + endif() +endfunction() + +unleashed_recomp_resolve_tool(UNLEASHED_RECOMP_FILE_TO_C_TOOL file_to_c "tools/file_to_c/file_to_c") + if (WIN32) option(UNLEASHED_RECOMP_D3D12 "Add D3D12 support for rendering" ON) endif() @@ -28,7 +42,7 @@ function(BIN2C) endif() add_custom_command(OUTPUT "${BIN2C_ARGS_DEST_FILE}.c" - COMMAND $ "${BIN2C_ARGS_SOURCE_FILE}" "${BIN2C_ARGS_ARRAY_NAME}" "${BIN2C_ARGS_COMPRESSION_TYPE}" "${BIN2C_ARGS_DEST_FILE}.c" "${BIN2C_ARGS_DEST_FILE}.h" + COMMAND "${UNLEASHED_RECOMP_FILE_TO_C_TOOL}" "${BIN2C_ARGS_SOURCE_FILE}" "${BIN2C_ARGS_ARRAY_NAME}" "${BIN2C_ARGS_COMPRESSION_TYPE}" "${BIN2C_ARGS_DEST_FILE}.c" "${BIN2C_ARGS_DEST_FILE}.h" DEPENDS "${BIN2C_ARGS_SOURCE_FILE}" BYPRODUCTS "${BIN2C_ARGS_DEST_FILE}.h" COMMENT "Generating binary header for ${BIN2C_ARGS_SOURCE_FILE}..." @@ -58,11 +72,18 @@ else() add_compile_options(-ffp-model=strict) endif() -add_compile_definitions( - SDL_MAIN_HANDLED +set(UNLEASHED_RECOMP_COMPILE_DEFINITIONS _DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR # Microsoft wtf? _CRT_SECURE_NO_WARNINGS) +if (NOT CMAKE_SYSTEM_NAME STREQUAL "iOS") + list(APPEND UNLEASHED_RECOMP_COMPILE_DEFINITIONS SDL_MAIN_HANDLED) +else() + list(APPEND UNLEASHED_RECOMP_COMPILE_DEFINITIONS UNLEASHED_RECOMP_IOS) +endif() + +add_compile_definitions(${UNLEASHED_RECOMP_COMPILE_DEFINITIONS}) + set(UNLEASHED_RECOMP_PRECOMPILED_HEADERS "stdafx.h" ) @@ -97,6 +118,14 @@ elseif (CMAKE_SYSTEM_NAME MATCHES "Linux") "os/linux/user_linux.cpp" "os/linux/version_linux.cpp" ) +elseif (CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(UNLEASHED_RECOMP_OS_CXX_SOURCES + "os/ios/logger_ios.cpp" + "os/ios/media_ios.cpp" + "os/ios/process_ios.mm" + "os/ios/user_ios.mm" + "os/ios/version_ios.mm" + ) elseif (APPLE) set(UNLEASHED_RECOMP_OS_CXX_SOURCES "os/macos/logger_macos.cpp" @@ -156,10 +185,12 @@ set(UNLEASHED_RECOMP_UI_CXX_SOURCES "ui/fader.cpp" "ui/game_window.cpp" "ui/imgui_utils.cpp" + "ui/input_coords.cpp" "ui/installer_wizard.cpp" "ui/message_window.cpp" "ui/options_menu.cpp" "ui/options_menu_thumbnails.cpp" + "ui/touch_controls.cpp" "ui/tv_static.cpp" ) @@ -292,6 +323,54 @@ if (WIN32) if (${CMAKE_BUILD_TYPE} MATCHES "Release") target_link_options(UnleashedRecomp PRIVATE "/SUBSYSTEM:WINDOWS" "/ENTRY:mainCRTStartup") endif() +elseif (CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(UNLEASHED_RECOMP_IOS_MINIMUM_VERSION "${CMAKE_OSX_DEPLOYMENT_TARGET}") + if (NOT UNLEASHED_RECOMP_IOS_MINIMUM_VERSION) + execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version + OUTPUT_VARIABLE UNLEASHED_RECOMP_IOS_MINIMUM_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) + endif() + CreateVersionString( + VERSION_TXT ${VERSION_TXT} + OUTPUT_VAR IOS_BUNDLE_VERSION + ) + string(REGEX REPLACE "^v" "" IOS_BUNDLE_VERSION "${IOS_BUNDLE_VERSION}") + + set(UNLEASHED_RECOMP_IOS_ICON_FILES + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon20x20@2x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon20x20@3x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon29x29@2x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon29x29@3x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon40x40@2x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon40x40@3x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon60x60@2x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon60x60@3x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon76x76.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon76x76@2x.png" + "${CMAKE_CURRENT_SOURCE_DIR}/res/ios/icons/AppIcon83.5x83.5@2x.png" + ) + + add_executable(UnleashedRecomp MACOSX_BUNDLE + ${UNLEASHED_RECOMP_CXX_SOURCES} + ) + set_target_properties(UnleashedRecomp PROPERTIES + OUTPUT_NAME "Unleashed Recompiled" + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/res/ios/Info.plist.in + MACOSX_BUNDLE_GUI_IDENTIFIER hedge-dev.UnleashedRecomp + MACOSX_BUNDLE_BUNDLE_NAME "Unleashed Recompiled" + MACOSX_BUNDLE_BUNDLE_VERSION ${IOS_BUNDLE_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${IOS_BUNDLE_VERSION} + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "hedge-dev.UnleashedRecomp" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" + XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" + ) + foreach(UNLEASHED_RECOMP_IOS_ICON_FILE IN LISTS UNLEASHED_RECOMP_IOS_ICON_FILES) + add_custom_command(TARGET UnleashedRecomp POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${UNLEASHED_RECOMP_IOS_ICON_FILE}" + "$" + ) + endforeach() elseif (APPLE) # Create version number for app bundle. CreateVersionString( @@ -387,6 +466,22 @@ if (WIN32) ) endif() +set(UNLEASHED_RECOMP_SDL_LIBS SDL2::SDL2-static) +if (CMAKE_SYSTEM_NAME STREQUAL "iOS" AND TARGET SDL2::SDL2main) + list(PREPEND UNLEASHED_RECOMP_SDL_LIBS SDL2::SDL2main) +endif() + +set(UNLEASHED_RECOMP_PLATFORM_LIBS) +if (CMAKE_SYSTEM_NAME STREQUAL "iOS") + target_link_options(UnleashedRecomp PRIVATE "LINKER:-ObjC") + if (UNLEASHED_RECOMP_IOS_NATIVE_METAL) + target_compile_definitions(UnleashedRecomp PRIVATE UNLEASHED_RECOMP_METAL=1) + else() + list(APPEND UNLEASHED_RECOMP_PLATFORM_LIBS MoltenVK) + target_link_options(UnleashedRecomp PRIVATE "LINKER:-u,_vkGetInstanceProcAddr") + endif() +endif() + target_link_libraries(UnleashedRecomp PRIVATE fmt::fmt libzstd_static @@ -394,13 +489,14 @@ target_link_libraries(UnleashedRecomp PRIVATE nfd::nfd o1heap XenonUtils - SDL2::SDL2-static + ${UNLEASHED_RECOMP_SDL_LIBS} SDL2_mixer tomlplusplus::tomlplusplus UnleashedRecompLib xxHash::xxhash CURL::libcurl plume + ${UNLEASHED_RECOMP_PLATFORM_LIBS} ) target_include_directories(UnleashedRecomp PRIVATE diff --git a/UnleashedRecomp/cpu/guest_thread.cpp b/UnleashedRecomp/cpu/guest_thread.cpp index 3dbe9152f..70f12aea0 100644 --- a/UnleashedRecomp/cpu/guest_thread.cpp +++ b/UnleashedRecomp/cpu/guest_thread.cpp @@ -144,10 +144,31 @@ uint32_t GuestThread::Start(const GuestThreadParams& params) const auto procMask = (uint8_t)(params.flags >> 24); const auto cpuNumber = procMask == 0 ? 0 : 7 - std::countl_zero(procMask); +#ifdef UNLEASHED_RECOMP_IOS + fprintf(stderr, "[iOS guest] start function=0x%08X value=0x%08X flags=0x%08X cpu=%u\n", + params.function, + params.value, + params.flags, + cpuNumber); + fflush(stderr); +#endif + GuestThreadContext ctx(cpuNumber); ctx.ppcContext.r3.u64 = params.value; - g_memory.FindFunction(params.function)(ctx.ppcContext, g_memory.base); + PPCFunc* function = g_memory.FindFunction(params.function); + +#ifdef UNLEASHED_RECOMP_IOS + fprintf(stderr, "[iOS guest] host function=%p\n", reinterpret_cast(function)); + fflush(stderr); +#endif + + function(ctx.ppcContext, g_memory.base); + +#ifdef UNLEASHED_RECOMP_IOS + fprintf(stderr, "[iOS guest] returned r3=0x%08X\n", ctx.ppcContext.r3.u32); + fflush(stderr); +#endif return ctx.ppcContext.r3.u32; } diff --git a/UnleashedRecomp/gpu/video.cpp b/UnleashedRecomp/gpu/video.cpp index a9edd0b39..cbdd85a49 100644 --- a/UnleashedRecomp/gpu/video.cpp +++ b/UnleashedRecomp/gpu/video.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -100,6 +102,9 @@ extern "C" namespace plume { +#ifdef UNLEASHED_RECOMP_METAL + extern std::unique_ptr CreateMetalInterface(); +#endif #ifdef UNLEASHED_RECOMP_D3D12 extern std::unique_ptr CreateD3D12Interface(); #endif @@ -319,6 +324,12 @@ static std::unique_ptr g_copyCommandFence; static std::unique_ptr g_swapChain; static bool g_swapChainValid; +#ifdef UNLEASHED_RECOMP_IOS +#define IOS_VIDEO_LOG(...) do { fprintf(stderr, "[iOS video] " __VA_ARGS__); fflush(stderr); } while (false) +#else +#define IOS_VIDEO_LOG(...) do { } while (false) +#endif + static constexpr RenderFormat BACKBUFFER_FORMAT = RenderFormat::B8G8R8A8_UNORM; static std::unique_ptr g_acquireSemaphores[NUM_FRAMES]; @@ -1530,13 +1541,18 @@ static void CreateImGuiBackend() static void CheckSwapChain() { g_swapChain->setVsyncEnabled(Config::VSync); - g_swapChainValid &= !g_swapChain->needsResize(); + const bool neededResize = g_swapChain->needsResize(); + bool resized = false; + bool acquired = false; + + g_swapChainValid &= !neededResize; if (!g_swapChainValid) { Video::WaitForGPU(); g_backBuffer->framebuffers.clear(); g_swapChainValid = g_swapChain->resize(); + resized = g_swapChainValid; g_needsResize = g_swapChainValid; } @@ -1544,6 +1560,7 @@ static void CheckSwapChain() { g_swapChainAcquireProfiler.Begin(); g_swapChainValid = g_swapChain->acquireTexture(g_acquireSemaphores[g_frame].get(), &g_backBufferIndex); + acquired = g_swapChainValid; g_swapChainAcquireProfiler.End(); } @@ -1552,6 +1569,25 @@ static void CheckSwapChain() g_backBuffer->width = Video::s_viewportWidth; g_backBuffer->height = Video::s_viewportHeight; + +#ifdef UNLEASHED_RECOMP_IOS + static uint32_t s_checkLogCount = 0; + if (s_checkLogCount < 20 || !g_swapChainValid) + { + IOS_VIDEO_LOG("check #%u neededResize=%d resized=%d acquired=%d valid=%d backBuffer=%u viewport=%ux%u swapchain=%ux%u\n", + s_checkLogCount, + neededResize ? 1 : 0, + resized ? 1 : 0, + acquired ? 1 : 0, + g_swapChainValid ? 1 : 0, + g_backBufferIndex, + Video::s_viewportWidth, + Video::s_viewportHeight, + g_swapChain->getWidth(), + g_swapChain->getHeight()); + } + s_checkLogCount++; +#endif } static void BeginCommandList() @@ -1645,6 +1681,65 @@ static void ApplyLowEndDefault(ConfigDef &configDef, T newDefault, bool &chan configDef.DefaultValue = newDefault; } +#ifdef UNLEASHED_RECOMP_IOS +template +static void ApplyIOSDefault(ConfigDef& configDef, T desktopDefault, T previousIOSDefault, T iosDefault, bool& changed) +{ + const bool shouldApply = !configDef.IsLoadedFromConfig || configDef.Value == desktopDefault || configDef.Value == previousIOSDefault; + + if (shouldApply && configDef.Value != iosDefault) + { + configDef = iosDefault; + changed = true; + } + + configDef.DefaultValue = iosDefault; +} + +template +static void ApplyIOSDefault(ConfigDef& configDef, T desktopDefault, T iosDefault, bool& changed) +{ + ApplyIOSDefault(configDef, desktopDefault, iosDefault, iosDefault, changed); +} + +static void ApplyIOSPerformanceDefaults() +{ + bool changed = false; + + ApplyIOSDefault(Config::ResolutionScale, 1.0f, 0.65f, 0.55f, changed); + ApplyIOSDefault(Config::AntiAliasing, EAntiAliasing::MSAA4x, EAntiAliasing::None, changed); + ApplyIOSDefault(Config::TransparencyAntiAliasing, true, false, changed); + ApplyIOSDefault(Config::AnisotropicFiltering, 16u, 4u, 2u, changed); + ApplyIOSDefault(Config::ShadowResolution, EShadowResolution::x4096, EShadowResolution::Original, changed); + ApplyIOSDefault(Config::GITextureFiltering, EGITextureFiltering::Bicubic, EGITextureFiltering::Bilinear, changed); + ApplyIOSDefault(Config::DepthOfFieldQuality, EDepthOfFieldQuality::Auto, EDepthOfFieldQuality::Low, changed); + ApplyIOSDefault(Config::MotionBlur, EMotionBlur::Original, EMotionBlur::Off, changed); + ApplyIOSDefault(Config::TripleBuffering, ETripleBuffering::Auto, ETripleBuffering::On, changed); + ApplyIOSDefault(Config::MaxFrameLatency, 2u, 1u, 2u, changed); + + if (changed) + Config::Save(); +} +#endif + +static float GetEffectiveResolutionScale() +{ + float resolutionScale = Config::ResolutionScale.Value; + +#ifdef UNLEASHED_RECOMP_IOS + if (Config::ResolutionScale.Value <= Config::ResolutionScale.DefaultValue + 0.001f && Video::s_viewportHeight != 0) + { + constexpr float maxDefaultRenderHeight = 720.0f; + const float cappedScale = maxDefaultRenderHeight / float(Video::s_viewportHeight); + + if (resolutionScale > cappedScale) + resolutionScale = cappedScale; + } +#endif + + return resolutionScale; +} + static void ApplyLowEndDefaults() { bool changed = false; @@ -1694,8 +1789,12 @@ bool Video::CreateHostDevice(const char *sdlVideoDriver, bool graphicsApiRetry) interfaceFunctions.push_back(g_vulkan ? CreateVulkanInterfaceWrapper : CreateD3D12Interface); interfaceFunctions.push_back(g_vulkan ? CreateD3D12Interface : CreateVulkanInterfaceWrapper); +#else +#ifdef UNLEASHED_RECOMP_METAL + interfaceFunctions.push_back(CreateMetalInterface); #else interfaceFunctions.push_back(CreateVulkanInterfaceWrapper); +#endif #endif for (size_t i = 0; i < interfaceFunctions.size(); i++) @@ -1805,6 +1904,9 @@ bool Video::CreateHostDevice(const char *sdlVideoDriver, bool graphicsApiRetry) #endif g_capabilities = g_device->getCapabilities(); +#ifdef UNLEASHED_RECOMP_METAL + IOS_VIDEO_LOG("backend=Native Metal device=%s\n", g_device->getDescription().name.c_str()); +#endif LoadEmbeddedResources(); @@ -1820,6 +1922,10 @@ bool Video::CreateHostDevice(const char *sdlVideoDriver, bool graphicsApiRetry) ApplyLowEndDefaults(); } +#ifdef UNLEASHED_RECOMP_IOS + ApplyIOSPerformanceDefaults(); +#endif + const RenderSampleCounts colourSampleCount = g_device->getSampleCountsSupported(RenderFormat::R16G16B16A16_FLOAT); const RenderSampleCounts depthSampleCount = g_device->getSampleCountsSupported(RenderFormat::D32_FLOAT); const RenderSampleCounts commonSampleCount = colourSampleCount & depthSampleCount; @@ -2538,35 +2644,30 @@ static void DrawFPS() static void DrawImGui() { +#ifdef UNLEASHED_RECOMP_IOS + GameWindow::ProcessImGuiEvents(); +#endif ImGui_ImplSDL2_NewFrame(); auto& io = ImGui::GetIO(); io.DisplaySize = { float(Video::s_viewportWidth), float(Video::s_viewportHeight) }; + Video::s_drawableWidth = g_swapChain->getWidth(); + Video::s_drawableHeight = g_swapChain->getHeight(); + // ImGui doesn't know that we center the screen for specific aspect ratio // settings, which causes mouse events to not work correctly. To fix this, // we can adjust the mouse events before ImGui processes them. - uint32_t width = g_swapChain->getWidth(); - uint32_t height = g_swapChain->getHeight(); - float mousePosScaleX = float(width) / float(GameWindow::s_width); - float mousePosScaleY = float(height) / float(GameWindow::s_height); - float mousePosOffsetX = (width - Video::s_viewportWidth) / 2.0f; - float mousePosOffsetY = (height - Video::s_viewportHeight) / 2.0f; for (int i = 0; i < io.Ctx->InputEventsQueue.Size; i++) { auto& e = io.Ctx->InputEventsQueue[i]; if (e.Type == ImGuiInputEventType_MousePos) { - if (e.MousePos.PosX != -FLT_MAX) - { - e.MousePos.PosX *= mousePosScaleX; - e.MousePos.PosX -= mousePosOffsetX; - } - - if (e.MousePos.PosY != -FLT_MAX) + if (e.MousePos.PosX != -FLT_MAX && e.MousePos.PosY != -FLT_MAX) { - e.MousePos.PosY *= mousePosScaleY; - e.MousePos.PosY -= mousePosOffsetY; + const ImVec2 transformed = TransformWindowPointToViewport(e.MousePos.PosX, e.MousePos.PosY); + e.MousePos.PosX = transformed.x; + e.MousePos.PosY = transformed.y; } } } @@ -2599,6 +2700,7 @@ static void DrawImGui() ButtonGuide::Draw(); Fader::Draw(); BlackBar::Draw(); + TouchControls::Draw(); assert(ImGui::GetBackgroundDrawList()->_ClipRectStack.Size == 1 && "Some clip rects were not removed from the stack!"); @@ -2790,6 +2892,13 @@ static std::atomic g_executedCommandList; void Video::Present() { +#ifdef UNLEASHED_RECOMP_IOS + GameWindow::WaitUntilActive(); + static uint32_t s_presentEntryCount = 0; + if (s_presentEntryCount++ < 5) + IOS_VIDEO_LOG("guest present entered\n"); +#endif + g_readyForCommands = false; RenderCommand cmd; @@ -2811,6 +2920,8 @@ void Video::Present() g_executedCommandList.wait(false); g_executedCommandList = false; + bool presented = false; + if (g_swapChainValid) { if (g_pendingWaitOnSwapChain) @@ -2822,8 +2933,24 @@ void Video::Present() RenderCommandSemaphore* signalSemaphores[] = { g_renderSemaphores[g_frame].get() }; g_swapChainValid = g_swapChain->present(g_backBufferIndex, signalSemaphores, std::size(signalSemaphores)); + presented = g_swapChainValid; } +#ifdef UNLEASHED_RECOMP_IOS + static uint32_t s_presentLogCount = 0; + if (s_presentLogCount < 20 || !g_swapChainValid) + { + IOS_VIDEO_LOG("present #%u submitted=%d valid=%d backBuffer=%u frame=%u next=%u\n", + s_presentLogCount, + presented ? 1 : 0, + g_swapChainValid ? 1 : 0, + g_backBufferIndex, + g_frame, + g_nextFrame); + } + s_presentLogCount++; +#endif + g_pendingWaitOnSwapChain = true; g_frame = g_nextFrame; @@ -2877,6 +3004,31 @@ void Video::Present() } g_presentProfiler.Reset(); +#ifdef UNLEASHED_RECOMP_IOS + static const bool profileEnabled = std::getenv("UNLEASHED_IOS_PROFILE") != nullptr; + if (profileEnabled) + { + static auto reportStart = std::chrono::steady_clock::now(); + static uint32_t frameCount = 0; + static double gpuTime = 0, updateTime = 0, renderTime = 0, fenceTime = 0; + ++frameCount; + gpuTime += g_gpuFrameProfiler.value.load(); + updateTime += g_updateDirectorProfiler.value.load(); + renderTime += g_renderDirectorProfiler.value.load(); + fenceTime += g_frameFenceProfiler.value.load(); + const auto now = std::chrono::steady_clock::now(); + const double seconds = std::chrono::duration(now - reportStart).count(); + if (seconds >= 5.0) + { + IOS_VIDEO_LOG("profile fps=%.1f gpu=%.2fms update=%.2fms render=%.2fms fence=%.2fms scale=%.3f\n", + frameCount / seconds, gpuTime / frameCount, updateTime / frameCount, + renderTime / frameCount, fenceTime / frameCount, GetEffectiveResolutionScale()); + reportStart = now; + frameCount = 0; + gpuTime = updateTime = renderTime = fenceTime = 0; + } + } +#endif } void Video::StartPipelinePrecompilation() @@ -2888,10 +3040,14 @@ static void SetRootDescriptor(const UploadAllocation& allocation, size_t index) { auto& commandList = g_commandLists[g_frame]; +#if defined(UNLEASHED_RECOMP_METAL) + commandList->setGraphicsRootDescriptor(allocation.buffer->at(allocation.offset), index); +#else if (g_vulkan) commandList->setGraphicsPushConstants(0, &allocation.deviceAddress, 8 * index, 8); else commandList->setGraphicsRootDescriptor(allocation.buffer->at(allocation.offset), index); +#endif } static void ProcExecuteCommandList(const RenderCommand& cmd) @@ -5205,7 +5361,7 @@ static void ProcSetPixelShader(const RenderCommand& cmd) { if (g_aspectRatio >= WIDE_ASPECT_RATIO) { - size_t height = round(Video::s_viewportHeight * Config::ResolutionScale); + size_t height = round(Video::s_viewportHeight * GetEffectiveResolutionScale()); if (height > 1440) shaderIndex = GAUSSIAN_BLUR_9X9; @@ -5219,7 +5375,7 @@ static void ProcSetPixelShader(const RenderCommand& cmd) else { // Narrow aspect ratios should check for width to account for VERT+. - size_t width = round(Video::s_viewportWidth * Config::ResolutionScale); + size_t width = round(Video::s_viewportWidth * GetEffectiveResolutionScale()); if (width > 2560) shaderIndex = GAUSSIAN_BLUR_9X9; @@ -5897,8 +6053,9 @@ static void SetResolution(be* device) { Video::ComputeViewportDimensions(); - uint32_t width = uint32_t(round(Video::s_viewportWidth * Config::ResolutionScale)); - uint32_t height = uint32_t(round(Video::s_viewportHeight * Config::ResolutionScale)); + const float resolutionScale = GetEffectiveResolutionScale(); + uint32_t width = uint32_t(round(Video::s_viewportWidth * resolutionScale)); + uint32_t height = uint32_t(round(Video::s_viewportHeight * resolutionScale)); device[46] = width == 0 ? 880 : width; device[47] = height == 0 ? 720 : height; } @@ -6944,16 +7101,20 @@ static std::thread::id g_mainThreadId = std::this_thread::get_id(); PPC_FUNC_IMPL(__imp__sub_825369A0); PPC_FUNC(sub_825369A0) { +#ifndef UNLEASHED_RECOMP_IOS assert(std::this_thread::get_id() == g_mainThreadId); +#endif // Wait for pipeline compilations to finish. uint32_t value; while ((value = g_compilingPipelineTaskCount.load()) != 0) { +#ifndef UNLEASHED_RECOMP_IOS // Pump SDL events to prevent the OS // from thinking the process is unresponsive. SDL_PumpEvents(); SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT); +#endif g_compilingPipelineTaskCount.wait(value); } diff --git a/UnleashedRecomp/gpu/video.h b/UnleashedRecomp/gpu/video.h index 6c24d3014..beb044235 100644 --- a/UnleashedRecomp/gpu/video.h +++ b/UnleashedRecomp/gpu/video.h @@ -24,6 +24,9 @@ struct Video static void StartPipelinePrecompilation(); static void WaitForGPU(); static void ComputeViewportDimensions(); + + static inline uint32_t s_drawableWidth{}; + static inline uint32_t s_drawableHeight{}; }; struct GuestSamplerState diff --git a/UnleashedRecomp/hid/driver/sdl_hid.cpp b/UnleashedRecomp/hid/driver/sdl_hid.cpp index 762355242..bf705adfc 100644 --- a/UnleashedRecomp/hid/driver/sdl_hid.cpp +++ b/UnleashedRecomp/hid/driver/sdl_hid.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -339,6 +340,10 @@ void hid::Init() SDL_AddEventWatch(HID_OnSDLEvent, nullptr); SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER); + +#ifdef UNLEASHED_RECOMP_IOS + TouchControls::Init(); +#endif } uint32_t hid::GetState(uint32_t dwUserIndex, XAMINPUT_STATE* pState) @@ -352,6 +357,14 @@ uint32_t hid::GetState(uint32_t dwUserIndex, XAMINPUT_STATE* pState) pState->dwPacketNumber = packet++; +#ifdef UNLEASHED_RECOMP_IOS + if (TouchControls::IsActive() && TouchControls::IsEnabled()) + { + pState->Gamepad = TouchControls::GetState(); + return ERROR_SUCCESS; + } +#endif + if (!g_activeController) return ERROR_DEVICE_NOT_CONNECTED; diff --git a/UnleashedRecomp/hid/hid.cpp b/UnleashedRecomp/hid/hid.cpp index 900de327f..7842edb5d 100644 --- a/UnleashedRecomp/hid/hid.cpp +++ b/UnleashedRecomp/hid/hid.cpp @@ -1,5 +1,7 @@ #include "hid.h" #include +#include +#include #include hid::EInputDevice hid::g_inputDevice; @@ -19,6 +21,9 @@ void hid::SetProhibitedInputs(uint16_t wButtons, bool leftStick, bool rightStick bool hid::IsInputAllowed() { + if (InstallerWizard::s_isVisible || MessageWindow::s_isVisible) + return true; + return GameWindow::s_isFocused || Config::AllowBackgroundInput; } diff --git a/UnleashedRecomp/install/directory_file_system.h b/UnleashedRecomp/install/directory_file_system.h index a246351d0..867bf566b 100644 --- a/UnleashedRecomp/install/directory_file_system.h +++ b/UnleashedRecomp/install/directory_file_system.h @@ -29,6 +29,19 @@ struct DirectoryFileSystem : VirtualFileSystem } } + bool read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const override + { + std::ifstream fileStream(directoryPath / std::filesystem::path(std::u8string_view((const char8_t *)(path.c_str()))), std::ios::binary); + if (!fileStream.is_open()) + { + return false; + } + + fileStream.seekg(offset, std::ios::beg); + fileStream.read(reinterpret_cast(fileData), size); + return !fileStream.fail() && static_cast(fileStream.gcount()) == size; + } + size_t getSize(const std::string &path) const override { std::error_code ec; diff --git a/UnleashedRecomp/install/installer.cpp b/UnleashedRecomp/install/installer.cpp index 774a141e1..c06ea5712 100644 --- a/UnleashedRecomp/install/installer.cpp +++ b/UnleashedRecomp/install/installer.cpp @@ -134,7 +134,7 @@ static bool checkFile(const FilePair &pair, const uint64_t *fileHashes, const st return true; } -static bool copyFile(const FilePair &pair, const uint64_t *fileHashes, VirtualFileSystem &sourceVfs, const std::filesystem::path &targetDirectory, bool skipHashChecks, std::vector &fileData, Journal &journal, const std::function &progressCallback) { +static bool copyFile(const FilePair &pair, const uint64_t *fileHashes, VirtualFileSystem &sourceVfs, const std::filesystem::path &targetDirectory, bool skipHashChecks, Journal &journal, const std::function &progressCallback) { const std::string filename(pair.first); const uint32_t hashCount = pair.second; if (!sourceVfs.exists(filename)) @@ -144,30 +144,14 @@ static bool copyFile(const FilePair &pair, const uint64_t *fileHashes, VirtualFi return false; } - if (!sourceVfs.load(filename, fileData)) + const size_t fileSize = sourceVfs.getSize(filename); + if (fileSize == 0) { journal.lastResult = Journal::Result::FileReadFailed; journal.lastErrorMessage = fmt::format("Failed to read file {} from {}.", filename, sourceVfs.getName()); return false; } - if (!skipHashChecks) - { - uint64_t fileHash = XXH3_64bits(fileData.data(), fileData.size()); - bool fileHashFound = false; - for (uint32_t i = 0; i < hashCount && !fileHashFound; i++) - { - fileHashFound = fileHash == fileHashes[i]; - } - - if (!fileHashFound) - { - journal.lastResult = Journal::Result::FileHashFailed; - journal.lastErrorMessage = fmt::format("File {} from {} did not match any of the known hashes.", filename, sourceVfs.getName()); - return false; - } - } - std::filesystem::path targetPath = targetDirectory / std::filesystem::path(std::u8string_view((const char8_t *)(pair.first))); std::filesystem::path parentPath = targetPath.parent_path(); if (!std::filesystem::exists(parentPath)) @@ -197,15 +181,59 @@ static bool copyFile(const FilePair &pair, const uint64_t *fileHashes, VirtualFi journal.createdFiles.push_back(targetPath); - outStream.write((const char *)(fileData.data()), fileData.size()); - if (outStream.bad()) + static constexpr size_t CopyChunkSize = 4 * 1024 * 1024; + std::vector chunk(std::min(fileSize, CopyChunkSize)); + XXH3_state_t hashState; + if (!skipHashChecks) { - journal.lastResult = Journal::Result::FileWriteFailed; - journal.lastErrorMessage = fmt::format("Failed to create file at {}.", fromPath(targetPath)); - return false; + XXH3_64bits_reset(&hashState); } - journal.progressCounter += fileData.size(); + size_t offset = 0; + while (offset < fileSize) + { + const size_t bytesToRead = std::min(CopyChunkSize, fileSize - offset); + if (!sourceVfs.read(filename, offset, chunk.data(), bytesToRead)) + { + journal.lastResult = Journal::Result::FileReadFailed; + journal.lastErrorMessage = fmt::format("Failed to read file {} from {}.", filename, sourceVfs.getName()); + return false; + } + + if (!skipHashChecks) + { + XXH3_64bits_update(&hashState, chunk.data(), bytesToRead); + } + + outStream.write(reinterpret_cast(chunk.data()), bytesToRead); + if (outStream.bad()) + { + journal.lastResult = Journal::Result::FileWriteFailed; + journal.lastErrorMessage = fmt::format("Failed to create file at {}.", fromPath(targetPath)); + return false; + } + + offset += bytesToRead; + } + + if (!skipHashChecks) + { + const uint64_t fileHash = XXH3_64bits_digest(&hashState); + bool fileHashFound = false; + for (uint32_t i = 0; i < hashCount && !fileHashFound; i++) + { + fileHashFound = fileHash == fileHashes[i]; + } + + if (!fileHashFound) + { + journal.lastResult = Journal::Result::FileHashFailed; + journal.lastErrorMessage = fmt::format("File {} from {} did not match any of the known hashes.", filename, sourceVfs.getName()); + return false; + } + } + + journal.progressCounter += fileSize; if (!progressCallback()) { @@ -447,7 +475,6 @@ bool Installer::copyFiles(std::span filePairs, const uint64_t *f uint32_t validationHashIndex = 0; uint32_t hashIndex = 0; uint32_t hashCount = 0; - std::vector fileData; for (FilePair pair : filePairs) { hashIndex = hashCount; @@ -460,7 +487,7 @@ bool Installer::copyFiles(std::span filePairs, const uint64_t *f continue; } - if (!copyFile(pair, &fileHashes[hashIndex], sourceVfs, targetDirectory, skipHashChecks, fileData, journal, progressCallback)) + if (!copyFile(pair, &fileHashes[hashIndex], sourceVfs, targetDirectory, skipHashChecks, journal, progressCallback)) { return false; } @@ -469,7 +496,7 @@ bool Installer::copyFiles(std::span filePairs, const uint64_t *f // Validation file is copied last after all other files have been copied. if (validationPair.first != nullptr) { - if (!copyFile(validationPair, &fileHashes[validationHashIndex], sourceVfs, targetDirectory, skipHashChecks, fileData, journal, progressCallback)) + if (!copyFile(validationPair, &fileHashes[validationHashIndex], sourceVfs, targetDirectory, skipHashChecks, journal, progressCallback)) { return false; } @@ -565,7 +592,7 @@ bool Installer::parseSources(const Input &input, Journal &journal, Sources &sour return true; } -bool Installer::install(const Sources &sources, const std::filesystem::path &targetDirectory, bool skipHashChecks, Journal &journal, std::chrono::seconds endWaitTime, const std::function &progressCallback) +bool Installer::install(Sources &sources, const std::filesystem::path &targetDirectory, bool skipHashChecks, Journal &journal, std::chrono::seconds endWaitTime, const std::function &progressCallback) { // Install files in reverse order of importance. In case of a process crash or power outage, this will increase the likelihood of the installation // missing critical files required for the game to run. These files are used as the way to detect if the game is installed. @@ -576,14 +603,18 @@ bool Installer::install(const Sources &sources, const std::filesystem::path &tar journal.createdDirectories.insert(targetDirectory / DLCDirectory); } - for (const DLCSource &dlcSource : sources.dlc) + for (DLCSource &dlcSource : sources.dlc) { if (!copyFiles(dlcSource.filePairs, dlcSource.fileHashes, *dlcSource.sourceVfs, targetDirectory / dlcSource.targetSubDirectory, DLCValidationFile, skipHashChecks, journal, progressCallback)) { return false; } + + dlcSource.sourceVfs.reset(); } + sources.dlc.clear(); + // If no game or update was specified, we're finished. This means the user was only installing the DLC. if ((sources.game == nullptr) && (sources.update == nullptr)) { @@ -596,12 +627,16 @@ bool Installer::install(const Sources &sources, const std::filesystem::path &tar return false; } + sources.update.reset(); + // Install the base game. if (!copyFiles({ GameFiles, GameFilesSize }, GameHashes, *sources.game, targetDirectory / GameDirectory, GameExecutableFile, skipHashChecks, journal, progressCallback)) { return false; } + sources.game.reset(); + // Create the directory where the patched executable will be stored. std::error_code ec; std::filesystem::path patchedDirectory = targetDirectory / PatchedDirectory; diff --git a/UnleashedRecomp/install/installer.h b/UnleashedRecomp/install/installer.h index 8bc047770..b39c583fe 100644 --- a/UnleashedRecomp/install/installer.h +++ b/UnleashedRecomp/install/installer.h @@ -81,7 +81,7 @@ struct Installer static bool copyFiles(std::span filePairs, const uint64_t *fileHashes, VirtualFileSystem &sourceVfs, const std::filesystem::path &targetDirectory, const std::string &validationFile, bool skipHashChecks, Journal &journal, const std::function &progressCallback); static bool parseContent(const std::filesystem::path &sourcePath, std::unique_ptr &targetVfs, Journal &journal); static bool parseSources(const Input &input, Journal &journal, Sources &sources); - static bool install(const Sources &sources, const std::filesystem::path &targetDirectory, bool skipHashChecks, Journal &journal, std::chrono::seconds endWaitTime, const std::function &progressCallback); + static bool install(Sources &sources, const std::filesystem::path &targetDirectory, bool skipHashChecks, Journal &journal, std::chrono::seconds endWaitTime, const std::function &progressCallback); static void rollback(Journal &journal); // Convenience method for checking if the specified file contains the game. This should be used when the user selects the file. diff --git a/UnleashedRecomp/install/iso_file_system.cpp b/UnleashedRecomp/install/iso_file_system.cpp index b0a89ef61..b75b5c03d 100644 --- a/UnleashedRecomp/install/iso_file_system.cpp +++ b/UnleashedRecomp/install/iso_file_system.cpp @@ -23,22 +23,31 @@ ISOFileSystem::ISOFileSystem(const std::filesystem::path &isoPath) name = (const char *)(isoPath.filename().u8string().data()); + auto readBytes = [this](size_t offset, void *dest, size_t size) -> bool + { + return mappedFile.readAt(offset, dest, size); + }; + // Find root sector. - const uint8_t *mappedFileData = mappedFile.data(); uint32_t gameOffset = 0; const size_t XeSectorSize = 2048; static const size_t PossibleOffsets[] = { 0x00000000, 0x0000FB20, 0x00020600, 0x02080000, 0x0FD90000, }; bool magicFound = false; - const char RefMagic[] = "MICROSOFT*XBOX*MEDIA"; + static constexpr char RefMagic[] = "MICROSOFT*XBOX*MEDIA"; + static constexpr size_t RefMagicSize = sizeof(RefMagic) - 1; for (size_t i = 0; i < std::size(PossibleOffsets); i++) { size_t fileOffset = PossibleOffsets[i] + (32 * XeSectorSize); - if ((fileOffset + strlen(RefMagic)) > mappedFile.size()) + if ((fileOffset + RefMagicSize) > mappedFile.size()) { continue; } - if (std::memcmp(&mappedFileData[fileOffset], RefMagic, strlen(RefMagic)) == 0) + char magic[RefMagicSize]; + if (!readBytes(fileOffset, magic, sizeof(magic))) + continue; + + if (std::memcmp(magic, RefMagic, RefMagicSize) == 0) { gameOffset = PossibleOffsets[i]; magicFound = true; @@ -53,8 +62,15 @@ ISOFileSystem::ISOFileSystem(const std::filesystem::path &isoPath) } // Parse root information. - uint32_t rootSector = *(uint32_t *)(&mappedFileData[rootInfoOffset + 0]); - uint32_t rootSize = *(uint32_t *)(&mappedFileData[rootInfoOffset + 4]); + uint32_t rootSector = 0; + uint32_t rootSize = 0; + if (!readBytes(rootInfoOffset + 0, &rootSector, sizeof(rootSector)) || + !readBytes(rootInfoOffset + 4, &rootSize, sizeof(rootSize))) + { + mappedFile.close(); + return; + } + size_t rootOffset = gameOffset + (rootSector * XeSectorSize); const uint32_t MinRootSize = 13; const uint32_t MaxRootSize = 32 * 1024 * 1024; @@ -95,12 +111,16 @@ ISOFileSystem::ISOFileSystem(const std::filesystem::path &isoPath) return; } - nodeL = *(uint16_t *)(&mappedFileData[infoOffset + 0]); - nodeR = *(uint16_t *)(&mappedFileData[infoOffset + 2]); - sector = *(uint32_t *)(&mappedFileData[infoOffset + 4]); - length = *(uint32_t *)(&mappedFileData[infoOffset + 8]); - attributes = *(uint8_t *)(&mappedFileData[infoOffset + 12]); - nameLength = *(uint8_t *)(&mappedFileData[infoOffset + 13]); + if (!readBytes(infoOffset + 0, &nodeL, sizeof(nodeL)) || + !readBytes(infoOffset + 2, &nodeR, sizeof(nodeR)) || + !readBytes(infoOffset + 4, §or, sizeof(sector)) || + !readBytes(infoOffset + 8, &length, sizeof(length)) || + !readBytes(infoOffset + 12, &attributes, sizeof(attributes)) || + !readBytes(infoOffset + 13, &nameLength, sizeof(nameLength))) + { + mappedFile.close(); + return; + } size_t nameOffset = infoOffset + 14; if ((nameOffset + nameLength) > mappedFile.size()) @@ -109,7 +129,12 @@ ISOFileSystem::ISOFileSystem(const std::filesystem::path &isoPath) return; } - memcpy(fileName, &mappedFileData[nameOffset], nameLength); + if (!readBytes(nameOffset, fileName, nameLength)) + { + mappedFile.close(); + return; + } + fileName[nameLength] = '\0'; if (nodeL) @@ -147,9 +172,7 @@ bool ISOFileSystem::load(const std::string &path, uint8_t *fileData, size_t file return false; } - const uint8_t *mappedFileData = mappedFile.data(); - memcpy(fileData, &mappedFileData[std::get<0>(it->second)], std::get<1>(it->second)); - return true; + return mappedFile.readAt(std::get<0>(it->second), fileData, std::get<1>(it->second)); } else { @@ -157,6 +180,24 @@ bool ISOFileSystem::load(const std::string &path, uint8_t *fileData, size_t file } } +bool ISOFileSystem::read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const +{ + auto it = fileMap.find(path); + if (it == fileMap.end()) + { + return false; + } + + size_t fileOffset = std::get<0>(it->second); + size_t fileSize = std::get<1>(it->second); + if (offset + size > fileSize) + { + return false; + } + + return mappedFile.readAt(fileOffset + offset, fileData, size); +} + size_t ISOFileSystem::getSize(const std::string &path) const { auto it = fileMap.find(path); diff --git a/UnleashedRecomp/install/iso_file_system.h b/UnleashedRecomp/install/iso_file_system.h index dceca0779..424f7c611 100644 --- a/UnleashedRecomp/install/iso_file_system.h +++ b/UnleashedRecomp/install/iso_file_system.h @@ -26,6 +26,7 @@ struct ISOFileSystem : VirtualFileSystem ISOFileSystem(const std::filesystem::path &isoPath); bool load(const std::string &path, uint8_t *fileData, size_t fileDataMaxByteCount) const override; + bool read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const override; size_t getSize(const std::string &path) const override; bool exists(const std::string &path) const override; const std::string &getName() const override; diff --git a/UnleashedRecomp/install/update_checker.cpp b/UnleashedRecomp/install/update_checker.cpp index 2a6f5b1f3..6e08de0ea 100644 --- a/UnleashedRecomp/install/update_checker.cpp +++ b/UnleashedRecomp/install/update_checker.cpp @@ -166,9 +166,11 @@ void UpdateChecker::visitWebsite() #elif defined(__linux__) std::string command = "xdg-open " + std::string(VISIT_URL) + " &"; std::system(command.c_str()); -#elif defined(__APPLE__) +#elif defined(__APPLE__) && !defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) std::string command = "open " + std::string(VISIT_URL) + " &"; std::system(command.c_str()); +#elif defined(__APPLE__) + // iOS apps cannot spawn external commands; leave website navigation to platform UI. #else static_assert(false, "Visit website not implemented for this platform."); #endif diff --git a/UnleashedRecomp/install/virtual_file_system.h b/UnleashedRecomp/install/virtual_file_system.h index 079f16ee6..da700c36c 100644 --- a/UnleashedRecomp/install/virtual_file_system.h +++ b/UnleashedRecomp/install/virtual_file_system.h @@ -6,6 +6,7 @@ struct VirtualFileSystem { virtual ~VirtualFileSystem() { }; virtual bool load(const std::string &path, uint8_t *fileData, size_t fileDataMaxByteCount) const = 0; + virtual bool read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const = 0; virtual size_t getSize(const std::string &path) const = 0; virtual bool exists(const std::string &path) const = 0; virtual const std::string &getName() const = 0; diff --git a/UnleashedRecomp/install/xcontent_file_system.cpp b/UnleashedRecomp/install/xcontent_file_system.cpp index 68605fbd5..a4777ec92 100644 --- a/UnleashedRecomp/install/xcontent_file_system.cpp +++ b/UnleashedRecomp/install/xcontent_file_system.cpp @@ -13,8 +13,11 @@ #include "xcontent_file_system.h" #include +#include +#include #include #include +#include enum class XContentPackageType { @@ -214,11 +217,12 @@ size_t blockIndexToHashBlockOffset(uint64_t baseOffset, uint32_t blockIndex) return baseOffset + (blockNumber << 12); } -const StfsHashEntry *hashEntryFromBlockIndex(const uint8_t *fileData, uint64_t baseOffset, uint64_t blockIndex) +static bool readHashEntry(const MemoryMappedFile &file, uint64_t baseOffset, uint64_t blockIndex, StfsHashEntry &outEntry) { size_t hashOffset = blockIndexToHashBlockOffset(baseOffset, blockIndex); - const StfsHashTable *hashTable = (const StfsHashTable *)(&fileData[hashOffset]); - return &hashTable->entries[blockIndex % StfsBlocksPerHashLevel[0]]; + size_t entryIndex = blockIndex % StfsBlocksPerHashLevel[0]; + size_t entryOffset = hashOffset + offsetof(StfsHashTable, entries) + entryIndex * sizeof(StfsHashEntry); + return file.readAt(entryOffset, &outEntry, sizeof(outEntry)); } void blockToOffsetAndFile(SvodLayoutType svodLayoutType, size_t svodStartDataBlock, size_t svodBaseOffset, size_t block, size_t &outOffset, size_t &outFileIndex) @@ -271,14 +275,20 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) name = (const char *)(contentPath.filename().u8string().data()); - const uint8_t *rootMappedFileData = rootMappedFile.data(); if (sizeof(XContentContainerHeader) > rootMappedFile.size()) { mappedFiles.clear(); return; } - XContentContainerHeader contentContainerHeader = *(const XContentContainerHeader *)(rootMappedFileData); + alignas(XContentContainerHeader) uint8_t headerBuffer[sizeof(XContentContainerHeader)]; + if (!rootMappedFile.readAt(0, headerBuffer, sizeof(headerBuffer))) + { + mappedFiles.clear(); + return; + } + + const XContentContainerHeader &contentContainerHeader = *reinterpret_cast(headerBuffer); XContentPackageType packageType = XContentPackageType(contentContainerHeader.contentHeader.magic.get()); if (packageType != XContentPackageType::CON && packageType != XContentPackageType::LIVE && packageType != XContentPackageType::PIRS) { @@ -312,10 +322,16 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) return; } - StfsDirectoryBlock *directoryBlock = (StfsDirectoryBlock *)(&rootMappedFileData[offset]); + StfsDirectoryBlock directoryBlock; + if (!rootMappedFile.readAt(offset, &directoryBlock, sizeof(directoryBlock))) + { + mappedFiles.clear(); + return; + } + for (uint32_t j = 0; j < StfsEntriesPerDirectoryBlock; j++) { - const StfsDirectoryEntry &directoryEntry = directoryBlock->entries[j]; + const StfsDirectoryEntry &directoryEntry = directoryBlock.entries[j]; if (directoryEntry.name[0] == '\0') { break; @@ -335,8 +351,14 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) entryCount++; } - const StfsHashEntry *hashEntry = hashEntryFromBlockIndex(rootMappedFileData, baseOffset, tableBlockIndex); - tableBlockIndex = hashEntry->infoRaw & 0xFFFFFF; + StfsHashEntry hashEntry; + if (!readHashEntry(rootMappedFile, baseOffset, tableBlockIndex, hashEntry)) + { + mappedFiles.clear(); + return; + } + + tableBlockIndex = hashEntry.infoRaw.get() & 0xFFFFFF; if (tableBlockIndex == StfsEndOfChain) { break; @@ -384,14 +406,26 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) // Determine the layout of the SVOD from the first file. MemoryMappedFile &firstMappedFile = mappedFiles.front(); - const uint8_t *firstMappedFileData = firstMappedFile.data(); const char *RefMagic = "MICROSOFT*XBOX*MEDIA"; + static constexpr size_t RefMagicSize = 20; size_t RefXSFMagicOffset = 0x12000; size_t SingleFileMagicOffset = 0xD000; + + auto readMagicAt = [&](size_t offset) -> bool + { + if (offset + RefMagicSize > firstMappedFile.size()) + { + return false; + } + + char magic[RefMagicSize]; + return firstMappedFile.readAt(offset, magic, sizeof(magic)) && std::memcmp(magic, RefMagic, RefMagicSize) == 0; + }; + if (metadata.svodDeviceDescriptor.features.bits.enhancedGdfLayout) { size_t EGDFMagicOffset = 0x2000; - if (EGDFMagicOffset >= firstMappedFile.size() || std::memcmp(&firstMappedFileData[EGDFMagicOffset], RefMagic, strlen(RefMagic)) != 0) + if (!readMagicAt(EGDFMagicOffset)) { mappedFiles.clear(); return; @@ -401,14 +435,15 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) svodMagicOffset = EGDFMagicOffset; svodLayoutType = SvodLayoutType::EnhancedGDF; } - else if (RefXSFMagicOffset < firstMappedFile.size() && std::memcmp(&firstMappedFileData[RefXSFMagicOffset], RefMagic, strlen(RefMagic)) == 0) + else if (readMagicAt(RefXSFMagicOffset)) { const char *XSFMagic = "XSF"; size_t XSFMagicOffset = 0x2000; svodBaseOffset = 0x10000; svodMagicOffset = 0x12000; - if (std::memcmp(&firstMappedFileData[XSFMagicOffset], XSFMagic, strlen(XSFMagic)) == 0) + char xsfMagic[4]; + if (firstMappedFile.readAt(XSFMagicOffset, xsfMagic, 3) && std::memcmp(xsfMagic, XSFMagic, 3) == 0) { svodLayoutType = SvodLayoutType::XSF; } @@ -417,7 +452,7 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) svodLayoutType = SvodLayoutType::Unknown; } } - else if (SingleFileMagicOffset < firstMappedFile.size() && std::memcmp(&firstMappedFileData[SingleFileMagicOffset], RefMagic, strlen(RefMagic)) == 0) + else if (readMagicAt(SingleFileMagicOffset)) { svodBaseOffset = 0xB000; svodMagicOffset = 0xD000; @@ -441,7 +476,12 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) }; std::stack iterationStack; - uint32_t rootBlock = *(uint32_t *)(&firstMappedFileData[svodMagicOffset + 0x14]); + uint32_t rootBlock = 0; + if (!firstMappedFile.readAt(svodMagicOffset + 0x14, &rootBlock, sizeof(rootBlock))) + { + mappedFiles.clear(); + return; + } iterationStack.emplace("", rootBlock, 0); IterationStep step; @@ -471,39 +511,48 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) return; } - const uint8_t *mappedFileData = mappedFile.data(); - const SvodDirectoryEntry *directoryEntry = (const SvodDirectoryEntry *)(&mappedFileData[fileOffset]); + SvodDirectoryEntry directoryEntry; + if (!mappedFile.readAt(fileOffset, &directoryEntry, sizeof(directoryEntry))) + { + mappedFiles.clear(); + return; + } + size_t nameOffset = fileOffset + sizeof(SvodDirectoryEntry); - if ((nameOffset + directoryEntry->nameLength) > mappedFile.size()) + if ((nameOffset + directoryEntry.nameLength) > mappedFile.size()) { mappedFiles.clear(); return; } - memcpy(fileName, &mappedFileData[nameOffset], directoryEntry->nameLength); - fileName[directoryEntry->nameLength] = '\0'; + if (!mappedFile.readAt(nameOffset, fileName, directoryEntry.nameLength)) + { + mappedFiles.clear(); + return; + } + fileName[directoryEntry.nameLength] = '\0'; - if (directoryEntry->nodeL) + if (directoryEntry.nodeL) { - iterationStack.emplace(step.fileNameBase, step.blockIndex, directoryEntry->nodeL); + iterationStack.emplace(step.fileNameBase, step.blockIndex, directoryEntry.nodeL); } - if (directoryEntry->nodeR) + if (directoryEntry.nodeR) { - iterationStack.emplace(step.fileNameBase, step.blockIndex, directoryEntry->nodeR); + iterationStack.emplace(step.fileNameBase, step.blockIndex, directoryEntry.nodeR); } std::string fileNameUTF8 = step.fileNameBase + fileName; - if (directoryEntry->attributes & FileAttributeDirectory) + if (directoryEntry.attributes & FileAttributeDirectory) { - if (directoryEntry->length > 0) + if (directoryEntry.length > 0) { - iterationStack.emplace(fileNameUTF8 + "/", directoryEntry->dataBlock, 0); + iterationStack.emplace(fileNameUTF8 + "/", directoryEntry.dataBlock, 0); } } else { - fileMap[fileNameUTF8] = { directoryEntry->length, directoryEntry->dataBlock, 0 }; + fileMap[fileNameUTF8] = { directoryEntry.length, directoryEntry.dataBlock, 0 }; } } } @@ -513,6 +562,121 @@ XContentFileSystem::XContentFileSystem(const std::filesystem::path &contentPath) } } +static bool readStfsFileRange(const MemoryMappedFile &rootMappedFile, uint64_t baseOffset, const XContentFileSystem::File &file, size_t offset, uint8_t *fileData, size_t size) +{ + size_t logicalOffset = 0; + uint32_t fileBlockIndex = file.blockIndex; + while (logicalOffset + StfsBlockSize <= offset && fileBlockIndex != StfsEndOfChain) + { + logicalOffset += StfsBlockSize; + StfsHashEntry hashEntry; + if (!readHashEntry(rootMappedFile, baseOffset, fileBlockIndex, hashEntry)) + { + return false; + } + + fileBlockIndex = hashEntry.infoRaw.get() & 0xFFFFFF; + } + + size_t skipInBlock = offset - logicalOffset; + size_t remainingSize = size; + size_t fileDataOffset = 0; + while (remainingSize > 0 && fileBlockIndex != StfsEndOfChain) + { + size_t blockSize = std::min(StfsBlockSize - skipInBlock, remainingSize); + size_t blockOffset = blockIndexToOffset(baseOffset, fileBlockIndex) + skipInBlock; + if (blockOffset + blockSize > rootMappedFile.size()) + { + return false; + } + + if (!rootMappedFile.readAt(blockOffset, &fileData[fileDataOffset], blockSize)) + { + return false; + } + + skipInBlock = 0; + fileDataOffset += blockSize; + remainingSize -= blockSize; + if (remainingSize > 0) + { + StfsHashEntry hashEntry; + if (!readHashEntry(rootMappedFile, baseOffset, fileBlockIndex, hashEntry)) + { + return false; + } + + fileBlockIndex = hashEntry.infoRaw.get() & 0xFFFFFF; + } + } + + return remainingSize == 0; +} + +static bool readSvodFileRange(const std::vector &mappedFiles, SvodLayoutType svodLayoutType, size_t svodStartDataBlock, size_t svodBaseOffset, const XContentFileSystem::File &file, size_t offset, uint8_t *fileData, size_t size) +{ + static constexpr size_t SvodBlockSize = 0x800; + size_t logicalOffset = 0; + size_t currentBlock = file.blockIndex; + while (logicalOffset + SvodBlockSize <= offset) + { + logicalOffset += SvodBlockSize; + currentBlock++; + } + + size_t skipInBlock = offset - logicalOffset; + size_t remainingSize = size; + size_t fileDataOffset = 0; + while (remainingSize > 0) + { + size_t blockFileOffset, blockFileIndex; + blockToOffsetAndFile(svodLayoutType, svodStartDataBlock, svodBaseOffset, currentBlock, blockFileOffset, blockFileIndex); + if (blockFileIndex >= mappedFiles.size()) + { + return false; + } + + const MemoryMappedFile &mappedFile = mappedFiles[blockFileIndex]; + size_t blockSize = std::min(SvodBlockSize - skipInBlock, remainingSize); + if (blockFileOffset + skipInBlock + blockSize > mappedFile.size()) + { + return false; + } + + if (!mappedFile.readAt(blockFileOffset + skipInBlock, &fileData[fileDataOffset], blockSize)) + { + return false; + } + + skipInBlock = 0; + fileDataOffset += blockSize; + remainingSize -= blockSize; + currentBlock++; + } + + return true; +} + +bool XContentFileSystem::read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const +{ + auto it = fileMap.find(path); + if (it == fileMap.end() || offset + size > it->second.size) + { + return false; + } + + if (volumeType == XContentVolumeType::STFS) + { + return readStfsFileRange(mappedFiles.back(), baseOffset, it->second, offset, fileData, size); + } + else if (volumeType == XContentVolumeType::SVOD) + { + return readSvodFileRange(mappedFiles, svodLayoutType, svodStartDataBlock, svodBaseOffset, it->second, offset, fileData, size); + } + + return false; +} + bool XContentFileSystem::load(const std::string &path, uint8_t *fileData, size_t fileDataMaxByteCount) const { auto it = fileMap.find(path); @@ -526,7 +690,6 @@ bool XContentFileSystem::load(const std::string &path, uint8_t *fileData, size_t if (volumeType == XContentVolumeType::STFS) { const MemoryMappedFile &rootMappedFile = mappedFiles.back(); - const uint8_t *rootMappedFileData = rootMappedFile.data(); size_t fileDataOffset = 0; size_t remainingSize = it->second.size; uint32_t fileBlockIndex = it->second.blockIndex; @@ -539,10 +702,18 @@ bool XContentFileSystem::load(const std::string &path, uint8_t *fileData, size_t return false; } - memcpy(&fileData[fileDataOffset], &rootMappedFileData[blockOffset], blockSize); + if (!rootMappedFile.readAt(blockOffset, &fileData[fileDataOffset], blockSize)) + { + return false; + } - const StfsHashEntry *hashEntry = hashEntryFromBlockIndex(rootMappedFileData, baseOffset, fileBlockIndex); - fileBlockIndex = hashEntry->infoRaw & 0xFFFFFF; + StfsHashEntry hashEntry; + if (!readHashEntry(rootMappedFile, baseOffset, fileBlockIndex, hashEntry)) + { + return false; + } + + fileBlockIndex = hashEntry.infoRaw.get() & 0xFFFFFF; fileDataOffset += blockSize; remainingSize -= blockSize; } @@ -564,14 +735,16 @@ bool XContentFileSystem::load(const std::string &path, uint8_t *fileData, size_t } const MemoryMappedFile &mappedFile = mappedFiles[blockFileIndex]; - const uint8_t *mappedFileData = mappedFile.data(); size_t blockSize = std::min(size_t(0x800), remainingSize); if (blockFileOffset + blockSize > mappedFile.size()) { return false; } - memcpy(&fileData[fileDataOffset], &mappedFileData[blockFileOffset], blockSize); + if (!mappedFile.readAt(blockFileOffset, &fileData[fileDataOffset], blockSize)) + { + return false; + } fileDataOffset += blockSize; remainingSize -= blockSize; diff --git a/UnleashedRecomp/install/xcontent_file_system.h b/UnleashedRecomp/install/xcontent_file_system.h index c776975f6..d383400f5 100644 --- a/UnleashedRecomp/install/xcontent_file_system.h +++ b/UnleashedRecomp/install/xcontent_file_system.h @@ -53,6 +53,7 @@ struct XContentFileSystem : VirtualFileSystem XContentFileSystem(const std::filesystem::path &contentPath); bool load(const std::string &path, uint8_t *fileData, size_t fileDataMaxByteCount) const override; + bool read(const std::string &path, size_t offset, uint8_t *fileData, size_t size) const override; size_t getSize(const std::string &path) const override; bool exists(const std::string &path) const override; const std::string &getName() const override; diff --git a/UnleashedRecomp/kernel/imports.cpp b/UnleashedRecomp/kernel/imports.cpp index 58b33fab9..da7d2694b 100644 --- a/UnleashedRecomp/kernel/imports.cpp +++ b/UnleashedRecomp/kernel/imports.cpp @@ -59,7 +59,8 @@ struct Event final : KernelObject, HostObject while (true) { bool expected = true; - if (signaled.compare_exchange_weak(expected, false)) + // A spurious failure must not put a signaled event to sleep. + if (signaled.compare_exchange_strong(expected, false)) break; signaled.wait(expected); @@ -117,7 +118,7 @@ struct Semaphore final : KernelObject, HostObject uint32_t currentCount = count.load(); if (currentCount != 0) { - if (count.compare_exchange_weak(currentCount, currentCount - 1)) + if (count.compare_exchange_strong(currentCount, currentCount - 1)) return STATUS_SUCCESS; } @@ -699,7 +700,8 @@ void RtlEnterCriticalSection(XRTL_CRITICAL_SECTION* cs) { uint32_t previousOwner = 0; - if (owningThread.compare_exchange_weak(previousOwner, thisThread) || previousOwner == thisThread) + // The following wait must only observe a genuinely occupied lock. + if (owningThread.compare_exchange_strong(previousOwner, thisThread) || previousOwner == thisThread) { cs->RecursionCount++; return; @@ -1207,7 +1209,7 @@ bool RtlTryEnterCriticalSection(XRTL_CRITICAL_SECTION* cs) uint32_t previousOwner = 0; - if (owningThread.compare_exchange_weak(previousOwner, thisThread) || previousOwner == thisThread) + if (owningThread.compare_exchange_strong(previousOwner, thisThread) || previousOwner == thisThread) { cs->RecursionCount++; return true; diff --git a/UnleashedRecomp/kernel/memory.cpp b/UnleashedRecomp/kernel/memory.cpp index a52b7e7a1..91f84c63a 100644 --- a/UnleashedRecomp/kernel/memory.cpp +++ b/UnleashedRecomp/kernel/memory.cpp @@ -17,11 +17,14 @@ Memory::Memory() #else base = (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0); - if (base == (uint8_t*)MAP_FAILED) + if (base == MAP_FAILED) base = (uint8_t*)mmap(NULL, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0); - if (base == nullptr) + if (base == MAP_FAILED) + { + base = nullptr; return; + } mprotect(base, 4096, PROT_NONE); #endif diff --git a/UnleashedRecomp/main.cpp b/UnleashedRecomp/main.cpp index bb241760c..dbf569384 100644 --- a/UnleashedRecomp/main.cpp +++ b/UnleashedRecomp/main.cpp @@ -49,6 +49,12 @@ Heap g_userHeap; XDBFWrapper g_xdbfWrapper; std::unordered_map g_xdbfTextureCache; +static std::string PathToUTF8(const std::filesystem::path& path) +{ + auto pathU8 = path.u8string(); + return std::string(pathU8.begin(), pathU8.end()); +} + void HostStartup() { #ifdef _WIN32 @@ -200,6 +206,14 @@ int main(int argc, char *argv[]) os::process::CheckConsole(); +#ifdef UNLEASHED_RECOMP_IOS + // MoltenVK must convert Vulkan clip coordinates to Metal, including shaders + // compiled with DXC's -fvk-invert-y. + setenv("MVK_CONFIG_SHADER_CONVERSION_FLIP_VERTEX_Y", "1", 1); +#endif + + InitPaths(); + if (!os::registry::Init()) LOGN_WARNING("OS does not support registry."); @@ -246,10 +260,14 @@ int main(int argc, char *argv[]) // Create the console to show progress to the user, otherwise it will seem as if the game didn't boot at all. os::process::ShowConsole(); + const auto installCheckPath = GetGamePath(); + fprintf(stdout, "Checking installation at %s\n", PathToUTF8(installCheckPath).c_str()); + LOGFN("Checking installation at {}", PathToUTF8(installCheckPath)); + Journal journal; double lastProgressMiB = 0.0; double lastTotalMib = 0.0; - Installer::checkInstallIntegrity(GAME_INSTALL_DIRECTORY, journal, [&]() + Installer::checkInstallIntegrity(installCheckPath, journal, [&]() { constexpr double MiBDivisor = 1024.0 * 1024.0; constexpr double MiBProgressThreshold = 128.0; @@ -324,9 +342,27 @@ int main(int argc, char *argv[]) HostStartup(); + const auto gamePath = GetGamePath(); std::filesystem::path modulePath; - bool isGameInstalled = Installer::checkGameInstall(GetGamePath(), modulePath); + LOGFN("Game path: {}", PathToUTF8(gamePath)); + bool isGameInstalled = Installer::checkGameInstall(gamePath, modulePath); + LOGFN("Install status before wizard: installed={}, forceInstaller={}, forceDLCInstaller={}, modulePath={}", + isGameInstalled, + forceInstaller, + forceDLCInstaller, + PathToUTF8(modulePath)); + bool runInstallerWizard = forceInstaller || forceDLCInstaller || !isGameInstalled; + +#ifdef UNLEASHED_RECOMP_IOS + auto modulePathU8 = modulePath.u8string(); + fprintf(stderr, "[iOS startup] installed=%d installer=%d module=%s\n", + isGameInstalled ? 1 : 0, + runInstallerWizard ? 1 : 0, + reinterpret_cast(modulePathU8.c_str())); + fflush(stderr); +#endif + if (runInstallerWizard) { if (!Video::CreateHostDevice(sdlVideoDriver, graphicsApiRetry)) @@ -335,10 +371,28 @@ int main(int argc, char *argv[]) std::_Exit(1); } - if (!InstallerWizard::Run(GetGamePath(), isGameInstalled && forceDLCInstaller)) + if (!InstallerWizard::Run(gamePath, isGameInstalled && forceDLCInstaller)) { + LOGN("Installer wizard cancelled."); std::_Exit(0); } + + isGameInstalled = Installer::checkGameInstall(gamePath, modulePath); + LOGFN("Install status after wizard: installed={}, modulePath={}", isGameInstalled, PathToUTF8(modulePath)); +#ifdef UNLEASHED_RECOMP_IOS + modulePathU8 = modulePath.u8string(); + fprintf(stderr, "[iOS startup] post-installer installed=%d module=%s\n", + isGameInstalled ? 1 : 0, + reinterpret_cast(modulePathU8.c_str())); + fflush(stderr); +#endif + if (!isGameInstalled) + { + const std::string errorMessage = "The game data installation did not finish correctly. Please run the installer again and select the base game ISO plus update file."; + LOGN_ERROR(errorMessage); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, GameWindow::GetTitle(), errorMessage.c_str(), GameWindow::s_pWindow); + std::_Exit(4); + } } ModLoader::Init(); @@ -348,7 +402,21 @@ int main(int argc, char *argv[]) KiSystemStartup(); + LOGFN("Loading module from {}", PathToUTF8(modulePath)); uint32_t entry = LdrLoadModule(modulePath); + if (entry == 0) + { + const std::string errorMessage = "Failed to load the patched game executable. Please run the installer again."; + LOGN_ERROR(errorMessage); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, GameWindow::GetTitle(), errorMessage.c_str(), GameWindow::s_pWindow); + std::_Exit(4); + } + LOGFN("Loaded module entry: 0x{:08X}", entry); + +#ifdef UNLEASHED_RECOMP_IOS + fprintf(stderr, "[iOS startup] loaded module entry=0x%08X\n", entry); + fflush(stderr); +#endif if (!runInstallerWizard) { @@ -359,9 +427,27 @@ int main(int argc, char *argv[]) } } +#ifndef UNLEASHED_RECOMP_IOS Video::StartPipelinePrecompilation(); +#endif + +#ifdef UNLEASHED_RECOMP_IOS + fprintf(stderr, "[iOS startup] starting guest entry=0x%08X\n", entry); + fflush(stderr); + GuestThread::Start({ entry, 0, 0 }, nullptr); + fprintf(stderr, "[iOS startup] entering SDL event pump\n"); + fflush(stderr); + while (true) + { + SDL_PumpEvents(); + SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT); + GameWindow::Update(); + SDL_Delay(1); + } +#else GuestThread::Start({ entry, 0, 0 }); +#endif return 0; } diff --git a/UnleashedRecomp/os/ios/logger_ios.cpp b/UnleashedRecomp/os/ios/logger_ios.cpp new file mode 100644 index 000000000..82740ad92 --- /dev/null +++ b/UnleashedRecomp/os/ios/logger_ios.cpp @@ -0,0 +1,19 @@ +#include + +void os::logger::Init() +{ +} + +void os::logger::Log(const std::string_view str, ELogType type, const char* func) +{ + (void)type; + + if (func) + { + fmt::println("[{}] {}", func, str); + } + else + { + fmt::println("{}", str); + } +} diff --git a/UnleashedRecomp/os/ios/media_ios.cpp b/UnleashedRecomp/os/ios/media_ios.cpp new file mode 100644 index 000000000..5431ede13 --- /dev/null +++ b/UnleashedRecomp/os/ios/media_ios.cpp @@ -0,0 +1,6 @@ +#include + +bool os::media::IsExternalMediaPlaying() +{ + return false; +} diff --git a/UnleashedRecomp/os/ios/process_ios.mm b/UnleashedRecomp/os/ios/process_ios.mm new file mode 100644 index 000000000..7c89b341c --- /dev/null +++ b/UnleashedRecomp/os/ios/process_ios.mm @@ -0,0 +1,62 @@ +#include + +#import + +#include +#include +#include + +std::filesystem::path os::process::GetExecutablePath() +{ + uint32_t exePathSize = PATH_MAX; + char exePath[PATH_MAX] = {}; + if (_NSGetExecutablePath(exePath, &exePathSize) == 0) + { + return std::filesystem::path(std::u8string_view(reinterpret_cast(exePath))); + } + + return {}; +} + +std::filesystem::path os::process::GetExecutableRoot() +{ + @autoreleasepool + { + NSString* resourcePath = [[NSBundle mainBundle] resourcePath]; + if (resourcePath.length > 0) + return std::filesystem::path(std::u8string_view(reinterpret_cast(resourcePath.UTF8String))); + } + + return GetExecutablePath().remove_filename(); +} + +std::filesystem::path os::process::GetWorkingDirectory() +{ + char cwd[PATH_MAX] = {}; + if (getcwd(cwd, sizeof(cwd)) != nullptr) + return std::filesystem::path(std::u8string_view(reinterpret_cast(cwd))); + + return {}; +} + +bool os::process::SetWorkingDirectory(const std::filesystem::path& path) +{ + return chdir(path.c_str()) == 0; +} + +bool os::process::StartProcess(const std::filesystem::path& path, const std::vector& args, std::filesystem::path work) +{ + (void)path; + (void)args; + (void)work; + return false; +} + +void os::process::CheckConsole() +{ + g_consoleVisible = false; +} + +void os::process::ShowConsole() +{ +} diff --git a/UnleashedRecomp/os/ios/user_ios.mm b/UnleashedRecomp/os/ios/user_ios.mm new file mode 100644 index 000000000..d79d9ebe0 --- /dev/null +++ b/UnleashedRecomp/os/ios/user_ios.mm @@ -0,0 +1,11 @@ +#include + +#import + +bool os::user::IsDarkTheme() +{ + if (@available(iOS 13.0, *)) + return UIScreen.mainScreen.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark; + + return false; +} diff --git a/UnleashedRecomp/os/ios/version_ios.mm b/UnleashedRecomp/os/ios/version_ios.mm new file mode 100644 index 000000000..261db3579 --- /dev/null +++ b/UnleashedRecomp/os/ios/version_ios.mm @@ -0,0 +1,15 @@ +#include + +#import + +os::version::OSVersion os::version::GetOSVersion() +{ + os::version::OSVersion result; + + NSOperatingSystemVersion version = NSProcessInfo.processInfo.operatingSystemVersion; + result.Major = static_cast(version.majorVersion); + result.Minor = static_cast(version.minorVersion); + result.Build = static_cast(version.patchVersion); + + return result; +} diff --git a/UnleashedRecomp/res/ios/Info.plist.in b/UnleashedRecomp/res/ios/Info.plist.in new file mode 100644 index 000000000..1cfa70e0c --- /dev/null +++ b/UnleashedRecomp/res/ios/Info.plist.in @@ -0,0 +1,105 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Unleashed Recompiled + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIcons + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon20x20 + AppIcon29x29 + AppIcon40x40 + AppIcon60x60 + + UIPrerenderedIcon + + + + CFBundleIcons~ipad + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon20x20 + AppIcon29x29 + AppIcon40x40 + AppIcon76x76 + AppIcon83.5x83.5 + + UIPrerenderedIcon + + + + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleSupportedPlatforms + + iPhoneOS + + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + MinimumOSVersion + ${UNLEASHED_RECOMP_IOS_MINIMUM_VERSION} + UIFileSharingEnabled + + UIApplicationSupportsIndirectInputEvents + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Unleashed + UISceneDelegateClassName + SDLUIKitSceneDelegate + + + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + metal + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIDeviceFamily + + 1 + 2 + + + diff --git a/UnleashedRecomp/res/ios/icons/AppIcon20x20@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon20x20@2x.png new file mode 100644 index 000000000..ed4dd7f4d Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon20x20@2x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon20x20@3x.png b/UnleashedRecomp/res/ios/icons/AppIcon20x20@3x.png new file mode 100644 index 000000000..57e231c06 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon20x20@3x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon29x29@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon29x29@2x.png new file mode 100644 index 000000000..5fb300d87 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon29x29@2x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon29x29@3x.png b/UnleashedRecomp/res/ios/icons/AppIcon29x29@3x.png new file mode 100644 index 000000000..f9f361586 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon29x29@3x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon40x40@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon40x40@2x.png new file mode 100644 index 000000000..d2d0ccdae Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon40x40@2x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon40x40@3x.png b/UnleashedRecomp/res/ios/icons/AppIcon40x40@3x.png new file mode 100644 index 000000000..b0b9f01c9 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon40x40@3x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon60x60@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon60x60@2x.png new file mode 100644 index 000000000..b0b9f01c9 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon60x60@2x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon60x60@3x.png b/UnleashedRecomp/res/ios/icons/AppIcon60x60@3x.png new file mode 100644 index 000000000..6ce66a0c1 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon60x60@3x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon76x76.png b/UnleashedRecomp/res/ios/icons/AppIcon76x76.png new file mode 100644 index 000000000..8b3d6fb6f Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon76x76.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon76x76@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon76x76@2x.png new file mode 100644 index 000000000..c3ac9798d Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon76x76@2x.png differ diff --git a/UnleashedRecomp/res/ios/icons/AppIcon83.5x83.5@2x.png b/UnleashedRecomp/res/ios/icons/AppIcon83.5x83.5@2x.png new file mode 100644 index 000000000..73bfdc058 Binary files /dev/null and b/UnleashedRecomp/res/ios/icons/AppIcon83.5x83.5@2x.png differ diff --git a/UnleashedRecomp/ui/button_guide.cpp b/UnleashedRecomp/ui/button_guide.cpp index 6db93d58b..6390a6daf 100644 --- a/UnleashedRecomp/ui/button_guide.cpp +++ b/UnleashedRecomp/ui/button_guide.cpp @@ -60,14 +60,14 @@ std::unordered_map g_iconHeights = { EButtonIcon::Escape, 40 }, }; -std::tuple, GuestTexture*> GetButtonIcon(EButtonIcon icon) +std::tuple, GuestTexture*> GetButtonIcon(EButtonIcon icon, bool forceXbox) { std::tuple btn; GuestTexture* texture; - auto isPlayStation = Config::ControllerIcons == EControllerIcons::Auto + auto isPlayStation = !forceXbox && (Config::ControllerIcons == EControllerIcons::Auto ? hid::g_inputDeviceController == hid::EInputDevice::PlayStation - : Config::ControllerIcons == EControllerIcons::PlayStation; + : Config::ControllerIcons == EControllerIcons::PlayStation); auto yOffsetCmn = isPlayStation ? 42 : 0; auto yOffsetStartBack = isPlayStation ? 46 : 0; diff --git a/UnleashedRecomp/ui/button_guide.h b/UnleashedRecomp/ui/button_guide.h index 94e3ae242..cb9d6e819 100644 --- a/UnleashedRecomp/ui/button_guide.h +++ b/UnleashedRecomp/ui/button_guide.h @@ -30,6 +30,8 @@ enum class EButtonAlignment Right }; +std::tuple, GuestTexture*> GetButtonIcon(EButtonIcon icon, bool forceXbox = false); + enum class EFontQuality { Low, diff --git a/UnleashedRecomp/ui/game_window.cpp b/UnleashedRecomp/ui/game_window.cpp index d15aac630..3ff7e6e6e 100644 --- a/UnleashedRecomp/ui/game_window.cpp +++ b/UnleashedRecomp/ui/game_window.cpp @@ -6,6 +6,9 @@ #include #include #include +#if defined(UNLEASHED_RECOMP_IOS) && defined(UNLEASHED_RECOMP_METAL) +#include +#endif #if _WIN32 #include @@ -18,10 +21,71 @@ bool m_isFullscreenKeyReleased = true; bool m_isResizing = false; +#ifdef UNLEASHED_RECOMP_IOS +static std::mutex g_imGuiEventsMutex; +static std::vector g_imGuiEvents; +static SDL_threadID g_uiThreadId; + +void GameWindow::WaitUntilActive() +{ + while (!s_isActive.load()) + { + if (SDL_ThreadID() == g_uiThreadId) + { + SDL_PumpEvents(); + SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT); + SDL_Delay(10); + } + else + { + s_isActive.wait(false); + } + } +} + +void GameWindow::ProcessImGuiEvents() +{ + std::vector events; + { + std::lock_guard lock(g_imGuiEventsMutex); + events.swap(g_imGuiEvents); + } + for (const auto& event : events) + ImGui_ImplSDL2_ProcessEvent(&event); +} +#endif + int Window_OnSDLEvent(void*, SDL_Event* event) { +#ifdef UNLEASHED_RECOMP_IOS + // Lifecycle events must not be swallowed by an installer/dialog listener. + if (event->type == SDL_APP_WILLENTERBACKGROUND) + { + GameWindow::s_isActive = false; + GameWindow::s_isFocused = false; +#ifdef UNLEASHED_RECOMP_METAL + plume::setMetalApplicationActive(false); +#endif + } + else if (event->type == SDL_APP_DIDENTERFOREGROUND) + { +#ifdef UNLEASHED_RECOMP_METAL + plume::setMetalApplicationActive(true); +#endif + GameWindow::s_isFocused = true; + GameWindow::s_isActive = true; + GameWindow::s_isActive.notify_all(); + } + + // UIKit pumps on the main thread; ImGui is owned by the presenting thread. + { + std::lock_guard lock(g_imGuiEventsMutex); + g_imGuiEvents.push_back(*event); + } +#else if (ImGui::GetIO().BackendPlatformUserData != nullptr) ImGui_ImplSDL2_ProcessEvent(event); +#endif for (auto listener : GetEventListeners()) { @@ -157,10 +221,18 @@ int Window_OnSDLEvent(void*, SDL_Event* event) void GameWindow::Init(const char* sdlVideoDriver) { +#ifdef UNLEASHED_RECOMP_IOS + g_uiThreadId = SDL_ThreadID(); +#endif #ifdef __linux__ SDL_SetHint("SDL_APP_ID", "io.github.hedge_dev.unleashedrecomp"); #endif +#ifdef UNLEASHED_RECOMP_IOS + SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "1"); + SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"); +#endif + if (SDL_VideoInit(sdlVideoDriver) != 0 && sdlVideoDriver) { LOGFN_ERROR("Failed to initialise the SDL video driver: \"{}\". Falling back to default.", sdlVideoDriver); @@ -192,6 +264,10 @@ void GameWindow::Init(const char* sdlVideoDriver) s_pWindow = SDL_CreateWindow("Unleashed Recompiled", s_x, s_y, s_width, s_height, GetWindowFlags()); +#ifdef UNLEASHED_RECOMP_IOS + SDL_GetWindowSize(s_pWindow, &s_width, &s_height); +#endif + if (IsFullscreen()) SDL_ShowCursor(SDL_DISABLE); @@ -213,6 +289,10 @@ void GameWindow::Init(const char* sdlVideoDriver) DWM_WINDOW_CORNER_PREFERENCE wcp = DWMWCP_DONOTROUND; DwmSetWindowAttribute(s_renderWindow, DWMWA_WINDOW_CORNER_PREFERENCE, &wcp, sizeof(wcp)); } +#elif defined(UNLEASHED_RECOMP_METAL) && defined(UNLEASHED_RECOMP_IOS) + static SDL_MetalView metalView = SDL_Metal_CreateView(s_pWindow); + s_renderWindow.window = metalView; + s_renderWindow.view = SDL_Metal_GetLayer(metalView); #elif defined(SDL_VULKAN_ENABLED) s_renderWindow = s_pWindow; #elif defined(__linux__) @@ -227,6 +307,10 @@ void GameWindow::Init(const char* sdlVideoDriver) SetTitleBarColour(); SDL_ShowWindow(s_pWindow); + +#ifdef UNLEASHED_RECOMP_IOS + s_isFocused = true; +#endif } void GameWindow::Update() @@ -434,7 +518,9 @@ uint32_t GameWindow::GetWindowFlags() if (Config::Fullscreen) flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; -#ifdef SDL_VULKAN_ENABLED +#ifdef UNLEASHED_RECOMP_METAL + flags |= SDL_WINDOW_METAL; +#elif defined(SDL_VULKAN_ENABLED) flags |= SDL_WINDOW_VULKAN; #endif diff --git a/UnleashedRecomp/ui/game_window.h b/UnleashedRecomp/ui/game_window.h index b666f4bfd..03823ede5 100644 --- a/UnleashedRecomp/ui/game_window.h +++ b/UnleashedRecomp/ui/game_window.h @@ -3,6 +3,7 @@ #include #include #include +#include #define DEFAULT_WIDTH 1280 #define DEFAULT_HEIGHT 720 @@ -20,7 +21,7 @@ class GameWindow static inline int s_width = DEFAULT_WIDTH; static inline int s_height = DEFAULT_HEIGHT; - static inline bool s_isFocused; + static inline std::atomic s_isFocused; static inline bool s_isIconNight; static inline bool s_isFullscreenCursorVisible; static inline bool s_isChangingDisplay; @@ -49,4 +50,9 @@ class GameWindow static bool IsPositionValid(); static void Init(const char* sdlVideoDriver = nullptr); static void Update(); +#ifdef UNLEASHED_RECOMP_IOS + static inline std::atomic s_isActive{ true }; + static void WaitUntilActive(); + static void ProcessImGuiEvents(); +#endif }; diff --git a/UnleashedRecomp/ui/input_coords.cpp b/UnleashedRecomp/ui/input_coords.cpp new file mode 100644 index 000000000..e91952abb --- /dev/null +++ b/UnleashedRecomp/ui/input_coords.cpp @@ -0,0 +1,61 @@ +#include "input_coords.h" + +#include +#include + +static ImVec2 GetCurrentWindowSize() +{ + int width = GameWindow::s_width; + int height = GameWindow::s_height; + + if (GameWindow::s_pWindow) + SDL_GetWindowSize(GameWindow::s_pWindow, &width, &height); + + return { float(width), float(height) }; +} + +ImVec2 TransformWindowPointToViewport(float x, float y) +{ + const ImVec2 windowSize = GetCurrentWindowSize(); + + if (windowSize.x <= 0.0f || windowSize.y <= 0.0f || Video::s_drawableWidth == 0 || Video::s_drawableHeight == 0) + return { x, y }; + + const float scaleX = float(Video::s_drawableWidth) / windowSize.x; + const float scaleY = float(Video::s_drawableHeight) / windowSize.y; + const float offsetX = (Video::s_drawableWidth - Video::s_viewportWidth) / 2.0f; + const float offsetY = (Video::s_drawableHeight - Video::s_viewportHeight) / 2.0f; + + return { x * scaleX - offsetX, y * scaleY - offsetY }; +} + +ImVec2 GetViewportPointFromSDLEvent(const SDL_Event* event) +{ + switch (event->type) + { + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + return TransformWindowPointToViewport(float(event->button.x), float(event->button.y)); + + case SDL_MOUSEMOTION: + return TransformWindowPointToViewport(float(event->motion.x), float(event->motion.y)); + + case SDL_FINGERDOWN: + case SDL_FINGERUP: + case SDL_FINGERMOTION: + { + const ImVec2 windowSize = GetCurrentWindowSize(); + return TransformWindowPointToViewport( + event->tfinger.x * windowSize.x, + event->tfinger.y * windowSize.y); + } + + default: + return {}; + } +} + +bool IsPointInRect(const ImVec2& point, const ImVec2& min, const ImVec2& max) +{ + return point.x >= min.x && point.x <= max.x && point.y >= min.y && point.y <= max.y; +} diff --git a/UnleashedRecomp/ui/input_coords.h b/UnleashedRecomp/ui/input_coords.h new file mode 100644 index 000000000..b5c0b7e6e --- /dev/null +++ b/UnleashedRecomp/ui/input_coords.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +ImVec2 TransformWindowPointToViewport(float x, float y); +ImVec2 GetViewportPointFromSDLEvent(const SDL_Event* event); +bool IsPointInRect(const ImVec2& point, const ImVec2& min, const ImVec2& max); diff --git a/UnleashedRecomp/ui/installer_wizard.cpp b/UnleashedRecomp/ui/installer_wizard.cpp index a9d060ed1..722120e05 100644 --- a/UnleashedRecomp/ui/installer_wizard.cpp +++ b/UnleashedRecomp/ui/installer_wizard.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -124,6 +125,14 @@ static std::atomic g_installerHalted = false; static std::atomic g_installerCancelled = false; static bool g_installerFailed = false; static std::string g_installerErrorMessage; +static std::atomic g_parseSourcesActive = false; +static std::atomic g_parseSourcesFinished = false; +static std::atomic g_parseSourcesSuccess = false; +static std::string g_parseSourcesErrorMessage; +static std::unique_ptr g_parseSourcesThread; +static bool g_parseSourcesDlcIncomplete = false; +static bool g_parseSourcesDlcInstallerMode = false; +static bool g_parseSourcesSkipButton = false; enum class WizardPage { @@ -268,18 +277,27 @@ class SDLEventListenerForInstaller : public SDLEventListener } case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: case SDL_MOUSEMOTION: + case SDL_FINGERDOWN: + case SDL_FINGERUP: + case SDL_FINGERMOTION: { + const ImVec2 uiPos = GetViewportPointFromSDLEvent(event); + for (size_t i = 0; i < g_currentCursorRects.size(); i++) { auto ¤tRect = g_currentCursorRects[i]; - if (ImGui::IsMouseHoveringRect(currentRect.first, currentRect.second, false)) + if (IsPointInRect(uiPos, currentRect.first, currentRect.second)) { newCursorIndex = int(i); - if (event->type == SDL_MOUSEBUTTONDOWN && event->button.button == SDL_BUTTON_LEFT) + if ((event->type == SDL_MOUSEBUTTONDOWN && event->button.button == SDL_BUTTON_LEFT) || + event->type == SDL_FINGERDOWN) + { g_currentCursorAccepted = true; + } break; } @@ -1109,7 +1127,7 @@ static bool ConvertPathSet(const nfdpathset_t *pathSet, std::list(InstallerThread); } +static bool InstallerParseSources(std::string &errorMessage); + +static void StartParseSources(bool dlcIncomplete, bool dlcInstallerMode, bool skipButton) +{ + g_parseSourcesDlcIncomplete = dlcIncomplete; + g_parseSourcesDlcInstallerMode = dlcInstallerMode; + g_parseSourcesSkipButton = skipButton; + g_parseSourcesActive = true; + g_parseSourcesFinished = false; + g_parseSourcesSuccess = false; + g_parseSourcesErrorMessage.clear(); + + g_parseSourcesThread = std::make_unique([]() + { + std::string errorMessage; + bool success = InstallerParseSources(errorMessage); + g_parseSourcesErrorMessage = std::move(errorMessage); + g_parseSourcesSuccess = success; + g_parseSourcesFinished = true; + }); +} + +static void ProcessParseSourcesResult() +{ + if (!g_parseSourcesActive || !g_parseSourcesFinished) + { + return; + } + + g_parseSourcesThread->join(); + g_parseSourcesThread.reset(); + g_parseSourcesActive = false; + g_parseSourcesFinished = false; + + if (!g_parseSourcesSuccess) + { + std::stringstream stringStream; + stringStream << Localise("Installer_Message_InvalidFiles"); + if (!g_parseSourcesErrorMessage.empty()) + { + stringStream << std::endl << std::endl << g_parseSourcesErrorMessage; + } + + g_currentMessagePrompt = stringStream.str(); + g_currentMessagePromptConfirmation = false; + g_currentPage = g_parseSourcesDlcInstallerMode ? WizardPage::SelectDLC : WizardPage::SelectGameAndUpdate; + } + else if (g_parseSourcesDlcIncomplete && !g_parseSourcesDlcInstallerMode) + { + g_currentMessagePrompt = Localise("Installer_Message_DLCWarning"); + g_currentMessagePromptSource = MessagePromptSource::Next; + g_currentMessagePromptConfirmation = true; + } + else if (g_parseSourcesSkipButton && g_parseSourcesDlcInstallerMode) + { + g_isDisappearing = true; + g_disappearTime = ImGui::GetTime(); + } + else + { + g_currentPage = WizardPage::CheckSpace; + } +} + +static void DrawParseSourcesOverlay() +{ + if (!g_parseSourcesActive) + { + return; + } + + auto drawList = ImGui::GetBackgroundDrawList(); + drawList->AddRectFilled({ 0.0f, 0.0f }, ImGui::GetIO().DisplaySize, IM_COL32(0, 0, 0, 190)); + + const char *text = "..."; + float fontSize = Scale(28.0f); + ImVec2 textSize = g_dfsogeistdFont->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text); + ImVec2 textPos = { + (ImGui::GetIO().DisplaySize.x - textSize.x) * 0.5f, + (ImGui::GetIO().DisplaySize.y - textSize.y) * 0.5f + }; + drawList->AddText(g_dfsogeistdFont, fontSize, textPos, IM_COL32(255, 255, 255, 255), text); +} + static bool InstallerParseSources(std::string &errorMessage) { std::error_code spaceErrorCode; @@ -1401,7 +1503,7 @@ static void DrawNavigationButton() return; } - bool nextButtonEnabled = !g_isDisappearing && (g_currentPage != WizardPage::Installing); + bool nextButtonEnabled = !g_isDisappearing && (g_currentPage != WizardPage::Installing) && !g_parseSourcesActive; if (nextButtonEnabled && g_currentPage == WizardPage::SelectGameAndUpdate) { nextButtonEnabled = !g_gameSourcePath.empty() && !g_updateSourcePath.empty(); @@ -1454,37 +1556,7 @@ static void DrawNavigationButton() } bool dlcInstallerMode = g_gameSourcePath.empty(); - std::string sourcesErrorMessage; - if (!InstallerParseSources(sourcesErrorMessage)) - { - // Some of the sources that were provided to the installer are not valid. Restart the file selection process. - std::stringstream stringStream; - stringStream << Localise("Installer_Message_InvalidFiles"); - if (!sourcesErrorMessage.empty()) { - stringStream << std::endl << std::endl << sourcesErrorMessage; - } - - g_currentMessagePrompt = stringStream.str(); - g_currentMessagePromptConfirmation = false; - g_currentPage = dlcInstallerMode ? WizardPage::SelectDLC : WizardPage::SelectGameAndUpdate; - } - else if (dlcIncomplete && !dlcInstallerMode) - { - // Not all the DLC was specified, we show a prompt and await a confirmation before starting the installer. - g_currentMessagePrompt = Localise("Installer_Message_DLCWarning"); - g_currentMessagePromptSource = MessagePromptSource::Next; - g_currentMessagePromptConfirmation = true; - } - else if (skipButton && dlcInstallerMode) - { - // Nothing was selected and the installer was in DLC mode, just close it. - g_isDisappearing = true; - g_disappearTime = ImGui::GetTime(); - } - else - { - g_currentPage = WizardPage::CheckSpace; - } + StartParseSources(dlcIncomplete, dlcInstallerMode, skipButton); } else if (g_currentPage == WizardPage::CheckSpace) { @@ -1786,6 +1858,8 @@ void InstallerWizard::Draw() DrawSourcePickers(); DrawSources(); DrawInstallingProgress(); + ProcessParseSourcesResult(); + DrawParseSourcesOverlay(); DrawNavigationButton(); CheckCancelAction(); DrawBorders(); @@ -1825,6 +1899,12 @@ void InstallerWizard::Shutdown() g_currentPickerThread.reset(); } + if (g_parseSourcesThread != nullptr) + { + g_parseSourcesThread->join(); + g_parseSourcesThread.reset(); + } + // Erase the sources. g_installerSources.game.reset(); g_installerSources.update.reset(); diff --git a/UnleashedRecomp/ui/touch_controls.cpp b/UnleashedRecomp/ui/touch_controls.cpp new file mode 100644 index 000000000..ee39160be --- /dev/null +++ b/UnleashedRecomp/ui/touch_controls.cpp @@ -0,0 +1,660 @@ +#include "touch_controls.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef UNLEASHED_RECOMP_IOS + +namespace +{ + struct VirtualButton + { + ImVec2 center{}; + float drawRadius{}; + float hitRadius{}; + uint16_t button{}; + uint8_t trigger{}; + SDL_FingerID fingerId{ -1 }; + bool pressed{}; + }; + + struct VirtualStick + { + ImVec2 center{}; + float radius{}; + float hitRadius{}; + SDL_FingerID fingerId{ -1 }; + ImVec2 delta{}; + }; + + struct ToggleButton + { + ImVec2 min{}; + ImVec2 max{}; + SDL_FingerID fingerId{ -1 }; + }; + + static XAMINPUT_GAMEPAD g_gamepadState{}; + static VirtualStick g_leftStick{}; + static VirtualStick g_rightStick{}; + static std::vector g_buttons; + static ToggleButton g_toggleButton{}; + static std::mutex g_touchMutex; + + static float ViewportHeight() + { + return Video::s_viewportHeight != 0 ? float(Video::s_viewportHeight) : float(GameWindow::s_height); + } + + static float TouchScale() + { + const float width = Video::s_viewportWidth != 0 ? float(Video::s_viewportWidth) : float(GameWindow::s_width); + return std::max(0.1f, std::min(width / 1280.0f, ViewportHeight() / 720.0f)); + } + + static ImVec2 ScreenPoint(float x, float y) + { + return { std::round(x), std::round(y) }; + } + + static ImVec2 LogicalPoint(float x, float y) + { + const float width = Video::s_viewportWidth != 0 ? float(Video::s_viewportWidth) : float(GameWindow::s_width); + const float scale = TouchScale(); + return ScreenPoint((width - 1280.0f * scale) * 0.5f + x * scale, + (ViewportHeight() - 720.0f * scale) * 0.5f + y * scale); + } + + static void ResetGamepadState() + { + g_gamepadState = {}; + g_leftStick.fingerId = -1; + g_leftStick.delta = {}; + g_rightStick.fingerId = -1; + g_rightStick.delta = {}; + g_toggleButton.fingerId = -1; + + for (auto& button : g_buttons) + { + button.fingerId = -1; + button.pressed = false; + } + } + + static void SetButtonLayout(size_t index, const ImVec2& center, float drawRadius, uint16_t button, uint8_t trigger = 0) + { + if (index >= g_buttons.size()) + g_buttons.resize(index + 1); + + auto& virtualButton = g_buttons[index]; + virtualButton.center = center; + virtualButton.drawRadius = drawRadius; + virtualButton.hitRadius = std::max(drawRadius * 1.2f, 38.0f * TouchScale()); + virtualButton.button = button; + virtualButton.trigger = trigger; + } + + static void UpdateLayout() + { + const float scale = TouchScale(); + const float stickRadius = 78.0f * scale; + const float rightStickRadius = 68.0f * scale; + const float buttonRadius = 42.0f * scale; + const float smallButtonRadius = 26.0f * scale; + + // Use the extra width on phones without putting controls at the screen + // edge/notch; on tablets this reduces to the centered 16:9 layout. + const float width = Video::s_viewportWidth != 0 ? float(Video::s_viewportWidth) : float(GameWindow::s_width); + const float wing = std::min(90.0f * scale, std::max(0.0f, (width - 1280.0f * scale) * 0.5f)); + auto left = [wing](float x, float y) { auto p = LogicalPoint(x, y); p.x -= wing; return p; }; + auto right = [wing](float x, float y) { auto p = LogicalPoint(x, y); p.x += wing; return p; }; + + g_leftStick.center = left(145.0f, 430.0f); + g_leftStick.radius = stickRadius; + g_leftStick.hitRadius = stickRadius * 1.25f; + + g_rightStick.center = right(965.0f, 585.0f); + g_rightStick.radius = rightStickRadius; + g_rightStick.hitRadius = rightStickRadius * 1.25f; + + SetButtonLayout(0, left(300.0f, 473.0f), smallButtonRadius, XAMINPUT_GAMEPAD_DPAD_UP); + SetButtonLayout(1, left(300.0f, 557.0f), smallButtonRadius, XAMINPUT_GAMEPAD_DPAD_DOWN); + SetButtonLayout(2, left(258.0f, 515.0f), smallButtonRadius, XAMINPUT_GAMEPAD_DPAD_LEFT); + SetButtonLayout(3, left(342.0f, 515.0f), smallButtonRadius, XAMINPUT_GAMEPAD_DPAD_RIGHT); + + SetButtonLayout(4, left(145.0f, 280.0f), 34.0f * scale, XAMINPUT_GAMEPAD_LEFT_SHOULDER); + SetButtonLayout(5, right(1140.0f, 220.0f), 34.0f * scale, XAMINPUT_GAMEPAD_RIGHT_SHOULDER); + g_buttons[4].hitRadius = g_buttons[5].hitRadius = 60.0f * scale; + + SetButtonLayout(6, right(1140.0f, 500.0f), buttonRadius, XAMINPUT_GAMEPAD_A); + SetButtonLayout(7, right(1218.0f, 422.0f), buttonRadius, XAMINPUT_GAMEPAD_B); + SetButtonLayout(8, right(1062.0f, 422.0f), buttonRadius, XAMINPUT_GAMEPAD_X); + SetButtonLayout(9, right(1140.0f, 344.0f), buttonRadius, XAMINPUT_GAMEPAD_Y); + + SetButtonLayout(10, LogicalPoint(724.0f, 654.0f), smallButtonRadius, XAMINPUT_GAMEPAD_START); + SetButtonLayout(11, LogicalPoint(556.0f, 654.0f), smallButtonRadius, XAMINPUT_GAMEPAD_BACK); + SetButtonLayout(12, left(270.0f, 280.0f), 34.0f * scale, 0, 1); + SetButtonLayout(13, right(1015.0f, 220.0f), 34.0f * scale, 0, 2); + + g_toggleButton.min = LogicalPoint(606.0f, 620.0f); + g_toggleButton.max = LogicalPoint(674.0f, 688.0f); + } + + static bool IsTouchMouseEvent(const SDL_Event* event) + { + switch (event->type) + { + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + return event->button.which == SDL_TOUCH_MOUSEID; + + case SDL_MOUSEMOTION: + return event->motion.which == SDL_TOUCH_MOUSEID; + + default: + return false; + } + } + + static bool IsPointInToggle(const ImVec2& point) + { + return point.x >= g_toggleButton.min.x && point.x <= g_toggleButton.max.x && + point.y >= g_toggleButton.min.y && point.y <= g_toggleButton.max.y; + } + + static VirtualButton* FindButtonAtPoint(const ImVec2& point) + { + VirtualButton* nearest = nullptr; + float nearestDistance = FLT_MAX; + for (auto& button : g_buttons) + { + const ImVec2 delta = { point.x - button.center.x, point.y - button.center.y }; + const float distanceSq = delta.x * delta.x + delta.y * delta.y; + if (distanceSq <= button.hitRadius * button.hitRadius && distanceSq < nearestDistance) + { + nearest = &button; + nearestDistance = distanceSq; + } + } + + return nearest; + } + + static VirtualStick* FindStickAtPoint(const ImVec2& point) + { + auto testStick = [&](VirtualStick& stick) -> VirtualStick* + { + const ImVec2 delta = { point.x - stick.center.x, point.y - stick.center.y }; + const float distanceSq = delta.x * delta.x + delta.y * delta.y; + if (distanceSq <= stick.hitRadius * stick.hitRadius) + return &stick; + + return nullptr; + }; + + if (auto* stick = testStick(g_leftStick)) + return stick; + + return testStick(g_rightStick); + } + + static ImVec2 ClampDelta(const ImVec2& delta, float radius) + { + const float length = sqrtf(delta.x * delta.x + delta.y * delta.y); + if (length <= radius || length <= 0.0f) + return delta; + + const float scale = radius / length; + return { delta.x * scale, delta.y * scale }; + } + + static void SetStickPoint(VirtualStick& stick, const ImVec2& point) + { + stick.delta = ClampDelta({ point.x - stick.center.x, point.y - stick.center.y }, stick.radius); + } + + static void UpdateStickValue(VirtualStick& stick) + { + float normX = stick.delta.x / stick.radius; + float normY = stick.delta.y / stick.radius; + const float length = sqrtf(normX * normX + normY * normY); + if (length > 1.0f) + { + normX /= length; + normY /= length; + } + + const int16_t axisX = int16_t(normX * 32767.0f); + const int16_t axisY = int16_t(-normY * 32767.0f); + + if (&stick == &g_leftStick) + { + g_gamepadState.sThumbLX = axisX; + g_gamepadState.sThumbLY = axisY; + } + else + { + g_gamepadState.sThumbRX = axisX; + g_gamepadState.sThumbRY = axisY; + } + } + + static void ResetStick(VirtualStick& stick) + { + stick.fingerId = -1; + stick.delta = {}; + + if (&stick == &g_leftStick) + { + g_gamepadState.sThumbLX = 0; + g_gamepadState.sThumbLY = 0; + } + else + { + g_gamepadState.sThumbRX = 0; + g_gamepadState.sThumbRY = 0; + } + } + + static void RebuildGamepadButtons() + { + g_gamepadState.wButtons = 0; + g_gamepadState.bLeftTrigger = 0; + g_gamepadState.bRightTrigger = 0; + + for (const auto& button : g_buttons) + { + if (button.pressed) + { + g_gamepadState.wButtons |= button.button; + if (button.trigger == 1) + g_gamepadState.bLeftTrigger = 255; + else if (button.trigger == 2) + g_gamepadState.bRightTrigger = 255; + } + } + } + + static bool HandleTogglePress(const ImVec2& point, SDL_FingerID fingerId, bool pressed) + { + if (pressed) + { + if (!IsPointInToggle(point)) + return false; + + if (g_toggleButton.fingerId < 0) + g_toggleButton.fingerId = fingerId; + + return true; + } + + if (g_toggleButton.fingerId != fingerId) + return false; + + if (IsPointInToggle(point)) + { + Config::TouchControls = !Config::TouchControls; + Config::Save(); + ResetGamepadState(); + } + + g_toggleButton.fingerId = -1; + return true; + } + + static bool HandlePointerDown(const ImVec2& point, SDL_FingerID fingerId) + { + if (HandleTogglePress(point, fingerId, true)) + return true; + + if (!TouchControls::IsEnabled()) + return false; + + if (VirtualButton* button = FindButtonAtPoint(point)) + { + if (button->fingerId < 0) + { + button->fingerId = fingerId; + button->pressed = true; + RebuildGamepadButtons(); + } + + return true; + } + + if (VirtualStick* stick = FindStickAtPoint(point)) + { + if (stick->fingerId < 0) + { + stick->fingerId = fingerId; + SetStickPoint(*stick, point); + UpdateStickValue(*stick); + } + + return true; + } + + return false; + } + + static bool HandlePointerMove(const ImVec2& point, SDL_FingerID fingerId) + { + if (!TouchControls::IsEnabled()) + return false; + + VirtualButton* ownedButton = nullptr; + for (auto& button : g_buttons) + { + if (button.fingerId == fingerId) + { + ownedButton = &button; + break; + } + } + + if (ownedButton) + { + VirtualButton* hoveredButton = FindButtonAtPoint(point); + if (hoveredButton != ownedButton) + { + ownedButton->fingerId = -1; + ownedButton->pressed = false; + + if (hoveredButton && hoveredButton->fingerId < 0) + { + hoveredButton->fingerId = fingerId; + hoveredButton->pressed = true; + } + + RebuildGamepadButtons(); + } + + return true; + } + + for (VirtualStick* stick : { &g_leftStick, &g_rightStick }) + { + if (stick->fingerId != fingerId) + continue; + + SetStickPoint(*stick, point); + UpdateStickValue(*stick); + return true; + } + + return false; + } + + static bool HandlePointerUp(const ImVec2& point, SDL_FingerID fingerId) + { + if (g_toggleButton.fingerId == fingerId) + { + HandleTogglePress(point, fingerId, false); + return true; + } + + if (!TouchControls::IsEnabled()) + return false; + + for (VirtualStick* stick : { &g_leftStick, &g_rightStick }) + { + if (stick->fingerId != fingerId) + continue; + + ResetStick(*stick); + return true; + } + + bool releasedButton = false; + for (auto& button : g_buttons) + { + if (button.fingerId == fingerId) + { + button.fingerId = -1; + button.pressed = false; + releasedButton = true; + } + } + + if (releasedButton) + { + RebuildGamepadButtons(); + return true; + } + + return false; + } + + static void DrawStick(const VirtualStick& stick) + { + auto* drawList = ImGui::GetBackgroundDrawList(); + const float scale = TouchScale(); + const bool held = stick.fingerId >= 0; + drawList->AddCircleFilled(stick.center, stick.radius + 3.0f * scale, IM_COL32(15, 17, 18, 150), 64); + drawList->AddCircle(stick.center, stick.radius, IM_COL32(197, 203, 202, 185), 64, 2.0f * scale); + drawList->AddCircle(stick.center, stick.radius - 5.0f * scale, IM_COL32(70, 74, 73, 160), 64, scale); + + // Limit visual travel so the cap stays inside its socket. Input still + // uses the full radius, independent of this cosmetic movement. + const ImVec2 knob = { stick.center.x + stick.delta.x * 0.42f, stick.center.y + stick.delta.y * 0.42f }; + const float cap = stick.radius * 0.53f; + drawList->AddCircleFilled({ knob.x, knob.y + 4.0f * scale }, cap + 2.0f * scale, IM_COL32(0, 0, 0, 115), 48); + drawList->AddCircleFilled(knob, cap, IM_COL32(39, 43, 42, 220), 48); + drawList->AddCircle(knob, cap, held ? IM_COL32(146, 207, 65, 255) : IM_COL32(166, 173, 171, 220), 48, 2.0f * scale); + drawList->AddCircle(knob, cap * 0.76f, IM_COL32(12, 15, 14, 180), 48, 2.0f * scale); + for (int i = 0; i < 4; ++i) + { + const float angle = float(i) * IM_PI * 0.5f; + drawList->AddCircleFilled({ knob.x + cosf(angle) * cap * 0.86f, knob.y + sinf(angle) * cap * 0.86f }, 1.6f * scale, IM_COL32(189, 195, 193, 200), 8); + } + } + + static void DrawDPad() + { + auto* drawList = ImGui::GetBackgroundDrawList(); + const float scale = TouchScale(); + const ImVec2 c = { g_buttons[0].center.x, g_buttons[2].center.y }; + drawList->AddCircleFilled(c, 72.0f * scale, IM_COL32(14, 17, 16, 110), 64); + drawList->AddCircle(c, 72.0f * scale, IM_COL32(171, 179, 176, 125), 64, 2.0f * scale); + const float arm = 65.0f * scale, neck = 22.0f * scale; + const ImU32 face = IM_COL32(92, 99, 96, 215); + drawList->AddRectFilled({ c.x - neck, c.y - arm }, { c.x + neck, c.y + arm }, face, 4.0f * scale); + drawList->AddRectFilled({ c.x - arm, c.y - neck }, { c.x + arm, c.y + neck }, face, 4.0f * scale); + const ImVec2 outline[] = { + { c.x - neck, c.y - arm }, { c.x + neck, c.y - arm }, + { c.x + neck, c.y - neck }, { c.x + arm, c.y - neck }, + { c.x + arm, c.y + neck }, { c.x + neck, c.y + neck }, + { c.x + neck, c.y + arm }, { c.x - neck, c.y + arm }, + { c.x - neck, c.y + neck }, { c.x - arm, c.y + neck }, + { c.x - arm, c.y - neck }, { c.x - neck, c.y - neck } + }; + drawList->AddPolyline(outline, std::size(outline), IM_COL32(204, 211, 207, 210), ImDrawFlags_Closed, 2.0f * scale); + for (size_t i = 0; i < 4; ++i) + { + const auto& button = g_buttons[i]; + if (button.pressed) + drawList->AddRectFilled({ button.center.x - 18.0f * scale, button.center.y - 18.0f * scale }, { button.center.x + 18.0f * scale, button.center.y + 18.0f * scale }, IM_COL32(135, 187, 65, 215), 3.0f * scale); + const ImVec2 direction = { (button.center.x - c.x) / (42.0f * scale), (button.center.y - c.y) / (42.0f * scale) }; + const ImVec2 tangent = { -direction.y, direction.x }; + const float r = 7.0f * scale; + drawList->AddTriangleFilled( + { button.center.x + direction.x * r, button.center.y + direction.y * r }, + { button.center.x - direction.x * r + tangent.x * r, button.center.y - direction.y * r + tangent.y * r }, + { button.center.x - direction.x * r - tangent.x * r, button.center.y - direction.y * r - tangent.y * r }, + IM_COL32(24, 29, 26, 230)); + } + drawList->AddCircleFilled(c, 10.0f * scale, IM_COL32(61, 68, 64, 200), 24); + } + + static void DrawButton(const VirtualButton& button, EButtonIcon icon) + { + auto* drawList = ImGui::GetBackgroundDrawList(); + const float scale = TouchScale(); + const bool bumper = icon == EButtonIcon::LB || icon == EButtonIcon::RB; + const float radius = button.drawRadius * (button.pressed ? 0.93f : 1.0f); + const float halfWidth = radius * (bumper ? 1.75f : 1.0f); + if (!bumper) + { + drawList->AddCircleFilled(button.center, button.drawRadius + 3.0f * scale, IM_COL32(12, 15, 13, 150), 48); + drawList->AddCircle(button.center, button.drawRadius + 3.0f * scale, + button.pressed ? IM_COL32(233, 255, 208, 255) : IM_COL32(180, 189, 182, 120), 48, 2.0f * scale); + } + const auto [uv, texture] = GetButtonIcon(icon, true); + drawList->AddImage(texture, + { button.center.x - halfWidth, button.center.y - radius }, + { button.center.x + halfWidth, button.center.y + radius }, + GET_UV_COORDS(uv), IM_COL32(255, 255, 255, button.pressed ? 255 : 210)); + } + + static void DrawToggle() + { + auto* drawList = ImGui::GetBackgroundDrawList(); + const bool enabled = TouchControls::IsEnabled(); + const ImVec2 c = { (g_toggleButton.min.x + g_toggleButton.max.x) * 0.5f, + (g_toggleButton.min.y + g_toggleButton.max.y) * 0.5f }; + const float scale = TouchScale(); + drawList->AddCircleFilled(c, 31.0f * scale, IM_COL32(27, 33, 29, 210), 48); + drawList->AddCircle(c, 31.0f * scale, enabled ? IM_COL32(137, 211, 44, 235) : IM_COL32(135, 145, 136, 180), 48, 3.0f * scale); + drawList->AddCircleFilled(c, 24.0f * scale, IM_COL32(202, 210, 202, 230), 48); + drawList->AddCircleFilled({ c.x, c.y + 3.0f * scale }, 19.0f * scale, IM_COL32(153, 166, 155, 200), 48); + const ImU32 ink = enabled ? IM_COL32(65, 123, 24, 255) : IM_COL32(75, 86, 78, 255); + drawList->AddLine({ c.x - 11.0f * scale, c.y - 12.0f * scale }, { c.x + 11.0f * scale, c.y + 12.0f * scale }, ink, 5.0f * scale); + drawList->AddLine({ c.x + 11.0f * scale, c.y - 12.0f * scale }, { c.x - 11.0f * scale, c.y + 12.0f * scale }, ink, 5.0f * scale); + if (ImGui::IsMouseHoveringRect(g_toggleButton.min, g_toggleButton.max) && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) + ImGui::SetTooltip("Touch controls"); + } + + class SDLEventListenerForTouchControls : public SDLEventListener + { + public: + bool OnSDLEvent(SDL_Event* event) override + { + std::lock_guard lock(g_touchMutex); + if (event->type == SDL_APP_WILLENTERBACKGROUND || event->type == SDL_APP_DIDENTERFOREGROUND || + (event->type == SDL_WINDOWEVENT && (event->window.event == SDL_WINDOWEVENT_FOCUS_LOST || event->window.event == SDL_WINDOWEVENT_FOCUS_GAINED))) + { + ResetGamepadState(); + return false; + } + if (!TouchControls::IsActive()) + return false; + + UpdateLayout(); + + switch (event->type) + { + case SDL_FINGERDOWN: + { + const ImVec2 point = GetViewportPointFromSDLEvent(event); + return HandlePointerDown(point, event->tfinger.fingerId); + } + + case SDL_FINGERMOTION: + { + const ImVec2 point = GetViewportPointFromSDLEvent(event); + return HandlePointerMove(point, event->tfinger.fingerId); + } + + case SDL_FINGERUP: + { + const ImVec2 point = GetViewportPointFromSDLEvent(event); + return HandlePointerUp(point, event->tfinger.fingerId); + } + + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + { + // Finger events already handled touch-generated mouse events. + if (IsTouchMouseEvent(event) || event->button.button != SDL_BUTTON_LEFT) + break; + + const ImVec2 point = GetViewportPointFromSDLEvent(event); + const SDL_FingerID fingerId = 0; + if (event->type == SDL_MOUSEBUTTONDOWN) + return HandlePointerDown(point, fingerId); + + return HandlePointerUp(point, fingerId); + } + + case SDL_MOUSEMOTION: + { + if (IsTouchMouseEvent(event)) + break; + + const ImVec2 point = GetViewportPointFromSDLEvent(event); + return HandlePointerMove(point, 0); + } + + } + + return false; + } + }; + + static SDLEventListenerForTouchControls g_touchControlsListener; +} + +void TouchControls::Init() +{ + std::lock_guard lock(g_touchMutex); + UpdateLayout(); + ResetGamepadState(); +} + +bool TouchControls::IsActive() +{ + return App::s_isInit && !InstallerWizard::s_isVisible && GameWindow::s_isActive && GameWindow::s_isFocused; +} + +bool TouchControls::IsEnabled() +{ + return Config::TouchControls; +} + +XAMINPUT_GAMEPAD TouchControls::GetState() +{ + std::lock_guard lock(g_touchMutex); + return g_gamepadState; +} + +void TouchControls::Draw() +{ + std::lock_guard lock(g_touchMutex); + if (!IsActive()) + return; + + UpdateLayout(); + DrawToggle(); + + if (!IsEnabled()) + return; + + DrawStick(g_leftStick); + DrawStick(g_rightStick); + DrawDPad(); + + static const EButtonIcon icons[] = { EButtonIcon::LB, EButtonIcon::RB, EButtonIcon::A, EButtonIcon::B, + EButtonIcon::X, EButtonIcon::Y, EButtonIcon::Start, EButtonIcon::Back, EButtonIcon::LT, EButtonIcon::RT }; + for (size_t i = 0; i < std::size(icons); ++i) + DrawButton(g_buttons[i + 4], icons[i]); +} + +#else + +void TouchControls::Init() {} +bool TouchControls::IsActive() { return false; } +bool TouchControls::IsEnabled() { return false; } +XAMINPUT_GAMEPAD TouchControls::GetState() { return {}; } +void TouchControls::Draw() {} + +#endif diff --git a/UnleashedRecomp/ui/touch_controls.h b/UnleashedRecomp/ui/touch_controls.h new file mode 100644 index 000000000..00e0e9641 --- /dev/null +++ b/UnleashedRecomp/ui/touch_controls.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace TouchControls +{ + void Init(); + void Draw(); + bool IsActive(); + bool IsEnabled(); + XAMINPUT_GAMEPAD GetState(); +} diff --git a/UnleashedRecomp/user/config_def.h b/UnleashedRecomp/user/config_def.h index 80eb29c93..85f1869fd 100644 --- a/UnleashedRecomp/user/config_def.h +++ b/UnleashedRecomp/user/config_def.h @@ -14,6 +14,7 @@ CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, HorizontalCamera, ECa CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, VerticalCamera, ECameraRotationMode::Normal); CONFIG_DEFINE_LOCALISED("Input", bool, Vibration, true); CONFIG_DEFINE_LOCALISED("Input", bool, AllowBackgroundInput, false); +CONFIG_DEFINE_HIDDEN("Input", bool, TouchControls, true); CONFIG_DEFINE_ENUM_LOCALISED("Input", EControllerIcons, ControllerIcons, EControllerIcons::Auto); CONFIG_DEFINE_ENUM("Bindings", SDL_Scancode, Key_A, SDL_SCANCODE_S); diff --git a/UnleashedRecomp/user/paths.cpp b/UnleashedRecomp/user/paths.cpp index e3dbf101a..2dd19f4f5 100644 --- a/UnleashedRecomp/user/paths.cpp +++ b/UnleashedRecomp/user/paths.cpp @@ -1,9 +1,27 @@ #include "paths.h" #include +#if defined(UNLEASHED_RECOMP_IOS) +std::filesystem::path g_executableRoot; +std::filesystem::path g_userPath; + +void InitPaths() +{ + if (!g_executableRoot.empty()) + return; + + g_executableRoot = os::process::GetExecutableRoot(); + g_userPath = BuildUserPath(); +} +#else std::filesystem::path g_executableRoot = os::process::GetExecutableRoot(); std::filesystem::path g_userPath = BuildUserPath(); +void InitPaths() +{ +} +#endif + bool CheckPortable() { return std::filesystem::exists(g_executableRoot / "portable.txt"); @@ -22,6 +40,12 @@ std::filesystem::path BuildUserPath() userPath = std::filesystem::path{ knownPath } / USER_DIRECTORY; CoTaskMemFree(knownPath); +#elif defined(UNLEASHED_RECOMP_IOS) + const char* homeDir = getenv("HOME"); + if (homeDir != nullptr) + { + userPath = std::filesystem::path(homeDir) / "Documents" / USER_DIRECTORY; + } #elif defined(__linux__) || defined(__APPLE__) const char* homeDir = getenv("HOME"); #if defined(__linux__) diff --git a/UnleashedRecomp/user/paths.h b/UnleashedRecomp/user/paths.h index ecb3e042c..87fe55c92 100644 --- a/UnleashedRecomp/user/paths.h +++ b/UnleashedRecomp/user/paths.h @@ -10,6 +10,8 @@ extern std::filesystem::path g_executableRoot; +void InitPaths(); + bool CheckPortable(); std::filesystem::path BuildUserPath(); const std::filesystem::path& GetUserPath(); diff --git a/UnleashedRecompLib/CMakeLists.txt b/UnleashedRecompLib/CMakeLists.txt index 8514bde8b..a9464d99a 100644 --- a/UnleashedRecompLib/CMakeLists.txt +++ b/UnleashedRecompLib/CMakeLists.txt @@ -10,9 +10,27 @@ else() add_compile_options(-ffp-model=strict) endif() -target_compile_definitions(XenonRecomp PRIVATE - XENON_RECOMP_CONFIG_FILE_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/config/SWA.toml\" - XENON_RECOMP_HEADER_FILE_PATH=\"${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/XenonUtils/ppc_context.h\") +set(UNLEASHED_RECOMP_HOST_TOOLS_DIR "" CACHE PATH "Directory containing host-built recompilation tools.") + +function(unleashed_recomp_resolve_tool OUT_VAR TARGET_NAME RELATIVE_PATH) + if (UNLEASHED_RECOMP_HOST_TOOLS_DIR) + set(${OUT_VAR} "${UNLEASHED_RECOMP_HOST_TOOLS_DIR}/${RELATIVE_PATH}" PARENT_SCOPE) + elseif (TARGET ${TARGET_NAME}) + set(${OUT_VAR} "$" PARENT_SCOPE) + else() + message(FATAL_ERROR "Tool ${TARGET_NAME} is not available. Set UNLEASHED_RECOMP_HOST_TOOLS_DIR.") + endif() +endfunction() + +unleashed_recomp_resolve_tool(UNLEASHED_RECOMP_XENON_RECOMP_TOOL XenonRecomp "tools/XenonRecomp/XenonRecomp/XenonRecomp") +unleashed_recomp_resolve_tool(UNLEASHED_RECOMP_X_DECOMPRESS_TOOL x_decompress "tools/x_decompress/x_decompress") +unleashed_recomp_resolve_tool(UNLEASHED_RECOMP_XENOS_RECOMP_TOOL XenosRecomp "tools/XenosRecomp/XenosRecomp/XenosRecomp") + +if (TARGET XenonRecomp) + target_compile_definitions(XenonRecomp PRIVATE + XENON_RECOMP_CONFIG_FILE_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/config/SWA.toml\" + XENON_RECOMP_HEADER_FILE_PATH=\"${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/XenonUtils/ppc_context.h\") +endif() set(UNLEASHED_RECOMP_PPC_RECOMPILED_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/ppc/ppc_config.h" @@ -30,7 +48,7 @@ add_custom_command( "${CMAKE_CURRENT_SOURCE_DIR}/private/default_patched.xex" ${UNLEASHED_RECOMP_PPC_RECOMPILED_SOURCES} COMMAND - $ + "${UNLEASHED_RECOMP_XENON_RECOMP_TOOL}" DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/private/default.xex" "${CMAKE_CURRENT_SOURCE_DIR}/private/default.xexp" @@ -42,7 +60,7 @@ add_custom_command( OUTPUT "${CMAKE_CURRENT_SOURCE_DIR}/private/shader_decompressed.ar" COMMAND - $ "${CMAKE_CURRENT_SOURCE_DIR}/private/shader.ar" "${CMAKE_CURRENT_SOURCE_DIR}/private/shader_decompressed.ar" + "${UNLEASHED_RECOMP_X_DECOMPRESS_TOOL}" "${CMAKE_CURRENT_SOURCE_DIR}/private/shader.ar" "${CMAKE_CURRENT_SOURCE_DIR}/private/shader_decompressed.ar" DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/private/shader.ar" ) @@ -50,12 +68,14 @@ add_custom_command( set(XENOS_RECOMP_ROOT "${UNLEASHED_RECOMP_TOOLS_ROOT}/XenosRecomp/XenosRecomp") set(XENOS_RECOMP_INCLUDE "${XENOS_RECOMP_ROOT}/shader_common.h") -target_compile_definitions(XenosRecomp PRIVATE - XENOS_RECOMP_INPUT=\"${CMAKE_CURRENT_SOURCE_DIR}/private\" - XENOS_RECOMP_OUTPUT=\"${CMAKE_CURRENT_SOURCE_DIR}/shader/shader_cache.cpp\" - XENOS_RECOMP_INCLUDE_INPUT=\"${XENOS_RECOMP_INCLUDE}\" - UNLEASHED_RECOMP -) +if (TARGET XenosRecomp) + target_compile_definitions(XenosRecomp PRIVATE + XENOS_RECOMP_INPUT=\"${CMAKE_CURRENT_SOURCE_DIR}/private\" + XENOS_RECOMP_OUTPUT=\"${CMAKE_CURRENT_SOURCE_DIR}/shader/shader_cache.cpp\" + XENOS_RECOMP_INCLUDE_INPUT=\"${XENOS_RECOMP_INCLUDE}\" + UNLEASHED_RECOMP + ) +endif() file(GLOB XENOS_RECOMP_SOURCES "${XENOS_RECOMP_ROOT}/*.h" @@ -66,7 +86,7 @@ add_custom_command( OUTPUT "${CMAKE_CURRENT_SOURCE_DIR}/shader/shader_cache.cpp" COMMAND - $ + "${UNLEASHED_RECOMP_XENOS_RECOMP_TOOL}" DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/private/default_patched.xex" "${CMAKE_CURRENT_SOURCE_DIR}/private/shader_decompressed.ar" diff --git a/docs/IOS.md b/docs/IOS.md new file mode 100644 index 000000000..d9e04c0c0 --- /dev/null +++ b/docs/IOS.md @@ -0,0 +1,102 @@ +# iOS Builds + +The default IPA packaging target is `ios-native-metal-release`. It uses Plume's +native Metal command queues and pipelines, without linking MoltenVK. Existing +SPIR-V shader assets are translated to MSL with SPIRV-Cross before Metal compiles +them. The original installer and on-device game files are still used. + +## Build an IPA + +Use macOS with Xcode selected as the active developer directory, CMake, Ninja, +and the initialized submodules. Supply your own compatible game files as +described by the packaging script (`ISO_PATH`, `UPDATE_PATH`, or `XEXP_PATH`), +or place the extracted files in `UnleashedRecompLib/private`. + +```sh +bash tools/package_ios_ipa.sh +``` + +The output is `out/ipa/UnleashedRecompiled.ipa`. Without a provisioning profile, +use a sideloading tool that signs the IPA on import. For an already provisioned +device build, provide matching signing settings: + +```sh +CODESIGN_IDENTITY="Apple Development: ..." \ +MOBILEPROVISION="/absolute/path/profile.mobileprovision" \ +ENTITLEMENTS="/absolute/path/entitlements.plist" \ +BUNDLE_IDENTIFIER="your.provisioned.bundle.identifier" \ +bash tools/package_ios_ipa.sh +``` + +Keep the installed bundle identifier when updating to preserve its game data. +The provisioning profile must include the device and match the identifier and +entitlements. Native Metal requires Metal 3 buffer GPU addresses (iOS 16 or +later on compatible hardware). + +The current device-tested IPA was built with Xcode 27 and targets iOS/iPadOS +27.0. Without an explicit `CMAKE_OSX_DEPLOYMENT_TARGET`, builds target the +selected SDK version. Supporting an older OS requires rebuilding the app and +its dependencies with a matching deployment target and testing that OS. + +## Incremental Build + +After the initial packaging/build has created the host tools and shader cache: + +```sh +export VCPKG_ROOT="$PWD/thirdparty/vcpkg" +bash tools/apply_ios_submodule_patches.sh +cmake --preset ios-native-metal-release +cmake --build out/build/ios-native-metal-release --target UnleashedRecomp -j 8 +``` + +`tools/patches/plume-ios-renderers.patch` contains both iOS renderer adaptations; +`tools/patches/sdl-ios-scene-lifecycle.patch` adds the SDL scene lifecycle needed +by current iOS SDKs. The patch script is idempotent and refuses conflicting +submodule changes. + +## Vulkan Fallback + +```sh +IOS_PRESET=ios-device-release bash tools/package_ios_ipa.sh +``` + +This separate preset uses Vulkan through MoltenVK. It does not overwrite the +native build directory, but both packaging commands use the same output IPA +path; archive the previous IPA before switching. + +## Device Checks + +- Confirm `[iOS video] backend=Native Metal` in the launch log. +- Check startup, installer, title screen, and a playable 3D level. +- Check touch input in landscape and after foregrounding the app. +- Use `MTL_DEBUG_LAYER=1` for validation only, not performance comparisons. +- `UNLEASHED_IOS_PROFILE=1` enables sampled frame statistics. Metal GPU timestamp + queries are not implemented yet, so its reported GPU milliseconds are not a + valid GPU timing measurement. + +### Suspend/Resume Regression + +With the console attached, switch from a playable scene to Settings or the Home +Screen, wait at least ten seconds, then return without terminating the app. +Repeat during loading and while holding a touch control. Verify: + +- The process survives and frames continue after each return. +- The log pairs `Metal lifecycle: suspended (submissions scheduled)` with + `Metal lifecycle: resumed`, without background-permission or ignored-submission + errors. An FPS sample spanning time in the background will be artificially low. +- Sticks/buttons return to neutral after suspension, and fresh touches respond. +- Also exercise screen lock/unlock and the installer on the target OS/device. + +The Metal submission barrier covers rendering, drawable acquisition, and +presentation. It schedules each queue's outstanding tail before returning from +the deactivation notification, following Apple's background GPU requirements. +The UIKit thread must continue pumping events while the installer is suspended. + +### Touch Controls + +The overlay uses Xbox 360-style button artwork with offset sticks and a cross +D-pad. The bottom-center green X toggles the overlay; the small arrow buttons +beside it are Back and Start. LT/RT are separate full-pressure digital triggers, +not aliases of LB/RB. Test simultaneous stick/button input, independent shoulder +and trigger presses, and hiding/restoring the overlay. Loading/cutscene black +bars must remain behind the touch controls. diff --git a/thirdparty/CMakeLists.txt b/thirdparty/CMakeLists.txt index d5c085818..e89e5b5e2 100644 --- a/thirdparty/CMakeLists.txt +++ b/thirdparty/CMakeLists.txt @@ -15,8 +15,17 @@ set(SDL2MIXER_OPUS OFF) set(SDL2MIXER_VORBIS "VORBISFILE") set(SDL2MIXER_WAVPACK OFF) -if (CMAKE_SYSTEM_NAME MATCHES "Linux") - set(SDL_VULKAN_ENABLED ON CACHE BOOL "") +if (CMAKE_SYSTEM_NAME MATCHES "Linux" OR CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(SDL_VULKAN_ENABLED ON CACHE BOOL "" FORCE) +endif() + +if (CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(SDL_VULKAN ON CACHE BOOL "" FORCE) + option(UNLEASHED_RECOMP_IOS_NATIVE_METAL "Use the native Metal renderer on iOS" OFF) + if (UNLEASHED_RECOMP_IOS_NATIVE_METAL) + set(SDL_VULKAN_ENABLED OFF CACHE BOOL "" FORCE) + add_compile_definitions(UNLEASHED_RECOMP_METAL=1) + endif() endif() if (WIN32) @@ -24,9 +33,22 @@ if (WIN32) endif() add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/msdf-atlas-gen") -add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/nativefiledialog-extended") +if (CMAKE_SYSTEM_NAME STREQUAL "iOS") + find_library(UIKIT_LIBRARY UIKit REQUIRED) + find_library(FOUNDATION_LIBRARY Foundation REQUIRED) + + add_library(nfd STATIC "${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/nfd_ios.mm") + add_library(nfd::nfd ALIAS nfd) + target_include_directories(nfd PUBLIC "${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/nativefiledialog-extended/src/include") + target_link_libraries(nfd PUBLIC ${UIKIT_LIBRARY} ${FOUNDATION_LIBRARY}) +else() + add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/nativefiledialog-extended") +endif() add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/o1heap") add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/SDL") +if (CMAKE_SYSTEM_NAME STREQUAL "iOS") + target_compile_definitions(SDL2-static PRIVATE SDL_UIKIT_USE_SCENES=1) +endif() add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/SDL_mixer") add_subdirectory("${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/plume") diff --git a/thirdparty/MoltenVK/CMakeLists.txt b/thirdparty/MoltenVK/CMakeLists.txt index c7ef4f9b4..44fc1878b 100644 --- a/thirdparty/MoltenVK/CMakeLists.txt +++ b/thirdparty/MoltenVK/CMakeLists.txt @@ -25,13 +25,34 @@ set(MVK_VERSION "${MVK_VERSION_MAJOR}.${MVK_VERSION_MINOR}.${MVK_VERSION_PATCH}" message(STATUS "MoltenVK version: ${MVK_VERSION}") # Find required system libraries -find_library(APPKIT_LIBRARY AppKit REQUIRED) find_library(FOUNDATION_LIBRARY Foundation REQUIRED) find_library(IOKIT_LIBRARY IOKit REQUIRED) find_library(IOSURFACE_LIBRARY IOSurface REQUIRED) find_library(METAL_LIBRARY Metal REQUIRED) find_library(QUARTZCORE_LIBRARY QuartzCore REQUIRED) +if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + find_library(UIKIT_LIBRARY UIKit REQUIRED) + set(MVK_PLATFORM_LIBRARIES + ${FOUNDATION_LIBRARY} + ${IOKIT_LIBRARY} + ${IOSURFACE_LIBRARY} + ${METAL_LIBRARY} + ${QUARTZCORE_LIBRARY} + ${UIKIT_LIBRARY}) + set(MVK_LIBRARY_TYPE STATIC) +else() + find_library(APPKIT_LIBRARY AppKit REQUIRED) + set(MVK_PLATFORM_LIBRARIES + ${APPKIT_LIBRARY} + ${FOUNDATION_LIBRARY} + ${IOKIT_LIBRARY} + ${IOSURFACE_LIBRARY} + ${METAL_LIBRARY} + ${QUARTZCORE_LIBRARY}) + set(MVK_LIBRARY_TYPE SHARED) +endif() + # SPIRV-Cross option(SPIRV_CROSS_CLI "" OFF) option(SPIRV_CROSS_ENABLE_TESTS "" OFF) @@ -77,10 +98,10 @@ set(MVK_INCLUDES ${MVK_SRC_INCLUDES} ${MVK_GENERATED_INCLUDES} ${MVK_DIR}/include ${UNLEASHED_RECOMP_THIRDPARTY_ROOT}/plume/contrib/Vulkan-Headers/include) -add_library(MoltenVK SHARED ${MVK_SOURCES}) +add_library(MoltenVK ${MVK_LIBRARY_TYPE} ${MVK_SOURCES}) target_include_directories(MoltenVK PRIVATE ${MVK_INCLUDES}) target_compile_options(MoltenVK PRIVATE -w) target_link_libraries(MoltenVK PRIVATE - ${APPKIT_LIBRARY} ${FOUNDATION_LIBRARY} ${IOKIT_LIBRARY} ${IOSURFACE_LIBRARY} ${METAL_LIBRARY} ${QUARTZCORE_LIBRARY} + ${MVK_PLATFORM_LIBRARIES} spirv-cross-msl MoltenVKCommon MoltenVKShaderConverter) target_compile_definitions(MoltenVK PRIVATE MVK_FRAMEWORK_VERSION=${MVK_VERSION} MVK_USE_CEREAL=0) diff --git a/thirdparty/nfd_ios.mm b/thirdparty/nfd_ios.mm new file mode 100644 index 000000000..b6945c92d --- /dev/null +++ b/thirdparty/nfd_ios.mm @@ -0,0 +1,637 @@ +#include + +#import + +#include +#include + +#include + +struct NFDIOSPathSet +{ + nfdpathsetsize_t count; + char** paths; +}; + +struct NFDIOSPathSetEnumerator +{ + const NFDIOSPathSet* pathSet; + nfdpathsetsize_t index; +}; + +static std::string g_error; +static NSMutableArray* g_securityScopedURLs; + +static void NFDi_SetError(const char* msg) +{ + g_error = msg ? msg : ""; +} + +static void NFDi_SetErrorNSString(NSString* msg) +{ + NFDi_SetError(msg ? [msg UTF8String] : "Unknown iOS document picker error."); +} + +static nfdresult_t NFDi_CopyUtf8String(const char* utf8Str, char** out) +{ + if (!out) + { + NFDi_SetError("NFD output path was null."); + return NFD_ERROR; + } + + *out = nullptr; + + if (!utf8Str) + { + NFDi_SetError("iOS document picker returned a null path."); + return NFD_ERROR; + } + + size_t len = strlen(utf8Str); + char* result = static_cast(malloc(len + 1)); + if (!result) + { + NFDi_SetError("Failed to allocate NFD path."); + return NFD_ERROR; + } + + memcpy(result, utf8Str, len + 1); + *out = result; + return NFD_OKAY; +} + +static UIViewController* NFDi_GetTopViewController() +{ + UIApplication* application = [UIApplication sharedApplication]; + UIWindow* keyWindow = nil; + + if (@available(iOS 13.0, *)) + { + for (UIScene* scene in [application connectedScenes]) + { + if (scene.activationState != UISceneActivationStateForegroundActive || + ![scene isKindOfClass:[UIWindowScene class]]) + { + continue; + } + + UIWindowScene* windowScene = static_cast(scene); + for (UIWindow* window in windowScene.windows) + { + if (window.isKeyWindow) + { + keyWindow = window; + break; + } + } + + if (keyWindow) + break; + } + } + + if (!keyWindow) + { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + keyWindow = application.keyWindow; +#pragma clang diagnostic pop + } + + UIViewController* controller = keyWindow.rootViewController; + while (controller.presentedViewController) + controller = controller.presentedViewController; + + return controller; +} + +static nfdresult_t NFDi_CreatePathSet(NSArray* paths, const nfdpathset_t** outPaths) +{ + if (!outPaths) + { + NFDi_SetError("NFD output path set was null."); + return NFD_ERROR; + } + + *outPaths = nullptr; + + if (!paths || paths.count == 0) + return NFD_CANCEL; + + NFDIOSPathSet* pathSet = static_cast(calloc(1, sizeof(NFDIOSPathSet))); + if (!pathSet) + { + NFDi_SetError("Failed to allocate NFD path set."); + return NFD_ERROR; + } + + pathSet->count = static_cast(paths.count); + pathSet->paths = static_cast(calloc(pathSet->count, sizeof(char*))); + if (!pathSet->paths) + { + free(pathSet); + NFDi_SetError("Failed to allocate NFD path set entries."); + return NFD_ERROR; + } + + for (nfdpathsetsize_t i = 0; i < pathSet->count; i++) + { + NSString* path = [paths objectAtIndex:i]; + if (NFDi_CopyUtf8String([path UTF8String], &pathSet->paths[i]) != NFD_OKAY) + { + for (nfdpathsetsize_t j = 0; j < i; j++) + free(pathSet->paths[j]); + + free(pathSet->paths); + free(pathSet); + return NFD_ERROR; + } + } + + *outPaths = reinterpret_cast(pathSet); + return NFD_OKAY; +} + +@interface NFDIOSPickerDelegate : NSObject +{ +@public + BOOL completed; + nfdresult_t result; + NSArray* paths; +} +@end + +@implementation NFDIOSPickerDelegate + +- (id)init +{ + self = [super init]; + if (self) + { + completed = NO; + result = NFD_CANCEL; + paths = nil; + } + + return self; +} + +- (void)dealloc +{ + [paths release]; + [super dealloc]; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController*)controller +{ + (void)controller; + result = NFD_CANCEL; + completed = YES; +} + +- (void)documentPicker:(UIDocumentPickerViewController*)controller didPickDocumentsAtURLs:(NSArray*)urls +{ + (void)controller; + + NSMutableArray* pickedPaths = [NSMutableArray arrayWithCapacity:urls.count]; + + for (NSURL* url in urls) + { + BOOL scoped = [url startAccessingSecurityScopedResource]; + if (scoped) + { + if (!g_securityScopedURLs) + g_securityScopedURLs = [[NSMutableArray alloc] init]; + + [g_securityScopedURLs addObject:url]; + } + + NSString* path = url.path; + if (path.length > 0) + [pickedPaths addObject:path]; + } + + [paths release]; + paths = [[NSArray alloc] initWithArray:pickedPaths]; + result = paths.count > 0 ? NFD_OKAY : NFD_CANCEL; + completed = YES; +} + +@end + +static void NFDi_PresentPicker(NFDIOSPickerDelegate* delegate, bool folderMode, bool multiple) +{ + UIViewController* presenter = NFDi_GetTopViewController(); + if (!presenter) + { + NFDi_SetError("Unable to find an iOS view controller for the document picker."); + delegate->result = NFD_ERROR; + delegate->completed = YES; + return; + } + + NSArray* documentTypes = folderMode ? @[ @"public.folder" ] : @[ @"public.item" ]; + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initWithDocumentTypes:documentTypes + inMode:UIDocumentPickerModeOpen]; + + picker.delegate = delegate; + picker.allowsMultipleSelection = multiple ? YES : NO; + + UIPopoverPresentationController* popover = picker.popoverPresentationController; + if (popover) + { + popover.sourceView = presenter.view; + popover.sourceRect = CGRectMake(CGRectGetMidX(presenter.view.bounds), CGRectGetMidY(presenter.view.bounds), 1.0, 1.0); + popover.permittedArrowDirections = 0; + } + + [presenter presentViewController:picker animated:YES completion:nil]; + [picker release]; +} + +static nfdresult_t NFDi_RunPicker(const nfdpathset_t** outPaths, bool folderMode, bool multiple) +{ + @autoreleasepool + { + NFDIOSPickerDelegate* delegate = [[NFDIOSPickerDelegate alloc] init]; + + if ([NSThread isMainThread]) + { + NFDi_PresentPicker(delegate, folderMode, multiple); + while (!delegate->completed) + { + @autoreleasepool + { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + } + } + else + { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + NFDi_PresentPicker(delegate, folderMode, multiple); + if (delegate->completed) + dispatch_semaphore_signal(semaphore); + }); + + while (!delegate->completed) + { + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC)); + } + } + + nfdresult_t result = delegate->result; + if (result == NFD_OKAY) + result = NFDi_CreatePathSet(delegate->paths, outPaths); + + [delegate release]; + return result; + } +} + +extern "C" { + +nfdresult_t NFD_Init(void) +{ + NFDi_SetError(nullptr); + return NFD_OKAY; +} + +void NFD_Quit(void) +{ + for (NSURL* url in g_securityScopedURLs) + [url stopAccessingSecurityScopedResource]; + + [g_securityScopedURLs release]; + g_securityScopedURLs = nil; +} + +const char* NFD_GetError(void) +{ + return g_error.empty() ? nullptr : g_error.c_str(); +} + +void NFD_ClearError(void) +{ + NFDi_SetError(nullptr); +} + +void NFD_FreePathN(nfdnchar_t* filePath) +{ + free(filePath); +} + +void NFD_FreePathU8(nfdu8char_t* filePath) +{ + free(filePath); +} + +nfdresult_t NFD_OpenDialogN(nfdnchar_t** outPath, + const nfdnfilteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdnchar_t* defaultPath) +{ + (void)filterList; + (void)filterCount; + (void)defaultPath; + + const nfdpathset_t* pathSet = nullptr; + nfdresult_t result = NFDi_RunPicker(&pathSet, false, false); + if (result != NFD_OKAY) + return result; + + result = NFD_PathSet_GetPathN(pathSet, 0, outPath); + NFD_PathSet_Free(pathSet); + return result; +} + +nfdresult_t NFD_OpenDialogU8(nfdu8char_t** outPath, + const nfdu8filteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdu8char_t* defaultPath) +{ + return NFD_OpenDialogN(outPath, filterList, filterCount, defaultPath); +} + +nfdresult_t NFD_OpenDialogN_With_Impl(nfdversion_t version, + nfdnchar_t** outPath, + const nfdopendialognargs_t* args) +{ + (void)version; + return NFD_OpenDialogN(outPath, + args ? args->filterList : nullptr, + args ? args->filterCount : 0, + args ? args->defaultPath : nullptr); +} + +nfdresult_t NFD_OpenDialogU8_With_Impl(nfdversion_t version, + nfdu8char_t** outPath, + const nfdopendialogu8args_t* args) +{ + return NFD_OpenDialogN_With_Impl(version, outPath, args); +} + +nfdresult_t NFD_OpenDialogMultipleN(const nfdpathset_t** outPaths, + const nfdnfilteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdnchar_t* defaultPath) +{ + (void)filterList; + (void)filterCount; + (void)defaultPath; + return NFDi_RunPicker(outPaths, false, true); +} + +nfdresult_t NFD_OpenDialogMultipleU8(const nfdpathset_t** outPaths, + const nfdu8filteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdu8char_t* defaultPath) +{ + return NFD_OpenDialogMultipleN(outPaths, filterList, filterCount, defaultPath); +} + +nfdresult_t NFD_OpenDialogMultipleN_With_Impl(nfdversion_t version, + const nfdpathset_t** outPaths, + const nfdopendialognargs_t* args) +{ + (void)version; + return NFD_OpenDialogMultipleN(outPaths, + args ? args->filterList : nullptr, + args ? args->filterCount : 0, + args ? args->defaultPath : nullptr); +} + +nfdresult_t NFD_OpenDialogMultipleU8_With_Impl(nfdversion_t version, + const nfdpathset_t** outPaths, + const nfdopendialogu8args_t* args) +{ + return NFD_OpenDialogMultipleN_With_Impl(version, outPaths, args); +} + +nfdresult_t NFD_SaveDialogN(nfdnchar_t** outPath, + const nfdnfilteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdnchar_t* defaultPath, + const nfdnchar_t* defaultName) +{ + (void)outPath; + (void)filterList; + (void)filterCount; + (void)defaultPath; + (void)defaultName; + NFDi_SetError("Save dialogs are not supported by the iOS document picker backend."); + return NFD_ERROR; +} + +nfdresult_t NFD_SaveDialogU8(nfdu8char_t** outPath, + const nfdu8filteritem_t* filterList, + nfdfiltersize_t filterCount, + const nfdu8char_t* defaultPath, + const nfdu8char_t* defaultName) +{ + return NFD_SaveDialogN(outPath, filterList, filterCount, defaultPath, defaultName); +} + +nfdresult_t NFD_SaveDialogN_With_Impl(nfdversion_t version, + nfdnchar_t** outPath, + const nfdsavedialognargs_t* args) +{ + (void)version; + return NFD_SaveDialogN(outPath, + args ? args->filterList : nullptr, + args ? args->filterCount : 0, + args ? args->defaultPath : nullptr, + args ? args->defaultName : nullptr); +} + +nfdresult_t NFD_SaveDialogU8_With_Impl(nfdversion_t version, + nfdu8char_t** outPath, + const nfdsavedialogu8args_t* args) +{ + return NFD_SaveDialogN_With_Impl(version, outPath, args); +} + +nfdresult_t NFD_PickFolderN(nfdnchar_t** outPath, const nfdnchar_t* defaultPath) +{ + (void)defaultPath; + + const nfdpathset_t* pathSet = nullptr; + nfdresult_t result = NFDi_RunPicker(&pathSet, true, false); + if (result != NFD_OKAY) + return result; + + result = NFD_PathSet_GetPathN(pathSet, 0, outPath); + NFD_PathSet_Free(pathSet); + return result; +} + +nfdresult_t NFD_PickFolderU8(nfdu8char_t** outPath, const nfdu8char_t* defaultPath) +{ + return NFD_PickFolderN(outPath, defaultPath); +} + +nfdresult_t NFD_PickFolderN_With_Impl(nfdversion_t version, + nfdnchar_t** outPath, + const nfdpickfoldernargs_t* args) +{ + (void)version; + return NFD_PickFolderN(outPath, args ? args->defaultPath : nullptr); +} + +nfdresult_t NFD_PickFolderU8_With_Impl(nfdversion_t version, + nfdu8char_t** outPath, + const nfdpickfolderu8args_t* args) +{ + return NFD_PickFolderN_With_Impl(version, outPath, args); +} + +nfdresult_t NFD_PickFolderMultipleN(const nfdpathset_t** outPaths, const nfdnchar_t* defaultPath) +{ + (void)defaultPath; + return NFDi_RunPicker(outPaths, true, true); +} + +nfdresult_t NFD_PickFolderMultipleU8(const nfdpathset_t** outPaths, const nfdu8char_t* defaultPath) +{ + return NFD_PickFolderMultipleN(outPaths, defaultPath); +} + +nfdresult_t NFD_PickFolderMultipleN_With_Impl(nfdversion_t version, + const nfdpathset_t** outPaths, + const nfdpickfoldernargs_t* args) +{ + (void)version; + return NFD_PickFolderMultipleN(outPaths, args ? args->defaultPath : nullptr); +} + +nfdresult_t NFD_PickFolderMultipleU8_With_Impl(nfdversion_t version, + const nfdpathset_t** outPaths, + const nfdpickfolderu8args_t* args) +{ + return NFD_PickFolderMultipleN_With_Impl(version, outPaths, args); +} + +nfdresult_t NFD_PathSet_GetCount(const nfdpathset_t* pathSet, nfdpathsetsize_t* count) +{ + if (!pathSet || !count) + { + NFDi_SetError("NFD path set or count was null."); + return NFD_ERROR; + } + + const NFDIOSPathSet* iosPathSet = reinterpret_cast(pathSet); + *count = iosPathSet->count; + return NFD_OKAY; +} + +nfdresult_t NFD_PathSet_GetPathN(const nfdpathset_t* pathSet, nfdpathsetsize_t index, nfdnchar_t** outPath) +{ + if (!pathSet) + { + NFDi_SetError("NFD path set was null."); + return NFD_ERROR; + } + + const NFDIOSPathSet* iosPathSet = reinterpret_cast(pathSet); + if (index >= iosPathSet->count) + { + NFDi_SetError("NFD path set index was out of range."); + return NFD_ERROR; + } + + return NFDi_CopyUtf8String(iosPathSet->paths[index], outPath); +} + +nfdresult_t NFD_PathSet_GetPathU8(const nfdpathset_t* pathSet, nfdpathsetsize_t index, nfdu8char_t** outPath) +{ + return NFD_PathSet_GetPathN(pathSet, index, outPath); +} + +void NFD_PathSet_FreePathN(const nfdnchar_t* filePath) +{ + free(const_cast(filePath)); +} + +void NFD_PathSet_FreePathU8(const nfdu8char_t* filePath) +{ + free(const_cast(filePath)); +} + +void NFD_PathSet_Free(const nfdpathset_t* pathSet) +{ + if (!pathSet) + return; + + const NFDIOSPathSet* iosPathSet = reinterpret_cast(pathSet); + for (nfdpathsetsize_t i = 0; i < iosPathSet->count; i++) + free(iosPathSet->paths[i]); + + free(iosPathSet->paths); + free(const_cast(iosPathSet)); +} + +nfdresult_t NFD_PathSet_GetEnum(const nfdpathset_t* pathSet, nfdpathsetenum_t* outEnumerator) +{ + if (!pathSet || !outEnumerator) + { + NFDi_SetError("NFD path set or enumerator was null."); + return NFD_ERROR; + } + + NFDIOSPathSetEnumerator* enumerator = + static_cast(calloc(1, sizeof(NFDIOSPathSetEnumerator))); + if (!enumerator) + { + NFDi_SetError("Failed to allocate NFD path set enumerator."); + return NFD_ERROR; + } + + enumerator->pathSet = reinterpret_cast(pathSet); + enumerator->index = 0; + outEnumerator->ptr = enumerator; + return NFD_OKAY; +} + +void NFD_PathSet_FreeEnum(nfdpathsetenum_t* enumerator) +{ + if (!enumerator) + return; + + free(enumerator->ptr); + enumerator->ptr = nullptr; +} + +nfdresult_t NFD_PathSet_EnumNextN(nfdpathsetenum_t* enumerator, nfdnchar_t** outPath) +{ + if (!enumerator || !outPath) + { + NFDi_SetError("NFD enumerator or output path was null."); + return NFD_ERROR; + } + + NFDIOSPathSetEnumerator* iosEnumerator = static_cast(enumerator->ptr); + if (!iosEnumerator || !iosEnumerator->pathSet) + { + NFDi_SetError("NFD enumerator was invalid."); + return NFD_ERROR; + } + + if (iosEnumerator->index >= iosEnumerator->pathSet->count) + { + *outPath = nullptr; + return NFD_OKAY; + } + + return NFDi_CopyUtf8String(iosEnumerator->pathSet->paths[iosEnumerator->index++], outPath); +} + +nfdresult_t NFD_PathSet_EnumNextU8(nfdpathsetenum_t* enumerator, nfdu8char_t** outPath) +{ + return NFD_PathSet_EnumNextN(enumerator, outPath); +} + +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index bdcf384aa..eeff45f0d 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -1,6 +1,22 @@ add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/bc_diff) -add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/file_to_c) add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/fshasher) -add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/x_decompress) -add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp) -add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenosRecomp) +if (NOT UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/file_to_c) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/x_decompress) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenosRecomp) +else() + # Shaders still need the host DXC executable when target tools are skipped. + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenosRecomp/thirdparty/dxc-bin) + set(THIRDPARTY_ROOT ${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/thirdparty) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/thirdparty) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/XenonUtils) + + if (NOT TARGET libzstd) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/XenosRecomp/thirdparty/zstd/build/cmake) + endif() +endif() +if (NOT UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/iso_extract) + add_subdirectory(${UNLEASHED_RECOMP_TOOLS_ROOT}/xcontent_extract) +endif() diff --git a/tools/apply_ios_submodule_patches.sh b/tools/apply_ios_submodule_patches.sh new file mode 100755 index 000000000..1f0912dbe --- /dev/null +++ b/tools/apply_ios_submodule_patches.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUME_DIR="$ROOT/thirdparty/plume" +PLUME_PATCH_FILE="$ROOT/tools/patches/plume-ios-renderers.patch" +XENON_RECOMP_DIR="$ROOT/tools/XenonRecomp" +XENON_RECOMP_PATCH_FILE="$ROOT/tools/patches/xenonrecomp-ios-streaming-memory-map.patch" +PLUME_VOLK_HEADER="$PLUME_DIR/contrib/volk/volk.h" +PLUME_VOLK_RENAME_HEADER="$PLUME_DIR/plume_volk_rename_ios.h" +SDL_DIR="$ROOT/thirdparty/SDL" +SDL_PATCH_FILE="$ROOT/tools/patches/sdl-ios-scene-lifecycle.patch" + +if git -C "$SDL_DIR" apply --check "$SDL_PATCH_FILE" >/dev/null 2>&1; then + git -C "$SDL_DIR" apply "$SDL_PATCH_FILE" + printf 'Applied SDL iOS scene lifecycle patch.\n' +elif git -C "$SDL_DIR" apply --reverse --check "$SDL_PATCH_FILE" >/dev/null 2>&1; then + printf 'SDL iOS scene lifecycle patch already applied.\n' +else + printf 'SDL iOS scene lifecycle patch cannot be applied cleanly. Check thirdparty/SDL for local changes.\n' >&2 + exit 1 +fi + +if [[ ! -d "$PLUME_DIR/.git" && ! -f "$PLUME_DIR/.git" ]]; then + printf 'Missing Plume submodule. Run: git submodule update --init --recursive\n' >&2 + exit 1 +fi + +if [[ ! -d "$XENON_RECOMP_DIR/.git" && ! -f "$XENON_RECOMP_DIR/.git" ]]; then + printf 'Missing XenonRecomp submodule. Run: git submodule update --init --recursive\n' >&2 + exit 1 +fi + +if git -C "$PLUME_DIR" apply --check "$PLUME_PATCH_FILE" >/dev/null 2>&1; then + git -C "$PLUME_DIR" apply "$PLUME_PATCH_FILE" + printf 'Applied Plume iOS patch.\n' +else + if git -C "$PLUME_DIR" apply --reverse --check "$PLUME_PATCH_FILE" >/dev/null 2>&1; then + printf 'Plume iOS patch already applied.\n' + else + printf 'Plume iOS patch cannot be applied cleanly. Check thirdparty/plume for local changes.\n' >&2 + exit 1 + fi +fi + +if [[ ! -f "$PLUME_VOLK_HEADER" ]]; then + printf 'Missing Plume volk header: %s\n' "$PLUME_VOLK_HEADER" >&2 + exit 1 +fi + +{ + printf '#pragma once\n\n' + printf '// Keep Plume'\''s embedded volk function-pointer globals from colliding with statically linked MoltenVK on iOS.\n' + awk '/^extern PFN_vk/ { name = $3; sub(/;$/, "", name); print "#define " name " plumeVolk_" name }' "$PLUME_VOLK_HEADER" +} > "$PLUME_VOLK_RENAME_HEADER" +printf 'Generated Plume iOS volk rename header.\n' + +if git -C "$XENON_RECOMP_DIR" apply --check "$XENON_RECOMP_PATCH_FILE" >/dev/null 2>&1; then + git -C "$XENON_RECOMP_DIR" apply "$XENON_RECOMP_PATCH_FILE" + printf 'Applied XenonRecomp iOS patch.\n' +else + if git -C "$XENON_RECOMP_DIR" apply --reverse --check "$XENON_RECOMP_PATCH_FILE" >/dev/null 2>&1; then + printf 'XenonRecomp iOS patch already applied.\n' + else + printf 'XenonRecomp iOS patch cannot be applied cleanly. Check tools/XenonRecomp for local changes.\n' >&2 + exit 1 + fi +fi diff --git a/tools/iso_extract/CMakeLists.txt b/tools/iso_extract/CMakeLists.txt new file mode 100644 index 000000000..d38f5218e --- /dev/null +++ b/tools/iso_extract/CMakeLists.txt @@ -0,0 +1,13 @@ +project("iso_extract") + +add_executable(iso_extract + "iso_extract.cpp" + "${CMAKE_SOURCE_DIR}/UnleashedRecomp/install/iso_file_system.cpp" +) + +target_include_directories(iso_extract PRIVATE + "${CMAKE_SOURCE_DIR}/UnleashedRecomp/install" + "${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/XenonUtils" +) + +target_link_libraries(iso_extract PRIVATE XenonUtils) diff --git a/tools/iso_extract/iso_extract.cpp b/tools/iso_extract/iso_extract.cpp new file mode 100644 index 000000000..4333b99db --- /dev/null +++ b/tools/iso_extract/iso_extract.cpp @@ -0,0 +1,74 @@ +#include +#include +#include +#include +#include +#include + +#include + +static bool ExtractFile(const ISOFileSystem& iso, const std::string& sourcePath, const std::filesystem::path& outputPath) +{ + std::vector data; + size_t dataSize = iso.getSize(sourcePath); + if (dataSize == 0) + { + std::cerr << "File not found in ISO: " << sourcePath << "\n"; + return false; + } + + data.resize(dataSize); + if (!iso.load(sourcePath, data.data(), data.size())) + { + std::cerr << "File not found in ISO: " << sourcePath << "\n"; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(outputPath.parent_path(), ec); + if (ec) + { + std::cerr << "Failed to create output directory: " << outputPath.parent_path() << "\n"; + return false; + } + + std::ofstream output(outputPath, std::ios::binary); + if (!output) + { + std::cerr << "Failed to open output file: " << outputPath << "\n"; + return false; + } + + output.write(reinterpret_cast(data.data()), static_cast(data.size())); + if (!output) + { + std::cerr << "Failed to write output file: " << outputPath << "\n"; + return false; + } + + return true; +} + +int main(int argc, char** argv) +{ + if (argc < 4 || ((argc - 2) % 2) != 0) + { + std::cerr << "Usage: iso_extract [ ...]\n"; + return 2; + } + + auto iso = ISOFileSystem::create(argv[1]); + if (!iso) + { + std::cerr << "Failed to open Xbox 360 ISO: " << argv[1] << "\n"; + return 1; + } + + bool ok = true; + for (int i = 2; i < argc; i += 2) + { + ok = ExtractFile(*iso, argv[i], argv[i + 1]) && ok; + } + + return ok ? 0 : 1; +} diff --git a/tools/package_ios_ipa.sh b/tools/package_ios_ipa.sh new file mode 100755 index 000000000..45dc2b105 --- /dev/null +++ b/tools/package_ios_ipa.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IOS_PRESET="${IOS_PRESET:-ios-native-metal-release}" +BUILD_DIR="$ROOT/out/build/$IOS_PRESET" +export VCPKG_ROOT="${VCPKG_ROOT:-$ROOT/thirdparty/vcpkg}" +HOST_BUILD_DIR="$ROOT/out/build/macos-release" +IPA_DIR="$ROOT/out/ipa" +PAYLOAD_DIR="$IPA_DIR/Payload" +APP_PATH="$BUILD_DIR/UnleashedRecomp/Unleashed Recompiled.app" +IPA_PATH="$IPA_DIR/UnleashedRecompiled.ipa" +DEFAULT_SIGNING_IDENTITY="Apple Development: aroblesalago@gmail.com (MK28YRUCCG)" + +"$ROOT/tools/apply_ios_submodule_patches.sh" + +if [[ -n "${ISO_PATH:-}" ]]; then + cmake --preset macos-release + cmake --build "$HOST_BUILD_DIR" --target iso_extract -j "${JOBS:-8}" + "$HOST_BUILD_DIR/tools/iso_extract/iso_extract" "$ISO_PATH" \ + default.xex "$ROOT/UnleashedRecompLib/private/default.xex" \ + shader.ar "$ROOT/UnleashedRecompLib/private/shader.ar" +fi + +if [[ -n "${XEXP_PATH:-}" ]]; then + mkdir -p "$ROOT/UnleashedRecompLib/private" + cp "$XEXP_PATH" "$ROOT/UnleashedRecompLib/private/default.xexp" +fi + +if [[ -n "${UPDATE_PATH:-}" ]]; then + cmake --preset macos-release + cmake --build "$HOST_BUILD_DIR" --target xcontent_extract -j "${JOBS:-8}" + "$HOST_BUILD_DIR/tools/xcontent_extract/xcontent_extract" "$UPDATE_PATH" \ + default.xexp "$ROOT/UnleashedRecompLib/private/default.xexp" +fi + +missing=0 +for file in \ + "$ROOT/UnleashedRecompLib/private/default.xex" \ + "$ROOT/UnleashedRecompLib/private/default.xexp" \ + "$ROOT/UnleashedRecompLib/private/shader.ar"; do + if [[ ! -f "$file" ]]; then + printf 'Missing required file: %s\n' "$file" >&2 + missing=1 + fi +done + +if [[ "$missing" -ne 0 ]]; then + printf '\nAdd these files from your own compatible Sonic Unleashed Xbox 360 dump before building an IPA.\n' >&2 + exit 1 +fi + +cmake --preset macos-release +cmake --build "$HOST_BUILD_DIR" --target XenonRecomp XenosRecomp x_decompress file_to_c -j "${JOBS:-8}" + +cmake --preset "$IOS_PRESET" +cmake --build "$BUILD_DIR" --target UnleashedRecomp -j "${JOBS:-8}" + +if [[ ! -d "$APP_PATH" ]]; then + printf 'Expected app bundle was not produced: %s\n' "$APP_PATH" >&2 + exit 1 +fi + +rm -rf "$IPA_DIR" +mkdir -p "$PAYLOAD_DIR" +cp -R "$APP_PATH" "$PAYLOAD_DIR/" + +if [[ -n "${BUNDLE_IDENTIFIER:-}" ]]; then + plutil -replace CFBundleIdentifier -string "$BUNDLE_IDENTIFIER" "$PAYLOAD_DIR/Unleashed Recompiled.app/Info.plist" +fi + +if [[ -z "${CODESIGN_IDENTITY:-}" ]] && security find-identity -v -p codesigning | grep -q "$DEFAULT_SIGNING_IDENTITY"; then + CODESIGN_IDENTITY="$DEFAULT_SIGNING_IDENTITY" +fi + +if [[ -n "${MOBILEPROVISION:-}" && ! -f "${MOBILEPROVISION:-}" ]]; then + printf 'Provisioning profile not found: %s\n' "$MOBILEPROVISION" >&2 + exit 1 +fi + +if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then + if [[ -z "${MOBILEPROVISION:-}" ]]; then + printf 'CODESIGN_IDENTITY is set, but MOBILEPROVISION is not. Producing an unsigned IPA for sideloading tools that sign on import.\n' >&2 + else + cp "$MOBILEPROVISION" "$PAYLOAD_DIR/Unleashed Recompiled.app/embedded.mobileprovision" + fi +fi + +if [[ -n "${CODESIGN_IDENTITY:-}" && -n "${MOBILEPROVISION:-}" ]]; then + codesign --force --sign "$CODESIGN_IDENTITY" \ + ${ENTITLEMENTS:+--entitlements "$ENTITLEMENTS"} \ + "$PAYLOAD_DIR/Unleashed Recompiled.app" +fi + +( + cd "$IPA_DIR" + zip -qry "$IPA_PATH" Payload +) + +printf '%s\n' "$IPA_PATH" diff --git a/tools/patches/plume-ios-renderers.patch b/tools/patches/plume-ios-renderers.patch new file mode 100644 index 000000000..6ea35ac94 --- /dev/null +++ b/tools/patches/plume-ios-renderers.patch @@ -0,0 +1,1548 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 6a7645a..4fcf8d8 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -8,10 +8,16 @@ if(APPLE) + endif() + + string(COMPARE EQUAL ${CMAKE_SYSTEM_NAME} "Linux" IS_LINUX) ++string(COMPARE EQUAL ${CMAKE_SYSTEM_NAME} "iOS" IS_IOS) ++if(IS_LINUX OR IS_IOS) ++ set(IS_SDL_VULKAN_PLATFORM ON) ++else() ++ set(IS_SDL_VULKAN_PLATFORM OFF) ++endif() + + # Project options + include(CMakeDependentOption) +-cmake_dependent_option(SDL_VULKAN_ENABLED "Enable SDL Vulkan integration" OFF IS_LINUX OFF) ++cmake_dependent_option(SDL_VULKAN_ENABLED "Enable SDL Vulkan integration" OFF IS_SDL_VULKAN_PLATFORM OFF) + cmake_dependent_option(D3D12_AGILITY_SDK_ENABLED "Enable D3D12 Agility SDK" OFF WIN32 OFF) + option(PLUME_BUILD_EXAMPLES "Build example applications" OFF) + +@@ -50,7 +56,7 @@ set(PLUME_SOURCES + ) + + # Platform-specific files +-if(APPLE) ++if(APPLE AND (NOT IS_IOS OR UNLEASHED_RECOMP_IOS_NATIVE_METAL)) + list(APPEND PLUME_SOURCES + plume_metal.cpp + plume_metal.h +@@ -94,10 +100,16 @@ if(D3D12_AGILITY_SDK_ENABLED) + endif() + + # Platform-specific includes +-if(APPLE) ++if(APPLE AND (NOT IS_IOS OR UNLEASHED_RECOMP_IOS_NATIVE_METAL)) + target_include_directories(plume PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/contrib/metal-cpp + ) ++ target_link_libraries(plume PUBLIC spirv-cross-msl "-framework Foundation" "-framework Metal" "-framework QuartzCore") ++ if(IS_IOS) ++ target_link_libraries(plume PUBLIC "-framework UIKit") ++ else() ++ target_link_libraries(plume PUBLIC "-framework AppKit" "-framework IOKit") ++ endif() + endif() + + # Add examples if requested +diff --git a/plume_apple.h b/plume_apple.h +index 731d3c4..3739b4f 100644 +--- a/plume_apple.h ++++ b/plume_apple.h +@@ -14,6 +14,9 @@ + namespace plume { + RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID); + ++ // iOS Metal: stop submissions and schedule outstanding work before suspension. ++ void setMetalApplicationActive(bool active); ++ + struct CocoaWindowAttributes { + int x, y; + int width, height; +diff --git a/plume_apple.mm b/plume_apple.mm +index 64e4dc9..1d14600 100644 +--- a/plume_apple.mm ++++ b/plume_apple.mm +@@ -7,10 +7,20 @@ + + #include "plume_apple.h" + ++#include ++ ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++#import ++#else + #import +-#import + #import ++#endif ++ ++#import + ++#include ++ ++#if !(TARGET_OS_IOS && !TARGET_OS_MACCATALYST) + static uint32_t plumeGetEntryProperty(io_registry_entry_t entry, CFStringRef propertyName) { + uint32_t value = 0; + CFTypeRef cfProp = IORegistryEntrySearchCFProperty(entry, kIOServicePlane, propertyName, kCFAllocatorDefault, kIORegistryIterateRecursively | kIORegistryIterateParents); +@@ -27,9 +37,62 @@ static uint32_t plumeGetEntryProperty(io_registry_entry_t entry, CFStringRef pro + + return value; + } ++#endif ++ ++static plume::CocoaWindowAttributes plumeGetWindowAttributes(void* windowHandle) { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ UIView *view = (__bridge UIView *)windowHandle; ++ CGRect bounds = [view bounds]; ++ CGFloat scaleFactor = [view contentScaleFactor]; ++ if (scaleFactor <= 0.0) { ++ scaleFactor = [[UIScreen mainScreen] scale]; ++ } ++ ++ return { ++ 0, ++ 0, ++ (int)round(bounds.size.width * scaleFactor), ++ (int)round(bounds.size.height * scaleFactor) ++ }; ++#else ++ NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; ++ NSRect contentFrame = [[nsWindow contentView] frame]; ++ CGFloat scaleFactor = [nsWindow backingScaleFactor]; ++ ++ return { ++ (int)round(contentFrame.origin.x), ++ (int)round(contentFrame.origin.y), ++ (int)round(contentFrame.size.width * scaleFactor), ++ (int)round(contentFrame.size.height * scaleFactor) ++ }; ++#endif ++} ++ ++static int plumeGetRefreshRate(void* windowHandle, int fallbackRate) { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ UIView *view = (__bridge UIView *)windowHandle; ++ UIScreen *screen = [[view window] screen] ?: [UIScreen mainScreen]; ++ if (@available(iOS 10.3, *)) { ++ return (int)[screen maximumFramesPerSecond]; ++ } ++ ++ return fallbackRate > 0 ? fallbackRate : 60; ++#else ++ NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; ++ NSScreen *screen = [nsWindow screen]; ++ if (@available(macOS 12.0, *)) { ++ return (int)[screen maximumFramesPerSecond]; ++ } ++ ++ return fallbackRate; ++#endif ++} + + namespace plume { + RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ return RenderDeviceVendor::APPLE; ++#else + io_service_t entry = IOServiceGetMatchingService(MACH_PORT_NULL, IORegistryEntryIDMatching(registryID)); + + if (entry) { +@@ -44,6 +107,7 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + } + + return RenderDeviceVendor::UNKNOWN; ++#endif + } + + // MARK: - CocoaWindow +@@ -53,19 +117,8 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + cachedAttributes = {0, 0, 0, 0}; + + if ([NSThread isMainThread]) { +- NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; +- +- cachedAttributes.x = (int)round(contentFrame.origin.x); +- cachedAttributes.y = (int)round(contentFrame.origin.y); +- cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); +- +- NSScreen *screen = [nsWindow screen]; +- if (@available(macOS 12.0, *)) { +- cachedRefreshRate.store((int)[screen maximumFramesPerSecond]); +- } ++ cachedAttributes = plumeGetWindowAttributes(windowHandle); ++ cachedRefreshRate.store(plumeGetRefreshRate(windowHandle, cachedRefreshRate.load())); + } else { + updateWindowAttributesInternal(true); + updateRefreshRateInternal(true); +@@ -76,15 +129,8 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + + void CocoaWindow::updateWindowAttributesInternal(bool forceSync) { + auto updateBlock = ^{ +- NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; +- + std::lock_guard lock(attributesMutex); +- cachedAttributes.x = (int)round(contentFrame.origin.x); +- cachedAttributes.y = (int)round(contentFrame.origin.y); +- cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ cachedAttributes = plumeGetWindowAttributes(windowHandle); + }; + + if (forceSync) { +@@ -96,11 +142,7 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + + void CocoaWindow::updateRefreshRateInternal(bool forceSync) { + auto updateBlock = ^{ +- NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSScreen *screen = [nsWindow screen]; +- if (@available(macOS 12.0, *)) { +- cachedRefreshRate.store((int)[screen maximumFramesPerSecond]); +- } ++ cachedRefreshRate.store(plumeGetRefreshRate(windowHandle, cachedRefreshRate.load())); + }; + + if (forceSync) { +@@ -112,16 +154,9 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + + void CocoaWindow::getWindowAttributes(CocoaWindowAttributes* attributes) const { + if ([NSThread isMainThread]) { +- NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; +- + { + std::lock_guard lock(attributesMutex); +- const_cast(this)->cachedAttributes.x = (int)round(contentFrame.origin.x); +- const_cast(this)->cachedAttributes.y = (int)round(contentFrame.origin.y); +- const_cast(this)->cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- const_cast(this)->cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ const_cast(this)->cachedAttributes = plumeGetWindowAttributes(windowHandle); + + *attributes = cachedAttributes; + } +@@ -137,16 +172,9 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + + int CocoaWindow::getRefreshRate() const { + if ([NSThread isMainThread]) { +- NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSScreen *screen = [nsWindow screen]; +- +- if (@available(macOS 12.0, *)) { +- int freshRate = (int)[screen maximumFramesPerSecond]; +- const_cast(this)->cachedRefreshRate.store(freshRate); +- return freshRate; +- } +- +- return cachedRefreshRate.load(); ++ int freshRate = plumeGetRefreshRate(windowHandle, cachedRefreshRate.load()); ++ const_cast(this)->cachedRefreshRate.store(freshRate); ++ return freshRate; + } else { + int rate = cachedRefreshRate.load(); + +@@ -157,6 +185,9 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + } + + void CocoaWindow::toggleFullscreen() { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ return; ++#else + if ([NSThread isMainThread]) { + NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; + [nsWindow toggleFullScreen:NULL]; +@@ -166,5 +197,6 @@ RenderDeviceVendor getRenderDeviceVendor(uint64_t registryID) { + [nsWindow toggleFullScreen:NULL]; + }); + } ++#endif + } + } +diff --git a/plume_metal.cpp b/plume_metal.cpp +index ebbbaa3..b34aefb 100644 +--- a/plume_metal.cpp ++++ b/plume_metal.cpp +@@ -14,12 +14,75 @@ + #include + #include + ++#include ++ + #include ++#include + #include ++#include ++#include ++#include + + #include "plume_metal.h" + + namespace plume { ++#if PLUME_IOS ++ static std::mutex submissionMutex; ++ static std::condition_variable submissionCondition; ++ static bool applicationActive = true; ++ static std::unordered_map pendingSubmissions; ++ ++ static std::unique_lock lockSubmissions() { ++ std::unique_lock lock(submissionMutex); ++ while (!applicationActive) { ++ if (pthread_main_np()) { ++ // The installer renders on the UIKit thread. Keep activation ++ // notifications deliverable while a submission is suspended. ++ lock.unlock(); ++ CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true); ++ lock.lock(); ++ } ++ else { ++ submissionCondition.wait(lock); ++ } ++ } ++ return lock; ++ } ++ ++ // Called under submissionMutex; keep only the tail of each ordered queue. ++ static void trackSubmission(MTL::CommandQueue *queue, MTL::CommandBuffer *buffer) { ++ auto &previous = pendingSubmissions[queue]; ++ if (previous != nullptr) { ++ previous->release(); ++ } ++ previous = buffer->retain(); ++ } ++ ++ void setMetalApplicationActive(bool active) { ++ { ++ std::lock_guard lock(submissionMutex); ++ if (applicationActive == active) { ++ return; ++ } ++ applicationActive = active; ++ if (!active) { ++ // iOS may suspend us as soon as its lifecycle callback returns. ++ // Draining scheduling here also prevents lost event signals from ++ // rejected background buffers poisoning all subsequent frames. ++ for (auto &[queue, buffer] : pendingSubmissions) { ++ buffer->waitUntilScheduled(); ++ buffer->release(); ++ } ++ pendingSubmissions.clear(); ++ } ++ fprintf(stderr, "Metal lifecycle: %s\n", active ? "resumed" : "suspended (submissions scheduled)"); ++ } ++ if (active) { ++ submissionCondition.notify_all(); ++ } ++ } ++#endif ++ + // MARK: - Constants + + static constexpr size_t MAX_DRAWABLES = 3; +@@ -38,6 +101,310 @@ namespace plume { + return (n + alignment - 1) & ~(alignment - 1); + } + ++ bool supportsMetalFamily(MTL::Device* device, MTL::GPUFamily family) { ++ return device != nullptr && device->supportsFamily(family); ++ } ++ ++ bool supportsBufferDeviceAddress(MTL::Device* device) { ++ return supportsMetalFamily(device, MTL::GPUFamilyMetal3); ++ } ++ ++ uint64_t getRecommendedWorkingSetSize(MTL::Device* device) { ++#if PLUME_IOS ++ // recommendedMaxWorkingSetSize is iOS 16+. The app requires Metal 3 for ++ // gpuAddress, but keep startup safe on older runtimes and SDK shims. ++ (void)device; ++ return 2ULL * 1024ULL * 1024ULL * 1024ULL; ++#else ++ return device->recommendedMaxWorkingSetSize(); ++#endif ++ } ++ ++ bool hasUnifiedMemory(MTL::Device* device) { ++#if PLUME_IOS ++ (void)device; ++ return true; ++#else ++ return device->hasUnifiedMemory(); ++#endif ++ } ++ ++ bool supportsProgrammableSamplePositions(MTL::Device* device) { ++#if PLUME_IOS ++ return supportsMetalFamily(device, MTL::GPUFamilyApple3); ++#else ++ return device->programmableSamplePositionsSupported(); ++#endif ++ } ++ ++ spirv_cross::CompilerMSL::Options::ArgumentBuffersTier getArgumentBuffersTier(MTL::Device* device) { ++#if PLUME_IOS ++ return supportsMetalFamily(device, MTL::GPUFamilyApple3) ++ ? spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier2 ++ : spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier1; ++#else ++ return device->argumentBuffersSupport() == MTL::ArgumentBuffersTier2 ++ ? spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier2 ++ : spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier1; ++#endif ++ } ++ ++ uint32_t getSpirvResourceArrayCount(const spirv_cross::SPIRType& type) { ++ uint32_t count = 1; ++ for (const uint32_t dimension : type.array) { ++ if (dimension == 0) { ++ return count; ++ } ++ count *= dimension; ++ } ++ ++ return count; ++ } ++ ++ void addSpirvResourceBinding(spirv_cross::CompilerMSL& compiler, const spirv_cross::Resource& resource) { ++ if (!compiler.has_decoration(resource.id, spv::DecorationDescriptorSet) || ++ !compiler.has_decoration(resource.id, spv::DecorationBinding)) { ++ return; ++ } ++ ++ const uint32_t descriptorSet = compiler.get_decoration(resource.id, spv::DecorationDescriptorSet); ++ const uint32_t bindingIndex = compiler.get_decoration(resource.id, spv::DecorationBinding); ++ const spirv_cross::SPIRType& type = compiler.get_type(resource.type_id); ++ ++ spirv_cross::MSLResourceBinding binding = {}; ++ binding.stage = compiler.get_execution_model(); ++ binding.basetype = type.basetype; ++ binding.desc_set = descriptorSet; ++ binding.binding = bindingIndex; ++ binding.count = getSpirvResourceArrayCount(type); ++ binding.msl_buffer = bindingIndex; ++ binding.msl_texture = bindingIndex; ++ binding.msl_sampler = bindingIndex; ++ ++ compiler.add_msl_resource_binding(binding); ++ } ++ ++ struct MetalShaderSource { ++ std::string source; ++ std::string entryPointName; ++ bool writesDepth = false; ++ }; ++ ++#if PLUME_IOS ++ bool isMetalIdentifierChar(const char c) { ++ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; ++ } ++ ++ std::string findMetalEntryPointName(const std::string& source, const std::string& fallbackName) { ++ for (const char* stagePrefix : { "vertex ", "fragment ", "kernel " }) { ++ size_t searchOffset = 0; ++ while (true) { ++ const size_t stageOffset = source.find(stagePrefix, searchOffset); ++ if (stageOffset == std::string::npos) { ++ break; ++ } ++ ++ const size_t openParenOffset = source.find('(', stageOffset); ++ if (openParenOffset == std::string::npos) { ++ break; ++ } ++ ++ size_t nameEnd = openParenOffset; ++ while (nameEnd > stageOffset && (source[nameEnd - 1] == ' ' || source[nameEnd - 1] == '\t' || source[nameEnd - 1] == '\r' || source[nameEnd - 1] == '\n')) { ++ nameEnd--; ++ } ++ ++ size_t nameStart = nameEnd; ++ while (nameStart > stageOffset && isMetalIdentifierChar(source[nameStart - 1])) { ++ nameStart--; ++ } ++ ++ if (nameStart < nameEnd) { ++ return source.substr(nameStart, nameEnd - nameStart); ++ } ++ ++ searchOffset = openParenOffset + 1; ++ } ++ } ++ ++ return fallbackName; ++ } ++ ++ size_t findMetalEntryPointOpenParen(const std::string& source, const std::string& entryPointName, const char* builtinName) { ++ if (!entryPointName.empty()) { ++ const std::string functionNeedle = entryPointName + "("; ++ const size_t functionNameOffset = source.find(functionNeedle); ++ if (functionNameOffset != std::string::npos) { ++ return functionNameOffset + entryPointName.size(); ++ } ++ } ++ ++ for (const char* stagePrefix : { "vertex ", "fragment ", "kernel " }) { ++ size_t searchOffset = 0; ++ while (true) { ++ const size_t stageOffset = source.find(stagePrefix, searchOffset); ++ if (stageOffset == std::string::npos) { ++ break; ++ } ++ ++ const size_t openParenOffset = source.find('(', stageOffset); ++ const size_t openBraceOffset = source.find('{', openParenOffset); ++ if (openParenOffset == std::string::npos || openBraceOffset == std::string::npos) { ++ break; ++ } ++ ++ if (source.find(builtinName, openBraceOffset) != std::string::npos) { ++ return openParenOffset; ++ } ++ ++ searchOffset = openBraceOffset + 1; ++ } ++ } ++ ++ return std::string::npos; ++ } ++ ++ void patchImplicitBaseBuiltinArgument(std::string& source, const std::string& entryPointName, const char* builtinName, const char* builtinAttribute) { ++ if (source.find(builtinName) == std::string::npos || source.find(builtinAttribute) != std::string::npos) { ++ return; ++ } ++ ++ const size_t openParenOffset = findMetalEntryPointOpenParen(source, entryPointName, builtinName); ++ if (openParenOffset == std::string::npos) { ++ return; ++ } ++ ++ size_t closeParenOffset = std::string::npos; ++ int depth = 0; ++ ++ for (size_t i = openParenOffset; i < source.size(); i++) { ++ if (source[i] == '(') { ++ depth++; ++ } ++ else if (source[i] == ')') { ++ depth--; ++ if (depth == 0) { ++ closeParenOffset = i; ++ break; ++ } ++ } ++ } ++ ++ if (closeParenOffset == std::string::npos) { ++ return; ++ } ++ ++ const bool hasArguments = source.find_first_not_of(" \t\r\n", openParenOffset + 1) < closeParenOffset; ++ source.insert(closeParenOffset, std::string(hasArguments ? ", uint " : "uint ") + builtinName + " [[" + builtinAttribute + "]]"); ++ } ++ ++ void patchImplicitBaseBuiltinArguments(std::string& source, const std::string& entryPointName) { ++ patchImplicitBaseBuiltinArgument(source, entryPointName, "gl_BaseVertex", "base_vertex"); ++ patchImplicitBaseBuiltinArgument(source, entryPointName, "gl_BaseInstance", "base_instance"); ++ } ++#endif ++ ++ MetalShaderSource compileSpirvToMetalSource(const MetalDevice* device, const void* data, const uint64_t size, const char* entryPointName, bool enableDepth = true) { ++ if ((size % sizeof(uint32_t)) != 0) { ++ throw std::runtime_error("SPIR-V shader data is not 32-bit aligned."); ++ } ++ ++ spirv_cross::CompilerMSL compiler(static_cast(data), size / sizeof(uint32_t)); ++ std::string spirvEntryPointName = entryPointName != nullptr ? entryPointName : ""; ++ spv::ExecutionModel executionModel = compiler.get_execution_model(); ++ ++ if (!spirvEntryPointName.empty()) { ++ for (const auto& entryPoint : compiler.get_entry_points_and_stages()) { ++ if (entryPoint.name == spirvEntryPointName) { ++ compiler.set_entry_point(entryPoint.name, entryPoint.execution_model); ++ executionModel = entryPoint.execution_model; ++ break; ++ } ++ } ++ } ++ ++ if (spirvEntryPointName.empty()) { ++ const auto entryPoints = compiler.get_entry_points_and_stages(); ++ if (!entryPoints.empty()) { ++ spirvEntryPointName = entryPoints.front().name; ++ executionModel = entryPoints.front().execution_model; ++ } ++ } ++ ++ spirv_cross::CompilerMSL::Options options = compiler.get_msl_options(); ++#if PLUME_IOS ++ options.platform = spirv_cross::CompilerMSL::Options::iOS; ++ options.ios_support_base_vertex_instance = true; ++ options.emulate_cube_array = true; ++ spirv_cross::CompilerGLSL::Options commonOptions = compiler.get_common_options(); ++ commonOptions.vertex.flip_vert_y = true; ++ compiler.set_common_options(commonOptions); ++#else ++ options.platform = spirv_cross::CompilerMSL::Options::macOS; ++#endif ++ options.set_msl_version(3, 0); ++ options.argument_buffers = true; ++ options.argument_buffers_tier = getArgumentBuffersTier(device->mtl); ++ options.enable_base_index_zero = true; ++ options.enable_decoration_binding = true; ++ options.force_active_argument_buffer_resources = true; ++ options.texture_buffer_native = true; ++ options.enable_frag_depth_builtin = enableDepth; ++ ++ compiler.set_msl_options(options); ++ ++ for (uint32_t descriptorSet = 0; descriptorSet < MAX_DESCRIPTOR_SET_BINDINGS; descriptorSet++) { ++ spirv_cross::MSLResourceBinding argumentBufferBinding = {}; ++ argumentBufferBinding.stage = executionModel; ++ argumentBufferBinding.desc_set = descriptorSet; ++ argumentBufferBinding.binding = spirv_cross::kArgumentBufferBinding; ++ argumentBufferBinding.msl_buffer = DESCRIPTOR_SETS_BINDING_INDEX + descriptorSet; ++ compiler.add_msl_resource_binding(argumentBufferBinding); ++ } ++ ++ const spirv_cross::ShaderResources resources = compiler.get_shader_resources(); ++ const auto addResourceList = [&](const auto& list) { ++ for (const spirv_cross::Resource& resource : list) { ++ addSpirvResourceBinding(compiler, resource); ++ } ++ }; ++ ++ addResourceList(resources.uniform_buffers); ++ addResourceList(resources.storage_buffers); ++ addResourceList(resources.stage_inputs); ++ addResourceList(resources.stage_outputs); ++ addResourceList(resources.subpass_inputs); ++ addResourceList(resources.storage_images); ++ addResourceList(resources.sampled_images); ++ addResourceList(resources.separate_images); ++ addResourceList(resources.separate_samplers); ++ addResourceList(resources.atomic_counters); ++ addResourceList(resources.acceleration_structures); ++ ++ if (!resources.push_constant_buffers.empty()) { ++ const spirv_cross::SPIRType& pushConstantType = compiler.get_type(resources.push_constant_buffers.front().type_id); ++ spirv_cross::MSLResourceBinding pushConstantsBinding = {}; ++ pushConstantsBinding.stage = executionModel; ++ pushConstantsBinding.basetype = pushConstantType.basetype; ++ pushConstantsBinding.desc_set = spirv_cross::kPushConstDescSet; ++ pushConstantsBinding.binding = spirv_cross::kPushConstBinding; ++ pushConstantsBinding.count = 1; ++ pushConstantsBinding.msl_buffer = PUSH_CONSTANTS_BINDING_INDEX; ++ compiler.add_msl_resource_binding(pushConstantsBinding); ++ } ++ ++ MetalShaderSource translatedSource = {}; ++ translatedSource.entryPointName = compiler.get_cleansed_entry_point_name(spirvEntryPointName, executionModel); ++ translatedSource.source = compiler.compile(); ++ translatedSource.writesDepth = compiler.has_active_builtin(spv::BuiltInFragDepth, spv::StorageClassOutput); ++#if PLUME_IOS ++ translatedSource.entryPointName = findMetalEntryPointName(translatedSource.source, translatedSource.entryPointName); ++ patchImplicitBaseBuiltinArguments(translatedSource.source, translatedSource.entryPointName); ++#endif ++ ++ return translatedSource; ++ } ++ + uint64_t createClearPipelineKey(MTL::RenderPipelineDescriptor *pipelineDesc, bool depthWriteEnabled, bool stencilWriteEnabled) { + auto colorFormat = [&](uint32_t index) { + if (auto colorAttachment = pipelineDesc->colorAttachments()->object(index)) { +@@ -1056,7 +1423,7 @@ namespace plume { + MTL::ArgumentDescriptor *argumentDesc = MTL::ArgumentDescriptor::alloc()->init(); + argumentDesc->setDataType(mapDataType(lastRange.type)); + argumentDesc->setIndex(lastRange.binding); +- argumentDesc->setArrayLength(device->capabilities.maxTextureSize); ++ argumentDesc->setArrayLength(desc.boundlessRangeSize); + + if (lastRange.type == RenderDescriptorRangeType::TEXTURE) { + argumentDesc->setTextureType(MTL::TextureType2D); +@@ -1133,13 +1500,14 @@ namespace plume { + } + + uint64_t MetalBuffer::getDeviceAddress() const { +- assert(device->mtl->supportsFamily(MTL::GPUFamilyMetal3) && "Device address is only supported on Metal3 devices."); ++ assert(supportsBufferDeviceAddress(device->mtl) && "Device address is only supported on Metal3 devices."); + return mtl->gpuAddress(); + } + + // MetalBufferFormattedView + + MetalBufferFormattedView::MetalBufferFormattedView(MetalBuffer *buffer, RenderFormat format) { ++ const auto releasePool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); + assert(buffer != nullptr); + assert((buffer->desc.flags & RenderBufferFlag::FORMATTED) && "Buffer must allow formatted views."); + +@@ -1159,7 +1527,6 @@ namespace plume { + MTL::TextureDescriptor *descriptor = MTL::TextureDescriptor::textureBufferDescriptor(pixelFormat, width, options, usage); + this->texture = buffer->mtl->newTexture(descriptor, 0, bytesPerRow); + +- descriptor->release(); + } + + MetalBufferFormattedView::~MetalBufferFormattedView() { +@@ -1275,27 +1642,57 @@ namespace plume { + // MetalShader + + MetalShader::MetalShader(const MetalDevice *device, const void *data, uint64_t size, const char *entryPointName, const RenderShaderFormat format) { ++ const auto releasePool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); + assert(device != nullptr); + assert(data != nullptr); + assert(size > 0); +- assert(format == RenderShaderFormat::METAL); + + this->format = format; +- this->functionName = (entryPointName != nullptr) ? NS::String::string(entryPointName, NS::UTF8StringEncoding) : MTLSTR(""); ++ this->functionName = NS::String::alloc()->init(entryPointName != nullptr ? entryPointName : "", NS::UTF8StringEncoding); + + NS::Error *error = nullptr; +- const dispatch_data_t dispatchData = dispatch_data_create(data, size, dispatch_get_main_queue(), ^{}); +- library = device->mtl->newLibrary(dispatchData, &error); ++ if (format == RenderShaderFormat::METAL) { ++ const dispatch_data_t dispatchData = dispatch_data_create(data, size, dispatch_get_main_queue(), ^{}); ++ library = device->mtl->newLibrary(dispatchData, &error); ++ } else if (format == RenderShaderFormat::SPIRV) { ++ try { ++ const MetalShaderSource metalSource = compileSpirvToMetalSource(device, data, size, entryPointName); ++ functionName->release(); ++ functionName = NS::String::alloc()->init(metalSource.entryPointName.c_str(), NS::UTF8StringEncoding); ++ MTL::CompileOptions *compileOptions = MTL::CompileOptions::alloc()->init(); ++ compileOptions->setFastMathEnabled(true); ++ compileOptions->setLanguageVersion(MTL::LanguageVersion3_0); ++ library = device->mtl->newLibrary(NS::String::string(metalSource.source.c_str(), NS::UTF8StringEncoding), compileOptions, &error); ++ if (library != nullptr && metalSource.writesDepth) { ++ const auto noDepthSource = compileSpirvToMetalSource(device, data, size, entryPointName, false); ++ libraryWithoutDepth = device->mtl->newLibrary(NS::String::string(noDepthSource.source.c_str(), NS::UTF8StringEncoding), compileOptions, &error); ++ } ++ compileOptions->release(); ++ } catch (const std::exception& e) { ++ fprintf(stderr, "SPIR-V to MSL translation failed: %s.\n", e.what()); ++ return; ++ } ++ } else { ++ assert(false && "Unsupported Metal shader format."); ++ return; ++ } + + if (error != nullptr) { +- fprintf(stderr, "MTLDevice newLibraryWithSource: failed with error %s.\n", error->localizedDescription()->utf8String()); ++ fprintf(stderr, "MTLDevice newLibrary: failed with error %s.\n", error->localizedDescription()->utf8String()); + return; + } + } + + MetalShader::~MetalShader() { +- functionName->release(); +- library->release(); ++ if (functionName) { ++ functionName->release(); ++ } ++ if (library) { ++ library->release(); ++ } ++ if (libraryWithoutDepth) { ++ libraryWithoutDepth->release(); ++ } + if (debugName) { + debugName->release(); + } +@@ -1305,11 +1702,18 @@ namespace plume { + if (debugName) { + debugName->release(); + } +- debugName = NS::String::string(name.c_str(), NS::UTF8StringEncoding); +- library->setLabel(debugName); ++ debugName = NS::String::alloc()->init(name.c_str(), NS::UTF8StringEncoding); ++ if (library) { ++ library->setLabel(debugName); ++ } + } + +- MTL::Function* MetalShader::createFunction(const RenderSpecConstant *specConstants, const uint32_t specConstantsCount) const { ++ MTL::Function* MetalShader::createFunction(const RenderSpecConstant *specConstants, const uint32_t specConstantsCount, bool hasDepthAttachment) const { ++ const auto releasePool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); ++ if (library == nullptr) { ++ return nullptr; ++ } ++ + MTL::FunctionConstantValues *values = MTL::FunctionConstantValues::alloc()->init(); + if (specConstants != nullptr) { + for (uint32_t i = 0; i < specConstantsCount; i++) { +@@ -1318,13 +1722,18 @@ namespace plume { + } + } + NS::Error *error = nullptr; +- MTL::Function *function = library->newFunction(functionName, values, &error); ++ MTL::Library *selectedLibrary = !hasDepthAttachment && libraryWithoutDepth ? libraryWithoutDepth : library; ++ MTL::Function *function = selectedLibrary->newFunction(functionName, values, &error); + values->release(); + + if (error != nullptr) { + fprintf(stderr, "MTLLibrary newFunction: failed with error: %s.\n", error->localizedDescription()->utf8String()); + return nullptr; + } ++ if (function == nullptr) { ++ fprintf(stderr, "MTLLibrary newFunction: failed without an error object.\n"); ++ return nullptr; ++ } + + if (debugName) { + function->setLabel(debugName); +@@ -1384,6 +1793,11 @@ namespace plume { + + MTL::ComputePipelineDescriptor *descriptor = MTL::ComputePipelineDescriptor::alloc()->init(); + MTL::Function *function = computeShader->createFunction(desc.specConstants, desc.specConstantsCount); ++ if (function == nullptr) { ++ fprintf(stderr, "Metal compute pipeline creation failed: shader function is null.\n"); ++ descriptor->release(); ++ return; ++ } + descriptor->setComputeFunction(function); + descriptor->setLabel(computeShader->functionName); + +@@ -1437,6 +1851,13 @@ namespace plume { + const MetalShader *metalShader = static_cast(desc.vertexShader); + + MTL::Function *vertexFunction = metalShader->createFunction(desc.specConstants, desc.specConstantsCount); ++ if (vertexFunction == nullptr) { ++ fprintf(stderr, "Metal graphics pipeline creation failed: vertex shader function is null.\n"); ++ descriptor->release(); ++ releasePool->release(); ++ return; ++ } ++ + descriptor->setVertexFunction(vertexFunction); + + MTL::VertexDescriptor *vertexDescriptor = MTL::VertexDescriptor::alloc()->init(); +@@ -1478,7 +1899,16 @@ namespace plume { + + if (desc.pixelShader != nullptr) { + const MetalShader *pixelShader = static_cast(desc.pixelShader); +- MTL::Function *fragmentFunction = pixelShader->createFunction(desc.specConstants, desc.specConstantsCount); ++ MTL::Function *fragmentFunction = pixelShader->createFunction(desc.specConstants, desc.specConstantsCount, desc.depthTargetFormat != RenderFormat::UNKNOWN); ++ if (fragmentFunction == nullptr) { ++ fprintf(stderr, "Metal graphics pipeline creation failed: fragment shader function is null.\n"); ++ vertexDescriptor->release(); ++ vertexFunction->release(); ++ descriptor->release(); ++ releasePool->release(); ++ return; ++ } ++ + descriptor->setFragmentFunction(fragmentFunction); + fragmentFunction->release(); + } +@@ -1547,8 +1977,24 @@ namespace plume { + state.depthBiasSlopeFactor = desc.slopeScaledDepthBias; + } + +- if (error != nullptr) { +- fprintf(stderr, "MTLDevice newRenderPipelineState: failed with error %s.\n", error->localizedDescription()->utf8String()); ++ if (error != nullptr || state.renderPipelineState == nullptr) { ++ if (error != nullptr) { ++ fprintf(stderr, "MTLDevice newRenderPipelineState: failed with error %s.\n", error->localizedDescription()->utf8String()); ++ } else { ++ fprintf(stderr, "MTLDevice newRenderPipelineState: failed without an error object.\n"); ++ } ++ ++ vertexDescriptor->release(); ++ vertexFunction->release(); ++ descriptor->release(); ++ depthStencilDescriptor->release(); ++ if (frontFaceStencilDescriptor) { ++ frontFaceStencilDescriptor->release(); ++ } ++ if (backFaceStencilDescriptor) { ++ backFaceStencilDescriptor->release(); ++ } ++ releasePool->release(); + return; + } + +@@ -1718,7 +2164,7 @@ namespace plume { + + const uint32_t indexBase = setLayout->descriptorIndexBases[descriptorIndex]; + const uint32_t bindingIndex = setLayout->descriptorBindingIndices[descriptorIndex]; +- const auto &setLayoutBinding = setLayout->setBindings[indexBase]; ++ const auto &setLayoutBinding = *setLayout->getBinding(bindingIndex); + const MTL::DataType dtype = mapDataType(setLayoutBinding.descriptorType); + MTL::Resource *nativeResource = nullptr; + RenderDescriptorRangeType descriptorType = getDescriptorType(bindingIndex); +@@ -1777,7 +2223,7 @@ namespace plume { + } + + MetalDrawable::~MetalDrawable() { +- mtl->release(); ++ if (mtl) mtl->release(); + } + + std::unique_ptr MetalDrawable::createTextureView(const RenderTextureViewDesc& desc) const { +@@ -1793,6 +2239,11 @@ namespace plume { + + MetalSwapChain::MetalSwapChain(MetalCommandQueue *commandQueue, const RenderWindow renderWindow, uint32_t textureCount, const RenderFormat format) { + this->layer = static_cast(renderWindow.view); ++ if (layer == nullptr) { ++ fprintf(stderr, "Metal swapchain creation failed: SDL did not provide a CAMetalLayer.\n"); ++ return; ++ } ++ + layer->setDevice(commandQueue->device->mtl); + layer->setPixelFormat(mapPixelFormat(format)); + +@@ -1819,7 +2270,14 @@ namespace plume { + } + + bool MetalSwapChain::present(const uint32_t textureIndex, RenderCommandSemaphore **waitSemaphores, const uint32_t waitSemaphoreCount) { +- assert(layer != nullptr && "Cannot present without a valid layer."); ++ if (layer == nullptr) { ++ return false; ++ } ++ ++#if PLUME_IOS ++ auto submissionLock = lockSubmissions(); ++#endif ++ + NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init(); + + const MetalDrawable &drawable = drawables[textureIndex]; +@@ -1842,12 +2300,14 @@ namespace plume { + drawableMtl->present(); + }); + +- presentBuffer->addCompletedHandler([drawableMtl, this](MTL::CommandBuffer* cmdBuffer) { +- currentAvailableDrawableIndex = (currentAvailableDrawableIndex + 1) % MAX_DRAWABLES; ++ presentBuffer->addCompletedHandler([drawableMtl](MTL::CommandBuffer* cmdBuffer) { + drawableMtl->release(); + }); + + presentBuffer->commit(); ++#if PLUME_IOS ++ trackSubmission(commandQueue->mtl, presentBuffer); ++#endif + + releasePool->release(); + +@@ -1861,7 +2321,7 @@ namespace plume { + bool MetalSwapChain::resize() { + getWindowSize(width, height); + +- if (width == 0 || height == 0) { ++ if (layer == nullptr || width == 0 || height == 0) { + return false; + } + +@@ -1886,11 +2346,29 @@ namespace plume { + } + + void MetalSwapChain::setVsyncEnabled(const bool vsyncEnabled) { ++ if (layer == nullptr) { ++ return; ++ } ++ ++#if PLUME_IOS ++ // CAMetalLayer.displaySyncEnabled is unavailable on iOS. iOS drawables ++ // are display-synchronized by default, so keep this as a no-op. ++ (void)vsyncEnabled; ++#else + layer->setDisplaySyncEnabled(vsyncEnabled); ++#endif + } + + bool MetalSwapChain::isVsyncEnabled() const { ++ if (layer == nullptr) { ++ return false; ++ } ++ ++#if PLUME_IOS ++ return true; ++#else + return layer->displaySyncEnabled(); ++#endif + } + + uint32_t MetalSwapChain::getWidth() const { +@@ -1910,25 +2388,42 @@ namespace plume { + assert(textureIndex != nullptr); + assert(*textureIndex < MAX_DRAWABLES); + ++ if (layer == nullptr) { ++ return false; ++ } ++ + NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init(); + + // Create a command buffer just to encode the signal ++#if PLUME_IOS ++ auto submissionLock = lockSubmissions(); ++#endif + MTL::CommandBuffer *acquireBuffer = commandQueue->mtl->commandBufferWithUnretainedReferences(); + acquireBuffer->setLabel(MTLSTR("Acquire Drawable Command Buffer")); + const MetalCommandSemaphore *interfaceSemaphore = static_cast(signalSemaphore); + acquireBuffer->enqueue(); + acquireBuffer->encodeSignalEvent(interfaceSemaphore->mtl, interfaceSemaphore->mtlEventValue); + acquireBuffer->commit(); ++#if PLUME_IOS ++ trackSubmission(commandQueue->mtl, acquireBuffer); ++ submissionLock.unlock(); ++#endif + ++ MetalDrawable &drawable = drawables[currentAvailableDrawableIndex]; ++ if (drawable.mtl) { ++ drawable.mtl->release(); ++ drawable.mtl = nullptr; ++ } + CA::MetalDrawable *nextDrawable = layer->nextDrawable(); + if (nextDrawable == nullptr) { + fprintf(stderr, "No more drawables available for rendering.\n"); ++ releasePool->release(); + return false; + } + + // Set the texture index and drawable data + *textureIndex = currentAvailableDrawableIndex; +- MetalDrawable &drawable = drawables[currentAvailableDrawableIndex]; ++ currentAvailableDrawableIndex = (currentAvailableDrawableIndex + 1) % MAX_DRAWABLES; + drawable.desc.width = width; + drawable.desc.height = height; + drawable.desc.flags = RenderTextureFlag::RENDER_TARGET; +@@ -1954,10 +2449,20 @@ namespace plume { + } + + uint32_t MetalSwapChain::getRefreshRate() const { ++ if (windowWrapper == nullptr) { ++ return 60; ++ } ++ + return windowWrapper->getRefreshRate(); + } + + void MetalSwapChain::getWindowSize(uint32_t &dstWidth, uint32_t &dstHeight) const { ++ if (windowWrapper == nullptr) { ++ dstWidth = 0; ++ dstHeight = 0; ++ return; ++ } ++ + CocoaWindowAttributes attributes; + windowWrapper->getWindowAttributes(&attributes); + dstWidth = attributes.width; +@@ -2084,12 +2589,15 @@ namespace plume { + } + + MetalCommandList::~MetalCommandList() { +- mtl->release(); ++ if (mtl) mtl->release(); + } + + void MetalCommandList::begin() { ++ const auto releasePool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); + assert(mtl == nullptr); ++ graphicsAddressResources.clear(); + mtl = queue->mtl->commandBufferWithUnretainedReferences(); ++ mtl->retain(); + mtl->setLabel(MTLSTR("RT64 Command List")); + } + +@@ -2110,6 +2618,11 @@ namespace plume { + } + + void MetalCommandList::commit() { ++ mtl->addCompletedHandler([](MTL::CommandBuffer *buffer) { ++ if (buffer->status() == MTL::CommandBufferStatusError) { ++ fprintf(stderr, "Metal command buffer failed: %s\n", buffer->error()->localizedDescription()->utf8String()); ++ } ++ }); + mtl->commit(); + mtl->release(); + mtl = nullptr; +@@ -2245,7 +2758,7 @@ namespace plume { + pushConstants[rangeIndex].binding = range.binding; + pushConstants[rangeIndex].set = range.set; + pushConstants[rangeIndex].offset = range.offset; +- pushConstants[rangeIndex].size = alignUp(range.size); ++ pushConstants[rangeIndex].size = range.size; + pushConstants[rangeIndex].stageFlags = range.stageFlags; + + dirtyComputeState.pushConstants = 1; +@@ -2298,7 +2811,7 @@ namespace plume { + pushConstants[rangeIndex].binding = range.binding; + pushConstants[rangeIndex].set = range.set; + pushConstants[rangeIndex].offset = range.offset; +- pushConstants[rangeIndex].size = alignUp(range.size); ++ pushConstants[rangeIndex].size = range.size; + pushConstants[rangeIndex].stageFlags = range.stageFlags; + + dirtyGraphicsState.pushConstants = 1; +@@ -2316,7 +2829,11 @@ namespace plume { + } + + void MetalCommandList::setGraphicsRootDescriptor(RenderBufferReference bufferReference, uint32_t rootDescriptorIndex) { +- assert(false && "Root descriptors are not supported in Metal."); ++ const auto *buffer = static_cast(bufferReference.ref); ++ const uint64_t address = buffer->mtl->gpuAddress() + bufferReference.offset; ++ // Raw GPU addresses do not establish residency implicitly. ++ graphicsAddressResources.insert(buffer->mtl); ++ setGraphicsPushConstants(0, &address, sizeof(address) * rootDescriptorIndex, sizeof(address)); + } + + void MetalCommandList::setRaytracingPipelineLayout(const RenderPipelineLayout *pipelineLayout) { +@@ -3116,6 +3633,7 @@ namespace plume { + + if (activeBlitEncoder == nullptr) { + activeBlitEncoder = mtl->blitCommandEncoder(device->sharedBlitDescriptor); ++ activeBlitEncoder->retain(); + activeBlitEncoder->setLabel(MTLSTR("Copy Blit Encoder")); + } + } +@@ -3136,6 +3654,7 @@ namespace plume { + + if (activeResolveComputeEncoder == nullptr) { + activeResolveComputeEncoder = mtl->computeCommandEncoder(); ++ activeResolveComputeEncoder->retain(); + activeResolveComputeEncoder->setLabel(MTLSTR("Resolve Texture Encoder")); + activeResolveComputeEncoder->setComputePipelineState(device->resolveTexturePipelineState); + } +@@ -3161,6 +3680,9 @@ namespace plume { + } + } else { + auto* renderEncoder = static_cast(encoder); ++ for (auto* buffer : graphicsAddressResources) { ++ renderEncoder->useResource(buffer, MTL::ResourceUsageRead, MTL::RenderStageVertex | MTL::RenderStageFragment); ++ } + for (const auto* descriptorSet : currentEncoderDescriptorSets) { + for (const auto& entry : descriptorSet->resourceEntries) { + if (entry.resource != nullptr) { +@@ -3203,6 +3725,14 @@ namespace plume { + } + + MetalCommandQueue::~MetalCommandQueue() { ++#if PLUME_IOS ++ std::lock_guard lock(submissionMutex); ++ auto pending = pendingSubmissions.find(mtl); ++ if (pending != pendingSubmissions.end()) { ++ pending->second->release(); ++ pendingSubmissions.erase(pending); ++ } ++#endif + mtl->release(); + } + +@@ -3216,6 +3746,10 @@ namespace plume { + } + + void MetalCommandQueue::executeCommandLists(const RenderCommandList **commandLists, const uint32_t commandListCount, RenderCommandSemaphore **waitSemaphores, const uint32_t waitSemaphoreCount, RenderCommandSemaphore **signalSemaphores, const uint32_t signalSemaphoreCount, RenderCommandFence *signalFence) { ++#if PLUME_IOS ++ auto submissionLock = lockSubmissions(); ++#endif ++ const auto releasePool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); + assert(commandLists != nullptr); + assert(commandListCount > 0); + +@@ -3258,6 +3792,9 @@ namespace plume { + } + + MetalCommandList *mutableCommandList = const_cast(interfaceCommandList); ++#if PLUME_IOS ++ trackSubmission(mtl, mutableCommandList->mtl); ++#endif + mutableCommandList->commit(); + } + +@@ -3308,6 +3845,9 @@ namespace plume { + this->renderInterface = renderInterface; + + // Device Selection ++#if PLUME_IOS ++ mtl = MTL::CreateSystemDefaultDevice(); ++#else + const NS::Array* devices = MTL::CopyAllDevices(); + MTL::Device *preferredDevice = nullptr; + for (NS::UInteger i = 0; i < devices->count(); i++) { +@@ -3320,12 +3860,31 @@ namespace plume { + } + + mtl = preferredDevice ? preferredDevice : MTL::CreateSystemDefaultDevice();; ++#endif ++ if (mtl == nullptr) { ++ return; ++ } ++ ++#if PLUME_IOS ++ if (!supportsBufferDeviceAddress(mtl)) { ++ fprintf(stderr, "Metal device '%s' does not support Metal 3 buffer GPU addresses required by Unleashed Recompiled.\n", mtl->name()->utf8String()); ++ mtl->release(); ++ mtl = nullptr; ++ return; ++ } ++#endif ++ ++ const uint64_t workingSetSize = getRecommendedWorkingSetSize(mtl); + const std::string deviceName(mtl->name()->utf8String()); + description.name = deviceName; ++#if PLUME_IOS ++ description.type = RenderDeviceType::INTEGRATED; ++#else + description.type = mapDeviceType(mtl->location()); ++#endif + description.driverVersion = 1; // Unavailable + description.vendor = mtl->supportsFamily(MTL::GPUFamilyApple1) ? RenderDeviceVendor::APPLE : getRenderDeviceVendor(mtl->registryID()); +- description.dedicatedVideoMemory = mtl->recommendedMaxWorkingSetSize(); ++ description.dedicatedVideoMemory = workingSetSize; + + // Setup blit, clear and resolve shaders / pipelines + createClearShaderLibrary(); +@@ -3337,7 +3896,7 @@ namespace plume { + // TODO: Support Raytracing. + // capabilities.raytracing = mtl->supportsRaytracing(); + capabilities.maxTextureSize = mtl->supportsFamily(MTL::GPUFamilyApple3) ? 16384 : 8192; +- capabilities.sampleLocations = mtl->programmableSamplePositionsSupported(); ++ capabilities.sampleLocations = supportsProgrammableSamplePositions(mtl); + capabilities.resolveModes = false; + #if PLUME_IOS + capabilities.descriptorIndexing = mtl->supportsFamily(MTL::GPUFamilyApple3); +@@ -3345,11 +3904,11 @@ namespace plume { + capabilities.descriptorIndexing = true; + #endif + capabilities.scalarBlockLayout = true; +- capabilities.bufferDeviceAddress = mtl->supportsFamily(MTL::GPUFamilyApple3); ++ capabilities.bufferDeviceAddress = supportsBufferDeviceAddress(mtl); + capabilities.presentWait = false; +- capabilities.preferHDR = mtl->recommendedMaxWorkingSetSize() > (512 * 1024 * 1024); ++ capabilities.preferHDR = workingSetSize > (512 * 1024 * 1024); + capabilities.dynamicDepthBias = true; +- capabilities.uma = mtl->hasUnifiedMemory(); ++ capabilities.uma = hasUnifiedMemory(mtl); + capabilities.gpuUploadHeap = capabilities.uma; + capabilities.queryPools = false; + +@@ -3357,17 +3916,19 @@ namespace plume { + } + + MetalDevice::~MetalDevice() { +- mtl->release(); ++ if (mtl) { ++ mtl->release(); ++ } + + for (const auto& [key, state] : clearRenderPipelineStates) { + state->release(); + } + +- resolveTexturePipelineState->release(); +- clearVertexFunction->release(); +- clearColorFunction->release(); +- clearDepthFunction->release(); +- sharedBlitDescriptor->release(); ++ if (resolveTexturePipelineState) resolveTexturePipelineState->release(); ++ if (clearVertexFunction) clearVertexFunction->release(); ++ if (clearColorFunction) clearColorFunction->release(); ++ if (clearDepthFunction) clearDepthFunction->release(); ++ if (sharedBlitDescriptor) sharedBlitDescriptor->release(); + } + + std::unique_ptr MetalDevice::createDescriptorSet(const RenderDescriptorSetDesc &desc) { +@@ -3635,16 +4196,24 @@ namespace plume { + + MetalInterface::MetalInterface() { + NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init(); +- capabilities.shaderFormat = RenderShaderFormat::METAL; ++ capabilities.shaderFormat = RenderShaderFormat::SPIRV; + + releasePool->release(); + + // Fill device names. ++#if PLUME_IOS ++ MTL::Device* device = MTL::CreateSystemDefaultDevice(); ++ if (device != nullptr) { ++ deviceNames.push_back(std::string(device->name()->utf8String())); ++ device->release(); ++ } ++#else + const NS::Array* devices = MTL::CopyAllDevices(); + for (NS::UInteger i = 0; i < devices->count(); i++) { + NS::String* deviceName = ((MTL::Device *)devices->object(i))->name(); + deviceNames.push_back(std::string(deviceName->utf8String())); + } ++#endif + } + + MetalInterface::~MetalInterface() {} +diff --git a/plume_metal.h b/plume_metal.h +index fd367ed..dcdbb62 100644 +--- a/plume_metal.h ++++ b/plume_metal.h +@@ -371,6 +371,7 @@ namespace plume { + const MetalDescriptorSet* computeDescriptorSets[MAX_DESCRIPTOR_SET_BINDINGS] = {}; + + std::unordered_set currentEncoderDescriptorSets; ++ std::unordered_set graphicsAddressResources; + void bindEncoderResources(MTL::CommandEncoder* encoder, bool isCompute); + + MetalCommandList(const MetalCommandQueue *queue); +@@ -543,12 +544,13 @@ namespace plume { + NS::String *functionName = nullptr; + RenderShaderFormat format = RenderShaderFormat::UNKNOWN; + MTL::Library *library = nullptr; ++ MTL::Library *libraryWithoutDepth = nullptr; + NS::String *debugName = nullptr; + + MetalShader(const MetalDevice *device, const void *data, uint64_t size, const char *entryPointName, RenderShaderFormat format); + ~MetalShader() override; + virtual void setName(const std::string &name) override; +- MTL::Function* createFunction(const RenderSpecConstant *specConstants, uint32_t specConstantsCount) const; ++ MTL::Function* createFunction(const RenderSpecConstant *specConstants, uint32_t specConstantsCount, bool hasDepthAttachment = true) const; + }; + + struct MetalSampler : RenderSampler { +@@ -608,16 +610,16 @@ namespace plume { + RenderDeviceDescription description; + + // Resolve functionality +- MTL::ComputePipelineState *resolveTexturePipelineState; ++ MTL::ComputePipelineState *resolveTexturePipelineState = nullptr; + + // Clear functionality +- MTL::Function* clearVertexFunction; +- MTL::Function* clearColorFunction; +- MTL::Function* clearDepthFunction; +- MTL::Function* clearStencilFunction; +- MTL::DepthStencilState *clearDepthState; +- MTL::DepthStencilState *clearStencilState; +- MTL::DepthStencilState *clearDepthStencilState; ++ MTL::Function* clearVertexFunction = nullptr; ++ MTL::Function* clearColorFunction = nullptr; ++ MTL::Function* clearDepthFunction = nullptr; ++ MTL::Function* clearStencilFunction = nullptr; ++ MTL::DepthStencilState *clearDepthState = nullptr; ++ MTL::DepthStencilState *clearStencilState = nullptr; ++ MTL::DepthStencilState *clearDepthStencilState = nullptr; + + std::mutex clearPipelineStateMutex; + std::unordered_map clearRenderPipelineStates; +diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp +index 9103ca8..9a41ec4 100644 +--- a/plume_vulkan.cpp ++++ b/plume_vulkan.cpp +@@ -8,6 +8,9 @@ + #define VMA_IMPLEMENTATION + #define VOLK_IMPLEMENTATION + ++#if defined(__APPLE__) && defined(SDL_VULKAN_ENABLED) ++#include "plume_volk_rename_ios.h" ++#endif + #include "plume_vulkan.h" + + #include +@@ -19,6 +22,14 @@ + # include "render/plume_dlss.h" + #endif + ++#if defined(__APPLE__) && defined(SDL_VULKAN_ENABLED) ++extern "C" VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL mvkGetInstanceProcAddr(VkInstance instance, const char *pName) __asm("_vkGetInstanceProcAddr"); ++ ++#define PLUME_IOS_VULKAN_LOG(...) do { fprintf(stderr, "[iOS plume] " __VA_ARGS__); fflush(stderr); } while (false) ++#else ++#define PLUME_IOS_VULKAN_LOG(...) do { } while (false) ++#endif ++ + #ifndef NDEBUG + # define VULKAN_VALIDATION_LAYER_ENABLED + # define VULKAN_OBJECT_NAMES_ENABLED +@@ -88,6 +99,19 @@ namespace plume { + VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME, + }; + ++ static std::unordered_set getRequiredDeviceExtensions(uint32_t apiVersion) { ++ std::unordered_set extensions = RequiredDeviceExtensions; ++ ++ // MoltenVK and other Vulkan 1.2+ implementations expose these as core features ++ // and no longer enumerate them as separate device extensions. ++ if (apiVersion >= VK_API_VERSION_1_2) { ++ extensions.erase(VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME); ++ extensions.erase(VK_KHR_SAMPLER_MIRROR_CLAMP_TO_EDGE_EXTENSION_NAME); ++ } ++ ++ return extensions; ++ } ++ + // Common functions. + + static uint32_t roundUp(uint32_t value, uint32_t powerOf2Alignment) { +@@ -2086,6 +2110,7 @@ namespace plume { + fprintf(stderr, "SDL_Vulkan_CreateSurface failed with error %s.\n", SDL_GetError()); + return; + } ++ PLUME_IOS_VULKAN_LOG("SDL Vulkan surface created: %p\n", (void *)surface); + # elif defined(__ANDROID__) + assert(renderWindow != nullptr); + VkAndroidSurfaceCreateInfoKHR surfaceCreateInfo = {}; +@@ -2112,6 +2137,12 @@ namespace plume { + fprintf(stderr, "vkCreateXlibSurfaceKHR failed with error code 0x%X.\n", res); + return; + } ++# elif defined(__APPLE__) && defined(SDL_VULKAN_ENABLED) ++ VulkanInterface *renderInterface = commandQueue->device->renderInterface; ++ if (!SDL_Vulkan_CreateSurface(renderWindow, renderInterface->instance, &surface)) { ++ fprintf(stderr, "SDL_Vulkan_CreateSurface failed: %s.\n", SDL_GetError()); ++ return; ++ } + # elif defined(__APPLE__) + assert(renderWindow.window != 0); + assert(renderWindow.view != 0); +@@ -2275,9 +2306,16 @@ namespace plume { + #else + if ((res != VK_SUCCESS) && (res != VK_SUBOPTIMAL_KHR)) { + #endif ++ PLUME_IOS_VULKAN_LOG("present failed with error code 0x%X.\n", res); + return false; + } + ++ static uint32_t s_iosPresentLogCount = 0; ++ if (s_iosPresentLogCount < 20) { ++ PLUME_IOS_VULKAN_LOG("present #%u image=%u waitSemaphores=%u\n", s_iosPresentLogCount, textureIndex, waitSemaphoreCount); ++ } ++ s_iosPresentLogCount++; ++ + return true; + } + +@@ -2325,6 +2363,17 @@ namespace plume { + return false; + } + ++ static uint32_t s_iosResizeLogCount = 0; ++ if (s_iosResizeLogCount < 20) { ++ PLUME_IOS_VULKAN_LOG("resize #%u extent=%ux%u requestedImages=%u presentMode=%d\n", ++ s_iosResizeLogCount, ++ createInfo.imageExtent.width, ++ createInfo.imageExtent.height, ++ textureCount, ++ int(requiredPresentMode)); ++ } ++ s_iosResizeLogCount++; ++ + // Store the chosen present mode to identify later whether the swapchain needs to be recreated. + createdPresentMode = requiredPresentMode; + +@@ -2443,7 +2492,7 @@ namespace plume { + // The attributes width and height members do not include the border. + dstWidth = attributes.width; + dstHeight = attributes.height; +-# elif defined(__APPLE__) ++# elif defined(__APPLE__) && !defined(SDL_VULKAN_ENABLED) + CocoaWindowAttributes attributes; + windowWrapper->getWindowAttributes(&attributes); + dstWidth = attributes.width; +@@ -3754,6 +3803,10 @@ namespace plume { + return; + } + ++ VkPhysicalDeviceProperties selectedDeviceProperties; ++ vkGetPhysicalDeviceProperties(physicalDevice, &selectedDeviceProperties); ++ const std::unordered_set requiredDeviceExtensions = getRequiredDeviceExtensions(selectedDeviceProperties.apiVersion); ++ + // Check for extensions. + uint32_t extensionCount; + vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extensionCount, nullptr); +@@ -3761,7 +3814,7 @@ namespace plume { + std::vector availableExtensions(extensionCount); + vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extensionCount, availableExtensions.data()); + +- std::unordered_set missingRequiredExtensions = RequiredDeviceExtensions; ++ std::unordered_set missingRequiredExtensions = requiredDeviceExtensions; + std::unordered_set supportedOptionalExtensions; + # if DLSS_ENABLED + const std::unordered_set dlssExtensions = DLSS::getRequiredDeviceExtensionsVulkan(this); +@@ -3969,7 +4022,7 @@ namespace plume { + } + + std::vector enabledExtensions; +- for (const std::string &extension : RequiredDeviceExtensions) { ++ for (const std::string &extension : requiredDeviceExtensions) { + enabledExtensions.push_back(extension.c_str()); + } + +@@ -4398,11 +4451,27 @@ namespace plume { + #else + VulkanInterface::VulkanInterface() { + #endif +- VkResult res = volkInitialize(); ++ VkResult res = VK_SUCCESS; ++#if defined(__APPLE__) && defined(SDL_VULKAN_ENABLED) ++ volkInitializeCustom(mvkGetInstanceProcAddr); ++#else ++ res = volkInitialize(); + if (res != VK_SUCCESS) { + fprintf(stderr, "volkInitialize failed with error code 0x%X.\n", res); + return; + } ++#endif ++ ++#if SDL_VULKAN_ENABLED ++ if (sdlWindow != nullptr) { ++ if (SDL_Vulkan_LoadLibrary(nullptr) != 0) { ++ fprintf(stderr, "SDL_Vulkan_LoadLibrary failed: %s.\n", SDL_GetError()); ++ return; ++ } ++ ++ sdlVulkanLoaded = true; ++ } ++#endif + + appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + appInfo.pApplicationName = "plume"; +@@ -4540,6 +4609,12 @@ namespace plume { + if (instance != nullptr) { + vkDestroyInstance(instance, nullptr); + } ++ ++#if SDL_VULKAN_ENABLED ++ if (sdlVulkanLoaded) { ++ SDL_Vulkan_UnloadLibrary(); ++ } ++#endif + } + + std::unique_ptr VulkanInterface::createDevice(const std::string &preferredDeviceName) { +diff --git a/plume_vulkan.h b/plume_vulkan.h +index 73022bb..a611e8f 100644 +--- a/plume_vulkan.h ++++ b/plume_vulkan.h +@@ -22,8 +22,10 @@ + #define VK_USE_PLATFORM_XLIB_KHR + #elif defined(__APPLE__) + #define VK_USE_PLATFORM_METAL_EXT ++#ifndef SDL_VULKAN_ENABLED + #include "plume_apple.h" + #endif ++#endif + + // For VK_KHR_portability_subset + #define VK_ENABLE_BETA_EXTENSIONS +@@ -226,7 +228,7 @@ namespace plume { + VulkanCommandQueue *commandQueue = nullptr; + VkSurfaceKHR surface = VK_NULL_HANDLE; + RenderWindow renderWindow = {}; +-#if defined(__APPLE__) ++#if defined(__APPLE__) && !defined(SDL_VULKAN_ENABLED) + std::unique_ptr windowWrapper; + #endif + uint32_t textureCount = 0; +@@ -455,6 +457,9 @@ namespace plume { + VkApplicationInfo appInfo = {}; + RenderInterfaceCapabilities capabilities; + std::vector deviceNames; ++# if SDL_VULKAN_ENABLED ++ bool sdlVulkanLoaded = false; ++# endif + + # if SDL_VULKAN_ENABLED + VulkanInterface(RenderWindow sdlWindow); diff --git a/tools/patches/sdl-ios-scene-lifecycle.patch b/tools/patches/sdl-ios-scene-lifecycle.patch new file mode 100644 index 000000000..1226b97c9 --- /dev/null +++ b/tools/patches/sdl-ios-scene-lifecycle.patch @@ -0,0 +1,183 @@ +diff --git a/src/video/uikit/SDL_uikitappdelegate.h b/src/video/uikit/SDL_uikitappdelegate.h +index c40dae079..2cee647fe 100644 +--- a/src/video/uikit/SDL_uikitappdelegate.h ++++ b/src/video/uikit/SDL_uikitappdelegate.h +@@ -36,6 +36,13 @@ + + - (void)hideLaunchScreen; + ++#ifdef SDL_UIKIT_USE_SCENES ++/* Unleashed Recompiled: single-scene lifecycle for iOS 13 and later. */ ++@property (nonatomic, strong) UIWindowScene *windowScene; ++- (void)startMainForScene:(UIWindowScene *)scene; ++- (void)sendDropFileForURL:(NSURL *)url; ++#endif ++ + /* This property is marked as optional, and is only intended to be used when + * the app's UI is storyboard-based. SDL is not storyboard-based, however + * several major third-party ad APIs (e.g. Google admob) incorrectly assume this +diff --git a/src/video/uikit/SDL_uikitappdelegate.m b/src/video/uikit/SDL_uikitappdelegate.m +index b7f48edb9..42b789c3b 100644 +--- a/src/video/uikit/SDL_uikitappdelegate.m ++++ b/src/video/uikit/SDL_uikitappdelegate.m +@@ -351,6 +351,9 @@ - (NSUInteger)supportedInterfaceOrientations + + @implementation SDLUIKitDelegate { + UIWindow *launchWindow; ++#ifdef SDL_UIKIT_USE_SCENES ++ BOOL startedMain; ++#endif + } + + /* convenience method */ +@@ -414,7 +417,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( + { + NSBundle *bundle = [NSBundle mainBundle]; + +-#ifdef SDL_IPHONE_LAUNCHSCREEN ++#if defined(SDL_IPHONE_LAUNCHSCREEN) && !defined(SDL_UIKIT_USE_SCENES) + /* The normal launch screen is displayed until didFinishLaunching returns, + * but SDL_main is called after that happens and there may be a noticeable + * delay between the start of SDL_main and when the first real frame is +@@ -470,11 +473,32 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( + SDL_IdleTimerDisabledChanged, NULL); + + SDL_SetMainReady(); ++#ifndef SDL_UIKIT_USE_SCENES + [self performSelector:@selector(postFinishLaunch) withObject:nil afterDelay:0.0]; ++#endif + + return YES; + } + ++#ifdef SDL_UIKIT_USE_SCENES ++- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options ++{ ++ UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:@"Unleashed" sessionRole:session.role]; ++ configuration.delegateClass = NSClassFromString(@"SDLUIKitSceneDelegate"); ++ return configuration; ++} ++ ++- (void)startMainForScene:(UIWindowScene *)scene ++{ ++ self.windowScene = scene; ++ self.window.windowScene = scene; ++ if (!startedMain) { ++ startedMain = YES; ++ [self performSelector:@selector(postFinishLaunch) withObject:nil afterDelay:0.0]; ++ } ++} ++#endif ++ + - (UIWindow *)window + { + SDL_VideoDevice *_this = SDL_GetVideoDevice(); +@@ -527,6 +551,28 @@ - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceAppl + + @end + ++#ifdef SDL_UIKIT_USE_SCENES ++/* Unleashed Recompiled: wait for a connected scene before creating SDL windows. */ ++@interface SDLUIKitSceneDelegate : NSObject ++@end ++ ++@implementation SDLUIKitSceneDelegate ++- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options ++{ ++ if ([scene isKindOfClass:[UIWindowScene class]]) { ++ [[SDLUIKitDelegate sharedAppDelegate] startMainForScene:(UIWindowScene *)scene]; ++ } ++} ++ ++- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)contexts ++{ ++ for (UIOpenURLContext *context in contexts) { ++ [[SDLUIKitDelegate sharedAppDelegate] sendDropFileForURL:context.URL]; ++ } ++} ++@end ++#endif ++ + #endif /* SDL_VIDEO_DRIVER_UIKIT */ + + /* vi: set ts=4 sw=4 expandtab: */ +diff --git a/src/video/uikit/SDL_uikitevents.m b/src/video/uikit/SDL_uikitevents.m +index 59e2eeef3..385284075 100644 +--- a/src/video/uikit/SDL_uikitevents.m ++++ b/src/video/uikit/SDL_uikitevents.m +@@ -53,10 +53,18 @@ - (void)eventPumpChanged + NSNotificationCenter *notificationCenter = NSNotificationCenter.defaultCenter; + if (UIKit_EventPumpEnabled && !self.isObservingNotifications) { + self.isObservingNotifications = YES; ++#ifdef SDL_UIKIT_USE_SCENES ++ /* Unleashed Recompiled: lifecycle notifications now belong to the scene. */ ++ [notificationCenter addObserver:self selector:@selector(applicationDidBecomeActive) name:UISceneDidActivateNotification object:nil]; ++ [notificationCenter addObserver:self selector:@selector(applicationWillResignActive) name:UISceneWillDeactivateNotification object:nil]; ++ [notificationCenter addObserver:self selector:@selector(applicationDidEnterBackground) name:UISceneDidEnterBackgroundNotification object:nil]; ++ [notificationCenter addObserver:self selector:@selector(applicationWillEnterForeground) name:UISceneWillEnterForegroundNotification object:nil]; ++#else + [notificationCenter addObserver:self selector:@selector(applicationDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationWillResignActive) name:UIApplicationWillResignActiveNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationDidEnterBackground) name:UIApplicationDidEnterBackgroundNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationWillEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil]; ++#endif + [notificationCenter addObserver:self selector:@selector(applicationWillTerminate) name:UIApplicationWillTerminateNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationDidReceiveMemoryWarning) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; + #if !TARGET_OS_TV +diff --git a/src/video/uikit/SDL_uikitvideo.m b/src/video/uikit/SDL_uikitvideo.m +index f23b5d31b..bde4c72df 100644 +--- a/src/video/uikit/SDL_uikitvideo.m ++++ b/src/video/uikit/SDL_uikitvideo.m +@@ -209,6 +209,10 @@ CGRect UIKit_ComputeViewFrame(SDL_Window *window, UIScreen *screen) + * is used on an iPad. */ + if (data != nil && data.uiwindow != nil) { + frame = data.uiwindow.bounds; ++#ifdef SDL_UIKIT_USE_SCENES ++ /* Scene windows already apply rotation; the legacy flip applies it twice. */ ++ return frame; ++#endif + } + + #if !TARGET_OS_TV +diff --git a/src/video/uikit/SDL_uikitwindow.m b/src/video/uikit/SDL_uikitwindow.m +index 41a6bbb9b..1e92381fb 100644 +--- a/src/video/uikit/SDL_uikitwindow.m ++++ b/src/video/uikit/SDL_uikitwindow.m +@@ -90,7 +90,11 @@ static int SetupWindowData(_THIS, SDL_Window *window, UIWindow *uiwindow, SDL_bo + SDL_DisplayData *displaydata = (__bridge SDL_DisplayData *) display->driverdata; + SDL_uikitview *view; + ++#ifdef SDL_UIKIT_USE_SCENES ++ CGRect frame = uiwindow.bounds; ++#else + CGRect frame = UIKit_ComputeViewFrame(window, displaydata.uiscreen); ++#endif + int width = (int) frame.size.width; + int height = (int) frame.size.height; + +@@ -112,7 +116,7 @@ static int SetupWindowData(_THIS, SDL_Window *window, UIWindow *uiwindow, SDL_bo + window->flags |= SDL_WINDOW_BORDERLESS; /* never has a status bar. */ + } + +-#if !TARGET_OS_TV ++#if !TARGET_OS_TV && !defined(SDL_UIKIT_USE_SCENES) + if (displaydata.uiscreen == [UIScreen mainScreen]) { + NSUInteger orients = UIKit_GetSupportedOrientations(window); + BOOL supportsLandscape = (orients & UIInterfaceOrientationMaskLandscape) != 0; +@@ -207,7 +211,16 @@ int UIKit_CreateWindow(_THIS, SDL_Window *window) + + /* ignore the size user requested, and make a fullscreen window */ + /* !!! FIXME: can we have a smaller view? */ ++#ifdef SDL_UIKIT_USE_SCENES ++ /* Unleashed Recompiled: legacy unscened windows are invisible on iOS 27. */ ++ UIWindowScene *scene = [[SDLUIKitDelegate sharedAppDelegate] windowScene]; ++ if (!scene) { ++ return SDL_SetError("The application's window scene is not connected."); ++ } ++ uiwindow = [[SDL_uikitwindow alloc] initWithWindowScene:scene]; ++#else + uiwindow = [[SDL_uikitwindow alloc] initWithFrame:data.uiscreen.bounds]; ++#endif + + /* put the window on an external display if appropriate. */ + if (data.uiscreen != [UIScreen mainScreen]) { diff --git a/tools/patches/xenonrecomp-ios-streaming-memory-map.patch b/tools/patches/xenonrecomp-ios-streaming-memory-map.patch new file mode 100644 index 000000000..926533bbb --- /dev/null +++ b/tools/patches/xenonrecomp-ios-streaming-memory-map.patch @@ -0,0 +1,185 @@ +diff --git a/XenonUtils/memory_mapped_file.cpp b/XenonUtils/memory_mapped_file.cpp +index ba3c5d8..530321d 100644 +--- a/XenonUtils/memory_mapped_file.cpp ++++ b/XenonUtils/memory_mapped_file.cpp +@@ -1,5 +1,7 @@ + #include "memory_mapped_file.h" + ++#include ++ + #if !defined(_WIN32) + # include + # include +@@ -34,14 +36,19 @@ MemoryMappedFile::MemoryMappedFile(MemoryMappedFile &&other) + other.fileMappingHandle = nullptr; + other.fileView = nullptr; + other.fileSize.QuadPart = 0; ++ other.streamingMode = false; + #else + fileHandle = other.fileHandle; + fileView = other.fileView; + fileSize = other.fileSize; ++ mappedLength = other.mappedLength; ++ streamingMode = other.streamingMode; + + other.fileHandle = -1; + other.fileView = MAP_FAILED; + other.fileSize = 0; ++ other.mappedLength = 0; ++ other.streamingMode = false; + #endif + } + +@@ -103,14 +110,23 @@ bool MemoryMappedFile::open(const std::filesystem::path &path) + } + + fileView = mmap(nullptr, fileSize, PROT_READ, MAP_PRIVATE, fileHandle, 0); +- if (fileView == MAP_FAILED) ++ if (fileView != MAP_FAILED) + { +- fprintf(stderr, "mmap failed with error %s.\n", strerror(errno)); +- ::close(fileHandle); +- fileHandle = -1; +- return false; ++ mappedLength = fileSize; ++ return true; + } + ++ static constexpr size_t PartialMapSize = 128 * 1024 * 1024; ++ const size_t partialMapSize = std::min(static_cast(fileSize), PartialMapSize); ++ fileView = mmap(nullptr, partialMapSize, PROT_READ, MAP_PRIVATE, fileHandle, 0); ++ if (fileView != MAP_FAILED) ++ { ++ mappedLength = static_cast(partialMapSize); ++ return true; ++ } ++ ++ fprintf(stderr, "mmap failed with error %s, falling back to streaming I/O.\n", strerror(errno)); ++ streamingMode = true; + return true; + #endif + } +@@ -135,7 +151,7 @@ void MemoryMappedFile::close() + #else + if (fileView != MAP_FAILED) + { +- munmap(fileView, fileSize); ++ munmap(fileView, mappedLength); + } + + if (fileHandle != -1) +@@ -148,14 +164,86 @@ void MemoryMappedFile::close() + bool MemoryMappedFile::isOpen() const + { + #if defined(_WIN32) +- return (fileView != nullptr); ++ return (fileView != nullptr) || (streamingMode && fileHandle != nullptr); ++#else ++ return (fileView != MAP_FAILED) || (streamingMode && fileHandle != -1); ++#endif ++} ++ ++bool MemoryMappedFile::readAt(size_t offset, void *buffer, size_t size) const ++{ ++ if (!isOpen() || buffer == nullptr) ++ return false; ++ ++ if (static_cast(offset) < 0 || static_cast(offset + size) > fileSize) ++ return false; ++ ++#if defined(_WIN32) ++ if (fileView != nullptr) ++ { ++ memcpy(buffer, reinterpret_cast(fileView) + offset, size); ++ return true; ++ } ++ ++ if (!streamingMode || fileHandle == nullptr) ++ return false; ++ ++ OVERLAPPED overlapped = {}; ++ overlapped.Offset = static_cast(offset & 0xFFFFFFFF); ++ overlapped.OffsetHigh = static_cast(offset >> 32); ++ DWORD bytesRead = 0; ++ if (!ReadFile(fileHandle, buffer, static_cast(size), &bytesRead, &overlapped) || bytesRead != size) ++ return false; ++ ++ return true; + #else +- return (fileView != MAP_FAILED); ++ if (fileView != MAP_FAILED) ++ { ++ const size_t mappedEnd = static_cast(mappedLength); ++ if (offset + size <= mappedEnd) ++ { ++ memcpy(buffer, reinterpret_cast(fileView) + offset, size); ++ return true; ++ } ++ ++ if (offset >= mappedEnd) ++ { ++ return pread(fileHandle, buffer, size, static_cast(offset)) == static_cast(size); ++ } ++ ++ const size_t mappedBytes = mappedEnd - offset; ++ memcpy(buffer, reinterpret_cast(fileView) + offset, mappedBytes); ++ const size_t remainingBytes = size - mappedBytes; ++ return pread(fileHandle, static_cast(buffer) + mappedBytes, remainingBytes, static_cast(mappedEnd)) == static_cast(remainingBytes); ++ } ++ ++ if (!streamingMode || fileHandle == -1) ++ return false; ++ ++ size_t bytesRead = 0; ++ while (bytesRead < size) ++ { ++ const ssize_t result = pread(fileHandle, static_cast(buffer) + bytesRead, size - bytesRead, static_cast(offset + bytesRead)); ++ if (result <= 0) ++ return false; ++ ++ bytesRead += static_cast(result); ++ } ++ ++ return true; + #endif + } + + uint8_t *MemoryMappedFile::data() const + { ++#if defined(_WIN32) ++ if (fileView == nullptr) ++ return nullptr; ++#else ++ if (fileView == MAP_FAILED) ++ return nullptr; ++#endif ++ + return reinterpret_cast(fileView); + } + +diff --git a/XenonUtils/memory_mapped_file.h b/XenonUtils/memory_mapped_file.h +index a3de88b..640cc42 100644 +--- a/XenonUtils/memory_mapped_file.h ++++ b/XenonUtils/memory_mapped_file.h +@@ -14,10 +14,13 @@ struct MemoryMappedFile { + HANDLE fileMappingHandle = nullptr; + LPVOID fileView = nullptr; + LARGE_INTEGER fileSize = {}; ++ bool streamingMode = false; + #else + int fileHandle = -1; + void *fileView = MAP_FAILED; + off_t fileSize = 0; ++ off_t mappedLength = 0; ++ bool streamingMode = false; + #endif + + MemoryMappedFile(); +@@ -27,6 +30,7 @@ struct MemoryMappedFile { + bool open(const std::filesystem::path &path); + void close(); + bool isOpen() const; ++ bool readAt(size_t offset, void *buffer, size_t size) const; + uint8_t *data() const; + size_t size() const; + }; diff --git a/tools/xcontent_extract/CMakeLists.txt b/tools/xcontent_extract/CMakeLists.txt new file mode 100644 index 000000000..230c17c97 --- /dev/null +++ b/tools/xcontent_extract/CMakeLists.txt @@ -0,0 +1,13 @@ +project("xcontent_extract") + +add_executable(xcontent_extract + "xcontent_extract.cpp" + "${CMAKE_SOURCE_DIR}/UnleashedRecomp/install/xcontent_file_system.cpp" +) + +target_include_directories(xcontent_extract PRIVATE + "${CMAKE_SOURCE_DIR}/UnleashedRecomp/install" + "${UNLEASHED_RECOMP_TOOLS_ROOT}/XenonRecomp/XenonUtils" +) + +target_link_libraries(xcontent_extract PRIVATE XenonUtils) diff --git a/tools/xcontent_extract/xcontent_extract.cpp b/tools/xcontent_extract/xcontent_extract.cpp new file mode 100644 index 000000000..a532a030d --- /dev/null +++ b/tools/xcontent_extract/xcontent_extract.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include +#include + +#include + +static bool ExtractFile(const XContentFileSystem& content, const std::string& sourcePath, const std::filesystem::path& outputPath) +{ + size_t dataSize = content.getSize(sourcePath); + if (dataSize == 0) + { + std::cerr << "File not found in XContent package: " << sourcePath << "\n"; + return false; + } + + std::vector data(dataSize); + if (!content.load(sourcePath, data.data(), data.size())) + { + std::cerr << "Failed to read XContent file: " << sourcePath << "\n"; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(outputPath.parent_path(), ec); + if (ec) + { + std::cerr << "Failed to create output directory: " << outputPath.parent_path() << "\n"; + return false; + } + + std::ofstream output(outputPath, std::ios::binary); + if (!output) + { + std::cerr << "Failed to open output file: " << outputPath << "\n"; + return false; + } + + output.write(reinterpret_cast(data.data()), static_cast(data.size())); + if (!output) + { + std::cerr << "Failed to write output file: " << outputPath << "\n"; + return false; + } + + return true; +} + +int main(int argc, char** argv) +{ + if (argc < 4 || ((argc - 2) % 2) != 0) + { + std::cerr << "Usage: xcontent_extract [ ...]\n"; + return 2; + } + + auto content = XContentFileSystem::create(argv[1]); + if (!content) + { + std::cerr << "Failed to open XContent package: " << argv[1] << "\n"; + return 1; + } + + bool ok = true; + for (int i = 2; i < argc; i += 2) + { + ok = ExtractFile(*content, argv[i], argv[i + 1]) && ok; + } + + return ok ? 0 : 1; +}