Compare commits

...

1 Commits

Author SHA1 Message Date
bryanthaboi 6b8e137b42 attempt at wii u and 3ds 2026-07-31 14:52:27 -04:00
14 changed files with 1000 additions and 56 deletions
+25
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
+49
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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)
+9
View File
@@ -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 })
+5 -1
View File
@@ -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
+67
View File
@@ -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")
+68
View File
@@ -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")
+140
View File
@@ -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")
+37
View File
@@ -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")
+124
View File
@@ -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()