diff --git a/.gitignore b/.gitignore
index bc7f4683..9b39b23e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ mobile/dist/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+
+# Xbox UWP build output
+/ports/uwp/build/
diff --git a/mobile/android/love/src/jni/SDL2/src/joystick/SDL_gamecontroller.c b/mobile/android/love/src/jni/SDL2/src/joystick/SDL_gamecontroller.c
index 035176b3..04e17019 100644
--- a/mobile/android/love/src/jni/SDL2/src/joystick/SDL_gamecontroller.c
+++ b/mobile/android/love/src/jni/SDL2/src/joystick/SDL_gamecontroller.c
@@ -726,7 +726,12 @@ static ControllerMapping_t *SDL_CreateMappingForWGIController(SDL_JoystickGUID g
}
SDL_strlcpy(mapping_string, "none,*,", sizeof(mapping_string));
+#ifdef __WINRT__
+ /* Xbox WGI exposes the canonical SDL axis and hat layout. */
+ SDL_strlcat(mapping_string, "a:b0,b:b1,x:b2,y:b3,back:b6,start:b7,leftstick:b8,rightstick:b9,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,lefttrigger:a2,rightx:a3,righty:a4,righttrigger:a5,", sizeof(mapping_string));
+#else
SDL_strlcat(mapping_string, "a:b0,b:b1,x:b2,y:b3,back:b6,start:b7,leftstick:b8,rightstick:b9,leftshoulder:b4,rightshoulder:b5,dpup:b10,dpdown:b12,dpleft:b13,dpright:b11,leftx:a1,lefty:a0~,rightx:a3,righty:a2~,lefttrigger:a4,righttrigger:a5,", sizeof(mapping_string));
+#endif
return SDL_PrivateAddMappingForGUID(guid, mapping_string, &existing, SDL_CONTROLLER_MAPPING_PRIORITY_DEFAULT);
}
diff --git a/mobile/android/love/src/jni/SDL2/src/joystick/windows/SDL_windows_gaming_input.c b/mobile/android/love/src/jni/SDL2/src/joystick/windows/SDL_windows_gaming_input.c
index 6f01b4e1..26b88116 100644
--- a/mobile/android/love/src/jni/SDL2/src/joystick/windows/SDL_windows_gaming_input.c
+++ b/mobile/android/love/src/jni/SDL2/src/joystick/windows/SDL_windows_gaming_input.c
@@ -731,10 +731,19 @@ static int WGI_JoystickOpen(SDL_Joystick *joystick, int device_index)
__x_ABI_CWindows_CGaming_CInput_CIGameController_get_IsWireless(hwdata->gamecontroller, &wireless);
}
- /* Initialize the joystick capabilities */
- joystick->nbuttons = state->nbuttons;
- joystick->naxes = state->naxes;
- joystick->nhats = state->nhats;
+#ifdef __WINRT__
+ if (hwdata->gamepad) {
+ joystick->nbuttons = 10;
+ joystick->naxes = 6;
+ joystick->nhats = 1;
+ } else
+#endif
+ {
+ /* Initialize the joystick capabilities */
+ joystick->nbuttons = state->nbuttons;
+ joystick->naxes = state->naxes;
+ joystick->nhats = state->nhats;
+ }
joystick->epowerlevel = wireless ? SDL_JOYSTICK_POWER_UNKNOWN : SDL_JOYSTICK_POWER_WIRED;
if (wireless && hwdata->battery) {
@@ -872,6 +881,51 @@ static void WGI_JoystickUpdate(SDL_Joystick *joystick)
{
struct joystick_hwdata *hwdata = joystick->hwdata;
HRESULT hr;
+
+#ifdef __WINRT__
+ if (hwdata->gamepad) {
+ struct __x_ABI_CWindows_CGaming_CInput_CGamepadReading reading;
+ static const __x_ABI_CWindows_CGaming_CInput_CGamepadButtons button_masks[] = {
+ GamepadButtons_A, GamepadButtons_B, GamepadButtons_X, GamepadButtons_Y,
+ GamepadButtons_LeftShoulder, GamepadButtons_RightShoulder,
+ GamepadButtons_View, GamepadButtons_Menu,
+ GamepadButtons_LeftThumbstick, GamepadButtons_RightThumbstick
+ };
+ Uint8 hat = SDL_HAT_CENTERED;
+ Uint8 i;
+
+ hr = __x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading(hwdata->gamepad, &reading);
+ if (SUCCEEDED(hr) && (!reading.Timestamp || reading.Timestamp != hwdata->timestamp)) {
+ const DOUBLE left_x = SDL_max(-1.0, SDL_min(1.0, reading.LeftThumbstickX));
+ const DOUBLE left_y = SDL_max(-1.0, SDL_min(1.0, reading.LeftThumbstickY));
+ const DOUBLE right_x = SDL_max(-1.0, SDL_min(1.0, reading.RightThumbstickX));
+ const DOUBLE right_y = SDL_max(-1.0, SDL_min(1.0, reading.RightThumbstickY));
+ const DOUBLE left_trigger = SDL_max(0.0, SDL_min(1.0, reading.LeftTrigger));
+ const DOUBLE right_trigger = SDL_max(0.0, SDL_min(1.0, reading.RightTrigger));
+
+ SDL_PrivateJoystickAxis(joystick, 0, (Sint16)(left_x * 32767.0));
+ SDL_PrivateJoystickAxis(joystick, 1, (Sint16)(-left_y * 32767.0));
+ SDL_PrivateJoystickAxis(joystick, 2, (Sint16)(left_trigger * 65535.0 - 32768.0));
+ SDL_PrivateJoystickAxis(joystick, 3, (Sint16)(right_x * 32767.0));
+ SDL_PrivateJoystickAxis(joystick, 4, (Sint16)(-right_y * 32767.0));
+ SDL_PrivateJoystickAxis(joystick, 5, (Sint16)(right_trigger * 65535.0 - 32768.0));
+
+ for (i = 0; i < (Uint8)SDL_arraysize(button_masks); ++i) {
+ SDL_PrivateJoystickButton(joystick, i,
+ (reading.Buttons & button_masks[i]) ? SDL_PRESSED : SDL_RELEASED);
+ }
+
+ if (reading.Buttons & GamepadButtons_DPadUp) hat |= SDL_HAT_UP;
+ if (reading.Buttons & GamepadButtons_DPadDown) hat |= SDL_HAT_DOWN;
+ if (reading.Buttons & GamepadButtons_DPadLeft) hat |= SDL_HAT_LEFT;
+ if (reading.Buttons & GamepadButtons_DPadRight) hat |= SDL_HAT_RIGHT;
+ SDL_PrivateJoystickHat(joystick, 0, hat);
+ hwdata->timestamp = reading.Timestamp;
+ }
+ return;
+ }
+#endif
+
UINT32 nbuttons = SDL_min(joystick->nbuttons, SDL_MAX_UINT8);
boolean *buttons = NULL;
UINT32 nhats = SDL_min(joystick->nhats, SDL_MAX_UINT8);
@@ -994,7 +1048,38 @@ static void WGI_JoystickQuit(void)
static SDL_bool WGI_JoystickGetGamepadMapping(int device_index, SDL_GamepadMapping *out)
{
+#ifdef __WINRT__
+ WindowsGamingInputControllerState *state = &wgi.controllers[device_index];
+
+ if (state->type != SDL_JOYSTICK_TYPE_GAMECONTROLLER) {
+ return SDL_FALSE;
+ }
+
+ SDL_zero(*out);
+ out->a = (SDL_InputMapping){ EMappingKind_Button, 0 };
+ out->b = (SDL_InputMapping){ EMappingKind_Button, 1 };
+ out->x = (SDL_InputMapping){ EMappingKind_Button, 2 };
+ out->y = (SDL_InputMapping){ EMappingKind_Button, 3 };
+ out->leftshoulder = (SDL_InputMapping){ EMappingKind_Button, 4 };
+ out->rightshoulder = (SDL_InputMapping){ EMappingKind_Button, 5 };
+ out->back = (SDL_InputMapping){ EMappingKind_Button, 6 };
+ out->start = (SDL_InputMapping){ EMappingKind_Button, 7 };
+ out->leftstick = (SDL_InputMapping){ EMappingKind_Button, 8 };
+ out->rightstick = (SDL_InputMapping){ EMappingKind_Button, 9 };
+ out->dpup = (SDL_InputMapping){ EMappingKind_Hat, SDL_HAT_UP };
+ out->dpdown = (SDL_InputMapping){ EMappingKind_Hat, SDL_HAT_DOWN };
+ out->dpleft = (SDL_InputMapping){ EMappingKind_Hat, SDL_HAT_LEFT };
+ out->dpright = (SDL_InputMapping){ EMappingKind_Hat, SDL_HAT_RIGHT };
+ out->leftx = (SDL_InputMapping){ EMappingKind_Axis, 0 };
+ out->lefty = (SDL_InputMapping){ EMappingKind_Axis, 1 };
+ out->lefttrigger = (SDL_InputMapping){ EMappingKind_Axis, 2 };
+ out->rightx = (SDL_InputMapping){ EMappingKind_Axis, 3 };
+ out->righty = (SDL_InputMapping){ EMappingKind_Axis, 4 };
+ out->righttrigger = (SDL_InputMapping){ EMappingKind_Axis, 5 };
+ return SDL_TRUE;
+#else
return SDL_FALSE;
+#endif
}
SDL_JoystickDriver SDL_WGI_JoystickDriver = {
diff --git a/ports/uwp/Assets/LargeTile.png b/ports/uwp/Assets/LargeTile.png
new file mode 100644
index 00000000..48e69064
Binary files /dev/null and b/ports/uwp/Assets/LargeTile.png differ
diff --git a/ports/uwp/Assets/SmallTile.png b/ports/uwp/Assets/SmallTile.png
new file mode 100644
index 00000000..4a7714ab
Binary files /dev/null and b/ports/uwp/Assets/SmallTile.png differ
diff --git a/ports/uwp/Assets/SplashScreen.png b/ports/uwp/Assets/SplashScreen.png
new file mode 100644
index 00000000..5e938064
Binary files /dev/null and b/ports/uwp/Assets/SplashScreen.png differ
diff --git a/ports/uwp/Assets/Square150x150Logo.png b/ports/uwp/Assets/Square150x150Logo.png
new file mode 100644
index 00000000..39a868dd
Binary files /dev/null and b/ports/uwp/Assets/Square150x150Logo.png differ
diff --git a/ports/uwp/Assets/Square44x44Logo.png b/ports/uwp/Assets/Square44x44Logo.png
new file mode 100644
index 00000000..b99245bd
Binary files /dev/null and b/ports/uwp/Assets/Square44x44Logo.png differ
diff --git a/ports/uwp/Assets/StoreLogo.png b/ports/uwp/Assets/StoreLogo.png
new file mode 100644
index 00000000..29e1785a
Binary files /dev/null and b/ports/uwp/Assets/StoreLogo.png differ
diff --git a/ports/uwp/Assets/WideTile.png b/ports/uwp/Assets/WideTile.png
new file mode 100644
index 00000000..8f30a761
Binary files /dev/null and b/ports/uwp/Assets/WideTile.png differ
diff --git a/ports/uwp/BUILD.md b/ports/uwp/BUILD.md
new file mode 100644
index 00000000..59a9c58f
--- /dev/null
+++ b/ports/uwp/BUILD.md
@@ -0,0 +1,112 @@
+# Gen1Recomp Xbox UWP build notes
+
+This is the Xbox Dev Mode package for Gen1Recomp.
+
+The rough shape is:
+
+- `Gen1RecompUWP.exe` starts LÖVE through SDL's WinRT wrapper
+- [love-xbox-uwp](https://github.com/caorthann-celt/love-xbox-uwp) provides the LÖVE 11.5 UWP backend and LuaJIT
+- SDL2 is built from the copy already carried by Gen1Recomp
+- ANGLE provides OpenGL ES over D3D11
+- vcpkg provides the audio, font, video, and compression libraries
+
+## What You Need
+
+The tested toolchain is:
+
+- Visual Studio 2022 17.14
+- MSVC v143 x64/x86 build tools
+- C++ Universal Windows Platform tools
+- Windows 11 SDK `10.0.26100.0`
+- CMake 3.24 or newer
+- Git
+- vcpkg with the `x64-uwp` triplet
+
+Use Visual Studio Installer to add **Universal Windows Platform development**, the v143 C++ tools, CMake tools for Windows, and Windows SDK `10.0.26100.0`.
+
+## Checkouts
+
+Keep the game, LÖVE backend, and vcpkg in separate folders. From the parent of the existing Gen1Recomp checkout:
+
+```powershell
+git clone --branch gen1recomp --single-branch `
+ https://github.com/caorthann-celt/love-xbox-uwp.git `
+ love-xbox-uwp
+git clone https://github.com/microsoft/vcpkg.git vcpkg
+```
+
+The LÖVE checkout must stay on the `gen1recomp` branch.
+
+## vcpkg
+
+Bootstrap vcpkg and install the UWP libraries used by LÖVE:
+
+```powershell
+Set-Location vcpkg
+./bootstrap-vcpkg.bat
+./vcpkg.exe install `
+ freetype:x64-uwp `
+ openal-soft:x64-uwp `
+ libtheora:x64-uwp `
+ libvorbis:x64-uwp `
+ libogg:x64-uwp `
+ zlib:x64-uwp
+Set-Location ..
+```
+
+## Environment
+
+Set the three build roots from the parent folder:
+
+```powershell
+$env:LOVE_UWP_ROOT = (Resolve-Path './love-xbox-uwp').Path
+$env:VCPKG_ROOT = (Resolve-Path './vcpkg').Path
+$gameRoot = (Resolve-Path './Gen1Recomp-UWP').Path
+```
+
+## Build SDL2
+
+Start from a Visual Studio 2022 UWP developer prompt, or initialize `VsDevCmd.bat` for x64 UWP. Then build and install SDL2:
+
+```powershell
+Set-Location "$gameRoot\ports\uwp"
+./scripts/build-sdl2-angle.ps1
+$env:UWP_SDL2_ANGLE_ROOT = (Resolve-Path './build/sdl2-install').Path
+```
+
+## Build LÖVE
+
+Configure the backend once, then build the Release libraries and DLLs:
+
+```powershell
+cmake -S $env:LOVE_UWP_ROOT -B "$env:LOVE_UWP_ROOT/build/uwp-x64-angle" `
+ -G "Visual Studio 17 2022" -A x64 `
+ -DCMAKE_SYSTEM_NAME=WindowsStore -DCMAKE_SYSTEM_VERSION=10.0 `
+ "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" `
+ -DVCPKG_TARGET_TRIPLET=x64-uwp `
+ "-DLOVE_UWP_SDL_ROOT=$env:UWP_SDL2_ANGLE_ROOT" `
+ -DLOVE_UWP_LUAJIT=ON -DLOVE_UWP_ANGLE=ON
+
+cmake --build "$env:LOVE_UWP_ROOT/build/uwp-x64-angle" `
+ --config Release --parallel
+```
+
+## Build the MSIX
+
+Build the game package from `ports/uwp`:
+
+```powershell
+Set-Location "$gameRoot\ports\uwp"
+cmake --preset uwp-release
+cmake --build --preset uwp-release
+```
+
+The build creates `gen1recomp.love`, links the UWP host, and stages LÖVE, LuaJIT, SDL2, ANGLE, and the vcpkg runtime DLLs.
+
+## Build Output
+
+The Release package lands under:
+
+```text
+ports\uwp\build\release\AppPackages\Gen1RecompUWP
+```
diff --git a/ports/uwp/CMakeLists.txt b/ports/uwp/CMakeLists.txt
new file mode 100644
index 00000000..033e2e0b
--- /dev/null
+++ b/ports/uwp/CMakeLists.txt
@@ -0,0 +1,110 @@
+cmake_minimum_required(VERSION 3.24)
+project(Gen1RecompUWP LANGUAGES CXX)
+
+if(NOT CMAKE_SYSTEM_NAME STREQUAL "WindowsStore")
+ message(FATAL_ERROR "Configure with a WindowsStore preset.")
+endif()
+
+foreach(required_env LOVE_UWP_ROOT UWP_SDL2_ANGLE_ROOT VCPKG_ROOT)
+ if(NOT DEFINED ENV{${required_env}} OR "$ENV{${required_env}}" STREQUAL "")
+ message(FATAL_ERROR "Set ${required_env} before configuring.")
+ endif()
+endforeach()
+
+file(TO_CMAKE_PATH "$ENV{LOVE_UWP_ROOT}" LOVE_UWP_ROOT)
+file(TO_CMAKE_PATH "$ENV{UWP_SDL2_ANGLE_ROOT}" UWP_SDL2_ANGLE_ROOT)
+file(TO_CMAKE_PATH "$ENV{VCPKG_ROOT}" VCPKG_ROOT)
+get_filename_component(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
+set(ANGLE_RUNTIME_ROOT "${CMAKE_CURRENT_LIST_DIR}/third_party/angle")
+
+set(LOVE_BUILD_ROOT "${LOVE_UWP_ROOT}/build/uwp-x64-angle" CACHE PATH "LÖVE UWP build directory")
+set(LOVE_CONFIG "RelWithDebInfo" CACHE STRING "Configuration used for the LÖVE UWP backend")
+set(LOVE_OUTPUT_ROOT "${LOVE_BUILD_ROOT}/${LOVE_CONFIG}")
+if(LOVE_CONFIG STREQUAL "Release" OR LOVE_CONFIG STREQUAL "Debug")
+ set(LOVE_LIBRARY "${LOVE_OUTPUT_ROOT}/liblove.lib")
+ set(LOVE_RUNTIME "${LOVE_OUTPUT_ROOT}/love.dll")
+else()
+ set(LOVE_LIBRARY "${LOVE_OUTPUT_ROOT}/libliblove.lib")
+ set(LOVE_RUNTIME "${LOVE_OUTPUT_ROOT}/liblove.dll")
+endif()
+
+set(REQUIRED_FILES
+ "${LOVE_OUTPUT_ROOT}/lovestatic.lib"
+ "${LOVE_LIBRARY}"
+ "${LOVE_RUNTIME}"
+ "${LOVE_OUTPUT_ROOT}/lua51.lib"
+ "${LOVE_OUTPUT_ROOT}/lua51.dll"
+ "${UWP_SDL2_ANGLE_ROOT}/include/SDL2/SDL.h"
+ "${UWP_SDL2_ANGLE_ROOT}/lib/SDL2.lib"
+ "${UWP_SDL2_ANGLE_ROOT}/bin/SDL2.dll"
+ "${ANGLE_RUNTIME_ROOT}/libEGL.dll"
+ "${ANGLE_RUNTIME_ROOT}/libGLESv2.dll"
+ "${ANGLE_RUNTIME_ROOT}/d3dcompiler_47.dll"
+)
+foreach(path IN LISTS REQUIRED_FILES)
+ if(NOT EXISTS "${path}")
+ message(FATAL_ERROR "Missing UWP dependency: ${path}")
+ endif()
+endforeach()
+
+set(GAME_ARCHIVE "${CMAKE_CURRENT_BINARY_DIR}/gen1recomp.love")
+set_source_files_properties("${GAME_ARCHIVE}" PROPERTIES GENERATED TRUE)
+add_custom_target(gen1recomp_love ALL
+ COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File
+ "${CMAKE_CURRENT_LIST_DIR}/scripts/package-game.ps1"
+ -SourceRoot "${GAME_ROOT}" -Output "${GAME_ARCHIVE}"
+ BYPRODUCTS "${GAME_ARCHIVE}"
+ VERBATIM
+)
+
+add_executable(${PROJECT_NAME} WIN32 "app/main.cpp")
+add_dependencies(${PROJECT_NAME} gen1recomp_love)
+set_target_properties(${PROJECT_NAME} PROPERTIES
+ CXX_STANDARD 17
+ CXX_STANDARD_REQUIRED YES
+ VS_SDK_REFERENCES "Microsoft.VCLibs, Version=14.0"
+)
+target_include_directories(${PROJECT_NAME} PRIVATE "${UWP_SDL2_ANGLE_ROOT}/include/SDL2")
+target_link_libraries(${PROJECT_NAME} PRIVATE
+ "${LOVE_OUTPUT_ROOT}/lovestatic.lib"
+ "${LOVE_LIBRARY}"
+ "${UWP_SDL2_ANGLE_ROOT}/lib/SDL2.lib"
+ "${LOVE_OUTPUT_ROOT}/lua51.lib"
+ WindowsApp.lib
+)
+
+set(PACKAGE_ROOT_FILES
+ "${CMAKE_CURRENT_LIST_DIR}/Package.appxmanifest"
+ "${GAME_ARCHIVE}"
+ "${LOVE_RUNTIME}"
+ "${LOVE_OUTPUT_ROOT}/lua51.dll"
+ "${UWP_SDL2_ANGLE_ROOT}/bin/SDL2.dll"
+ "${ANGLE_RUNTIME_ROOT}/libEGL.dll"
+ "${ANGLE_RUNTIME_ROOT}/libGLESv2.dll"
+ "${ANGLE_RUNTIME_ROOT}/d3dcompiler_47.dll"
+)
+
+set(VCPKG_RUNTIME_NAMES
+ brotlicommon.dll brotlidec.dll bz2.dll fmt.dll freetype.dll libpng16.dll
+ OpenAL32.dll theora.dll theoradec.dll vorbis.dll vorbisfile.dll ogg.dll z.dll
+)
+foreach(name IN LISTS VCPKG_RUNTIME_NAMES)
+ set(runtime "${VCPKG_ROOT}/installed/x64-uwp/bin/${name}")
+ if(NOT EXISTS "${runtime}")
+ message(FATAL_ERROR "Missing UWP runtime DLL: ${name}")
+ endif()
+ list(APPEND PACKAGE_ROOT_FILES "${runtime}")
+endforeach()
+
+set_source_files_properties(${PACKAGE_ROOT_FILES} PROPERTIES
+ VS_COPY_TO_OUT_DIR Always
+ VS_DEPLOYMENT_CONTENT TRUE
+ VS_DEPLOYMENT_LOCATION "."
+)
+
+file(GLOB PACKAGE_ASSETS CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/Assets/*.png")
+set_source_files_properties(${PACKAGE_ASSETS} PROPERTIES
+ VS_DEPLOYMENT_CONTENT TRUE
+ VS_DEPLOYMENT_LOCATION "Assets"
+)
+target_sources(${PROJECT_NAME} PRIVATE ${PACKAGE_ROOT_FILES} ${PACKAGE_ASSETS})
diff --git a/ports/uwp/CMakePresets.json b/ports/uwp/CMakePresets.json
new file mode 100644
index 00000000..977d4591
--- /dev/null
+++ b/ports/uwp/CMakePresets.json
@@ -0,0 +1,49 @@
+{
+ "version": 6,
+ "configurePresets": [
+ {
+ "name": "uwp-common",
+ "hidden": true,
+ "generator": "Visual Studio 17 2022",
+ "architecture": "x64",
+ "cacheVariables": {
+ "CMAKE_SYSTEM_NAME": "WindowsStore",
+ "CMAKE_SYSTEM_VERSION": "10.0",
+ "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
+ "VCPKG_TARGET_TRIPLET": "x64-uwp"
+ }
+ },
+ {
+ "name": "uwp-relwithdebinfo",
+ "inherits": "uwp-common",
+ "displayName": "Gen1Recomp Xbox UWP (RelWithDebInfo)",
+ "binaryDir": "${sourceDir}/build/relwithdebinfo",
+ "cacheVariables": {
+ "LOVE_CONFIG": "RelWithDebInfo"
+ }
+ },
+ {
+ "name": "uwp-release",
+ "inherits": "uwp-common",
+ "displayName": "Gen1Recomp Xbox UWP (Release)",
+ "binaryDir": "${sourceDir}/build/release",
+ "cacheVariables": {
+ "LOVE_CONFIG": "Release"
+ }
+ }
+ ],
+ "buildPresets": [
+ {
+ "name": "uwp-relwithdebinfo",
+ "configurePreset": "uwp-relwithdebinfo",
+ "configuration": "RelWithDebInfo",
+ "jobs": 8
+ },
+ {
+ "name": "uwp-release",
+ "configurePreset": "uwp-release",
+ "configuration": "Release",
+ "jobs": 8
+ }
+ ]
+}
diff --git a/ports/uwp/Package.appxmanifest b/ports/uwp/Package.appxmanifest
new file mode 100644
index 00000000..70a92d93
--- /dev/null
+++ b/ports/uwp/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Gen1Recomp
+ Gen1Recomp
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ports/uwp/app/main.cpp b/ports/uwp/app/main.cpp
new file mode 100644
index 00000000..e6015722
--- /dev/null
+++ b/ports/uwp/app/main.cpp
@@ -0,0 +1,31 @@
+#include
+#include
+#include
+#include
+#include
+
+extern "C" int SDL_main(int argc, char **argv);
+
+namespace
+{
+
+int runLove(int, char **)
+{
+ std::wstring packagePath = winrt::Windows::ApplicationModel::Package::Current()
+ .InstalledLocation().Path().c_str();
+ std::string gamePath = winrt::to_string(packagePath + L"\\gen1recomp.love");
+
+ char executable[] = "Gen1RecompUWP";
+ char fused[] = "--fused";
+ char *loveArgv[] = {executable, gamePath.data(), fused, nullptr};
+ return SDL_main(3, loveArgv);
+}
+
+} // namespace
+
+int CALLBACK WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
+{
+ SDL_SetHint(SDL_HINT_WINRT_HANDLE_BACK_BUTTON, "1");
+ OutputDebugStringA("Gen1Recomp UWP startup\n");
+ return SDL_WinRTRunApp(runLove, nullptr);
+}
diff --git a/ports/uwp/scripts/build-sdl2-angle.ps1 b/ports/uwp/scripts/build-sdl2-angle.ps1
new file mode 100644
index 00000000..60bf2740
--- /dev/null
+++ b/ports/uwp/scripts/build-sdl2-angle.ps1
@@ -0,0 +1,24 @@
+param(
+ [string]$Configuration = "Release",
+ [string]$WindowsSdkVersion = "10.0"
+)
+
+$ErrorActionPreference = "Stop"
+
+$portRoot = Split-Path -Parent $PSScriptRoot
+$gameRoot = (Resolve-Path (Join-Path $portRoot "..\..")).Path
+$sourceRoot = Join-Path $gameRoot "mobile\android\love\src\jni\SDL2"
+$buildRoot = Join-Path $portRoot "build\sdl2"
+$installRoot = Join-Path $portRoot "build\sdl2-install"
+
+cmake -S $sourceRoot -B $buildRoot `
+ -G "Visual Studio 17 2022" -A x64 `
+ -DCMAKE_SYSTEM_NAME=WindowsStore `
+ "-DCMAKE_SYSTEM_VERSION=$WindowsSdkVersion" `
+ "-DCMAKE_INSTALL_PREFIX=$installRoot" `
+ -DSDL_SHARED=ON -DSDL_STATIC=OFF `
+ -DSDL_OPENGL=OFF -DSDL_OPENGLES=ON -DSDL_VULKAN=OFF
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+cmake --build $buildRoot --config $Configuration --target INSTALL --parallel
+exit $LASTEXITCODE
diff --git a/ports/uwp/scripts/package-game.ps1 b/ports/uwp/scripts/package-game.ps1
new file mode 100644
index 00000000..d7f4d6ea
--- /dev/null
+++ b/ports/uwp/scripts/package-game.ps1
@@ -0,0 +1,49 @@
+param(
+ [Parameter(Mandatory = $true)][string]$SourceRoot,
+ [Parameter(Mandatory = $true)][string]$Output
+)
+
+$ErrorActionPreference = 'Stop'
+$source = (Resolve-Path -LiteralPath $SourceRoot).Path
+$required = @('main.lua', 'conf.lua', 'tools/rom_manifest.json')
+$allowedRoots = @('assets/', 'data/', 'mods/', 'src/', 'tools/rom_manifest')
+$forbidden = '(?i)(^|/)(assets/generated|data/generated|build|dist|ports|mobile|tests?)(/|$)|\.(gb|gbc|sav|srm)$'
+
+$tracked = & git -C $source ls-files
+if ($LASTEXITCODE -ne 0) { throw 'git ls-files failed' }
+
+$entries = $tracked |
+ ForEach-Object { $_.Replace('\', '/') } |
+ Where-Object {
+ $path = $_
+ (($required -contains $path) -or ($allowedRoots | Where-Object { $path.StartsWith($_) })) -and
+ $path -notmatch $forbidden
+ } |
+ Sort-Object -Unique
+
+foreach ($path in $required) {
+ if ($entries -notcontains $path) { throw "Required game file is missing from archive input: $path" }
+}
+
+$parent = Split-Path -Parent $Output
+New-Item -ItemType Directory -Force -Path $parent | Out-Null
+if (Test-Path -LiteralPath $Output) { Remove-Item -LiteralPath $Output -Force }
+
+Add-Type -AssemblyName System.IO.Compression
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$archive = [System.IO.Compression.ZipFile]::Open($Output, 'Create')
+try {
+ foreach ($relative in $entries) {
+ $full = Join-Path $source $relative
+ if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { throw "Tracked file is missing: $relative" }
+ $entry = $archive.CreateEntry($relative, [System.IO.Compression.CompressionLevel]::Optimal)
+ $entry.LastWriteTime = [DateTimeOffset]'2000-01-01T00:00:00Z'
+ $input = [System.IO.File]::OpenRead($full)
+ $outputStream = $entry.Open()
+ try { $input.CopyTo($outputStream) } finally { $outputStream.Dispose(); $input.Dispose() }
+ }
+} finally {
+ $archive.Dispose()
+}
+
+Write-Host "Created $Output with $($entries.Count) tracked, ROM-free entries."
diff --git a/ports/uwp/third_party/README.md b/ports/uwp/third_party/README.md
new file mode 100644
index 00000000..a174cbec
--- /dev/null
+++ b/ports/uwp/third_party/README.md
@@ -0,0 +1,9 @@
+# UWP dependencies
+
+The ANGLE UWP runtime is committed under `angle`. The package also uses:
+
+- `LOVE_UWP_ROOT`: [caorthann-celt/love-xbox-uwp](https://github.com/caorthann-celt/love-xbox-uwp) on the `gen1recomp` branch.
+- `UWP_SDL2_ANGLE_ROOT`: SDL2 built by `scripts/build-sdl2-angle.ps1`.
+- `VCPKG_ROOT`: the UWP codec, font, compression, and audio runtimes used by LÖVE.
+
+SDL headers, import library, and DLL must come from the same build. Runtime DLLs stay inside the installed package.
diff --git a/ports/uwp/third_party/angle/AUTHORS b/ports/uwp/third_party/angle/AUTHORS
new file mode 100644
index 00000000..37a9c07d
--- /dev/null
+++ b/ports/uwp/third_party/angle/AUTHORS
@@ -0,0 +1,89 @@
+# This is the official list of The ANGLE Project Authors
+# for copyright purposes.
+# This file is distinct from the CONTRIBUTORS files.
+# See the latter for an explanation.
+
+# Names should be added to this file as
+# Name or Organization
+# Email addresses for individuals are tracked elsewhere to avoid spam.
+
+Google Inc.
+TransGaming Inc.
+3DLabs Inc. Ltd.
+
+Adobe Systems Inc.
+Autodesk, Inc.
+BlackBerry Limited
+Cable Television Laboratories, Inc.
+Collabora, Ltd.
+Cloud Party, Inc.
+Igalia, S.L.
+Imagination Technologies Ltd.
+Intel Corporation
+LunarG, Inc.
+Mozilla Corporation
+Turbulenz
+Klarälvdalens Datakonsult AB
+Microsoft Corporation
+Microsoft Open Technologies, Inc.
+NVIDIA Corporation
+Opera Software ASA
+The Qt Company Ltd.
+Advanced Micro Devices, Inc.
+LG Electronics, Inc.
+IBM Inc.
+AdaptVis GmbH
+Samsung Electronics, Inc.
+Arm Ltd.
+Broadcom Inc.
+Facebook, Inc.
+The Khronos Group, Inc.
+Numfum GmbH
+Yandex LLC
+Rive
+Institute of Software, Chinese Academy of Sciences
+Guangdong OPPO Mobile Telecommunications Corp., Ltd
+Qualcomm Innovation Center, Inc.
+
+Jacek Caban
+Mark Callow
+Ginn Chen
+Tibor den Ouden
+Régis Fénéon
+James Hauxwell
+Sam Hocevar
+Pierre Leveille
+Jonathan Liu
+Boying Lu
+Aitor Moreno
+Yuri O'Donnell
+Josh Soref
+Ma Aiguo
+Maks Naumov
+Jinyoung Hur
+Sebastian Bergstein
+James Ross-Gowan
+Nickolay Artamonov
+Ihsan Akmal
+Andrei Volykhin
+Jérôme Duval
+Руслан Ижбулатов
+Thomas Miller
+Till Rathmann
+Nick Shaforostov
+Jaime Bernardo
+Le Hoang Quyen
+Lu Yahan
+Ethan Lee
+Renaud Lepage
+Artem Bolgar
+Wander Lairson Costa
+Stephan Hartmann
+SeongHwan Park
+Xiaopeng Li
+Akihiko Odaki
+Ho Cheung
+Tao Wang
+Phan Quang Minh
+Hongchen Yan
+Andrew Sumsion
diff --git a/ports/uwp/third_party/angle/LICENSE b/ports/uwp/third_party/angle/LICENSE
new file mode 100644
index 00000000..0f65fd60
--- /dev/null
+++ b/ports/uwp/third_party/angle/LICENSE
@@ -0,0 +1,32 @@
+// Copyright 2018 The ANGLE Project Authors.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions
+// are met:
+//
+// Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following
+// disclaimer in the documentation and/or other materials provided
+// with the distribution.
+//
+// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc.
+// Ltd., nor the names of their contributors may be used to endorse
+// or promote products derived from this software without specific
+// prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
diff --git a/ports/uwp/third_party/angle/README.md b/ports/uwp/third_party/angle/README.md
new file mode 100644
index 00000000..825e2e10
--- /dev/null
+++ b/ports/uwp/third_party/angle/README.md
@@ -0,0 +1,13 @@
+# ANGLE UWP runtime
+
+`libEGL.dll` and `libGLESv2.dll` were built for x64 UWP from [SternXD/angle](https://github.com/SternXD/angle) commit `45b0b1e03400b7a10aaa9a077e196d1abcddafce`. They report ANGLE version `2.1.25011` and source hash `45b0b1e03400`.
+
+`d3dcompiler_47.dll` is the x64 Direct3D HLSL compiler redistributable from Windows SDK `10.0.26100.7705`.
+
+SHA-256:
+
+- `d3dcompiler_47.dll`: `A05F99734F7C4822FEFC12B367AF21FD0976ED6608752FB1E1E80B6ECE7ECBBB`
+- `libEGL.dll`: `FE2AC107D0B6CAAE917E755064B430E4ECC52495B12E44EC7B0218E10504ECD1`
+- `libGLESv2.dll`: `C8A7E08E0C34E534A781DD8784065CC0608D6E497ADB451F6EEF348E594A00CF`
+
+ANGLE's upstream `LICENSE` and `AUTHORS` files are included beside its binaries.
diff --git a/ports/uwp/third_party/angle/d3dcompiler_47.dll b/ports/uwp/third_party/angle/d3dcompiler_47.dll
new file mode 100644
index 00000000..948cc90b
Binary files /dev/null and b/ports/uwp/third_party/angle/d3dcompiler_47.dll differ
diff --git a/ports/uwp/third_party/angle/libEGL.dll b/ports/uwp/third_party/angle/libEGL.dll
new file mode 100644
index 00000000..620c3b1f
Binary files /dev/null and b/ports/uwp/third_party/angle/libEGL.dll differ
diff --git a/ports/uwp/third_party/angle/libGLESv2.dll b/ports/uwp/third_party/angle/libGLESv2.dll
new file mode 100644
index 00000000..dec1134d
Binary files /dev/null and b/ports/uwp/third_party/angle/libGLESv2.dll differ
diff --git a/src/core/Music.lua b/src/core/Music.lua
index b5d54908..183443c9 100644
--- a/src/core/Music.lua
+++ b/src/core/Music.lua
@@ -259,7 +259,8 @@ function Music.play(data, song, loop, ctx)
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
- else
+ elseif not isChip then
+ -- ChipAudio owns the loop policy for its queueable source.
pcall(src.setLooping, src, wantLoop)
end
applyVolume(src)
diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua
index 1f19dc66..342e0e1f 100644
--- a/src/import/CacheFs.lua
+++ b/src/import/CacheFs.lua
@@ -31,6 +31,11 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1)
+local function isUWP()
+ return love and love.system and love.system.getOS
+ and love.system.getOS() == "UWP"
+end
+
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- while a Red import keeps the historical root. The launcher sets it per
@@ -52,6 +57,7 @@ local mkdirFn = nil
local function resolveMkdir()
if mkdirFn ~= nil then return mkdirFn end
mkdirFn = false
+ if isUWP() then return mkdirFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return mkdirFn end
if ffi.os == "Windows" then
@@ -83,6 +89,7 @@ local rmdirFn = nil
local function resolveRmdir()
if rmdirFn ~= nil then return rmdirFn end
rmdirFn = false
+ if isUWP() then return rmdirFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return rmdirFn end
if ffi.os == "Windows" then
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 5409a371..be01ee22 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -8,6 +8,11 @@ local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
+local function isUWP()
+ return love and love.system and love.system.getOS
+ and love.system.getOS() == "UWP"
+end
+
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
@@ -990,6 +995,7 @@ end
-- import-only run all skip the release check so headless and CI runs never spin
-- up the background worker or reach out to the network.
local function updaterAllowed()
+ if isUWP() then return false end
if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
@@ -1034,6 +1040,7 @@ function RomImporter.new(onComplete, opts)
mobileFileBridge = mobileFileBridge,
android = android,
ios = mobileOS == "iOS",
+ nativePicker = romImportMode == "native-picker",
-- One startup poll pass on both mobiles. iOS: files dropped through the
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
-- no love.focus event necessarily following. Android: the SAF picker is a
@@ -1493,7 +1500,7 @@ function RomImporter:chooseMod()
self:rescanModsAction()
return
end
- if self.ios and love.system.getPickedFile then
+ if self.nativePicker and love.system.getPickedFile then
self.iosPendingKind = "mod"
if not pickFile("mod") then
self.iosPendingKind = nil
@@ -1569,7 +1576,7 @@ function RomImporter:chooseSaveImport(version)
self:rescanSavesAction(version)
return
end
- if self.ios and love.system.getPickedFile then
+ if self.nativePicker and love.system.getPickedFile then
self.iosPendingKind = "sav"
self.iosPendingVersion = version
if not pickFile("sav") then
@@ -1687,7 +1694,7 @@ function RomImporter:choose(version)
self:rescanAction(self.chooseVersion)
return
end
- if self.ios and love.system.getPickedFile then
+ if self.nativePicker and love.system.getPickedFile then
self.iosPendingKind = "rom"
if not pickFile("rom") then
self.iosPendingKind = nil
@@ -1889,7 +1896,7 @@ function RomImporter:update(dt)
end)
end
end
- if self.ios and love.system.getPickedFile and self.workState ~= "working" then
+ if self.nativePicker and love.system.getPickedFile and self.workState ~= "working" then
local path = love.system.getPickedFile()
if path then
local kind = self.iosPendingKind or "rom"
@@ -1898,10 +1905,18 @@ function RomImporter:update(dt)
self.iosPendingVersion = nil
if kind == "mod" then
self:_installMod(path)
+ if isUWP() and self.modNotice and self.modNotice.ok then
+ os.remove(path)
+ end
elseif kind == "sav" then
- self:_importSave(version or self:_savedropTarget(), path)
+ local target = version or self:_savedropTarget()
+ self:_importSave(target, path)
+ if isUWP() and self.saveNotice[target] and self.saveNotice[target].ok then
+ os.remove(path)
+ end
else
self:startPath(path)
+ if isUWP() then os.remove(path) end
end
elseif love.system.getPickError then
local errorText = love.system.getPickError()
diff --git a/src/update/Check.lua b/src/update/Check.lua
index b06b1f61..6ac03eb6 100644
--- a/src/update/Check.lua
+++ b/src/update/Check.lua
@@ -99,6 +99,11 @@ local cache = { status = "idle" } -- newest snapshot from the worker
local function ensureWorker()
if workerReady ~= nil then return workerReady end
+ if love and love.system and love.system.getOS
+ and love.system.getOS() == "UWP" then
+ workerReady = false
+ return false
+ end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
diff --git a/tests/rom_importer_double_pick_test.lua b/tests/rom_importer_double_pick_test.lua
index e7eba41e..6bf5e041 100644
--- a/tests/rom_importer_double_pick_test.lua
+++ b/tests/rom_importer_double_pick_test.lua
@@ -28,6 +28,8 @@ love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
+ getPickedFile = love.system.getPickedFile,
+ getPickError = love.system.getPickError,
getDirectoryItems = love.filesystem.getDirectoryItems,
getInfo = love.filesystem.getInfo,
read = love.filesystem.read,
@@ -135,6 +137,29 @@ contract:choose("red")
check(picks == 2,
"choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")")
+-- 7. UWP consumes the absolute LocalState path returned by its native picker
+-- and removes the temporary copy only after a successful import.
+local removedPath
+local savedOsRemove = os.remove
+os.remove = function(path) removedPath = path; return true end
+love.system.getPickedFile = function()
+ love.system.getPickedFile = function() return nil end
+ return [[C:\LocalState\picked_mod.zip]]
+end
+love.system.getPickError = function() return nil end
+local uwp = importer("UWP")
+uwp.iosPendingKind = "mod"
+uwp._installMod = function(self, source)
+ self._installed = source
+ self.modNotice = { ok = true, text = "Installed test-mod" }
+end
+uwp:update(0)
+check(uwp._installed == [[C:\LocalState\picked_mod.zip]],
+ "UWP passes the picker path to the mod installer")
+check(removedPath == [[C:\LocalState\picked_mod.zip]],
+ "UWP removes the temporary picker copy after installation")
+os.remove = savedOsRemove
+
local touchForwardsToImporter = {
Android = true,
iOS = true,
@@ -161,6 +186,8 @@ check(not touch._flex,
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
+love.system.getPickedFile = saved.getPickedFile
+love.system.getPickError = saved.getPickError
love.filesystem.getDirectoryItems = saved.getDirectoryItems
love.filesystem.getInfo = saved.getInfo
love.filesystem.read = saved.read