mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8e137b42 |
@@ -49,6 +49,7 @@ function love.conf(t)
|
||||
t.modules.joystick = true
|
||||
t.modules.physics = false
|
||||
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
local osName = love._os
|
||||
@@ -92,4 +93,28 @@ function love.conf(t)
|
||||
else
|
||||
t.window.resizable = true
|
||||
end
|
||||
|
||||
-- Consoles, last: LOVE Potion (3DS / Switch / Wii U) publishes love._console,
|
||||
-- set when the love module initializes and so available here exactly like
|
||||
-- love._os above. This runs after the branch above because that branch's
|
||||
-- desktop `else` would otherwise re-enable resizing underneath it.
|
||||
if love._console then
|
||||
-- LOVE Potion implements the 12.0 API, so the 11.5 declared above trips its
|
||||
-- version-mismatch notice. Ask the running engine for its own version
|
||||
-- string rather than hardcoding a second number that would need keeping in
|
||||
-- sync with whichever LOVE Potion release the player installed.
|
||||
t.version = love._version or t.version
|
||||
-- It builds no love.mouse module at all (its source/modules ships touch,
|
||||
-- joystick and keyboard, and nothing that points), so do not ask for one.
|
||||
t.modules.mouse = false
|
||||
-- Consoles own their resolution: the 3DS screens are fixed at 400x240 top
|
||||
-- and 320x240 bottom (800x240 in wide mode), while the Switch and Wii U
|
||||
-- backends set their size at runtime from the dock / TV state. The desktop
|
||||
-- sizing above is not merely ignored there but actively wrong -- a 480x360
|
||||
-- minimum is larger than the whole 3DS screen -- so drop the fields that
|
||||
-- only ever described a resizable desktop window.
|
||||
t.window.minwidth = nil
|
||||
t.window.minheight = nil
|
||||
t.window.resizable = false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,38 @@
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
-- Crash log, for the console ports.
|
||||
--
|
||||
-- LÖVE's error screen assumes someone can read it. On the Nintendo builds
|
||||
-- nobody can: the Wii U reports only "terminated by calling coreinit.exit(1)"
|
||||
-- to the host, the 3DS closes back to the menu, and neither surfaces the Lua
|
||||
-- message or traceback anywhere. That turns any boot-time error into a silent
|
||||
-- exit, which is exactly the failure this port kept hitting.
|
||||
--
|
||||
-- So persist it. The save directory is a real folder on the SD card (and on
|
||||
-- the emulator's mlc), so the file survives the exit and can be read back
|
||||
-- afterwards. Desktop is unchanged: this only prepends the write, then hands
|
||||
-- off to LÖVE's normal handler and its error screen.
|
||||
do
|
||||
local previous = love.errorhandler or love.errhand
|
||||
local function handler(msg)
|
||||
pcall(function()
|
||||
local body = table.concat({
|
||||
"gen1recomp crash",
|
||||
"console: " .. tostring(love._console),
|
||||
"os: " .. tostring(love._os),
|
||||
"version: " .. tostring(love._version),
|
||||
"",
|
||||
debug.traceback(tostring(msg), 2),
|
||||
}, "\n")
|
||||
love.filesystem.write("crash.txt", body)
|
||||
end)
|
||||
if previous then return previous(msg) end
|
||||
end
|
||||
love.errorhandler = handler
|
||||
love.errhand = handler -- 11.x name, still read by some builds
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
|
||||
+201
-5
@@ -4,9 +4,12 @@
|
||||
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
|
||||
# binaries, fusing our game.love onto them the same way love.exe does).
|
||||
#
|
||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all|3ds|switch|wiiu|console]
|
||||
# [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||
# [--notary-profile NAME] [--no-notarize]
|
||||
# [--release] # ios only: release config instead of debug
|
||||
# [--unpackaged] # console only: emit loose game files
|
||||
# # instead of fusing into the binary
|
||||
#
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
# dist/win/gen1recomp-win64.zip
|
||||
@@ -15,6 +18,8 @@
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
||||
# under mobile/ios/build/Build/Products/)
|
||||
# dist/console/sdcard/ (LÖVE Potion game folder, runs as-is)
|
||||
# dist/console/gen1recomp-<ver>-console-bundle.zip (for lovebrew.org)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -35,6 +40,21 @@ TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
IOS_RELEASE=false
|
||||
# Console bundles ask for a single executable by default. With packaged=false
|
||||
# the bundler has nothing to build for a non-3DS target -- no executable, and no
|
||||
# asset conversion outside the 3DS -- so it hands back the source unchanged,
|
||||
# which is what dist/console/sdcard already is.
|
||||
CONSOLE_PACKAGED=true
|
||||
|
||||
# Console targets requested, in bundler names (ctr / hac / cafe), deduped and
|
||||
# kept in the order the user asked for them.
|
||||
CONSOLE_LIST=""
|
||||
add_console() {
|
||||
case " $CONSOLE_LIST " in
|
||||
*" $1 "*) ;; # already requested
|
||||
*) CONSOLE_LIST="${CONSOLE_LIST:+$CONSOLE_LIST }$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -43,11 +63,21 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||
# Console targets accumulate instead of overwriting: one bundle can build
|
||||
# for several consoles at once, and each build_console run clears
|
||||
# dist/console, so `build.sh 3ds wiiu` as two separate runs would leave
|
||||
# only the Wii U output behind.
|
||||
3ds) TARGET="console"; add_console ctr ;;
|
||||
switch) TARGET="console"; add_console hac ;;
|
||||
wiiu) TARGET="console"; add_console cafe ;;
|
||||
console) TARGET="console"; add_console ctr; add_console hac; add_console cafe ;;
|
||||
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
|
||||
--identity) IDENTITY="$2"; shift ;;
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--packaged) CONSOLE_PACKAGED=true ;;
|
||||
--unpackaged) CONSOLE_PACKAGED=false ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -68,8 +98,17 @@ rm -f "$LOVE_FILE"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
# List the archive once into a file, and grep that file rather than a pipe.
|
||||
# `unzip -Z1 ... | grep -q ...` looks harmless but is a race under the
|
||||
# `set -o pipefail` above: grep -q exits the instant it matches, unzip takes
|
||||
# SIGPIPE on the next write, and pipefail then reports the whole pipeline as
|
||||
# failed even though the match succeeded. Every check below matches something
|
||||
# in the last 20 of ~300 entries, so unzip is virtually always still writing --
|
||||
# which turned every one of these guards into an unconditional "missing file"
|
||||
# abort, on every target.
|
||||
LOVE_LIST="$WORK/game.love.list"
|
||||
unzip -Z1 "$LOVE_FILE" > "$LOVE_LIST"
|
||||
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LIST"; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
# The editor is only reachable if its entry point and both module directories
|
||||
@@ -81,7 +120,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
grep -qx "$required" "$LOVE_LIST" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
@@ -103,7 +142,10 @@ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||
# Captured, not piped into grep -q, for the same SIGPIPE-under-pipefail
|
||||
# reason as the listing checks above.
|
||||
stamped="$(unzip -p "$LOVE_FILE" src/core/Version.lua)"
|
||||
printf '%s' "$stamped" \
|
||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||
say "stamped engine version: $VERSION"
|
||||
@@ -291,6 +333,139 @@ build_linux() {
|
||||
say "Linux build: $zip_out"
|
||||
}
|
||||
|
||||
# ------------------------------------------------- Nintendo (LÖVE Potion)
|
||||
# 3DS / Switch / Wii U, via LÖVE Potion (https://lovebrew.org).
|
||||
#
|
||||
# Nothing is compiled here, and nothing can be: LÖVE Potion ships prebuilt
|
||||
# console binaries, and turning a game into a .3dsx/.nro/.wuhb is done by the
|
||||
# lovebrew bundler, which is a hosted service (it shells out to devkitPro tools
|
||||
# server-side). There is no working local CLI for it. The old `lovebrew`
|
||||
# client still on the releases page posts to www.bundle.lovebrew.org/data:
|
||||
# that hostname no longer resolves, and the /data route is gone from the host
|
||||
# that does (404 on GET, 405 on POST), so the client cannot be repaired by
|
||||
# repointing it. So this target produces the two things that do not depend on
|
||||
# any service being up:
|
||||
#
|
||||
# 1. an SD-card tree, which needs no bundling at all. With `packaged = false`
|
||||
# LÖVE Potion runs a plain `game/` folder sitting next to its own binary,
|
||||
# so this is the route that actually boots today, and the one where a
|
||||
# player can drop their .gb right next to main.lua for the auto-import.
|
||||
# 2. a bundle zip in the layout the bundler expects, for whenever the service
|
||||
# is reachable, so producing the single-file executables is a drag and
|
||||
# drop rather than a re-derivation of this layout by hand.
|
||||
#
|
||||
# The game payload is unpacked from game.love rather than re-copied from the
|
||||
# source tree, so the console files are byte-identical to every other platform's
|
||||
# -- including the version stamp above, which a fresh copy would miss.
|
||||
build_console() {
|
||||
local targets="$1" # toml array body, e.g. '"ctr", "hac"'
|
||||
local label="$2" # human name for the log line
|
||||
say "building for Nintendo consoles ($label) via LÖVE Potion"
|
||||
|
||||
local out="$DIST/console"
|
||||
local stage="$WORK/console"
|
||||
rm -rf "$stage" "$out"
|
||||
mkdir -p "$stage/game" "$out"
|
||||
|
||||
# 1. game payload, straight out of the archive the desktop builds ship
|
||||
unzip -q "$LOVE_FILE" -d "$stage/game"
|
||||
[ -f "$stage/game/main.lua" ] \
|
||||
|| fail "console payload has no main.lua at its root"
|
||||
|
||||
# 2. icons, in each console's required size and container
|
||||
say "generating console icons"
|
||||
python3 "$ROOT/tools/make_console_icons.py" --out "$stage/icons" \
|
||||
|| fail "icon generation failed (needs Pillow: python3 -m pip install Pillow)"
|
||||
for required in icon-ctr.png icon-hac.jpg icon-cafe.png; do
|
||||
[ -f "$stage/icons/$required" ] || fail "icon generation did not write $required"
|
||||
done
|
||||
|
||||
# 3. bundler config. Two files on purpose, and they are not redundant: the
|
||||
# published example and docs use lovebrew.toml with a per-target `icons`
|
||||
# table, while the current bundler source reads bundle.toml with a single
|
||||
# `icon`. The two schemas are mutually exclusive, the service picks one
|
||||
# filename and ignores the other, and writing both costs nothing while
|
||||
# removing a coin flip over which deployment is live.
|
||||
local semver="$VERSION"
|
||||
printf '%s' "$semver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || semver="0.1.0"
|
||||
|
||||
cat > "$stage/lovebrew.toml" <<TOML
|
||||
[metadata]
|
||||
title = "$APP_NAME"
|
||||
author = "bryanthaboi"
|
||||
description = "Pokemon Red/Blue recompilation"
|
||||
version = "$semver"
|
||||
icons = { ctr = "icons/icon-ctr.png", hac = "icons/icon-hac.jpg", cafe = "icons/icon-cafe.png" }
|
||||
|
||||
[build]
|
||||
targets = [$targets]
|
||||
source = "game"
|
||||
packaged = $CONSOLE_PACKAGED
|
||||
TOML
|
||||
|
||||
cat > "$stage/bundle.toml" <<TOML
|
||||
[metadata]
|
||||
title = "$APP_NAME"
|
||||
author = "bryanthaboi"
|
||||
description = "Pokemon Red/Blue recompilation"
|
||||
version = "$semver"
|
||||
icon = "icons/icon-ctr.png"
|
||||
|
||||
[build]
|
||||
targets = [$targets]
|
||||
source = "game"
|
||||
TOML
|
||||
|
||||
# 4. bundle zip, for the hosted bundler
|
||||
local bundle="$out/$APP_NAME-$VERSION-console-bundle.zip"
|
||||
(cd "$stage" && zip -q -9 -r "$bundle" lovebrew.toml bundle.toml game icons \
|
||||
-x '*.DS_Store')
|
||||
|
||||
# 5. SD-card tree. The LÖVE Potion binary is deliberately not vendored: it
|
||||
# is a 20-35MB per-console download with its own release cadence, and pinning
|
||||
# a stale copy inside our dist is how a player ends up debugging a runtime
|
||||
# mismatch that we caused.
|
||||
local sd="$out/sdcard"
|
||||
mkdir -p "$sd"
|
||||
cp -R "$stage/game" "$sd/game"
|
||||
cp -R "$stage/icons" "$sd/icons"
|
||||
cat > "$sd/README.txt" <<'TXT'
|
||||
Pokemon Gen 1 recompilation, Nintendo homebrew (3DS / Switch / Wii U)
|
||||
====================================================================
|
||||
|
||||
This folder is the game, not the runtime. It runs on LÖVE Potion, which is a
|
||||
separate download:
|
||||
|
||||
https://github.com/lovebrew/lovepotion/releases
|
||||
|
||||
1. Copy LÖVE Potion's binary for your console (.3dsx, .nro or .wuhb) onto the
|
||||
SD card, in the usual homebrew location.
|
||||
2. Copy this `game` folder in next to that binary.
|
||||
3. Copy your own Pokemon Red / Blue / Yellow ROM (.gb or .gbc) into the `game`
|
||||
folder, right beside main.lua.
|
||||
4. Launch it from the homebrew menu.
|
||||
|
||||
No ROM ships with this, and none can: the game builds everything it needs from
|
||||
your own cartridge dump on first launch. That import runs once, takes a while
|
||||
on a 3DS, and writes into the save directory afterwards.
|
||||
|
||||
The ROM is found by its SHA-1, so the filename does not matter, and Red, Blue
|
||||
and Yellow can all sit there together.
|
||||
|
||||
If instead you built a single fused executable with the bundler (packaged =
|
||||
true), there is no `game` folder to drop the ROM beside -- the source lives
|
||||
inside the binary and is read-only. Put the ROM in the game's save directory
|
||||
instead; the launcher prints the exact path when it cannot find one.
|
||||
TXT
|
||||
|
||||
say "console bundle: $bundle"
|
||||
say "console SD tree: $sd"
|
||||
warn "not built into .3dsx/.nro/.wuhb here: drag the bundle zip above into"
|
||||
warn "https://bundle.lovebrew.org, which returns the executables. (Ignore"
|
||||
warn "api.lovebrew.org -- that is the unreleased rewrite's endpoint, it 502s,"
|
||||
warn "and it is not what the live bundler uses.) The SD tree needs no bundler."
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Android
|
||||
build_android() {
|
||||
say "building Android (delegating to scripts/build_android.sh)"
|
||||
@@ -320,11 +495,32 @@ case "$TARGET" in
|
||||
linux) build_linux ;;
|
||||
android) build_android ;;
|
||||
ios) build_ios ;;
|
||||
# ctr / hac / cafe are the bundler's own names for the three consoles.
|
||||
console)
|
||||
# "ctr cafe" -> '"ctr", "cafe"' for the toml array, and "3DS, Wii U" for
|
||||
# the log line.
|
||||
console_toml=""
|
||||
console_label=""
|
||||
for c in $CONSOLE_LIST; do
|
||||
case "$c" in
|
||||
ctr) name="3DS" ;;
|
||||
hac) name="Switch" ;;
|
||||
cafe) name="Wii U" ;;
|
||||
esac
|
||||
console_toml="${console_toml:+$console_toml, }\"$c\""
|
||||
console_label="${console_label:+$console_label, }$name"
|
||||
done
|
||||
build_console "$console_toml" "$console_label"
|
||||
;;
|
||||
# `all` stays the desktop trio: the console output is not a finished
|
||||
# executable, so folding it in would make every desktop build print bundler
|
||||
# instructions nobody asked for.
|
||||
all) build_mac; build_win; build_linux ;;
|
||||
esac
|
||||
|
||||
case "$TARGET" in
|
||||
android) say "done. See $DIST/android/" ;;
|
||||
ios) say "done. See $DIST/ios/" ;;
|
||||
3ds|switch|wiiu|console) say "done. See $DIST/console/" ;;
|
||||
*) say "done. Artifacts in $DIST" ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Which Nintendo console this is running on, or nil everywhere else.
|
||||
--
|
||||
-- love.system.getOS() cannot answer this. The LOVE Potion build defines
|
||||
-- __OS__ as "Horizon" for BOTH the 3DS and the Switch, and "Cafe" for the
|
||||
-- Wii U (lovebrew/lovepotion CMakeLists.txt), so an OS test meaning "3DS"
|
||||
-- would equally catch a Switch -- two machines separated by roughly two orders
|
||||
-- of magnitude of CPU and an order of magnitude of pixels. Anything scaled to
|
||||
-- the hardware has to distinguish them.
|
||||
--
|
||||
-- The console name is published separately, as the love._console string
|
||||
-- ("3DS" / "Switch" / "Wii U", set in source/modules/love/love.cpp). It is
|
||||
-- absent on desktop LOVE, on Android and on iOS, which is what makes its mere
|
||||
-- presence the "is this a console" probe.
|
||||
--
|
||||
-- Nothing here is cached: the two table lookups are free next to a frame, and
|
||||
-- caching would freeze whatever the first caller saw, which tests override.
|
||||
|
||||
local Console = {}
|
||||
|
||||
-- The console names LOVE Potion publishes. An unrecognized string is treated
|
||||
-- as "not a console we know", not as a console -- a future port would need its
|
||||
-- own branches here anyway, and guessing would silently apply 3DS-shaped
|
||||
-- compromises to hardware that does not need them.
|
||||
local NAMES = { ["3DS"] = true, ["Switch"] = true, ["Wii U"] = true }
|
||||
|
||||
-- "3DS" / "Switch" / "Wii U", or nil.
|
||||
function Console.name()
|
||||
local name = love and rawget(love, "_console")
|
||||
if type(name) == "string" and NAMES[name] then return name end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Running on a LOVE Potion console at all. Falls back to the OS name so a
|
||||
-- build that stops publishing love._console still takes the console paths
|
||||
-- (both strings are unique to LOVE Potion), even though it cannot then say
|
||||
-- which console it is.
|
||||
function Console.isConsole()
|
||||
if Console.name() then return true end
|
||||
local os = love and love.system and love.system.getOS and love.system.getOS()
|
||||
return os == "Horizon" or os == "Cafe"
|
||||
end
|
||||
|
||||
-- The 3DS specifically: a 400x240 top screen on a 268MHz ARM11, which is the
|
||||
-- one target where effects the other two render for free have to be dropped.
|
||||
function Console.is3DS()
|
||||
return Console.name() == "3DS"
|
||||
end
|
||||
|
||||
return Console
|
||||
@@ -112,7 +112,11 @@ end
|
||||
local function loadImages()
|
||||
local img = {}
|
||||
for name, path in pairs(IMAGES) do
|
||||
local ok, im = pcall(love.graphics.newImage, path)
|
||||
-- Resolved, not raw: the 3DS build ships these as .t3x (the bundler
|
||||
-- converts and drops the .png). Without this the pcall below quietly
|
||||
-- fails and the console loses its on-screen controls entirely.
|
||||
local Assets = require("src.render.Assets")
|
||||
local ok, im = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
if not ok then return nil end
|
||||
im:setFilter("linear", "linear")
|
||||
img[name] = im
|
||||
|
||||
+207
-49
@@ -1,6 +1,10 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local Console = require("src.core.Console")
|
||||
-- for Assets.resolve only: the launcher's own art has to survive the 3DS
|
||||
-- bundler rewriting every shipped .png to .t3x
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -281,13 +285,42 @@ end
|
||||
-- moment and never the launcher. pump() only drains OS events into LOVE's
|
||||
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
|
||||
-- and the release is still delivered normally on the next frame.
|
||||
-- Pointer reads, for platforms that may have no pointer module at all.
|
||||
--
|
||||
-- LOVE Potion builds no love.mouse: its source/modules directory ships touch,
|
||||
-- joystick and keyboard, and nothing else that points. The launcher polls the
|
||||
-- pointer every frame (no move events reach it), so on a console every one of
|
||||
-- those reads would index a nil module. Off-screen coordinates are the honest
|
||||
-- answer for a machine with no cursor: they hover nothing, which is what the
|
||||
-- hit-testing below should conclude.
|
||||
-- Whether there is a cursor to poll and hover with: Android and iOS emulate a
|
||||
-- mouse from touch, and the LOVE Potion consoles have no love.mouse at all.
|
||||
--
|
||||
-- Derived on each read rather than cached in a field at construction, because
|
||||
-- importers are also built by hand (setmetatable, a couple of fields) in tests
|
||||
-- and would silently lose a derived flag while still setting `android` -- the
|
||||
-- inputs are what callers set, so the inputs are what this reads.
|
||||
local function pointerless(self)
|
||||
return self.android or self.console ~= nil
|
||||
end
|
||||
|
||||
local function mousePosition()
|
||||
if not (love.mouse and love.mouse.getPosition) then return -1, -1 end
|
||||
return love.mouse.getPosition()
|
||||
end
|
||||
|
||||
local function mouseDown(...)
|
||||
if not (love.mouse and love.mouse.isDown) then return false end
|
||||
return love.mouse.isDown(...)
|
||||
end
|
||||
|
||||
local function releasePointerGrab()
|
||||
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
|
||||
and love.timer) then
|
||||
return
|
||||
end
|
||||
local deadline = love.timer.getTime() + 1
|
||||
while love.mouse.isDown(1, 2, 3) do
|
||||
while mouseDown(1, 2, 3) do
|
||||
love.event.pump()
|
||||
if love.timer.getTime() > deadline then break end
|
||||
love.timer.sleep(0.005)
|
||||
@@ -544,6 +577,11 @@ function RomImporter.new(onComplete, opts)
|
||||
-- keeps its historical name so every Android call site stays untouched.
|
||||
local mobileOS = love.system.getOS()
|
||||
local android = mobileOS == "Android" or mobileOS == "iOS"
|
||||
-- The LOVE Potion consoles ("3DS" / "Switch" / "Wii U", or nil elsewhere).
|
||||
-- They are NOT folded into `android`: that flag also means SAF pickers and
|
||||
-- Files-app export, none of which exist here. What they share is having no
|
||||
-- pointer, which is what `pointerless` names.
|
||||
local console = Console.name()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local self = setmetatable({
|
||||
onComplete = onComplete,
|
||||
@@ -552,6 +590,7 @@ function RomImporter.new(onComplete, opts)
|
||||
onEditSave = opts.onEditSave,
|
||||
onEditTouchControls = opts.onEditTouchControls,
|
||||
android = android,
|
||||
console = console,
|
||||
ios = mobileOS == "iOS",
|
||||
-- One startup poll pass: files dropped through the Files app are swept
|
||||
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
||||
@@ -563,11 +602,17 @@ function RomImporter.new(onComplete, opts)
|
||||
-- love.touch IS pollable, so where it exists a touch drag can be resolved
|
||||
-- inside draw the same way the desktop mouse is. Where it does not, every
|
||||
-- Android path stays exactly as it was: act on press, never arm.
|
||||
touchPollable = android and love.touch ~= nil
|
||||
-- The consoles have a touchscreen too (the 3DS bottom screen, the Wii U
|
||||
-- gamepad, the Switch in handheld mode) and LOVE Potion does build a touch
|
||||
-- module, so they resolve a drag the same pollable way.
|
||||
touchPollable = (android or console ~= nil) and love.touch ~= nil
|
||||
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
|
||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
-- Through Assets.resolve: on the 3DS these two are .t3x inside the build,
|
||||
-- and these are the very first images the app loads, so an unresolved .png
|
||||
-- here is the whole launcher failing before it draws a single frame.
|
||||
logo = love.graphics.newImage(Assets.resolve("assets/logo/logo.png")),
|
||||
bcg = love.graphics.newImage(Assets.resolve("assets/logo/bcg.png")),
|
||||
ready = {}, returning = {}, romName = {},
|
||||
importing = nil, -- the version currently extracting, or nil
|
||||
workState = nil, -- "working" / "complete" / "error" for that import
|
||||
@@ -639,11 +684,17 @@ function RomImporter.new(onComplete, opts)
|
||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||
-- so a stale picked_rom.gb cannot block another version.
|
||||
--
|
||||
-- The consoles ride the same scan, and it is their ONLY way in: LOVE Potion
|
||||
-- has no file picker, no drag-and-drop and no shell to fall back on, so a
|
||||
-- 3DS/Switch/Wii U player copies the cart onto the SD card and launches. The
|
||||
-- scan reads the physfs root, which covers both the save directory and the
|
||||
-- game folder itself, so a ROM sitting next to main.lua is found too.
|
||||
local needRom = false
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[version] then needRom = true; break end
|
||||
end
|
||||
if android and needRom then
|
||||
if (android or self.console) and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
@@ -786,8 +837,9 @@ end
|
||||
-- Choose control. Once the importer is torn down that draw path stops
|
||||
-- running, so restore the arrow before handing off to boot (issue #114).
|
||||
local function resetPointerCursor(self)
|
||||
if self.android then return end
|
||||
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
|
||||
if pointerless(self) then return end
|
||||
if not (love.mouse and love.mouse.isCursorSupported
|
||||
and love.mouse.isCursorSupported()) then
|
||||
return
|
||||
end
|
||||
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
|
||||
@@ -1126,6 +1178,30 @@ end
|
||||
function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.console then
|
||||
-- LOVE Potion has no picker of any kind (no love.system.pickFile, no
|
||||
-- drag-and-drop, no shell to fall back on), so the only way in is a file
|
||||
-- already on the SD card. Rescan, and failing that say exactly where the
|
||||
-- cart has to go rather than opening a dialog that cannot exist.
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
return
|
||||
end
|
||||
-- Report the SAVE directory, not the source. Both are on the scan's read
|
||||
-- path, but the source is inside the executable for a fused (packaged)
|
||||
-- build, so naming it would send the player somewhere they cannot write.
|
||||
-- The save directory is a real SD folder in either layout.
|
||||
self.notice = {
|
||||
version = self.chooseVersion,
|
||||
status = "Copy your .gb/.gbc onto the SD card, into:",
|
||||
detail = (love.filesystem.getSaveDirectory
|
||||
and love.filesystem.getSaveDirectory())
|
||||
or (love.filesystem.getSource and love.filesystem.getSource())
|
||||
or "the game folder",
|
||||
}
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
@@ -1272,7 +1348,7 @@ end
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
local mx, my = mousePosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
@@ -1467,23 +1543,54 @@ end
|
||||
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
||||
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
||||
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
||||
local gradMesh
|
||||
local gradMesh -- false once resolved unavailable, so the attempt is made once
|
||||
local function setGrad(cTop, cBot, aTop, aBot)
|
||||
if not gradMesh then gradMesh = love.graphics.newMesh(4, "fan", "dynamic") end
|
||||
if gradMesh == nil then
|
||||
local ok, mesh = pcall(love.graphics.newMesh, 4, "fan", "dynamic")
|
||||
gradMesh = ok and mesh or false
|
||||
end
|
||||
if not gradMesh then return false end
|
||||
gradMesh:setVertices({
|
||||
{ 0, 0, 0, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
||||
{ 1, 0, 1, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
||||
{ 1, 1, 1, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
||||
{ 0, 1, 0, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
||||
})
|
||||
return true
|
||||
end
|
||||
local function fillGrad(x, y, w, h, cTop, cBot, aTop, aBot)
|
||||
setGrad(cTop, cBot, aTop, aBot)
|
||||
if not setGrad(cTop, cBot, aTop, aBot) then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(gradMesh, x, y, 0, w, h)
|
||||
end
|
||||
-- Whether the rounded-rect clip below can be drawn at all. love.graphics.stencil
|
||||
-- is an 11.x entry point that LOVE 12 replaced, and the LOVE Potion console
|
||||
-- backends (3DS/Switch/Wii U) expose no stencil path either, so resolve it once
|
||||
-- and let each caller degrade rather than crash the launcher -- which on those
|
||||
-- platforms is also what runs the ROM auto-import.
|
||||
local stencilOk = nil
|
||||
local function hasStencil()
|
||||
if stencilOk == nil then
|
||||
stencilOk = type(love.graphics.stencil) == "function"
|
||||
and type(love.graphics.setStencilTest) == "function"
|
||||
end
|
||||
return stencilOk
|
||||
end
|
||||
|
||||
-- vertical gradient clipped to a rounded rectangle (via the stencil buffer)
|
||||
local function fillGradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot)
|
||||
if not hasStencil() then
|
||||
-- Without a stencil the gradient mesh cannot be clipped to the rounded
|
||||
-- shape, and an unclipped mesh would square off every panel corner in the
|
||||
-- launcher. Fill the rounded rect with the gradient's vertical midpoint
|
||||
-- instead: the falloff is lost, the silhouette every card and button is
|
||||
-- built from is not.
|
||||
love.graphics.setColor((cTop[1] + cBot[1]) / 510, (cTop[2] + cBot[2]) / 510,
|
||||
(cTop[3] + cBot[3]) / 510, (aTop + aBot) / 2)
|
||||
love.graphics.rectangle("fill", x, y, w, h, r, r)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
love.graphics.stencil(function()
|
||||
love.graphics.rectangle("fill", x, y, w, h, r, r)
|
||||
end, "replace", 1)
|
||||
@@ -1512,6 +1619,10 @@ end
|
||||
-- rounded shape. phase is 0..1 (left of the button -> right of it).
|
||||
local shineMesh
|
||||
local function buttonShine(x, y, w, h, r, phase)
|
||||
-- Pure decoration, and it is the stencil that keeps the band inside the
|
||||
-- button: with no stencil, skip it rather than paint a rectangle across the
|
||||
-- panel behind.
|
||||
if not hasStencil() then return end
|
||||
if not shineMesh then
|
||||
-- triangle strip: three columns (transparent, white, transparent)
|
||||
shineMesh = love.graphics.newMesh({
|
||||
@@ -1691,9 +1802,9 @@ function RomImporter:draw()
|
||||
if self._padCursorActive then
|
||||
self._mx, self._my = self._padCursor.x, self._padCursor.y
|
||||
else
|
||||
self._mx, self._my = love.mouse.getPosition()
|
||||
self._mx, self._my = mousePosition()
|
||||
end
|
||||
self._hoverEnabled = self._padCursorActive or not self.android
|
||||
self._hoverEnabled = self._padCursorActive or not pointerless(self)
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
@@ -1734,7 +1845,10 @@ function RomImporter:draw()
|
||||
verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0,
|
||||
PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 }
|
||||
end
|
||||
self.bgMesh = love.graphics.newMesh(verts, "fan", "static")
|
||||
-- pcall'd like every other optional GPU object here: a backend without
|
||||
-- meshes still gets the flat PAL.bgBot clear the fan draws over.
|
||||
local ok, mesh = pcall(love.graphics.newMesh, verts, "fan", "static")
|
||||
self.bgMesh = ok and mesh or false
|
||||
end
|
||||
|
||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||
@@ -1748,12 +1862,18 @@ function RomImporter:draw()
|
||||
verts[#verts + 1] =
|
||||
{ cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, 0, 0, 0, 0.32 }
|
||||
end
|
||||
self.vignetteMesh = love.graphics.newMesh(verts, "fan", "static")
|
||||
local ok, mesh = pcall(love.graphics.newMesh, verts, "fan", "static")
|
||||
self.vignetteMesh = ok and mesh or false
|
||||
end
|
||||
|
||||
-- CRT scanlines: a 1px dark line every 3px, baked into a tiny tile and
|
||||
-- drawn once with a repeat-wrapped quad (one draw call, correct alpha).
|
||||
if not self.scanlineImage then
|
||||
--
|
||||
-- Off on the 3DS. The tile is 1x3, and non-power-of-two textures cannot be
|
||||
-- "repeat"-wrapped on that GPU, so the one draw call this effect is built
|
||||
-- around is exactly the call it cannot make there. A 240px-tall screen has
|
||||
-- little room for a 3px CRT pitch to read as anything but noise anyway.
|
||||
if not self.scanlineImage and not Console.is3DS() then
|
||||
local id = love.image.newImageData(1, 3)
|
||||
id:setPixel(0, 0, 0, 0, 0, 0.08)
|
||||
id:setPixel(0, 1, 0, 0, 0, 0)
|
||||
@@ -1762,37 +1882,63 @@ function RomImporter:draw()
|
||||
self.scanlineImage:setWrap("repeat", "repeat")
|
||||
self.scanlineImage:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
||||
self.scanlineQuad = self.scanlineImage
|
||||
and love.graphics.newQuad(0, 0, width, height, 1, 3) or nil
|
||||
end
|
||||
|
||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||
-- is rendered white (the design's filter:invert(1)). Built lazily so a
|
||||
-- headless require never needs a GL context.
|
||||
self.invertShader = self.invertShader or love.graphics.newShader([[
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a);
|
||||
}
|
||||
]])
|
||||
-- headless require never needs a GL context, and pcall'd because LOVE Potion
|
||||
-- (3DS/Switch/Wii U) ships no shader compiler at all. `false` is the
|
||||
-- resolved-unavailable sentinel, so the attempt happens once, not per frame.
|
||||
if self.invertShader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, [[
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a);
|
||||
}
|
||||
]])
|
||||
self.invertShader = ok and sh or false
|
||||
if not self.invertShader then
|
||||
-- No shader to invert with, and the un-inverted mark is dark ink on a
|
||||
-- near-black panel, i.e. invisible. Invert it once on the CPU instead;
|
||||
-- it is one small image, and the result draws identically.
|
||||
-- love.image is itself optional (a headless stub omits it), and indexing
|
||||
-- the field is what would raise -- pcall never gets to run on a nil
|
||||
-- module -- so probe the module before the call.
|
||||
local newImageData = love.image and love.image.newImageData
|
||||
local okd, id = false, nil
|
||||
if newImageData then okd, id = pcall(newImageData, "assets/logo/bcg.png") end
|
||||
if okd and id and id.mapPixel then
|
||||
id:mapPixel(function(_, _, r, g, b, a) return 1 - r, 1 - g, 1 - b, a end)
|
||||
local oki, img = pcall(love.graphics.newImage, id)
|
||||
self.bcgInverted = oki and img or nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Shine shader: the same white sweep the active buttons get, but clipped to
|
||||
-- the logo's own shape (a soft band brightens the pixels it crosses; fully
|
||||
-- transparent pixels stay transparent).
|
||||
self.shineShader = self.shineShader or love.graphics.newShader([[
|
||||
extern number shinePos;
|
||||
extern number shineW;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos));
|
||||
return vec4(p.rgb + band * 0.55, p.a) * color;
|
||||
}
|
||||
]])
|
||||
-- transparent pixels stay transparent). Decoration only -- where shaders are
|
||||
-- unavailable the logo simply draws without the sweep.
|
||||
if self.shineShader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, [[
|
||||
extern number shinePos;
|
||||
extern number shineW;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos));
|
||||
return vec4(p.rgb + band * 0.55, p.a) * color;
|
||||
}
|
||||
]])
|
||||
self.shineShader = ok and sh or false
|
||||
end
|
||||
|
||||
-- background
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bgMesh)
|
||||
if self.bgMesh then love.graphics.draw(self.bgMesh) end
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- with a responsive side gutter; every column below derives from these.
|
||||
@@ -2012,15 +2158,18 @@ function RomImporter:draw()
|
||||
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
|
||||
|
||||
local bcgHot = self:_hover(self.bcgButton)
|
||||
love.graphics.setShader(self.invertShader)
|
||||
-- Either the shader inverts self.bcg on the GPU, or self.bcgInverted already
|
||||
-- holds the CPU-inverted copy; both draw through the same two passes.
|
||||
local bcgImg = self.invertShader and self.bcg or (self.bcgInverted or self.bcg)
|
||||
if self.invertShader then love.graphics.setShader(self.invertShader) end
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22)
|
||||
love.graphics.draw(self.bcg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0,
|
||||
love.graphics.draw(bcgImg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0,
|
||||
bcgScale * 1.04, bcgScale * 1.04)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bcg, bcgX, bcgY, 0, bcgScale, bcgScale)
|
||||
love.graphics.setShader()
|
||||
love.graphics.draw(bcgImg, bcgX, bcgY, 0, bcgScale, bcgScale)
|
||||
if self.invertShader then love.graphics.setShader() end
|
||||
|
||||
love.graphics.setFont(self.warningFont)
|
||||
col(PAL.warning)
|
||||
@@ -2062,12 +2211,14 @@ function RomImporter:draw()
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
||||
self.shineShader:send("shineW", shineW)
|
||||
love.graphics.setShader(self.shineShader)
|
||||
if self.shineShader then
|
||||
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
||||
self.shineShader:send("shineW", shineW)
|
||||
love.graphics.setShader(self.shineShader)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
|
||||
love.graphics.setShader()
|
||||
if self.shineShader then love.graphics.setShader() end
|
||||
|
||||
-- page scrollbar: the same thin thumb the lists use, against the app edge
|
||||
if paged then
|
||||
@@ -2080,8 +2231,10 @@ function RomImporter:draw()
|
||||
|
||||
-- CRT scanlines + vignette, over everything
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
|
||||
love.graphics.draw(self.vignetteMesh)
|
||||
if self.scanlineImage then
|
||||
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
|
||||
end
|
||||
if self.vignetteMesh then love.graphics.draw(self.vignetteMesh) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
-- drag-to-scroll the save-slot list (polls the pointer; no move/release
|
||||
@@ -2459,7 +2612,8 @@ function RomImporter:draw()
|
||||
|
||||
-- pointer cursor over any interactive element (desktop only)
|
||||
if self._hoverEnabled and not self._padCursorActive
|
||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
and love.mouse and love.mouse.isCursorSupported
|
||||
and love.mouse.isCursorSupported() then
|
||||
if self._anyHover then
|
||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||
love.mouse.setCursor(self.handCursor)
|
||||
@@ -2583,11 +2737,11 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
-- Whether a press can be ARMED and resolved on release, which needs a
|
||||
-- pollable pointer: always on desktop, on Android only where love.touch is.
|
||||
local armDrag = (not self.android) or self.touchPollable
|
||||
local armDrag = (not pointerless(self)) or self.touchPollable
|
||||
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
||||
-- has no secondary button)
|
||||
if button == 2 then
|
||||
if not self.android and self.workState ~= "working" then
|
||||
if not pointerless(self) and self.workState ~= "working" then
|
||||
for _, r in ipairs(self.slotRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_beginRename(self.panelVersion, r.id)
|
||||
@@ -3211,6 +3365,10 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
|
||||
sfHintText, sfHintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
|
||||
elseif locked then
|
||||
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
||||
elseif self.console then
|
||||
-- No picker here either, so the .sav moves the same way the ROM did.
|
||||
sfHintText, sfHintCol =
|
||||
"Copy a .sav onto the SD card, next to the game.", PAL.warning
|
||||
elseif self.android then
|
||||
sfHintText, sfHintCol =
|
||||
"Import or export a .sav with the system file picker.", PAL.warning
|
||||
@@ -3445,7 +3603,7 @@ end
|
||||
-- first active touch on Android. A nil y means "nothing to read" -- the
|
||||
-- release branches below do not need one.
|
||||
function RomImporter:_pointerHold()
|
||||
if not self.android then return love.mouse.isDown(1), self._my end
|
||||
if not pointerless(self) then return mouseDown(1), self._my end
|
||||
if not self.touchPollable then return false, nil end
|
||||
local ok, list = pcall(love.touch.getTouches)
|
||||
if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
-- keeps a mod-free boot (and every headless test) loading exactly the
|
||||
-- paths it always did.
|
||||
|
||||
-- Console has no requires of its own, so pulling it in here cannot cycle.
|
||||
local Console = require("src.core.Console")
|
||||
|
||||
local Assets = {}
|
||||
|
||||
-- resolved path -> love Image
|
||||
@@ -29,10 +32,38 @@ local function exists(path)
|
||||
end
|
||||
Assets.exists = exists
|
||||
|
||||
-- 3DS: the lovebrew bundler converts every shipped PNG in the bundle to .t3x
|
||||
-- (the console's own texture format) and does NOT keep the .png, so a .png
|
||||
-- path written in the source resolves to a file that is not in the build --
|
||||
-- which is a hard error at load, not a missing-texture placeholder. Swap in
|
||||
-- the sibling .t3x when one exists.
|
||||
--
|
||||
-- Existence-driven rather than blanket, because only the shipped art is
|
||||
-- converted: everything under assets/generated/ is written as PNG at runtime
|
||||
-- by the ROM import, long after the bundler has run, and must stay PNG.
|
||||
--
|
||||
-- Memoized, because resolve() is on the per-draw path and this would
|
||||
-- otherwise cost a filesystem stat per image request per frame.
|
||||
local t3xFor = {}
|
||||
local function consoleTexture(path)
|
||||
local hit = t3xFor[path]
|
||||
if hit ~= nil then return hit end
|
||||
local swapped = path
|
||||
if path:sub(-4) == ".png" then
|
||||
local candidate = path:sub(1, -5) .. ".t3x"
|
||||
if exists(candidate) then swapped = candidate end
|
||||
end
|
||||
t3xFor[path] = swapped
|
||||
return swapped
|
||||
end
|
||||
|
||||
-- an override dir shadows the generated cache; a transform's derived
|
||||
-- output is the fallback under it, so hand-authored art beats generated
|
||||
function Assets.resolve(path)
|
||||
local loader = Assets.loader
|
||||
if type(path) == "string" and Console.is3DS() then
|
||||
path = consoleTexture(path)
|
||||
end
|
||||
if not loader or type(path) ~= "string" then return path end
|
||||
if path:sub(1, #GENERATED) ~= GENERATED then return path end
|
||||
local rel = path:sub(#GENERATED + 1)
|
||||
|
||||
@@ -830,6 +830,15 @@ end
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
-- applying the active COLORS display mode
|
||||
function PaletteFX.sendColors(shader, c)
|
||||
-- No shader is a real state, not a caller bug: shader() and keyedShader()
|
||||
-- both resolve to nil where the backend compiles none (the headless stub, and
|
||||
-- every LOVE Potion console -- that port ships no shader compiler at all).
|
||||
-- Most callers test for it and skip the whole colorized path, but the battle
|
||||
-- zone pass sets the shader once and then feeds this per zone inside its
|
||||
-- loop, so the nil has to stop here or it becomes a nil:send() mid-battle.
|
||||
-- Falling through with no uniforms sent is the right degradation: the zones
|
||||
-- draw uncolorized, which is the DMG picture the fallback path renders.
|
||||
if not shader then return end
|
||||
c = PaletteFX.effectiveColors(c)
|
||||
if not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
|
||||
@@ -94,7 +94,11 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
-- Assets.resolve so the shipped title art still loads on the 3DS, where the
|
||||
-- bundler has rewritten it to .t3x. Generated title art is unaffected: it
|
||||
-- is written as PNG at runtime and has no .t3x sibling to swap to.
|
||||
local Assets = require("src.render.Assets")
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- 3DS texture paths: the bundler rewrites shipped PNGs to .t3x.
|
||||
--
|
||||
-- The lovebrew bundler converts every image in the bundle to the 3DS texture
|
||||
-- format and does NOT keep the original, so `assets/logo/logo.png` simply does
|
||||
-- not exist inside a built .3dsx -- `assets/logo/logo.t3x` does. Loading the
|
||||
-- .png path there is a hard error, and it fired in RomImporter.new, which is
|
||||
-- the first thing the app builds: the launcher died before drawing a frame,
|
||||
-- with nothing in the log after service registration.
|
||||
--
|
||||
-- Only shipped art is converted. Everything under assets/generated/ is
|
||||
-- written as PNG by the ROM import at runtime, long after the bundler ran, so
|
||||
-- the swap must be driven by whether a .t3x actually exists rather than
|
||||
-- applied to every .png path.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local love = _G.love
|
||||
|
||||
-- Model a built .3dsx: the shipped logo exists only as .t3x, while a
|
||||
-- runtime-generated texture exists only as .png.
|
||||
local present = {
|
||||
["assets/logo/logo.t3x"] = true,
|
||||
["assets/generated/tilesets/ship.png"] = true,
|
||||
}
|
||||
local realGetInfo = love.filesystem.getInfo
|
||||
love.filesystem.getInfo = function(path, ...)
|
||||
if present[path] then return { type = "file" } end
|
||||
if path:match("^assets/") then return nil end
|
||||
return realGetInfo and realGetInfo(path, ...) or nil
|
||||
end
|
||||
|
||||
local realConsole = love._console
|
||||
|
||||
-- --------------------------------------------------------------- desktop
|
||||
love._console = nil
|
||||
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.png",
|
||||
"off-console, a .png path is left exactly as written")
|
||||
|
||||
-- ------------------------------------------------------------------- 3DS
|
||||
love._console = "3DS"
|
||||
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.t3x",
|
||||
"on a 3DS, shipped art resolves to the .t3x the bundler produced")
|
||||
T.eq(Assets.resolve("assets/generated/tilesets/ship.png"),
|
||||
"assets/generated/tilesets/ship.png",
|
||||
"but a runtime-generated PNG stays PNG: it has no .t3x, having been written "
|
||||
.. "after the bundler ran")
|
||||
T.eq(Assets.resolve("assets/logo/missing.png"), "assets/logo/missing.png",
|
||||
"and a path with no .t3x sibling is left alone rather than pointed at a "
|
||||
.. "file that does not exist either")
|
||||
|
||||
-- Non-strings pass through untouched: resolve() is called with whatever a
|
||||
-- caller had, and must not start indexing nil.
|
||||
T.eq(Assets.resolve(nil), nil, "a nil path survives resolve")
|
||||
|
||||
-- The Switch and Wii U keep their PNGs -- only the 3DS gets converted art, so
|
||||
-- swapping there would point at a file the build does not contain.
|
||||
love._console = "Switch"
|
||||
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.png",
|
||||
"the Switch keeps the .png: only the 3DS build converts textures")
|
||||
|
||||
love._console = realConsole
|
||||
love.filesystem.getInfo = realGetInfo
|
||||
|
||||
T.finish("assets t3x 3ds")
|
||||
@@ -0,0 +1,68 @@
|
||||
-- conf.lua on the LOVE Potion consoles (3DS / Switch / Wii U).
|
||||
--
|
||||
-- conf runs before love.load and before any module is available, so it has only
|
||||
-- the love._* strings the engine sets during initialization to go on. Three
|
||||
-- things have to come out right there, and none of them are observable later:
|
||||
-- the declared API version (LOVE Potion is 12.0, this game declares 11.5), the
|
||||
-- mouse module (that port builds none), and the window constraints (a 480x360
|
||||
-- minimum is larger than the entire 3DS screen).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- A conf table shaped like the one LOVE hands love.conf: defaults already
|
||||
-- filled in, for the callback to overwrite.
|
||||
local function newConf()
|
||||
return {
|
||||
identity = nil, version = "0.0.0", window = {}, modules = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function runConf()
|
||||
local t = newConf()
|
||||
-- conf.lua defines a global love.conf; load it fresh each time so an earlier
|
||||
-- run cannot leak state into the next.
|
||||
assert(loadfile("conf.lua"))()
|
||||
love.conf(t)
|
||||
return t
|
||||
end
|
||||
|
||||
local realConsole, realVersion, realOs = love._console, love._version, love._os
|
||||
|
||||
-- ------------------------------------------------------------------ desktop
|
||||
love._console, love._version, love._os = nil, nil, "OS X"
|
||||
local desktop = runConf()
|
||||
T.eq(desktop.version, "11.5", "desktop still declares the LOVE it targets")
|
||||
T.eq(desktop.window.resizable, true, "and keeps its resizable window")
|
||||
T.eq(desktop.window.minwidth, 480, "with the drag floor the launcher needs")
|
||||
T.eq(desktop.modules.mouse, nil, "and says nothing about the mouse module")
|
||||
|
||||
-- ------------------------------------------------------------------ console
|
||||
love._console, love._version, love._os = "3DS", "12.0", "Horizon"
|
||||
local ctr = runConf()
|
||||
T.eq(ctr.version, "12.0", "a console declares the API the running engine has, "
|
||||
.. "read from love._version rather than hardcoded a second time")
|
||||
T.eq(ctr.modules.mouse, false, "and asks for no mouse module, which that port "
|
||||
.. "does not build")
|
||||
T.eq(ctr.window.resizable, false, "a console window does not resize")
|
||||
T.eq(ctr.window.minwidth, nil, "and carries no desktop drag floor: 480x360 is "
|
||||
.. "larger than the whole 400x240 3DS screen")
|
||||
T.eq(ctr.window.minheight, nil, "neither dimension of it")
|
||||
|
||||
-- The console branch has to run after the mobile/desktop branch, which ends in
|
||||
-- an `else` that re-enables resizing. Ordering is the whole bug here, so pin
|
||||
-- it on a second console rather than trusting one case.
|
||||
love._console, love._version, love._os = "Switch", "12.0", "Horizon"
|
||||
T.eq(runConf().window.resizable, false,
|
||||
"the console branch wins over the desktop else that follows it")
|
||||
|
||||
-- A LOVE Potion build that stopped publishing love._version must not blank the
|
||||
-- declared version, which would read as "no version" to the engine.
|
||||
love._console, love._version, love._os = "Wii U", nil, "Cafe"
|
||||
T.eq(runConf().version, "11.5", "a missing love._version falls back rather "
|
||||
.. "than clearing the declaration")
|
||||
|
||||
love._console, love._version, love._os = realConsole, realVersion, realOs
|
||||
|
||||
T.finish("conf console")
|
||||
@@ -0,0 +1,140 @@
|
||||
-- Launcher draw on a backend with no shaders, meshes or stencil.
|
||||
--
|
||||
-- LOVE Potion (the 3DS/Switch/Wii U port) ships no shader compiler at all, and
|
||||
-- the console backends expose no stencil path, so every optional GPU object the
|
||||
-- launcher builds has to resolve to a fallback instead of raising. This is not
|
||||
-- cosmetic there: the launcher is also what runs the ROM auto-import on those
|
||||
-- platforms (findPendingRom scans the save/source dir), so a crash in draw is a
|
||||
-- crash before the player can import anything at all.
|
||||
--
|
||||
-- tests/love_stub deliberately omits newShader / newMesh / stencil, which makes
|
||||
-- it exactly that backend. Drawing a full frame through it is the assertion.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- love_stub covers what the engine's headless tests need; the launcher is the
|
||||
-- one screen that also asks for the desktop shell (OS name, pointer, clock).
|
||||
-- Fill only the gaps, and only here, so the shared stub keeps modelling a
|
||||
-- console-shaped graphics backend for everyone else.
|
||||
local love = _G.love
|
||||
-- Both the 3DS and the Switch report getOS() == "Horizon" (the LOVE Potion
|
||||
-- build defines __OS__ that way for both), which is exactly why the console is
|
||||
-- identified by love._console instead. Set here for the Switch pass; the 3DS
|
||||
-- pass at the bottom swaps it.
|
||||
love.system = love.system or { getOS = function() return "Horizon" end }
|
||||
love._console = "Switch"
|
||||
love.timer = love.timer or { getTime = function() return 0 end }
|
||||
-- No love.mouse is stubbed in on purpose: LOVE Potion does not build that
|
||||
-- module (its source/modules ships touch, joystick and keyboard, no mouse), so
|
||||
-- its absence here is the platform under test, not a gap.
|
||||
love.window = love.window or { getMode = function() return 640, 576, {} end }
|
||||
|
||||
-- love.image is genuinely present on LOVE Potion, so its absence from the
|
||||
-- shared stub is a stub gap, not the platform under test. It stays local:
|
||||
-- adding it to love_stub would flip the `love.image and ...` fallback branches
|
||||
-- that TileRenderer, PartyMenu, Credits and Evolution are tested through.
|
||||
if not love.image then
|
||||
local ImageData = {}
|
||||
ImageData.__index = ImageData
|
||||
function ImageData:setPixel() end
|
||||
function ImageData:mapPixel() end
|
||||
function ImageData:getWidth() return self.w end
|
||||
function ImageData:getHeight() return self.h end
|
||||
love.image = {
|
||||
newImageData = function(w, h)
|
||||
-- the path form (a PNG on disk) is not modelled; callers that pass one
|
||||
-- are expected to pcall, which is the behaviour being tested
|
||||
if type(w) ~= "number" then error("no image decoder in this stub", 0) end
|
||||
return setmetatable({ w = w, h = h }, ImageData)
|
||||
end,
|
||||
}
|
||||
local newImage = love.graphics.newImage
|
||||
love.graphics.newImage = function(src)
|
||||
if type(src) == "table" then
|
||||
return { w = src.w, h = src.h, setWrap = function() end,
|
||||
setFilter = function() end, getWidth = function() return src.w end,
|
||||
getHeight = function() return src.h end }
|
||||
end
|
||||
return newImage(src)
|
||||
end
|
||||
end
|
||||
|
||||
-- The stub's fonts measure but do not wrap; the launcher's footer asks for the
|
||||
-- wrapped lines so it can hit-test the community URL inside them.
|
||||
do
|
||||
local newFont = love.graphics.newFont
|
||||
love.graphics.newFont = function(size)
|
||||
local font = newFont(size)
|
||||
font.getWrap = font.getWrap or function(self, text, width)
|
||||
return math.min(width, self:getWidth(text)), { tostring(text) }
|
||||
end
|
||||
return font
|
||||
end
|
||||
end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local importer = RomImporter.new(function() end, { launcher = true })
|
||||
|
||||
-- Two frames: the first resolves every lazy object (shaders, meshes, the
|
||||
-- CPU-inverted mark), the second proves the `false` sentinels are read back as
|
||||
-- "unavailable" rather than retried and re-failed.
|
||||
local ok, err = pcall(function()
|
||||
importer:draw()
|
||||
importer:draw()
|
||||
end)
|
||||
T.eq(ok, true, "the launcher draws with no shaders, meshes or stencil: "
|
||||
.. tostring(err))
|
||||
|
||||
-- The sentinels must be false, not nil: nil would mean "not resolved yet" and
|
||||
-- would re-attempt the failing call on every frame of a 60Hz launcher.
|
||||
T.eq(importer.invertShader, false, "the invert shader resolves to unavailable")
|
||||
T.eq(importer.shineShader, false, "the shine shader resolves to unavailable")
|
||||
T.eq(importer.bgMesh, false, "the background fan resolves to unavailable")
|
||||
|
||||
-- The console profile: no pointer to poll (there is no love.mouse to poll it
|
||||
-- with), and the ROM scan is the only way in, so it must not be gated behind
|
||||
-- the Android-only picker flags.
|
||||
T.eq(importer.console, "Switch", "the console is identified by love._console")
|
||||
T.eq(importer.android, false, "and is not misfiled as Android, which would "
|
||||
.. "reach for SAF pickers that do not exist here")
|
||||
-- Hovering is the observable half of "there is no pointer here": the draw above
|
||||
-- set it from the same derivation the cursor and drag paths read.
|
||||
T.eq(importer._hoverEnabled, false, "a console hovers nothing, having no "
|
||||
.. "pointer to hover with")
|
||||
|
||||
-- choose() must not open a dialog that cannot exist: with no ROM on the card it
|
||||
-- reports where to put one instead of calling love.system.pickFile (absent).
|
||||
local okChoose, chooseErr = pcall(function() importer:choose("red") end)
|
||||
T.eq(okChoose, true, "choosing a ROM on a console does not reach for a picker: "
|
||||
.. tostring(chooseErr))
|
||||
T.eq(type(importer.notice), "table", "it leaves a notice saying where to copy "
|
||||
.. "the cart")
|
||||
|
||||
-- ---------------------------------------------------------------- 3DS
|
||||
-- The 3DS is the one console that has to drop the scanline overlay: the tile is
|
||||
-- 1x3 and that GPU cannot "repeat"-wrap a non-power-of-two texture, so the
|
||||
-- single draw call the effect exists for is the call it cannot make.
|
||||
local Console = require("src.core.Console")
|
||||
love._console = "3DS"
|
||||
T.eq(Console.is3DS(), true, "love._console identifies the 3DS")
|
||||
T.eq(Console.isConsole(), true, "and it counts as a console")
|
||||
|
||||
local threeDS = RomImporter.new(function() end, { launcher = true })
|
||||
local ok3, err3 = pcall(function() threeDS:draw() end)
|
||||
T.eq(ok3, true, "the launcher draws on a 3DS: " .. tostring(err3))
|
||||
T.eq(threeDS.scanlineImage, nil, "the 3DS builds no scanline tile")
|
||||
T.eq(threeDS.scanlineQuad, nil, "and no quad to draw it with")
|
||||
|
||||
-- The Switch keeps it: this is a 3DS-specific compromise, not a console-wide
|
||||
-- one, which is the distinction love._console exists to make.
|
||||
love._console = "Switch"
|
||||
local switch = RomImporter.new(function() end, { launcher = true })
|
||||
switch:draw()
|
||||
T.eq(switch.scanlineImage ~= nil, true, "the Switch still gets scanlines")
|
||||
|
||||
love._console = nil
|
||||
|
||||
T.finish("launcher console degrade")
|
||||
@@ -0,0 +1,37 @@
|
||||
-- PaletteFX with no shader compiler behind it.
|
||||
--
|
||||
-- PaletteFX.shader() / keyedShader() already resolve to nil where newShader
|
||||
-- fails, and most callers test for that and skip the colorized path entirely.
|
||||
-- sendColors is the exception: BattleState:drawZonePass sets the shader once
|
||||
-- and then calls sendColors per zone from inside its loop, so a nil arriving
|
||||
-- there used to become nil:send() in the middle of a battle draw.
|
||||
--
|
||||
-- This is not hypothetical on the LOVE Potion consoles: that port compiles no
|
||||
-- shaders at all, so shader() is nil on every frame of every battle.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- The stub compiles no shaders, which is the platform being modelled.
|
||||
T.eq(PaletteFX.shader(), nil, "no shader resolves where none can be compiled")
|
||||
T.eq(PaletteFX.keyedShader(), nil, "and neither does the keyed variant")
|
||||
|
||||
local COLORS = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 },
|
||||
{ 0, 0, 0 } }
|
||||
|
||||
local ok, err = pcall(PaletteFX.sendColors, nil, COLORS)
|
||||
T.eq(ok, true, "sending colors to a nil shader is a no-op, not a crash: "
|
||||
.. tostring(err))
|
||||
|
||||
-- The guard must not swallow the real path: a shader that IS present still
|
||||
-- receives all four uniforms, or every colorized platform silently goes gray.
|
||||
local sent = {}
|
||||
local fake = { send = function(_, name, value) sent[name] = value end }
|
||||
PaletteFX.sendColors(fake, COLORS)
|
||||
T.eq(sent.c0 ~= nil and sent.c1 ~= nil and sent.c2 ~= nil and sent.c3 ~= nil,
|
||||
true, "a real shader still gets all four shade uniforms")
|
||||
T.eq(sent.c3[1], 0, "and they carry the mapped colors, normalized to 0..1")
|
||||
|
||||
T.finish("palettefx no shader")
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Nintendo homebrew menu icons for the LÖVE Potion builds.
|
||||
|
||||
Each console wants a different size AND a different container, and the sizes
|
||||
are not advisory: the homebrew menu reads a fixed-size record out of the
|
||||
metadata section, so an icon of the wrong dimensions is a corrupt entry rather
|
||||
than a scaled one. From lovebrew/bundler-api (crates/asset/src/icon.rs), which
|
||||
is what the bundler service applies server-side:
|
||||
|
||||
ctr (3DS) 48x48 PNG
|
||||
hac (Switch) 256x256 JPEG
|
||||
cafe (Wii U) 128x128 PNG
|
||||
|
||||
The service resizes with `thumbnail`, which preserves aspect ratio. That is a
|
||||
trap for a non-square source: a 822x241 logo thumbnailed into a 48x48 box comes
|
||||
back 48x14, and that is what gets embedded. So this script always emits an
|
||||
exactly square icon, letterboxed rather than stretched, and the service's
|
||||
resize then becomes a no-op on an already-correct file.
|
||||
|
||||
JPEG cannot carry alpha, so the Switch icon is flattened onto a background
|
||||
first; left to Pillow, an RGBA->JPEG save either raises or produces black
|
||||
fringing wherever the source was transparent.
|
||||
|
||||
Usage:
|
||||
python3 tools/make_console_icons.py [--source PATH] [--out DIR]
|
||||
[--background '#RRGGBB']
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
sys.exit("Pillow is required: python3 -m pip install Pillow")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# The square cover art, not logo.png: the logo is 822x241, so every console
|
||||
# icon cut from it would be a thin band floating in a mostly empty square.
|
||||
DEFAULT_SOURCE = ROOT / "assets" / "logo" / "gen1recomp_cover.png"
|
||||
|
||||
# (filename, pixel size, Pillow format). Names are referenced by the
|
||||
# [metadata].icons table that scripts/build.sh writes into lovebrew.toml.
|
||||
TARGETS = (
|
||||
("icon-ctr.png", 48, "PNG"),
|
||||
("icon-hac.jpg", 256, "JPEG"),
|
||||
("icon-cafe.png", 128, "PNG"),
|
||||
)
|
||||
|
||||
|
||||
def square(image, background):
|
||||
"""Center `image` on a square canvas without distorting it.
|
||||
|
||||
Returns the image unchanged when it is already square, so a square source
|
||||
(the cover art) never takes a needless resample.
|
||||
"""
|
||||
w, h = image.size
|
||||
if w == h:
|
||||
return image
|
||||
side = max(w, h)
|
||||
canvas = Image.new("RGBA", (side, side), background + (0,))
|
||||
canvas.paste(image, ((side - w) // 2, (side - h) // 2))
|
||||
return canvas
|
||||
|
||||
|
||||
def render(source, out_dir, background):
|
||||
src = Image.open(source)
|
||||
# Normalize up front: palette and grayscale sources both reach the resize
|
||||
# with a usable alpha channel this way, and RGBA is what square() pastes.
|
||||
src = src.convert("RGBA")
|
||||
src = square(src, background)
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for name, size, fmt in TARGETS:
|
||||
# LANCZOS: these are large downscales (1024 -> 48 is 21x), where a
|
||||
# cheaper filter aliases the sprite work into noise.
|
||||
icon = src.resize((size, size), Image.LANCZOS)
|
||||
if fmt == "JPEG":
|
||||
# Flatten: JPEG has no alpha, and compositing explicitly is what
|
||||
# keeps transparent edges from turning into black fringes.
|
||||
flat = Image.new("RGB", icon.size, background)
|
||||
flat.paste(icon, mask=icon.split()[3])
|
||||
icon = flat
|
||||
path = out_dir / name
|
||||
icon.save(path, fmt, **({"quality": 95} if fmt == "JPEG" else {}))
|
||||
written.append((path, size, fmt))
|
||||
return written
|
||||
|
||||
|
||||
def parse_color(text):
|
||||
text = text.lstrip("#")
|
||||
if len(text) != 6:
|
||||
raise argparse.ArgumentTypeError("expected #RRGGBB")
|
||||
return tuple(int(text[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--source", type=Path, default=DEFAULT_SOURCE,
|
||||
help="source image (default: assets/logo/gen1recomp_cover.png)")
|
||||
ap.add_argument("--out", type=Path, default=ROOT / "dist" / "console" / "icons",
|
||||
help="output directory")
|
||||
ap.add_argument("--background", type=parse_color, default="#000000",
|
||||
help="fill behind transparency, for JPEG and letterboxing")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.source.is_file():
|
||||
sys.exit(f"source image not found: {args.source}")
|
||||
|
||||
background = args.background
|
||||
if isinstance(background, str):
|
||||
background = parse_color(background)
|
||||
|
||||
for path, size, fmt in render(args.source, args.out, background):
|
||||
print(f"{path.relative_to(ROOT) if ROOT in path.parents else path}"
|
||||
f" {size}x{size} {fmt}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user