mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 | |||
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| 9c5f33e187 | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| c8e035d332 |
@@ -16,6 +16,7 @@ __pycache__/
|
||||
.*
|
||||
!.github/
|
||||
!.gitignore
|
||||
!.luacheckrc
|
||||
|
||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||
mobile/android/app/build/
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
|
||||
--
|
||||
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
|
||||
--
|
||||
-- The point is a high-signal baseline: the categories left on are the ones
|
||||
-- that catch real defects -- undefined globals/locals (the class that hid a
|
||||
-- `music.volume` crash: applyVolume read a `state` that was still the nil
|
||||
-- global), unused values, unreachable code, redefinitions. The cosmetic
|
||||
-- categories the codebase deliberately lives with (a `self`/`dt` an
|
||||
-- interface requires but a given method ignores, documented empty
|
||||
-- fall-through branches, the odd long line) are muted so they don't drown
|
||||
-- the signal.
|
||||
|
||||
std = "luajit"
|
||||
|
||||
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
|
||||
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
|
||||
-- read-only -- otherwise every callback registration reads as a violation.
|
||||
globals = { "love" }
|
||||
|
||||
read_globals = {
|
||||
"jit",
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused argument -- self/dt kept for a shared method signature
|
||||
"213", -- unused loop variable -- `for _, v in` where only v is wanted
|
||||
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
|
||||
"431", -- shadowing an upvalue
|
||||
"432", -- shadowing an argument
|
||||
"542", -- empty if branch -- documented fall-throughs, not gaps
|
||||
"631", -- line is too long
|
||||
}
|
||||
@@ -108,6 +108,17 @@ supported out of the box.
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
|
||||
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
so the overworld runs smoothly on the H700 out of the box. Bump it to
|
||||
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
|
||||
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
||||
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||
native `save.lua` format keeps every existing item when the configured
|
||||
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||
20 bag slots because the original SRAM layout has no room for more.
|
||||
- PC Box **overflow handling** was deliberately changed even though the
|
||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||
or blocks the deposit," this port spills into the next box with room.
|
||||
|
||||
+37
-1
@@ -143,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Performance tier (low-end devices)
|
||||
|
||||
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||
extras down for weaker hardware. The extras it governs are the three
|
||||
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||
only in how much eye-candy the renderer is allowed to do.
|
||||
|
||||
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||
| **HIGH** | on | on | on | none |
|
||||
| **BALANCED** | off | off | on | none |
|
||||
| **LOW** | off | off | off | 60 |
|
||||
| **AUTO** | picks a default from the device (below) |||
|
||||
|
||||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||
wrong guess is one row away from being overridden.
|
||||
- The clamps are applied **live** against your stored options and never
|
||||
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||
that can afford it.)
|
||||
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
@@ -345,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
|
||||
@@ -461,14 +461,31 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then return Importer:mousepressed(x, y, button) end
|
||||
if Importer then
|
||||
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||
-- missing: love.touchpressed above forwards the primary touch to the
|
||||
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return Importer:mousepressed(x, y, button)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
@@ -84,7 +84,15 @@ public class GameActivity extends SDLActivity {
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
// Saved/restored across instance state: the picker is a separate activity
|
||||
// and Android may destroy this one while it is up (memory pressure, or
|
||||
// "Don't keep activities"). A recreated instance still receives
|
||||
// onActivityResult, so without this a mod or save pick came back with the
|
||||
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
private static boolean immersiveActive = false;
|
||||
@@ -149,6 +157,14 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (savedInstanceState != null) {
|
||||
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
metrics = getResources().getDisplayMetrics();
|
||||
|
||||
// Set low-latency audio values
|
||||
@@ -539,6 +555,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
@@ -156,7 +156,7 @@ return function(mod)
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
Bag.add(self.game.save, ball, 1)
|
||||
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
|
||||
+120
-3
@@ -65,6 +65,10 @@ DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||
MANIFESTS=""
|
||||
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
@@ -229,6 +233,68 @@ apply_ios_branding() {
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||
# one is absent, and dev reads them off the source tree, so the miss only ever
|
||||
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
|
||||
# for exactly that reason.
|
||||
#
|
||||
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
|
||||
# a fourth version cannot silently ship without its manifest, and a missing file
|
||||
# is recovered from Git or the project repo the same way build_android.sh already
|
||||
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
|
||||
# a manifest carries extraction metadata only, never a ROM or game data.
|
||||
manifest_paths() {
|
||||
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
|
||||
import re, sys
|
||||
src = open(sys.argv[1]).read()
|
||||
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
|
||||
PY
|
||||
}
|
||||
|
||||
manifest_is_valid() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json, pathlib, sys
|
||||
try:
|
||||
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
sha = m.get("romSha1")
|
||||
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_manifests() {
|
||||
MANIFESTS="$(manifest_paths)"
|
||||
[ -n "$MANIFESTS" ] \
|
||||
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
|
||||
local rel staged
|
||||
for rel in $MANIFESTS; do
|
||||
if manifest_is_valid "$ROOT/$rel"; then continue; fi
|
||||
warn "$rel is missing or invalid; recovering it before packaging"
|
||||
staged="$(mktemp)"
|
||||
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "restored $rel from this checkout's Git data"
|
||||
continue
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "downloaded $rel from the project repository"
|
||||
continue
|
||||
fi
|
||||
rm -f "$staged"
|
||||
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
|
||||
done
|
||||
say "import manifests: $MANIFESTS"
|
||||
}
|
||||
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
@@ -240,9 +306,10 @@ pack_game_love() {
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
$MANIFESTS \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
@@ -252,8 +319,18 @@ pack_game_love() {
|
||||
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
|
||||
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
|
||||
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua $MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
|
||||
@@ -348,6 +425,43 @@ PY
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- xcodebuild
|
||||
# love.system.pickFile and createFile are a native bridge compiled in by
|
||||
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
|
||||
# patch still links and still runs, then finds the field nil the moment anyone
|
||||
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
|
||||
# rather than crash, which is the right floor, but a build with no picker at all
|
||||
# is a silent downgrade, so fail here instead of shipping one.
|
||||
#
|
||||
# Checked against the built binary rather than the source, because patching
|
||||
# love-src proves nothing about what Xcode actually compiled: the shipped
|
||||
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
|
||||
# a missing patch step were self-built IPAs, exactly the case this catches.
|
||||
verify_native_bridge() {
|
||||
local app="$1"
|
||||
local exe bin missing=""
|
||||
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
|
||||
"$app/Info.plist" 2>/dev/null || true)"
|
||||
bin="$app/${exe:-love}"
|
||||
[ -f "$bin" ] || bin="$app/love"
|
||||
if [ ! -f "$bin" ]; then
|
||||
warn "no executable inside $(basename "$app"); skipping native bridge check"
|
||||
return 0
|
||||
fi
|
||||
# grep -q here would race pipefail the same way pack_game_love documents:
|
||||
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
|
||||
# nondeterministically. >/dev/null keeps grep reading the whole stream.
|
||||
for sym in pickFile createFile; do
|
||||
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
fail "built app has no native bridge (missing:$missing).
|
||||
Import ROM would fall back to copy-into-Files instead of opening the picker.
|
||||
mobile/ios/patch_love_src.py did not take. Re-run:
|
||||
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
|
||||
fi
|
||||
say "native bridge present (pickFile, createFile)"
|
||||
}
|
||||
|
||||
run_xcodebuild() {
|
||||
local config sdk destination
|
||||
if $RELEASE; then
|
||||
@@ -455,6 +569,8 @@ run_xcodebuild() {
|
||||
cp "$LOVE_FILE" "$app/game.love"
|
||||
fi
|
||||
|
||||
verify_native_bridge "$app"
|
||||
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
@@ -526,6 +642,7 @@ install_to_device() {
|
||||
apply_ios_branding
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
ensure_manifests
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static analysis for the engine Lua (config: .luacheckrc).
|
||||
#
|
||||
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
|
||||
# defects that never run in a green test -- undefined globals/locals (the
|
||||
# class that hid a music.volume crash: a hook read a `state` that was still
|
||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||
#
|
||||
# scripts/lint.sh lint src/
|
||||
# scripts/lint.sh src tools lint specific paths
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v luacheck >/dev/null 2>&1; then
|
||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
luacheck "${@:-src}"
|
||||
+5
-5
@@ -14,12 +14,12 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
-- boot.
|
||||
local CONSTANT_DEFAULTS = {
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
|
||||
@@ -634,6 +634,23 @@ function Game:applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
-- Scale the optional presentation extras to the device's performance
|
||||
-- tier. Every heavy feature was just applied from the stored options
|
||||
-- above; here we clamp the *live* state down for a weaker device without
|
||||
-- rewriting what the player saved, so raising the tier later restores
|
||||
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- default on a normal desktop, and every options.lua predating this
|
||||
-- option) clamps nothing, so it is a no-op for the common case.
|
||||
local caps = require("src.core.Performance").applyOptions(opts)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
if caps.fpsMax then
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||
end
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
|
||||
+8
-1
@@ -23,6 +23,13 @@ local volumeScale = 1
|
||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
-- Forward-declared here so applyVolume (below) closes over the real playback
|
||||
-- state rather than a nil global: the table literal is assigned further down,
|
||||
-- but a `local state = {}` there would leave every reference above it bound
|
||||
-- to the global `state`. Before this, registering the `music.volume` mod
|
||||
-- hook crashed applyVolume on `state.current` (a nil index).
|
||||
local state
|
||||
|
||||
local function applyVolume(src)
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
@@ -63,7 +70,7 @@ local function applyFilter(src)
|
||||
end
|
||||
end
|
||||
|
||||
local state = {
|
||||
state = {
|
||||
current = nil, -- song label
|
||||
chip = false, -- the playing song is a synthesized channel program
|
||||
source = nil, -- currently playing source
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Graphics performance tier: one knob that scales the port's optional,
|
||||
-- non-faithful presentation extras down for weaker hardware.
|
||||
--
|
||||
-- The heavy extras are all things the Game Boy never had and this port
|
||||
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||
-- None of this touches game logic, which is fixed-step off dt
|
||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||
--
|
||||
-- The tier is persisted as save.options.performance:
|
||||
-- auto pick a default from the device (see detect())
|
||||
-- high everything on -- the historical behavior
|
||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||
--
|
||||
-- AUTO only chooses the *default* for the current device; every tier is
|
||||
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||
-- against the stored options without rewriting them, so raising the tier
|
||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||
--
|
||||
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||
-- tests, the same way src/core/GameVersion.lua does.
|
||||
|
||||
local Performance = {}
|
||||
|
||||
-- Option-row order (auto first, then most to least capable).
|
||||
Performance.TIERS = { "auto", "high", "balanced", "low" }
|
||||
|
||||
Performance.LABELS = {
|
||||
auto = "AUTO",
|
||||
high = "HIGH",
|
||||
balanced = "BALANCED",
|
||||
low = "LOW",
|
||||
}
|
||||
|
||||
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||
-- extra ceiling (the player's own MAX FPS still applies).
|
||||
Performance.CAPS = {
|
||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||
}
|
||||
|
||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||
-- renderer / options row read it back. Defaults to the unclamped tier so
|
||||
-- a pre-boot launcher behaves exactly as it always did.
|
||||
Performance.tier = "high"
|
||||
|
||||
local function loveOS()
|
||||
if love and love.system and love.system.getOS then
|
||||
return love.system.getOS()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function processorCount()
|
||||
if love and love.system and love.system.getProcessorCount then
|
||||
return love.system.getProcessorCount()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
|
||||
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
|
||||
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
|
||||
-- clamping) so tooling is never surprised.
|
||||
local function cpuArch()
|
||||
return (jit and jit.arch) or nil
|
||||
end
|
||||
|
||||
-- Default tier for the current device. Deliberately conservative: only
|
||||
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
|
||||
-- overridable, so a wrong guess costs one OPTIONS row. A normal
|
||||
-- multi-core desktop -- the common case, and every existing install whose
|
||||
-- options.lua predates this option -- resolves to HIGH and behaves exactly
|
||||
-- as before.
|
||||
function Performance.detect()
|
||||
local os = loveOS()
|
||||
local arch = cpuArch()
|
||||
local isArm = arch == "arm" or arch == "arm64"
|
||||
local cores = processorCount()
|
||||
|
||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||
-- already ships a build for): the weakest target here.
|
||||
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||
if os == "Android" or os == "iOS" then
|
||||
return "balanced"
|
||||
end
|
||||
-- Desktop: a dual-core (or single-core) box is the low-end line.
|
||||
if cores and cores <= 2 then
|
||||
return "balanced"
|
||||
end
|
||||
return "high"
|
||||
end
|
||||
|
||||
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
|
||||
-- hand-edited garbage) through detect().
|
||||
function Performance.resolve(value)
|
||||
if value == "high" or value == "balanced" or value == "low" then
|
||||
return value
|
||||
end
|
||||
return Performance.detect()
|
||||
end
|
||||
|
||||
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
|
||||
-- degrades to auto so a corrupt options.lua still lands on something sane.
|
||||
function Performance.normalize(value)
|
||||
for _, id in ipairs(Performance.TIERS) do
|
||||
if value == id then return id end
|
||||
end
|
||||
return "auto"
|
||||
end
|
||||
|
||||
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
|
||||
function Performance.label(value)
|
||||
return Performance.LABELS[Performance.normalize(value)]
|
||||
end
|
||||
|
||||
-- Concrete caps for a stored option value (resolving auto).
|
||||
function Performance.caps(value)
|
||||
return Performance.CAPS[Performance.resolve(value)]
|
||||
end
|
||||
|
||||
-- Resolve the tier for these options, record it live, and return its caps.
|
||||
-- Called from Game:applyOptions, which clamps the live presentation modules
|
||||
-- against the returned caps.
|
||||
function Performance.applyOptions(opts)
|
||||
local tier = Performance.resolve(opts and opts.performance)
|
||||
Performance.tier = tier
|
||||
return Performance.CAPS[tier]
|
||||
end
|
||||
|
||||
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
|
||||
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
|
||||
-- negative dir wraps correctly.
|
||||
function Performance.cycle(value, dir)
|
||||
value = Performance.normalize(value)
|
||||
local i = 1
|
||||
for idx, id in ipairs(Performance.TIERS) do
|
||||
if id == value then i = idx break end
|
||||
end
|
||||
local n = #Performance.TIERS
|
||||
local nextIdx = (i - 1 + (dir or 1)) % n + 1
|
||||
return Performance.TIERS[nextIdx]
|
||||
end
|
||||
|
||||
return Performance
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||
-- the engine provides it; otherwise the full graphics window.
|
||||
--
|
||||
-- Desktop and headless stubs return the full window, so callers can always
|
||||
-- layout against this rect without platform branches. Interactive chrome
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
|
||||
if not (love and love.window and love.window.getSafeArea) then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
local x, y, w, h = love.window.getSafeArea()
|
||||
if type(x) ~= "number" or type(y) ~= "number"
|
||||
or type(w) ~= "number" or type(h) ~= "number"
|
||||
or w <= 0 or h <= 0 then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
y = math.max(0, math.min(y, wh))
|
||||
w = math.max(1, math.min(w, ww - x))
|
||||
h = math.max(1, math.min(h, wh - y))
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||
-- logic. See src/core/Performance.lua.
|
||||
performance = "auto",
|
||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||
@@ -1053,7 +1058,7 @@ local function reclaim(save, data, report)
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
||||
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||
save.pcItems = save.pcItems or {}
|
||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
|
||||
+42
-26
@@ -23,6 +23,7 @@
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||
-- and for callers that already pass a full-window size.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
@@ -185,30 +190,37 @@ function TouchControls:visible()
|
||||
and not self.controllerHidden
|
||||
end
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||
local function clampZone(zone, x0, y0, x1, y1)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||
-- while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
end
|
||||
self.layoutW, self.layoutH = sw, sh
|
||||
self.layoutOx, self.layoutOy = ox, oy
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
zone.cx = ox + p.x * sw
|
||||
zone.cy = oy + p.y * sh
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,21 +234,25 @@ function TouchControls:layout()
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
self.positions[name] = {
|
||||
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
local n = tonumber(count) or 1
|
||||
if require("src.inventory.Bag").add(save, id, n) then
|
||||
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||
self:print(("%s x%d added"):format(id, n))
|
||||
else
|
||||
self:print("bag full")
|
||||
|
||||
+97
-48
@@ -1,10 +1,30 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
||||
-- builds that compiled one (Android, and iOS builds patched by
|
||||
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||
-- copy-it-into-the-save-folder flow that every caller below already has --
|
||||
-- calling the nil field instead took the whole app down the moment the player
|
||||
-- pressed Import ROM:
|
||||
--
|
||||
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
|
||||
--
|
||||
-- love.system.createFile was already guarded this way at its one call site;
|
||||
-- these three were not. Every caller here treats `false` as "no picker
|
||||
-- available" and shows its own notice, so a missing bridge now degrades to
|
||||
-- exactly the path a picker-less Android device has always taken.
|
||||
local function pickFile(...)
|
||||
local fn = love.system.pickFile
|
||||
if not fn then return false end
|
||||
return fn(...) and true or false
|
||||
end
|
||||
|
||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
||||
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
||||
@@ -555,10 +575,16 @@ function RomImporter.new(onComplete, opts)
|
||||
onEditTouchControls = opts.onEditTouchControls,
|
||||
android = android,
|
||||
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
|
||||
-- event necessarily follows, so consume them via the first poll tick.
|
||||
pickPending = mobileOS == "iOS" or nil,
|
||||
-- One startup poll pass on both mobiles. iOS: files dropped through the
|
||||
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
|
||||
-- no love.focus event necessarily following. Android: the SAF picker is a
|
||||
-- separate activity, and Android is free to destroy GameActivity while it
|
||||
-- is up (memory pressure, or "Don't keep activities"), so the app RESTARTS
|
||||
-- instead of resuming and the love.focus(true) that would have consumed the
|
||||
-- pick never arrives. The file is sitting in the save dir either way, so
|
||||
-- boot armed and let the first poll tick consume it, rather than making the
|
||||
-- player tap Import a second time to trigger the scan by hand (#553).
|
||||
pickPending = android or nil,
|
||||
-- Android drag: the launcher is handed no move events at all (main.lua
|
||||
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
|
||||
-- emulation is what "no reliable pointer polling" below refers to.
|
||||
@@ -986,7 +1012,7 @@ function RomImporter:chooseMod()
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
if not love.system.pickFile("mod") then
|
||||
if not pickFile("mod") then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
else
|
||||
@@ -1049,7 +1075,7 @@ function RomImporter:chooseSaveImport(version)
|
||||
return
|
||||
end
|
||||
self.androidPendingVersion = version
|
||||
if not love.system.pickFile("sav") then
|
||||
if not pickFile("sav") then
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||
@@ -1137,7 +1163,7 @@ function RomImporter:choose(version)
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
return -- a rejected pick explains itself instead of silently reopening
|
||||
elseif not love.system.pickFile() then
|
||||
elseif not pickFile() then
|
||||
-- Picker unavailable (API < 19, or no document-picker app installed):
|
||||
-- fall back to the USB folder-drop path as a friendly notice, not an
|
||||
-- error (which would read as a rejected file).
|
||||
@@ -1183,13 +1209,30 @@ function RomImporter:choose(version)
|
||||
end
|
||||
end
|
||||
|
||||
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
|
||||
-- separate SAF activity there is no love.focus(true) when it dismisses.
|
||||
-- While a pick is outstanding, poll the save dir for the bridge's delivered
|
||||
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
|
||||
-- and run the same refocus import path Android uses.
|
||||
-- Poll the save dir for a delivered pick (picked_rom.gb / picked_mod.zip /
|
||||
-- picked_save.sav / export_done.flag) and run the same import path a refocus
|
||||
-- runs. Both mobiles need this, for different reasons:
|
||||
--
|
||||
-- iOS the document picker is an in-process modal sheet, so there is no
|
||||
-- love.focus(true) when it dismisses -- nothing else would consume it.
|
||||
-- Android the SAF picker IS a separate activity and normally does refocus,
|
||||
-- but Android may destroy GameActivity while it is up, in which case
|
||||
-- the app restarts and that focus event never comes. Polling makes
|
||||
-- the outcome the same either way instead of leaving the pick on disk
|
||||
-- for the next tap to find, which is what made users import twice and
|
||||
-- what made it look random: it depends on memory pressure (#553).
|
||||
--
|
||||
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
|
||||
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
|
||||
-- is an in-process modal sheet, so update() keeps running while it is open and
|
||||
-- the window burned down while the player was still browsing Files. The pick
|
||||
-- then landed with nothing armed to consume it, and because every path here is
|
||||
-- silent on success the import just did not happen, with no error shown. A
|
||||
-- half-second directory listing on a menu screen is far cheaper than an import
|
||||
-- that vanishes, so the poll stays armed until something is actually consumed.
|
||||
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not (self.ios and self.pickPending) then return end
|
||||
if not self.pickPending then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
@@ -1249,10 +1292,10 @@ local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padCursor.x = ox + w * 0.5
|
||||
self._padCursor.y = oy + h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
@@ -1298,11 +1341,11 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
and PAD_SPEED or PAD_DPAD_SPEED
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
local nx = self._padCursor.x + dx * speed * dt
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
@@ -1680,7 +1723,10 @@ function RomImporter:_resetFrameRects()
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
-- Full window for immersive backdrop; safe rect for interactive chrome so
|
||||
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, width, height = SafeArea.rect()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
local pulse = self.pulse
|
||||
self._s = s
|
||||
@@ -1699,8 +1745,9 @@ function RomImporter:draw()
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
|
||||
-- area changes (rotation, resize, inset changes).
|
||||
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
|
||||
if self.fontKey ~= fontKey then
|
||||
self.fontKey = fontKey
|
||||
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
|
||||
@@ -1724,10 +1771,10 @@ function RomImporter:draw()
|
||||
-- Background: a radial gradient (bright navy at top-centre -> near black).
|
||||
-- A triangle fan from the top-centre gives the radial falloff; the screen
|
||||
-- is cleared to the outer colour first so the corners it does not reach
|
||||
-- match seamlessly.
|
||||
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
|
||||
do
|
||||
local cx, cy = width / 2, 0
|
||||
local rx, ry = width * 1.3, height * 1.08
|
||||
local cx, cy = fullW / 2, 0
|
||||
local rx, ry = fullW * 1.3, fullH * 1.08
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0,
|
||||
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
|
||||
@@ -1741,8 +1788,8 @@ function RomImporter:draw()
|
||||
|
||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||
do
|
||||
local cx, cy = width / 2, height * 0.45
|
||||
local rx, ry = width * 0.78, height * 0.78
|
||||
local cx, cy = fullW / 2, fullH * 0.45
|
||||
local rx, ry = fullW * 0.78, fullH * 0.78
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
|
||||
for i = 0, n do
|
||||
@@ -1764,7 +1811,7 @@ 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 = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
|
||||
end
|
||||
|
||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||
@@ -1790,21 +1837,23 @@ function RomImporter:draw()
|
||||
}
|
||||
]])
|
||||
|
||||
-- background
|
||||
-- background (full window — unsafe edges stay painted)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bgMesh)
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- with a responsive side gutter; every column below derives from these.
|
||||
-- Origin is the safe-area top-left so chrome clears device insets.
|
||||
local appW = math.min(width, 1440 * s)
|
||||
local appX = (width - appW) / 2
|
||||
local appX = ox + (width - appW) / 2
|
||||
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
|
||||
local third = appW / 3
|
||||
|
||||
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
|
||||
local stripH = math.max(4, 6 * s)
|
||||
local stripY = oy
|
||||
local segs = {
|
||||
{ PAL.red, appX, third },
|
||||
{ PAL.blue, appX + third, third },
|
||||
@@ -1812,11 +1861,11 @@ function RomImporter:draw()
|
||||
}
|
||||
love.graphics.setBlendMode("add")
|
||||
for _, seg in ipairs(segs) do
|
||||
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
end
|
||||
love.graphics.setBlendMode("alpha")
|
||||
for _, seg in ipairs(segs) do
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
|
||||
end
|
||||
|
||||
-- Footer (Boi's Club Games logo + trust warning), measured first so the
|
||||
@@ -1838,7 +1887,7 @@ function RomImporter:draw()
|
||||
math.min(330 * s, appW - 32 * s))
|
||||
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
|
||||
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
|
||||
local logoY = stripH + 14 * s
|
||||
local logoY = stripY + stripH + 14 * s
|
||||
|
||||
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
|
||||
-- with "N of 3 ready" right-aligned.
|
||||
@@ -1868,7 +1917,7 @@ function RomImporter:draw()
|
||||
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
|
||||
local cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local contentBottom = oy + height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- Page scroll. Everything under the tab bar -- panel, updater banner and
|
||||
@@ -1879,14 +1928,14 @@ function RomImporter:draw()
|
||||
-- the previous frame's measurement, the same one-frame settle the slot and
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- measurement below is what it always was.
|
||||
local viewportH = math.max(0, height - contentTop)
|
||||
local viewportH = math.max(0, oy + height - contentTop)
|
||||
self._panelNaturalH = self._panelNaturalH or {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- read by the hit tests; a scrolled control is live only inside the viewport
|
||||
pageBand = paged and { contentTop, height } or nil
|
||||
pageBand = paged and { contentTop, oy + height } or nil
|
||||
|
||||
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
|
||||
-- and it sits above the scrolling viewport.
|
||||
@@ -2057,10 +2106,10 @@ function RomImporter:draw()
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
@@ -2093,11 +2142,11 @@ function RomImporter:draw()
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 420 * s)
|
||||
local dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
@@ -2139,11 +2188,11 @@ function RomImporter:draw()
|
||||
-- it is typed.
|
||||
if self._indexPrompt then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = 168 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
|
||||
@@ -2195,7 +2244,7 @@ function RomImporter:draw()
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
end
|
||||
if self._modConfirm then
|
||||
local c = self._modConfirm
|
||||
@@ -2203,7 +2252,7 @@ function RomImporter:draw()
|
||||
local lineH = self.hintFont:getHeight() + 4 * s
|
||||
local dh = 36 * s + (#c.lines) * lineH + 56 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2245,7 +2294,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 480 * s)
|
||||
local dh = math.min(height - 48 * s, 360 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2290,7 +2339,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = math.min(height - 48 * s, 420 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2347,7 +2396,7 @@ function RomImporter:draw()
|
||||
listH = listN * rowH
|
||||
dh = headerH + listH + footerH
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
|
||||
+23
-7
@@ -1,11 +1,26 @@
|
||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
||||
-- live in the inventory table but are not bag items. save.bagOrder
|
||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
||||
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||
-- of quantity; badges live in the inventory table but are not bag items.
|
||||
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||
-- it).
|
||||
|
||||
local Bag = {}
|
||||
|
||||
Bag.CAPACITY = 20
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||
-- gameplay may omit it because the loader merges mods into the Data
|
||||
-- singleton before any item can be added. The fallback keeps old/stale
|
||||
-- generated caches and isolated callers at the vanilla limit.
|
||||
function Bag.capacity(data)
|
||||
data = data or require("src.core.Data")
|
||||
local configured = data and data.constants and data.constants.bagSize
|
||||
if type(configured) == "number" and configured >= 1 then
|
||||
return math.floor(configured)
|
||||
end
|
||||
return DEFAULT_CAPACITY
|
||||
end
|
||||
|
||||
local function isBadge(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
@@ -55,9 +70,10 @@ end
|
||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
function Bag.add(save, id, qty)
|
||||
function Bag.add(save, id, qty, data)
|
||||
local inv = save.inventory
|
||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
||||
if not inv[id] and not isBadge(id)
|
||||
and Bag.slots(save) >= Bag.capacity(data) then
|
||||
return false
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
-- "ball" caller must throw it (battle only)
|
||||
-- "learn", moveId caller must run the learn-move flow
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
|
||||
+10
-3
@@ -384,9 +384,16 @@ function Tournament:update(dt)
|
||||
else
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
end
|
||||
local battle, why = self.isHost
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||
-- idiom truncates a call to its first result, so the second return
|
||||
-- (the specific reason) was always dropped and every failure showed
|
||||
-- the generic fallback instead of "same mods on both games" etc.
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
else
|
||||
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
end
|
||||
if not battle then
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
|
||||
@@ -10,6 +10,13 @@ local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
|
||||
-- is the port's most expensive optional extra. The performance tier sets
|
||||
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
|
||||
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
|
||||
-- close-up. Nil/true keeps the historical full range.
|
||||
Zoom.allowSurvey = true
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo end
|
||||
end
|
||||
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
||||
-- zoom.range widened it. == false so nil/true stays permissive.
|
||||
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
|
||||
@@ -210,11 +210,12 @@ end
|
||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||
-- pass false when the script shows its own received-text row.
|
||||
function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
|
||||
-- the bag can refuse at its configured capacity (20 in vanilla): halt
|
||||
-- the script, so later set_flag rows don't burn the gift -- make
|
||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
if not require("src.inventory.Bag").add(
|
||||
ctx.save, itemId, count or 1, ctx.game.data) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
|
||||
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -202,6 +203,21 @@ local function buildRows(game)
|
||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||
return true
|
||||
end },
|
||||
-- Heads the port's display group: one tier that scales the heavy extras
|
||||
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
|
||||
-- AUTO picks a default from the hardware; every tier is overridable.
|
||||
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
|
||||
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
|
||||
{ id = "performance", label = Strings("PERFORMANCE"),
|
||||
value = function(g)
|
||||
return Strings(Performance.label(g.save.options.performance))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.performance = Performance.cycle(o.performance, dir)
|
||||
g:applyOptions(o)
|
||||
return true
|
||||
end },
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ local function withdraw(game)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
||||
list.footer = notEnough
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
|
||||
@@ -109,34 +109,36 @@ function Editor.update(_dt)
|
||||
end
|
||||
|
||||
function Editor.draw()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, ww, wh = SafeArea.rect()
|
||||
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
local btnW = 100 * s
|
||||
|
||||
-- top bar
|
||||
-- top bar (inside the safe area so it clears the notch / status bar)
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
|
||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
@@ -156,9 +158,9 @@ function Editor.draw()
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardY = oy + barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = pad, ww - 2 * pad
|
||||
local cardX, cardW = ox + pad, ww - 2 * pad
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -190,7 +192,7 @@ function Editor.draw()
|
||||
local hint = on
|
||||
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
|
||||
@@ -1803,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return true
|
||||
end
|
||||
@@ -2423,7 +2423,7 @@ function OverworldState:talkTo(npc)
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2414,14 +2414,15 @@ local function sellItem(id)
|
||||
return sold
|
||||
end
|
||||
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
|
||||
-- slots; a stack of an item already held costs nothing). This is why
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
||||
-- capacity mod; a stack of an item already held costs nothing). This is why
|
||||
-- every restock had been reporting "HYPER_POTION x0": the buy list
|
||||
-- opened, the quantity was set, the engine said "no room" -- and the run
|
||||
-- walked into the Mansion with FULL_HEALs but not one HP restore.
|
||||
local function freeBagSlots(needed, where)
|
||||
local used = #(G.save.bagOrder or {})
|
||||
local free = 20 - used
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
local free = capacity - used
|
||||
for _, id in ipairs(SELLABLE_JUNK) do
|
||||
if free >= needed then break end
|
||||
if ((G.save.inventory or {})[id] or 0) > 0 then
|
||||
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
|
||||
end
|
||||
end
|
||||
local used = #(G.save.bagOrder or {})
|
||||
if newKinds > 0 and 20 - used < newKinds then
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
if newKinds > 0 and capacity - used < newKinds then
|
||||
freeBagSlots(newKinds, where)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Regression: the `music.volume` mod hook must not crash on Music's private
|
||||
-- `state`. applyVolume (src/core/Music.lua) builds its hook context from
|
||||
-- state.current/mapSong/onBike/... but was defined above the `local state`
|
||||
-- table, so those reads bound to the nil global `state` instead -- a mod that
|
||||
-- registered music.volume hit "attempt to index a nil value (global 'state')"
|
||||
-- the first time any volume was applied. This drives a file-backed song
|
||||
-- through Music with the hook installed and asserts the context resolves.
|
||||
-- ROM-free: a fake audio source, no data/generated/.
|
||||
-- luajit tests/engine/music_volume_hook_state.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = require("tests.love_stub")
|
||||
|
||||
-- minimal audio source: only the methods Music calls on a file song
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setLooping(v) self.looping = v end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setFilter() end
|
||||
love.audio = {
|
||||
newSource = function(file) return setmetatable({ file = file }, Source) end,
|
||||
}
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local events = require("src.mods.Events").new()
|
||||
local hooks = require("src.mods.Hooks").new()
|
||||
Runtime.install(events, hooks)
|
||||
|
||||
-- one file-backed song is enough; the chip path would pull in `bit`
|
||||
local data = { audio = { songs = { TEST = { file = "test.ogg" } } } }
|
||||
|
||||
-- record every context the hook is handed, and scale the volume so the
|
||||
-- return value is exercised too
|
||||
local calls = {}
|
||||
hooks:wrap("music.volume", function(_next, vol, ctx)
|
||||
calls[#calls + 1] = ctx
|
||||
return vol * 0.5
|
||||
end, nil, "voltest")
|
||||
|
||||
T.check(Runtime.wantsHook("music.volume"), "the music.volume hook is registered")
|
||||
|
||||
-- Before the fix this call raised inside applyVolume; reaching the next line
|
||||
-- at all is the core of the regression.
|
||||
Music.play(data, "TEST")
|
||||
T.check(#calls >= 1, "applyVolume ran the hook during play without crashing")
|
||||
|
||||
-- A second application, now that the song is current: state must resolve to
|
||||
-- the real playback table, so the context carries the live song + scale.
|
||||
local before = #calls
|
||||
Music.setVolumeLevel(5)
|
||||
T.check(#calls > before, "setVolumeLevel re-applies volume through the hook")
|
||||
local ctx = calls[#calls]
|
||||
T.check(type(ctx) == "table", "the hook receives a context table")
|
||||
T.eq(ctx.song, "TEST", "ctx.song is the live song (state resolved, not nil)")
|
||||
T.eq(ctx.optionScale, 5 / 7, "ctx.optionScale reflects the 0-7 volume level")
|
||||
|
||||
T.finish("music_volume_hook_state")
|
||||
@@ -0,0 +1,113 @@
|
||||
-- Graphics performance tier (src/core/Performance.lua): the AUTO device
|
||||
-- heuristic, tier normalization/labels, per-tier caps, the option-row
|
||||
-- cycle, and applyOptions recording the live tier. Pure logic over
|
||||
-- stubbed love/jit globals, so it needs no ROM and runs in the T2 tier.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Performance = require("src.core.Performance")
|
||||
|
||||
-- detect() reads the live `love` and `jit` globals; swap them per scenario
|
||||
-- and restore afterward so nothing leaks between checks (or into luajit).
|
||||
local realLove, realJit = love, jit
|
||||
|
||||
local function device(os, arch, cores)
|
||||
love = {
|
||||
system = {
|
||||
getOS = function() return os end,
|
||||
getProcessorCount = function() return cores end,
|
||||
},
|
||||
}
|
||||
jit = arch and { arch = arch } or nil
|
||||
end
|
||||
|
||||
local function restore()
|
||||
love, jit = realLove, realJit
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- detect
|
||||
device("Linux", "arm64", 4)
|
||||
T.eq(Performance.detect(), "low", "ARM64 Linux handheld -> low")
|
||||
|
||||
device("Linux", "arm", 2)
|
||||
T.eq(Performance.detect(), "low", "32-bit ARM Linux handheld -> low")
|
||||
|
||||
device("Android", "arm64", 8)
|
||||
T.eq(Performance.detect(), "balanced", "Android phone -> balanced (not low)")
|
||||
|
||||
device("iOS", "arm64", 6)
|
||||
T.eq(Performance.detect(), "balanced", "iOS -> balanced")
|
||||
|
||||
device("OS X", "x64", 8)
|
||||
T.eq(Performance.detect(), "high", "8-core desktop -> high")
|
||||
|
||||
device("Windows", "x64", 2)
|
||||
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
|
||||
|
||||
device("Windows", "x86", 1)
|
||||
T.eq(Performance.detect(), "balanced", "single-core desktop -> balanced")
|
||||
|
||||
-- No love, no jit (a plain-Lua tool/test): nothing looks weak -> high, so
|
||||
-- tooling that runs applyOptions is never surprised by a clamp.
|
||||
love, jit = nil, nil
|
||||
T.eq(Performance.detect(), "high", "no love/jit -> high (no clamping)")
|
||||
restore()
|
||||
|
||||
-- ---------------------------------------------------------------- resolve
|
||||
T.eq(Performance.resolve("high"), "high", "resolve passes concrete high")
|
||||
T.eq(Performance.resolve("balanced"), "balanced", "resolve passes balanced")
|
||||
T.eq(Performance.resolve("low"), "low", "resolve passes low")
|
||||
|
||||
device("Linux", "arm64", 4)
|
||||
T.eq(Performance.resolve("auto"), "low", "resolve auto -> detect (handheld)")
|
||||
T.eq(Performance.resolve(nil), "low", "resolve nil -> detect")
|
||||
T.eq(Performance.resolve("bogus"), "low", "resolve garbage -> detect")
|
||||
restore()
|
||||
|
||||
-- --------------------------------------------------------------- normalize
|
||||
T.eq(Performance.normalize("auto"), "auto", "normalize keeps auto")
|
||||
T.eq(Performance.normalize("low"), "low", "normalize keeps low")
|
||||
T.eq(Performance.normalize("xyz"), "auto", "normalize garbage -> auto")
|
||||
T.eq(Performance.normalize(nil), "auto", "normalize nil -> auto")
|
||||
|
||||
-- ------------------------------------------------------------------- label
|
||||
T.eq(Performance.label("low"), "LOW", "label low")
|
||||
T.eq(Performance.label("balanced"), "BALANCED", "label balanced")
|
||||
T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
|
||||
|
||||
-- -------------------------------------------------------------------- caps
|
||||
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
|
||||
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
|
||||
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
|
||||
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
|
||||
local bal = Performance.CAPS.balanced
|
||||
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
|
||||
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
|
||||
|
||||
device("Linux", "arm64", 4)
|
||||
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
|
||||
restore()
|
||||
|
||||
-- ------------------------------------------------------------- applyOptions
|
||||
local caps = Performance.applyOptions({ performance = "low" })
|
||||
T.eq(caps, Performance.CAPS.low, "applyOptions returns the resolved caps")
|
||||
T.eq(Performance.tier, "low", "applyOptions records the live tier")
|
||||
|
||||
Performance.applyOptions({ performance = "high" })
|
||||
T.eq(Performance.tier, "high", "applyOptions high tier")
|
||||
|
||||
device("Linux", "arm64", 4)
|
||||
Performance.applyOptions(nil)
|
||||
T.eq(Performance.tier, "low", "applyOptions(nil) resolves auto to the device tier")
|
||||
restore()
|
||||
|
||||
-- ------------------------------------------------------------------- cycle
|
||||
T.eq(Performance.cycle("auto", 1), "high", "cycle auto -> high")
|
||||
T.eq(Performance.cycle("high", 1), "balanced", "cycle high -> balanced")
|
||||
T.eq(Performance.cycle("balanced", 1), "low", "cycle balanced -> low")
|
||||
T.eq(Performance.cycle("low", 1), "auto", "cycle low wraps to auto")
|
||||
T.eq(Performance.cycle("auto", -1), "low", "cycle back auto -> low")
|
||||
T.eq(Performance.cycle("high", -1), "auto", "cycle back high -> auto")
|
||||
|
||||
T.finish("performance_tiers")
|
||||
@@ -203,4 +203,12 @@ stub.mouse = {
|
||||
|
||||
stub.timer = { getTime = function() return 0 end }
|
||||
|
||||
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
|
||||
stub.window = {
|
||||
getSafeArea = function()
|
||||
local ww, wh = stub.graphics.getDimensions()
|
||||
return 0, 0, ww, wh
|
||||
end,
|
||||
}
|
||||
|
||||
return stub
|
||||
|
||||
+16
-14
@@ -282,7 +282,8 @@ local function optGame()
|
||||
end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
|
||||
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
||||
"performance", "colors",
|
||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
|
||||
"speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
@@ -320,35 +321,36 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows
|
||||
-- ZOOM / VOID FILL rows (indices shifted +1 by the PERFORMANCE row spliced
|
||||
-- in ahead of COLORS)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
Zoom.offset = 0
|
||||
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[13].step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
om.rows[13].step(om.game, 1)
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
"VOID FILL row cycles TREES → WATER")
|
||||
om.rows[13].step(om.game, 1)
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
om.rows[13].step(om.game, 1)
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[15].value(om.game) == "60",
|
||||
check(om.rows[16].value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[15].step(om.game, 1)
|
||||
om.rows[16].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[15].step(om.game, 1)
|
||||
om.rows[16].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[15].step(om.game, -1)
|
||||
om.rows[16].step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
@@ -378,7 +380,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[17].activate(mgGame)
|
||||
om.rows[18].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -388,7 +390,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[18].activate(cbGame)
|
||||
om.rows[19].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- T4: constants.bagSize controls the bag through the public mod API while
|
||||
-- vanilla and existing-save behavior remain unchanged.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local CAPACITY_MOD = {
|
||||
["mods/fix_small_bag/manifest.json"] = [[{
|
||||
"id": "fix_small_bag",
|
||||
"name": "Fixture Small Bag",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_small_bag/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.constants:patch("bagSize", 2)
|
||||
]],
|
||||
}
|
||||
|
||||
-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit.
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadNone({ data = data })
|
||||
T.eq(#run.errors, 0, "the no-mod baseline loads cleanly")
|
||||
T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots")
|
||||
T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20")
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- The public constants registry changes both the reported and enforced cap.
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadMods({ "mods/fix_small_bag" },
|
||||
{ data = data, fs = T.sdk.memfs(CAPACITY_MOD) })
|
||||
T.eq(#run.errors, 0, "the capacity mod loads cleanly")
|
||||
T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize")
|
||||
|
||||
local save = { inventory = {} }
|
||||
T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits")
|
||||
T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits")
|
||||
T.check(not Bag.add(save, "FIX_TM", 1, data),
|
||||
"a new item is refused at the modded limit")
|
||||
T.eq(Bag.slots(save), 2, "a refused add does not change the bag")
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- Saves are dictionaries, not fixed arrays: lowering the active cap never
|
||||
-- truncates an older or modded save. Existing stacks remain usable while a
|
||||
-- new item waits until the player makes enough room.
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
data.constants.bagSize = 1
|
||||
local save = {
|
||||
inventory = { FIX_POTION = 1, FIX_BALL = 1 },
|
||||
bagOrder = { "FIX_POTION", "FIX_BALL" },
|
||||
}
|
||||
T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots")
|
||||
T.check(Bag.add(save, "FIX_POTION", 1, data),
|
||||
"an over-cap save can still add to an existing stack")
|
||||
T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated")
|
||||
T.check(not Bag.add(save, "FIX_TM", 1, data),
|
||||
"an over-cap save cannot add another item kind")
|
||||
T.eq(Bag.slots(save), 2, "the compatibility path never drops items")
|
||||
end
|
||||
|
||||
T.finish("bag_capacity")
|
||||
@@ -0,0 +1,160 @@
|
||||
-- #553: "App makes user import both game and/or mods twice before installing."
|
||||
--
|
||||
-- Android's SAF picker is a separate activity, and Android may destroy
|
||||
-- GameActivity while it is up (memory pressure, or "Don't keep activities").
|
||||
-- When that happens the app RESTARTS rather than resuming, so the
|
||||
-- love.focus(true) that RomImporter:focus consumes a pick on never arrives.
|
||||
-- GameActivity has already written picked_rom.gb / picked_mod.zip into the save
|
||||
-- dir, but nothing scanned for it, so the file sat there until the player
|
||||
-- tapped Import a second time and chooseMod/choose found it by hand. Whether it
|
||||
-- happened at all depended on memory pressure, which is why the report says
|
||||
-- "may be random".
|
||||
--
|
||||
-- _pollPickedFiles was the fix that already existed, gated to iOS. These checks
|
||||
-- pin it armed on Android too, and pin the timeout that keeps a cancelled
|
||||
-- picker from scanning the save dir for the rest of the session.
|
||||
--
|
||||
-- Self-contained: `luajit tests/rom_importer_double_pick_test.lua`; also
|
||||
-- dofile'd by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer double pick (#553)")
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
getDirectoryItems = love.filesystem.getDirectoryItems,
|
||||
getInfo = love.filesystem.getInfo,
|
||||
read = love.filesystem.read,
|
||||
remove = love.filesystem.remove,
|
||||
}
|
||||
|
||||
-- A fake save dir we can drop a delivered pick into.
|
||||
local saveDir = {}
|
||||
love.filesystem.getDirectoryItems = function()
|
||||
local names = {}
|
||||
for name in pairs(saveDir) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
love.filesystem.getInfo = function(name, kind)
|
||||
if saveDir[name] then return { type = kind or "file" } end
|
||||
return nil
|
||||
end
|
||||
love.filesystem.read = function(name) return saveDir[name] end
|
||||
love.filesystem.remove = function(name) saveDir[name] = nil; return true end
|
||||
|
||||
local function importer(os)
|
||||
love.system.getOS = function() return os end
|
||||
local ri = RomImporter.new(function() end, { launcher = true })
|
||||
ri.ready = { red = false, blue = false, yellow = false }
|
||||
return ri
|
||||
end
|
||||
|
||||
-- 1. The regression itself: Android must boot armed, or a pick delivered while
|
||||
-- the activity was dead is invisible until the next tap.
|
||||
local android = importer("Android")
|
||||
check(android.pickPending,
|
||||
"Android boots with a pick poll armed so a restart-delivered file is consumed")
|
||||
|
||||
local ios = importer("iOS")
|
||||
check(ios.pickPending, "iOS still boots armed (unchanged by this fix)")
|
||||
|
||||
love.system.getOS = function() return "OS X" end
|
||||
local desktop = RomImporter.new(function() end, { launcher = true })
|
||||
check(not desktop.pickPending, "desktop does not poll: it has no save-dir picks")
|
||||
|
||||
-- 2. The poll consumes a mod pick with no focus event at all, which is exactly
|
||||
-- the destroyed-activity case. Before the fix this ran only on iOS, so on
|
||||
-- Android nothing happened here and the file waited for a second tap.
|
||||
local ri = importer("Android")
|
||||
local consumed = false
|
||||
ri.focus = function(self, f) if f then consumed = true end end
|
||||
saveDir["picked_mod.zip"] = "PK\003\004 pretend mod"
|
||||
ri:_pollPickedFiles(0.6)
|
||||
check(consumed, "a delivered mod pick is consumed by the poll, with no refocus")
|
||||
check(not ri.pickPending, "and the poll disarms once it has fired")
|
||||
|
||||
-- 3. A ROM pick goes the same way.
|
||||
local ri2 = importer("Android")
|
||||
local consumed2 = false
|
||||
ri2.focus = function(self, f) if f then consumed2 = true end end
|
||||
saveDir = { ["picked_rom.gb"] = "not a real cart" }
|
||||
ri2:_pollPickedFiles(0.6)
|
||||
check(consumed2, "a delivered ROM pick is consumed by the poll too")
|
||||
|
||||
-- 4. Nothing delivered means nothing happens, and the poll gives up rather than
|
||||
-- scanning the save directory forever after a cancelled picker.
|
||||
saveDir = {}
|
||||
local ri3 = importer("Android")
|
||||
local fired = false
|
||||
ri3.focus = function(self, f) if f then fired = true end end
|
||||
ri3:_pollPickedFiles(0.6)
|
||||
check(not fired, "an empty save dir consumes nothing")
|
||||
check(ri3.pickPending, "and stays armed, waiting for the pick to land")
|
||||
-- No timeout on purpose: a 120s disarm silently dropped iOS imports, because the
|
||||
-- picker there is an in-process sheet and update() keeps running while the
|
||||
-- player browses Files. Staying armed costs a directory listing; disarming cost
|
||||
-- the import, with no error shown.
|
||||
ri3:_pollPickedFiles(600)
|
||||
check(ri3.pickPending, "and is still armed after a long browse in the picker")
|
||||
|
||||
-- 5. A pick that is still importing must not be double-started.
|
||||
saveDir = { ["picked_mod.zip"] = "PK\003\004" }
|
||||
local ri4 = importer("Android")
|
||||
local fired4 = false
|
||||
ri4.focus = function(self, f) if f then fired4 = true end end
|
||||
ri4.workState = "working"
|
||||
ri4:_pollPickedFiles(0.6)
|
||||
check(not fired4, "the poll stands down while an import is already running")
|
||||
|
||||
-- 6. THE ACTUAL #553 CAUSE lives in main.lua, not here. On Android
|
||||
-- love.touchpressed forwards the primary touch to Importer:mousepressed AND
|
||||
-- LOVE synthesizes a mouse press for the same touch, so one tap ran choose()
|
||||
-- twice and opened two stacked SAF pickers: the player picked their ROM, the
|
||||
-- top picker closed, and the second was underneath asking again. main.lua now
|
||||
-- drops the synthesized event (istouch), which is the same guard TouchEditor
|
||||
-- already had and the launcher was missing.
|
||||
--
|
||||
-- Deliberately NOT deduped here: tests/engine/save_import_retry_bug420.lua
|
||||
-- and rom_pick_error_bug442.lua both pin the opposite contract, that a second
|
||||
-- chooseMod()/choose() reopens the picker rather than retrying a stale file.
|
||||
-- Swallowing a second call in the importer breaks #420 and #442, so the fix
|
||||
-- belongs at the dispatch layer that is actually double-firing.
|
||||
saveDir = {}
|
||||
local picks = 0
|
||||
love.system.pickFile = function() picks = picks + 1; return true end
|
||||
local contract = importer("Android")
|
||||
contract:choose("red")
|
||||
contract:choose("red")
|
||||
check(picks == 2,
|
||||
"choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")")
|
||||
|
||||
-- 7. iOS taps must survive the Android double-fire guard. love.touchpressed in
|
||||
-- main.lua returns early on iOS and never forwards, so the synthesized
|
||||
-- love.mousepressed is the ONLY event the launcher gets there. Filtering
|
||||
-- istouch on both platforms killed every tap on iOS. The guard is Android
|
||||
-- only, and this pins the asymmetry the guard depends on.
|
||||
local touchForwardsToImporter = {
|
||||
Android = true, -- love.touchpressed -> Importer:mousepressed
|
||||
iOS = false, -- returns early; mousepressed(istouch=true) is the only path
|
||||
}
|
||||
for os, forwards in pairs(touchForwardsToImporter) do
|
||||
local dropSynthesized = (os == "Android")
|
||||
check(dropSynthesized == forwards,
|
||||
os .. ": the synthesized mouse press is dropped only where touch already forwarded")
|
||||
end
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.filesystem.getDirectoryItems = saved.getDirectoryItems
|
||||
love.filesystem.getInfo = saved.getInfo
|
||||
love.filesystem.read = saved.read
|
||||
love.filesystem.remove = saved.remove
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,97 @@
|
||||
-- #482: pressing Import ROM crashed on iOS with
|
||||
--
|
||||
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
|
||||
--
|
||||
-- love.system.pickFile is a native bridge, not part of LÖVE, so it is absent
|
||||
-- on any mobile build that did not compile one. The mobile path called it
|
||||
-- unguarded, so a missing bridge took the app down instead of falling back to
|
||||
-- the copy-into-the-save-folder flow each caller already had for a device with
|
||||
-- no document picker.
|
||||
--
|
||||
-- Self-contained: `luajit tests/rom_importer_no_picker_test.lua`; also
|
||||
-- dofile'd by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer without a picker")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
}
|
||||
|
||||
love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end
|
||||
love.system.getOS = function() return "iOS" end
|
||||
-- the condition the crash reports were in: a build with no native bridge
|
||||
love.system.pickFile = nil
|
||||
|
||||
local function freshImporter()
|
||||
return setmetatable({
|
||||
android = true, -- RomImporter treats iOS as the mobile path
|
||||
workState = nil,
|
||||
ready = { red = false, blue = false, yellow = false },
|
||||
notice = nil,
|
||||
modNotice = nil,
|
||||
saveNotice = {},
|
||||
chooseVersion = nil,
|
||||
startData = function(self, data, displayName)
|
||||
self._started = { data = data, name = displayName }
|
||||
end,
|
||||
_installMod = function(self, name) self._mod = name end,
|
||||
_importSave = function(self, version, name) self._save = name end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- ------- Import ROM
|
||||
|
||||
local ri = freshImporter()
|
||||
local ok, err = pcall(function() ri:choose("red") end)
|
||||
check(ok, "Import ROM does not crash without a picker: " .. tostring(err))
|
||||
check(ri.notice ~= nil, "and it explains itself instead of doing nothing")
|
||||
eq(ri.notice.detail, "/tmp/pokemon-love2d",
|
||||
"pointing at the folder to copy the ROM into")
|
||||
check(not ri.pickPending, "with no pick left pending on a picker that never opened")
|
||||
|
||||
-- Yellow takes the same path: the crash was never version-specific.
|
||||
ri = freshImporter()
|
||||
ok = pcall(function() ri:choose("yellow") end)
|
||||
check(ok, "Import ROM for Yellow does not crash either")
|
||||
|
||||
-- ------- Import mod .zip
|
||||
|
||||
ri = freshImporter()
|
||||
ok, err = pcall(function() ri:chooseMod() end)
|
||||
check(ok, "Import mod does not crash without a picker: " .. tostring(err))
|
||||
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
|
||||
"and reports that the picker could not open")
|
||||
|
||||
-- ------- Import save
|
||||
|
||||
ri = freshImporter()
|
||||
ok, err = pcall(function() ri:chooseSaveImport("red") end)
|
||||
check(ok, "Import save does not crash without a picker: " .. tostring(err))
|
||||
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.ok == false,
|
||||
"and reports that the picker could not open")
|
||||
eq(ri.androidPendingVersion, nil,
|
||||
"leaving no pending import for a pick that never happened")
|
||||
|
||||
-- ------- and the picker is still used when the bridge IS there
|
||||
|
||||
local pickCalls = 0
|
||||
love.system.pickFile = function() pickCalls = pickCalls + 1 return true end
|
||||
ri = freshImporter()
|
||||
ri:choose("red")
|
||||
eq(pickCalls, 1, "a build WITH the bridge still opens the picker")
|
||||
check(ri.pickPending, "and waits for the pick to come back")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
|
||||
S.finish()
|
||||
+33
-1
@@ -1696,6 +1696,7 @@ end
|
||||
-- ---------------------------------------------------------------- touch controls layout (#327)
|
||||
do
|
||||
local TC = require("src.core.TouchControls")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local cfg = TC.normalizeConfig(nil)
|
||||
eq(cfg.enabled, true, "touchControls default enabled")
|
||||
check(cfg.positions == nil, "touchControls default positions nil")
|
||||
@@ -1721,6 +1722,12 @@ do
|
||||
check(L.a.cx > 200, "default A on right half")
|
||||
check(L.dpad.cy > 400, "default d-pad in bottom half")
|
||||
|
||||
-- safe-area origin shifts defaults without changing relative layout
|
||||
local Ls = TC.defaultLayout(400, 800, 20, 30)
|
||||
eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
|
||||
eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
|
||||
eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
|
||||
|
||||
-- applyOptions + visible gate (no real images needed for the gate)
|
||||
TC.enabled = true
|
||||
TC.active = true
|
||||
@@ -1743,16 +1750,37 @@ do
|
||||
-- custom position applied through layout()
|
||||
local g = love.graphics
|
||||
local oldDim, oldFont = g.getDimensions, g.newFont
|
||||
local oldSafe = love.window and love.window.getSafeArea
|
||||
g.getDimensions = function() return 400, 800 end
|
||||
g.newFont = function() return { getWidth = function() return 10 end,
|
||||
getHeight = function() return 10 end } end
|
||||
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
|
||||
love.window = love.window or {}
|
||||
love.window.getSafeArea = function() return 0, 0, 400, 800 end
|
||||
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
|
||||
local lay = TC:layout()
|
||||
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
|
||||
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
|
||||
|
||||
-- inset safe area: custom positions stay inside the usable rect
|
||||
love.window.getSafeArea = function() return 10, 40, 380, 720 end
|
||||
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
|
||||
lay = TC:layout()
|
||||
eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
|
||||
eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
|
||||
check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
|
||||
"safe-area dpad clears bottom inset")
|
||||
|
||||
local x, y, w, h = SafeArea.rect()
|
||||
eq(x, 10, "SafeArea.rect x")
|
||||
eq(y, 40, "SafeArea.rect y")
|
||||
eq(w, 380, "SafeArea.rect w")
|
||||
eq(h, 720, "SafeArea.rect h")
|
||||
|
||||
TC:clearPositions()
|
||||
check(TC.positions == nil, "clearPositions wipes overrides")
|
||||
g.getDimensions, g.newFont = oldDim, oldFont
|
||||
if oldSafe then love.window.getSafeArea = oldSafe
|
||||
else love.window.getSafeArea = nil end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
|
||||
@@ -3337,6 +3365,10 @@ runSuites({ "tests/rom_importer_android_pick_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- Android mod / save SAF pick
|
||||
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- import with no picker (#482)
|
||||
runSuites({ "tests/rom_importer_no_picker_test.lua" })
|
||||
runSuites({ "tests/rom_importer_double_pick_test.lua" })
|
||||
-- ---------------------------------------------- parity workstream tests
|
||||
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
||||
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
||||
|
||||
@@ -181,12 +181,13 @@ end
|
||||
do
|
||||
-- the bag has a hard slot cap; the picker must refuse past it
|
||||
local S = newState()
|
||||
local capacity = Bag.capacity(S.data)
|
||||
local added = 0
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end
|
||||
if added >= Bag.CAPACITY then break end
|
||||
if added >= capacity then break end
|
||||
end
|
||||
eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap")
|
||||
eq(Bag.slots(S.save), capacity, "the bag filled to its cap")
|
||||
S.dirty = false
|
||||
local spare
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
|
||||
@@ -427,7 +427,7 @@ local function tabCount(id)
|
||||
return tostring(n)
|
||||
elseif id == "items" then
|
||||
local Bag = require("src.inventory.Bag")
|
||||
return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY)
|
||||
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
|
||||
elseif id == "events" then
|
||||
local n = 0
|
||||
for _ in pairs(S.save.flags or {}) do n = n + 1 end
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
--
|
||||
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
|
||||
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
|
||||
-- item stack 99 and 20 bag slots (src/inventory/Bag).
|
||||
-- item stack 99 and the configured bag capacity (20 by default;
|
||||
-- src/inventory/Bag).
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PartyMod = require("src.pokemon.Party")
|
||||
@@ -338,11 +339,13 @@ end
|
||||
|
||||
function Ops.addToBag(S, id)
|
||||
if not id then return Ops.say(S, "Pick an item first") end
|
||||
if Bag.add(S.save, id, 1) then
|
||||
local capacity = Bag.capacity(S.data)
|
||||
if Bag.add(S.save, id, 1, S.data) then
|
||||
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
|
||||
:format(id, Bag.slots(S.save), Bag.CAPACITY))
|
||||
:format(id, Bag.slots(S.save), capacity))
|
||||
end
|
||||
return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY))
|
||||
return Ops.say(S, ("Bag is full (%d/%d slots)")
|
||||
:format(Bag.slots(S.save), capacity))
|
||||
end
|
||||
|
||||
function Ops.bagAdjust(S, id, delta)
|
||||
@@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta)
|
||||
if have >= Ops.STACK_MAX then
|
||||
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
|
||||
end
|
||||
Bag.add(S.save, id, delta)
|
||||
Bag.add(S.save, id, delta, S.data)
|
||||
else
|
||||
Bag.remove(S.save, id, -delta)
|
||||
if not S.save.inventory[id] then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Items panel: money, the shared item picker, badges, the 20-slot bag
|
||||
-- Items panel: money, the shared item picker, badges, the configurable bag
|
||||
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
|
||||
-- S.save.pcItems dict with no slot cap).
|
||||
--
|
||||
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
|
||||
-- --------------------------------------------------------------- bag
|
||||
local order = Bag.order(S.save)
|
||||
local capacity = Bag.capacity(S.data)
|
||||
Kit.card(bagX, y, listW, h)
|
||||
Kit.caption(bagX + pad, y + pad, "BAG")
|
||||
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY),
|
||||
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity),
|
||||
bagX + listW - pad, y + pad, PAL.caption)
|
||||
local barY = y + pad + Kit.textHeight("caption") + 8 * s
|
||||
local slotFrac = Bag.slots(S.save) / Bag.CAPACITY
|
||||
local slotFrac = Bag.slots(S.save) / capacity
|
||||
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
|
||||
slotFrac >= 1 and PAL.yellow or PAL.blue)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user