From 92f73d8bed0b126fb65d67614e798452e94f66c3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:28:07 -0400 Subject: [PATCH 01/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index b00dc5e1..58265901 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.65", + "date": "2026-08-03", + "size": 9376618, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE" + }, { "version": "0.1.64", "date": "2026-08-03", From 7b060c19f48cee13a0c958ca25db688abfa51604 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:50:19 -0400 Subject: [PATCH 02/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 58265901..f8dc0ddf 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.66", + "date": "2026-08-04", + "size": 9749980, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot" + }, { "version": "0.1.65", "date": "2026-08-03", From d37751863201288f6ed48d00548d3b04a0de8024 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:41:03 -0400 Subject: [PATCH 03/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index f8dc0ddf..10a8d109 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.67", + "date": "2026-08-04", + "size": 9767619, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE" + }, { "version": "0.1.66", "date": "2026-08-04", From 1963a5453e7b015ed40412389a30c7e78321e9e2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:10:21 -0400 Subject: [PATCH 04/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 10a8d109..a27a1f89 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.68", + "date": "2026-08-04", + "size": 9768011, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt" + }, { "version": "0.1.67", "date": "2026-08-04", From afc5a529784aa4ca1bafdd60c42def25e02a8e30 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:55:32 -0400 Subject: [PATCH 05/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index a27a1f89..93a79d2d 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.69", + "date": "2026-08-04", + "size": 9552851, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto" + }, { "version": "0.1.68", "date": "2026-08-04", From 7def560726d488c7b91612b3696c8e6ab06b6b75 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:08:35 -0400 Subject: [PATCH 06/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 93a79d2d..dcb54762 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.70", + "date": "2026-08-05", + "size": 9562731, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro" + }, { "version": "0.1.69", "date": "2026-08-04", From 9696c8d3e2256138f9f3a3c65fbab7351fc32c9c Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:59:49 -0400 Subject: [PATCH 07/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index dcb54762..c4c9947e 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.71", + "date": "2026-08-05", + "size": 9569738, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi" + }, { "version": "0.1.70", "date": "2026-08-05", From 683fee80285801f52c52e429c39e5595fa08e88f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:49:41 -0400 Subject: [PATCH 08/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index c4c9947e..ee54391b 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.72", + "date": "2026-08-05", + "size": 9586678, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi" + }, { "version": "0.1.71", "date": "2026-08-05", From 439e97aee60bc5ba1fc3f71411f0d5e31764ec6a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:23:19 -0400 Subject: [PATCH 09/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index ee54391b..0ab602ef 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.73", + "date": "2026-08-06", + "size": 9585729, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi" + }, { "version": "0.1.72", "date": "2026-08-05", From 35ed326dda4a4987accd08d65455d215485e3919 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:27:09 -0400 Subject: [PATCH 10/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 0ab602ef..5cb0d7e0 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.74", + "date": "2026-08-06", + "size": 9588946, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing" + }, { "version": "0.1.73", "date": "2026-08-06", From 0f484682d8dd6a6ba089c92f115bc62fd0839c63 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:41:34 -0400 Subject: [PATCH 11/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 5cb0d7e0..d4eb8901 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.75", + "date": "2026-08-06", + "size": 9574086, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE" + }, { "version": "0.1.74", "date": "2026-08-06", From ab5fed60fe5af8ff32b033b7e03214be12141acd Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:16:03 -0400 Subject: [PATCH 12/13] chore(ios): update app-repo.json [skip ci] --- mobile/ios/app-repo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index d4eb8901..59256250 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -12,6 +12,13 @@ "tintColor": "3b5ca8", "category": "games", "versions": [ + { + "version": "0.1.76", + "date": "2026-08-10", + "size": 9610081, + "downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.76/gen1recomp-0.1.76-ios.ipa", + "localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #617 Old Man in Viridian\n- #889 Pokemon yellow can't export save file right\n- #915 wrong sprite used in opening\n- #916 Trainer sprite at end of fly animation\n- #931 Mod update checks fail\n- #932 Bugs reset settings\n- #933 Add TitleState override for player\n- #945 Cannot edit Trainer Class's Battle Theme\n- #969 Linux x86_64 AppImage Shows up as \"LOVE\" instead of \"gen1recomp\"\n- #1016 Mod API: support variable-size overworld sprites\n- #1039 Encounter rate grace period not working\n- #1040 Player sprite walks right through rival after defeating him at the end of the game\n\n## Contributors\n\n- @ArmstrongThomas\n- @AverageConsumer\n- @Bortlesboat\n- @bryanthaboi\n- @crusty\n- @dlloa\n- @jherediagu\n- @KikiManjaro\n- @martin2844\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @steve1337\n- @swuff-star\n- @thibautbus\n- @Yukitty\n- hernan" + }, { "version": "0.1.75", "date": "2026-08-06", From a9767e5df14e7908a5a5476d63928799859269a6 Mon Sep 17 00:00:00 2001 From: Solidus Snake <63137482+TheRealSolidusSnake@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:43:53 -0400 Subject: [PATCH 13/13] Add native TLS for Android love.system and desktop gen1tls. Expose a non-blocking TLS socket API to mods (WSS clients) without bundling any game-specific multiworld content. Co-authored-by: Cursor --- .gitignore | 7 + .../love/src/jni/love/src/common/android.cpp | 177 ++++++++++ .../love/src/jni/love/src/common/android.h | 21 ++ .../jni/love/src/modules/system/System.cpp | 66 ++++ .../src/jni/love/src/modules/system/System.h | 14 + .../love/src/modules/system/wrap_System.cpp | 79 +++++ .../java/org/love2d/android/GameActivity.java | 39 +++ .../java/org/love2d/android/TlsSocket.java | 310 ++++++++++++++++++ native/tls_dial/Gen1Tls.csproj | 24 ++ native/tls_dial/README.md | 30 ++ native/tls_dial/TlsDialer.cs | 282 ++++++++++++++++ scripts/build_windows.ps1 | 94 ++++++ tools/winbuild/python3 | 7 + tools/winbuild/zip | 6 + tools/winbuild/zip_impl.py | 111 +++++++ 15 files changed, 1267 insertions(+) create mode 100644 mobile/android/love/src/main/java/org/love2d/android/TlsSocket.java create mode 100644 native/tls_dial/Gen1Tls.csproj create mode 100644 native/tls_dial/README.md create mode 100644 native/tls_dial/TlsDialer.cs create mode 100644 scripts/build_windows.ps1 create mode 100644 tools/winbuild/python3 create mode 100644 tools/winbuild/zip create mode 100644 tools/winbuild/zip_impl.py diff --git a/.gitignore b/.gitignore index d68dbada..084cdfbb 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,10 @@ mobile/ios/bundle_id.local /ports/uwp/build/ /ports/uwp/third_party/*/source/ /ports/uwp/third_party/angle/depot_tools/ + +# Native TLS dialer build output (dotnet publish) +/native/tls_dial/bin/ +/native/tls_dial/obj/ +/dist/native/ +/dist/win/ +/.bazinga/ diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 2204e45d..429e0353 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -23,6 +23,7 @@ #ifdef LOVE_ANDROID #include +#include #include #include @@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent, return result; } +/* + * TLS sockets. Same resolution rule as httpDownload above -- the activity's + * own class, never FindClass -- and the same tolerance for an old APK: a + * missing method answers like a platform without TLS instead of aborting. + */ +static jclass tlsActivityClass(JNIEnv *env) +{ + jobject activityObj = (jobject) SDL_AndroidGetActivity(); + if (activityObj == nullptr) + return nullptr; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + return activity; +} + +int tlsOpen(const char *host, int port) +{ + if (host == nullptr) + return -1; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return -1; + + jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return -1; + } + + jstring jhost = env->NewStringUTF(host); + jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port); + env->DeleteLocalRef(jhost); + env->DeleteLocalRef(activity); + return (int) result; +} + +int tlsStatus(int handle) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return -1; + + jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return -1; + } + + jint result = env->CallStaticIntMethod(activity, method, (jint) handle); + env->DeleteLocalRef(activity); + return (int) result; +} + +int tlsSend(int handle, const char *data, int length) +{ + if (data == nullptr || length <= 0) + return 0; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return -1; + + jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return -1; + } + + jbyteArray payload = env->NewByteArray((jsize) length); + if (payload == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return -1; + } + env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data); + + jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload); + env->DeleteLocalRef(payload); + env->DeleteLocalRef(activity); + return (int) result; +} + +int tlsReceive(int handle, char *buf, int max) +{ + if (buf == nullptr || max <= 0) + return 0; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return -1; + + jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return -1; + } + + jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max); + env->DeleteLocalRef(activity); + if (result == nullptr) + return 0; + + jbyteArray bytes = (jbyteArray) result; + jsize length = env->GetArrayLength(bytes); + if (length > max) + length = max; + env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf); + env->DeleteLocalRef(result); + return (int) length; +} + +bool tlsError(int handle, char *buf, int max) +{ + if (buf == nullptr || max <= 0) + return false; + buf[0] = '\0'; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return false; + + jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle); + env->DeleteLocalRef(activity); + if (result == nullptr) + return false; + + jstring text = (jstring) result; + const char *utf = env->GetStringUTFChars(text, nullptr); + if (utf != nullptr) + { + strncpy(buf, utf, (size_t) max - 1); + buf[max - 1] = '\0'; + env->ReleaseStringUTFChars(text, utf); + } + env->DeleteLocalRef(result); + return buf[0] != '\0'; +} + +void tlsClose(int handle) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = tlsActivityClass(env); + if (activity == nullptr) + return; + + jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return; + } + + env->CallStaticVoidMethod(activity, method, (jint) handle); + env->DeleteLocalRef(activity); +} + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 23bb905f..6e2fa017 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -98,6 +98,27 @@ bool restartApp(); **/ bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); +/** + * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). + * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise + * unreachable -- and an Archipelago room hosted on archipelago.gg accepts a + * plain connection only to drop it. The platform has both a TLS stack and the + * system trust store, so this borrows them rather than vendoring mbedTLS. + * + * tlsOpen returns a handle immediately and connects on its own thread: poll + * tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that + * does not exist. Bytes given to tlsSend before the handshake finishes are + * queued rather than refused. tlsReceive fills buf and returns how much it + * took, 0 when nothing is waiting. A closed connection keeps both its reason + * (tlsError) and whatever arrived before it closed until tlsClose. + **/ +int tlsOpen(const char *host, int port); +int tlsStatus(int handle); +int tlsSend(int handle, const char *data, int length); +int tlsReceive(int handle, char *buf, int max); +bool tlsError(int handle, char *buf, int max); +void tlsClose(int handle); + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 8dd9e8de..b2b4ac5b 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -244,6 +244,72 @@ bool System::httpDownload(const char *url, const char *destPath, #endif } +int System::tlsOpen(const char *host, int port) const +{ +#ifdef LOVE_ANDROID + return love::android::tlsOpen(host, port); +#else + LOVE_UNUSED(host); + LOVE_UNUSED(port); + return -1; +#endif +} + +int System::tlsStatus(int handle) const +{ +#ifdef LOVE_ANDROID + return love::android::tlsStatus(handle); +#else + LOVE_UNUSED(handle); + return -1; +#endif +} + +int System::tlsSend(int handle, const char *data, int length) const +{ +#ifdef LOVE_ANDROID + return love::android::tlsSend(handle, data, length); +#else + LOVE_UNUSED(handle); + LOVE_UNUSED(data); + LOVE_UNUSED(length); + return -1; +#endif +} + +int System::tlsReceive(int handle, char *buf, int max) const +{ +#ifdef LOVE_ANDROID + return love::android::tlsReceive(handle, buf, max); +#else + LOVE_UNUSED(handle); + LOVE_UNUSED(buf); + LOVE_UNUSED(max); + return -1; +#endif +} + +bool System::tlsError(int handle, char *buf, int max) const +{ +#ifdef LOVE_ANDROID + return love::android::tlsError(handle, buf, max); +#else + LOVE_UNUSED(handle); + LOVE_UNUSED(buf); + LOVE_UNUSED(max); + return false; +#endif +} + +void System::tlsClose(int handle) const +{ +#ifdef LOVE_ANDROID + love::android::tlsClose(handle); +#else + LOVE_UNUSED(handle); +#endif +} + bool System::hasBackgroundMusic() const { #if defined(LOVE_ANDROID) diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index be1ea307..ccf3538d 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -149,6 +149,20 @@ public: virtual bool httpDownload(const char *url, const char *destPath, const char *userAgent = nullptr, const char *accept = nullptr) const; + /** + * TLS client sockets (Android only; every call fails elsewhere, where + * LuaSec or another provider is the answer). Non-blocking by contract: + * tlsOpen returns a handle and connects on its own thread, tlsStatus + * reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent + * before the handshake completes are queued rather than refused. + **/ + virtual int tlsOpen(const char *host, int port) const; + virtual int tlsStatus(int handle) const; + virtual int tlsSend(int handle, const char *data, int length) const; + virtual int tlsReceive(int handle, char *buf, int max) const; + virtual bool tlsError(int handle, char *buf, int max) const; + virtual void tlsClose(int handle) const; + /** * Gets if the user is playing music on background. * Throws an exception on unsupported platforms. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 1b94ef7c..fc8c2240 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -139,6 +139,79 @@ int w_hasBackgroundMusic(lua_State *L) return 1; } +/* + * TLS sockets. Deliberately a handle-and-poll API rather than an object: + * the caller is a per-frame pump that must never block, and everything with + * a thread behind it lives on the Java side. + */ +int w_tlsOpen(lua_State *L) +{ + const char *host = luaL_checkstring(L, 1); + int port = (int) luaL_checknumber(L, 2); + lua_pushnumber(L, instance()->tlsOpen(host, port)); + return 1; +} + +int w_tlsStatus(lua_State *L) +{ + int handle = (int) luaL_checknumber(L, 1); + lua_pushnumber(L, instance()->tlsStatus(handle)); + return 1; +} + +int w_tlsSend(lua_State *L) +{ + int handle = (int) luaL_checknumber(L, 1); + size_t length = 0; + const char *data = luaL_checklstring(L, 2, &length); + lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length)); + return 1; +} + +int w_tlsReceive(lua_State *L) +{ + int handle = (int) luaL_checknumber(L, 1); + int max = (int) luaL_optnumber(L, 2, 8192); + if (max <= 0) + { + lua_pushliteral(L, ""); + return 1; + } + // A frame's worth of a busy room, on the C stack rather than the heap: + // this runs every frame and an allocation per poll is not worth it. + if (max > 65536) + max = 65536; + char buf[65536]; + int got = instance()->tlsReceive(handle, buf, max); + if (got < 0) + { + lua_pushnil(L); + return 1; + } + lua_pushlstring(L, buf, (size_t) got); + return 1; +} + +int w_tlsError(lua_State *L) +{ + int handle = (int) luaL_checknumber(L, 1); + char buf[512]; + if (!instance()->tlsError(handle, buf, (int) sizeof(buf))) + { + lua_pushnil(L); + return 1; + } + lua_pushstring(L, buf); + return 1; +} + +int w_tlsClose(lua_State *L) +{ + int handle = (int) luaL_checknumber(L, 1); + instance()->tlsClose(handle); + return 0; +} + static const luaL_Reg functions[] = { { "getOS", w_getOS }, @@ -153,6 +226,12 @@ static const luaL_Reg functions[] = { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, + { "tlsOpen", w_tlsOpen }, + { "tlsStatus", w_tlsStatus }, + { "tlsSend", w_tlsSend }, + { "tlsReceive", w_tlsReceive }, + { "tlsError", w_tlsError }, + { "tlsClose", w_tlsClose }, { "hasBackgroundMusic", w_hasBackgroundMusic }, { 0, 0 } }; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 09fa2148..3bad1ea0 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -606,6 +606,45 @@ public class GameActivity extends SDLActivity { * The body lands in a .part file and is renamed only once complete, so a * dropped connection can never leave a half file the caller trusts. */ + /** + * TLS client sockets, exposed as love.system.tls* and used by the + * Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without + * these a hosted room -- every one of which is TLS-only -- is unreachable + * from the game. The work is in TlsSocket; these are the static entry + * points, because the JNI side resolves methods on the activity's own + * class (see love/src/common/android.cpp) and cannot see other classes + * from a worker thread. + */ + @Keep + public static int tlsOpen(String host, int port) { + return TlsSocket.open(host, port); + } + + @Keep + public static int tlsStatus(int handle) { + return TlsSocket.status(handle); + } + + @Keep + public static int tlsSend(int handle, byte[] data) { + return TlsSocket.send(handle, data); + } + + @Keep + public static byte[] tlsReceive(int handle, int max) { + return TlsSocket.receive(handle, max); + } + + @Keep + public static String tlsError(int handle) { + return TlsSocket.error(handle); + } + + @Keep + public static void tlsClose(int handle) { + TlsSocket.close(handle); + } + @Keep public static boolean httpDownload(String url, String destPath, String userAgent, String accept) { if (url == null || destPath == null) return false; diff --git a/mobile/android/love/src/main/java/org/love2d/android/TlsSocket.java b/mobile/android/love/src/main/java/org/love2d/android/TlsSocket.java new file mode 100644 index 00000000..2014211e --- /dev/null +++ b/mobile/android/love/src/main/java/org/love2d/android/TlsSocket.java @@ -0,0 +1,310 @@ +package org.love2d.android; + +import android.util.Log; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +/** + * A TLS client socket that Lua can drive without ever blocking a frame. + * + * WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing + * else, so wss:// was simply unreachable from the game -- and every room hosted + * on archipelago.gg is TLS-only, accepting a plain connection just long enough + * to drop it. The alternative was vendoring mbedTLS into the NDK build and + * carrying a CA bundle in the APK; the platform already has both a TLS stack + * and the system trust store, so this asks Android instead. + * + * THE CONTRACT. Callers get an int handle and poll it. open() returns + * immediately and the connect and handshake happen on their own thread, so a + * slow or unreachable host costs nothing on the game thread -- which matters + * more here than it did for httpDownload, since that runs on a worker and this + * is serviced from the frame loop. Bytes handed to send() before the handshake + * finishes are queued, not refused, so a caller can write its request the + * moment it has a handle and never think about readiness again. + * + * Reads are drained by a thread into a chunk queue and handed over a copy at a + * time; a caller that stops polling stops the connection rather than growing + * the heap without limit. + */ +final class TlsSocket { + static final int STATUS_CONNECTING = 0; + static final int STATUS_OPEN = 1; + static final int STATUS_CLOSED = 2; + + private static final int CONNECT_TIMEOUT_MS = 15000; + private static final int READ_CHUNK = 16384; + /** Roughly a second of a very chatty room; past this the reader is gone. */ + private static final int MAX_BUFFERED = 4 * 1024 * 1024; + + private static final ConcurrentHashMap LIVE = + new ConcurrentHashMap(); + private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1); + + private final String host; + private final int port; + private final int handle; + + private volatile int status = STATUS_CONNECTING; + private volatile String error = null; + private volatile boolean closing = false; + private volatile SSLSocket socket = null; + + private final Object inLock = new Object(); + private final ArrayDeque inChunks = new ArrayDeque(); + private int inHeadOffset = 0; + private int inAvailable = 0; + + private final Object outLock = new Object(); + private final ArrayDeque outChunks = new ArrayDeque(); + + private TlsSocket(String host, int port, int handle) { + this.host = host; + this.port = port; + this.handle = handle; + } + + // ---------------------------------------------------------------- API + + static int open(String host, int port) { + if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1; + final int handle = NEXT_HANDLE.getAndIncrement(); + final TlsSocket self = new TlsSocket(host, port, handle); + LIVE.put(Integer.valueOf(handle), self); + Thread dialer = new Thread(new Runnable() { + @Override public void run() { self.dial(); } + }, "tls-dial-" + handle); + dialer.setDaemon(true); + dialer.start(); + return handle; + } + + static int status(int handle) { + TlsSocket self = LIVE.get(Integer.valueOf(handle)); + return self == null ? -1 : self.status; + } + + static String error(int handle) { + TlsSocket self = LIVE.get(Integer.valueOf(handle)); + return self == null ? null : self.error; + } + + static int send(int handle, byte[] data) { + TlsSocket self = LIVE.get(Integer.valueOf(handle)); + if (self == null || data == null) return -1; + if (self.status == STATUS_CLOSED) return -1; + if (data.length == 0) return 0; + synchronized (self.outLock) { + self.outChunks.add(data); + self.outLock.notifyAll(); + } + return data.length; + } + + static byte[] receive(int handle, int max) { + TlsSocket self = LIVE.get(Integer.valueOf(handle)); + if (self == null || max <= 0) return null; + return self.take(max); + } + + static void close(int handle) { + TlsSocket self = LIVE.remove(Integer.valueOf(handle)); + if (self != null) self.shutdown(null); + } + + // ------------------------------------------------------------ internals + + private void dial() { + Socket plain = null; + try { + plain = new Socket(); + plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS); + plain.setTcpNoDelay(true); + + SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true); + + // Wrapping an already-connected socket skips the SNI and hostname + // checking that createSocket(host, port) would have done for us, and + // a shared address like archipelago.gg answers with the wrong + // certificate without the name in the hello. Both are set through + // SSLParameters where the platform has it, with the verifier below + // as the floor for anything older. + boolean verifiedByPlatform = false; + try { + SSLParameters params = ssl.getSSLParameters(); + params.setEndpointIdentificationAlgorithm("HTTPS"); + List names = new ArrayList(1); + names.add(new SNIHostName(host)); + params.setServerNames(names); + ssl.setSSLParameters(params); + verifiedByPlatform = true; + } catch (Throwable ignored) { + // Older platform: handled after the handshake instead. + } + + enableModernProtocols(ssl); + ssl.startHandshake(); + + if (!verifiedByPlatform + && !HttpsURLConnection.getDefaultHostnameVerifier() + .verify(host, ssl.getSession())) { + throw new java.io.IOException( + "certificate does not match " + host); + } + + socket = ssl; + if (closing) { shutdown(null); return; } + status = STATUS_OPEN; + + Thread writer = new Thread(new Runnable() { + @Override public void run() { pumpOut(); } + }, "tls-write-" + handle); + writer.setDaemon(true); + writer.start(); + + pumpIn(); + } catch (Throwable t) { + shutdown(describe(t)); + if (plain != null) { + try { plain.close(); } catch (Throwable ignored) {} + } + } + } + + /** + * minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every + * modern server refuses everything older, so switch on whatever the + * platform has rather than leaving an old device negotiating TLS 1.0. + */ + private static void enableModernProtocols(SSLSocket ssl) { + try { + List wanted = new ArrayList(3); + for (String supported : ssl.getSupportedProtocols()) { + if (supported.startsWith("TLSv1.1") + || supported.startsWith("TLSv1.2") + || supported.startsWith("TLSv1.3")) { + wanted.add(supported); + } + } + if (!wanted.isEmpty()) { + ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()])); + } + } catch (Throwable ignored) { + } + } + + private void pumpIn() { + try { + InputStream in = socket.getInputStream(); + byte[] buf = new byte[READ_CHUNK]; + while (!closing) { + int n = in.read(buf); + if (n < 0) break; + if (n == 0) continue; + byte[] chunk = new byte[n]; + System.arraycopy(buf, 0, chunk, 0, n); + synchronized (inLock) { + if (inAvailable + n > MAX_BUFFERED) { + throw new java.io.IOException("read buffer overflow"); + } + inChunks.add(chunk); + inAvailable += n; + } + } + shutdown(null); + } catch (Throwable t) { + shutdown(describe(t)); + } + } + + private void pumpOut() { + try { + OutputStream out = socket.getOutputStream(); + while (true) { + byte[] chunk; + synchronized (outLock) { + while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) { + outLock.wait(); + } + if (closing || status == STATUS_CLOSED) return; + chunk = outChunks.poll(); + } + if (chunk != null) { + out.write(chunk); + out.flush(); + } + } + } catch (Throwable t) { + shutdown(describe(t)); + } + } + + private byte[] take(int max) { + synchronized (inLock) { + if (inAvailable <= 0) return null; + int want = Math.min(max, inAvailable); + byte[] out = new byte[want]; + int filled = 0; + while (filled < want) { + byte[] head = inChunks.peek(); + if (head == null) break; + int have = head.length - inHeadOffset; + int take = Math.min(have, want - filled); + System.arraycopy(head, inHeadOffset, out, filled, take); + filled += take; + inHeadOffset += take; + if (inHeadOffset >= head.length) { + inChunks.poll(); + inHeadOffset = 0; + } + } + inAvailable -= filled; + if (filled == want) return out; + byte[] short_ = new byte[filled]; + System.arraycopy(out, 0, short_, 0, filled); + return short_; + } + } + + /** + * The handle stays registered until the caller closes it, so why the + * connection ended and whatever arrived before it did are both still + * readable. Dropping it here instead would turn a server that states its + * refusal and hangs up into an unknown handle, which is the one failure a + * player most needs the reason for. + */ + private void shutdown(String why) { + if (why != null && error == null) error = why; + closing = true; + status = STATUS_CLOSED; + synchronized (outLock) { outLock.notifyAll(); } + SSLSocket s = socket; + socket = null; + if (s != null) { + try { s.close(); } catch (Throwable ignored) {} + } + if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why); + } + + private static String describe(Throwable t) { + String msg = t.getMessage(); + String name = t.getClass().getSimpleName(); + if (msg == null || msg.length() == 0) return name; + return name + ": " + msg; + } +} diff --git a/native/tls_dial/Gen1Tls.csproj b/native/tls_dial/Gen1Tls.csproj new file mode 100644 index 00000000..414eebe0 --- /dev/null +++ b/native/tls_dial/Gen1Tls.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + true + true + Shared + true + true + gen1tls + Gen1Tls + + diff --git a/native/tls_dial/README.md b/native/tls_dial/README.md new file mode 100644 index 00000000..13a1540c --- /dev/null +++ b/native/tls_dial/README.md @@ -0,0 +1,30 @@ +# gen1tls — desktop TLS dialer + +Native AOT library that gives mods a non-blocking TLS client with the same +handle/poll contract as the Android `TlsSocket` / `love.system.tls*` bridge. + +- **Windows:** Schannel via `SslStream` (system trust store, SNI) +- **Linux / macOS:** same project, publish with `-r linux-x64` / `osx-x64` / + `osx-arm64` when those builds are wired up + +## Build + +```powershell +dotnet publish native/tls_dial/Gen1Tls.csproj -c Release -r win-x64 -o dist/native/win-x64 +``` + +The Windows game zip script already does this: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Version 0.1.77 +``` + +Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the +fused executable. Mods load it through LuaJIT FFI; no .NET runtime is +required on the player's machine. + +## Android + +On Android, the matching API is exposed as `love.system.tlsOpen` / +`tlsStatus` / `tlsSend` / `tlsReceive` / `tlsError` / `tlsClose`, backed by +`org.love2d.android.TlsSocket`. diff --git a/native/tls_dial/TlsDialer.cs b/native/tls_dial/TlsDialer.cs new file mode 100644 index 00000000..24a6f072 --- /dev/null +++ b/native/tls_dial/TlsDialer.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Text; +using System.Threading; + +namespace Gen1Tls; + +/// +/// Non-blocking TLS client with the same handle/poll contract as the Android +/// TlsSocket bridge. Connect and handshake run on a background thread; send +/// queues until the stream is ready; receive drains a chunk queue. Certificate +/// validation and SNI are SslStream's defaults (platform trust store). +/// +public static class TlsDialer +{ + public const int StatusConnecting = 0; + public const int StatusOpen = 1; + public const int StatusClosed = 2; + + const int ConnectTimeoutMs = 15000; + const int ReadChunk = 16384; + const int MaxBuffered = 4 * 1024 * 1024; + + static readonly ConcurrentDictionary Live = new(); + static int NextHandle = 1; + + sealed class Conn + { + public required string Host; + public required int Port; + public int Status = StatusConnecting; + public string? Error; + public bool Closing; + public SslStream? Stream; + public TcpClient? Client; + + public readonly object InLock = new(); + public readonly Queue InChunks = new(); + public int InHeadOffset; + public int InAvailable; + + public readonly object OutLock = new(); + public readonly Queue OutChunks = new(); + public readonly ManualResetEventSlim OutPulse = new(false); + } + + // ---------------------------------------------------------------- C ABI + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_open")] + public static int Open(nint hostPtr, int port) + { + if (hostPtr == 0 || port <= 0 || port > 65535) return -1; + string? host = Marshal.PtrToStringUTF8(hostPtr); + if (string.IsNullOrEmpty(host)) return -1; + + int handle = Interlocked.Increment(ref NextHandle); + var conn = new Conn { Host = host, Port = port }; + if (!Live.TryAdd(handle, conn)) return -1; + + var dialer = new Thread(() => Dial(conn)) + { + IsBackground = true, + Name = "gen1tls-dial-" + handle, + }; + dialer.Start(); + return handle; + } + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_status")] + public static int Status(int handle) + => Live.TryGetValue(handle, out var conn) ? conn.Status : -1; + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_send")] + public static int Send(int handle, nint dataPtr, int length) + { + if (!Live.TryGetValue(handle, out var conn) || dataPtr == 0) return -1; + if (conn.Status == StatusClosed) return -1; + if (length <= 0) return 0; + + var copy = new byte[length]; + Marshal.Copy(dataPtr, copy, 0, length); + lock (conn.OutLock) + { + conn.OutChunks.Enqueue(copy); + conn.OutPulse.Set(); + } + return length; + } + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_receive")] + public static int Receive(int handle, nint bufPtr, int max) + { + if (!Live.TryGetValue(handle, out var conn) || bufPtr == 0 || max <= 0) + return 0; + return Take(conn, bufPtr, max); + } + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_error")] + public static int Error(int handle, nint bufPtr, int max) + { + if (!Live.TryGetValue(handle, out var conn)) return 0; + string? err = conn.Error; + if (string.IsNullOrEmpty(err) || bufPtr == 0 || max <= 1) return 0; + + byte[] utf8 = Encoding.UTF8.GetBytes(err); + int n = Math.Min(utf8.Length, max - 1); + Marshal.Copy(utf8, 0, bufPtr, n); + Marshal.WriteByte(bufPtr, n, 0); + return 1; + } + + [UnmanagedCallersOnly(EntryPoint = "gen1tls_close")] + public static void Close(int handle) + { + if (Live.TryRemove(handle, out var conn)) + Shutdown(conn, null); + } + + // ------------------------------------------------------------ internals + + static void Dial(Conn conn) + { + try + { + var client = new TcpClient(); + var connect = client.ConnectAsync(conn.Host, conn.Port); + if (!connect.Wait(ConnectTimeoutMs)) + throw new TimeoutException($"connect to {conn.Host}:{conn.Port} timed out"); + connect.GetAwaiter().GetResult(); + client.NoDelay = true; + conn.Client = client; + + var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + // AuthenticateAsClient sets SNI from targetHost and validates against + // the platform trust store -- the whole reason this dialer exists. + var auth = ssl.AuthenticateAsClientAsync(conn.Host); + if (!auth.Wait(ConnectTimeoutMs)) + throw new TimeoutException($"TLS handshake with {conn.Host} timed out"); + auth.GetAwaiter().GetResult(); + + conn.Stream = ssl; + if (conn.Closing) { Shutdown(conn, null); return; } + conn.Status = StatusOpen; + + var writer = new Thread(() => PumpOut(conn)) + { + IsBackground = true, + Name = "gen1tls-write", + }; + writer.Start(); + PumpIn(conn); + } + catch (Exception ex) + { + Shutdown(conn, Describe(ex)); + } + } + + static void PumpIn(Conn conn) + { + var stream = conn.Stream; + if (stream == null) return; + var buf = new byte[ReadChunk]; + try + { + while (!conn.Closing) + { + int n = stream.Read(buf, 0, buf.Length); + if (n <= 0) break; + var chunk = new byte[n]; + Buffer.BlockCopy(buf, 0, chunk, 0, n); + lock (conn.InLock) + { + // A stalled Lua pump must not grow forever; drop the + // connection rather than the room's backlog. + if (conn.InAvailable + n > MaxBuffered) + throw new InvalidOperationException("TLS receive buffer overflow"); + conn.InChunks.Enqueue(chunk); + conn.InAvailable += n; + } + } + } + catch (Exception ex) + { + if (!conn.Closing) Shutdown(conn, Describe(ex)); + return; + } + Shutdown(conn, null); + } + + static void PumpOut(Conn conn) + { + var stream = conn.Stream; + if (stream == null) return; + try + { + while (!conn.Closing) + { + byte[]? chunk = null; + lock (conn.OutLock) + { + if (conn.OutChunks.Count == 0) + { + conn.OutPulse.Reset(); + // fall through to wait outside the lock + } + else + { + chunk = conn.OutChunks.Dequeue(); + } + } + if (chunk == null) + { + conn.OutPulse.Wait(250); + continue; + } + stream.Write(chunk, 0, chunk.Length); + stream.Flush(); + } + } + catch (Exception ex) + { + if (!conn.Closing) Shutdown(conn, Describe(ex)); + } + } + + static int Take(Conn conn, nint bufPtr, int max) + { + lock (conn.InLock) + { + if (conn.InAvailable == 0 || conn.InChunks.Count == 0) return 0; + int copied = 0; + while (copied < max && conn.InChunks.Count > 0) + { + byte[] head = conn.InChunks.Peek(); + int avail = head.Length - conn.InHeadOffset; + int n = Math.Min(avail, max - copied); + Marshal.Copy(head, conn.InHeadOffset, bufPtr + copied, n); + copied += n; + conn.InHeadOffset += n; + conn.InAvailable -= n; + if (conn.InHeadOffset >= head.Length) + { + conn.InChunks.Dequeue(); + conn.InHeadOffset = 0; + } + } + return copied; + } + } + + static void Shutdown(Conn conn, string? why) + { + if (conn.Closing && why == null && conn.Status == StatusClosed) return; + conn.Closing = true; + if (why != null) conn.Error = why; + conn.Status = StatusClosed; + conn.OutPulse.Set(); + try { conn.Stream?.Dispose(); } catch { /* ignore */ } + try { conn.Client?.Dispose(); } catch { /* ignore */ } + conn.Stream = null; + conn.Client = null; + } + + static string Describe(Exception ex) + { + for (Exception? e = ex; e != null; e = e.InnerException) + { + if (e is AuthenticationException) return e.Message; + if (e is SocketException se) return se.Message; + if (e is TimeoutException) return e.Message; + if (e is IOException) return e.Message; + } + return ex.GetBaseException().Message; + } +} diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 new file mode 100644 index 00000000..77923c13 --- /dev/null +++ b/scripts/build_windows.ps1 @@ -0,0 +1,94 @@ +# Build a Windows win64 Gen1Recomp zip with native TLS (gen1tls.dll). +# +# Usage: +# powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 [-Version 0.1.77] +# +# Produces: +# dist\win\gen1recomp--windows.zip +# gen1recomp.exe fused LÖVE + game.love +# gen1tls.dll Native AOT TLS dialer (Schannel via SslStream) +# +# Requires: .NET 8 SDK (for Native AOT), Git Bash, and tools/winbuild on PATH +# for pack_love.sh under Git Bash on Windows. + +param( + [string]$Version = "0.1.77" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +if (-not (Test-Path (Join-Path $Root "main.lua"))) { + $Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} + +if ($Version -notmatch '^\d+\.\d+\.\d+$') { + throw "Version must be X.Y.Z (got '$Version')" +} + +$Cache = Join-Path $Root ".bazinga\cache" +$Work = Join-Path $Root ".bazinga\work" +$OutDir = Join-Path $Work "gen1recomp-win64" +$DistDir = Join-Path $Root "dist\win" +$NativeOut = Join-Path $Root "dist\native\win-x64" +$LoveZip = Join-Path $Cache "love-11.5-win64.zip" +$LoveUrl = "https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip" +$Bash = "C:\Program Files\Git\bin\bash.exe" + +New-Item -ItemType Directory -Force -Path $Cache, $Work, $DistDir, $NativeOut | Out-Null + +# Put zip/python shims first so pack_love.sh works in Git Bash on Windows. +$env:PATH = "$(Join-Path $Root 'tools\winbuild');$env:PATH" + +Write-Host "==> building gen1tls.dll (Native AOT)" +dotnet publish (Join-Path $Root "native\tls_dial\Gen1Tls.csproj") ` + -c Release -r win-x64 -o $NativeOut +if ($LASTEXITCODE -ne 0) { throw "gen1tls publish failed" } +$TlsDll = Join-Path $NativeOut "gen1tls.dll" +if (-not (Test-Path $TlsDll)) { throw "gen1tls.dll missing after publish" } + +if (-not (Test-Path $LoveZip) -or (Get-Item $LoveZip).Length -lt 1MB) { + Write-Host "==> downloading love-11.5-win64.zip" + Invoke-WebRequest -Uri $LoveUrl -OutFile $LoveZip -UseBasicParsing +} + +Write-Host "==> packing game.love (engine $Version)" +if (-not (Test-Path $Bash)) { throw "Git Bash not found at $Bash" } +$RootPosix = ($Root -replace '\\', '/') -replace '^([A-Za-z]):', '/$1' +& $Bash -lc "cd '$RootPosix' && PATH=`"`$PWD/tools/winbuild:`$PATH`" scripts/pack_love.sh --output .bazinga/work/game.love --listing .bazinga/work/love-listing.txt --version $Version" +if ($LASTEXITCODE -ne 0) { throw "pack_love failed" } + +Write-Host "==> fusing gen1recomp.exe" +$Extract = Join-Path $Work "love-win64" +Remove-Item -Recurse -Force $Extract, $OutDir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Extract, $OutDir | Out-Null +Expand-Archive -Path $LoveZip -DestinationPath $Extract -Force +$LoveDir = Get-ChildItem $Extract -Directory | Select-Object -First 1 +Copy-Item (Join-Path $LoveDir.FullName "*.dll") $OutDir +Copy-Item (Join-Path $LoveDir.FullName "license.txt") $OutDir -ErrorAction SilentlyContinue +Copy-Item $TlsDll $OutDir + +$out = [IO.File]::Create((Join-Path $OutDir "gen1recomp.exe")) +try { + $a = [IO.File]::OpenRead((Join-Path $LoveDir.FullName "love.exe")) + try { $a.CopyTo($out) } finally { $a.Dispose() } + $b = [IO.File]::OpenRead((Join-Path $Work "game.love")) + try { $b.CopyTo($out) } finally { $b.Dispose() } +} finally { $out.Dispose() } + +@" +Gen1Recomp Windows (engine $Version) — native TLS + +gen1tls.dll sits next to gen1recomp.exe and exposes a non-blocking TLS +client (Windows Schannel via .NET SslStream, Native AOT). Mods can load it +through LuaJIT FFI, or call love.system.tls* on Android. + +Keep gen1tls.dll beside the executable when you redistribute the zip. +"@ | Set-Content (Join-Path $OutDir "README-TLS.txt") -Encoding UTF8 + +$ZipOut = Join-Path $DistDir "gen1recomp-$Version-windows.zip" +Remove-Item $ZipOut -ErrorAction SilentlyContinue +Compress-Archive -Path $OutDir -DestinationPath $ZipOut -Force + +Write-Host "==> built $ZipOut" +Get-Item $ZipOut | ForEach-Object { " {0:N1} MB" -f ($_.Length / 1MB) } +Get-ChildItem $OutDir | ForEach-Object { " $($_.Name)" } diff --git a/tools/winbuild/python3 b/tools/winbuild/python3 new file mode 100644 index 00000000..810f787e --- /dev/null +++ b/tools/winbuild/python3 @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Windows ships a python3.exe under WindowsApps that exists purely to advertise +# the Microsoft Store, so `command -v python3` succeeds and running it fails. +# build_android.sh calls python3 three times (the Yellow manifest check, the +# gradle.properties rewrite and the manifest permission trim), and this puts a +# real interpreter behind that name for the length of the build. +exec py "$@" diff --git a/tools/winbuild/zip b/tools/winbuild/zip new file mode 100644 index 00000000..b074be01 --- /dev/null +++ b/tools/winbuild/zip @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Stands in for Info-ZIP's `zip`, which Git for Windows does not ship. Put +# this directory first on PATH before running scripts/build_android.sh; see +# zip_impl.py for what is and is not supported. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec py "$here/zip_impl.py" "$@" diff --git a/tools/winbuild/zip_impl.py b/tools/winbuild/zip_impl.py new file mode 100644 index 00000000..48cc15ee --- /dev/null +++ b/tools/winbuild/zip_impl.py @@ -0,0 +1,111 @@ +"""Enough of Info-ZIP's `zip` for scripts/build_android.sh, on Windows. + +Git for Windows ships unzip but not zip, which is the one tool standing between +a Windows checkout and a local Android build. Rather than fetching a binary +from somewhere unaudited, this covers exactly the two forms the build script +uses and refuses anything it does not understand, so a future flag fails loudly +instead of silently producing a wrong archive: + + zip -q -9 -r out.love main.lua src data -x '*.DS_Store' -x 'data/generated/*' + zip -q out.love src/core/Version.lua # add or replace one entry + +Paths are stored relative to the working directory with forward slashes, and +directories are walked in sorted order so the archive is reproducible. +""" +import fnmatch +import os +import sys +import zipfile + + +def excluded(name, patterns): + # zip's wildcards cross directory separators, which is also what fnmatch + # does, so the build script's '*/.git/*' behaves the same either way. + return any(fnmatch.fnmatch(name, pattern) for pattern in patterns) + + +def collect(paths, patterns): + """-> archive-relative names, in a stable order.""" + names = [] + for path in paths: + if os.path.isdir(path): + for root, dirs, files in os.walk(path): + dirs.sort() + for f in sorted(files): + full = os.path.join(root, f) + name = os.path.relpath(full, ".").replace(os.sep, "/") + if not excluded(name, patterns): + names.append(name) + elif os.path.isfile(path): + name = os.path.relpath(path, ".").replace(os.sep, "/") + if not excluded(name, patterns): + names.append(name) + else: + sys.stderr.write("zip: %s not found\n" % path) + return None + return names + + +def main(argv): + patterns, paths, archive = [], [], None + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "-x": + # Info-ZIP accepts one -x followed by several patterns, which is how + # pack_love.sh and build.sh write their excludes. Consume every + # following non-flag argument as a pattern; paths always come before + # -x in those scripts, so this does not steal archive members. + i += 1 + if i >= len(argv) or argv[i].startswith("-"): + sys.stderr.write("zip: -x needs a pattern\n") + return 2 + while i < len(argv) and not argv[i].startswith("-"): + patterns.append(argv[i]) + i += 1 + continue + elif arg in ("-q", "-9", "-r", "-X", "-o"): + pass # quiet, compression level, recurse, no-extra, ordering + elif arg.startswith("-"): + sys.stderr.write("zip shim: unsupported flag %s\n" % arg) + return 2 + elif archive is None: + archive = arg + else: + paths.append(arg) + i += 1 + + if archive is None or not paths: + sys.stderr.write("usage: zip [-q9r] [-x pat] archive path...\n") + return 2 + + names = collect(paths, patterns) + if names is None: + return 1 + + # Adding to an existing archive means rewriting it: zipfile can append, but + # appending a name that is already in there leaves both copies and readers + # disagree about which one wins. The version stamp does exactly that. + keep = [] + if os.path.exists(archive): + replacing = set(names) + with zipfile.ZipFile(archive, "r") as old: + for info in old.infolist(): + if info.filename not in replacing: + keep.append((info, old.read(info.filename))) + + tmp = archive + ".shimtmp" + with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as out: + for info, data in keep: + out.writestr(info, data) + for name in names: + out.write(name, name) + + if os.path.exists(archive): + os.remove(archive) + os.rename(tmp, archive) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))