Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb97318e87 | |||
| fc83ecd52f | |||
| 5ba49bdf3f | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| fb4eaeda10 | |||
| 69100301a1 | |||
| 114352b75f | |||
| 0e40a7a1f4 |
@@ -1 +0,0 @@
|
||||
* @bryanthaboi
|
||||
@@ -26,64 +26,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
macos-changes:
|
||||
name: detect macOS changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(conf\.lua$|scripts/(build\.sh|build_love_macos\.sh|run\.sh|setup\.sh)$|Play-Mac\.command$|mobile/macos/|src/core/SaveData\.lua$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
macos-build:
|
||||
name: macOS LÖVE 12 build
|
||||
needs: macos-changes
|
||||
if: needs.macos-changes.outputs.changed == 'true'
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build LÖVE 12 macOS runtime
|
||||
run: scripts/build_love_macos.sh --fetch
|
||||
- name: Build and verify macOS app
|
||||
env:
|
||||
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
|
||||
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build.sh mac --no-notarize --identity - --version 0.0.0
|
||||
unzip -tqq 'dist/mac/gen1recomp++-macos.zip'
|
||||
app="$MAC_STAGE_DIR/gen1recomp++.app"
|
||||
[ -d "$app" ]
|
||||
[ -x "$app/Contents/MacOS/gen1recomp++" ]
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = 'com.theboisclub.gen1recompplusplus' ]
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = 0.0.0 ]
|
||||
codesign --verify --deep --strict "$app"
|
||||
otool -L "$app/Contents/Frameworks/love.framework/love" | grep -q '/Metal.framework/'
|
||||
- name: Upload macOS build
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp++-macos
|
||||
path: dist/mac/gen1recomp++-macos.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
ios-changes:
|
||||
name: detect iOS changes
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -344,16 +344,9 @@ jobs:
|
||||
echo "Identities available to codesign:"
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build LÖVE 12 macOS runtime
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_love_macos.sh --fetch
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
|
||||
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
@@ -435,8 +428,8 @@ jobs:
|
||||
set -a; . "$ci_dir/notary.env"; set +a
|
||||
echo "::add-mask::$APPLE_APP_PASSWORD"
|
||||
|
||||
app="$RUNNER_TEMP/gen1recomp-mac-stage/gen1recomp++.app"
|
||||
zip="dist/mac/gen1recomp++-macos.zip"
|
||||
app=".bazinga/work/gen1recomp.app"
|
||||
zip="dist/mac/gen1recomp-macos.zip"
|
||||
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
||||
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
||||
@@ -481,7 +474,7 @@ jobs:
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/gen1recomp++-macos.zip" "$outdir/gen1recomp++-${v}-macos.zip"
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
|
||||
@@ -27,15 +27,9 @@ ask() { # ask "question" -> yes by default
|
||||
printf '\n \033[1mPokémon Red - LÖVE2D port\033[0m\n\n'
|
||||
|
||||
have_love() {
|
||||
local app version
|
||||
for app in ".bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||
[ -x "$app/Contents/MacOS/love" ] || continue
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||
printf '%s' "$version" | grep -Eq '^12(\.|$)' || continue
|
||||
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||
| grep -q '/Metal.framework/' && return 0
|
||||
done
|
||||
command -v love >/dev/null 2>&1 && return 0
|
||||
[ -x "/Applications/love.app/Contents/MacOS/love" ] && return 0
|
||||
[ -x "$HOME/Applications/love.app/Contents/MacOS/love" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -63,11 +57,22 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! have_love && ! command -v xcodebuild >/dev/null 2>&1; then
|
||||
warn "LÖVE 12 is not installed and Xcode is required to build it for macOS."
|
||||
warn "Install Xcode from the App Store, launch it once, then run this again."
|
||||
pause_exit 1
|
||||
# ----------------------------------------------------------------- Homebrew
|
||||
if ! have_love && ! command -v brew >/dev/null 2>&1 \
|
||||
&& [ ! -x /opt/homebrew/bin/brew ] && [ ! -x /usr/local/bin/brew ]; then
|
||||
warn "LÖVE (the game engine) is not installed; the easiest installer is Homebrew"
|
||||
if ask "Install Homebrew now? (asks for your macOS password)"; then
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \
|
||||
|| { err "Homebrew install failed"; pause_exit 1; }
|
||||
else
|
||||
warn "OK, download LÖVE 11.x yourself from https://love2d.org,"
|
||||
warn "drop love.app into /Applications, then run this again."
|
||||
pause_exit 1
|
||||
fi
|
||||
fi
|
||||
# make brew visible in THIS shell (fresh installs aren't on PATH yet)
|
||||
[ -x /opt/homebrew/bin/brew ] && eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
[ -x /usr/local/bin/brew ] && eval "$(/usr/local/bin/brew shellenv)"
|
||||
|
||||
# ------------------------------------------------------------------- build
|
||||
echo
|
||||
|
||||
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
@@ -194,9 +194,8 @@ By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `gen1recomp++.app` on macOS or `gen1recomp.exe` on Windows, or next
|
||||
to `main.lua`/`conf.lua` when running from source), then launch the game.
|
||||
Portable mode is desktop-only
|
||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
running from source), then launch the game. Portable mode is desktop-only
|
||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||
runs from a read-only package.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -61,7 +61,7 @@ function love.conf(t)
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = (love._os == "iOS" or love._os == "OS X") and "12.0" or "11.5"
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
|
||||
@@ -5,27 +5,8 @@
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- data/events/hidden_events.asm:542
|
||||
local BIKE_DISPLAYS = {
|
||||
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
|
||||
}
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
-- engine/events/hidden_events/new_bike.asm:1
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
for _, c in ipairs(BIKE_DISPLAYS) do
|
||||
if c[1] == fx and c[2] == fy then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
|
||||
talk = {
|
||||
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
||||
-- always shows the same flavor line, no branching.
|
||||
|
||||
@@ -168,7 +168,6 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -516,17 +516,7 @@ M.ROUTE_24 = {
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
-- scripts/Route24.asm:125
|
||||
ow:engageTrainer(npc, function()
|
||||
if ow:trainerDefeated(npc) then
|
||||
-- scripts/Route24.asm:62
|
||||
push(game,
|
||||
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end, text(game)._Route24CooltrainerM1DefeatedText, true)
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
|
||||
@@ -7,9 +7,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done, opts)
|
||||
local function push(game, s, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
@@ -236,6 +236,7 @@ M.CINNABAR_GYM = {
|
||||
if yes == machine.yes then
|
||||
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
||||
-- slides open (SFX_GO_INSIDE) if it was still locked
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
push(game, t._CinnabarGymQuizCorrectText
|
||||
or "You're absolutely\ncorrect!\fGo on through!", function()
|
||||
if not game.save.flags[gymGateFlag(index)] then
|
||||
@@ -243,9 +244,7 @@ M.CINNABAR_GYM = {
|
||||
Sound.play(game.data, "Go_Inside")
|
||||
end
|
||||
applyGymGates(game, ow)
|
||||
end, { preSound = function()
|
||||
return Sound.play(game.data, "Get_Item1")
|
||||
end })
|
||||
end)
|
||||
return
|
||||
end
|
||||
Sound.play(game.data, "Denied")
|
||||
|
||||
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function push(game, text, done, opts)
|
||||
local function push(game, text, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text, done, opts))
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
end
|
||||
|
||||
-- the two-variant posters: the surf-capable line once a surfing
|
||||
@@ -69,11 +69,11 @@ return {
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
-- scripts/SummerBeachHouse.asm:68
|
||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||
done, { auto = { wait = true, delay = 0, sound = function()
|
||||
return require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
end } })
|
||||
function()
|
||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
done()
|
||||
end)
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||
|
||||
@@ -512,9 +512,8 @@ gains a field instead of the name gaining a prefix.
|
||||
id under Gen 1's `name` key, which is the one payload difference the
|
||||
numeric flag space forces.
|
||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
|
||||
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
|
||||
`ui.list_menu` covers Gold's script
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||
menus draw with does not raise it yet, so those two are composed through
|
||||
their own hooks only.
|
||||
@@ -541,8 +540,9 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
||||
difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
|
||||
@@ -11,8 +11,9 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with 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**
|
||||
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
|
||||
@@ -4,23 +4,17 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/skin_studio_ux.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
`tests/engine/skin_format_import_test.lua`,
|
||||
`tests/engine/launcher_skins_tab.lua`,
|
||||
`tests/engine/launcher_skins_ux.lua`.
|
||||
`tests/engine/launcher_skins_tab.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
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.
|
||||
Two load. `skin.lua` wins when a folder has both.
|
||||
|
||||
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||
as-is. Supported keys:
|
||||
@@ -47,15 +41,6 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
||||
control that holds both. A `nul` desc is decoration: it draws and never
|
||||
captures a touch.
|
||||
|
||||
The area desc types are expanded rather than ignored: `dpad_area`,
|
||||
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
|
||||
the same area, one per 45 degree sector measured from its centre, the way
|
||||
RetroArch resolves them: there is no neutral middle, and the four corner
|
||||
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
|
||||
the per-side reach are honoured, and the desc's own art is kept as decoration
|
||||
over the top. Exporting a cfg folds the eight back into the one area desc they
|
||||
came from. `retrok_<key>` is a keyboard bind.
|
||||
|
||||
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||
@@ -86,32 +71,6 @@ 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. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||
stretch to the window the way Delta does. 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 usually a JPEG wrapped so iOS can scale it (Delta's
|
||||
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
|
||||
are an older, incompatible schema and are refused by name.
|
||||
|
||||
## Bindable actions
|
||||
|
||||
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||
@@ -160,22 +119,10 @@ them. Anything that binds a button still follows the usual mobile /
|
||||
|
||||
## Installing
|
||||
|
||||
Four roads, all of them landing in `skins/` in the save directory:
|
||||
|
||||
* **Import** on the Skins tab opens the host file picker for a `.zip` or a
|
||||
`.deltaskin`.
|
||||
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
|
||||
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
|
||||
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
|
||||
into an archive on the way in. This is the road that works on a phone, where
|
||||
there is no file picker to speak of.
|
||||
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
|
||||
open.
|
||||
* Copy a folder or archive into `skins/` by hand.
|
||||
|
||||
An archive is mounted in place, so there is nothing to unpack. It needs one
|
||||
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
|
||||
`info.json`, plus the images it names.
|
||||
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
||||
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
||||
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
||||
(`overlay.cfg` is preferred when there are several) and the images it names.
|
||||
|
||||
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||
|
||||
@@ -210,40 +157,23 @@ The Super Game Boy preset locks the viewport to the real screen window,
|
||||
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||
|
||||
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||
resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
|
||||
a control is dragged it snaps to the centres and edges of the other controls
|
||||
and of the page itself when it comes within a few pixels, and the guide it
|
||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
||||
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||
|
||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||
how a pipe bind like `left|down` is built without typing it.
|
||||
|
||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||
actions. `L` toggles the bind captions drawn on the canvas.
|
||||
|
||||
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||
(the default), the page list picks a matching mock device and the canvas preset
|
||||
(the default), Next page picks a matching mock device and the canvas preset
|
||||
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||
landscape device. **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.
|
||||
landscape device.
|
||||
|
||||
A RetroArch overlay whose pages are already named portrait / landscape
|
||||
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||
when you open it. You do not have to click Lock first.
|
||||
|
||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
|
||||
thumbnail grid of the images already in the skin folder, with `(none)` first;
|
||||
the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
||||
images already in the skin folder; the **Import** button beside each one opens
|
||||
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
@@ -255,24 +185,11 @@ buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
the Skins tab's gear offers the same three for any installed skin:
|
||||
|
||||
| Export | Contents |
|
||||
| --- | --- |
|
||||
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
|
||||
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
|
||||
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
|
||||
|
||||
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
|
||||
in the save directory, which is outside the folder the skin list scans, so an
|
||||
export can never shadow the skin it came from. The notice names the full path
|
||||
so a phone can find the file in its own file manager. On desktop **Show the
|
||||
exported file** opens that folder.
|
||||
skin names, so the folder stands alone. **Export** packs it as one zip
|
||||
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
||||
images, and the original `.cfg` when it came from one. An exported skin drops
|
||||
straight back into `skins/` and still opens in RetroArch.
|
||||
|
||||
## Not implemented
|
||||
|
||||
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||
a PDF renderer this engine does not carry, so they are refused with a message
|
||||
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
|
||||
skin case, extract on import.
|
||||
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
||||
|
||||
@@ -291,78 +291,6 @@ function closeSkinStudio()
|
||||
end
|
||||
end
|
||||
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
end
|
||||
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
|
||||
if love.window and love.window.setTitle then
|
||||
local Version = require("src.core.Version")
|
||||
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
|
||||
end
|
||||
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
@@ -454,7 +382,7 @@ function love.load(args)
|
||||
|
||||
-- Apply the persisted Android orientation lock (#592) before the launcher
|
||||
-- shows: SDL created the window with no orientation hint, so without this
|
||||
-- the launcher would rotate freely until options are applied at boot.
|
||||
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -514,8 +442,8 @@ function love.load(args)
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. The active game's loader replaces
|
||||
-- this with the real merged catalog once a version boots.
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
@@ -556,7 +484,17 @@ function love.load(args)
|
||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = makeLauncher()
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -889,27 +827,6 @@ function love.handlers.audioreset()
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.intent_game(version)
|
||||
if type(version) ~= "string" or version == "" then return end
|
||||
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
if not RomImporter.isReady(version) then return end
|
||||
|
||||
local currentVersion = GameVersion.get()
|
||||
if Game and currentVersion == version then
|
||||
return
|
||||
end
|
||||
|
||||
if Game then
|
||||
returnToLauncher()
|
||||
end
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then
|
||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||
@@ -1115,16 +1032,11 @@ function love.quit()
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and (isAndroid or not launchedIntoGame)
|
||||
and not launchedIntoGame
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
if isAndroid then
|
||||
returnToLauncher()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
|
||||
@@ -29,8 +29,7 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:icon="@drawable/love"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
@@ -40,7 +39,7 @@
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:label="${NAME}"
|
||||
android:launchMode="singleTask"
|
||||
android:launchMode="singleInstance"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:resizeableActivity="false"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -3,9 +3,4 @@
|
||||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#FF4081</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="shortcut_red">#E53935</color>
|
||||
<color name="shortcut_blue">#1E88E5</color>
|
||||
<color name="shortcut_yellow">#FDD835</color>
|
||||
<color name="shortcut_gold">#D4AF37</color>
|
||||
</resources>
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
#include "common/Module.h"
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/openal/Audio.h"
|
||||
#include "event/Event.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
@@ -283,70 +282,6 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
|
||||
for (size_t i = 0; i < versions.size(); ++i)
|
||||
{
|
||||
jstring jstr = env->NewStringUTF(versions[i].c_str());
|
||||
env->SetObjectArrayElement(array, (jsize) i, jstr);
|
||||
env->DeleteLocalRef(jstr);
|
||||
}
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
|
||||
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(stringClass);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string getLaunchGame()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return "";
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (jgame == nullptr)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
const char *str = env->GetStringUTFChars(jgame, nullptr);
|
||||
std::string result = (str != nullptr) ? str : "";
|
||||
if (str != nullptr)
|
||||
env->ReleaseStringUTFChars(jgame, str);
|
||||
|
||||
env->DeleteLocalRef(jgame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||
{
|
||||
if (url == nullptr || destPath == nullptr)
|
||||
@@ -443,104 +378,6 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent, std::string &out)
|
||||
{
|
||||
out.clear();
|
||||
if (url == nullptr)
|
||||
return false;
|
||||
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// Same resolution rule as httpDownload: the activity's own class via
|
||||
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
|
||||
// runs on a love.thread worker, whose class loader cannot see them.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" instead of aborting
|
||||
// on a missing method (#597).
|
||||
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
|
||||
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
|
||||
if (method_id == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobjectArray jheaders = nullptr;
|
||||
if (headerPairCount > 0)
|
||||
{
|
||||
// java/lang/String, unlike an app class, resolves from any thread.
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
if (stringClass == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
|
||||
env->DeleteLocalRef(stringClass);
|
||||
if (jheaders == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < headerPairCount; i++)
|
||||
{
|
||||
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
|
||||
env->SetObjectArrayElement(jheaders, (jsize) i, field);
|
||||
if (field != nullptr)
|
||||
env->DeleteLocalRef(field);
|
||||
}
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
|
||||
// raw bytes across the bridge, as httpPost does: a request body is JSON
|
||||
// carrying a base64 save, and a jstring would run it through modified UTF-8
|
||||
jbyteArray jbody = nullptr;
|
||||
if (body != nullptr && bodyLen >= 0)
|
||||
{
|
||||
jbody = env->NewByteArray((jsize) bodyLen);
|
||||
if (jbody != nullptr && bodyLen > 0)
|
||||
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
|
||||
}
|
||||
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
|
||||
jheaders, jbody, jua);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
env->DeleteLocalRef(jmethod);
|
||||
if (jheaders != nullptr)
|
||||
env->DeleteLocalRef(jheaders);
|
||||
if (jbody != nullptr)
|
||||
env->DeleteLocalRef(jbody);
|
||||
env->DeleteLocalRef(jua);
|
||||
env->DeleteLocalRef(activity);
|
||||
|
||||
if (result == nullptr)
|
||||
return false;
|
||||
|
||||
jbyteArray bytes = (jbyteArray) result;
|
||||
jsize length = env->GetArrayLength(bytes);
|
||||
if (length > 0)
|
||||
{
|
||||
out.resize((size_t) length);
|
||||
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
|
||||
}
|
||||
env->DeleteLocalRef(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
@@ -1553,32 +1390,4 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
|
||||
love::audio::openal::pushAudioResetEvent();
|
||||
}
|
||||
|
||||
static void pushGameIntentEvent(const char *game)
|
||||
{
|
||||
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
|
||||
if (eventmodule == nullptr || game == nullptr)
|
||||
return;
|
||||
|
||||
std::vector<love::Variant> args;
|
||||
args.push_back(love::Variant(std::string(game)));
|
||||
|
||||
love::event::Message *msg = new love::event::Message("intent_game", args);
|
||||
eventmodule->push(msg);
|
||||
msg->release();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
|
||||
{
|
||||
(void) cls;
|
||||
if (game == nullptr)
|
||||
return;
|
||||
const char *str = env->GetStringUTFChars(game, nullptr);
|
||||
if (str != nullptr)
|
||||
{
|
||||
pushGameIntentEvent(str);
|
||||
env->ReleaseStringUTFChars(game, str);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -90,16 +90,6 @@ bool syncHealthSteps();
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/**
|
||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||
**/
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions);
|
||||
|
||||
/**
|
||||
* Returns the game version requested via initial launch Intent (if any).
|
||||
**/
|
||||
std::string getLaunchGame();
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||
@@ -116,21 +106,6 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
**/
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||
|
||||
/**
|
||||
* Blocking HTTPS request with a method, headers and a byte body
|
||||
* (GameActivity.httpRequest). What save sync needs and neither of the two
|
||||
* above can give it: PUT, per-request auth headers, and the response body of
|
||||
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
|
||||
* headerPairCount entries; body/userAgent may be null. `out` receives the
|
||||
* Java side's envelope -- a head line of "STATUS <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).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
|
||||
@@ -245,25 +245,6 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::updateAppShortcuts(versions);
|
||||
#else
|
||||
LOVE_UNUSED(versions);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string System::getLaunchGame() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::getLaunchGame();
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent, const char *accept) const
|
||||
{
|
||||
@@ -293,27 +274,6 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent,
|
||||
std::string &out) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
|
||||
body, bodyLen, userAgent, out);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(method);
|
||||
LOVE_UNUSED(headerPairs);
|
||||
LOVE_UNUSED(headerPairCount);
|
||||
LOVE_UNUSED(body);
|
||||
LOVE_UNUSED(bodyLen);
|
||||
LOVE_UNUSED(userAgent);
|
||||
out.clear();
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -143,9 +143,6 @@ public:
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||
virtual std::string getLaunchGame() const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||
* elsewhere). Android has no curl, which is what every other platform
|
||||
@@ -162,18 +159,6 @@ public:
|
||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS request with a method, headers and a byte body (Android
|
||||
* only; false elsewhere). Save sync needs PUT, auth headers and the body
|
||||
* of a 4xx, none of which the two bridges above can express. headerPairs
|
||||
* is a flat name, value array; `out` receives the response envelope
|
||||
* ("STATUS <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
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
#include "wrap_System.h"
|
||||
#include "sdl/System.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace system
|
||||
@@ -153,57 +150,6 @@ int w_httpPost(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||
*
|
||||
* `headers` is a flat array of alternating header name and value strings, so
|
||||
* it maps straight onto the Java bridge's String[] without any parsing here.
|
||||
* The single return is the response envelope -- a head line of
|
||||
* "STATUS <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)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -283,34 +229,6 @@ int w_tlsClose(lua_State *L)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w_updateShortcuts(lua_State *L)
|
||||
{
|
||||
if (!lua_istable(L, 1))
|
||||
return luaL_error(L, "Expected table of game version strings");
|
||||
|
||||
std::vector<std::string> versions;
|
||||
int len = (int) luax_objlen(L, 1);
|
||||
for (int i = 1; i <= len; ++i)
|
||||
{
|
||||
lua_rawgeti(L, 1, i);
|
||||
if (lua_isstring(L, -1))
|
||||
versions.push_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
luax_pushboolean(L, instance()->updateShortcuts(versions));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_getLaunchGame(lua_State *L)
|
||||
{
|
||||
std::string game = instance()->getLaunchGame();
|
||||
if (game.empty())
|
||||
lua_pushnil(L);
|
||||
else
|
||||
luax_pushstring(L, game);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -325,11 +243,8 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.libsdl.app.SDLActivity;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
@@ -37,7 +36,6 @@ import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import android.Manifest;
|
||||
@@ -69,11 +67,8 @@ import android.os.Vibrator;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.content.pm.ShortcutManager;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.view.*;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
@@ -162,10 +157,6 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static native void nativeAudioDeviceChanged();
|
||||
|
||||
private static native void nativeOnGameIntent(String game);
|
||||
|
||||
private static String initialGame = "";
|
||||
|
||||
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
||||
private Object audioFocusRequest = null;
|
||||
private Object audioDeviceCallback = null;
|
||||
@@ -235,10 +226,6 @@ public class GameActivity extends SDLActivity {
|
||||
embed = getResources().getBoolean(R.bool.embed);
|
||||
needToCopyGameInArchive = embed;
|
||||
|
||||
Intent startIntent = getIntent();
|
||||
if (startIntent != null && startIntent.hasExtra("game")) {
|
||||
initialGame = startIntent.getStringExtra("game");
|
||||
}
|
||||
if (!embed) {
|
||||
Intent intent = getIntent();
|
||||
handleIntent(intent);
|
||||
@@ -272,12 +259,6 @@ public class GameActivity extends SDLActivity {
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
Log.d("GameActivity", "onNewIntent() with " + intent);
|
||||
if (intent != null && intent.hasExtra("game")) {
|
||||
String game = intent.getStringExtra("game");
|
||||
if (game != null && !game.isEmpty()) {
|
||||
nativeOnGameIntent(game);
|
||||
}
|
||||
}
|
||||
if (!embed) {
|
||||
handleIntent(intent);
|
||||
resetNative();
|
||||
@@ -690,95 +671,6 @@ public class GameActivity extends SDLActivity {
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String getLaunchGame() {
|
||||
return initialGame != null ? initialGame : "";
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean updateAppShortcuts(String[] readyVersions) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT < 25) return false;
|
||||
try {
|
||||
Context context = self.getApplicationContext();
|
||||
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
|
||||
if (shortcutManager == null) return false;
|
||||
|
||||
if (readyVersions == null || readyVersions.length == 0) {
|
||||
shortcutManager.removeAllDynamicShortcuts();
|
||||
return true;
|
||||
}
|
||||
|
||||
List<ShortcutInfo> shortcuts = new ArrayList<>();
|
||||
int maxShortcuts = Math.min(readyVersions.length, 4);
|
||||
|
||||
for (int i = 0; i < maxShortcuts; i++) {
|
||||
String ver = readyVersions[i];
|
||||
if (ver == null || ver.isEmpty()) continue;
|
||||
String lower = ver.toLowerCase();
|
||||
String shortLabel;
|
||||
String longLabel;
|
||||
int iconResId;
|
||||
|
||||
switch (lower) {
|
||||
case "red":
|
||||
shortLabel = "Play Red";
|
||||
longLabel = "Play Red";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "blue":
|
||||
shortLabel = "Play Blue";
|
||||
longLabel = "Play Blue";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "yellow":
|
||||
shortLabel = "Play Yellow";
|
||||
longLabel = "Play Yellow";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "gold":
|
||||
shortLabel = "Play Gold";
|
||||
longLabel = "Play Gold";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
|
||||
break;
|
||||
default:
|
||||
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
|
||||
shortLabel = "Play " + capitalized;
|
||||
longLabel = "Play " + capitalized;
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
|
||||
break;
|
||||
}
|
||||
|
||||
if (iconResId == 0) {
|
||||
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, GameActivity.class);
|
||||
intent.setAction(Intent.ACTION_VIEW);
|
||||
intent.putExtra("game", lower);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
|
||||
.setShortLabel(shortLabel)
|
||||
.setLongLabel(longLabel)
|
||||
.setIntent(intent);
|
||||
|
||||
if (iconResId != 0) {
|
||||
builder.setIcon(Icon.createWithResource(context, iconResId));
|
||||
}
|
||||
|
||||
shortcuts.add(builder.build());
|
||||
}
|
||||
|
||||
shortcutManager.setDynamicShortcuts(shortcuts);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
||||
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
||||
@@ -955,149 +847,6 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
|
||||
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
|
||||
|
||||
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
|
||||
private static byte[] httpEnvelope(String head, byte[] payload) {
|
||||
byte[] prefix;
|
||||
try {
|
||||
prefix = (head + "\n").getBytes("UTF-8");
|
||||
} catch (Exception e) {
|
||||
prefix = (head + "\n").getBytes();
|
||||
}
|
||||
if (payload == null || payload.length == 0) return prefix;
|
||||
byte[] out = new byte[prefix.length + payload.length];
|
||||
System.arraycopy(prefix, 0, out, 0, prefix.length);
|
||||
System.arraycopy(payload, 0, out, prefix.length, payload.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
|
||||
private static String httpErrorText(Exception e) {
|
||||
String text = e.getMessage();
|
||||
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
|
||||
text = text.replace('\r', ' ').replace('\n', ' ');
|
||||
if (text.length() > 160) text = text.substring(0, 160);
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS request with a chosen method, headers and byte body,
|
||||
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
|
||||
* for save sync. Sync needs PUT, per-request auth headers and the response
|
||||
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
|
||||
* that won), none of which httpDownload or httpPost above can express.
|
||||
*
|
||||
* Same rules as those two: https only, redirects followed by hand
|
||||
* (re-sending method and body on each hop), 15s connect / 60s read, and
|
||||
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
|
||||
* as a flat name, value array; a field carrying CR or LF is refused rather
|
||||
* than sent, so a header value can never inject a second header.
|
||||
*
|
||||
* The reply is an envelope: a head line of "STATUS <code>" or
|
||||
* "ERROR <text>", a newline, then the raw response bytes.
|
||||
*/
|
||||
@Keep
|
||||
public static byte[] httpRequest(String url, String method, String[] headerPairs,
|
||||
byte[] body, String userAgent) {
|
||||
if (url == null) return httpEnvelope("ERROR missing url", null);
|
||||
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
|
||||
if (!"GET".equals(verb) && !"POST".equals(verb)
|
||||
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
|
||||
return httpEnvelope("ERROR unsupported request method", null);
|
||||
}
|
||||
if (headerPairs != null) {
|
||||
if ((headerPairs.length % 2) != 0) {
|
||||
return httpEnvelope("ERROR bad request header", null);
|
||||
}
|
||||
for (int i = 0; i < headerPairs.length; i++) {
|
||||
String field = headerPairs[i];
|
||||
if (field == null) return httpEnvelope("ERROR bad request header", null);
|
||||
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
|
||||
return httpEnvelope("ERROR bad request header", null);
|
||||
}
|
||||
if ((i % 2) == 0 && field.length() == 0) {
|
||||
return httpEnvelope("ERROR bad request header", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
String current = url;
|
||||
for (int hop = 0; hop < 5; hop++) {
|
||||
URL parsed = new URL(current);
|
||||
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
|
||||
return httpEnvelope("ERROR https only", null);
|
||||
}
|
||||
conn = (HttpURLConnection) parsed.openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(60000);
|
||||
conn.setRequestMethod(verb);
|
||||
conn.setRequestProperty("User-Agent",
|
||||
userAgent == null ? "gen1recomp" : userAgent);
|
||||
if (headerPairs != null) {
|
||||
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
|
||||
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
|
||||
}
|
||||
}
|
||||
if (body != null && !"GET".equals(verb)) {
|
||||
conn.setDoOutput(true);
|
||||
conn.setFixedLengthStreamingMode(body.length);
|
||||
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||
try {
|
||||
out.write(body);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||
String next = conn.getHeaderField("Location");
|
||||
conn.disconnect();
|
||||
conn = null;
|
||||
if (next == null) {
|
||||
return httpEnvelope("ERROR redirect without a location", null);
|
||||
}
|
||||
current = new URL(parsed, next).toString();
|
||||
continue;
|
||||
}
|
||||
// A rejection's body is the diagnosis the caller wants, so 4xx
|
||||
// and 5xx are read through getErrorStream rather than dropped.
|
||||
InputStream in;
|
||||
try {
|
||||
in = conn.getInputStream();
|
||||
} catch (IOException e) {
|
||||
in = conn.getErrorStream();
|
||||
}
|
||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
if (in != null) {
|
||||
InputStream reader = new BufferedInputStream(in);
|
||||
try {
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = reader.read(buf)) > 0) {
|
||||
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
|
||||
return httpEnvelope("ERROR the reply was too large", null);
|
||||
}
|
||||
sink.write(buf, 0, n);
|
||||
}
|
||||
} finally {
|
||||
try { reader.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
return httpEnvelope("STATUS " + code, sink.toByteArray());
|
||||
}
|
||||
return httpEnvelope("ERROR too many redirects", null);
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
|
||||
return httpEnvelope("ERROR " + httpErrorText(e), null);
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
|
||||
@@ -12,6 +12,69 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.6",
|
||||
"date": "2026-08-18",
|
||||
"size": 13589036,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.5",
|
||||
"date": "2026-08-18",
|
||||
"size": 13586158,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.4",
|
||||
"date": "2026-08-18",
|
||||
"size": 13582747,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.3",
|
||||
"date": "2026-08-18",
|
||||
"size": 13579254,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.2",
|
||||
"date": "2026-08-18",
|
||||
"size": 13575387,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575320,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575299,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.99",
|
||||
"date": "2026-08-17",
|
||||
"size": 11391467,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.98",
|
||||
"date": "2026-08-16",
|
||||
"size": 11380117,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.97",
|
||||
"date": "2026-08-16",
|
||||
|
||||
@@ -6,27 +6,6 @@
|
||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <sys/utsname.h>
|
||||
|
||||
@interface GRDeviceBridge : NSObject
|
||||
+ (NSString *)deviceModel;
|
||||
@end
|
||||
|
||||
@implementation GRDeviceBridge
|
||||
+ (NSString *)deviceModel
|
||||
{
|
||||
#if TARGET_OS_SIMULATOR
|
||||
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
|
||||
if (simulatorModel.length > 0) return simulatorModel;
|
||||
#endif
|
||||
struct utsname systemInfo;
|
||||
if (uname(&systemInfo) == 0) {
|
||||
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
|
||||
if (model.length > 0) return model;
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
@end
|
||||
|
||||
__attribute__((constructor))
|
||||
static void GRBootstrapInstall(void)
|
||||
|
||||
@@ -67,124 +67,6 @@ public final class GRPickerBridge: NSObject {
|
||||
return succeeded
|
||||
}
|
||||
|
||||
// MARK: - General HTTP request (love.system.httpRequest)
|
||||
|
||||
private static let httpMaxResponse = 4 * 1024 * 1024
|
||||
|
||||
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
|
||||
// signs a method and a body, so every hop re-sends the original request
|
||||
// against the new URL instead, and only over https.
|
||||
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
guard let original = task.originalRequest,
|
||||
let target = request.url,
|
||||
target.scheme?.lowercased() == "https" else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
var next = original
|
||||
next.url = target
|
||||
completionHandler(next)
|
||||
}
|
||||
}
|
||||
|
||||
private static let httpSession = URLSession(configuration: .ephemeral,
|
||||
delegate: GRRedirectKeeper(),
|
||||
delegateQueue: nil)
|
||||
|
||||
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
|
||||
var out = Data((head + "\n").utf8)
|
||||
if let payload { out.append(payload) }
|
||||
return out as NSData
|
||||
}
|
||||
|
||||
private static func httpErrorText(_ error: Error) -> String {
|
||||
var text = error.localizedDescription
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
if text.isEmpty { text = "the request failed" }
|
||||
if text.count > 160 { text = String(text.prefix(160)) }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Blocking HTTPS request with a chosen method, headers and byte body, the
|
||||
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
|
||||
/// Headers arrive as "name: value" lines joined by newlines. The reply is
|
||||
/// an envelope: a head line of "STATUS <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)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
|
||||
@@ -9,8 +9,7 @@ What it does:
|
||||
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||
love.system.createFile, love.system.syncHealthSteps,
|
||||
love.system.httpDownload and love.system.httpRequest on iOS (each
|
||||
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
||||
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||
liblove never links against Swift directly).
|
||||
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||
@@ -51,7 +50,6 @@ WRAP_INCLUDES = """
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
@@ -156,13 +154,11 @@ int w_syncHealthSteps(lua_State *L)
|
||||
""" % MARKER
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "getDeviceModel", w_getDeviceModel },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
#endif
|
||||
"""
|
||||
|
||||
@@ -203,42 +199,13 @@ int w_syncHealthSteps(lua_State *L)
|
||||
"""
|
||||
|
||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "getDeviceModel", w_getDeviceModel },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
#endif
|
||||
"""
|
||||
|
||||
BRIDGE_EXTRA_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
int w_getDeviceModel(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRDeviceBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
|
||||
if (value == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(value,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpDownload(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
@@ -259,80 +226,6 @@ int w_httpDownload(lua_State *L)
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||
//
|
||||
// The transport save sync needs: a chosen method, per-request auth headers,
|
||||
// and the response body of a 4xx as well as a 2xx. `headers` is a flat array
|
||||
// of alternating name and value strings, joined into "name: value" lines here
|
||||
// because the Swift bridge takes C strings and no Foundation type may be
|
||||
// NAMED in this translation unit (see w_pickFileKinds above).
|
||||
//
|
||||
// The single return is the response envelope -- a head line of
|
||||
// "STATUS <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
|
||||
"""
|
||||
|
||||
@@ -415,7 +308,7 @@ def patch_wrap_system():
|
||||
text = text.replace(reg_anchor, reg_anchor + registration, 1)
|
||||
WRAP_SYSTEM.write_text(text)
|
||||
print("patch_love_src: wrap_System.cpp patched "
|
||||
"(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
|
||||
"(pickFile/createFile/syncHealthSteps/httpDownload)")
|
||||
|
||||
|
||||
def patch_public_documents():
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
a5522634f1e581a1ebab73bf3ab4bd7a853b7a3e
|
||||
@@ -1 +0,0 @@
|
||||
97d4465e8c81099f79696ea4b4bb8b1f9083bc1a
|
||||
@@ -1 +0,0 @@
|
||||
12.0
|
||||
@@ -1,19 +0,0 @@
|
||||
# macOS build
|
||||
|
||||
The desktop app uses the pinned LÖVE 12 runtime built from the LÖVE source
|
||||
tree and the matching Apple dependency repository. The runtime enables Metal
|
||||
and is fused into the branded `gen1recomp++.app` bundle.
|
||||
|
||||
Build the runtime and desktop app from the repository root:
|
||||
|
||||
```bash
|
||||
scripts/build_love_macos.sh --fetch
|
||||
LOVE_APP="$PWD/.bazinga/love12/love.app" scripts/build.sh mac --no-notarize --identity -
|
||||
```
|
||||
|
||||
The source and dependency revisions are recorded in `LOVE_SOURCE_REF` and
|
||||
`APPLE_DEPENDENCIES_REF`. Delete `.bazinga/love12/source` or use `--clean`
|
||||
when changing those pins.
|
||||
|
||||
The packaged executable is `gen1recomp++` inside `gen1recomp++.app`, and the
|
||||
bundle declares LÖVE 12.0 compatibility.
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
@@ -1,12 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Silly Oak Intro Example
|
||||
|
||||
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||
|
||||
## Try it (play through yourself)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
love .
|
||||
```
|
||||
|
||||
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||
|
||||
## Headless check
|
||||
|
||||
```sh
|
||||
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
```
|
||||
|
||||
## Auto driver (screenshots + save asserts)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||
```
|
||||
|
||||
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
|
||||
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
|
||||
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||
|
Before Width: | Height: | Size: 245 B |
@@ -1,107 +0,0 @@
|
||||
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||
-- that land in mod.save.
|
||||
--
|
||||
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||
|
||||
return function(mod)
|
||||
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||
|
||||
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||
steps = next(steps, speech)
|
||||
|
||||
-- after oak says hello, immediately derail
|
||||
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "silly_quiz_intro",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||
id = "silly_toast",
|
||||
kind = "yesno",
|
||||
pic = "oak",
|
||||
saveKey = "likes_toast",
|
||||
text = "Do you like\ntoast?",
|
||||
})
|
||||
|
||||
-- brand new sprite mid-speech
|
||||
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||
id = "silly_toast_kid",
|
||||
kind = "say",
|
||||
pic = { type = "image", path = toastPic },
|
||||
reveal = "fade",
|
||||
saveKey = nil,
|
||||
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||
})
|
||||
|
||||
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||
id = "silly_mew",
|
||||
kind = "say",
|
||||
pic = { type = "pokemon", id = "MEW" },
|
||||
reveal = "wipe",
|
||||
cry = "MEW",
|
||||
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||
id = "silly_snack",
|
||||
kind = "choice",
|
||||
pic = "oak",
|
||||
saveKey = "snack",
|
||||
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||
})
|
||||
|
||||
-- swap to rival pic for a loaded question before naming him
|
||||
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||
id = "silly_trust",
|
||||
kind = "choice",
|
||||
pic = "rival",
|
||||
reveal = "fade",
|
||||
saveKey = "trusts_rival",
|
||||
text = "Look at this kid.\nTrustworthy?",
|
||||
choices = { "SURE", "NO" },
|
||||
values = { true, false },
|
||||
})
|
||||
|
||||
-- player pic for one last bit after both names are set
|
||||
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||
id = "silly_pineapple",
|
||||
kind = "yesno",
|
||||
pic = "player",
|
||||
saveKey = "pineapple_on_pizza",
|
||||
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||
id = "silly_closing",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||
})
|
||||
|
||||
return steps
|
||||
end)
|
||||
|
||||
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||
-- save.modData[mod.id] once the slot is written)
|
||||
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||
if not ev.saveKey then return end
|
||||
mod.save:set(ev.saveKey, ev.value)
|
||||
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||
end)
|
||||
|
||||
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||
local answers = ev.answers or {}
|
||||
for key, value in pairs(answers) do
|
||||
if mod.save:get(key) == nil then
|
||||
mod.save:set(key, value)
|
||||
end
|
||||
end
|
||||
mod.save:set("quiz_done", true)
|
||||
mod.log:info("silly oak quiz done")
|
||||
end)
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "example_silly_oak",
|
||||
"name": "Silly Oak Intro Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "UI",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Sharing metadata for the manager detail pane.
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||
},
|
||||
added = {
|
||||
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||
"Custom Toast Kid pic mid-intro",
|
||||
},
|
||||
known = { "vanilla naming and the shrink-away still run" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||
},
|
||||
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||
-- and answers landing in mod.save.
|
||||
--
|
||||
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local function hasGenerated()
|
||||
local handle = io.open("data/generated/constants.lua", "r")
|
||||
if handle then handle:close() return true end
|
||||
return false
|
||||
end
|
||||
if not hasGenerated() then
|
||||
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local mod = run.mod
|
||||
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local bucket = function()
|
||||
return run.loader.modSave.example_silly_oak or {}
|
||||
end
|
||||
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||
.. "/assets/toast_kid.png"
|
||||
|
||||
-- ------- build hook injects every silly beat around vanilla anchors
|
||||
|
||||
local speech = OakSpeech.new({
|
||||
data = Data,
|
||||
save = { player = { name = "RED", rival = "BLUE" } },
|
||||
stack = { push = function() end, pop = function() end },
|
||||
}, nil)
|
||||
local steps = speech:buildSteps()
|
||||
|
||||
local ids = {}
|
||||
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||
local function has(id)
|
||||
for _, x in ipairs(ids) do if x == id then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||
"vanilla anchors still present")
|
||||
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||
"toast quiz beats injected")
|
||||
T.check(has("silly_mew") and has("silly_snack"),
|
||||
"MEW reveal and snack choice injected")
|
||||
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||
"rival trust + pineapple beats injected")
|
||||
|
||||
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||
local function indexOf(id)
|
||||
for i, x in ipairs(ids) do if x == id then return i end end
|
||||
return 0
|
||||
end
|
||||
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||
"Toast Kid shows before the demo mon")
|
||||
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||
"MEW shows after the demo mon")
|
||||
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||
"trust question is before rival naming")
|
||||
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||
"pineapple lands between rival name and the legend beat")
|
||||
|
||||
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||
|
||||
local byId = {}
|
||||
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||
|
||||
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||
"Toast Kid uses a custom image pic")
|
||||
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||
"MEW beat uses pokemon pic + cry")
|
||||
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||
|
||||
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||
|
||||
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "image", path = toastPath }, speech)
|
||||
-- headless love stub may return nil images; the call itself must not throw
|
||||
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||
or type(oakImg) == "table",
|
||||
"oak shorthand resolves without error")
|
||||
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||
or type(rivalImg) == "table",
|
||||
"rival shorthand resolves without error")
|
||||
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||
"player shorthand resolves without error")
|
||||
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||
|
||||
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_toast, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||
step = byId.silly_snack, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||
step = byId.silly_trust, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_pineapple, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = speech, answers = speech.answers,
|
||||
})
|
||||
|
||||
local saved = bucket()
|
||||
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||
|
||||
-- ------- ModUI step helpers (public surface)
|
||||
|
||||
local tiny = {
|
||||
{ id = "a", kind = "say" },
|
||||
{ id = "b", kind = "say" },
|
||||
}
|
||||
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||
ModUI.removeStep(tiny, "mid")
|
||||
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||
|
||||
run.release()
|
||||
T.finish("example_silly_oak")
|
||||
@@ -8,7 +8,7 @@
|
||||
# [--notary-profile NAME] [--no-notarize]
|
||||
# [--release] # ios only: release config instead of debug
|
||||
#
|
||||
# Output: dist/mac/gen1recomp++-macos.zip
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
# dist/win/gen1recomp-win64.zip
|
||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||
# dist/android/debug/*.apk (full gradle output stays under
|
||||
@@ -27,18 +27,13 @@ ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
MAC_APP_NAME="gen1recomp++"
|
||||
MAC_BUNDLE_ID="com.theboisclub.gen1recompplusplus"
|
||||
LOVE_VERSION="11.5"
|
||||
LOVE_MAC_VERSION="$(tr -d '[:space:]' < "$ROOT/mobile/macos/LOVE_VERSION" 2>/dev/null || echo 12.0)"
|
||||
LOVE_MAC_APP="$HERE/love12/love.app"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
VERSION_EXPLICIT=false
|
||||
IDENTITY=""
|
||||
TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
FETCH_MAC_RUNTIME=false
|
||||
IOS_RELEASE=false
|
||||
IOS_IPA=false
|
||||
|
||||
@@ -46,22 +41,6 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
strip_bundle_metadata() {
|
||||
local bundle="$1"
|
||||
xattr -rc "$bundle"
|
||||
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
|
||||
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
|
||||
}
|
||||
|
||||
valid_love12_app() {
|
||||
local app="$1" version
|
||||
[ -x "$app/Contents/MacOS/love" ] || return 1
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
|
||||
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||
| grep -q '/Metal.framework/'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||
@@ -69,7 +48,6 @@ while [ $# -gt 0 ]; do
|
||||
--identity) IDENTITY="$2"; shift ;;
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--fetch) FETCH_MAC_RUNTIME=true ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--ipa) IOS_IPA=true ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
@@ -172,55 +150,24 @@ make_ico() { # $1 = output .ico path
|
||||
# --------------------------------------------------------------- macOS
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
local love_app="${LOVE_APP:-}"
|
||||
if [ -z "$love_app" ]; then
|
||||
for candidate in "$LOVE_MAC_APP" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||
if [ -d "$candidate" ] && valid_love12_app "$candidate"; then
|
||||
love_app="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ ! -d "$love_app" ] && [ -z "${LOVE_APP:-}" ] && $FETCH_MAC_RUNTIME; then
|
||||
"$ROOT/scripts/build_love_macos.sh" --fetch
|
||||
love_app="$LOVE_MAC_APP"
|
||||
fi
|
||||
[ -d "$love_app" ] || fail "LÖVE 12 Metal app not found; run scripts/build_love_macos.sh --fetch or set LOVE_APP=/path/to/love.app"
|
||||
local love_version
|
||||
love_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$love_app/Contents/Info.plist" 2>/dev/null || true)
|
||||
printf '%s' "$love_version" | grep -Eq '^12(\.|$)' \
|
||||
|| fail "macOS build requires LÖVE $LOVE_MAC_VERSION at $love_app (set LOVE_APP to a LÖVE 12 app)"
|
||||
[ -f "$love_app/Contents/Frameworks/love.framework/love" ] \
|
||||
|| fail "macOS build requires love.framework at $love_app"
|
||||
otool -L "$love_app/Contents/Frameworks/love.framework/love" \
|
||||
| grep -q '/Metal.framework/' \
|
||||
|| fail "macOS build requires a LÖVE runtime linked to Metal at $love_app"
|
||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
||||
[ -d "$love_app" ] || fail "LÖVE.app not found at $love_app (install it or set LOVE_APP=/path/to/love.app)"
|
||||
|
||||
local stage_dir="${MAC_STAGE_DIR:-${RUNNER_TEMP:-/tmp}/gen1recomp-mac-stage}"
|
||||
local out_app="$stage_dir/$MAC_APP_NAME.app"
|
||||
mkdir -p "$stage_dir"
|
||||
local out_app="$WORK/$APP_NAME.app"
|
||||
rm -rf "$out_app"
|
||||
ditto --norsrc "$love_app" "$out_app"
|
||||
|
||||
local love_executable="$out_app/Contents/MacOS/love"
|
||||
local app_executable="$out_app/Contents/MacOS/$MAC_APP_NAME"
|
||||
[ -f "$love_executable" ] || fail "LÖVE app is missing Contents/MacOS/love"
|
||||
mv "$love_executable" "$app_executable"
|
||||
cp -R "$love_app" "$out_app"
|
||||
|
||||
# drop any bundled placeholder .love and fuse ours in
|
||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||
|
||||
local plist="$out_app/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $MAC_APP_NAME" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $MAC_APP_NAME" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleExecutable string $MAC_APP_NAME" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $MAC_BUNDLE_ID" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $MAC_BUNDLE_ID" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
||||
@@ -242,11 +189,6 @@ build_mac() {
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
|
||||
|
||||
strip_bundle_metadata "$out_app"
|
||||
|
||||
rm -rf "$WORK/$MAC_APP_NAME.app"
|
||||
ln -s "$out_app" "$WORK/$MAC_APP_NAME.app"
|
||||
|
||||
local id="$IDENTITY"
|
||||
if [ -z "$id" ]; then
|
||||
id="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/^[^"]*"(.*)"$/\1/' || true)"
|
||||
@@ -268,9 +210,9 @@ build_mac() {
|
||||
warn "keychain profile '$NOTARY_PROFILE' not found/working, skipping notarization."
|
||||
warn "set it up with: xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id ... --team-id ... --password ..."
|
||||
else
|
||||
local notarize_zip="$stage_dir/$MAC_APP_NAME-notarize.zip"
|
||||
local notarize_zip="$WORK/$APP_NAME-notarize.zip"
|
||||
rm -f "$notarize_zip"
|
||||
(cd "$stage_dir" && ditto -c -k --keepParent "$MAC_APP_NAME.app" "$notarize_zip")
|
||||
(cd "$WORK" && ditto -c -k --keepParent "$APP_NAME.app" "$notarize_zip")
|
||||
say "submitting to Apple notary service (this can take a few minutes)"
|
||||
xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
say "stapling notarization ticket"
|
||||
@@ -279,11 +221,9 @@ build_mac() {
|
||||
fi
|
||||
fi
|
||||
|
||||
strip_bundle_metadata "$out_app"
|
||||
|
||||
local zip_out="$DIST/mac/$MAC_APP_NAME-macos.zip"
|
||||
local zip_out="$DIST/mac/$APP_NAME-macos.zip"
|
||||
rm -f "$zip_out"
|
||||
(cd "$stage_dir" && ditto -c -k --norsrc --keepParent "$MAC_APP_NAME.app" "$zip_out")
|
||||
(cd "$WORK" && ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$zip_out")
|
||||
say "macOS build: $zip_out"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MACOS_DIR="$ROOT/mobile/macos"
|
||||
CACHE="$ROOT/.bazinga/love12"
|
||||
SOURCE_DIR="${LOVE_SOURCE_DIR:-$CACHE/source}"
|
||||
RUNTIME_APP="${LOVE_APP_OUTPUT:-$CACHE/love.app}"
|
||||
BUILD_DIR="$CACHE/build"
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_VERSION")"
|
||||
LOVE_SOURCE_REF="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_SOURCE_REF")"
|
||||
APPLE_DEPENDENCIES_REF="$(tr -d '[:space:]' < "$MACOS_DIR/APPLE_DEPENDENCIES_REF")"
|
||||
LOVE_SOURCE_REPO="${LOVE_SOURCE_REPO:-https://github.com/love2d/love.git}"
|
||||
APPLE_DEPENDENCIES_REPO="${APPLE_DEPENDENCIES_REPO:-https://github.com/love2d/love-apple-dependencies.git}"
|
||||
|
||||
FETCH=false
|
||||
CLEAN=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
strip_bundle_metadata() {
|
||||
local bundle="$1"
|
||||
xattr -rc "$bundle"
|
||||
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
|
||||
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--fetch) FETCH=true ;;
|
||||
--clean) CLEAN=true ;;
|
||||
-h|--help)
|
||||
printf '%s\n' 'usage: scripts/build_love_macos.sh [--fetch] [--clean]'
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ "$(uname -s)" = "Darwin" ] || fail "macOS LÖVE builds require Darwin"
|
||||
command -v git >/dev/null 2>&1 || fail "git is required to fetch LÖVE sources"
|
||||
command -v xcodebuild >/dev/null 2>&1 || fail "xcodebuild is required to build LÖVE for macOS"
|
||||
command -v xattr >/dev/null 2>&1 || fail "xattr is required to normalize the runtime bundle"
|
||||
|
||||
source_ready() {
|
||||
[ -d "$SOURCE_DIR/platform/xcode/love.xcodeproj" ] \
|
||||
&& [ -d "$SOURCE_DIR/platform/xcode/macosx/Frameworks/Lua.framework" ] \
|
||||
&& [ -d "$SOURCE_DIR/platform/xcode/shared/Frameworks/SDL3.xcframework" ]
|
||||
}
|
||||
|
||||
source_is_pinned() {
|
||||
[ "$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)" = "$LOVE_SOURCE_REF" ] \
|
||||
&& [ -f "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref" ] \
|
||||
&& [ "$(tr -d '[:space:]' < "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref")" = "$APPLE_DEPENDENCIES_REF" ]
|
||||
}
|
||||
|
||||
fetch_repo() {
|
||||
local repo_url="$1"
|
||||
local ref="$2"
|
||||
local destination="$3"
|
||||
mkdir -p "$destination"
|
||||
git -C "$destination" init -q
|
||||
git -C "$destination" remote add origin "$repo_url"
|
||||
git -C "$destination" fetch --depth 1 origin "$ref"
|
||||
git -C "$destination" checkout --detach -q FETCH_HEAD
|
||||
}
|
||||
|
||||
fetch_sources() {
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$CACHE/fetch.XXXXXX")"
|
||||
say "fetching LÖVE source $LOVE_SOURCE_REF"
|
||||
fetch_repo "$LOVE_SOURCE_REPO" "$LOVE_SOURCE_REF" "$tmp/love"
|
||||
say "fetching Apple dependencies $APPLE_DEPENDENCIES_REF"
|
||||
fetch_repo "$APPLE_DEPENDENCIES_REPO" "$APPLE_DEPENDENCIES_REF" "$tmp/dependencies"
|
||||
mkdir -p "$tmp/love/platform/xcode/macosx/Frameworks" "$tmp/love/platform/xcode/shared"
|
||||
cp -R "$tmp/dependencies/macOS/Frameworks/." "$tmp/love/platform/xcode/macosx/Frameworks/"
|
||||
cp -R "$tmp/dependencies/shared/." "$tmp/love/platform/xcode/shared/"
|
||||
rm -rf "$SOURCE_DIR"
|
||||
mkdir -p "$(dirname "$SOURCE_DIR")"
|
||||
mv "$tmp/love" "$SOURCE_DIR"
|
||||
printf '%s\n' "$APPLE_DEPENDENCIES_REF" > "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref"
|
||||
rm -rf "$tmp"
|
||||
say "LÖVE source ready at $SOURCE_DIR"
|
||||
}
|
||||
|
||||
if $CLEAN; then
|
||||
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|
||||
|| fail "--clean cannot be used with LOVE_SOURCE_DIR"
|
||||
rm -rf "$SOURCE_DIR" "$BUILD_DIR" "$RUNTIME_APP"
|
||||
fi
|
||||
|
||||
if ! source_ready || ! source_is_pinned; then
|
||||
if ! $FETCH; then
|
||||
fail "pinned LÖVE 12 sources are missing at $SOURCE_DIR; run scripts/build_love_macos.sh --fetch"
|
||||
fi
|
||||
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|
||||
|| fail "LOVE_SOURCE_DIR is not the pinned LÖVE commit $LOVE_SOURCE_REF"
|
||||
fetch_sources
|
||||
fi
|
||||
|
||||
PROJECT="$SOURCE_DIR/platform/xcode/love.xcodeproj"
|
||||
rm -rf "$BUILD_DIR" "$RUNTIME_APP"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
say "building LÖVE 12 macOS runtime"
|
||||
xcodebuild \
|
||||
-quiet \
|
||||
-project "$PROJECT" \
|
||||
-target love-macosx \
|
||||
-configuration Release \
|
||||
-sdk macosx \
|
||||
SYMROOT="$BUILD_DIR" \
|
||||
OBJROOT="$BUILD_DIR/Intermediates" \
|
||||
ARCHS="arm64 x86_64" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
MACOSX_DEPLOYMENT_TARGET=12.0 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY=-
|
||||
|
||||
BUILT_APP="$BUILD_DIR/Release/love.app"
|
||||
[ -d "$BUILT_APP" ] || fail "xcodebuild produced no LÖVE app at $BUILT_APP"
|
||||
ditto --norsrc "$BUILT_APP" "$RUNTIME_APP"
|
||||
strip_bundle_metadata "$RUNTIME_APP"
|
||||
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNTIME_APP/Contents/Info.plist" 2>/dev/null || true)"
|
||||
version_re="$(printf '%s' "$LOVE_VERSION" | sed 's/\./\\./g')"
|
||||
printf '%s' "$version" | grep -Eq "^${version_re}(\.|$)" \
|
||||
|| fail "built runtime reports LÖVE version '$version'"
|
||||
[ -x "$RUNTIME_APP/Contents/MacOS/love" ] \
|
||||
|| fail "built runtime is missing Contents/MacOS/love"
|
||||
[ -f "$RUNTIME_APP/Contents/Frameworks/love.framework/love" ] \
|
||||
|| fail "built runtime is missing love.framework"
|
||||
otool -L "$RUNTIME_APP/Contents/Frameworks/love.framework/love" \
|
||||
| grep -q '/Metal.framework/' \
|
||||
|| fail "built LÖVE runtime is not linked to Metal"
|
||||
|
||||
archs="$(lipo -archs "$RUNTIME_APP/Contents/MacOS/love")"
|
||||
printf '%s' "$archs" | grep -qw arm64 \
|
||||
|| fail "built LÖVE app is missing arm64"
|
||||
printf '%s' "$archs" | grep -qw x86_64 \
|
||||
|| fail "built LÖVE app is missing x86_64"
|
||||
framework_archs="$(lipo -archs "$RUNTIME_APP/Contents/Frameworks/love.framework/love")"
|
||||
printf '%s' "$framework_archs" | grep -qw arm64 \
|
||||
|| fail "built LÖVE framework is missing arm64"
|
||||
printf '%s' "$framework_archs" | grep -qw x86_64 \
|
||||
|| fail "built LÖVE framework is missing x86_64"
|
||||
|
||||
say "LÖVE 12 macOS runtime: $RUNTIME_APP"
|
||||
@@ -32,30 +32,8 @@ find_love() {
|
||||
return 1
|
||||
}
|
||||
|
||||
find_love12() {
|
||||
local app version
|
||||
for app in "${LOVE_APP:-}" "$ROOT/.bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||
[ -n "$app" ] || continue
|
||||
if [ -x "$app/Contents/MacOS/love" ]; then
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||
if printf '%s' "$version" | grep -Eq '^12(\.|$)' \
|
||||
&& otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null | grep -q '/Metal.framework/'; then
|
||||
echo "$app/Contents/MacOS/love"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
LOVE_BIN="$(find_love12)" \
|
||||
|| fail "LÖVE 12 with Metal not found, run scripts/setup.sh or scripts/build_love_macos.sh --fetch"
|
||||
else
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||
fi
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
|
||||
@@ -70,32 +70,11 @@ find_love() {
|
||||
return 1
|
||||
}
|
||||
|
||||
valid_love12_app() {
|
||||
local app="$1" version
|
||||
[ -x "$app/Contents/MacOS/love" ] || return 1
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
|
||||
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||
| grep -q '/Metal.framework/'
|
||||
}
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
if valid_love12_app "$ROOT/.bazinga/love12/love.app"; then
|
||||
say "LÖVE 12 found: $ROOT/.bazinga/love12/love.app"
|
||||
elif valid_love12_app "/Applications/love12.app"; then
|
||||
say "LÖVE 12 found: /Applications/love12.app"
|
||||
elif valid_love12_app "$HOME/Applications/love12.app"; then
|
||||
say "LÖVE 12 found: $HOME/Applications/love12.app"
|
||||
elif valid_love12_app "/Applications/love.app"; then
|
||||
say "LÖVE 12 found: /Applications/love.app"
|
||||
elif valid_love12_app "$HOME/Applications/love.app"; then
|
||||
say "LÖVE 12 found: $HOME/Applications/love.app"
|
||||
else
|
||||
say "building LÖVE 12 for macOS"
|
||||
"$ROOT/scripts/build_love_macos.sh" --fetch
|
||||
fi
|
||||
elif LOVE_BIN="$(find_love)"; then
|
||||
if LOVE_BIN="$(find_love)"; then
|
||||
say "LÖVE found: $LOVE_BIN"
|
||||
elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
|
||||
say "installing LÖVE via Homebrew"
|
||||
brew install --cask love
|
||||
else
|
||||
fail "LÖVE 11.x is not installed; install it from https://love2d.org"
|
||||
fi
|
||||
|
||||
@@ -1008,23 +1008,9 @@ end
|
||||
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
||||
function BattleState:animNext(name, isPlayer, shakes, ball)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||
ball = ball }
|
||||
table.insert(self.queue, self.nextInsert, row)
|
||||
return row
|
||||
end
|
||||
|
||||
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
|
||||
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
|
||||
function BattleState:animBeforeMove(name, isPlayer)
|
||||
local at
|
||||
for i, item in ipairs(self.queue) do
|
||||
if item == self.moveAnimRow then at = i break end
|
||||
end
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, at or self.nextInsert,
|
||||
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
|
||||
hit = { animType = isPlayer and 6 or 3 } })
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||
ball = ball })
|
||||
end
|
||||
|
||||
-- insert an act right after the current queue item
|
||||
@@ -1568,13 +1554,8 @@ end
|
||||
function BattleState:sendOutText(name)
|
||||
local e = self.enemy and self.enemy.mon
|
||||
local pct = 100
|
||||
if e and e.hp > 0 then
|
||||
-- the same routine stamps wLastSwitchInEnemyMonHP
|
||||
-- (engine/battle/common_text.asm:105-110)
|
||||
self.lastSwitchInEnemyHP = e.hp
|
||||
if math.floor(e.stats.hp / 4) > 0 then
|
||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||
end
|
||||
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
|
||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||
end
|
||||
if pct >= 70 then return Strings("Go! %s!", name) end
|
||||
if pct >= 40 then return Strings("Do it! %s!", name) end
|
||||
@@ -1582,27 +1563,6 @@ function BattleState:sendOutText(name)
|
||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||
end
|
||||
|
||||
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
|
||||
-- adjective reads the enemy HP lost since this mon switched in
|
||||
function BattleState:withdrawText(name)
|
||||
local e = self.enemy and self.enemy.mon
|
||||
local drop = 0
|
||||
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
|
||||
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
|
||||
/ math.floor(e.stats.hp / 4))
|
||||
end
|
||||
local word = ""
|
||||
if drop <= 0 then
|
||||
word = self:romText("_EnoughText", "enough!")
|
||||
elseif drop >= 70 then
|
||||
word = self:romText("_GoodText", "good!")
|
||||
elseif drop >= 30 then
|
||||
word = self:romText("_OKExclamationText", "OK!")
|
||||
end
|
||||
return self:romText("_PlayerMon2Text", "%s ", name) .. word
|
||||
.. self:romText("_ComeBackText", "\nCome back!")
|
||||
end
|
||||
|
||||
-- The cry a mon makes as it takes the field. Yellow does not run its
|
||||
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
||||
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
||||
@@ -1856,8 +1816,7 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1886,8 +1845,7 @@ function BattleState:enter()
|
||||
self.sendingOut = true
|
||||
self:slidePic("back")
|
||||
end)
|
||||
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
|
||||
self:sayAuto(self:sendOutText(self.player.name))
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- then the POOF plays and the mon appears with its cry
|
||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
self:queueSendOutAnim(true)
|
||||
@@ -2569,7 +2527,11 @@ end
|
||||
|
||||
-- the HUD label drawn in place of the level for a statused mon
|
||||
function BattleState:statusLabel(mon)
|
||||
return Status.hudLabelFor(self.data.statuses, mon.status)
|
||||
local record = Status.recordFor(self.data.statuses, mon.status)
|
||||
if record then
|
||||
return record.hudLabel or record.label or mon.status
|
||||
end
|
||||
return mon.status
|
||||
end
|
||||
|
||||
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
|
||||
@@ -2701,28 +2663,22 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:actNext(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
@@ -2751,12 +2707,7 @@ function BattleState:residualFor(b, opp)
|
||||
if b.residualDone then return end
|
||||
b.residualDone = true
|
||||
local msgs = Status.residual(b, opp, self)
|
||||
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||
-- engine/battle/core.asm:490-493
|
||||
if rec and rec.residual then
|
||||
self:animNext("BURN_PSN_ANIM", b.isPlayer)
|
||||
end
|
||||
if b.leechSeeded and b.mon.hp > 0 then
|
||||
-- the drain plays the ABSORB animation from the healing side
|
||||
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
||||
@@ -3640,17 +3591,7 @@ function BattleState:executeAction(user, target, action)
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||
self.enemySendingOut = true
|
||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:actNext(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -4340,9 +4281,6 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
|
||||
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
|
||||
self.lastSwitchInEnemyHP = self.enemy.mon.hp
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
@@ -4362,7 +4300,7 @@ function BattleState:enemyMonFainted()
|
||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||
self.enemySendingOut = true
|
||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:actNext(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -4375,41 +4313,34 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||
-- hold, then the recall and the send-out
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:actNext(function()
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
return
|
||||
end
|
||||
@@ -4599,7 +4530,7 @@ function BattleState:openReplacementMenu()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -92,20 +92,6 @@ local function hitCount(ctx, record)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
|
||||
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
|
||||
|
||||
local function secondaryStatusFx(battle, user, status)
|
||||
if status == "PSN" then
|
||||
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
|
||||
or "SHAKE_SCREEN_ANIM", user.isPlayer)
|
||||
row.animDelayed = true
|
||||
row.hit = { animType = user.isPlayer and 6 or 3 }
|
||||
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
|
||||
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||
-- stage keeps the original's exact check order and rng consumption
|
||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
||||
@@ -332,12 +318,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- secondary side effects (blocked by fainting)
|
||||
if record and record.run and record.kind ~= "primary"
|
||||
and target.mon.hp > 0 and totalDealt > 0 then
|
||||
local hadStatus = target.mon.status
|
||||
local msgs = record.run(ctx)
|
||||
if target.mon.status and target.mon.status ~= hadStatus then
|
||||
secondaryStatusFx(battle, user, target.mon.status)
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
for _, m in ipairs(record.run(ctx)) do
|
||||
battle:sayNext(m)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -581,15 +581,6 @@ MoveEffects.full = {
|
||||
end,
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||
beforeAccuracy = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
|
||||
@@ -54,13 +54,6 @@ end
|
||||
-- freeze the English. They are already translatable through the
|
||||
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||
--
|
||||
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
|
||||
-- hudLabel before label, and Registry:patch only overrides the fields a
|
||||
-- mod actually passes, so a label-only translation patch would be
|
||||
-- shadowed by this hudLabel forever. Nothing in this codebase gives
|
||||
-- hudLabel a value different from label -- setting it here only recreates
|
||||
-- that trap for no observed benefit.
|
||||
--
|
||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
@@ -68,7 +61,7 @@ end
|
||||
-- read these fields, so a mod's sixth status plugs into every consumer.
|
||||
Status.RECORDS = {
|
||||
SLP = {
|
||||
id = "SLP", label = "SLP",
|
||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battler, _, battle)
|
||||
@@ -89,7 +82,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
id = "FRZ", label = "FRZ",
|
||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler, _, battle)
|
||||
@@ -103,7 +96,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN",
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime("_HurtByPoisonText",
|
||||
Strings.source("%s's\nhurt by poison!")),
|
||||
@@ -119,7 +112,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN",
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime("_HurtByBurnText",
|
||||
@@ -131,7 +124,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
id = "PAR", label = "PAR",
|
||||
id = "PAR", label = "PAR", hudLabel = "PAR",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "speed", div = 4 },
|
||||
beforeMovePriority = 10,
|
||||
@@ -167,14 +160,6 @@ function Status.recordFor(statuses, id)
|
||||
return (statuses or Status.RECORDS)[id]
|
||||
end
|
||||
|
||||
-- the HUD label for a status id: a mod's patched hudLabel/label if the
|
||||
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
|
||||
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
|
||||
function Status.hudLabelFor(statuses, id)
|
||||
local record = Status.recordFor(statuses, id)
|
||||
return record and (record.hudLabel or record.label) or id
|
||||
end
|
||||
|
||||
local function battleStatuses(battle)
|
||||
return battle and battle.data and battle.data.statuses
|
||||
end
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
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 = {}
|
||||
local pdfName
|
||||
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
|
||||
pdfName = name
|
||||
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
|
||||
pdfName = resizable
|
||||
pdfFiles[#pdfFiles + 1] = resizable
|
||||
else
|
||||
raster[#raster + 1] = { key = "large", name = resizable }
|
||||
end
|
||||
end
|
||||
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
|
||||
if #raster == 0 then return nil, pdfPath 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), nil
|
||||
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 imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
|
||||
local page = {
|
||||
name = orient,
|
||||
orient = orient,
|
||||
imagePath = imagePath,
|
||||
pdfPath = pdfPath,
|
||||
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
|
||||
else
|
||||
-- mappingSize is the overlay, not the device. Portrait controller
|
||||
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
|
||||
-- that aspect, sit at the bottom, and leave the leftover for the
|
||||
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
|
||||
page.aspectFromCfg = true
|
||||
page.screenFit = "remainder"
|
||||
if orient == "portrait" then page.anchor = "bottom" end
|
||||
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
|
||||
|
||||
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
|
||||
-- A raster asset, or a JPEG recovered from the PDF at load, means the
|
||||
-- skin can draw. Parse-only callers still see pdfOnly because they
|
||||
-- have not run extract yet.
|
||||
if page.rasterData then return nil end
|
||||
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
|
||||
@@ -34,7 +34,6 @@ end
|
||||
|
||||
function Game:load()
|
||||
self.data = Data
|
||||
self.sessionStartedAt = os.time()
|
||||
Data:load()
|
||||
|
||||
-- Mods are a native engine subsystem. They load after the verified ROM
|
||||
@@ -156,7 +155,6 @@ function Game:makeTitleState()
|
||||
onNewGame = function()
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.sessionStartedAt = os.time()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- no bucket carry-over: mod state from an abandoned session must
|
||||
-- not leak into a fresh slot; mods seed via save.created instead
|
||||
@@ -176,7 +174,6 @@ function Game:makeTitleState()
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
end,
|
||||
onExit = self.onExit,
|
||||
})
|
||||
title.screenId = title.screenId or "TitleState"
|
||||
return title
|
||||
@@ -359,7 +356,6 @@ function Game:update(dt)
|
||||
-- reason: they are presentational, so fast-forward must not speed them up
|
||||
require("src.render.Pipelines").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
self:updateSync(dt)
|
||||
-- Steady-state memory backstop: advance the incremental collector one
|
||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
||||
@@ -1182,41 +1178,11 @@ function Game:writeSave()
|
||||
-- stamp here so the save.writing payload carries the exact meta the
|
||||
-- file gets; mods snapshot runtime state into their namespace now
|
||||
self.save.meta = SaveData.buildMeta(
|
||||
self.modStatus and self.modStatus.loaded, self.save.meta,
|
||||
self.sessionStartedAt)
|
||||
self.modStatus and self.modStatus.loaded, self.save.meta)
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
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)
|
||||
return SaveData.save(self.save)
|
||||
end
|
||||
|
||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||
@@ -1275,7 +1241,6 @@ function Game:applyOptions(opts)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
self.sessionStartedAt = os.time()
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
|
||||
@@ -36,7 +36,6 @@ local World = require("src.world.gen2.World")
|
||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||
-- IO is src/core/gen2/Save.lua.
|
||||
@@ -196,7 +195,6 @@ end
|
||||
function Game2:persistOptions()
|
||||
pcall(Save.saveOptions, self.options)
|
||||
end
|
||||
Game2.writeOptions = Game2.persistOptions
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod state
|
||||
-- persists with the slot. Same contract and same three call sites as Gen 1
|
||||
@@ -320,7 +318,6 @@ function Game2:showMainMenu()
|
||||
onNewGame = function() self:newGame() end,
|
||||
onContinue = function(save) self:continueGame(save) end,
|
||||
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
||||
onExit = self.onExit,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -1163,8 +1160,7 @@ end
|
||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||
-- one screen pixel a cell out at survey range.
|
||||
function Game2:pixelScale(w, h)
|
||||
local _, _, pw, ph = Playfield.rect(w, h)
|
||||
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
|
||||
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
||||
end
|
||||
|
||||
-- A window-sized canvas the whole frame is composed into, so the post passes
|
||||
@@ -1281,8 +1277,7 @@ end
|
||||
function Game2:blitZones(canvas, zones, w, h)
|
||||
local G = love.graphics
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local px, py, pw, ph = Playfield.rect(w, h)
|
||||
local sx, sy = pw / 160, ph / 144
|
||||
local sx, sy = w / 160, h / 144
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for _, z in ipairs(zones) do
|
||||
-- a colors == false zone is the true-colour opt-out; anything the shader
|
||||
@@ -1301,11 +1296,11 @@ function Game2:blitZones(canvas, zones, w, h)
|
||||
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
|
||||
-- come out of this at exactly the pixels the plain floor/ceil pair gave
|
||||
-- them, so the vanilla picture is untouched.
|
||||
local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
|
||||
local x1 = math.floor(math.max(zx, px))
|
||||
local y1 = math.floor(math.max(zy, py))
|
||||
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
|
||||
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
|
||||
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy
|
||||
local x1 = math.floor(math.max(zx, 0))
|
||||
local y1 = math.floor(math.max(zy, 0))
|
||||
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w))
|
||||
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h))
|
||||
if x2 > x1 and y2 > y1 then
|
||||
G.setScissor(x1, y1, x2 - x1, y2 - y1)
|
||||
G.draw(canvas, 0, 0)
|
||||
@@ -1407,7 +1402,7 @@ function Game2:drawViewportFrame()
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
self:drawContained(w, h)
|
||||
self:drawScene(w, h)
|
||||
self:drawHud(w, h)
|
||||
return
|
||||
end
|
||||
@@ -1418,7 +1413,7 @@ function Game2:drawViewportFrame()
|
||||
G.origin()
|
||||
G.setCanvas(scene)
|
||||
G.clear(0, 0, 0, 1)
|
||||
self:drawContained(w, h)
|
||||
self:drawScene(w, h)
|
||||
G.setCanvas(previous)
|
||||
|
||||
if composing and self:compose(scene, zones, w, h) then
|
||||
@@ -1467,8 +1462,6 @@ function Game2:drawViewportFrame()
|
||||
generation = 2,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
@@ -1476,7 +1469,6 @@ function Game2:drawViewportFrame()
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
end
|
||||
if cx then G.setScissor() end
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
@@ -1507,13 +1499,6 @@ function Game2:textboxPaper()
|
||||
return nil
|
||||
end
|
||||
|
||||
function Game2:drawContained(w, h)
|
||||
local pw, ph = Playfield.push(w, h)
|
||||
local ok, err = pcall(self.drawScene, self, pw, ph)
|
||||
Playfield.pop()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
function Game2:drawScene(w, h)
|
||||
local G = love.graphics
|
||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||
|
||||
@@ -350,23 +350,11 @@ local function haveBridge()
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
local function haveRequestBridge()
|
||||
if not (love and love.system and type(love.system.httpRequest) == "function") then
|
||||
return false
|
||||
end
|
||||
local osName = love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
function HostShell.canFetch()
|
||||
return HostShell.haveCurl() or haveBridge()
|
||||
end
|
||||
|
||||
function HostShell.canHttpRequest()
|
||||
return (HostShell.haveCurl() or haveRequestBridge()) and true or false
|
||||
end
|
||||
|
||||
-- Download url to an absolute host path. Returns true, or nil plus an error.
|
||||
-- The curl branch deliberately ignores curl's exit code, as the download paths
|
||||
-- always did: callers judge the result by the file they got.
|
||||
@@ -569,173 +557,4 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
local function requestHeaderList(headers)
|
||||
local out = {}
|
||||
if type(headers) == "table" then
|
||||
if #headers > 0 then
|
||||
for _, line in ipairs(headers) do
|
||||
if type(line) == "string" then out[#out + 1] = line end
|
||||
end
|
||||
else
|
||||
local names = {}
|
||||
for name in pairs(headers) do names[#names + 1] = tostring(name) end
|
||||
table.sort(names)
|
||||
for _, name in ipairs(names) do
|
||||
out[#out + 1] = name .. ": " .. tostring(headers[name])
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, line in ipairs(out) do
|
||||
if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true }
|
||||
|
||||
local function requestHeaderPairs(lines)
|
||||
local out = {}
|
||||
for _, line in ipairs(lines) do
|
||||
local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$")
|
||||
if not name or name == "" then return nil end
|
||||
if name:find("[\r\n]") or value:find("[\r\n]") then return nil end
|
||||
out[#out + 1] = name
|
||||
out[#out + 1] = value
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function bridgeRequest(url, method, headers, body, userAgent)
|
||||
if not BRIDGE_METHODS[method] then
|
||||
return nil, "no request transport for " .. method .. " on this platform"
|
||||
end
|
||||
local fields = requestHeaderPairs(headers)
|
||||
if not fields then return nil, "bad request header" end
|
||||
local ok, envelope = pcall(love.system.httpRequest, url, method, fields,
|
||||
body, userAgent)
|
||||
if not ok or type(envelope) ~= "string" or envelope == "" then
|
||||
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||
end
|
||||
local head, rest = envelope:match("^([^\n]*)\n(.*)$")
|
||||
if not head then
|
||||
return nil, fetchError(url, nil, "unreadable reply from the network bridge")
|
||||
end
|
||||
local status = tonumber(head:match("^STATUS (%d+)$"))
|
||||
if status then return rest or "", nil, status end
|
||||
return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head)
|
||||
end
|
||||
|
||||
local requestSeq = 0
|
||||
|
||||
local function requestStagingPath(kind)
|
||||
local dir
|
||||
if love and love.filesystem and love.filesystem.getSaveDirectory then
|
||||
local ok, saveDir = pcall(love.filesystem.getSaveDirectory)
|
||||
if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end
|
||||
end
|
||||
if not dir then
|
||||
dir = os.getenv("TEMP") or os.getenv("TMP")
|
||||
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
|
||||
end
|
||||
local sep = dir:find("\\") and "\\" or "/"
|
||||
requestSeq = requestSeq + 1
|
||||
return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format(
|
||||
kind, os.time() % 1000000, requestSeq, math.random(0, 999999))
|
||||
end
|
||||
|
||||
local function writeStagingFile(kind, text)
|
||||
local path = requestStagingPath(kind)
|
||||
local file, openErr = io.open(path, "wb")
|
||||
if not file then
|
||||
return nil, "could not create the request " .. kind .. ": " .. tostring(openErr)
|
||||
end
|
||||
local wrote, writeErr = pcall(function()
|
||||
assert(file:write(text))
|
||||
assert(file:close())
|
||||
end)
|
||||
if not wrote then
|
||||
pcall(function() file:close() end)
|
||||
pcall(os.remove, path)
|
||||
return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
function HostShell.httpRequest(url, opts)
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
local method = tostring(opts.method or "GET"):upper()
|
||||
if not method:match("^%u+$") then return nil, "bad request method" end
|
||||
local headers = requestHeaderList(opts.headers)
|
||||
if not headers then return nil, "bad request header" end
|
||||
local body = opts.body
|
||||
if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end
|
||||
local userAgent = opts.userAgent or "gen1recomp"
|
||||
local maxTime = tonumber(opts.maxTime) or 30
|
||||
|
||||
if not HostShell.haveCurl() then
|
||||
if haveRequestBridge() then
|
||||
return bridgeRequest(url, method, headers, body, userAgent)
|
||||
end
|
||||
if method == "GET" and #headers == 0 then
|
||||
local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime)
|
||||
if not got then return nil, err end
|
||||
return got, nil, 200
|
||||
end
|
||||
if haveBridge() then
|
||||
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||
end
|
||||
return nil, "no request transport on this platform"
|
||||
end
|
||||
|
||||
local bodyPath, stageErr
|
||||
if body then
|
||||
bodyPath, stageErr = writeStagingFile("body", body)
|
||||
if not bodyPath then return nil, stageErr end
|
||||
end
|
||||
|
||||
local lines = { "User-Agent: " .. userAgent }
|
||||
for _, line in ipairs(headers) do lines[#lines + 1] = line end
|
||||
if body then
|
||||
lines[#lines + 1] = "Content-Length: " .. tostring(#body)
|
||||
end
|
||||
local headerPath
|
||||
headerPath, stageErr = writeStagingFile("head",
|
||||
table.concat(lines, "\n") .. "\n")
|
||||
if not headerPath then
|
||||
if bodyPath then pcall(os.remove, bodyPath) end
|
||||
return nil, stageErr
|
||||
end
|
||||
|
||||
local function cleanup()
|
||||
if bodyPath then pcall(os.remove, bodyPath) end
|
||||
pcall(os.remove, headerPath)
|
||||
end
|
||||
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
|
||||
.. "-X " .. HostShell.quote(method) .. " "
|
||||
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
|
||||
if body then
|
||||
cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
end
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then
|
||||
cleanup()
|
||||
return nil, "could not run curl"
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
cleanup()
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
local respBody, status, noise = splitCurlOutput(out)
|
||||
if not status then return nil, fetchError(url, nil, noise) end
|
||||
return respBody or "", nil, status
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local IssueReport = {}
|
||||
|
||||
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||
local TEMPLATE = "bug_report.yml"
|
||||
|
||||
local APPLE_MODELS = {
|
||||
["iPhone14,2"] = "iPhone 13 Pro",
|
||||
["iPhone14,3"] = "iPhone 13 Pro Max",
|
||||
["iPhone14,4"] = "iPhone 13 mini",
|
||||
["iPhone14,5"] = "iPhone 13",
|
||||
["iPhone14,7"] = "iPhone 14",
|
||||
["iPhone14,8"] = "iPhone 14 Plus",
|
||||
["iPhone15,2"] = "iPhone 14 Pro",
|
||||
["iPhone15,3"] = "iPhone 14 Pro Max",
|
||||
["iPhone15,4"] = "iPhone 15",
|
||||
["iPhone15,5"] = "iPhone 15 Plus",
|
||||
["iPhone16,1"] = "iPhone 15 Pro",
|
||||
["iPhone16,2"] = "iPhone 15 Pro Max",
|
||||
["iPhone17,1"] = "iPhone 16 Pro",
|
||||
["iPhone17,2"] = "iPhone 16 Pro Max",
|
||||
["iPhone17,3"] = "iPhone 16",
|
||||
["iPhone17,4"] = "iPhone 16 Plus",
|
||||
["iPhone17,5"] = "iPhone 16e",
|
||||
["Mac14,2"] = "MacBook Air (13-inch, M2)",
|
||||
["Mac14,3"] = "Mac mini (M2)",
|
||||
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
|
||||
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
|
||||
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
|
||||
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
|
||||
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
|
||||
["Mac14,12"] = "Mac mini (M2 Pro)",
|
||||
["Mac14,13"] = "Mac Studio (M2 Max)",
|
||||
["Mac14,14"] = "Mac Studio (M2 Ultra)",
|
||||
["Mac14,15"] = "MacBook Air (15-inch, M2)",
|
||||
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
|
||||
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
|
||||
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
|
||||
["Mac15,12"] = "MacBook Air (13-inch, M3)",
|
||||
["Mac15,13"] = "MacBook Air (15-inch, M3)",
|
||||
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
|
||||
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
|
||||
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
|
||||
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
|
||||
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
|
||||
["Mac16,10"] = "Mac mini (M4)",
|
||||
["Mac16,11"] = "Mac mini (M4 Pro)",
|
||||
}
|
||||
|
||||
local function clean(value)
|
||||
if value == nil then return nil end
|
||||
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if text == "" or text == "unknown" or text == "Unknown" then return nil end
|
||||
return text
|
||||
end
|
||||
|
||||
local function call(fn, ...)
|
||||
if type(fn) ~= "function" then return nil end
|
||||
local ok, a, b, c, d, e = pcall(fn, ...)
|
||||
if not ok then return nil end
|
||||
return a, b, c, d, e
|
||||
end
|
||||
|
||||
local function invoke(fn, ...)
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, result = pcall(fn, ...)
|
||||
return ok, result
|
||||
end
|
||||
|
||||
local function commandValue(command)
|
||||
if not io or type(io.popen) ~= "function" then return nil end
|
||||
local ok, pipe = pcall(io.popen, command, "r")
|
||||
if not ok or not pipe then return nil end
|
||||
local readOK, value = pcall(pipe.read, pipe, "*l")
|
||||
pcall(pipe.close, pipe)
|
||||
if not readOK then return nil end
|
||||
return clean(value)
|
||||
end
|
||||
|
||||
local function commandText(command)
|
||||
if not io or type(io.popen) ~= "function" then return nil end
|
||||
local ok, pipe = pcall(io.popen, command, "r")
|
||||
if not ok or not pipe then return nil end
|
||||
local readOK, value = pcall(pipe.read, pipe, "*a")
|
||||
pcall(pipe.close, pipe)
|
||||
if not readOK then return nil end
|
||||
return clean(value)
|
||||
end
|
||||
|
||||
local function percentEncode(value)
|
||||
local text = tostring(value or "")
|
||||
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||
return ("%%%02X"):format(char:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
local function formOS(raw)
|
||||
local values = {
|
||||
["OS X"] = "macOS",
|
||||
macOS = "macOS",
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
Android = "Android",
|
||||
iOS = "iOS",
|
||||
NX = "Nintendo Switch",
|
||||
UWP = "Xbox",
|
||||
Xbox = "Xbox",
|
||||
}
|
||||
return values[raw] or ""
|
||||
end
|
||||
|
||||
local function loveVersion()
|
||||
local major, minor, revision, codename = call(love and love.getVersion)
|
||||
if not major then return "" end
|
||||
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
|
||||
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
|
||||
return result
|
||||
end
|
||||
|
||||
local function friendlyModel(identifier)
|
||||
identifier = clean(identifier)
|
||||
if not identifier then return nil end
|
||||
return APPLE_MODELS[identifier] or identifier
|
||||
end
|
||||
|
||||
local function macModel()
|
||||
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
|
||||
if details then
|
||||
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
|
||||
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
|
||||
if name and chip and not name:find(chip, 1, true) then
|
||||
return name .. " (" .. chip .. ")"
|
||||
end
|
||||
if name then return name end
|
||||
end
|
||||
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
|
||||
end
|
||||
|
||||
local function appVersion()
|
||||
local version = clean(Version.engine)
|
||||
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||
return version
|
||||
end
|
||||
|
||||
local function deviceModel(rawOS, system)
|
||||
local nativeModel = clean(call(system.getDeviceModel))
|
||||
if nativeModel then return friendlyModel(nativeModel) end
|
||||
if rawOS == "OS X" or rawOS == "macOS" then
|
||||
return macModel()
|
||||
end
|
||||
local model = clean(call(system.getModel))
|
||||
if model and not model:lower():find("gpu", 1, true)
|
||||
and not model:lower():find("renderer", 1, true) then
|
||||
return friendlyModel(model)
|
||||
end
|
||||
if rawOS == "Windows" then
|
||||
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
|
||||
end
|
||||
if rawOS == "Linux" then
|
||||
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
|
||||
end
|
||||
if rawOS == "Android" then
|
||||
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function modRows(context)
|
||||
if context and type(context.mods) == "table" then return context.mods end
|
||||
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
|
||||
if ok and LauncherMods and LauncherMods.list then
|
||||
local listed = call(LauncherMods.list)
|
||||
if type(listed) == "table" then return listed end
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
local function modNames(rows, safeMode)
|
||||
local enabled = {}
|
||||
for _, mod in ipairs(rows or {}) do
|
||||
if type(mod) == "table" then
|
||||
local name = clean(mod.name or mod.id)
|
||||
if name and not safeMode and mod.enabled == true then
|
||||
enabled[#enabled + 1] = name
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(enabled)
|
||||
return enabled
|
||||
end
|
||||
|
||||
local function metadata(options, context)
|
||||
local system = love and love.system or {}
|
||||
local graphics = love and love.graphics or {}
|
||||
local window = love and love.window or {}
|
||||
local rawOS = clean(call(system.getOS))
|
||||
local model = deviceModel(rawOS, system)
|
||||
local renderer, rendererVersion = call(graphics.getRendererInfo)
|
||||
local width, height = call(graphics.getDimensions)
|
||||
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||
local safeMode = SaveData.isSafeMode(options)
|
||||
local rows = modRows(context or {})
|
||||
local enabledMods = modNames(rows, safeMode)
|
||||
local lines = { "Diagnostics:" }
|
||||
local function add(label, value)
|
||||
value = clean(value)
|
||||
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||
end
|
||||
add("Platform", formOS(rawOS))
|
||||
add("Device", model)
|
||||
local rendererDetails = clean(renderer)
|
||||
if rendererDetails and clean(rendererVersion) then
|
||||
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||
end
|
||||
add("Renderer", rendererDetails)
|
||||
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
|
||||
if displayWidth and displayHeight then
|
||||
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
|
||||
end
|
||||
if pixelWidth and pixelHeight
|
||||
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
|
||||
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
|
||||
end
|
||||
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||
local version = appVersion()
|
||||
add("App", version ~= "" and Version.title() or "gen1recomp")
|
||||
add("LÖVE", loveVersion())
|
||||
if safeMode then add("Safe mode", "on") end
|
||||
return {
|
||||
rawOS = rawOS,
|
||||
os = formOS(rawOS),
|
||||
device = model,
|
||||
version = version,
|
||||
safeMode = safeMode,
|
||||
enabledMods = enabledMods,
|
||||
metadata = table.concat(lines, "\n"),
|
||||
}
|
||||
end
|
||||
|
||||
function IssueReport.build(options, context)
|
||||
options = options or SaveData.loadOptions()
|
||||
context = context or {}
|
||||
local info = metadata(options, context)
|
||||
local fields = {
|
||||
summary = "",
|
||||
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
|
||||
version = info.version or "",
|
||||
location = "",
|
||||
screenshot = "",
|
||||
steps = "",
|
||||
expected = "",
|
||||
extra = info.metadata,
|
||||
}
|
||||
local params = {
|
||||
"template=" .. percentEncode(TEMPLATE),
|
||||
"title=" .. percentEncode("bug: replace this with a meaningful title"),
|
||||
}
|
||||
local order = { "summary", "mods_which",
|
||||
"version", "location", "screenshot", "steps", "expected", "extra" }
|
||||
for _, key in ipairs(order) do
|
||||
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
|
||||
end
|
||||
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
|
||||
end
|
||||
|
||||
function IssueReport.open(options, context)
|
||||
local url = IssueReport.build(options, context)
|
||||
local system = love and love.system or {}
|
||||
local opened, openResult = invoke(system.openURL, url)
|
||||
if opened and openResult ~= false then
|
||||
return true, url
|
||||
end
|
||||
local copied, copyResult = invoke(system.setClipboardText, url)
|
||||
if copied and copyResult ~= false then
|
||||
return true, url, "Issue URL copied to the clipboard."
|
||||
end
|
||||
local filesystem = love and love.filesystem or {}
|
||||
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
|
||||
if written and writeResult ~= false then
|
||||
return true, url, "Issue URL saved to issue-report-url.txt."
|
||||
end
|
||||
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
|
||||
end
|
||||
|
||||
IssueReport.percentEncode = percentEncode
|
||||
IssueReport.metadata = metadata
|
||||
|
||||
return IssueReport
|
||||
@@ -67,23 +67,10 @@ local function argFlag(argv, name)
|
||||
return false
|
||||
end
|
||||
|
||||
local cachedIntentGame = nil
|
||||
|
||||
-- Returns version, slotId (either may be nil). Command line wins over env,
|
||||
-- so a shortcut can override a machine-wide default.
|
||||
function LaunchOptions.resolve(argv)
|
||||
if cachedIntentGame == nil then
|
||||
if love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||
and love.system.getLaunchGame then
|
||||
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
|
||||
else
|
||||
cachedIntentGame = false
|
||||
end
|
||||
end
|
||||
local intentGame = cachedIntentGame or nil
|
||||
|
||||
local game = normalizeVersion(argValue(argv, "game"))
|
||||
or intentGame
|
||||
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
||||
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
||||
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
|
||||
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
|
||||
-- import pulls the embedded image out instead of refusing the skin. True
|
||||
-- vector PDFs (no Image XObject, no JPEG) still fail.
|
||||
|
||||
local PdfImage = {}
|
||||
|
||||
local function isPdf(bytes)
|
||||
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
|
||||
end
|
||||
|
||||
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
|
||||
-- `endstream` also contains the letters "stream", so skip that match.
|
||||
local function streamDataStart(bytes, from)
|
||||
local s, e = bytes:find("stream", from, true)
|
||||
while s do
|
||||
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
|
||||
local p = e + 1
|
||||
if bytes:sub(p, p) == "\r" then p = p + 1 end
|
||||
if bytes:sub(p, p) == "\n" then p = p + 1 end
|
||||
return p, s
|
||||
end
|
||||
s, e = bytes:find("stream", e + 1, true)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function dictWindow(bytes, imageAt)
|
||||
local from = imageAt > 400 and (imageAt - 400) or 1
|
||||
local to = math.min(#bytes, imageAt + 800)
|
||||
return bytes:sub(from, to)
|
||||
end
|
||||
|
||||
local function dictNumber(window, key)
|
||||
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
|
||||
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
|
||||
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
|
||||
end
|
||||
|
||||
local function dictFilter(window)
|
||||
local named = window:match("/Filter%s*/(%w+)")
|
||||
if named then return named end
|
||||
return window:match("/Filter%s*%[%s*/(%w+)")
|
||||
end
|
||||
|
||||
local function jpegIn(bytes, from, to)
|
||||
if from < 1 then from = 1 end
|
||||
if not to or to > #bytes then to = #bytes end
|
||||
if to < from then return nil end
|
||||
local region = bytes:sub(from, to)
|
||||
local soi = region:find("\255\216\255", 1, true)
|
||||
if not soi then return nil end
|
||||
local eoi = region:find("\255\217", soi + 3, true)
|
||||
if not eoi then return nil end
|
||||
return region:sub(soi, eoi + 1)
|
||||
end
|
||||
|
||||
local function candidate(data, width, height, ext)
|
||||
if not data or data == "" then return nil end
|
||||
return {
|
||||
data = data,
|
||||
ext = ext or "jpg",
|
||||
width = width or 0,
|
||||
height = height or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function bigger(a, b)
|
||||
if not a then return b end
|
||||
if not b then return a end
|
||||
local as = (a.width or 0) * (a.height or 0)
|
||||
local bs = (b.width or 0) * (b.height or 0)
|
||||
if bs ~= as then return bs > as and b or a end
|
||||
return #b.data > #a.data and b or a
|
||||
end
|
||||
|
||||
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
|
||||
local function fromImageXObjects(bytes)
|
||||
local best
|
||||
local i = 1
|
||||
while true do
|
||||
local s, e = bytes:find("/Subtype%s*/Image", i)
|
||||
if not s then break end
|
||||
local window = dictWindow(bytes, s)
|
||||
local filter = dictFilter(window)
|
||||
local width = dictNumber(window, "Width")
|
||||
local height = dictNumber(window, "Height")
|
||||
local dataStart = streamDataStart(bytes, e)
|
||||
i = e + 1
|
||||
if dataStart and filter == "DCTDecode" then
|
||||
local es = bytes:find("endstream", dataStart, true)
|
||||
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
|
||||
best = bigger(best, candidate(jpeg, width, height, "jpg"))
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
|
||||
-- body even when /Length is an indirect object we do not resolve.
|
||||
local function fromBareJpeg(bytes)
|
||||
local jpeg = jpegIn(bytes, 1, #bytes)
|
||||
if not jpeg then return nil end
|
||||
return candidate(jpeg, 0, 0, "jpg")
|
||||
end
|
||||
|
||||
function PdfImage.extract(bytes)
|
||||
if not isPdf(bytes) then return nil, "not a pdf" end
|
||||
local best = fromImageXObjects(bytes)
|
||||
if not best then best = fromBareJpeg(bytes) end
|
||||
if not best then return nil, "no extractable image" end
|
||||
return best
|
||||
end
|
||||
|
||||
return PdfImage
|
||||
@@ -140,7 +140,7 @@ function SaveData.gameFolders()
|
||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||
local sbd = love.filesystem.getSourceBaseDirectory
|
||||
and love.filesystem.getSourceBaseDirectory()
|
||||
-- A packaged macOS build nests the game inside gen1recomp++.app/Contents/
|
||||
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
|
||||
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
|
||||
-- bundle -- not where the player drops portable.txt (next to the .app).
|
||||
-- Recover the folder containing the .app so a packaged app finds its
|
||||
@@ -300,7 +300,6 @@ function SaveData.defaultOptions()
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
safeMode = false,
|
||||
-- Mods the player forced past the target gate (Loader:_gateGeneration).
|
||||
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
|
||||
-- pre-per-game shape and means the Gen 2 games only (see modForced).
|
||||
@@ -357,8 +356,6 @@ function SaveData.defaultOptions()
|
||||
-- rewind presentation preferences.
|
||||
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
|
||||
pendingConflicts = {} },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -387,16 +384,6 @@ function SaveData.mergeOptions(loaded)
|
||||
return opts
|
||||
end
|
||||
|
||||
function SaveData.isSafeMode(options)
|
||||
return type(options) == "table" and options.safeMode == true
|
||||
end
|
||||
|
||||
function SaveData.setSafeMode(options, enabled)
|
||||
if type(options) ~= "table" then return false end
|
||||
options.safeMode = enabled == true
|
||||
return options.safeMode
|
||||
end
|
||||
|
||||
function SaveData.encode(data)
|
||||
return SaveSerializer.encode(data)
|
||||
end
|
||||
@@ -1026,22 +1013,6 @@ function SaveData.listSlots(version)
|
||||
return out
|
||||
end
|
||||
|
||||
function SaveData.readSlotSource(version, slotId, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) or type(slotId) ~= "string" then return nil end
|
||||
local fs = persistFs(injectedFs)
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
for _, name in ipairs({ main, tmp, bak }) do
|
||||
if fs.getInfo(name) then
|
||||
local body = fs.read(name)
|
||||
if type(body) == "string" and body ~= "" then
|
||||
if SaveSerializer.decode(body) then return body end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||
-- you can see that in the launcher"). The label lives in the options
|
||||
-- registry next to list/active, never in the save file itself, so renaming
|
||||
@@ -1356,7 +1327,7 @@ end
|
||||
-- loaded list sorted by id and is the ground truth for the load-time
|
||||
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
|
||||
-- headless writer (the save editor) never wipes it.
|
||||
function SaveData.buildMeta(mods, previous, sessionStart)
|
||||
function SaveData.buildMeta(mods, previous)
|
||||
local list
|
||||
if mods ~= nil then
|
||||
list = {}
|
||||
@@ -1367,18 +1338,10 @@ function SaveData.buildMeta(mods, previous, sessionStart)
|
||||
else
|
||||
list = (type(previous) == "table" and previous.mods) or {}
|
||||
end
|
||||
local started = tonumber(sessionStart)
|
||||
if not started or started ~= started or started <= 0
|
||||
or started == math.huge then
|
||||
started = type(previous) == "table" and tonumber(previous.sessionStart) or nil
|
||||
end
|
||||
local savedAt = os.time()
|
||||
if started and started > savedAt then started = savedAt end
|
||||
return {
|
||||
format = Version.saveFormat,
|
||||
engine = Version.engine,
|
||||
savedAt = savedAt,
|
||||
sessionStart = started,
|
||||
savedAt = os.time(),
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
mods = list,
|
||||
}
|
||||
|
||||
@@ -616,15 +616,13 @@ local function exitControl(self, ctl)
|
||||
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
|
||||
end
|
||||
|
||||
function skinHitSet(self, x, y, prev)
|
||||
function skinHitSet(self, x, y)
|
||||
local page = TouchSkin.page()
|
||||
if not page then return nil end
|
||||
local ww, wh, ox, oy = surfaceRect()
|
||||
local set = nil
|
||||
for _, ctl in ipairs(page.controls) do
|
||||
local held = (prev and prev[ctl]) == true
|
||||
if not ctl.decorative
|
||||
and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
|
||||
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
|
||||
set = set or {}
|
||||
set[ctl] = true
|
||||
end
|
||||
@@ -667,7 +665,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
return
|
||||
end
|
||||
if TouchSkin.active then
|
||||
local set = skinHitSet(self, x, y, nil)
|
||||
local set = skinHitSet(self, x, y)
|
||||
if not set then return end
|
||||
local touch = { control = "skin" }
|
||||
self.touches[id] = touch
|
||||
@@ -700,7 +698,7 @@ function TouchControls:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
if touch.control == "skin" then
|
||||
applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
|
||||
applySkinSet(self, touch, skinHitSet(self, x, y))
|
||||
return
|
||||
end
|
||||
-- only the d-pad tracks movement (slide between directions without
|
||||
|
||||
@@ -2,7 +2,6 @@ local TouchSkin = {}
|
||||
|
||||
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
||||
TouchSkin.USER_ROOT = "skins"
|
||||
TouchSkin.EXPORT_ROOT = "skins/_export"
|
||||
|
||||
TouchSkin.GB_BUTTONS = {
|
||||
a = "a", b = "b", start = "start", select = "select",
|
||||
@@ -84,110 +83,6 @@ local function parseBinds(spec)
|
||||
return buttons, hotkeys, keys, decorative
|
||||
end
|
||||
|
||||
TouchSkin.AREA_DEFAULTS = {
|
||||
dpad_area = { up = "up", down = "down", left = "left", right = "right" },
|
||||
abxy_area = { up = "x", down = "b", left = "y", right = "a" },
|
||||
analog_left = { up = "up", down = "down", left = "left", right = "right" },
|
||||
analog_right = { up = "up", down = "down", left = "left", right = "right" },
|
||||
}
|
||||
|
||||
local DIRECTIONAL_CELLS = {
|
||||
{ col = 1, row = 1, h = "left", v = "up" },
|
||||
{ col = 2, row = 1, v = "up" },
|
||||
{ col = 3, row = 1, h = "right", v = "up" },
|
||||
{ col = 1, row = 2, h = "left" },
|
||||
{ col = 3, row = 2, h = "right" },
|
||||
{ col = 1, row = 3, h = "left", v = "down" },
|
||||
{ col = 2, row = 3, v = "down" },
|
||||
{ col = 3, row = 3, h = "right", v = "down" },
|
||||
}
|
||||
|
||||
local function outwardReach(reach)
|
||||
return 1 + 3 * ((num(reach, 1)) - 1)
|
||||
end
|
||||
|
||||
function TouchSkin.expandDirectional(base, names)
|
||||
names = names or {}
|
||||
local cellX = math.abs(num(base.rangeX, 0.05)) / 3
|
||||
local cellY = math.abs(num(base.rangeY, 0.05)) / 3
|
||||
local out = {}
|
||||
for _, cell in ipairs(DIRECTIONAL_CELLS) do
|
||||
local parts = {}
|
||||
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||
local ctl = TouchSkin.newControl(spec,
|
||||
num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
|
||||
num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
|
||||
cellX * 2, cellY * 2, "rect")
|
||||
ctl.rangeMod = num(base.rangeMod, 1)
|
||||
ctl.alphaMod = num(base.alphaMod, 1)
|
||||
ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
|
||||
ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
|
||||
ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
|
||||
ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
|
||||
ctl.pixelCoords = base.pixelCoords
|
||||
ctl.movable = base.movable
|
||||
ctl.exclusive = base.exclusive
|
||||
out[#out + 1] = ctl
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local SECTOR_CELLS = {
|
||||
{ h = "right" },
|
||||
{ h = "right", v = "down" },
|
||||
{ v = "down" },
|
||||
{ h = "left", v = "down" },
|
||||
{ h = "left" },
|
||||
{ h = "left", v = "up" },
|
||||
{ v = "up" },
|
||||
{ h = "right", v = "up" },
|
||||
}
|
||||
|
||||
TouchSkin.SECTOR_SPAN = math.pi / 4
|
||||
|
||||
function TouchSkin.sectorHit(sector, dx, dy)
|
||||
local span = TouchSkin.SECTOR_SPAN
|
||||
local start = (sector - 1) * span - span * 0.5
|
||||
local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
|
||||
return a < span
|
||||
end
|
||||
|
||||
function TouchSkin.expandSectors(base, names)
|
||||
names = names or {}
|
||||
local out = {}
|
||||
for i, cell in ipairs(SECTOR_CELLS) do
|
||||
local parts = {}
|
||||
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||
local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
|
||||
math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
|
||||
base.shape)
|
||||
ctl.sector = i
|
||||
ctl.areaKind = base.areaKind
|
||||
ctl.areaNames = base.areaNames
|
||||
ctl.rangeMod = num(base.rangeMod, 1)
|
||||
ctl.alphaMod = num(base.alphaMod, 1)
|
||||
ctl.reachLeft = num(base.reachLeft, 1)
|
||||
ctl.reachRight = num(base.reachRight, 1)
|
||||
ctl.reachUp = num(base.reachUp, 1)
|
||||
ctl.reachDown = num(base.reachDown, 1)
|
||||
ctl.pixelCoords = base.pixelCoords
|
||||
ctl.movable = base.movable
|
||||
ctl.exclusive = base.exclusive
|
||||
out[#out + 1] = ctl
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function areaSide(kv, prefix, side, fallback)
|
||||
local v = kv[prefix .. "_" .. side]
|
||||
if v == nil or trim(v) == "" then return fallback end
|
||||
return trim(v)
|
||||
end
|
||||
|
||||
local function parseDesc(kv, prefix, page)
|
||||
local spec = kv[prefix]
|
||||
if not spec then return nil end
|
||||
@@ -221,28 +116,9 @@ local function parseDesc(kv, prefix, page)
|
||||
imagePath = kv[prefix .. "_overlay"],
|
||||
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
||||
nextTarget = kv[prefix .. "_next_target"],
|
||||
movable = toBool(kv[prefix .. "_movable"]) or nil,
|
||||
exclusive = (toBool(kv[prefix .. "_exclusive"])
|
||||
or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
|
||||
saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
|
||||
}
|
||||
if ctl.imagePath == "" then ctl.imagePath = nil end
|
||||
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
||||
|
||||
local normalized = kv[prefix .. "_normalized"]
|
||||
if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
|
||||
|
||||
local areaKind = trim(t[1]):lower()
|
||||
local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
|
||||
if defaults then
|
||||
ctl.areaKind = areaKind
|
||||
ctl.areaNames = {
|
||||
up = areaSide(kv, prefix, "up", defaults.up),
|
||||
down = areaSide(kv, prefix, "down", defaults.down),
|
||||
left = areaSide(kv, prefix, "left", defaults.left),
|
||||
right = areaSide(kv, prefix, "right", defaults.right),
|
||||
}
|
||||
end
|
||||
return ctl
|
||||
end
|
||||
|
||||
@@ -251,14 +127,7 @@ function TouchSkin.parse(text)
|
||||
local count = math.floor(num(kv.overlays, 0))
|
||||
if count <= 0 then return nil, "no overlays" end
|
||||
|
||||
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
|
||||
|
||||
local pages = {}
|
||||
for i = 0, count - 1 do
|
||||
local p = "overlay" .. i
|
||||
local page = {
|
||||
@@ -297,34 +166,10 @@ function TouchSkin.parse(text)
|
||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||
end
|
||||
|
||||
page.pixelCoords = not page.normalized
|
||||
if page.pixelCoords and not page.imagePath then
|
||||
page.pixelCoords = false
|
||||
warn(page.name .. " has no base image: desc coordinates read as normalized")
|
||||
end
|
||||
|
||||
local descs = math.floor(num(kv[p .. "_descs"], 0))
|
||||
for d = 0, descs - 1 do
|
||||
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
|
||||
if 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
|
||||
if ctl then page.controls[#page.controls + 1] = ctl end
|
||||
end
|
||||
pages[#pages + 1] = page
|
||||
end
|
||||
@@ -335,7 +180,7 @@ function TouchSkin.parse(text)
|
||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||
end
|
||||
|
||||
return { pages = pages, warnings = warnings }
|
||||
return { pages = pages }
|
||||
end
|
||||
|
||||
local function readFile(path)
|
||||
@@ -374,26 +219,18 @@ end
|
||||
|
||||
TouchSkin.NATIVE_NAME = "skin.lua"
|
||||
|
||||
TouchSkin.readFile = readFile
|
||||
TouchSkin.listDir = listDir
|
||||
TouchSkin.isDir = isDir
|
||||
|
||||
local function findConfig(root)
|
||||
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
||||
return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
|
||||
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
|
||||
end
|
||||
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
|
||||
for _, name in ipairs(named) do
|
||||
if readFile(root .. "/" .. name) then
|
||||
return root .. "/" .. name, "retroarch", ""
|
||||
end
|
||||
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
|
||||
end
|
||||
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
|
||||
if infoPath then return infoPath, "delta", prefix end
|
||||
local items = listDir(root)
|
||||
table.sort(items)
|
||||
for _, name in ipairs(items) do
|
||||
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
|
||||
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -425,17 +262,12 @@ function TouchSkin.parseNative(text)
|
||||
imagePath = raw.image,
|
||||
fullScreen = raw.fullScreen ~= false,
|
||||
normalized = true,
|
||||
pixelCoords = false,
|
||||
rangeMod = num(raw.rangeMod, 1),
|
||||
alphaMod = num(raw.alphaMod, 1),
|
||||
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||
aspectFromCfg = raw.fitAspect == true,
|
||||
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||
or raw.orient == "any") and raw.orient or nil,
|
||||
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
|
||||
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
|
||||
or raw.anchor == "left" or raw.anchor == "right")
|
||||
and raw.anchor or nil,
|
||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||
controls = {},
|
||||
}
|
||||
@@ -451,24 +283,7 @@ function TouchSkin.parseNative(text)
|
||||
end
|
||||
for _, c in ipairs(raw.controls or {}) do
|
||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||
local sector = tonumber(c.sector)
|
||||
if sector then
|
||||
sector = math.floor(sector)
|
||||
if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
|
||||
end
|
||||
local areaNames
|
||||
if type(c.areaNames) == "table" then
|
||||
areaNames = {}
|
||||
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||
if type(c.areaNames[side]) == "string" then
|
||||
areaNames[side] = c.areaNames[side]
|
||||
end
|
||||
end
|
||||
end
|
||||
page.controls[#page.controls + 1] = {
|
||||
sector = sector,
|
||||
areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
|
||||
areaNames = areaNames,
|
||||
spec = tostring(c.bind or "nul"),
|
||||
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
||||
decorative = decorative,
|
||||
@@ -483,8 +298,6 @@ function TouchSkin.parseNative(text)
|
||||
imagePath = c.image,
|
||||
pressedImagePath = c.imagePressed,
|
||||
nextTarget = c.nextTarget,
|
||||
movable = c.movable == true or nil,
|
||||
exclusive = c.exclusive == true or nil,
|
||||
}
|
||||
end
|
||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||
@@ -511,8 +324,6 @@ function TouchSkin.toNative(skin)
|
||||
alphaMod = page.alphaMod,
|
||||
aspect = page.aspect,
|
||||
fitAspect = page.aspectFromCfg or nil,
|
||||
screenFit = page.screenFit == "remainder" and "remainder" or nil,
|
||||
anchor = page.anchor,
|
||||
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||
or page.orient == "any") and page.orient or nil,
|
||||
controls = {},
|
||||
@@ -544,14 +355,6 @@ function TouchSkin.toNative(skin)
|
||||
image = ctl.imagePath,
|
||||
imagePressed = ctl.pressedImagePath,
|
||||
nextTarget = ctl.nextTarget,
|
||||
movable = ctl.movable or nil,
|
||||
exclusive = ctl.exclusive or nil,
|
||||
sector = ctl.sector,
|
||||
areaKind = ctl.areaKind,
|
||||
areaNames = ctl.areaNames and {
|
||||
up = ctl.areaNames.up, down = ctl.areaNames.down,
|
||||
left = ctl.areaNames.left, right = ctl.areaNames.right,
|
||||
} or nil,
|
||||
}
|
||||
end
|
||||
out.pages[#out.pages + 1] = p
|
||||
@@ -576,78 +379,14 @@ local function loadImage(path)
|
||||
return img
|
||||
end
|
||||
|
||||
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
|
||||
local function loadImageFromBytes(bytes, name)
|
||||
if not bytes or bytes == "" then return nil end
|
||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||
if not (love.filesystem and love.filesystem.newFileData) then return nil end
|
||||
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
|
||||
local cached = imageCache[key]
|
||||
if cached then return cached end
|
||||
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
|
||||
if not okFd or not fd then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, fd)
|
||||
if not ok or not img then
|
||||
if love.image and love.image.newImageData then
|
||||
local okData, data = pcall(love.image.newImageData, fd)
|
||||
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
|
||||
end
|
||||
end
|
||||
if not ok or not img then return nil end
|
||||
if img.setFilter then img:setFilter("linear", "linear") end
|
||||
imageCache[key] = img
|
||||
return img
|
||||
end
|
||||
|
||||
local function rasterizePdfPage(page, root)
|
||||
if not page or page.image or not page.pdfPath then return end
|
||||
local pdf = readFile(joinPath(root, page.pdfPath))
|
||||
local raster = require("src.core.PdfImage").extract(pdf)
|
||||
if not raster then return end
|
||||
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
|
||||
page.rasterData = raster.data
|
||||
page.rasterName = name:match("([^/]+)$") or name
|
||||
page.image = loadImageFromBytes(raster.data, page.rasterName)
|
||||
end
|
||||
|
||||
local function pixelScalePending(page)
|
||||
if page.pixelCoords then return true end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
if ctl.pixelCoords then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function applyPixelScale(page)
|
||||
if not pixelScalePending(page) then return true end
|
||||
if not page.image or not page.image.getDimensions then return false end
|
||||
local iw, ih = page.image:getDimensions()
|
||||
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
local pixel = ctl.pixelCoords
|
||||
if pixel == nil then pixel = page.pixelCoords end
|
||||
if pixel then
|
||||
ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
|
||||
ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
|
||||
ctl.pixelCoords = false
|
||||
end
|
||||
end
|
||||
page.pixelCoords = false
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchSkin.load(root, id)
|
||||
local cfgPath, format, prefix = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||
local cfgPath, format = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
|
||||
local text = readFile(cfgPath)
|
||||
if not text then return nil, "unreadable " .. cfgPath end
|
||||
local skin, err
|
||||
if format == "native" then
|
||||
skin, err = TouchSkin.parseNative(text)
|
||||
elseif format == "delta" then
|
||||
local dir = cfgPath:match("^(.*)/[^/]+$") or root
|
||||
skin, err = require("src.core.DeltaSkin").parse(text,
|
||||
{ prefix = prefix or "", names = listDir(dir) })
|
||||
else
|
||||
skin, err = TouchSkin.parse(text)
|
||||
end
|
||||
@@ -662,12 +401,6 @@ function TouchSkin.load(root, id)
|
||||
for _, page in ipairs(skin.pages) do
|
||||
if page.imagePath then
|
||||
page.image = loadImage(joinPath(root, page.imagePath))
|
||||
elseif page.pdfPath then
|
||||
rasterizePdfPage(page, root)
|
||||
end
|
||||
if not applyPixelScale(page) then
|
||||
return nil, "could not read " .. tostring(page.imagePath)
|
||||
.. ", which " .. page.name .. " measures its coordinates against"
|
||||
end
|
||||
for _, ctl in ipairs(page.controls) do
|
||||
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
||||
@@ -685,30 +418,14 @@ local function mountZip(archive, point)
|
||||
return ok and mounted == true
|
||||
end
|
||||
|
||||
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||
TouchSkin.PDF_ONLY_MESSAGE =
|
||||
"This skin uses PDF artwork with no extractable image. "
|
||||
.. "Ask the author for a PNG version."
|
||||
|
||||
function TouchSkin.archiveId(name)
|
||||
name = tostring(name or "")
|
||||
local ext = name:match("%.([%w]+)$")
|
||||
if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
|
||||
local id = name:sub(1, #name - #ext - 1)
|
||||
if id == "" then return nil end
|
||||
return id, ext:lower()
|
||||
end
|
||||
|
||||
function TouchSkin.list()
|
||||
local out, seen = {}, {}
|
||||
local function scan(root, source)
|
||||
for _, name in ipairs(listDir(root)) do
|
||||
local archiveId = TouchSkin.archiveId(name)
|
||||
local id = archiveId or name
|
||||
if not seen[id] and name:sub(1, 1) ~= "_" then
|
||||
local id = name:gsub("%.zip$", "")
|
||||
if not seen[id] then
|
||||
local path = root .. "/" .. name
|
||||
if archiveId then
|
||||
if name:match("%.zip$") then
|
||||
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
||||
if mountZip(path, point) and findConfig(point) then
|
||||
seen[id] = true
|
||||
@@ -730,20 +447,17 @@ function TouchSkin.list()
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
|
||||
-- Drop a .zip into <save>/skins and report the id it will list under.
|
||||
function TouchSkin.installArchive(name, data)
|
||||
if not data or data == "" then return nil, "empty archive" end
|
||||
if not (love and love.filesystem and love.filesystem.write) then
|
||||
return nil, "no writable filesystem"
|
||||
end
|
||||
name = tostring(name or ""):match("([^/\\]+)$") or ""
|
||||
name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
|
||||
local legacy = name:match("%.([%w]+)$")
|
||||
if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
|
||||
return nil, "old GBA4iOS skin, not supported"
|
||||
end
|
||||
local id = TouchSkin.archiveId(name)
|
||||
if not id then return nil, "not a .zip or .deltaskin" end
|
||||
name = name:gsub("[^%w%._%-]", "_")
|
||||
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
|
||||
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
|
||||
if id == "" then return nil, "bad archive name" end
|
||||
|
||||
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
||||
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
||||
@@ -753,14 +467,9 @@ function TouchSkin.installArchive(name, data)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then
|
||||
love.filesystem.remove(dest)
|
||||
return nil, "no skin.lua, .cfg or info.json inside " .. name
|
||||
return nil, "no skin.lua or .cfg inside " .. name
|
||||
end
|
||||
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
|
||||
return id
|
||||
end
|
||||
|
||||
function TouchSkin.find(id)
|
||||
@@ -789,13 +498,29 @@ function TouchSkin.assetPaths(skin)
|
||||
return out
|
||||
end
|
||||
|
||||
local function writeArchive(entries, destPath)
|
||||
local blob = require("src.core.SkinZip").encode(entries)
|
||||
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
||||
if not absolute and love and love.filesystem and love.filesystem.createDirectory then
|
||||
local dir = destPath:match("^(.*)/[^/]+$")
|
||||
if dir then pcall(love.filesystem.createDirectory, dir) end
|
||||
function TouchSkin.export(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local SkinZip = require("src.core.SkinZip")
|
||||
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||
local missing = {}
|
||||
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
||||
local data = readFile(joinPath(skin.root, rel))
|
||||
if data then
|
||||
entries[#entries + 1] = { name = rel, data = data }
|
||||
else
|
||||
missing[#missing + 1] = rel
|
||||
end
|
||||
end
|
||||
if skin.configPath and skin.format == "retroarch" then
|
||||
local cfg = readFile(skin.configPath)
|
||||
if cfg then
|
||||
entries[#entries + 1] =
|
||||
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||
end
|
||||
end
|
||||
local blob = SkinZip.encode(entries)
|
||||
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
||||
if not absolute and love and love.filesystem and love.filesystem.write then
|
||||
local ok, err = love.filesystem.write(destPath, blob)
|
||||
if not ok then return nil, tostring(err) end
|
||||
@@ -805,167 +530,9 @@ local function writeArchive(entries, destPath)
|
||||
handle:write(blob)
|
||||
handle:close()
|
||||
end
|
||||
return destPath
|
||||
end
|
||||
|
||||
local function collectAssets(skin, rels)
|
||||
local entries, missing = {}, {}
|
||||
for _, rel in ipairs(rels) do
|
||||
local data = readFile(joinPath(skin.root, rel))
|
||||
if data then
|
||||
entries[#entries + 1] = { name = rel, data = data }
|
||||
else
|
||||
missing[#missing + 1] = rel
|
||||
end
|
||||
end
|
||||
return entries, missing
|
||||
end
|
||||
|
||||
function TouchSkin.export(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
if skin.configPath and skin.format == "retroarch" then
|
||||
local cfg = readFile(skin.configPath)
|
||||
if cfg then
|
||||
entries[#entries + 1] =
|
||||
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||
end
|
||||
end
|
||||
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing
|
||||
end
|
||||
|
||||
local function fmtNum(n)
|
||||
n = tonumber(n) or 0
|
||||
if n == math.floor(n) then return string.format("%d", n) end
|
||||
local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
|
||||
return s
|
||||
end
|
||||
|
||||
local function fmtRect(r)
|
||||
return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
|
||||
end
|
||||
|
||||
local function cfgSpec(spec)
|
||||
local parts = {}
|
||||
for raw in tostring(spec or ""):gmatch("[^|]+") do
|
||||
local name = trim(raw)
|
||||
local key = name:lower():match("^key:(.+)$")
|
||||
parts[#parts + 1] = key and ("retrok_" .. key) or name
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function TouchSkin.toRetroArchConfig(skin)
|
||||
local pages = (skin and skin.pages) or {}
|
||||
local out = { "overlays = " .. #pages }
|
||||
for i, page in ipairs(pages) do
|
||||
local p = "overlay" .. (i - 1)
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
|
||||
if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
|
||||
out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
|
||||
out[#out + 1] = p .. "_normalized = true"
|
||||
if num(page.rangeMod, 1) ~= 1 then
|
||||
out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
|
||||
end
|
||||
if num(page.alphaMod, 1) ~= 1 then
|
||||
out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
|
||||
end
|
||||
if page.aspectFromCfg and page.aspect and page.aspect > 0 then
|
||||
out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
|
||||
end
|
||||
local r = page.rect
|
||||
if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
|
||||
out[#out + 1] = p .. "_rect = " .. fmtRect(r)
|
||||
end
|
||||
if page.viewport then
|
||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||
end
|
||||
local controls = {}
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
|
||||
end
|
||||
out[#out + 1] = p .. "_descs = " .. #controls
|
||||
for j, ctl in ipairs(controls) do
|
||||
local d = p .. "_desc" .. (j - 1)
|
||||
local spec = ctl.areaKind and ctl.sector and ctl.areaKind
|
||||
or cfgSpec(ctl.spec)
|
||||
if spec == "" then spec = "nul" end
|
||||
out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
|
||||
fmtNum(ctl.x), fmtNum(ctl.y),
|
||||
ctl.shape == "radial" and "radial" or "rect",
|
||||
fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
|
||||
if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
|
||||
if ctl.pressedImagePath then
|
||||
out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
|
||||
end
|
||||
if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
|
||||
out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
|
||||
end
|
||||
if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
|
||||
out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
|
||||
end
|
||||
for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
|
||||
left = ctl.reachLeft, right = ctl.reachRight }) do
|
||||
if num(value, 1) ~= 1 then
|
||||
out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
|
||||
end
|
||||
end
|
||||
if ctl.movable then out[#out + 1] = d .. "_movable = true" end
|
||||
if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
|
||||
if ctl.nextTarget then
|
||||
out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
|
||||
end
|
||||
if ctl.areaKind and ctl.sector and ctl.areaNames then
|
||||
local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
|
||||
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||
local name = ctl.areaNames[side]
|
||||
if name and name ~= defaults[side] then
|
||||
out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return table.concat(out, "\n") .. "\n"
|
||||
end
|
||||
|
||||
function TouchSkin.exportRetroArch(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
|
||||
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing
|
||||
end
|
||||
|
||||
function TouchSkin.exportDelta(skin, opts)
|
||||
if not skin then return nil, "no skin" end
|
||||
opts = opts or {}
|
||||
local DeltaSkin = require("src.core.DeltaSkin")
|
||||
local info, assetRels, warnings = DeltaSkin.build(skin, opts)
|
||||
if not info then return nil, assetRels end
|
||||
local entries = {
|
||||
{ name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
|
||||
}
|
||||
local assets, missing = collectAssets(skin, assetRels)
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
local destPath = opts.path
|
||||
or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing, warnings
|
||||
end
|
||||
|
||||
TouchSkin.BINDS = {
|
||||
"nul",
|
||||
"up", "down", "left", "right",
|
||||
@@ -1030,7 +597,6 @@ function TouchSkin.clone(skin)
|
||||
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
|
||||
author = skin.author, notes = skin.notes, configPath = skin.configPath,
|
||||
source = skin.source, pages = {},
|
||||
warnings = skin.warnings and copyTable(skin.warnings) or nil,
|
||||
}
|
||||
for i, page in ipairs(skin.pages or {}) do
|
||||
local p = copyTable(page)
|
||||
@@ -1110,8 +676,7 @@ function TouchSkin.listImages(root)
|
||||
local function scan(dir, prefix)
|
||||
for _, name in ipairs(listDir(dir)) do
|
||||
local path = dir .. "/" .. name
|
||||
local lower = name:lower()
|
||||
if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
|
||||
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
|
||||
out[#out + 1] = prefix .. name
|
||||
elseif isDir(path) and prefix == "" then
|
||||
scan(path, name .. "/")
|
||||
@@ -1288,27 +853,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
and page.aspect and page.aspect > 0 and h > 0
|
||||
if fit then
|
||||
local displayAspect = w / h
|
||||
local anchor = page.anchor
|
||||
if displayAspect > page.aspect then
|
||||
bw = h * page.aspect
|
||||
local extra = w - bw
|
||||
if anchor == "right" then
|
||||
bx = ox + extra
|
||||
elseif anchor == "left" then
|
||||
bx = ox
|
||||
else
|
||||
bx = ox + extra * 0.5
|
||||
end
|
||||
bx = ox + (w - bw) * 0.5
|
||||
else
|
||||
bh = w / page.aspect
|
||||
local extra = h - bh
|
||||
if anchor == "bottom" then
|
||||
by = oy + extra
|
||||
elseif anchor == "top" then
|
||||
by = oy
|
||||
else
|
||||
by = oy + extra * 0.5
|
||||
end
|
||||
by = oy + (h - bh) * 0.5
|
||||
end
|
||||
end
|
||||
local r = page.rect
|
||||
@@ -1322,21 +872,17 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||
return cx, cy, halfW, halfH
|
||||
end
|
||||
|
||||
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
|
||||
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
|
||||
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||
local mod = held == false and 1 or ctl.rangeMod
|
||||
local left = halfW * ctl.reachLeft * mod
|
||||
local right = halfW * ctl.reachRight * mod
|
||||
local up = halfH * ctl.reachUp * mod
|
||||
local down = halfH * ctl.reachDown * mod
|
||||
local left = halfW * ctl.reachLeft * ctl.rangeMod
|
||||
local right = halfW * ctl.reachRight * ctl.rangeMod
|
||||
local up = halfH * ctl.reachUp * ctl.rangeMod
|
||||
local down = halfH * ctl.reachDown * ctl.rangeMod
|
||||
local dx = px - cx
|
||||
local dy = py - cy
|
||||
local rx = dx < 0 and left or right
|
||||
local ry = dy < 0 and up or down
|
||||
if rx <= 0 or ry <= 0 then return false end
|
||||
if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
|
||||
return false
|
||||
end
|
||||
if ctl.shape == "radial" then
|
||||
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
||||
end
|
||||
@@ -1365,55 +911,18 @@ end
|
||||
|
||||
function TouchSkin.hasViewport()
|
||||
local page = TouchSkin.page()
|
||||
if not page or not TouchSkin.drawable() then return false end
|
||||
return page.viewport ~= nil or page.screenFit == "remainder"
|
||||
end
|
||||
|
||||
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
|
||||
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
local right, bottom = ox + w, oy + h
|
||||
local cand = {
|
||||
{ ox, oy, w, by - oy },
|
||||
{ ox, by + bh, w, bottom - (by + bh) },
|
||||
{ ox, oy, bx - ox, h },
|
||||
{ bx + bw, oy, right - (bx + bw), h },
|
||||
}
|
||||
local best, bestArea
|
||||
for _, r in ipairs(cand) do
|
||||
if r[3] > 1 and r[4] > 1 then
|
||||
local area = r[3] * r[4]
|
||||
if not best or area > bestArea then
|
||||
best, bestArea = r, area
|
||||
end
|
||||
end
|
||||
end
|
||||
if not best then return nil end
|
||||
return best[1], best[2], best[3], best[4]
|
||||
end
|
||||
|
||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not page then return nil end
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
if page.viewport then
|
||||
local v = page.viewport
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
end
|
||||
if page.screenFit == "remainder" then
|
||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
if not x then return nil end
|
||||
return x, y, vw, vh, false, false
|
||||
end
|
||||
return nil
|
||||
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
||||
end
|
||||
|
||||
function TouchSkin.viewport(w, h, ox, oy)
|
||||
local page = TouchSkin.page()
|
||||
if not page or not TouchSkin.drawable() then return nil end
|
||||
return TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
||||
local v = page.viewport
|
||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
end
|
||||
|
||||
return TouchSkin
|
||||
|
||||
@@ -301,13 +301,6 @@ end
|
||||
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
|
||||
Save.OPTIONS_KEY = "gold"
|
||||
|
||||
local SHARED_KEYS = {
|
||||
touchControls = true, haptics = true,
|
||||
mods = true, modsByVersion = true, modsGen2 = true,
|
||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||
activeProfile = true,
|
||||
}
|
||||
|
||||
function Save.loadOptions(fs)
|
||||
local options = Save.defaultOptions()
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
@@ -315,21 +308,7 @@ function Save.loadOptions(fs)
|
||||
local loaded = SaveData.loadOptions(fs)
|
||||
local stored = loaded and loaded[Save.OPTIONS_KEY]
|
||||
if type(stored) == "table" then
|
||||
for key, value in pairs(stored) do
|
||||
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
|
||||
for key, value in pairs(stored) do options[key] = value end
|
||||
end
|
||||
return options
|
||||
end
|
||||
@@ -342,13 +321,7 @@ function Save.saveOptions(options, fs)
|
||||
if not ok then return false end
|
||||
local file = SaveData.loadOptions(fs) or {}
|
||||
local block = {}
|
||||
for key, value in pairs(options) do
|
||||
if SHARED_KEYS[key] then
|
||||
file[key] = value
|
||||
else
|
||||
block[key] = value
|
||||
end
|
||||
end
|
||||
for key, value in pairs(options) do block[key] = value end
|
||||
file[Save.OPTIONS_KEY] = block
|
||||
SaveData.saveOptions(file, fs)
|
||||
return true
|
||||
|
||||
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
|
||||
-- except experimental mods, which stay off until opted in.
|
||||
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
|
||||
local enabled = flag == true or (flag == nil and not m.experimental)
|
||||
if enabled and not SaveData.isSafeMode(opts) then
|
||||
if enabled then
|
||||
local chunk = fs.load(path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local okR, schema = pcall(chunk)
|
||||
@@ -521,23 +521,6 @@ local function modRows(opts, mod)
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
row.safeModeBlocked = true
|
||||
if row.step then
|
||||
local step = row.step
|
||||
row.step = function(dir)
|
||||
if SaveData.isSafeMode(opts) then return false end
|
||||
return step(dir)
|
||||
end
|
||||
end
|
||||
if row.setText then
|
||||
local setText = row.setText
|
||||
row.setText = function(text)
|
||||
if SaveData.isSafeMode(opts) then return false end
|
||||
return setText(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
@@ -136,9 +136,6 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- costs nothing on a current cache and is the difference between every
|
||||
-- trainer battle opening with a picture and opening with none.
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
|
||||
-- before the four ball tiles were extracted (#1502).
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
},
|
||||
}
|
||||
@@ -329,32 +326,6 @@ function RomImporter.isReady(version)
|
||||
return marker == markerFor(version) and allRequiredFilesExist(version)
|
||||
end
|
||||
|
||||
function RomImporter.syncAndroidShortcuts(activeVersion)
|
||||
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||
and love.system.updateShortcuts) then
|
||||
return false
|
||||
end
|
||||
|
||||
local allVersions = { "red", "blue", "yellow", "gold" }
|
||||
local ready = {}
|
||||
local seen = {}
|
||||
|
||||
if activeVersion and RomImporter.isReady(activeVersion) then
|
||||
table.insert(ready, activeVersion)
|
||||
seen[activeVersion] = true
|
||||
end
|
||||
|
||||
for _, v in ipairs(allVersions) do
|
||||
if not seen[v] and RomImporter.isReady(v) then
|
||||
table.insert(ready, v)
|
||||
seen[v] = true
|
||||
if #ready >= 4 then break end
|
||||
end
|
||||
end
|
||||
|
||||
return love.system.updateShortcuts(ready)
|
||||
end
|
||||
|
||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||
local function sha1(data)
|
||||
local digest = love.data.hash("sha1", data)
|
||||
@@ -1140,18 +1111,18 @@ local function chooseZip()
|
||||
end
|
||||
|
||||
local function chooseSkinZip()
|
||||
local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
|
||||
local prompt = shellSafe(Strings("Choose a skin .zip"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]])
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
|
||||
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||
"$t=Join-Path $env:TEMP $n;",
|
||||
@@ -1163,11 +1134,11 @@ local function chooseSkinZip()
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]])
|
||||
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
|
||||
:format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -1266,7 +1237,6 @@ end
|
||||
function RomImporter:_applyLastVersionTab()
|
||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||
if okLO and LO.pendingTab then return end
|
||||
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
|
||||
local okOpt, opts = pcall(function()
|
||||
return require("src.core.SaveData").loadOptions()
|
||||
end)
|
||||
@@ -1338,7 +1308,7 @@ function RomImporter.new(onComplete, opts)
|
||||
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
|
||||
tab = (function()
|
||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||
return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
|
||||
return (okLO and LO.pendingTab) or "red"
|
||||
end)(),
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
@@ -1365,8 +1335,7 @@ function RomImporter.new(onComplete, opts)
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
|
||||
requiredImportNotice = nil,
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1380,7 +1349,6 @@ function RomImporter.new(onComplete, opts)
|
||||
findLoaded = false, findSources = nil, findIndex = nil,
|
||||
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
|
||||
_findSearchFocus = false, _findThumbs = nil,
|
||||
skinUrl = "", _skinUrlFocus = false,
|
||||
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
|
||||
-- banner and footer -- used only while that column is taller than the window
|
||||
-- (see draw()). Clamped against content in draw, reset on a tab change.
|
||||
@@ -1424,7 +1392,6 @@ function RomImporter.new(onComplete, opts)
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
||||
end
|
||||
RomImporter.syncAndroidShortcuts()
|
||||
self:_applyLastVersionTab()
|
||||
self:_queueBaseRomScan()
|
||||
|
||||
@@ -1819,7 +1786,6 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
RomImporter.syncAndroidShortcuts(version)
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
@@ -1885,14 +1851,10 @@ end
|
||||
function RomImporter:filedropped(file)
|
||||
if self.workState == "working" then return end
|
||||
-- A dropped .zip is a mod archive: hand it straight to the mods installer
|
||||
-- (which mounts + validates it). A .deltaskin is only ever a skin, and
|
||||
-- everything else is treated as a ROM. The dropped file itself is passed
|
||||
-- through -- installZip opens it the same way readDroppedFile does here.
|
||||
-- (which mounts + validates it). Everything else is treated as a ROM. The
|
||||
-- dropped file itself is passed through -- installZip opens it the same way
|
||||
-- readDroppedFile does here.
|
||||
local name = file:getFilename() or ""
|
||||
if name:lower():match("%.deltaskin$") then
|
||||
self:_installSkinZip(file)
|
||||
return
|
||||
end
|
||||
if name:lower():match("%.zip$") then
|
||||
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
||||
if self.tab == "skins" then
|
||||
@@ -2519,8 +2481,6 @@ function RomImporter:update(dt)
|
||||
self:_pumpModInfoFetch()
|
||||
self:_pumpFindStats()
|
||||
self:_pumpFindThumbs()
|
||||
self:_pumpSkinFetch()
|
||||
self:_pumpSync(dt)
|
||||
self:_pumpModCheck()
|
||||
self:_pumpModInstall()
|
||||
self:_pumpExtract()
|
||||
@@ -2757,7 +2717,7 @@ function RomImporter:resumeAfterOverlay()
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
@@ -3122,8 +3082,6 @@ end
|
||||
function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self._skinUrlFocus = false
|
||||
self._modScrollMax, self._modListRect = 0, nil
|
||||
self:_disarmTextInput()
|
||||
-- the skins list is cheap and can change behind the launcher's back
|
||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||
@@ -3149,11 +3107,9 @@ function RomImporter:_ensureSkins(force)
|
||||
out[#out + 1] = {
|
||||
id = entry.id,
|
||||
source = entry.source,
|
||||
format = skin and skin.format or nil,
|
||||
pages = skin and #skin.pages or 0,
|
||||
controls = controls,
|
||||
screen = page ~= nil
|
||||
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
||||
screen = page ~= nil and page.viewport ~= nil,
|
||||
ok = skin ~= nil,
|
||||
}
|
||||
end
|
||||
@@ -3184,6 +3140,7 @@ end
|
||||
function RomImporter:_installSkinZip(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "skins"
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local name, data, readError
|
||||
if type(source) == "string" then
|
||||
name = source
|
||||
@@ -3203,368 +3160,13 @@ function RomImporter:_installSkinZip(source)
|
||||
.. tostring(readError or name) }
|
||||
return
|
||||
end
|
||||
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)
|
||||
local id, err = TouchSkin.installArchive(name, data)
|
||||
self:_ensureSkins(true)
|
||||
if not id then
|
||||
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
|
||||
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
|
||||
return
|
||||
end
|
||||
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)
|
||||
self._skinNotice = { ok = true, text = "Imported " .. id }
|
||||
end
|
||||
|
||||
function RomImporter:_skinsImportButtonLabel()
|
||||
@@ -3631,7 +3233,6 @@ function RomImporter:_openSettings()
|
||||
-- The tab rides along: the editor persists the layout into that game's own
|
||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||
local hooks = {}
|
||||
local version = self.tab
|
||||
if self.onEditTouchControls then
|
||||
local version = self.tab
|
||||
hooks.editTouchControls = function()
|
||||
@@ -3649,12 +3250,11 @@ function RomImporter:_openSettings()
|
||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||
-- different option block entirely, and offering it Gen 1's rows meant a
|
||||
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
||||
local version = self.tab
|
||||
local ok, model = pcall(function()
|
||||
return require("src.import.LauncherSettings").open(hooks, version)
|
||||
end)
|
||||
if ok and model then
|
||||
self._settings = model
|
||||
end
|
||||
if ok and model then self._settings = model end
|
||||
end
|
||||
|
||||
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
||||
@@ -3665,54 +3265,10 @@ function RomImporter:_quitApp()
|
||||
end
|
||||
|
||||
function RomImporter:_closeSettings()
|
||||
local model = self._settings
|
||||
if model then
|
||||
model.save()
|
||||
end
|
||||
if self._settings then self._settings.save() end
|
||||
self._settings = nil
|
||||
end
|
||||
|
||||
function RomImporter:_safeModeEnabled()
|
||||
if self.safeMode == nil then
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
end
|
||||
return self.safeMode == true
|
||||
end
|
||||
|
||||
function RomImporter:_toggleSafeMode()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local options = SaveData.loadOptions()
|
||||
local enabled = not SaveData.isSafeMode(options)
|
||||
SaveData.setSafeMode(options, enabled)
|
||||
SaveData.saveOptions(options)
|
||||
self.safeMode = enabled
|
||||
self.mods = nil
|
||||
self._modSortCache = nil
|
||||
self._modInfoFetch = nil
|
||||
self.modNotice = nil
|
||||
end
|
||||
|
||||
function RomImporter:_reportIssue(options, version)
|
||||
self.issueNotice = nil
|
||||
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
||||
if not ok then
|
||||
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||
return false
|
||||
end
|
||||
local opened, url, reason = IssueReport.open(options, {
|
||||
version = version,
|
||||
mods = self.mods,
|
||||
})
|
||||
if not opened then
|
||||
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||
return false
|
||||
end
|
||||
self._lastIssueReportURL = url
|
||||
if reason then self.issueNotice = { ok = true, text = reason } end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_commitSettingsText()
|
||||
local st = self._settingsText
|
||||
self._settingsText = nil
|
||||
@@ -3782,27 +3338,6 @@ function RomImporter:keypressed(key)
|
||||
if key == "escape" then self:_closeSettings() end
|
||||
return
|
||||
end
|
||||
if self._syncModal then
|
||||
local field = self._syncFocus
|
||||
if field then
|
||||
local mo = self._syncModal
|
||||
if key == "backspace" then
|
||||
mo[field] = tostring(mo[field] or ""):sub(1, -2)
|
||||
elseif key == "return" or key == "kpenter" or key == "escape" then
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v"
|
||||
and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||
self:_syncPaste()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
|
||||
return
|
||||
end
|
||||
if key == "escape" then self:_closeSync() end
|
||||
return
|
||||
end
|
||||
if self._rename then
|
||||
if key == "backspace" then
|
||||
self._rename.text = utf8Back(self._rename.text)
|
||||
@@ -3852,21 +3387,6 @@ function RomImporter:keypressed(key)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._skinUrlFocus then
|
||||
if key == "backspace" then
|
||||
self.skinUrl = utf8Back(self.skinUrl or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
self._skinUrlFocus = false
|
||||
self:_disarmTextInput()
|
||||
self:_addSkinFromUrl()
|
||||
elseif key == "escape" then
|
||||
self._skinUrlFocus = false
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||
self:_pasteSkinUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._findSearchFocus then
|
||||
if key == "backspace" then
|
||||
self.findQuery = utf8Back(self.findQuery or "")
|
||||
@@ -3974,10 +3494,6 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._syncModal and self._syncFocus then
|
||||
self:_syncTypeInto(self._syncFocus, text)
|
||||
return
|
||||
end
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
@@ -3998,11 +3514,6 @@ function RomImporter:textinput(text)
|
||||
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
||||
return
|
||||
end
|
||||
if self._skinUrlFocus then
|
||||
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
|
||||
MAX_SKIN_URL)
|
||||
return
|
||||
end
|
||||
if self._findSearchFocus then
|
||||
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
|
||||
self.findScroll = 0
|
||||
@@ -4050,8 +3561,6 @@ end
|
||||
-- so a still list costs nothing after the first paint.
|
||||
function RomImporter:_refreshMods()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||
-- unzipped beside the executable, which an ordinary (non-portable) install
|
||||
-- has no way to read. It happens here rather than behind a button because
|
||||
@@ -4191,10 +3700,6 @@ end
|
||||
-- so that game's checkbox and status chips reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local cur, experimental = false, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
@@ -4235,10 +3740,6 @@ end
|
||||
-- must not be the way around it. Disabling needs no confirm -- it is the
|
||||
-- recovery action, and Delete is the only destructive one on this panel.
|
||||
function RomImporter:_setAllMods(want, confirmed)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ids, experimental = {}, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
|
||||
@@ -291,7 +291,6 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||
local safeMode = SaveData.isSafeMode(options)
|
||||
|
||||
-- the override is one answer per game (SaveData.modForced), the same scope
|
||||
-- the loader resolves it under
|
||||
@@ -305,24 +304,17 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads for the selected game.
|
||||
if not safeMode then
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
end
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, m in ipairs(ordered) do
|
||||
local enabled = not safeMode and enabledSet[m.id] == true
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local forced = forcedFor(m.id)
|
||||
local status, detail
|
||||
if safeMode then
|
||||
status, detail = "safe_mode", "Disabled by safe mode"
|
||||
else
|
||||
status, detail =
|
||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||
end
|
||||
local status, detail =
|
||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||
-- nil, not false, when the panel is showing every game at once
|
||||
local here = nil
|
||||
if version then here = ModTargets.runsHere(m, version, nil, forced) end
|
||||
@@ -340,8 +332,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
local answers = {}
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local answer = SaveData.modEnabled(options, m.id, game)
|
||||
answers[game] = not safeMode
|
||||
and (answer == true or (answer == nil and not m.experimental))
|
||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
||||
end
|
||||
return answers
|
||||
end)(),
|
||||
@@ -355,7 +346,6 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
targetsHere = here,
|
||||
safeMode = safeMode,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -596,7 +586,6 @@ end
|
||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
@@ -610,7 +599,6 @@ end
|
||||
-- and leaves a half-applied state behind if one of them fails.
|
||||
function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
if scope then
|
||||
@@ -1196,7 +1184,6 @@ end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
|
||||
@@ -259,7 +259,6 @@ function Loader.new(opts)
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
safeMode = false,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
-- active version is set once in main.lua's bootGame before anything
|
||||
-- builds a loader, and a run never changes generation underneath one.
|
||||
@@ -301,8 +300,6 @@ end
|
||||
function Loader:_loadState()
|
||||
self.disabled = {}
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
self.safeMode = SaveData.isSafeMode(options)
|
||||
Runtime.safeMode = self.safeMode
|
||||
local scope = self:_enableScope()
|
||||
local ids = {}
|
||||
for id in pairs(options.mods or {}) do ids[id] = true end
|
||||
@@ -418,7 +415,6 @@ function Loader:_writeOptionSchemas()
|
||||
end
|
||||
|
||||
function Loader:setEnabled(id, enabled)
|
||||
if self.safeMode then return false end
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
self.mods[id].enabled = enabled
|
||||
@@ -431,7 +427,6 @@ end
|
||||
-- choice could not be persisted for a game, so the caller does not promise a
|
||||
-- restart will honour it.
|
||||
function Loader:setGen2Forced(id, forced)
|
||||
if self.safeMode then return false, false end
|
||||
if not self.mods[id] then return false, false end
|
||||
self.gen2Forced[id] = forced or nil
|
||||
self:_saveState()
|
||||
@@ -1544,9 +1539,6 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
if self.safeMode then
|
||||
for id in pairs(self.mods) do self.disabled[id] = true end
|
||||
end
|
||||
-- Existing installs stored one shared answer. Once their manifests are
|
||||
-- known, split that answer across every game before the next launcher/game
|
||||
-- toggle can change one independently. _loadState already used the same
|
||||
@@ -1582,7 +1574,7 @@ function Loader:load(data)
|
||||
-- the one build where its env var is set.
|
||||
for id, mod in pairs(self.mods) do
|
||||
local envName = mod.manifest.force_enable_env
|
||||
if not self.safeMode and envName and os.getenv(envName) == "1" then
|
||||
if envName and os.getenv(envName) == "1" then
|
||||
self.disabled[id] = nil
|
||||
end
|
||||
end
|
||||
@@ -1748,7 +1740,6 @@ function Loader:status()
|
||||
local manifest = {}
|
||||
for key, value in pairs(mod.manifest) do manifest[key] = value end
|
||||
manifest.enabled = mod.enabled ~= false
|
||||
manifest.safeMode = self.safeMode == true
|
||||
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
|
||||
manifest.error = mod.failure
|
||||
-- set instead of `error` when the mod was left out for a reason that is
|
||||
|
||||
@@ -365,13 +365,9 @@ end
|
||||
|
||||
function ManagerState:detailRows(m)
|
||||
local rows = {}
|
||||
if Runtime.safeMode then
|
||||
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
|
||||
else
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
end
|
||||
if not Runtime.safeMode and self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
if self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||
action = function() self:openOptions(m) end }
|
||||
end
|
||||
@@ -387,8 +383,7 @@ function ManagerState:detailRows(m)
|
||||
-- what this mod does.
|
||||
local loader = self.game.mods
|
||||
local version, gen = self:targetGame()
|
||||
if loader and loader.setGen2Forced and not Runtime.safeMode
|
||||
and not ModTargets.supports(m, version, gen) then
|
||||
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
|
||||
rows[#rows + 1] = {
|
||||
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
||||
action = function() self:toggleGen2Force(m) end }
|
||||
@@ -679,10 +674,6 @@ end
|
||||
-- ------- the enable/disable flow
|
||||
|
||||
function ManagerState:beginToggle(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
if not m then return end
|
||||
local want = not m.enabled
|
||||
local loader = self.game.mods
|
||||
@@ -720,10 +711,6 @@ end
|
||||
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
|
||||
-- memory only, which the notice says rather than promising a restart.
|
||||
function ManagerState:toggleGen2Force(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
if not (loader and loader.setGen2Forced) then return end
|
||||
local want = not m.gen2Forced
|
||||
@@ -756,10 +743,6 @@ function ManagerState:enableScope()
|
||||
end
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
@@ -774,10 +757,6 @@ function ManagerState:commitToggle(apply)
|
||||
end
|
||||
|
||||
function ManagerState:discardChanges()
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
@@ -823,10 +802,6 @@ function ManagerState:persistOptions()
|
||||
end
|
||||
|
||||
function ManagerState:applyProfile(p)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local mods = self:manifestMap()
|
||||
local set = self:enabledSet()
|
||||
local combined = {}
|
||||
@@ -988,10 +963,6 @@ function ManagerState:optionValue(modId, row)
|
||||
end
|
||||
|
||||
function ManagerState:setOption(modId, key, value)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return false
|
||||
end
|
||||
local save = self.game.save
|
||||
if save and save.options then
|
||||
save.options.modOptions = save.options.modOptions or {}
|
||||
@@ -1152,10 +1123,6 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end
|
||||
|
||||
function ManagerState:openOptions(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local schema = self:schemaFor(m)
|
||||
if not schema then
|
||||
self:notify("NO OPTIONS")
|
||||
|
||||
@@ -33,21 +33,11 @@ Runtime.currentMod = nil
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
Runtime.safeMode = false
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
end
|
||||
|
||||
function Runtime.reset()
|
||||
Runtime.events = NullEvents
|
||||
Runtime.hooks = NullHooks
|
||||
Runtime.errors = nil
|
||||
Runtime.currentMod = nil
|
||||
Runtime.modRequire = nil
|
||||
end
|
||||
|
||||
-- attribute a runtime failure to the mod that owns the offending record.
|
||||
-- "base" is the engine's own owner id: a vanilla record that fails is a
|
||||
-- console line, not something the manager can ask the player to disable.
|
||||
|
||||
@@ -86,7 +86,6 @@ local function drain()
|
||||
else
|
||||
j.status = msg.ok and "ok" or "error"
|
||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||
j.code = msg.code
|
||||
j.progress = msg.ok and 1 or j.progress
|
||||
end
|
||||
end
|
||||
@@ -144,14 +143,6 @@ function Fetch.post(url, body, opts)
|
||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
function Fetch.request(url, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "request", url = url,
|
||||
method = opts.method, body = opts.body, headers = opts.headers,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
|
||||
@@ -113,22 +113,6 @@ local function doPost(job)
|
||||
post({ id = job.id, ok = true, done = true })
|
||||
end
|
||||
|
||||
local function doRequest(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
return
|
||||
end
|
||||
local body, err, code = HostShell.httpRequest(job.url, {
|
||||
method = job.method, body = job.body, headers = job.headers,
|
||||
userAgent = job.userAgent,
|
||||
maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS })
|
||||
if not code then
|
||||
post({ id = job.id, ok = false, err = err or "request failed" })
|
||||
return
|
||||
end
|
||||
post({ id = job.id, ok = true, body = body or "", code = code, done = true })
|
||||
end
|
||||
|
||||
while true do
|
||||
local job = cmdCh:demand()
|
||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||
@@ -147,9 +131,6 @@ while true do
|
||||
elseif job.kind == "post" then
|
||||
local ok, err = pcall(doPost, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "request" then
|
||||
local ok, err = pcall(doRequest, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "download" then
|
||||
local ok, err = pcall(doDownload, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
|
||||