skin studio updates, save sync CLOSES #1533

This commit is contained in:
bryanthaboi
2026-08-19 05:57:44 -04:00
parent fd73ab2a11
commit 93374fbbbb
54 changed files with 9762 additions and 271 deletions
-1
View File
@@ -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 * **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 * **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** * **Pokédex diploma and printer image exports**
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics ## Gen 2 Specifics
+96 -18
View File
@@ -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: 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` `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport), (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: `src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `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/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 Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name. opens the studio. `options.touchControls.skin` holds the folder name.
## Formats ## 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 **RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys: 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 control that holds both. A `nul` desc is decoration: it draws and never
captures a touch. 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_<key>` is a keyboard bind.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every 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 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 `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 ## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`, 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 ## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on Four roads, all of them landing in `skins/` in the save directory:
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` * **Import** on the Skins tab opens the host file picker for a `.zip` or a
(`overlay.cfg` is preferred when there are several) and the images it names. `.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: 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. 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 **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 resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and 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 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. 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 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 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 A RetroArch overlay whose pages are already named portrait / landscape
(the auto-rotate convention) locks those pages and turns Match canvas on (the auto-rotate convention) locks those pages and turns Match canvas on
when you open it. You do not have to click Lock first. when you open it. You do not have to click Lock first.
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the **Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
images already in the skin folder; the **Import** button beside each one opens thumbnail grid of the images already in the skin folder, with `(none)` first;
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, 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 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 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 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. it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the **Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip skin names, so the folder stands alone. **Export** offers three formats, and
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the the Skins tab's gear offers the same three for any installed skin:
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch. | 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 ## 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.
@@ -378,6 +378,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
return result; 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 * TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a * own class, never FindClass -- and the same tolerance for an old APK: a
@@ -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); 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 <code>" or "ERROR <text>",
* 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). * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -274,6 +274,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
#endif #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 int System::tlsOpen(const char *host, int port) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -159,6 +159,18 @@ public:
virtual bool httpPost(const char *url, const char *body, int bodyLen, virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const; 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 <code>" or "ERROR <text>", 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 * TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract: * LuaSec or another provider is the answer). Non-blocking by contract:
@@ -22,6 +22,9 @@
#include "wrap_System.h" #include "wrap_System.h"
#include "sdl/System.h" #include "sdl/System.h"
#include <string>
#include <vector>
namespace love namespace love
{ {
namespace system namespace system
@@ -150,6 +153,57 @@ int w_httpPost(lua_State *L)
return 1; 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 <code>" or "ERROR <text>", 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<std::string> 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<const char *> 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) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -245,6 +299,7 @@ static const luaL_Reg functions[] =
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost }, { "httpPost", w_httpPost },
{ "httpRequest", w_httpRequest },
{ "tlsOpen", w_tlsOpen }, { "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus }, { "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend }, { "tlsSend", w_tlsSend },
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@@ -36,6 +37,7 @@ import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import android.Manifest; 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 &lt;code&gt;" or
* "ERROR &lt;text&gt;", 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 * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive / * (pending_export.sav in the app save identity) to Downloads / Drive /
+118
View File
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
return succeeded 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 <code>" or "ERROR <text>", 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<CChar>?,
method: UnsafePointer<CChar>?,
headers: UnsafePointer<CChar>?,
body: UnsafePointer<UInt8>?,
bodyLength: Int32,
userAgent: UnsafePointer<CChar>?) -> 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..<colon]
.trimmingCharacters(in: .whitespaces)
let value = line[line.index(after: colon)...]
.trimmingCharacters(in: .whitespaces)
if name.isEmpty {
return httpEnvelope("ERROR bad request header", nil)
}
request.setValue(value, forHTTPHeaderField: name)
}
}
if verb != "GET", let body, bodyLength > 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) // MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:) @objc(presentPickerWithKind:saveDir:)
+80 -2
View File
@@ -9,7 +9,8 @@ What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift, 1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree. GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile, 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 calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly). liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native 3. Patches love.xcodeproj so the love-ios app target compiles the native
@@ -50,6 +51,7 @@ WRAP_INCLUDES = """
#include <objc/runtime.h> #include <objc/runtime.h>
#include <objc/message.h> #include <objc/message.h>
#include <string> #include <string>
#include <vector>
#include "filesystem/Filesystem.h" #include "filesystem/Filesystem.h"
#endif #endif
""" % MARKER """ % MARKER
@@ -159,6 +161,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif #endif
""" """
@@ -201,6 +204,7 @@ int w_syncHealthSteps(lua_State *L)
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif #endif
""" """
@@ -226,6 +230,80 @@ int w_httpDownload(lua_State *L)
lua_pushboolean(L, ok != 0); lua_pushboolean(L, ok != 0);
return 1; 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 <code>" or "ERROR <text>", 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<std::string> 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 #endif
""" """
@@ -308,7 +386,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1) text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text) WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched " print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps/httpDownload)") "(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
def patch_public_documents(): def patch_public_documents():
+523
View File
@@ -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
+36 -2
View File
@@ -34,6 +34,7 @@ end
function Game:load() function Game:load()
self.data = Data self.data = Data
self.sessionStartedAt = os.time()
Data:load() Data:load()
-- Mods are a native engine subsystem. They load after the verified ROM -- Mods are a native engine subsystem. They load after the verified ROM
@@ -155,6 +156,7 @@ function Game:makeTitleState()
onNewGame = function() onNewGame = function()
while self.stack:top() do self.stack:pop() end while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences -- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig()) self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must -- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead -- 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 -- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt) require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end) pcall(function() require("src.core.DiscordPresence").update(dt) end)
self:updateSync(dt)
-- Steady-state memory backstop: advance the incremental collector one -- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed -- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this -- 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 -- stamp here so the save.writing payload carries the exact meta the
-- file gets; mods snapshot runtime state into their namespace now -- file gets; mods snapshot runtime state into their namespace now
self.save.meta = SaveData.buildMeta( 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 if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end 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 end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -1241,6 +1274,7 @@ function Game:applyOptions(opts)
end end
function Game:restoreSave(loaded, recovered, opts) function Game:restoreSave(loaded, recovered, opts)
self.sessionStartedAt = os.time()
if ModRuntime.wants("save.loading") then if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded }) ModRuntime.emit("save.loading", { raw = loaded })
end end
+23 -9
View File
@@ -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. -- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport") 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 -- 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 -- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua. -- IO is src/core/gen2/Save.lua.
@@ -195,6 +196,7 @@ end
function Game2:persistOptions() function Game2:persistOptions()
pcall(Save.saveOptions, self.options) pcall(Save.saveOptions, self.options)
end end
Game2.writeOptions = Game2.persistOptions
-- Point the loader's mod.save backing at this save's modData so per-mod state -- 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 -- 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 -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
-- one screen pixel a cell out at survey range. -- one screen pixel a cell out at survey range.
function Game2:pixelScale(w, h) 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 end
-- A window-sized canvas the whole frame is composed into, so the post passes -- 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) function Game2:blitZones(canvas, zones, w, h)
local G = love.graphics local G = love.graphics
local GbcPalette = require("src.render.GbcPalette") 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) G.setColor(1, 1, 1, 1)
for _, z in ipairs(zones) do for _, z in ipairs(zones) do
-- a colors == false zone is the true-colour opt-out; anything the shader -- 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 -- 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 -- come out of this at exactly the pixels the plain floor/ceil pair gave
-- them, so the vanilla picture is untouched. -- them, so the vanilla picture is untouched.
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
local x1 = math.floor(math.max(zx, 0)) local x1 = math.floor(math.max(zx, px))
local y1 = math.floor(math.max(zy, 0)) local y1 = math.floor(math.max(zy, py))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w)) 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, h)) local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
if x2 > x1 and y2 > y1 then if x2 > x1 and y2 > y1 then
G.setScissor(x1, y1, x2 - x1, y2 - y1) G.setScissor(x1, y1, x2 - x1, y2 - y1)
G.draw(canvas, 0, 0) G.draw(canvas, 0, 0)
@@ -1402,7 +1406,7 @@ function Game2:drawViewportFrame()
scene = self:presentCanvas(1, w, h) scene = self:presentCanvas(1, w, h)
end end
if not scene then if not scene then
self:drawScene(w, h) self:drawContained(w, h)
self:drawHud(w, h) self:drawHud(w, h)
return return
end end
@@ -1413,7 +1417,7 @@ function Game2:drawViewportFrame()
G.origin() G.origin()
G.setCanvas(scene) G.setCanvas(scene)
G.clear(0, 0, 0, 1) G.clear(0, 0, 0, 1)
self:drawScene(w, h) self:drawContained(w, h)
G.setCanvas(previous) G.setCanvas(previous)
if composing and self:compose(scene, zones, w, h) then if composing and self:compose(scene, zones, w, h) then
@@ -1462,6 +1466,8 @@ function Game2:drawViewportFrame()
generation = 2, generation = 2,
}) == true }) == true
if not outputHandled then 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 if fx then
GBCFX.present(source, self:pixelScale(w, h)) GBCFX.present(source, self:pixelScale(w, h))
else else
@@ -1469,6 +1475,7 @@ function Game2:drawViewportFrame()
G.draw(source, 0, 0) G.draw(source, 0, 0)
G.setShader() G.setShader()
end end
if cx then G.setScissor() end
end end
end end
G.pop() G.pop()
@@ -1499,6 +1506,13 @@ function Game2:textboxPaper()
return nil return nil
end 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) function Game2:drawScene(w, h)
local G = love.graphics local G = love.graphics
-- render.compose reads this after the scene is drawn; the plain overworld -- render.compose reads this after the scene is drawn; the plain overworld
+181
View File
@@ -350,11 +350,23 @@ local function haveBridge()
return osName == "Android" or osName == "iOS" or osName == "UWP" return osName == "Android" or osName == "iOS" or osName == "UWP"
end 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. -- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch() function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge() return HostShell.haveCurl() or haveBridge()
end 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. -- 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 -- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got. -- 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" return nil, "no POST transport on this platform"
end 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 return HostShell
+28 -2
View File
@@ -356,6 +356,8 @@ function SaveData.defaultOptions()
-- rewind presentation preferences. -- rewind presentation preferences.
dateFormat = "device", -- device | dmy | mdy | ymd dateFormat = "device", -- device | dmy | mdy | ymd
timeFormat = "device", -- device | 24h | 12h timeFormat = "device", -- device | 24h | 12h
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
pendingConflicts = {} },
} }
end end
@@ -1013,6 +1015,22 @@ function SaveData.listSlots(version)
return out return out
end 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 -- 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 -- 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 -- 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 -- 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 -- mod-set diff. A nil mods list keeps the previous stamp's set so a
-- headless writer (the save editor) never wipes it. -- headless writer (the save editor) never wipes it.
function SaveData.buildMeta(mods, previous) function SaveData.buildMeta(mods, previous, sessionStart)
local list local list
if mods ~= nil then if mods ~= nil then
list = {} list = {}
@@ -1338,10 +1356,18 @@ function SaveData.buildMeta(mods, previous)
else else
list = (type(previous) == "table" and previous.mods) or {} list = (type(previous) == "table" and previous.mods) or {}
end 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 { return {
format = Version.saveFormat, format = Version.saveFormat,
engine = Version.engine, engine = Version.engine,
savedAt = os.time(), savedAt = savedAt,
sessionStart = started,
playthroughId = type(previous) == "table" and previous.playthroughId or nil, playthroughId = type(previous) == "table" and previous.playthroughId or nil,
mods = list, mods = list,
} }
+6 -4
View File
@@ -616,13 +616,15 @@ local function exitControl(self, ctl)
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end end
function skinHitSet(self, x, y) function skinHitSet(self, x, y, prev)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page then return nil end if not page then return nil end
local ww, wh, ox, oy = surfaceRect() local ww, wh, ox, oy = surfaceRect()
local set = nil local set = nil
for _, ctl in ipairs(page.controls) do 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 = set or {}
set[ctl] = true set[ctl] = true
end end
@@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y)
return return
end end
if TouchSkin.active then if TouchSkin.active then
local set = skinHitSet(self, x, y) local set = skinHitSet(self, x, y, nil)
if not set then return end if not set then return end
local touch = { control = "skin" } local touch = { control = "skin" }
self.touches[id] = touch self.touches[id] = touch
@@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id] local touch = self.touches[id]
if not touch then return end if not touch then return end
if touch.control == "skin" then if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y)) applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
return return
end end
-- only the d-pad tracks movement (slide between directions without -- only the d-pad tracks movement (slide between directions without
+443 -46
View File
@@ -2,6 +2,7 @@ local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins" TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins" TouchSkin.USER_ROOT = "skins"
TouchSkin.EXPORT_ROOT = "skins/_export"
TouchSkin.GB_BUTTONS = { TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select", a = "a", b = "b", start = "start", select = "select",
@@ -83,6 +84,110 @@ local function parseBinds(spec)
return buttons, hotkeys, keys, decorative return buttons, hotkeys, keys, decorative
end 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 function parseDesc(kv, prefix, page)
local spec = kv[prefix] local spec = kv[prefix]
if not spec then return nil end if not spec then return nil end
@@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page)
imagePath = kv[prefix .. "_overlay"], imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"], pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"], 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.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = 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 return ctl
end end
@@ -127,7 +251,14 @@ function TouchSkin.parse(text)
local count = math.floor(num(kv.overlays, 0)) local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end 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 for i = 0, count - 1 do
local p = "overlay" .. i local p = "overlay" .. i
local page = { local page = {
@@ -166,10 +297,34 @@ function TouchSkin.parse(text)
page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end 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)) local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page) 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 end
pages[#pages + 1] = page pages[#pages + 1] = page
end end
@@ -180,7 +335,7 @@ function TouchSkin.parse(text)
if not page.orient then page.orient = TouchSkin.pageOrient(page) end if not page.orient then page.orient = TouchSkin.pageOrient(page) end
end end
return { pages = pages } return { pages = pages, warnings = warnings }
end end
local function readFile(path) local function readFile(path)
@@ -219,18 +374,26 @@ end
TouchSkin.NATIVE_NAME = "skin.lua" TouchSkin.NATIVE_NAME = "skin.lua"
TouchSkin.readFile = readFile
TouchSkin.listDir = listDir
TouchSkin.isDir = isDir
local function findConfig(root) local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native" return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
end end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do 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
end
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
if infoPath then return infoPath, "delta", prefix end
local items = listDir(root) local items = listDir(root)
table.sort(items) table.sort(items)
for _, name in ipairs(items) do 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 end
return nil return nil
end end
@@ -262,6 +425,7 @@ function TouchSkin.parseNative(text)
imagePath = raw.image, imagePath = raw.image,
fullScreen = raw.fullScreen ~= false, fullScreen = raw.fullScreen ~= false,
normalized = true, normalized = true,
pixelCoords = false,
rangeMod = num(raw.rangeMod, 1), rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1), alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT), aspect = num(raw.aspect, DEFAULT_ASPECT),
@@ -283,7 +447,24 @@ function TouchSkin.parseNative(text)
end end
for _, c in ipairs(raw.controls or {}) do for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") 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] = { 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"), spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys, buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative, decorative = decorative,
@@ -298,6 +479,8 @@ function TouchSkin.parseNative(text)
imagePath = c.image, imagePath = c.image,
pressedImagePath = c.imagePressed, pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget, nextTarget = c.nextTarget,
movable = c.movable == true or nil,
exclusive = c.exclusive == true or nil,
} }
end end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end if not page.orient then page.orient = TouchSkin.pageOrient(page) end
@@ -355,6 +538,14 @@ function TouchSkin.toNative(skin)
image = ctl.imagePath, image = ctl.imagePath,
imagePressed = ctl.pressedImagePath, imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget, 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 end
out.pages[#out.pages + 1] = p out.pages[#out.pages + 1] = p
@@ -379,14 +570,44 @@ local function loadImage(path)
return img return img
end 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) function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root) local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
local text = readFile(cfgPath) local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end if not text then return nil, "unreadable " .. cfgPath end
local skin, err local skin, err
if format == "native" then if format == "native" then
skin, err = TouchSkin.parseNative(text) 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 else
skin, err = TouchSkin.parse(text) skin, err = TouchSkin.parse(text)
end end
@@ -402,6 +623,10 @@ function TouchSkin.load(root, id)
if page.imagePath then if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath)) page.image = loadImage(joinPath(root, page.imagePath))
end 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 for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
if ctl.pressedImagePath then if ctl.pressedImagePath then
@@ -418,14 +643,30 @@ local function mountZip(archive, point)
return ok and mounted == true return ok and mounted == true
end 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() function TouchSkin.list()
local out, seen = {}, {} local out, seen = {}, {}
local function scan(root, source) local function scan(root, source)
for _, name in ipairs(listDir(root)) do for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "") local archiveId = TouchSkin.archiveId(name)
if not seen[id] then local id = archiveId or name
if not seen[id] and name:sub(1, 1) ~= "_" then
local path = root .. "/" .. name local path = root .. "/" .. name
if name:match("%.zip$") then if archiveId then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then if mountZip(path, point) and findConfig(point) then
seen[id] = true seen[id] = true
@@ -447,17 +688,20 @@ function TouchSkin.list()
return out return out
end end
-- Drop a .zip into <save>/skins and report the id it will list under. -- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
function TouchSkin.installArchive(name, data) function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem" return nil, "no writable filesystem"
end end
name = tostring(name or ""):match("([^/\\]+)$") or "" name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_") name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end local legacy = name:match("%.([%w]+)$")
local id = name:gsub("%.[Zz][Ii][Pp]$", "") if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
if id == "" then return nil, "bad archive name" end 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) pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name local dest = TouchSkin.USER_ROOT .. "/" .. name
@@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data)
local entry = TouchSkin.find(id) local entry = TouchSkin.find(id)
if not entry then if not entry then
love.filesystem.remove(dest) 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 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 end
function TouchSkin.find(id) function TouchSkin.find(id)
@@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin)
return out return out
end end
function TouchSkin.export(skin, destPath) local function writeArchive(entries, destPath)
if not skin then return nil, "no skin" end local blob = require("src.core.SkinZip").encode(entries)
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 absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil 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 if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob) local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end if not ok then return nil, tostring(err) end
@@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath)
handle:write(blob) handle:write(blob)
handle:close() handle:close()
end 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 return destPath, missing
end 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 = { TouchSkin.BINDS = {
"nul", "nul",
"up", "down", "left", "right", "up", "down", "left", "right",
@@ -597,6 +988,7 @@ function TouchSkin.clone(skin)
id = skin.id, name = skin.name, root = skin.root, format = skin.format, id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath, author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {}, source = skin.source, pages = {},
warnings = skin.warnings and copyTable(skin.warnings) or nil,
} }
for i, page in ipairs(skin.pages or {}) do for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page) local p = copyTable(page)
@@ -676,7 +1068,8 @@ function TouchSkin.listImages(root)
local function scan(dir, prefix) local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name 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 out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then elseif isDir(path) and prefix == "" then
scan(path, name .. "/") scan(path, name .. "/")
@@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
return cx, cy, halfW, halfH return cx, cy, halfW, halfH
end 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 cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod local mod = held == false and 1 or ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod local left = halfW * ctl.reachLeft * mod
local up = halfH * ctl.reachUp * ctl.rangeMod local right = halfW * ctl.reachRight * mod
local down = halfH * ctl.reachDown * ctl.rangeMod local up = halfH * ctl.reachUp * mod
local down = halfH * ctl.reachDown * mod
local dx = px - cx local dx = px - cx
local dy = py - cy local dy = py - cy
local rx = dx < 0 and left or right local rx = dx < 0 and left or right
local ry = dy < 0 and up or down local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end 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 if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end end
+29 -2
View File
@@ -301,6 +301,13 @@ end
-- gear edit these before the game starts (src/import/LauncherSettings.lua). -- gear edit these before the game starts (src/import/LauncherSettings.lua).
Save.OPTIONS_KEY = "gold" 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) function Save.loadOptions(fs)
local options = Save.defaultOptions() local options = Save.defaultOptions()
local ok, SaveData = pcall(require, "src.core.SaveData") local ok, SaveData = pcall(require, "src.core.SaveData")
@@ -308,7 +315,21 @@ function Save.loadOptions(fs)
local loaded = SaveData.loadOptions(fs) local loaded = SaveData.loadOptions(fs)
local stored = loaded and loaded[Save.OPTIONS_KEY] local stored = loaded and loaded[Save.OPTIONS_KEY]
if type(stored) == "table" then 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 end
return options return options
end end
@@ -321,7 +342,13 @@ function Save.saveOptions(options, fs)
if not ok then return false end if not ok then return false end
local file = SaveData.loadOptions(fs) or {} local file = SaveData.loadOptions(fs) or {}
local block = {} 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 file[Save.OPTIONS_KEY] = block
SaveData.saveOptions(file, fs) SaveData.saveOptions(file, fs)
return true return true
+632 -40
View File
@@ -48,6 +48,14 @@ local TAP_SLOP2 = 16 * 16
-- Installed mods should not turn into a one- or two-item pager on a compact -- 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. -- display. Keep a useful page size, then let the list viewport scroll.
local MIN_MODS_PER_PAGE = 10 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 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 and y >= rect.y and y <= rect.y + rect.h
end 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 -- ------------------------------------------------------------- lifecycle
local function ensureState(imp) local function ensureState(imp)
@@ -65,6 +99,9 @@ local function ensureState(imp)
imp._actAt = imp._actAt or {} imp._actAt = imp._actAt or {}
imp._uiActions = imp._uiActions or {} imp._uiActions = imp._uiActions or {}
imp._pages = imp._pages 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 -- Held backspace/arrows must repeat in the text fields; restored on
-- detach because the game's Input does its own per-step edge detection -- detach because the game's Input does its own per-step edge detection
-- and never expects repeated keypressed events. -- and never expects repeated keypressed events.
@@ -155,7 +192,10 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {} imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = { imp._touchAt[tostring(id)] = {
x = x, y = y, 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 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 -- 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 -- rows. Its pager remains available for moving to the next ten-plus
-- entries; a drag elsewhere keeps the normal short-window page scroll. -- 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 local last = start.lastY or start.y
imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0, local move = -(y - last)
imp._modScrollMax or 0) if start.modsList then
elseif start.dragged and (imp._pageScrollMax or 0) > 0 then local listMax = imp._modScrollMax or 0
local last = start.lastY or start.y local at, leftover = Kit.scrollHandoff(
imp._pageScroll = (imp._pageScroll or 0) - (y - last) 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 end
start.lastY = y start.lastY = y
end 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) Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
end 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) local function drawCross(x, y, size, color)
love.graphics.push("all") love.graphics.push("all")
love.graphics.setColor(color) love.graphics.setColor(color)
@@ -885,6 +965,8 @@ local function headerChrome(imp)
hot and QUIT_INK_HOT or QUIT_INK_REST) hot and QUIT_INK_HOT or QUIT_INK_REST)
end }, end },
tab = {}, tab = {},
sync = { face = "tab", drawFn = drawSyncGlyph,
action = function() imp:_openSync() end },
game = { face = "tab", font = "tab", game = { face = "tab", font = "tab",
action = function() action = function()
local g = currentGame(imp) local g = currentGame(imp)
@@ -1050,6 +1132,27 @@ local function buildHeader(imp, m)
tx = tx + w + tabGap tx = tx + w + tabGap
end 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. -- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s) y = ty + tabH + math.floor(8 * m.s)
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) 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 return h
end 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 imp.panelVersion = version
local info = GameVersion.info(version) local info = GameVersion.info(version)
local locked = info == nil 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 afterTitle = math.floor((ready and 22 or 12) * m.s)
local cy = y + titleH + afterTitle local cy = y + titleH + afterTitle
local remaining = availH - (titleH + afterTitle) local remaining = availH - (titleH + afterTitle)
local budgetLeft = math.max(remaining,
(budgetH or availH) - (titleH + afterTitle))
local gap = m.gap local gap = m.gap
local lx, lw, rx2, rw local lx, lw, rx2, rw
if m.twoCol then if m.twoCol then
lx, lw = x, m.colW local colW = math.floor((w - m.colGap) / 2)
rx2, rw = x + m.colW + m.colGap, m.colW lx, lw = x, colW
rx2, rw = x + colW + m.colGap, colW
else else
lx, lw, rx2, rw = x, w, x, w lx, lw, rx2, rw = x, w, x, w
end 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 -- 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 -- stacks them underneath. Either way the card is clipped to the room it
-- actually has, and sizes its own list to that budget. -- actually has, and sizes its own list to that budget.
local bottom = ly
if not locked then if not locked then
local slotY = m.twoCol and cy or ly 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 if slotAvail > 80 * m.s then
Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) 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() Kit.popClip()
bottom = math.max(bottom, slotY + math.min(slotH or 0, slotAvail))
end end
end end
return bottom - y
end end
-- --------------------------------------------------------------- mods panel -- --------------------------------------------------------------- mods panel
@@ -1821,7 +1931,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
if #mods == 0 then if #mods == 0 then
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return return (cy - y) + math.floor(110 * m.s)
end end
local sortKey = currentSort(imp, "mods") 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 if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax 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) scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0 Kit.wheelY = 0
elseif scrollMax == 0 then elseif scrollMax == 0 then
@@ -2008,9 +2119,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
Kit.popClip() Kit.popClip()
local pagerY = listTop + listH + gap 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 if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage) setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
end end
-- ---------------------------------------------------------- find mods panel -- ---------------------------------------------------------- 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) imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end 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. -- Studio button. Desktop only: the host supplies the hook nowhere else.
if imp.onOpenSkinStudio then if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio") 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 -- 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 -- 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. -- 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 gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) 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, 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 local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s)) and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * 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 tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s) local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty, 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 TouchSkin = require("src.core.TouchSkin")
local hint = Strings( 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) TouchSkin.USER_ROOT)
local hintH = Kit.wrapHeight("small", hint, w, 3) local hintH = Kit.wrapHeight("small", hint, w, 3)
local importH = math.floor(10 * m.s) + hintH 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 pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listTop = cy local listTop = cy
local listH = availH - (cy - y) - importH 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 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 end
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"), local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
#entries, perPage) #entries, perPage)
@@ -2142,11 +2291,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
bits[#bits + 1] = entry.pages .. " " .. Strings("pages") bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = imp.onOpenSkinStudio and function() local configure = function() imp._skinActions = { id = entry.id } end
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
end or nil
skinRow("skin-" .. entry.id, entry.id, entry.id, 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
end end
@@ -2164,6 +2312,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + math.floor(10 * m.s) cy = cy + math.floor(10 * m.s)
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3) Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
return cy + hintH - y
end end
local function buildFindPanel(imp, x, y, w, availH, m) 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"), { aw, m.btnH, "find-add", Strings("Add an index"), {
kind = "accent", font = "small", kind = "accent", font = "small",
action = function() imp._indexManage = true end }) action = function() imp._indexManage = true end })
return return (cy - y) + h
end end
-- One row: the search field, then Filter / Sort / Indexes popup buttons. -- 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), Kit.emptyBox(x, cy, w, math.floor(110 * m.s),
(total == 0) and Strings("This index lists no mods yet.") (total == 0) and Strings("This index lists no mods yet.")
or Strings("No mods match that search.")) or Strings("No mods match that search."))
return return (cy - y) + math.floor(110 * m.s)
end end
local sortKey = currentSort(imp, "find") local sortKey = currentSort(imp, "find")
@@ -2283,7 +2432,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s) + math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap 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) local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage)
setPage(imp, "find", cur) setPage(imp, "find", cur)
local listTop = cy local listTop = cy
@@ -2384,7 +2533,10 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end end
local pagerY = listTop + (last - first + 1) * (rowH + gap) 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 -- 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 -- 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...", Kit.text("micro", Strings("Checking %d of %d on this page...",
waiting, last - first + 1), waiting, last - first + 1),
x + dh + math.floor(6 * m.s), py, PAL.muted) x + dh + math.floor(6 * m.s), py, PAL.muted)
bottom = math.max(bottom, py + dh)
end end
return bottom - y
end end
-- ------------------------------------------------------------------ footer -- ------------------------------------------------------------------ footer
@@ -3161,6 +3315,75 @@ end
-- Per-mod actions for the MODS tab: the row itself only carries the enable -- Per-mod actions for the MODS tab: the row itself only carries the enable
-- toggle, everything episodic (update check, versions, delete) lives here. -- 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 function buildModActionsModal(imp, m)
local mod local mod
for _, mm in ipairs(imp.mods or {}) do for _, mm in ipairs(imp.mods or {}) do
@@ -3907,6 +4130,335 @@ local function buildDepResolverModal(imp, m)
end end
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 -- 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 build: immediate mode hit-tests each control as it draws, so the
-- panels underneath a modal must run with Kit.blockClicks already raised or -- 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._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup 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._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end end
@@ -4064,6 +4616,8 @@ local function buildModals(imp, m)
if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(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._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._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(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 if imp._gameManage then buildGameManageModal(imp, m) return true end
@@ -4173,13 +4727,6 @@ function LauncherView.draw(imp)
local footH = footerHeight(imp, m) local footH = footerHeight(imp, m)
local naturalAvail = m.h - headerHeight(m) - footH - m.gap local naturalAvail = m.h - headerHeight(m) - footH - m.gap
local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail) 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) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil imp._clickPt = nil
@@ -4192,6 +4739,35 @@ function LauncherView.draw(imp)
-- one is up; buildModals lowers the shield for the modal's own controls. -- one is up; buildModals lowers the shield for the modal's own controls.
Kit.blockClicks = modalUp(imp) 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 -- 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 -- 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 -- proxy: the proxy cost two tables a frame and put a metatable lookup on
@@ -4210,15 +4786,31 @@ function LauncherView.draw(imp)
end end
local x, w = m.contentX, m.contentW 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 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 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 elseif imp.tab == "skins" then
buildSkinsPanel(imp, x, contentY, w, availH, m) contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
else else
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab) contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end 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) buildFooter(imp, m, footY)
Kit.blockClicks = false Kit.blockClicks = false
+421 -12
View File
@@ -1111,18 +1111,18 @@ local function chooseZip()
end end
local function chooseSkinZip() 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() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( 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)) :format(prompt))
elseif platform == "Windows" then elseif platform == "Windows" then
local script = table.concat({ local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;", "Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';", "$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'){", "if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;", "$t=Join-Path $env:TEMP $n;",
@@ -1134,11 +1134,11 @@ local function chooseSkinZip()
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then elseif platform == "Linux" then
local path = commandOutput( 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)) :format(prompt))
if path then return path end if path then return path end
return commandOutput( return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]]) [[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
end end
return nil return nil
end end
@@ -1349,6 +1349,7 @@ function RomImporter.new(onComplete, opts)
findLoaded = false, findSources = nil, findIndex = nil, findLoaded = false, findSources = nil, findIndex = nil,
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
_findSearchFocus = false, _findThumbs = nil, _findSearchFocus = false, _findThumbs = nil,
skinUrl = "", _skinUrlFocus = false,
-- Page scroll offset (px) for the column under the tab bar -- panel, updater -- 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 -- 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. -- (see draw()). Clamped against content in draw, reset on a tab change.
@@ -1851,10 +1852,14 @@ end
function RomImporter:filedropped(file) function RomImporter:filedropped(file)
if self.workState == "working" then return end if self.workState == "working" then return end
-- A dropped .zip is a mod archive: hand it straight to the mods installer -- 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 -- (which mounts + validates it). A .deltaskin is only ever a skin, and
-- dropped file itself is passed through -- installZip opens it the same way -- everything else is treated as a ROM. The dropped file itself is passed
-- readDroppedFile does here. -- through -- installZip opens it the same way readDroppedFile does here.
local name = file:getFilename() or "" local name = file:getFilename() or ""
if name:lower():match("%.deltaskin$") then
self:_installSkinZip(file)
return
end
if name:lower():match("%.zip$") then if name:lower():match("%.zip$") then
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then if self.tab == "skins" then
@@ -2481,6 +2486,8 @@ function RomImporter:update(dt)
self:_pumpModInfoFetch() self:_pumpModInfoFetch()
self:_pumpFindStats() self:_pumpFindStats()
self:_pumpFindThumbs() self:_pumpFindThumbs()
self:_pumpSkinFetch()
self:_pumpSync(dt)
self:_pumpModCheck() self:_pumpModCheck()
self:_pumpModInstall() self:_pumpModInstall()
self:_pumpExtract() self:_pumpExtract()
@@ -3082,6 +3089,8 @@ end
function RomImporter:_switchTab(id) function RomImporter:_switchTab(id)
self.tab = id self.tab = id
self._findSearchFocus = false self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput() self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back -- 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 -- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3107,6 +3116,7 @@ function RomImporter:_ensureSkins(force)
out[#out + 1] = { out[#out + 1] = {
id = entry.id, id = entry.id,
source = entry.source, source = entry.source,
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0, pages = skin and #skin.pages or 0,
controls = controls, controls = controls,
screen = page ~= nil and page.viewport ~= nil, screen = page ~= nil and page.viewport ~= nil,
@@ -3140,7 +3150,6 @@ end
function RomImporter:_installSkinZip(source) function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end if self.workState == "working" then return end
self.tab = "skins" self.tab = "skins"
local TouchSkin = require("src.core.TouchSkin")
local name, data, readError local name, data, readError
if type(source) == "string" then if type(source) == "string" then
name = source name = source
@@ -3160,13 +3169,368 @@ function RomImporter:_installSkinZip(source)
.. tostring(readError or name) } .. tostring(readError or name) }
return return
end 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) self:_ensureSkins(true)
if not id then 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 return
end 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 end
function RomImporter:_skinsImportButtonLabel() function RomImporter:_skinsImportButtonLabel()
@@ -3338,6 +3702,27 @@ function RomImporter:keypressed(key)
if key == "escape" then self:_closeSettings() end if key == "escape" then self:_closeSettings() end
return return
end 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 self._rename then
if key == "backspace" then if key == "backspace" then
self._rename.text = utf8Back(self._rename.text) self._rename.text = utf8Back(self._rename.text)
@@ -3387,6 +3772,21 @@ function RomImporter:keypressed(key)
end end
return return
end 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 self._findSearchFocus then
if key == "backspace" then if key == "backspace" then
self.findQuery = utf8Back(self.findQuery or "") self.findQuery = utf8Back(self.findQuery or "")
@@ -3494,6 +3894,10 @@ function RomImporter:_commitRename()
end end
function RomImporter:textinput(text) function RomImporter:textinput(text)
if self._syncModal and self._syncFocus then
self:_syncTypeInto(self._syncFocus, text)
return
end
if self._profileSavePrompt then if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL) self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return return
@@ -3514,6 +3918,11 @@ function RomImporter:textinput(text)
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
return return
end end
if self._skinUrlFocus then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
return
end
if self._findSearchFocus then if self._findSearchFocus then
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY) self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
self.findScroll = 0 self.findScroll = 0
+9
View File
@@ -86,6 +86,7 @@ local function drain()
else else
j.status = msg.ok and "ok" or "error" j.status = msg.ok and "ok" or "error"
j.body, j.err, j.path = msg.body, msg.err, msg.path 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 j.progress = msg.ok and 1 or j.progress
end end
end end
@@ -143,6 +144,14 @@ function Fetch.post(url, body, opts)
contentType = opts.contentType, maxSeconds = opts.maxSeconds }) contentType = opts.contentType, maxSeconds = opts.maxSeconds })
end 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. -- 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. -- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts) function Fetch.download(url, saveRel, opts)
+19
View File
@@ -113,6 +113,22 @@ local function doPost(job)
post({ id = job.id, ok = true, done = true }) post({ id = job.id, ok = true, done = true })
end 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 while true do
local job = cmdCh:demand() local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a -- 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 elseif job.kind == "post" then
local ok, err = pcall(doPost, job) local ok, err = pcall(doPost, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end 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 elseif job.kind == "download" then
local ok, err = pcall(doDownload, job) local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
+81
View File
@@ -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
+113 -61
View File
@@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport") local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle -- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes") local FaithfulRes = require("src.core.FaithfulRes")
local TouchSkin = require("src.core.TouchSkin") local Playfield = require("src.render.Playfield")
local Renderer = {} local Renderer = {}
@@ -86,12 +86,12 @@ local function displayMetrics()
if dpiX < 1e-6 then dpiX = 1 end if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end if dpiY < 1e-6 then dpiY = 1 end
local vx, vy = 0, 0 local vx, vy = 0, 0
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph) local cut, grow = false, false
if sw and sw >= 1 and sh >= 1 then local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
vx, vy = math.floor(sx), math.floor(sy) if sx then
pw, ph = math.floor(sw), math.floor(sh) vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
end end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
end end
function Renderer:init() function Renderer:init()
@@ -269,7 +269,7 @@ end
-- corners; flat mode returns exactly today's size (growth factor is 1 when -- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive). -- tilt is inactive).
function Renderer:worldViewSize() 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 -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars. -- 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 -- 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 -- 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. -- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap() local cap = FaithfulRes.scaleCap()
if not cap and TouchSkin.hasViewport() then if not cap and cut and not grow then cap = self:fitScale() end
local page = TouchSkin.page()
if not page.viewportExpand then cap = self:fitScale() end
end
if cap then if cap then
local uiw, uih = self:uiSize() 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 end
local sp = Zoom.scale(self:fitScale()) local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
@@ -346,19 +344,20 @@ end
-- window is the classic wipe unchanged. -- window is the classic wipe unchanged.
-- --
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces). -- 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 if not wipe or not wipe.prog or wipe.prog <= 0 then return end
Sy = Sy or Sx Sy = Sy or Sx
wx, wy = wx or 0, wy or 0
local TW, TH = 8 * Sx, 8 * Sy local TW, TH = 8 * Sx, 8 * Sy
if TW < 1 then TW = 1 end if TW < 1 then TW = 1 end
if TH < 1 then TH = 1 end if TH < 1 then TH = 1 end
local prog = math.min(1, wipe.prog) local prog = math.min(1, wipe.prog)
love.graphics.setColor(0, 0, 0, 1) 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 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.setScissor()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
return 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 -- whole-tile padding out to each window edge, keeping the grid in phase
-- with the letterbox's tiles -- with the letterbox's tiles
local padL = math.max(0, math.ceil(ox / TW)) local padL = math.max(0, math.ceil((ox - wx) / TW))
local padT = math.max(0, math.ceil(oy / TH)) local padT = math.max(0, math.ceil((oy - wy) / TH))
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW)) local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW))
local padB = math.max(0, math.ceil((wh - oy - vph) / TH)) local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH))
local lbCols = math.max(1, math.floor(vpw / TW + 0.5)) local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
local lbRows = math.max(1, math.floor(vph / TH + 0.5)) local lbRows = math.max(1, math.floor(vph / TH + 0.5))
local cols, rows = padL + lbCols + padR, padT + lbRows + padB 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 for row = 0, rows - 1 do
local y = y0 + row * TH local y = y0 + row * TH
if row % 2 == 0 then if row % 2 == 0 then
love.graphics.rectangle("fill", 0, y, w, TH) love.graphics.rectangle("fill", wx, y, w, TH)
else else
love.graphics.rectangle("fill", ww - w, y, w, TH) love.graphics.rectangle("fill", wx + ww - w, y, w, TH)
end end
end end
elseif style == "vstripes" then 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 for col = 0, cols - 1 do
local x = x0 + col * TW local x = x0 + col * TW
if col % 2 == 0 then if col % 2 == 0 then
love.graphics.rectangle("fill", x, 0, TW, h) love.graphics.rectangle("fill", x, wy, TW, h)
else else
love.graphics.rectangle("fill", x, wh - h, TW, h) love.graphics.rectangle("fill", x, wy + wh - h, TW, h)
end end
end end
elseif style == "shrink" then elseif style == "shrink" then
local h, w = wh / 2 * prog, ww / 2 * prog local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, 0, ww, h) love.graphics.rectangle("fill", wx, wy, ww, h)
love.graphics.rectangle("fill", 0, wh - h, ww, h) love.graphics.rectangle("fill", wx, wy + wh - h, ww, h)
love.graphics.rectangle("fill", 0, 0, w, wh) love.graphics.rectangle("fill", wx, wy, w, wh)
love.graphics.rectangle("fill", ww - w, 0, 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 else -- split: a black cross growing out of the centre in both axes
local h, w = wh / 2 * prog, ww / 2 * prog local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2) love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2)
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh) love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh)
end end
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -542,7 +541,8 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on). -- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall -- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged. -- 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 shader = self:tiltShader()
local mesh = self:tiltMesh() local mesh = self:tiltMesh()
if not (shader and mesh) then return false end 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:setTexture(self.tiltCanvas)
mesh:setVertices(Tilt.meshCorners(wvw, wvh)) mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push() 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.translate(wox, woy)
love.graphics.scale(sx, sy) love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader) love.graphics.setShader(shader)
love.graphics.draw(mesh) love.graphics.draw(mesh)
love.graphics.setShader() love.graphics.setShader()
love.graphics.setScissor()
love.graphics.pop() love.graphics.pop()
return true return true
end end
@@ -755,20 +759,23 @@ end
-- scissored through the shade-remap shader, later zones on top. -- scissored through the shade-remap shader, later zones on top.
-- When GBC FX is active the composite is drawn into presentCanvas and -- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:frameRects()
GameViewport.setTarget() local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics() local r = {
local vux, vuy = vx / dpiX, vy / dpiY ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY,
local vuw, vuh = pw / dpiX, ph / 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; -- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale() 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 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. -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY 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 -- 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 -- 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. -- 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 if self.uiFill then
Up = math.min(ph / uih, pw / uiw) Up = math.min(ph / uih, pw / uiw)
end end
local Ux, Uy = Up / dpiX, Up / dpiY if uiw * Up > pw or uih * Up > ph then
local uvpw, uvph = uiw * Ux, uih * Uy Up = math.min(ph / uih, pw / uiw)
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX end
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY 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") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- 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, ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih, vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
secondScreen = require("src.render.SecondScreen"), secondScreen = require("src.render.SecondScreen"),
} }
if Runtime.call("render.compose", function() return false end, self, ctx) == true then 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) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end end
end end
love.graphics.setColor(clearR, clearG, clearB, 1) if cut then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
if extendedBlackBand then if extendedBlackBand then
love.graphics.setColor(bandR, bandG, bandB, 1) love.graphics.setColor(bandR, bandG, bandB, 1)
love.graphics.rectangle("fill", uox, 0, uvpw, wh) love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the -- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game -- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border. -- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then if Runtime.wantsHook("render.letterbox") then
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
Runtime.call("render.letterbox", function() end, { Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph, ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph, ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY, scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false, worldActive = self.worldActive and true or false,
}) })
if cut then love.graphics.setScissor() end
end end
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite -- 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) bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end end
local function clipToView(x, y, w, h)
return Renderer.clipToView(R, x, y, w, h)
end
if self.worldOverride then if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain, -- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image, -- 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) love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion() local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then 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 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 end
love.graphics.setScissor() love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite -- 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 -- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today. -- identical to today.
local projected = 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 not projected then
if worldZones then if worldZones then
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh) 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 and not FaithfulRes.scaleCap() then
local ok, Game = pcall(require, "src.core.Game") local ok, Game = pcall(require, "src.core.Game")
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data)) 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) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones)
-- always been. -- always been.
local anchors = self.uiAnchors local anchors = self.uiAnchors
if not anchors or #anchors == 0 then 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 else
local rest = { { uox, uoy, uvpw, uvph } } local rest = { { clipToView(uox, uoy, uvpw, uvph) } }
local placed = {} local placed = {}
for _, a in ipairs(anchors) do for _, a in ipairs(anchors) do
local dw, dh = a.w * Ux, a.h * Uy local dw, dh = a.w * Ux, a.h * Uy
@@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones)
local dx, dy local dx, dy
if a.anchor == "bottom" then if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox 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 elseif a.anchor == "top" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox 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 elseif a.anchor == "topright" then
dx = ww - gapR - dw dx = vux + vuw - gapR - dw
dy = a.y * Uy dy = vuy + a.y * Uy
else -- unknown anchor: leave it where it is else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end end
if a.windowClamped then if a.windowClamped then
dx = math.max(0, math.min(math.max(0, ww - dw), dx)) dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx))
dy = math.max(0, math.min(math.max(0, wh - dh), dy)) dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy))
end end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
if a.extract then 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 -- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox. -- region travels with the element instead of staying in the letterbox.
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy, 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
end end
local uiRedraws = PaletteFX.uiSpriteRedraws() local uiRedraws = PaletteFX.uiSpriteRedraws()
if uiRedraws[1] then if uiRedraws[1] then
love.graphics.setColor(1, 1, 1, 1) 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 for _, r in ipairs(uiRedraws) do
if r.quad then if r.quad then
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy, 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 -- 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. -- it is the tilemap being overwritten -- there is nothing it does not cover.
if self.battleWipe then 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 end
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the -- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
@@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones)
if FaithfulRes.scaleCap() then if FaithfulRes.scaleCap() then
love.graphics.rectangle("fill", ox, oy, vpw, vph) love.graphics.rectangle("fill", ox, oy, vpw, vph)
else else
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones)
generation = 1, generation = 1,
}) == true }) == true
if not outputHandled then if not outputHandled then
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
if GBCFX.active() then if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp) GBCFX.present(composed, Sp)
@@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0) love.graphics.draw(composed, 0, 0)
end end
if cut then love.graphics.setScissor() end
end end
end end
self.worldActive = false self.worldActive = false
@@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones)
gameWidth = vpw, gameHeight = vph, gameWidth = vpw, gameHeight = vph,
scale = Sp, scale = Sp,
dpiX = dpiX, dpiY = dpiY, dpiX = dpiX, dpiY = dpiY,
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
} }
end end
+197
View File
@@ -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
+690
View File
@@ -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
+196
View File
@@ -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
+129
View File
@@ -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
+41
View File
@@ -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
+1030 -55
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -28,7 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws. -- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette") 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 Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims") local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end end
function BattleTransition:draw() function BattleTransition:draw()
local w, h = GameViewport.dimensions() local w, h = Playfield.dimensions()
self:drawWidescreen(w, h) self:drawWidescreen(w, h)
end end
+17 -4
View File
@@ -43,16 +43,29 @@ end
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua -- 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 -- 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. -- 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) function Chrome.fitScale(winW, winH)
return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8), local _, _, w, h = playfieldRect(winW, winH)
(winH or 0) / (Chrome.SCREEN_H * 8)))) return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8),
h / (Chrome.SCREEN_H * 8))))
end end
-- The centred origin that goes with it, so a caller does not re-derive it. -- The centred origin that goes with it, so a caller does not re-derive it.
function Chrome.fitOrigin(winW, winH, scale) function Chrome.fitOrigin(winW, winH, scale)
scale = scale or Chrome.fitScale(winW, winH) scale = scale or Chrome.fitScale(winW, winH)
return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2), local x, y, w, h = playfieldRect(winW, winH)
math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2) return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
end end
-- A bordered box, tile coords. Leaves the draw color black for text. -- A bordered box, tile coords. Leaves the draw color black for text.
+60 -2
View File
@@ -856,8 +856,8 @@ end
-- -------------------------------------------------------------------- pager -- -------------------------------------------------------------------- pager
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is -- 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 -- never silently truncated. A long list still PAGES rather than scrolling:
-- a long list: no scrollbars, no momentum, bounded row count per frame. -- no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed. -- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {} local pagerLabels = {}
@@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage)
return math.floor(moved) return math.floor(moved)
end 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 -- ------------------------------------------------------------------ spinner
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N -- 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 -- short lines at descending alpha, which needs no shader, no canvas and no
+1 -2
View File
@@ -9,7 +9,6 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter") local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader") 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 -- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one -- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run. -- 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 pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = { local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
+5 -5
View File
@@ -34,7 +34,7 @@ local Font = require("src.render.Font")
-- a mod has taken a facade (src/mods/Gen2Compat.lua). -- a mod has taken a facade (src/mods/Gen2Compat.lua).
local Gen1Facade = require("src.mods.Gen2Compat") local Gen1Facade = require("src.mods.Gen2Compat")
local GbcPalette = require("src.render.GbcPalette") 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 Gen2Save = require("src.core.gen2.Save")
local HallOfFame = require("src.core.gen2.HallOfFame") local HallOfFame = require("src.core.gen2.HallOfFame")
local HiddenItems = require("src.world.gen2.HiddenItems") local HiddenItems = require("src.world.gen2.HiddenItems")
@@ -7499,7 +7499,7 @@ function World:interactBody()
end end
function World:fitScale() 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))) return math.max(1, math.floor(math.min(w / 160, h / 144)))
end end
@@ -8336,7 +8336,7 @@ function World:rebuildNeighbors()
self.neighbors = {} self.neighbors = {}
if not self.map then return end if not self.map then return end
local s = self:zoomScale() local s = self:zoomScale()
local ww, wh = GameViewport.dimensions() local ww, wh = Playfield.dimensions()
local vw = math.ceil(ww / s) local vw = math.ceil(ww / s)
local vh = math.ceil(wh / s) local vh = math.ceil(wh / s)
if vw % 2 ~= 0 then vw = vw + 1 end if vw % 2 ~= 0 then vw = vw + 1 end
@@ -9748,7 +9748,7 @@ function World:drawGround(s)
if canvas then if canvas then
bw, bh = canvas:getDimensions() bw, bh = canvas:getDimensions()
else else
bw, bh = GameViewport.dimensions() bw, bh = Playfield.dimensions()
end end
if BorderFill.fillBlock(self.map.def) == false then if BorderFill.fillBlock(self.map.def) == false then
-- BLACK: World:draw clears to a brown letterbox, so the void itself -- BLACK: World:draw clears to a brown letterbox, so the void itself
@@ -10017,7 +10017,7 @@ end
function World:draw() function World:draw()
local G = love.graphics local G = love.graphics
local w, h = GameViewport.dimensions() local w, h = Playfield.dimensions()
self:refreshColorMode() self:refreshColorMode()
G.clear(0.07, 0.05, 0.02, 1) G.clear(0.07, 0.05, 0.02, 1)
+128
View File
@@ -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
+115
View File
@@ -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
+227
View File
@@ -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
+83
View File
@@ -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")
+85
View File
@@ -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")
+125
View File
@@ -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")
@@ -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")
+324
View File
@@ -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")
+268
View File
@@ -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")
+307
View File
@@ -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")
+626
View File
@@ -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")
+339
View File
@@ -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")
+214
View File
@@ -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")
+175
View File
@@ -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, "<html>nope</html>")
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")
+459
View File
@@ -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")
+148
View File
@@ -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")
+171
View File
@@ -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")
+120
View File
@@ -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")
+199
View File
@@ -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")