mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 07:21:22 +02:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08121faa3d | |||
| bbe0f0d9ae | |||
| b35fab845a | |||
| d622e83a8e | |||
| 9dd38e06a5 | |||
| 6b4796b08f | |||
| ab61d08fe9 | |||
| c94ff016d9 | |||
| 85e47bc422 | |||
| 1850b50ee6 | |||
| ef62962010 | |||
| ba201aa4ad | |||
| 7d6566fa51 | |||
| 1ad6c810fa | |||
| 233082967c | |||
| d76d173b60 | |||
| bc7b96c963 | |||
| aea38240b2 | |||
| 56fc4653d7 | |||
| 0142468125 | |||
| e7d898625d | |||
| 5ee4a3cd35 | |||
| 4794ddcfdf | |||
| 6503e982db | |||
| 72ce1fe33e | |||
| 493fca7297 | |||
| 7d5856f93b | |||
| ef43814efc | |||
| bed1062a5f | |||
| 190c03cfca | |||
| cec264ad81 | |||
| 041051e154 | |||
| 92fd4221a0 | |||
| 07c828cab0 | |||
| b89771508e | |||
| 2c1c2fe080 | |||
| 6f4c59df8b | |||
| 0d14980205 | |||
| 1bd6e35931 | |||
| da73a4fe25 | |||
| cd4ef14f13 | |||
| d575a64287 | |||
| ff826ce01e | |||
| 0ab4ef2755 | |||
| 6f343d9b29 | |||
| 41790637f0 | |||
| 4ab0ac433b | |||
| 44c304574b | |||
| c8b05e7aa1 | |||
| 8c6c360ce0 | |||
| 6b6b8f46a1 | |||
| 2d62805128 | |||
| d27c59b18d | |||
| 1905261c5b | |||
| 333949ce0d | |||
| fb95dc5b6a | |||
| 96ed862b50 | |||
| 58681fe62e | |||
| 68fc01dd1c | |||
| cec19db5e0 | |||
| 38d515f547 | |||
| 7e069df740 | |||
| ca700c44d7 | |||
| 2ad2e028d1 | |||
| b286e6584f | |||
| 654650e3ff | |||
| fad7443f94 | |||
| f895293217 | |||
| ebf44f20d3 | |||
| ca4d3d283c | |||
| 5807b8d836 | |||
| b550db3abb | |||
| 01c5eb2189 | |||
| dd0f0e0892 | |||
| d17f0725d8 | |||
| 3b7fc42858 | |||
| c9221daa90 | |||
| 0147e3d87b | |||
| fb2cebd54f | |||
| cb504e5328 | |||
| bdbdbf2ed6 | |||
| 7ae469603c | |||
| 77e189bc2e | |||
| e88f2ef060 | |||
| 16eefcde68 | |||
| 6d55da0ed2 | |||
| 745374d8ae | |||
| 3470e3c4ae | |||
| f76a8b2f96 | |||
| 24a7ff86d4 | |||
| c2e1d810f9 | |||
| 6d6ccb5778 | |||
| d54f9a02e0 | |||
| e094b73536 | |||
| 6e7b9c0848 | |||
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 70d7b6383e | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 63b31d234c |
@@ -33,6 +33,8 @@ body:
|
|||||||
- Blue
|
- Blue
|
||||||
- Yellow
|
- Yellow
|
||||||
- Gold
|
- Gold
|
||||||
|
- Silver
|
||||||
|
- Crystal
|
||||||
- N/A
|
- N/A
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -468,3 +468,24 @@ jobs:
|
|||||||
if [ "$found" = "0" ]; then
|
if [ "$found" = "0" ]; then
|
||||||
echo "no committed mods to lint"
|
echo "no committed mods to lint"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
luacheck:
|
||||||
|
name: engine lint (luacheck)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: install luacheck
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||||
|
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||||
|
luacheck --version
|
||||||
|
|
||||||
|
- name: luacheck gate (undefined globals, unreachable code)
|
||||||
|
run: ./scripts/lint.sh --gate
|
||||||
|
|
||||||
|
- name: luacheck full report (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: ./scripts/lint.sh
|
||||||
|
|||||||
+9
-2
@@ -1,5 +1,9 @@
|
|||||||
# Generated from a user-provided Pokemon Red ROM.
|
# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
|
||||||
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean
|
# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean
|
||||||
|
# Yellow Surfing Pikachu minigame art also lands here on import:
|
||||||
|
# assets/generated/minigame/surf_1{a,b,c}.png
|
||||||
|
# assets/generated/minigame/title_bg.png
|
||||||
|
# assets/generated/minigame/intro_pika_{0,1,2,3}.png
|
||||||
data/generated/
|
data/generated/
|
||||||
assets/generated/
|
assets/generated/
|
||||||
|
|
||||||
@@ -62,6 +66,9 @@ mobile/dist/
|
|||||||
# ROM cache exactly like data/generated/, so it is never committable.
|
# ROM cache exactly like data/generated/, so it is never committable.
|
||||||
/build/tiled/
|
/build/tiled/
|
||||||
|
|
||||||
|
# Local visual scratch from minigame development (not wired to CI).
|
||||||
|
/tests/fixtures/
|
||||||
|
|
||||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||||
mobile/ios/bundle_id.local
|
mobile/ios/bundle_id.local
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ read_globals = {
|
|||||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
-- 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.
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
table = { fields = { "unpack" } },
|
table = { fields = { "unpack" } },
|
||||||
|
"POKEPORT_DISPLAY_COMPANION",
|
||||||
|
"POKEPORT_EDITOR_MODE",
|
||||||
|
"rawlen",
|
||||||
|
package = { fields = { "searchers" } },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Vendored/native trees and the test suites have their own conventions.
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
@@ -30,6 +34,7 @@ exclude_files = {
|
|||||||
"mobile/",
|
"mobile/",
|
||||||
"tests/",
|
"tests/",
|
||||||
"tools/save-editor/",
|
"tools/save-editor/",
|
||||||
|
"tools/save_convert/vendor/",
|
||||||
}
|
}
|
||||||
|
|
||||||
ignore = {
|
ignore = {
|
||||||
|
|||||||
+16
-14
@@ -140,8 +140,8 @@ ship text.
|
|||||||
|
|
||||||
### 4. `games` (and the legacy `gen2compat`)
|
### 4. `games` (and the legacy `gen2compat`)
|
||||||
|
|
||||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
|
||||||
overworld, script VM and save format. The mod API is shared across both
|
engine, overworld, script VM and save format. The mod API is shared across both
|
||||||
generations (same hook names, same event names, same registry names) but Gen 2
|
generations (same hook names, same event names, same registry names) but Gen 2
|
||||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||||
|
|
||||||
@@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
|
||||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
|
||||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
|
||||||
|
by every game -- targeting is declared, never filed.
|
||||||
|
|
||||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
|
||||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||||
have actually run your mod on Gold or Silver.
|
run your mod on Gold, Silver or Crystal.
|
||||||
|
|
||||||
Every token is enforced, per game: the loader gates on the same
|
Every token is enforced, per game: the loader gates on the same
|
||||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||||
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
|
|||||||
before the key existed changes behavior; list both generations or say `"all"`
|
before the key existed changes behavior; list both generations or say `"all"`
|
||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
|
||||||
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
|
||||||
writes with a report, and which hooks and events are still to come.
|
and drop their writes with a report, and which hooks and events are still to
|
||||||
|
come.
|
||||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
Gen 1 mod, and it is the one to start from.
|
Gen 1 mod, and it is the one to start from.
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
[](https://youtu.be/yi7LkWQPKKM)
|
[](https://youtu.be/yi7LkWQPKKM)
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||||
Silver ROM is the only game content input.
|
Silver, or Crystal ROM is the only game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
not copied into the cache. Later launches load the private generated cache and
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
|
||||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
|
||||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
|
||||||
|
construction, and Crystal is the newest of the three, so the launcher lists it
|
||||||
|
as Crystal (Beta).
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -71,14 +73,17 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
|||||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
game starts automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
|
||||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
|
||||||
|
game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||||
|
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||||
|
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
@@ -122,24 +127,23 @@ supported out of the box.
|
|||||||
| `2` | Cycle COLORS |
|
| `2` | Cycle COLORS |
|
||||||
| `3` | Cycle TILT (free-roam overworld) |
|
| `3` | Cycle TILT (free-roam overworld) |
|
||||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||||
| `5` | Cycle GBC FX |
|
|
||||||
| `F1` | Save |
|
| `F1` | Save |
|
||||||
| `F2` | Load |
|
| `F2` | Load |
|
||||||
| `F10` | Open / close the mod manager |
|
| `F10` | Open / close the mod manager |
|
||||||
|
|
||||||
|
|
||||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the
|
||||||
Options menu and persist in `options.lua`.
|
Options menu and persist in `options.lua`.
|
||||||
|
|
||||||
### Low-end devices
|
### Low-end devices
|
||||||
|
|
||||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt),
|
||||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
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
|
your tilt/zoom preferences without forgetting them. Details in
|
||||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
### Rulesets
|
### Rulesets
|
||||||
@@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
|
||||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC"
|
|||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
|||||||
+3
-13
@@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC"
|
|||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||||
printf '%s\n' "$payload_list" \
|
printf '%s\n' "$payload_list" \
|
||||||
@@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
|||||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||||
|| fail "payload is missing tools/rom_manifest_silver.json"
|
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||||
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|
||||||
|
|| fail "payload is missing tools/rom_manifest_crystal.json"
|
||||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
rm -f "$WORK/game-payload.zip"
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
@@ -228,11 +230,6 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
|||||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||||
# Mali / H700: prefer GLES where available
|
# Mali / H700: prefer GLES where available
|
||||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
|
||||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
|
||||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
|
||||||
# persisted in options.lua.
|
|
||||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
|
||||||
|
|
||||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||||
@@ -323,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
|||||||
|
|
||||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||||
|
|
||||||
### Display options
|
|
||||||
|
|
||||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
|
||||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
|
||||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
|
||||||
`POKEPORT_GBCFX=1`.
|
|
||||||
|
|
||||||
### First run
|
### First run
|
||||||
|
|
||||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ function love.conf(t)
|
|||||||
t.modules.audio = not companion
|
t.modules.audio = not companion
|
||||||
t.modules.joystick = not companion
|
t.modules.joystick = not companion
|
||||||
t.modules.physics = false
|
t.modules.physics = false
|
||||||
|
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
|
||||||
|
-- independent of t.accelerometerjoystick below (which instead maps the
|
||||||
|
-- accelerometer onto joystick axes and stays off -- see that flag's
|
||||||
|
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
|
||||||
|
-- reflective-screen look) has sensor data available without reviving #468.
|
||||||
|
t.modules.sensor = true
|
||||||
|
|
||||||
-- love.system is not loaded during love.conf; love._os is set by the
|
-- love.system is not loaded during love.conf; love._os is set by the
|
||||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||||
|
|||||||
@@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
|||||||
|
|
||||||
local function linkCableHelp(game)
|
local function linkCableHelp(game)
|
||||||
local text = game.data.text or {}
|
local text = game.data.text or {}
|
||||||
local items, showMenu, askHeading
|
local items, menu, openMenu
|
||||||
function showMenu()
|
local function closeAll()
|
||||||
game.stack:push(Menu.new(game, items,
|
game.stack:pop()
|
||||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
|
||||||
end
|
|
||||||
function askHeading()
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
|
||||||
showMenu))
|
|
||||||
end
|
end
|
||||||
items = {}
|
items = {}
|
||||||
for i, label in ipairs(LINK_HEADINGS) do
|
for i, label in ipairs(LINK_HEADINGS) do
|
||||||
items[i] = { label = label, onSelect = function()
|
items[i] = { label = label, keepOpen = true, onSelect = function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
text["_LinkCableInfoText" .. i] or label))
|
||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
items[#items + 1] = { label = "STOP READING" }
|
items[#items + 1] = { label = "STOP READING", onSelect = closeAll }
|
||||||
|
menu = Menu.new(game, items,
|
||||||
|
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2,
|
||||||
|
onCancel = closeAll })
|
||||||
|
function openMenu() game.stack:push(menu) end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||||
askHeading))
|
function()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||||
|
nil, { stay = { onShown = openMenu } }))
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,21 +86,15 @@ return {
|
|||||||
{ "jump_if_false", 5 }, -- 2
|
{ "jump_if_false", 5 }, -- 2
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
|
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
|
||||||
{ "jump", 13 }, -- 4
|
{ "jump", 11 }, -- 4
|
||||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
{ "give_pokemon", "EEVEE", 25, false, true }, -- 5
|
||||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
{ "jump_if_false", 10 }, -- 6 (party+box full)
|
||||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
-- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
|
||||||
-- prompt inside give_pokemon and the text row both yield, and a
|
|
||||||
-- script that dies there would leave the EEVEE taken with the ball
|
|
||||||
-- still on the table and the gift claimable again (#426). The row
|
|
||||||
-- count is unchanged, so the numeric jump targets still hold.
|
|
||||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
{ "jump", 11 }, -- 9
|
||||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
{ "show_text", "_BoxIsFullText" }, -- 10
|
||||||
{ "jump", 13 }, -- 11
|
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
return {
|
return {
|
||||||
CELADON_CITY = {
|
CELADON_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
-- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far
|
|
||||||
-- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends.
|
|
||||||
-- The cry playback has no port-side equivalent command, so we just
|
|
||||||
-- show the flavor line.
|
|
||||||
TEXT_CELADONCITY_POLIWRATH = {
|
TEXT_CELADONCITY_POLIWRATH = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonCity.asm:90
|
||||||
|
{"play_cry", "POLIWRATH", true},
|
||||||
{"show_text", "_CeladonCityPoliwrathText"},
|
{"show_text", "_CeladonCityPoliwrathText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,30 +2,24 @@
|
|||||||
return {
|
return {
|
||||||
CELADON_MANSION_1F = {
|
CELADON_MANSION_1F = {
|
||||||
talk = {
|
talk = {
|
||||||
-- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far
|
|
||||||
-- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends.
|
|
||||||
-- The cry playback has no port-side equivalent command, so we just
|
|
||||||
-- show the flavor line.
|
|
||||||
TEXT_CELADONMANSION1F_CLEFAIRY = {
|
TEXT_CELADONMANSION1F_CLEFAIRY = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:27
|
||||||
|
{"play_cry", "CLEFAIRY", true},
|
||||||
{"show_text", "_CeladonMansion1FClefairyText"},
|
{"show_text", "_CeladonMansion1FClefairyText"},
|
||||||
},
|
},
|
||||||
|
|
||||||
-- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far
|
|
||||||
-- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends.
|
|
||||||
-- The cry playback has no port-side equivalent command, so we just
|
|
||||||
-- show the flavor line.
|
|
||||||
TEXT_CELADONMANSION1F_MEOWTH = {
|
TEXT_CELADONMANSION1F_MEOWTH = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:18
|
||||||
|
{"play_cry", "MEOWTH", true},
|
||||||
{"show_text", "_CeladonMansion1FMeowthText"},
|
{"show_text", "_CeladonMansion1FMeowthText"},
|
||||||
},
|
},
|
||||||
|
|
||||||
-- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far
|
|
||||||
-- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends.
|
|
||||||
-- The cry playback has no port-side equivalent command, so we just
|
|
||||||
-- show the flavor line.
|
|
||||||
TEXT_CELADONMANSION1F_NIDORANF = {
|
TEXT_CELADONMANSION1F_NIDORANF = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:33
|
||||||
|
{"play_cry", "NIDORAN_F", true},
|
||||||
{"show_text", "_CeladonMansion1FNidoranFText"},
|
{"show_text", "_CeladonMansion1FNidoranFText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
return {
|
return {
|
||||||
LAVENDER_CUBONE_HOUSE = {
|
LAVENDER_CUBONE_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText,
|
-- LavenderCuboneHouse.asm:10
|
||||||
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
|
|
||||||
-- equivalent in this port, so just show the line.
|
|
||||||
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
|
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
{ "play_cry", "CUBONE", true },
|
||||||
{ "show_text", "_LavenderCuboneHouseCuboneText" },
|
{ "show_text", "_LavenderCuboneHouseCuboneText" },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
|
|||||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||||
},
|
},
|
||||||
|
|
||||||
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then
|
-- scripts/MrFujisHouse.asm:56
|
||||||
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
|
|
||||||
-- show the flavor text.
|
|
||||||
TEXT_MRFUJISHOUSE_PSYDUCK = {
|
TEXT_MRFUJISHOUSE_PSYDUCK = {
|
||||||
|
{ "play_cry", "PSYDUCK", true },
|
||||||
{ "show_text", "_MrFujisHousePsyduckText" },
|
{ "show_text", "_MrFujisHousePsyduckText" },
|
||||||
},
|
},
|
||||||
|
|
||||||
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then
|
-- scripts/MrFujisHouse.asm:63
|
||||||
-- PlayCry(NIDORINO).
|
|
||||||
TEXT_MRFUJISHOUSE_NIDORINO = {
|
TEXT_MRFUJISHOUSE_NIDORINO = {
|
||||||
|
{ "play_cry", "NIDORINO", true },
|
||||||
{ "show_text", "_MrFujisHouseNidorinoText" },
|
{ "show_text", "_MrFujisHouseNidorinoText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
|||||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||||
},
|
},
|
||||||
|
|
||||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
|
||||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||||
|
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
|
|
||||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||||
-- itself, flavor line only.
|
-- shape with SEEL, PlayCry at :89.
|
||||||
TEXT_POKEMONFANCLUB_SEEL = {
|
TEXT_POKEMONFANCLUB_SEEL = {
|
||||||
{ "show_text", "_PokemonFanClubSeelText" },
|
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
|
||||||
|
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
|
||||||
|
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
|
||||||
|
if require("src.core.GameVersion").isYellow() then
|
||||||
|
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
|
||||||
|
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
|
||||||
|
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
return {
|
return {
|
||||||
ROUTE_16_FLY_HOUSE = {
|
ROUTE_16_FLY_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- Route16FlyHouseFearowText: text_asm just prints the one line and
|
-- scripts/Route16FlyHouse.asm:45-51
|
||||||
-- plays the FEAROW cry (no cry-playback command exists in this
|
|
||||||
-- port's Commands vocabulary, so only the text is ported).
|
|
||||||
TEXT_ROUTE16FLYHOUSE_FEAROW = {
|
TEXT_ROUTE16FLYHOUSE_FEAROW = {
|
||||||
|
{ "play_cry", "FEAROW", true },
|
||||||
{ "show_text", "_Route16FlyHouseFearowText" },
|
{ "show_text", "_Route16FlyHouseFearowText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||||
return {
|
return {
|
||||||
SS_ANNE_1F_ROOMS = {
|
SS_ANNE_1F_ROOMS = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
|
||||||
-- the flavor text is ported).
|
|
||||||
return {
|
return {
|
||||||
SS_ANNE_B1F_ROOMS = {
|
SS_ANNE_B1F_ROOMS = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ return {
|
|||||||
|
|
||||||
-- VermilionCityMachopText: cries out, then follows up with a
|
-- VermilionCityMachopText: cries out, then follows up with a
|
||||||
-- second line about stomping the land flat.
|
-- second line about stomping the land flat.
|
||||||
-- (pokered/scripts/VermilionCity.asm)
|
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||||
TEXT_VERMILIONCITY_MACHOP = {
|
TEXT_VERMILIONCITY_MACHOP = {
|
||||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||||
|
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
|
||||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
|
||||||
-- flavor line is ported.
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
VERMILION_PIDGEY_HOUSE = {
|
VERMILION_PIDGEY_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local back = function(text)
|
local back = function(text)
|
||||||
game.stack:push(TextBox.new(game, text, function()
|
game.stack:push(TextBox.new(game, text, function()
|
||||||
ow:scriptMove(ow.player, "down", 1, done)
|
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
+40
-25
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
|||||||
if shipLeft then
|
if shipLeft then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
@@ -377,26 +377,39 @@ M.VERMILION_CITY = {
|
|||||||
ask .. "\f"
|
ask .. "\f"
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
-- scripts/VermilionCity.asm:158 (#1651)
|
||||||
-- flashing the ticket just lets you through -- he never hides, and
|
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||||
-- once the ship has sailed he only reports it gone
|
local Flags = require("src.script.Flags")
|
||||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
local TextBox = require("src.render.TextBox")
|
||||||
{ "face_player" }, -- 1
|
local t = game.data.text
|
||||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
{ "jump_if_true", 11 }, -- 3
|
game.stack:push(TextBox.new(game,
|
||||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
{ "check_item", "S_S_TICKET" }, -- 5
|
done))
|
||||||
{ "jump_if_false", 9 }, -- 6
|
return
|
||||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
end
|
||||||
{ "jump", 12 }, -- 8
|
-- scripts/VermilionCity.asm:195
|
||||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
local p = ow and ow.player
|
||||||
{ "jump", 12 }, -- 10
|
if not p or p.facing == "right"
|
||||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
|
||||||
},
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._VermilionCitySailor1WelcomeToSSAnneText
|
||||||
|
or "Welcome to S.S.\nANNE!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||||
|
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||||
|
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
|
||||||
|
and (t._VermilionCitySailor1FlashedTicketText
|
||||||
|
or "{PLAYER} flashed\nthe S.S.TICKET!")
|
||||||
|
or (t._VermilionCitySailor1YouNeedATicketText
|
||||||
|
or "You need a ticket\nto get aboard.")
|
||||||
|
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
|
|||||||
TEXT_SSANNE2F_RIVAL = {
|
TEXT_SSANNE2F_RIVAL = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||||
{ "jump_if_true", 9 }, -- 3
|
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", 10 }, -- 6
|
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
{ "jump_if_false", 9 }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1033,6 +1047,7 @@ local championsRoomRivalScript = {
|
|||||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||||
-- it, so a returning champion warped straight into the induction.
|
-- it, so a returning champion warped straight into the induction.
|
||||||
{ "jump_if_true", "end" }, -- 3
|
{ "jump_if_true", "end" }, -- 3
|
||||||
|
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
|
||||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||||
|
|||||||
+25
-2
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||||
function()
|
function()
|
||||||
ow:scriptMove(ow.player, back, 1)
|
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
|||||||
local function museumClerk(game, ow, done, onDecline)
|
local function museumClerk(game, ow, done, onDecline)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local t = game.data.text or {}
|
local t = game.data.text or {}
|
||||||
|
local p = ow and ow.player
|
||||||
|
-- scripts/Museum1F.asm:45 (#1690)
|
||||||
|
if p and ((p.cellY == 4 and p.cellX == 13)
|
||||||
|
or (p.cellY == 3 and p.cellX == 12)) then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._Museum1FScientist1DoYouKnowWhatAmberIsText
|
||||||
|
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
game.stack:push(TextBox.new(game, yes
|
||||||
|
and (t._Museum1FScientist1TheresALabSomewhereText
|
||||||
|
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
|
||||||
|
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
|
||||||
|
or "AMBER is fossil-\nized tree sap."), done))
|
||||||
|
end }))
|
||||||
|
return
|
||||||
|
end
|
||||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._Museum1FScientist1TakePlentyOfTimeText
|
t._Museum1FScientist1TakePlentyOfTimeText
|
||||||
or "Take your time,\nand enjoy it all!", done))
|
or "Take your time,\nand enjoy it all!", done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- scripts/Museum1F.asm:58
|
||||||
|
if p and p.cellY ~= 4 then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._Museum1FScientist1GoToOtherSideText
|
||||||
|
or "Please go to the\nother side!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
-- scripts/Museum1F.asm:72
|
-- scripts/Museum1F.asm:72
|
||||||
local money = function() return game.save.money end
|
local money = function() return game.save.money end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
@@ -769,7 +792,7 @@ M.MUSEUM_1F = {
|
|||||||
if y == 4 and (x == 9 or x == 10)
|
if y == 4 and (x == 9 or x == 10)
|
||||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
museumClerk(game, ow, nil, function()
|
museumClerk(game, ow, nil, function()
|
||||||
ow:scriptMove(ow.player, "down", 1)
|
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
+66
-71
@@ -2,6 +2,8 @@
|
|||||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||||
-- and the Hall of Fame record. Each cites its pokered source.
|
-- and the Hall of Fame record. Each cites its pokered source.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -9,20 +11,30 @@ local M = {}
|
|||||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||||
return {
|
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||||
{ "face_player" }, -- 1
|
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||||
{ "check_flag", flag }, -- 2
|
refusedText, followText)
|
||||||
{ "jump_if_true", 9 }, -- 3
|
local rows = {
|
||||||
{ "ask", askText }, -- 4
|
{ "face_player" },
|
||||||
{ "jump_if_false", 10 }, -- 5
|
{ "check_flag", flag },
|
||||||
|
{ "jump_if_true", "already_got" },
|
||||||
|
{ "ask", askText },
|
||||||
|
{ "jump_if_false", "refused" },
|
||||||
-- give-then-print like the three rod-house scripts (GiveItem fills
|
-- give-then-print like the three rod-house scripts (GiveItem fills
|
||||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||||
{ "give_item", rodItem, 1, false }, -- 6
|
{ "give_item", rodItem, 1, false },
|
||||||
{ "show_text", receivedText }, -- 7
|
{ "set_flag", flag },
|
||||||
{ "set_flag", flag }, -- 8
|
{ "show_text", receivedText },
|
||||||
{ "jump", 10 }, -- 9 is below
|
|
||||||
}
|
}
|
||||||
|
if followText then rows[#rows + 1] = { "show_text", followText } end
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "refused" }
|
||||||
|
rows[#rows + 1] = { "show_text", refusedText }
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "already_got" }
|
||||||
|
rows[#rows + 1] = { "show_text", afterText }
|
||||||
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
M.VERMILION_OLD_ROD_HOUSE = {
|
M.VERMILION_OLD_ROD_HOUSE = {
|
||||||
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
|||||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||||
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
||||||
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
|
"OLD_ROD", "EVENT_GOT_OLD_ROD",
|
||||||
|
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
|
||||||
|
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
|
|
||||||
|
|
||||||
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
|||||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||||
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
||||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
|
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
|
||||||
|
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
|
|
||||||
|
|
||||||
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
|||||||
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
||||||
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
||||||
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
||||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD"),
|
"SUPER_ROD", "EVENT_GOT_SUPER_ROD",
|
||||||
|
"_Route12SuperRodHouseFishingGuruThatsDisappointingText",
|
||||||
|
"_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||||
@@ -176,7 +187,7 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
-- so fleeing does not leave you standing on a cell that
|
-- so fleeing does not leave you standing on a cell that
|
||||||
-- immediately re-fires.
|
-- immediately re-fires.
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
ow:afterBattle(result, battle)
|
ow:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
|
|||||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
|
||||||
-- and the open-water ids of the rows she sits in
|
|
||||||
local DOCK_SHIP_COLUMNS = {
|
|
||||||
{ bx = 5, top = 4, bottom = 8 },
|
|
||||||
{ bx = 6, top = 5, bottom = 9 },
|
|
||||||
{ bx = 7, top = 6, bottom = 10 },
|
|
||||||
{ bx = 8, top = 7, bottom = 11 },
|
|
||||||
}
|
|
||||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
|
||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
-- the ship is long gone: erase her right away, and anyone who
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
-- still lands here is sent back out past the guard
|
-- still lands here is sent back out past the guard unless a mod
|
||||||
|
-- explicitly permits this occupied map state. This hook surrounds
|
||||||
|
-- only the ejection decision; map-script registration and dispatch
|
||||||
|
-- stay unchanged, and the departed ship remains erased.
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
ow.map:setBlock(b.bx, b.by, b.water)
|
ow.map:setBlock(b.bx, b.by, b.water)
|
||||||
end
|
end
|
||||||
ow.map.renderer:rebuild()
|
ow.map.renderer:rebuild()
|
||||||
|
local occupancyAllowed = false
|
||||||
|
if Runtime.wantsHook("map.occupancy_allowed") then
|
||||||
|
local player = ow.player or {}
|
||||||
|
occupancyAllowed = Runtime.call("map.occupancy_allowed",
|
||||||
|
function() return false end, game, {
|
||||||
|
mapId = "VERMILION_DOCK",
|
||||||
|
reason = "ss_anne_departed",
|
||||||
|
gameVersion = game.save and game.save.version,
|
||||||
|
x = player.cellX,
|
||||||
|
y = player.cellY,
|
||||||
|
}) == true
|
||||||
|
end
|
||||||
|
if not occupancyAllowed then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||||
or "The ship set sail.", function()
|
or "The ship set sail.", function()
|
||||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||||
end))
|
end))
|
||||||
|
end
|
||||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
|
||||||
-- drift off the funnel, the horn blows, the ship is erased to
|
|
||||||
-- open water, and the player is walked off the dock into the
|
|
||||||
-- city past the guard (VermilionCity's
|
|
||||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
|
||||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
Music.stop()
|
Music.stop()
|
||||||
Music.play(game.data, "Music_Surfing")
|
Music.play(game.data, "Music_Surfing")
|
||||||
local function puff(n, cx)
|
ow:queueScript({
|
||||||
if n <= 0 then return end
|
-- scripts/VermilionDock.asm:50 zeroes the player image index and
|
||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
|
||||||
end
|
{ "face_player_dir", "down" },
|
||||||
puff(3, 15)
|
{ "wait", 120 },
|
||||||
-- scripts/VermilionDock.asm:182-203
|
{ "play_sound", "SS_Anne_Horn" },
|
||||||
local rows = {}
|
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||||
local function setBlock(bx, by, block)
|
{ "ss_anne_departs" },
|
||||||
if bx < 1 or bx > 8 then return end
|
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
|
||||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
{ "play_sound", "SS_Anne_Horn" },
|
||||||
end
|
{ "wait", 120 },
|
||||||
rows[#rows + 1] = { "wait", 120 }
|
{ "move_player", "up", 2 },
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
|
||||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
|
||||||
-- rSCX split; with no split scroll here she sails one block per beat
|
|
||||||
-- and the water closes in astern (#360)
|
|
||||||
for step = 1, 8 do
|
|
||||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
|
||||||
setBlock(col.bx - step, 1, col.top)
|
|
||||||
setBlock(col.bx - step, 2, col.bottom)
|
|
||||||
end
|
|
||||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
|
||||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
|
||||||
rows[#rows + 1] = { "wait", 20 }
|
|
||||||
end
|
|
||||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
|
||||||
-- frames before the walk out
|
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
|
||||||
rows[#rows + 1] = { "wait", 120 }
|
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
|
||||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
{ "warp", "VERMILION_CITY", 18, 31, "up" },
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
{ "move_player", "up", 2 },
|
||||||
ow:queueScript(rows)
|
})
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = {
|
|||||||
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
|
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
if game.save.flags.EVENT_GOT_HM02 then
|
if game.save.flags.EVENT_GOT_HM02 then
|
||||||
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText
|
push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
|
||||||
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done)
|
or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
push(game, t._Route16FlyHouseBrunetteGirlText
|
push(game, t._Route16FlyHouseBrunetteGirlText
|
||||||
or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function()
|
or "Oh, you found my\nsecret retreat!", function()
|
||||||
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
|
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
|
||||||
push(game, "You don't have\nroom for HM02!", done)
|
push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText
|
||||||
|
or "You don't have any\nroom for this.", done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.save.flags.EVENT_GOT_HM02 = true
|
game.save.flags.EVENT_GOT_HM02 = true
|
||||||
push(game, ("%s got\nHM02!"):format(game.save.player.name), done)
|
-- scripts/Route16FlyHouse.asm:32-35
|
||||||
|
push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text
|
||||||
|
or "{PLAYER} received\nHM02!", { player = game.save.player.name }),
|
||||||
|
done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item"))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -734,7 +738,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[dontRunText] or "Don't run away!", function()
|
game.data.text[dontRunText] or "Don't run away!", function()
|
||||||
ow:scriptMove(ow.player, "up", 1)
|
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
|||||||
+11
-8
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
|||||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||||
ow.player.facing = opts.push
|
ow.player.facing = opts.push
|
||||||
if not ow:checkLedgeHop(opts.push) then
|
if not ow:checkLedgeHop(opts.push) then
|
||||||
ow:scriptMove(ow.player, opts.push, 1)
|
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
|||||||
-- on the west-side cells and walks you back
|
-- on the west-side cells and walks you back
|
||||||
local function bikeGateGuard(coords, stopText, explainText)
|
local function bikeGateGuard(coords, stopText, explainText)
|
||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
if game.save.inventory.BICYCLE then return false end
|
local bike = game.save.inventory.BICYCLE
|
||||||
|
if bike and bike ~= 0 then return false end
|
||||||
if not inCoords(coords, x, y) then return false end
|
if not inCoords(coords, x, y) then return false end
|
||||||
-- walk the player up to the tile beside the counter, no further:
|
-- walk the player up to the tile beside the counter, no further:
|
||||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||||
@@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
-- (PlayerMovingRightScript). Without it the player was left
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
-- parked beside the guard's counter with no way past. #518
|
-- parked beside the guard's counter with no way past. #518
|
||||||
local function shoveRight()
|
local function shoveRight()
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||||
else
|
else
|
||||||
shoveRight()
|
shoveRight()
|
||||||
end
|
end
|
||||||
@@ -921,10 +922,12 @@ M.SS_ANNE_2F = {
|
|||||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", 13 }, -- 6
|
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||||
|
{ "jump_if_false", 13 }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||||
|
|||||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
|
||||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
|
||||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
|
||||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
|
||||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
|
||||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
|
||||||
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
|
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
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,
|
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
|
||||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
|
||||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
|
||||||
`"gen2"` now expands to both Gold and Silver.
|
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
|
||||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||||
loader's gate reads.
|
loader's gate reads.
|
||||||
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
|
|||||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||||
|
|
||||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||||
Generation-agnostic; nothing to adapt.
|
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
|
||||||
|
`mod.developer` is the same fixed boot-time boolean on both generations and is
|
||||||
|
available while the entry chunk runs. Gold does not gain Gen 1's developer
|
||||||
|
console or F5 hot-reload hotkey; the field reports the loader's mode only.
|
||||||
|
|
||||||
**`mod.world`.** Same method set, resolved against Gold's world
|
**`mod.world`.** Same method set, resolved against Gold's world
|
||||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||||
@@ -550,6 +553,17 @@ gains a field instead of the name gaining a prefix.
|
|||||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
`ctx.battle.data` instead gets nil on Gold.
|
`ctx.battle.data` instead gets nil on Gold.
|
||||||
|
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||||
|
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||||
|
it silently, so one mod source can print a single summary line for a
|
||||||
|
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||||
|
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||||
|
against a seam that always announced on Gold and keeps announcing there,
|
||||||
|
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||||
|
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||||
|
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||||
|
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||||
|
happen either way.
|
||||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||||
`pokemon.caught` once the mon is in the party or the box, and
|
`pokemon.caught` once the mon is in the party or the box, and
|
||||||
@@ -562,7 +576,7 @@ gains a field instead of the name gaining a prefix.
|
|||||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||||
first refusal on, the palette zone list handed to the present pass, the
|
first refusal on, the palette zone list handed to the present pass, the
|
||||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
composed frame before ShaderFX, the letterbox, and the finished playfield rect
|
||||||
-- and carries the same payload.
|
-- and carries the same payload.
|
||||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||||
@@ -772,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
|||||||
|
|
||||||
The list is much shorter than it was. What is outstanding, in descending value:
|
The list is much shorter than it was. What is outstanding, in descending value:
|
||||||
|
|
||||||
|
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
|
||||||
|
processing. Gold already has a native weather/between-turn pipeline but does
|
||||||
|
not yet expose the shared data-only descriptor hook.
|
||||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||||
|
|||||||
+280
-5
@@ -200,6 +200,60 @@ the adapter's own coverage table:
|
|||||||
python3 tools/modkit.py gen2check mods/my_mod
|
python3 tools/modkit.py gen2check mods/my_mod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Imported version datasets
|
||||||
|
|
||||||
|
A mod can inspect semantic content from another game the player has already
|
||||||
|
imported without switching the active game or reaching into engine cache
|
||||||
|
internals:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local gold, reason = mod.datasets:open("gold")
|
||||||
|
if not gold then
|
||||||
|
-- reason is "unknown_version", "not_imported", or "invalid_cache"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local chikorita = gold.content.pokemon:get("CHIKORITA")
|
||||||
|
local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST")
|
||||||
|
local spritePath = gold.assets:path(chikorita.spriteFront)
|
||||||
|
|
||||||
|
for id, record in gold.content.pokemon:each() do
|
||||||
|
-- ids are returned in deterministic lexical order
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`view.version` and `view.generation` identify the selected dataset.
|
||||||
|
`view.content` exposes the same registry names, aliases, generation routing,
|
||||||
|
and data-only record shapes as `mod.content`, but only `get`, `has`, and
|
||||||
|
`each`. Returned records are detached copies and cannot mutate either dataset.
|
||||||
|
Every generated base record passes the selected generation's existing public
|
||||||
|
schema before it is returned; extractor metadata beside record maps stays out
|
||||||
|
of the registry id space and is reserved against `register`, `override`,
|
||||||
|
`patch`, and `remove` writes through the active registry. A malformed record
|
||||||
|
makes `get` return nil, `has`
|
||||||
|
return false, and `each` return no rows, and invalidates that dataset view.
|
||||||
|
Records containing functions, userdata, threads, metatables, or cycles are not
|
||||||
|
exposed. Each open call receives an independent facade, so one mod cannot
|
||||||
|
replace another mod view method. Canonical boot shaping is included, such as
|
||||||
|
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
|
||||||
|
derived `held_items`.
|
||||||
|
|
||||||
|
`open` checks the completion marker, exact version-specific file inventory,
|
||||||
|
source/cache boundary, and file-size bounds without reading or decoding the
|
||||||
|
semantic modules. A root is read, bounded-decoded, normalized, and cached only
|
||||||
|
when a content operation first needs it. Each later view operation rechecks
|
||||||
|
readiness and the source bytes behind already cached roots; unchanged roots are
|
||||||
|
not decoded again. Missing, partial, and stale imports return
|
||||||
|
`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a
|
||||||
|
record that fails the public schema is discovered on first root access and
|
||||||
|
fails that operation closed; a later `open` against the same source returns
|
||||||
|
`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar
|
||||||
|
and are never executed. No raw ROM bytes or generated source are exposed.
|
||||||
|
`view.assets:path(relative)` and
|
||||||
|
`view.assets:info(relative)` accept only `assets/generated/...` paths and
|
||||||
|
keep them under the selected version cache prefix. The API never changes
|
||||||
|
`mod.game`, the active `Data` table, `GameVersion`, or cache mount state.
|
||||||
|
|
||||||
## Editing maps in Tiled
|
## Editing maps in Tiled
|
||||||
|
|
||||||
Maps are data, not assets, so they can be authored in a real map editor and
|
Maps are data, not assets, so they can be authored in a real map editor and
|
||||||
@@ -228,6 +282,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
|||||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||||
the same contract while applying their own object and event visibility rules.
|
the same contract while applying their own object and event visibility rules.
|
||||||
|
|
||||||
|
### Active Gen 1 block checks
|
||||||
|
|
||||||
|
Red, Blue, and Yellow expose
|
||||||
|
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
|
||||||
|
ID at one zero-based block coordinate only when `mapId` is the active map.
|
||||||
|
The value is a scalar snapshot: changing it cannot change the map. This lets a
|
||||||
|
mod compare a small runtime map signature before it applies a lawful authored
|
||||||
|
replacement, without reading the mutable map or ROM cache through engine
|
||||||
|
internals.
|
||||||
|
|
||||||
|
The method fails closed. Before an overworld exists it returns
|
||||||
|
`nil, "no overworld"`; for a different active map it returns
|
||||||
|
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
|
||||||
|
return `nil, "invalid block coordinates"`; and negative or out-of-range
|
||||||
|
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
|
||||||
|
or malformed active block returns `nil, "block unavailable"`. The caller must
|
||||||
|
require every expected cell to match before changing presentation. This method
|
||||||
|
is Gen 1-only; Gold callers receive no parity promise for it.
|
||||||
|
|
||||||
|
The same unavailable result covers missing or sparse active block storage and
|
||||||
|
an accessor result that does not match its validated active block slot.
|
||||||
|
|
||||||
|
### Conditional map occupancy
|
||||||
|
|
||||||
|
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
|
||||||
|
decision to eject the player from an otherwise valid loaded map. Its first
|
||||||
|
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
|
||||||
|
been erased when the hook runs, and the hook does not replace or suppress any
|
||||||
|
base or peer map handler.
|
||||||
|
|
||||||
|
The wrapper receives `(next, game, context)`. The dock context is a copied
|
||||||
|
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
|
||||||
|
record. Vanilla returns `false`. Return exactly `true` to allow the player to
|
||||||
|
remain; every other value denies occupancy and preserves the normal message
|
||||||
|
and warp. A composable wrapper calls downstream first and only adds its own
|
||||||
|
permission:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
|
||||||
|
local allowed = next(game, ctx)
|
||||||
|
local mine = ctx.mapId == "VERMILION_DOCK"
|
||||||
|
and ctx.reason == "ss_anne_departed"
|
||||||
|
and myPublicEligibilityCheck(game)
|
||||||
|
return allowed == true or mine == true
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no wrapper, the hook allocates no context and vanilla behavior is
|
||||||
|
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
|
||||||
|
string, number, table, or other malformed final answer fails closed. Disabling
|
||||||
|
or uninstalling the permitting mod therefore restores vanilla ejection without
|
||||||
|
changing the S.S. Anne story flag or restoring the ship.
|
||||||
|
|
||||||
|
Normal hook-chain ownership applies: a wrapper that does not call `next`
|
||||||
|
intentionally owns the final answer and does not run lower-priority wrappers.
|
||||||
|
Permission wrappers must call `next` as shown above to compose. A noncompliant
|
||||||
|
wrapper that returns false without calling `next` safely denies occupancy and
|
||||||
|
can suppress downstream permission by this standard rule. A malformed answer
|
||||||
|
also fails closed and cannot force occupancy.
|
||||||
|
|
||||||
## Party ordering
|
## Party ordering
|
||||||
|
|
||||||
Companion UIs and alternate party screens can call
|
Companion UIs and alternate party screens can call
|
||||||
@@ -265,6 +379,80 @@ Red exposes FLY separately because it requires a destination picker:
|
|||||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||||
Fly town list. Gold does not expose these two methods yet.
|
Fly town list. Gold does not expose these two methods yet.
|
||||||
|
|
||||||
|
## Self-driven world actors
|
||||||
|
|
||||||
|
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||||
|
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||||
|
knows a cutscene is running, so it gates player input for as long as the
|
||||||
|
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||||
|
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||||
|
Five handle methods drive one without that lockout:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||||
|
sprite = "SPRITE_RED" })
|
||||||
|
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||||
|
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||||
|
ghost:setPassable(true) -- walk-through
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||||
|
queue. It deliberately does **not** check collision: a caller replaying a
|
||||||
|
move that was already decided elsewhere (validated on a peer's machine, or
|
||||||
|
authored) would let the two copies disagree about where the actor is if this
|
||||||
|
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||||
|
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||||
|
flight, for a warp arrival or a resync too far gone to walk off.
|
||||||
|
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||||
|
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||||
|
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||||
|
draws and can still be talked to.
|
||||||
|
|
||||||
|
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||||
|
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||||
|
object, raised before the map's text tables get it:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
if mine(target) then
|
||||||
|
say(target) -- the mod answers for an object it owns
|
||||||
|
return -- ...by not calling next()
|
||||||
|
end
|
||||||
|
return next(ow, target) -- everything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||||
|
mid-step raises no hook, matching the vanilla gate.
|
||||||
|
|
||||||
|
## Adopting an already-paired link session
|
||||||
|
|
||||||
|
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||||
|
session on a transport that is *already* paired, skipping the address/code
|
||||||
|
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||||
|
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||||
|
tunnel a battle through its own connection rather than opening a second one.
|
||||||
|
|
||||||
|
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = { result, myParty, theirParty, peerName, role }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The party copies are the point. Cable rules leave the real party untouched,
|
||||||
|
so a mode built on link battles -- a tournament ladder, a battle royale --
|
||||||
|
has no other way to learn what the fight cost, and by the time the state
|
||||||
|
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||||
|
|
||||||
|
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||||
|
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||||
|
false` to land straight in the world -- a mode that hands out its own starting
|
||||||
|
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||||
|
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||||
|
can also carry a room code or an address.
|
||||||
|
|
||||||
## Read-only battle snapshots
|
## Read-only battle snapshots
|
||||||
|
|
||||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||||
@@ -317,6 +505,29 @@ Menu choices and moves use the same engine methods as the native controls;
|
|||||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||||
|
|
||||||
|
## Party-full custody at a catch
|
||||||
|
|
||||||
|
When a capture lands on a full party, the cart deposits the mon in storage
|
||||||
|
without a question. `catch.party_full` (RFC 0018) lets a mode stand in front
|
||||||
|
of `SendNewMonToBox` and take custody instead:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("catch.party_full", function(next, ctx)
|
||||||
|
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||||
|
-- game = <Game> }
|
||||||
|
if myMode.active then
|
||||||
|
takeCustody(ctx.mon) -- the mon is the mod's problem now
|
||||||
|
return true -- nothing is deposited
|
||||||
|
end
|
||||||
|
return next(ctx) -- false: deposit, as today
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
A truthy return skips the box entirely -- the mon is neither in the party nor
|
||||||
|
in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
|
||||||
|
find its own custody again. Anything falsy deposits as always, "But every BOX
|
||||||
|
is full!" included.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
@@ -687,15 +898,38 @@ the hook context.
|
|||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
|
||||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
|
||||||
`--developer` on the command line:
|
on the command line:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
love . --developer
|
love . --developer
|
||||||
```
|
```
|
||||||
|
|
||||||
While developer mode is active:
|
The mod loader independently derives a matching boolean for every sandboxed
|
||||||
|
entry chunk as `mod.developer`. It is available while the entry file is
|
||||||
|
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
|
||||||
|
out of player builds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", function(ctx)
|
||||||
|
-- open or print this mod's diagnostic view
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`mod.developer` is a plain boolean snapshot for this boot. It grants no
|
||||||
|
permission and exposes neither the process environment nor the loader. In a
|
||||||
|
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
|
||||||
|
`true`. On Gen 1 those inputs separately enable the console and hot-reload
|
||||||
|
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
|
||||||
|
signal and diagnostics; it does not enable the game's console or hot reload.
|
||||||
|
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
|
||||||
|
console or hotkeys. Use a mod option for player-facing feature toggles rather
|
||||||
|
than treating developer mode as configuration.
|
||||||
|
|
||||||
|
While developer mode is active on Gen 1:
|
||||||
|
|
||||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||||
@@ -806,7 +1040,7 @@ contract, so a mod does not need a desktop-specific rendering path.
|
|||||||
|
|
||||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||||
for mods that need the engine's normal composite rather than its separate
|
for mods that need the engine's normal composite rather than its separate
|
||||||
layers. It runs after registered present pipelines and before GBCFX,
|
layers. It runs after registered present pipelines and before ShaderFX,
|
||||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||||
`true` only while output ownership is needed, and the second receives
|
`true` only while output ownership is needed, and the second receives
|
||||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||||
@@ -857,6 +1091,47 @@ which stay unconditional and are never visible to a subscriber.
|
|||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|
||||||
|
## Battle field residual hook
|
||||||
|
|
||||||
|
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
|
||||||
|
damage without mutating live battlers. It is guarded and runs after vanilla
|
||||||
|
status residuals, before field-token expiry and `battle.turn_ended`. The
|
||||||
|
wrapper receives `(next, context)`, calls `next(context)` for the existing
|
||||||
|
descriptor list, and appends data-only rows:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy", amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.field` is a detached, data-only view with the same
|
||||||
|
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
|
||||||
|
`field.sides` or any live battler aliases. The projection recursively retains
|
||||||
|
raw tables and finite numbers, strings, and booleans under scalar keys. It
|
||||||
|
strips metatables and omits functions, userdata, threads, unsupported keys, and
|
||||||
|
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
|
||||||
|
even if a live field token uses one internally, and changing any nested view
|
||||||
|
value cannot change live field state. `context.battlers.player` and `.enemy` are
|
||||||
|
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
|
||||||
|
is the current turn number. A descriptor accepts `side`
|
||||||
|
(`player` or `enemy`), a positive, finite integer number `amount`, and an
|
||||||
|
optional string `message`.
|
||||||
|
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
|
||||||
|
The engine retains HP-bar, faint, experience, and replacement authority. If
|
||||||
|
both active battlers take terminal residual damage together and the player has
|
||||||
|
no healthy reserve, this hook batch queues only the player faint authority and
|
||||||
|
resolves as a blackout loss without an enemy-faint EXP award or replacement.
|
||||||
|
That precedence is local to accepted rows from this hook; native faint paths
|
||||||
|
are unchanged when no hook is active. Hook callbacks remain process-local;
|
||||||
|
checkpoints serialize only field data. Gold does not yet raise this hook; its
|
||||||
|
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
|
||||||
|
|
||||||
## Process-lifecycle hooks
|
## Process-lifecycle hooks
|
||||||
|
|
||||||
These exist so a platform-specific launcher integration (a native shell
|
These exist so a platform-specific launcher integration (a native shell
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ accepted and merged as-is.
|
|||||||
| `source` | string |
|
| `source` | string |
|
||||||
| `swarmGrass` | map of string -> {map?, rates, slots} |
|
| `swarmGrass` | map of string -> {map?, rates, slots} |
|
||||||
| `swarmWater` | map of string -> {map?, rate, slots} |
|
| `swarmWater` | map of string -> {map?, rate, slots} |
|
||||||
|
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
|
||||||
| `treeSets` | map of string -> {common, rare} |
|
| `treeSets` | map of string -> {common, rare} |
|
||||||
| `trees` | map of string -> string |
|
| `trees` | map of string -> string |
|
||||||
| `water` | map of string -> {map?, rate, slots} |
|
| `water` | map of string -> {map?, rate, slots} |
|
||||||
@@ -509,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })
|
|||||||
mod.content.items:patch("POTION", { price = 100 })
|
mod.content.items:patch("POTION", { price = 100 })
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### On Gold (Gen 2)
|
||||||
|
|
||||||
|
- semantics: `record`
|
||||||
|
- target: `Data.items`
|
||||||
|
|
||||||
|
The record differs; the registry name, the verbs and the id space
|
||||||
|
do not.
|
||||||
|
|
||||||
|
| field | type | required |
|
||||||
|
|---|---|---|
|
||||||
|
| `ball` | balls id | no |
|
||||||
|
| `effect` | item_effects id | no |
|
||||||
|
| `id` | string | yes |
|
||||||
|
| `index` | integer 0..255 | no |
|
||||||
|
| `machine` | {kind, move, number} | no |
|
||||||
|
| `name` | string | yes |
|
||||||
|
| `needsTarget` | boolean | no |
|
||||||
|
| `price` | integer >= 0 | yes |
|
||||||
|
| `tossable` | boolean | no |
|
||||||
|
|
||||||
## landmarks
|
## landmarks
|
||||||
|
|
||||||
- semantics: `record`
|
- semantics: `record`
|
||||||
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
|
|||||||
| `id` | string | yes |
|
| `id` | string | yes |
|
||||||
| `index` | integer >= 0 | no |
|
| `index` | integer >= 0 | no |
|
||||||
| `label` | string | no |
|
| `label` | string | no |
|
||||||
| `objects` | list of any value | no |
|
| `objects` | list of {pokemon?, ...} | no |
|
||||||
| `palette` | string | no |
|
| `palette` | string | no |
|
||||||
| `signs` | list of any value | no |
|
| `signs` | list of any value | no |
|
||||||
| `tileset` | tilesets id | yes |
|
| `tileset` | tilesets id | yes |
|
||||||
@@ -673,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f
|
|||||||
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
|
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### On Gold (Gen 2)
|
||||||
|
|
||||||
|
- semantics: `record`
|
||||||
|
- target: `Data.moves`
|
||||||
|
|
||||||
|
The record differs; the registry name, the verbs and the id space
|
||||||
|
do not.
|
||||||
|
|
||||||
|
| field | type | required |
|
||||||
|
|---|---|---|
|
||||||
|
| `accuracy` | integer 0..100 | yes |
|
||||||
|
| `anim` | any value | no |
|
||||||
|
| `category` | one of "physical" | "special" | "status" | no |
|
||||||
|
| `chargeText` | string | no |
|
||||||
|
| `counterable` | boolean | no |
|
||||||
|
| `effect` | move_effects id | yes |
|
||||||
|
| `fixedDamage` | integer >= 1 | function | no |
|
||||||
|
| `highCrit` | boolean | no |
|
||||||
|
| `id` | string | yes |
|
||||||
|
| `index` | integer 0..255 | no |
|
||||||
|
| `multiHit` | integer >= 1 | list of integer >= 1 | no |
|
||||||
|
| `name` | string | yes |
|
||||||
|
| `power` | integer 0..255 | yes |
|
||||||
|
| `pp` | integer 0..64 | yes |
|
||||||
|
| `priority` | integer -7..7 | no |
|
||||||
|
| `semiInvulnerable` | boolean | no |
|
||||||
|
| `type` | type_chart id | yes |
|
||||||
|
|
||||||
## music
|
## music
|
||||||
|
|
||||||
- semantics: `record`
|
- semantics: `record`
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||||
|
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
|
||||||
|
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
|
||||||
|
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||||
|
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
|
||||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
|||||||
|
|
||||||
| token | means |
|
| token | means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||||
| `"all"` | every game this engine has |
|
| `"all"` | every game this engine has |
|
||||||
|
|
||||||
@@ -768,7 +768,8 @@ the same:
|
|||||||
So: read the log for coverage problems, and the manager for load problems.
|
So: read the log for coverage problems, and the manager for load problems.
|
||||||
|
|
||||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
|
||||||
|
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
|
||||||
|
|
||||||
## What this guide does not promise
|
## What this guide does not promise
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||||
|
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||||
|
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||||
|
a mod that pays the whole party prints **one** summary line instead of a box
|
||||||
|
per recipient.
|
||||||
|
|
||||||
|
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||||
|
call says. So the same mod source, running the same code, prints one line on
|
||||||
|
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||||
|
paid.
|
||||||
|
|
||||||
|
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||||
|
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||||
|
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||||
|
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||||
|
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||||
|
into six boxes to click through.
|
||||||
|
|
||||||
|
There is no mod-side fix. The announcement is emitted inside
|
||||||
|
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||||
|
because the argument that means "quietly" is discarded. The only workaround is
|
||||||
|
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||||
|
what a mod written for this had to do — a mod reaching into engine internals to
|
||||||
|
undo something the public seam should never have done.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||||
|
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||||
|
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||||
|
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||||
|
not survive the crossing, so the promise the catalog already makes is what this
|
||||||
|
change delivers.
|
||||||
|
|
||||||
|
The delta follows Route B's additive, guarded convention in
|
||||||
|
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||||
|
exists today changes behaviour.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||||
|
|
||||||
|
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||||
|
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||||
|
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||||
|
|
||||||
|
The argument is honoured **only when it is actually passed**, decided by
|
||||||
|
argument count rather than by value:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local function applyShare(mon, split, ...)
|
||||||
|
local announce = ...
|
||||||
|
local silent = select("#", ...) > 0 and not announce
|
||||||
|
...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||||
|
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||||
|
the first is a Gen 2-era call written against a seam that always announced, and
|
||||||
|
the second is a deliberate "pay this one quietly".
|
||||||
|
|
||||||
|
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||||
|
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||||
|
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||||
|
happen exactly as before, in the same order.
|
||||||
|
|
||||||
|
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||||
|
defaults to announcing, so both of the cart's own passes are untouched.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||||
|
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||||
|
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||||
|
calls this seam nor loads differently.
|
||||||
|
|
||||||
|
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||||
|
seam has always documented. Exp Share already does, and needs no edit to get
|
||||||
|
its own README's behaviour on Gold.
|
||||||
|
|
||||||
|
One residual difference is deliberate and now documented rather than silent: an
|
||||||
|
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||||
|
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||||
|
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||||
|
author needs is one sentence: *say what you mean and both games do the same
|
||||||
|
thing.*
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||||
|
checks it already had are unchanged — which is itself the vanilla-parity
|
||||||
|
evidence for this file.
|
||||||
|
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||||
|
participant still prints one line and the EXP.SHARE double pass still
|
||||||
|
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||||
|
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||||
|
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||||
|
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||||
|
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||||
|
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||||
|
are identical either way, and no `experience` event leaks into the queue on
|
||||||
|
a silent pass.
|
||||||
|
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||||
|
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||||
|
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||||
|
gate list moves.
|
||||||
|
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||||
|
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||||
|
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||||
|
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||||
|
`battle.exp_award` as shared — including the argument-count rule and the
|
||||||
|
residual difference above.
|
||||||
|
|
||||||
|
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||||
|
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||||
|
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||||
|
docs name the explicit form as the one that behaves the same on both games.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# RFC 0013: Conditional map occupancy and active-block reads
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
|
||||||
|
S.S. Anne has departed. A content mod can add a city-side route back to that
|
||||||
|
empty harbor, but it cannot preserve the dock visit: replacing the complete
|
||||||
|
dock handler would discard vanilla behavior and peer handlers, while an added
|
||||||
|
handler cannot cancel the base handler's ejection.
|
||||||
|
|
||||||
|
A mod that changes one active map block also needs to prove that it is looking
|
||||||
|
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
|
||||||
|
intentionally presentation-oriented and does not expose block identity.
|
||||||
|
Requiring internal `Map` state or generated ROM data would cross the public mod
|
||||||
|
boundary and make a wrong-version edit difficult to fail closed.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
|
||||||
|
ordinary hook composition remains the authority, an empty hook chain is a
|
||||||
|
provable no-op, and mods receive copied or scalar data rather than mutable
|
||||||
|
engine state. It supports the approved Mew-under-the-truck implementation plan
|
||||||
|
without adding any Mew-specific rule, asset, flag, or content to the engine.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### `map.occupancy_allowed`
|
||||||
|
|
||||||
|
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
|
||||||
|
ship blocks with water and immediately before it would display the departure
|
||||||
|
message and warp the player to Vermilion City.
|
||||||
|
|
||||||
|
A wrapper has this shape:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function(next, game, context) -> boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is a new table with these fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `mapId` | `"VERMILION_DOCK"` at this call site |
|
||||||
|
| `reason` | Stable reason key `"ss_anne_departed"` |
|
||||||
|
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
|
||||||
|
| `x`, `y` | Current player cell coordinates when present |
|
||||||
|
|
||||||
|
Vanilla returns `false`. The player remains only when the final chain result is
|
||||||
|
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
|
||||||
|
ejection. A wrapper composes by calling `next(game, context)` and returning
|
||||||
|
true when either downstream or its own narrow rule permits occupancy. The hook
|
||||||
|
does not replace `MapScripts` registration, merging, or dispatch, and does not
|
||||||
|
change the departure flag or reconstruct the ship.
|
||||||
|
|
||||||
|
As with every wrapper hook, a callback that does not call `next` intentionally
|
||||||
|
owns the final answer and does not run lower-priority callbacks. Permission
|
||||||
|
wrappers must call downstream to compose. A false, non-forwarding wrapper
|
||||||
|
safely denies occupancy and can suppress downstream permission by this normal
|
||||||
|
rule. A malformed final result also fails closed and cannot permit occupancy.
|
||||||
|
|
||||||
|
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
|
||||||
|
context and follows the prior branch exactly.
|
||||||
|
|
||||||
|
### `WorldAPI:activeBlockAt`
|
||||||
|
|
||||||
|
Gen 1's public `mod.world` facade adds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
activeBlockAt(mapId, blockX, blockY) -> blockId
|
||||||
|
| nil, reason
|
||||||
|
```
|
||||||
|
|
||||||
|
`mapId` must equal the active map ID. Coordinates are finite, integral,
|
||||||
|
zero-based block coordinates. A successful result is a numeric scalar copied
|
||||||
|
from the active runtime map. The method never returns the map's mutable block
|
||||||
|
array and never writes game or save state.
|
||||||
|
|
||||||
|
Failure reasons are stable:
|
||||||
|
|
||||||
|
| Condition | Reason |
|
||||||
|
|---|---|
|
||||||
|
| No active overworld map | `no overworld` |
|
||||||
|
| `mapId` differs from the active map | `map is not active` |
|
||||||
|
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
|
||||||
|
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
|
||||||
|
| Active block data is absent or malformed | `block unavailable` |
|
||||||
|
|
||||||
|
The block slot and the active map accessor must both contain the same valid
|
||||||
|
nonnegative integer. Missing or sparse storage and inconsistent accessor data
|
||||||
|
return `block unavailable`.
|
||||||
|
|
||||||
|
Requiring the expected map ID and rejecting all ambiguous input lets a mod
|
||||||
|
compare every cell in its version-specific signature before it calls an
|
||||||
|
existing mutation API. Red, Blue, and Yellow each use their own loaded map
|
||||||
|
data. Gold does not gain this method in this RFC.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook, event, registry, manifest field, save
|
||||||
|
field, map handler, or WorldAPI method is removed or renamed. With no hook
|
||||||
|
subscriber the departed-dock behavior is unchanged. Existing callers cannot
|
||||||
|
invoke the new WorldAPI method accidentally.
|
||||||
|
|
||||||
|
The occupancy answer is not persisted by the engine. Disabling or uninstalling
|
||||||
|
a mod removes its wrapper through normal owner cleanup, so a later dock entry
|
||||||
|
uses vanilla ejection. The engine writes no new save state and neither API
|
||||||
|
returns or serializes ROM or save data.
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
The parity gate must prove:
|
||||||
|
|
||||||
|
- vanilla departed-dock message and warp remain with no subscriber;
|
||||||
|
- one permission wrapper can allow occupancy without removing the ship-erasure
|
||||||
|
work or any map handler;
|
||||||
|
- multiple cooperative wrappers preserve downstream permission;
|
||||||
|
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
|
||||||
|
closed according to the normal wrapper-chain rule;
|
||||||
|
- Red, Blue, and Yellow contexts keep their version identity separate;
|
||||||
|
- disabling or removing the owner restores vanilla behavior without a save
|
||||||
|
migration;
|
||||||
|
- `activeBlockAt` accepts only the active map and valid in-range integral
|
||||||
|
coordinates, returns a scalar, and never mutates map or save state; and
|
||||||
|
- the test fixtures and resulting changes contain no ROM or save payload.
|
||||||
|
|
||||||
|
The hook must be driven through a real public `hooks:wrap` chain. The block API
|
||||||
|
must be exercised through a public `WorldAPI` instance. Tests must not replace
|
||||||
|
the production map handler with a test-only implementation.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents both contracts, their failure behavior, the
|
||||||
|
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
|
||||||
|
schema changes occur, so generated registry documentation is unchanged.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# RFC 0014: Mod-driven world actors and adopted link sessions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
|
||||||
|
already start a link battle. It cannot make either of them behave like
|
||||||
|
something the mod itself owns.
|
||||||
|
|
||||||
|
Three walls, each of which stops a mode rather than inconveniencing it:
|
||||||
|
|
||||||
|
**An actor cannot move on its own schedule.** The only public way to animate a
|
||||||
|
spawned object is `Handle:scriptMove`, which queues onto
|
||||||
|
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
|
||||||
|
knows a cutscene is running -- `handleInput` gates on it -- so anything
|
||||||
|
animated that way freezes the player's controls for as long as it walks. That
|
||||||
|
is correct for Oak marching into his lab and wrong for an ambient walker or a
|
||||||
|
networked player's ghost, which move continuously and must not lock anyone out.
|
||||||
|
|
||||||
|
**A spawned object cannot answer the A press.** Talking is resolved from the
|
||||||
|
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
|
||||||
|
path has nothing to say for it, and the mod that created it has no way to say
|
||||||
|
anything either.
|
||||||
|
|
||||||
|
**A link battle cannot ride a connection the mod already has.** `LinkState`
|
||||||
|
owns pairing, so a mode that already has a socket to its peer must either open
|
||||||
|
a second connection for the battle or reimplement lockstep. And when the
|
||||||
|
battle ends, cable rules leave the real party untouched, so the damage exists
|
||||||
|
only in the battle's own copies -- which are gone by the time the state
|
||||||
|
unwinds. A mode built on link battles cannot learn what the fight cost.
|
||||||
|
|
||||||
|
The immediate consumer is an overworld multiplayer mode, but nothing here is
|
||||||
|
specific to it: the first two are wanted by any mod with an actor that moves
|
||||||
|
itself, and the third by any mode that runs battles over its own transport --
|
||||||
|
a tournament ladder, a draft, a gauntlet.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
This adds nothing to the compatibility surface's shape; it extends the
|
||||||
|
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
|
||||||
|
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
|
||||||
|
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
|
||||||
|
|
||||||
|
There is no in-repo D-number registry to amend; the consuming design lives
|
||||||
|
outside this repository, as it did for RFC 0011.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### New hook: `world.talk`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
-- ow = the OverworldState raising it
|
||||||
|
-- target = the object on the faced cell
|
||||||
|
if mine(target) then
|
||||||
|
say(target)
|
||||||
|
return -- the mod answered; the text path is skipped
|
||||||
|
end
|
||||||
|
return next(ow, target) -- anything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `OverworldState:interact`, on the branch that has already resolved
|
||||||
|
an object on the faced cell (including across a counter) and confirmed it is
|
||||||
|
not mid-step and not the Pikachu follower. It runs before the map's text
|
||||||
|
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
|
||||||
|
fallthrough, which is a file-local function rather than a per-press closure,
|
||||||
|
so an unhooked A press allocates nothing it did not allocate before.
|
||||||
|
|
||||||
|
### New event: `link.battle_ended`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = {
|
||||||
|
-- result = "win" | "lose" | "draw" | "ended",
|
||||||
|
-- myParty = lockstep copy of our party,
|
||||||
|
-- theirParty = lockstep copy of theirs,
|
||||||
|
-- peerName = string,
|
||||||
|
-- role = "host" | "guest",
|
||||||
|
-- }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
|
||||||
|
state has been popped and before `exitWith` unwinds the link stack. Guarded by
|
||||||
|
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
|
||||||
|
is allocated and the branch runs exactly as before.
|
||||||
|
|
||||||
|
The party copies are the point of the event. They are the lockstep records the
|
||||||
|
battle actually fought with, which is where the damage lives under cable rules.
|
||||||
|
|
||||||
|
### New `WorldAPI` handle methods
|
||||||
|
|
||||||
|
```lua
|
||||||
|
handle:stepNow(dir) --> true when the step started
|
||||||
|
handle:canStep(dir) --> would stepNow land somewhere legal?
|
||||||
|
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
|
||||||
|
handle:isMoving() --> true while a step is animating
|
||||||
|
handle:setPassable(flag) --> may the player walk through this object?
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
|
||||||
|
therefore without the input lockout. It does **not** consult collision: the
|
||||||
|
intended caller is replaying a move already decided elsewhere (validated on a
|
||||||
|
peer, or authored), and re-judging it locally would let two copies of the same
|
||||||
|
actor disagree about where it is. `canStep` is the separate opinion for callers
|
||||||
|
that want it. `setPassable` sets the flag `Collision.occupied` already honours
|
||||||
|
for Yellow's companion Pikachu; a passable object still draws and still talks.
|
||||||
|
|
||||||
|
### Two supporting changes
|
||||||
|
|
||||||
|
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
|
||||||
|
with `opts.intro = false` to land directly in the world. A mode that issues its
|
||||||
|
own starting state has no use for the intro cutscene.
|
||||||
|
|
||||||
|
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
|
||||||
|
slot-scrub widget that enters a link code can also carry a room code or a
|
||||||
|
host:port address. Called with no argument it is byte-for-byte the previous
|
||||||
|
widget.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** Every item above is a new name or a new
|
||||||
|
optional argument; no existing name, payload, signature, or default changes.
|
||||||
|
|
||||||
|
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
|
||||||
|
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
|
||||||
|
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
|
||||||
|
`tests/run_modkit.lua` proves on every run.
|
||||||
|
|
||||||
|
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
|
||||||
|
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
|
||||||
|
without building an event payload, and no handle method is reachable unless a
|
||||||
|
mod calls it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
|
||||||
|
path. It asserts the unhooked build first (the A press reaches `talkTo`),
|
||||||
|
then that a mod owning the object suppresses the text path by not calling
|
||||||
|
`next`, that an object the mod ignores still falls through, and that an
|
||||||
|
object mid-step raises no hook at all.
|
||||||
|
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
|
||||||
|
path. It asserts that with nothing subscribed the event is not wanted (so no
|
||||||
|
payload is built) and the battle still unwinds, then that a subscriber
|
||||||
|
receives the result, the role, the peer name and both party copies including
|
||||||
|
the damage the real party never took, from both sides of the cable.
|
||||||
|
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
|
||||||
|
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
|
||||||
|
passes 208/208 with both names covered and no DEBT entry added.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# RFC 0015: Read-only imported dataset views
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A cross-version content mod can read only the active merged dataset through
|
||||||
|
`mod.content`. Even when the player has already imported another supported
|
||||||
|
game, a mod cannot inspect that version's semantic species, moves, items, type
|
||||||
|
chart, or generated asset namespace. The available alternatives are private:
|
||||||
|
mutating `CacheFs.prefix`, mounting another cache over the active one, loading
|
||||||
|
generated Lua directly, or asking for a second raw-ROM import. They compose
|
||||||
|
poorly with the active game, expose unstable import layout, and make safe
|
||||||
|
read-only use impossible from the sandbox.
|
||||||
|
|
||||||
|
The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+
|
||||||
|
sidecar must derive nine Kanto-line continuations, Steel/type and move
|
||||||
|
definitions, and generated sprites from the player's existing verified Gold
|
||||||
|
cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the
|
||||||
|
active R/B/Y dataset; `mod.imports` can read only separately declared raw mod
|
||||||
|
imports; and `mod.cache` is mod-private generated output. None can inspect the
|
||||||
|
launcher-owned Gold semantic dataset without a second ROM import and mod-side
|
||||||
|
ROM interpretation. The engine API remains generic and contains no Adaptive
|
||||||
|
Trainers policy.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-004: optional Kanto+ content consumes an
|
||||||
|
active-independent semantic dataset view**. The consuming design is tracked in
|
||||||
|
the Adaptive Trainers implementation plan,
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 8. The delta is a generic, additive public API and contains no trainer
|
||||||
|
pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers
|
||||||
|
policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Every sandboxed mod receives:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local view, reason = mod.datasets:open("gold")
|
||||||
|
```
|
||||||
|
|
||||||
|
A known version with the current completed import marker returns a read-only view:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
view = {
|
||||||
|
version = "gold",
|
||||||
|
generation = 2,
|
||||||
|
content = {
|
||||||
|
pokemon = {
|
||||||
|
get = function(self, id) end,
|
||||||
|
has = function(self, id) end,
|
||||||
|
each = function(self) end,
|
||||||
|
},
|
||||||
|
-- every public registry name and alias
|
||||||
|
},
|
||||||
|
assets = {
|
||||||
|
path = function(self, generatedPath) end,
|
||||||
|
info = function(self, generatedPath) end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`get` returns a bounded detached data-only copy or nil. `has` reports data-only
|
||||||
|
semantic presence.
|
||||||
|
`each` returns ids in lexical order and detached values. The registries use
|
||||||
|
the selected version's generation routing and the engine's existing
|
||||||
|
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources
|
||||||
|
such as type matchups retain the same public ids used by the active
|
||||||
|
`mod.content` facade and extractor metadata beside record maps is not exposed
|
||||||
|
as a record id or writable through the active registry's mutation verbs. Every
|
||||||
|
generated base record is checked with that selected generation's existing
|
||||||
|
public schema before it can cross `get`, `has`, or
|
||||||
|
`each`; there is no dataset-specific duplicate schema. No register, patch,
|
||||||
|
override, or remove verb is exposed. Each call returns an independent facade
|
||||||
|
over the cached internal dataset, so facade mutation cannot cross mod
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
`assets:path` returns the selected cache-prefixed virtual path.
|
||||||
|
`assets:info` returns sanitized `type` and optional `size` metadata. Both
|
||||||
|
accept only relative paths below `assets/generated/`, reject control
|
||||||
|
characters, absolute paths, backslashes, and traversal, and expose no byte
|
||||||
|
reader.
|
||||||
|
|
||||||
|
An unknown version returns `nil, "unknown_version"`. `open` checks the current
|
||||||
|
completion marker, exact version-specific file inventory, source/cache
|
||||||
|
boundary, and available file sizes; it does not read or decode semantic
|
||||||
|
modules. A missing, partial, or stale cache returns `nil, "not_imported"`.
|
||||||
|
|
||||||
|
The first content operation that needs a root reads it once, applies the
|
||||||
|
limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB
|
||||||
|
per string, and 250,000 entries per table), decodes the restricted literal
|
||||||
|
grammar, applies canonical selected-version normalization, and caches the
|
||||||
|
detached root. Each later content or asset operation rechecks marker/file and
|
||||||
|
source-bound readiness. It also rereads the source bytes for already cached
|
||||||
|
roots; unchanged roots are not decoded again, while a changed root clears the
|
||||||
|
derived registry cache and is decoded on its next use.
|
||||||
|
|
||||||
|
Malformed syntax, non-table roots, binary/trailing content, resource-limit
|
||||||
|
violations, and records that fail the public schema are therefore discovered
|
||||||
|
on first access rather than during `open`. The triggering `get` returns nil,
|
||||||
|
`has` returns false, or `each` returns no rows; the whole internal view is
|
||||||
|
invalidated, and a subsequent `open` against the unchanged source returns
|
||||||
|
`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to
|
||||||
|
the mod. Generated Lua is never executed. Functions, userdata, threads,
|
||||||
|
metatables, and cycles cannot cross the facade. The API never exposes raw ROM
|
||||||
|
bytes, generated source, host paths, or a mount.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. `mod.datasets` is additive and requires no
|
||||||
|
permission. The service is allocated lazily on the first explicit
|
||||||
|
`mod.datasets:open` call. A boot with no mods, or with mods that do not call
|
||||||
|
it, performs no cross-version cache reads.
|
||||||
|
|
||||||
|
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
|
||||||
|
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
|
||||||
|
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
|
||||||
|
|
||||||
|
The completion marker and per-version required-file rules live in the pure,
|
||||||
|
injected `CacheContract` shared by the importer and dataset service. It also
|
||||||
|
defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while
|
||||||
|
checking readiness. Every `open` revalidates the contract and required module
|
||||||
|
inventory without semantic decoding. Every view operation rechecks that
|
||||||
|
readiness before serving cached state; a stale/remove/reimport transition
|
||||||
|
evicts the previous semantic view.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
|
||||||
|
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
|
||||||
|
- The test proves semantic registry normalization, deterministic iteration,
|
||||||
|
detached records, read-only facades, cross-mod facade isolation,
|
||||||
|
version-prefixed generated assets,
|
||||||
|
traversal rejection, stable failure reasons, and stale-marker rejection.
|
||||||
|
- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile
|
||||||
|
generated-source and malformed-record rejection, canonical Gen
|
||||||
|
1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold,
|
||||||
|
and the approved Kanto+ Gold records and assets.
|
||||||
|
- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and
|
||||||
|
decoder calls to prove `open` decodes nothing, unused roots stay unread, and
|
||||||
|
repeated access does not re-decode an unchanged cached root.
|
||||||
|
- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code
|
||||||
|
is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua`
|
||||||
|
proves every decoder resource bound.
|
||||||
|
- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod
|
||||||
|
parity suite and proves the service stays unallocated with zero cache reads.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# RFC 0016: Engine-owned field residual descriptors
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Battle-rule mods can keep deterministic data-only state in the public battle
|
||||||
|
field and observe `battle.turn_ended`, but that event fires after the engine's
|
||||||
|
residual and faint pipeline. A listener cannot safely deal end-of-round field
|
||||||
|
damage: directly changing live HP bypasses bar drains, faint messages,
|
||||||
|
experience, replacements, double-faint resolution, and checkpoint continuation.
|
||||||
|
Putting callbacks into `battle.field` is also rejected by the checkpoint
|
||||||
|
serializer, correctly, because executable state is not save-safe.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-004: public engine-owned field residual execution**, the
|
||||||
|
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
|
||||||
|
RESIDUALS`. The plan is
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 8. The engine delta defines only a generic end-of-round extension point;
|
||||||
|
it contains no weather names, immunities, damage formula, trainer identity, or
|
||||||
|
Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Add the guarded Gen 1 hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy",
|
||||||
|
amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook runs once during an undecided battle's end-of-round processing, after
|
||||||
|
vanilla status residuals and before field/side token expiry and
|
||||||
|
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
|
||||||
|
changes nothing. Vanilla contributes an empty list.
|
||||||
|
|
||||||
|
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
|
||||||
|
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
|
||||||
|
recursive projection retains raw tables and finite numbers, strings, and
|
||||||
|
booleans under scalar keys. It strips metatables and omits functions, userdata,
|
||||||
|
threads, unsupported keys, and cyclic edges. Thus it exposes neither
|
||||||
|
`field.sides` nor a graph or executable callback back into live engine state.
|
||||||
|
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
|
||||||
|
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
|
||||||
|
change the live battle. `turn` is the current Gen 1 turn counter.
|
||||||
|
|
||||||
|
A result row is `{ side = "player"|"enemy", amount =
|
||||||
|
positive_finite_integer_number, message = optional_string }`. Numeric strings,
|
||||||
|
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
|
||||||
|
messages fail closed. Damage is clamped to current HP. The engine owns
|
||||||
|
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
|
||||||
|
accepted row before scheduling newly fainted battlers. If this hook batch
|
||||||
|
terminally faints the player with no healthy reserve, it queues only the player
|
||||||
|
faint authority, so descriptor order cannot race a blackout against an enemy
|
||||||
|
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
|
||||||
|
player/enemy order. This precedence is scoped to this hook response; native
|
||||||
|
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
|
||||||
|
Wrappers compose by calling `next(context)` and appending their own rows.
|
||||||
|
Callbacks are not part of the descriptor contract and hook functions are never
|
||||||
|
stored in the battle.
|
||||||
|
|
||||||
|
Gold already owns native weather and a generation-specific between-turn order;
|
||||||
|
this first additive call site is Gen 1-only. A future Gold site must keep the
|
||||||
|
same context and descriptor contract and choose its native ordering explicitly.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook name or payload changes. With no wrapper,
|
||||||
|
Gen 1 performs the same residual, token, event, and faint work as before,
|
||||||
|
including native simultaneous-faint resolution, and allocates no context. Gen
|
||||||
|
2 is unchanged. Existing and new checkpoints keep
|
||||||
|
serializing only the data stored in `battle.field`; hook callbacks remain
|
||||||
|
process-local loader state and are never serialized.
|
||||||
|
|
||||||
|
The v1 surface remains unchanged: `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
|
||||||
|
`pokemon.before_give` keep their existing behavior.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- The catalog hook parity gate proves null and live-empty buses return the
|
||||||
|
vanilla list unchanged.
|
||||||
|
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
|
||||||
|
detached checkpoint-shaped context, applies damage, and reaches the engine
|
||||||
|
faint pipeline.
|
||||||
|
- Engine validation tests cover strict number validation (including numeric
|
||||||
|
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
|
||||||
|
omission of functions/userdata/threads/cycles and metatables from the public
|
||||||
|
field projection, optional messages, non-table results, clamping, and
|
||||||
|
settled-battle suppression.
|
||||||
|
- Both descriptor orders are driven through queue completion for a simultaneous
|
||||||
|
terminal residual. Each proves player blackout loss with no EXP event,
|
||||||
|
enemy replacement, or replacement UI.
|
||||||
|
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
|
||||||
|
context construction. An ordering probe proves the enabled hook runs after
|
||||||
|
vanilla status residuals and before token expiry and `battle.turn_ended`.
|
||||||
|
- A no-hook native regression proves a simultaneous zero-HP state still enters
|
||||||
|
the pre-existing enemy-faint EXP and win authority outside this hook batch.
|
||||||
|
- Capture/restore/capture evidence proves checkpointed field state round-trips
|
||||||
|
while the enabled process-local hook remains installed and callable.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
|
||||||
|
validation, simultaneous-terminal result, and checkpoint boundary.
|
||||||
|
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
|
||||||
|
No registry or schema changes are involved, so generated registry docs do not
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is deprecated. The hook is additive.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# RFC 0017: Public mod developer-mode signal
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The loader already derives a boot-time developer-mode flag for its permission
|
||||||
|
diagnostics and headless test seam. Gen 1's `Game` independently derives a
|
||||||
|
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
|
||||||
|
read either one. `mod.commands` can register a diagnostic command but cannot
|
||||||
|
say whether the current boot is a developer boot. `mod.exports` only publishes
|
||||||
|
values to other mods. `game.ready` fires after entry registration and carries
|
||||||
|
only the game. `mod.options` is player configuration, not engine mode, and
|
||||||
|
`mod.log` logs unconditionally. The pre-sandbox compatibility
|
||||||
|
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
|
||||||
|
environment is hidden from mods.
|
||||||
|
|
||||||
|
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
|
||||||
|
Phase H require trainer, boss, Rival, and League diagnostic views plus
|
||||||
|
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
|
||||||
|
public signal, the mod must either ship those registrations in production,
|
||||||
|
misuse a player option, or import loader/Logger internals. All three violate
|
||||||
|
the approved observability boundary or the sandbox/public-API policy.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
|
||||||
|
the engine's developer-mode decision**. The consuming design is tracked in the
|
||||||
|
Adaptive Trainers implementation plan,
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 9. The engine delta is generic and contains no trainer, balancing,
|
||||||
|
diagnostic-layout, seed-label, or Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Every sandboxed mod object adds one field:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.developer -- boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader copies its existing `dev` decision into this field before invoking
|
||||||
|
the mod's entry chunk. It is therefore available for load-time registration:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", diagnostics_command)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is a plain boolean snapshot, not a loader reference or environment
|
||||||
|
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
|
||||||
|
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
|
||||||
|
separately make `Game`'s own developer flag true for its console and hot
|
||||||
|
reload. The loader's existing injected `opts.dev` test seam changes only the
|
||||||
|
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
|
||||||
|
and does not expose environment variables. The answer is fixed for the life of
|
||||||
|
that loader; changing a field on a mod's own table cannot change engine mode.
|
||||||
|
|
||||||
|
The field is generation-independent and has identical semantics on Red, Blue,
|
||||||
|
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
|
||||||
|
or hot-reload hotkeys from this field.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. `mod.developer` is additive, requires no
|
||||||
|
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
|
||||||
|
entry chunks receive one extra scalar field and retain all prior fields and
|
||||||
|
methods unchanged. No name is removed or shadowed.
|
||||||
|
|
||||||
|
With no mods installed, `Loader:_api` is never called, so the delta allocates no
|
||||||
|
mod object and changes no data, save, options, event, hook, command, or file.
|
||||||
|
With mods installed in a normal boot, the new field is `false` unless an author
|
||||||
|
explicitly reads it. Existing registration and logging behavior is unchanged.
|
||||||
|
|
||||||
|
An adopting mod should gate developer-only registrations and verbose logging
|
||||||
|
directly on `mod.developer`. Player-facing behavior belongs behind
|
||||||
|
`mod.options`, not this signal.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
|
||||||
|
the public SDK with developer mode both on and off. It proves the boolean is
|
||||||
|
available during entry execution and that the same source registers its
|
||||||
|
diagnostic command only for the developer load. It also covers the
|
||||||
|
command-line global path and a Gen 2 load.
|
||||||
|
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
|
||||||
|
parity suite. It proves both developer answers discover no mods, create no
|
||||||
|
files, and leave injected vanilla data unchanged. It also loads an unchanged
|
||||||
|
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
|
||||||
|
- The full ROM-free engine and modkit tiers remain the compatibility proof for
|
||||||
|
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
|
||||||
|
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
|
||||||
|
|
||||||
|
No registry or schema changes are involved, so generated registry documentation
|
||||||
|
is unaffected.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
When a capture lands and the party already holds six Pokémon, the cart does
|
||||||
|
not ask the player anything: `AddPartyMon` fails and the mon goes to
|
||||||
|
`SendNewMonToBox` without a stop. The engine mirrors that —
|
||||||
|
`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game
|
||||||
|
mode has no way to stand in front of it.
|
||||||
|
|
||||||
|
That silence is wrong for any mode where the box is not a thing. A battle
|
||||||
|
royale locks the Pokémon Center PC for the whole match (the box is a second
|
||||||
|
health bar: deposit the healthy ones, fight with one, withdraw fresh ones),
|
||||||
|
so a seventh catch is deposited into storage the player cannot reach — the
|
||||||
|
mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon
|
||||||
|
as lost, a randomizer that wants to hand out a replacement instead, a
|
||||||
|
challenge run that makes the player release someone for the catch — all want
|
||||||
|
the decision, and today there is no seam to catch it at.
|
||||||
|
|
||||||
|
The immediate consumer is a battle-royale mode, where the fix is the game's
|
||||||
|
own rule: at 6/6 you choose who makes room, and whoever leaves hits the
|
||||||
|
ground as a ball. Neither the decision nor the spill is specific to it.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
This extends the **additive, guarded seam convention** Route B in
|
||||||
|
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||||
|
`tests/engine/gate_meta_coverage.lua` enforces. It sits next to
|
||||||
|
`catch.nickname` (RFC 0015) on the same capture path: that hook answers
|
||||||
|
*what the mon is called* once its home is decided; this one decides the home
|
||||||
|
when the party cannot hold it.
|
||||||
|
|
||||||
|
There is no in-repo D-number registry to amend.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### New hook: `catch.party_full`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("catch.party_full", function(next, ctx)
|
||||||
|
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||||
|
-- game = <Game> }
|
||||||
|
if myMode.active then
|
||||||
|
takeCustody(ctx.mon) -- the mon is the mod's problem now
|
||||||
|
return true -- nothing is deposited
|
||||||
|
end
|
||||||
|
return next(ctx) -- false: deposit, as today
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `BattleState:partyFullDestination(mon)`, called from the capture
|
||||||
|
path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run.
|
||||||
|
The vanilla link returns `false`. A truthy return skips the box entirely —
|
||||||
|
the mon is neither in the party nor in any box, and `pokemon.caught` reports
|
||||||
|
`destination = "mod"` so the mode can find its own custody again. Anything
|
||||||
|
falsy deposits as always, "But every BOX is full!" included.
|
||||||
|
|
||||||
|
The method returns `"box"` or `"mod"`, and is a *method* on purpose: the
|
||||||
|
compatibility seam for engines that predate this RFC needs a name to ask for
|
||||||
|
(see below), and `battleStyle`/`offerNickname` set the precedent.
|
||||||
|
|
||||||
|
### One event value, already emitted
|
||||||
|
|
||||||
|
`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to
|
||||||
|
`"party"` and `"box"`. Nothing that reads the event today matches on it.
|
||||||
|
|
||||||
|
### No other surface changes
|
||||||
|
|
||||||
|
The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a
|
||||||
|
file-local, so a build with nothing wrapped runs the branch exactly as
|
||||||
|
before and allocates nothing it did not allocate before.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Nothing changes for existing mods. A mode that was fighting the box after
|
||||||
|
the fact — withdrawing the deposit it could not prevent, or eating the loss —
|
||||||
|
should wrap `catch.party_full` and take the mon at the moment of the catch.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/modkit/cases/catch_party_full.lua` — through the public mod API:
|
||||||
|
with no mod a 6/6 catch lands in the box with the transfer text; a wrapped
|
||||||
|
mod that claims custody leaves the mon out of both party and boxes; a
|
||||||
|
fall-through deposits; the hook's ctx carries the battle, the mon, the
|
||||||
|
display name and the game.
|
||||||
|
- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes
|
||||||
|
the no-mod parity gate (vanilla called exactly once, result unchanged,
|
||||||
|
nothing allocated).
|
||||||
|
- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit
|
||||||
|
corpus.
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
Additive. No existing hook, event, registry or manifest field changes shape.
|
||||||
|
`storeCaughtMon`'s box branch keeps its text, its nickname offer and its
|
||||||
|
`pokemon.caught` emit; the only reader-visible difference with no subscriber
|
||||||
|
is one extra method on `BattleState`.
|
||||||
|
|
||||||
|
## Compatibility seam for older engines
|
||||||
|
|
||||||
|
The call site is mid-function, so a mod on a stock engine cannot see it —
|
||||||
|
the same problem `world.talk` has. The battle-royale mod's shim covers the
|
||||||
|
gap by wrapping `Boxes.deposit` (the one call the old branch makes that a
|
||||||
|
patch can reach): during a match it raises `catch.party_full` first, and a
|
||||||
|
claim refuses the deposit, so nothing reaches a box even there. The mod then
|
||||||
|
takes custody from the `pokemon.caught` emit, which carries the mon. What
|
||||||
|
the shim cannot repair is the text: the old branch answers a refused deposit
|
||||||
|
with "But every BOX is full!" before the mode's own prompt opens — the wrong
|
||||||
|
reason for the right decision, and the argument for the seam.
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
# ShaderFX: runtime slang shader presets
|
||||||
|
|
||||||
|
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
|
||||||
|
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
|
||||||
|
a picker: any preset the player drops in a folder, or any preset pulled from
|
||||||
|
the RetroArch buildbot, can be selected and run. Engine:
|
||||||
|
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
|
||||||
|
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
|
||||||
|
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
|
||||||
|
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
|
||||||
|
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
|
||||||
|
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
|
||||||
|
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
|
||||||
|
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
|
||||||
|
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
|
||||||
|
|
||||||
|
This is first-party engine code, not a mod. It calls the native translator
|
||||||
|
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
|
||||||
|
declaration, and no mod boundary. Mods do not get that, and the distinction is
|
||||||
|
deliberate: the upstream maintainer will not accept `native` in mods.
|
||||||
|
|
||||||
|
## What a player sees
|
||||||
|
|
||||||
|
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
|
||||||
|
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
|
||||||
|
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
|
||||||
|
action row at the bottom.
|
||||||
|
|
||||||
|
A preset that has never been translated draws muted with a `CONVERT` hint on
|
||||||
|
the right. `A` on that row translates it in place and stays open: converting is
|
||||||
|
a preparation step, not a selection. `A` on a converted row activates it,
|
||||||
|
persists the choice, and closes. `SELECT` on a converted row opens
|
||||||
|
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
|
||||||
|
declares and lets the player step each one (`A` wraps, Left/Right clamp,
|
||||||
|
`SELECT` resets one row, `START` resets all behind a confirm).
|
||||||
|
|
||||||
|
Presets live in a plain OS folder, `shaders/` under the portable base directory
|
||||||
|
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
|
||||||
|
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
|
||||||
|
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
|
||||||
|
whatever nested layout it shipped with. These are real filesystem paths rather
|
||||||
|
than `love.filesystem` virtual paths on purpose: the native translator does
|
||||||
|
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
|
||||||
|
LUT loads.
|
||||||
|
|
||||||
|
Persisted state, all in `save.options`:
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `shaderfx` | main slot's preset name, or absent for OFF |
|
||||||
|
| `shaderfxSecondary` | secondary slot's preset name |
|
||||||
|
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
|
||||||
|
|
||||||
|
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
|
||||||
|
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
|
||||||
|
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
|
||||||
|
env var can never later override a real player choice, including a real choice
|
||||||
|
of OFF.
|
||||||
|
|
||||||
|
One quiet behavior worth knowing about: if a slot wants a real preset while
|
||||||
|
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
|
||||||
|
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
|
||||||
|
the saved choice was force-deactivated a few lines later on every boot, which
|
||||||
|
looks identical to "the setting does not save" from the player's side. On
|
||||||
|
Android and iOS that is the common case, because AUTO always resolves to
|
||||||
|
`balanced` there. It never touches an already-explicit performance choice and
|
||||||
|
never un-escalates.
|
||||||
|
|
||||||
|
## The five stages
|
||||||
|
|
||||||
|
| Stage | Owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
|
||||||
|
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
|
||||||
|
| Fixup | `src/render/ShaderFixup.lua` |
|
||||||
|
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
|
||||||
|
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
|
||||||
|
|
||||||
|
### Fetch
|
||||||
|
|
||||||
|
Two acquisition paths, and they land in the same place. A player can copy a
|
||||||
|
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
|
||||||
|
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
|
||||||
|
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
|
||||||
|
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
|
||||||
|
mod index already use.
|
||||||
|
|
||||||
|
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
|
||||||
|
mirror and has no notion of this project's cache format, so a downloaded preset
|
||||||
|
goes through the same `CONVERT` row a hand-copied one does. Every platform
|
||||||
|
ships both convert and use; there is no asymmetry to work around.
|
||||||
|
|
||||||
|
Repeat downloads are conditional. The zip itself is deleted right after
|
||||||
|
extraction, so what is cached instead is the buildbot's own ETag
|
||||||
|
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
|
||||||
|
match with 304 and no body, and curl writes no file at all in that case, so
|
||||||
|
`installDownloaded(notModified)` short-circuits before touching the filesystem
|
||||||
|
and reports "already up to date" rather than "FAILED".
|
||||||
|
|
||||||
|
The interesting part is what gets extracted. `handheld/` is not self-contained:
|
||||||
|
its 78 presets carry 101 references that escape the folder (color-mod LUTs
|
||||||
|
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
|
||||||
|
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
|
||||||
|
them. Extracting `handheld/` alone silently breaks about a third of its own
|
||||||
|
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
|
||||||
|
content nobody asked for. So `extractClosure` walks the real file-level
|
||||||
|
dependency closure in Lua before a single file is copied, the same
|
||||||
|
`#reference`-closure idea librashader applies internally, done up front because
|
||||||
|
deciding what to copy has to happen before the translator ever sees these
|
||||||
|
files. Against this zip that is 207 files and about 9.5 MB with zero broken
|
||||||
|
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
|
||||||
|
than all 78, after most of `color-mod/` and `console-border/` turned out either
|
||||||
|
irrelevant (color-only, no LCD effect) or broken for this project. That list
|
||||||
|
is a temporary trim pending wider testing and is expected to change.
|
||||||
|
|
||||||
|
Two mechanical details in that walk that are easy to get wrong a second time.
|
||||||
|
`extractRefs` tries a quoted `key = "path"` match per line first, with an
|
||||||
|
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
|
||||||
|
delimiter and real packs ship paths with spaces and parentheses in them
|
||||||
|
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
|
||||||
|
quoted match falls back to a conservative character class, since an unquoted
|
||||||
|
path has no delimiter to trust past. And `love.filesystem.write` does not
|
||||||
|
create intermediate directories, so each destination directory is created once
|
||||||
|
before anything is written into it; without that, every file under a subfolder
|
||||||
|
`handheld/` never had before was silently dropped while flat writes succeeded.
|
||||||
|
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
|
||||||
|
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
|
||||||
|
it returns false, leaves the zip's handle open, and the following `remove()`
|
||||||
|
silently fails too, so every download used to leave 54 MB on disk forever.
|
||||||
|
|
||||||
|
### Translate: the native bridge
|
||||||
|
|
||||||
|
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
|
||||||
|
named `librashader_bridge`. It wraps `librashader-presets`,
|
||||||
|
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
|
||||||
|
|
||||||
|
```
|
||||||
|
char* librashader_translate_preset(const char* preset_path, int es);
|
||||||
|
void librashader_free_string(char* s);
|
||||||
|
```
|
||||||
|
|
||||||
|
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
|
||||||
|
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
|
||||||
|
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
|
||||||
|
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
|
||||||
|
with resolved absolute paths and filter/wrap settings), its
|
||||||
|
`parameter_overrides`, and an `error` field.
|
||||||
|
|
||||||
|
**What the bridge is not.** No librashader runtime backend is linked in, for
|
||||||
|
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
|
||||||
|
graphics context is ever touched. It is the translation step only. LOVE still
|
||||||
|
owns every draw call, every canvas and every shader object. The bridge hands
|
||||||
|
back text and metadata and nothing else.
|
||||||
|
|
||||||
|
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
|
||||||
|
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
|
||||||
|
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
|
||||||
|
cached artifact and never call the library. An entry with no cached artifact
|
||||||
|
fails `activate()` loudly instead of silently live-translating.
|
||||||
|
|
||||||
|
The classification is done with librashader's real semantics resolution rather
|
||||||
|
than name matching on the Lua side, and that matters for correctness, not just
|
||||||
|
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
|
||||||
|
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
|
||||||
|
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
|
||||||
|
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
|
||||||
|
alias resolves. The per-pass convenience API only registers the alias of the
|
||||||
|
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
|
||||||
|
loop and builds a preset-wide alias map first; without that,
|
||||||
|
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
|
||||||
|
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
|
||||||
|
classification runs the same resolution over the pass's `uniform_semantics`
|
||||||
|
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
|
||||||
|
present preset uses but that the convention allows.
|
||||||
|
|
||||||
|
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
|
||||||
|
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
|
||||||
|
`love.system.getOS()`, and the same function decides which dialect
|
||||||
|
`validateShader` is asked about at run time, so the two always agree. Convert
|
||||||
|
and render always happen on the same device; **artifacts are not portable
|
||||||
|
across platforms**.
|
||||||
|
|
||||||
|
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
|
||||||
|
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
|
||||||
|
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
|
||||||
|
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
|
||||||
|
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
|
||||||
|
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
|
||||||
|
the source first, and only for the ES target:
|
||||||
|
|
||||||
|
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
|
||||||
|
the preset's declared static textures, with dimensions read straight out of
|
||||||
|
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
|
||||||
|
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
||||||
|
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
||||||
|
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
||||||
|
reciprocal-size uniform. The containing block instance (`params`, `global`,
|
||||||
|
whatever) is discovered by scanning the source's own uniform block bodies,
|
||||||
|
never assumed. When a pass samples another pass purely through these calls it
|
||||||
|
may never declare that `<Tex>Size` uniform at all, so one is injected first,
|
||||||
|
before any byte offsets are computed.
|
||||||
|
|
||||||
|
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
|
||||||
|
mode at out-of-range coordinates, which is not necessarily identical to
|
||||||
|
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
|
||||||
|
discrepancy for passes whose offsets can leave the image is unmeasured.
|
||||||
|
|
||||||
|
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
|
||||||
|
`ShaderFX` looks for the library, most specific first: the
|
||||||
|
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
|
||||||
|
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
|
||||||
|
finally the bare name handed to the system loader. Per-OS names are
|
||||||
|
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
|
||||||
|
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||||
|
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||||
|
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||||
|
without a path. The desktop path is still a developer build sitting in cargo's
|
||||||
|
output directory; nothing packages it next to a shipped game yet.
|
||||||
|
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||||
|
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||||
|
preset never needs any of this.
|
||||||
|
|
||||||
|
**Statically linked bridges.** On iOS there is no loadable library at all:
|
||||||
|
the bridge is linked straight into the app binary, so its symbols live in the
|
||||||
|
main image and are reachable only through `ffi.C`. The candidate list is empty
|
||||||
|
on iOS and, on every platform, a failed candidate walk falls back to probing
|
||||||
|
`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol
|
||||||
|
is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it
|
||||||
|
unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was
|
||||||
|
probed as well, followed by the file paths that were tried (omitted on iOS,
|
||||||
|
where none are).
|
||||||
|
|
||||||
|
**Android ships the bridge inside the APK.** `scripts/build_android.sh`
|
||||||
|
stages `liblibrashader_bridge.so` into
|
||||||
|
`mobile/android/app/src/main/jniLibs/<abi>/` for the two ABIs the APK ships,
|
||||||
|
arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it
|
||||||
|
by bare name in the app's native library directory. The build cross-compiles
|
||||||
|
the crate with `cargo ndk` against the same NDK the gradle project uses
|
||||||
|
(25.2.9519653), which needs `cargo install cargo-ndk` and
|
||||||
|
`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script
|
||||||
|
names the exact command for any target that is missing. Set
|
||||||
|
`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding
|
||||||
|
`<abi>/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on
|
||||||
|
desktop, the bridge is only needed to CONVERT a preset: a build without it
|
||||||
|
still runs presets converted elsewhere, and the packager warns and continues
|
||||||
|
rather than failing.
|
||||||
|
|
||||||
|
**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a
|
||||||
|
dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a
|
||||||
|
`staticlib` and `scripts/build_ios.sh` links it into the app executable
|
||||||
|
(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in
|
||||||
|
`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive;
|
||||||
|
otherwise cargo builds `aarch64-apple-ios` for device builds and every
|
||||||
|
installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for
|
||||||
|
the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to
|
||||||
|
what got built. The archive is never linked directly: LÖVE 12 carries its own
|
||||||
|
glslang for shader validation and the crate drags in a second, incompatible
|
||||||
|
one, and linking both crashed inside `Shader::validateInternal` at startup.
|
||||||
|
Each slice is therefore prelinked with `ld -r -all_load
|
||||||
|
-exported_symbols_list` so only `_librashader_translate_preset` and
|
||||||
|
`_librashader_free_string` stay external and every other symbol (glslang,
|
||||||
|
spirv-cross, Rust std) becomes private to the object, then `rust-objcopy`
|
||||||
|
drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since
|
||||||
|
Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry
|
||||||
|
points to keep them past dead-stripping and `-lc++` for the C++ the crate
|
||||||
|
needs; no Objective-C framework is involved. A build that linked the object
|
||||||
|
fails if `nm` cannot find `_librashader_translate_preset` in the finished
|
||||||
|
binary; a build that could not produce one only warns and lands where the
|
||||||
|
desktop packages without cargo land: converted presets run, CONVERT does not.
|
||||||
|
|
||||||
|
### Fixup
|
||||||
|
|
||||||
|
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
||||||
|
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
|
||||||
|
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
|
||||||
|
`position()` convention and refuses a raw `main()`-shaped source outright. This
|
||||||
|
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
|
||||||
|
real preset in the corpus failed without it. This list is the least guessable
|
||||||
|
part of the whole feature.
|
||||||
|
|
||||||
|
**`#version` line.** Stripped; LOVE prepends its own.
|
||||||
|
|
||||||
|
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
|
||||||
|
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
|
||||||
|
literals, which validation rejects with "arrayed constructor: not supported for
|
||||||
|
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
|
||||||
|
three real shapes in the corpus and each is handled: a `const` global (declared
|
||||||
|
without an initializer at global scope, with per-element assignments relocated
|
||||||
|
to the top of `main()`), a non-const local declaration with initializer
|
||||||
|
(rewritten in place, since a function body can hold assignment statements where
|
||||||
|
the literal was), and a bare reassignment of an array declared elsewhere (also
|
||||||
|
in place). Splitting the element list needs `splitTopLevelCommas`, because a
|
||||||
|
naive comma split breaks on any element containing its own parentheses
|
||||||
|
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
|
||||||
|
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
|
||||||
|
whole match fails, and the literal passes through completely untouched.
|
||||||
|
|
||||||
|
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
|
||||||
|
a function-parameter array before passing it on, since GLSL array arguments are
|
||||||
|
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
|
||||||
|
declaration plus an element-by-element copy. The size is known from the
|
||||||
|
declaration, so no comma splitting is involved. This one only became reachable
|
||||||
|
once the other array shapes stopped masking it in the same file.
|
||||||
|
|
||||||
|
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
|
||||||
|
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
|
||||||
|
integer operands, so routing through float `mod()` and back is exact for the
|
||||||
|
non-negative operands this shader family uses (rotation and orientation enum
|
||||||
|
indices). Four patterns are tried in order (paren/paren, paren/bare,
|
||||||
|
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
|
||||||
|
operand and leaves a simple one bare, and the balanced form must be tried
|
||||||
|
before a plain identifier can partially match. Seen live on
|
||||||
|
`authentic_gbc`'s subpixel-rotation math.
|
||||||
|
|
||||||
|
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
|
||||||
|
`precision highp int;` pair with no toggle. That is removed and replaced with
|
||||||
|
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
|
||||||
|
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
|
||||||
|
highp, and fall through to the stage default otherwise.
|
||||||
|
|
||||||
|
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
|
||||||
|
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
|
||||||
|
nor sending the whole struct as a table works, both raise "Shader uniform '...'
|
||||||
|
does not exist." librashader emits every pass's `#pragma parameter`s and size
|
||||||
|
uniforms as exactly that kind of struct, so as shipped the output is unusable
|
||||||
|
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
|
||||||
|
instance uniform and re-declares the members at top level. Scalar members are
|
||||||
|
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
|
||||||
|
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
|
||||||
|
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
|
||||||
|
budget unpacked. Non-scalar members keep their own uniform, since packing an
|
||||||
|
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
|
||||||
|
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
|
||||||
|
type was `int` or `bool` gets an explicit cast back on every read, since a
|
||||||
|
packed slot only stores floats. Vertex and fragment declare identically ordered
|
||||||
|
structs for the same parameters, so packing both with the same prefix assigns
|
||||||
|
the same slot and component to the same parameter in both stages, and one
|
||||||
|
`shader:send` reaches whichever stage uses it.
|
||||||
|
|
||||||
|
Two ordering constraints inside that function are load bearing and look
|
||||||
|
arbitrary from the outside. Packing must walk the members in the struct's own
|
||||||
|
declaration order, because that order is what keeps the scalars in one
|
||||||
|
contiguous run; an earlier version sorted them longest-name-first before
|
||||||
|
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
|
||||||
|
budget. Substitution, separately, must go longest-name-first, so a replacement
|
||||||
|
can never land as a substring inside a still-pending member name that shares a
|
||||||
|
prefix. The two orders are separate copies of the list for exactly that reason.
|
||||||
|
|
||||||
|
`Fixup.packValues` turns a flat `{name = value}` table back into the
|
||||||
|
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
|
||||||
|
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
|
||||||
|
slots against that same 16-vector budget, samplers excluded. Its pattern uses
|
||||||
|
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
|
||||||
|
unlike regex `\w`, and every generated name here is full of underscores.
|
||||||
|
|
||||||
|
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
|
||||||
|
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
|
||||||
|
prefix so their groups cannot collide with the push block's numbering. `MVP` is
|
||||||
|
the one special member: it is substituted directly to `transform_projection`
|
||||||
|
instead of becoming a uniform, because "multiply the incoming vertex by it" is
|
||||||
|
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
|
||||||
|
nothing would ever supply a value for it. An earlier version assumed the UBO
|
||||||
|
block only ever carried `MVP` and deleted the whole declaration after
|
||||||
|
substituting it. That is true only for presets that declare their parameters in
|
||||||
|
a push-constant block; presets that use a UBO instead (many real handheld and
|
||||||
|
console-border presets do) had every other member reference left dangling, and
|
||||||
|
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
|
||||||
|
identifier" and "unknown swizzle selection" errors on real Android hardware came
|
||||||
|
from.
|
||||||
|
|
||||||
|
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
|
||||||
|
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
|
||||||
|
precision, because LOVE forward-declares `effect()`'s prototype under its own
|
||||||
|
header's precision default before this source runs, which can mismatch whatever
|
||||||
|
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
|
||||||
|
two variants (`mediump`, then unqualified) and the caller tries them in order
|
||||||
|
against `validateShader`, taking the first that passes.
|
||||||
|
|
||||||
|
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
|
||||||
|
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
|
||||||
|
`gbFragColor`, declared at the top of the function, with a single `return`
|
||||||
|
appended before the closing brace. Rewriting *every* occurrence regardless of
|
||||||
|
the operator that follows is necessary, not just symmetric with the vertex side:
|
||||||
|
an earlier assign-then-return pair assumed one write at the very end of
|
||||||
|
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
|
||||||
|
that write once with `=` and later accumulate with `+=`. The `+=` statement
|
||||||
|
passed through unconverted and collided with LOVE's own `gl_FragColor` write
|
||||||
|
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
|
||||||
|
rewritten to `return gbFragColor;`, which holds the value assigned just before
|
||||||
|
it on every real shape seen.
|
||||||
|
|
||||||
|
**Vertex entry point.** `void main()` becomes `position(mat4
|
||||||
|
transform_projection, vec4 vertex_position)`, the source's own `attribute`
|
||||||
|
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
|
||||||
|
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
|
||||||
|
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
|
||||||
|
appended. The `Position` and `TexCoord` substitutions are frontier-matched
|
||||||
|
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
|
||||||
|
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
|
||||||
|
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
|
||||||
|
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
|
||||||
|
|
||||||
|
### Cache
|
||||||
|
|
||||||
|
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
|
||||||
|
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
|
||||||
|
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
|
||||||
|
path with the extension swapped to `.lua`, so the artifact sits next to the
|
||||||
|
preset it came from. The file is a plain `return { ... }` chunk written by
|
||||||
|
`serializeLua`, which handles the string/number/boolean/nested-table shape
|
||||||
|
`Json.decode` produces. Array detection walks every key rather than trusting
|
||||||
|
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
|
||||||
|
naive length check by accident.
|
||||||
|
|
||||||
|
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
|
||||||
|
failure it says "convert this preset first" rather than falling back to a live
|
||||||
|
translation.
|
||||||
|
|
||||||
|
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
|
||||||
|
explicit, user-initiated action whose result is stable for a given preset and
|
||||||
|
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
|
||||||
|
and the native call off every activation, every boot and every frame. The cost
|
||||||
|
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
|
||||||
|
exist" check with no version or content stamp, and there is no explicit
|
||||||
|
"reconvert" action in the UI. A preset converted by an older build never picks
|
||||||
|
up a later translator fix on its own. This was seen on a real device, where
|
||||||
|
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
|
||||||
|
because that device's cache predated the fix while `pixel_transparency`'s
|
||||||
|
happened to be fresher. Two places compensate by reconverting unconditionally:
|
||||||
|
`ShaderFXScreen`'s explicit selection of an already-converted row, and
|
||||||
|
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
|
||||||
|
CPU-only work with no GPU compile, and neither is on the per-frame path.
|
||||||
|
|
||||||
|
`ShaderSourcePatches.lua` sits just before translation and patches the raw
|
||||||
|
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
|
||||||
|
reading the real files, discovers `#pragma parameter` lines and struct members;
|
||||||
|
nothing downstream can add one. Patches are small, explicit, per-preset literal
|
||||||
|
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
|
||||||
|
pattern magic), re-applied idempotently on every convert so a buildbot
|
||||||
|
re-download that replaces the upstream file wholesale does not quietly undo
|
||||||
|
them. **The patch table ships empty on purpose and nothing registers one.** Its
|
||||||
|
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
|
||||||
|
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
|
||||||
|
gap above: a new pragma can only reach an artifact that gets reconverted, and at
|
||||||
|
the time nothing forced one. The mechanism is kept for a future preset that
|
||||||
|
genuinely needs a new declaration, but an already-wired engine-side channel is
|
||||||
|
preferred whenever one exists.
|
||||||
|
|
||||||
|
### Run: the pass graph
|
||||||
|
|
||||||
|
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
|
||||||
|
player's edited parameters over the artifact's own defaults, loads the preset's
|
||||||
|
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
|
||||||
|
runs the chain each frame.
|
||||||
|
|
||||||
|
`newChainState` builds one instance of chain-local state per loaded preset,
|
||||||
|
never module-global, so switching presets cannot leak a previous preset's
|
||||||
|
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
|
||||||
|
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
|
||||||
|
player's `shaderfxParams` edits.
|
||||||
|
|
||||||
|
Sizes resolve through `resolveScale`, which handles all four slang scale types
|
||||||
|
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
|
||||||
|
input dimensions and the original frame. Size uniforms are packed as
|
||||||
|
`{w, h, 1/w, 1/h}`, the slang convention.
|
||||||
|
|
||||||
|
`runPass` caches two things per `(state, pass index)`. The **shader** is
|
||||||
|
compiled once for the state's lifetime, along with the fragment manifest it was
|
||||||
|
compiled against, since a pass's GLSL depends only on the preset and never on
|
||||||
|
per-frame input. The **canvas** is reallocated only when the pass's resolved
|
||||||
|
size actually changes, a window resize or a different preset. Every harness this
|
||||||
|
runtime was ported from ran the chain once and quit, so allocating a fresh
|
||||||
|
canvas and compiling a fresh shader on every call was invisible there. On a real
|
||||||
|
per-frame render path it is one GPU allocation per pass per frame, and a shader
|
||||||
|
recompile on top. The same discipline applies to the crop canvas in
|
||||||
|
`cropToGbSource`, which is called once per frame and whose size grows with the
|
||||||
|
world canvas as the player zooms out; leaving it uncached was a real cost that
|
||||||
|
scaled with zoom level even with a single preset active.
|
||||||
|
|
||||||
|
Sampler binding is by semantic, from the bridge's classification, never by a
|
||||||
|
hardcoded per-preset name check: `Source` is the previous pass's output (or the
|
||||||
|
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
|
||||||
|
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
|
||||||
|
real name the translation reported. A sampler that resolves to nothing asserts
|
||||||
|
rather than drawing garbage.
|
||||||
|
|
||||||
|
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
|
||||||
|
bytes with plain `io.open` and goes through `love.data.newByteData` and
|
||||||
|
`love.image.newImageData`, because a preset's texture paths are arbitrary
|
||||||
|
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
|
||||||
|
those outright ("Could not open file ... Does not exist") even when the file is
|
||||||
|
real. Wrap modes are mapped from librashader's names to LOVE's
|
||||||
|
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
|
||||||
|
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
|
||||||
|
left nil; the fail-loud point is the sampler assertion in `runPass` that
|
||||||
|
actually needed it, not the loader.
|
||||||
|
|
||||||
|
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
|
||||||
|
history slot reads the current frame, both for the sampler binding and for the
|
||||||
|
size uniform. Real per-frame history rotation has been proven out in a desktop
|
||||||
|
harness but is not wired into this path.
|
||||||
|
|
||||||
|
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
|
||||||
|
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
|
||||||
|
sampler resolves to nothing and trips the binding assertion. No preset in the
|
||||||
|
shipped shortlist uses it.
|
||||||
|
|
||||||
|
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
|
||||||
|
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
|
||||||
|
|
||||||
|
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
|
||||||
|
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
|
||||||
|
composite (world, UI, and any post-process pipeline that already ran); `rect` is
|
||||||
|
this frame's real playfield rectangle in physical framebuffer pixels and
|
||||||
|
`source` is the real pixel size of the content it frames. The sequence is: crop
|
||||||
|
`rect` out of the composite, run whichever slots are active over that crop, draw
|
||||||
|
the untouched composite, then stretch the chain output back over `rect`. UI and
|
||||||
|
letterbox bars outside the playfield pass through untouched. If the chain throws,
|
||||||
|
the frame still shows the unprocessed composite; a broken preset degrades to
|
||||||
|
"shader off", never to a crash or a blank frame.
|
||||||
|
|
||||||
|
Three details in that path are non-obvious:
|
||||||
|
|
||||||
|
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
|
||||||
|
logical units, not raw pixels, so the viewport handed to the pass graph and
|
||||||
|
the final draw-back position are both converted from `rect`'s physical pixels
|
||||||
|
first. On a `dpiscale = 1` desktop the two are numerically identical and the
|
||||||
|
bug is invisible; at dpiscale 3 on real Android hardware the chain output
|
||||||
|
rendered about three times too large and at a pixel-valued offset in unit
|
||||||
|
space.
|
||||||
|
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
|
||||||
|
caller can leave the draw color dirty (a menu's black text leaves it at
|
||||||
|
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
|
||||||
|
and `push("all")` saves state for `pop()` without resetting it. That was the
|
||||||
|
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
||||||
|
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
|
||||||
|
`flushBatch()` on the line above is cheap insurance against a read-after-write
|
||||||
|
ordering hazard between this draw and whatever last rendered into the canvas;
|
||||||
|
it was never confirmed to fix anything on its own.
|
||||||
|
- **The final blit stretches.** A slang chain's last pass is not required to
|
||||||
|
land on the viewport size, and most presets (21 of the 78 in the corpus)
|
||||||
|
declare their last pass `scale_type = "source"` and stay at native Game Boy
|
||||||
|
resolution, relying on the frontend's blit exactly as RetroArch does.
|
||||||
|
Requiring an exact size match here used to skip the draw outright for every
|
||||||
|
such preset on every frame, which is a silent total no-op rather than a sizing
|
||||||
|
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
|
||||||
|
nearest or linear.
|
||||||
|
|
||||||
|
### What this engine feeds shaders that a libretro core does not
|
||||||
|
|
||||||
|
A stock libretro core hands its frontend a raw framebuffer and a frame count.
|
||||||
|
This engine has more context available and passes some of it through.
|
||||||
|
|
||||||
|
| Context | How it reaches the shader |
|
||||||
|
| --- | --- |
|
||||||
|
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
|
||||||
|
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
|
||||||
|
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
|
||||||
|
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
|
||||||
|
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
|
||||||
|
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
|
||||||
|
|
||||||
|
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
|
||||||
|
bound to 0 because librashader's own documentation is explicit that it is
|
||||||
|
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
|
||||||
|
oriented arcade core, say), not device orientation. Nothing here ever rotates
|
||||||
|
Game Boy content, so 0 is the correct answer, not a placeholder.
|
||||||
|
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
|
||||||
|
rest" calibration reference, no preset in the corpus reads it, and a fixed
|
||||||
|
placeholder beats an invented value.
|
||||||
|
|
||||||
|
`src/core/Sensors.lua` is what makes the two real motion semantics work.
|
||||||
|
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
|
||||||
|
any platform including Android; it is a LOVE 12 addition. The working path is
|
||||||
|
raw FFI into the SDL2 that LOVE already links, the same technique
|
||||||
|
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
|
||||||
|
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
|
||||||
|
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
|
||||||
|
dead code after a future LOVE 12 upgrade. Loading order matters:
|
||||||
|
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
|
||||||
|
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
|
||||||
|
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
|
||||||
|
device with no sensor is probed once and then permanently reports zeros, so a
|
||||||
|
desktop run does not pay for it every frame.
|
||||||
|
|
||||||
|
SDL keeps sensor readings in the device's fixed chassis frame regardless of
|
||||||
|
screen orientation, so `rotateForScreen` remaps x and y into
|
||||||
|
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
|
||||||
|
compensation is mobile-only: a desktop monitor is legitimately and permanently
|
||||||
|
"landscape" to that query, which says something about the monitor's shape and
|
||||||
|
nothing about how a player is holding anything.
|
||||||
|
|
||||||
|
The accelerometer path in `sizeTable` does three things to the raw reading
|
||||||
|
before it becomes a uniform, all of them driven by real on-device data:
|
||||||
|
|
||||||
|
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
|
||||||
|
actually holding the device in and every later reading is measured relative
|
||||||
|
to that, rather than to an assumed idealized vertical. The shipped tilt maths
|
||||||
|
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
|
||||||
|
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
|
||||||
|
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
|
||||||
|
the effect sat near-saturated all the time instead of starting near neutral.
|
||||||
|
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
|
||||||
|
dominant on Z, the one axis it never reads. This engine's rest pose is
|
||||||
|
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
|
||||||
|
gravity back on the ignored axis.
|
||||||
|
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
|
||||||
|
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
|
||||||
|
quietly damped tilt and noise alike by the same factor; calibration correctly
|
||||||
|
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
|
||||||
|
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
|
||||||
|
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
|
||||||
|
on the ignored axis to keep the denominator stable, but only when there is a
|
||||||
|
genuine live reading to calibrate against. An all-zero raw read is
|
||||||
|
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
|
||||||
|
Earth, and injecting into that case would make the shader believe it had
|
||||||
|
sensor data and silently replace its own static fallback with fake motion.
|
||||||
|
|
||||||
|
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
|
||||||
|
angle, so it only becomes a usable on-screen offset by integrating over time,
|
||||||
|
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
|
||||||
|
is deliberately a decaying spring rather than a true integrated heading:
|
||||||
|
gyro-only integration drifts without a magnetometer to correct it, so this
|
||||||
|
settles back toward neutral and stays bounded by construction. It is folded onto
|
||||||
|
the accelerometer's x component before the shader's own normalize and clamp,
|
||||||
|
because that is the only already-compiled channel the stock upstream maths
|
||||||
|
reads, and reaching an already-converted artifact with no reconvert was worth
|
||||||
|
the tradeoff that the twist reads as an added simulated tilt rather than a
|
||||||
|
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
|
||||||
|
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
|
||||||
|
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
|
||||||
|
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
|
||||||
|
looked worse, because overshooting a comfortable range reads worse than being
|
||||||
|
subtle. Retune in small steps with real device checks, not big jumps.
|
||||||
|
|
||||||
|
## Two slots
|
||||||
|
|
||||||
|
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
|
||||||
|
to its save key. The slots are activated and persisted independently, and the
|
||||||
|
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
|
||||||
|
parameter edits are keyed by *preset name*, not by slot, because a preset's
|
||||||
|
values are a property of the preset the same way its cached artifact is;
|
||||||
|
editing them re-activates every slot currently showing that preset and persists
|
||||||
|
for the next load in either.
|
||||||
|
|
||||||
|
When both slots are active, `render` runs main's chain first and hands its
|
||||||
|
finished output to secondary as secondary's own input frame, along with its
|
||||||
|
dimensions, so a secondary preset that scales off its input sees main's real
|
||||||
|
output size rather than the original crop. Either slot alone behaves exactly as
|
||||||
|
a single-preset path; neither active is a plain passthrough.
|
||||||
|
|
||||||
|
**This is not how RetroArch composes multiple presets.** RetroArch merges
|
||||||
|
presets into a *single* pass list through `#reference` and `Append`, producing
|
||||||
|
one pass graph with one shared semantics map, where a later pass can reference
|
||||||
|
an earlier one's output by alias and the whole thing resolves as one unit. Two
|
||||||
|
slots here are two independent librashader chains run back to back, which is
|
||||||
|
what stacking two separate preset chains would give you, not what merging them
|
||||||
|
gives you. Presets that assume merged semantics will not behave the same way.
|
||||||
|
|
||||||
|
## Test seams
|
||||||
|
|
||||||
|
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
|
||||||
|
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
|
||||||
|
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
|
||||||
|
canvas, which the later unconditional draw-back would otherwise mask),
|
||||||
|
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
|
||||||
|
actually reached the packed uniform, per slot). `Sensors.setOverride`,
|
||||||
|
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
|
||||||
|
readings on a machine with no hardware.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
None of these are theoretical.
|
||||||
|
|
||||||
|
- **Tested on very little real hardware.** Essentially one Android phone, one
|
||||||
|
desktop, and the automated harnesses. Anything about how a preset actually
|
||||||
|
looks or performs elsewhere is unverified.
|
||||||
|
- **No performance tier is actually tuned.** The chain-resolution multiplier in
|
||||||
|
`Performance.CAPS` is a working mechanism, but every tier that permits
|
||||||
|
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
|
||||||
|
today. Picking a real value for weak hardware needs a device this project does
|
||||||
|
not have.
|
||||||
|
- **The dual-slot design does not match RetroArch.** See above. Two chains in
|
||||||
|
sequence is not one merged pass list.
|
||||||
|
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
|
||||||
|
falls back to "every slot is the current frame" in the live render path, and
|
||||||
|
is unverified there.
|
||||||
|
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
||||||
|
error and its sampler trips an assertion.
|
||||||
|
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
||||||
|
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
||||||
|
player-supplied filename still collides with the row's right-hand hint.
|
||||||
|
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
|
||||||
|
Intentional, for the reason given above, but it means the mechanism has no
|
||||||
|
live coverage.
|
||||||
|
- **Cached artifacts have no staleness detection.** Existence is the only check.
|
||||||
|
The two unconditional reconvert points paper over it; anything that does not
|
||||||
|
go through them can be running a stale translation.
|
||||||
|
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
|
||||||
|
Copying a converted preset folder between a phone and a desktop copies a
|
||||||
|
wrong artifact along with it.
|
||||||
|
- **The bundled bridge is only as good as the build machine.**
|
||||||
|
`scripts/build.sh` bundles the cdylib for mac, win and linux via
|
||||||
|
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||||
|
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||||
|
package that can run converted presets but cannot CONVERT new ones, and says
|
||||||
|
so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh`
|
||||||
|
follow the same rule with `cargo ndk` and the iOS static archive.
|
||||||
|
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||||
|
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
||||||
|
- **Tilt direction is unverified.** Which way forward and back rocking moves the
|
||||||
|
effect was never confirmed on a device; if it feels backwards the fix is a
|
||||||
|
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
|
||||||
|
landscape rotation compensation matches real RetroArch is genuinely unknown:
|
||||||
|
RetroArch's Android input driver computes a screen rotation but does not
|
||||||
|
visibly apply it to the accelerometer values that reach shader uniforms, so
|
||||||
|
this project's compensation may be an improvement over upstream rather than a
|
||||||
|
match to it.
|
||||||
|
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
||||||
|
in the bridge turns those calls into `texture()`, which honours the sampler's
|
||||||
|
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
||||||
|
implementation defined. Unmeasured.
|
||||||
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
|||||||
| ----- | -------------- | ------------------- |
|
| ----- | -------------- | ------------------- |
|
||||||
| Select + **A** | `2` | COLORS |
|
| Select + **A** | `2` | COLORS |
|
||||||
| Select + **B** | `3` | TILT |
|
| Select + **B** | `3` | TILT |
|
||||||
| Select + **Y** | `5` | GBC FX |
|
|
||||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
|
||||||
`exports/<red|blue|yellow|gold|silver>/`
|
`exports/<red|blue|yellow|gold|silver|crystal>/`
|
||||||
path the launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -126,9 +126,12 @@ bundled game, in that case.
|
|||||||
persistent launcher control. Android downloads the release APK, verifies
|
persistent launcher control. Android downloads the release APK, verifies
|
||||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||||
Installer. The installer asks the user for consent and enforces package,
|
Installer. The installer asks the user for consent and enforces package,
|
||||||
version-code, and signing-certificate compatibility. iOS links the
|
version-code, and signing-certificate compatibility. A legacy APK without
|
||||||
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds
|
the installer bridge links its full package for one manual bootstrap
|
||||||
link their correctly named full package. Switch keeps its native OTA flow.
|
update, including when its downloaded payload already reports the latest
|
||||||
|
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||||
|
desktop, and PortMaster builds link their correctly named full package.
|
||||||
|
Switch keeps its native OTA flow.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
|||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
local defaultErrorHandler = love.errorhandler
|
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||||
function love.errorhandler(msg)
|
function love.errorhandler(msg)
|
||||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||||
if hint and type(msg) == "string" then
|
if ok and hint and type(msg) == "string" then
|
||||||
msg = msg .. "\n\n" .. hint
|
msg = msg .. "\n\n" .. hint
|
||||||
end
|
end
|
||||||
if defaultErrorHandler then
|
if defaultErrorHandler then
|
||||||
@@ -82,6 +82,11 @@ end
|
|||||||
local editorHost, editorVersion, editorWindow
|
local editorHost, editorVersion, editorWindow
|
||||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||||
|
|
||||||
|
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||||
|
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||||
|
-- both go through SessionLifecycle so neither path forgets a singleton.
|
||||||
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
|
|
||||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||||
-- their directories have to be on the require path. It must be
|
-- their directories have to be on the require path. It must be
|
||||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||||
@@ -157,9 +162,7 @@ local function openEditor(version, slotId)
|
|||||||
local okReq, appOrErr = pcall(require, "App")
|
local okReq, appOrErr = pcall(require, "App")
|
||||||
if not okReq then
|
if not okReq then
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
end
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -179,10 +182,7 @@ local function openEditor(version, slotId)
|
|||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
end
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -197,21 +197,14 @@ end
|
|||||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||||
-- with this one's data.
|
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
||||||
|
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||||
function closeEditor()
|
function closeEditor()
|
||||||
local version = editorVersion
|
local version = editorVersion
|
||||||
|
local app = EditorApp
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
end
|
|
||||||
for k in pairs(package.loaded) do
|
|
||||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
|
||||||
package.loaded[k] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
editorVersion = nil
|
editorVersion = nil
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
@@ -294,9 +287,9 @@ end
|
|||||||
local function makeLauncher()
|
local function makeLauncher()
|
||||||
local RomImporter = require("src.import.RomImporter")
|
local RomImporter = require("src.import.RomImporter")
|
||||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||||
return RomImporter.new(function(version)
|
return RomImporter.new(function(version, cartId)
|
||||||
Importer = nil
|
Importer = nil
|
||||||
bootGame(version)
|
bootGame(version, cartId)
|
||||||
end, {
|
end, {
|
||||||
launcher = true,
|
launcher = true,
|
||||||
forceImport = forceImport,
|
forceImport = forceImport,
|
||||||
@@ -312,45 +305,19 @@ end
|
|||||||
local function returnToLauncher()
|
local function returnToLauncher()
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
|
|
||||||
pcall(function() require("src.core.Music").stop() end)
|
|
||||||
pcall(function() require("src.core.Sound").stop() end)
|
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.core.DiscordPresence"] then
|
|
||||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.core.gen2.Clock"] then
|
|
||||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.net.Gen1Tls"] then
|
|
||||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
|
||||||
end
|
|
||||||
if love.audio and love.audio.stop then
|
|
||||||
pcall(love.audio.stop)
|
|
||||||
end
|
|
||||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
if currentVersion then
|
SessionLifecycle.endGameSession(Game)
|
||||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
|
||||||
end
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
|
||||||
if Runtime.reset then
|
|
||||||
Runtime.reset()
|
|
||||||
end
|
|
||||||
|
|
||||||
Game = nil
|
Game = nil
|
||||||
autopilot = nil
|
autopilot = nil
|
||||||
driverCo = nil
|
driverCo = nil
|
||||||
|
-- Leave the cart's scope behind: the launcher's own settings and slots are
|
||||||
|
-- the base game's, not the cart's. The speed ladder is cart state too, so
|
||||||
|
-- a 1x/2x cart must not pin the launcher or the next game.
|
||||||
|
require("src.core.SaveData").setCart(nil)
|
||||||
|
require("src.core.GameSpeed").setAllowed(nil)
|
||||||
|
|
||||||
local Input = require("src.core.Input")
|
SessionLifecycle.endMountedSession(currentVersion)
|
||||||
local TouchControls = require("src.core.TouchControls")
|
|
||||||
Input:reset()
|
|
||||||
TouchControls:reset()
|
|
||||||
|
|
||||||
require("src.core.Orientation").applyOptions(
|
require("src.core.Orientation").applyOptions(
|
||||||
require("src.core.SaveData").loadOptions())
|
require("src.core.SaveData").loadOptions())
|
||||||
@@ -366,7 +333,7 @@ local function returnToLauncher()
|
|||||||
Importer = makeLauncher()
|
Importer = makeLauncher()
|
||||||
end
|
end
|
||||||
|
|
||||||
function bootGame(version)
|
function bootGame(version, cartId)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||||
@@ -379,6 +346,25 @@ function bootGame(version)
|
|||||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||||
CacheFs.prefix = GameVersion.cachePrefix()
|
CacheFs.prefix = GameVersion.cachePrefix()
|
||||||
CacheFs.mountVersion(GameVersion.get())
|
CacheFs.mountVersion(GameVersion.get())
|
||||||
|
local cartHash, cartSpeeds, cartOptions
|
||||||
|
if cartId then
|
||||||
|
local ok, cart, hash = pcall(function()
|
||||||
|
return require("src.carts.CartStore").get(cartId)
|
||||||
|
end)
|
||||||
|
if ok and cart then
|
||||||
|
cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options
|
||||||
|
else
|
||||||
|
cartId = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
SaveData.setCart(cartId, cartHash)
|
||||||
|
-- The author's settings land in the cart's own scope the first time only;
|
||||||
|
-- after that the player owns them.
|
||||||
|
if cartOptions then SaveData.seedCartOptions(cartOptions) end
|
||||||
|
-- A cart may narrow or pin the speed ladder; nil restores the full one.
|
||||||
|
require("src.core.GameSpeed").setAllowed(cartSpeeds)
|
||||||
|
if cartId then SaveData.adoptCartSeal(cartId) end
|
||||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||||
pcall(function()
|
pcall(function()
|
||||||
@@ -398,7 +384,14 @@ function bootGame(version)
|
|||||||
Game = require("src.core.Game2").new()
|
Game = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
else
|
||||||
Game = require("src.core.Game")
|
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
|
||||||
|
-- place; if a prior teardown left load missing, rebuild from source.
|
||||||
|
local gameMod = require("src.core.Game")
|
||||||
|
if type(gameMod.load) ~= "function" then
|
||||||
|
package.loaded["src.core.Game"] = nil
|
||||||
|
gameMod = require("src.core.Game")
|
||||||
|
end
|
||||||
|
Game = gameMod
|
||||||
Game:load()
|
Game:load()
|
||||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||||
autopilot = require("tests.autopilot")
|
autopilot = require("tests.autopilot")
|
||||||
@@ -694,7 +687,7 @@ function love.gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadpressed(joystick, button) end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
@@ -714,7 +707,7 @@ function love.gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadreleased(joystick, button) end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
@@ -734,7 +727,7 @@ function love.gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
@@ -754,7 +747,7 @@ function love.joystickpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickpressed(joystick, button) end
|
||||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickpressed(joystick, button)
|
Game:joystickpressed(joystick, button)
|
||||||
@@ -774,7 +767,7 @@ function love.joystickreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickreleased(joystick, button) end
|
||||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickreleased(joystick, button)
|
Game:joystickreleased(joystick, button)
|
||||||
@@ -794,7 +787,7 @@ function love.joystickaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickaxis(joystick, axis, value)
|
Game:joystickaxis(joystick, axis, value)
|
||||||
@@ -814,7 +807,7 @@ function love.joystickhat(joystick, hat, direction)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickhat(joystick, hat, direction) end
|
||||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickhat(joystick, hat, direction)
|
Game:joystickhat(joystick, hat, direction)
|
||||||
@@ -1153,22 +1146,7 @@ function love.quit()
|
|||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
end)
|
||||||
-- LOVE waits for every live love.thread before the process exits, and both
|
SessionLifecycle.endProcess()
|
||||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
|
||||||
-- without this the process outlived the window and the next launch re-entered
|
|
||||||
-- the dead one instead of starting fresh (#339)
|
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.update.Check"] then
|
|
||||||
pcall(package.loaded["src.update.Check"].shutdown)
|
|
||||||
end
|
|
||||||
-- The launcher's fetch pool is the same story: its workers idle in
|
|
||||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
|
||||||
-- touched the network would hang the process on exit (#339's shape again).
|
|
||||||
if package.loaded["src.net.Fetch"] then
|
|
||||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
@@ -1199,6 +1177,17 @@ function love.run()
|
|||||||
-- per-frame sleep-granularity jitter.
|
-- per-frame sleep-granularity jitter.
|
||||||
local nextFrame = love.timer and love.timer.getTime() or 0
|
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||||
local dt = 0
|
local dt = 0
|
||||||
|
local idleFor = 0
|
||||||
|
local SLEEP_FLOOR = 0.001
|
||||||
|
local WAKE = {
|
||||||
|
keypressed = true, keyreleased = true, textinput = true,
|
||||||
|
mousepressed = true, mousereleased = true, mousemoved = true,
|
||||||
|
wheelmoved = true, touchpressed = true, touchreleased = true,
|
||||||
|
touchmoved = true, joystickpressed = true, joystickreleased = true,
|
||||||
|
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||||
|
joystickadded = true, joystickremoved = true, filedropped = true,
|
||||||
|
directorydropped = true, focus = true, visible = true, resize = true,
|
||||||
|
}
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
-- process events
|
-- process events
|
||||||
@@ -1217,17 +1206,34 @@ function love.run()
|
|||||||
return a or 0
|
return a or 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if WAKE[name] then
|
||||||
|
idleFor = 0
|
||||||
|
elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then
|
||||||
|
idleFor = 0
|
||||||
|
end
|
||||||
love.handlers[name](a, b, c, d, e, f)
|
love.handlers[name](a, b, c, d, e, f)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- update dt
|
-- update dt
|
||||||
if love.timer then dt = love.timer.step() end
|
if love.timer then dt = love.timer.step() end
|
||||||
|
idleFor = idleFor + dt
|
||||||
|
|
||||||
-- call update and draw
|
-- call update and draw
|
||||||
if love.update then love.update(dt) end
|
if love.update then love.update(dt) end
|
||||||
|
|
||||||
if love.graphics and love.graphics.isActive() then
|
local visible = not (love.window and love.window.isVisible)
|
||||||
|
or love.window.isVisible()
|
||||||
|
local focused = not (love.window and love.window.hasFocus)
|
||||||
|
or love.window.hasFocus()
|
||||||
|
local cap = FrameCap.current
|
||||||
|
if not visible then
|
||||||
|
cap = 10
|
||||||
|
elseif Importer and (not focused or idleFor > 30) then
|
||||||
|
cap = 15
|
||||||
|
end
|
||||||
|
|
||||||
|
if visible and love.graphics and love.graphics.isActive() then
|
||||||
love.graphics.origin()
|
love.graphics.origin()
|
||||||
love.graphics.clear(love.graphics.getBackgroundColor())
|
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||||
if love.draw then love.draw() end
|
if love.draw then love.draw() end
|
||||||
@@ -1238,9 +1244,9 @@ function love.run()
|
|||||||
if paced then
|
if paced then
|
||||||
-- Sleep out the remainder of the frame budget, measured from the
|
-- Sleep out the remainder of the frame budget, measured from the
|
||||||
-- carried deadline, in small chunks so the OS timer stays
|
-- carried deadline, in small chunks so the OS timer stays
|
||||||
-- responsive. vsync is untouched: when it already paces slower
|
-- responsive. The pacer yields to vsync inside a 1ms dead band, so
|
||||||
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
-- when the panel already paces at or below the cap it is a no-op.
|
||||||
local budget = 1 / FrameCap.current
|
local budget = 1 / cap
|
||||||
nextFrame = nextFrame + budget
|
nextFrame = nextFrame + budget
|
||||||
local now = love.timer.getTime()
|
local now = love.timer.getTime()
|
||||||
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||||
@@ -1251,8 +1257,8 @@ function love.run()
|
|||||||
end
|
end
|
||||||
while true do
|
while true do
|
||||||
local remaining = nextFrame - love.timer.getTime()
|
local remaining = nextFrame - love.timer.getTime()
|
||||||
if remaining <= 0 then break end
|
if remaining <= SLEEP_FLOOR then break end
|
||||||
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
love.timer.sleep(0.001)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.timer.sleep(0.001)
|
love.timer.sleep(0.001)
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ love-android 11.5a expects:
|
|||||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
|
|
||||||
|
**ShaderFX bridge**: `scripts/build_android.sh` bundles
|
||||||
|
`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or
|
||||||
|
from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is
|
||||||
|
available; see `docs/shaderfx.md`.
|
||||||
|
|
||||||
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||||
Build task: `assembleEmbedNoRecordDebug`.
|
Build task: `assembleEmbedNoRecordDebug`.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
<!-- Low latency audio -->
|
<!-- Low latency audio -->
|
||||||
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
||||||
|
<!-- love.sensor (accelerometer). No runtime permission needed on Android;
|
||||||
|
this is a Play Store filtering hint only, so required=false like the
|
||||||
|
other optional hardware features above. -->
|
||||||
|
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ The script verifies the final app before packaging it:
|
|||||||
|
|
||||||
- the public Documents plist settings are present
|
- the public Documents plist settings are present
|
||||||
- the native picker bridge is present
|
- the native picker bridge is present
|
||||||
|
- the SHADER FX librashader bridge is linked into the app binary, when this build produced one
|
||||||
- `game.love` exists and is non-empty
|
- `game.love` exists and is non-empty
|
||||||
|
|
||||||
If the payload is missing, the build fails instead of producing a blank app.
|
If the payload is missing, the build fails instead of producing a blank app.
|
||||||
|
|||||||
@@ -12,6 +12,62 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.24",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14060528,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.23",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14060437,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.22",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14060365,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.21",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14058979,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.20",
|
||||||
|
"date": "2026-08-22",
|
||||||
|
"size": 13812226,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.19",
|
||||||
|
"date": "2026-08-22",
|
||||||
|
"size": 13778435,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.18",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13772120,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.17",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13771903,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"date": "2026-08-21",
|
"date": "2026-08-21",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# LÖVE UWP binaries
|
# LÖVE UWP binaries
|
||||||
|
|
||||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`.
|
||||||
|
|
||||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -7,7 +7,7 @@
|
|||||||
"love": {
|
"love": {
|
||||||
"version": "11.5",
|
"version": "11.5",
|
||||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
|
||||||
},
|
},
|
||||||
"luajit": {
|
"luajit": {
|
||||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/bin/love.dll",
|
"path": "love/bin/love.dll",
|
||||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/bin/lua51.dll",
|
"path": "love/bin/lua51.dll",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/lib/lovestatic.lib",
|
"path": "love/lib/lovestatic.lib",
|
||||||
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
|
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/lib/lua51.lib",
|
"path": "love/lib/lua51.lib",
|
||||||
|
|||||||
+33
-3
@@ -79,7 +79,7 @@ else
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
fi
|
fi
|
||||||
# Materialize the listing once and grep the file: piping unzip straight into
|
# Materialize the listing once and grep the file: piping unzip straight into
|
||||||
@@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json; do
|
tools/rom_manifest_silver.json \
|
||||||
|
tools/rom_manifest_crystal.json; do
|
||||||
grep -qxF "$required" "$LOVE_LISTING" \
|
grep -qxF "$required" "$LOVE_LISTING" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
@@ -167,6 +168,31 @@ make_ico() { # $1 = output .ico path
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- macOS
|
# --------------------------------------------------------------- macOS
|
||||||
|
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
|
||||||
|
# without it still runs presets that were converted elsewhere.
|
||||||
|
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it.
|
||||||
|
bundle_shader_bridge() {
|
||||||
|
local dest="$1" name="$2"
|
||||||
|
local src="${SHADERFX_BRIDGE:-}"
|
||||||
|
local crate="$ROOT/tools/shaderfx-bridge"
|
||||||
|
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
|
||||||
|
src="$crate/target/release/$name"
|
||||||
|
fi
|
||||||
|
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then
|
||||||
|
say "building the ShaderFX bridge with cargo"
|
||||||
|
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
|
||||||
|
src="$crate/target/release/$name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$src" ] && [ -f "$src" ]; then
|
||||||
|
mkdir -p "$dest"
|
||||||
|
cp "$src" "$dest/$name"
|
||||||
|
say "bundled $name for SHADER FX preset conversion"
|
||||||
|
else
|
||||||
|
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
build_mac() {
|
build_mac() {
|
||||||
say "building macOS app"
|
say "building macOS app"
|
||||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
local love_app="${LOVE_APP:-/Applications/love.app}"
|
||||||
@@ -179,6 +205,7 @@ build_mac() {
|
|||||||
# drop any bundled placeholder .love and fuse ours in
|
# drop any bundled placeholder .love and fuse ours in
|
||||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||||
|
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
|
||||||
|
|
||||||
local plist="$out_app/Contents/Info.plist"
|
local plist="$out_app/Contents/Info.plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
||||||
@@ -292,9 +319,11 @@ build_win() {
|
|||||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||||
else
|
else
|
||||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
|
||||||
|
|
||||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||||
@@ -390,6 +419,7 @@ build_linux() {
|
|||||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||||
|
|
||||||
cp "$LOVE_FILE" "$appdir/game.love"
|
cp "$LOVE_FILE" "$appdir/game.love"
|
||||||
|
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
|
||||||
|
|
||||||
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||||
|
|||||||
+187
-3
@@ -2,11 +2,22 @@
|
|||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||||
|
# [--test-application-id ID]
|
||||||
#
|
#
|
||||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
# --release build the production-signed release APK (requires the
|
# --release build the production-signed release APK (requires the
|
||||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
|
# --test-application-id ID install under a distinct application id (e.g.
|
||||||
|
# com.theboisclub.pokemonred.shaderfxtest) so a
|
||||||
|
# test build installs side by side with a real
|
||||||
|
# played copy instead of overwriting it. App name
|
||||||
|
# gets a " (test)" suffix so it's distinguishable
|
||||||
|
# in the launcher too. Without this flag,
|
||||||
|
# apply_android_branding always writes back the
|
||||||
|
# real shipping identity, which previously had to
|
||||||
|
# be restored by hand in gradle.properties after
|
||||||
|
# every test build.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
@@ -34,10 +45,13 @@ GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
|||||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||||
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
||||||
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
||||||
|
CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json"
|
||||||
|
CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}"
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
RELEASE=false
|
RELEASE=false
|
||||||
|
TEST_APPLICATION_ID=""
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -48,11 +62,12 @@ while [ $# -gt 0 ]; do
|
|||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
--release) RELEASE=true ;;
|
--release) RELEASE=true ;;
|
||||||
|
--test-application-id) TEST_APPLICATION_ID="$2"; shift ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,28p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
@@ -84,6 +99,14 @@ if $RELEASE; then
|
|||||||
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TEST_APPLICATION_ID" ]; then
|
||||||
|
if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then
|
||||||
|
fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)"
|
||||||
|
fi
|
||||||
|
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||||
|
APP_NAME="$APP_NAME (test)"
|
||||||
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||||
fail "love-android not found at mobile/android/.
|
fail "love-android not found at mobile/android/.
|
||||||
@@ -246,6 +269,54 @@ ensure_silver_manifest() {
|
|||||||
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crystal_manifest_is_valid() {
|
||||||
|
local path="$1"
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||||
|
"f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_crystal_manifest() {
|
||||||
|
local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE"
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp)"
|
||||||
|
|
||||||
|
if crystal_manifest_is_valid "$manifest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Crystal import manifest is missing or invalid; recovering it before packaging"
|
||||||
|
if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||||
|
&& crystal_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "restored Crystal import manifest from this checkout's Git data"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$CRYSTAL_MANIFEST_URL" \
|
||||||
|
&& crystal_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "downloaded Crystal import manifest from the project repository"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -312,6 +383,7 @@ pack_game_love() {
|
|||||||
ensure_yellow_manifest
|
ensure_yellow_manifest
|
||||||
ensure_gold_manifest
|
ensure_gold_manifest
|
||||||
ensure_silver_manifest
|
ensure_silver_manifest
|
||||||
|
ensure_crystal_manifest
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
@@ -326,7 +398,7 @@ pack_game_love() {
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
# List once and match against the captured text: piping unzip straight into
|
# List once and match against the captured text: piping unzip straight into
|
||||||
@@ -350,6 +422,8 @@ pack_game_love() {
|
|||||||
|| fail "game.love is missing the Gold ROM import manifest"
|
|| fail "game.love is missing the Gold ROM import manifest"
|
||||||
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||||
|| fail "game.love is missing the Silver ROM import manifest"
|
|| fail "game.love is missing the Silver ROM import manifest"
|
||||||
|
grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \
|
||||||
|
|| fail "game.love is missing the Crystal ROM import manifest"
|
||||||
# This gate exists because the launcher's UI toolkit once lived outside
|
# This gate exists because the launcher's UI toolkit once lived outside
|
||||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||||
@@ -387,6 +461,115 @@ pack_game_love() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- ShaderFX bridge
|
||||||
|
SHADER_BRIDGE_LIB="liblibrashader_bridge.so"
|
||||||
|
SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a"
|
||||||
|
|
||||||
|
shader_bridge_rust_target() {
|
||||||
|
case "$1" in
|
||||||
|
arm64-v8a) printf 'aarch64-linux-android' ;;
|
||||||
|
armeabi-v7a) printf 'armv7-linux-androideabi' ;;
|
||||||
|
x86_64) printf 'x86_64-linux-android' ;;
|
||||||
|
x86) printf 'i686-linux-android' ;;
|
||||||
|
*) printf '' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
shader_bridge_staged_count() {
|
||||||
|
local jni="$1" abi count=0
|
||||||
|
for abi in $SHADER_BRIDGE_ABIS; do
|
||||||
|
[ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1))
|
||||||
|
done
|
||||||
|
printf '%s' "$count"
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_shader_bridge_android() {
|
||||||
|
local jni="$ANDROID_DIR/app/src/main/jniLibs"
|
||||||
|
local crate="$ROOT/tools/shaderfx-bridge"
|
||||||
|
local abi target
|
||||||
|
|
||||||
|
for abi in $SHADER_BRIDGE_ABIS; do
|
||||||
|
rm -f "$jni/$abi/$SHADER_BRIDGE_LIB"
|
||||||
|
done
|
||||||
|
|
||||||
|
local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}"
|
||||||
|
if [ -n "$prebuilt" ]; then
|
||||||
|
for abi in $SHADER_BRIDGE_ABIS; do
|
||||||
|
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
|
||||||
|
mkdir -p "$jni/$abi"
|
||||||
|
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
|
||||||
|
else
|
||||||
|
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
|
||||||
|
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$crate/Cargo.toml" ]; then
|
||||||
|
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then
|
||||||
|
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ndk="${ANDROID_NDK_HOME:-}"
|
||||||
|
if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
|
||||||
|
ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
|
||||||
|
fi
|
||||||
|
if [ ! -d "$ndk" ]; then
|
||||||
|
warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installed missing="" buildable=""
|
||||||
|
installed="$(rustup target list --installed 2>/dev/null || true)"
|
||||||
|
for abi in $SHADER_BRIDGE_ABIS; do
|
||||||
|
target="$(shader_bridge_rust_target "$abi")"
|
||||||
|
if grep -qx "$target" <<< "$installed"; then
|
||||||
|
buildable="$buildable $abi"
|
||||||
|
else
|
||||||
|
missing="$missing $target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing"
|
||||||
|
fi
|
||||||
|
if [ -z "$buildable" ]; then
|
||||||
|
warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local args=()
|
||||||
|
for abi in $buildable; do
|
||||||
|
args+=(-t "$abi")
|
||||||
|
done
|
||||||
|
|
||||||
|
say "building the ShaderFX bridge with cargo-ndk (${buildable# })"
|
||||||
|
mkdir -p "$jni"
|
||||||
|
if ! (
|
||||||
|
cd "$crate"
|
||||||
|
export ANDROID_NDK_HOME="$ndk"
|
||||||
|
export ANDROID_NDK_ROOT="$ndk"
|
||||||
|
export CARGO_PROFILE_RELEASE_STRIP="symbols"
|
||||||
|
cargo ndk "${args[@]}" -o "$jni" build --release
|
||||||
|
); then
|
||||||
|
warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
|
||||||
|
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion"
|
||||||
|
else
|
||||||
|
warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- SDK check
|
# --------------------------------------------------------------- SDK check
|
||||||
require_android_sdk() {
|
require_android_sdk() {
|
||||||
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||||
@@ -501,5 +684,6 @@ if $PACKAGE_ONLY; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
require_android_sdk
|
require_android_sdk
|
||||||
|
bundle_shader_bridge_android
|
||||||
run_gradle
|
run_gradle
|
||||||
say "done"
|
say "done"
|
||||||
|
|||||||
+148
-2
@@ -615,6 +615,138 @@ verify_game_payload() {
|
|||||||
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
|
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SHADER_BRIDGE_LIB=""
|
||||||
|
SHADER_BRIDGE_NAME="liblibrashader_bridge.a"
|
||||||
|
SHADER_BRIDGE_OBJ="librashader_bridge.o"
|
||||||
|
|
||||||
|
SHADER_BRIDGE_ARCHS=""
|
||||||
|
|
||||||
|
rust_targets_for_sdk() {
|
||||||
|
if [ "$1" = "iphoneos" ]; then
|
||||||
|
printf 'aarch64-apple-ios'
|
||||||
|
else
|
||||||
|
printf 'aarch64-apple-ios-sim x86_64-apple-ios'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_shader_bridge_slice() {
|
||||||
|
local rust_target="$1"
|
||||||
|
local crate="$ROOT/tools/shaderfx-bridge"
|
||||||
|
local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME"
|
||||||
|
if [ -f "$built" ]; then
|
||||||
|
printf '%s' "$built"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v cargo >/dev/null 2>&1 \
|
||||||
|
&& rustup target list --installed 2>/dev/null \
|
||||||
|
| grep -x "$rust_target" >/dev/null; then
|
||||||
|
say "building the ShaderFX bridge with cargo ($rust_target)" >&2
|
||||||
|
if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \
|
||||||
|
cargo build --release --target "$rust_target" >/dev/null 2>&1); then
|
||||||
|
printf '%s' "$built"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
prelink_shader_bridge_slice() {
|
||||||
|
local archive="$1" sdk="$2" out="$3"
|
||||||
|
local arch platform sdk_version syms objcopy tmp
|
||||||
|
arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')"
|
||||||
|
[ -n "$arch" ] || return 1
|
||||||
|
if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi
|
||||||
|
sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)"
|
||||||
|
[ -n "$sdk_version" ] || return 1
|
||||||
|
syms="$LIBS_DIR/librashader_bridge.exports"
|
||||||
|
printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms"
|
||||||
|
tmp="$out.tmp"
|
||||||
|
xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \
|
||||||
|
-all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1
|
||||||
|
objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)"
|
||||||
|
if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \
|
||||||
|
--remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
else
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_shader_bridge_ios() {
|
||||||
|
local rust_targets="$1" sdk="$2"
|
||||||
|
local src="${SHADERFX_BRIDGE_IOS:-}"
|
||||||
|
local slices=() objs=() target slice obj i
|
||||||
|
SHADER_BRIDGE_LIB=""
|
||||||
|
SHADER_BRIDGE_ARCHS=""
|
||||||
|
rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o
|
||||||
|
if [ -n "$src" ]; then
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
slices+=("$src")
|
||||||
|
else
|
||||||
|
warn "SHADERFX_BRIDGE_IOS=$src does not exist"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
for target in $rust_targets; do
|
||||||
|
if slice="$(build_shader_bridge_slice "$target")"; then
|
||||||
|
slices+=("$slice")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "${#slices[@]}" -gt 0 ]; then
|
||||||
|
mkdir -p "$LIBS_DIR"
|
||||||
|
i=0
|
||||||
|
for slice in "${slices[@]}"; do
|
||||||
|
i=$((i + 1))
|
||||||
|
obj="$LIBS_DIR/librashader_bridge.$i.o"
|
||||||
|
if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then
|
||||||
|
objs+=("$obj")
|
||||||
|
else
|
||||||
|
warn "could not prelink $(basename "$slice") for $sdk"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "${#objs[@]}" -eq 1 ]; then
|
||||||
|
mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||||
|
elif [ "${#objs[@]}" -gt 1 ]; then
|
||||||
|
lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||||
|
rm -f "${objs[@]}"
|
||||||
|
fi
|
||||||
|
if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then
|
||||||
|
SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||||
|
SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)"
|
||||||
|
say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})"
|
||||||
|
else
|
||||||
|
warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_shader_bridge() {
|
||||||
|
local app="$1"
|
||||||
|
local exe bin
|
||||||
|
if [ -z "$SHADER_BRIDGE_LIB" ]; then
|
||||||
|
warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
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 SHADER FX bridge check"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \
|
||||||
|
|| xcrun dyld_info -exports "$bin" 2>/dev/null \
|
||||||
|
| grep -E ' _librashader_translate_preset$' >/dev/null; then
|
||||||
|
say "SHADER FX bridge present (librashader_translate_preset)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fail "built app does not carry the SHADER FX bridge symbols.
|
||||||
|
$SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent,
|
||||||
|
so SHADER FX CONVERT would fail at runtime.
|
||||||
|
Rebuild after: rm -rf tools/shaderfx-bridge/target"
|
||||||
|
}
|
||||||
|
|
||||||
run_xcodebuild() {
|
run_xcodebuild() {
|
||||||
local config sdk destination
|
local config sdk destination
|
||||||
if $RELEASE; then
|
if $RELEASE; then
|
||||||
@@ -633,6 +765,8 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
|
||||||
|
|
||||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||||
@@ -661,6 +795,13 @@ run_xcodebuild() {
|
|||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||||
)
|
)
|
||||||
|
if [ -n "$SHADER_BRIDGE_LIB" ]; then
|
||||||
|
args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++")
|
||||||
|
if [ -n "$SHADER_BRIDGE_ARCHS" ]; then
|
||||||
|
args+=(ARCHS="$SHADER_BRIDGE_ARCHS")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if ! $DEVICE; then
|
if ! $DEVICE; then
|
||||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||||
# build would drop the entitlements file, and HealthKit refuses to run
|
# build would drop the entitlements file, and HealthKit refuses to run
|
||||||
@@ -723,11 +864,14 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
||||||
local app=""
|
local app=""
|
||||||
local candidate
|
local candidate newest=0 mtime
|
||||||
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
||||||
if [ -d "$candidate" ]; then
|
if [ -d "$candidate" ]; then
|
||||||
|
mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
|
||||||
|
if [ "$mtime" -gt "$newest" ]; then
|
||||||
|
newest="$mtime"
|
||||||
app="$candidate"
|
app="$candidate"
|
||||||
break
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$app" ]; then
|
if [ -z "$app" ]; then
|
||||||
@@ -736,6 +880,7 @@ run_xcodebuild() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
||||||
|
rm -rf "$products/$APP_NAME.app"
|
||||||
mv "$app" "$products/$APP_NAME.app"
|
mv "$app" "$products/$APP_NAME.app"
|
||||||
app="$products/$APP_NAME.app"
|
app="$products/$APP_NAME.app"
|
||||||
fi
|
fi
|
||||||
@@ -754,6 +899,7 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
verify_game_payload "$app"
|
verify_game_payload "$app"
|
||||||
verify_native_bridge "$app"
|
verify_native_bridge "$app"
|
||||||
|
verify_shader_bridge "$app"
|
||||||
|
|
||||||
local dist_dir="$DIST/${config}-${sdk}"
|
local dist_dir="$DIST/${config}-${sdk}"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
|
|||||||
+21
-2
@@ -7,7 +7,8 @@
|
|||||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||||
#
|
#
|
||||||
# scripts/lint.sh lint src/
|
# scripts/lint.sh full advisory report over every shipped tree
|
||||||
|
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
||||||
# scripts/lint.sh src tools lint specific paths
|
# scripts/lint.sh src tools lint specific paths
|
||||||
#
|
#
|
||||||
# Install once with: luarocks install luacheck
|
# Install once with: luarocks install luacheck
|
||||||
@@ -15,9 +16,27 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
||||||
|
|
||||||
|
GATE=0
|
||||||
|
if [ "${1:-}" = "--gate" ]; then
|
||||||
|
GATE=1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v luacheck >/dev/null 2>&1; then
|
if ! command -v luacheck >/dev/null 2>&1; then
|
||||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
luacheck "${@:-src}"
|
if [ "$#" -gt 0 ]; then
|
||||||
|
PATHS=("$@")
|
||||||
|
else
|
||||||
|
PATHS=("${DEFAULT_PATHS[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$GATE" = "1" ]; then
|
||||||
|
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
||||||
|
fi
|
||||||
|
|
||||||
|
luacheck "${PATHS[@]}"
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
|||||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||||
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||||
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
||||||
|
grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \
|
||||||
|
|| fail "game.love is missing tools/rom_manifest_crystal.json"
|
||||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||||
|
|||||||
@@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
|||||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||||
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||||
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
||||||
|
grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \
|
||||||
|
|| fail "shared payload is missing tools/rom_manifest_crystal.json"
|
||||||
|
|
||||||
say "Linux arm64 self-test passed"
|
say "Linux arm64 self-test passed"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||||
@@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
tools/rom_manifest_silver.json \
|
||||||
|
tools/rom_manifest_crystal.json \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
src/import/LauncherView.lua; do
|
src/import/LauncherView.lua; do
|
||||||
grep -qxF "$required" "$LISTING" \
|
grep -qxF "$required" "$LISTING" \
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
|||||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then
|
||||||
fail "generated data missing, run scripts/setup.sh first"
|
fail "generated data missing, run scripts/setup.sh first"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet)
|
||||||
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
|||||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
||||||
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \
|
||||||
|
"Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
@@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
|||||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||||
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/exports/crystal/README.txt" \
|
||||||
|
"Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -145,11 +149,13 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -272,11 +272,13 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
+68
-5
@@ -5,8 +5,8 @@
|
|||||||
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
||||||
# nothing but the committed fixture dataset, so they run anywhere --
|
# nothing but the committed fixture dataset, so they run anywhere --
|
||||||
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
|
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
|
||||||
# data/generated/, so it is skipped automatically when the ROM has never
|
# data/generated/ or an imported Red cache, so it is skipped automatically
|
||||||
# been imported rather than failing the run.
|
# when neither exists rather than failing the run.
|
||||||
#
|
#
|
||||||
# scripts/test.sh every tier this checkout can run
|
# scripts/test.sh every tier this checkout can run
|
||||||
# scripts/test.sh --quick skip the slow content tier
|
# scripts/test.sh --quick skip the slow content tier
|
||||||
@@ -16,6 +16,13 @@
|
|||||||
#
|
#
|
||||||
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
|
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
|
||||||
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
|
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
|
||||||
|
#
|
||||||
|
# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache
|
||||||
|
# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as
|
||||||
|
# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with:
|
||||||
|
# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION=<version> \
|
||||||
|
# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="<rom>" love .
|
||||||
|
# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -32,7 +39,7 @@ for arg in "$@"; do
|
|||||||
--bless) BLESS=1 ;;
|
--bless) BLESS=1 ;;
|
||||||
--bless-shots) SHOTS=1; BLESS=1 ;;
|
--bless-shots) SHOTS=1; BLESS=1 ;;
|
||||||
--quick) QUICK=1 ;;
|
--quick) QUICK=1 ;;
|
||||||
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
--help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -49,6 +56,36 @@ fi
|
|||||||
# save-editor suite pins that to the default identity.
|
# save-editor suite pins that to the default identity.
|
||||||
SANDBOX_IDENTITY="ci-$$"
|
SANDBOX_IDENTITY="ci-$$"
|
||||||
|
|
||||||
|
# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an
|
||||||
|
# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/).
|
||||||
|
CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches}
|
||||||
|
if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then
|
||||||
|
CACHE_ROOT="$POKEPORT_TEST_CACHES"
|
||||||
|
elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then
|
||||||
|
CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY"
|
||||||
|
else
|
||||||
|
CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only a cache with the importer's completion marker is offered: a half-written
|
||||||
|
# one would fail suites that are meant to self-skip.
|
||||||
|
adopt_cache() {
|
||||||
|
local var="$1" dir="$CACHE_ROOT/$2"
|
||||||
|
[ -z "${!var:-}" ] || return 0
|
||||||
|
[ -f "$dir/rom-cache.complete" ] || return 0
|
||||||
|
export "$var=$dir"
|
||||||
|
echo " $var=$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "-- per-version caches under $CACHE_ROOT"
|
||||||
|
adopt_cache RED_CACHE red
|
||||||
|
adopt_cache BLUE_CACHE blue
|
||||||
|
adopt_cache YELLOW_CACHE yellow
|
||||||
|
adopt_cache GOLD_CACHE gold
|
||||||
|
adopt_cache SILVER_CACHE silver
|
||||||
|
adopt_cache CRYSTAL_CACHE crystal
|
||||||
|
|
||||||
FAILED=()
|
FAILED=()
|
||||||
run_tier() {
|
run_tier() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
@@ -66,8 +103,18 @@ run_tier() {
|
|||||||
|
|
||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
|
if command -v luacheck >/dev/null 2>&1; then
|
||||||
|
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
||||||
|
./scripts/lint.sh --gate
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
||||||
|
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
||||||
|
fi
|
||||||
|
|
||||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||||
|
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||||
@@ -78,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
|||||||
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
|
# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache),
|
||||||
|
# so it runs here rather than behind the Red content gate below.
|
||||||
|
run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
run_tier "T4 title checkpoint cold restart" \
|
run_tier "T4 title checkpoint cold restart" \
|
||||||
bash tests/integration/title_checkpoint_cold_start.sh
|
bash tests/integration/title_checkpoint_cold_start.sh
|
||||||
@@ -124,7 +174,19 @@ run_content_behavior() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The Red dataset is the source tree's when tools/build_data.py wrote one, and
|
||||||
|
# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook.
|
||||||
|
HAVE_RED_DATA=0
|
||||||
if [ -f data/generated/maps.lua ]; then
|
if [ -f data/generated/maps.lua ]; then
|
||||||
|
HAVE_RED_DATA=1
|
||||||
|
elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then
|
||||||
|
HAVE_RED_DATA=1
|
||||||
|
export POKEPORT_DATA_DIR="$RED_CACHE/data/generated"
|
||||||
|
echo ""
|
||||||
|
echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HAVE_RED_DATA" = "1" ]; then
|
||||||
if [ "$QUICK" = "1" ]; then
|
if [ "$QUICK" = "1" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "-- T3 content: skipped (--quick)"
|
echo "-- T3 content: skipped (--quick)"
|
||||||
@@ -154,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
|
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
|
||||||
echo " import a ROM to run them; the modded-link cases ran in T4)"
|
echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran"
|
||||||
|
echo " in T4 and the Gen 2 suites in T2)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ------- golden screenshots: needs love + a display
|
# ------- golden screenshots: needs love + a display
|
||||||
|
|||||||
@@ -348,6 +348,12 @@ local EMITTERS = {
|
|||||||
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
|
||||||
|
local function tilesetLoadFrames(data, ts)
|
||||||
|
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
|
||||||
|
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
|
||||||
|
end
|
||||||
|
|
||||||
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
||||||
-- with the subanimation transform applied (DrawFrameBlock).
|
-- with the subanimation transform applied (DrawFrameBlock).
|
||||||
local function placeTile(transform, bc, t, tileset)
|
local function placeTile(transform, bc, t, tileset)
|
||||||
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
|||||||
local obp0Flip = false
|
local obp0Flip = false
|
||||||
|
|
||||||
for _, row in ipairs(anim.seq) do
|
for _, row in ipairs(anim.seq) do
|
||||||
|
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
|
||||||
|
-- row's sound and its first frame block (#1653)
|
||||||
|
if not row.effect then
|
||||||
|
emit(tilesetLoadFrames(self.data, row.tileset))
|
||||||
|
end
|
||||||
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
||||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||||
-- the row starts (GetMoveSound)
|
-- the row starts (GetMoveSound)
|
||||||
|
|||||||
+225
-17
@@ -97,7 +97,7 @@ end
|
|||||||
-- space.
|
-- space.
|
||||||
function BattleState:extendedHUD()
|
function BattleState:extendedHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
local bg = options and options.battleBg
|
local bg = options and self:bgMode()
|
||||||
return self:wideLayout()
|
return self:wideLayout()
|
||||||
and options and options.battleHud == "extended"
|
and options and options.battleHud == "extended"
|
||||||
and ((options.battleFit == "fixed"
|
and ((options.battleFit == "fixed"
|
||||||
@@ -108,12 +108,12 @@ end
|
|||||||
function BattleState:extendedWorldHUD()
|
function BattleState:extendedWorldHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
return self:extendedHUD() and options
|
return self:extendedHUD() and options
|
||||||
and options.battleFit == "fixed" and options.battleBg == "world"
|
and options.battleFit == "fixed" and self:bgMode() == "world"
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:extendedBlackHUD()
|
function BattleState:extendedBlackHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
return self:extendedHUD() and options and options.battleBg == "black"
|
return self:extendedHUD() and options and self:bgMode() == "black"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||||
@@ -130,6 +130,10 @@ end
|
|||||||
-- opaque 160x144 field over the top, so only the surround changes.
|
-- opaque 160x144 field over the top, so only the surround changes.
|
||||||
function BattleState:bgMode()
|
function BattleState:bgMode()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
|
if options and self:wideLayout() and options.battleFit == "fill"
|
||||||
|
and options.battleHud == "extended" then
|
||||||
|
return "white"
|
||||||
|
end
|
||||||
local mode = options and options.battleBg
|
local mode = options and options.battleBg
|
||||||
if mode == "black" or mode == "world" then return mode end
|
if mode == "black" or mode == "world" then return mode end
|
||||||
return "white"
|
return "white"
|
||||||
@@ -212,6 +216,13 @@ local BALL_ANIMS = {
|
|||||||
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
|
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- engine/battle/effects.asm:552
|
||||||
|
local ENEMY_STAT_DOWN_MISS = {
|
||||||
|
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||||
|
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||||
|
ACCURACY_DOWN1_EFFECT = true,
|
||||||
|
}
|
||||||
|
|
||||||
local imageCache = {}
|
local imageCache = {}
|
||||||
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
||||||
-- running battle holds the pics it built at enter() (battler.sprite,
|
-- running battle holds the pics it built at enter() (battler.sprite,
|
||||||
@@ -229,6 +240,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
|||||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||||
-- rebuilt for any battle pic, whatever code loaded it
|
-- rebuilt for any battle pic, whatever code loaded it
|
||||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||||
|
local function mattedPic(path)
|
||||||
|
return path:sub(1, 17) == "assets/generated/"
|
||||||
|
or path:sub(1, 17) == "save/mod-derived/"
|
||||||
|
end
|
||||||
|
|
||||||
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
|
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
|
||||||
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
||||||
-- entirely, so its palette variant collapses back onto the plain path.
|
-- entirely, so its palette variant collapses back onto the plain path.
|
||||||
@@ -254,6 +270,7 @@ local function getImage(path, pal, trueColor)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
local w, h = id:getDimensions()
|
local w, h = id:getDimensions()
|
||||||
|
if mattedPic(Assets.resolve(path)) then
|
||||||
local bottom = h - 1
|
local bottom = h - 1
|
||||||
while bottom >= 0 do
|
while bottom >= 0 do
|
||||||
local opaque = false
|
local opaque = false
|
||||||
@@ -274,9 +291,10 @@ local function getImage(path, pal, trueColor)
|
|||||||
if opaque then break end
|
if opaque then break end
|
||||||
left = left + 1
|
left = left + 1
|
||||||
end
|
end
|
||||||
img = love.graphics.newImage(id)
|
|
||||||
pad = h - 1 - bottom
|
pad = h - 1 - bottom
|
||||||
padL = left
|
padL = left
|
||||||
|
end
|
||||||
|
img = love.graphics.newImage(id)
|
||||||
else
|
else
|
||||||
img = Assets.image(path) -- headless stub: no pixel access
|
img = Assets.image(path) -- headless stub: no pixel access
|
||||||
end
|
end
|
||||||
@@ -538,6 +556,7 @@ end
|
|||||||
|
|
||||||
local function makeBattler(data, mon, isPlayer, save)
|
local function makeBattler(data, mon, isPlayer, save)
|
||||||
local def = data.pokemon[mon.species]
|
local def = data.pokemon[mon.species]
|
||||||
|
require("src.pokemon.Stats").ensure(def, mon)
|
||||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||||
local badges = nil
|
local badges = nil
|
||||||
if isPlayer and save then
|
if isPlayer and save then
|
||||||
@@ -637,6 +656,9 @@ local function newBattle(game)
|
|||||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||||
game.partyMenuSavedIndex = nil
|
game.partyMenuSavedIndex = nil
|
||||||
|
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
|
||||||
|
-- (init_battle_variables.asm:7-12) #1732
|
||||||
|
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
|
||||||
self.data = game.data
|
self.data = game.data
|
||||||
-- ruleset from the merged registry (the requires above are the same
|
-- ruleset from the merged registry (the requires above are the same
|
||||||
-- records on a mod-free boot); an unknown save value falls back to the
|
-- records on a mod-free boot); an unknown save value falls back to the
|
||||||
@@ -1917,6 +1939,8 @@ function BattleState:exit()
|
|||||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||||
-- field party menu comes back on slot 1 after a battle. #768
|
-- field party menu comes back on slot 1 after a battle. #768
|
||||||
self.game.partyMenuSavedIndex = nil
|
self.game.partyMenuSavedIndex = nil
|
||||||
|
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
|
||||||
|
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
|
||||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||||
@@ -1941,6 +1965,7 @@ local function clearTrapping(battler)
|
|||||||
battler.trappingTurns = nil
|
battler.trappingTurns = nil
|
||||||
battler.trapMove = nil
|
battler.trapMove = nil
|
||||||
battler.trapDamage = nil
|
battler.trapDamage = nil
|
||||||
|
battler.trapHitSfx = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||||
@@ -2207,6 +2232,9 @@ function BattleState:update(dt)
|
|||||||
self.waitFrames = nil
|
self.waitFrames = nil
|
||||||
if destination == "menu" then
|
if destination == "menu" then
|
||||||
self.introSlide = nil
|
self.introSlide = nil
|
||||||
|
-- engine/battle/core.asm:2007
|
||||||
|
self.msgHold = nil
|
||||||
|
self.shown = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif destination == "finish" then
|
elseif destination == "finish" then
|
||||||
self:finish()
|
self:finish()
|
||||||
@@ -2791,6 +2819,111 @@ function BattleState:queueResidual(b, opp)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fieldBattlerView(battler, side)
|
||||||
|
local types = {}
|
||||||
|
for index, typeId in ipairs(battler.curTypes or {}) do
|
||||||
|
types[index] = typeId
|
||||||
|
end
|
||||||
|
local mon = battler.mon or {}
|
||||||
|
return {
|
||||||
|
side = side,
|
||||||
|
name = battler.name,
|
||||||
|
hp = tonumber(mon.hp) or 0,
|
||||||
|
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
|
||||||
|
types = types,
|
||||||
|
vanished = battler.invulnerable and true or false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function publicScalar(value)
|
||||||
|
local kind = type(value)
|
||||||
|
if kind == "string" or kind == "boolean" then return value, true end
|
||||||
|
if kind == "number" and value == value
|
||||||
|
and value < math.huge and value > -math.huge then
|
||||||
|
return value, true
|
||||||
|
end
|
||||||
|
return nil, false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function publicDataCopy(value, visiting)
|
||||||
|
local scalar, ok = publicScalar(value)
|
||||||
|
if ok then return scalar, true end
|
||||||
|
if type(value) ~= "table" then return nil, false end
|
||||||
|
|
||||||
|
visiting = visiting or {}
|
||||||
|
if visiting[value] then return nil, false end
|
||||||
|
visiting[value] = true
|
||||||
|
|
||||||
|
local copy = {}
|
||||||
|
for key, child in next, value do
|
||||||
|
local copiedKey, keyOk = publicScalar(key)
|
||||||
|
local copiedChild, childOk = publicDataCopy(child, visiting)
|
||||||
|
if keyOk and childOk then copy[copiedKey] = copiedChild end
|
||||||
|
end
|
||||||
|
visiting[value] = nil
|
||||||
|
return copy, true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function checkpointFieldView(field)
|
||||||
|
field = field or {}
|
||||||
|
local view = publicDataCopy({
|
||||||
|
weather = field.weather,
|
||||||
|
tokens = field.tokens or {},
|
||||||
|
})
|
||||||
|
return view
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Public field residuals are data-only requests. Mods can inspect a detached
|
||||||
|
-- checkpoint-shaped field view and detached battler views, but only the engine
|
||||||
|
-- mutates HP, animates the bar, or enters the faint pipeline.
|
||||||
|
function BattleState:applyFieldResiduals()
|
||||||
|
if not Runtime.wantsHook("battle.field_residual") then return end
|
||||||
|
local views = {
|
||||||
|
player = fieldBattlerView(self.player, "player"),
|
||||||
|
enemy = fieldBattlerView(self.enemy, "enemy"),
|
||||||
|
}
|
||||||
|
local rows = Runtime.call("battle.field_residual", function() return {} end, {
|
||||||
|
field = checkpointFieldView(self.field),
|
||||||
|
battlers = views,
|
||||||
|
turn = self.turnCount or 0,
|
||||||
|
})
|
||||||
|
if type(rows) ~= "table" then return end
|
||||||
|
|
||||||
|
local fainted = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local battler = type(row) == "table" and row.side == "player"
|
||||||
|
and self.player or type(row) == "table" and row.side == "enemy"
|
||||||
|
and self.enemy or nil
|
||||||
|
local amount = type(row) == "table" and row.amount or nil
|
||||||
|
if battler and battler.mon.hp > 0 and type(amount) == "number"
|
||||||
|
and amount > 0
|
||||||
|
and amount < math.huge and amount == math.floor(amount)
|
||||||
|
and (row.message == nil or type(row.message) == "string") then
|
||||||
|
amount = math.min(amount, battler.mon.hp)
|
||||||
|
if type(row.message) == "string" and row.message ~= "" then
|
||||||
|
self:sayNext(row.message)
|
||||||
|
end
|
||||||
|
battler.mon.hp = battler.mon.hp - amount
|
||||||
|
self:drainNext(battler, battler.mon.hp)
|
||||||
|
if battler.mon.hp <= 0 then fainted[battler] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- A terminal player faint owns a simultaneous field-residual batch. Queue
|
||||||
|
-- only that authority so its blackout cannot race an enemy EXP/replacement
|
||||||
|
-- path from the same hook response. Native faint paths remain untouched.
|
||||||
|
if fainted[self.player]
|
||||||
|
and not Party.firstHealthy(self:playerPartyView()) then
|
||||||
|
self:onFaint(self.player)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Otherwise resolve the two sides in engine order after every accepted
|
||||||
|
-- descriptor has landed. Descriptor order must not decide resolution.
|
||||||
|
for _, battler in ipairs({ self.player, self.enemy }) do
|
||||||
|
if fainted[battler] then self:onFaint(battler) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:endOfTurn()
|
function BattleState:endOfTurn()
|
||||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||||
@@ -2844,6 +2977,7 @@ function BattleState:endOfTurn()
|
|||||||
b.trappingTurns = nil
|
b.trappingTurns = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self:applyFieldResiduals()
|
||||||
self:tickTokens()
|
self:tickTokens()
|
||||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||||
end
|
end
|
||||||
@@ -2928,18 +3062,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
|
|||||||
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
|
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
|
||||||
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
|
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
|
||||||
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
|
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
|
||||||
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB)
|
local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4
|
||||||
|
local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9
|
||||||
|
|
||||||
|
-- engine/battle/animations.asm:1090
|
||||||
|
local function onSgb()
|
||||||
|
local m = require("src.render.PaletteFX").mode
|
||||||
|
return m == "gbc" or m == "gbc_inv"
|
||||||
|
end
|
||||||
|
|
||||||
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
|
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
|
||||||
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
|
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
|
||||||
-- (FlashScreenLongDelay)
|
-- (FlashScreenLongDelay)
|
||||||
local FLASH_LONG_MAPS = {
|
local FLASH_LONG_SGB = {
|
||||||
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
|
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
|
||||||
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
|
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
|
||||||
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
|
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
|
||||||
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
|
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- engine/battle/animations.asm:992
|
||||||
|
local FLASH_LONG_MONO = {
|
||||||
|
{ [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
|
||||||
|
{ [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
|
||||||
|
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
|
||||||
|
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
|
||||||
|
}
|
||||||
|
|
||||||
-- the shade map in force this frame (a running flash wins over the
|
-- the shade map in force this frame (a running flash wins over the
|
||||||
-- persistent palette)
|
-- persistent palette)
|
||||||
function BattleState:activeBgp()
|
function BattleState:activeBgp()
|
||||||
@@ -3073,7 +3222,7 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
|
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
|
||||||
fx.bgp = BGP_LIGHT
|
fx.bgp = BGP_LIGHT
|
||||||
elseif e == "SE_DARKEN_MON_PALETTE" then
|
elseif e == "SE_DARKEN_MON_PALETTE" then
|
||||||
fx.bgp = BGP_DARKEN
|
fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO
|
||||||
elseif e == "SE_RESET_SCREEN_PALETTE" then
|
elseif e == "SE_RESET_SCREEN_PALETTE" then
|
||||||
fx.bgp = nil
|
fx.bgp = nil
|
||||||
elseif e == "SE_DARK_SCREEN_FLASH" then
|
elseif e == "SE_DARK_SCREEN_FLASH" then
|
||||||
@@ -3083,8 +3232,9 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
idx = 1, left = 2 }
|
idx = 1, left = 2 }
|
||||||
elseif e == "SE_FLASH_SCREEN_LONG" then
|
elseif e == "SE_FLASH_SCREEN_LONG" then
|
||||||
local steps = {}
|
local steps = {}
|
||||||
|
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
|
||||||
for cycle = 1, 3 do
|
for cycle = 1, 3 do
|
||||||
for _, m in ipairs(FLASH_LONG_MAPS) do
|
for _, m in ipairs(maps) do
|
||||||
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
|
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4026,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
|
|
||||||
-- pure status moves
|
-- pure status moves
|
||||||
if move.power == 0 and record and record.kind == "primary" and record.run then
|
if move.power == 0 and record and record.kind == "primary" and record.run then
|
||||||
|
if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer
|
||||||
|
and self.kind ~= "link" and self.rng(0, 255) < 64 then
|
||||||
|
self:cancelMoveAnim()
|
||||||
|
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
|
||||||
|
return
|
||||||
|
end
|
||||||
-- accuracy-checked status effects run MoveHitTest, which has no
|
-- accuracy-checked status effects run MoveHitTest, which has no
|
||||||
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
|
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
|
||||||
-- roll) and misses outright against a mid-Fly/Dig target; the
|
-- roll) and misses outright against a mid-Fly/Dig target; the
|
||||||
@@ -4069,12 +4225,15 @@ function BattleState:continueTrapping(user, target)
|
|||||||
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
||||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||||
-- animation replays each locked turn (same damage, animation shown).
|
-- animation replays each locked turn (same damage, animation shown).
|
||||||
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
|
-- Mirror performMove's anim row (BattleState.lua ~1307), with the
|
||||||
-- OPTIONS animation toggle.
|
-- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 /
|
||||||
if user.trapMove and self:animationsOn() then
|
-- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653).
|
||||||
|
if user.trapMove then
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert,
|
table.insert(self.queue, self.nextInsert,
|
||||||
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer })
|
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer,
|
||||||
|
hit = { animType = user.isPlayer and 5 or 2,
|
||||||
|
sfx = user.trapHitSfx } })
|
||||||
end
|
end
|
||||||
-- the counter can sit at 0 until the END of the turn: the trapping
|
-- the counter can sit at 0 until the END of the turn: the trapping
|
||||||
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
|
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
|
||||||
@@ -4249,7 +4408,7 @@ function BattleState:awardExp()
|
|||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, traded)
|
split, traded)
|
||||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
-- Track level-ups for EvolveAfterBattle (BattleState:finish ->
|
||||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||||
-- without this gate it re-triggers after every later fight (#213).
|
-- without this gate it re-triggers after every later fight (#213).
|
||||||
if #levels > 0 then
|
if #levels > 0 then
|
||||||
@@ -4301,7 +4460,11 @@ function BattleState:awardExp()
|
|||||||
-- new current HP (house convention: potions drain the bar too, see
|
-- new current HP (house convention: potions drain the bar too, see
|
||||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||||
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||||
if mon == self.player.mon then self:drainNext() end
|
if mon == self.player.mon then
|
||||||
|
-- engine/battle/experience.asm:236
|
||||||
|
self.player.badgeExtraBoosts = nil
|
||||||
|
self:drainNext()
|
||||||
|
end
|
||||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||||
self.data.pokemon[mon.species], lv)) do
|
self.data.pokemon[mon.species], lv)) do
|
||||||
self:learnMove(mon, moveId)
|
self:learnMove(mon, moveId)
|
||||||
@@ -4626,6 +4789,14 @@ function BattleState:playerMonFainted()
|
|||||||
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||||
if not nextMon and self.result ~= "lose" then
|
if not nextMon and self.result ~= "lose" then
|
||||||
if self.oppClass == "OPP_RIVAL1" then
|
if self.oppClass == "OPP_RIVAL1" then
|
||||||
|
-- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic
|
||||||
|
-- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721)
|
||||||
|
self:actNext(function()
|
||||||
|
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||||
|
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
||||||
|
end)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local raw = (self.data.text and self.data.text._Rival1WinText)
|
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||||
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
||||||
@@ -4962,6 +5133,22 @@ end
|
|||||||
-- "New POKéDEX data will be added" + the dex entry page, then
|
-- "New POKéDEX data will be added" + the dex entry page, then
|
||||||
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
||||||
-- transfer text when the party was full.
|
-- transfer text when the party was full.
|
||||||
|
-- Where a caught mon goes when the party has no room for it (RFC 0018):
|
||||||
|
-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or
|
||||||
|
-- "mod" when the catch.party_full hook claims it -- a game mode that has
|
||||||
|
-- done away with storage hands the decision to the player instead of
|
||||||
|
-- laundering the catch through a PC it has locked. A method rather than
|
||||||
|
-- an inline read, so a mod or a compatibility shim can tell a seam engine
|
||||||
|
-- from a stock one by name.
|
||||||
|
function BattleState:partyFullDestination(mon)
|
||||||
|
if not Runtime.wantsHook("catch.party_full") then return "box" end
|
||||||
|
local claimed = Runtime.call("catch.party_full", function() return false end,
|
||||||
|
{ battle = self, mon = mon, name = self.enemy and self.enemy.name,
|
||||||
|
game = self.game })
|
||||||
|
if claimed then return "mod" end
|
||||||
|
return "box"
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:storeCaughtMon()
|
function BattleState:storeCaughtMon()
|
||||||
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
||||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||||
@@ -5002,7 +5189,8 @@ function BattleState:storeCaughtMon()
|
|||||||
if Party.add(game.save.party, self.enemy.mon) then
|
if Party.add(game.save.party, self.enemy.mon) then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
else
|
else
|
||||||
destination = "box"
|
destination = self:partyFullDestination(self.enemy.mon)
|
||||||
|
if destination == "box" then
|
||||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
@@ -5017,6 +5205,7 @@ function BattleState:storeCaughtMon()
|
|||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
Runtime.emit("pokemon.caught", {
|
Runtime.emit("pokemon.caught", {
|
||||||
battle = self, mon = self.enemy.mon, species = species, isNew = isNew,
|
battle = self, mon = self.enemy.mon, species = species, isNew = isNew,
|
||||||
ball = self.lastBall, destination = destination, game = game,
|
ball = self.lastBall, destination = destination, game = game,
|
||||||
@@ -5215,6 +5404,14 @@ function BattleState:finish()
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the
|
||||||
|
-- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213)
|
||||||
|
if not self.evolutionsChecked then
|
||||||
|
self.evolutionsChecked = true
|
||||||
|
require("src.pokemon.Evolution").checkParty(self.game,
|
||||||
|
function() self:finish() end, self.leveledUp)
|
||||||
|
return
|
||||||
|
end
|
||||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||||
@@ -5255,6 +5452,13 @@ function BattleState:finish()
|
|||||||
local result = self.result or "run"
|
local result = self.result or "run"
|
||||||
local onFinish = self.onFinish
|
local onFinish = self.onFinish
|
||||||
if result == "lose" then
|
if result == "lose" then
|
||||||
|
-- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map
|
||||||
|
-- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721)
|
||||||
|
if BattleState.isOaksLabStarterRival(self) then
|
||||||
|
self.game.stack:push(require("src.render.Transition").battleReturn(
|
||||||
|
self.game, function() if onFinish then onFinish(result) end end))
|
||||||
|
return
|
||||||
|
end
|
||||||
-- the blackout path warps to the heal point with its own transition
|
-- the blackout path warps to the heal point with its own transition
|
||||||
if onFinish then onFinish(result) end
|
if onFinish then onFinish(result) end
|
||||||
return
|
return
|
||||||
@@ -5781,7 +5985,9 @@ function BattleState:animSpriteColors(s, px, py)
|
|||||||
local P
|
local P
|
||||||
-- engine/battle/animations.asm:551 (.notSGB)
|
-- engine/battle/animations.asm:551 (.notSGB)
|
||||||
if PaletteFX.usesSpriteObp() then
|
if PaletteFX.usesSpriteObp() then
|
||||||
P = PaletteFX.ogObj()
|
-- engine/battle/init_battle_variables.asm:18
|
||||||
|
P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE
|
||||||
|
or PaletteFX.GBC_OBJ
|
||||||
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
||||||
else
|
else
|
||||||
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
||||||
@@ -6119,8 +6325,10 @@ function BattleState:drawHUDs(slide)
|
|||||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||||
end
|
end
|
||||||
local hidePlayer = self.safari or self.demo
|
local hidePlayer = self.safari or self.demo
|
||||||
|
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and
|
||||||
|
-- nothing redraws it until the next SendOutMon (#1721)
|
||||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||||
and slide == 0 then
|
and slide == 0 and not self.player.fainted then
|
||||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||||
-- the tick at (18,10) and the triangle at (9,11)
|
-- the tick at (18,10) and the triangle at (9,11)
|
||||||
|
|||||||
+30
-8
@@ -41,6 +41,34 @@ local function badgeBoost(battler, stat)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:6454
|
||||||
|
function Damage.applyBadgeBoost(battler, stat, value)
|
||||||
|
if battler.hazeStatReset then return value end
|
||||||
|
local row = badgeBoost(battler, stat)
|
||||||
|
if not row then return value end
|
||||||
|
local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0
|
||||||
|
for _ = 1, 1 + extra do
|
||||||
|
value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8)))
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effects.asm:498,689
|
||||||
|
function Damage.reapplyBadgeBoosts(battler, changedStat)
|
||||||
|
if not battler or not battler.badges then return end
|
||||||
|
local extra = battler.badgeExtraBoosts
|
||||||
|
if not extra then extra = {} battler.badgeExtraBoosts = extra end
|
||||||
|
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
||||||
|
if battler.badges[row.badge] then
|
||||||
|
if row.stat == changedStat then
|
||||||
|
extra[row.stat] = 0
|
||||||
|
else
|
||||||
|
extra[row.stat] = (extra[row.stat] or 0) + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- the merged status record for a battler's persistent condition, or nil
|
-- the merged status record for a battler's persistent condition, or nil
|
||||||
local function statusRecord(battler)
|
local function statusRecord(battler)
|
||||||
return Status.recordFor(battler.statuses, battler.mon.status)
|
return Status.recordFor(battler.statuses, battler.mon.status)
|
||||||
@@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
|
|||||||
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
||||||
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
||||||
-- Volcano -> special
|
-- Volcano -> special
|
||||||
local atkBoost = badgeBoost(attacker, atkStat)
|
atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
|
||||||
if atkBoost then
|
dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
|
||||||
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
|
|
||||||
end
|
|
||||||
local defBoost = badgeBoost(defender, defStat)
|
|
||||||
if defBoost then
|
|
||||||
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
|
|
||||||
end
|
|
||||||
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
||||||
-- the status record's statPenalty names the stat it cuts).
|
-- the status record's statPenalty names the stat it cuts).
|
||||||
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
local totalDealt = 0
|
local totalDealt = 0
|
||||||
local landed, brokeSub = 0, false
|
local landed, brokeSub = 0, false
|
||||||
|
local critPending, ohkoPending = info.crit, info.ohko
|
||||||
for h = 1, hits do
|
for h = 1, hits do
|
||||||
if target.mon.hp <= 0 then break end
|
if target.mon.hp <= 0 then break end
|
||||||
local hitRow
|
local hitRow
|
||||||
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
totalDealt = totalDealt + dealt
|
totalDealt = totalDealt + dealt
|
||||||
landed = h
|
landed = h
|
||||||
if dealt > 0 then hitRow.hit = hitFx end
|
if dealt > 0 then hitRow.hit = hitFx end
|
||||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
|
||||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
|
||||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
if critPending then
|
||||||
-- every strike -- damage was only rolled once
|
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
|
||||||
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
|
critPending = false
|
||||||
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
|
end
|
||||||
|
if ohkoPending then
|
||||||
|
battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!"))
|
||||||
|
ohkoPending = false
|
||||||
|
end
|
||||||
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
|
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
|
||||||
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
|
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
|
||||||
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
|
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
|
||||||
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
||||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||||
|
ctx.hitSfx = hitSfx
|
||||||
if record and record.afterDamage then
|
if record and record.afterDamage then
|
||||||
record.afterDamage(ctx, totalDealt)
|
record.afterDamage(ctx, totalDealt)
|
||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
-- is the registry view of all three -- the merged Data.move_effects a
|
-- is the registry view of all three -- the merged Data.move_effects a
|
||||||
-- battle dispatches on serves these same objects.
|
-- battle dispatches on serves these same objects.
|
||||||
|
|
||||||
|
local Damage = require("src.battle.Damage")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
@@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
|||||||
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
|
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
|
||||||
-- re-baking the burn/para penalty and ending Haze's temporary lift.
|
-- re-baking the burn/para penalty and ending Haze's temporary lift.
|
||||||
who.hazeStatReset = nil
|
who.hazeStatReset = nil
|
||||||
|
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
|
||||||
|
and battle.kind ~= "link" and who == battle.player then
|
||||||
|
Damage.reapplyBadgeBoosts(who, stat)
|
||||||
|
end
|
||||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- two-stage variants scroll "greatly" onto a third line
|
||||||
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
|
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
|
||||||
@@ -132,6 +137,11 @@ end
|
|||||||
|
|
||||||
local function statDownSide(stat)
|
local function statDownSide(stat)
|
||||||
return function(battle, user, target)
|
return function(battle, user, target)
|
||||||
|
-- engine/battle/effects.asm:552
|
||||||
|
if not user.isPlayer and battle.kind ~= "link"
|
||||||
|
and battle.rng(0, 255) < 64 then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
if target.substituteHP then return {} end
|
if target.substituteHP then return {} end
|
||||||
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
|
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
|
||||||
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
|
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
|
||||||
@@ -254,6 +264,7 @@ MoveEffects.primary = {
|
|||||||
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
|
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
|
||||||
-- until the next stat recompute (a stage change or switch-in).
|
-- until the next stat recompute (a stage change or switch-in).
|
||||||
b.hazeStatReset = true
|
b.hazeStatReset = true
|
||||||
|
b.badgeExtraBoosts = nil
|
||||||
end
|
end
|
||||||
-- Gen 1 also removes the enemy's major status; if that cured sleep
|
-- Gen 1 also removes the enemy's major status; if that cured sleep
|
||||||
-- or freeze, the target forfeits its move this turn (haze.asm
|
-- or freeze, the target forfeits its move this turn (haze.asm
|
||||||
@@ -574,6 +585,8 @@ MoveEffects.full = {
|
|||||||
local r = ctx.rng(0, 7)
|
local r = ctx.rng(0, 7)
|
||||||
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
||||||
user.trapDamage = ctx.rawDamage
|
user.trapDamage = ctx.rawDamage
|
||||||
|
-- PlayApplyingAttackSound (animations.asm:2639) replays it each turn
|
||||||
|
user.trapHitSfx = ctx.hitSfx
|
||||||
-- remember the move so its animation can replay on each locked
|
-- remember the move so its animation can replay on each locked
|
||||||
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
||||||
user.trapMove = ctx.move.id
|
user.trapMove = ctx.move.id
|
||||||
@@ -645,7 +658,9 @@ MoveEffects.full = {
|
|||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.battle:cancelMoveAnim()
|
ctx.battle:cancelMoveAnim()
|
||||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
ctx.battle:animBeforeMove(
|
||||||
|
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
||||||
|
user.isPlayer)
|
||||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,15 +14,7 @@ local function effectiveSpeed(battler)
|
|||||||
battler.stages and battler.stages.speed or 0)
|
battler.stages and battler.stages.speed or 0)
|
||||||
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
|
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
|
||||||
-- the battler's merged badgeBoosts with the vanilla list as fallback
|
-- the battler's merged badgeBoosts with the vanilla list as fallback
|
||||||
local badges = battler.badges
|
spd = Damage.applyBadgeBoost(battler, "speed", spd)
|
||||||
if badges then
|
|
||||||
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
|
||||||
if row.stat == "speed" and badges[row.badge] then
|
|
||||||
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- paralysis quarters speed (the status record's statPenalty);
|
-- paralysis quarters speed (the status record's statPenalty);
|
||||||
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
|
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
|
||||||
-- copied the unmodified speed over the quartered battle stat, lifting
|
-- copied the unmodified speed over the quartered battle stat, lifting
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ local function battleIsTopState(battle)
|
|||||||
return not (stack and stack.top) or stack:top() == battle
|
return not (stack and stack.top) or stack:top() == battle
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/menus/party_menu.asm:4
|
||||||
|
local function coveredByOpaqueState(battle)
|
||||||
|
local stack = battle.game and battle.game.stack
|
||||||
|
local states = stack and stack.states
|
||||||
|
if not states then return false end
|
||||||
|
local above = false
|
||||||
|
for i = 1, #states do
|
||||||
|
if above and states[i] and states[i].isOpaque then return true end
|
||||||
|
if states[i] == battle then above = true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local function anchorHUD(battle, x, y, w, h, anchor)
|
local function anchorHUD(battle, x, y, w, h, anchor)
|
||||||
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
||||||
local renderer = battle.game and battle.game.renderer
|
local renderer = battle.game and battle.game.renderer
|
||||||
@@ -161,8 +174,10 @@ local function drawHUDs(battle, slide)
|
|||||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||||
-- drawCommandMenu below like the classic layout's (#540).
|
-- drawCommandMenu below like the classic layout's (#540).
|
||||||
|
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721)
|
||||||
if showStatus and not battle.safari and battle.player and not battle.demo
|
if showStatus and not battle.safari and battle.player and not battle.demo
|
||||||
and not battle.showPlayerBack and slide == 0 then
|
and not battle.showPlayerBack and slide == 0
|
||||||
|
and not battle.player.fainted then
|
||||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -349,7 +364,7 @@ function WideBattle.draw(battle)
|
|||||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
end
|
end
|
||||||
-- AskName clears the field the same way the classic layout does
|
-- AskName clears the field the same way the classic layout does
|
||||||
if battle.blankForAskName then return end
|
if battle.blankForAskName or coveredByOpaqueState(battle) then return end
|
||||||
|
|
||||||
local fx = battle.fx
|
local fx = battle.fx
|
||||||
local sx = (fx and fx.shakeX) or 0
|
local sx = (fx and fx.shakeX) or 0
|
||||||
|
|||||||
+313
-51
@@ -16,7 +16,7 @@
|
|||||||
--
|
--
|
||||||
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
|
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
|
||||||
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
|
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
|
||||||
-- from 1-7 turns.
|
-- from 2-7 turns.
|
||||||
|
|
||||||
local Damage = require("src.battle.gen2.Damage")
|
local Damage = require("src.battle.gen2.Damage")
|
||||||
local Ai = require("src.battle.gen2.Ai")
|
local Ai = require("src.battle.gen2.Ai")
|
||||||
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
|||||||
-- be run from or Roared away.
|
-- be run from or Roared away.
|
||||||
Battle.BATTLETYPE_FORCESHINY = 7
|
Battle.BATTLETYPE_FORCESHINY = 7
|
||||||
Battle.BATTLETYPE_TRAP = 9
|
Battle.BATTLETYPE_TRAP = 9
|
||||||
|
-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends
|
||||||
|
Battle.BATTLETYPE_CELEBI = 11
|
||||||
|
Battle.BATTLETYPE_SUICUNE = 12
|
||||||
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||||
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||||
Battle.BATTLETYPE_CANLOSE = 1
|
Battle.BATTLETYPE_CANLOSE = 1
|
||||||
@@ -247,6 +250,13 @@ function Battle.new(opts)
|
|||||||
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
|
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
|
||||||
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
|
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
|
||||||
self.battleType = opts.battleType
|
self.battleType = opts.battleType
|
||||||
|
-- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set
|
||||||
|
-- around the Tower's own StartBattle (engine/events/battle_tower/
|
||||||
|
-- battle_tower.asm:220-223) and cleared again at :253-254.
|
||||||
|
self.inBattleTowerBattle = opts.battleTower and true or false
|
||||||
|
-- wTimeOfDay: only BattleCommand_TimeBasedHealContinue reads it in battle
|
||||||
|
-- (engine/battle/effect_commands.asm:6401-6404).
|
||||||
|
self.timeOfDay = opts.timeOfDay
|
||||||
self.events = {}
|
self.events = {}
|
||||||
self.turn = 0
|
self.turn = 0
|
||||||
self.over = false
|
self.over = false
|
||||||
@@ -635,6 +645,7 @@ function Battle:smartAiState()
|
|||||||
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
|
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
|
||||||
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
|
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
|
||||||
playerLockOn = playerState.lockOn or nil,
|
playerLockOn = playerState.lockOn or nil,
|
||||||
|
playerIdentified = playerState.identified or nil,
|
||||||
playerPhysicalMoves = physical,
|
playerPhysicalMoves = physical,
|
||||||
enemyRage = enemyState.rage,
|
enemyRage = enemyState.rage,
|
||||||
enemyRageCount = enemyState.rageCount,
|
enemyRageCount = enemyState.rageCount,
|
||||||
@@ -765,6 +776,9 @@ end
|
|||||||
function Battle:battleStat(mon, key)
|
function Battle:battleStat(mon, key)
|
||||||
local value = (mon.stats or {})[key] or 1
|
local value = (mon.stats or {})[key] or 1
|
||||||
if mon ~= self.player then return value end
|
if mon ~= self.player then return value end
|
||||||
|
-- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788):
|
||||||
|
-- adventure badges do not follow the player into the standardised Tower.
|
||||||
|
if self.inBattleTowerBattle then return value end
|
||||||
local badge = Battle.BADGE_STAT_BOOSTS[key]
|
local badge = Battle.BADGE_STAT_BOOSTS[key]
|
||||||
if badge and self:hasBadge("badges", badge) then
|
if badge and self:hasBadge("badges", badge) then
|
||||||
return Battle.boostStat(value)
|
return Battle.boostStat(value)
|
||||||
@@ -783,6 +797,8 @@ end
|
|||||||
-- boosts the damage. Each type appears once, so this is a plain scan.
|
-- boosts the damage. Each type appears once, so this is a plain scan.
|
||||||
function Battle:badgeTypeBoost(attacker, moveType)
|
function Battle:badgeTypeBoost(attacker, moveType)
|
||||||
if attacker ~= self.player or not moveType then return false end
|
if attacker ~= self.player or not moveType then return false end
|
||||||
|
-- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154).
|
||||||
|
if self.inBattleTowerBattle then return false end
|
||||||
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
|
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
|
||||||
if row.type == moveType then
|
if row.type == moveType then
|
||||||
return self:hasBadge(row.store, row.badge)
|
return self:hasBadge(row.store, row.badge)
|
||||||
@@ -903,8 +919,16 @@ function Battle:movePriority(moveId)
|
|||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
||||||
|
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
||||||
|
|
||||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||||
function Battle:canAct(mon)
|
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
||||||
|
local function clearBide(state)
|
||||||
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function checkTurn(self, mon, moveId)
|
||||||
local name = self:monName(mon)
|
local name = self:monName(mon)
|
||||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||||
@@ -925,7 +949,11 @@ function Battle:canAct(mon)
|
|||||||
local beforeMove = record and record.beforeMove
|
local beforeMove = record and record.beforeMove
|
||||||
if beforeMove
|
if beforeMove
|
||||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||||
return beforeMove(self, mon, name) and true or false
|
-- engine/battle/effect_commands.asm:188-200
|
||||||
|
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
||||||
|
local acted = beforeMove(self, mon, name) and true or false
|
||||||
|
if acted or not bypass then return acted end
|
||||||
|
beforeMove = nil
|
||||||
end
|
end
|
||||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||||
@@ -959,6 +987,14 @@ function Battle:canAct(mon)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
|
||||||
|
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
|
||||||
|
function Battle:canAct(mon, moveId)
|
||||||
|
local acted = checkTurn(self, mon, moveId)
|
||||||
|
if not acted then clearBide(self:volatile(mon)) end
|
||||||
|
return acted
|
||||||
|
end
|
||||||
|
|
||||||
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
||||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||||
@@ -1047,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
|
|||||||
local attackerStages = self.stages[self:sideOf(attacker)]
|
local attackerStages = self.stages[self:sideOf(attacker)]
|
||||||
local defenderStages = self.stages[self:sideOf(defender)]
|
local defenderStages = self.stages[self:sideOf(defender)]
|
||||||
local types = self.data.type_chart and self.data.type_chart.types
|
local types = self.data.type_chart and self.data.type_chart.types
|
||||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
local matchups = self:matchupsAgainst(defender)
|
||||||
|
|
||||||
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
|
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
|
||||||
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
|
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
|
||||||
@@ -1167,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts)
|
|||||||
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
|
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
|
||||||
damage = math.max(0, (defender.hp or 0) - 1)
|
damage = math.max(0, (defender.hp or 0) - 1)
|
||||||
end
|
end
|
||||||
|
-- engine/battle_anims/anim_commands.asm:1200
|
||||||
|
if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end
|
||||||
return self:dealDamage(attacker, defender, damage, {
|
return self:dealDamage(attacker, defender, damage, {
|
||||||
critical = critical, effectiveness = info.effectiveness,
|
critical = critical, effectiveness = info.effectiveness,
|
||||||
-- Counter answers physical damage and Mirror Coat special, so what kind
|
-- Counter answers physical damage and Mirror Coat special, so what kind
|
||||||
@@ -1234,10 +1272,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
|||||||
if opts.critical then
|
if opts.critical then
|
||||||
self:emit({ kind = "message", text = "A critical hit!" })
|
self:emit({ kind = "message", text = "A critical hit!" })
|
||||||
end
|
end
|
||||||
|
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
|
||||||
|
-- The cart breaks both across the box's two lines and hyphenates "super-"
|
||||||
|
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
|
||||||
|
-- charmap carries at $75, not three periods.
|
||||||
if opts.effectiveness and opts.effectiveness > 10 then
|
if opts.effectiveness and opts.effectiveness > 10 then
|
||||||
self:emit({ kind = "message", text = "It's super effective!" })
|
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
|
||||||
elseif opts.effectiveness and opts.effectiveness < 10 then
|
elseif opts.effectiveness and opts.effectiveness < 10 then
|
||||||
self:emit({ kind = "message", text = "It's not very effective..." })
|
self:emit({ kind = "message",
|
||||||
|
text = Strings("It's not very\neffective…") })
|
||||||
end
|
end
|
||||||
if endured then
|
if endured then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -1389,9 +1432,18 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- free of PP and obedience in exactly the same way.
|
-- free of PP and obedience in exactly the same way.
|
||||||
local rolling = state.rolloutLock == moveId
|
local rolling = state.rolloutLock == moveId
|
||||||
|
|
||||||
if not (charging or rampaging or rolling) then
|
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
||||||
|
-- engine/battle/move_effects/bide.asm:62-68
|
||||||
|
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
||||||
|
local called = (self.copyDepth or 0) > 0
|
||||||
|
|
||||||
|
if not (charging or rampaging or rolling or biding or called) then
|
||||||
if move and (move.pp or 0) <= 0 then
|
if move and (move.pp or 0) <= 0 then
|
||||||
self:emit({ kind = "message", text = "No PP left for this move!" })
|
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = Strings("There's no PP left\nfor this move!") })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if move then move.pp = (move.pp or 1) - 1 end
|
if move then move.pp = (move.pp or 1) - 1 end
|
||||||
@@ -1471,9 +1523,38 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
||||||
|
if def.effect == "EFFECT_SLEEP_TALK" then
|
||||||
|
local picked
|
||||||
|
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
||||||
|
local pool = {}
|
||||||
|
for _, own in ipairs(attacker.moves or {}) do
|
||||||
|
local ownDef = self:moveDef(own.id)
|
||||||
|
local effect = ownDef and ownDef.effect
|
||||||
|
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
||||||
|
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
||||||
|
pool[#pool + 1] = own.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
||||||
|
end
|
||||||
|
if not picked then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.lastMove = nil
|
||||||
|
self.copyDepth = (self.copyDepth or 0) + 1
|
||||||
|
self:useMove(attacker, defender, picked)
|
||||||
|
self.copyDepth = self.copyDepth - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||||
-- Encore and Disable.
|
-- Encore and Disable. A called move skips the write
|
||||||
state.lastMove = moveId
|
-- (engine/battle/used_move_text.asm:30-36).
|
||||||
|
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
||||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||||
state.usedMoves = state.usedMoves or {}
|
state.usedMoves = state.usedMoves or {}
|
||||||
local seen = false
|
local seen = false
|
||||||
@@ -1516,6 +1597,13 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
||||||
|
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||||
-- and fail outright when nothing of the right kind landed.
|
-- and fail outright when nothing of the right kind landed.
|
||||||
local counterKind = Effects.COUNTER[def.effect]
|
local counterKind = Effects.COUNTER[def.effect]
|
||||||
@@ -1605,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
text = ("Magnitude %d!"):format(number) })
|
text = ("Magnitude %d!"):format(number) })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- data/moves/effects.asm:1607, :1649
|
||||||
|
if def.effect == "EFFECT_RETURN" then
|
||||||
|
powerOverride = Effects.happinessPower(attacker.happiness)
|
||||||
|
elseif def.effect == "EFFECT_FRUSTRATION" then
|
||||||
|
powerOverride = Effects.happinessPower(attacker.happiness, true)
|
||||||
|
end
|
||||||
|
|
||||||
if not sureHit
|
if not sureHit
|
||||||
and not self:accuracyRoll(def, attacker, defender) then
|
and not self:accuracyRoll(def, attacker, defender) then
|
||||||
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
|
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
|
||||||
@@ -1659,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
||||||
local defenderTypes = (self:speciesDef(defender) or {}).types
|
local defenderTypes = (self:speciesDef(defender) or {}).types
|
||||||
or defender.types
|
or defender.types
|
||||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
local matchups = self:matchupsAgainst(defender)
|
||||||
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
||||||
self:markMissed()
|
self:markMissed()
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -1747,8 +1842,12 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
if landed > 1 then
|
if landed > 1 then
|
||||||
self:emit({ kind = "message",
|
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
||||||
text = ("Hit %d time(s)!"):format(landed) })
|
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
|
||||||
|
-- plural stands even at one hit rather than the "(s)" this printed.
|
||||||
|
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
|
||||||
|
-- _HitXTimesText).
|
||||||
|
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- move_effects/pay_day.asm:13
|
-- move_effects/pay_day.asm:13
|
||||||
@@ -1968,8 +2067,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
|||||||
if mine.perish and theirs.perish then return fail(self) end
|
if mine.perish and theirs.perish then return fail(self) end
|
||||||
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
||||||
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
||||||
|
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
|
||||||
|
-- sentence no cart prints; the Gen 2 line names both sides and counts in
|
||||||
|
-- digits.
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = "All POKéMON hearing the song will faint in three turns!" })
|
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||||
@@ -2027,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
|
|||||||
text = self:monName(attacker) .. " took aim!" })
|
text = self:monName(attacker) .. " took aim!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/foresight.asm
|
||||||
|
Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender,
|
||||||
|
def, _, sureHit)
|
||||||
|
if not sureHit
|
||||||
|
and not self:accuracyRoll(def, attacker, defender) then
|
||||||
|
return fail(self)
|
||||||
|
end
|
||||||
|
local target = self:volatile(defender)
|
||||||
|
if target.vanished or target.identified then return fail(self) end
|
||||||
|
target.identified = true
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = self:monName(attacker) .. " identified "
|
||||||
|
.. self:monName(defender) .. "!" })
|
||||||
|
end
|
||||||
|
|
||||||
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
|
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
|
||||||
-- move aimed at the mon carrying it, whether or not that move was the one the
|
-- move aimed at the mon carrying it, whether or not that move was the one the
|
||||||
-- lock-on was meant for, and whether or not the exception at :1683-1688 then
|
-- lock-on was meant for, and whether or not the exception at :1683-1688 then
|
||||||
@@ -2074,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
|
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
|
||||||
return Damage.rollHit(self:moveAccuracy(accuracy, defender),
|
local acc = self.stages[self:sideOf(attacker)].accuracy
|
||||||
self.stages[self:sideOf(attacker)].accuracy,
|
local eva = self.stages[self:sideOf(defender)].evasion
|
||||||
self.stages[self:sideOf(defender)].evasion, self.random)
|
-- engine/battle/effect_commands.asm:1786
|
||||||
|
if defender and self:volatile(defender).identified
|
||||||
|
and (eva or 0) >= (acc or 0) then
|
||||||
|
acc, eva = 0, 0
|
||||||
|
end
|
||||||
|
return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva,
|
||||||
|
self.random)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
|
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
|
||||||
@@ -2099,6 +2222,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
|||||||
local side = self:sideOf(defender)
|
local side = self:sideOf(defender)
|
||||||
if self.spikes[side] then return fail(self) end
|
if self.spikes[side] then return fail(self) end
|
||||||
self.spikes[side] = true
|
self.spikes[side] = true
|
||||||
|
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
|
||||||
|
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
|
||||||
|
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
|
||||||
|
-- printMessage cuts past two rows, so the cart's line cannot be told here
|
||||||
|
-- yet without the name being dropped on screen. Left as it stands.
|
||||||
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2133,6 +2261,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
if not state.bideTurns then
|
if not state.bideTurns then
|
||||||
state.bideTurns = Effects.bideTurns(self.random)
|
state.bideTurns = Effects.bideTurns(self.random)
|
||||||
state.bideStored = 0
|
state.bideStored = 0
|
||||||
|
-- engine/battle/core.asm:574-576
|
||||||
|
state.bideMove = moveId
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " is storing energy!" })
|
text = self:monName(attacker) .. " is storing energy!" })
|
||||||
return
|
return
|
||||||
@@ -2144,7 +2274,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local damage = Effects.bideDamage(state.bideStored)
|
local damage = Effects.bideDamage(state.bideStored)
|
||||||
state.bideTurns, state.bideStored = nil, nil
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " unleashed energy!" })
|
text = self:monName(attacker) .. " unleashed energy!" })
|
||||||
if damage <= 0 then return fail(self) end
|
if damage <= 0 then return fail(self) end
|
||||||
@@ -2255,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def
|
|||||||
stages = self.stages[self:sideOf(defender)],
|
stages = self.stages[self:sideOf(defender)],
|
||||||
},
|
},
|
||||||
types = self.data.type_chart and self.data.type_chart.types,
|
types = self.data.type_chart and self.data.type_chart.types,
|
||||||
matchups = self.data.type_chart and self.data.type_chart.matchups,
|
matchups = self:matchupsAgainst(defender),
|
||||||
random = self.random,
|
random = self.random,
|
||||||
})
|
})
|
||||||
state.futureSight = Effects.FUTURE_SIGHT_TURNS
|
state.futureSight = Effects.FUTURE_SIGHT_TURNS
|
||||||
@@ -2334,7 +2464,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
|||||||
{ move = def, moveId = def and def.id })
|
{ move = def, moveId = def and def.id })
|
||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
|
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
|
||||||
|
-- land exactly once, which is the case the cart still prints as "times".
|
||||||
|
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||||
@@ -2371,7 +2503,7 @@ end
|
|||||||
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
|
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
|
||||||
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
|
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
|
||||||
-- RegainedHealthText (:6440).
|
-- RegainedHealthText (:6440).
|
||||||
for effect in pairs(Effects.SUN_HEAL) do
|
for effect, wants in pairs(Effects.SUN_HEAL) do
|
||||||
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
|
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
|
||||||
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
|
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
|
||||||
if (attacker.hp or 0) >= maxHp then
|
if (attacker.hp or 0) >= maxHp then
|
||||||
@@ -2380,7 +2512,9 @@ for effect in pairs(Effects.SUN_HEAL) do
|
|||||||
text = self:monName(attacker) .. "'s HP is full!" })
|
text = self:monName(attacker) .. "'s HP is full!" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local fraction = Effects.weatherHealFraction(self.weather)
|
-- effect_commands.asm:6396-6417, the time of day and the weather (#1751).
|
||||||
|
local fraction = Effects.timeBasedHealFraction(self.weather, wants,
|
||||||
|
self.timeOfDay)
|
||||||
self:heal(attacker, math.max(1, math.floor(maxHp * fraction)))
|
self:heal(attacker, math.max(1, math.floor(maxHp * fraction)))
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " regained health!" })
|
text = self:monName(attacker) .. " regained health!" })
|
||||||
@@ -2417,6 +2551,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
|||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = party[target]
|
self.enemy = party[target]
|
||||||
self.enemy.volatile = carried
|
self.enemy.volatile = carried
|
||||||
|
-- engine/battle/move_effects/baton_pass.asm:59
|
||||||
|
self:resetParticipants()
|
||||||
end
|
end
|
||||||
local sent = side == "player" and self.player or self.enemy
|
local sent = side == "player" and self.player or self.enemy
|
||||||
self:emit({ kind = "send", side = side, mon = sent,
|
self:emit({ kind = "send", side = side, mon = sent,
|
||||||
@@ -2679,6 +2815,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.enemyIndex = pick
|
self.enemyIndex = pick
|
||||||
self.enemy = incoming
|
self.enemy = incoming
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
|
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
||||||
|
self:resetParticipants()
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||||
hp = incoming.hp or 0, status = incoming.status or false,
|
hp = incoming.hp or 0, status = incoming.status or false,
|
||||||
@@ -2823,9 +2961,12 @@ Battle.STATUSES = {
|
|||||||
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
|
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
|
||||||
inflictText = " fell asleep!",
|
inflictText = " fell asleep!",
|
||||||
catchBonus = 10, catchBonusIntended = 10,
|
catchBonus = 10, catchBonusIntended = 10,
|
||||||
-- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero.
|
-- BattleCommand_SleepTarget's .random_loop rerolls 0 and SLP_MASK before
|
||||||
|
-- `inc a`, so sleep opens at 2 (effect_commands.asm:3591-3598, #1707).
|
||||||
|
-- Crystal masks the roll to %011 in the Battle Tower, capping it at 4
|
||||||
|
-- (../pokecrystal/engine/battle/effect_commands.asm:3609-3613).
|
||||||
onInflict = function(battle, mon)
|
onInflict = function(battle, mon)
|
||||||
mon.statusTurns = rand(battle.random, 7) + 1
|
mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2
|
||||||
end,
|
end,
|
||||||
beforeMovePriority = 40,
|
beforeMovePriority = 40,
|
||||||
beforeMove = function(battle, mon, name)
|
beforeMove = function(battle, mon, name)
|
||||||
@@ -2962,6 +3103,30 @@ function Battle:safeguarded(mon)
|
|||||||
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:1305
|
||||||
|
function Battle:matchupsAgainst(defender)
|
||||||
|
local chart = self.data.type_chart
|
||||||
|
local rows = chart and chart.matchups
|
||||||
|
if not rows or not defender then return rows end
|
||||||
|
if not self:volatile(defender).identified then return rows end
|
||||||
|
local skipped = chart.foresightMatchups
|
||||||
|
if not skipped or #skipped == 0 then return rows end
|
||||||
|
if not self.identifiedMatchups then
|
||||||
|
local drop = {}
|
||||||
|
for _, row in ipairs(skipped) do
|
||||||
|
drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then
|
||||||
|
out[#out + 1] = row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.identifiedMatchups = out
|
||||||
|
end
|
||||||
|
return self.identifiedMatchups
|
||||||
|
end
|
||||||
|
|
||||||
-- `source` is the battler that inflicted it, carried only so
|
-- `source` is the battler that inflicted it, carried only so
|
||||||
-- battle.status_inflicted can name it the way Gen 1's does.
|
-- battle.status_inflicted can name it the way Gen 1's does.
|
||||||
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
|
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
|
||||||
@@ -2974,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status)
|
|||||||
end
|
end
|
||||||
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
|
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
|
||||||
if moveType then
|
if moveType then
|
||||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
local matchups = self:matchupsAgainst(defender)
|
||||||
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
|
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
|
||||||
end
|
end
|
||||||
if status == "poison" or status == "toxic" then
|
if status == "poison" or status == "toxic" then
|
||||||
@@ -3088,6 +3253,12 @@ end
|
|||||||
|
|
||||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||||
function Battle:resolveFaints()
|
function Battle:resolveFaints()
|
||||||
|
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
||||||
|
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
||||||
|
self.participantsCleared = self.player
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
if (self.enemy.hp or 0) <= 0 then
|
if (self.enemy.hp or 0) <= 0 then
|
||||||
self:emit({ kind = "faint", side = "enemy",
|
self:emit({ kind = "faint", side = "enemy",
|
||||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||||
@@ -3272,7 +3443,16 @@ end
|
|||||||
-- `count` is the pass's own divisor -- the participant count for the first
|
-- `count` is the pass's own divisor -- the participant count for the first
|
||||||
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
||||||
-- any Share holder taxed the whole pool.
|
-- any Share holder taxed the whole pool.
|
||||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
--
|
||||||
|
-- `silent` suppresses only the GainedText line. It exists for the
|
||||||
|
-- battle.exp_award seam below, where a mod paying the bench wants one summary
|
||||||
|
-- line rather than a box per mon; the cart's own two passes never pass it, so
|
||||||
|
-- vanilla prints exactly what it always did. Everything else about the pass
|
||||||
|
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
|
||||||
|
-- and the forget prompt -- is unaffected, because a silent award is still an
|
||||||
|
-- award.
|
||||||
|
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
||||||
|
silent)
|
||||||
for _, index in ipairs(recipients) do
|
for _, index in ipairs(recipients) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||||
@@ -3323,10 +3503,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
|||||||
index = index,
|
index = index,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
if not silent then
|
||||||
self:emit({ kind = "experience", index = index, amount = amount,
|
self:emit({ kind = "experience", index = index, amount = amount,
|
||||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||||
text = self:monName(mon) .. " gained "
|
text = self:monName(mon) .. " gained "
|
||||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||||
|
end
|
||||||
if result.levels > 0 then
|
if result.levels > 0 then
|
||||||
-- "level up happiness mod", the cart's own comment, sitting right
|
-- "level up happiness mod", the cart's own comment, sitting right
|
||||||
-- after the stat recalc and before the "grew to level" text. It fires
|
-- after the stat recalc and before the "grew to level" text. It fires
|
||||||
@@ -3341,7 +3523,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
|||||||
local moveDef = self:moveDef(moveId)
|
local moveDef = self:moveDef(moveId)
|
||||||
local moveName = (moveDef and moveDef.name) or moveId
|
local moveName = (moveDef and moveDef.name) or moveId
|
||||||
if ok then
|
if ok then
|
||||||
|
-- data/text/common_3.asm:119
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
|
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||||
elseif reason == "full" then
|
elseif reason == "full" then
|
||||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||||
@@ -3420,22 +3604,38 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
||||||
-- with the same ctx: the participant COUNT, the live participants, and an
|
-- with the same ctx: the participant COUNT, the live participants, and an
|
||||||
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
||||||
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
||||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
--
|
||||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
||||||
-- `holders` and `halved` are the Gen 2 additions.
|
-- applyShare) and means the same thing here: truthy prints the mon's
|
||||||
|
-- GainedText, falsy pays it silently. That is what lets one mod source
|
||||||
|
-- print ONE summary line for a party-wide award on both generations instead
|
||||||
|
-- of a box per mon -- which is what the Exp Share mod documents and could
|
||||||
|
-- not do on Gold, because this argument used to be accepted and ignored.
|
||||||
|
--
|
||||||
|
-- It is honoured only when it is actually PASSED, by argument count rather
|
||||||
|
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
|
||||||
|
-- against a seam that always announced and keeps announcing; a caller that
|
||||||
|
-- passes the argument -- including an explicit nil, which is what a "pay
|
||||||
|
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
|
||||||
|
-- mod changes behaviour, and a mod that opts in gets parity.
|
||||||
if Runtime.wantsHook("battle.exp_award") then
|
if Runtime.wantsHook("battle.exp_award") then
|
||||||
local alive = {}
|
local alive = {}
|
||||||
for _, index in ipairs(participants) do
|
for _, index in ipairs(participants) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split)
|
local function applyShare(mon, split, ...)
|
||||||
|
local announce = ...
|
||||||
|
-- select("#") counts an explicit nil; `announce == nil` alone could not
|
||||||
|
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
|
||||||
|
-- those two have to mean different things here.
|
||||||
|
local silent = select("#", ...) > 0 and not announce
|
||||||
for index, candidate in ipairs(self.party) do
|
for index, candidate in ipairs(self.party) do
|
||||||
if candidate == mon then
|
if candidate == mon then
|
||||||
return self:giveExperiencePass(loser, def, { index },
|
return self:giveExperiencePass(loser, def, { index },
|
||||||
math.max(1, split or 1), halved)
|
math.max(1, split or 1), halved, silent)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3450,8 +3650,7 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||||
self.participants = {}
|
self:resetParticipants()
|
||||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||||
@@ -3472,7 +3671,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
|||||||
end
|
end
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||||
|
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
|
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||||
text = self:monName(mon) .. " learned "
|
text = self:monName(mon) .. " learned "
|
||||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||||
-- The forget path writes the slot itself rather than going through
|
-- The forget path writes the slot itself rather than going through
|
||||||
@@ -3511,6 +3712,13 @@ function Battle:switchLocked()
|
|||||||
return self:volatile(self.enemy).trapsTarget == true
|
return self:volatile(self.enemy).trapsTarget == true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||||
|
-- (engine/battle/core.asm:3033 and :3037).
|
||||||
|
function Battle:resetParticipants()
|
||||||
|
self.participants = {}
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||||
|
end
|
||||||
|
|
||||||
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
||||||
-- (engine/battle/core.asm:2959-2961).
|
-- (engine/battle/core.asm:2959-2961).
|
||||||
function Battle:shiftSwitch(index)
|
function Battle:shiftSwitch(index)
|
||||||
@@ -3533,6 +3741,7 @@ function Battle:switch(index)
|
|||||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||||
-- announce its own faint again; see resolveFaints.
|
-- announce its own faint again; see resolveFaints.
|
||||||
self.faintAnnounced = nil
|
self.faintAnnounced = nil
|
||||||
|
self.participantsCleared = nil
|
||||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||||
self.pendingSwitch = nil
|
self.pendingSwitch = nil
|
||||||
self.player = mon
|
self.player = mon
|
||||||
@@ -3763,6 +3972,8 @@ end
|
|||||||
-- check. Split out because playerAttack needs the same answer.
|
-- check. Split out because playerAttack needs the same answer.
|
||||||
function Battle:lockedInMove(mon)
|
function Battle:lockedInMove(mon)
|
||||||
local state = self:volatile(mon)
|
local state = self:volatile(mon)
|
||||||
|
-- engine/battle/core.asm:543
|
||||||
|
if state.chargeMove then return state.chargeMove end
|
||||||
if state.rolloutLock then return state.rolloutLock end
|
if state.rolloutLock then return state.rolloutLock end
|
||||||
if state.rampageMove and (state.rampageTurns or 0) > 0 then
|
if state.rampageMove and (state.rampageTurns or 0) > 0 then
|
||||||
return state.rampageMove
|
return state.rampageMove
|
||||||
@@ -3770,19 +3981,41 @@ function Battle:lockedInMove(mon)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
||||||
|
-- at :5650
|
||||||
|
function Battle:fightLockedMove(mon)
|
||||||
|
local state = self:volatile(mon)
|
||||||
|
if state.bideTurns then return state.bideMove end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:627-629
|
||||||
|
function Battle:cancelBide(mon)
|
||||||
|
clearBide(self:volatile(mon))
|
||||||
|
end
|
||||||
|
|
||||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||||
-- (to grey out the move list) and by the enemy's own choice below.
|
-- (to grey out the move list) and by the enemy's own choice below.
|
||||||
|
-- engine/battle/core.asm:561-566
|
||||||
|
local function encoredMove(state, mon)
|
||||||
|
if not state.encore then return nil end
|
||||||
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
|
if move.id == state.encore and (move.pp or 0) > 0 then
|
||||||
|
return state.encore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
state.encore, state.encoreTurns = nil, nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
function Battle:forcedMove(mon)
|
function Battle:forcedMove(mon)
|
||||||
local locked = self:lockedInMove(mon)
|
local locked = self:lockedInMove(mon)
|
||||||
if locked then return locked end
|
if locked then return locked end
|
||||||
local state = self:volatile(mon)
|
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||||
if not state.encore then return nil end
|
-- (engine/battle/core.asm:561-566).
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
local encored = encoredMove(self:volatile(mon), mon)
|
||||||
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
if encored then return encored end
|
||||||
end
|
return self:fightLockedMove(mon)
|
||||||
-- Encore ends early when the move runs out of PP.
|
|
||||||
state.encore, state.encoreTurns = nil, nil
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Battle:moveDisabled(mon, moveId)
|
function Battle:moveDisabled(mon, moveId)
|
||||||
@@ -3796,8 +4029,9 @@ function Battle:usableMoves(mon)
|
|||||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||||
-- ends it the moment the encored move runs dry.
|
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||||
local locked = self:lockedInMove(mon)
|
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||||
|
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||||
@@ -3808,6 +4042,16 @@ function Battle:usableMoves(mon)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
|
||||||
|
-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third.
|
||||||
|
function Battle:noEscapeBattleType()
|
||||||
|
local t = self.battleType
|
||||||
|
return t == Battle.BATTLETYPE_FORCESHINY
|
||||||
|
or t == Battle.BATTLETYPE_TRAP
|
||||||
|
or t == Battle.BATTLETYPE_CELEBI
|
||||||
|
or t == Battle.BATTLETYPE_SUICUNE
|
||||||
|
end
|
||||||
|
|
||||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||||
-- based on the speed ratio and how many times you have tried this battle.
|
-- based on the speed ratio and how many times you have tried this battle.
|
||||||
-- Trainers never let you run.
|
-- Trainers never let you run.
|
||||||
@@ -3821,8 +4065,7 @@ function Battle:tryRun(pSpd)
|
|||||||
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
|
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
|
||||||
-- trainer check and any speed math. Without this, running from the Red
|
-- trainer check and any speed math. Without this, running from the Red
|
||||||
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
|
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
|
||||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
if self:noEscapeBattleType() then
|
||||||
or self.battleType == Battle.BATTLETYPE_TRAP then
|
|
||||||
self:emit({ kind = "message", text = "Can't escape!" })
|
self:emit({ kind = "message", text = "Can't escape!" })
|
||||||
self.runRefused = true
|
self.runRefused = true
|
||||||
return false
|
return false
|
||||||
@@ -3999,6 +4242,8 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
.. self:monName(outgoing) .. "!" })
|
.. self:monName(outgoing) .. "!" })
|
||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = self.enemyParty[target]
|
self.enemy = self.enemyParty[target]
|
||||||
|
-- AI_Switch (engine/battle/ai/items.asm:697)
|
||||||
|
self:resetParticipants()
|
||||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||||
@@ -4089,9 +4334,16 @@ function Battle:vanillaEnemyMove()
|
|||||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||||
-- died there, and no amount of grinding could ever have got past it.
|
-- died there, and no amount of grinding could ever have got past it.
|
||||||
local charged = self:volatile(self.enemy).chargeMove
|
local enemyState = self:volatile(self.enemy)
|
||||||
|
local charged = enemyState.chargeMove
|
||||||
if charged then return charged end
|
if charged then return charged end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
|
||||||
|
-- CheckEnemyLockedIn (:5650).
|
||||||
|
local encored = encoredMove(enemyState, self.enemy)
|
||||||
|
if encored then return encored end
|
||||||
|
if enemyState.bideTurns then return enemyState.bideMove end
|
||||||
|
|
||||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||||
local moves = self:usableMoves(self.enemy)
|
local moves = self:usableMoves(self.enemy)
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -4185,6 +4437,7 @@ local function runTurn(self, action)
|
|||||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||||
if self.runRefused then return self:takeEvents() end
|
if self.runRefused then return self:takeEvents() end
|
||||||
|
self:cancelBide(self.player)
|
||||||
action = { kind = "skip" }
|
action = { kind = "skip" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4200,6 +4453,9 @@ local function runTurn(self, action)
|
|||||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||||
Happiness.change(self.player, "USEDXITEM")
|
Happiness.change(self.player, "USEDXITEM")
|
||||||
end
|
end
|
||||||
|
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
||||||
|
-- instead and keeps the store.
|
||||||
|
if action.kind == "item" then self:cancelBide(self.player) end
|
||||||
|
|
||||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||||
-- to rotate or drink a potion spends its whole turn on it.
|
-- to rotate or drink a potion spends its whole turn on it.
|
||||||
@@ -4248,7 +4504,6 @@ local function runTurn(self, action)
|
|||||||
|
|
||||||
local function playerAttack()
|
local function playerAttack()
|
||||||
if action.kind ~= "move" then return end
|
if action.kind ~= "move" then return end
|
||||||
if not self:canAct(self.player) then return end
|
|
||||||
local move = action.move
|
local move = action.move
|
||||||
-- An encored mon has no choice, whatever the menu said.
|
-- An encored mon has no choice, whatever the menu said.
|
||||||
local forced = self:forcedMove(self.player)
|
local forced = self:forcedMove(self.player)
|
||||||
@@ -4268,13 +4523,20 @@ local function runTurn(self, action)
|
|||||||
-- player's own mon spends the rest of the battle underground.
|
-- player's own mon spends the rest of the battle underground.
|
||||||
local stored = self:volatile(self.player).chargeMove
|
local stored = self:volatile(self.player).chargeMove
|
||||||
if stored then move = stored end
|
if stored then move = stored end
|
||||||
|
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
||||||
|
-- engine/battle/effect_commands.asm:193 reads it.
|
||||||
|
if not self:canAct(self.player, move) then return end
|
||||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||||
-- Struggle substitution and the obedience roll the same way the second
|
-- Struggle substitution and the obedience roll the same way the second
|
||||||
-- half of a charge move is.
|
-- half of a charge move is.
|
||||||
local charging = self:volatile(self.player).chargeMove == move
|
local charging = self:volatile(self.player).chargeMove == move
|
||||||
or self:lockedInMove(self.player) == move
|
or self:lockedInMove(self.player) == move
|
||||||
if not charging and not self:hasUsableMoves(self.player) then
|
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
||||||
|
-- `checkobedience`.
|
||||||
|
local bideLocked = self:fightLockedMove(self.player) == move
|
||||||
|
if not charging and not bideLocked
|
||||||
|
and not self:hasUsableMoves(self.player) then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(self.player) .. " has no moves left!" })
|
text = self:monName(self.player) .. " has no moves left!" })
|
||||||
move = Battle.STRUGGLE
|
move = Battle.STRUGGLE
|
||||||
@@ -4312,7 +4574,7 @@ local function runTurn(self, action)
|
|||||||
-- against a trainer, could not be escaped either.
|
-- against a trainer, could not be escaped either.
|
||||||
enemyMoveId = Battle.STRUGGLE
|
enemyMoveId = Battle.STRUGGLE
|
||||||
end
|
end
|
||||||
if not self:canAct(self.enemy) then return end
|
if not self:canAct(self.enemy, enemyMoveId) then return end
|
||||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
|
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
|
||||||
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
|
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
|
|
||||||
local Catching = {}
|
local Catching = {}
|
||||||
|
|
||||||
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
|
|||||||
return bonuses[status] or 0
|
return bonuses[status] or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- constants/landmark_constants.asm:24, the fallback when no cache is passed.
|
||||||
|
Catching.LANDMARK_NATIONAL_PARK = 19
|
||||||
|
|
||||||
|
local function landmarkIndex(opts, id, fallback)
|
||||||
|
local data = opts and (opts.data or (opts.battle and opts.battle.data))
|
||||||
|
local rows = (opts and opts.landmarks)
|
||||||
|
or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks)
|
||||||
|
local row = rows and rows[id]
|
||||||
|
return (row and row.index) or fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
-- GetWorldMapLocation with the POKECENTER_2F backup-map swap
|
||||||
|
-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81).
|
||||||
|
function Catching.caughtLandmark(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if opts.bugContest then
|
||||||
|
return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK",
|
||||||
|
Catching.LANDMARK_NATIONAL_PARK)
|
||||||
|
end
|
||||||
|
if opts.landmark then return opts.landmark end
|
||||||
|
local map = opts.map
|
||||||
|
if map and map.id == "POKECENTER_2F" and opts.backupMap then
|
||||||
|
map = opts.backupMap
|
||||||
|
end
|
||||||
|
return (map and map.landmark) or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal.
|
||||||
|
function Catching.stampCaughtData(mon, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if not Mon.hasCaughtData(opts.version) then return mon end
|
||||||
|
local save = opts.save or (opts.battle and opts.battle.save)
|
||||||
|
return Mon.setCaughtData(mon, {
|
||||||
|
level = opts.level or (type(mon) == "table" and mon.level) or 0,
|
||||||
|
timeOfDay = opts.timeOfDay,
|
||||||
|
landmark = Catching.caughtLandmark(opts),
|
||||||
|
playerGender = opts.playerGender
|
||||||
|
or (save and save.player and save.player.gender),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
|
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
|
||||||
-- A rate of 255 or a Master Ball is certain.
|
-- A rate of 255 or a Master Ball is certain.
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -21,8 +21,13 @@
|
|||||||
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
|
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
|
||||||
-- records carry that as `category`.
|
-- records carry that as `category`.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local Damage = {}
|
local Damage = {}
|
||||||
|
|
||||||
|
-- ../pokecrystal/constants/battle_constants.asm:78
|
||||||
|
Damage.MAX_STAT_VALUE = 999
|
||||||
|
|
||||||
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
||||||
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
||||||
|
|
||||||
@@ -60,7 +65,23 @@ end
|
|||||||
function Damage.applyStage(value, stage)
|
function Damage.applyStage(value, stage)
|
||||||
local numerator, denominator = Damage.stageMultiplier(stage)
|
local numerator, denominator = Damage.stageMultiplier(stage)
|
||||||
local out = math.floor(value * numerator / denominator)
|
local out = math.floor(value * numerator / denominator)
|
||||||
return math.max(1, out)
|
-- ../pokecrystal/engine/battle/core.asm:6739
|
||||||
|
return math.max(1, math.min(Damage.MAX_STAT_VALUE, out))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one
|
||||||
|
-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it.
|
||||||
|
function Damage.truncateStats(attack, defense, fixed)
|
||||||
|
local a = math.max(0, math.floor(attack or 0))
|
||||||
|
local d = math.max(0, math.floor(defense or 0))
|
||||||
|
while a > 255 or d > 255 do
|
||||||
|
d = math.floor(d / 4)
|
||||||
|
if d == 0 then d = 1 end
|
||||||
|
a = math.floor(a / 4)
|
||||||
|
if a == 0 then a = 1 end
|
||||||
|
if not fixed then break end
|
||||||
|
end
|
||||||
|
return a % 256, d % 256
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Is this move physical? `types` is type_chart.lua's `types` table.
|
-- Is this move physical? `types` is type_chart.lua's `types` table.
|
||||||
@@ -169,6 +190,8 @@ end
|
|||||||
-- random(n) -- 0..n-1, for the variation roll
|
-- random(n) -- 0..n-1, for the variation roll
|
||||||
-- screen -- Reflect/Light Screen active on the defender
|
-- screen -- Reflect/Light Screen active on the defender
|
||||||
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
|
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
|
||||||
|
-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC
|
||||||
|
-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644
|
||||||
--
|
--
|
||||||
-- Returns damage, info where info carries the pieces a battle message needs:
|
-- Returns damage, info where info carries the pieces a battle message needs:
|
||||||
-- effectiveness (x10), critical, physical, variation.
|
-- effectiveness (x10), critical, physical, variation.
|
||||||
@@ -204,6 +227,12 @@ function Damage.calc(opts)
|
|||||||
defense = defense * 2
|
defense = defense * 2
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PlayerAttackDamage hands DamageCalc one-byte stats
|
||||||
|
-- (../pokecrystal/engine/battle/effect_commands.asm:2604).
|
||||||
|
local fixed = opts.reflectOverflowFixed
|
||||||
|
if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end
|
||||||
|
attack, defense = Damage.truncateStats(attack, defense, fixed)
|
||||||
|
|
||||||
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
|
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
|
||||||
-- Explosion halve the defence, never below 1.
|
-- Explosion halve the defence, never below 1.
|
||||||
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
||||||
|
|||||||
+38
-11
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
|
|||||||
-- power and the magnitude number the text prints.
|
-- power and the magnitude number the text prints.
|
||||||
--
|
--
|
||||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
-- / math.random -- never hard-code 0 (that always yields Magnitude 4).
|
||||||
function Effects.magnitudePower(random)
|
function Effects.magnitudePower(random)
|
||||||
local roll
|
local roll
|
||||||
if type(random) == "function" then
|
if type(random) == "function" then
|
||||||
@@ -284,6 +284,14 @@ function Effects.magnitudePower(random)
|
|||||||
return last[2], last[3]
|
return last[2], last[3]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25
|
||||||
|
function Effects.happinessPower(happiness, frustration)
|
||||||
|
local h = happiness or 0
|
||||||
|
if h < 0 then h = 0 elseif h > 255 then h = 255 end
|
||||||
|
if frustration then h = 255 - h end
|
||||||
|
return math.floor(h * 10 / 25)
|
||||||
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------- weather
|
-- ------------------------------------------------------------------- weather
|
||||||
--
|
--
|
||||||
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
|
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
|
||||||
@@ -356,19 +364,38 @@ function Effects.sandstormHits(types)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
|
-- BattleCommand_TimeBasedHealContinue's .Multipliers
|
||||||
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
|
-- (engine/battle/effect_commands.asm:6450-6454).
|
||||||
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
|
Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 }
|
||||||
|
|
||||||
|
-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1,
|
||||||
|
-- NITE_F 2, DARKNESS_F 3.
|
||||||
|
Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
||||||
|
|
||||||
|
-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants
|
||||||
|
-- (engine/battle/effect_commands.asm:6362-6371).
|
||||||
Effects.SUN_HEAL = {
|
Effects.SUN_HEAL = {
|
||||||
EFFECT_MORNING_SUN = true,
|
EFFECT_MORNING_SUN = 0,
|
||||||
EFFECT_SYNTHESIS = true,
|
EFFECT_SYNTHESIS = 1,
|
||||||
EFFECT_MOONLIGHT = true,
|
EFFECT_MOONLIGHT = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
function Effects.weatherHealFraction(weather)
|
function Effects.timeOfDayIndex(timeOfDay)
|
||||||
if weather == "sun" then return 2 / 3 end
|
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
|
||||||
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
|
return Effects.TIME_OF_DAY_ID[timeOfDay]
|
||||||
return 1 / 2
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the
|
||||||
|
-- wrong time of day steps it down, sun steps it up, rain and sandstorm down.
|
||||||
|
function Effects.timeBasedHealFraction(weather, wants, timeOfDay)
|
||||||
|
local index = 3
|
||||||
|
local now = Effects.timeOfDayIndex(timeOfDay)
|
||||||
|
if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end
|
||||||
|
if weather then
|
||||||
|
index = index + 1
|
||||||
|
if weather ~= "sun" then index = index - 2 end
|
||||||
|
end
|
||||||
|
return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- Perish Song
|
-- ---------------------------------------------------------------- Perish Song
|
||||||
|
|||||||
+102
-1
@@ -17,6 +17,7 @@
|
|||||||
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
||||||
-- GROWTH_* so this needs no hardcoded table.
|
-- GROWTH_* so this needs no hardcoded table.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Unown = require("src.core.gen2.Unown")
|
local Unown = require("src.core.gen2.Unown")
|
||||||
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
|
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
|
||||||
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
|
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
|
||||||
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
|
|||||||
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
||||||
Mon.MAX_DV = 15
|
Mon.MAX_DV = 15
|
||||||
|
|
||||||
|
-- MON_CAUGHTDATA's two packed bytes and their masks --
|
||||||
|
-- constants/pokemon_data_constants.asm:93-99, :120-130.
|
||||||
|
Mon.CAUGHT_TIME_MASK = 0xc0
|
||||||
|
Mon.CAUGHT_LEVEL_MASK = 0x3f
|
||||||
|
Mon.CAUGHT_GENDER_MASK = 0x80
|
||||||
|
Mon.CAUGHT_LOCATION_MASK = 0x7f
|
||||||
|
Mon.CAUGHT_EGG_LEVEL = 1
|
||||||
|
-- constants/landmark_constants.asm:111-113
|
||||||
|
Mon.LANDMARK_EVENT = 0x7f
|
||||||
|
Mon.LANDMARK_GIFT = 0x7e
|
||||||
|
|
||||||
|
-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData --
|
||||||
|
-- pokegold constants/pokemon_data_constants.asm:93.
|
||||||
|
function Mon.hasCaughtData(version)
|
||||||
|
return GameVersion.engine(version) == "crystal"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored
|
||||||
|
-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172.
|
||||||
|
local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 }
|
||||||
|
|
||||||
|
function Mon.caughtTimeOf(timeOfDay)
|
||||||
|
if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end
|
||||||
|
if type(timeOfDay) ~= "number" then return 0 end
|
||||||
|
local id = math.floor(timeOfDay)
|
||||||
|
if id < 0 or id > 3 then return 0 end
|
||||||
|
if id > 2 then id = 2 end
|
||||||
|
return id + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177).
|
||||||
|
local CAUGHT_GENDER = {
|
||||||
|
girl = "girl", female = "girl", boy = "boy", male = "boy",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mon.caughtGenderOf(gender)
|
||||||
|
if gender == true then return "girl" end
|
||||||
|
if gender == false then return "boy" end
|
||||||
|
if type(gender) ~= "string" then return nil end
|
||||||
|
return CAUGHT_GENDER[gender:lower()]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function landmarkByte(landmark)
|
||||||
|
if type(landmark) ~= "number" then return 0 end
|
||||||
|
return math.floor(landmark) % 0x80
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the
|
||||||
|
-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242).
|
||||||
|
function Mon.setCaughtData(mon, opts)
|
||||||
|
if type(mon) ~= "table" then return mon end
|
||||||
|
opts = opts or {}
|
||||||
|
mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay)
|
||||||
|
mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0))
|
||||||
|
mon.caughtLocation = landmarkByte(opts.landmark)
|
||||||
|
mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy"
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b`
|
||||||
|
-- (constants/pokemon_data_constants.asm:126-128).
|
||||||
|
Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 }
|
||||||
|
|
||||||
|
-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or
|
||||||
|
-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit.
|
||||||
|
function Mon.setGiftCaughtData(mon, caughtBy)
|
||||||
|
if type(mon) ~= "table" then return mon end
|
||||||
|
local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0
|
||||||
|
local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK
|
||||||
|
local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated)
|
||||||
|
mon.caughtTime, mon.caughtLevel = 0, 0
|
||||||
|
mon.caughtLocation = unpacked.caughtLocation
|
||||||
|
mon.caughtByGender = unpacked.caughtByGender
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it.
|
||||||
|
function Mon.packCaughtData(mon)
|
||||||
|
mon = type(mon) == "table" and mon or {}
|
||||||
|
local time = math.floor(tonumber(mon.caughtTime) or 0) % 4
|
||||||
|
local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40
|
||||||
|
local location = landmarkByte(tonumber(mon.caughtLocation) or 0)
|
||||||
|
local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl"
|
||||||
|
return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location
|
||||||
|
end
|
||||||
|
|
||||||
|
function Mon.unpackCaughtData(byte0, byte1)
|
||||||
|
byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256
|
||||||
|
return {
|
||||||
|
caughtTime = math.floor(byte0 / 0x40),
|
||||||
|
caughtLevel = byte0 % 0x40,
|
||||||
|
caughtLocation = byte1 % 0x80,
|
||||||
|
caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
local function rand(a, b)
|
local function rand(a, b)
|
||||||
if love and love.math and love.math.random then
|
if love and love.math and love.math.random then
|
||||||
return love.math.random(a, b)
|
return love.math.random(a, b)
|
||||||
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
|
|||||||
status = nil,
|
status = nil,
|
||||||
-- 70 for a caught mon, 120 for a gift/hatched one.
|
-- 70 for a caught mon, 120 for a gift/hatched one.
|
||||||
happiness = opts.happiness or 70,
|
happiness = opts.happiness or 70,
|
||||||
caughtLevel = level,
|
caughtLevel = opts.caughtLevel or level,
|
||||||
|
-- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99.
|
||||||
|
caughtTime = opts.caughtTime,
|
||||||
|
caughtLocation = opts.caughtLocation,
|
||||||
|
caughtByGender = opts.caughtByGender,
|
||||||
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
|
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
|
||||||
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
||||||
-- overriding the roll rather than a roll to be hooked.
|
-- overriding the roll rather than a roll to be hooked.
|
||||||
|
|||||||
@@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN"
|
|||||||
-- data/text/battle.asm. Declared here and formatted at the call site so
|
-- data/text/battle.asm. Declared here and formatted at the call site so
|
||||||
-- Strings.source is what registers them, the same way Decorations declares
|
-- Strings.source is what registers them, the same way Decorations declares
|
||||||
-- its own five. No line markers: every battle message in this port is one
|
-- its own five. No line markers: every battle message in this port is one
|
||||||
-- flowing string that Chrome.wrap breaks to the box.
|
-- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText,
|
||||||
|
-- which keeps the cart's own `line`/`cont` breaks because it does not fit two
|
||||||
|
-- rows.
|
||||||
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
|
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
|
||||||
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
|
-- data/text/battle.asm:179-185
|
||||||
|
local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!")
|
||||||
-- The half and all texts really are this short on the cart: they replace the
|
-- The half and all texts really are this short on the cart: they replace the
|
||||||
-- money line rather than following it, which is a quirk no Gold player can
|
-- money line rather than following it, which is a quirk no Gold player can
|
||||||
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
|
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ return {
|
|||||||
-- (core.asm:426-464): poison/burn/leech seed tick before the slower
|
-- (core.asm:426-464): poison/burn/leech seed tick before the slower
|
||||||
-- mon acts, not in an end-of-round sweep like Gen 3+.
|
-- mon acts, not in an end-of-round sweep like Gen 3+.
|
||||||
residualAfterMove = true,
|
residualAfterMove = true,
|
||||||
|
-- engine/battle/effects.asm:498,689
|
||||||
|
badgeBoostReapplyBug = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
local Base64 = require("src.core.Base64")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local SafePath = require("src.mods.SafePath")
|
||||||
|
local SaveSerializer = require("src.core.SaveSerializer")
|
||||||
|
local Semver = require("src.mods.Semver")
|
||||||
|
local StreamMD5 = require("src.mods.StreamMD5")
|
||||||
|
|
||||||
|
local CartManifest = {}
|
||||||
|
|
||||||
|
CartManifest.SCHEMA = 1
|
||||||
|
CartManifest.EXT = ".g1rcart"
|
||||||
|
CartManifest.FORMAT = "g1rcart"
|
||||||
|
CartManifest.DIR = "carts"
|
||||||
|
-- sealed: the pinned set runs exactly as pinned. sealed+: the same fixed set,
|
||||||
|
-- but the player may switch any pinned mod on or off. open: additive.
|
||||||
|
CartManifest.SEALS = { sealed = true, ["sealed+"] = true, open = true }
|
||||||
|
-- Shell and label finishes the launcher cartridge can render.
|
||||||
|
CartManifest.FINISHES = {
|
||||||
|
sparkle = true, holo = true, ["sparkle+holo"] = true,
|
||||||
|
}
|
||||||
|
CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true }
|
||||||
|
CartManifest.ART_ENCODINGS = { base64 = true }
|
||||||
|
CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10"
|
||||||
|
|
||||||
|
CartManifest.MAX_ID = 64
|
||||||
|
CartManifest.MAX_TITLE = 48
|
||||||
|
CartManifest.MAX_AUTHOR = 64
|
||||||
|
CartManifest.MAX_SUMMARY = 120
|
||||||
|
CartManifest.MAX_LABEL = 128
|
||||||
|
CartManifest.MAX_LABEL_ART = 1024 * 1024
|
||||||
|
CartManifest.MAX_MODS = 64
|
||||||
|
CartManifest.MAX_OPTIONS = 64
|
||||||
|
CartManifest.MAX_OPTION_KEY = 64
|
||||||
|
CartManifest.MAX_OPTION_TEXT = 256
|
||||||
|
|
||||||
|
local function trim(text)
|
||||||
|
return text:match("^%s*(.-)%s*$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isId(value)
|
||||||
|
return type(value) == "string" and value ~= "" and #value <= CartManifest.MAX_ID
|
||||||
|
and value:match("^[%w_%-]+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isRepo(value)
|
||||||
|
if type(value) ~= "string" then return false end
|
||||||
|
local owner, name = value:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||||
|
return owner ~= nil and name ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isHex(value, width)
|
||||||
|
return type(value) == "string" and #value == width
|
||||||
|
and value:match("^[0-9a-f]+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isCount(value)
|
||||||
|
return type(value) == "number" and value > 0 and value % 1 == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isSemver(value)
|
||||||
|
return type(value) == "string" and Semver.parse(value) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseOptions(raw, label)
|
||||||
|
if raw == nil then return nil end
|
||||||
|
if type(raw) ~= "table" then
|
||||||
|
return nil, label .. " options must be a table"
|
||||||
|
end
|
||||||
|
local keys = {}
|
||||||
|
for key in pairs(raw) do
|
||||||
|
if type(key) ~= "string" then
|
||||||
|
return nil, label .. " option keys must be strings"
|
||||||
|
end
|
||||||
|
if key == "" or #key > CartManifest.MAX_OPTION_KEY then
|
||||||
|
return nil, ("%s option keys must be 1 to %d characters")
|
||||||
|
:format(label, CartManifest.MAX_OPTION_KEY)
|
||||||
|
end
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
end
|
||||||
|
if #keys > CartManifest.MAX_OPTIONS then
|
||||||
|
return nil, ("%s carries more than %d options")
|
||||||
|
:format(label, CartManifest.MAX_OPTIONS)
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
local value = raw[key]
|
||||||
|
local kind = type(value)
|
||||||
|
if kind == "string" then
|
||||||
|
if #value > CartManifest.MAX_OPTION_TEXT then
|
||||||
|
return nil, ("%s option %q must be %d characters or fewer")
|
||||||
|
:format(label, key, CartManifest.MAX_OPTION_TEXT)
|
||||||
|
end
|
||||||
|
elseif kind ~= "number" and kind ~= "boolean" then
|
||||||
|
return nil, ("%s option %q must be a string, number or boolean")
|
||||||
|
:format(label, key)
|
||||||
|
end
|
||||||
|
out[key] = value
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseMod(raw, index, seen)
|
||||||
|
local label = ("cart mod #%d"):format(index)
|
||||||
|
if type(raw) ~= "table" then return nil, label .. " must be a table" end
|
||||||
|
if not isId(raw.id) then
|
||||||
|
return nil, ("%s id must be 1 to %d characters of letters, numbers, _ or -")
|
||||||
|
:format(label, CartManifest.MAX_ID)
|
||||||
|
end
|
||||||
|
label = ("cart mod %q"):format(raw.id)
|
||||||
|
if seen[raw.id] then return nil, label .. " is pinned twice" end
|
||||||
|
seen[raw.id] = true
|
||||||
|
|
||||||
|
local source = raw.source
|
||||||
|
if type(source) ~= "string" or not CartManifest.SOURCES[source] then
|
||||||
|
return nil, label .. " source must be github, gamebanana or local"
|
||||||
|
end
|
||||||
|
|
||||||
|
local entry = { id = raw.id, source = source }
|
||||||
|
if source == "github" then
|
||||||
|
if not isRepo(raw.repo) then
|
||||||
|
return nil, label .. " repo must be owner/name"
|
||||||
|
end
|
||||||
|
if not isSemver(raw.version) then
|
||||||
|
return nil, label .. " version must be a semantic version"
|
||||||
|
end
|
||||||
|
if not isHex(raw.sha256, 64) then
|
||||||
|
return nil, label .. " sha256 must be 64 lowercase hex characters"
|
||||||
|
end
|
||||||
|
entry.repo = raw.repo
|
||||||
|
entry.version = trim(raw.version)
|
||||||
|
entry.sha256 = raw.sha256
|
||||||
|
elseif source == "local" then
|
||||||
|
if not isSemver(raw.version) then
|
||||||
|
return nil, label .. " version must be a semantic version"
|
||||||
|
end
|
||||||
|
entry.version = trim(raw.version)
|
||||||
|
else
|
||||||
|
if not isCount(raw.mod) then
|
||||||
|
return nil, label .. " mod must be a positive integer"
|
||||||
|
end
|
||||||
|
if not isCount(raw.file) then
|
||||||
|
return nil, label .. " file must be a positive integer"
|
||||||
|
end
|
||||||
|
if not isHex(raw.md5, 32) then
|
||||||
|
return nil, label .. " md5 must be 32 lowercase hex characters"
|
||||||
|
end
|
||||||
|
entry.mod = raw.mod
|
||||||
|
entry.file = raw.file
|
||||||
|
entry.md5 = raw.md5
|
||||||
|
end
|
||||||
|
|
||||||
|
if raw.enabled ~= nil and type(raw.enabled) ~= "boolean" then
|
||||||
|
return nil, label .. " enabled must be true or false"
|
||||||
|
end
|
||||||
|
-- Only a declared OFF is carried: an absent or explicit true is the default,
|
||||||
|
-- so a cart that says nothing serializes exactly as it did before this field.
|
||||||
|
if raw.enabled == false then entry.enabled = false end
|
||||||
|
|
||||||
|
local options, err = parseOptions(raw.options, label)
|
||||||
|
if err then return nil, err end
|
||||||
|
entry.options = options
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A pin the cart ships switched off is installed and configured but not run.
|
||||||
|
function CartManifest.modEnabled(entry)
|
||||||
|
return type(entry) == "table" and entry.enabled ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseOrder(raw, mods)
|
||||||
|
local out = {}
|
||||||
|
if raw == nil then
|
||||||
|
for i, entry in ipairs(mods) do out[i] = entry.id end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
if type(raw) ~= "table" then return nil, "cart load_order must be an array" end
|
||||||
|
if #raw ~= #mods then
|
||||||
|
return nil, "cart load_order must list every pinned mod exactly once"
|
||||||
|
end
|
||||||
|
local pinned, seen = {}, {}
|
||||||
|
for _, entry in ipairs(mods) do pinned[entry.id] = true end
|
||||||
|
for i = 1, #raw do
|
||||||
|
local id = raw[i]
|
||||||
|
if type(id) ~= "string" or not pinned[id] then
|
||||||
|
return nil, ("cart load_order names %s, which the cart does not pin")
|
||||||
|
:format(tostring(id))
|
||||||
|
end
|
||||||
|
if seen[id] then
|
||||||
|
return nil, ("cart load_order names %s twice"):format(id)
|
||||||
|
end
|
||||||
|
seen[id] = true
|
||||||
|
out[i] = id
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.parse(tbl)
|
||||||
|
if type(tbl) ~= "table" then return nil, "cart must be a table" end
|
||||||
|
|
||||||
|
if not isId(tbl.id) then
|
||||||
|
return nil, ("cart id must be 1 to %d characters of letters, numbers, _ or -")
|
||||||
|
:format(CartManifest.MAX_ID)
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(tbl.title) ~= "string" then return nil, "cart title is required" end
|
||||||
|
local title = trim(tbl.title)
|
||||||
|
if title == "" or #title > CartManifest.MAX_TITLE then
|
||||||
|
return nil, ("cart title must be 1 to %d characters"):format(CartManifest.MAX_TITLE)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not isSemver(tbl.version) then
|
||||||
|
return nil, "cart version must be a semantic version"
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(tbl.author) ~= "string" then return nil, "cart author is required" end
|
||||||
|
local author = trim(tbl.author)
|
||||||
|
if author == "" or #author > CartManifest.MAX_AUTHOR then
|
||||||
|
return nil, ("cart author must be 1 to %d characters"):format(CartManifest.MAX_AUTHOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
local repo = nil
|
||||||
|
if tbl.repo ~= nil then
|
||||||
|
if not isRepo(tbl.repo) then return nil, "cart repo must be owner/name" end
|
||||||
|
repo = tbl.repo
|
||||||
|
end
|
||||||
|
|
||||||
|
local summary = nil
|
||||||
|
if tbl.summary ~= nil then
|
||||||
|
if type(tbl.summary) ~= "string" then
|
||||||
|
return nil, "cart summary must be a string"
|
||||||
|
end
|
||||||
|
summary = trim(tbl.summary)
|
||||||
|
if #summary > CartManifest.MAX_SUMMARY then
|
||||||
|
return nil, ("cart summary must be %d characters or fewer")
|
||||||
|
:format(CartManifest.MAX_SUMMARY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local shell = type(tbl.shell) == "string" and tbl.shell:match("^#(%x%x%x%x%x%x)$")
|
||||||
|
if not shell then return nil, "cart shell must be a #RRGGBB colour" end
|
||||||
|
shell = "#" .. shell:lower()
|
||||||
|
|
||||||
|
local label = nil
|
||||||
|
if tbl.label ~= nil then
|
||||||
|
if type(tbl.label) ~= "string" or #tbl.label > CartManifest.MAX_LABEL then
|
||||||
|
return nil, ("cart label must be a path of %d characters or fewer")
|
||||||
|
:format(CartManifest.MAX_LABEL)
|
||||||
|
end
|
||||||
|
label = SafePath.safe(tbl.label)
|
||||||
|
if not label then return nil, "cart label must stay inside the cart" end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(tbl.base) ~= "string" or not GameVersion.VERSIONS[tbl.base] then
|
||||||
|
return nil, "cart base must name a game this engine knows"
|
||||||
|
end
|
||||||
|
|
||||||
|
local engine = nil
|
||||||
|
if tbl.engine ~= nil then
|
||||||
|
if type(tbl.engine) ~= "string" or trim(tbl.engine) == "" then
|
||||||
|
return nil, "cart engine must be a non-empty version range"
|
||||||
|
end
|
||||||
|
engine = trim(tbl.engine)
|
||||||
|
end
|
||||||
|
|
||||||
|
local speeds
|
||||||
|
if tbl.speeds ~= nil then
|
||||||
|
if type(tbl.speeds) ~= "table" or #tbl.speeds == 0 then
|
||||||
|
return nil, "cart speeds must be a non-empty array of multipliers"
|
||||||
|
end
|
||||||
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
local valid, seen = {}, {}
|
||||||
|
for _, want in ipairs(GameSpeed.LEVELS) do
|
||||||
|
for _, have in ipairs(tbl.speeds) do
|
||||||
|
if have == want and not seen[want] then
|
||||||
|
seen[want] = true
|
||||||
|
valid[#valid + 1] = want
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #valid ~= #tbl.speeds then
|
||||||
|
return nil, "cart speeds must all be GameSpeed levels"
|
||||||
|
end
|
||||||
|
speeds = valid
|
||||||
|
end
|
||||||
|
|
||||||
|
local finish = tbl.finish
|
||||||
|
if finish ~= nil then
|
||||||
|
if type(finish) ~= "string" or not CartManifest.FINISHES[finish] then
|
||||||
|
return nil, "cart finish must be sparkle, holo or sparkle+holo"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local seal = tbl.seal
|
||||||
|
if seal == nil then seal = "sealed" end
|
||||||
|
if type(seal) ~= "string" or not CartManifest.SEALS[seal] then
|
||||||
|
return nil, "cart seal must be sealed, sealed+ or open"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The author's preferred game settings, seeded once per cart
|
||||||
|
-- (SaveData.CART_OPTION_KEYS); unknown keys are kept but ignored.
|
||||||
|
local cartOptions, cartOptErr = parseOptions(tbl.options, "cart")
|
||||||
|
if cartOptErr then return nil, cartOptErr end
|
||||||
|
|
||||||
|
if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end
|
||||||
|
local count = #tbl.mods
|
||||||
|
if count < 1 or count > CartManifest.MAX_MODS then
|
||||||
|
return nil, ("cart must pin 1 to %d mods"):format(CartManifest.MAX_MODS)
|
||||||
|
end
|
||||||
|
local mods, seen = {}, {}
|
||||||
|
for i = 1, count do
|
||||||
|
local entry, err = parseMod(tbl.mods[i], i, seen)
|
||||||
|
if not entry then return nil, err end
|
||||||
|
mods[i] = entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local order, orderErr = parseOrder(tbl.load_order, mods)
|
||||||
|
if not order then return nil, orderErr end
|
||||||
|
|
||||||
|
return {
|
||||||
|
id = tbl.id,
|
||||||
|
title = title,
|
||||||
|
version = trim(tbl.version),
|
||||||
|
author = author,
|
||||||
|
repo = repo,
|
||||||
|
summary = summary,
|
||||||
|
shell = shell,
|
||||||
|
finish = finish,
|
||||||
|
speeds = speeds,
|
||||||
|
label = label,
|
||||||
|
base = tbl.base,
|
||||||
|
engine = engine,
|
||||||
|
seal = seal,
|
||||||
|
options = cartOptions,
|
||||||
|
mods = mods,
|
||||||
|
load_order = order,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.parseLabelArt(raw)
|
||||||
|
if raw == nil then return nil, "cart carries no label art" end
|
||||||
|
if type(raw) ~= "table" then return nil, "cart label art must be a table" end
|
||||||
|
|
||||||
|
if type(raw.encoding) ~= "string" or not CartManifest.ART_ENCODINGS[raw.encoding] then
|
||||||
|
return nil, "cart label art encoding must be base64"
|
||||||
|
end
|
||||||
|
if type(raw.data) ~= "string" or raw.data == "" then
|
||||||
|
return nil, "cart label art data must be a base64 string"
|
||||||
|
end
|
||||||
|
|
||||||
|
local tooBig = ("cart label art must be %d bytes or fewer")
|
||||||
|
:format(CartManifest.MAX_LABEL_ART)
|
||||||
|
if #raw.data > math.ceil(CartManifest.MAX_LABEL_ART / 3) * 4 then
|
||||||
|
return nil, tooBig
|
||||||
|
end
|
||||||
|
|
||||||
|
local bytes, err = Base64.decode(raw.data)
|
||||||
|
if not bytes then return nil, "cart label art " .. err end
|
||||||
|
if #bytes > CartManifest.MAX_LABEL_ART then return nil, tooBig end
|
||||||
|
if not isCount(raw.bytes) or raw.bytes ~= #bytes then
|
||||||
|
return nil, ("cart label art declares %s bytes but decodes to %d")
|
||||||
|
:format(tostring(raw.bytes), #bytes)
|
||||||
|
end
|
||||||
|
if bytes:sub(1, #CartManifest.PNG_SIGNATURE) ~= CartManifest.PNG_SIGNATURE then
|
||||||
|
return nil, "cart label art must be a PNG"
|
||||||
|
end
|
||||||
|
|
||||||
|
local name = nil
|
||||||
|
if raw.name ~= nil then
|
||||||
|
if type(raw.name) ~= "string" or #raw.name > CartManifest.MAX_LABEL then
|
||||||
|
return nil, ("cart label art name must be a path of %d characters or fewer")
|
||||||
|
:format(CartManifest.MAX_LABEL)
|
||||||
|
end
|
||||||
|
name = SafePath.safe(raw.name)
|
||||||
|
if not name then return nil, "cart label art name must stay inside the cart" end
|
||||||
|
end
|
||||||
|
|
||||||
|
return { name = name, encoding = raw.encoding, bytes = raw.bytes, data = raw.data },
|
||||||
|
nil, bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.labelArtBytes(cart)
|
||||||
|
if type(cart) ~= "table" then return nil, "cart must be a table" end
|
||||||
|
local art, err, bytes = CartManifest.parseLabelArt(cart.labelArt)
|
||||||
|
if not art then return nil, err end
|
||||||
|
return bytes, art.name
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.publishable(cart)
|
||||||
|
if type(cart) ~= "table" or type(cart.mods) ~= "table" then
|
||||||
|
return false, "a cart must be parsed before it can be published"
|
||||||
|
end
|
||||||
|
local unpinned = {}
|
||||||
|
for _, entry in ipairs(cart.mods) do
|
||||||
|
if type(entry) == "table" and entry.source == "local" then
|
||||||
|
unpinned[#unpinned + 1] = tostring(entry.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #unpinned == 0 then return true end
|
||||||
|
table.sort(unpinned)
|
||||||
|
return false, ("%s %s pinned to this install only, so nobody else can fetch %s: publish needs a repo and an archive hash for %s")
|
||||||
|
:format(table.concat(unpinned, ", "),
|
||||||
|
#unpinned == 1 and "is" or "are",
|
||||||
|
#unpinned == 1 and "it" or "them",
|
||||||
|
#unpinned == 1 and "it" or "each")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function number(value)
|
||||||
|
return ("%.17g"):format(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeText(out, prefix, text)
|
||||||
|
out[#out + 1] = ("%s%d:%s"):format(prefix, #text, text)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeValue(out, value)
|
||||||
|
local kind = type(value)
|
||||||
|
if kind == "number" then
|
||||||
|
out[#out + 1] = "#" .. number(value)
|
||||||
|
elseif kind == "boolean" then
|
||||||
|
out[#out + 1] = value and "T" or "F"
|
||||||
|
elseif kind == "table" then
|
||||||
|
-- Length-prefixed so a list can never collide with another field's text.
|
||||||
|
out[#out + 1] = "*" .. tostring(#value)
|
||||||
|
for _, item in ipairs(value) do writeValue(out, item) end
|
||||||
|
else
|
||||||
|
writeText(out, "$", tostring(value))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeField(out, name, value)
|
||||||
|
if value == nil then return end
|
||||||
|
writeText(out, ".", name)
|
||||||
|
writeValue(out, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local CART_FIELDS = { "author", "base", "engine", "finish", "id", "label",
|
||||||
|
"repo", "seal", "shell", "speeds", "summary", "title",
|
||||||
|
"version" }
|
||||||
|
local MOD_FIELDS = { "enabled", "file", "id", "md5", "mod", "repo", "sha256",
|
||||||
|
"source", "version" }
|
||||||
|
|
||||||
|
local function writeOptions(out, marker, options)
|
||||||
|
out[#out + 1] = marker
|
||||||
|
local keys = {}
|
||||||
|
for key in pairs(options or {}) do keys[#keys + 1] = key end
|
||||||
|
table.sort(keys)
|
||||||
|
for _, key in ipairs(keys) do writeField(out, key, options[key]) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.canonical(cart)
|
||||||
|
local out = { "[cart]" }
|
||||||
|
for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end
|
||||||
|
-- Absent when the cart ships no settings, so a cart written before this
|
||||||
|
-- field hashes byte for byte as it did.
|
||||||
|
if cart.options ~= nil then writeOptions(out, "[cart_options]", cart.options) end
|
||||||
|
out[#out + 1] = "[mods]"
|
||||||
|
for _, entry in ipairs(cart.mods or {}) do
|
||||||
|
writeText(out, "@", tostring(entry.id))
|
||||||
|
for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end
|
||||||
|
writeOptions(out, "[options]", entry.options)
|
||||||
|
end
|
||||||
|
out[#out + 1] = "[order]"
|
||||||
|
for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.hash(cart)
|
||||||
|
return StreamMD5.new():update(CartManifest.canonical(cart)):final()
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.encode(cart)
|
||||||
|
return SaveSerializer.encode({
|
||||||
|
format = CartManifest.FORMAT,
|
||||||
|
formatVersion = CartManifest.SCHEMA,
|
||||||
|
labelArt = CartManifest.parseLabelArt(cart.labelArt),
|
||||||
|
-- Every field parse() keeps: a name missing here is a field the file
|
||||||
|
-- round trip silently drops, as finish and speeds were.
|
||||||
|
cart = { id = cart.id, title = cart.title, version = cart.version,
|
||||||
|
author = cart.author, repo = cart.repo, summary = cart.summary,
|
||||||
|
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||||
|
label = cart.label, base = cart.base,
|
||||||
|
engine = cart.engine, seal = cart.seal, options = cart.options,
|
||||||
|
mods = cart.mods, load_order = cart.load_order },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartManifest.decode(str)
|
||||||
|
if type(str) ~= "string" or str == "" then return nil, "EMPTY FILE" end
|
||||||
|
local data = SaveSerializer.decode(str)
|
||||||
|
if type(data) ~= "table" then return nil, "BAD FILE" end
|
||||||
|
if data.format ~= CartManifest.FORMAT then return nil, "NOT A CART" end
|
||||||
|
if data.formatVersion ~= CartManifest.SCHEMA then
|
||||||
|
return nil, ("unknown cart schema %s"):format(tostring(data.formatVersion))
|
||||||
|
end
|
||||||
|
local cart, err = CartManifest.parse(data.cart)
|
||||||
|
if not cart then return nil, err end
|
||||||
|
cart.labelArt = CartManifest.parseLabelArt(data.labelArt)
|
||||||
|
return cart
|
||||||
|
end
|
||||||
|
|
||||||
|
return CartManifest
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
local CartManifest = require("src.carts.CartManifest")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Semver = require("src.mods.Semver")
|
||||||
|
|
||||||
|
local CartStore = {}
|
||||||
|
|
||||||
|
CartStore.DIR = CartManifest.DIR
|
||||||
|
CartStore.EXT = CartManifest.EXT
|
||||||
|
CartStore.OPTIONS_KEY = "carts"
|
||||||
|
CartStore.UNPINNED_VERSION = "0.0.0"
|
||||||
|
|
||||||
|
local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal",
|
||||||
|
"shell", "finish", "speeds", "summary", "hash", "file" }
|
||||||
|
|
||||||
|
local function fsOr(fs)
|
||||||
|
return fs or (love and love.filesystem) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function safeId(id)
|
||||||
|
return type(id) == "string" and id ~= "" and #id <= CartManifest.MAX_ID
|
||||||
|
and id:match("^[%w_%-]+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fileFor(id)
|
||||||
|
return CartStore.DIR .. "/" .. id .. CartStore.EXT
|
||||||
|
end
|
||||||
|
CartStore.fileFor = fileFor
|
||||||
|
|
||||||
|
local function readOptions(fs)
|
||||||
|
local ok, opts = pcall(SaveData.loadOptions, fs)
|
||||||
|
if not ok or type(opts) ~= "table" then return {} end
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeOptions(opts, fs)
|
||||||
|
local ok = pcall(SaveData.saveOptions, opts, fs)
|
||||||
|
return ok and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function registry(opts)
|
||||||
|
local reg = opts[CartStore.OPTIONS_KEY]
|
||||||
|
return type(reg) == "table" and reg or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureRegistry(opts)
|
||||||
|
if type(opts[CartStore.OPTIONS_KEY]) ~= "table" then
|
||||||
|
opts[CartStore.OPTIONS_KEY] = {}
|
||||||
|
end
|
||||||
|
return opts[CartStore.OPTIONS_KEY]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function recordFor(cart, hash)
|
||||||
|
return { id = cart.id, title = cart.title, version = cart.version,
|
||||||
|
author = cart.author, base = cart.base, seal = cart.seal,
|
||||||
|
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||||
|
summary = cart.summary,
|
||||||
|
hash = hash, file = fileFor(cart.id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sameValue(a, b)
|
||||||
|
if type(a) == "table" and type(b) == "table" then
|
||||||
|
if #a ~= #b then return false end
|
||||||
|
for i = 1, #a do
|
||||||
|
if a[i] ~= b[i] then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return a == b
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sameRecord(a, b)
|
||||||
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||||
|
for _, field in ipairs(RECORD_FIELDS) do
|
||||||
|
if not sameValue(a[field], b[field]) then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function entryFor(cart, hash)
|
||||||
|
return { id = cart.id, title = cart.title, version = cart.version,
|
||||||
|
author = cart.author, base = cart.base, seal = cart.seal,
|
||||||
|
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||||
|
summary = cart.summary, label = cart.label,
|
||||||
|
cart = cart, cartHash = hash, file = fileFor(cart.id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readCart(fs, id)
|
||||||
|
if not safeId(id) then return nil, "unknown cart id" end
|
||||||
|
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
|
||||||
|
local path = fileFor(id)
|
||||||
|
if fs.getInfo and not fs.getInfo(path) then
|
||||||
|
return nil, ("cart %q is not installed"):format(id)
|
||||||
|
end
|
||||||
|
local body = fs.read(path)
|
||||||
|
if type(body) ~= "string" or body == "" then
|
||||||
|
return nil, ("cart %q is not installed"):format(id)
|
||||||
|
end
|
||||||
|
local ok, cart, err = pcall(CartManifest.decode, body)
|
||||||
|
if not ok then return nil, "BAD CART" end
|
||||||
|
if not cart then return nil, err or "BAD CART" end
|
||||||
|
if cart.id ~= id then
|
||||||
|
return nil, ("cart file %s names %q"):format(path, tostring(cart.id))
|
||||||
|
end
|
||||||
|
local hashed, hash = pcall(CartManifest.hash, cart)
|
||||||
|
if not hashed then return nil, "BAD CART" end
|
||||||
|
return cart, hash
|
||||||
|
end
|
||||||
|
|
||||||
|
local function strayIds(fs, seen)
|
||||||
|
local out = {}
|
||||||
|
if not (fs and fs.getDirectoryItems) then return out end
|
||||||
|
if fs.getInfo and not fs.getInfo(CartStore.DIR) then return out end
|
||||||
|
local ok, items = pcall(fs.getDirectoryItems, CartStore.DIR)
|
||||||
|
if not ok then return out end
|
||||||
|
for _, name in ipairs(items or {}) do
|
||||||
|
if type(name) == "string" and name:sub(-#CartStore.EXT) == CartStore.EXT then
|
||||||
|
local id = name:sub(1, #name - #CartStore.EXT)
|
||||||
|
if safeId(id) and not seen[id] then
|
||||||
|
seen[id] = true
|
||||||
|
out[#out + 1] = id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.index(fs)
|
||||||
|
local opts = readOptions(fsOr(fs))
|
||||||
|
local reg = registry(opts) or {}
|
||||||
|
local out = {}
|
||||||
|
for id, record in pairs(reg) do
|
||||||
|
if safeId(id) and type(record) == "table" then
|
||||||
|
local row = { id = id, file = fileFor(id) }
|
||||||
|
for _, field in ipairs(RECORD_FIELDS) do
|
||||||
|
if record[field] ~= nil then row[field] = record[field] end
|
||||||
|
end
|
||||||
|
row.id, row.title = id, record.title or id
|
||||||
|
out[#out + 1] = row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b)
|
||||||
|
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
|
||||||
|
if at ~= bt then return at < bt end
|
||||||
|
return a.id < b.id
|
||||||
|
end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.list(fs)
|
||||||
|
fs = fsOr(fs)
|
||||||
|
local rows = {}
|
||||||
|
if not fs then return rows end
|
||||||
|
local opts = readOptions(fs)
|
||||||
|
local reg = registry(opts) or {}
|
||||||
|
local ids, seen = {}, {}
|
||||||
|
for id in pairs(reg) do
|
||||||
|
if safeId(id) and not seen[id] then
|
||||||
|
seen[id] = true
|
||||||
|
ids[#ids + 1] = id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(ids)
|
||||||
|
for _, id in ipairs(strayIds(fs, seen)) do ids[#ids + 1] = id end
|
||||||
|
for _, id in ipairs(ids) do
|
||||||
|
local cart, hash = readCart(fs, id)
|
||||||
|
if cart then rows[#rows + 1] = entryFor(cart, hash) end
|
||||||
|
end
|
||||||
|
table.sort(rows, function(a, b)
|
||||||
|
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
|
||||||
|
if at ~= bt then return at < bt end
|
||||||
|
return a.id < b.id
|
||||||
|
end)
|
||||||
|
local healed, changed = {}, false
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
healed[row.id] = recordFor(row.cart, row.cartHash)
|
||||||
|
if not sameRecord(reg[row.id], healed[row.id]) then changed = true end
|
||||||
|
end
|
||||||
|
for id in pairs(reg) do
|
||||||
|
if healed[id] == nil then changed = true end
|
||||||
|
end
|
||||||
|
if changed then
|
||||||
|
opts[CartStore.OPTIONS_KEY] = healed
|
||||||
|
writeOptions(opts, fs)
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.listFor(version, fs)
|
||||||
|
local out = {}
|
||||||
|
for _, row in ipairs(CartStore.list(fs)) do
|
||||||
|
if row.base == version then out[#out + 1] = row end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.get(id, fs)
|
||||||
|
return readCart(fsOr(fs), id)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.labelArt(id, fs)
|
||||||
|
local cart, err = readCart(fsOr(fs), id)
|
||||||
|
if not cart then return nil, err end
|
||||||
|
return CartManifest.labelArtBytes(cart)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.export(id, fs)
|
||||||
|
local cart, err = readCart(fsOr(fs), id)
|
||||||
|
if not cart then return nil, err end
|
||||||
|
return CartManifest.encode(cart), CartManifest.hash(cart)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.install(bytes, fs)
|
||||||
|
fs = fsOr(fs)
|
||||||
|
if not (fs and fs.write) then return nil, "NO FILESYSTEM" end
|
||||||
|
local ok, cart, err = pcall(CartManifest.decode, bytes)
|
||||||
|
if not ok then return nil, "BAD CART" end
|
||||||
|
if not cart then return nil, err or "BAD CART" end
|
||||||
|
local existing = readCart(fs, cart.id)
|
||||||
|
if existing then
|
||||||
|
local order = Semver.compare(cart.version, existing.version)
|
||||||
|
if order and order < 0 then
|
||||||
|
return nil, ("%s %s is older than the installed %s")
|
||||||
|
:format(cart.title, cart.version, existing.version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fs.createDirectory then fs.createDirectory(CartStore.DIR) end
|
||||||
|
local wrote, writeErr = fs.write(fileFor(cart.id), CartManifest.encode(cart))
|
||||||
|
if not wrote then return nil, tostring(writeErr) end
|
||||||
|
local hash = CartManifest.hash(cart)
|
||||||
|
local opts = readOptions(fs)
|
||||||
|
ensureRegistry(opts)[cart.id] = recordFor(cart, hash)
|
||||||
|
writeOptions(opts, fs)
|
||||||
|
return cart, hash
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.uninstall(id, fs)
|
||||||
|
fs = fsOr(fs)
|
||||||
|
if not safeId(id) then return nil, "unknown cart id" end
|
||||||
|
if not fs then return nil, "NO FILESYSTEM" end
|
||||||
|
local path = fileFor(id)
|
||||||
|
local present = not fs.getInfo or fs.getInfo(path) ~= nil
|
||||||
|
if present and fs.read and fs.read(path) == nil then present = false end
|
||||||
|
local opts = readOptions(fs)
|
||||||
|
local reg = registry(opts)
|
||||||
|
local known = reg ~= nil and reg[id] ~= nil
|
||||||
|
if not present and not known then
|
||||||
|
return nil, ("cart %q is not installed"):format(id)
|
||||||
|
end
|
||||||
|
if present and fs.remove then fs.remove(path) end
|
||||||
|
if known then
|
||||||
|
reg[id] = nil
|
||||||
|
writeOptions(opts, fs)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function manifestOf(row)
|
||||||
|
return type(row.manifest) == "table" and row.manifest or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textOf(...)
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
local value = select(i, ...)
|
||||||
|
if type(value) == "string" and value ~= "" then return value end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pinRepo(row)
|
||||||
|
local m = manifestOf(row)
|
||||||
|
return textOf(row.github, m and m.github)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pinHash(row)
|
||||||
|
local m = manifestOf(row)
|
||||||
|
local hash = textOf(row.sha256, row.archiveSha256,
|
||||||
|
m and m.sha256, m and m.archiveSha256)
|
||||||
|
if hash and #hash == 64 and hash:match("^[0-9a-f]+$") then return hash end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function frozenOptions(bucket)
|
||||||
|
if type(bucket) ~= "table" then return nil end
|
||||||
|
local out, any = {}, false
|
||||||
|
for key, value in pairs(bucket) do
|
||||||
|
local kind = type(value)
|
||||||
|
if type(key) == "string" and key ~= ""
|
||||||
|
and (kind == "string" or kind == "number" or kind == "boolean") then
|
||||||
|
out[key] = value
|
||||||
|
any = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return any and out or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pinReason(repo, version, semver, hash)
|
||||||
|
local why = {}
|
||||||
|
if not repo then why[#why + 1] = "no GitHub repo is recorded" end
|
||||||
|
if not semver then
|
||||||
|
why[#why + 1] = ("version %s is not a semantic version, so it pins as %s")
|
||||||
|
:format(version and ("%q"):format(version) or "is missing",
|
||||||
|
CartStore.UNPINNED_VERSION)
|
||||||
|
end
|
||||||
|
if repo and semver and not hash then
|
||||||
|
why[#why + 1] = "no archive hash is known yet"
|
||||||
|
end
|
||||||
|
return table.concat(why, " and ")
|
||||||
|
end
|
||||||
|
|
||||||
|
function CartStore.capture(identity, available, modOptions)
|
||||||
|
if type(identity) ~= "table" then return nil, "cart identity is required" end
|
||||||
|
local mods, order, unresolved = {}, {}, {}
|
||||||
|
for _, row in ipairs(available or {}) do
|
||||||
|
if type(row) == "table" and safeId(row.id) then
|
||||||
|
local m = manifestOf(row)
|
||||||
|
local version = textOf(row.version, m and m.version)
|
||||||
|
local semver = version and Semver.parse(version) and version or nil
|
||||||
|
local repo = pinRepo(row)
|
||||||
|
local hash = pinHash(row)
|
||||||
|
local entry
|
||||||
|
if repo and semver and hash then
|
||||||
|
entry = { id = row.id, source = "github", repo = repo,
|
||||||
|
version = semver, sha256 = hash }
|
||||||
|
else
|
||||||
|
entry = { id = row.id, source = "local",
|
||||||
|
version = semver or CartStore.UNPINNED_VERSION }
|
||||||
|
unresolved[#unresolved + 1] = {
|
||||||
|
id = row.id,
|
||||||
|
name = textOf(row.name, m and m.name) or row.id,
|
||||||
|
version = version,
|
||||||
|
reason = pinReason(repo, version, semver, hash),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
-- A switched-off mod is pinned OFF rather than dropped, so the cart
|
||||||
|
-- still ships it with the author's settings.
|
||||||
|
if not row.enabled then entry.enabled = false end
|
||||||
|
entry.options = frozenOptions(modOptions and modOptions[row.id])
|
||||||
|
mods[#mods + 1] = entry
|
||||||
|
order[#order + 1] = row.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local cart, err = CartManifest.parse({
|
||||||
|
id = identity.id, title = identity.title, version = identity.version,
|
||||||
|
author = identity.author, repo = identity.repo, summary = identity.summary,
|
||||||
|
shell = identity.shell, finish = identity.finish, speeds = identity.speeds,
|
||||||
|
label = identity.label, base = identity.base,
|
||||||
|
engine = identity.engine, seal = identity.seal, options = identity.options,
|
||||||
|
mods = mods, load_order = order,
|
||||||
|
})
|
||||||
|
if not cart then return nil, err end
|
||||||
|
return cart, unresolved
|
||||||
|
end
|
||||||
|
|
||||||
|
return CartStore
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
local Base64 = {}
|
||||||
|
|
||||||
|
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
local PAD = 61
|
||||||
|
|
||||||
|
local ENC, DEC = {}, {}
|
||||||
|
for i = 0, 63 do
|
||||||
|
local c = ALPHABET:sub(i + 1, i + 1)
|
||||||
|
ENC[i] = c
|
||||||
|
DEC[c:byte()] = i
|
||||||
|
end
|
||||||
|
|
||||||
|
local floor = math.floor
|
||||||
|
local char = string.char
|
||||||
|
local concat = table.concat
|
||||||
|
|
||||||
|
function Base64.encode(bytes)
|
||||||
|
if type(bytes) ~= "string" then return nil, "base64 input must be a string" end
|
||||||
|
local out, n, i = {}, #bytes, 1
|
||||||
|
while i + 2 <= n do
|
||||||
|
local a, b, c = bytes:byte(i, i + 2)
|
||||||
|
local word = a * 65536 + b * 256 + c
|
||||||
|
out[#out + 1] = ENC[floor(word / 262144)] .. ENC[floor(word / 4096) % 64]
|
||||||
|
.. ENC[floor(word / 64) % 64] .. ENC[word % 64]
|
||||||
|
i = i + 3
|
||||||
|
end
|
||||||
|
local rest = n - i + 1
|
||||||
|
if rest == 1 then
|
||||||
|
local a = bytes:byte(i)
|
||||||
|
out[#out + 1] = ENC[floor(a / 4)] .. ENC[(a % 4) * 16] .. "=="
|
||||||
|
elseif rest == 2 then
|
||||||
|
local a, b = bytes:byte(i, i + 1)
|
||||||
|
local word = a * 256 + b
|
||||||
|
out[#out + 1] = ENC[floor(word / 1024)] .. ENC[floor(word / 16) % 64]
|
||||||
|
.. ENC[(word % 16) * 4] .. "="
|
||||||
|
end
|
||||||
|
return concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Base64.decode(text)
|
||||||
|
if type(text) ~= "string" then return nil, "base64 input must be a string" end
|
||||||
|
local n = #text
|
||||||
|
if n % 4 ~= 0 then return nil, "base64 length must be a multiple of four" end
|
||||||
|
if n == 0 then return "" end
|
||||||
|
local out = {}
|
||||||
|
local last = n - 3
|
||||||
|
for i = 1, n, 4 do
|
||||||
|
local b1, b2, b3, b4 = text:byte(i, i + 3)
|
||||||
|
local v1, v2 = DEC[b1], DEC[b2]
|
||||||
|
if not v1 or not v2 then
|
||||||
|
return nil, "base64 holds a character outside the alphabet"
|
||||||
|
end
|
||||||
|
if i == last and b3 == PAD then
|
||||||
|
if b4 ~= PAD then return nil, "base64 padding is malformed" end
|
||||||
|
if v2 % 16 ~= 0 then return nil, "base64 padding carries data bits" end
|
||||||
|
out[#out + 1] = char(v1 * 4 + floor(v2 / 16))
|
||||||
|
elseif i == last and b4 == PAD then
|
||||||
|
local v3 = DEC[b3]
|
||||||
|
if not v3 then return nil, "base64 holds a character outside the alphabet" end
|
||||||
|
if v3 % 4 ~= 0 then return nil, "base64 padding carries data bits" end
|
||||||
|
local word = v1 * 1024 + v2 * 16 + floor(v3 / 4)
|
||||||
|
out[#out + 1] = char(floor(word / 256), word % 256)
|
||||||
|
else
|
||||||
|
local v3, v4 = DEC[b3], DEC[b4]
|
||||||
|
if not v3 or not v4 then
|
||||||
|
return nil, "base64 holds a character outside the alphabet"
|
||||||
|
end
|
||||||
|
local word = v1 * 262144 + v2 * 4096 + v3 * 64 + v4
|
||||||
|
out[#out + 1] = char(floor(word / 65536), floor(word / 256) % 256, word % 256)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Base64
|
||||||
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
|
|||||||
"chargeReady", "invulnerable", "mustRecharge",
|
"chargeReady", "invulnerable", "mustRecharge",
|
||||||
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||||
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||||
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
"skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
|
||||||
"trapMove", "trapDamage", "fainted",
|
"trapMove", "trapDamage", "fainted",
|
||||||
"aiLayer2",
|
"aiLayer2",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
|
|||||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||||
if worker then pcall(function() worker:wait() end) end
|
if worker then pcall(function() worker:wait() end) end
|
||||||
worker, cmdCh, outCh = nil, nil, nil
|
worker, cmdCh, outCh = nil, nil, nil
|
||||||
workerReady = false
|
workerReady = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChipAudio.currentSource()
|
function ChipAudio.currentSource()
|
||||||
@@ -498,6 +498,8 @@ end
|
|||||||
-- program (20 §2 cache contract, chip music row)
|
-- program (20 §2 cache contract, chip music row)
|
||||||
Assets.register(ChipAudio.invalidate)
|
Assets.register(ChipAudio.invalidate)
|
||||||
|
|
||||||
|
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
|
|||||||
ticks = ticks + duration
|
ticks = ticks + duration
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
extendDrumEnvelope(segments)
|
||||||
self.noiseInstruments[key] = segments
|
self.noiseInstruments[key] = segments
|
||||||
return segments
|
return segments
|
||||||
end
|
end
|
||||||
|
|||||||
+9
-7
@@ -82,8 +82,9 @@ local function copy(value)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function Data:applyVersionedFieldData()
|
function Data:applyVersionedFieldData(version)
|
||||||
if require("src.core.GameVersion").isYellow() then
|
version = version or require("src.core.GameVersion").get()
|
||||||
|
if version == "yellow" then
|
||||||
self.field.trades = copy(YELLOW_TRADES)
|
self.field.trades = copy(YELLOW_TRADES)
|
||||||
-- The old man's catch demo is a RATTATA in Yellow
|
-- The old man's catch demo is a RATTATA in Yellow
|
||||||
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
||||||
@@ -106,7 +107,8 @@ end
|
|||||||
|
|
||||||
-- Fills only what the cache is missing, so an importer that learns to
|
-- Fills only what the cache is missing, so an importer that learns to
|
||||||
-- stamp one of these keys silently takes over from the engine.
|
-- stamp one of these keys silently takes over from the engine.
|
||||||
function Data:seedDefaults()
|
function Data:seedDefaults(version)
|
||||||
|
version = version or require("src.core.GameVersion").get()
|
||||||
local constants = self.constants or {}
|
local constants = self.constants or {}
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
self.field = self.field or {}
|
self.field = self.field or {}
|
||||||
@@ -131,7 +133,7 @@ function Data:seedDefaults()
|
|||||||
if constants.dexDigits == nil then
|
if constants.dexDigits == nil then
|
||||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||||
end
|
end
|
||||||
self:applyVersionedFieldData()
|
Data.applyVersionedFieldData(self, version)
|
||||||
local boot = self.field.boot
|
local boot = self.field.boot
|
||||||
if boot == nil then
|
if boot == nil then
|
||||||
boot = {}
|
boot = {}
|
||||||
@@ -144,7 +146,7 @@ function Data:seedDefaults()
|
|||||||
-- only the un-overridden default flips, so a total conversion that set
|
-- only the un-overridden default flips, so a total conversion that set
|
||||||
-- field.boot.screens.splash keeps its choice on any version.
|
-- field.boot.screens.splash keeps its choice on any version.
|
||||||
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
||||||
and require("src.core.GameVersion").isYellow() then
|
and version == "yellow" then
|
||||||
boot.screens.splash = "YellowIntro"
|
boot.screens.splash = "YellowIntro"
|
||||||
end
|
end
|
||||||
-- the naming screen presets the importer already extracts but nothing
|
-- the naming screen presets the importer already extracts but nothing
|
||||||
@@ -163,11 +165,11 @@ function Data:seedDefaults()
|
|||||||
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
|
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
|
||||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||||
self:seedCinnabarGymTrainerHeaders()
|
Data.seedCinnabarGymTrainerHeaders(self)
|
||||||
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
||||||
-- writes no header for him -- seed one so he engages on sight and has
|
-- writes no header for him -- seed one so he engages on sight and has
|
||||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||||
self:seedFightingDojoKarateMaster()
|
Data.seedFightingDojoKarateMaster(self)
|
||||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||||
require("src.world.SsAnneLayout").apply(self.maps)
|
require("src.world.SsAnneLayout").apply(self.maps)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Canonical pure shaping shared by the active boot and inactive dataset views.
|
||||||
|
-- The caller supplies both the data table and selected version; no global
|
||||||
|
-- GameVersion, cache prefix, mount, or active Data table is changed.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local DatasetHydration = {}
|
||||||
|
|
||||||
|
function DatasetHydration.applyGen2(data, moduleLoader)
|
||||||
|
local chart = data.type_chart or {}
|
||||||
|
chart.matchups = chart.matchups or {}
|
||||||
|
for _, row in ipairs(chart.foresightMatchups or {}) do
|
||||||
|
chart.matchups[#chart.matchups + 1] = row
|
||||||
|
end
|
||||||
|
data.type_chart = chart
|
||||||
|
data.gen2HeldItems =
|
||||||
|
(moduleLoader or require)("src.core.gen2.ItemEffects").heldItemsFrom(data.items)
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function DatasetHydration.apply(data, version, moduleLoader)
|
||||||
|
if GameVersion.generation(version) == 2 then
|
||||||
|
return DatasetHydration.applyGen2(data, moduleLoader)
|
||||||
|
end
|
||||||
|
require("src.core.Data").seedDefaults(data, version)
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
return DatasetHydration
|
||||||
+19
-1
@@ -6,6 +6,9 @@ local FixedStep = {}
|
|||||||
|
|
||||||
FixedStep.STEP = 1 / 60
|
FixedStep.STEP = 1 / 60
|
||||||
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
||||||
|
local SMOOTH_FRAMES = 4
|
||||||
|
local SMOOTH_MAX = 1 / 60 * 2.5
|
||||||
|
local STEP_EPS = 1 / 60 * 0.02
|
||||||
|
|
||||||
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
|
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
|
||||||
-- been paid for. Half a step is the balanced point: a frame has to come in
|
-- been paid for. Half a step is the balanced point: a frame has to come in
|
||||||
@@ -23,6 +26,7 @@ function FixedStep:init(callback)
|
|||||||
self.accum = 0
|
self.accum = 0
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.suppressCatchup = false
|
self.suppressCatchup = false
|
||||||
|
self.dtHistory, self.dtSum = nil, 0
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||||
@@ -39,12 +43,26 @@ function FixedStep:update(dt)
|
|||||||
-- the burst it would otherwise release doesn't play out as a slide.
|
-- the burst it would otherwise release doesn't play out as a slide.
|
||||||
if self.suppressCatchup then
|
if self.suppressCatchup then
|
||||||
self.suppressCatchup = false
|
self.suppressCatchup = false
|
||||||
|
self.dtHistory, self.dtSum = nil, 0
|
||||||
self.accum = self.STEP * RESEED_PHASE
|
self.accum = self.STEP * RESEED_PHASE
|
||||||
self.callback(self.STEP)
|
self.callback(self.STEP)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if dt > 0 and dt <= SMOOTH_MAX then
|
||||||
|
local hist = self.dtHistory
|
||||||
|
if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end
|
||||||
|
hist[#hist + 1] = dt
|
||||||
|
self.dtSum = self.dtSum + dt
|
||||||
|
if #hist > SMOOTH_FRAMES then
|
||||||
|
self.dtSum = self.dtSum - hist[1]
|
||||||
|
table.remove(hist, 1)
|
||||||
|
end
|
||||||
|
dt = self.dtSum / #hist
|
||||||
|
else
|
||||||
|
self.dtHistory, self.dtSum = nil, 0
|
||||||
|
end
|
||||||
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||||
while self.accum >= self.STEP do
|
while self.accum >= self.STEP - STEP_EPS do
|
||||||
self.accum = self.accum - self.STEP
|
self.accum = self.accum - self.STEP
|
||||||
self.callback(self.STEP)
|
self.callback(self.STEP)
|
||||||
end
|
end
|
||||||
|
|||||||
+68
-24
@@ -147,13 +147,16 @@ function Game:bootConfig()
|
|||||||
return boot
|
return boot
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
|
||||||
-- and by the START-menu QUIT confirmation
|
-- can reshape it), the overworld at the skeleton's spawn, and the intro
|
||||||
function Game:makeTitleState()
|
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
|
||||||
|
-- game on its own terms -- a match, a challenge mode -- calls it directly.
|
||||||
|
--
|
||||||
|
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
|
||||||
|
-- lands straight in the world; the skeleton then has to carry a name and a
|
||||||
|
-- party, which is the caller's job via save.new_game.
|
||||||
|
function Game:startNewGame(opts)
|
||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
|
||||||
local title = factory.new(self, {
|
|
||||||
onNewGame = function()
|
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
-- New Game keeps the standalone options.lua preferences
|
-- New Game keeps the standalone options.lua preferences
|
||||||
self.sessionStartedAt = os.time()
|
self.sessionStartedAt = os.time()
|
||||||
@@ -167,9 +170,19 @@ function Game:makeTitleState()
|
|||||||
self.save.player.x, self.save.player.y,
|
self.save.player.x, self.save.player.y,
|
||||||
self.save.player.facing,
|
self.save.player.facing,
|
||||||
{ via = "boot", freshBoot = true })
|
{ via = "boot", freshBoot = true })
|
||||||
|
if not (opts and opts.intro == false) then
|
||||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||||
function() end)
|
function() end)
|
||||||
end,
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||||
|
-- and by the START-menu QUIT confirmation
|
||||||
|
function Game:makeTitleState()
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||||
|
local title = factory.new(self, {
|
||||||
|
onNewGame = function() self:startNewGame() end,
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
@@ -817,14 +830,6 @@ function Game:keypressed(key)
|
|||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
elseif key == "5" then
|
|
||||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
|
||||||
-- desktop. Mobile refuses the present shader (issue #136).
|
|
||||||
local GBCFX = require("src.render.GBCFX")
|
|
||||||
if not GBCFX.isSupported() then return end
|
|
||||||
self.save.options.gbcfx = GBCFX.cycle()
|
|
||||||
self:writeOptions()
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
||||||
-- an engine display key however a mod declares it (12 §rendering
|
-- an engine display key however a mod declares it (12 §rendering
|
||||||
@@ -1258,8 +1263,10 @@ function Game:applyOptions(opts)
|
|||||||
require("src.render.Pipelines").applyOptions(opts)
|
require("src.render.Pipelines").applyOptions(opts)
|
||||||
require("src.render.Zoom").applyOptions(opts)
|
require("src.render.Zoom").applyOptions(opts)
|
||||||
require("src.render.TileRenderer").applyOptions(opts)
|
require("src.render.TileRenderer").applyOptions(opts)
|
||||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
-- returns true when a persisted preset name no longer resolves (deleted
|
||||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||||
|
-- cleared back to OFF -- "sanitized, please persist" contract
|
||||||
|
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(opts)
|
||||||
require("src.core.VideoMode").applyOptions(opts)
|
require("src.core.VideoMode").applyOptions(opts)
|
||||||
-- Android orientation lock (#592); no-op everywhere else
|
-- Android orientation lock (#592); no-op everywhere else
|
||||||
require("src.core.Orientation").applyOptions(opts)
|
require("src.core.Orientation").applyOptions(opts)
|
||||||
@@ -1274,12 +1281,12 @@ function Game:applyOptions(opts)
|
|||||||
-- tier. Every heavy feature was just applied from the stored options
|
-- tier. Every heavy feature was just applied from the stored options
|
||||||
-- above; here we clamp the *live* state down for a weaker device without
|
-- above; here we clamp the *live* state down for a weaker device without
|
||||||
-- rewriting what the player saved, so raising the tier later restores
|
-- rewriting what the player saved, so raising the tier later restores
|
||||||
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
-- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||||
-- default on a normal desktop, and every options.lua predating this
|
-- default on a normal desktop, and every options.lua predating this
|
||||||
-- option) clamps nothing, so it is a no-op for the common case.
|
-- option) clamps nothing, so it is a no-op for the common case.
|
||||||
local caps = require("src.core.Performance").applyOptions(opts)
|
local caps = require("src.core.Performance").applyOptions(opts)
|
||||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||||
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
|
||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
Zoom.allowSurvey = caps.survey
|
Zoom.allowSurvey = caps.survey
|
||||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||||
@@ -1289,8 +1296,7 @@ function Game:applyOptions(opts)
|
|||||||
end
|
end
|
||||||
Input:applyBindings(opts.bindings)
|
Input:applyBindings(opts.bindings)
|
||||||
TouchControls:applyOptions(opts)
|
TouchControls:applyOptions(opts)
|
||||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
if shaderfxCleared then self:writeOptions() end
|
||||||
if gbcCleared then self:writeOptions() end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered, opts)
|
function Game:restoreSave(loaded, recovered, opts)
|
||||||
@@ -1318,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts)
|
|||||||
self:adoptSave(loaded)
|
self:adoptSave(loaded)
|
||||||
-- SaveData.load already attached the standalone options.lua table
|
-- SaveData.load already attached the standalone options.lua table
|
||||||
self:applyOptions(loaded.options)
|
self:applyOptions(loaded.options)
|
||||||
-- saves from before OT/ID stamping: backfill with the player's (after
|
-- saves from before OT/ID stamping: backfill with the player's
|
||||||
-- the scrub, so every mon the stamp loop sees is known)
|
|
||||||
SaveData.repairTradedOtIds(loaded)
|
|
||||||
local stamp = require("src.battle.BattleState").stampOT
|
local stamp = require("src.battle.BattleState").stampOT
|
||||||
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
||||||
for _, box in ipairs(loaded.boxes or {}) do
|
for _, box in ipairs(loaded.boxes or {}) do
|
||||||
@@ -1381,4 +1385,44 @@ function Game:restoreCheckpointBattle(battle)
|
|||||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Drop every session-owned field so the next Game:load() starts clean when
|
||||||
|
-- the process returns to the launcher in-place (Android / intent_game).
|
||||||
|
--
|
||||||
|
-- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan
|
||||||
|
-- out arbitrary field:release() here: session fields can hold shared modules
|
||||||
|
-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance
|
||||||
|
-- teardown -- calling them as value:release() corrupts process state and has
|
||||||
|
-- been observed to leave Game.load nil after EXIT GAME on Android.
|
||||||
|
-- Explicit GPU owners are released below; everything else is just dropped.
|
||||||
|
function Game:reset()
|
||||||
|
if self.stack and self.stack.clear then
|
||||||
|
pcall(function() self.stack:clear() end)
|
||||||
|
end
|
||||||
|
if self.world and self.world.release then
|
||||||
|
pcall(function() self.world:release() end)
|
||||||
|
end
|
||||||
|
if self._canvases then
|
||||||
|
for _, canvas in pairs(self._canvases) do
|
||||||
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.renderer then
|
||||||
|
local release = self.renderer.releaseCanvases or self.renderer.release
|
||||||
|
if release then pcall(release, self.renderer) end
|
||||||
|
end
|
||||||
|
-- Keep methods; clear every other field (including session scalars like
|
||||||
|
-- speedOverride). Re-seed module constants afterward.
|
||||||
|
local skinFast = self.SKIN_FAST_FORWARD
|
||||||
|
local keys = {}
|
||||||
|
for key, value in pairs(self) do
|
||||||
|
if type(value) ~= "function" then
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
self[key] = nil
|
||||||
|
end
|
||||||
|
self.SKIN_FAST_FORWARD = skinFast or 4
|
||||||
|
end
|
||||||
|
|
||||||
return Game
|
return Game
|
||||||
|
|||||||
+181
-53
@@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock")
|
|||||||
local FixedStep = require("src.core.FixedStep")
|
local FixedStep = require("src.core.FixedStep")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local GamepadMap = require("src.core.GamepadMap")
|
local GamepadMap = require("src.core.GamepadMap")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
@@ -288,8 +289,6 @@ function Game2:continueGame(save)
|
|||||||
self:startWorld()
|
self:startWorld()
|
||||||
-- After the adopt and after the world is standing, which is where Gen 1
|
-- After the adopt and after the world is standing, which is where Gen 1
|
||||||
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
|
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
|
||||||
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
|
|
||||||
-- is derived from it and so comes back empty rather than missing.
|
|
||||||
if modsDiff then
|
if modsDiff then
|
||||||
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
|
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
|
||||||
if notice then require("src.core.Logger").warn("%s", notice) end
|
if notice then require("src.core.Logger").warn("%s", notice) end
|
||||||
@@ -351,27 +350,43 @@ function Game2:showTitle()
|
|||||||
onContinue = function()
|
onContinue = function()
|
||||||
self:showMainMenu()
|
self:showMainMenu()
|
||||||
end,
|
end,
|
||||||
|
-- engine/menus/intro_menu.asm:848-889
|
||||||
|
onTimeout = function()
|
||||||
|
self:showCopyright()
|
||||||
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
|
||||||
|
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
|
||||||
function Game2:showIntro()
|
function Game2:showIntro()
|
||||||
self.stack:clear()
|
self.stack:clear()
|
||||||
self.phase = "boot"
|
self.phase = "boot"
|
||||||
Screens.push(self, "Gen2GoldSilverIntro", {
|
local id = (GameVersion.engine() == "crystal")
|
||||||
|
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
|
||||||
|
Screens.push(self, id, {
|
||||||
onDone = function()
|
onDone = function()
|
||||||
self:showTitle()
|
self:showTitle()
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's
|
||||||
|
-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title.
|
||||||
function Game2:showGameFreak()
|
function Game2:showGameFreak()
|
||||||
self.stack:clear()
|
self.stack:clear()
|
||||||
self.phase = "boot"
|
self.phase = "boot"
|
||||||
Screens.push(self, "Gen2GameFreakPresents", {
|
local id = (GameVersion.engine() == "crystal")
|
||||||
|
and "Gen2CrystalSplash" or "Gen2GameFreakPresents"
|
||||||
|
Screens.push(self, id, {
|
||||||
title = self.titleData or {},
|
title = self.titleData or {},
|
||||||
oakSpeech = self.oakSpeechData or {},
|
oakSpeech = self.oakSpeechData or {},
|
||||||
onDone = function()
|
onDone = function(skipped)
|
||||||
|
if skipped then
|
||||||
|
self:showTitle()
|
||||||
|
else
|
||||||
self:showIntro()
|
self:showIntro()
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -483,8 +498,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
if onDone then onDone(learned) end
|
if onDone then onDone(learned) end
|
||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
|
-- data/text/common_3.asm:119
|
||||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||||
function() finish(true) end)
|
function() finish(true) end,
|
||||||
|
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end
|
end
|
||||||
if reason ~= "full" then return finish(false) end
|
if reason ~= "full" then return finish(false) end
|
||||||
local askForget, pickMove, askStop
|
local askForget, pickMove, askStop
|
||||||
@@ -539,9 +556,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
-- The slot is written here rather than through Mon.learnMove, so
|
-- The slot is written here rather than through Mon.learnMove, so
|
||||||
-- pokemon.move_learned is raised here too.
|
-- pokemon.move_learned is raised here too.
|
||||||
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
|
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||||
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
||||||
:format(name, oldName, name, moveName),
|
:format(name, oldName, name, moveName),
|
||||||
function() finish(true) end)
|
function() finish(true) end,
|
||||||
|
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -706,7 +725,9 @@ function Game2:usePartyItem(itemId)
|
|||||||
})
|
})
|
||||||
elseif action == "candy" then
|
elseif action == "candy" then
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
-- data/text/common_1.asm:86
|
||||||
|
self:say(result.text, function() self:afterRareCandy(mon, result) end,
|
||||||
|
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
|
||||||
else
|
else
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
@@ -765,8 +786,10 @@ function Game2:useSelectItem()
|
|||||||
local name = (items[itemId] and items[itemId].name) or itemId
|
local name = (items[itemId] and items[itemId].name) or itemId
|
||||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||||
elseif outcome == "trophy_sent" then
|
elseif outcome == "trophy_sent" then
|
||||||
|
-- data/text/common_3.asm:372
|
||||||
self:say(Strings(
|
self:say(Strings(
|
||||||
"There was a trophy\ninside!\fThe trophy was\nsent home."))
|
"There was a trophy\ninside!\fThe trophy was\nsent home."),
|
||||||
|
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end
|
end
|
||||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||||
-- drives its own presentation off World:step -- nothing left to print here.
|
-- drives its own presentation off World:step -- nothing left to print here.
|
||||||
@@ -778,9 +801,9 @@ end
|
|||||||
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
||||||
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
||||||
-- was a silent extra pop.
|
-- was a silent extra pop.
|
||||||
function Game2:say(text, onDone)
|
function Game2:say(text, onDone, opts)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.stack:push(TextBox.new(self, text, onDone))
|
self.stack:push(TextBox.new(self, text, onDone, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||||
@@ -935,6 +958,12 @@ function Game2:load()
|
|||||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||||
|
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
||||||
|
-- above is the script text and is keyed by bank:address for the overworld
|
||||||
|
-- VM, so the two are different tables and both are loaded. This one is
|
||||||
|
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
|
||||||
|
-- helper is shared with Gen 1 and looks up data.text[label].
|
||||||
|
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
|
||||||
-- data/generated/events.lua: the side tables a script command NAMES rather
|
-- data/generated/events.lua: the side tables a script command NAMES rather
|
||||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||||
@@ -1081,19 +1110,27 @@ function Game2:load()
|
|||||||
-- The play clock only runs in the overworld, the way wGameTimerPaused is
|
-- The play clock only runs in the overworld, the way wGameTimerPaused is
|
||||||
-- set while the intro menu is up.
|
-- set while the intro menu is up.
|
||||||
Save.tickPlayTime(self.save)
|
Save.tickPlayTime(self.save)
|
||||||
-- START and SELECT are read only at the tail of OWPlayerInput, which
|
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
|
||||||
-- PlayerEvents never reaches while a script is running or the player is
|
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
|
||||||
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
|
local accepts = self.world:acceptsMenuInput()
|
||||||
-- that arrives under one of them is dropped, not queued -- and the frame
|
local latch = self.joyLatch
|
||||||
-- still runs, so world:step must not be skipped on the swallowed press.
|
if accepts then
|
||||||
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
|
self.joyLatch = nil
|
||||||
|
if self.input:wasPressed("start")
|
||||||
|
or (latch and latch.start and self.input:isDown("start")) then
|
||||||
self:openStartMenu()
|
self:openStartMenu()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
|
if self.input:wasPressed("select")
|
||||||
|
or (latch and latch.select and self.input:isDown("select")) then
|
||||||
self:useSelectItem()
|
self:useSelectItem()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
if not latch then latch = {}; self.joyLatch = latch end
|
||||||
|
if self.input:wasPressed("start") then latch.start = true end
|
||||||
|
if self.input:wasPressed("select") then latch.select = true end
|
||||||
|
end
|
||||||
self.world:pollInput(self.input)
|
self.world:pollInput(self.input)
|
||||||
if self.input:wasPressed("a") then
|
if self.input:wasPressed("a") then
|
||||||
self.world:interact()
|
self.world:interact()
|
||||||
@@ -1144,8 +1181,8 @@ end
|
|||||||
|
|
||||||
-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and
|
-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and
|
||||||
-- shadow offsets stay window-size independent. Always the plain letterbox
|
-- shadow offsets stay window-size independent. Always the plain letterbox
|
||||||
-- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is
|
-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture
|
||||||
-- being shown on, and the panel does not resize when the player zooms the map
|
-- is being shown on, and the panel does not resize when the player zooms the map
|
||||||
-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason
|
-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason
|
||||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||||
-- one screen pixel a cell out at survey range.
|
-- one screen pixel a cell out at survey range.
|
||||||
@@ -1342,25 +1379,29 @@ end
|
|||||||
-- Gold's frame, and then the passes that run over it.
|
-- Gold's frame, and then the passes that run over it.
|
||||||
--
|
--
|
||||||
-- The Gen 1 path gets these for free because everything it draws goes through
|
-- The Gen 1 path gets these for free because everything it draws goes through
|
||||||
-- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there.
|
-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX
|
||||||
-- Gold draws straight to the screen instead, which is why its GBC FX row used
|
-- there. Gold draws straight to the screen instead, which is why its SHADER
|
||||||
-- to change a number and nothing else: nothing ever presented a canvas for the
|
-- FX row used to change a number and nothing else (back when it was GBC FX):
|
||||||
-- shader to read. So compose into one here when a pass wants it, and skip the
|
-- nothing ever presented a canvas for the shader to read. So compose into
|
||||||
-- canvas entirely when none does -- the common case, and one less full-screen
|
-- one here when a pass wants it, and skip the canvas entirely when none does
|
||||||
-- blit than the old path would have paid.
|
-- -- the common case, and one less full-screen blit than the old path would
|
||||||
|
-- have paid.
|
||||||
--
|
--
|
||||||
-- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette
|
-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the
|
||||||
-- IS the picture, and the screen effects are simulating the panel that picture
|
-- palette IS the picture, and the screen effects are simulating the panel
|
||||||
-- is being shown on. Mod post-processes fold in between the two, where
|
-- that picture is being shown on. Mod post-processes fold in between the
|
||||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is
|
||||||
-- is then drawn over, rather than something that smears the grid itself.
|
-- what the LCD grid is then drawn over, rather than something that smears
|
||||||
|
-- the grid itself.
|
||||||
function Game2:drawViewportFrame()
|
function Game2:drawViewportFrame()
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local w, h = GameViewport.dimensions()
|
local w, h = GameViewport.dimensions()
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local ShaderFX = require("src.render.ShaderFX")
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local fx = GBCFX.active()
|
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
|
||||||
|
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
|
||||||
|
local shaderfx = ShaderFX.active()
|
||||||
|
|
||||||
-- render.zones, at the instant Gen 1 raises it: the palette list is settled
|
-- render.zones, at the instant Gen 1 raises it: the palette list is settled
|
||||||
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
|
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
|
||||||
@@ -1383,14 +1424,14 @@ function Game2:drawViewportFrame()
|
|||||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||||
|
|
||||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||||
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
-- SHADER FX, a mod post-process, render.compose, or an enabled render.output
|
||||||
-- subscriber. With none of them the frame draws straight to the screen
|
-- subscriber. With none of them the frame draws straight to the screen
|
||||||
-- exactly as it always did.
|
-- exactly as it always did.
|
||||||
local composing = ModRuntime.wantsHook("render.compose")
|
local composing = ModRuntime.wantsHook("render.compose")
|
||||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||||
local scene = nil
|
local scene = nil
|
||||||
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||||
scene = self:presentCanvas(1, w, h)
|
scene = self:presentCanvas(1, w, h)
|
||||||
end
|
end
|
||||||
if not scene then
|
if not scene then
|
||||||
@@ -1418,11 +1459,11 @@ function Game2:drawViewportFrame()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- The zone pass has to land in a texture whenever anything still reads one
|
-- The zone pass has to land in a texture whenever anything still reads one
|
||||||
-- after it: GBC FX and a post-process both sample the tinted image, not the
|
-- after it: SHADER FX and a post-process both sample the tinted image, not
|
||||||
-- untinted one. On its own the tint rides the final blit and no second
|
-- the untinted one. On its own the tint rides the final blit and no second
|
||||||
-- canvas is paid for.
|
-- canvas is paid for.
|
||||||
local source = scene
|
local source = scene
|
||||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook
|
||||||
if zoned and reread then
|
if zoned and reread then
|
||||||
local tinted = self:presentCanvas(2, w, h)
|
local tinted = self:presentCanvas(2, w, h)
|
||||||
if tinted then
|
if tinted then
|
||||||
@@ -1442,7 +1483,7 @@ function Game2:drawViewportFrame()
|
|||||||
-- Post-process pipelines run over the finished composite and before GBC
|
-- Post-process pipelines run over the finished composite and before GBC
|
||||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||||
local scale, ox, oy, dpi = self:frameFit(w, h)
|
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
|
||||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||||
local outputHandled = hasOutputHook
|
local outputHandled = hasOutputHook
|
||||||
@@ -1456,8 +1497,30 @@ function Game2:drawViewportFrame()
|
|||||||
if not outputHandled then
|
if not outputHandled then
|
||||||
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||||
if cx then G.setScissor(cx, cy, cw, ch) end
|
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||||
if fx then
|
if shaderfx then
|
||||||
GBCFX.present(source, self:pixelScale(w, h))
|
-- rect is physical framebuffer pixels and source is the un-scaled
|
||||||
|
-- size, matching Renderer.lua's fxRectPx / fxSrc contract.
|
||||||
|
-- A live overworld draws edge to edge at World:zoomScale, so the
|
||||||
|
-- faithful 160*scale box would leave the rest of the map unshaded.
|
||||||
|
local rect, srcW, srcH
|
||||||
|
if self.frameWorldActive and self.world then
|
||||||
|
local s = self.world:zoomScale() * dpi
|
||||||
|
srcW = self.world.viewW or 160
|
||||||
|
srcH = self.world.viewH or 144
|
||||||
|
local rw, rh = srcW * s, srcH * s
|
||||||
|
rect = {
|
||||||
|
x = math.floor((pw - rw) / 2), y = math.floor((ph - rh) / 2),
|
||||||
|
w = rw, h = rh, scale = s,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
srcW, srcH = 160, 144
|
||||||
|
rect = {
|
||||||
|
x = ox * dpi, y = oy * dpi,
|
||||||
|
w = 160 * scale * dpi, h = 144 * scale * dpi,
|
||||||
|
scale = scale * dpi,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
ShaderFX.render(source, rect, { w = srcW, h = srcH }, dpi, dpi)
|
||||||
else
|
else
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
G.draw(source, 0, 0)
|
G.draw(source, 0, 0)
|
||||||
@@ -1501,6 +1564,28 @@ function Game2:drawContained(w, h)
|
|||||||
if not ok then error(err, 0) end
|
if not ok then error(err, 0) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BATTLE BG (#1709): the surround around the centred GB screen, taken from
|
||||||
|
-- whichever state in the stack owns a battle.
|
||||||
|
function Game2:paintBattleSurround(w, h)
|
||||||
|
local states = self.stack and self.stack.states or {}
|
||||||
|
local mode
|
||||||
|
for i = #states, 1, -1 do
|
||||||
|
local state = states[i]
|
||||||
|
if state and state.bgMode then mode = state:bgMode() break end
|
||||||
|
end
|
||||||
|
if mode ~= "black" then return end
|
||||||
|
local G = love.graphics
|
||||||
|
local scale = Chrome.fitScale(w, h)
|
||||||
|
local ox, oy = Chrome.fitOrigin(w, h, scale)
|
||||||
|
local pw, ph = 160 * scale, 144 * scale
|
||||||
|
G.setColor(0, 0, 0, 1)
|
||||||
|
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
|
||||||
|
if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end
|
||||||
|
if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end
|
||||||
|
if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function Game2:drawScene(w, h)
|
function Game2:drawScene(w, h)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||||
@@ -1570,6 +1655,7 @@ function Game2:drawScene(w, h)
|
|||||||
and base.drawWidescreen and base)
|
and base.drawWidescreen and base)
|
||||||
if wide then
|
if wide then
|
||||||
wide:drawWidescreen(w, h)
|
wide:drawWidescreen(w, h)
|
||||||
|
self:paintBattleSurround(w, h)
|
||||||
self:letterbox(w, h, false)
|
self:letterbox(w, h, false)
|
||||||
if wide ~= top then
|
if wide ~= top then
|
||||||
-- The pushed box blits at the same integer fit the widescreen layer
|
-- The pushed box blits at the same integer fit the widescreen layer
|
||||||
@@ -1674,7 +1760,6 @@ end
|
|||||||
-- F1/F2 write / reload the save 1 GAME SPEED
|
-- F1/F2 write / reload the save 1 GAME SPEED
|
||||||
-- - = zoom one step out / in 2 COLOR
|
-- - = zoom one step out / in 2 COLOR
|
||||||
-- 4 cycle ZOOM 3 TILT (mnemonic: 3D)
|
-- 4 cycle ZOOM 3 TILT (mnemonic: 3D)
|
||||||
-- 5 GBC FX
|
|
||||||
--
|
--
|
||||||
-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB
|
-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB
|
||||||
-- palette packs, which a CGB-native game has no use for; what it cycles here
|
-- palette packs, which a CGB-native game has no use for; what it cycles here
|
||||||
@@ -1709,13 +1794,6 @@ function Game2:hotkey(key)
|
|||||||
options.tilt = Tilt.cycle()
|
options.tilt = Tilt.cycle()
|
||||||
persist()
|
persist()
|
||||||
return true
|
return true
|
||||||
elseif key == "5" then
|
|
||||||
local GBCFX = require("src.render.GBCFX")
|
|
||||||
if GBCFX.isSupported() then
|
|
||||||
options.gbcfx = GBCFX.cycle()
|
|
||||||
persist()
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
if not (self.world and self.world.map) then
|
if not (self.world and self.world.map) then
|
||||||
return self:pipelineHotkey(key, options, persist)
|
return self:pipelineHotkey(key, options, persist)
|
||||||
@@ -1950,7 +2028,8 @@ end
|
|||||||
|
|
||||||
-- Push the saved display options into the modules that own them. Called
|
-- Push the saved display options into the modules that own them. Called
|
||||||
-- whenever the options table changes hands (boot, CONTINUE, the OPTION
|
-- whenever the options table changes hands (boot, CONTINUE, the OPTION
|
||||||
-- screen), so a reload comes back at the zoom, tilt and GBC FX the player left.
|
-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player
|
||||||
|
-- left.
|
||||||
function Game2:applyOptions()
|
function Game2:applyOptions()
|
||||||
local options = self.options or {}
|
local options = self.options or {}
|
||||||
Music.applyOptions(options)
|
Music.applyOptions(options)
|
||||||
@@ -1977,9 +2056,26 @@ function Game2:applyOptions()
|
|||||||
require("src.core.ScreenPosition").applyOptions(options)
|
require("src.core.ScreenPosition").applyOptions(options)
|
||||||
require("src.core.FrameCap").applyOptions(options)
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
-- returns true when a persisted preset name no longer resolves (deleted
|
||||||
if GBCFX.applyOptions(options) and self.save then
|
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||||
-- applyOptions returns true when it had to clear an unsupported level.
|
-- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for
|
||||||
|
-- Gen 1 (SHADER FX reaches Gen 2 too)
|
||||||
|
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options)
|
||||||
|
-- Scale the optional presentation extras to the device's performance
|
||||||
|
-- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see
|
||||||
|
-- that site's comment for the full rationale.
|
||||||
|
local caps = require("src.core.Performance").applyOptions(options)
|
||||||
|
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||||
|
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() 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
|
||||||
|
if shaderfxCleared and self.save then
|
||||||
|
-- applyOptions returns true when it had to clear an unresolved preset.
|
||||||
self.save.options = options
|
self.save.options = options
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2109,4 +2205,36 @@ function Game2:joystickremoved()
|
|||||||
TouchControls:joystickremoved()
|
TouchControls:joystickremoved()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- In-process return-to-launcher (Android / intent_game): drop session fields
|
||||||
|
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
|
||||||
|
-- Methods live on the class table; pairs(self) only sees instance state.
|
||||||
|
-- Same rule as Gen1: only release known GPU owners -- never fan out
|
||||||
|
-- arbitrary field:release() (shared modules use :release as a handle API).
|
||||||
|
function Game2:reset()
|
||||||
|
if self.stack and self.stack.clear then
|
||||||
|
pcall(function() self.stack:clear() end)
|
||||||
|
end
|
||||||
|
if self.world and self.world.release then
|
||||||
|
pcall(function() self.world:release() end)
|
||||||
|
end
|
||||||
|
if self._canvases then
|
||||||
|
for _, canvas in pairs(self._canvases) do
|
||||||
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.renderer then
|
||||||
|
local release = self.renderer.releaseCanvases or self.renderer.release
|
||||||
|
if release then pcall(release, self.renderer) end
|
||||||
|
end
|
||||||
|
local keys = {}
|
||||||
|
for key, value in pairs(self) do
|
||||||
|
if type(value) ~= "function" then
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
self[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return Game2
|
return Game2
|
||||||
|
|||||||
+31
-4
@@ -29,11 +29,38 @@ end
|
|||||||
|
|
||||||
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||||
-- a --speed argument), so a bad number degrades to something sane
|
-- a --speed argument), so a bad number degrades to something sane
|
||||||
|
-- A cart may narrow the levels a player can reach (CartManifest's `speeds`).
|
||||||
|
-- nil restores the full ladder; a one-entry list pins the speed outright.
|
||||||
|
local allowed
|
||||||
|
|
||||||
|
function GameSpeed.setAllowed(levels)
|
||||||
|
if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end
|
||||||
|
local valid, seen = {}, {}
|
||||||
|
for _, want in ipairs(GameSpeed.LEVELS) do
|
||||||
|
for _, have in ipairs(levels) do
|
||||||
|
if have == want and not seen[want] then
|
||||||
|
seen[want] = true
|
||||||
|
valid[#valid + 1] = want
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
allowed = (#valid > 0) and valid or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameSpeed.allowed()
|
||||||
|
return allowed or GameSpeed.LEVELS
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameSpeed.isLocked()
|
||||||
|
return allowed ~= nil and #allowed <= 1
|
||||||
|
end
|
||||||
|
|
||||||
function GameSpeed.clamp(v)
|
function GameSpeed.clamp(v)
|
||||||
v = tonumber(v)
|
v = tonumber(v)
|
||||||
if not v then return GameSpeed.DEFAULT end
|
local levels = GameSpeed.allowed()
|
||||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
if not v then return levels[1] or GameSpeed.DEFAULT end
|
||||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
|
||||||
|
for _, level in ipairs(levels) do
|
||||||
local diff = math.abs(level - v)
|
local diff = math.abs(level - v)
|
||||||
if diff < bestDiff then best, bestDiff = level, diff end
|
if diff < bestDiff then best, bestDiff = level, diff end
|
||||||
end
|
end
|
||||||
@@ -42,7 +69,7 @@ end
|
|||||||
|
|
||||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||||
function GameSpeed.cycle(v, dir)
|
function GameSpeed.cycle(v, dir)
|
||||||
local levels = GameSpeed.LEVELS
|
local levels = GameSpeed.allowed()
|
||||||
local cur = 1
|
local cur = 1
|
||||||
for i, level in ipairs(levels) do
|
for i, level in ipairs(levels) do
|
||||||
if level == GameSpeed.clamp(v) then cur = i break end
|
if level == GameSpeed.clamp(v) then cur = i break end
|
||||||
|
|||||||
+75
-12
@@ -1,6 +1,6 @@
|
|||||||
-- Which game this process is running: Red (the historical default), Blue,
|
-- Which game this process is running: Red (the historical default), Blue,
|
||||||
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
|
||||||
-- version -- the accepted ROM hash, the import manifest, where the
|
-- differs by version -- the accepted ROM hash, the import manifest, where the
|
||||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||||
-- cache mount, SaveData, title screen and palette all agree.
|
-- cache mount, SaveData, title screen and palette all agree.
|
||||||
--
|
--
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||||
-- and selected side by side. Gold and Silver are Gen 2 (see
|
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
|
||||||
-- docs/gold-phase1.md).
|
-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine`
|
||||||
|
-- splits Gold/Silver from Crystal within Gen 2.
|
||||||
--
|
--
|
||||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||||
-- and tests. The active version is a process-global set once at boot from
|
-- and tests. The active version is a process-global set once at boot from
|
||||||
@@ -27,6 +28,8 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest.json",
|
manifest = "tools/rom_manifest.json",
|
||||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||||
|
-- Absent reads as "gen1" (GameVersion.engine)
|
||||||
|
engine = "gen1",
|
||||||
},
|
},
|
||||||
blue = {
|
blue = {
|
||||||
id = "blue",
|
id = "blue",
|
||||||
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest_blue.json",
|
manifest = "tools/rom_manifest_blue.json",
|
||||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||||
|
engine = "gen1",
|
||||||
},
|
},
|
||||||
yellow = {
|
yellow = {
|
||||||
id = "yellow",
|
id = "yellow",
|
||||||
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest_yellow.json",
|
manifest = "tools/rom_manifest_yellow.json",
|
||||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||||
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||||
|
engine = "gen1",
|
||||||
},
|
},
|
||||||
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
|
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
|
||||||
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
|
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
|
||||||
@@ -55,15 +60,14 @@ GameVersion.VERSIONS = {
|
|||||||
id = "gold",
|
id = "gold",
|
||||||
label = "Gold",
|
label = "Gold",
|
||||||
displayName = "Pokemon Gold",
|
displayName = "Pokemon Gold",
|
||||||
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
|
launcherName = "Gold",
|
||||||
-- players do not treat it like the shipped Gen 1 columns.
|
|
||||||
launcherName = "Gold (Beta)",
|
|
||||||
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
|
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
|
||||||
manifest = "tools/rom_manifest_gold.json",
|
manifest = "tools/rom_manifest_gold.json",
|
||||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||||
-- Absent reads as 1 (GameVersion.generation)
|
-- Absent reads as 1 (GameVersion.generation)
|
||||||
generation = 2,
|
generation = 2,
|
||||||
|
engine = "gs",
|
||||||
},
|
},
|
||||||
-- Gold's engine with edition-selected data; the manifest is derived from
|
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||||
-- Gold's by tools/make_silver_manifest.py.
|
-- Gold's by tools/make_silver_manifest.py.
|
||||||
@@ -71,17 +75,46 @@ GameVersion.VERSIONS = {
|
|||||||
id = "silver",
|
id = "silver",
|
||||||
label = "Silver",
|
label = "Silver",
|
||||||
displayName = "Pokemon Silver",
|
displayName = "Pokemon Silver",
|
||||||
launcherName = "Silver (Beta)",
|
launcherName = "Silver",
|
||||||
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
||||||
manifest = "tools/rom_manifest_silver.json",
|
manifest = "tools/rom_manifest_silver.json",
|
||||||
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||||
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||||
generation = 2,
|
generation = 2,
|
||||||
|
engine = "gs",
|
||||||
|
},
|
||||||
|
crystal = {
|
||||||
|
id = "crystal",
|
||||||
|
label = "Crystal",
|
||||||
|
displayName = "Pokemon Crystal",
|
||||||
|
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
|
||||||
|
-- players do not treat it like the shipped Gold and Silver columns.
|
||||||
|
launcherName = "Crystal (Beta)",
|
||||||
|
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||||
|
manifest = "tools/rom_manifest_crystal.json",
|
||||||
|
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
|
||||||
|
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
|
||||||
|
generation = 2,
|
||||||
|
engine = "crystal",
|
||||||
|
revisions = {
|
||||||
|
{ sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" },
|
||||||
|
{ sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" },
|
||||||
|
},
|
||||||
|
fixes = {
|
||||||
|
-- pokegold/docs/bugs_and_glitches.md:61
|
||||||
|
luckyNumberBoxes = true,
|
||||||
|
-- pokegold/docs/bugs_and_glitches.md:88
|
||||||
|
surfOntoNpc = true,
|
||||||
|
-- pokecrystal/engine/battle/effect_commands.asm:2614
|
||||||
|
reflectOverflow = true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Launcher column order.
|
local NO_FIXES = {}
|
||||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
|
||||||
|
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
|
||||||
|
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||||
|
|
||||||
GameVersion.current = "red"
|
GameVersion.current = "red"
|
||||||
|
|
||||||
@@ -115,6 +148,17 @@ function GameVersion.generation(id)
|
|||||||
return GameVersion.info(id).generation or 1
|
return GameVersion.info(id).generation or 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- "gen1" | "gs" | "crystal": lineage within a generation.
|
||||||
|
function GameVersion.engine(id)
|
||||||
|
return GameVersion.info(id).engine or "gen1"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged.
|
||||||
|
function GameVersion.fixes(id)
|
||||||
|
local info = GameVersion.info(id)
|
||||||
|
return (info and info.fixes) or NO_FIXES
|
||||||
|
end
|
||||||
|
|
||||||
-- Metadata for a version id, defaulting to the active one.
|
-- Metadata for a version id, defaulting to the active one.
|
||||||
function GameVersion.info(id)
|
function GameVersion.info(id)
|
||||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||||
@@ -128,10 +172,29 @@ function GameVersion.cachePrefix(id)
|
|||||||
return GameVersion.info(id).cachePrefix
|
return GameVersion.info(id).cachePrefix
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function GameVersion.revisions(id)
|
||||||
|
local info = GameVersion.info(id)
|
||||||
|
return info.revisions or { { sha1 = info.sha1 } }
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.acceptsSha1(id, sha1)
|
||||||
|
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||||
|
if revision.sha1 == sha1 then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.revisionLabel(id, sha1)
|
||||||
|
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||||
|
if revision.sha1 == sha1 then return revision.label end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
||||||
function GameVersion.forSha1(sha1)
|
function GameVersion.forSha1(sha1)
|
||||||
for id, info in pairs(GameVersion.VERSIONS) do
|
for id in pairs(GameVersion.VERSIONS) do
|
||||||
if info.sha1 == sha1 then return id end
|
if GameVersion.acceptsSha1(id, sha1) then return id end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-1
@@ -376,7 +376,19 @@ end
|
|||||||
-- notice a quit command until curl returns. With the launcher's default 300s
|
-- notice a quit command until curl returns. With the launcher's default 300s
|
||||||
-- ceiling, closing the window during a mod download hung the process for
|
-- ceiling, closing the window during a mod download hung the process for
|
||||||
-- minutes. Callers on the interactive fetch pool pass something short.
|
-- minutes. Callers on the interactive fetch pool pass something short.
|
||||||
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
--
|
||||||
|
-- `etagPath` (optional, absolute host path) turns this into a conditional
|
||||||
|
-- GET: curl sends `If-None-Match` from whatever ETag is already saved there
|
||||||
|
-- (`--etag-compare`) and overwrites it with the response's own ETag on
|
||||||
|
-- success (`--etag-save`), same file for both so a first call with no prior
|
||||||
|
-- ETag degrades to a plain unconditional download. A server that replies 304
|
||||||
|
-- Not Modified is reported as a third return value (`notModified`), and --
|
||||||
|
-- confirmed empirically against the real buildbot.libretro.com endpoint
|
||||||
|
-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT
|
||||||
|
-- write `absPath` at all in that case (not even an empty file), matching
|
||||||
|
-- HTTP: a 304 carries no body. A caller must not treat a missing file as an
|
||||||
|
-- error when `notModified` comes back true.
|
||||||
|
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath)
|
||||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||||
userAgent = userAgent or "gen1recomp"
|
userAgent = userAgent or "gen1recomp"
|
||||||
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
|||||||
if accept then
|
if accept then
|
||||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||||
end
|
end
|
||||||
|
if type(etagPath) == "string" and etagPath ~= "" then
|
||||||
|
cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " "
|
||||||
|
.. "--etag-save " .. HostShell.quote(etagPath) .. " "
|
||||||
|
end
|
||||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
||||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||||
.. HostShell.quote(url) .. " 2>&1"
|
.. HostShell.quote(url) .. " 2>&1"
|
||||||
@@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
|||||||
-- status is here purely so the failure can NAME itself: "download failed"
|
-- status is here purely so the failure can NAME itself: "download failed"
|
||||||
-- with no URL and no code is the report this whole change exists to fix.
|
-- with no URL and no code is the report this whole change exists to fix.
|
||||||
local body, status, noise = splitCurlOutput(readOk and out or "")
|
local body, status, noise = splitCurlOutput(readOk and out or "")
|
||||||
|
if status == 304 then
|
||||||
|
return true, nil, true
|
||||||
|
end
|
||||||
if status and (status < 200 or status >= 300) then
|
if status and (status < 200 or status >= 300) then
|
||||||
return nil, fetchError(url, status, body)
|
return nil, fetchError(url, status, body)
|
||||||
end
|
end
|
||||||
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
|||||||
if not haveBridge() then
|
if not haveBridge() then
|
||||||
return nil, "no network transport on this platform"
|
return nil, "no network transport on this platform"
|
||||||
end
|
end
|
||||||
|
-- The Android/iOS bridge has no conditional-GET support -- etagPath is
|
||||||
|
-- silently ignored here, so a downloaded preset re-fetches in full every
|
||||||
|
-- time on those platforms. No transport-level ETag/If-None-Match hook
|
||||||
|
-- exists on the bridge to hang one off, and it's low-priority for now.
|
||||||
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
|
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
|
||||||
if ok and done then return true end
|
if ok and done then return true end
|
||||||
return nil, "download failed for " .. url
|
return nil, "download failed for " .. url
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
|
|||||||
y = "yellow", yellow = "yellow",
|
y = "yellow", yellow = "yellow",
|
||||||
g = "gold", gold = "gold",
|
g = "gold", gold = "gold",
|
||||||
s = "silver", silver = "silver",
|
s = "silver", silver = "silver",
|
||||||
|
c = "crystal", crystal = "crystal",
|
||||||
}
|
}
|
||||||
v = alias[v] or v
|
v = alias[v] or v
|
||||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||||
|
|||||||
+29
-12
@@ -3,9 +3,12 @@
|
|||||||
--
|
--
|
||||||
-- The heavy extras are all things the Game Boy never had and this port
|
-- 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
|
-- 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
|
-- ground plane), survey ZOOM (zooming out renders the connected
|
||||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
|
||||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
-- translated libretro slang-shader chain, reasoned -- not yet measured --
|
||||||
|
-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced).
|
||||||
|
-- A hard FPS ceiling caps present cost on
|
||||||
|
-- top of that.
|
||||||
-- None of this touches game logic, which is fixed-step off dt
|
-- None of this touches game logic, which is fixed-step off dt
|
||||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||||
-- only in how much optional eye-candy the renderer is allowed to do.
|
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||||
@@ -13,14 +16,15 @@
|
|||||||
-- The tier is persisted as save.options.performance:
|
-- The tier is persisted as save.options.performance:
|
||||||
-- auto pick a default from the device (see detect())
|
-- auto pick a default from the device (see detect())
|
||||||
-- high everything on -- the historical behavior
|
-- high everything on -- the historical behavior
|
||||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
|
||||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
|
||||||
--
|
--
|
||||||
-- AUTO only chooses the *default* for the current device; every tier is
|
-- 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
|
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||||
-- against the stored options without rewriting them, so raising the tier
|
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
|
||||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX
|
||||||
|
-- / ZOOM / FPS the player had. Wired into both generations.
|
||||||
--
|
--
|
||||||
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||||
-- tests, the same way src/core/GameVersion.lua does.
|
-- tests, the same way src/core/GameVersion.lua does.
|
||||||
@@ -40,10 +44,24 @@ Performance.LABELS = {
|
|||||||
-- What each concrete tier permits. `auto` is resolved to one of these
|
-- 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
|
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||||
-- extra ceiling (the player's own MAX FPS still applies).
|
-- extra ceiling (the player's own MAX FPS still applies).
|
||||||
|
--
|
||||||
|
-- `shaderfx` is `false` (fully off) or a positive number: an internal
|
||||||
|
-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole
|
||||||
|
-- SHADER FX pass chain at half resolution, then stretch up for display --
|
||||||
|
-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its
|
||||||
|
-- final output to the real display rect regardless of the chain's own
|
||||||
|
-- internal size, so shrinking the chain's working
|
||||||
|
-- resolution costs nothing extra at that final blit). Every existing
|
||||||
|
-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works
|
||||||
|
-- unchanged: `false` stays falsy, any positive number stays truthy -- only
|
||||||
|
-- `ShaderFX.render` itself reads the actual number. No tier is
|
||||||
|
-- set below 1.0 yet -- tuning the actual per-tier value for real low-end
|
||||||
|
-- hardware needs a device this project doesn't have to hand right now; this
|
||||||
|
-- is the mechanism, not the tuning.
|
||||||
Performance.CAPS = {
|
Performance.CAPS = {
|
||||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
|
||||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
|
||||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||||
@@ -93,8 +111,7 @@ function Performance.detect()
|
|||||||
if isArm and os == "Linux" then
|
if isArm and os == "Linux" then
|
||||||
return "low"
|
return "low"
|
||||||
end
|
end
|
||||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
|
||||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
|
||||||
if os == "Android" or os == "iOS" then
|
if os == "Android" or os == "iOS" then
|
||||||
return "balanced"
|
return "balanced"
|
||||||
end
|
end
|
||||||
|
|||||||
+549
-125
File diff suppressed because it is too large
Load Diff
+69
-14
@@ -70,9 +70,27 @@ local function fail(state, why)
|
|||||||
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
|
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function limit(state, name)
|
||||||
|
return state.limits and state.limits[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bumpNode(state)
|
||||||
|
state.nodes = state.nodes + 1
|
||||||
|
local maximum = limit(state, "maxNodes")
|
||||||
|
if maximum and state.nodes > maximum then fail(state, "too many values") end
|
||||||
|
end
|
||||||
|
|
||||||
local function skip(state)
|
local function skip(state)
|
||||||
|
while true do
|
||||||
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
|
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
|
||||||
state.pos = last + 1
|
state.pos = last + 1
|
||||||
|
if not (state.limits and state.limits.allowComments
|
||||||
|
and state.src:sub(state.pos, state.pos + 1) == "--") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local newline = state.src:find("\n", state.pos + 2, true)
|
||||||
|
state.pos = newline and newline + 1 or (#state.src + 1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function peek(state)
|
local function peek(state)
|
||||||
@@ -90,7 +108,10 @@ local function readString(state)
|
|||||||
fail(state, "unterminated string")
|
fail(state, "unterminated string")
|
||||||
elseif c == '"' then
|
elseif c == '"' then
|
||||||
state.pos = i + 1
|
state.pos = i + 1
|
||||||
return table.concat(out)
|
local value = table.concat(out)
|
||||||
|
local maximum = limit(state, "maxStringBytes")
|
||||||
|
if maximum and #value > maximum then fail(state, "string too long") end
|
||||||
|
return value
|
||||||
elseif c == "\\" then
|
elseif c == "\\" then
|
||||||
local nxt = src:sub(i + 1, i + 1)
|
local nxt = src:sub(i + 1, i + 1)
|
||||||
if nxt:match("%d") then
|
if nxt:match("%d") then
|
||||||
@@ -136,10 +157,14 @@ end
|
|||||||
local readValue
|
local readValue
|
||||||
|
|
||||||
local function readTable(state)
|
local function readTable(state)
|
||||||
|
bumpNode(state)
|
||||||
state.depth = state.depth + 1
|
state.depth = state.depth + 1
|
||||||
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end
|
if state.depth > (limit(state, "maxDepth") or MAX_DEPTH) then
|
||||||
|
fail(state, "table nesting too deep")
|
||||||
|
end
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
local out = {}
|
local out = {}
|
||||||
|
local entries, nextArray = 0, 1
|
||||||
skip(state)
|
skip(state)
|
||||||
if peek(state) == "}" then
|
if peek(state) == "}" then
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
@@ -148,7 +173,7 @@ local function readTable(state)
|
|||||||
end
|
end
|
||||||
while true do
|
while true do
|
||||||
skip(state)
|
skip(state)
|
||||||
local key
|
local key, value
|
||||||
local c = peek(state)
|
local c = peek(state)
|
||||||
if c == "[" then
|
if c == "[" then
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
@@ -156,15 +181,38 @@ local function readTable(state)
|
|||||||
skip(state)
|
skip(state)
|
||||||
if peek(state) ~= "]" then fail(state, "expected ]") end
|
if peek(state) ~= "]" then fail(state, "expected ]") end
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
elseif c:match("[%a_]") then
|
|
||||||
key = readIdent(state)
|
|
||||||
else
|
|
||||||
fail(state, "expected key")
|
|
||||||
end
|
|
||||||
skip(state)
|
skip(state)
|
||||||
if peek(state) ~= "=" then fail(state, "expected =") end
|
if peek(state) ~= "=" then fail(state, "expected =") end
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
out[key] = readValue(state)
|
value = readValue(state)
|
||||||
|
elseif c:match("[%a_]") then
|
||||||
|
local start = state.pos
|
||||||
|
local ident = readIdent(state)
|
||||||
|
skip(state)
|
||||||
|
if peek(state) == "=" then
|
||||||
|
key = ident
|
||||||
|
state.pos = state.pos + 1
|
||||||
|
value = readValue(state)
|
||||||
|
elseif state.limits and state.limits.allowArray then
|
||||||
|
state.pos = start
|
||||||
|
key = nextArray
|
||||||
|
value = readValue(state)
|
||||||
|
else
|
||||||
|
fail(state, "expected =")
|
||||||
|
end
|
||||||
|
elseif state.limits and state.limits.allowArray then
|
||||||
|
key = nextArray
|
||||||
|
value = readValue(state)
|
||||||
|
else
|
||||||
|
fail(state, "expected key")
|
||||||
|
end
|
||||||
|
if key == nil then fail(state, "nil table key") end
|
||||||
|
if out[key] ~= nil then fail(state, "duplicate table key") end
|
||||||
|
entries = entries + 1
|
||||||
|
local maximum = limit(state, "maxTableEntries")
|
||||||
|
if maximum and entries > maximum then fail(state, "too many table entries") end
|
||||||
|
out[key] = value
|
||||||
|
if type(key) == "number" and key == nextArray then nextArray = nextArray + 1 end
|
||||||
skip(state)
|
skip(state)
|
||||||
local sep = peek(state)
|
local sep = peek(state)
|
||||||
if sep == "," then
|
if sep == "," then
|
||||||
@@ -189,25 +237,30 @@ readValue = function(state)
|
|||||||
skip(state)
|
skip(state)
|
||||||
local c = peek(state)
|
local c = peek(state)
|
||||||
if c == '"' then
|
if c == '"' then
|
||||||
|
bumpNode(state)
|
||||||
return readString(state)
|
return readString(state)
|
||||||
elseif c == "{" then
|
elseif c == "{" then
|
||||||
return readTable(state)
|
return readTable(state)
|
||||||
elseif c:match("[%a_]") then
|
elseif c:match("[%a_]") then
|
||||||
-- the only bare words in the grammar are the boolean literals
|
-- the only bare words in the grammar are the boolean literals
|
||||||
local word = readIdent(state)
|
local word = readIdent(state)
|
||||||
if word == "true" then return true end
|
if word == "true" then bumpNode(state); return true end
|
||||||
if word == "false" then return false end
|
if word == "false" then bumpNode(state); return false end
|
||||||
state.pos = state.pos - #word
|
state.pos = state.pos - #word
|
||||||
fail(state, "unexpected name '" .. word .. "'")
|
fail(state, "unexpected name '" .. word .. "'")
|
||||||
elseif c:match("[%-%d%.]") then
|
elseif c:match("[%-%d%.]") then
|
||||||
|
bumpNode(state)
|
||||||
return readNumber(state)
|
return readNumber(state)
|
||||||
end
|
end
|
||||||
fail(state, c == "" and "unexpected end of input" or "unexpected character")
|
fail(state, c == "" and "unexpected end of input" or "unexpected character")
|
||||||
end
|
end
|
||||||
|
|
||||||
function SaveSerializer.decode(str)
|
function SaveSerializer.decode(str, limits)
|
||||||
if type(str) ~= "string" then return nil, "save must be a string" end
|
if type(str) ~= "string" then return nil, "save must be a string" end
|
||||||
local state = { src = str, pos = 1, depth = 0 }
|
if limits and limits.maxBytes and #str > limits.maxBytes then
|
||||||
|
return nil, ("input is %d bytes (max %d)"):format(#str, limits.maxBytes)
|
||||||
|
end
|
||||||
|
local state = { src = str, pos = 1, depth = 0, nodes = 0, limits = limits }
|
||||||
local ok, result = pcall(function()
|
local ok, result = pcall(function()
|
||||||
skip(state)
|
skip(state)
|
||||||
local word = state.src:match("^[%a_][%w_]*", state.pos)
|
local word = state.src:match("^[%a_][%w_]*", state.pos)
|
||||||
@@ -219,7 +272,9 @@ function SaveSerializer.decode(str)
|
|||||||
return value
|
return value
|
||||||
end)
|
end)
|
||||||
if not ok then return nil, result end
|
if not ok then return nil, result end
|
||||||
if type(result) ~= "table" then return nil, "save root must be a table" end
|
if type(result) ~= "table" then
|
||||||
|
return nil, (limits and limits.rootName or "save") .. " root must be a table"
|
||||||
|
end
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus
|
||||||
|
-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any
|
||||||
|
-- platform, so the working path is raw FFI into the SDL2 LOVE already links,
|
||||||
|
-- the same technique src/core/Orientation.lua uses. What is and is not verified
|
||||||
|
-- on real hardware is written down in docs/shaderfx.md.
|
||||||
|
|
||||||
|
local Sensors = {}
|
||||||
|
|
||||||
|
-- Test seams: a no-device harness injects synthetic readings.
|
||||||
|
local overrides = {}
|
||||||
|
local orientationOverride = nil
|
||||||
|
|
||||||
|
function Sensors.setOverride(kind, x, y, z)
|
||||||
|
overrides[kind] = { x, y, z }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Sensors.clearOverride(kind)
|
||||||
|
overrides[kind] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Sensors.clearAllOverrides()
|
||||||
|
overrides = {}
|
||||||
|
orientationOverride = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil
|
||||||
|
-- (nil = ask SDL for real). Test seam for rotateForScreen below.
|
||||||
|
function Sensors.setOrientationOverride(o)
|
||||||
|
orientationOverride = o
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today).
|
||||||
|
local function safeHasSensor(kind)
|
||||||
|
if not love or not love.sensor then return false end
|
||||||
|
local ok, has = pcall(love.sensor.hasSensor, kind)
|
||||||
|
return ok and has == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loveSensorRead(kind)
|
||||||
|
if not safeHasSensor(kind) then return nil end
|
||||||
|
local ok, x, y, z = pcall(love.sensor.getData, kind)
|
||||||
|
if not ok then return nil end
|
||||||
|
return x or 0, y or 0, z or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5).
|
||||||
|
local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2
|
||||||
|
local SDL_INIT_SENSOR = 0x00008000
|
||||||
|
local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO }
|
||||||
|
|
||||||
|
local sdlCdefOk = nil
|
||||||
|
local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent)
|
||||||
|
local sdlSensorTried = {}
|
||||||
|
|
||||||
|
-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library,
|
||||||
|
-- then bare ffi.C, needed on Android where love-android links SDL2 statically
|
||||||
|
-- into libmain.so and there is no standalone libSDL2.so to attach to by name
|
||||||
|
-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716).
|
||||||
|
local sdlLib = nil
|
||||||
|
local function sdlFfi()
|
||||||
|
local okFfi, ffi = pcall(require, "ffi")
|
||||||
|
if not okFfi then return nil end
|
||||||
|
if sdlCdefOk == nil then
|
||||||
|
sdlCdefOk = pcall(ffi.cdef, [[
|
||||||
|
typedef struct SDL_Window SDL_Window;
|
||||||
|
typedef struct SDL_Sensor SDL_Sensor;
|
||||||
|
int SDL_InitSubSystem(unsigned int flags);
|
||||||
|
int SDL_NumSensors(void);
|
||||||
|
int SDL_SensorGetDeviceType(int device_index);
|
||||||
|
SDL_Sensor *SDL_SensorOpen(int device_index);
|
||||||
|
void SDL_SensorUpdate(void);
|
||||||
|
int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values);
|
||||||
|
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||||
|
int SDL_GetWindowDisplayIndex(SDL_Window *window);
|
||||||
|
int SDL_GetDisplayOrientation(int displayIndex);
|
||||||
|
]])
|
||||||
|
if sdlCdefOk then
|
||||||
|
local okLoad, lib = pcall(ffi.load, "SDL2")
|
||||||
|
lib = okLoad and lib or ffi.C
|
||||||
|
local okSyms = pcall(function()
|
||||||
|
return lib.SDL_InitSubSystem, lib.SDL_NumSensors, lib.SDL_SensorGetDeviceType,
|
||||||
|
lib.SDL_SensorOpen, lib.SDL_SensorUpdate, lib.SDL_SensorGetData,
|
||||||
|
lib.SDL_GL_GetCurrentWindow, lib.SDL_GetWindowDisplayIndex,
|
||||||
|
lib.SDL_GetDisplayOrientation
|
||||||
|
end)
|
||||||
|
sdlLib = okSyms and lib or false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not sdlCdefOk or not sdlLib then return nil end
|
||||||
|
return sdlLib
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Opens and caches once, and returns nil permanently once a real attempt finds
|
||||||
|
-- none, so a no-hardware desktop run pays this cost once rather than per frame.
|
||||||
|
local function sdlSensorHandle(lib, kind)
|
||||||
|
if sdlSensorTried[kind] then return sdlSensorHandles[kind] end
|
||||||
|
sdlSensorTried[kind] = true
|
||||||
|
local wantType = KIND_TO_SDL_TYPE[kind]
|
||||||
|
if not wantType then return nil end
|
||||||
|
local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR)
|
||||||
|
if not okInit then return nil end
|
||||||
|
local okCount, count = pcall(lib.SDL_NumSensors)
|
||||||
|
if not okCount or count <= 0 then return nil end
|
||||||
|
for i = 0, count - 1 do
|
||||||
|
local okType, t = pcall(lib.SDL_SensorGetDeviceType, i)
|
||||||
|
if okType and t == wantType then
|
||||||
|
local okOpen, handle = pcall(lib.SDL_SensorOpen, i)
|
||||||
|
if okOpen and handle ~= nil then
|
||||||
|
sdlSensorHandles[kind] = handle
|
||||||
|
return handle
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sdlSensorRead(kind)
|
||||||
|
local lib = sdlFfi()
|
||||||
|
if not lib then return nil end
|
||||||
|
local handle = sdlSensorHandle(lib, kind)
|
||||||
|
if not handle then return nil end
|
||||||
|
pcall(lib.SDL_SensorUpdate)
|
||||||
|
local data = require("ffi").new("float[3]")
|
||||||
|
local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3)
|
||||||
|
if not okGet or n ~= 0 then return nil end
|
||||||
|
return data[0], data[1], data[2]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen
|
||||||
|
-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is
|
||||||
|
-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation.
|
||||||
|
local function isMobile()
|
||||||
|
if not love or not love.system or not love.system.getOS then return false end
|
||||||
|
local os = love.system.getOS()
|
||||||
|
return os == "Android" or os == "iOS"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sdlOrientation()
|
||||||
|
if not isMobile() then return nil end
|
||||||
|
local lib = sdlFfi()
|
||||||
|
if not lib then return nil end
|
||||||
|
local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow)
|
||||||
|
if not okWin or win == nil then return nil end
|
||||||
|
local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win)
|
||||||
|
if not okIdx or idx < 0 then return nil end
|
||||||
|
local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx)
|
||||||
|
if not okOr then return nil end
|
||||||
|
-- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED,
|
||||||
|
-- 3=PORTRAIT, 4=PORTRAIT_FLIPPED.
|
||||||
|
if o == 1 then return "landscape"
|
||||||
|
elseif o == 2 then return "landscapeFlipped"
|
||||||
|
elseif o == 3 then return "portrait"
|
||||||
|
elseif o == 4 then return "portraitFlipped"
|
||||||
|
else return nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait,
|
||||||
|
-- and unknown/undetectable (desktop always lands here), is a no-op.
|
||||||
|
local function rotateForScreen(x, y)
|
||||||
|
local o = orientationOverride or sdlOrientation()
|
||||||
|
if o == "landscape" then return -y, x
|
||||||
|
elseif o == "landscapeFlipped" then return y, -x
|
||||||
|
elseif o == "portraitFlipped" then return -x, -y
|
||||||
|
else return x, y end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden
|
||||||
|
-- that way, no hardware/module is available, or an underlying call errors.
|
||||||
|
-- x/y are rotated to be screen-relative; z passes through unchanged.
|
||||||
|
function Sensors.read(kind)
|
||||||
|
local override = overrides[kind]
|
||||||
|
if override then
|
||||||
|
local x, y = rotateForScreen(override[1], override[2])
|
||||||
|
return x, y, override[3]
|
||||||
|
end
|
||||||
|
|
||||||
|
local x, y, z = loveSensorRead(kind)
|
||||||
|
if not x then x, y, z = sdlSensorRead(kind) end
|
||||||
|
if not x then return 0, 0, 0 end
|
||||||
|
|
||||||
|
x, y = rotateForScreen(x, y)
|
||||||
|
return x, y, z
|
||||||
|
end
|
||||||
|
|
||||||
|
return Sensors
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
-- Central session lifecycle orchestrator. Subsystems register teardown hooks
|
||||||
|
-- at module load (Assets release/invalidate bus, process shutdown below); the
|
||||||
|
-- host only calls phase entry points.
|
||||||
|
--
|
||||||
|
-- Three tiers:
|
||||||
|
-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets
|
||||||
|
-- game endGameSession — audio + game:reset; workers stay alive
|
||||||
|
-- process endProcess — worker shutdown on real app exit (love.quit)
|
||||||
|
--
|
||||||
|
-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever.
|
||||||
|
|
||||||
|
local SessionLifecycle = {}
|
||||||
|
|
||||||
|
local processShutdowns = {}
|
||||||
|
|
||||||
|
function SessionLifecycle.registerProcessShutdown(fn)
|
||||||
|
processShutdowns[#processShutdowns + 1] = fn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||||
|
-- version session (save editor or game). GPU release runs before soft
|
||||||
|
-- invalidate via installLoader(nil).
|
||||||
|
function SessionLifecycle.endMountedSession(version)
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
if Assets.releaseSession then Assets.releaseSession() end
|
||||||
|
if version then
|
||||||
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
|
end
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
if Runtime.reset then Runtime.reset() end
|
||||||
|
if Assets.installLoader then Assets.installLoader(nil) end
|
||||||
|
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||||
|
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Evict every save-editor module from package.loaded without a hardcoded
|
||||||
|
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
||||||
|
-- tools/save-editor/; path-style keys may also appear.
|
||||||
|
local function flushEditorPackageLoaded()
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
local function isEditorFlat(name)
|
||||||
|
if not (fs and fs.getInfo) then return false end
|
||||||
|
if name:find("[./]") then return false end
|
||||||
|
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||||
|
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||||
|
end
|
||||||
|
for k in pairs(package.loaded) do
|
||||||
|
if type(k) == "string"
|
||||||
|
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||||
|
package.loaded[k] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SessionLifecycle.endEditorSession(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if opts.app and opts.app.unload then pcall(opts.app.unload) end
|
||||||
|
flushEditorPackageLoaded()
|
||||||
|
if opts.version then
|
||||||
|
SessionLifecycle.endMountedSession(opts.version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||||
|
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||||
|
function SessionLifecycle.endGameSession(game)
|
||||||
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
|
pcall(function() require("src.core.Sound").stop() end)
|
||||||
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
|
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.DiscordPresence"] then
|
||||||
|
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.gen2.Clock"] then
|
||||||
|
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.net.Gen1Tls"] then
|
||||||
|
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||||
|
end
|
||||||
|
if love.audio and love.audio.stop then
|
||||||
|
pcall(love.audio.stop)
|
||||||
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
|
if game and game.reset then
|
||||||
|
pcall(function() game:reset() end)
|
||||||
|
end
|
||||||
|
-- Gen1 Game is the module singleton. If teardown left it without load,
|
||||||
|
-- drop the cached module so the next require rebuilds a clean table
|
||||||
|
-- (bootGame also guards this; doing it here keeps Play-again reliable).
|
||||||
|
if game and type(game.load) ~= "function"
|
||||||
|
and package.loaded["src.core.Game"] == game then
|
||||||
|
package.loaded["src.core.Game"] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
Input:reset()
|
||||||
|
TouchControls:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SessionLifecycle.endProcess()
|
||||||
|
for _, fn in ipairs(processShutdowns) do pcall(fn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
return SessionLifecycle
|
||||||
@@ -363,6 +363,21 @@ function Sound.sfxBusy()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound`
|
||||||
|
-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483).
|
||||||
|
function Sound.waitSfxDone()
|
||||||
|
if not curSfx then return end
|
||||||
|
pcall(curSfx.src.stop, curSfx.src)
|
||||||
|
curSfx = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SFXChannelsOff (home/audio.asm:545)
|
||||||
|
function Sound.sfxChannelsOff()
|
||||||
|
if not curSfx then return end
|
||||||
|
pcall(curSfx.src.stop, curSfx.src)
|
||||||
|
curSfx = nil
|
||||||
|
end
|
||||||
|
|
||||||
local function startSfx(data, name, def)
|
local function startSfx(data, name, def)
|
||||||
local src = playPath(data, name, def)
|
local src = playPath(data, name, def)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
|
|||||||
@@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local abW = dpadW * 0.46
|
local abW = dpadW * 0.46
|
||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
local margin = dpadW * 0.12
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
|
||||||
if ok and GameVersion.generation and GameVersion.generation() == 2 then
|
|
||||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
|
||||||
end
|
|
||||||
return {
|
return {
|
||||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
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 },
|
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||||
|
|||||||
+13
-6
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
|
|||||||
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||||
w = num(vp[3], 1), h = num(vp[4], 1) }
|
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||||
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
page.pixelCoords = not page.normalized
|
page.pixelCoords = not page.normalized
|
||||||
@@ -447,7 +446,6 @@ function TouchSkin.parseNative(text)
|
|||||||
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
||||||
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||||
page.viewportFill = raw.viewport.fill == true
|
page.viewportFill = raw.viewport.fill == true
|
||||||
page.viewportExpand = raw.viewport.expand == true
|
|
||||||
end
|
end
|
||||||
for _, c in ipairs(raw.controls or {}) do
|
for _, c in ipairs(raw.controls or {}) do
|
||||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||||
@@ -526,7 +524,6 @@ function TouchSkin.toNative(skin)
|
|||||||
x = page.viewport.x, y = page.viewport.y,
|
x = page.viewport.x, y = page.viewport.y,
|
||||||
w = page.viewport.w, h = page.viewport.h,
|
w = page.viewport.w, h = page.viewport.h,
|
||||||
fill = page.viewportFill or nil,
|
fill = page.viewportFill or nil,
|
||||||
expand = page.viewportExpand or nil,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -922,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
|
|||||||
if page.viewport then
|
if page.viewport then
|
||||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
|
||||||
end
|
end
|
||||||
local controls = {}
|
local controls = {}
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -1434,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
|||||||
return best[1], best[2], best[3], best[4]
|
return best[1], best[2], best[3], best[4]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
|
||||||
|
-- that belongs to the game, so a screen rect flush with the top of the deck
|
||||||
|
-- grows into it instead of showing a black band.
|
||||||
|
local function deckHeadroom(y, vh, by, bh, oy, h)
|
||||||
|
if by <= oy + 0.5 then return y, vh end
|
||||||
|
if by + bh < oy + h - 0.5 then return y, vh end
|
||||||
|
if y - by > math.max(2, bh * 0.01) then return y, vh end
|
||||||
|
return oy, vh + (y - oy)
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
if not page then return nil end
|
if not page then return nil end
|
||||||
ox, oy = ox or 0, oy or 0
|
ox, oy = ox or 0, oy or 0
|
||||||
@@ -1443,12 +1449,13 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
|||||||
local x, y = bx + v.x * bw, by + v.y * bh
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
local vw, vh = v.w * bw, v.h * bh
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
|
||||||
|
return x, y, vw, vh, page.viewportFill == true
|
||||||
end
|
end
|
||||||
if page.screenFit == "remainder" then
|
if page.screenFit == "remainder" then
|
||||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
if not x then return nil end
|
if not x then return nil end
|
||||||
return x, y, vw, vh, false, false
|
return x, y, vw, vh, false
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user