diff --git a/docs/new-features.md b/docs/new-features.md index 4207c3d5..64615d10 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -13,7 +13,6 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Mobile touch controls** with editable layouts, vibration, and orientation settings * **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders * **Pokédex diploma and printer image exports** -* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts ## Gen 2 Specifics diff --git a/docs/skin-studio.md b/docs/skin-studio.md index 068ae316..24e345d2 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -4,17 +4,23 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a control layout, and the rectangle the Game Boy screen is drawn into. Engine: `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` (draw and input), `src/render/Renderer.lua` (the screen viewport), +`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export), `src/ui/SkinStudio.lua` (the desktop editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, +`tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_image_import.lua`, -`tests/engine/launcher_skins_tab.lua`. +`tests/engine/skin_format_import_test.lua`, +`tests/engine/launcher_skins_tab.lua`, +`tests/engine/launcher_skins_ux.lua`. Skins are picked in the launcher's **Skins** tab, which also imports them and opens the studio. `options.touchControls.skin` holds the folder name. ## Formats -Two load. `skin.lua` wins when a folder has both. +Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta +`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher +badges each installed skin with the format it was read from. **RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads as-is. Supported keys: @@ -41,6 +47,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one control that holds both. A `nul` desc is decoration: it draws and never captures a touch. +The area desc types are expanded rather than ignored: `dpad_area`, +`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over +the same area, one per 45 degree sector measured from its centre, the way +RetroArch resolves them: there is no neutral middle, and the four corner +sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and +the per-side reach are honoured, and the desc's own art is kept as decoration +over the top. Exporting a cfg folds the eight back into the one area desc they +came from. `retrok_` is a keyboard bind. + Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every image sits at the overlay opacity, and a pressed control's image swaps to `opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1 @@ -71,6 +86,28 @@ return { } ``` +**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an +`info.json` plus its art. The `representations` tree is walked +device / display type / orientation, and every orientation that exists becomes +a page; `page.orient` is the orientation key, so a portrait/landscape pair +auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in +`mappingSize` points and are converted to the native centre plus half extent; +`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a +radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the +corners fire two directions. `screens[1].outputFrame` (or the legacy +`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the +window the way Delta does rather than letterboxing. Host functions map to +engine hotkeys: `menu` to `menu_toggle`, `fastForward` to +`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; +`quickSave` and `quickLoad` have nothing to bind to and drop to decoration. +Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*` +identifiers are accepted, and a non Game Boy system warns instead of failing. + +PDF artwork is the one thing that does not come across: Delta's own templates +are all-PDF and this engine has no rasterizer, so such a skin is refused with +the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are +an older, incompatible schema and are refused by name. + ## Bindable actions The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`, @@ -119,10 +156,22 @@ them. Anything that binds a button still follows the usual mobile / ## Installing -Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on -the launcher window while the Skins tab is open. A zip is mounted in place, so -there is nothing to unpack. The folder needs one `skin.lua` or `.cfg` -(`overlay.cfg` is preferred when there are several) and the images it names. +Four roads, all of them landing in `skins/` in the save directory: + +* **Import** on the Skins tab opens the host file picker for a `.zip` or a + `.deltaskin`. +* **Paste a skin link** in the tab's URL row, then **Add**. The download runs + on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the + row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped + into an archive on the way in. This is the road that works on a phone, where + there is no file picker to speak of. +* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is + open. +* Copy a folder or archive into `skins/` by hand. + +An archive is mounted in place, so there is nothing to unpack. It needs one +`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or +`info.json`, plus the images it names. Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0: @@ -157,23 +206,40 @@ The Super Game Boy preset locks the viewport to the real screen window, 160x144 at (48,40), so an SGB border cannot be drawn out of register. **Editing.** Click a control to select it, drag to move, eight handles to -resize. X / Y / W / H are in canvas pixels, so a control can be typed to the -coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and +resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While +a control is dragged it snaps to the centres and edges of the other controls +and of the page itself when it comes within a few pixels, and the guide it +snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be +typed to the coordinate its art was drawn at. **Back** and **Front** move the +selection through the draw order. Bind, hitbox shape, hit reach and idle and pressed images are per control; the bezel, the pages and the screen cutout are per page. The cutout is itself a draggable element with a 10:9 lock. +**Bind** opens a grid of every bind the engine understands: the eight Game Boy +buttons, the diagonal pairs, every hotkey, a few `key:` entries, and +decoration. The COMBINE chips at the top toggle one part at a time, which is +how a pipe bind like `left|down` is built without typing it. + +**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or +`u` / shift+`u` without a keyboard modifier). The stack holds the last 50 +actions. `L` toggles the bind captions drawn on the canvas. + Each page can **Lock** to portrait or landscape. With **Match canvas** on -(the default), Next page picks a matching mock device and the canvas preset +(the default), the page list picks a matching mock device and the canvas preset picks a matching page. Turn Match canvas off to look at a portrait page on a -landscape device. +landscape device. **Pages** opens the page list, where a page is selected, +renamed or deleted. + +Starting a new skin, opening another one or closing the studio with unsaved +edits prompts first, with Save first / Discard / Cancel. A RetroArch overlay whose pages are already named portrait / landscape (the auto-rotate convention) locks those pages and turns Match canvas on when you open it. You do not have to click Lock first. -**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the -images already in the skin folder; the **Import** button beside each one opens -the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, +**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a +thumbnail grid of the images already in the skin folder, with `(none)` first; +the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the window does the same for whichever slot was last touched. A new bezel does not @@ -185,11 +251,23 @@ buttons and the footer reports what is held. **Play** saves the skin, selects it, and boots the game with it. **Saving.** **Save** writes `skins//skin.lua` and copies every image the -skin names, so the folder stands alone. **Export** packs it as one zip -(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the -images, and the original `.cfg` when it came from one. An exported skin drops -straight back into `skins/` and still opens in RetroArch. +skin names, so the folder stands alone. **Export** offers three formats, and +the Skins tab's gear offers the same three for any installed skin: + +| Export | Contents | +| --- | --- | +| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one | +| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images | +| Delta `.deltaskin` | an `info.json` generated from the model, plus the images | + +All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/` +in the save directory, which is outside the folder the skin list scans, so an +export can never shadow the skin it came from. The notice names the full path +so a phone can find the file in its own file manager. On desktop **Show the +exported file** opens that folder. ## Not implemented -RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. +Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this +engine does not carry, so they are refused with a message rather than imported +half-drawn. 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 82834d7f..31940e30 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -378,6 +378,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten return result; } +bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, std::string &out) +{ + out.clear(); + if (url == nullptr) + return false; + if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr)) + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // Same resolution rule as httpDownload: the activity's own class via + // SDL_AndroidGetActivity, never FindClass for an app class -- save sync + // runs on a love.thread worker, whose class loader cannot see them. + jobject activityObj = (jobject) SDL_AndroidGetActivity(); + if (activityObj == nullptr) + return false; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + + // Old APK / new liblove skew: report "no transport" instead of aborting + // on a missing method (#597). + jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest", + "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B"); + if (method_id == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jobjectArray jheaders = nullptr; + if (headerPairCount > 0) + { + // java/lang/String, unlike an app class, resolves from any thread. + jclass stringClass = env->FindClass("java/lang/String"); + if (stringClass == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr); + env->DeleteLocalRef(stringClass); + if (jheaders == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + for (int i = 0; i < headerPairCount; i++) + { + jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : ""); + env->SetObjectArrayElement(jheaders, (jsize) i, field); + if (field != nullptr) + env->DeleteLocalRef(field); + } + } + + jstring jurl = env->NewStringUTF(url); + jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET"); + // raw bytes across the bridge, as httpPost does: a request body is JSON + // carrying a base64 save, and a jstring would run it through modified UTF-8 + jbyteArray jbody = nullptr; + if (body != nullptr && bodyLen >= 0) + { + jbody = env->NewByteArray((jsize) bodyLen); + if (jbody != nullptr && bodyLen > 0) + env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body); + } + jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp"); + + jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod, + jheaders, jbody, jua); + + env->DeleteLocalRef(jurl); + env->DeleteLocalRef(jmethod); + if (jheaders != nullptr) + env->DeleteLocalRef(jheaders); + if (jbody != nullptr) + env->DeleteLocalRef(jbody); + env->DeleteLocalRef(jua); + env->DeleteLocalRef(activity); + + if (result == nullptr) + return false; + + jbyteArray bytes = (jbyteArray) result; + jsize length = env->GetArrayLength(bytes); + if (length > 0) + { + out.resize((size_t) length); + env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]); + } + env->DeleteLocalRef(result); + return true; +} + /* * TLS sockets. Same resolution rule as httpDownload above -- the activity's * own class, never FindClass -- and the same tolerance for an old APK: a 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 a412b25c..c323d21a 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -106,6 +106,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent, **/ bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent); +/** + * Blocking HTTPS request with a method, headers and a byte body + * (GameActivity.httpRequest). What save sync needs and neither of the two + * above can give it: PUT, per-request auth headers, and the response body of + * a 4xx as well as a 2xx. headerPairs is a flat name, value array of + * headerPairCount entries; body/userAgent may be null. `out` receives the + * Java side's envelope -- a head line of "STATUS " or "ERROR ", + * a newline, then the raw response bytes. False means the platform has no + * such bridge at all (an old APK under a newer liblove), which the Lua side + * reports as "update the app" rather than as a failed request. + **/ +bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, std::string &out); + /** * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise 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 032228cb..7f7e2d30 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 @@ -274,6 +274,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen, #endif } +bool System::httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, + std::string &out) const +{ +#ifdef LOVE_ANDROID + return love::android::httpRequest(url, method, headerPairs, headerPairCount, + body, bodyLen, userAgent, out); +#else + LOVE_UNUSED(url); + LOVE_UNUSED(method); + LOVE_UNUSED(headerPairs); + LOVE_UNUSED(headerPairCount); + LOVE_UNUSED(body); + LOVE_UNUSED(bodyLen); + LOVE_UNUSED(userAgent); + out.clear(); + return false; +#endif +} + int System::tlsOpen(const char *host, int port) const { #ifdef 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 bbffd544..b5d0ff3f 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 @@ -159,6 +159,18 @@ public: virtual bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType = nullptr, const char *userAgent = nullptr) const; + /** + * Blocking HTTPS request with a method, headers and a byte body (Android + * only; false elsewhere). Save sync needs PUT, auth headers and the body + * of a 4xx, none of which the two bridges above can express. headerPairs + * is a flat name, value array; `out` receives the response envelope + * ("STATUS " or "ERROR ", a newline, then the raw body). + **/ + virtual bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, + std::string &out) const; + /** * TLS client sockets (Android only; every call fails elsewhere, where * LuaSec or another provider is the answer). Non-blocking by contract: 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 48436638..d75f9c85 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 @@ -22,6 +22,9 @@ #include "wrap_System.h" #include "sdl/System.h" +#include +#include + namespace love { namespace system @@ -150,6 +153,57 @@ int w_httpPost(lua_State *L) return 1; } +/* + * love.system.httpRequest(url, method, headers, body, userAgent) -> envelope + * + * `headers` is a flat array of alternating header name and value strings, so + * it maps straight onto the Java bridge's String[] without any parsing here. + * The single return is the response envelope -- a head line of + * "STATUS " or "ERROR ", a newline, then the raw body -- or nil + * where the build has no bridge, which src/core/HostShell.lua turns into an + * "update the app" notice rather than a failed request. + */ +int w_httpRequest(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + const char *method = luaL_optstring(L, 2, "GET"); + + std::vector fields; + if (!lua_isnoneornil(L, 3)) + { + luaL_checktype(L, 3, LUA_TTABLE); + size_t count = luax_objlen(L, 3); + for (size_t i = 1; i <= count; i++) + { + lua_rawgeti(L, 3, (int) i); + const char *field = lua_tostring(L, -1); + fields.push_back(field != nullptr ? field : ""); + lua_pop(L, 1); + } + } + std::vector pairs; + for (size_t i = 0; i < fields.size(); i++) + pairs.push_back(fields[i].c_str()); + + size_t bodyLen = 0; + const char *body = nullptr; + if (!lua_isnoneornil(L, 4)) + body = luaL_checklstring(L, 4, &bodyLen); + const char *ua = luaL_optstring(L, 5, nullptr); + + std::string out; + bool ok = instance()->httpRequest(url, method, + pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(), + body, (int) bodyLen, ua, out); + if (!ok) + { + lua_pushnil(L); + return 1; + } + lua_pushlstring(L, out.data(), out.size()); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -245,6 +299,7 @@ static const luaL_Reg functions[] = { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, { "httpPost", w_httpPost }, + { "httpRequest", w_httpRequest }, { "tlsOpen", w_tlsOpen }, { "tlsStatus", w_tlsStatus }, { "tlsSend", w_tlsSend }, 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 f40f51b5..b60b5d5a 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 @@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -36,6 +37,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import android.Manifest; @@ -847,6 +849,149 @@ public class GameActivity extends SDLActivity { } } + /** Response ceiling for httpRequest; anything larger is refused, not buffered. */ + private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024; + + /** Builds an httpRequest envelope: one head line, a newline, then the body. */ + private static byte[] httpEnvelope(String head, byte[] payload) { + byte[] prefix; + try { + prefix = (head + "\n").getBytes("UTF-8"); + } catch (Exception e) { + prefix = (head + "\n").getBytes(); + } + if (payload == null || payload.length == 0) return prefix; + byte[] out = new byte[prefix.length + payload.length]; + System.arraycopy(prefix, 0, out, 0, prefix.length); + System.arraycopy(payload, 0, out, prefix.length, payload.length); + return out; + } + + /** One-line, CR/LF-free failure text, so an envelope head stays one line. */ + private static String httpErrorText(Exception e) { + String text = e.getMessage(); + if (text == null || text.length() == 0) text = e.getClass().getSimpleName(); + text = text.replace('\r', ' ').replace('\n', ' '); + if (text.length() > 160) text = text.substring(0, 160); + return text; + } + + /** + * Blocking HTTPS request with a chosen method, headers and byte body, + * exposed as love.system.httpRequest and used by src/core/HostShell.lua + * for save sync. Sync needs PUT, per-request auth headers and the response + * body of a 4xx as well as a 2xx (a conflict answers 409 with the save + * that won), none of which httpDownload or httpPost above can express. + * + * Same rules as those two: https only, redirects followed by hand + * (re-sending method and body on each hop), 15s connect / 60s read, and + * blocking on the Lua/worker thread -- never the UI thread. Headers arrive + * as a flat name, value array; a field carrying CR or LF is refused rather + * than sent, so a header value can never inject a second header. + * + * The reply is an envelope: a head line of "STATUS <code>" or + * "ERROR <text>", a newline, then the raw response bytes. + */ + @Keep + public static byte[] httpRequest(String url, String method, String[] headerPairs, + byte[] body, String userAgent) { + if (url == null) return httpEnvelope("ERROR missing url", null); + String verb = method == null ? "GET" : method.toUpperCase(Locale.US); + if (!"GET".equals(verb) && !"POST".equals(verb) + && !"PUT".equals(verb) && !"DELETE".equals(verb)) { + return httpEnvelope("ERROR unsupported request method", null); + } + if (headerPairs != null) { + if ((headerPairs.length % 2) != 0) { + return httpEnvelope("ERROR bad request header", null); + } + for (int i = 0; i < headerPairs.length; i++) { + String field = headerPairs[i]; + if (field == null) return httpEnvelope("ERROR bad request header", null); + if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) { + return httpEnvelope("ERROR bad request header", null); + } + if ((i % 2) == 0 && field.length() == 0) { + return httpEnvelope("ERROR bad request header", null); + } + } + } + HttpURLConnection conn = null; + try { + String current = url; + for (int hop = 0; hop < 5; hop++) { + URL parsed = new URL(current); + if (!"https".equalsIgnoreCase(parsed.getProtocol())) { + return httpEnvelope("ERROR https only", null); + } + conn = (HttpURLConnection) parsed.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestMethod(verb); + conn.setRequestProperty("User-Agent", + userAgent == null ? "gen1recomp" : userAgent); + if (headerPairs != null) { + for (int i = 0; i + 1 < headerPairs.length; i += 2) { + conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]); + } + } + if (body != null && !"GET".equals(verb)) { + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(body.length); + OutputStream out = new BufferedOutputStream(conn.getOutputStream()); + try { + out.write(body); + } finally { + try { out.close(); } catch (IOException ignored) {} + } + } + int code = conn.getResponseCode(); + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String next = conn.getHeaderField("Location"); + conn.disconnect(); + conn = null; + if (next == null) { + return httpEnvelope("ERROR redirect without a location", null); + } + current = new URL(parsed, next).toString(); + continue; + } + // A rejection's body is the diagnosis the caller wants, so 4xx + // and 5xx are read through getErrorStream rather than dropped. + InputStream in; + try { + in = conn.getInputStream(); + } catch (IOException e) { + in = conn.getErrorStream(); + } + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + if (in != null) { + InputStream reader = new BufferedInputStream(in); + try { + byte[] buf = new byte[16384]; + int n; + while ((n = reader.read(buf)) > 0) { + if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) { + return httpEnvelope("ERROR the reply was too large", null); + } + sink.write(buf, 0, n); + } + } finally { + try { reader.close(); } catch (IOException ignored) {} + } + } + return httpEnvelope("STATUS " + code, sink.toByteArray()); + } + return httpEnvelope("ERROR too many redirects", null); + } catch (Exception e) { + Log.d("GameActivity", "httpRequest failed: " + e.getMessage()); + return httpEnvelope("ERROR " + httpErrorText(e), null); + } finally { + if (conn != null) conn.disconnect(); + } + } + /** * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * (pending_export.sav in the app save identity) to Downloads / Drive / diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index b51925f6..06f0db26 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject { return succeeded } + // MARK: - General HTTP request (love.system.httpRequest) + + private static let httpMaxResponse = 4 * 1024 * 1024 + + // URLSession turns a 301/302/303 POST into a GET on its own. Save sync + // signs a method and a body, so every hop re-sends the original request + // against the new URL instead, and only over https. + private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + guard let original = task.originalRequest, + let target = request.url, + target.scheme?.lowercased() == "https" else { + completionHandler(nil) + return + } + var next = original + next.url = target + completionHandler(next) + } + } + + private static let httpSession = URLSession(configuration: .ephemeral, + delegate: GRRedirectKeeper(), + delegateQueue: nil) + + private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData { + var out = Data((head + "\n").utf8) + if let payload { out.append(payload) } + return out as NSData + } + + private static func httpErrorText(_ error: Error) -> String { + var text = error.localizedDescription + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + if text.isEmpty { text = "the request failed" } + if text.count > 160 { text = String(text.prefix(160)) } + return text + } + + /// Blocking HTTPS request with a chosen method, headers and byte body, the + /// iOS half of love.system.httpRequest (see the Android GameActivity one). + /// Headers arrive as "name: value" lines joined by newlines. The reply is + /// an envelope: a head line of "STATUS " or "ERROR ", a + /// newline, then the raw response bytes -- read for 4xx and 5xx as well, + /// because a sync conflict answers 409 with the save that won. + @objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:) + public static func httpRequest(url: UnsafePointer?, + method: UnsafePointer?, + headers: UnsafePointer?, + body: UnsafePointer?, + bodyLength: Int32, + userAgent: UnsafePointer?) -> NSData? { + guard let url, let requestURL = URL(string: String(cString: url)) else { + return httpEnvelope("ERROR missing url", nil) + } + guard requestURL.scheme?.lowercased() == "https" else { + return httpEnvelope("ERROR https only", nil) + } + let verb = (method.map { String(cString: $0) } ?? "GET").uppercased() + guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else { + return httpEnvelope("ERROR unsupported request method", nil) + } + + var request = URLRequest(url: requestURL) + request.httpMethod = verb + request.timeoutInterval = 60 + request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp", + forHTTPHeaderField: "User-Agent") + if let headers, headers.pointee != 0 { + for line in String(cString: headers).split(separator: "\n") { + guard let colon = line.firstIndex(of: ":") else { + return httpEnvelope("ERROR bad request header", nil) + } + let name = line[line.startIndex.. 0 { + request.httpBody = Data(bytes: body, count: Int(bodyLength)) + } + + let semaphore = DispatchSemaphore(value: 0) + var envelope = httpEnvelope("ERROR no response", nil) + let task = httpSession.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { + envelope = httpEnvelope("ERROR " + httpErrorText(error), nil) + return + } + guard let http = response as? HTTPURLResponse else { + envelope = httpEnvelope("ERROR no response", nil) + return + } + let payload = data ?? Data() + if payload.count > httpMaxResponse { + envelope = httpEnvelope("ERROR the reply was too large", nil) + return + } + envelope = httpEnvelope("STATUS \(http.statusCode)", payload) + } + task.resume() + guard semaphore.wait(timeout: .now() + 65) == .success else { + task.cancel() + return httpEnvelope("ERROR the request timed out", nil) + } + return envelope + } + // MARK: - Entry points called from liblove (C strings on purpose) @objc(presentPickerWithKind:saveDir:) diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index d59363bf..a37e5325 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -9,7 +9,8 @@ What it does: 1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift, GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree. 2. Patches liblove's wrap_System.cpp to expose love.system.pickFile, - love.system.createFile, and love.system.syncHealthSteps on iOS (each + love.system.createFile, love.system.syncHealthSteps, + love.system.httpDownload and love.system.httpRequest on iOS (each calls a GR*Bridge Swift class through the Objective-C runtime, so liblove never links against Swift directly). 3. Patches love.xcodeproj so the love-ios app target compiles the native @@ -50,6 +51,7 @@ WRAP_INCLUDES = """ #include #include #include +#include #include "filesystem/Filesystem.h" #endif """ % MARKER @@ -159,6 +161,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, + { "httpRequest", w_httpRequest }, #endif """ @@ -201,6 +204,7 @@ int w_syncHealthSteps(lua_State *L) WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, + { "httpRequest", w_httpRequest }, #endif """ @@ -226,6 +230,80 @@ int w_httpDownload(lua_State *L) lua_pushboolean(L, ok != 0); return 1; } + +// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope +// +// The transport save sync needs: a chosen method, per-request auth headers, +// and the response body of a 4xx as well as a 2xx. `headers` is a flat array +// of alternating name and value strings, joined into "name: value" lines here +// because the Swift bridge takes C strings and no Foundation type may be +// NAMED in this translation unit (see w_pickFileKinds above). +// +// The single return is the response envelope -- a head line of +// "STATUS " or "ERROR ", a newline, then the raw body -- or nil +// where the build carries no bridge at all, which src/core/HostShell.lua +// turns into an "update the app" notice rather than a failed request. +int w_httpRequest(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + const char *method = luaL_optstring(L, 2, "GET"); + + std::string headerBlob; + if (!lua_isnoneornil(L, 3)) + { + luaL_checktype(L, 3, LUA_TTABLE); + std::vector fields; + size_t count = luax_objlen(L, 3); + for (size_t i = 1; i <= count; i++) + { + lua_rawgeti(L, 3, (int) i); + const char *field = lua_tostring(L, -1); + fields.push_back(field != nullptr ? field : ""); + lua_pop(L, 1); + } + for (size_t i = 0; i + 1 < fields.size(); i += 2) + headerBlob += fields[i] + ": " + fields[i + 1] + "\\n"; + } + + size_t bodyLen = 0; + const char *body = nullptr; + if (!lua_isnoneornil(L, 4)) + body = luaL_checklstring(L, 4, &bodyLen); + const char *ua = luaL_optstring(L, 5, "gen1recomp"); + + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef id (*GRRequest)(Class, SEL, const char *, const char *, + const char *, const unsigned char *, int, + const char *); + id reply = ((GRRequest)objc_msgSend)( + cls, + sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"), + url, method, headerBlob.c_str(), (const unsigned char *) body, + (int) bodyLen, ua); + if (reply == nullptr) + { + lua_pushnil(L); + return 1; + } + // NSData read through the runtime, for the same reason as above: the + // bytes are copied out immediately, before any autorelease pool drains. + typedef const void *(*GRBytes)(id, SEL); + typedef unsigned long (*GRLength)(id, SEL); + const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes")); + unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length")); + if (bytes == nullptr || length == 0) + { + lua_pushnil(L); + return 1; + } + lua_pushlstring(L, (const char *) bytes, (size_t) length); + return 1; +} #endif """ @@ -308,7 +386,7 @@ def patch_wrap_system(): text = text.replace(reg_anchor, reg_anchor + registration, 1) WRAP_SYSTEM.write_text(text) print("patch_love_src: wrap_System.cpp patched " - "(pickFile/createFile/syncHealthSteps/httpDownload)") + "(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)") def patch_public_documents(): diff --git a/src/core/DeltaSkin.lua b/src/core/DeltaSkin.lua new file mode 100644 index 00000000..e2d9bd8f --- /dev/null +++ b/src/core/DeltaSkin.lua @@ -0,0 +1,523 @@ +local Json = require("src.link.Json") +local TouchSkin = require("src.core.TouchSkin") + +local DeltaSkin = {} + +DeltaSkin.INFO_NAME = "info.json" +DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024 + +DeltaSkin.GAME_TYPE_PREFIXES = { + "com.rileytestut.delta.game.", + "public.aoshuang.game.", +} + +DeltaSkin.SYSTEMS = { gb = true, gbc = true } + +DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } + +DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" } +DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" } +DeltaSkin.ORIENTATIONS = { "portrait", "landscape" } +DeltaSkin.SIDES = { "up", "down", "left", "right" } + +DeltaSkin.ASSET_LADDER = { "small", "medium", "large" } +DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 } +DeltaSkin.DEFAULT_TARGET_WIDTH = 1080 + +DeltaSkin.INPUTS = { + a = "a", b = "b", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + menu = "menu_toggle", + fastforward = "hold_fast_forward", + togglefastforward = "toggle_fast_forward", +} + +DeltaSkin.OUTPUT_HOTKEYS = { + menu = "menu", + fast_forward_hold = "fastForward", + fast_forward_toggle = "toggleFastForward", +} + +DeltaSkin.MAPPING = { + portrait = { width = 1080, height = 1920 }, + landscape = { width = 1920, height = 1080 }, +} + +DeltaSkin.SCREEN_WIDTH = 160 +DeltaSkin.SCREEN_HEIGHT = 144 + +local function pick(t, key) + if type(t) ~= "table" then return nil end + local direct = t[key] + if direct ~= nil then return direct end + local want = tostring(key):lower() + for k, v in pairs(t) do + if tostring(k):lower() == want then return v end + end + return nil +end + +local function numOr(v, fallback) + local n = tonumber(v) + if not n or n ~= n then return fallback end + return n +end + +local function round(n) + return math.floor(numOr(n, 0) + 0.5) +end + +local function isArray(t) + return type(t) == "table" and t[1] ~= nil +end + +local function addWarning(list, text) + if type(list) ~= "table" then return end + for _, existing in ipairs(list) do + if existing == text then return end + end + list[#list + 1] = text +end + +function DeltaSkin.findInfo(root) + local direct = root .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(direct) then return direct, "" end + local items = TouchSkin.listDir(root) + for _, name in ipairs(items) do + if tostring(name):lower() == DeltaSkin.INFO_NAME then + return root .. "/" .. name, "" + end + end + table.sort(items) + for _, name in ipairs(items) do + local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(nested) then return nested, name .. "/" end + end + return nil +end + +function DeltaSkin.resolveName(name, opts) + name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "") + if name == "" then return nil end + local names = opts and opts.names + if type(names) == "table" then + local want = name:lower() + for _, entry in ipairs(names) do + if tostring(entry):lower() == want then + name = tostring(entry) + break + end + end + end + return ((opts and opts.prefix) or "") .. name +end + +function DeltaSkin.pickAsset(assets, opts, pdfFiles) + if type(assets) ~= "table" then return nil end + pdfFiles = pdfFiles or {} + local raster = {} + for _, key in ipairs(DeltaSkin.ASSET_LADDER) do + local name = pick(assets, key) + if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end + if type(name) == "string" and name ~= "" then + if name:lower():match("%.pdf$") then + pdfFiles[#pdfFiles + 1] = name + else + raster[#raster + 1] = { key = key, name = name } + end + end + end + local resizable = pick(assets, "resizable") + if type(resizable) == "string" and resizable ~= "" then + if resizable:lower():match("%.pdf$") then + pdfFiles[#pdfFiles + 1] = resizable + else + raster[#raster + 1] = { key = "large", name = resizable } + end + end + if #raster == 0 then return nil end + + local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) + local chosen + for _, cand in ipairs(raster) do + if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then + chosen = cand.name + end + end + if not chosen then chosen = raster[#raster].name end + return DeltaSkin.resolveName(chosen, opts) +end + +function DeltaSkin.mergeEdges(base, item) + local out = { top = 0, bottom = 0, left = 0, right = 0 } + for _, side in ipairs({ "top", "bottom", "left", "right" }) do + local v = pick(item, side) + if v == nil then v = pick(base, side) end + out[side] = numOr(v, 0) + end + return out +end + +function DeltaSkin.representation(reps, orient) + for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do + local dev = pick(reps, device) + if type(dev) == "table" then + for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do + local shown = pick(dev, display) + if type(shown) == "table" then + local obj = pick(shown, orient) + if type(obj) == "table" then return obj, device, display end + end + end + local flat = pick(dev, orient) + if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then + return flat, device, nil + end + end + end + return nil +end + +function DeltaSkin.directionalInputs(inputs) + if type(inputs) ~= "table" or isArray(inputs) then return nil end + local out, found = {}, 0 + for _, side in ipairs(DeltaSkin.SIDES) do + local v = pick(inputs, side) + if type(v) == "string" then + local lower = v:lower() + local mapped = DeltaSkin.INPUTS[lower] + if not mapped and lower:find(side, 1, true) then mapped = side end + if mapped then + out[side] = mapped + found = found + 1 + end + end + end + if found >= 2 then return out end + return nil +end + +function DeltaSkin.specFor(inputs) + local parts = {} + local function add(v) + if type(v) ~= "string" then return end + local mapped = DeltaSkin.INPUTS[v:lower()] + if mapped then parts[#parts + 1] = mapped end + end + if type(inputs) == "string" then + add(inputs) + elseif type(inputs) == "table" then + if isArray(inputs) then + for _, v in ipairs(inputs) do add(v) end + else + local keys = {} + for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end + table.sort(keys) + for _, k in ipairs(keys) do add(inputs[k]) end + end + end + if #parts == 0 then return "nul" end + return table.concat(parts, "|") +end + +function DeltaSkin.screenRect(obj, mapW, mapH) + local frame + local screens = pick(obj, "screens") + if type(screens) == "table" and type(screens[1]) == "table" then + frame = pick(screens[1], "outputFrame") + end + if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end + if type(frame) ~= "table" then return nil end + local w = numOr(pick(frame, "width"), 0) + local h = numOr(pick(frame, "height"), 0) + if w <= 0 or h <= 0 then return nil end + return { + x = numOr(pick(frame, "x"), 0) / mapW, + y = numOr(pick(frame, "y"), 0) / mapH, + w = w / mapW, h = h / mapH, + } +end + +function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + if type(item) ~= "table" then return end + local frame = pick(item, "frame") + if type(frame) ~= "table" then return end + local fw = numOr(pick(frame, "width"), 0) + local fh = numOr(pick(frame, "height"), 0) + if fw <= 0 or fh <= 0 then return end + local fx = numOr(pick(frame, "x"), 0) + local fy = numOr(pick(frame, "y"), 0) + + local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges")) + local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH + local w, h = fw / mapW, fh / mapH + local reachLeft = 1 + edges.left / (fw * 0.5) + local reachRight = 1 + edges.right / (fw * 0.5) + local reachUp = 1 + edges.top / (fh * 0.5) + local reachDown = 1 + edges.bottom / (fh * 0.5) + + local inputs = pick(item, "inputs") + local dirs = DeltaSkin.directionalInputs(inputs) + if dirs then + local base = { + x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5, + rangeMod = 1, alphaMod = page.alphaMod, shape = "rect", + reachLeft = reachLeft, reachRight = reachRight, + reachUp = reachUp, reachDown = reachDown, + } + for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do + page.controls[#page.controls + 1] = ctl + end + return + end + + local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect" + local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape) + ctl.alphaMod = page.alphaMod + ctl.reachLeft, ctl.reachRight = reachLeft, reachRight + ctl.reachUp, ctl.reachDown = reachUp, reachDown + page.controls[#page.controls + 1] = ctl +end + +function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + local mapping = pick(obj, "mappingSize") + local mapW = numOr(pick(mapping, "width"), 0) + local mapH = numOr(pick(mapping, "height"), 0) + if mapW <= 0 or mapH <= 0 then + mapW, mapH = 320, 240 + addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") + end + + local page = { + name = orient, + orient = orient, + imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles), + fullScreen = true, + normalized = true, + pixelCoords = false, + rangeMod = 1, + alphaMod = pick(obj, "translucent") == true and 0.7 or 1, + aspect = mapW / mapH, + aspectFromCfg = false, + rect = { x = 0, y = 0, w = 1, h = 1 }, + mappingWidth = mapW, + mappingHeight = mapH, + controls = {}, + } + + local screen = DeltaSkin.screenRect(obj, mapW, mapH) + if screen then + page.viewport = screen + page.viewportFill = false + end + + local baseEdges = pick(obj, "extendedEdges") + local items = pick(obj, "items") + if type(items) == "table" then + for _, item in ipairs(items) do + DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + end + end + return page +end + +function DeltaSkin.systemOf(gameType) + if type(gameType) ~= "string" or gameType == "" then return nil end + for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do + if gameType:sub(1, #prefix) == prefix then + return gameType:sub(#prefix + 1):lower() + end + end + return nil +end + +function DeltaSkin.parse(text, opts) + opts = opts or {} + local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES) + if type(info) ~= "table" then + return nil, "info.json does not parse: " .. tostring(err) + end + + local gameType = info.gameTypeIdentifier + if type(gameType) ~= "string" or gameType == "" then + return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier" + end + if gameType:lower():find("gba4ios", 1, true) then + return nil, "old GBA4iOS skin, not supported" + end + local system = DeltaSkin.systemOf(gameType) + if not system then + return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType + end + + local warnings = {} + if not DeltaSkin.SYSTEMS[system] then + addWarning(warnings, "this skin is for " .. system .. ", not Game Boy") + end + + local reps = info.representations + if type(reps) ~= "table" then return nil, "info.json has no representations" end + + local pdfFiles, pages = {}, {} + for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do + local obj = DeltaSkin.representation(reps, orient) + if obj then + local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + page.index = #pages + 1 + pages[#pages + 1] = page + end + end + if #pages == 0 then return nil, "info.json has no usable representation" end + if #pdfFiles > 0 then + addWarning(warnings, "PDF artwork cannot be imported yet") + end + + return { + pages = pages, + name = info.name, + author = info.author, + notes = info.notes, + format = "delta", + system = system, + identifier = info.identifier, + warnings = warnings, + pdfFiles = pdfFiles, + } +end + +function DeltaSkin.needsConversion(skin) + if type(skin) ~= "table" then return nil end + local files = skin.pdfFiles + if type(files) ~= "table" or #files == 0 then return nil end + for _, page in ipairs(skin.pages or {}) do + if page.imagePath then return nil end + end + return { pdfOnly = true, files = files } +end + +function DeltaSkin.outputInputs(ctl) + local out = {} + for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end + for _, h in ipairs(ctl.hotkeys or {}) do + local mapped = DeltaSkin.OUTPUT_HOTKEYS[h] + if mapped then out[#out + 1] = mapped end + end + return out +end + +function DeltaSkin.buildRepresentation(page, orient, warnings) + local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait + local mapW, mapH = map.width, map.height + local items, files = {}, {} + + for _, ctl in ipairs(page.controls or {}) do + local names = DeltaSkin.outputInputs(ctl) + if ctl.sector and ctl.sector ~= 1 then + names = {} + elseif ctl.sector and ctl.areaNames then + local TouchSkin = require("src.core.TouchSkin") + local dirs = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()] + if mapped then dirs[side] = mapped end + end + names = next(dirs) and dirs or {} + end + if names.up or names.down or names.left or names.right or #names > 0 then + local item = { + inputs = names, + frame = { + x = round((ctl.x - ctl.rangeX) * mapW), + y = round((ctl.y - ctl.rangeY) * mapH), + width = round(ctl.rangeX * 2 * mapW), + height = round(ctl.rangeY * 2 * mapH), + }, + } + if ctl.shape == "radial" then item.mask = "circle" end + local edges, any = {}, false + local pairsList = { + { key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW }, + { key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW }, + { key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH }, + { key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH }, + } + for _, side in ipairs(pairsList) do + local reach = numOr(side.reach, 1) + if reach ~= 1 then + edges[side.key] = round((reach - 1) * side.half) + any = true + end + end + if any then item.extendedEdges = edges end + items[#items + 1] = item + elseif ctl.imagePath then + addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image") + end + end + + local obj = { + items = items, + mappingSize = { width = mapW, height = mapH }, + extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 }, + translucent = false, + } + if page.imagePath then + obj.assets = { + small = page.imagePath, medium = page.imagePath, large = page.imagePath, + } + files[#files + 1] = page.imagePath + end + if page.viewport then + obj.screens = { { + inputFrame = { x = 0, y = 0, + width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT }, + outputFrame = { + x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH), + width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH), + }, + } } + end + return obj, files +end + +function DeltaSkin.build(skin, opts) + if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then + return nil, "skin has no pages" + end + opts = opts or {} + local standard, edgeToEdge = {}, {} + local assets, warnings, used = {}, {}, {} + + for _, page in ipairs(skin.pages) do + local orient = TouchSkin.pageOrient(page) + if orient ~= "portrait" and orient ~= "landscape" then + orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape" + end + if not used[orient] then + used[orient] = true + local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings) + standard[orient] = obj + edgeToEdge[orient] = obj + for _, rel in ipairs(files) do assets[#assets + 1] = rel end + end + end + + local system = tostring(opts.system or "gbc") + local info = { + name = skin.name or skin.id or "skin", + identifier = opts.identifier + or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")), + gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system, + debug = false, + representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } }, + } + return info, assets, warnings +end + +function DeltaSkin.encodeInfo(skin, opts) + local info, assets, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assets end + return Json.encode(info), assets, warnings +end + +return DeltaSkin diff --git a/src/core/Game.lua b/src/core/Game.lua index 1c01d889..31f63ebd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -34,6 +34,7 @@ end function Game:load() self.data = Data + self.sessionStartedAt = os.time() Data:load() -- Mods are a native engine subsystem. They load after the verified ROM @@ -155,6 +156,7 @@ function Game:makeTitleState() onNewGame = function() while self.stack:top() do self.stack:pop() end -- New Game keeps the standalone options.lua preferences + self.sessionStartedAt = os.time() self.save = SaveData.newGame(self:bootConfig()) -- no bucket carry-over: mod state from an abandoned session must -- not leak into a fresh slot; mods seed via save.created instead @@ -356,6 +358,7 @@ function Game:update(dt) -- reason: they are presentational, so fast-forward must not speed them up require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) + self:updateSync(dt) -- Steady-state memory backstop: advance the incremental collector one -- small step every rendered frame. The heavy GPU objects are now freed -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this @@ -1178,11 +1181,41 @@ function Game:writeSave() -- stamp here so the save.writing payload carries the exact meta the -- file gets; mods snapshot runtime state into their namespace now self.save.meta = SaveData.buildMeta( - self.modStatus and self.modStatus.loaded, self.save.meta) + self.modStatus and self.modStatus.loaded, self.save.meta, + self.sessionStartedAt) if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) end - return SaveData.save(self.save) + local written = SaveData.save(self.save) + if written then + local eng = self:syncEngine() + if eng then pcall(eng.noteSaveWritten, eng) end + end + return written +end + +function Game:syncEngine() + if self._syncOff then return nil end + if self._syncEngineRef then return self._syncEngineRef end + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + local eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + return eng +end + +function Game:updateSync(dt) + local eng = self:syncEngine() + if not eng then return end + if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end + pcall(eng.update, eng, dt) end -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings @@ -1241,6 +1274,7 @@ function Game:applyOptions(opts) end function Game:restoreSave(loaded, recovered, opts) + self.sessionStartedAt = os.time() if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 77856d79..3de97efc 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -36,6 +36,7 @@ local World = require("src.world.gen2.World") -- other engine file, so a call site here is the same call site Gen 1 has. local ModRuntime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") -- Only for the mod-supplied save migrations and the mods-changed report, which -- are keyed off save.meta and know nothing about a generation; Gold's own save -- IO is src/core/gen2/Save.lua. @@ -195,6 +196,7 @@ end function Game2:persistOptions() pcall(Save.saveOptions, self.options) end +Game2.writeOptions = Game2.persistOptions -- Point the loader's mod.save backing at this save's modData so per-mod state -- persists with the slot. Same contract and same three call sites as Gen 1 @@ -1160,7 +1162,8 @@ end -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to -- one screen pixel a cell out at survey range. function Game2:pixelScale(w, h) - return math.max(1, math.floor(math.min(w / 160, h / 144))) + local _, _, pw, ph = Playfield.rect(w, h) + return math.max(1, math.floor(math.min(pw / 160, ph / 144))) end -- A window-sized canvas the whole frame is composed into, so the post passes @@ -1277,7 +1280,8 @@ end function Game2:blitZones(canvas, zones, w, h) local G = love.graphics local GbcPalette = require("src.render.GbcPalette") - local sx, sy = w / 160, h / 144 + local px, py, pw, ph = Playfield.rect(w, h) + local sx, sy = pw / 160, ph / 144 G.setColor(1, 1, 1, 1) for _, z in ipairs(zones) do -- a colors == false zone is the true-colour opt-out; anything the shader @@ -1296,11 +1300,11 @@ function Game2:blitZones(canvas, zones, w, h) -- whose contract differs from Gen 1's. Whole-screen and half-screen zones -- come out of this at exactly the pixels the plain floor/ceil pair gave -- them, so the vanilla picture is untouched. - local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy - local x1 = math.floor(math.max(zx, 0)) - local y1 = math.floor(math.max(zy, 0)) - local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w)) - local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h)) + local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy + local x1 = math.floor(math.max(zx, px)) + local y1 = math.floor(math.max(zy, py)) + local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw)) + local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph)) if x2 > x1 and y2 > y1 then G.setScissor(x1, y1, x2 - x1, y2 - y1) G.draw(canvas, 0, 0) @@ -1402,7 +1406,7 @@ function Game2:drawViewportFrame() scene = self:presentCanvas(1, w, h) end if not scene then - self:drawScene(w, h) + self:drawContained(w, h) self:drawHud(w, h) return end @@ -1413,7 +1417,7 @@ function Game2:drawViewportFrame() G.origin() G.setCanvas(scene) G.clear(0, 0, 0, 1) - self:drawScene(w, h) + self:drawContained(w, h) G.setCanvas(previous) if composing and self:compose(scene, zones, w, h) then @@ -1462,6 +1466,8 @@ function Game2:drawViewportFrame() generation = 2, }) == true if not outputHandled then + local cx, cy, cw, ch = Playfield.cutout(w, h) + if cx then G.setScissor(cx, cy, cw, ch) end if fx then GBCFX.present(source, self:pixelScale(w, h)) else @@ -1469,6 +1475,7 @@ function Game2:drawViewportFrame() G.draw(source, 0, 0) G.setShader() end + if cx then G.setScissor() end end end G.pop() @@ -1499,6 +1506,13 @@ function Game2:textboxPaper() return nil end +function Game2:drawContained(w, h) + local pw, ph = Playfield.push(w, h) + local ok, err = pcall(self.drawScene, self, pw, ph) + Playfield.pop() + if not ok then error(err, 0) end +end + function Game2:drawScene(w, h) local G = love.graphics -- render.compose reads this after the scene is drawn; the plain overworld diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 156715f2..82a42bfb 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -350,11 +350,23 @@ local function haveBridge() return osName == "Android" or osName == "iOS" or osName == "UWP" end +local function haveRequestBridge() + if not (love and love.system and type(love.system.httpRequest) == "function") then + return false + end + local osName = love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" or osName == "UWP" +end + -- Is any transport available at all? Callers gate on this, never on curl. function HostShell.canFetch() return HostShell.haveCurl() or haveBridge() end +function HostShell.canHttpRequest() + return (HostShell.haveCurl() or haveRequestBridge()) and true or false +end + -- Download url to an absolute host path. Returns true, or nil plus an error. -- The curl branch deliberately ignores curl's exit code, as the download paths -- always did: callers judge the result by the file they got. @@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) return nil, "no POST transport on this platform" end +local function requestHeaderList(headers) + local out = {} + if type(headers) == "table" then + if #headers > 0 then + for _, line in ipairs(headers) do + if type(line) == "string" then out[#out + 1] = line end + end + else + local names = {} + for name in pairs(headers) do names[#names + 1] = tostring(name) end + table.sort(names) + for _, name in ipairs(names) do + out[#out + 1] = name .. ": " .. tostring(headers[name]) + end + end + end + for _, line in ipairs(out) do + if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end + end + return out +end + +local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true } + +local function requestHeaderPairs(lines) + local out = {} + for _, line in ipairs(lines) do + local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$") + if not name or name == "" then return nil end + if name:find("[\r\n]") or value:find("[\r\n]") then return nil end + out[#out + 1] = name + out[#out + 1] = value + end + return out +end + +local function bridgeRequest(url, method, headers, body, userAgent) + if not BRIDGE_METHODS[method] then + return nil, "no request transport for " .. method .. " on this platform" + end + local fields = requestHeaderPairs(headers) + if not fields then return nil, "bad request header" end + local ok, envelope = pcall(love.system.httpRequest, url, method, fields, + body, userAgent) + if not ok or type(envelope) ~= "string" or envelope == "" then + return nil, "this app build cannot make signed requests: update the app to use save sync" + end + local head, rest = envelope:match("^([^\n]*)\n(.*)$") + if not head then + return nil, fetchError(url, nil, "unreadable reply from the network bridge") + end + local status = tonumber(head:match("^STATUS (%d+)$")) + if status then return rest or "", nil, status end + return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head) +end + +local requestSeq = 0 + +local function requestStagingPath(kind) + local dir + if love and love.filesystem and love.filesystem.getSaveDirectory then + local ok, saveDir = pcall(love.filesystem.getSaveDirectory) + if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end + end + if not dir then + dir = os.getenv("TEMP") or os.getenv("TMP") + if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end + end + local sep = dir:find("\\") and "\\" or "/" + requestSeq = requestSeq + 1 + return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format( + kind, os.time() % 1000000, requestSeq, math.random(0, 999999)) +end + +local function writeStagingFile(kind, text) + local path = requestStagingPath(kind) + local file, openErr = io.open(path, "wb") + if not file then + return nil, "could not create the request " .. kind .. ": " .. tostring(openErr) + end + local wrote, writeErr = pcall(function() + assert(file:write(text)) + assert(file:close()) + end) + if not wrote then + pcall(function() file:close() end) + pcall(os.remove, path) + return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr) + end + return path +end + +function HostShell.httpRequest(url, opts) + opts = type(opts) == "table" and opts or {} + if type(url) ~= "string" or url == "" then return nil, "missing url" end + local method = tostring(opts.method or "GET"):upper() + if not method:match("^%u+$") then return nil, "bad request method" end + local headers = requestHeaderList(opts.headers) + if not headers then return nil, "bad request header" end + local body = opts.body + if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end + local userAgent = opts.userAgent or "gen1recomp" + local maxTime = tonumber(opts.maxTime) or 30 + + if not HostShell.haveCurl() then + if haveRequestBridge() then + return bridgeRequest(url, method, headers, body, userAgent) + end + if method == "GET" and #headers == 0 then + local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime) + if not got then return nil, err end + return got, nil, 200 + end + if haveBridge() then + return nil, "this app build cannot make signed requests: update the app to use save sync" + end + return nil, "no request transport on this platform" + end + + local bodyPath, stageErr + if body then + bodyPath, stageErr = writeStagingFile("body", body) + if not bodyPath then return nil, stageErr end + end + + local lines = { "User-Agent: " .. userAgent } + for _, line in ipairs(headers) do lines[#lines + 1] = line end + if body then + lines[#lines + 1] = "Content-Length: " .. tostring(#body) + end + local headerPath + headerPath, stageErr = writeStagingFile("head", + table.concat(lines, "\n") .. "\n") + if not headerPath then + if bodyPath then pcall(os.remove, bodyPath) end + return nil, stageErr + end + + local function cleanup() + if bodyPath then pcall(os.remove, bodyPath) end + pcall(os.remove, headerPath) + end + + local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d "):format(maxTime) + .. "-X " .. HostShell.quote(method) .. " " + .. "-H " .. HostShell.quote("@" .. headerPath) .. " " + if body then + cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " + end + cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" + + local pipe = HostShell.popen(cmd) + if not pipe then + cleanup() + return nil, "could not run curl" + end + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + cleanup() + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local respBody, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + return respBody or "", nil, status +end + return HostShell diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a63db2a4..2b03df44 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -356,6 +356,8 @@ function SaveData.defaultOptions() -- rewind presentation preferences. dateFormat = "device", -- device | dmy | mdy | ymd timeFormat = "device", -- device | 24h | 12h + saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {}, + pendingConflicts = {} }, } end @@ -1013,6 +1015,22 @@ function SaveData.listSlots(version) return out end +function SaveData.readSlotSource(version, slotId, injectedFs) + version = version or GameVersion.get() + if not knownVersion(version) or type(slotId) ~= "string" then return nil end + local fs = persistFs(injectedFs) + local main, bak, tmp = slotNames(version, slotId) + for _, name in ipairs({ main, tmp, bak }) do + if fs.getInfo(name) then + local body = fs.read(name) + if type(body) == "string" and body ~= "" then + if SaveSerializer.decode(body) then return body end + end + end + end + return nil +end + -- Give a registered slot a custom label (#205: "a way to name save slots so -- you can see that in the launcher"). The label lives in the options -- registry next to list/active, never in the save file itself, so renaming @@ -1327,7 +1345,7 @@ end -- loaded list sorted by id and is the ground truth for the load-time -- mod-set diff. A nil mods list keeps the previous stamp's set so a -- headless writer (the save editor) never wipes it. -function SaveData.buildMeta(mods, previous) +function SaveData.buildMeta(mods, previous, sessionStart) local list if mods ~= nil then list = {} @@ -1338,10 +1356,18 @@ function SaveData.buildMeta(mods, previous) else list = (type(previous) == "table" and previous.mods) or {} end + local started = tonumber(sessionStart) + if not started or started ~= started or started <= 0 + or started == math.huge then + started = type(previous) == "table" and tonumber(previous.sessionStart) or nil + end + local savedAt = os.time() + if started and started > savedAt then started = savedAt end return { format = Version.saveFormat, engine = Version.engine, - savedAt = os.time(), + savedAt = savedAt, + sessionStart = started, playthroughId = type(previous) == "table" and previous.playthroughId or nil, mods = list, } diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index d54a04c0..dd9c679a 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -616,13 +616,15 @@ local function exitControl(self, ctl) for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end end -function skinHitSet(self, x, y) +function skinHitSet(self, x, y, prev) local page = TouchSkin.page() if not page then return nil end local ww, wh, ox, oy = surfaceRect() local set = nil for _, ctl in ipairs(page.controls) do - if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then + local held = (prev and prev[ctl]) == true + if not ctl.decorative + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then set = set or {} set[ctl] = true end @@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y) return end if TouchSkin.active then - local set = skinHitSet(self, x, y) + local set = skinHitSet(self, x, y, nil) if not set then return end local touch = { control = "skin" } self.touches[id] = touch @@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y) local touch = self.touches[id] if not touch then return end if touch.control == "skin" then - applySkinSet(self, touch, skinHitSet(self, x, y)) + applySkinSet(self, touch, skinHitSet(self, x, y, touch.set)) return end -- only the d-pad tracks movement (slide between directions without diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 64eddfae..c9aa6080 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -2,6 +2,7 @@ local TouchSkin = {} TouchSkin.BUNDLED_ROOT = "assets/skins" TouchSkin.USER_ROOT = "skins" +TouchSkin.EXPORT_ROOT = "skins/_export" TouchSkin.GB_BUTTONS = { a = "a", b = "b", start = "start", select = "select", @@ -83,6 +84,110 @@ local function parseBinds(spec) return buttons, hotkeys, keys, decorative end +TouchSkin.AREA_DEFAULTS = { + dpad_area = { up = "up", down = "down", left = "left", right = "right" }, + abxy_area = { up = "x", down = "b", left = "y", right = "a" }, + analog_left = { up = "up", down = "down", left = "left", right = "right" }, + analog_right = { up = "up", down = "down", left = "left", right = "right" }, +} + +local DIRECTIONAL_CELLS = { + { col = 1, row = 1, h = "left", v = "up" }, + { col = 2, row = 1, v = "up" }, + { col = 3, row = 1, h = "right", v = "up" }, + { col = 1, row = 2, h = "left" }, + { col = 3, row = 2, h = "right" }, + { col = 1, row = 3, h = "left", v = "down" }, + { col = 2, row = 3, v = "down" }, + { col = 3, row = 3, h = "right", v = "down" }, +} + +local function outwardReach(reach) + return 1 + 3 * ((num(reach, 1)) - 1) +end + +function TouchSkin.expandDirectional(base, names) + names = names or {} + local cellX = math.abs(num(base.rangeX, 0.05)) / 3 + local cellY = math.abs(num(base.rangeY, 0.05)) / 3 + local out = {} + for _, cell in ipairs(DIRECTIONAL_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, + num(base.x, 0.5) + (cell.col - 2) * cellX * 2, + num(base.y, 0.5) + (cell.row - 2) * cellY * 2, + cellX * 2, cellY * 2, "rect") + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1 + ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1 + ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1 + ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1 + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local SECTOR_CELLS = { + { h = "right" }, + { h = "right", v = "down" }, + { v = "down" }, + { h = "left", v = "down" }, + { h = "left" }, + { h = "left", v = "up" }, + { v = "up" }, + { h = "right", v = "up" }, +} + +TouchSkin.SECTOR_SPAN = math.pi / 4 + +function TouchSkin.sectorHit(sector, dx, dy) + local span = TouchSkin.SECTOR_SPAN + local start = (sector - 1) * span - span * 0.5 + local a = (math.atan2(dy, dx) - start) % (math.pi * 2) + return a < span +end + +function TouchSkin.expandSectors(base, names) + names = names or {} + local out = {} + for i, cell in ipairs(SECTOR_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5), + math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2, + base.shape) + ctl.sector = i + ctl.areaKind = base.areaKind + ctl.areaNames = base.areaNames + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = num(base.reachLeft, 1) + ctl.reachRight = num(base.reachRight, 1) + ctl.reachUp = num(base.reachUp, 1) + ctl.reachDown = num(base.reachDown, 1) + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local function areaSide(kv, prefix, side, fallback) + local v = kv[prefix .. "_" .. side] + if v == nil or trim(v) == "" then return fallback end + return trim(v) +end + local function parseDesc(kv, prefix, page) local spec = kv[prefix] if not spec then return nil end @@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page) imagePath = kv[prefix .. "_overlay"], pressedImagePath = kv[prefix .. "_overlay_pressed"], nextTarget = kv[prefix .. "_next_target"], + movable = toBool(kv[prefix .. "_movable"]) or nil, + exclusive = (toBool(kv[prefix .. "_exclusive"]) + or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil, + saturatePct = num(kv[prefix .. "_saturate_pct"], nil), } if ctl.imagePath == "" then ctl.imagePath = nil end if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end + + local normalized = kv[prefix .. "_normalized"] + if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end + + local areaKind = trim(t[1]):lower() + local defaults = TouchSkin.AREA_DEFAULTS[areaKind] + if defaults then + ctl.areaKind = areaKind + ctl.areaNames = { + up = areaSide(kv, prefix, "up", defaults.up), + down = areaSide(kv, prefix, "down", defaults.down), + left = areaSide(kv, prefix, "left", defaults.left), + right = areaSide(kv, prefix, "right", defaults.right), + } + end return ctl end @@ -127,7 +251,14 @@ function TouchSkin.parse(text) local count = math.floor(num(kv.overlays, 0)) if count <= 0 then return nil, "no overlays" end - local pages = {} + local pages, warnings = {}, {} + local function warn(text) + for _, existing in ipairs(warnings) do + if existing == text then return end + end + warnings[#warnings + 1] = text + end + for i = 0, count - 1 do local p = "overlay" .. i local page = { @@ -166,10 +297,34 @@ function TouchSkin.parse(text) page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) end + page.pixelCoords = not page.normalized + if page.pixelCoords and not page.imagePath then + page.pixelCoords = false + warn(page.name .. " has no base image: desc coordinates read as normalized") + end + local descs = math.floor(num(kv[p .. "_descs"], 0)) for d = 0, descs - 1 do local ctl = parseDesc(kv, p .. "_desc" .. d, page) - if ctl then page.controls[#page.controls + 1] = ctl end + if not ctl then + warn(page.name .. " is missing desc " .. d) + elseif ctl.areaKind then + if ctl.imagePath or ctl.pressedImagePath then + local art = TouchSkin.newControl("nul", ctl.x, ctl.y, + ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + art.imagePath = ctl.imagePath + art.pressedImagePath = ctl.pressedImagePath + art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod + art.pixelCoords = ctl.pixelCoords + art.movable, art.exclusive = ctl.movable, ctl.exclusive + page.controls[#page.controls + 1] = art + end + for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do + page.controls[#page.controls + 1] = cell + end + else + page.controls[#page.controls + 1] = ctl + end end pages[#pages + 1] = page end @@ -180,7 +335,7 @@ function TouchSkin.parse(text) if not page.orient then page.orient = TouchSkin.pageOrient(page) end end - return { pages = pages } + return { pages = pages, warnings = warnings } end local function readFile(path) @@ -219,18 +374,26 @@ end TouchSkin.NATIVE_NAME = "skin.lua" +TouchSkin.readFile = readFile +TouchSkin.listDir = listDir +TouchSkin.isDir = isDir + local function findConfig(root) if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then - return root .. "/" .. TouchSkin.NATIVE_NAME, "native" + return root .. "/" .. TouchSkin.NATIVE_NAME, "native", "" end local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } for _, name in ipairs(named) do - if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end + if readFile(root .. "/" .. name) then + return root .. "/" .. name, "retroarch", "" + end end + local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root) + if infoPath then return infoPath, "delta", prefix end local items = listDir(root) table.sort(items) for _, name in ipairs(items) do - if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end + if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end end return nil end @@ -262,6 +425,7 @@ function TouchSkin.parseNative(text) imagePath = raw.image, fullScreen = raw.fullScreen ~= false, normalized = true, + pixelCoords = false, rangeMod = num(raw.rangeMod, 1), alphaMod = num(raw.alphaMod, 1), aspect = num(raw.aspect, DEFAULT_ASPECT), @@ -283,7 +447,24 @@ function TouchSkin.parseNative(text) end for _, c in ipairs(raw.controls or {}) do local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") + local sector = tonumber(c.sector) + if sector then + sector = math.floor(sector) + if sector < 1 or sector > #SECTOR_CELLS then sector = nil end + end + local areaNames + if type(c.areaNames) == "table" then + areaNames = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + if type(c.areaNames[side]) == "string" then + areaNames[side] = c.areaNames[side] + end + end + end page.controls[#page.controls + 1] = { + sector = sector, + areaKind = type(c.areaKind) == "string" and c.areaKind or nil, + areaNames = areaNames, spec = tostring(c.bind or "nul"), buttons = buttons, hotkeys = hotkeys, keys = keys, decorative = decorative, @@ -298,6 +479,8 @@ function TouchSkin.parseNative(text) imagePath = c.image, pressedImagePath = c.imagePressed, nextTarget = c.nextTarget, + movable = c.movable == true or nil, + exclusive = c.exclusive == true or nil, } end if not page.orient then page.orient = TouchSkin.pageOrient(page) end @@ -355,6 +538,14 @@ function TouchSkin.toNative(skin) image = ctl.imagePath, imagePressed = ctl.pressedImagePath, nextTarget = ctl.nextTarget, + movable = ctl.movable or nil, + exclusive = ctl.exclusive or nil, + sector = ctl.sector, + areaKind = ctl.areaKind, + areaNames = ctl.areaNames and { + up = ctl.areaNames.up, down = ctl.areaNames.down, + left = ctl.areaNames.left, right = ctl.areaNames.right, + } or nil, } end out.pages[#out.pages + 1] = p @@ -379,14 +570,44 @@ local function loadImage(path) return img end +local function pixelScalePending(page) + if page.pixelCoords then return true end + for _, ctl in ipairs(page.controls or {}) do + if ctl.pixelCoords then return true end + end + return false +end + +local function applyPixelScale(page) + if not pixelScalePending(page) then return true end + if not page.image or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + for _, ctl in ipairs(page.controls or {}) do + local pixel = ctl.pixelCoords + if pixel == nil then pixel = page.pixelCoords end + if pixel then + ctl.x, ctl.y = ctl.x / iw, ctl.y / ih + ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih + ctl.pixelCoords = false + end + end + page.pixelCoords = false + return true +end + function TouchSkin.load(root, id) - local cfgPath, format = findConfig(root) - if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end + local cfgPath, format, prefix = findConfig(root) + if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end local text = readFile(cfgPath) if not text then return nil, "unreadable " .. cfgPath end local skin, err if format == "native" then skin, err = TouchSkin.parseNative(text) + elseif format == "delta" then + local dir = cfgPath:match("^(.*)/[^/]+$") or root + skin, err = require("src.core.DeltaSkin").parse(text, + { prefix = prefix or "", names = listDir(dir) }) else skin, err = TouchSkin.parse(text) end @@ -402,6 +623,10 @@ function TouchSkin.load(root, id) if page.imagePath then page.image = loadImage(joinPath(root, page.imagePath)) end + if not applyPixelScale(page) then + return nil, "could not read " .. tostring(page.imagePath) + .. ", which " .. page.name .. " measures its coordinates against" + end for _, ctl in ipairs(page.controls) do if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end if ctl.pressedImagePath then @@ -418,14 +643,30 @@ local function mountZip(archive, point) return ok and mounted == true end +TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } +TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } +TouchSkin.PDF_ONLY_MESSAGE = + "This skin uses PDF artwork, which cannot be imported yet. " + .. "Ask the author for a PNG version." + +function TouchSkin.archiveId(name) + name = tostring(name or "") + local ext = name:match("%.([%w]+)$") + if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end + local id = name:sub(1, #name - #ext - 1) + if id == "" then return nil end + return id, ext:lower() +end + function TouchSkin.list() local out, seen = {}, {} local function scan(root, source) for _, name in ipairs(listDir(root)) do - local id = name:gsub("%.zip$", "") - if not seen[id] then + local archiveId = TouchSkin.archiveId(name) + local id = archiveId or name + if not seen[id] and name:sub(1, 1) ~= "_" then local path = root .. "/" .. name - if name:match("%.zip$") then + if archiveId then local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id if mountZip(path, point) and findConfig(point) then seen[id] = true @@ -447,17 +688,20 @@ function TouchSkin.list() return out end --- Drop a .zip into /skins and report the id it will list under. +-- Drop a .zip or .deltaskin into /skins and report the id it lists under. function TouchSkin.installArchive(name, data) if not data or data == "" then return nil, "empty archive" end if not (love and love.filesystem and love.filesystem.write) then return nil, "no writable filesystem" end name = tostring(name or ""):match("([^/\\]+)$") or "" - name = name:gsub("[^%w%._%-]", "_") - if not name:lower():match("%.zip$") then return nil, "not a .zip" end - local id = name:gsub("%.[Zz][Ii][Pp]$", "") - if id == "" then return nil, "bad archive name" end + name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "") + local legacy = name:match("%.([%w]+)$") + if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then + return nil, "old GBA4iOS skin, not supported" + end + local id = TouchSkin.archiveId(name) + if not id then return nil, "not a .zip or .deltaskin" end pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) local dest = TouchSkin.USER_ROOT .. "/" .. name @@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data) local entry = TouchSkin.find(id) if not entry then love.filesystem.remove(dest) - return nil, "no skin.lua or .cfg inside " .. name + return nil, "no skin.lua, .cfg or info.json inside " .. name end - return id + local skin = TouchSkin.load(entry.root, entry.id) + if skin and require("src.core.DeltaSkin").needsConversion(skin) then + love.filesystem.remove(dest) + return nil, TouchSkin.PDF_ONLY_MESSAGE + end + return id, skin and skin.warnings or nil end function TouchSkin.find(id) @@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin) return out end -function TouchSkin.export(skin, destPath) - if not skin then return nil, "no skin" end - local SkinZip = require("src.core.SkinZip") - local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } - local missing = {} - for _, rel in ipairs(TouchSkin.assetPaths(skin)) do - local data = readFile(joinPath(skin.root, rel)) - if data then - entries[#entries + 1] = { name = rel, data = data } - else - missing[#missing + 1] = rel - end - end - if skin.configPath and skin.format == "retroarch" then - local cfg = readFile(skin.configPath) - if cfg then - entries[#entries + 1] = - { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } - end - end - local blob = SkinZip.encode(entries) - destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip") +local function writeArchive(entries, destPath) + local blob = require("src.core.SkinZip").encode(entries) local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil + if not absolute and love and love.filesystem and love.filesystem.createDirectory then + local dir = destPath:match("^(.*)/[^/]+$") + if dir then pcall(love.filesystem.createDirectory, dir) end + end if not absolute and love and love.filesystem and love.filesystem.write then local ok, err = love.filesystem.write(destPath, blob) if not ok then return nil, tostring(err) end @@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath) handle:write(blob) handle:close() end + return destPath +end + +local function collectAssets(skin, rels) + local entries, missing = {}, {} + for _, rel in ipairs(rels) do + local data = readFile(joinPath(skin.root, rel)) + if data then + entries[#entries + 1] = { name = rel, data = data } + else + missing[#missing + 1] = rel + end + end + return entries, missing +end + +function TouchSkin.export(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + if skin.configPath and skin.format == "retroarch" then + local cfg = readFile(skin.configPath) + if cfg then + entries[#entries + 1] = + { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } + end + end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end return destPath, missing end +local function fmtNum(n) + n = tonumber(n) or 0 + if n == math.floor(n) then return string.format("%d", n) end + local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "") + return s +end + +local function fmtRect(r) + return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h)) +end + +local function cfgSpec(spec) + local parts = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw) + local key = name:lower():match("^key:(.+)$") + parts[#parts + 1] = key and ("retrok_" .. key) or name + end + return table.concat(parts, "|") +end + +function TouchSkin.toRetroArchConfig(skin) + local pages = (skin and skin.pages) or {} + local out = { "overlays = " .. #pages } + for i, page in ipairs(pages) do + local p = "overlay" .. (i - 1) + out[#out + 1] = "" + out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"' + if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end + out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false") + out[#out + 1] = p .. "_normalized = true" + if num(page.rangeMod, 1) ~= 1 then + out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod) + end + if num(page.alphaMod, 1) ~= 1 then + out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod) + end + if page.aspectFromCfg and page.aspect and page.aspect > 0 then + out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect) + end + local r = page.rect + if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then + out[#out + 1] = p .. "_rect = " .. fmtRect(r) + end + if page.viewport then + out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) + if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end + if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end + end + local controls = {} + for _, ctl in ipairs(page.controls or {}) do + if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end + end + out[#out + 1] = p .. "_descs = " .. #controls + for j, ctl in ipairs(controls) do + local d = p .. "_desc" .. (j - 1) + local spec = ctl.areaKind and ctl.sector and ctl.areaKind + or cfgSpec(ctl.spec) + if spec == "" then spec = "nul" end + out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec, + fmtNum(ctl.x), fmtNum(ctl.y), + ctl.shape == "radial" and "radial" or "rect", + fmtNum(ctl.rangeX), fmtNum(ctl.rangeY)) + if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end + if ctl.pressedImagePath then + out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath + end + if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then + out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod) + end + if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then + out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod) + end + for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown, + left = ctl.reachLeft, right = ctl.reachRight }) do + if num(value, 1) ~= 1 then + out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value) + end + end + if ctl.movable then out[#out + 1] = d .. "_movable = true" end + if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end + if ctl.nextTarget then + out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"' + end + if ctl.areaKind and ctl.sector and ctl.areaNames then + local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local name = ctl.areaNames[side] + if name and name ~= defaults[side] then + out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"' + end + end + end + end + end + return table.concat(out, "\n") .. "\n" +end + +function TouchSkin.exportRetroArch(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +function TouchSkin.exportDelta(skin, opts) + if not skin then return nil, "no skin" end + opts = opts or {} + local DeltaSkin = require("src.core.DeltaSkin") + local info, assetRels, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assetRels end + local entries = { + { name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) }, + } + local assets, missing = collectAssets(skin, assetRels) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + local destPath = opts.path + or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing, warnings +end + TouchSkin.BINDS = { "nul", "up", "down", "left", "right", @@ -597,6 +988,7 @@ function TouchSkin.clone(skin) id = skin.id, name = skin.name, root = skin.root, format = skin.format, author = skin.author, notes = skin.notes, configPath = skin.configPath, source = skin.source, pages = {}, + warnings = skin.warnings and copyTable(skin.warnings) or nil, } for i, page in ipairs(skin.pages or {}) do local p = copyTable(page) @@ -676,7 +1068,8 @@ function TouchSkin.listImages(root) local function scan(dir, prefix) for _, name in ipairs(listDir(dir)) do local path = dir .. "/" .. name - if name:lower():match("%.png$") or name:lower():match("%.jpg$") then + local lower = name:lower() + if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then out[#out + 1] = prefix .. name elseif isDir(path) and prefix == "" then scan(path, name .. "/") @@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) return cx, cy, halfW, halfH end -function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy) +function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held) local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) - local left = halfW * ctl.reachLeft * ctl.rangeMod - local right = halfW * ctl.reachRight * ctl.rangeMod - local up = halfH * ctl.reachUp * ctl.rangeMod - local down = halfH * ctl.reachDown * ctl.rangeMod + local mod = held == false and 1 or ctl.rangeMod + local left = halfW * ctl.reachLeft * mod + local right = halfW * ctl.reachRight * mod + local up = halfH * ctl.reachUp * mod + local down = halfH * ctl.reachDown * mod local dx = px - cx local dy = py - cy local rx = dx < 0 and left or right local ry = dy < 0 and up or down if rx <= 0 or ry <= 0 then return false end + if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then + return false + end if ctl.shape == "radial" then return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index ee2ee4f0..29d7d661 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -301,6 +301,13 @@ end -- gear edit these before the game starts (src/import/LauncherSettings.lua). Save.OPTIONS_KEY = "gold" +local SHARED_KEYS = { + touchControls = true, haptics = true, + mods = true, modsByVersion = true, modsGen2 = true, + modOptions = true, modProfiles = true, modProfilesSeeded = true, + activeProfile = true, +} + function Save.loadOptions(fs) local options = Save.defaultOptions() local ok, SaveData = pcall(require, "src.core.SaveData") @@ -308,7 +315,21 @@ function Save.loadOptions(fs) local loaded = SaveData.loadOptions(fs) local stored = loaded and loaded[Save.OPTIONS_KEY] if type(stored) == "table" then - for key, value in pairs(stored) do options[key] = value end + for key, value in pairs(stored) do + if not SHARED_KEYS[key] then options[key] = value end + end + end + if type(loaded) == "table" then + for key in pairs(SHARED_KEYS) do + if loaded[key] ~= nil then options[key] = loaded[key] end + end + end + if type(stored) == "table" then + for key in pairs(SHARED_KEYS) do + if options[key] == nil and stored[key] ~= nil then + options[key] = stored[key] + end + end end return options end @@ -321,7 +342,13 @@ function Save.saveOptions(options, fs) if not ok then return false end local file = SaveData.loadOptions(fs) or {} local block = {} - for key, value in pairs(options) do block[key] = value end + for key, value in pairs(options) do + if SHARED_KEYS[key] then + file[key] = value + else + block[key] = value + end + end file[Save.OPTIONS_KEY] = block SaveData.saveOptions(file, fs) return true diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index cd41d034..a2f56efe 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -48,6 +48,14 @@ local TAP_SLOP2 = 16 * 16 -- Installed mods should not turn into a one- or two-item pager on a compact -- display. Keep a useful page size, then let the list viewport scroll. local MIN_MODS_PER_PAGE = 10 +local MIN_SKIN_ROWS = 4 +local SKIN_FORMAT_LABEL = { + native = "GEN1", + retroarch = "RETROARCH", + delta = "DELTA", +} +local MIN_FIND_ROWS = 3 +local PANEL_OVERSCAN = 0.75 local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end @@ -56,6 +64,32 @@ local function inRect(rect, x, y) and y >= rect.y and y <= rect.y + rect.h end +local function tabKeyOf(imp) return imp.tab or "red" end + +local function tabScrollMax(imp) + local t = imp._tabScrollMax + return (t and t[tabKeyOf(imp)]) or 0 +end + +local function tabScrollAt(imp) + local t = imp._tabScroll + return clamp((t and t[tabKeyOf(imp)]) or 0, 0, tabScrollMax(imp)) +end + +local function setTabScroll(imp, value) + imp._tabScroll = imp._tabScroll or {} + imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp)) +end + +local function modListWantsWheel(imp, wheel) + if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end + if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end + if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end + local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax) + if wheel < 0 then return at < imp._modScrollMax end + return at > 0 +end + -- ------------------------------------------------------------- lifecycle local function ensureState(imp) @@ -65,6 +99,9 @@ local function ensureState(imp) imp._actAt = imp._actAt or {} imp._uiActions = imp._uiActions or {} imp._pages = imp._pages or {} + imp._tabScroll = imp._tabScroll or {} + imp._tabScrollMax = imp._tabScrollMax or {} + imp._tabContentH = imp._tabContentH or {} -- Held backspace/arrows must repeat in the text fields; restored on -- detach because the game's Input does its own per-step edge detection -- and never expects repeated keypressed events. @@ -155,7 +192,10 @@ function LauncherView.touchpressed(imp, id, x, y) imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { x = x, y = y, - modsList = (imp._modScrollMax or 0) > 0 and inRect(imp._modListRect, x, y), + modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0 + and inRect(imp._modListRect, x, y) + and inRect(imp._tabRegionRect, x, y), + region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y), } end @@ -170,13 +210,25 @@ function LauncherView.touchmoved(imp, id, x, y) -- A drag that began in the installed-mod viewport scrolls that page's -- rows. Its pager remains available for moving to the next ten-plus -- entries; a drag elsewhere keeps the normal short-window page scroll. - if start.dragged and start.modsList then + if start.dragged then local last = start.lastY or start.y - imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0, - imp._modScrollMax or 0) - elseif start.dragged and (imp._pageScrollMax or 0) > 0 then - local last = start.lastY or start.y - imp._pageScroll = (imp._pageScroll or 0) - (y - last) + local move = -(y - last) + if start.modsList then + local listMax = imp._modScrollMax or 0 + local at, leftover = Kit.scrollHandoff( + clamp(imp.modScroll or 0, 0, listMax), listMax, move) + imp.modScroll = at + move = leftover + end + if move ~= 0 and start.region then + local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), + tabScrollMax(imp), move) + setTabScroll(imp, at) + move = leftover + end + if move ~= 0 and (imp._pageScrollMax or 0) > 0 then + imp._pageScroll = (imp._pageScroll or 0) + move + end end start.lastY = y end @@ -796,6 +848,34 @@ local function drawSkinGlyph(x, y, w, h, hot) Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r) end +local function drawSyncGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local pad = box * 0.24 + local ink = hot and PAL.inverse or PAL.ink + local left, right = bx + pad, bx + box - pad + local head = box * 0.15 + local bar = math.max(1, box * 0.09) + local topY, botY = by + box * 0.34, by + box * 0.58 + Theme.fill(left, topY, math.max(0, right - left - head * 0.5), bar, ink, 1) + Theme.fill(left + head * 0.5, botY, math.max(0, right - left - head * 0.5), + bar, ink, 1) + if love.graphics.line then + love.graphics.push("all") + Theme.col(ink, 1) + if love.graphics.setLineWidth then + love.graphics.setLineWidth(math.max(1.5, bar)) + end + local ty, byy = topY + bar / 2, botY + bar / 2 + love.graphics.line(right - head, ty - head, right, ty, right - head, + ty + head) + love.graphics.line(left + head, byy - head, left, byy, left + head, + byy + head) + love.graphics.pop() + end +end + local function drawCross(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) @@ -885,6 +965,8 @@ local function headerChrome(imp) hot and QUIT_INK_HOT or QUIT_INK_REST) end }, tab = {}, + sync = { face = "tab", drawFn = drawSyncGlyph, + action = function() imp:_openSync() end }, game = { face = "tab", font = "tab", action = function() local g = currentGame(imp) @@ -1050,6 +1132,27 @@ local function buildHeader(imp, m) tx = tx + w + tabGap end + do + local w = tabH + if tx > tabLeft and tx + w > tabRight then + tx = tabLeft + ty = ty + tabH + tabRowGap + end + local o = chrome.sync + o.active = imp._syncModal ~= nil + btn(imp, tx, ty, w, tabH, "tab-sync", "", o) + local bh = math.floor(11 * m.s) + local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s)) + Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh, + "BETA", o.active and PAL.inverse or PAL.yellow) + local eng = imp._sync + if eng and eng.busy and eng:busy() then + Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), + math.max(2, math.floor(4 * m.s))) + end + tx = tx + w + tabGap + end + -- `ty` has walked down with the wraps, so this stays correct at one row too. y = ty + tabH + math.floor(8 * m.s) Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) @@ -1481,7 +1584,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) return h end -local function buildGamePanel(imp, x, y, w, availH, m, version) +local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) imp.panelVersion = version local info = GameVersion.info(version) local locked = info == nil @@ -1527,12 +1630,15 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) local afterTitle = math.floor((ready and 22 or 12) * m.s) local cy = y + titleH + afterTitle local remaining = availH - (titleH + afterTitle) + local budgetLeft = math.max(remaining, + (budgetH or availH) - (titleH + afterTitle)) local gap = m.gap local lx, lw, rx2, rw if m.twoCol then - lx, lw = x, m.colW - rx2, rw = x + m.colW + m.colGap, m.colW + local colW = math.floor((w - m.colGap) / 2) + lx, lw = x, colW + rx2, rw = x + colW + m.colGap, colW else lx, lw, rx2, rw = x, w, x, w end @@ -1579,15 +1685,19 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) -- Save slots. Two columns put them beside the left stack; ONE column -- stacks them underneath. Either way the card is clipped to the room it -- actually has, and sizes its own list to that budget. + local bottom = ly if not locked then local slotY = m.twoCol and cy or ly - local slotAvail = m.twoCol and remaining or (cy + remaining - ly) + local slotAvail = m.twoCol and budgetLeft or (cy + budgetLeft - ly) if slotAvail > 80 * m.s then Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) - buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready) + local slotH = buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, + ready) Kit.popClip() + bottom = math.max(bottom, slotY + math.min(slotH or 0, slotAvail)) end end + return bottom - y end -- --------------------------------------------------------------- mods panel @@ -1821,7 +1931,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) if #mods == 0 then imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) - return + return (cy - y) + math.floor(110 * m.s) end local sortKey = currentSort(imp, "mods") @@ -1882,7 +1992,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) if not lr then lr = {}; imp._modListRect = lr end lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH imp._modScrollMax = scrollMax - if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks + and Kit.hit(x, listTop, w, listH) then scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax) Kit.wheelY = 0 elseif scrollMax == 0 then @@ -2008,9 +2119,10 @@ local function buildModsPanel(imp, x, y, w, availH, m) Kit.popClip() local pagerY = listTop + listH + gap - local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") + local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") if newPage ~= cur then imp.modScroll = 0 end setPage(imp, "mods", newPage) + return pagerY + newPagerH - y end -- ---------------------------------------------------------- find mods panel @@ -2044,6 +2156,32 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s) end + local urlH = m.btnH + local addLabel = Strings("Add") + local addW = Kit.textWidth("small", addLabel) + math.floor(24 * m.s) + local pasteLabel = Strings("Paste") + local pasteW = Kit.textWidth("small", pasteLabel) + math.floor(20 * m.s) + if imp._skinFetch then + Loader.inline(x, cy, w, urlH, + Strings("Downloading %s...", tostring(imp._skinFetch.name or ""))) + else + local urlPlace = Layout.rightCluster(x, w, math.floor(6 * m.s)) + btn(imp, urlPlace(addW), cy, addW, urlH, "skins-url-add", addLabel, { + kind = "accent", font = "small", + action = function() imp:_addSkinFromUrl() end }) + if w - addW - pasteW > math.floor(140 * m.s) then + btn(imp, urlPlace(pasteW), cy, pasteW, urlH, "skins-url-paste", + pasteLabel, { + font = "small", action = function() imp:_pasteSkinUrl() end }) + end + local fieldW = math.max(0, urlPlace(0) - x - math.floor(6 * m.s)) + textField(imp, x, cy, fieldW, urlH, "skins-url", imp.skinUrl or "", + Strings("Paste a skin link (.zip, .cfg, .deltaskin)"), + imp._skinUrlFocus == true, + function() imp:_toggleSkinUrlFocus() end) + end + cy = cy + urlH + math.floor(8 * m.s) + -- Studio button. Desktop only: the host supplies the hook nowhere else. if imp.onOpenSkinStudio then local label = Strings("Open Skin Studio") @@ -2071,7 +2209,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) -- The row itself is "use this skin"; the gear beside it configures that -- entry -- the built-in pad opens the drag-a-button layout editor, a skin -- opens the studio, so neither lands on a screen that cannot edit it. - local function skinRow(key, id, title, detail, selected, configure) + local function skinRow(key, id, title, detail, selected, configure, format) local gearW = configure and rowH or 0 local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0) local ink = rowHit(imp, x, cy, rowW, rowH, selected, key, @@ -2079,7 +2217,17 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local tagW = selected and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s)) or math.floor(12 * m.s) - local textW = rowW - math.floor(24 * m.s) - tagW + local badge = format and SKIN_FORMAT_LABEL[format] or nil + local badgeW = 0 + if badge then + badgeW = Kit.textWidth("micro", badge) + math.floor(16 * m.s) + local badgeH = math.floor(16 * m.s) + Kit.tag(x + rowW - tagW - badgeW - math.floor(12 * m.s), + cy + (rowH - badgeH) / 2, badgeW, badgeH, badge, + format == "native" and PAL.green or PAL.blue) + badgeW = badgeW + math.floor(10 * m.s) + end + local textW = math.max(0, rowW - math.floor(24 * m.s) - tagW - badgeW) local tx = x + math.floor(12 * m.s) local ty = cy + math.floor(7 * m.s) Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty, @@ -2102,7 +2250,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local TouchSkin = require("src.core.TouchSkin") local hint = Strings( - "You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.", + "You can also drop a skin .zip or .deltaskin on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files and Delta skins work as-is.", TouchSkin.USER_ROOT) local hintH = Kit.wrapHeight("small", hint, w, 3) local importH = math.floor(10 * m.s) + hintH @@ -2111,9 +2259,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listTop = cy local listH = availH - (cy - y) - importH - local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20) + local perPage = Kit.rowsThatFit(listH, rowH, rowGap, MIN_SKIN_ROWS, 20) if #entries > perPage then - perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20) + perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, + MIN_SKIN_ROWS, 20) end local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"), #entries, perPage) @@ -2142,11 +2291,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) bits[#bits + 1] = entry.pages .. " " .. Strings("pages") end if entry.screen then bits[#bits + 1] = Strings("screen cutout") end - local configure = imp.onOpenSkinStudio and function() - imp.onOpenSkinStudio(imp.modScope or "red", entry.id) - end or nil + local configure = function() imp._skinActions = { id = entry.id } end skinRow("skin-" .. entry.id, entry.id, entry.id, - table.concat(bits, " \194\183 "), active == entry.id, configure) + table.concat(bits, " \194\183 "), active == entry.id, configure, + entry.format) end end @@ -2164,6 +2312,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) cy = cy + math.floor(10 * m.s) Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3) + return cy + hintH - y end local function buildFindPanel(imp, x, y, w, availH, m) @@ -2201,7 +2350,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) aw, m.btnH, "find-add", Strings("Add an index"), { kind = "accent", font = "small", action = function() imp._indexManage = true end }) - return + return (cy - y) + h end -- One row: the search field, then Filter / Sort / Indexes popup buttons. @@ -2232,7 +2381,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) Kit.emptyBox(x, cy, w, math.floor(110 * m.s), (total == 0) and Strings("This index lists no mods yet.") or Strings("No mods match that search.")) - return + return (cy - y) + math.floor(110 * m.s) end local sortKey = currentSort(imp, "find") @@ -2283,7 +2432,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listH = availH - (cy - y) - pagerH - gap - local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20) + local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_FIND_ROWS, 20) local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage) setPage(imp, "find", cur) local listTop = cy @@ -2384,7 +2533,10 @@ local function buildFindPanel(imp, x, y, w, availH, m) end local pagerY = listTop + (last - first + 1) * (rowH + gap) - setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find")) + local findPage, findPagerH = Kit.pager(x, pagerY, w, cur, #rows, perPage, + "find") + setPage(imp, "find", findPage) + local bottom = pagerY + findPagerH -- Aggregate progress. Enrichment happens a page at a time and each row says -- so for itself, but with nothing summarising it the panel looked idle while @@ -2398,7 +2550,9 @@ local function buildFindPanel(imp, x, y, w, availH, m) Kit.text("micro", Strings("Checking %d of %d on this page...", waiting, last - first + 1), x + dh + math.floor(6 * m.s), py, PAL.muted) + bottom = math.max(bottom, py + dh) end + return bottom - y end -- ------------------------------------------------------------------ footer @@ -3161,6 +3315,75 @@ end -- Per-mod actions for the MODS tab: the row itself only carries the enable -- toggle, everything episodic (update check, versions, delete) lives here. +local SKIN_EXPORTS = { + { id = "native", key = "skinact-exp-native", label = "Export as gen1recomp .zip" }, + { id = "retroarch", key = "skinact-exp-ra", label = "Export as RetroArch .zip" }, + { id = "delta", key = "skinact-exp-delta", label = "Export as Delta .deltaskin" }, +} + +local function buildSkinActionsModal(imp, m) + local id = imp._skinActions and imp._skinActions.id + if not id then imp._skinActions = nil return end + local entry + for _, e in ipairs(imp:_ensureSkins()) do + if e.id == id then entry = e break end + end + if not entry then imp._skinActions = nil return end + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local rows = #SKIN_EXPORTS + 2 + (imp.onOpenSkinStudio and 1 or 0) + + (imp._skinExport and imp._skinExport.dir and 1 or 0) + local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(12 * m.s) + + rows * (m.btnH + gap) - gap + pad + local px, py, pw = modalPanel(m, math.floor(440 * m.s), h) + local cy = py + pad + Kit.text("button", Kit.ellipsize("button", entry.id, pw - 2 * pad), + px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + local fmt = SKIN_FORMAT_LABEL[entry.format or ""] or Strings("unknown format") + Kit.text("small", Kit.ellipsize("small", + fmt .. " \194\183 " .. entry.pages .. " " .. Strings("pages") + .. " \194\183 " .. entry.controls .. " " .. Strings("buttons"), + pw - 2 * pad), px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(12 * m.s) + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-use", + Strings("Use this skin"), { kind = "primary", font = "small", + action = function() + imp:_useSkin(id) + imp._skinActions = nil + end }) + cy = cy + m.btnH + gap + if imp.onOpenSkinStudio then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-edit", + Strings("Open in Skin Studio"), { kind = "accent", font = "small", + action = function() + imp._skinActions = nil + imp.onOpenSkinStudio(imp.modScope or "red", id) + end }) + cy = cy + m.btnH + gap + end + for _, spec in ipairs(SKIN_EXPORTS) do + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, spec.key, Strings(spec.label), { + font = "small", + action = function() + imp:_exportSkin(id, spec.id) + imp._skinActions = nil + end }) + cy = cy + m.btnH + gap + end + if imp._skinExport and imp._skinExport.dir then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-reveal", + Strings("Show the exported file"), { font = "small", + action = function() imp:_revealSkinExport() end }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-close", + Strings("Close"), { font = "small", + action = function() imp._skinActions = nil end }) +end + local function buildModActionsModal(imp, m) local mod for _, mm in ipairs(imp.mods or {}) do @@ -3907,6 +4130,335 @@ local function buildDepResolverModal(imp, m) end end +local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. It is brand new, so keep your own backups too." + +local function syncTitle(imp, m, px, py, pw, pad) + local label = Strings("SAVE SYNC") + Kit.text("button", label, px + pad, py, PAL.heading) + local bh = math.floor(15 * m.s) + local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s) + Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), + py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow) + return py + Kit.textHeight("button") + math.floor(12 * m.s) +end + +local function syncStatus(imp, m, x, y, w, eng) + if eng:busy() then + Loader.inline(x, y, w, m.btnH, eng.status) + return m.btnH + math.floor(8 * m.s) + end + Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y, + eng.phase == "error" and PAL.red or PAL.muted) + return Kit.textHeight("small") + math.floor(10 * m.s) +end + +local function syncRow(imp, m, x, y, w, key, label, opts) + opts = opts or {} + opts.font = "small" + btn(imp, x, y, w, m.btnH, key, label, opts) + return y + m.btnH + math.floor(8 * m.s) +end + +function LauncherView.syncSideText(meta) + meta = type(meta) == "table" and meta or {} + local summary = type(meta.summary) == "table" and meta.summary or {} + local bits = {} + if type(summary.name) == "string" and summary.name ~= "" then + bits[#bits + 1] = summary.name + end + if tonumber(summary.badges) then + bits[#bits + 1] = tostring(math.floor(summary.badges)) .. " " + .. Strings("badges") + end + if type(summary.timeText) == "string" and summary.timeText ~= "" then + bits[#bits + 1] = summary.timeText + end + if tonumber(summary.dexCount) then + bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " " + .. Strings("seen") + end + local when = tonumber(meta.savedAt) + if when then + bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when) + end + if #bits == 0 then return Strings("no details") end + return table.concat(bits, " \194\183 ") +end + +local function buildSyncConflict(imp, m, eng) + local row = eng.conflicts[1] + local pad = math.floor(18 * m.s) + local w = math.floor(520 * m.s) + local innerW = w - 2 * pad + local lead = row.overlap + and Strings("These saves were played at the same time.") + or Strings("This save also changed on another device.") + local leadH = Kit.wrapHeight("small", lead, innerW, 2) + local sideH = Kit.textHeight("small") + math.floor(2 * m.s) + + Kit.wrapHeight("micro", "x", innerW, 2) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH + + math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s)) + + 4 * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad, + PAL.detail, 2) + math.floor(10 * m.s) + + local function side(title, meta) + Kit.text("small", title, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("small") + math.floor(2 * m.s) + cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta), + px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s) + end + side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"), + row.localMeta) + side(Strings("Other device"), row.remoteMeta) + + local key = row.key + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this", + Strings("Keep this device"), { kind = "primary", + action = function() imp:_syncResolve(key, "local") end }) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other", + Strings("Keep the other device"), { kind = "accent", + action = function() imp:_syncResolve(key, "remote") end }) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both", + Strings("Keep both"), { + action = function() imp:_syncResolve(key, "both") end }) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close", + Strings("Close"), { action = function() imp:_closeSync() end }) +end + +local function buildSyncLink(imp, m, eng) + local mo = imp._syncModal + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local hint = Strings("Enter the two codes the other device is showing.") + local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH + + math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) + + Kit.textHeight("small") + math.floor(10 * m.s) + + 2 * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, + PAL.detail, 2) + math.floor(10 * m.s) + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1", + mo.code1 or "", Strings("First code"), imp._syncFocus == "code1", + function() imp:_syncFocusField("code1") end) + cy = cy + fieldH + math.floor(8 * m.s) + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2", + mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2", + function() imp:_syncFocusField("code2") end) + cy = cy + fieldH + math.floor(8 * m.s) + cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go", + Strings("Link this device"), { kind = "primary", enabled = not eng:busy(), + action = function() imp:_syncLink() end }) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back", + Strings("Back"), { action = function() imp:_syncView("home") end }) +end + +local function buildSyncMods(imp, m, eng) + local mo = imp._syncModal + local pad = math.floor(18 * m.s) + local w = math.floor(500 * m.s) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local plan = eng.modPlan + local rows = 4 + (plan and 1 or 0) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + 3 * (Kit.textHeight("small") + math.floor(8 * m.s)) + + fieldH + math.floor(8 * m.s) + + rows * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + local innerW = pw - 2 * pad + + if eng.shareCode then + Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(4 * m.s) + Kit.text("stat", eng.shareCode, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("stat") + math.floor(4 * m.s) + Kit.text("micro", Kit.ellipsize("micro", + Strings("Enter this code in Save Sync > Get mod list"), innerW), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) + end + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods", + Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(), + action = function() imp:_syncShareMods() end }) + + textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code", + mo.share or "", Strings("Paste a 6-character mod code"), + imp._syncFocus == "share", function() imp:_syncFocusField("share") end) + cy = cy + fieldH + math.floor(8 * m.s) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods", + Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(), + action = function() imp:_syncGetShare() end }) + + if plan then + local line = Strings("%d mods, %d indexes to add", + #(plan.toInstall or {}) + #(plan.toEnable or {}), #(plan.indexes or {})) + if #(plan.missing or {}) > 0 then + line = line .. " \194\183 " .. Strings("%d not in your indexes", + #plan.missing) + end + Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy, + PAL.detail) + cy = cy + Kit.textHeight("small") + math.floor(8 * m.s) + local prog = mo.progress + if prog then + Loader.inline(px + pad, cy, innerW, m.btnH, + Strings("%d of %d", prog.done or 0, prog.total or 0)) + cy = cy + m.btnH + math.floor(8 * m.s) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods", + Strings("Apply these mods"), { kind = "primary", + enabled = not eng:busy(), + action = function() imp:_syncApplyMods() end }) + end + end + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"), + { action = function() imp:_syncView("home") end }) +end + +function LauncherView.syncDeviceRows(eng, limit) + local out = {} + if not eng or type(eng.devices) ~= "table" then return out end + for _, row in ipairs(eng.devices) do + if #out >= (limit or 6) then break end + if type(row) == "table" and type(row.id) == "string" then + out[#out + 1] = { + id = row.id, + current = row.current == true, + label = type(row.label) == "string" and row.label ~= "" and row.label + or "device", + } + end + end + return out +end + +local function buildSyncHome(imp, m, eng) + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local linked = eng:linked() + local codes = eng.codes + local body = linked + and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") + or Strings(SYNC_HINT) + local innerW = w - 2 * pad + local hintH = Kit.wrapHeight("small", body, innerW, 5) + local codesH = codes + and (Kit.textHeight("small") + math.floor(6 * m.s) + + 2 * (Kit.textHeight("title") + math.floor(4 * m.s)) + + math.floor(8 * m.s)) or 0 + local devices = linked and LauncherView.syncDeviceRows(eng) or {} + local devicesH = #devices > 0 + and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 + local rows = (linked and 5 or 3) + #devices + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH + + math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s) + + rows * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5) + + math.floor(10 * m.s) + + if codes then + Kit.text("small", Strings("Enter these on your other device:"), px + pad, + cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + Kit.text("title", codes.code1, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("title") + math.floor(4 * m.s) + Kit.text("title", codes.code2, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("title") + math.floor(8 * m.s) + end + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + + if #devices > 0 then + Kit.text("small", Strings("Devices on this account:"), px + pad, cy, + PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + for i, device in ipairs(devices) do + local id = device.id + if device.current then + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, + device.label .. " \194\183 " .. Strings("this device"), + { enabled = false }) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, + Strings("Unlink %s", device.label), { kind = "danger", + enabled = not eng:busy(), + action = function() imp:_syncUnlinkDevice(id) end }) + end + end + end + + if linked then + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"), + { kind = "primary", enabled = not eng:busy(), + action = function() imp:_syncNow() end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods", + Strings("Share or get a mod list"), { kind = "accent", + action = function() imp:_syncView("mods") end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink", + Strings("Unlink this device"), { kind = "danger", + action = function() imp:_syncUnlink() end }) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create", + Strings("Create sync account"), { kind = "primary", + enabled = not eng:busy(), + action = function() imp:_syncCreate() end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link", + Strings("Link this device"), { kind = "accent", + action = function() imp:_syncView("link") end }) + end + syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"), + { action = function() imp:_closeSync() end }) +end + +local function buildSyncUnavailable(imp, m, msg) + local pad = math.floor(18 * m.s) + local w = math.floor(420 * m.s) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s) + + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad, + PAL.detail, 4) + math.floor(10 * m.s) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close", + Strings("Close"), { action = function() imp:_closeSync() end }) +end + +local function buildSyncModal(imp, m) + if not imp:_syncSupported() then + buildSyncUnavailable(imp, m, Strings( + "Save sync cannot run on this build: it has no way to send the signed requests it needs. Update to the latest app build, or use a desktop build.")) + return + end + local eng = imp._sync + if not eng then + buildSyncUnavailable(imp, m, + Strings("Save sync is not available in this build.")) + return + end + if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then + buildSyncConflict(imp, m, eng) + return + end + local view = imp._syncModal and imp._syncModal.view or "home" + if view == "link" then + buildSyncLink(imp, m, eng) + elseif view == "mods" then + buildSyncMods(imp, m, eng) + else + buildSyncHome(imp, m, eng) + end +end + -- Whether ANY modal will draw this frame. draw() consults this BEFORE the -- panels build: immediate mode hit-tests each control as it draws, so the -- panels underneath a modal must run with Kit.blockClicks already raised or @@ -3919,7 +4471,7 @@ local function modalUp(imp) or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._gamePopup - or imp._modActions or imp._modImports + or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil end @@ -4064,6 +4616,8 @@ local function buildModals(imp, m) if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end + if imp._syncModal then buildSyncModal(imp, m) return true end + if imp._skinActions then buildSkinActionsModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end if imp._gameManage then buildGameManageModal(imp, m) return true end @@ -4173,13 +4727,6 @@ function LauncherView.draw(imp) local footH = footerHeight(imp, m) local naturalAvail = m.h - headerHeight(m) - footH - m.gap local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail) - local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax)) - if scrollMax > 0 and (imp._wheelY or 0) ~= 0 then - scroll = math.max(0, math.min( - scroll - imp._wheelY * math.floor(48 * m.s), scrollMax)) - imp._wheelY = 0 -- the page consumed the wheel; lists page by tap here - end - imp._pageScroll, imp._pageScrollMax = scroll, scrollMax Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) imp._clickPt = nil @@ -4192,6 +4739,35 @@ function LauncherView.draw(imp) -- one is up; buildModals lowers the shield for the modal's own controls. Kit.blockClicks = modalUp(imp) + local step = Kit.scrollStep(m.s) + local nested = modListWantsWheel(imp, Kit.wheelY or 0) + if not nested then + local rect = imp._tabRegionRect + if rect then + setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp), + rect.x, rect.y, rect.w, rect.h, step))) + end + end + local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax)) + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested + and not Kit.blockClicks then + local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax)) + if moved ~= scroll then + scroll = moved + Kit.wheelY = 0 + end + end + imp._pageScroll, imp._pageScrollMax = scroll, scrollMax + if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks + and tabScrollMax(imp) > 0 then + local was = tabScrollAt(imp) + local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp)) + if to ~= was then + setTabScroll(imp, to) + Kit.wheelY = 0 + end + end + -- The header is the only block that moves with the page scroll, so shift -- m.top across the call and put it back rather than wrapping `m` in a -- proxy: the proxy cost two tables a frame and put a metatable lookup on @@ -4210,15 +4786,31 @@ function LauncherView.draw(imp) end local x, w = m.contentX, m.contentW + local viewH = math.max(0, availH) + local rect = imp._tabRegionRect + if not rect then rect = {}; imp._tabRegionRect = rect end + rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH + + local at = tabScrollAt(imp) + local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp)) + local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN)) + local panelW = math.max(0, w - Kit.scrollGutter(m.s)) + local contentH if imp.tab == "mods" then - buildModsPanel(imp, x, contentY, w, availH, m) + contentH = buildModsPanel(imp, x, py, panelW, budgetH, m) elseif imp.tab == "find" then - buildFindPanel(imp, x, contentY, w, availH, m) + contentH = buildFindPanel(imp, x, py, panelW, budgetH, m) elseif imp.tab == "skins" then - buildSkinsPanel(imp, x, contentY, w, availH, m) + contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m) else - buildGamePanel(imp, x, contentY, w, availH, m, imp.tab) + contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH) end + contentH = contentH or availH + imp._tabContentH[tabKeyOf(imp)] = contentH + imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH) + at = clamp(at, 0, tabScrollMax(imp)) + imp._tabScroll[tabKeyOf(imp)] = at + Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp)) buildFooter(imp, m, footY) Kit.blockClicks = false diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab9af931..7a94df36 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1111,18 +1111,18 @@ local function chooseZip() end local function chooseSkinZip() - local prompt = shellSafe(Strings("Choose a skin .zip")) + local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin")) local platform = love.system.getOS() if platform == "OS X" then return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]]) + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]]) :format(prompt)) elseif platform == "Windows" then local script = table.concat({ "Add-Type -AssemblyName System.Windows.Forms;", "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", - "$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';", + "$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';", "if($d.ShowDialog() -eq 'OK'){", "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$t=Join-Path $env:TEMP $n;", @@ -1134,11 +1134,11 @@ local function chooseSkinZip() 'powershell -NoProfile -STA -Command "' .. script .. '"') elseif platform == "Linux" then local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]]) + ([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]]) :format(prompt)) if path then return path end return commandOutput( - [[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]]) + [[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]]) end return nil end @@ -1349,6 +1349,7 @@ function RomImporter.new(onComplete, opts) findLoaded = false, findSources = nil, findIndex = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + skinUrl = "", _skinUrlFocus = false, -- Page scroll offset (px) for the column under the tab bar -- panel, updater -- banner and footer -- used only while that column is taller than the window -- (see draw()). Clamped against content in draw, reset on a tab change. @@ -1851,10 +1852,14 @@ end function RomImporter:filedropped(file) if self.workState == "working" then return end -- A dropped .zip is a mod archive: hand it straight to the mods installer - -- (which mounts + validates it). Everything else is treated as a ROM. The - -- dropped file itself is passed through -- installZip opens it the same way - -- readDroppedFile does here. + -- (which mounts + validates it). A .deltaskin is only ever a skin, and + -- everything else is treated as a ROM. The dropped file itself is passed + -- through -- installZip opens it the same way readDroppedFile does here. local name = file:getFilename() or "" + if name:lower():match("%.deltaskin$") then + self:_installSkinZip(file) + return + end if name:lower():match("%.zip$") then -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. if self.tab == "skins" then @@ -2481,6 +2486,8 @@ function RomImporter:update(dt) self:_pumpModInfoFetch() self:_pumpFindStats() self:_pumpFindThumbs() + self:_pumpSkinFetch() + self:_pumpSync(dt) self:_pumpModCheck() self:_pumpModInstall() self:_pumpExtract() @@ -3082,6 +3089,8 @@ end function RomImporter:_switchTab(id) self.tab = id self._findSearchFocus = false + self._skinUrlFocus = false + self._modScrollMax, self._modListRect = 0, nil self:_disarmTextInput() -- the skins list is cheap and can change behind the launcher's back -- (an export, a hand-dropped folder), so re-read it on every visit @@ -3107,6 +3116,7 @@ function RomImporter:_ensureSkins(force) out[#out + 1] = { id = entry.id, source = entry.source, + format = skin and skin.format or nil, pages = skin and #skin.pages or 0, controls = controls, screen = page ~= nil and page.viewport ~= nil, @@ -3140,7 +3150,6 @@ end function RomImporter:_installSkinZip(source) if self.workState == "working" then return end self.tab = "skins" - local TouchSkin = require("src.core.TouchSkin") local name, data, readError if type(source) == "string" then name = source @@ -3160,13 +3169,368 @@ function RomImporter:_installSkinZip(source) .. tostring(readError or name) } return end - local id, err = TouchSkin.installArchive(name, data) + self:_installSkinData(name, data) +end + +local MAX_SKIN_URL = 300 +local SKIN_TEMP_DIR = "skins/_download" + +function RomImporter.skinUrlName(url) + local path = tostring(url or ""):gsub("[?#].*$", "") + local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_") + local ext = base:match("%.([%w]+)$") + if not ext then + return (base ~= "" and base or "skin") .. ".zip" + end + ext = ext:lower() + local TouchSkin = require("src.core.TouchSkin") + if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end + return (base:gsub("%.[%w]+$", "")) .. ".zip" +end + +function RomImporter.wrapSkinPayload(name, data) + name = tostring(name or "") + if not name:lower():match("%.cfg$") then return name, data end + if not data then return name, data end + if data:sub(1, 2) == "PK" then + return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data + end + local blob = require("src.core.SkinZip").encode({ + { name = "overlay.cfg", data = data }, + }) + return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob +end + +function RomImporter:_installSkinData(name, data) + local TouchSkin = require("src.core.TouchSkin") + if not data or data == "" then + self._skinNotice = { ok = false, text = Strings("The skin file was empty.") } + return nil + end + local wrappedName, payload = RomImporter.wrapSkinPayload(name, data) + local id, note = TouchSkin.installArchive(wrappedName, payload) self:_ensureSkins(true) if not id then - self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) } + self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) } + return nil + end + local text = "Imported " .. id + if type(note) == "table" and note[1] then + text = text .. ": " .. tostring(note[1]) + end + self._skinNotice = { ok = true, text = text } + return id +end + +function RomImporter:_toggleSkinUrlFocus() + self._skinUrlFocus = not self._skinUrlFocus + if self._skinUrlFocus then + self:_armTextInput() + else + self:_disarmTextInput() + end +end + +function RomImporter:_pasteSkinUrl() + local ok, text = pcall(love.system.getClipboardText) + if ok and type(text) == "string" then + self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""), + MAX_SKIN_URL) + end +end + +function RomImporter:_addSkinFromUrl(url) + if self._skinFetch then return false end + url = tostring(url or self.skinUrl or ""):gsub("%s", "") + if url == "" then + self._skinNotice = { ok = false, + text = Strings("Paste a link to a skin archive first.") } + return false + end + if not url:match("^https?://") then + self._skinNotice = { ok = false, + text = Strings("A skin link has to start with http:// or https://") } + return false + end + if not require("src.core.Platform").canFetchRemote() then + self._skinNotice = { ok = false, + text = Strings("Downloading needs a network transport this build has not got.") } + return false + end + local name = RomImporter.skinUrlName(url) + local Fetch = require("src.net.Fetch") + self._skinFetch = { + url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name, + job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name, + { userAgent = "gen1recomp-skin", maxSeconds = 90 }), + } + self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) } + return true +end + +function RomImporter:_pumpSkinFetch() + local f = self._skinFetch + if not f then return end + local Fetch = require("src.net.Fetch") + local st = Fetch.poll(f.job) + if st.status == "pending" then + self._skinFetchProgress = st.progress return end - self._skinNotice = { ok = true, text = "Imported " .. id } + Fetch.release(f.job) + self._skinFetch, self._skinFetchProgress = nil, nil + if st.status ~= "ok" or not st.path then + self._skinNotice = { ok = false, + text = "Download failed: " .. tostring(st.err or "no data") } + return + end + local data = love.filesystem.read(st.path) + love.filesystem.remove(st.path) + if self:_installSkinData(f.name, data) then + self.skinUrl = "" + end +end + +function RomImporter:_exportSkin(id, kind) + local TouchSkin = require("src.core.TouchSkin") + local entry = id and TouchSkin.find(id) + if not entry then + self._skinNotice = { ok = false, text = Strings("That skin is gone.") } + return nil + end + local skin = TouchSkin.load(entry.root, entry.id) + if not skin then + self._skinNotice = { ok = false, + text = Strings("Could not read %s", tostring(id)) } + return nil + end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + self._skinNotice = { ok = false, + text = "Export failed: " .. tostring(missing) } + return nil + end + local dir = love.filesystem.getSaveDirectory + and love.filesystem.getSaveDirectory() or nil + self._skinExport = { path = path, dir = dir } + local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path) + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) missing)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + self._skinNotice = { ok = true, text = text } + return path +end + +function RomImporter:_revealSkinExport() + local e = self._skinExport + if not e or not e.dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, fileUrl(e.dir)) + end + return true +end + +local MAX_SYNC_CODE = 8 +local MAX_SHARE_CODE = 6 + +function RomImporter.syncDigits(text) + local digits = tostring(text or ""):gsub("[^%d]", "") + return digits:sub(1, MAX_SYNC_CODE) +end + +function RomImporter.syncShareCode(text) + local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "") + return out:sub(1, MAX_SHARE_CODE) +end + +function RomImporter:_syncDeviceLabel() + local name = love.system and love.system.getOS and love.system.getOS() + if type(name) ~= "string" or name == "" then return "device" end + return name +end + +function RomImporter:_syncEngine() + if self._sync ~= nil then return self._sync or nil end + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._sync = false + return nil + end + local made, eng = pcall(SyncEngine.shared) + if not made or type(eng) ~= "table" then + self._sync = false + return nil + end + self._sync = eng + return eng +end + +function RomImporter:_syncSupported() + if self._syncTransportOk ~= nil then return self._syncTransportOk end + local ok, HostShell = pcall(require, "src.core.HostShell") + if not ok or type(HostShell) ~= "table" + or type(HostShell.canHttpRequest) ~= "function" then + self._syncTransportOk = true + return true + end + local asked, can = pcall(HostShell.canHttpRequest) + self._syncTransportOk = (not asked) or (can and true or false) + return self._syncTransportOk +end + +function RomImporter:_pumpSync(dt) + if self._sync == nil then + if not self.launcher or self._syncBooted then return end + if not self:_syncSupported() then return end + self._syncBooted = true + local booted = self:_syncEngine() + if booted and booted.state.enabled and booted:linked() then + pcall(booted.syncNow, booted) + end + end + local eng = self._sync + if not eng then return end + pcall(eng.update, eng, dt) + if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then + if not self._syncModal and not self._syncConflictShown then + self._syncConflictShown = true + self:_openSync() + end + else + self._syncConflictShown = nil + end +end + +function RomImporter:_openSync() + self:_syncEngine() + self._syncModal = self._syncModal + or { view = "home", code1 = "", code2 = "", share = "" } + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_closeSync() + self._syncModal = nil + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_syncView(view) + if not self._syncModal then return end + self._syncModal.view = view + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_syncFocusField(field) + if not self._syncModal then return end + if self._syncFocus == field then + self._syncFocus = nil + self:_disarmTextInput() + return + end + self._syncFocus = field + self:_armTextInput() +end + +function RomImporter:_syncTypeInto(field, text) + local mo = self._syncModal + if not mo or not field then return end + if field == "share" then + mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or "")) + else + mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or "")) + end +end + +function RomImporter:_syncPaste() + local field = self._syncFocus + if not field then return end + local ok, text = pcall(love.system.getClipboardText) + if ok and type(text) == "string" then self:_syncTypeInto(field, text) end +end + +function RomImporter:_syncCreate() + local eng = self:_syncEngine() + if not eng then return false end + return eng:createAccount(self:_syncDeviceLabel()) +end + +function RomImporter:_syncLink() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng or not mo then return false end + local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel()) + if ok then + mo.code1, mo.code2, mo.view = "", "", "home" + self._syncFocus = nil + self:_disarmTextInput() + end + return ok +end + +function RomImporter:_syncNow() + local eng = self:_syncEngine() + if not eng then return false end + return eng:syncNow() +end + +function RomImporter:_syncUnlink() + local eng = self:_syncEngine() + if not eng then return false end + eng:unlink() + if self._syncModal then self._syncModal.view = "home" end + return true +end + +function RomImporter:_syncUnlinkDevice(deviceId) + local eng = self:_syncEngine() + if not eng or type(eng.unlinkDevice) ~= "function" then return false end + return eng:unlinkDevice(deviceId) +end + +function RomImporter:_syncShareMods() + local eng = self:_syncEngine() + if not eng then return false end + return eng:shareMods() +end + +function RomImporter:_syncGetShare() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng or not mo then return false end + return eng:fetchShare(mo.share or "") +end + +function RomImporter:_syncApplyMods() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng then return false end + local ok, err = eng:applyModPlan(function(done, total, label, finished) + if not mo then return end + if finished then + mo.progress = nil + if self._refreshMods then self:_refreshMods() end + else + mo.progress = { done = done, total = total, label = label } + end + end) + if mo then mo.progress = nil end + if ok and self._refreshMods then self:_refreshMods() end + return ok, err +end + +function RomImporter:_syncResolve(key, choice) + local eng = self:_syncEngine() + if not eng then return false end + return eng:resolveConflict(key, choice) end function RomImporter:_skinsImportButtonLabel() @@ -3338,6 +3702,27 @@ function RomImporter:keypressed(key) if key == "escape" then self:_closeSettings() end return end + if self._syncModal then + local field = self._syncFocus + if field then + local mo = self._syncModal + if key == "backspace" then + mo[field] = tostring(mo[field] or ""):sub(1, -2) + elseif key == "return" or key == "kpenter" or key == "escape" then + self._syncFocus = nil + self:_disarmTextInput() + elseif key == "v" + and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then + self:_syncPaste() + end + return + end + if self._flex and require("src.import.LauncherView").keypressed(self, key) then + return + end + if key == "escape" then self:_closeSync() end + return + end if self._rename then if key == "backspace" then self._rename.text = utf8Back(self._rename.text) @@ -3387,6 +3772,21 @@ function RomImporter:keypressed(key) end return end + if self._skinUrlFocus then + if key == "backspace" then + self.skinUrl = utf8Back(self.skinUrl or "") + elseif key == "return" or key == "kpenter" then + self._skinUrlFocus = false + self:_disarmTextInput() + self:_addSkinFromUrl() + elseif key == "escape" then + self._skinUrlFocus = false + self:_disarmTextInput() + elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then + self:_pasteSkinUrl() + end + return + end if self._findSearchFocus then if key == "backspace" then self.findQuery = utf8Back(self.findQuery or "") @@ -3494,6 +3894,10 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._syncModal and self._syncFocus then + self:_syncTypeInto(self._syncFocus, text) + return + end if self._profileSavePrompt then self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL) return @@ -3514,6 +3918,11 @@ function RomImporter:textinput(text) utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) return end + if self._skinUrlFocus then + self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""), + MAX_SKIN_URL) + return + end if self._findSearchFocus then self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY) self.findScroll = 0 diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 3e7d961a..2a987125 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -86,6 +86,7 @@ local function drain() else j.status = msg.ok and "ok" or "error" j.body, j.err, j.path = msg.body, msg.err, msg.path + j.code = msg.code j.progress = msg.ok and 1 or j.progress end end @@ -143,6 +144,14 @@ function Fetch.post(url, body, opts) contentType = opts.contentType, maxSeconds = opts.maxSeconds }) end +function Fetch.request(url, opts) + opts = opts or {} + return submit({ kind = "request", url = url, + method = opts.method, body = opts.body, headers = opts.headers, + userAgent = opts.userAgent or "gen1recomp", + maxSeconds = opts.maxSeconds }) +end + -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. function Fetch.download(url, saveRel, opts) diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index f386d7f8..79006d62 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -113,6 +113,22 @@ local function doPost(job) post({ id = job.id, ok = true, done = true }) end +local function doRequest(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + local body, err, code = HostShell.httpRequest(job.url, { + method = job.method, body = job.body, headers = job.headers, + userAgent = job.userAgent, + maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS }) + if not code then + post({ id = job.id, ok = false, err = err or "request failed" }) + return + end + post({ id = job.id, ok = true, body = body or "", code = code, done = true }) +end + while true do local job = cmdCh:demand() -- The flag is checked before the job's KIND, so a worker woken by a @@ -131,6 +147,9 @@ while true do elseif job.kind == "post" then local ok, err = pcall(doPost, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + elseif job.kind == "request" then + local ok, err = pcall(doRequest, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end elseif job.kind == "download" then local ok, err = pcall(doDownload, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end diff --git a/src/render/Playfield.lua b/src/render/Playfield.lua new file mode 100644 index 00000000..e150dfef --- /dev/null +++ b/src/render/Playfield.lua @@ -0,0 +1,81 @@ +local GameViewport = require("src.render.GameViewport") +local TouchSkin = require("src.core.TouchSkin") + +local Playfield = {} + +Playfield.WIDTH, Playfield.HEIGHT = 160, 144 + +Playfield.entered = false +Playfield.box = nil + +local function clampRect(x, y, w, h, sw, sh) + if type(w) ~= "number" or type(h) ~= "number" then return nil end + if w ~= w or h ~= h then return nil end + x = math.floor(tonumber(x) or 0) + y = math.floor(tonumber(y) or 0) + w, h = math.floor(w), math.floor(h) + if x < 0 then w, x = w + x, 0 end + if y < 0 then h, y = h + y, 0 end + if x + w > sw then w = sw - x end + if y + h > sh then h = sh - y end + if w < 1 or h < 1 then return nil end + return x, y, w, h +end + +function Playfield.cutout(sw, sh) + if Playfield.entered then return nil end + if type(sw) ~= "number" or type(sh) ~= "number" then return nil end + if sw < 1 or sh < 1 then return nil end + if type(TouchSkin.viewport) ~= "function" then return nil end + local ok, x, y, w, h, fill, expand = pcall(TouchSkin.viewport, sw, sh) + if not ok then return nil end + local cx, cy, cw, ch = clampRect(x, y, w, h, sw, sh) + if not cx then return nil end + return cx, cy, cw, ch, fill == true, expand == true +end + +function Playfield.rect(sw, sh) + local x, y, w, h, _, expand = Playfield.cutout(sw, sh) + if not x then return 0, 0, sw or 0, sh or 0, false end + if expand then return x, y, w, h, true end + local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH, + h / Playfield.HEIGHT))) + local pw = math.min(w, Playfield.WIDTH * s) + local ph = math.min(h, Playfield.HEIGHT * s) + return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true +end + +function Playfield.enter(x, y, w, h) + Playfield.entered = true + Playfield.box = { x = x, y = y, w = w, h = h } +end + +function Playfield.leave() + Playfield.entered = false + Playfield.box = nil +end + +function Playfield.dimensions() + if Playfield.entered and Playfield.box then + return Playfield.box.w, Playfield.box.h + end + return GameViewport.dimensions() +end + +function Playfield.push(sw, sh) + local x, y, w, h, active = Playfield.rect(sw, sh) + local G = love.graphics + G.push("all") + if active then G.setScissor(x, y, w, h) end + G.translate(x, y) + Playfield.enter(x, y, w, h) + return w, h, x, y, active +end + +function Playfield.pop() + Playfield.leave() + love.graphics.setScissor() + love.graphics.pop() +end + +return Playfield diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index e9d71727..79b79501 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle local FaithfulRes = require("src.core.FaithfulRes") -local TouchSkin = require("src.core.TouchSkin") +local Playfield = require("src.render.Playfield") local Renderer = {} @@ -86,12 +86,12 @@ local function displayMetrics() if dpiX < 1e-6 then dpiX = 1 end if dpiY < 1e-6 then dpiY = 1 end local vx, vy = 0, 0 - local sx, sy, sw, sh = TouchSkin.viewport(pw, ph) - if sw and sw >= 1 and sh >= 1 then - vx, vy = math.floor(sx), math.floor(sy) - pw, ph = math.floor(sw), math.floor(sh) + local cut, grow = false, false + local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph) + if sx then + vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand end - return ww, wh, pw, ph, dpiX, dpiY, vx, vy + return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow end function Renderer:init() @@ -269,7 +269,7 @@ end -- corners; flat mode returns exactly today's size (growth factor is 1 when -- tilt is inactive). function Renderer:worldViewSize() - local _, _, pw, ph = displayMetrics() + local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics() -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the -- WHOLE display, so letterbox voids become more map instead of black bars. -- That is why the lock appeared to do nothing in the overworld: it shrank @@ -281,13 +281,11 @@ function Renderer:worldViewSize() -- this is the same sum with the viewport standing in for the window, so -- both platforms show the same map area at the same zoom. local cap = FaithfulRes.scaleCap() - if not cap and TouchSkin.hasViewport() then - local page = TouchSkin.page() - if not page.viewportExpand then cap = self:fitScale() end - end + if not cap and cut and not grow then cap = self:fitScale() end if cap then local uiw, uih = self:uiSize() - pw, ph = uiw * cap, uih * cap + pw = cut and math.min(pw, uiw * cap) or uiw * cap + ph = cut and math.min(ph, uih * cap) or uih * cap end local sp = Zoom.scale(self:fitScale()) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) @@ -346,19 +344,20 @@ end -- window is the classic wipe unchanged. -- -- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces). -function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) +function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, wx, wy) if not wipe or not wipe.prog or wipe.prog <= 0 then return end Sy = Sy or Sx + wx, wy = wx or 0, wy or 0 local TW, TH = 8 * Sx, 8 * Sy if TW < 1 then TW = 1 end if TH < 1 then TH = 1 end local prog = math.min(1, wipe.prog) love.graphics.setColor(0, 0, 0, 1) - love.graphics.setScissor(0, 0, ww, wh) + love.graphics.setScissor(wx, wy, ww, wh) if prog >= 1 then - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", wx, wy, ww, wh) love.graphics.setScissor() love.graphics.setColor(1, 1, 1, 1) return @@ -366,10 +365,10 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) -- whole-tile padding out to each window edge, keeping the grid in phase -- with the letterbox's tiles - local padL = math.max(0, math.ceil(ox / TW)) - local padT = math.max(0, math.ceil(oy / TH)) - local padR = math.max(0, math.ceil((ww - ox - vpw) / TW)) - local padB = math.max(0, math.ceil((wh - oy - vph) / TH)) + local padL = math.max(0, math.ceil((ox - wx) / TW)) + local padT = math.max(0, math.ceil((oy - wy) / TH)) + local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW)) + local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH)) local lbCols = math.max(1, math.floor(vpw / TW + 0.5)) local lbRows = math.max(1, math.floor(vph / TH + 0.5)) local cols, rows = padL + lbCols + padR, padT + lbRows + padB @@ -396,9 +395,9 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) for row = 0, rows - 1 do local y = y0 + row * TH if row % 2 == 0 then - love.graphics.rectangle("fill", 0, y, w, TH) + love.graphics.rectangle("fill", wx, y, w, TH) else - love.graphics.rectangle("fill", ww - w, y, w, TH) + love.graphics.rectangle("fill", wx + ww - w, y, w, TH) end end elseif style == "vstripes" then @@ -406,21 +405,21 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) for col = 0, cols - 1 do local x = x0 + col * TW if col % 2 == 0 then - love.graphics.rectangle("fill", x, 0, TW, h) + love.graphics.rectangle("fill", x, wy, TW, h) else - love.graphics.rectangle("fill", x, wh - h, TW, h) + love.graphics.rectangle("fill", x, wy + wh - h, TW, h) end end elseif style == "shrink" then local h, w = wh / 2 * prog, ww / 2 * prog - love.graphics.rectangle("fill", 0, 0, ww, h) - love.graphics.rectangle("fill", 0, wh - h, ww, h) - love.graphics.rectangle("fill", 0, 0, w, wh) - love.graphics.rectangle("fill", ww - w, 0, w, wh) + love.graphics.rectangle("fill", wx, wy, ww, h) + love.graphics.rectangle("fill", wx, wy + wh - h, ww, h) + love.graphics.rectangle("fill", wx, wy, w, wh) + love.graphics.rectangle("fill", wx + ww - w, wy, w, wh) else -- split: a black cross growing out of the centre in both axes local h, w = wh / 2 * prog, ww / 2 * prog - love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2) - love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh) + love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2) + love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh) end end love.graphics.setScissor() @@ -542,7 +541,8 @@ end -- into (nil = default framebuffer; presentCanvas when CRT is on). -- Returns true on success; false (no shader/mesh) tells endFrame to fall -- back to the flat blit unchanged. -function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) +function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target, + boxX, boxY, boxW, boxH) local shader = self:tiltShader() local mesh = self:tiltMesh() if not (shader and mesh) then return false end @@ -593,12 +593,16 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) mesh:setTexture(self.tiltCanvas) mesh:setVertices(Tilt.meshCorners(wvw, wvh)) love.graphics.push() + if boxW and boxH and boxW > 0 and boxH > 0 then + love.graphics.setScissor(boxX, boxY, boxW, boxH) + end love.graphics.translate(wox, woy) love.graphics.scale(sx, sy) love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) love.graphics.draw(mesh) love.graphics.setShader() + love.graphics.setScissor() love.graphics.pop() return true end @@ -755,20 +759,23 @@ end -- scissored through the shade-remap shader, later zones on top. -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. -function Renderer:endFrame(zones, worldZones) - GameViewport.setTarget() - local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics() - local vux, vuy = vx / dpiX, vy / dpiY - local vuw, vuh = pw / dpiX, ph / dpiY +function Renderer:frameRects() + local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics() + local r = { + ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY, + vx = vx, vy = vy, cut = cut, + vux = vx / dpiX, vuy = vy / dpiY, vuw = pw / dpiX, vuh = ph / dpiY, + } -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). local Sp = self:fitScale() - local Sx, Sy = Sp / dpiX, Sp / dpiY + r.Sp, r.Sx, r.Sy = Sp, Sp / dpiX, Sp / dpiY local uiw, uih = self:uiSize() - local vpw, vph = uiw * Sx, uih * Sy + r.uiw, r.uih = uiw, uih + r.vpw, r.vph = uiw * r.Sx, uih * r.Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. - local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX - local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY + r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX + r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these -- are identical to Sp/ox/oy and every rect below is what it always was. @@ -782,10 +789,38 @@ function Renderer:endFrame(zones, worldZones) if self.uiFill then Up = math.min(ph / uih, pw / uiw) end - local Ux, Uy = Up / dpiX, Up / dpiY - local uvpw, uvph = uiw * Ux, uih * Uy - local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX - local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY + if uiw * Up > pw or uih * Up > ph then + Up = math.min(ph / uih, pw / uiw) + end + r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY + r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy + r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX + r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY + return r +end + +function Renderer.clipToView(r, x, y, w, h) + local x2, y2 = math.min(x + w, r.vux + r.vuw), math.min(y + h, r.vuy + r.vuh) + x, y = math.max(x, r.vux), math.max(y, r.vuy) + return x, y, math.max(0, x2 - x), math.max(0, y2 - y) +end + +function Renderer:playfieldRect() + local r = self:frameRects() + return r.vux, r.vuy, r.vuw, r.vuh, r.cut +end + +function Renderer:endFrame(zones, worldZones) + GameViewport.setTarget() + local R = self:frameRects() + local ww, wh, pw, ph = R.ww, R.wh, R.pw, R.ph + local dpiX, dpiY, vx, vy, cut = R.dpiX, R.dpiY, R.vx, R.vy, R.cut + local vux, vuy, vuw, vuh = R.vux, R.vuy, R.vuw, R.vuh + local Sp, Sx, Sy = R.Sp, R.Sx, R.Sy + local uiw, uih = R.uiw, R.uih + local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy + local Ux, Uy = R.Ux, R.Uy + local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -814,6 +849,7 @@ function Renderer:endFrame(zones, worldZones) ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, uiw = uiw, uih = uih, scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, + viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh, secondScreen = require("src.render.SecondScreen"), } if Runtime.call("render.compose", function() return false end, self, ctx) == true then @@ -901,23 +937,29 @@ function Renderer:endFrame(zones, worldZones) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end end + if cut then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, ww, wh) + end love.graphics.setColor(clearR, clearG, clearB, 1) - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) if extendedBlackBand then love.graphics.setColor(bandR, bandG, bandB, 1) - love.graphics.rectangle("fill", uox, 0, uvpw, wh) + love.graphics.rectangle("fill", uox, vuy, uvpw, vuh) end love.graphics.setColor(1, 1, 1, 1) -- render.letterbox: SGB borders / custom void art in the bars around the -- 160x144 (or world) blit. Drawn after the clear and before the game -- canvas so the playfield sits on top of the border. if Runtime.wantsHook("render.letterbox") then + if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end Runtime.call("render.letterbox", function() end, { ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, worldActive = self.worldActive and true or false, }) + if cut then love.graphics.setScissor() end end -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite @@ -928,6 +970,10 @@ function Renderer:endFrame(zones, worldZones) bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) end + local function clipToView(x, y, w, h) + return Renderer.clipToView(R, x, y, w, h) + end + if self.worldOverride then -- A render pipeline already produced the whole world -- terrain, -- characters and its own FX overlay -- as one window-resolution image, @@ -938,9 +984,9 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setScissor(vux, vuy, vuw, vuh) local loveMajor = love.getVersion() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then - love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY) + love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY) else - love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY) + love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY) end love.graphics.setScissor() -- the screen-space overlays the flat path draws over its composite @@ -964,7 +1010,8 @@ function Renderer:endFrame(zones, worldZones) -- falls through to the flat blit, keeping the flat frame byte-for-byte -- identical to today. local projected = - Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present) + Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, + present, vux, vuy, vuw, vuh) if not projected then if worldZones then blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh) @@ -1061,7 +1108,7 @@ function Renderer:endFrame(zones, worldZones) and not FaithfulRes.scaleCap() then local ok, Game = pcall(require, "src.core.Game") love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data)) - love.graphics.rectangle("fill", uox, 0, uvpw, wh) + love.graphics.rectangle("fill", uox, vuy, uvpw, vuh) love.graphics.setColor(1, 1, 1, 1) end @@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones) -- always been. local anchors = self.uiAnchors if not anchors or #anchors == 0 then - blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, clipToView(uox, uoy, uvpw, uvph)) else - local rest = { { uox, uoy, uvpw, uvph } } + local rest = { { clipToView(uox, uoy, uvpw, uvph) } } local placed = {} for _, a in ipairs(anchors) do local dw, dh = a.w * Ux, a.h * Uy @@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones) local dx, dy if a.anchor == "bottom" then dx = uox + a.x * Ux -- horizontally it stays with the letterbox - dy = wh - gapB - dh + dy = vuy + vuh - gapB - dh elseif a.anchor == "top" then dx = uox + a.x * Ux -- horizontally it stays with the letterbox - dy = a.y * Uy + dy = vuy + a.y * Uy elseif a.anchor == "topright" then - dx = ww - gapR - dw - dy = a.y * Uy + dx = vux + vuw - gapR - dw + dy = vuy + a.y * Uy else -- unknown anchor: leave it where it is dx, dy = uox + a.x * Ux, uoy + a.y * Uy end if a.windowClamped then - dx = math.max(0, math.min(math.max(0, ww - dw), dx)) - dy = math.max(0, math.min(math.max(0, wh - dh), dy)) + dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx)) + dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy)) end placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } if a.extract then @@ -1113,13 +1160,14 @@ function Renderer:endFrame(zones, worldZones) -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy, - p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) + p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, + clipToView(p.dx, p.dy, p.dw, p.dh)) end end local uiRedraws = PaletteFX.uiSpriteRedraws() if uiRedraws[1] then love.graphics.setColor(1, 1, 1, 1) - love.graphics.setScissor(uox, uoy, uvpw, uvph) + love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph)) for _, r in ipairs(uiRedraws) do if r.quad then love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy, @@ -1135,7 +1183,8 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. if self.battleWipe then - self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) + self:drawBattleWipe(self.battleWipe, vuw, vuh, ox, oy, vpw, vph, Sx, Sy, + vux, vuy) end -- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the @@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones) if FaithfulRes.scaleCap() then love.graphics.rectangle("fill", ox, oy, vpw, vph) else - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) end love.graphics.setColor(1, 1, 1, 1) end @@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones) generation = 1, }) == true if not outputHandled then + if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end if GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) @@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + if cut then love.graphics.setScissor() end end end self.worldActive = false @@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones) gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, + viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh, } end diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua new file mode 100644 index 00000000..e0874152 --- /dev/null +++ b/src/sync/SyncClient.lua @@ -0,0 +1,197 @@ +local Json = require("src.link.Json") + +local SyncClient = {} +SyncClient.__index = SyncClient + +SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL") + or "https://sync.147.182.215.255.sslip.io" +SyncClient.MAX_BLOB = 2 * 1024 * 1024 +SyncClient.MAX_RESPONSE = 4 * 1024 * 1024 +SyncClient.TIMEOUT = 25 + +function SyncClient.normalizeCode(code) + if type(code) ~= "string" and type(code) ~= "number" then return nil end + local digits = tostring(code):gsub("[^%d]", "") + if #digits ~= 8 then return nil end + return digits +end + +function SyncClient.formatCode(code) + local digits = SyncClient.normalizeCode(code) + if not digits then return nil end + return digits:sub(1, 4) .. "-" .. digits:sub(5, 8) +end + +local function escape(s) + return (tostring(s):gsub("[^%w%-%._~]", function(c) + return ("%%%02X"):format(c:byte()) + end)) +end + +local function query(params) + local names = {} + for name in pairs(params or {}) do names[#names + 1] = tostring(name) end + table.sort(names) + local out = {} + for _, name in ipairs(names) do + out[#out + 1] = escape(name) .. "=" .. escape(params[name]) + end + if #out == 0 then return "" end + return "?" .. table.concat(out, "&") +end + +function SyncClient.new(opts) + opts = opts or {} + local base = opts.baseUrl or SyncClient.DEFAULT_URL + base = tostring(base):gsub("/+$", "") + local transport = opts.transport + if not transport then + transport = require("src.sync.SyncTransport").new() + end + return setmetatable({ + baseUrl = base, + transport = transport, + account = opts.account, + token = opts.token, + }, SyncClient) +end + +function SyncClient:setAuth(account, token) + self.account = type(account) == "string" and account ~= "" and account or nil + self.token = type(token) == "string" and token ~= "" and token or nil +end + +function SyncClient:clearAuth() + self.account, self.token = nil, nil +end + +function SyncClient:isLinked() + return self.account ~= nil and self.token ~= nil +end + +function SyncClient:send(method, path, body, opts) + opts = opts or {} + local headers = { ["Accept"] = "application/json" } + local payload + if body ~= nil then + local ok, encoded = pcall(Json.encode, body) + if not ok then return nil, "could not encode the request" end + payload = encoded + headers["Content-Type"] = "application/json" + end + if not opts.noAuth then + if not self:isLinked() then return nil, "this device is not linked" end + headers["x-sync-account"] = self.account + headers["x-sync-token"] = self.token + end + local url = self.baseUrl .. path .. query(opts.params) + local handle = self.transport:begin({ + url = url, method = method, body = payload, headers = headers, + maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT, + }) + if handle == nil then return nil, "no network transport" end + return handle +end + +function SyncClient:poll(handle) + if handle == nil then return { status = "error", err = "no request" } end + local res = self.transport:poll(handle) + if res.status == "pending" then return { status = "pending" } end + if res.status ~= "ok" then + return { status = "error", err = res.err or "sync request failed" } + end + local raw = res.body or "" + local code = tonumber(res.code) or 0 + if #raw > SyncClient.MAX_RESPONSE then + return { status = "error", code = code, err = "the reply was too large" } + end + local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE) + if type(data) ~= "table" then + local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply" + if code >= 400 then + return { status = "error", code = code, + err = ("the server answered %d"):format(code) } + end + return { status = "error", code = code, err = why } + end + if code >= 400 or data.error then + local err = data.error + if type(err) ~= "string" or err == "" then + err = ("the server answered %d"):format(code) + end + return { status = "error", code = code, data = data, err = err } + end + return { status = "ok", code = code, data = data } +end + +function SyncClient:release(handle) + if handle ~= nil then self.transport:release(handle) end +end + +function SyncClient:create(deviceLabel) + return self:send("POST", "/sync/create", + { device = deviceLabel or "device" }, { noAuth = true }) +end + +function SyncClient:link(code1, code2, deviceLabel) + local a = SyncClient.normalizeCode(code1) + local b = SyncClient.normalizeCode(code2) + if not a or not b then return nil, "both codes are 8 digits" end + return self:send("POST", "/sync/link", + { code1 = a, code2 = b, device = deviceLabel or "device" }, + { noAuth = true }) +end + +function SyncClient:fetchState() + return self:send("GET", "/sync/state") +end + +function SyncClient:putSave(entry) + if type(entry) ~= "table" then return nil, "missing save entry" end + if type(entry.blob) ~= "string" or entry.blob == "" then + return nil, "missing save data" + end + if #entry.blob > SyncClient.MAX_BLOB then + return nil, "this save is too large to sync" + end + return self:send("PUT", "/sync/save", { + version = entry.version, + slot = entry.slot, + meta = entry.meta, + blob = entry.blob, + baseRev = entry.baseRev, + force = entry.force and true or nil, + }) +end + +function SyncClient:getSave(version, id) + return self:send("GET", "/sync/save", nil, + { params = { version = version, id = id } }) +end + +function SyncClient:putMods(manifest) + return self:send("PUT", "/sync/mods", { manifest = manifest }) +end + +function SyncClient:getMods() + return self:send("GET", "/sync/mods") +end + +function SyncClient:shareMods(manifest) + return self:send("POST", "/sync/modshare", { manifest = manifest }) +end + +function SyncClient:fetchShare(code) + local trimmed = tostring(code or ""):gsub("%s", ""):upper() + if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then + return nil, "share codes are 6 characters" + end + return self:send("GET", "/sync/modshare", nil, + { noAuth = true, params = { code = trimmed } }) +end + +function SyncClient:unlink(device) + return self:send("POST", "/sync/unlink", { device = device }) +end + +return SyncClient diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua new file mode 100644 index 00000000..49c3298f --- /dev/null +++ b/src/sync/SyncEngine.lua @@ -0,0 +1,690 @@ +local SyncClient = require("src.sync.SyncClient") +local SyncState = require("src.sync.SyncState") +local SyncMods = require("src.sync.SyncMods") + +local SyncEngine = {} +SyncEngine.__index = SyncEngine + +SyncEngine.UPLOAD_DEBOUNCE = 5 +SyncEngine.AUTO_INTERVAL = 300 +SyncEngine.MAX_STEPS_PER_UPDATE = 8 + +local IDLE_STATUS = "Ready" +local UNLINKED_STATUS = "Not set up" + +local function saveApi() + return require("src.core.SaveData") +end + +local function gameVersions() + return require("src.core.GameVersion").ORDER +end + +local function slotForPlaythrough(options, version, playthroughId) + local byVersion = options.playthroughIds and options.playthroughIds[version] + for slotId, id in pairs(byVersion or {}) do + if id == playthroughId then return slotId end + end + return nil +end + +function SyncEngine.defaultSaves() + return { + list = function() + local SaveData = saveApi() + local options = SaveData.loadOptions() + local out = {} + for _, version in ipairs(gameVersions()) do + for _, slot in ipairs(SaveData.listSlots(version)) do + if slot.exists then + local source = SaveData.readSlotSource(version, slot.id) + local save = source and SaveData.decode(source) + if type(save) == "table" then + local meta = type(save.meta) == "table" and save.meta or {} + local id = meta.playthroughId + if type(id) ~= "string" or id == "" then + local byVersion = options.playthroughIds + and options.playthroughIds[version] + id = byVersion and byVersion[slot.id] or nil + end + if id then + local name, summary = SaveData.slotSummary(save) + out[#out + 1] = { + version = version, + slot = slot.id, + playthroughId = id, + blob = source, + meta = { + savedAt = tonumber(meta.savedAt), + sessionStart = tonumber(meta.sessionStart), + playthroughId = id, + format = meta.format, + engine = meta.engine, + playTime = tonumber(save.playTime), + summary = { + name = name, + badges = summary and summary.badges, + timeText = summary and summary.timeText, + dexCount = summary and summary.dexCount, + }, + }, + } + end + end + end + end + end + return out + end, + + write = function(version, playthroughId, blob, mode) + local SaveData = saveApi() + local save = SaveData.decode(blob) + if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end + save.version = save.version or version + local options = SaveData.loadOptions() + local slotId + if mode == "new" then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = SaveData.newPlaythroughId() + else + slotId = slotForPlaythrough(options, version, playthroughId) + end + if not slotId then + slotId = SaveData.createSlot(version) + if not slotId then return nil, "could not make a save slot" end + end + local ok, err = SaveData.writeSlot(version, slotId, save) + if not ok then return nil, err or "could not write the save" end + options = SaveData.loadOptions() + options.playthroughIds = options.playthroughIds or {} + options.playthroughIds[version] = options.playthroughIds[version] or {} + options.playthroughIds[version][slotId] = + save.meta and save.meta.playthroughId or playthroughId + SaveData.saveOptions(options) + return slotId + end, + } +end + +function SyncEngine.overlaps(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt) + local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt) + if not (aStart and aEnd and bStart and bEnd) then return false end + return aStart <= bEnd and bStart <= aEnd +end + +function SyncEngine.new(opts) + opts = opts or {} + local eng = setmetatable({}, SyncEngine) + eng.fs = opts.fs + eng.state = opts.state or SyncState.load(eng.fs) + eng.client = opts.client or SyncClient.new({ + baseUrl = opts.baseUrl, transport = opts.transport }) + eng.saves = opts.saves or SyncEngine.defaultSaves() + eng.modDeps = opts.modDeps + eng.now = opts.now or os.time + eng.persist = opts.persist ~= false + eng.phase = "idle" + eng.error = nil + eng.conflicts = {} + eng.codes = nil + eng.modPlan = nil + eng.shareCode = nil + eng.clock = 0 + eng.queue = {} + eng.pending = nil + eng.uploadAt = nil + eng.client:setAuth(eng.state.account, eng.state.deviceToken) + eng.status = eng:defaultStatus() + return eng +end + +function SyncEngine.shared(opts) + if SyncEngine._shared == nil then + local ok, eng = pcall(SyncEngine.new, opts or {}) + SyncEngine._shared = (ok and type(eng) == "table") and eng or false + end + return SyncEngine._shared or nil +end + +function SyncEngine.forgetShared() + SyncEngine._shared = nil +end + +function SyncEngine:defaultStatus() + if not SyncState.linked(self.state) then return UNLINKED_STATUS end + return IDLE_STATUS +end + +function SyncEngine:linked() + return SyncState.linked(self.state) +end + +function SyncEngine:busy() + return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil +end + +function SyncEngine:_persist() + if not self.persist then return end + SyncState.save(self.state, self.fs) +end + +function SyncEngine:_fail(message) + self.phase = "error" + self.error = tostring(message or "sync failed") + self.status = "Sync failed: " .. self.error + self.queue = {} + self.pending = nil +end + +function SyncEngine:_finish() + if #self.conflicts > 0 then + self.phase = "conflict" + local overlap = false + for _, row in ipairs(self.conflicts) do + if row.overlap then overlap = true end + end + self.status = overlap + and "These saves were played at the same time." + or "This save also changed on another device." + return + end + self.phase = "idle" + self.error = nil + self.state.lastSyncAt = self.now() + self.status = self:defaultStatus() + self:_persist() +end + +function SyncEngine:_request(handle, err, onOk, onErr) + if not handle then + self:_fail(err or "could not start the request") + return false + end + self.pending = { handle = handle, onOk = onOk, onErr = onErr } + return true +end + +function SyncEngine:_enqueue(fn) + self.queue[#self.queue + 1] = fn +end + +function SyncEngine:cancel() + if self.pending then self.client:release(self.pending.handle) end + self.pending = nil + self.queue = {} + self.modApply = nil + self.uploadAt = nil + if self.phase ~= "conflict" then + self.phase = "idle" + self.status = self:defaultStatus() + end +end + +function SyncEngine:noteSaveWritten() + if not (self.state.enabled and self:linked()) then return end + self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE +end + +function SyncEngine:update(dt) + self.clock = self.clock + (tonumber(dt) or 0) + if self.pending then + local res = self.client:poll(self.pending.handle) + if res.status == "pending" then return end + local job = self.pending + self.pending = nil + self.client:release(job.handle) + if res.status == "ok" then + local ok, err = pcall(job.onOk, self, res) + if not ok then self:_fail(err) end + else + local handled = false + if job.onErr then + local ok, result = pcall(job.onErr, self, res) + if not ok then self:_fail(result) return end + handled = result == true + end + if not handled then self:_fail(res.err) end + end + end + if self.pending then return end + if self.modApply then + self:_stepModApply() + return + end + if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then + self.uploadAt = nil + if self.state.enabled and self:linked() then self:syncNow() end + end + local steps = 0 + while not self.pending and #self.queue > 0 + and steps < SyncEngine.MAX_STEPS_PER_UPDATE do + steps = steps + 1 + local task = table.remove(self.queue, 1) + local ok, err = pcall(task, self) + if not ok then self:_fail(err) return end + if not self.pending and #self.queue == 0 and self.phase ~= "error" then + self:_finish() + end + end +end + +function SyncEngine:createAccount(label) + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Creating a sync account..." + self.error = nil + local handle, err = self.client:create(label) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then + eng:_fail("the server sent an unexpected reply") + return + end + eng.codes = { + code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""), + code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""), + } + eng.state.account = data.account + eng.state.deviceToken = data.deviceToken + eng.state.deviceId = type(data.device) == "string" and data.device or nil + eng.state.deviceLabel = label + eng.state.enabled = true + eng.client:setAuth(data.account, data.deviceToken) + eng.phase = "idle" + eng.status = "Sync account created" + eng:_persist() + end) +end + +function SyncEngine:linkDevice(code1, code2, label) + if self:busy() then return false, "sync is busy" end + local a = SyncClient.normalizeCode(code1) + local b = SyncClient.normalizeCode(code2) + if not a or not b then + self:_fail("both codes are 8 digits") + return false, "both codes are 8 digits" + end + self.phase = "checking" + self.status = "Linking this device..." + self.error = nil + local handle, err = self.client:link(a, b, label) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then + eng:_fail("the server sent an unexpected reply") + return + end + eng.state.account = data.account + eng.state.deviceToken = data.deviceToken + eng.state.deviceId = type(data.device) == "string" and data.device or nil + eng.state.deviceLabel = label + eng.state.enabled = true + eng.client:setAuth(data.account, data.deviceToken) + eng.status = "This device is linked" + eng:_persist() + eng:syncNow() + end) +end + +function SyncEngine:_forgetLocal() + self.state = SyncState.defaults() + self.client:clearAuth() + self.codes = nil + self.conflicts = {} + self.devices = nil + self.phase = "idle" + self.status = UNLINKED_STATUS + self:_persist() +end + +function SyncEngine:unlink() + if not self:linked() then + self:_forgetLocal() + return true + end + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Unlinking this device..." + self.error = nil + local handle, err = self.client:unlink(self.state.deviceId) + return self:_request(handle, err, function(eng) + eng:_forgetLocal() + end, function(eng, res) + if res.code == 401 or res.code == 404 then + eng:_forgetLocal() + return true + end + return false + end) +end + +function SyncEngine:unlinkDevice(deviceId) + if type(deviceId) ~= "string" or deviceId == "" then + return false, "no such device" + end + if not self:linked() then return false, "this device is not linked" end + if deviceId == self.state.deviceId then return self:unlink() end + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Unlinking that device..." + self.error = nil + local handle, err = self.client:unlink(deviceId) + return self:_request(handle, err, function(eng) + eng.status = "That device was unlinked" + eng.phase = "idle" + eng:syncNow() + end) +end + +function SyncEngine:setEnabled(enabled) + self.state.enabled = enabled and true or false + self:_persist() + return self.state.enabled +end + +function SyncEngine:syncNow() + if not self:linked() then return false, "this device is not linked" end + if self.pending then return false, "sync is busy" end + self.queue = {} + self.conflicts = {} + self.state.pendingConflicts = {} + self.phase = "checking" + self.status = "Checking for changes..." + self.error = nil + local handle, err = self.client:fetchState() + return self:_request(handle, err, function(eng, res) + eng:_planFrom(res.data or {}) + end) +end + +function SyncEngine:_planFrom(remoteState) + self.devices = nil + if type(remoteState.devices) == "table" then + local list = {} + for _, row in ipairs(remoteState.devices) do + if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then + list[#list + 1] = { + id = row.id, + label = type(row.label) == "string" and row.label ~= "" and row.label + or "device", + createdAt = tonumber(row.createdAt), + current = row.current == true or row.id == self.state.deviceId, + } + end + end + self.devices = list + end + local remote = type(remoteState.saves) == "table" and remoteState.saves or {} + local locals = self.saves.list() or {} + local seen = {} + for _, entry in ipairs(locals) do + local key = SyncState.key(entry.version, entry.playthroughId) + if key then + seen[key] = true + local row = remote[key] + local knownRev = SyncState.rev(self.state, key) + local stamp = SyncState.stamp(self.state, key) + local localChanged = stamp == nil + or tonumber(entry.meta and entry.meta.savedAt) ~= stamp + local remoteRev = row and tonumber(row.rev) + local remoteChanged = row ~= nil and remoteRev ~= knownRev + if not row then + self:_queueUpload(entry, key, false) + elseif localChanged and remoteChanged then + self:_addConflict(entry, key, row) + elseif localChanged then + self:_queueUpload(entry, key, false) + elseif remoteChanged then + self:_queueDownload(key, entry.version, entry.playthroughId, "replace") + end + end + end + for key, row in pairs(remote) do + if not seen[key] then + local version, id = SyncState.splitKey(key) + if version and id then + self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) + end + end + end + if #self.queue == 0 then self:_finish() end +end + +function SyncEngine:_addConflict(entry, key, row) + local remoteMeta = row + if type(row.meta) == "table" then + remoteMeta = row.meta + elseif type(row.remoteMeta) == "table" then + remoteMeta = row.remoteMeta + end + self.conflicts[#self.conflicts + 1] = { + key = key, + version = entry.version, + playthroughId = entry.playthroughId, + slot = entry.slot, + entry = entry, + localMeta = entry.meta, + remoteMeta = remoteMeta, + remoteRev = tonumber(row.rev), + overlap = SyncEngine.overlaps(entry.meta, remoteMeta), + } + local pending = self.state.pendingConflicts or {} + self.state.pendingConflicts = pending + for _, row in ipairs(pending) do + if row.key == key then return end + end + pending[#pending + 1] = { + key = key, + version = entry.version, + playthroughId = entry.playthroughId, + overlap = SyncEngine.overlaps(entry.meta, remoteMeta), + } +end + +function SyncEngine:_queueUpload(entry, key, force) + self:_enqueue(function(eng) + eng.phase = "uploading" + eng.status = "Uploading saves..." + local handle, err = eng.client:putSave({ + version = entry.version, + slot = entry.slot, + meta = entry.meta, + blob = entry.blob, + baseRev = SyncState.rev(eng.state, key), + force = force, + }) + eng:_request(handle, err, function(e, res) + local data = res.data or {} + SyncState.setRev(e.state, key, tonumber(data.rev), + entry.meta and entry.meta.savedAt) + e:_persist() + if not e:busy() then e:_finish() end + end, function(e, res) + if res.code == 409 then + local row = res.data or {} + e:_addConflict(entry, key, row) + if not e:busy() then e:_finish() end + return true + end + return false + end) + end) +end + +function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) + self:_enqueue(function(eng) + eng.phase = "downloading" + eng.status = "Downloading saves..." + local handle, err = eng.client:getSave(version, playthroughId) + eng:_request(handle, err, function(e, res) + local data = res.data or {} + if type(data.blob) ~= "string" or data.blob == "" then + e:_fail("the server sent no save data") + return + end + local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode) + if not slotId then + e:_fail(writeErr or "could not write the downloaded save") + return + end + if mode ~= "new" then + local meta = type(data.meta) == "table" and data.meta or {} + SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev, + tonumber(meta.savedAt)) + end + e:_persist() + if not e:busy() then e:_finish() end + end) + end) +end + +function SyncEngine:resolveConflict(key, choice) + local index + for i, row in ipairs(self.conflicts) do + if row.key == key then index = i break end + end + if not index then return false, "no such conflict" end + local conflict = table.remove(self.conflicts, index) + local kept = {} + for _, row in ipairs(self.state.pendingConflicts or {}) do + if row.key ~= key then kept[#kept + 1] = row end + end + self.state.pendingConflicts = kept + + if choice == "local" then + SyncState.setRev(self.state, key, conflict.remoteRev, nil) + self:_queueUpload(conflict.entry, key, true) + elseif choice == "remote" then + self:_queueDownload(key, conflict.version, conflict.playthroughId, + "replace", conflict.remoteRev) + elseif choice == "both" then + self:_queueDownload(key, conflict.version, conflict.playthroughId, + "new", conflict.remoteRev) + SyncState.setRev(self.state, key, conflict.remoteRev, nil) + self:_queueUpload(conflict.entry, key, true) + else + return false, "unknown resolution" + end + self.phase = "uploading" + self.status = "Applying your choice..." + return true +end + +function SyncEngine:uploadMods() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + local manifest = SyncMods.build(self.modDeps) + self.phase = "uploading" + self.status = "Uploading the mod list..." + local handle, err = self.client:putMods(manifest) + return self:_request(handle, err, function(eng) + eng.phase = "idle" + eng.status = "Mod list synced" + end) +end + +function SyncEngine:fetchModPlan() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + self.phase = "downloading" + self.status = "Reading the mod list..." + local handle, err = self.client:getMods() + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + local manifest = type(data.manifest) == "table" and data.manifest or data + eng.modPlan = SyncMods.plan(manifest, eng.modDeps) + eng.phase = "idle" + eng.status = SyncMods.planEmpty(eng.modPlan) + and "Mods already match" or "Mod changes ready to apply" + end) +end + +function SyncEngine:shareMods() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + local manifest = SyncMods.build(self.modDeps) + self.phase = "uploading" + self.status = "Sharing the mod list..." + local handle, err = self.client:shareMods(manifest) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + eng.shareCode = type(data.code) == "string" and data.code or nil + eng.phase = "idle" + eng.status = eng.shareCode and ("Share code " .. eng.shareCode) + or "The server sent no share code" + end) +end + +function SyncEngine:fetchShare(code) + if self:busy() then return false, "sync is busy" end + self.phase = "downloading" + self.status = "Fetching that mod list..." + local handle, err = self.client:fetchShare(code) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + local manifest = type(data.manifest) == "table" and data.manifest or data + eng.modPlan = SyncMods.plan(manifest, eng.modDeps) + eng.phase = "idle" + eng.status = SyncMods.planEmpty(eng.modPlan) + and "Mods already match" or "Mod changes ready to apply" + end) +end + +function SyncEngine:applyModPlan(progress) + if not self.modPlan then return false, "no mod plan" end + if self.modApply then return false, "the mods are already being applied" end + local steps = SyncMods.steps(self.modPlan, self.modDeps) + if #steps == 0 then + self.modPlan = nil + self.status = "Mods already match" + if progress then progress(0, 0, nil, true) end + return true + end + self.modApply = { steps = steps, index = 0, failures = {}, + progress = progress } + self.phase = "applying" + self.status = ("Applying mods... 0 of %d"):format(#steps) + return true +end + +function SyncEngine:applyingMods() + return self.modApply ~= nil +end + +function SyncEngine:_stepModApply() + local job = self.modApply + local step = job.steps[job.index + 1] + job.index = job.index + 1 + local ok, res, why = pcall(step.run) + if not ok then + job.failures[#job.failures + 1] = tostring(res) + elseif not res then + job.failures[#job.failures + 1] = tostring(why or step.label) + end + local total = #job.steps + local done = job.index >= total + if not done then + self.status = ("Applying mods... %d of %d"):format(job.index, total) + if job.progress then + pcall(job.progress, job.index, total, step.label, false) + end + return + end + self.modApply = nil + self.modPlan = nil + self.phase = "idle" + if #job.failures > 0 then + self.status = "Some mods could not be applied: " + .. table.concat(job.failures, "; ") + else + self.status = "Mods applied" + end + if job.progress then + pcall(job.progress, job.index, total, step.label, true) + end +end + +return SyncEngine diff --git a/src/sync/SyncMods.lua b/src/sync/SyncMods.lua new file mode 100644 index 00000000..018c80c5 --- /dev/null +++ b/src/sync/SyncMods.lua @@ -0,0 +1,196 @@ +local SyncMods = {} + +SyncMods.REV = 1 + +local function versions() + local ok, GameVersion = pcall(require, "src.core.GameVersion") + if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end + return { "red", "blue", "yellow", "gold" } +end + +local function defaultDeps() + return { + installed = function() + return require("src.mods.LauncherMods").list() + end, + indexes = function() + return require("src.mods.ModIndex").sources() + end, + addIndex = function(url) + return require("src.mods.ModIndex").addSource(url) + end, + findEntry = function(id) + local ModIndex = require("src.mods.ModIndex") + for _, source in ipairs(ModIndex.sources()) do + local cached = ModIndex.readCache(source.feed) + for _, entry in ipairs((cached and cached.mods) or {}) do + if entry.id == id then return entry end + end + end + return nil + end, + install = function(entry) + return require("src.mods.LauncherMods").installFromIndex(entry) + end, + setEnabled = function(id, enabled, version) + return require("src.mods.LauncherMods").setEnabled(id, enabled, version) + end, + } +end + +local function deps(given) + local out = defaultDeps() + if type(given) == "table" then + for k, v in pairs(given) do out[k] = v end + end + return out +end + +local function sourceOf(row) + local github = row.github + or (type(row.manifest) == "table" and row.manifest.github) + if type(github) == "string" and github ~= "" then + return "github:" .. github + end + return "local" +end + +function SyncMods.build(given) + local d = deps(given) + local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } + for _, row in ipairs(d.indexes() or {}) do + local url = row.url or row.feed + if type(url) == "string" and url ~= "" then + manifest.indexes[#manifest.indexes + 1] = url + end + end + table.sort(manifest.indexes) + for _, row in ipairs(d.installed() or {}) do + if type(row) == "table" and type(row.id) == "string" then + local enabledFor = {} + local answers = row.enabledByVersion or {} + for _, version in ipairs(versions()) do + if answers[version] then enabledFor[#enabledFor + 1] = version end + end + manifest.mods[#manifest.mods + 1] = { + id = row.id, + version = row.version, + source = sourceOf(row), + enabledFor = enabledFor, + } + end + end + table.sort(manifest.mods, function(a, b) return a.id < b.id end) + return manifest +end + +function SyncMods.plan(manifest, given) + local d = deps(given) + local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} } + if type(manifest) ~= "table" then return plan end + + local haveIndex = {} + for _, row in ipairs(d.indexes() or {}) do + if type(row.url) == "string" then haveIndex[row.url] = true end + if type(row.feed) == "string" then haveIndex[row.feed] = true end + end + for _, url in ipairs(manifest.indexes or {}) do + if type(url) == "string" and url ~= "" and not haveIndex[url] then + plan.indexes[#plan.indexes + 1] = url + haveIndex[url] = true + end + end + + local installed = {} + for _, row in ipairs(d.installed() or {}) do + if type(row) == "table" and type(row.id) == "string" then + installed[row.id] = row + end + end + + for _, mod in ipairs(manifest.mods or {}) do + if type(mod) == "table" and type(mod.id) == "string" then + local here = installed[mod.id] + local available = here ~= nil + if not here then + local entry = d.findEntry(mod.id) + if entry then + available = true + plan.toInstall[#plan.toInstall + 1] = + { id = mod.id, version = mod.version, entry = entry } + else + plan.missing[#plan.missing + 1] = + { id = mod.id, version = mod.version, source = mod.source } + end + end + if available then + local answers = (here and here.enabledByVersion) or {} + for _, version in ipairs(mod.enabledFor or {}) do + if answers[version] ~= true then + plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version } + end + end + end + end + end + return plan +end + +function SyncMods.planEmpty(plan) + if type(plan) ~= "table" then return true end + return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0 + and #(plan.toEnable or {}) == 0 +end + +function SyncMods.steps(plan, given) + local d = deps(given) + local out = {} + if type(plan) ~= "table" then return out end + local broken = {} + + for _, url in ipairs(plan.indexes or {}) do + out[#out + 1] = { label = url, run = function() + local ok, err = d.addIndex(url) + if not ok then return nil, tostring(err or url) end + return true + end } + end + for _, mod in ipairs(plan.toInstall or {}) do + out[#out + 1] = { label = mod.id, run = function() + local ok, err = d.install(mod.entry) + if not ok then + broken[mod.id] = true + return nil, mod.id .. ": " .. tostring(err or "install failed") + end + return true + end } + end + for _, want in ipairs(plan.toEnable or {}) do + out[#out + 1] = { label = want.id, run = function() + if broken[want.id] then return true end + local ok, err = d.setEnabled(want.id, true, want.version) + if ok == false then + return nil, want.id .. ": " .. tostring(err or "could not enable") + end + return true + end } + end + return out +end + +function SyncMods.apply(plan, progress, given) + if type(plan) ~= "table" then return false, "nothing to apply" end + local steps = SyncMods.steps(plan, given) + local failures = {} + for i, step in ipairs(steps) do + local ok, err = step.run() + if not ok then failures[#failures + 1] = err end + if progress then progress(i, #steps, step.label) end + end + if #failures > 0 then + return false, table.concat(failures, "; ") + end + return true +end + +return SyncMods diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua new file mode 100644 index 00000000..53e4e840 --- /dev/null +++ b/src/sync/SyncState.lua @@ -0,0 +1,129 @@ +local SaveData = require("src.core.SaveData") + +local SyncState = {} + +SyncState.KEY = "saveSync" + +function SyncState.defaults() + return { + enabled = false, + lastSyncAt = 0, + revs = {}, + stamps = {}, + pendingConflicts = {}, + } +end + +local function str(v) + if type(v) == "string" and v ~= "" then return v end + return nil +end + +local function num(v) + local n = tonumber(v) + if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then + return nil + end + return n +end + +function SyncState.sanitize(raw) + local out = SyncState.defaults() + if type(raw) ~= "table" then return out end + out.enabled = raw.enabled == true + out.account = str(raw.account) + out.deviceToken = str(raw.deviceToken) + out.deviceId = str(raw.deviceId) + out.deviceLabel = str(raw.deviceLabel) + out.lastSyncAt = num(raw.lastSyncAt) or 0 + if type(raw.revs) == "table" then + for key, rev in pairs(raw.revs) do + local n = num(rev) + if type(key) == "string" and n then out.revs[key] = n end + end + end + if type(raw.stamps) == "table" then + for key, at in pairs(raw.stamps) do + local n = num(at) + if type(key) == "string" and n then out.stamps[key] = n end + end + end + if type(raw.pendingConflicts) == "table" then + for _, row in ipairs(raw.pendingConflicts) do + if type(row) == "table" and str(row.key) then + out.pendingConflicts[#out.pendingConflicts + 1] = { + key = row.key, + version = str(row.version), + playthroughId = str(row.playthroughId), + overlap = row.overlap == true, + } + end + end + end + return out +end + +function SyncState.load(fs) + local opts = SaveData.loadOptions(fs) + return SyncState.sanitize(opts and opts[SyncState.KEY]) +end + +function SyncState.save(state, fs) + local opts = SaveData.loadOptions(fs) + opts[SyncState.KEY] = SyncState.sanitize(state) + SaveData.saveOptions(opts, fs) + return opts[SyncState.KEY] +end + +function SyncState.update(fn, fs) + local state = SyncState.load(fs) + fn(state) + return SyncState.save(state, fs) +end + +function SyncState.clear(fs) + return SyncState.save(SyncState.defaults(), fs) +end + +function SyncState.linked(state) + return type(state) == "table" and str(state.account) ~= nil + and str(state.deviceToken) ~= nil +end + +function SyncState.key(version, playthroughId) + if type(version) ~= "string" or version == "" then return nil end + if type(playthroughId) ~= "string" or playthroughId == "" then return nil end + return version .. "/" .. playthroughId +end + +function SyncState.splitKey(key) + if type(key) ~= "string" then return nil end + local version, id = key:match("^([^/]+)/(.+)$") + return version, id +end + +function SyncState.rev(state, key) + if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end + return state.revs[key] +end + +function SyncState.stamp(state, key) + if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end + return state.stamps[key] +end + +function SyncState.setRev(state, key, rev, savedAt) + if type(state) ~= "table" or type(key) ~= "string" then return end + state.revs = state.revs or {} + state.stamps = state.stamps or {} + state.revs[key] = num(rev) + state.stamps[key] = num(savedAt) +end + +function SyncState.forget(state, key) + if type(state) ~= "table" or type(key) ~= "string" then return end + if type(state.revs) == "table" then state.revs[key] = nil end + if type(state.stamps) == "table" then state.stamps[key] = nil end +end + +return SyncState diff --git a/src/sync/SyncTransport.lua b/src/sync/SyncTransport.lua new file mode 100644 index 00000000..85c6b4b9 --- /dev/null +++ b/src/sync/SyncTransport.lua @@ -0,0 +1,41 @@ +local Transport = {} +Transport.__index = Transport + +function Transport.new(fetch) + return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport) +end + +function Transport:begin(req) + return self.fetch.request(req.url, { + method = req.method, + body = req.body, + headers = req.headers, + maxSeconds = req.maxSeconds, + }) +end + +function Transport:poll(handle) + local st = self.fetch.poll(handle) + if st.status == "pending" then return { status = "pending" } end + if st.status == "cancelled" then + return { status = "error", err = "sync request cancelled" } + end + if st.status ~= "ok" then + return { status = "error", err = st.err or "sync request failed" } + end + return { status = "ok", body = st.body or "", code = tonumber(st.code) } +end + +function Transport:release(handle) + if handle ~= nil and self.fetch.release then self.fetch.release(handle) end +end + +function Transport:cancel(handle) + if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end +end + +function Transport:available() + return self.fetch.available and self.fetch.available() or false +end + +return Transport diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 4233906f..2e624fa3 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -40,6 +40,51 @@ local HANDLES = { local GB_ASPECT = 160 / 144 +Studio.UNDO_CAP = 50 +Studio.SNAP_PX = 4 +Studio.STATUS_HOLD = 4 + +Studio.FORMAT_LABEL = { + native = "gen1recomp", + retroarch = "RetroArch", + delta = "Delta", +} + +Studio.EXPORTS = { + { id = "native", label = "gen1recomp .zip", + hint = "Reopens here and in the launcher." }, + { id = "retroarch", label = "RetroArch .zip", + hint = "An overlay .cfg RetroArch can load." }, + { id = "delta", label = "Delta .deltaskin", + hint = "info.json plus art, for Delta and Ignited." }, +} + +Studio.BIND_GROUPS = { + { title = "GAME BOY", + specs = { "a", "b", "start", "select", "up", "down", "left", "right" } }, + { title = "DIAGONALS", + specs = { "left|up", "right|up", "left|down", "right|down" } }, + { title = "HOTKEYS", + specs = { "menu_toggle", "reset", "hold_fast_forward", + "toggle_fast_forward", "overlay_next", "overlay_previous", + "pause_toggle", "screenshot", "exit_emulator" } }, + { title = "KEYBOARD", + specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + { title = "NO INPUT", specs = { "nul" } }, +} + +Studio.BIND_PARTS = { "left", "right", "up", "down", "a", "b", "start", "select" } + +local PART_RANK = { + left = 1, right = 2, up = 3, down = 4, + a = 5, b = 6, start = 7, select = 8, +} + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + local function clamp01(v) if v ~= v then return 0 end if v < 0 then return 0 end @@ -65,9 +110,136 @@ function Studio.selectedControl() return page.controls[Studio.selected] end +local function setStatus(text, isError) + Studio.status = text + Studio.statusErr = isError == true + Studio.statusAt = now() +end +Studio.setStatus = setStatus + +function Studio.expireStatus() + if not Studio.statusErr or not Studio.status then return end + if now() - (Studio.statusAt or 0) > Studio.STATUS_HOLD then + Studio.status, Studio.statusErr = nil, false + end +end + local function markDirty() Studio.dirty = true - Studio.status = nil + if not Studio.statusErr then Studio.status = nil end +end + +local function snapshot() + return { + skin = TouchSkin.clone(Studio.skin), + pageIndex = Studio.pageIndex, + selected = Studio.selected, + idField = Studio.skinIdField, + } +end + +local function restore(snap) + Studio.skin = snap.skin + Studio.pageIndex = snap.pageIndex or 1 + Studio.selected = snap.selected + Studio.skinIdField = snap.idField or Studio.skinIdField + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex + Studio.dirty = true +end + +function Studio.pushUndo(tag) + if not Studio.skin then return false end + Studio.undoStack = Studio.undoStack or {} + Studio.redoStack = {} + if tag and Studio.undoTag == tag and now() - (Studio.undoAt or 0) < 1 then + return false + end + Studio.undoTag, Studio.undoAt = tag, now() + local stack = Studio.undoStack + stack[#stack + 1] = snapshot() + while #stack > Studio.UNDO_CAP do table.remove(stack, 1) end + return true +end + +function Studio.undo() + local stack = Studio.undoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to undo.") + return false + end + table.remove(stack) + Studio.redoStack = Studio.redoStack or {} + Studio.redoStack[#Studio.redoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Undo") + return true +end + +function Studio.redo() + local stack = Studio.redoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to redo.") + return false + end + table.remove(stack) + Studio.undoStack = Studio.undoStack or {} + Studio.undoStack[#Studio.undoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Redo") + return true +end + +function Studio.canUndo() return #(Studio.undoStack or {}) > 0 end +function Studio.canRedo() return #(Studio.redoStack or {}) > 0 end + +function Studio.openModal(kind, data) + data = data or {} + data.kind = kind + data.scroll = 0 + Studio.modal = data + return data +end + +function Studio.closeModal() + Studio.modal = nil + Kit.blur() +end + +function Studio.modalUp() + return Studio.modal ~= nil or Studio.confirm ~= nil +end + +function Studio.ask(text, onYes, yesLabel) + Studio.confirm = { text = text, onYes = onYes, + yesLabel = yesLabel or "Discard" } + return Studio.confirm +end + +function Studio.confirmYes() + local c = Studio.confirm + Studio.confirm = nil + if c and c.onYes then c.onYes() end + return c ~= nil +end + +function Studio.confirmNo() + local had = Studio.confirm ~= nil + Studio.confirm = nil + return had +end + +function Studio.guard(text, fn) + if not Studio.dirty or not Studio.skin then + fn() + return true + end + Studio.ask(text, fn) + return false end local function syncActive() @@ -193,7 +365,15 @@ function Studio.load(opts) Studio.aspectLock = true Studio.skinIdField = "" Studio.available = TouchSkin.list() + Studio.availableMeta = {} Studio.imageTarget = "idle" + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.showLabels = true + Studio.statusErr = false + Studio.thumbs = {} TouchControls:init() TouchControls.active = true @@ -230,11 +410,91 @@ function Studio.open(id) Studio.selected = nil Studio.dirty = false Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag = nil + Studio.thumbs = {} syncActive() Studio.applyImportedOrient() return true end +function Studio.newSkin() + Studio.pushUndo() + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = "new_skin" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + Studio.thumbs = {} + syncActive() + markDirty() +end + +function Studio.skinFormat(skin) + skin = skin or Studio.skin + local fmt = skin and skin.format or "native" + return Studio.FORMAT_LABEL[fmt] or fmt +end + +function Studio.describeSkin(entry) + Studio.availableMeta = Studio.availableMeta or {} + local meta = Studio.availableMeta[entry.id] + if meta then return meta end + local skin = TouchSkin.load(entry.root, entry.id) + local page = skin and skin.pages[1] + local controls = 0 + for _, ctl in ipairs(page and page.controls or {}) do + if not ctl.decorative then controls = controls + 1 end + end + meta = { + id = entry.id, + source = entry.source, + format = Studio.skinFormat(skin), + pages = skin and #skin.pages or 0, + controls = controls, + ok = skin ~= nil, + } + Studio.availableMeta[entry.id] = meta + return meta +end + +function Studio.skinSummary(entry) + local meta = Studio.describeSkin(entry) + local bits = { meta.source == "user" and "installed" or "bundled", meta.format } + bits[#bits + 1] = meta.controls .. " buttons" + if meta.pages > 1 then bits[#bits + 1] = meta.pages .. " pages" end + return table.concat(bits, " \194\183 ") +end + +function Studio.refreshAvailable() + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + return Studio.available +end + +function Studio.openLoadPicker() + return Studio.guard("Open another skin and lose the unsaved changes?", + function() + Studio.refreshAvailable() + Studio.openModal("open") + end) +end + +function Studio.loadEntry(id) + Studio.closeModal() + if not Studio.open(id) then + setStatus("Could not open " .. tostring(id), true) + return false + end + local warning = Studio.skin and Studio.skin.warnings + and Studio.skin.warnings[1] + if warning then + setStatus("Opened " .. id .. ": " .. tostring(warning), true) + else + setStatus("Opened " .. id) + end + return true +end + function Studio.unload() Studio.pendingPlay = false TouchSkin.setSurface(nil) @@ -246,6 +506,9 @@ function Studio.unload() Studio.onClose = nil Studio.onPlay = nil Studio.drag = nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.undoStack, Studio.redoStack = {}, {} end -- --------------------------------------------------------------- editing @@ -253,6 +516,7 @@ end function Studio.addControl() local page = Studio.page() if not page then return end + Studio.pushUndo() page.controls[#page.controls + 1] = TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial") Studio.selected = #page.controls @@ -262,6 +526,7 @@ end function Studio.deleteControl() local page = Studio.page() if not page or not Studio.selected then return end + Studio.pushUndo() table.remove(page.controls, Studio.selected) Studio.selected = page.controls[Studio.selected] and Studio.selected or (#page.controls > 0 and #page.controls or nil) @@ -271,6 +536,7 @@ end function Studio.duplicateControl() local page, ctl = Studio.page(), Studio.selectedControl() if not page or not ctl then return end + Studio.pushUndo() local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04), clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) copy.imagePath, copy.image = ctl.imagePath, ctl.image @@ -288,10 +554,108 @@ function Studio.cycleBind(dir) for i, spec in ipairs(list) do if spec == ctl.spec then at = i break end end + Studio.pushUndo("bind") TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1]) markDirty() end +function Studio.bindParts(spec) + local out = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = raw:gsub("^%s+", ""):gsub("%s+$", ""):lower() + if name ~= "" and name ~= "nul" then out[#out + 1] = name end + end + return out +end + +function Studio.hasBindPart(spec, part) + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then return true end + end + return false +end + +function Studio.toggleBindPart(spec, part) + local kept, found = {}, false + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then + found = true + else + kept[#kept + 1] = name + end + end + if not found then kept[#kept + 1] = part end + if #kept == 0 then return "nul" end + table.sort(kept, function(a, b) + local ra, rb = PART_RANK[a] or 9, PART_RANK[b] or 9 + if ra ~= rb then return ra < rb end + return a < b + end) + return table.concat(kept, "|") +end + +function Studio.setBindSpec(spec) + local ctl = Studio.selectedControl() + if not ctl or not spec then return false end + Studio.pushUndo() + TouchSkin.setBind(ctl, spec) + markDirty() + return true +end + +function Studio.toggleSelectedBindPart(part) + local ctl = Studio.selectedControl() + if not ctl then return false end + return Studio.setBindSpec(Studio.toggleBindPart(ctl.spec, part)) +end + +function Studio.openBindPicker() + if not Studio.selectedControl() then + setStatus("Select a control first.", true) + return false + end + Studio.openModal("bind") + return true +end + +function Studio.moveControlOrder(delta) + local page, i = Studio.page(), Studio.selected + if not page or not i then return false end + local j = i + delta + if j < 1 or j > #page.controls then return false end + Studio.pushUndo() + page.controls[i], page.controls[j] = page.controls[j], page.controls[i] + Studio.selected = j + markDirty() + return true +end + +function Studio.nudge(dx, dy, big) + local ctl = Studio.selectedControl() + if not ctl then return false end + local canvas = Studio.canvas() + local step = big and 10 or 1 + Studio.pushUndo("nudge") + ctl.x = clamp01(ctl.x + dx * step / canvas.w) + ctl.y = clamp01(ctl.y + dy * step / canvas.h) + markDirty() + return true +end + +function Studio.snapOffset(edges, lines, tol) + local best, at = nil, nil + for _, e in ipairs(edges) do + for _, l in ipairs(lines) do + local d = l - e + if math.abs(d) <= tol and (best == nil or math.abs(d) < math.abs(best)) then + best, at = d, l + end + end + end + return best or 0, at +end + + function Studio.cycleImage(dir) local ctl = Studio.selectedControl() local page = Studio.page() @@ -318,6 +682,56 @@ function Studio.cycleImage(dir) markDirty() end +function Studio.refreshImages() + Studio.images = Studio.skin and TouchSkin.listImages(Studio.skin.root) or {} + Studio.thumbs = {} + return Studio.images +end + +function Studio.openImagePicker(target) + if not Studio.skin then return false end + target = target or Studio.imageTarget + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or pick a bezel image.", true) + return false + end + Studio.imageTarget = target + Studio.refreshImages() + Studio.openModal("image") + return true +end + +function Studio.currentImagePath() + local target = Studio.imageTarget + if target == "bezel" then + local page = Studio.page() + return page and page.imagePath + end + local ctl = Studio.selectedControl() + if not ctl then return nil end + return (target == "pressed") and ctl.pressedImagePath or ctl.imagePath +end + +function Studio.thumb(rel) + if not rel or not Studio.skin then return nil end + Studio.thumbs = Studio.thumbs or {} + local cached = Studio.thumbs[rel] + if cached ~= nil then return cached or nil end + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + Studio.thumbs[rel] = img or false + return img +end + +function Studio.chooseImage(rel) + if not Studio.skin then return false end + Studio.pushUndo() + Studio.assignImage(rel) + Studio.closeModal() + setStatus(rel and ("Using " .. rel .. " as " .. Studio.imageTargetLabel()) + or ("Cleared the " .. Studio.imageTargetLabel())) + return true +end + function Studio.imageTargetLabel() local target = Studio.imageTarget if target == "bezel" or not Studio.selectedControl() then return "bezel" end @@ -352,23 +766,24 @@ function Studio.adoptImage(name, data, target) if not Studio.skin then return false end if target then Studio.imageTarget = target end if not FilePicker.matches(name, FilePicker.IMAGE) then - Studio.status = "Pick a PNG or JPG." + setStatus("Pick a PNG or JPG.", true) return false end commitSkinId() local rel, err = TouchSkin.importImage(Studio.skin, name, data) if not rel then - Studio.status = "Import failed: " .. tostring(err) + setStatus("Import failed: " .. tostring(err), true) return false end local where = Studio.imageTargetLabel() + Studio.pushUndo() Studio.assignImage(rel) Studio.skinIdField = Studio.skin.id - Studio.status = "Imported " .. rel .. " as " .. where + local text = "Imported " .. rel .. " as " .. where if where == "bezel" and not Studio.canvas().lockViewport then - Studio.status = Studio.status - .. " -- use Detect screen from bezel to place the screen" + text = text .. " -- use Detect screen from bezel to place the screen" end + setStatus(text) return true end @@ -377,11 +792,11 @@ function Studio.importImageFile(target) target = target or Studio.imageTarget Studio.imageTarget = target if target ~= "bezel" and not Studio.selectedControl() then - Studio.status = "Select a control first, or import a bezel image." + setStatus("Select a control first, or import a bezel image.", true) return end if not FilePicker.available() then - Studio.status = "No file picker here -- drag a PNG onto the window instead." + setStatus("No file picker here -- drag a PNG onto the window instead.", true) return end local prompt = (target == "bezel") and "Choose a bezel image" @@ -391,7 +806,7 @@ function Studio.importImageFile(target) local base = FilePicker.basename(path) local data, err = FilePicker.read(path) if not data then - Studio.status = "Could not read " .. base .. ": " .. tostring(err) + setStatus("Could not read " .. base .. ": " .. tostring(err), true) return end Studio.adoptImage(base, data, target) @@ -402,7 +817,7 @@ function Studio.filedropped(file) local path = (file.getFilename and file:getFilename()) or "" local base = FilePicker.basename(path) if not FilePicker.matches(base, FilePicker.IMAGE) then - Studio.status = "Drop a PNG or JPG to use it as art." + setStatus("Drop a PNG or JPG to use it as art.", true) return end local ok, data = pcall(function() @@ -412,7 +827,7 @@ function Studio.filedropped(file) return bytes end) if not ok or not data then - Studio.status = "Could not read " .. base + setStatus("Could not read " .. base, true) return end Studio.adoptImage(base, data) @@ -422,20 +837,21 @@ function Studio.detectViewport() local page = Studio.page() if not page or not Studio.skin then return end if Studio.canvas().lockViewport then - Studio.status = "This preset locks the screen position." + setStatus("This preset locks the screen position.", true) return end if not page.imagePath then - Studio.status = "Pick a bezel image first." + setStatus("Pick a bezel image first.", true) return end local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath) if not rect then - Studio.status = "No transparent screen hole found in " .. page.imagePath + setStatus("No transparent screen hole found in " .. page.imagePath, true) return end + Studio.pushUndo() page.viewport = rect - Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph) + setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) Studio.dirty = true end @@ -443,6 +859,7 @@ function Studio.toggleViewport() local page = Studio.page() if not page then return end if Studio.canvas().lockViewport then return end + Studio.pushUndo() if page.viewport then page.viewport = nil else @@ -451,9 +868,80 @@ function Studio.toggleViewport() markDirty() end +function Studio.setPage(index) + local skin = Studio.skin + if not skin or not skin.pages[index] then return false end + Studio.pageIndex = index + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + return true +end + +function Studio.nextPage() + local skin = Studio.skin + if not skin or #skin.pages == 0 then return false end + return Studio.setPage((Studio.pageIndex % #skin.pages) + 1) +end + +function Studio.pageLabel(index) + local skin = Studio.skin + local page = skin and skin.pages[index] + if not page then return "" end + local name = tostring(page.name or ("page" .. index)) + local orient = TouchSkin.pageOrient(page) + local bits = { #(page.controls or {}) .. " controls" } + if orient then bits[#bits + 1] = orient end + if page.viewport then bits[#bits + 1] = "screen" end + return name, table.concat(bits, " \194\183 ") +end + +function Studio.openPageMenu() + local skin = Studio.skin + if not skin then return false end + Studio.pageNameField = tostring(Studio.page() and Studio.page().name or "") + Studio.openModal("page") + return true +end + +function Studio.renamePage(name) + local page = Studio.page() + if not page then return false end + name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "") + if name == "" then + setStatus("Give the page a name first.", true) + return false + end + Studio.pushUndo() + page.name = name + markDirty() + setStatus("Renamed page " .. Studio.pageIndex .. " to " .. name) + return true +end + +function Studio.deletePage(index) + local skin = Studio.skin + index = index or Studio.pageIndex + if not skin or not skin.pages[index] then return false end + if #skin.pages <= 1 then + setStatus("A skin needs at least one page.", true) + return false + end + Studio.pushUndo() + table.remove(skin.pages, index) + for i, page in ipairs(skin.pages) do page.index = i end + Studio.pageIndex = math.min(Studio.pageIndex, #skin.pages) + Studio.selected = nil + syncActive() + markDirty() + setStatus("Deleted a page") + return true +end + function Studio.addPage() local skin = Studio.skin if not skin then return end + Studio.pushUndo() local page = TouchSkin.newSkin(skin.id).pages[1] page.name = "page" .. (#skin.pages + 1) page.index = #skin.pages + 1 @@ -470,33 +958,84 @@ function Studio.save() if not skin then return end local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") if id == "" then - Studio.status = "Give the skin a name first." + setStatus("Give the skin a name first.", true) return end local dest, failed = TouchSkin.saveTo(skin, id) if not dest then - Studio.status = "Save failed: " .. tostring(failed) + setStatus("Save failed: " .. tostring(failed), true) return end Studio.dirty = false - Studio.available = TouchSkin.list() - Studio.status = "Saved to " .. dest + Studio.refreshAvailable() + local text = "Saved to " .. dest if type(failed) == "table" and failed[1] then - Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)" + text = text .. " (" .. #failed .. " image(s) not found)" end + setStatus(text) +end + +function Studio.exportAs(kind) + local skin = Studio.skin + if not skin then return nil end + if Studio.dirty then Studio.save() end + if Studio.dirty then return nil end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + local text = "Exported " .. path + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) not found)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + setStatus(text) + Studio.lastExport = path + Studio.refreshAvailable() + return path end function Studio.export() - local skin = Studio.skin - if not skin then return end - if Studio.dirty then Studio.save() end - local path, missing = TouchSkin.export(skin) - if not path then - Studio.status = "Export failed: " .. tostring(missing) - return + return Studio.exportAs("native") +end + +function Studio.openExportMenu() + Studio.openModal("export") + return true +end + +function Studio.fileUrl(path) + path = tostring(path):gsub("\\", "/") + if path:sub(1, 1) ~= "/" then path = "/" .. path end + local encoded = path:gsub("[^%w%-%._~/:]", function(c) + return string.format("%%%02X", string.byte(c)) + end) + return "file://" .. encoded +end + +function Studio.revealExport() + local path = Studio.lastExport + if not path then return false end + if not (love and love.filesystem and love.filesystem.getSaveDirectory) then + return false end - Studio.status = "Exported " .. path - Studio.available = TouchSkin.list() + local dir = love.filesystem.getSaveDirectory() + if not dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, Studio.fileUrl(dir)) + end + setStatus("Saved in " .. dir .. "/" .. path) + return true end function Studio.play() @@ -514,7 +1053,7 @@ function Studio.play() -- leave the rest of this frame drawing against a dead skin; Studio.update -- runs it on the next tick instead. Studio.pendingPlay = true - Studio.status = "Starting the game with " .. skin.id .. "..." + setStatus("Starting the game with " .. skin.id .. "...") end -- ---------------------------------------------------------------- canvas @@ -533,6 +1072,25 @@ local function controlRect(page, ctl, r) return cx - halfW, cy - halfH, halfW * 2, halfH * 2 end +function Studio.snapLines(page, r, skipIndex) + local xs, ys = {}, {} + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + xs[1], xs[2], xs[3] = px, px + pw * 0.5, px + pw + ys[1], ys[2], ys[3] = py, py + ph * 0.5, py + ph + for i, ctl in ipairs(page.controls or {}) do + if i ~= skipIndex then + local bx, by, bw, bh = controlRect(page, ctl, r) + xs[#xs + 1] = bx + xs[#xs + 1] = bx + bw * 0.5 + xs[#xs + 1] = bx + bw + ys[#ys + 1] = by + ys[#ys + 1] = by + bh * 0.5 + ys[#ys + 1] = by + bh + end + end + return xs, ys +end + local function viewportRect(page, r) local v = page.viewport if not v then return nil end @@ -564,12 +1122,14 @@ end function Studio.beginCanvasDrag(mx, my, r) local page = Studio.page() if not page then return end + Studio.guides = nil local ctl = Studio.selectedControl() if ctl then local bx, by, bw, bh = controlRect(page, ctl, r) for _, h in ipairs(handleRects(bx, by, bw, bh)) do if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my, bx = bx, by = by, bw = bw, bh = bh } return @@ -581,6 +1141,7 @@ function Studio.beginCanvasDrag(mx, my, r) if vx and not Studio.canvas().lockViewport then for _, h in ipairs(handleRects(vx, vy, vw, vh)) do if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my, bx = vx, by = vy, bw = vw, bh = vh } Studio.selected = nil @@ -594,6 +1155,7 @@ function Studio.beginCanvasDrag(mx, my, r) local bx, by, bw, bh = controlRect(page, c, r) if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then Studio.selected = i + Studio.pushUndo() Studio.drag = { kind = "control-move", mx = mx, my = my, bx = bx, by = by, bw = bw, bh = bh } return @@ -603,6 +1165,7 @@ function Studio.beginCanvasDrag(mx, my, r) if vx and not Studio.canvas().lockViewport and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then Studio.selected = nil + Studio.pushUndo() Studio.drag = { kind = "viewport-move", mx = mx, my = my, bx = vx, by = vy, bw = vw, bh = vh } return @@ -645,6 +1208,15 @@ function Studio.updateDrag(mx, my, r) end end + if d.kind == "control-move" then + local tol = Studio.SNAP_PX * Kit.scale + local xs, ys = Studio.snapLines(page, r, Studio.selected) + local offX, lineX = Studio.snapOffset({ bx, bx + bw * 0.5, bx + bw }, xs, tol) + local offY, lineY = Studio.snapOffset({ by, by + bh * 0.5, by + bh }, ys, tol) + bx, by = bx + offX, by + offY + Studio.guides = { x = lineX, y = lineY } + end + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) if pw <= 0 or ph <= 0 then return end if d.kind:find("control") then @@ -712,6 +1284,11 @@ local function drawCanvas(x, y, w, h) local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line) Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45, selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2) + if Studio.showLabels and bw > 8 * Kit.scale then + Kit.textCenter("micro", Kit.ellipsize("micro", ctl.spec, bw), bx, + by + bh * 0.5 - Kit.textHeight("micro") * 0.5, bw, + selected and PAL.green or PAL.muted) + end if selected then for _, hd in ipairs(handleRects(bx, by, bw, bh)) do Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1) @@ -720,6 +1297,16 @@ local function drawCanvas(x, y, w, h) PAL.green) end end + + local guides = Studio.drag and Studio.guides + if guides then + if guides.x then + Theme.fill(guides.x, r.y, math.max(1, Kit.scale), r.h, PAL.yellow, 0.6) + end + if guides.y then + Theme.fill(r.x, guides.y, r.w, math.max(1, Kit.scale), PAL.yellow, 0.6) + end + end return r end @@ -739,40 +1326,37 @@ local function inspectorBody(x, y, w) local third = (w - gap * 2) / 3 if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end - if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then - Studio.export() + if Kit.button(x + third + gap, cy, third, rowH, "Export \226\150\184", + { id = "export" }) then + Studio.openExportMenu() end if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then Studio.play() end - cy = cy + rowH + gap * 2 + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", "Format: " .. Studio.skinFormat(), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap * 2 Kit.caption(x, cy, "OPEN") cy = cy + Kit.textHeight("small") + gap local half = (w - gap) / 2 if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then - Studio.skin = TouchSkin.newSkin("new_skin") - Studio.skinIdField = "new_skin" - Studio.pageIndex, Studio.selected = 1, nil - Studio.images = {} - syncActive() - markDirty() + Studio.guard("Start a new skin and lose the unsaved changes?", + Studio.newSkin) end if Kit.button(x + half + gap, cy, half, rowH, "Load " .. (#Studio.available > 0 and "\226\150\184" or "-"), { id = "load", enabled = #Studio.available > 0 }) then - Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1 - Studio.open(Studio.available[Studio.loadIndex].id) + Studio.openLoadPicker() end cy = cy + rowH + gap * 2 Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {})) cy = cy + Kit.textHeight("small") + gap - if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then - Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1 - Studio.selected = nil - syncActive() - Studio.syncCanvasToPage() + if Kit.button(x, cy, half, rowH, + "Pages \226\150\184 " .. tostring(page and page.name or "-"), + { id = "pagelist" }) then + Studio.openPageMenu() end if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then Studio.addPage() @@ -794,8 +1378,7 @@ local function inspectorBody(x, y, w) local pickW = 82 * Kit.scale local cycleW = w - pickW - gap if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then - Studio.imageTarget = "bezel" - Studio.cycleImage(1) + Studio.openImagePicker("bezel") end if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", { id = "bezelpick" }) then @@ -838,19 +1421,36 @@ local function inspectorBody(x, y, w) end local canvas = Studio.canvas() + local zW = 68 * Kit.scale + if Kit.button(x, cy, zW, rowH, "Back", { id = "zback", + enabled = (Studio.selected or 1) > 1 }) then + Studio.moveControlOrder(-1) + end + if Kit.button(x + zW + gap, cy, zW, rowH, "Front", { id = "zfront", + enabled = Studio.selected ~= nil and page ~= nil + and Studio.selected < #page.controls }) then + Studio.moveControlOrder(1) + end + Kit.text("small", ("%d / %d"):format(Studio.selected or 0, + page and #page.controls or 0), x + (zW + gap) * 2, + cy + (rowH - Kit.textHeight("small")) * 0.5, PAL.faint) + cy = cy + rowH + gap + if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then - Studio.cycleBind(1) + Studio.openBindPicker() end cy = cy + rowH + 2 * Kit.scale Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted) cy = cy + Kit.textHeight("small") + gap if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then + Studio.pushUndo() ctl.shape = ctl.shape == "radial" and "rect" or "radial" markDirty() end if Kit.button(x + half + gap, cy, half, rowH, string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then + Studio.pushUndo() ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25) markDirty() end @@ -885,8 +1485,7 @@ local function inspectorBody(x, y, w) local artW = w - pickW - gap local idle = ctl.imagePath or "(none)" if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then - Studio.imageTarget = "idle" - Studio.cycleImage(1) + Studio.openImagePicker("idle") end if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then Studio.importImageFile("idle") @@ -894,8 +1493,7 @@ local function inspectorBody(x, y, w) cy = cy + rowH + gap local pressed = ctl.pressedImagePath or "(none)" if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then - Studio.imageTarget = "pressed" - Studio.cycleImage(1) + Studio.openImagePicker("pressed") end if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then Studio.importImageFile("pressed") @@ -934,6 +1532,7 @@ function Studio.commitField(id, text) if not ctl then return end local n = tonumber(text) if not n then return end + Studio.pushUndo("field") local canvas = Studio.canvas() local left = (ctl.x - ctl.rangeX) * canvas.w local top = (ctl.y - ctl.rangeY) * canvas.h @@ -950,6 +1549,307 @@ function Studio.commitField(id, text) markDirty() end +local function modalFrame(W, H, title, wFrac, hFrac) + Theme.fill(0, 0, W, H, PAL.bg, 0.85) + local pad = 14 * Kit.scale + local mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W)) + local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H)) + local mx = math.floor((W - mw) * 0.5) + local my = math.floor((H - mh) * 0.5) + Kit.card(mx, my, mw, mh) + Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) + return mx, my, mw, mh, pad +end + +local function modalFooter(mx, my, mw, mh, pad) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + if Kit.button(mx + mw - pad - 110 * Kit.scale, by, 110 * Kit.scale, bh, + "Close", { id = "modal-close" }) then + Studio.closeModal() + end + return by, bh +end + +local function modalScroll(modal, x, y, w, h, contentH) + local maxScroll = Kit.scrollExtent(contentH, h) + modal.scroll = select(1, Kit.scrollWheel(modal.scroll or 0, maxScroll, + x, y, w, h)) + modal.scroll = Kit.scrollClamp(modal.scroll, maxScroll) + return modal.scroll, maxScroll +end + +local function drawBindModal(W, H) + local modal = Studio.modal + local ctl = Studio.selectedControl() + local mx, my, mw, mh, pad = modalFrame(W, H, "Choose a bind", 0.66, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cols = math.max(2, math.floor((mw - pad * 2) / (150 * Kit.scale))) + local colW = (mw - pad * 2 - gap * (cols - 1)) / cols + + local contentH = modal.contentH or viewH + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local cy = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + local startY = cy + + Kit.caption(mx + pad, cy, "COMBINE") + cy = cy + Kit.textHeight("small") + gap + for i, part in ipairs(Studio.BIND_PARTS) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local on = ctl and Studio.hasBindPart(ctl.spec, part) + if Kit.chip(px, py, colW, rowH, part, on, on and PAL.green or nil, + "bindpart-" .. part) then + Studio.toggleSelectedBindPart(part) + end + end + cy = cy + math.ceil(#Studio.BIND_PARTS / cols) * (rowH + gap) + gap + + for _, group in ipairs(Studio.BIND_GROUPS) do + Kit.caption(mx + pad, cy, group.title) + cy = cy + Kit.textHeight("small") + gap + for i, spec in ipairs(group.specs) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local active = ctl and ctl.spec == spec + if Kit.button(px, py, colW, rowH, spec, + { id = "bind-" .. spec, kind = active and "accent" or nil, + font = "small" }) then + Studio.setBindSpec(spec) + Studio.closeModal() + end + end + cy = cy + math.ceil(#group.specs / cols) * (rowH + gap) + gap + end + modal.contentH = cy - startY + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + + if ctl then + Kit.text("small", Kit.ellipsize("small", + "Bind: " .. ctl.spec .. " -> " .. TouchSkin.describeBind(ctl.spec), + mw - pad * 2 - 120 * Kit.scale), mx + pad, by + Kit.scale * 8, PAL.muted) + end +end + +local function drawImageModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, + "Choose art for the " .. Studio.imageTargetLabel(), 0.7, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Import a file...", + { id = "modal-import", kind = "accent", + enabled = FilePicker.available() }) then + local target = Studio.imageTarget + Studio.closeModal() + Studio.importImageFile(target) + end + + local viewH = by - top - pad + local gap = 8 * Kit.scale + local tile = math.max(72 * Kit.scale, 96 * Kit.scale) + local cols = math.max(2, math.floor((mw - pad * 2) / (tile + gap))) + local tileW = (mw - pad * 2 - gap * (cols - 1)) / cols + local tileH = tileW * 0.75 + Kit.textHeight("micro") + 6 * Kit.scale + + local list = { false } + for _, rel in ipairs(Studio.images or {}) do list[#list + 1] = rel end + local rows = math.ceil(#list / cols) + local contentH = rows * (tileH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local current = Studio.currentImagePath() + + for i, rel in ipairs(list) do + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local px = mx + pad + col * (tileW + gap) + local py = baseY + row * (tileH + gap) + local selected = (rel or nil) == current + local clicked = Kit.row(px, py, tileW, tileH, selected, "imgtile-" .. i) + local art = rel and Studio.thumb(rel) or nil + local artH = tileH - Kit.textHeight("micro") - 6 * Kit.scale + if art and art.getWidth then + local iw, ih = art:getWidth(), art:getHeight() + if iw > 0 and ih > 0 then + local scale = math.min((tileW - 8 * Kit.scale) / iw, + (artH - 8 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (tileW - iw * scale) * 0.5, + py + (artH - ih * scale) * 0.5, 0, scale, scale) + end + elseif not rel then + Kit.textCenter("small", "(none)", px, + py + artH * 0.5 - Kit.textHeight("small") * 0.5, tileW, PAL.muted) + end + Kit.textCenter("micro", Kit.ellipsize("micro", rel or "no art", tileW), + px, py + tileH - Kit.textHeight("micro") - 2 * Kit.scale, tileW, + selected and PAL.green or PAL.detail) + if clicked then Studio.chooseImage(rel or nil) end + end + if #list == 1 then + Kit.text("small", "No images in this skin yet. Import one.", mx + pad, + baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawOpenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Open a skin", 0.62, 0.76) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) + local gap = 4 * Kit.scale + local list = Studio.available or {} + local contentH = #list * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + + for i, entry in ipairs(list) do + local py = baseY + (i - 1) * (rowH + gap) + local selected = Studio.skin and Studio.skin.id == entry.id + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "open-" .. entry.id) + Kit.text("mono", Kit.ellipsize("mono", entry.id, mw - pad * 4), + mx + pad * 2, py + 6 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", Studio.skinSummary(entry), + mw - pad * 4), mx + pad * 2, py + 6 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + if clicked then Studio.loadEntry(entry.id) end + end + if #list == 0 then + Kit.text("small", "No skins installed yet.", mx + pad, baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawPageModal(W, H) + local modal = Studio.modal + local skin = Studio.skin + local mx, my, mw, mh, pad = modalFrame(W, H, "Pages", 0.58, 0.72) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5) + Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh, + Studio.pageNameField or "", "page name") + if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename", + { id = "page-rename" }) then + Studio.renamePage(Studio.pageNameField) + end + if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale, + bh, "Delete", { id = "page-del", kind = "danger", + enabled = skin and #skin.pages > 1 }) then + local index = Studio.pageIndex + Studio.closeModal() + Studio.ask("Delete this page and everything on it?", + function() Studio.deletePage(index) end, "Delete") + end + + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) + local pages = (skin and skin.pages) or {} + local contentH = #pages * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + for i = 1, #pages do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.pageIndex + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "page-" .. i) + local name, detail = Studio.pageLabel(i) + Kit.text("mono", Kit.ellipsize("mono", i .. ". " .. name, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then + Studio.setPage(i) + Studio.pageNameField = name + end + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawExportModal(W, H) + local mx, my, mw, mh, pad = modalFrame(W, H, "Export this skin", 0.56, 0.6) + local cy = my + pad + Kit.textHeight("title") + pad + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local gap = 6 * Kit.scale + local by, bh = modalFooter(mx, my, mw, mh, pad) + for _, spec in ipairs(Studio.EXPORTS) do + if Kit.button(mx + pad, cy, mw - pad * 2, rowH, spec.label, + { id = "export-" .. spec.id, kind = "accent" }) then + Studio.exportAs(spec.id) + Studio.closeModal() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", Kit.ellipsize("small", spec.hint, mw - pad * 2), + mx + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + end + if Studio.lastExport then + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Show the file", + { id = "export-reveal" }) then + Studio.revealExport() + end + end + Kit.textWrapped("small", + "Exports land in the skins folder of your save directory.", + mx + pad, cy, mw - pad * 2, PAL.faint, 2) +end + +local function drawConfirm(W, H) + local c = Studio.confirm + local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) + Kit.textWrapped("button", c.text, mx + pad, + my + pad + Kit.textHeight("title") + pad, mw - pad * 2, PAL.text, 3) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + local bw = (mw - pad * 2 - 12 * Kit.scale) / 3 + if Kit.button(mx + pad, by, bw, bh, "Save first", + { id = "confirm-save", kind = "accent" }) then + Studio.save() + if not Studio.dirty then Studio.confirmYes() end + end + if Kit.button(mx + pad + bw + 6 * Kit.scale, by, bw, bh, c.yesLabel, + { id = "confirm-yes", kind = "danger" }) then + Studio.confirmYes() + end + if Kit.button(mx + pad + (bw + 6 * Kit.scale) * 2, by, bw, bh, "Cancel", + { id = "confirm-no" }) then + Studio.confirmNo() + end +end + +function Studio.drawOverlay(W, H) + if Studio.confirm then + drawConfirm(W, H) + return true + end + local modal = Studio.modal + if not modal then return false end + if modal.kind == "bind" then drawBindModal(W, H) + elseif modal.kind == "image" then drawImageModal(W, H) + elseif modal.kind == "open" then drawOpenModal(W, H) + elseif modal.kind == "page" then drawPageModal(W, H) + elseif modal.kind == "export" then drawExportModal(W, H) + else Studio.closeModal() end + return true +end + function Studio.draw() local W, H = love.graphics.getDimensions() Kit.layout(W, H) @@ -958,6 +1858,8 @@ function Studio.draw() Studio.clicked, Studio.wheel = false, 0 Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() local pad = 14 * Kit.scale local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad @@ -981,15 +1883,39 @@ function Studio.draw() TouchControls:reset() end bx = bx + 118 * Kit.scale + local smallW = 74 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo", + { id = "undo", font = "small", enabled = Studio.canUndo() }) then + Studio.undo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo", + { id = "redo", font = "small", enabled = Studio.canRedo() }) then + Studio.redo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale if Studio.dirty then Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow) end local closeW = 100 * Kit.scale + local closed = false if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", { id = "close" }) then - if Studio.onClose then Studio.onClose() end + Studio.guard("Close the studio and lose the unsaved changes?", function() + closed = true + if Studio.onClose then Studio.onClose() end + end) + end + if closed then + Kit.blockClicks = false Kit.endFrame() return end @@ -1016,7 +1942,11 @@ function Studio.draw() if not msg then msg = "Drag to move, corner handles to resize, blue box is the game screen." end - Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail) + Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, + Studio.statusErr and PAL.red or PAL.detail) + + Kit.blockClicks = false + Studio.drawOverlay(W, H) Kit.endFrame() Studio.canvasArea = r @@ -1034,6 +1964,7 @@ end function Studio.mousepressed(x, y, button) if button ~= 1 then return end Studio.clicked = true + if Studio.modalUp() then return end local r = Studio.lastCanvas if not r then return end if Studio.testing then @@ -1051,6 +1982,7 @@ function Studio.mousepressed(x, y, button) end function Studio.mousemoved(x, y) + if Studio.modalUp() then return end if Studio.testing then TouchControls:touchmoved("studio", x, y) return @@ -1067,6 +1999,7 @@ function Studio.mousereleased(x, y, button) return end Studio.drag = nil + Studio.guides = nil end function Studio.wheelmoved(_, dy) @@ -1087,13 +2020,55 @@ function Studio.textinput(text) Kit.textinput(text) end +local function heldCtrl() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lctrl", "rctrl", "lgui", "rgui") + return ok and down == true +end + +local function heldShift() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lshift", "rshift") + return ok and down == true +end + +Studio.NUDGES = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + function Studio.keypressed(key) if Kit.focus then Kit.keypressed(key) return end - if key == "escape" then - if Studio.onClose then Studio.onClose() end + if Studio.confirm then + if key == "escape" then Studio.confirmNo() + elseif key == "return" or key == "kpenter" then Studio.confirmYes() + else Kit.keypressed(key) end + return + end + if Studio.modal then + if key == "escape" then Studio.closeModal() else Kit.keypressed(key) end + return + end + local nudge = Studio.NUDGES[key] + if nudge then + Studio.nudge(nudge[1], nudge[2], heldShift()) + return + end + if (key == "y" and heldCtrl()) + or (key == "z" and heldShift() and heldCtrl()) then + Studio.redo() + elseif key == "z" and heldCtrl() then + Studio.undo() + elseif key == "u" then + if heldShift() then Studio.redo() else Studio.undo() end + elseif key == "l" then + Studio.showLabels = not Studio.showLabels + elseif key == "escape" then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) elseif key == "delete" or key == "backspace" then Studio.deleteControl() elseif key == "n" then diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index aff3f69d..ed3e4a15 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -28,7 +28,7 @@ -- covered by tests; the state at the bottom is the only part that draws. local GbcPalette = require("src.render.GbcPalette") -local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local SpriteAnims = require("src.ui.gen2.SpriteAnims") @@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row) end function BattleTransition:draw() - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() self:drawWidescreen(w, h) end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index a6ccd8a0..709a21f0 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -43,16 +43,29 @@ end -- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua -- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen -- paints still fills the window, the PANEL is what stays on the grid. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + function Chrome.fitScale(winW, winH) - return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8), - (winH or 0) / (Chrome.SCREEN_H * 8)))) + local _, _, w, h = playfieldRect(winW, winH) + return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8), + h / (Chrome.SCREEN_H * 8)))) end -- The centred origin that goes with it, so a caller does not re-derive it. function Chrome.fitOrigin(winW, winH, scale) scale = scale or Chrome.fitScale(winW, winH) - return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2), - math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2) + local x, y, w, h = playfieldRect(winW, winH) + return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2), + y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2) end -- A bordered box, tile coords. Leaves the draw color black for text. diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index f4542f21..7ae2464f 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -856,8 +856,8 @@ end -- -------------------------------------------------------------------- pager -- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is --- never silently truncated. This is the ONLY way the launcher moves through --- a long list: no scrollbars, no momentum, bounded row count per frame. +-- never silently truncated. A long list still PAGES rather than scrolling: +-- no momentum, bounded row count per frame. -- Returns the new page (1-based) and the row height consumed. local pagerLabels = {} @@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage) return math.floor(moved) end +function Kit.scrollExtent(contentH, viewH) + return math.max(0, (contentH or 0) - math.max(0, viewH or 0)) +end + +function Kit.scrollClamp(offset, maxScroll) + return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0))) +end + +function Kit.scrollStep(scale) + return math.floor(48 * (scale or Kit.scale)) +end + +function Kit.scrollBarW(scale) + return math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollGutter(scale) + return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollHandoff(offset, maxScroll, delta) + local want = (offset or 0) + (delta or 0) + local at = Kit.scrollClamp(want, maxScroll) + return at, want - at +end + +function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step) + local at = Kit.scrollClamp(offset, maxScroll) + local wheel = Kit.wheelY or 0 + if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then + return at, false + end + if not Kit.hit(x, y, w, h) then return at, false end + local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()), + maxScroll) + if moved == at then return at, false end + Kit.wheelY = 0 + return moved, true +end + +function Kit.scrollBegin(x, y, w, h, offset, maxScroll) + Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0)) + return y - Kit.scrollClamp(offset, maxScroll) +end + +function Kit.scrollEnd(x, y, w, h, offset, maxScroll) + Kit.popClip() + if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end + local barW = Kit.scrollBarW() + local barX = x + w - barW + local at = Kit.scrollClamp(offset, maxScroll) + local thumbH = math.max(math.floor(20 * Kit.scale), + math.floor(h * (h / (h + maxScroll)))) + local thumbY = y + (h - thumbH) * (at / maxScroll) + Theme.fill(barX, y, barW, h, PAL.bg, 0.35) + Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7) +end + -- ------------------------------------------------------------------ spinner -- The one animated element in the UI: a rotating arc of ticks. Drawn as N -- short lines at descending alpha, which needs no shader, no canvas and no diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0dbc0770..9a395acc 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,7 +9,6 @@ local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") local GameVersion = require("src.core.GameVersion") -local GameViewport = require("src.render.GameViewport") local Logger = require("src.core.Logger") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") @@ -5196,7 +5195,7 @@ function OverworldState:drawWorld() -- point projects under the pipeline's own camera. That is the direct -- analogue of what :billboard does for tilt, and it keeps exactly one -- copy of every effect: the closures above are the ones that run. - local pw, ph = GameViewport.dimensions() + local _, _, pw, ph = Game.renderer:playfieldRect() local pscale = Zoom.scale(Game.renderer:fitScale()) local ctx = { state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 198e86f3..45739943 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,7 +34,7 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") -local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -7499,7 +7499,7 @@ function World:interactBody() end function World:fitScale() - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end @@ -8336,7 +8336,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = GameViewport.dimensions() + local ww, wh = Playfield.dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -9748,7 +9748,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = GameViewport.dimensions() + bw, bh = Playfield.dimensions() end if BorderFill.fillBlock(self.map.def) == false then -- BLACK: World:draw clears to a brown letterbox, so the void itself @@ -10017,7 +10017,7 @@ end function World:draw() local G = love.graphics - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) diff --git a/tests/drivers/launcher_sync_shot.lua b/tests/drivers/launcher_sync_shot.lua new file mode 100644 index 00000000..b3109f07 --- /dev/null +++ b/tests/drivers/launcher_sync_shot.lua @@ -0,0 +1,128 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + + local dir = os.getenv("SHOT_DIR") or "/tmp/syncmodal" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local eng = { + phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true }, + isLinked = false, isBusy = false, + linked = function(self) return self.isLinked end, + busy = function(self) return self.isBusy end, + createAccount = function(self) + self.isLinked = true + self.codes = { code1 = "1234-5678", code2 = "8765-4321" } + self.status = "Sync account created" + return true + end, + linkDevice = function(self) self.isLinked = true return true end, + syncNow = function(self) self.status = "Checking for changes..." return true end, + unlink = function(self) self.isLinked, self.codes = false, nil return true end, + shareMods = function(self) self.shareCode = "K7QW3M" return true end, + fetchShare = function(self) return true end, + applyModPlan = function(self) self.modPlan = nil return true end, + resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end, + } + + local imp = RomImporter.new(function() end, { launcher = true }) + imp._sync = eng + imp._syncTransportOk = true + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp:_openSync() + U.wait(3) + U.log("modal view:", imp._syncModal.view, "linked:", tostring(eng:linked())) + shot("sync_new.png") + + imp:_syncView("link") + imp:_syncFocusField("code1") + imp:textinput("1234-5678") + imp:_syncFocusField("code2") + imp:textinput("8765ab4321") + U.wait(2) + U.log("codes typed:", imp._syncModal.code1, imp._syncModal.code2) + shot("sync_link.png") + + imp:_syncView("home") + imp:_syncCreate() + U.wait(2) + U.log("codes shown:", eng.codes.code1, eng.codes.code2) + shot("sync_codes.png") + + eng.isBusy = true + eng.status = "Uploading saves..." + U.wait(2) + shot("sync_busy.png") + eng.isBusy = false + eng.status = "Ready" + + imp:_syncView("mods") + imp:_syncShareMods() + eng.modPlan = { + indexes = { "https://example.invalid/index.json" }, + toInstall = { { id = "jp_green" }, { id = "randomizer" } }, + toEnable = { { id = "jp_green", version = "red" } }, + missing = { { id = "gone" } }, + } + U.wait(2) + U.log("share code:", tostring(eng.shareCode)) + shot("sync_mods.png") + + eng.phase = "conflict" + eng.status = "These saves were played at the same time." + eng.conflicts = { { + key = "red/abcd1234", version = "red", overlap = true, + localMeta = { savedAt = os.time(), sessionStart = os.time() - 3600, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }, + remoteMeta = { savedAt = os.time() - 600, sessionStart = os.time() - 4200, + summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } }, + } } + U.wait(2) + shot("sync_conflict.png") + + love.window.setMode(520, 760, { resizable = true, highdpi = true }) + U.wait(3) + shot("sync_conflict_narrow.png") + + eng.phase = "idle" + eng.conflicts = {} + imp:_syncView("home") + U.wait(2) + shot("sync_home_narrow.png") + + imp:_closeSync() + U.wait(2) + U.log("closed:", tostring(imp._syncModal == nil)) + shot("sync_closed.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_add_by_url_shot.lua b/tests/drivers/skin_add_by_url_shot.lua new file mode 100644 index 00000000..03441162 --- /dev/null +++ b/tests/drivers/skin_add_by_url_shot.lua @@ -0,0 +1,115 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local TouchSkin = require("src.core.TouchSkin") + local Fetch = require("src.net.Fetch") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skinurl" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local imp = RomImporter.new(function() end, { + launcher = true, + onOpenSkinStudio = function() end, + }) + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp:_switchTab("skins") + U.wait(3) + + U.log("name from url:", RomImporter.skinUrlName( + "https://example.com/pads/Neon.deltaskin")) + U.log("name from cfg:", RomImporter.skinUrlName( + "https://example.com/overlay.cfg")) + + imp.skinUrl = "https://example.com/pads/neon.deltaskin" + imp._skinUrlFocus = true + U.wait(2) + shot("skins_url_typed.png") + + local realDownload, realPoll, realRelease = + Fetch.download, Fetch.poll, Fetch.release + local state = { status = "pending", progress = 0.4 } + Fetch.download = function(url, dest) + U.log("download:", url, "->", dest) + return 1 + end + Fetch.poll = function() return state end + Fetch.release = function() end + + imp._skinUrlFocus = false + imp:_addSkinFromUrl() + U.log("in flight:", tostring(imp._skinFetch ~= nil)) + U.wait(2) + shot("skins_url_downloading.png") + + state = { status = "error", err = "could not resolve host" } + imp:_pumpSkinFetch() + U.log("failure notice:", imp._skinNotice.text) + U.wait(2) + shot("skins_url_failed.png") + + Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease + + local staged = TouchSkin.export( + assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")), + "skins/url_probe.zip") + local raw = love.filesystem.read("skins/url_probe.zip") + love.filesystem.remove("skins/url_probe.zip") + U.log("staged:", tostring(staged), "bytes:", raw and #raw or 0) + imp:_installSkinData("downloaded_pad.zip", raw) + U.log("install notice:", imp._skinNotice.text) + U.wait(2) + shot("skins_url_installed.png") + + local skins = imp:_ensureSkins(true) + for _, e in ipairs(skins) do + U.log((" %s format=%s buttons=%d"):format(e.id, tostring(e.format), + e.controls)) + end + + imp._skinActions = { id = skins[1] and skins[1].id } + U.wait(2) + shot("skins_actions_sheet.png") + + for _, kind in ipairs({ "native", "retroarch", "delta" }) do + local path = imp:_exportSkin(skins[1].id, kind) + U.log("export " .. kind .. ":", tostring(path)) + end + imp._skinActions = nil + U.wait(2) + shot("skins_exported.png") + + love.window.setMode(520, 820, { resizable = true, highdpi = true }) + U.wait(3) + shot("skins_url_narrow.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_containment_shot.lua b/tests/drivers/skin_containment_shot.lua new file mode 100644 index 00000000..359824ef --- /dev/null +++ b/tests/drivers/skin_containment_shot.lua @@ -0,0 +1,227 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Playfield = require("src.render.Playfield") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin-cutout" + local gen2 = game.overworld == nil + local failures, checks = 0, 0 + + local SKIN = [[ +return { + name = "containment_probe", + pages = { + { + name = "probe", + fullScreen = true, + viewport = { x = 0.25, y = 0.1, w = 0.5, h = 0.6 }, + controls = { { bind = "nul", x = 0.5, y = 0.92, w = 0.04, h = 0.04 } }, + }, + }, +} +]] + love.filesystem.createDirectory("skins/containment_probe") + assert(love.filesystem.write("skins/containment_probe/skin.lua", SKIN)) + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + love.graphics.setBackgroundColor(0, 0, 0, 1) + U.wait(2) + + local options = gen2 and game.options or game.save.options + options.touchControls = { enabled = true, skin = "containment_probe" } + options.tilt = 0 + options.zoom = 0 + options.pipelines = {} + options.videoMode = "windowed" + options.faithfulRes = 0 + game:applyOptions() + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(4) + + U.log("gen:", gen2 and 2 or 1, "skin:", tostring(TouchControls.skinId), + "err:", tostring(TouchControls.skinError)) + U.log("drawable:", TouchSkin.drawable(), "hasViewport:", TouchSkin.hasViewport()) + + local function cutoutPx() + local pw, ph = love.graphics.getPixelDimensions() + local x, y, w, h = Playfield.cutout(pw, ph) + return x, y, w, h, pw, ph + end + + local cx, cy, cw, ch, pw, ph = cutoutPx() + if not cx then + U.log("FAIL no cutout is active; nothing to prove") + love.event.quit() + while true do coroutine.yield() end + end + U.log(("cutout px: %d,%d %dx%d in %dx%d"):format(cx, cy, cw, ch, pw, ph)) + + local INSET = 4 + local function scan(label, data) + local w, h = data:getWidth(), data:getHeight() + local sx, sy = w / pw, h / ph + local x1, y1 = math.floor(cx * sx) - INSET, math.floor(cy * sy) - INSET + local x2 = math.ceil((cx + cw) * sx) + INSET + local y2 = math.ceil((cy + ch) * sy) + INSET + local bad, firstX, firstY, worst = 0, nil, nil, 0 + local inked = 0 + local step = math.max(2, math.floor(math.min(w, h) / 360)) + for y = 0, h - 1, step do + for x = 0, w - 1, step do + local r, g, b = data:getPixel(x, y) + local lit = math.max(r, g, b) + local outside = x < x1 or x >= x2 or y < y1 or y >= y2 + if outside then + if lit > 0.02 then + bad = bad + 1 + if not firstX then firstX, firstY = x, y end + if lit > worst then worst = lit end + end + elseif lit > 0.02 then + inked = inked + 1 + end + end + end + checks = checks + 1 + if bad > 0 then + failures = failures + 1 + U.log(("FAIL %s: %d lit samples outside the cutout (first %d,%d, max %.2f)") + :format(label, bad, firstX, firstY, worst)) + elseif inked == 0 then + failures = failures + 1 + U.log("FAIL " .. label .. ": nothing drew inside the cutout either") + else + U.log(("ok %s: contained (%d lit samples inside)"):format(label, inked)) + end + end + + local pending = nil + local function probe(label) + U.wait(2) + pending = label + love.graphics.captureScreenshot(function(data) + scan(pending, data) + pending = nil + end) + for _ = 1, 180 do + if not pending then break end + coroutine.yield() + end + if pending then + failures = failures + 1 + U.log("FAIL " .. tostring(pending) .. ": screenshot never arrived") + pending = nil + end + if os.getenv("SHOT_PNG") == "1" then + U.shot(game, ("%s/%s.png"):format(dir, label:gsub("[^%w]+", "_"))) + end + end + + if gen2 then + for i = 1, 2 do + probe("gold_boot_" .. i) + U.wait(60) + end + for _ = 1, 240 do + if game.world and game.world.map then break end + game.input.pressQueue[#game.input.pressQueue + 1] = "start" + U.wait(4) + end + if game.world and game.world.map then + probe("gold_overworld") + local Zoom = require("src.render.Zoom") + Zoom.allowSurvey = true + for _, off in ipairs({ -2, -1, 1, 2 }) do + Zoom.offset = off + probe("gold_zoom_" .. (off < 0 and "out" or "in") .. math.abs(off)) + end + Zoom.offset = 0 + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 12) + end + tap("start", 24) + probe("gold_start_menu") + tap("b", 12) + probe("gold_after_menu") + else + U.log("FAIL gold world never booted") + failures = failures + 1 + end + else + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(12) + probe("red_overworld") + + local Zoom = require("src.render.Zoom") + local Renderer = require("src.render.Renderer") + Zoom.allowSurvey = true + local lo, hi = Zoom.offsetRange(Renderer:fitScale()) + for off = lo, hi do + Zoom.offset = off + probe("red_zoom_" .. Zoom.offsetLabel(off)) + end + Zoom.offset = 0 + + U.tap(game, "start") + U.wait(20) + probe("red_start_menu") + + local function stress(label, mutate) + local state = game.stack:top() + local original = state.draw + state.draw = function(...) + original(...) + mutate() + end + probe(label) + state.draw = original + end + stress("red_screen_veil", function() + Renderer.screenVeil = { 1, 0.85 } + end) + stress("red_battle_wipe", function() + Renderer.battleWipe = { style = "spiralin", prog = 0.45 } + end) + stress("red_letterbox_paper", function() + Renderer.extendedWorldBand = true + end) + stress("red_ui_anchor", function() + Renderer.uiCentered = false + Renderer:setUIAnchor(0, 96, 160, 48, "bottom") + end) + U.tap(game, "b") + U.wait(10) + + game.save.options.uiLayout = "dynamic" + game:applyOptions() + U.tap(game, "start") + U.wait(20) + probe("red_dynamic_start_menu") + U.tap(game, "b") + U.wait(10) + game.save.options.uiLayout = "centered" + game:applyOptions() + + local Tilt = require("src.render.Tilt") + game.save.options.tilt = 1 + Tilt.applyOptions(game.save.options) + U.wait(30) + probe("red_tilt") + game.save.options.tilt = 0 + Tilt.applyOptions(game.save.options) + U.wait(20) + end + + U.log(("done: %d/%d frames contained, %d failures") + :format(checks - failures, checks, failures)) + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/gen2_mod_options_persist.lua b/tests/engine/gen2_mod_options_persist.lua new file mode 100644 index 00000000..c2a5c50c --- /dev/null +++ b/tests/engine/gen2_mod_options_persist.lua @@ -0,0 +1,83 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local Save = require("src.core.gen2.Save") + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +local fs = memfs() +SaveData.saveOptions(SaveData.defaultOptions(), fs) +local opts = Save.loadOptions(fs) +opts.modOptions = { nuzlocke = { dupes = true } } +opts.modProfiles = { { name = "casual", enabled = {} } } +opts.activeProfile = "casual" +opts.mods = { nuzlocke = true } +opts.modsByVersion = { gold = { hardmode = true } } +opts.textSpeed = "SLOW" +check(Save.saveOptions(opts, fs), "gold options write lands") + +local file = SaveData.loadOptions(fs) +eq(file.modOptions and file.modOptions.nuzlocke and file.modOptions.nuzlocke.dupes, + true, "modOptions lands flat where gen1 and the launcher read it") +eq(file.activeProfile, "casual", "activeProfile lands flat") +eq(file.modProfiles and file.modProfiles[1] and file.modProfiles[1].name, + "casual", "modProfiles lands flat") +eq(file.mods and file.mods.nuzlocke, true, "enable flags land flat") +eq(file.modsByVersion and file.modsByVersion.gold + and file.modsByVersion.gold.hardmode, true, "per-version flags land flat") +eq(file[Save.OPTIONS_KEY].modOptions, nil, "gold block no longer traps modOptions") +eq(file[Save.OPTIONS_KEY].activeProfile, nil, + "gold block no longer traps activeProfile") +eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block") + +local back = Save.loadOptions(fs) +eq(back.modOptions.nuzlocke.dupes, true, "flat modOptions round-trips into gold's table") +eq(back.activeProfile, "casual", "flat activeProfile round-trips") + +local fs2 = memfs() +fs2.files["options.lua"] = [[return { gold = { textSpeed = "FAST", + modOptions = { nuzlocke = { dupes = true } }, activeProfile = "old" } }]] +local legacy = Save.loadOptions(fs2) +eq(legacy.modOptions and legacy.modOptions.nuzlocke + and legacy.modOptions.nuzlocke.dupes, true, + "modOptions trapped in the gold block migrates out") +eq(legacy.activeProfile, "old", "trapped activeProfile migrates") +eq(legacy.textSpeed, "FAST", "gold-only keys still merge") +check(Save.saveOptions(legacy, fs2), "migrated write lands") +local migrated = SaveData.loadOptions(fs2) +eq(migrated.modOptions and migrated.modOptions.nuzlocke.dupes, true, + "migration lands the trapped store flat") +eq(migrated[Save.OPTIONS_KEY].modOptions, nil, "migration empties the trap") + +local fs3 = memfs() +fs3.files["options.lua"] = [[return { modOptions = { nuzlocke = { dupes = false } }, + gold = { modOptions = { nuzlocke = { dupes = true } } } }]] +local both = Save.loadOptions(fs3) +eq(both.modOptions.nuzlocke.dupes, false, "flat modOptions wins over a trapped copy") + +local Game2 = require("src.core.Game2") +check(type(Game2.writeOptions) == "function", "Game2 exposes writeOptions") +eq(Game2.writeOptions, Game2.persistOptions, "writeOptions is the persist path") + +local ManagerState = require("src.mods.ManagerState") +local wrote = false +ManagerState.persistOptions({ game = { writeOptions = function() wrote = true end } }) +check(wrote, "ManagerState:persistOptions writes through game.writeOptions") + +T.finish("gen2_mod_options_persist") diff --git a/tests/engine/gen2_touch_skin_options.lua b/tests/engine/gen2_touch_skin_options.lua new file mode 100644 index 00000000..67ba2fc2 --- /dev/null +++ b/tests/engine/gen2_touch_skin_options.lua @@ -0,0 +1,85 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local Save = require("src.core.gen2.Save") + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +local fs = memfs() +local seed = SaveData.defaultOptions() +seed.touchControls = { enabled = true, skin = "gb_anim" } +seed.haptics = "off" +seed[Save.OPTIONS_KEY] = { textSpeed = "FAST", touchControls = { enabled = false } } +check(SaveData.saveOptions(seed, fs) ~= nil, "seed write lands") + +local opts = Save.loadOptions(fs) +eq(opts.touchControls and opts.touchControls.skin, "gb_anim", + "gold sees the skin the launcher picked") +eq(opts.touchControls.enabled, true, "top-level touchControls wins over the gold block") +eq(opts.haptics, "off", "top-level haptics wins over the gold default") +eq(opts.textSpeed, "FAST", "gold-block keys still merge") + +local fs2 = memfs() +fs2.files["options.lua"] = + "return { gold = { touchControls = { enabled = false } } }" +local opts2 = Save.loadOptions(fs2) +eq(opts2.touchControls and opts2.touchControls.enabled, true, + "shared touchControls (default-folded) wins over a stale gold-block copy") + +local fs3 = memfs() +SaveData.saveOptions(SaveData.defaultOptions(), fs3) +local gopts = Save.loadOptions(fs3) +gopts.touchControls = { enabled = true, skin = "tv_crt" } +gopts.haptics = "strong" +gopts.textSpeed = "SLOW" +check(Save.saveOptions(gopts, fs3), "gold options write lands") + +local file = SaveData.loadOptions(fs3) +eq(file.touchControls and file.touchControls.skin, "tv_crt", + "gold's touch pick lands on the shared top-level key") +eq(file.haptics, "strong", "gold's haptics lands on the shared top-level key") +eq(file[Save.OPTIONS_KEY].touchControls, nil, "gold block no longer shadows touchControls") +eq(file[Save.OPTIONS_KEY].haptics, nil, "gold block no longer shadows haptics") +eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block") + +local g1 = Save.loadOptions(fs3) +eq(g1.touchControls.skin, "tv_crt", "hoisted value round-trips back into gold") + +local TouchSkin = require("src.core.TouchSkin") +local Chrome = require("src.ui.gen2.Chrome") + +local savedViewport = TouchSkin.viewport +TouchSkin.viewport = function() return nil end +eq(Chrome.fitScale(640, 576), 4, "no cutout: integer fit against the window") +local ox, oy = Chrome.fitOrigin(640, 576) +eq(ox, 0, "no cutout: centred x") +eq(oy, 0, "no cutout: centred y") + +TouchSkin.viewport = function(w, h) return w * 0.25, h * 0.125, w * 0.5, h * 0.5 end +eq(Chrome.fitScale(640, 576), 2, "cutout: integer fit against the cutout rect") +local cx, cy = Chrome.fitOrigin(640, 576) +eq(cx, 160 + (320 - 320) / 2, "cutout: origin starts at the cutout") +eq(cy, 72 + math.floor((288 - 288) / 2), "cutout: origin starts at the cutout y") + +TouchSkin.viewport = function() error("boom") end +eq(Chrome.fitScale(640, 576), 4, "a throwing viewport degrades to the window fit") + +TouchSkin.viewport = savedViewport + +T.finish("gen2_touch_skin_options") diff --git a/tests/engine/host_shell_bridge_request.lua b/tests/engine/host_shell_bridge_request.lua new file mode 100644 index 00000000..8f84b6ec --- /dev/null +++ b/tests/engine/host_shell_bridge_request.lua @@ -0,0 +1,125 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local HostShell = require("src.core.HostShell") + +local TOKEN = "0123456789abcdef0123456789abcdef" +local BODY = '{"blob":"return {}"}' + +HostShell.haveCurl = function() return false end +love.system.getOS = function() return "Android" end + +local calls = {} +local reply = "STATUS 200\n" .. '{"ok":true}' + +love.system.httpRequest = function(url, method, headers, body, userAgent) + calls[#calls + 1] = { url = url, method = method, headers = headers, + body = body, userAgent = userAgent } + if type(reply) == "function" then return reply() end + return reply +end + +check(HostShell.canHttpRequest(), + "the bridge counts as a request transport where curl does not exist") + +local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", + body = BODY, + headers = { + ["x-sync-account"] = "aa11bb22cc33dd44", + ["x-sync-token"] = TOKEN, + ["Content-Type"] = "application/json", + }, +}) + +eq(code, 200, "a bridge request completes: " .. tostring(err)) +eq(body, '{"ok":true}', "and the body arrives with the status line stripped") +eq(err, nil, "with no error alongside it") + +eq(#calls, 1, "the bridge is called once") +local sent = calls[1] +eq(sent.url, "https://sync.example/sync/save", "the url goes through untouched") +eq(sent.method, "PUT", "and so does the method curl would have taken with -X") +eq(sent.body, BODY, "the save blob rides the body argument, not the url") +eq(sent.userAgent, "gen1recomp", "with the default user agent") + +local seen = {} +for i = 1, #sent.headers, 2 do seen[sent.headers[i]] = sent.headers[i + 1] end +eq(seen["x-sync-token"], TOKEN, "auth headers arrive as flat name, value pairs") +eq(seen["x-sync-account"], "aa11bb22cc33dd44", "for the account id too") +eq(seen["Content-Type"], "application/json", "and for the content type") +eq(seen["User-Agent"], nil, + "the user agent stays its own argument rather than a duplicate header") + +calls = {} +reply = "STATUS 409\n" .. '{"error":"the save moved on"}' +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, headers = { ["Accept"] = "application/json" }, +}) +eq(code, 409, "a conflict comes back as a status, not as a transport failure") +eq(body, '{"error":"the save moved on"}', + "and its body survives, which is the whole point of the request arm") +eq(err, nil, "a 4xx is the caller's to interpret") + +calls = {} +reply = "ERROR the reply was too large\n" +body, err, code = HostShell.httpRequest("https://sync.example/sync/state", { + method = "GET", +}) +eq(code, nil, "an ERROR envelope has no status") +eq(body, nil, "and no body") +check(err and err:find("the reply was too large", 1, true) ~= nil, + "the bridge's own complaint reaches the caller: " .. tostring(err)) +check(err and err:find("https://sync.example/sync/state", 1, true) ~= nil, + "named with the url that failed") + +calls = {} +reply = "STATUS 200\n" .. '{"ok":true}' +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, + headers = { ["x-sync-token"] = TOKEN .. "\r\nx-sync-account: stolen" }, +}) +eq(code, nil, "a header value carrying CRLF is refused") +eq(err, "bad request header", "with the same complaint the curl branch gives") +eq(#calls, 0, "and the bridge is never reached") + +calls = {} +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PATCH", body = BODY, +}) +eq(code, nil, "a method the bridge cannot express is refused") +check(err and err:find("PATCH", 1, true) ~= nil, + "naming the method: " .. tostring(err)) +eq(#calls, 0, "without calling the bridge") + +calls = {} +reply = function() return nil end +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(code, nil, "an old app under a newer engine returns nothing") +check(err and err:find("update the app", 1, true) ~= nil, + "and degrades to an update notice rather than a crash: " .. tostring(err)) + +love.system.httpRequest = nil +love.system.httpDownload = function() return false end +check(not HostShell.canHttpRequest(), + "a build with only the download bridge cannot make signed requests") +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(code, nil, "so the request does not go out") +check(err and err:find("update the app", 1, true) ~= nil, + "and says what to do about it: " .. tostring(err)) + +love.system.httpDownload = nil +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(err, "no request transport on this platform", + "a platform with no bridge at all keeps its old answer") + +T.finish("host shell bridge request") diff --git a/tests/engine/host_shell_request_headers.lua b/tests/engine/host_shell_request_headers.lua new file mode 100644 index 00000000..2369dddf --- /dev/null +++ b/tests/engine/host_shell_request_headers.lua @@ -0,0 +1,95 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local HostShell = require("src.core.HostShell") + +local MARK = "\n__gen1recomp_http__" +local SAVE_DIR = "/tmp/pokeport-stub-save" +local TOKEN = "0123456789abcdef0123456789abcdef" +local BODY = '{"blob":"return {}"}' + +local realOpen = io.open +local realPopen = io.popen +local realRemove = os.remove +local realHaveCurl = HostShell.haveCurl + +local files, removed = {}, {} +local popenCommand + +HostShell.haveCurl = function() return true end +os.remove = function(path) + removed[path] = true + return true +end +io.open = function(path, mode) + local entry = { path = path, mode = mode, text = "" } + files[#files + 1] = entry + return { + write = function(_, value) + entry.text = entry.text .. value + return true + end, + close = function() return true end, + } +end +io.popen = function(command) + popenCommand = command + return { + read = function() return '{"ok":true}' .. MARK .. "200" end, + close = function() return true end, + } +end + +local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", + body = BODY, + headers = { + ["x-sync-account"] = "aa11bb22cc33dd44", + ["x-sync-token"] = TOKEN, + ["Content-Type"] = "application/json", + }, +}) + +io.open = realOpen +io.popen = realPopen +os.remove = realRemove +HostShell.haveCurl = realHaveCurl + +eq(code, 200, "the request completes: " .. tostring(err)) +eq(body, '{"ok":true}', "and the response body comes back without the marker") + +check(popenCommand:find(TOKEN, 1, true) == nil, + "the device token never reaches the command line") +check(popenCommand:find("aa11bb22cc33dd44", 1, true) == nil, + "and neither does the account id") +check(popenCommand:find(BODY, 1, true) == nil, + "the save blob stays out of the command line too") + +local headerFile, bodyFile +for _, entry in ipairs(files) do + if entry.text:find("x-sync-token", 1, true) then headerFile = entry end + if entry.text == BODY then bodyFile = entry end +end +check(headerFile ~= nil, "the headers are staged in a file") +check(bodyFile ~= nil, "and so is the body") +eq(headerFile.mode, "wb", "the header file is written as bytes") +check(headerFile.text:find("x%-sync%-token: " .. TOKEN) ~= nil, + "with one header per line for curl to read") +check(headerFile.text:find("User%-Agent: ") ~= nil, + "including the user agent curl would otherwise take on argv") +check(popenCommand:find("-H '@" .. headerFile.path .. "'", 1, true) ~= nil, + "and curl is pointed at that file") + +check(headerFile.path:find(SAVE_DIR, 1, true) == 1, + "staging happens in the user-private save directory, not shared /tmp") +check(bodyFile.path:find(SAVE_DIR, 1, true) == 1, + "for the body as well") +check(headerFile.path ~= bodyFile.path, + "two concurrent requests cannot collide on one name") +eq(removed[headerFile.path], true, "the staged headers are deleted afterwards") +eq(removed[bodyFile.path], true, "and so is the staged body") + +T.finish("host shell request headers") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua new file mode 100644 index 00000000..c74e4cce --- /dev/null +++ b/tests/engine/launcher_scroll_test.lua @@ -0,0 +1,324 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function pointer(x, y) + love.mouse.getPosition = function() return x, y end +end + +eq(Kit.scrollExtent(800, 500), 300, "the extent is exactly the overflow") +eq(Kit.scrollExtent(400, 500), 0, "content that fits has no extent") +eq(Kit.scrollExtent(400, -50), 400, "a negative viewport is no room, not more") +eq(Kit.scrollExtent(nil, nil), 0, "an unmeasured region has no extent") + +eq(Kit.scrollClamp(-10, 300), 0, "an offset above the top clamps to it") +eq(Kit.scrollClamp(5000, 300), 300, "an offset past the end clamps to it") +eq(Kit.scrollClamp(120, 0), 0, "a region with no travel sits at the top") + +local at, left = Kit.scrollHandoff(0, 300, 120) +eq(at, 120, "a move inside the extent is taken in full") +eq(left, 0, "and hands nothing on") +at, left = Kit.scrollHandoff(250, 300, 120) +eq(at, 300, "a move past the end stops at the end") +eq(left, 70, "and hands the remainder to whatever is behind it") +at, left = Kit.scrollHandoff(0, 300, -80) +eq(at, 0, "a move above the top stops at the top") +eq(left, -80, "and hands that remainder on with its sign") + +local function wheelCase(offset, maxScroll, wheel, mx, my) + Kit.blockClicks = false + Kit.mouseX, Kit.mouseY = mx, my + Kit.wheelY = wheel + Kit._clipRect = nil + local moved, took = Kit.scrollWheel(offset, maxScroll, 0, 0, 100, 100, 50) + return moved, took, Kit.wheelY +end + +local moved, took, leftWheel = wheelCase(0, 300, -1, 50, 50) +eq(moved, 50, "a notch over the region moves it by one step") +eq(took, true, "and reports the region took it") +eq(leftWheel, 0, "so nothing reaches the surface behind it") + +moved, took, leftWheel = wheelCase(300, 300, -1, 50, 50) +eq(moved, 300, "a region already at its bottom does not move") +eq(took, false, "and does not claim the notch") +eq(leftWheel, -1, "which is what lets the page scroll take over") + +moved, took, leftWheel = wheelCase(0, 300, 1, 50, 50) +eq(moved, 0, "a region at the top ignores an upward notch") +eq(leftWheel, 1, "and passes it on") + +moved, took, leftWheel = wheelCase(0, 300, -1, 400, 400) +eq(took, false, "a notch outside the region is not the region's") +eq(leftWheel, -1, "and stays queued") + +Kit.blockClicks = true +Kit.mouseX, Kit.mouseY, Kit.wheelY = 50, 50, -1 +moved, took = Kit.scrollWheel(0, 300, 0, 0, 100, 100, 50) +eq(took, false, "a shielded frame (modal up) leaves the region alone") +eq(Kit.wheelY, -1, "so the modal's own scroller still sees the notch") +Kit.blockClicks = false + +local function skinLauncher(count) + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "skins" + local skins = {} + for i = 1, count do + skins[i] = { id = "skin" .. i, source = "user", controls = 8, pages = 1 } + end + imp._skins = skins + imp._ensureSkins = function() return skins end + return imp +end + +window(360, 780) +local imp = skinLauncher(12) +LauncherView.draw(imp) +LauncherView.draw(imp) +local rect = imp._tabRegionRect +check(rect ~= nil, "the panel publishes the rect its region occupies") +check((imp._tabScrollMax.skins or 0) > 0, + "a panel with more rows than its viewport scrolls") +eq(imp._tabScroll.skins, 0, "a freshly drawn panel sits at the top") +check(imp._tabContentH.skins > rect.h, + "the region's content is taller than the viewport it is clipped to") + +pointer(rect.x + 10, rect.y + 10) +imp._wheelY = -1 +LauncherView.draw(imp) +local step = math.floor(48 * Kit.scale) +eq(imp._tabScroll.skins, step, "one notch scrolls the panel by one step") +eq(imp._pageScroll, 0, + "and the page under it does not move while the panel still can") + +for _ = 1, 30 do + imp._wheelY = -1 + LauncherView.draw(imp) +end +eq(imp._tabScroll.skins, imp._tabScrollMax.skins, + "held down, the panel reaches its own bottom") +eq(imp._pageScroll, imp._pageScrollMax, + "and only then does the leftover scroll the page") + +for _ = 1, 40 do + imp._wheelY = 1 + LauncherView.draw(imp) +end +eq(imp._tabScroll.skins, 0, "scrolling back up returns the panel to the top") +eq(imp._pageScroll, 0, "and the page with it") + +imp._wheelY = -1 +LauncherView.draw(imp) +local parked = imp._tabScroll.skins +check(parked > 0, "the skins panel is parked mid-scroll") +imp:_switchTab("red") +LauncherView.draw(imp) +eq(imp._tabScroll.red or 0, 0, "the game tab has its own offset") +eq(imp._tabScroll.skins, parked, "and the skins offset survives the switch") +imp:_switchTab("skins") +LauncherView.draw(imp) +eq(imp._tabScroll.skins, parked, "coming back lands where the player left") + +imp._skins = {} +imp._ensureSkins = function() return {} end +LauncherView.draw(imp) +LauncherView.draw(imp) +eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel") +eq(imp._tabScroll.skins, 0, "and its offset comes back with it") + +local mods = {} +for i = 1, 60 do + mods[#mods + 1] = { + id = "mod" .. i, name = "Mod " .. i, version = "1.0.0", + status = "ok", badge = "gameplay", description = "a mod", + enabledByVersion = { red = true }, + } +end +window(360, 780) +local modImp = RomImporter.new(function() end, { launcher = true }) +modImp.tab = "mods" +modImp.mods = mods +modImp._ensureMods = function() return mods end +LauncherView.draw(modImp) +LauncherView.draw(modImp) +check((modImp._modScrollMax or 0) > 0, + "60 mods overflow the list viewport inside the panel") +local list = modImp._modListRect +check(list.x + list.w + <= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(), + "the rows stop short of the region's scrollbar gutter") +pointer(list.x + 10, list.y + 10) +modImp._wheelY = -1 +LauncherView.draw(modImp) +check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list") +eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it") +eq(modImp._pageScroll, 0, "and not the page behind that") + +modImp._modActions = "mod1" +local shielded = modImp.modScroll +local shieldedPage = modImp._pageScroll +pointer(list.x + 10, list.y + 10) +modImp._wheelY = -1 +LauncherView.draw(modImp) +eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch") +eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim") +eq(modImp._pageScroll, shieldedPage, "and the page behind that") +modImp._modActions = nil +modImp._wheelY = 0 +LauncherView.draw(modImp) + +window(360, 780) +local gameImp = RomImporter.new(function() end, { launcher = true }) +gameImp.tab = "red" +gameImp.ready = { red = true } +gameImp.slots = { red = {} } +for i = 1, 8 do + gameImp.slots.red[i] = { id = "slot" .. i, name = "Slot " .. i } +end +gameImp._ensureSlots = function() end +LauncherView.draw(gameImp) +LauncherView.draw(gameImp) +check((gameImp._tabScrollMax.red or 0) > 0, + "a game tab whose cart and slots outgrow the viewport scrolls too") +check(gameImp._tabContentH.red > gameImp._tabRegionRect.h, + "because it reports its NATURAL height, not the height it was given") +pointer(gameImp._tabRegionRect.x + 10, gameImp._tabRegionRect.y + 10) +gameImp._wheelY = -1 +LauncherView.draw(gameImp) +check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it") + +window(360, 780) +local touchImp = skinLauncher(12) +LauncherView.draw(touchImp) +LauncherView.draw(touchImp) +local treg = touchImp._tabRegionRect +local tmax = touchImp._tabScrollMax.skins +check(tmax > 0, "the touched panel has travel") +LauncherView.touchpressed(touchImp, 1, treg.x + 20, treg.y + 40) +LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200) +eq(touchImp._tabScroll.skins, math.min(200, tmax), + "dragging up scrolls the panel by the finger's travel") +eq(touchImp._pageScroll, 0, "while the panel still has travel, the page waits") +LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2) +eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom") +check((touchImp._pageScroll or 0) > 0, "and spills into the page from there") +LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400) + +window(360, 780) +local dragMods = RomImporter.new(function() end, { launcher = true }) +dragMods.tab = "mods" +dragMods.mods = mods +dragMods._ensureMods = function() return mods end +LauncherView.draw(dragMods) +LauncherView.draw(dragMods) +local dlist = dragMods._modListRect +local dListMax = dragMods._modScrollMax +local dRegionMax = dragMods._tabScrollMax.mods +check(dListMax > 0 and dRegionMax > 0, + "the mods tab has both an inner list and a region to scroll") +LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30) +LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60) +eq(dragMods.modScroll, math.min(60, dListMax), + "the first pixels of the drag move the list") +eq(dragMods._tabScroll.mods or 0, 0, "and nothing else") +LauncherView.touchmoved(dragMods, 7, dlist.x + 20, + dlist.y + 30 - 60 - dListMax - dRegionMax * 2) +eq(dragMods.modScroll, dListMax, "carrying on saturates the list") +eq(dragMods._tabScroll.mods, dRegionMax, + "then the same gesture walks the region to its bottom") +check((dragMods._pageScroll or 0) > 0, "and only then reaches the page") +LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900) + +dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } +for i = 2, 12 do + dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } +end +dragMods._ensureSkins = function() return dragMods._skins end +dragMods.modScroll = 0 +local heldModScroll = dragMods.modScroll +local overList = dlist.y + 30 +dragMods:_switchTab("skins") +LauncherView.draw(dragMods) +LauncherView.draw(dragMods) +local sreg = dragMods._tabRegionRect +check((dragMods._tabScrollMax.skins or 0) > 0, "the skins tab has travel") +LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList) +LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200) +check((dragMods._tabScroll.skins or 0) > 0, + "a drag on the skins tab scrolls the skins tab") +eq(dragMods.modScroll, heldModScroll, + "and leaves the mod list where the player parked it") +LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) + +love.graphics.polygon = love.graphics.polygon or function() end +window(360, 780) +local padImp = skinLauncher(12) +LauncherView.draw(padImp) +LauncherView.draw(padImp) +local preg = padImp._tabRegionRect +padImp._padCursorActive = true +padImp._padCursor = { x = preg.x + 10, y = preg.y + preg.h - 4 } +LauncherView.wheelmoved(padImp, 0, -1) +LauncherView.draw(padImp) +check((padImp._tabScroll.skins or 0) > 0, + "the pad's synthesized wheel scrolls the region its cursor sits in") +eq(padImp._pageScroll, 0, "and not the page behind it") + +window(1280, 720) +local edgeImp = skinLauncher(40) +LauncherView.draw(edgeImp) +LauncherView.draw(edgeImp) +local ereg = edgeImp._tabRegionRect +check((edgeImp._tabScrollMax.skins or 0) > 0, "the wide window still overflows") +eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch") +check(ereg.y + ereg.h < 720, "and a region that ends above the safe area") +edgeImp._padCursorActive = true +edgeImp._padCursor = { x = ereg.x + 20, y = 719 } +edgeImp._padAxis = { lefty = 1 } +edgeImp._padDir = {} +pointer(ereg.x + 20, 719) +edgeImp:_updatePadCursor(0.5) +check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") +LauncherView.draw(edgeImp) +check((edgeImp._tabScroll.skins or 0) > 0, + "which reaches the tab region even though the cursor is below it") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local view = read("src/import/LauncherView.lua") +check(view:find("Kit.scrollBegin(", 1, true) ~= nil, + "the panel dispatch opens a scroll region") +check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it") +check(view:find("modListWantsWheel", 1, true) ~= nil, + "the nested mod list is asked before the region takes a notch") +check(view:find("start.region", 1, true) ~= nil, + "a touch drag that began in the region scrolls the region") +check(view:find("Kit.scrollGutter(", 1, true) ~= nil, + "the panels lay out inside a gutter, so the thumb covers no control") +check(view:find("Kit.scrollHandoff(tabScrollAt(imp)", 1, true) ~= nil, + "and hands its leftover to the page, like the wheel does") + +local kit = read("src/ui/kit/Kit.lua") +check(kit:find("function Kit.scrollWheel", 1, true) ~= nil, + "the kit owns the wheel rule, so no panel hand-rolls a fifth copy") + +T.finish("launcher scroll regions") diff --git a/tests/engine/launcher_skins_ux.lua b/tests/engine/launcher_skins_ux.lua new file mode 100644 index 00000000..3ce36f51 --- /dev/null +++ b/tests/engine/launcher_skins_ux.lua @@ -0,0 +1,268 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") +local TouchSkin = require("src.core.TouchSkin") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function launcher() + return RomImporter.new(function() end, { launcher = true }) +end + +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip"), "gbc.zip", + "a direct .zip keeps its name") +eq(RomImporter.skinUrlName("https://example.com/pads/Neon.deltaskin"), + "Neon.deltaskin", "and so does a .deltaskin") +eq(RomImporter.skinUrlName("https://example.com/overlay.cfg"), "overlay.cfg", + "a bare RetroArch cfg is kept as a cfg") +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip?raw=1"), "gbc.zip", + "a query string is not part of the name") +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip#frag"), "gbc.zip", + "nor is a fragment") +eq(RomImporter.skinUrlName("https://example.com/download"), "download.zip", + "an extension-less link is treated as an archive") +eq(RomImporter.skinUrlName("https://example.com/a b/../pad.tar"), "pad.zip", + "an unknown extension is replaced, and the name is sanitized") +check(RomImporter.skinUrlName("https://example.com/"):match("^[%w%._%-]+$"), + "the download name can never escape the skins folder") + +local name, payload = RomImporter.wrapSkinPayload("overlay.cfg", + "overlays = 1\noverlay0_descs = 0\n") +eq(name, "overlay.zip", "a downloaded .cfg is wrapped into an archive") +eq(payload:sub(1, 2), "PK", "which is a real zip") +check(payload:find("overlays = 1", 1, true) ~= nil, + "carrying the cfg text inside it") +check(payload:find("overlay.cfg", 1, true) ~= nil, + "under the name RetroArch parsing expects") + +local zipName, zipData = RomImporter.wrapSkinPayload("pad.zip", "PK\3\4stuff") +eq(zipName, "pad.zip", "a zip is passed through untouched") +eq(zipData, "PK\3\4stuff", "bytes and all") +eq(select(1, RomImporter.wrapSkinPayload("pad.deltaskin", "PK\3\4x")), + "pad.deltaskin", "and so is a .deltaskin") +local pkName, pkData = RomImporter.wrapSkinPayload("overlay.cfg", "PK\3\4real") +eq(pkName, "overlay.zip", "a .cfg link that serves zip bytes is renamed, not refused") +eq(pkData, "PK\3\4real", "and its bytes are left alone") + +local imp = launcher() +check(not imp:_addSkinFromUrl(""), "an empty link is refused") +check(imp._skinNotice and not imp._skinNotice.ok, "with a visible error") +eq(imp._skinFetch, nil, "and no download is started") +check(not imp:_addSkinFromUrl("file:///etc/passwd"), + "a non-http link is refused") +eq(imp._skinFetch, nil, "and still starts nothing") +check(not imp:_addSkinFromUrl("skins/local.zip"), + "a bare path is not a link either") + +local Fetch = require("src.net.Fetch") +local realDownload, realPoll, realRelease = Fetch.download, Fetch.poll, + Fetch.release +local asked +Fetch.download = function(url, dest) asked = { url = url, dest = dest } return 7 end +Fetch.poll = function() return { status = "pending", progress = 0.5 } end +Fetch.release = function() end + +imp = launcher() +imp.skinUrl = "https://example.com/pads/neon.deltaskin" +check(imp:_addSkinFromUrl(), "a good link starts a download") +check(imp._skinFetch ~= nil, "and parks the job on the importer") +eq(asked.url, "https://example.com/pads/neon.deltaskin", "the url is fetched") +check(asked.dest:find("neon.deltaskin", 1, true) ~= nil, + "into a file named after the link") +check(asked.dest:find("%.%.") == nil, "with no traversal in the path") +check(not imp:_addSkinFromUrl("https://example.com/other.zip"), + "a second add while one is in flight is ignored") + +imp:_pumpSkinFetch() +check(imp._skinFetch ~= nil, "a pending download stays in flight") + +local installed +imp._installSkinData = function(_, n, d) installed = { name = n, data = d } return "neon" end +Fetch.poll = function() + return { status = "ok", path = "skins/_download/neon.deltaskin" } +end +love.filesystem.write("skins/_download/neon.deltaskin", "PK\3\4payload") +imp:_pumpSkinFetch() +eq(imp._skinFetch, nil, "a finished download is released") +check(installed ~= nil, "and its bytes go to the installer") +eq(installed.name, "neon.deltaskin", "under the downloaded name") +eq(love.filesystem.read("skins/_download/neon.deltaskin"), nil, + "the temporary download is cleaned up") +eq(imp.skinUrl, "", "and the field is cleared for the next one") + +imp = launcher() +imp._installSkinData = function() return nil end +Fetch.download = function() return 8 end +Fetch.poll = function() return { status = "error", err = "404" } end +imp:_addSkinFromUrl("https://example.com/missing.zip") +imp:_pumpSkinFetch() +eq(imp._skinFetch, nil, "a failed download is released too") +check(imp._skinNotice and not imp._skinNotice.ok, "and reported") +check(tostring(imp._skinNotice.text):find("404", 1, true) ~= nil, + "with the reason attached") + +Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease + +imp = launcher() +eq(imp:_installSkinData("pad.zip", ""), nil, "an empty payload is refused") +check(imp._skinNotice and not imp._skinNotice.ok, "and says so") +eq(imp:_installSkinData("notes.txt", "hello"), nil, "a non-archive is refused") + +love.filesystem.write("skins/warny.zip/overlay.cfg", [[ +overlays = 1 +overlay0_name = "warny" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +imp = launcher() +eq(imp:_installSkinData("warny.zip", "PK\3\4stub"), "warny", "a skin installs") +check(imp._skinNotice.ok, "with an ok notice") +check(tostring(imp._skinNotice.text):find("missing desc", 1, true) ~= nil, + "that repeats what the importer had to complain about") + +love.filesystem.write("skins/vecty.deltaskin/info.json", [[ +{ "name": "Vecty", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]]) +imp = launcher() +eq(imp:_installSkinData("vecty.deltaskin", "PK\3\4stub"), nil, + "a PDF-only Delta skin does not install silently") +check(imp._skinNotice and not imp._skinNotice.ok, "the tab reports the refusal") +check(tostring(imp._skinNotice.text):find("PDF artwork", 1, true) ~= nil, + "and says why, instead of listing a skin with no buttons") + +local function dropped(fileName) + return { getFilename = function() return fileName end, + open = function() return false end } +end + +local routed +local function routeDrop(tab, fileName) + routed = nil + local drop = launcher() + drop.tab = tab + drop._installSkinZip = function() routed = "skin" end + drop._installMod = function() routed = "mod" end + drop.startData = function() routed = "rom" end + drop:filedropped(dropped(fileName)) + return routed +end + +eq(routeDrop("mods", "pad.deltaskin"), "skin", + "a dropped .deltaskin installs as a skin from any tab") +eq(routeDrop("skins", "pad.deltaskin"), "skin", "and from the skins tab") +eq(routeDrop("skins", "Neon.DeltaSkin"), "skin", "whatever its case") +eq(routeDrop("skins", "pad.zip"), "skin", "a zip on the skins tab is still a skin") +eq(routeDrop("mods", "pad.zip"), "mod", "and a mod anywhere else") + +check(TouchSkin.saveTo(TouchSkin.newSkin("uxskin"), "uxskin") ~= nil, + "a skin to list") +imp = launcher() +local entries = imp:_ensureSkins(true) +check(#entries > 0, "the installed skins are listed") +local byId = {} +for _, entry in ipairs(entries) do + byId[entry.id] = entry + check(type(entry.format) == "string", + entry.id .. " reports the format it was parsed from") +end +eq(byId.uxskin and byId.uxskin.format, "native", + "a skin.lua skin is badged as the native format") + +imp = launcher() +eq(imp:_exportSkin("no-such-skin", "native"), nil, "exporting a ghost fails") +check(imp._skinNotice and not imp._skinNotice.ok, "with an error notice") + +local first = entries[1] +imp = launcher() +local path = imp:_exportSkin(first.id, "delta") +check(path ~= nil and path:match("%.deltaskin$") ~= nil, + "a bundled skin exports as a .deltaskin") +check(imp._skinNotice.ok, "and the tab reports where it landed") +check(tostring(imp._skinNotice.text):find(path, 1, true) ~= nil, + "naming the path, which is the whole mobile story") +check(imp._skinExport ~= nil and imp._skinExport.path == path, + "the export is remembered so Show file can reveal it") +path = imp:_exportSkin(first.id, "retroarch") +check(path ~= nil and path:match("%.zip$") ~= nil, + "and as a RetroArch .zip") +path = imp:_exportSkin(first.id, "native") +check(path ~= nil and path:match("%.zip$") ~= nil, "and as a gen1recomp .zip") + +window(420, 900) +imp = launcher() +imp.tab = "skins" +LauncherView.draw(imp) +LauncherView.draw(imp) +check(true, "the skins tab draws with the URL row") +imp._skinActions = { id = entries[1].id } +LauncherView.draw(imp) +check(imp._skinActions ~= nil, "the actions sheet stays up while it draws") +imp._skinFetch = { name = "neon.zip" } +LauncherView.draw(imp) +imp._skinFetch = nil + +window(320, 640) +LauncherView.draw(imp) +LauncherView.draw(imp) +check(true, "and on a phone-width window, where Paste gives up its room") + +local view = read("src/import/LauncherView.lua") +local rom = read("src/import/RomImporter.lua") + +check(view:find('"skins-url"', 1, true) ~= nil, + "the skins tab carries an add-by-URL field") +check(view:find('"skins-url-add"', 1, true) ~= nil, "with a button to submit it") +check(view:find('"skins-url-paste"', 1, true) ~= nil, + "and a paste button, because a phone cannot type a URL") +check(view:find("_addSkinFromUrl", 1, true) ~= nil, + "which reaches the importer's downloader") +check(view:find("Loader.inline", 1, true) ~= nil, + "and the row shows progress while it runs") +check(view:find("SKIN_FORMAT_LABEL", 1, true) ~= nil, + "rows carry a format badge") +check(view:find("buildSkinActionsModal", 1, true) ~= nil, + "the gear opens an actions sheet") +check(view:find("_exportSkin", 1, true) ~= nil, "which can export the skin") +check(view:find("skinact-exp-delta", 1, true) ~= nil, + "including as a Delta skin") +local modals = view:match("local function modalUp%(imp%)(.-)\nend") +check(modals and modals:find("_skinActions", 1, true) ~= nil, + "the sheet raises the modal shield like every other popup") +check(view:find("imp.onOpenSkinStudio(imp.modScope or \"red\", id)", 1, true) + ~= nil, "and still hands the studio a real game version") + +check(rom:find("deltaskin", 1, true) ~= nil, + "the desktop file picker offers .deltaskin") +check(rom:find("_pumpSkinFetch", 1, true) ~= nil, + "the skin download is pumped from update()") +local update = rom:match("function RomImporter:update%(dt%)(.-)\nend\n") +check(update and update:find("_pumpSkinFetch", 1, true) ~= nil, + "from inside update itself, not just declared") +check(TouchSkin.ARCHIVE_EXTS.deltaskin == true, + "and the installer accepts the extension") + +T.finish("launcher_skins_ux") diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua new file mode 100644 index 00000000..8899d358 --- /dev/null +++ b/tests/engine/launcher_sync_modal.lua @@ -0,0 +1,307 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.polygon = love.graphics.polygon or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +eq(RomImporter.syncDigits("1234-5678"), "12345678", + "the dash people read the code with is not part of it") +eq(RomImporter.syncDigits(" 12 34 "), "1234", "spaces are dropped") +eq(RomImporter.syncDigits("abc9"), "9", "letters cannot enter a digit code") +eq(RomImporter.syncDigits("123456789012"), "12345678", + "a code is eight digits and no more") +eq(RomImporter.syncDigits(nil), "", "an empty field stays empty") + +eq(RomImporter.syncShareCode("abc234"), "ABC234", "share codes are upper case") +eq(RomImporter.syncShareCode("A1B0C-D"), "ABCD", + "1 and 0 are not in the share alphabet") +eq(RomImporter.syncShareCode("ABCDEFGH"), "ABCDEF", + "a share code is six characters") + +local function fakeEngine(over) + local eng = { + phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true }, + calls = {}, + isLinked = false, + linked = function(self) return self.isLinked end, + busy = function(self) return self.isBusy == true end, + createAccount = function(self, label) + self.calls[#self.calls + 1] = { "create", label } + self.isLinked = true + self.codes = { code1 = "1234-5678", code2 = "8765-4321" } + return true + end, + linkDevice = function(self, a, b, label) + self.calls[#self.calls + 1] = { "link", a, b, label } + if #tostring(a) ~= 8 or #tostring(b) ~= 8 then return false end + self.isLinked = true + return true + end, + syncNow = function(self) + self.calls[#self.calls + 1] = { "syncNow" } + return true + end, + unlink = function(self) + self.calls[#self.calls + 1] = { "unlink" } + self.isLinked, self.codes = false, nil + return true + end, + shareMods = function(self) + self.calls[#self.calls + 1] = { "shareMods" } + self.shareCode = "K7QW3M" + return true + end, + fetchShare = function(self, code) + self.calls[#self.calls + 1] = { "fetchShare", code } + return true + end, + applyModPlan = function(self, progress) + self.calls[#self.calls + 1] = { "applyModPlan" } + if progress then progress(1, 2, "a") progress(2, 2, "b") end + return true + end, + resolveConflict = function(self, key, choice) + self.calls[#self.calls + 1] = { "resolve", key, choice } + return true + end, + } + for k, v in pairs(over or {}) do eng[k] = v end + return eng +end + +local function launcher(eng) + local imp = RomImporter.new(function() end, { launcher = true }) + imp._sync = eng + imp._syncTransportOk = true + return imp +end + +local eng = fakeEngine() +local imp = launcher(eng) + +eq(imp._syncModal, nil, "the modal is closed until the header button opens it") +imp:_openSync() +check(imp._syncModal ~= nil, "the header button opens the modal") +eq(imp._syncModal.view, "home", "and lands on the home view") +eq(imp._syncFocus, nil, "with no field taking the keyboard") + +imp:_syncView("link") +eq(imp._syncModal.view, "link", "Link this device swaps the view") +imp:_syncFocusField("code1") +eq(imp._syncFocus, "code1", "tapping a field focuses it") +imp:textinput("12ab34") +eq(imp._syncModal.code1, "1234", "typed letters never reach a code field") +imp:textinput("5678") +eq(imp._syncModal.code1, "12345678", "the field fills to eight digits") +imp:textinput("9") +eq(imp._syncModal.code1, "12345678", "and refuses a ninth") +imp:keypressed("backspace") +eq(imp._syncModal.code1, "1234567", "backspace drops one digit") +imp:textinput("8") + +imp:_syncFocusField("code2") +eq(imp._syncFocus, "code2", "focus moves to the second code") +eq(imp._syncModal.code1, "12345678", "without disturbing the first") +imp:_syncFocusField("code2") +eq(imp._syncFocus, nil, "tapping the focused field again releases it") +imp:_syncFocusField("code2") +imp:textinput("87654321") + +imp:_syncLink() +eq(eng.calls[#eng.calls][1], "link", "Link sends both codes to the engine") +eq(eng.calls[#eng.calls][2], "12345678", "the first code as typed") +eq(eng.calls[#eng.calls][3], "87654321", "and the second") +eq(imp._syncModal.view, "home", "a linked device comes back to the home view") +eq(imp._syncModal.code1, "", "and the codes are not left lying in the field") +eq(imp._syncModal.code2, "", "either of them") +eq(imp._syncFocus, nil, "with the keyboard released") + +local short = launcher(fakeEngine()) +short:_openSync() +short:_syncView("link") +short._syncModal.code1, short._syncModal.code2 = "1234", "87654321" +eq(short:_syncLink(), false, "a short code does not link") +eq(short._syncModal.code1, "1234", + "and what was typed stays put to be corrected") + +imp:_syncFocusField("code1") +imp:keypressed("escape") +eq(imp._syncFocus, nil, "escape out of a field releases the keyboard") +check(imp._syncModal ~= nil, "and leaves the modal up") +imp:keypressed("escape") +eq(imp._syncModal, nil, "escape closes the modal") + +imp:_openSync() +imp:_syncView("mods") +imp:_syncShareMods() +eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code") +imp:_syncFocusField("share") +imp:textinput("k7qw3m") +eq(imp._syncModal.share, "K7QW3M", "a typed share code is normalized") +imp:_syncGetShare() +eq(eng.calls[#eng.calls][2], "K7QW3M", "and handed to the engine as typed") +imp._syncModal.progress = nil +imp:_syncApplyMods() +eq(imp._syncModal.progress, nil, + "the progress line is cleared once the apply returns") + +imp:_syncResolve("red/abc", "both") +eq(eng.calls[#eng.calls][1], "resolve", "the conflict buttons call the engine") +eq(eng.calls[#eng.calls][3], "both", "with the choice the player pressed") + +imp:_syncUnlink() +eq(eng.isLinked, false, "Unlink drops the device") +eq(imp._syncModal.view, "home", "and the modal returns to the home view") + +local bare = RomImporter.new(function() end, { launcher = true }) +bare._sync = false +bare._syncTransportOk = true +bare:_openSync() +check(bare._syncModal ~= nil, "the modal opens without an engine") +bare:_closeSync() +eq(bare._syncModal, nil, "and closes again") + +local function controls(imp2) + love.graphics.getDimensions = function() return 900, 780 end + love.graphics.getPixelDimensions = love.graphics.getDimensions + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp2) + local labels = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then labels[r.label] = true end + end + Kit.audit = nil + check(ok, "the sync modal draws: " .. tostring(err)) + return labels +end + +local rEng = fakeEngine() +local rImp = launcher(rEng) +rImp:_openSync() +local labels = controls(rImp) +check(labels["Create sync account"], "an unlinked device is offered an account") +check(labels["Link this device"], "and the link road") + +rEng:createAccount("mac") +labels = controls(rImp) +check(labels["Sync now"], "a linked device can sync on demand") +check(labels["Unlink this device"], "and unlink") +check(labels["Share or get a mod list"], "and reach the mod list road") + +rImp:_syncView("link") +labels = controls(rImp) +check(labels["Back"], "the link view can back out") + +rImp:_syncView("mods") +rEng.shareCode = "K7QW3M" +rEng.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } }, + toEnable = {}, missing = {} } +labels = controls(rImp) +check(labels["Share mod list"], "the mod view shares a list") +check(labels["Get mod list"], "and fetches one") +check(labels["Apply these mods"], "a fetched plan can be applied") + +rImp:_syncView("home") +rEng.devices = { + { id = "0a1b2c3d", label = "OS X", current = true }, + { id = "99998888", label = "Android" }, +} +labels = controls(rImp) +check(labels["Unlink Android"], "the other linked devices can be revoked here") +check(labels["OS X \194\183 this device"], + "and this one is named rather than offered twice") + +local devRows = LauncherView.syncDeviceRows(rEng) +eq(#devRows, 2, "the modal reads the device list off the engine") +eq(devRows[1].current, true, "knowing which one is this device") +eq(#LauncherView.syncDeviceRows({}), 0, + "an engine that has not synced yet lists nothing") + +local offline = launcher(fakeEngine()) +offline._syncTransportOk = false +offline:_openSync() +labels = controls(offline) +check(not labels["Create sync account"], + "a device with no way to send signed requests is not offered an account") +check(labels["Close"], "it just explains itself and closes") + +rEng.devices = nil +rEng.phase = "conflict" +rEng.conflicts = { { + key = "red/abc", version = "red", overlap = true, + localMeta = { savedAt = 1700000000, sessionStart = 1699999000, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }, + remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500, + summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } }, +} } +labels = controls(rImp) +check(labels["Keep this device"], "a conflict offers this device") +check(labels["Keep the other device"], "the other device") +check(labels["Keep both"], "and keeping both") +check(not labels["Sync now"], + "a conflict takes over the modal until it is answered") + +local side = LauncherView.syncSideText({ savedAt = 1700000000, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }) +check(side:find("ASH", 1, true) ~= nil, "a side summary names the trainer") +check(side:find("3 badges", 1, true) ~= nil, "counts badges") +check(side:find("5:42", 1, true) ~= nil, "and shows play time") +eq(LauncherView.syncSideText(nil), "no details", + "a side with no metadata says so rather than drawing blank") + +local quiet = launcher(fakeEngine()) +quiet:_pumpSync(0.016) +eq(quiet._syncModal, nil, "a quiet auto-sync never interrupts the launcher") + +local raised = launcher(fakeEngine({ phase = "conflict", + conflicts = { { key = "red/abc", version = "red" } } })) +raised:_pumpSync(0.016) +check(raised._syncModal ~= nil, + "a conflict found by the boot sync opens the prompt on its own") +raised:_closeSync() +raised:_pumpSync(0.016) +eq(raised._syncModal, nil, + "and a prompt the player dismissed does not reopen every frame") + +local view = read("src/import/LauncherView.lua") +local impSrc = read("src/import/RomImporter.lua") + +check(view:find('"tab-sync"', 1, true) ~= nil, + "the header tab row carries a Save Sync button") +local header = view:match("local HEADER_TABS = %{(.-)%}\n") +check(header and header:find('id = "skins"', 1, true) ~= nil, + "and it sits beside the skins tab") +check(view:find('"BETA"', 1, true) ~= nil, + "the button and the modal are labelled BETA") +check(view:find("buildSyncModal", 1, true) ~= nil, + "the sync UI is a modal, so it works from any tab") +local modals = view:match("local function modalUp%(imp%)(.-)\nend") +check(modals and modals:find("_syncModal", 1, true) ~= nil, + "the modal raises the click shield like every other one") +check(view:find("if imp._syncModal then buildSyncModal", 1, true) ~= nil, + "and buildModals routes it") + +check(impSrc:find("_pumpSync(dt)", 1, true) ~= nil, + "the launcher pumps the sync engine every frame") +local pump = impSrc:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n") +check(pump and pump:find("self.launcher", 1, true) ~= nil, + "only the interactive launcher boots an engine of its own") +check(impSrc:find("_syncTypeInto", 1, true) ~= nil, + "text input is routed through the code filter") + +T.finish("launcher_sync_modal") diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua new file mode 100644 index 00000000..20001bff --- /dev/null +++ b/tests/engine/skin_format_import_test.lua @@ -0,0 +1,626 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local DeltaSkin = require("src.core.DeltaSkin") +local Json = require("src.link.Json") + +local function near(got, want, msg) + return check(type(got) == "number" and math.abs(got - want) < 1e-6, + ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +local function hasWarning(skin, fragment) + for _, w in ipairs(skin.warnings or {}) do + if tostring(w):find(fragment, 1, true) then return true end + end + return false +end + +local function unzip(bytes) + local out, i = {}, 1 + while bytes:sub(i, i + 3) == "PK\3\4" do + local function u16(off) + local a, b = bytes:byte(i + off, i + off + 1) + return a + b * 256 + end + local function u32(off) + local a, b, c, d = bytes:byte(i + off, i + off + 3) + return a + b * 256 + c * 65536 + d * 16777216 + end + local size, nameLen, extraLen = u32(18), u16(26), u16(28) + local name = bytes:sub(i + 30, i + 29 + nameLen) + local start = i + 30 + nameLen + extraLen + out[name] = bytes:sub(start, start + size - 1) + out[#out + 1] = name + i = start + size + end + return out +end + +local function readBytes(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local GAMEBOY_CFG = [[ +overlays = 4 + +overlay0_name = "landscape" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 2.0 +overlay0_aspect_ratio = 2.22222222222222 +overlay0_descs = 13 +overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292" +overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292" +overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292" +overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292" +overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292" +overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292" +overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292" +overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292" +overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875" +overlay0_desc9_overlay = img/a.png +overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875" +overlay0_desc10_overlay = img/b.png +overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400" +overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400" + +overlay1_name = "portrait" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 0.45 +overlay1_descs = 2 +overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525" +overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525" + +overlay2_name = "menu" +overlay2_full_screen = true +overlay2_normalized = true +overlay2_descs = 1 +overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1" + +overlay3_name = "hide" +overlay3_full_screen = true +overlay3_normalized = true +overlay3_descs = 1 +overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04" +overlay3_desc0_next_target = "landscape" +]] + +local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG)) +eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages") +eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape") +eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait") +check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay") +eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair") +eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed") +check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing") +eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art") +eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background") +eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name") +local named = {} +for _, ctl in ipairs(gameboy.pages[1].controls) do + for _, btn in ipairs(ctl.buttons) do named[btn] = true end +end +for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do + check(named[btn], "landscape binds GB " .. btn) +end +eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing") + +local SPACED_CFG = [[ +overlays = 1 +overlay0_name = "spaced" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05" +overlay0_desc0_saturate_pct = 0.6 +overlay0_desc0_exclusive = true +overlay0_desc0_movable = true +overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05 +]] +local spaced = assert(TouchSkin.parse(SPACED_CFG)) +near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed") +check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed") +check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc") +eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited") +eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses") +eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind") +near(spaced.pages[1].controls[1].x, 0.5, "space-separated position") +eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too") +eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape") + +local SHORT_CFG = [[ +overlays = 1 +overlay0_name = "short" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]] +local short = assert(TouchSkin.parse(SHORT_CFG)) +eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked") +check(hasWarning(short, "missing desc 1"), "and the importer says so") + +eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil, + "a cfg without the overlays key is refused") + +local AREA_CFG = [[ +overlays = 1 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 3 +overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc0_reach_x = 1.5 +overlay0_desc0_movable = true +overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08" +overlay0_desc1_up = "start" +overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1" +overlay0_desc2_saturate_pct = 0.6 +overlay0_desc2_exclusive = true +]] +local area = assert(TouchSkin.parse(AREA_CFG)) +local ap = area.pages[1] +near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16") +check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock") +eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes") + +local art = ap.controls[1] +check(art.decorative, "the dpad_area art is carried by a decoration") +eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image") +near(art.rangeX, 0.15, "sized like the area it replaces") + +local sectorE = ap.controls[2] +eq(sectorE.spec, "right", "the first sector is the one pointing right") +near(sectorE.x, 0.2, "every sector sits on the area centre") +near(sectorE.y, 0.7, "on both axes") +near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it") +near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is") +near(sectorE.reachRight, 1.5, "on both sides") +eq(sectorE.sector, 1, "the sector index is kept for the hit test") +eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner") +eq(ap.controls[4].spec, "down", "then straight down, y growing downwards") +eq(ap.controls[8].spec, "up", "and straight up seven sectors along") +check(ap.controls[1].movable, "_movable is parsed") + +local abxy = ap.controls[10] +eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A") +eq(abxy.buttons[1], "a", "and reaches that GB button") +eq(ap.controls[16].spec, "start", "abxy_area honours an _up override") +eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides") +check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set") +check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert") +eq(abxy.shape, "radial", "a radial area keeps its ellipse") + +eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") +check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors") +eq(ap.controls[23].spec, "left|up", "with all eight sectors") +near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area") + +local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 } +local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area) +eq(#sectors, 8, "a dpad area expands into eight sector hitboxes") +local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true, + aspect = 1, controls = sectors } +local function hitSpecs(px, py) + local out = {} + for _, ctl in ipairs(sectors) do + if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end + end + return table.concat(out, "+") +end +eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone") +eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right") +eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards") +eq(hitSpecs(50, 40), "up", "a touch above centre is up") +eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions") +eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction") +eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner") +eq(hitSpecs(50, 80), "", "outside the area nothing fires") + +local PIXEL_NO_IMAGE = [[ +overlays = 1 +overlay0_name = "pixels" +overlay0_descs = 1 +overlay0_desc0 = "a,120,80,rect,20,10" +]] +local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE)) +check(hasWarning(noImage, "no base image"), + "pixel coords without a base image are called out") +check(noImage.pages[1].pixelCoords == false, + "and read as normalized rather than dividing by nothing") + +love.filesystem.write("skins/px/overlay.cfg", [[ +overlays = 1 +overlay0_name = "px" +overlay0_overlay = img/base.png +overlay0_full_screen = true +overlay0_descs = 2 +overlay0_desc0 = "a,4,4,rect,2,1" +overlay0_desc1 = "b,6,2,rect,1,1" +overlay0_desc1_normalized = true +]]) +local px = assert(TouchSkin.load("skins/px", "px")) +local pxPage = px.pages[1] +check(pxPage.image ~= nil, "the base overlay image loads") +local iw, ih = pxPage.image:getDimensions() +near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width") +near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height") +near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents") +near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out") +check(pxPage.pixelCoords == false, "the page is normalized once converted") + +love.filesystem.write("skins/pxbad/overlay.cfg", [[ +overlays = 1 +overlay0_name = "pxbad" +overlay0_overlay = img/broken.png +overlay0_descs = 1 +overlay0_desc0 = "a,4,4,rect,2,1" +]]) +local savedNewImage = love.graphics.newImage +love.graphics.newImage = function() error("unreadable image") end +local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad") +love.graphics.newImage = savedNewImage +eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load") +check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil, + "and the error names the image it could not read") + +local DELTA_JSON = [[ +{ + "name": "Test GBC", + "identifier": "com.example.gbc.test", + "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "debug": false, + "representations": { + "iphone": { + "edgeToEdge": { + "portrait": { + "assets": { "small": "p_small.png", "medium": "p_medium.png", + "large": "p_large.png" }, + "items": [ + { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64}, + "mask": "circle" }, + { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64}, + "extendedEdges": {"right":16} }, + { "inputs": {"up":"up","down":"down","left":"left","right":"right"}, + "frame": {"x":16,"y":320,"width":96,"height":96} }, + { "inputs": ["start","select"], + "frame": {"x":128,"y":448,"width":64,"height":32} }, + { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} }, + { "inputs": ["quickSave"], + "frame": {"x":288,"y":0,"width":32,"height":32} } + ], + "mappingSize": {"width":320,"height":480}, + "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8}, + "translucent": false, + "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144}, + "outputFrame": {"x":0,"y":32,"width":320,"height":288} }] + } + }, + "standard": { + "portrait": { "items": [], "mappingSize": {"width":320,"height":480} } + } + } + } +} +]] + +local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON)) +eq(delta.format, "delta", "a .deltaskin parses into the native model") +eq(delta.name, "Test GBC", "info.json name") +eq(delta.system, "gbc", "gbc covers both GB and GBC") +eq(#delta.pages, 1, "only the orientations present become pages") +local dp = delta.pages[1] +eq(dp.name, "portrait", "the page is named for its orientation") +eq(dp.orient, "portrait", "and locked to it") +eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone") +check(dp.fullScreen, "Delta stretches its skin over the whole surface") +check(not dp.aspectFromCfg, "so nothing letterboxes it") +near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect") +eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed") + +local dA = dp.controls[1] +eq(dA.buttons[1], "a", "an inputs array binds its button") +eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox') +near(dA.x, 0.85, "frame top-left plus half width is the native centre") +near(dA.y, 352 / 480, "and the same for y") +near(dA.rangeX, 0.1, "frame width halves into the native half extent") +near(dA.reachLeft, 1.25, "orientation extendedEdges become reach") + +local dB = dp.controls[2] +near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side") +near(dB.reachLeft, 1.25, "and leaves the others inherited") + +eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid") +near(dp.controls[3].x, 0.1, "dpad top-left cell x") +near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame") +near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them") +eq(dp.controls[4].spec, "up", "dpad top-centre cell") +eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell") + +eq(dp.controls[11].spec, "start|select", "a multi-input item fires both") +eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey") +check(dp.controls[13].decorative, + "quickSave has no engine hotkey, so it is inert rather than a game button") + +check(dp.viewport ~= nil, "screens[] places the emulator picture") +near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize") +near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize") + +local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500) +eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") +eq(bw, 1000, "delta page box fills the width") +eq(bh, 500, "delta page box fills the height") + +local LEGACY_SCREEN = [[ +{ "gameTypeIdentifier": "public.aoshuang.game.gbc", + "representations": { "iphone": { "standard": { "landscape": { + "mappingSize": {"width":640,"height":320}, + "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288}, + "translucent": true, + "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown", + "left":"analogStickLeft","right":"analogStickRight"}, + "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } } +]] +local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN)) +eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted") +eq(#legacy.pages, 1, "landscape only") +eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock") +near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect") +near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls") +eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad") +eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped") + +local snes = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes", + "representations": { "iphone": { "standard": { "portrait": { + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns") +eq(#snes.pages, 1, "but still imports") + +eq(select(1, DeltaSkin.parse([[ +{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba", + "representations": { "iphone": { "portrait": { "assets": {} } } } } +]])), nil, "a GBA4iOS skin is refused") +local _, gbaErr = DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} } +]]) +check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil, + "and the message names the old format") + +local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }') +check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil, + "info.json without a gameTypeIdentifier is refused by name") +eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused") +eq(select(1, DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} } +]])), nil, "an empty representations tree is refused") + +local PDF_JSON = [[ +{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]] +local pdf = assert(DeltaSkin.parse(PDF_JSON)) +eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") +local convert = DeltaSkin.needsConversion(pdf) +check(convert ~= nil, "PDF-only skins report that they need conversion") +if convert then + check(convert.pdfOnly, "the report is flagged pdfOnly") + eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert") +end +eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion") + +local mixed = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "art.pdf", "medium": "art.png" }, + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF") +eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed") + +eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png", + "the ladder falls back to the largest shipped asset") +eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" }, + { targetWidth = 640 }, {}), "s.png", + "a small target takes the small asset") +eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png", + 'the Manic "normal" alias is accepted') + +love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[ +{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "Portrait.PNG" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ] + } } } } } +]]) +love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n") + +local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") +eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) +local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) +eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") +eq(wrapped.name, "Wrapped", "and its name comes from info.json") +eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", + "the wrapping folder is prefixed onto assets and the real file name wins") +eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") + +love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) +local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") +eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible") +check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, + "with the message that asks for a PNG version") +eq(love.filesystem.read("skins/vector.deltaskin"), nil, + "and the refused archive is not left behind") + +eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, + "a GBA4iOS .gbcskin is refused at the door") +local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") +check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil, + "with a message that names the format") +eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil, + "an unknown archive extension is refused") +eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin") +eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip") +eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive") + +local AUTHORED = [[ +return { name = "Authored", pages = { + { name = "portrait", orient = "portrait", fullScreen = true, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + controls = { + { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" }, + { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial", + reachRight = 1.5 }, + { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 }, + { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 }, + { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" }, + } }, + { name = "landscape", orient = "landscape", fullScreen = true, + controls = { + { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" }, + } }, +} } +]] +local authored = assert(TouchSkin.parseNative(AUTHORED)) +authored.id = "authored" +authored.root = "skins/authored" + +local cfgText = TouchSkin.toRetroArchConfig(authored) +check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays") +local reparsed = assert(TouchSkin.parse(cfgText)) +eq(#reparsed.pages, 2, "the generated cfg round-trips both pages") +eq(reparsed.pages[1].name, "portrait", "and their names") +eq(#reparsed.pages[1].controls, 5, "and every desc") +eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip") +near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip") +near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive") +eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives") +near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives") +eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive") +check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration") +eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art") +near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives") +eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name") + +local KEY_SKIN = [[ +return { name = "Keys", pages = { + { name = "portrait", fullScreen = true, controls = { + { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 }, + } }, +} } +]] +local keySkin = assert(TouchSkin.parseNative(KEY_SKIN)) +local keyCfg = TouchSkin.toRetroArchConfig(keySkin) +check(keyCfg:find("retrok_escape", 1, true) ~= nil, + "a key bind exports in the grammar RetroArch understands") +check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling") +eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape", + "which this importer still reads back as the same key") + +local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages }) +local areaBack = assert(TouchSkin.parse(areaCfg)) +eq(#areaBack.pages[1].controls, #ap.controls, + "an area desc exports as one desc, not eight overlapping ones") +check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back") +check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override") +eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip") + +local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages })) +local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait +local dpadItem +for _, item in ipairs(areaRep.items) do + if not dpadItem and type(item.inputs) == "table" and item.inputs.up then + dpadItem = item + end +end +check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item") +eq(dpadItem.inputs.left, "left", "carrying each direction") +eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another") + +local raPath = os.tmpname() .. "-ra.zip" +local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath) +eq(raWritten, raPath, "exportRetroArch writes where it was told") +eq(raMissing[1], "img/dpad.png", "and reports art it could not find") +local raZip = unzip(readBytes(raPath)) +eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg") +check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes") +check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts") +os.remove(raPath) + +local dsPath = os.tmpname() .. ".deltaskin" +local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath }) +eq(dsWritten, dsPath, "exportDelta writes where it was told") +check(#dsWarnings > 0, "and warns that per-button art has nowhere to go") +local dsZip = unzip(readBytes(dsPath)) +eq(dsZip[1], "info.json", "the .deltaskin leads with info.json") +local info = assert(Json.decode(dsZip["info.json"])) +eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc", + "the export claims the GBC game type") +eq(info.name, "Authored", "and carries the skin name") +check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin") +local rep = info.representations.iphone.edgeToEdge.portrait +check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted") +eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080, + "standard portrait maps 1080 wide") +eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall") +eq(#rep.items, 4, "only bound controls become Delta items") +eq(rep.items[1].inputs[1], "a", "the first item is A") +eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask") +eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre") +eq(rep.items[1].frame.width, 216, "frame width is the full extent") +eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges") +eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input") +eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame") +eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport") +eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920, + "the landscape page maps 1920 wide") + +local back = assert(DeltaSkin.parse(dsZip["info.json"])) +eq(#back.pages, 2, "the exported skin re-imports both orientations") +local bp = back.pages[1] +eq(#bp.controls, 4, "with every bound control") +near(bp.controls[1].x, 0.8, "and the same centres it started with") +near(bp.controls[1].rangeX, 0.1, "and the same half extents") +eq(bp.controls[1].shape, "radial", "and the same hitbox shape") +near(bp.controls[2].reachRight, 1.5, "and the same reach") +near(bp.viewport.h, 0.5, "and the same screen cutout") +os.remove(dsPath) + +love.filesystem.write("skins/collide/overlay.cfg", [[ +overlays = 1 +overlay0_name = "collide" +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +local collide = assert(TouchSkin.load("skins/collide", "collide")) +local defaultDelta = assert(TouchSkin.exportDelta(collide)) +eq(defaultDelta, "skins/_export/collide.deltaskin", + "a default export lands outside the folder the skin list scans") +local listedRoot, listedExport +for _, entry in ipairs(TouchSkin.list()) do + if entry.id == "collide" then listedRoot = entry.root end + if entry.id == "_export" then listedExport = true end +end +eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") +check(not listedExport, "and the export folder is not a skin of its own") + +T.finish("skin_format_import") diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua new file mode 100644 index 00000000..ec794e78 --- /dev/null +++ b/tests/engine/skin_studio_ux.lua @@ -0,0 +1,339 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + Studio.thumbs = {} + Studio.available = {} + Studio.availableMeta = {} + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.status, Studio.statusErr = nil, false + Studio.imageTarget = "idle" + return Studio.skin +end + +session() +check(not Studio.canUndo(), "a fresh session has nothing to undo") +Studio.addControl() +eq(#Studio.page().controls, 1, "a control was added") +check(Studio.canUndo(), "adding a control is undoable") +Studio.undo() +eq(#Studio.page().controls, 0, "undo takes the control back off") +check(Studio.canRedo(), "and offers a redo") +Studio.redo() +eq(#Studio.page().controls, 1, "redo puts it back") +check(not Studio.canRedo(), "the redo stack is spent") + +Studio.addControl() +check(not Studio.canRedo(), "a fresh edit clears the redo stack") + +session() +Studio.addControl() +local before = Studio.page().controls[1] +Studio.pushUndo() +before.x = 0.9 +Studio.undo() +check(Studio.page().controls[1] ~= before, + "undo restores a copy, not the edited table") +near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position") + +session() +for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end +eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped") + +session() +check(not Studio.undo(), "undo on an empty stack reports nothing to do") +check(not Studio.redo(), "and so does redo") + +local realIsDown = love.keyboard and love.keyboard.isDown +love.keyboard = love.keyboard or {} +local held = {} +love.keyboard.isDown = function(...) + for _, k in ipairs({ ... }) do if held[k] then return true end end + return false +end +session() +Studio.addControl() +Studio.addControl() +held.lctrl = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+Z undoes one step") +Studio.keypressed("z") +eq(#Studio.page().controls, 0, "and again") +held.lshift = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further") +held.lshift = nil +Studio.keypressed("y") +eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well") +held.lctrl = nil +if realIsDown then love.keyboard.isDown = realIsDown end + +session() +local ran = 0 +check(Studio.guard("lose it?", function() ran = ran + 1 end), + "a clean skin runs the action straight away") +eq(ran, 1, "and does not prompt") +check(Studio.confirm == nil, "no prompt is left up") + +Studio.dirty = true +check(not Studio.guard("lose it?", function() ran = ran + 1 end), + "a dirty skin defers the action") +eq(ran, 1, "the action has not run yet") +check(Studio.confirm ~= nil, "and a prompt is up") +Studio.confirmNo() +eq(ran, 1, "cancelling drops the action") +check(Studio.confirm == nil, "and closes the prompt") + +Studio.guard("lose it?", function() ran = ran + 1 end) +Studio.confirmYes() +eq(ran, 2, "confirming runs it") +check(Studio.confirm == nil, "and closes the prompt") + +session() +Studio.dirty = true +Studio.openLoadPicker() +check(Studio.confirm ~= nil, "Load prompts over unsaved work") +check(Studio.modal == nil, "and does not open the picker yet") +Studio.confirmYes() +check(Studio.modal ~= nil and Studio.modal.kind == "open", + "confirming opens the picker") +Studio.closeModal() + +eq(Studio.toggleBindPart("nul", "left"), "left", + "a bind starts from decoration") +eq(Studio.toggleBindPart("left", "up"), "left|up", + "directions combine in the canonical order") +eq(Studio.toggleBindPart("up", "left"), "left|up", + "and the order does not depend on which was added first") +eq(Studio.toggleBindPart("left|up", "up"), "left", + "toggling a part off removes it") +eq(Studio.toggleBindPart("left", "left"), "nul", + "removing the last part leaves decoration") +eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well") +check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part") +check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent") + +session() +check(not Studio.openBindPicker(), "the bind picker needs a selected control") +check(Studio.statusErr, "and says so as an error") +Studio.addControl() +check(Studio.openBindPicker(), "with a control selected it opens") +eq(Studio.modal.kind, "bind", "as the bind modal") +Studio.setBindSpec("start") +eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec") +eq(Studio.selectedControl().buttons[1], "start", "and reparses it") +Studio.undo() +eq(Studio.selectedControl().spec, "a", "the bind change is undoable") +Studio.closeModal() + +Studio.toggleSelectedBindPart("b") +eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind") +eq(#Studio.selectedControl().buttons, 2, "which fires both buttons") + +local specs = {} +for _, group in ipairs(Studio.BIND_GROUPS) do + check(#group.specs > 0, group.title .. " lists at least one bind") + for _, spec in ipairs(group.specs) do specs[spec] = true end +end +check(specs["a"] and specs["start"], "the GB buttons are reachable") +check(specs["overlay_previous"], "overlay_previous is reachable at last") +check(specs["pause_toggle"] and specs["exit_emulator"], + "so are the hotkeys the old cycle could not reach") +check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["nul"], "decoration is still an option") + +session() +Studio.addControl() +Studio.addControl() +Studio.selected = 1 +local first = Studio.page().controls[1] +check(Studio.moveControlOrder(1), "bring forward moves the control up") +eq(Studio.selected, 2, "and follows it with the selection") +check(Studio.page().controls[2] == first, "the control really moved") +check(not Studio.moveControlOrder(1), "the front control cannot go further") +check(Studio.moveControlOrder(-1), "send back moves it down again") +eq(Studio.selected, 1, "selection follows back") +check(not Studio.moveControlOrder(-1), "and the back control stays put") + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +local canvas = Studio.canvas() +local startX, startY = ctl.x, ctl.y +Studio.nudge(1, 0) +near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel") +Studio.nudge(0, 1, true) +near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten") +Studio.undo() +near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable") +Studio.selected = nil +check(not Studio.nudge(1, 0), "nothing selected, nothing nudged") + +check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1, + "up is negative y on the canvas") +check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1, + "and left is negative x") + +local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4) +near(off, 2, 1e-9, "an edge within tolerance snaps to the guide") +near(line, 152, 1e-9, "and reports the line it snapped to") +off, line = Studio.snapOffset({ 100 }, { 400 }, 4) +eq(off, 0, "a line out of range does not move anything") +eq(line, nil, "and reports no guide") +off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4) +near(off, 1, 1e-9, "the nearest candidate wins") + +session() +Studio.addControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local xs, ys = Studio.snapLines(Studio.page(), r, nil) +check(#xs >= 6 and #ys >= 6, + "snap lines cover the page box and every other control") +local skipped = select(1, Studio.snapLines(Studio.page(), r, 1)) +eq(#skipped, 3, "the dragged control is not a guide for itself") + +session() +Studio.addControl() +Studio.page().controls[1].x = 0.25 +Studio.addControl() +Studio.selected = 2 +local moving = Studio.selectedControl() +moving.x = 0.6 +local target = Studio.page().controls[1] +local bx, by, bw, bh = 0, 0, 0, 0 +local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y) +bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2 +local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target, + r.w, r.h, r.x, r.y)) +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = bx, by = by, bw = bw, bh = bh } +Studio.updateDrag((tcx - cx) + 3, 0, r) +local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y)) +near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre") +check(Studio.guides ~= nil and Studio.guides.x ~= nil, + "and a guide line is recorded for the canvas to draw") +Studio.drag = nil + +session() +Studio.addPage() +Studio.addPage() +eq(#Studio.skin.pages, 3, "three pages") +check(Studio.setPage(1), "setPage jumps to a page by index") +eq(Studio.pageIndex, 1, "and lands there") +check(not Studio.setPage(9), "an index past the end is refused") +Studio.nextPage() +eq(Studio.pageIndex, 2, "next page still cycles") +local name, detail = Studio.pageLabel(2) +eq(name, "page2", "the page list shows the page name") +check(detail:find("controls", 1, true) ~= nil, "and what is on it") + +check(Studio.renamePage("landscape"), "a page can be renamed") +eq(Studio.page().name, "landscape", "and keeps the new name") +check(not Studio.renamePage(" "), "an empty name is refused") +Studio.undo() +eq(Studio.page().name, "page2", "renaming is undoable") + +Studio.pageIndex = 2 +check(Studio.deletePage(2), "a page can be deleted") +eq(#Studio.skin.pages, 2, "and the skin loses it") +Studio.deletePage(1) +check(not Studio.deletePage(1), "the last page cannot be deleted") +check(Studio.statusErr, "and the studio says why") + +session() +check(not Studio.openImagePicker("idle"), "art needs a selected control") +Studio.addControl() +check(Studio.openImagePicker("idle"), "with one selected the grid opens") +eq(Studio.modal.kind, "image", "as the image modal") +eq(Studio.imageTarget, "idle", "aimed at the idle art") +check(Studio.openImagePicker("bezel"), "the bezel needs no selection") +eq(Studio.currentImagePath(), nil, "a new page has no bezel yet") +Studio.imageTarget = "idle" +Studio.selectedControl().imagePath = "img/a.png" +eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art") +Studio.chooseImage(nil) +eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art") +eq(Studio.modal, nil, "and closes the picker") +Studio.undo() +eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable") + +session() +Studio.setStatus("boom", true) +check(Studio.statusErr, "an error status is flagged") +Studio.addControl() +eq(Studio.status, "boom", "a later edit does not wipe the error off the footer") +Studio.setStatus("fine") +Studio.addControl() +eq(Studio.status, nil, "an ordinary status still clears on the next edit") +Studio.setStatus("boom", true) +Studio.statusAt = -1000 +Studio.expireStatus() +eq(Studio.status, nil, "and an error clears itself after a few seconds") + +local ids = {} +for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end +check(ids.native and ids.retroarch and ids.delta, + "the export menu offers all three formats") + +session() +Studio.skinIdField = "uxtest" +local nativePath = Studio.exportAs("native") +check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil, + "the native export writes a .zip") +local raPath = Studio.exportAs("retroarch") +check(raPath ~= nil and raPath:match("%.zip$") ~= nil, + "the RetroArch export writes a .zip") +local deltaPath = Studio.exportAs("delta") +check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil, + "the Delta export writes a .deltaskin") +check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk") +eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file") + +eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch", + "a format badge reads in words") +eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included") + +love.graphics.getDimensions = love.graphics.getDimensions + or function() return 1280, 720 end +session() +Studio.addControl() +for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do + Studio.openModal(kind) + check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") +end +Studio.closeModal() +Studio.ask("sure?", function() end) +check(pcall(Studio.draw), "and with the confirm prompt up") +Studio.confirmNo() + +Studio.openModal("bind") +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.mousepressed(50, 50, 1) +eq(Studio.drag, nil, "a click under an open modal does not grab a control") +Studio.closeModal() + +T.finish("skin_studio_ux") diff --git a/tests/engine/skin_viewport_containment.lua b/tests/engine/skin_viewport_containment.lua new file mode 100644 index 00000000..9ad35d4f --- /dev/null +++ b/tests/engine/skin_viewport_containment.lua @@ -0,0 +1,214 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TouchSkin = require("src.core.TouchSkin") +local Playfield = require("src.render.Playfield") +local Renderer = require("src.render.Renderer") +local Chrome = require("src.ui.gen2.Chrome") +local Zoom = require("src.render.Zoom") + +local EPS = 1e-6 + +local function setWindow(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function cfg(viewport, extra) + return ([[ +overlays = 1 +overlay0_name = "bezel" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_viewport = "%s" +%s +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]]):format(viewport, extra or "") +end + +local function useSkin(viewport, extra) + local skin = assert(TouchSkin.parse(cfg(viewport, extra))) + TouchSkin.setActive(skin) + TouchSkin.setOverlayLive(false) + return skin +end + +local function inside(x, y, w, h, bx, by, bw, bh) + return x >= bx - EPS and y >= by - EPS + and x + w <= bx + bw + EPS and y + h <= by + bh + EPS +end + +setWindow(640, 576) +TouchSkin.setActive(nil) +Renderer:init() +local plain = Renderer:frameRects() +eq(plain.cut, false, "no skin: no cutout") +eq(plain.vux, 0, "no skin: the picture starts at the window origin") +eq(plain.vuw, 640, "no skin: the picture is the whole window") +eq(plain.Sp, 4, "no skin: 640x576 fits four whole GB pixels") +eq(plain.uox, 0, "no skin: the UI letterbox fills the window") +eq(select(3, Playfield.rect(640, 576)), 640, "no skin: the playfield is the window") +eq(Chrome.fitScale(640, 576), 4, "no skin: Gold fits the window the same way") + +local WINDOWS = { + { 640, 576 }, { 1280, 720 }, { 1920, 1080 }, + { 800, 480 }, { 480, 800 }, { 360, 640 }, +} +local VIEWPORTS = { + "0.2335,0.0855,0.5335,0.830", + "0.2,0.15,0.6,0.5", + "0.05,0.05,0.9,0.35", + "0.3,0.1,0.4,0.8", +} +local UI_SIZES = { { 160, 144 }, { 304, 144 } } + +local escapes, uncut, cases = 0, 0, 0 +for _, win in ipairs(WINDOWS) do + setWindow(win[1], win[2]) + for _, vp in ipairs(VIEWPORTS) do + useSkin(vp) + Renderer:init() + for _, size in ipairs(UI_SIZES) do + Renderer:setUISize(size[1], size[2]) + for off = -8, 8 do + Zoom.offset = off + for _, fill in ipairs({ false, true }) do + for _, centered in ipairs({ true, false }) do + Renderer.uiFill = fill + Renderer.uiCentered = centered + Renderer.worldActive = true + cases = cases + 1 + local r = Renderer:frameRects() + local ux, uy, uw, uh = Renderer.clipToView(r, r.uox, r.uoy, + r.uvpw, r.uvph) + if not inside(ux, uy, uw, uh, r.vux, r.vuy, r.vuw, r.vuh) then + escapes = escapes + 1 + end + if uw < r.uvpw - EPS or uh < r.uvph - EPS then uncut = uncut + 1 end + local vw, vh = Renderer:worldViewSize() + local sp = Zoom.scale(r.Sp) + if vw * sp > r.vuw + 2 * sp + EPS + or vh * sp > r.vuh + 2 * sp + EPS then + escapes = escapes + 1 + end + end + end + end + end + end +end +check(cases > 1000, "the sweep covers every window x cutout x zoom x layout") +eq(escapes, 0, "no zoom, battle surface or UI layout puts a rect past the cutout") +eq(uncut, 0, "and the UI was sized to fit, so the clip never has to cut it") + +setWindow(1280, 720) +useSkin("0.25,0.1,0.5,0.6") +Renderer:init() +Renderer:setUISize(160, 144) +Renderer.uiFill, Renderer.uiCentered = false, true +Zoom.offset = 0 +local r = Renderer:frameRects() +eq(r.cut, true, "the skin's cutout is folded into the frame") +eq(r.vux, 320, "cutout x") +eq(r.vuy, 72, "cutout y") +eq(r.vuw, 640, "cutout width") +eq(r.vuh, 432, "cutout height") +eq(r.Sp, 3, "the fit is measured against the cutout, not the window") +check(inside(r.uox, r.uoy, r.uvpw, r.uvph, r.vux, r.vuy, r.vuw, r.vuh), + "the UI letterbox sits inside the cutout") +check(inside(r.ox, r.oy, r.vpw, r.vph, r.vux, r.vuy, r.vuw, r.vuh), + "so does the world letterbox") + +local lo, hi = Zoom.offsetRange(r.Sp) +for off = lo, hi do + Zoom.offset = off + local z = Renderer:frameRects() + check(inside(z.uox, z.uoy, z.uvpw, z.uvph, z.vux, z.vuy, z.vuw, z.vuh), + "zoom " .. Zoom.offsetLabel(off) .. " keeps the UI in the cutout") + local vw, vh = Renderer:worldViewSize() + local sp = Zoom.scale(z.Sp) + check(vw * sp <= z.vuw + 2 * sp and vh * sp <= z.vuh + 2 * sp, + "zoom " .. Zoom.offsetLabel(off) .. " keeps the world pass capped") +end +Zoom.offset = 0 + +local capped = select(1, Renderer:worldViewSize()) +useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") +local expanded = select(1, Renderer:worldViewSize()) +check(expanded > capped, + "viewport_expand lets the survey world fill the cutout instead of the GB box") +useSkin("0.25,0.1,0.5,0.6") + +setWindow(480, 800) +useSkin("0.3,0.1,0.4,0.3") +Renderer:init() +Renderer:setUISize(304, 144) +Renderer.uiFill, Renderer.uiCentered = false, true +local tight = Renderer:frameRects() +check(tight.vuw < 304, "the cutout cannot hold the WIDE battle at 1x") +check(inside(tight.uox, tight.uoy, tight.uvpw, tight.uvph, + tight.vux, tight.vuy, tight.vuw, tight.vuh), + "so the surface is scaled down to the cutout rather than over the bezel") +Renderer:setUISize(160, 144) + +setWindow(1280, 720) +useSkin("0.25,0.1,0.5,0.6") +local px, py, pw, ph, active = Playfield.rect(1280, 720) +eq(active, true, "Gold sees the cutout too") +eq(pw, 480, "the playfield is a whole multiple of 160") +eq(ph, 432, "and of 144") +check(inside(px, py, pw, ph, 320, 72, 640, 432), + "centred inside the cutout") +eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield") +local cox, coy = Chrome.fitOrigin(1280, 720) +eq(cox, px, "and centres the panel on it") +eq(coy, py, "on both axes") + +useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") +local ex, ey, ew, eh = Playfield.rect(1280, 720) +eq(ew, 640, "expand hands the picture the full cutout width") +eq(eh, 432, "and its full height") +eq(ex, 320, "at the cutout origin") +eq(ey, 72, "on both axes") +useSkin("0.25,0.1,0.5,0.6") + +local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720) +eq(act2, true, "push reports the frame is contained") +eq(ex2, px, "push translates to the playfield origin") +eq(ey2, py, "on both axes") +eq(ew2, pw, "and hands the scene the playfield size") +eq(Playfield.cutout(ew2, eh2), nil, "inside the frame there is no cutout left") +eq(select(3, Playfield.rect(ew2, eh2)), pw, "so the playfield is the surface") +eq(Chrome.fitScale(ew2, eh2), 3, "and Chrome fits it without re-applying") +eq(select(1, Chrome.fitOrigin(ew2, eh2)), 0, "at a local origin") +eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display") +Playfield.pop() +eq(Playfield.entered, false, "pop leaves the frame") +eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again") + +useSkin("0.4,0.4,0.1,0.1") +local sx, sy, sw, sh = Playfield.rect(1280, 720) +check(inside(sx, sy, sw, sh, 512, 288, 128, 72), + "a cutout smaller than 160x144 still bounds the playfield") +check(sw <= 128 and sh <= 72, "the playfield never exceeds the cutout") + +TouchSkin.setActive(nil) +eq(Playfield.cutout(1280, 720), nil, "no skin, no cutout") +eq(select(3, Playfield.rect(1280, 720)), 1280, "and the playfield is the window") +local saved = TouchSkin.viewport +TouchSkin.viewport = function() error("boom") end +eq(Playfield.cutout(1280, 720), nil, "a throwing viewport is no cutout") +TouchSkin.viewport = function() return 10, 10, 0, 0 end +eq(Playfield.cutout(1280, 720), nil, "a zero-sized cutout is no cutout") +TouchSkin.viewport = function() return -50, -50, 200, 200 end +eq(select(1, Playfield.cutout(1280, 720)), 0, "a cutout off the surface is clamped") +eq(select(3, Playfield.cutout(1280, 720)), 150, "to what is left of it") +TouchSkin.viewport = saved +TouchSkin.setActive(nil) +setWindow(640, 576) + +T.finish("skin_viewport_containment") diff --git a/tests/engine/sync_client_test.lua b/tests/engine/sync_client_test.lua new file mode 100644 index 00000000..105e8c29 --- /dev/null +++ b/tests/engine/sync_client_test.lua @@ -0,0 +1,175 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local SyncClient = require("src.sync.SyncClient") + +local function recorder() + local t = { sent = {}, replies = {}, released = 0 } + function t:begin(req) + self.sent[#self.sent + 1] = req + return #self.sent + end + function t:poll(handle) + local reply = self.replies[handle] + if not reply then return { status = "pending" } end + return reply + end + function t:release() self.released = self.released + 1 end + function t:answer(handle, code, body) + self.replies[handle] = { status = "ok", code = code, body = body } + end + function t:fail(handle, err) + self.replies[handle] = { status = "error", err = err } + end + return t +end + +local function client(transport) + return SyncClient.new({ baseUrl = "http://sync.test/", transport = transport }) +end + +do + T.eq(SyncClient.normalizeCode("1234-5678"), "12345678", + "a dashed code normalizes to digits") + T.eq(SyncClient.normalizeCode(" 1234 5678 "), "12345678", + "and so does a spaced one") + T.eq(SyncClient.normalizeCode("1234567"), nil, "seven digits is not a code") + T.eq(SyncClient.normalizeCode("123456789"), nil, "nor is nine") + T.eq(SyncClient.normalizeCode("abcdefgh"), nil, "nor letters") + T.eq(SyncClient.formatCode("12345678"), "1234-5678", + "codes present as two groups of four") + T.eq(SyncClient.formatCode("nope"), nil, "a bad code has no presentation") +end + +do + local t = recorder() + local c = client(t) + T.eq(c:isLinked(), false, "a new client is not linked") + + local handle = c:create("laptop") + local req = t.sent[1] + T.eq(req.method, "POST", "create posts") + T.eq(req.url, "http://sync.test/sync/create", "to /sync/create") + T.eq(req.headers["x-sync-account"], nil, + "and carries no auth header before there is an account") + T.eq(Json.decode(req.body).device, "laptop", "the device label rides along") + + t:answer(handle, 200, + '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}') + local res = c:poll(handle) + T.eq(res.status, "ok", "a 200 with JSON reads as ok") + T.eq(res.data.account, "aa11", "and the account comes back decoded") + + c:setAuth(res.data.account, res.data.deviceToken) + T.eq(c:isLinked(), true, "storing the token links the client") + + local stateHandle = c:fetchState() + local stateReq = t.sent[2] + T.eq(stateReq.method, "GET", "state is a GET") + T.eq(stateReq.headers["x-sync-account"], "aa11", "with the account header") + T.eq(stateReq.headers["x-sync-token"], "tok", "and the device token header") + T.eq(stateReq.body, nil, "and no body") + T.eq(c:poll(stateHandle).status, "pending", "an unanswered request is pending") + + local bad, err = c:link("123", "456", "phone") + T.eq(bad, nil, "a short code never reaches the network") + T.check(tostring(err):find("8 digits", 1, true) ~= nil, + "and says what a code looks like") + T.eq(#t.sent, 2, "no request was sent for the bad codes") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local handle = c:putSave({ version = "red", slot = "slot1", + meta = { savedAt = 100, sessionStart = 50 }, blob = "return {}", + baseRev = 4 }) + local req = t.sent[1] + T.eq(req.method, "PUT", "a save upload is a PUT") + T.eq(req.url, "http://sync.test/sync/save", "to /sync/save") + local body = Json.decode(req.body) + T.eq(body.version, "red", "the version rides in the body") + T.eq(body.baseRev, 4, "with the rev the client last synced") + T.eq(body.meta.sessionStart, 50, "and the session start in the meta") + + t:answer(handle, 409, + '{"conflict":true,"rev":9,"remoteMeta":{"savedAt":200,"sessionStart":60}}') + local res = c:poll(handle) + T.eq(res.status, "error", "a 409 is not a success") + T.eq(res.code, 409, "the status code is reported") + T.eq(res.data.remoteMeta.savedAt, 200, + "and the conflict body is still readable") + + local tooBig, why = c:putSave({ version = "red", slot = "slot1", + blob = string.rep("x", SyncClient.MAX_BLOB + 1) }) + T.eq(tooBig, nil, "an oversized save is refused before it is sent") + T.check(tostring(why):find("too large", 1, true) ~= nil, + "with a reason the UI can show") + + local getHandle = c:getSave("red", "abc def") + T.eq(t.sent[2].url, "http://sync.test/sync/save?id=abc%20def&version=red", + "a download escapes its query parameters") + t:answer(getHandle, 200, '{"meta":{"savedAt":200},"blob":"return {}","rev":9}') + T.eq(c:poll(getHandle).data.rev, 9, "the download reports the served rev") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local h1 = c:fetchState() + t:fail(h1, "no route to host") + local res = c:poll(h1) + T.eq(res.status, "error", "a transport failure is an error") + T.check(res.err:find("no route", 1, true) ~= nil, "and keeps the reason") + + local h2 = c:fetchState() + t:answer(h2, 200, "nope") + local html = c:poll(h2) + T.eq(html.status, "error", "an HTML reply is not a sync reply") + T.check(html.err:find("HTML", 1, true) ~= nil, "and says so") + + local h3 = c:fetchState() + t:answer(h3, 401, '{"error":"bad_token"}') + local denied = c:poll(h3) + T.eq(denied.status, "error", "a 401 is an error") + T.eq(denied.err, "bad_token", "carrying the server's own reason") + + local h4 = c:fetchState() + t:answer(h4, 200, '{"ok":true,"error":"stale"}') + T.eq(c:poll(h4).status, "error", + "an error field in a 200 body still fails the call") + + c:clearAuth() + local nope, err = c:fetchState() + T.eq(nope, nil, "an unlinked client refuses an authenticated call") + T.check(tostring(err):find("not linked", 1, true) ~= nil, + "and says the device is not linked") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local handle = c:fetchShare("ab3d9k") + T.eq(t.sent[1].headers["x-sync-token"], nil, + "reading a share code needs no auth") + T.eq(t.sent[1].url, "http://sync.test/sync/modshare?code=AB3D9K", + "and the code is upper-cased in the query") + t:answer(handle, 200, '{"manifest":{"rev":1,"mods":[],"indexes":[]}}') + T.eq(c:poll(handle).data.manifest.rev, 1, "the shared manifest decodes") + + local bad, err = c:fetchShare("12") + T.eq(bad, nil, "a short share code never reaches the network") + T.check(tostring(err):find("6 characters", 1, true) ~= nil, + "and says how long one is") +end + +T.finish("sync_client") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua new file mode 100644 index 00000000..f6ee37fd --- /dev/null +++ b/tests/engine/sync_engine_test.lua @@ -0,0 +1,459 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local SyncState = require("src.sync.SyncState") +local SyncEngine = require("src.sync.SyncEngine") + +local function scripted(routes) + local t = { sent = {}, routes = routes, handles = {} } + function t:begin(req) + self.sent[#self.sent + 1] = req + local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "") + local route = self.routes[req.method .. " " .. path] + local reply + if type(route) == "function" then + reply = route(req, self) + else + reply = route + end + reply = reply or { code = 404, body = '{"error":"no route"}' } + self.handles[#self.sent] = { + status = "ok", code = reply.code or 200, + body = reply.body or Json.encode(reply.data or {}), + } + return #self.sent + end + function t:poll(handle) return self.handles[handle] end + function t:release() end + return t +end + +local function pump(eng, times) + for _ = 1, (times or 24) do eng:update(0.05) end +end + +local function linkedState() + local state = SyncState.defaults() + state.account = "aa11bb22cc33dd44" + state.deviceToken = "tok" + state.enabled = true + return state +end + +local function saveEntry(version, id, savedAt, sessionStart, slot) + return { + version = version, slot = slot or "slot1", playthroughId = id, + blob = "return { player = { name = 'ASH' } }", + meta = { savedAt = savedAt, sessionStart = sessionStart, + playthroughId = id, summary = { name = "ASH", badges = 2 } }, + } +end + +local function fakeSaves(entries) + local writes = {} + return { + writes = writes, + list = function() return entries end, + write = function(version, id, blob, mode) + writes[#writes + 1] = { version = version, playthroughId = id, + blob = blob, mode = mode } + return mode == "new" and "slot9" or "slot1" + end, + } +end + +local function engine(routes, entries, state) + local saves = fakeSaves(entries or {}) + local transport = scripted(routes) + local eng = SyncEngine.new({ + baseUrl = "http://sync.test", + transport = transport, + state = state or linkedState(), + saves = saves, + persist = false, + now = function() return 1700001000 end, + }) + return eng, transport, saves +end + +do + T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, + { sessionStart = 15, savedAt = 30 }), true, + "two sessions that ran over the same minutes overlap") + T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, + { sessionStart = 21, savedAt = 30 }), false, + "a session that started after the other ended does not") + T.eq(SyncEngine.overlaps({ savedAt = 20 }, { sessionStart = 1, savedAt = 30 }), + false, "a save with no session start cannot claim an overlap") +end + +do + local eng, transport = engine({ + ["POST /sync/create"] = { code = 200, body = + '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, + }, {}, SyncState.defaults()) + + T.eq(eng:linked(), false, "a fresh engine is not linked") + T.eq(eng.status, "Not set up", "and says so") + + eng:createAccount("laptop") + pump(eng, 3) + T.eq(eng:linked(), true, "creating an account links this device") + T.eq(eng.state.account, "aa11", "and stores the account id") + T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") + T.eq(eng.codes.code2, "3333-4444", "and so is the second") + T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(eng.phase, "idle", "and the engine settles") + T.eq(#transport.sent, 1, "one request was made") +end + +do + local eng, transport = engine({ + ["POST /sync/link"] = { code = 200, + body = '{"account":"aa11","deviceToken":"tok"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) + + eng:linkDevice("1111-2222", "3333 4444", "phone") + pump(eng) + T.eq(eng:linked(), true, "linking with both codes links the device") + T.eq(transport.sent[2].url, "http://sync.test/sync/state", + "and a sync starts immediately") + T.eq(transport.sent[3].method, "PUT", + "the local save the server has never seen is uploaded") + T.eq(SyncState.rev(eng.state, "red/abc"), 1, "the served rev is remembered") + T.eq(SyncState.stamp(eng.state, "red/abc"), 500, + "along with the savedAt that was uploaded") + T.eq(eng.phase, "idle", "and the engine settles") + T.eq(eng.state.lastSyncAt, 1700001000, "the sync time is stamped") +end + +do + local eng, transport = engine({}, {}, SyncState.defaults()) + eng:linkDevice("12", "34", "phone") + T.eq(#transport.sent, 0, "a malformed code pair is refused locally") + T.eq(eng.phase, "error", "and the engine reports the problem") + T.check(eng.status:find("8 digits", 1, true) ~= nil, + "with copy that says what a code is") +end + +do + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, {}) + + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, "the remote-only save is written locally") + T.eq(saves.writes[1].version, "gold", "into the right game") + T.eq(saves.writes[1].mode, "replace", "as that playthrough's slot") + T.eq(SyncState.rev(eng.state, "gold/xyz"), 4, "and its rev is remembered") + T.eq(eng.phase, "idle", "the engine settles") + T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold", + "the download names the playthrough, not the slot") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":7,"meta":{"savedAt":500}}}}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, "an unchanged save is neither uploaded nor downloaded") + T.eq(eng.phase, "idle", "and the sync ends idle") +end + +local function conflictEngine() + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + return engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. + '"sessionStart":600,"summary":{"name":"BLUE","badges":4}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":9,"meta":{"savedAt":760},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 700, 650) }, state) +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + T.eq(eng.phase, "conflict", "both sides changing is a conflict") + T.eq(#eng.conflicts, 1, "one conflict is raised") + T.eq(eng.conflicts[1].overlap, true, + "the two sessions ran over the same minutes") + T.eq(eng.status, "These saves were played at the same time.", + "and the status is the wording the player was promised") + T.eq(eng.conflicts[1].remoteMeta.summary.name, "BLUE", + "the other device's save is summarized for the prompt") + T.eq(#transport.sent, 1, "nothing is uploaded while the player decides") + T.eq(#eng.state.pendingConflicts, 1, "the conflict survives in the state") +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "local") + pump(eng) + local put = transport.sent[2] + T.eq(put.method, "PUT", "keep this device uploads") + T.eq(Json.decode(put.body).force, true, "with the force flag") + T.eq(SyncState.rev(eng.state, "red/abc"), 10, "and adopts the new rev") + T.eq(eng.phase, "idle", "the conflict is cleared") + T.eq(#eng.state.pendingConflicts, 0, "and dropped from the state") +end + +do + local eng, transport, saves = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "remote") + pump(eng) + T.eq(transport.sent[2].method, "GET", "keep the other device downloads") + T.eq(#saves.writes, 1, "and writes it locally") + T.eq(saves.writes[1].mode, "replace", "over this playthrough's slot") + T.eq(SyncState.rev(eng.state, "red/abc"), 9, "adopting the remote rev") + T.eq(eng.phase, "idle", "the conflict is cleared") +end + +do + local eng, transport, saves = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "both") + pump(eng) + T.eq(#saves.writes, 1, "keep both imports the other save") + T.eq(saves.writes[1].mode, "new", "into a new slot") + local put = transport.sent[3] + T.eq(put.method, "PUT", "and still uploads this device's save") + T.eq(Json.decode(put.body).force, true, "forcing past the stale rev") + T.eq(eng.phase, "idle", "the conflict is cleared") +end + +do + local eng = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 409, body = + '{"conflict":true,"rev":3,"remoteMeta":{"savedAt":710,"sessionStart":600}}' }, + }, { saveEntry("red", "abc", 700, 650) }) + + eng:syncNow() + pump(eng) + T.eq(eng.phase, "conflict", "a 409 on upload becomes a conflict, not an error") + T.eq(eng.conflicts[1].overlap, true, "with the overlap worked out") +end + +do + local eng = engine({ + ["GET /sync/state"] = function() + return { code = 500, body = '{"error":"server on fire"}' } + end, + }, {}) + eng:syncNow() + pump(eng, 3) + T.eq(eng.phase, "error", "a server error stops the sync") + T.check(eng.status:find("server on fire", 1, true) ~= nil, + "and shows what the server said") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }) + + eng:noteSaveWritten() + eng:update(1) + T.eq(#transport.sent, 0, "an in-game save does not sync straight away") + eng:update(SyncEngine.UPLOAD_DEBOUNCE) + T.eq(#transport.sent, 1, "it syncs once the debounce has passed") + pump(eng) + T.eq(transport.sent[2].method, "PUT", "and the save goes up") +end + +do + local eng, transport = engine({}, { saveEntry("red", "abc", 500, 400) }) + eng:setEnabled(false) + eng:noteSaveWritten() + eng:update(60) + T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") +end + +do + local eng = engine({ + ["POST /sync/create"] = { code = 200, body = + '{"account":"aa11","code1":"11112222","code2":"33334444",' .. + '"deviceToken":"tok","device":"0a1b2c3d"}' }, + }, {}, SyncState.defaults()) + eng:createAccount("laptop") + pump(eng, 3) + T.eq(eng.state.deviceId, "0a1b2c3d", + "creating an account records the id the server gave this device") + T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d", + "and it survives being persisted") +end + +do + local eng = engine({ + ["POST /sync/link"] = { code = 200, + body = '{"account":"aa11","deviceToken":"tok","device":"beefcafe"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, SyncState.defaults()) + eng:linkDevice("11112222", "33334444", "phone") + pump(eng) + T.eq(eng.state.deviceId, "beefcafe", "so does linking a second device") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + }, {}, state) + + eng:unlink() + T.eq(eng:linked(), true, "unlink waits for the server before forgetting") + local sent = transport.sent[1] + T.eq(sent.url, "http://sync.test/sync/unlink", "it asks the server first") + T.eq(Json.decode(sent.body).device, "0a1b2c3d", + "naming the device id the server knows, not the platform label") + pump(eng, 3) + T.eq(eng:linked(), false, "and only then drops the credentials") + T.eq(eng.status, "Not set up", "reporting the device as unlinked") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["POST /sync/unlink"] = { code = 500, body = '{"error":"nope"}' }, + }, {}, state) + eng:unlink() + pump(eng, 3) + T.eq(eng.phase, "error", "a failed revocation is surfaced") + T.eq(eng:linked(), true, + "and the device stays linked rather than lying about it") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["POST /sync/unlink"] = { code = 401, body = '{"error":"unauthorized"}' }, + }, {}, state) + eng:unlink() + pump(eng, 3) + T.eq(eng:linked(), false, + "a token the server already revoked is dropped rather than stuck forever") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, state) + eng:unlinkDevice("99998888") + T.eq(Json.decode(transport.sent[1].body).device, "99998888", + "another device is revoked by its id") + pump(eng, 3) + T.eq(eng:linked(), true, "without logging this device out") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["GET /sync/state"] = { code = 200, body = + '{"saves":{},"devices":[{"id":"0a1b2c3d","label":"OS X","current":true},' .. + '{"id":"99998888","label":"Android"}]}' }, + }, {}, state) + eng:syncNow() + pump(eng) + T.eq(#eng.devices, 2, "the linked devices are kept for the modal to show") + T.eq(eng.devices[1].current, true, "this device is marked") + T.eq(eng.devices[2].label, "Android", "and the others are named") +end + +do + local eng = conflictEngine() + eng:syncNow() + pump(eng) + eng:syncNow() + pump(eng) + eng:syncNow() + pump(eng) + T.eq(#eng.state.pendingConflicts, 1, + "syncing again over the same conflict does not stack up rows") + T.eq(#eng.conflicts, 1, "and the prompt still has exactly one to answer") +end + +do + local eng = engine({}, {}) + local order, seen = {}, {} + eng.modDeps = { + installed = function() return {} end, + indexes = function() return {} end, + addIndex = function(url) order[#order + 1] = "index" return { feed = url } end, + findEntry = function() return nil end, + install = function(entry) order[#order + 1] = "install:" .. entry.id return true end, + setEnabled = function(id) order[#order + 1] = "enable:" .. id return true end, + } + eng.modPlan = { + indexes = { "https://mods.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + missing = {}, + } + eng:applyModPlan(function(done, total, label, finished) + seen[#seen + 1] = ("%d/%d %s"):format(done, total, tostring(finished)) + end) + T.eq(#order, 0, "starting an apply installs nothing on the spot") + T.eq(eng:busy(), true, "the launcher can see it is working") + eng:update(0.016) + T.eq(#order, 1, "one step runs per frame, so the progress line can draw") + eng:update(0.016) + eng:update(0.016) + T.eq(#order, 3, "until the whole plan has run") + T.eq(order[3], "enable:beta", "in plan order") + T.eq(eng.modApply, nil, "the job is done") + T.eq(eng.modPlan, nil, "and the plan is spent") + T.eq(eng.status, "Mods applied", "the status says so") + T.eq(seen[#seen], "3/3 true", "and the last progress call reports the end") +end + +do + local eng = engine({}, {}) + eng.modDeps = { + installed = function() return {} end, + indexes = function() return {} end, + addIndex = function() return true end, + findEntry = function() return nil end, + install = function() return nil, "download failed" end, + setEnabled = function() return true end, + } + eng.modPlan = { indexes = {}, toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = {}, missing = {} } + eng:applyModPlan() + eng:update(0.016) + T.eq(eng.modApply, nil, "a failing step still ends the job") + T.check(eng.status:find("download failed", 1, true) ~= nil, + "and the failure reaches the status line") +end + +T.finish("sync_engine") diff --git a/tests/engine/sync_mods_test.lua b/tests/engine/sync_mods_test.lua new file mode 100644 index 00000000..5574e7d8 --- /dev/null +++ b/tests/engine/sync_mods_test.lua @@ -0,0 +1,148 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SyncMods = require("src.sync.SyncMods") + +local function row(id, version, enabled, github) + return { id = id, version = version, github = github, + enabledByVersion = enabled } +end + +local function deps(installed, indexes, catalog) + local calls = { installed = {}, enabled = {}, indexes = {} } + return calls, { + installed = function() return installed end, + indexes = function() return indexes or {} end, + addIndex = function(url) + calls.indexes[#calls.indexes + 1] = url + return { feed = url } + end, + findEntry = function(id) return (catalog or {})[id] end, + install = function(entry) + calls.installed[#calls.installed + 1] = entry.id + return true + end, + setEnabled = function(id, enabled, version) + calls.enabled[#calls.enabled + 1] = id .. ":" .. tostring(version) + return true + end, + } +end + +do + local _, d = deps({ + row("zeta", "1.0.0", { red = true, blue = false, yellow = false, gold = false }), + row("alpha", "2.1.0", { red = true, gold = true }, "someone/alpha"), + }, { { url = "https://mods.example/index.json", + feed = "https://mods.example/index.json" } }) + + local manifest = SyncMods.build(d) + T.eq(manifest.rev, SyncMods.REV, "the manifest carries its shape revision") + T.eq(#manifest.indexes, 1, "the player's index list rides along") + T.eq(manifest.indexes[1], "https://mods.example/index.json", + "as the url they typed") + T.eq(#manifest.mods, 2, "every installed mod is listed") + T.eq(manifest.mods[1].id, "alpha", "sorted by id so the manifest is stable") + T.eq(manifest.mods[1].source, "github:someone/alpha", + "a github mod records where it came from") + T.eq(manifest.mods[2].source, "local", + "a hand-installed mod is marked local rather than invented") + T.eq(#manifest.mods[1].enabledFor, 2, "alpha is on for two games") + T.eq(manifest.mods[1].enabledFor[1], "red", "in GameVersion order") + T.eq(manifest.mods[1].enabledFor[2], "gold", "red then gold") + T.eq(#manifest.mods[2].enabledFor, 1, "zeta is on for one") +end + +do + local manifest = { + rev = 1, + indexes = { "https://mods.example/index.json", "https://other.example/i.json" }, + mods = { + { id = "alpha", version = "2.1.0", enabledFor = { "red", "gold" } }, + { id = "beta", version = "1.0.0", enabledFor = { "red" } }, + { id = "ghost", version = "0.1.0", source = "local", enabledFor = { "red" } }, + }, + } + local _, d = deps( + { row("alpha", "2.1.0", { red = true }) }, + { { url = "https://mods.example/index.json", + feed = "https://mods.example/index.json" } }, + { beta = { id = "beta" } }) + + local plan = SyncMods.plan(manifest, d) + T.eq(#plan.indexes, 1, "only the index this device is missing is planned") + T.eq(plan.indexes[1], "https://other.example/i.json", "the new one") + T.eq(#plan.toInstall, 1, "one mod can be fetched from an index") + T.eq(plan.toInstall[1].id, "beta", "the one the catalog knows") + T.eq(#plan.missing, 1, "the mod nobody publishes is reported, not invented") + T.eq(plan.missing[1].id, "ghost", "by id") + T.eq(#plan.toEnable, 2, "every game answer that differs is planned") + for _, want in ipairs(plan.toEnable) do + T.check(want.id ~= "ghost", + "a mod that cannot be installed is never enabled") + end + T.eq(SyncMods.planEmpty(plan), false, "a plan with work is not empty") + + local same = SyncMods.plan({ rev = 1, indexes = {}, mods = { + { id = "alpha", version = "2.1.0", enabledFor = { "red" } } } }, d) + T.eq(SyncMods.planEmpty(same), true, "a matching device plans nothing") +end + +do + local calls, d = deps({}, {}, { beta = { id = "beta" } }) + local plan = { + indexes = { "https://other.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + missing = { { id = "ghost" } }, + } + local seen = {} + local ok = SyncMods.apply(plan, function(done, total, label) + seen[#seen + 1] = ("%d/%d %s"):format(done, total, label) + end, d) + T.eq(ok, true, "applying a plan reports success") + T.eq(calls.indexes[1], "https://other.example/i.json", "the index is added") + T.eq(calls.installed[1], "beta", "the mod is installed through the launcher path") + T.eq(calls.enabled[1], "beta:red", "and enabled for the game that wanted it") + T.eq(#seen, 3, "progress is reported once per step") + T.eq(seen[3], "3/3 beta", "counting up to the total") +end + +do + local _, d = deps({}, {}, {}) + d.install = function() return nil, "download failed" end + local ok, err = SyncMods.apply({ + toInstall = { { id = "beta", entry = { id = "beta" } } } }, nil, d) + T.eq(ok, false, "a failed install fails the apply") + T.check(tostring(err):find("download failed", 1, true) ~= nil, + "naming the mod and the reason") +end + +do + local calls, d = deps({}, {}, {}) + d.install = function() return nil, "download failed" end + local ok = SyncMods.apply({ + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + }, nil, d) + T.eq(ok, false, "the apply still reports the failure") + T.eq(#calls.enabled, 0, + "a mod whose install failed is not switched on regardless") +end + +do + local calls, d = deps({}, {}, {}) + local steps = SyncMods.steps({ + indexes = { "https://other.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + }, d) + T.eq(#steps, 3, "a plan splits into one step per unit of work") + T.eq(steps[1].run(), true, "steps run one at a time") + T.eq(#calls.indexes, 1, "so the caller can draw between them") + T.eq(#calls.installed, 0, "without the rest of the plan having run yet") +end + +T.finish("sync_mods") diff --git a/tests/engine/sync_session_meta_test.lua b/tests/engine/sync_session_meta_test.lua new file mode 100644 index 00000000..b1b558a6 --- /dev/null +++ b/tests/engine/sync_session_meta_test.lua @@ -0,0 +1,171 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local SyncEngine = require("src.sync.SyncEngine") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +do + local plain = SaveData.buildMeta({}) + T.check(type(plain.savedAt) == "number", "a save still records when it ended") + T.eq(plain.sessionStart, nil, + "and records no session start when nobody supplied one") + + local started = os.time() - 600 + local meta = SaveData.buildMeta({}, nil, started) + T.eq(meta.sessionStart, started, "the session start is stamped when given") + T.check(meta.savedAt >= meta.sessionStart, + "and savedAt is the end of that session") + + local carried = SaveData.buildMeta({}, { sessionStart = started }) + T.eq(carried.sessionStart, started, + "a rewrite with no session keeps the previous start") + + local future = SaveData.buildMeta({}, nil, os.time() + 9999) + T.check(future.sessionStart <= future.savedAt, + "a clock that ran backwards cannot start a session after it ended") + + local nan = SaveData.buildMeta({}, nil, 0 / 0) + T.eq(nan.sessionStart, nil, "a NaN session start is refused") + + local kept = SaveData.buildMeta(nil, { playthroughId = "abc", mods = {}, + sessionStart = 42 }) + T.eq(kept.playthroughId, "abc", "the playthrough id still rides on the meta") + T.eq(kept.sessionStart, 42, "next to the session start") +end + +do + local files = fresh() + T.eq(SaveData.readSlotSource("red", "slot1"), nil, + "an empty slot has no bytes to upload") + + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 60) + T.check(SaveData.writeSlot("red", "slot1", save), "a slot write lands") + + local source = SaveData.readSlotSource("red", "slot1") + T.check(type(source) == "string" and #source > 0, "the raw bytes read back") + local decoded = SaveData.decode(source) + T.eq(decoded.player.name, "ASH", "and decode to the same save") + T.eq(decoded.meta.playthroughId, "abc", "carrying the playthrough id") + + files["saves/red/slot1.lua"] = "this is not a save" + T.eq(SaveData.readSlotSource("red", "slot1"), nil, + "a corrupt slot never hands undecodable bytes to the uploader") + + files["saves/red/slot1.lua.bak"] = source + T.eq(SaveData.readSlotSource("red", "slot1"), source, + "and the backup copy is used instead") + + T.eq(SaveData.readSlotSource("nosuchgame", "slot1"), nil, + "an unknown version has no slots to read") +end + +do + fresh() + local provider = SyncEngine.defaultSaves() + T.eq(#provider.list(), 0, "a fresh install has nothing to sync") + + local slotId = SaveData.createSlot("red") + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 120) + SaveData.writeSlot("red", slotId, save) + + local entries = provider.list() + T.eq(#entries, 1, "a written slot becomes one sync entry") + T.eq(entries[1].version, "red", "keyed by its game") + T.eq(entries[1].playthroughId, "abc", "and its playthrough id") + T.eq(entries[1].slot, slotId, "remembering which slot it came from") + T.eq(entries[1].meta.summary.name, "ASH", + "with the launcher summary the conflict prompt shows") + T.check(entries[1].meta.sessionStart ~= nil, "and the session start") + T.check(entries[1].blob:find("ASH", 1, true) ~= nil, + "the blob is the encoded save itself") + + local other = SaveData.newGame() + other.player.name = "BLUE" + other.meta = SaveData.buildMeta({}, { playthroughId = "xyz" }, os.time() - 30) + local newSlot = provider.write("red", "xyz", SaveData.encode(other), "new") + T.check(newSlot ~= nil and newSlot ~= slotId, + "keep both imports the other device's save into a new slot") + local after = provider.list() + T.eq(#after, 2, "and both playthroughs are now local") + local ids = {} + for _, entry in ipairs(after) do ids[entry.playthroughId] = true end + T.eq(ids["abc"], true, "this device's playthrough is untouched") + T.eq(ids["xyz"], nil, + "and the imported copy gets its own identity so the two never merge") +end + +do + local source = assert(io.open("src/core/Game.lua")):read("*a") + T.check(source:find("self.sessionStartedAt = os.time()", 1, true) ~= nil, + "Game stamps when a play session began") + T.check(source:find("self.sessionStartedAt)", 1, true) ~= nil, + "and hands it to buildMeta when the save is written") + local _, stamps = source:gsub("self%.sessionStartedAt = os%.time%(%)", "") + T.eq(stamps, 3, + "boot, NEW GAME and CONTINUE each start a session") +end + +do + fresh() + local Game = require("src.core.Game") + local notes, pumped = 0, 0 + SyncEngine._shared = { + state = { enabled = true }, + linked = function() return true end, + busy = function() return false end, + noteSaveWritten = function() notes = notes + 1 end, + update = function(_, dt) pumped = pumped + dt end, + } + local game = setmetatable({ save = SaveData.newGame(), + sessionStartedAt = os.time() - 60 }, { __index = Game }) + T.eq(Game.writeSave(game), true, "an in-game save still writes") + T.eq(notes, 1, "and tells the sync engine, so the 5s debounce can start") + Game.updateSync(game, 0.5) + T.eq(pumped, 0.5, "the running game pumps the engine, not only the launcher") + + SyncEngine._shared = { + state = { enabled = false }, + linked = function() return false end, + busy = function() return false end, + noteSaveWritten = function() notes = notes + 1 end, + update = function() pumped = pumped + 1 end, + } + game._syncOff, game._syncEngineRef = nil, nil + Game.updateSync(game, 0.5) + T.eq(pumped, 0.5, "with sync off the engine is left alone") + SyncEngine.forgetShared() +end + +love.filesystem = realFS + +T.finish("sync_session_meta") diff --git a/tests/engine/sync_state_test.lua b/tests/engine/sync_state_test.lua new file mode 100644 index 00000000..066aa734 --- /dev/null +++ b/tests/engine/sync_state_test.lua @@ -0,0 +1,120 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SyncState = require("src.sync.SyncState") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + return files +end + +do + local opts = SaveData.defaultOptions() + T.check(type(opts.saveSync) == "table", "defaultOptions carries saveSync") + T.eq(opts.saveSync.enabled, false, "sync is off until the player sets it up") + T.check(type(opts.saveSync.revs) == "table", "and starts with no synced revs") + T.eq(opts.saveSync.account, nil, "and no account") + + local state = SyncState.defaults() + T.eq(SyncState.linked(state), false, "a default state is not linked") + T.eq(state.lastSyncAt, 0, "and has never synced") +end + +do + local dirty = SyncState.sanitize({ + enabled = "yes", + account = "aa11bb22cc33dd44", + deviceToken = "tok", + deviceLabel = "", + lastSyncAt = 0 / 0, + code1 = "12345678", + code2 = "87654321", + revs = { ["red/aaa"] = 4, [7] = 9, ["red/bad"] = "no" }, + stamps = { ["red/aaa"] = 1700 }, + pendingConflicts = { { key = "red/aaa", version = "red", overlap = true }, + { nope = true } }, + }) + T.eq(dirty.enabled, false, "a non-boolean enabled reads as off") + T.eq(dirty.account, "aa11bb22cc33dd44", "the account id survives") + T.eq(dirty.deviceLabel, nil, "an empty device label is dropped") + T.eq(dirty.lastSyncAt, 0, "a NaN lastSyncAt is refused") + T.eq(dirty.code1, nil, "the first account code is never kept") + T.eq(dirty.code2, nil, "nor the second") + T.eq(dirty.revs["red/aaa"], 4, "numeric revs survive") + T.eq(dirty.revs["red/bad"], nil, "a non-numeric rev is dropped") + T.eq(dirty.revs[7], nil, "a non-string rev key is dropped") + T.eq(dirty.stamps["red/aaa"], 1700, "the savedAt stamp survives") + T.eq(#dirty.pendingConflicts, 1, "only well-formed conflicts are kept") + T.eq(dirty.pendingConflicts[1].overlap, true, "with their overlap flag") +end + +do + local files = fresh() + local state = SyncState.load() + T.eq(SyncState.linked(state), false, "a first boot has no linked account") + + state.account = "aa11bb22cc33dd44" + state.deviceToken = "feedface" + state.deviceId = "0a1b2c3d" + state.deviceLabel = "laptop" + state.enabled = true + state.code1 = "12345678" + SyncState.setRev(state, SyncState.key("red", "abc"), 3, 1700000000) + SyncState.save(state) + + T.check(files["options.lua"] ~= nil, "the state lands in options.lua") + T.eq(files["options.lua"]:find("12345678", 1, true), nil, + "the account codes are never written to disk") + + local back = SyncState.load() + T.eq(SyncState.linked(back), true, "the linked account survives a reload") + T.eq(back.deviceLabel, "laptop", "and the device label") + T.eq(back.deviceId, "0a1b2c3d", + "and the device id the server revokes tokens by") + T.eq(SyncState.rev(back, "red/abc"), 3, "and the last synced rev") + T.eq(SyncState.stamp(back, "red/abc"), 1700000000, "and the savedAt stamp") + T.eq(back.code1, nil, "the code is gone from the reloaded state") + + local opts = SaveData.loadOptions() + T.eq(opts.textSpeed, 3, "writing sync state leaves other options alone") + + SyncState.forget(back, "red/abc") + T.eq(SyncState.rev(back, "red/abc"), nil, "forget drops the rev") + T.eq(SyncState.stamp(back, "red/abc"), nil, "and the stamp") + + SyncState.clear() + T.eq(SyncState.linked(SyncState.load()), false, "clear unlinks the device") +end + +do + T.eq(SyncState.key("red", "abc"), "red/abc", "keys join version and id") + T.eq(SyncState.key("red", ""), nil, "an empty playthrough id has no key") + T.eq(SyncState.key(nil, "abc"), nil, "and neither does a missing version") + local version, id = SyncState.splitKey("gold/deadbeef") + T.eq(version, "gold", "splitKey reads the version back") + T.eq(id, "deadbeef", "and the playthrough id") +end + +love.filesystem = realFS + +T.finish("sync_state") diff --git a/tests/engine/touch_skin_dpad_area.lua b/tests/engine/touch_skin_dpad_area.lua new file mode 100644 index 00000000..d5b260ff --- /dev/null +++ b/tests/engine/touch_skin_dpad_area.lua @@ -0,0 +1,199 @@ +-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired +-- input is resolved by the angle of the touch from the area centre, and +-- range_mod growing a hitbox only while it is held. The cfg below is the +-- reporter's GBA skin, trimmed to the d-pad and face buttons. +-- luajit tests/engine/touch_skin_dpad_area.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 1 +overlay0_aspect_ratio = 0.45 +overlay0_descs = 13 +overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03" +overlay0_desc0_overlay = p-btn-select.png +overlay0_desc0_reach_x = 1.25 +overlay0_desc0_reach_y = 1.25 +overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03" +overlay0_desc1_overlay = p-btn-start.png +overlay0_desc1_reach_x = 1.25 +overlay0_desc1_reach_y = 1.25 +overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035" +overlay0_desc2_overlay = p-btn-dpad-up.png +overlay0_desc2_reach_x = 0 +overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035" +overlay0_desc3_overlay = p-btn-dpad-left.png +overlay0_desc3_reach_x = 0 +overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035" +overlay0_desc4_overlay = p-btn-dpad-right.png +overlay0_desc4_reach_x = 0 +overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035" +overlay0_desc5_overlay = p-btn-dpad-down.png +overlay0_desc5_reach_x = 0 +overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075" +overlay0_desc6_overlay = p-btn-corner.png +overlay0_desc6_reach_x = 0 +overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075" +overlay0_desc7_overlay = p-btn-corner.png +overlay0_desc7_reach_x = 0 +overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075" +overlay0_desc8_overlay = p-btn-corner.png +overlay0_desc8_reach_x = 0 +overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075" +overlay0_desc9_overlay = p-btn-corner.png +overlay0_desc9_reach_x = 0 +overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025" +overlay0_desc10_overlay = p-area-dpad.png +overlay0_desc10_reach_x = 1.25 +overlay0_desc10_reach_y = 1.25 +overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375" +overlay0_desc11_overlay = p-btn-act2-a.png +overlay0_desc11_reach_x = 1.25 +overlay0_desc11_reach_y = 1.25 +overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375" +overlay0_desc12_overlay = p-btn-act2-b.png +overlay0_desc12_reach_x = 1.25 +overlay0_desc12_reach_y = 1.25 + +overlay1_name = "areas" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 2 +overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2" +overlay1_desc0_up = "start" +overlay1_desc0_down = "select" +overlay1_desc0_left = "nul" +overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2" +]] + +local skin = assert(TouchSkin.parse(CFG)) +local page = skin.pages[1] + +eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls") +local sectors = {} +for _, ctl in ipairs(page.controls) do + if ctl.sector then sectors[ctl.sector] = ctl end +end +eq(#sectors, 8, "eight sectors, one per direction") +eq(sectors[1].spec, "right", "sector 1 is right") +eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal") +eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards") +eq(sectors[7].spec, "up", "sector 7 is up") +eq(sectors[1].x, 0.25, "every sector keeps the area centre") +eq(sectors[5].y, 0.72, "on both axes") +eq(sectors[3].rangeX, 0.22778, "and the whole area range") +eq(sectors[3].shape, "radial", "and the declared hitbox shape") + +local art = page.controls[11] +eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc") +check(art.decorative, "which presses nothing") + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local W, H = 720, 1600 +TouchSkin.setSurface(0, 0, W, H) +eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600") + +local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" } +local function heldNow() + local out = {} + for _, btn in ipairs(BUTTONS) do + if Input:isDown(btn) then out[#out + 1] = btn end + end + return table.concat(out, "+") +end + +local function press(nx, ny) + TouchControls:touchpressed("f1", nx * W, ny * H) + local got = heldNow() + TouchControls:touchreleased("f1", nx * W, ny * H) + return got +end + +local function fires(nx, ny, want, why) + eq(press(nx, ny), want, why) +end + +fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone") +fires(0.12222, 0.72, "left", "the left arrow fires left alone") +fires(0.37778, 0.72, "right", "the right arrow fires right alone") +fires(0.25, 0.7775, "down", "the down arrow fires down alone") +fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both") +fires(0.38356, 0.7801, "down+right", "as does the down-right corner") + +fires(0.32, 0.77, "down+right", + "a spot inside the area but off every arrow resolves by angle: " + .. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector") + +fires(0.84382, 0.69563, "a", "A fires alone") +fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it") +fires(0.58056, 0.58793, "start", "START fires alone") +fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction") +fires(0.5, 0.3, "", "the screen area presses nothing") + +TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H) +eq(heldNow(), "up", "slide starts on up") +TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H) +eq(heldNow(), "right", "sliding across the area swaps direction") +TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H) +eq(heldNow(), "down+right", "and picks up the diagonal") +TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H) +eq(heldNow(), "", "sliding out of the area releases it") +TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H) + +local area = sectors[1] +local bx = 0.65618 * W +local by = 0.74438 * H +check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false), + "at rest the area hitbox stops short of B") +check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true), + "a held area grows over B so the finger keeps its direction") +TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H) +TouchControls:touchmoved("f3", bx, by) +eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held") +TouchControls:touchreleased("f3", bx, by) +eq(heldNow(), "", "and lifting clears both") + +TouchSkin.autoOrient = false +TouchSkin.setPage("areas") +eq(TouchSkin.page().name, "areas", "second page is live") + +W, H = 1000, 1000 +TouchSkin.setSurface(0, 0, W, H) + +fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area") +fires(0.25, 0.65, "select", "_down rebinds the down sector") +fires(0.12, 0.5, "", "_left = nul makes that sector inert") +fires(0.38, 0.5, "right", "an unset side keeps the d-pad default") + +fires(0.88, 0.5, "a", "abxy_area right is GB A") +fires(0.75, 0.62, "b", "abxy_area down is GB B") +fires(0.88, 0.62, "a+b", "the down-right sector fires both") +fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert") +fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner") +fires(0.75, 0.75, "", "and nothing past its edge") + +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) +TouchSkin.autoOrient = true + +T.finish("touch_skin_dpad_area")