Merge pull request #1064 from TheRealSolidusSnake/feature/tls-support

TLS support
This commit is contained in:
bryanthaboi
2026-08-11 21:23:20 -04:00
committed by GitHub
16 changed files with 1351 additions and 0 deletions
+7
View File
@@ -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/
@@ -23,6 +23,7 @@
#ifdef LOVE_ANDROID
#include <cerrno>
#include <cstring>
#include <unordered_map>
#include <SDL.h>
@@ -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
*/
@@ -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
*/
@@ -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)
@@ -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.
@@ -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 }
};
@@ -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;
@@ -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<Integer, TlsSocket> LIVE =
new ConcurrentHashMap<Integer, TlsSocket>();
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<byte[]> inChunks = new ArrayDeque<byte[]>();
private int inHeadOffset = 0;
private int inAvailable = 0;
private final Object outLock = new Object();
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
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<SNIServerName> names = new ArrayList<SNIServerName>(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<String> wanted = new ArrayList<String>(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;
}
}
+84
View File
@@ -12,6 +12,90 @@
"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",
"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",
"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",
"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",
"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 Wont 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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Desktop TLS dialer for mods that need outbound WSS/TLS (e.g. multiworld
clients). Native AOT so the game gets a plain C ABI DLL with no .NET
runtime to install. Windows uses Schannel via SslStream; Linux and macOS
use their platform backends the same way.
Publish examples:
dotnet publish -c Release -r win-x64 -o ../../dist/native/win-x64
dotnet publish -c Release -r linux-x64 -o ../../dist/native/linux-x64
dotnet publish -c Release -r osx-x64 -o ../../dist/native/osx-x64
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PublishAot>true</PublishAot>
<NativeLib>Shared</NativeLib>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<AssemblyName>gen1tls</AssemblyName>
<RootNamespace>Gen1Tls</RootNamespace>
</PropertyGroup>
</Project>
+30
View File
@@ -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`.
+282
View File
@@ -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;
/// <summary>
/// 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).
/// </summary>
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<int, Conn> 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<byte[]> InChunks = new();
public int InHeadOffset;
public int InAvailable;
public readonly object OutLock = new();
public readonly Queue<byte[]> 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;
}
}
+94
View File
@@ -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-<version>-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)" }
+7
View File
@@ -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 "$@"
+6
View File
@@ -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" "$@"
+111
View File
@@ -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:]))