mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92fd4221a0 | |||
| 07c828cab0 | |||
| b89771508e | |||
| 2c1c2fe080 | |||
| 6f4c59df8b | |||
| 1bd6e35931 | |||
| da73a4fe25 | |||
| cd4ef14f13 | |||
| d575a64287 | |||
| ff826ce01e | |||
| 0ab4ef2755 | |||
| 6f343d9b29 | |||
| 41790637f0 | |||
| 4ab0ac433b | |||
| 44c304574b | |||
| c8b05e7aa1 | |||
| 8c6c360ce0 | |||
| 6b6b8f46a1 | |||
| 2d62805128 | |||
| d27c59b18d | |||
| 1905261c5b | |||
| 333949ce0d | |||
| fb95dc5b6a | |||
| 96ed862b50 | |||
| 58681fe62e | |||
| 2ad2e028d1 | |||
| 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 | |||
| 44f4680b24 | |||
| 06e06e305b | |||
| 464fb47756 | |||
| fe3746fd1a | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| 738f7317d7 | |||
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 |
@@ -468,3 +468,24 @@ jobs:
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
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
|
||||
|
||||
@@ -23,6 +23,10 @@ read_globals = {
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
"POKEPORT_DISPLAY_COMPANION",
|
||||
"POKEPORT_EDITOR_MODE",
|
||||
"rawlen",
|
||||
package = { fields = { "searchers" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
@@ -30,6 +34,7 @@ exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
"tools/save_convert/vendor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
|
||||
+16
-14
@@ -140,8 +140,8 @@ ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||
overworld, script VM and save format. The mod API is shared across both
|
||||
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
|
||||
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
|
||||
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"`,
|
||||
`"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
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
||||
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
|
||||
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
|
||||
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
|
||||
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
|
||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||
have actually run your mod on Gold or Silver.
|
||||
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold, Silver or Crystal.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`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"`
|
||||
when you mean everywhere.
|
||||
|
||||
`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 43 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
|
||||
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
|
||||
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
Silver ROM is the only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||
Silver, or Crystal ROM is the only game content input.
|
||||
|
||||
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
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
|
||||
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
|
||||
(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
|
||||
|
||||
@@ -71,14 +73,16 @@ 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
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
|
||||
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
|
||||
game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||
- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||
|
||||
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
|
||||
@@ -122,24 +126,23 @@ supported out of the box.
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `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`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**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
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
your tilt/zoom preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
@@ -219,7 +222,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| 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 |
|
||||
| `--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 \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| 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 \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
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"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "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"
|
||||
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:-}"
|
||||
# Mali / H700: prefer GLES where available
|
||||
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 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).
|
||||
|
||||
### 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
|
||||
|
||||
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.joystick = not companion
|
||||
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
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
|
||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||
},
|
||||
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
||||
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||
},
|
||||
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
||||
-- itself, flavor line only.
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||
-- shape with SEEL, PlayCry at :89.
|
||||
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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
||||
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||
return {
|
||||
SS_ANNE_1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||
{"face_player"},
|
||||
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
||||
-- the flavor text is ported).
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||
return {
|
||||
SS_ANNE_B1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||
{ "face_player" },
|
||||
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,10 +18,11 @@ return {
|
||||
|
||||
-- VermilionCityMachopText: cries out, then follows up with a
|
||||
-- second line about stomping the land flat.
|
||||
-- (pokered/scripts/VermilionCity.asm)
|
||||
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||
TEXT_VERMILIONCITY_MACHOP = {
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
||||
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
||||
-- flavor line is ported.
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||
|
||||
return {
|
||||
VERMILION_PIDGEY_HOUSE = {
|
||||
talk = {
|
||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||
{"face_player"},
|
||||
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
||||
local t = game.data.text
|
||||
local back = function(text)
|
||||
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
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
+39
-25
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
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
|
||||
end,
|
||||
}
|
||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
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
|
||||
end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
@@ -377,26 +377,39 @@ M.VERMILION_CITY = {
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
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
|
||||
end,
|
||||
talk = {
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
-- scripts/VermilionCity.asm:158 (#1651)
|
||||
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
done))
|
||||
return
|
||||
end
|
||||
-- scripts/VermilionCity.asm:195
|
||||
local p = ow and ow.player
|
||||
if not p or p.facing == "right"
|
||||
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 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 9 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+25
-2
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
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
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
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
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
@@ -769,7 +792,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
+66
-71
@@ -2,6 +2,8 @@
|
||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||
-- and the Hall of Fame record. Each cites its pokered source.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -9,20 +11,30 @@ local M = {}
|
||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", flag }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "ask", askText }, -- 4
|
||||
{ "jump_if_false", 10 }, -- 5
|
||||
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||
refusedText, followText)
|
||||
local rows = {
|
||||
{ "face_player" },
|
||||
{ "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
|
||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||
{ "give_item", rodItem, 1, false }, -- 6
|
||||
{ "show_text", receivedText }, -- 7
|
||||
{ "set_flag", flag }, -- 8
|
||||
{ "jump", 10 }, -- 9 is below
|
||||
{ "give_item", rodItem, 1, false },
|
||||
{ "set_flag", flag },
|
||||
{ "show_text", receivedText },
|
||||
}
|
||||
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
|
||||
|
||||
M.VERMILION_OLD_ROD_HOUSE = {
|
||||
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||
"_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 = {
|
||||
talk = {
|
||||
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||
"_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 = {
|
||||
talk = {
|
||||
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
||||
"_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
|
||||
@@ -176,7 +187,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ 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 = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
local f = game.save.flags
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
-- 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
|
||||
ow.map:setBlock(b.bx, b.by, b.water)
|
||||
end
|
||||
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")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
end
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- 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)
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local Music = require("src.core.Music")
|
||||
Music.stop()
|
||||
Music.play(game.data, "Music_Surfing")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
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 }
|
||||
ow:queueScript({
|
||||
-- scripts/VermilionDock.asm:50 zeroes the player image index and
|
||||
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
|
||||
{ "face_player_dir", "down" },
|
||||
{ "wait", 120 },
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||
{ "ss_anne_departs" },
|
||||
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
{ "wait", 120 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- 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
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
{ "warp", "VERMILION_CITY", 18, 31, "up" },
|
||||
{ "move_player", "up", 2 },
|
||||
})
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -734,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
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))
|
||||
return true
|
||||
end,
|
||||
|
||||
+11
-8
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
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)
|
||||
return true
|
||||
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
||||
-- on the west-side cells and walks you back
|
||||
local function bikeGateGuard(coords, stopText, explainText)
|
||||
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
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (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
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
@@ -921,10 +922,12 @@ M.SS_ANNE_2F = {
|
||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
|
||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
|
||||
## 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
|
||||
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,
|
||||
|
||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
@@ -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"`,
|
||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gen2"` now expands to both Gold and Silver.
|
||||
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
|
||||
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
|
||||
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
|
||||
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
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
|
||||
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.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||
`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
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
@@ -539,7 +542,8 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
||||
`battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
@@ -549,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
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`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
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
@@ -561,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
|
||||
-- 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
|
||||
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.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
@@ -771,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:
|
||||
|
||||
- `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
|
||||
`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
|
||||
|
||||
+260
-10
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||
use the launcher's streaming large-file path rather than being materialized as
|
||||
one Lua string.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
because its manifest names the same digest. Small sources can still be read
|
||||
with the existing scoped `mod:read` API, for example
|
||||
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||
`mod.imports` facade described below; no host path or new general filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
#### Bounded access to validated imports
|
||||
|
||||
A loaded mod can address only ids declared by its own `required_imports` or
|
||||
`optional_imports` arrays:
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("stadium2")
|
||||
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||
```
|
||||
|
||||
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||
copy, and never gives the mod a host path or file handle. This is intended for
|
||||
large source formats whose table/index can be parsed with small reads before
|
||||
selectively reading the payloads a transform actually needs.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
@@ -209,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
@@ -246,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
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
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -441,6 +594,28 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Installation-scoped generated cache
|
||||
|
||||
Generated data derived from a validated user source often belongs to the mod
|
||||
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||
|
||||
```lua
|
||||
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||
local info = mod.cache:info("extract/v1/arena.bin")
|
||||
mod.cache:delete("extract/v1/arena.bin")
|
||||
```
|
||||
|
||||
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
||||
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
||||
The cache does not rewind with checkpoints and is not scoped to game version,
|
||||
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
||||
policy, and completion marker; the engine treats the bytes as opaque data.
|
||||
|
||||
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
||||
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
||||
from a declared user source.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
@@ -633,17 +808,51 @@ the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||
charge-capable move is selected for its initial turn and the active ruleset
|
||||
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||
skip only that initial charge and continue through the ordinary move pipeline;
|
||||
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||
by the engine. With no subscriber, the vanilla decision runs without building
|
||||
the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||
`--developer` on the command line:
|
||||
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 `--developer`
|
||||
on the command line:
|
||||
|
||||
```sh
|
||||
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`,
|
||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||
@@ -754,7 +963,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
|
||||
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
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
@@ -805,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
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
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
|
||||
@@ -14,9 +14,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||
* **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**
|
||||
* **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
|
||||
|
||||
* **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`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
||||
|
||||
| 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) |
|
||||
| `"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.
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
||||
|
||||
## Motivation
|
||||
|
||||
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
||||
the existing launcher and public mod API still assume imported bytes are small:
|
||||
|
||||
* the Windows desktop picker stages a selected required import through a fixed
|
||||
`%TEMP%/pokeport_required_import.bin` path before validation;
|
||||
* the fallback import path materializes the selected file as one Lua string;
|
||||
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
||||
materializes the whole file;
|
||||
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
||||
not an appropriate home for a one-time generated asset cache shared by every
|
||||
save using the same installed mod.
|
||||
|
||||
This makes optical-disc-sized user sources impractical even though the manifest
|
||||
schema already accepts them. A failed temporary staging copy can also turn a
|
||||
valid large source into a smaller temporary file and produce a misleading
|
||||
"wrong file size" rejection.
|
||||
|
||||
A mod should be able to consume its own already-validated source incrementally
|
||||
and compile derived runtime data once, without receiving a host path or general
|
||||
filesystem access.
|
||||
|
||||
## Decision being extended
|
||||
|
||||
This extends the same legal/sandbox direction as **D11 asset transforms**
|
||||
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
||||
user-owned sources rather than shipping ROM-derived data. It also follows the
|
||||
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
||||
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
||||
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
||||
the same change.
|
||||
|
||||
The historical D11 plan document is referenced by source comments but is not
|
||||
present in the current repository tree; this RFC is the checked-in design
|
||||
record for the new surface.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
No manifest field changes. Existing `required_imports` and `optional_imports`
|
||||
remain the declaration/validation authority.
|
||||
|
||||
Two additive facades are added to the `mod` object.
|
||||
|
||||
### `mod.imports`
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("source_id")
|
||||
local bytes, err = mod.imports:read("source_id", offset, length)
|
||||
```
|
||||
|
||||
* `source_id` must name an import declared by the calling mod.
|
||||
* the import is rechecked through `RequiredImports.validateStored` before it is
|
||||
exposed, so missing, replaced, or invalid optional imports are not readable;
|
||||
* `offset` and `length` are zero-based byte coordinates;
|
||||
* one read is capped at 8 MiB;
|
||||
* no host path or file handle is returned;
|
||||
* production reads seek into the engine-owned stored copy instead of reading
|
||||
the whole source.
|
||||
|
||||
`info()` returns declaration metadata plus stored size. It does not expose a
|
||||
host path.
|
||||
|
||||
### `mod.cache`
|
||||
|
||||
```lua
|
||||
mod.cache:write("extract/v1/model.bin", bytes)
|
||||
local bytes = mod.cache:read("extract/v1/model.bin")
|
||||
local info = mod.cache:info("extract/v1/model.bin")
|
||||
mod.cache:delete("extract/v1/model.bin")
|
||||
```
|
||||
|
||||
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
||||
backend, and is independent of game version, launcher slot, and playthrough.
|
||||
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
||||
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
||||
generated datasets are naturally split into independently replaceable files.
|
||||
|
||||
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
||||
fingerprints, transactional completion markers, and rebuild policy.
|
||||
|
||||
## Launcher/import transport delta
|
||||
|
||||
For large raw required imports:
|
||||
|
||||
1. desktop pickers return the original selected path instead of staging it
|
||||
through a fixed temporary file;
|
||||
2. the engine opens that source itself;
|
||||
3. bytes are copied directly to the existing engine-owned
|
||||
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
||||
4. MD5 is updated incrementally during the copy;
|
||||
5. the normal size/MD5 validation receipt is written only after the complete
|
||||
destination passes validation;
|
||||
6. partial destinations are removed on short reads, write failure, size
|
||||
mismatch, or digest mismatch.
|
||||
|
||||
N64 imports stay on the existing canonicalization path because byte-order and
|
||||
copier-header normalization require transformation rather than a raw copy.
|
||||
|
||||
If a validation receipt for an already-stored large raw import is missing, the
|
||||
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
||||
|
||||
## Backward compatibility / migration
|
||||
|
||||
**Existing mods do nothing.** This is additive.
|
||||
|
||||
* manifest v1/v2 fields are unchanged;
|
||||
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
||||
retain their existing behavior;
|
||||
* small required imports retain the existing in-memory validation path;
|
||||
* N64 imports retain canonicalization and existing accepted byte orders;
|
||||
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
||||
files and observes no new behavior.
|
||||
|
||||
The mod API integer is not bumped because no existing member changes meaning or
|
||||
shape.
|
||||
|
||||
## Security and legal posture
|
||||
|
||||
The launcher remains the authority that validates user-supplied bytes. The new
|
||||
facade narrows access rather than widening it: a mod can read only ids declared
|
||||
in its own manifest, only after validation, and only in bounded ranges. It does
|
||||
not receive host paths, `io`, or a raw filesystem handle.
|
||||
|
||||
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
||||
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
||||
continue to enforce the existing legal posture.
|
||||
|
||||
## Parity guarantee
|
||||
|
||||
The change ships with:
|
||||
|
||||
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
||||
`mod:read` load do not create cache data or change the old surface;
|
||||
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
||||
real `Loader` load, including bounded reads, undeclared/missing imports,
|
||||
cache isolation, and traversal rejection;
|
||||
* incremental MD5 vectors and a large-import streaming regression test;
|
||||
* the existing engine suite, required-import suite, and mod lint gates.
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0011: Charge-required battle hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A battle-mechanics mod can change damage through `battle.damage` and register
|
||||
move effects, but it cannot conditionally skip the first turn of an existing
|
||||
charge move. In Gen 1, the engine decides and stores the charge continuation
|
||||
before any public effect callback can run. Reaching into `user.charging`,
|
||||
`user.chargeReady`, or generation-specific volatile state is private,
|
||||
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
||||
|
||||
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
||||
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
||||
and secondary effects to the engine. The capability is generic and useful to
|
||||
other ruleset and move-mechanics mods.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
||||
generic guarded engine decision seam**. 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 follows the additive, guarded hook convention documented by
|
||||
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
||||
Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
||||
-- ctx = {
|
||||
-- battle = live battle controller,
|
||||
-- user = attacking battler,
|
||||
-- target = defending battler,
|
||||
-- move = merged move record,
|
||||
-- charge = true,
|
||||
-- isCalled = false,
|
||||
-- }
|
||||
if should_resolve_now(ctx) then return false end
|
||||
return next(ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
The call site is the initial-use charge decision, after announcement and PP
|
||||
handling but before charge state, invulnerability, charge animation, or charge
|
||||
text is created. It runs only when the active engine rules would otherwise
|
||||
require a charge. It does not run on the release turn. Returning exactly
|
||||
`false` skips that initial charge and continues through the engine-owned move
|
||||
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
||||
when Metronome or Mirror Move selected the move.
|
||||
|
||||
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
||||
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
||||
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
||||
mod-compatibility rules continue to govern deterministic peers.
|
||||
|
||||
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
||||
subscriber, no hook payload table is allocated and the existing branch runs
|
||||
unchanged.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. The hook name and payload are additive. With no
|
||||
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
||||
charge state, PP use, text, animation, accuracy, damage, and native weather
|
||||
behavior. Existing charge-move data and effect records require no migration.
|
||||
|
||||
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
||||
skip this charge. It should not mutate private charge fields or re-run the move.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
||||
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
||||
release-turn behavior, called-move PP semantics, shared payload shape, and
|
||||
native Gold sun behavior.
|
||||
- The same test proves no-mod charge/release parity and replaces
|
||||
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
||||
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
||||
chains preserve vanilla values and allocation behavior.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
||||
generations and keeps the compatibility reference list complete.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -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,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,673 @@
|
||||
# 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.
|
||||
|
||||
### 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. Android ships the `.so` via `jniLibs`.
|
||||
- **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.
|
||||
+64
-25
@@ -1,11 +1,11 @@
|
||||
# Touch skins and the Skin Studio
|
||||
|
||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
control layout, and a screen-placement anchor. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/skin_studio_ux.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
`tests/engine/launcher_skins_tab.lua`,
|
||||
`tests/engine/launcher_skins_ux.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||
grid, pagination, edit, delete and per-skin export actions.
|
||||
`options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
@@ -30,7 +32,7 @@ as-is. Supported keys:
|
||||
| `overlays` | page count |
|
||||
| `overlayN_name` | page name, the target of `next_target` |
|
||||
| `overlayN_overlay` | bezel image |
|
||||
| `overlayN_full_screen` | stretch the page to the window |
|
||||
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||
stretch to the window the way Delta does. Host functions map to
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||
game into it. Host functions map to
|
||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
|
||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||
how a skin button reaches a mod hotkey.
|
||||
|
||||
## The screen viewport
|
||||
## Screen placement
|
||||
|
||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||
|
||||
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||
the world, and the start menu stays at the hole's fit scale instead of
|
||||
shrinking with the map.
|
||||
|
||||
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||
as a controller deck: it is contained without deformation and pinned to the
|
||||
bottom on taller screens. The spare space belongs to the game above it.
|
||||
|
||||
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||
skin rather than the normal Center / Upper / Top setting.
|
||||
|
||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||
channel and writes the rect.
|
||||
@@ -153,10 +166,8 @@ channel and writes the rect.
|
||||
## Bezels versus pads
|
||||
|
||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||
them. Anything that binds a button still follows the usual mobile /
|
||||
`POKEPORT_TOUCH` rule.
|
||||
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||
**desktop** as well as mobile; a gamepad does not hide them.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -190,9 +201,32 @@ shipping branding.
|
||||
|
||||
## The studio
|
||||
|
||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||
because it wants a mouse, typed coordinates and room for an inspector.
|
||||
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||
and import actions, with each card owning Edit, Export and (for installed
|
||||
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||
|
||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||
window size so a phone skin is authored at that phone's form factor rather
|
||||
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||
workspace so the screen hole can be dragged larger than the bezel while the
|
||||
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||
same controls that a mouse edits on desktop.
|
||||
|
||||
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||
status bar, home indicator), the same inset the launcher uses. The mock
|
||||
device still represents the full window, because a skin covers the whole
|
||||
screen at play time.
|
||||
|
||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||
is not itself a skin card.
|
||||
|
||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||
phone proportions on a desktop monitor.
|
||||
@@ -200,6 +234,7 @@ phone proportions on a desktop monitor.
|
||||
| Preset | Size |
|
||||
| --- | --- |
|
||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||
| This screen | the live window, so a phone is authored at its own height |
|
||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||
| Steam Deck | 1280x800 |
|
||||
| Desktop 1080p | 1920x1080 |
|
||||
@@ -216,13 +251,16 @@ and of the page itself when it comes within a few pixels, and the guide it
|
||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||
shape is freeform, with an optional 10:9 lock.
|
||||
|
||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||
how a pipe bind like `left|down` is built without typing it.
|
||||
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||
which is how a pipe bind like `left|down` is built without typing it.
|
||||
|
||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||
@@ -247,12 +285,13 @@ the **Import** button there and beside each row opens the host file picker (`src
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||
the art's alpha.
|
||||
|
||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||
**Play** saves the skin, selects it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
|
||||
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| 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 |
|
||||
| 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** |
|
||||
| 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 exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export 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\|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` |
|
||||
| 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
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver>/`
|
||||
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver|crystal>/`
|
||||
path the launcher prints.
|
||||
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
|
||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||
Installer. The installer asks the user for consent and enforces package,
|
||||
version-code, and signing-certificate compatibility. 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.
|
||||
version-code, and signing-certificate compatibility. A legacy APK without
|
||||
the installer bridge links its full package for one manual bootstrap
|
||||
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
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
if defaultErrorHandler then
|
||||
@@ -82,6 +82,11 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
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
|
||||
-- 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
|
||||
@@ -157,9 +162,7 @@ local function openEditor(version, slotId)
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -179,10 +182,7 @@ local function openEditor(version, slotId)
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -197,21 +197,14 @@ end
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
-- 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
|
||||
-- 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()
|
||||
local version = editorVersion
|
||||
local app = EditorApp
|
||||
editorMode = false
|
||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
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
|
||||
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -294,14 +287,16 @@ end
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
return RomImporter.new(function(version, cartId)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
bootGame(version, cartId)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||
-- Keep the compatibility predicate so external hosts using it still work.
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
@@ -310,45 +305,19 @@ end
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
SessionLifecycle.endGameSession(Game)
|
||||
Game = nil
|
||||
autopilot = 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")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
SessionLifecycle.endMountedSession(currentVersion)
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -364,7 +333,7 @@ local function returnToLauncher()
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
function bootGame(version, cartId)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||
@@ -377,6 +346,25 @@ function bootGame(version)
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
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
|
||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||
pcall(function()
|
||||
@@ -692,7 +680,7 @@ function love.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
@@ -712,7 +700,7 @@ function love.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
@@ -732,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
@@ -752,7 +740,7 @@ function love.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:joystickpressed(joystick, button)
|
||||
@@ -772,7 +760,7 @@ function love.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:joystickreleased(joystick, button)
|
||||
@@ -792,7 +780,7 @@ function love.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
@@ -812,7 +800,7 @@ function love.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
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 not Game then return end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
@@ -877,6 +865,8 @@ love.handlers = love.handlers or {}
|
||||
function love.handlers.audiosuspend()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.audioreset()
|
||||
@@ -929,7 +919,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchpressed(id, x, y) end
|
||||
if Importer then
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
@@ -946,7 +936,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchmoved(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -960,7 +950,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchreleased(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -1013,7 +1003,12 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousepressed(x, y, button) end
|
||||
if Studio then
|
||||
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||
-- Studio consumes the real finger stream above, so discard the twin.
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
@@ -1048,7 +1043,10 @@ function love.mousereleased(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousereleased(x, y, button) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousereleased(x, y, button)
|
||||
end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -1066,7 +1064,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if Studio then return Studio.mousemoved(x, y) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
@@ -1138,22 +1139,7 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- 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
|
||||
SessionLifecycle.endProcess()
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<!-- Low latency audio -->
|
||||
<uses-feature android:name="android.hardware.audio.low_latency" 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
|
||||
android:allowBackup="true"
|
||||
@@ -54,7 +58,7 @@
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:label="${NAME}"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
@@ -71,7 +75,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
@@ -12,6 +12,55 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"date": "2026-08-21",
|
||||
"size": 13769300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.15",
|
||||
"date": "2026-08-21",
|
||||
"size": 13753007,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.14",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749372,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.13",
|
||||
"date": "2026-08-20",
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lovestatic.lib",
|
||||
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
|
||||
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lua51.lib",
|
||||
|
||||
+33
-3
@@ -79,7 +79,7 @@ else
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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/*')
|
||||
fi
|
||||
# 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 \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
@@ -167,6 +168,31 @@ make_ico() { # $1 = output .ico path
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- 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() {
|
||||
say "building macOS app"
|
||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
||||
@@ -179,6 +205,7 @@ build_mac() {
|
||||
# drop any bundled placeholder .love and fuse ours in
|
||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
|
||||
|
||||
local plist="$out_app/Contents/Info.plist"
|
||||
/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"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
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
|
||||
|
||||
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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
|
||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
# 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]
|
||||
# [--test-application-id ID]
|
||||
#
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --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:
|
||||
# - 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}"
|
||||
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}"
|
||||
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=""
|
||||
PACKAGE_ONLY=false
|
||||
RELEASE=false
|
||||
TEST_APPLICATION_ID=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,11 +62,12 @@ while [ $# -gt 0 ]; do
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--test-application-id) TEST_APPLICATION_ID="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
sed -n '2,28p' "$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
|
||||
shift
|
||||
done
|
||||
@@ -84,6 +99,14 @@ if $RELEASE; then
|
||||
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||
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
|
||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -312,6 +383,7 @@ pack_game_love() {
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
ensure_silver_manifest
|
||||
ensure_crystal_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# 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 \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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 'data/generated/*' -x 'assets/generated/*')
|
||||
# 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"
|
||||
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||
|| 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
|
||||
# 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
|
||||
|
||||
+21
-2
@@ -7,7 +7,8 @@
|
||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||
#
|
||||
# scripts/lint.sh lint src/
|
||||
# scripts/lint.sh 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
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
@@ -15,9 +16,27 @@
|
||||
set -uo pipefail
|
||||
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
|
||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||
exit 2
|
||||
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"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||
|| 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
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
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"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||
|| 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"
|
||||
|
||||
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.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/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(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_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_crystal.json \
|
||||
src/ui/kit/Kit.lua \
|
||||
src/import/LauncherView.lua; do
|
||||
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/yellow/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"
|
||||
fi
|
||||
|
||||
|
||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces the NRO(s) and these help files.
|
||||
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/
|
||||
then use Scan again in the launcher if needed.
|
||||
|
||||
Inboxes (drop files here via MTP / SD / FTP):
|
||||
imports/ — ROM .gb / .gbc
|
||||
imports/mods/ — community mod .zip
|
||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
||||
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver 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|crystal/ - pull after Export save (Gen 2 not yet)
|
||||
|
||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||
EOF
|
||||
@@ -92,7 +92,7 @@ write_readme() {
|
||||
}
|
||||
|
||||
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" \
|
||||
"Put community mod .zip files here, then MODS → Scan again."
|
||||
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."
|
||||
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."
|
||||
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" \
|
||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
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."
|
||||
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||
"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"
|
||||
(
|
||||
@@ -145,11 +149,13 @@ REQUIRED=(
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/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/crystal/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||
)
|
||||
for rel in "${REQUIRED[@]}"; do
|
||||
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/gold/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/blue/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/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
|
||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||
done
|
||||
|
||||
+68
-5
@@ -5,8 +5,8 @@
|
||||
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
||||
# nothing but the committed fixture dataset, so they run anywhere --
|
||||
# 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
|
||||
# been imported rather than failing the run.
|
||||
# data/generated/ or an imported Red cache, so it is skipped automatically
|
||||
# when neither exists rather than failing the run.
|
||||
#
|
||||
# scripts/test.sh every tier this checkout can run
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -32,7 +39,7 @@ for arg in "$@"; do
|
||||
--bless) BLESS=1 ;;
|
||||
--bless-shots) SHOTS=1; BLESS=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 ;;
|
||||
esac
|
||||
done
|
||||
@@ -49,6 +56,36 @@ fi
|
||||
# save-editor suite pins that to the default identity.
|
||||
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=()
|
||||
run_tier() {
|
||||
local label="$1"; shift
|
||||
@@ -66,8 +103,18 @@ run_tier() {
|
||||
|
||||
# ------- 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 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 transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# 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 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
|
||||
# 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 title checkpoint cold restart" \
|
||||
bash tests/integration/title_checkpoint_cold_start.sh
|
||||
@@ -124,7 +174,19 @@ run_content_behavior() {
|
||||
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
|
||||
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
|
||||
echo ""
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
@@ -154,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
|
||||
echo " import a ROM to run them; the modded-link cases ran in T4)"
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
|
||||
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
|
||||
|
||||
# ------- golden screenshots: needs love + a display
|
||||
|
||||
@@ -348,6 +348,12 @@ local EMITTERS = {
|
||||
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`,
|
||||
-- with the subanimation transform applied (DrawFrameBlock).
|
||||
local function placeTile(transform, bc, t, tileset)
|
||||
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local obp0Flip = false
|
||||
|
||||
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
|
||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||
-- the row starts (GetMoveSound)
|
||||
|
||||
+158
-7
@@ -538,6 +538,7 @@ end
|
||||
|
||||
local function makeBattler(data, mon, isPlayer, save)
|
||||
local def = data.pokemon[mon.species]
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||
local badges = nil
|
||||
if isPlayer and save then
|
||||
@@ -637,6 +638,9 @@ local function newBattle(game)
|
||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||
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
|
||||
-- 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
|
||||
@@ -1917,6 +1921,8 @@ function BattleState:exit()
|
||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||
-- field party menu comes back on slot 1 after a battle. #768
|
||||
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
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
@@ -1941,6 +1947,7 @@ local function clearTrapping(battler)
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
battler.trapHitSfx = nil
|
||||
end
|
||||
|
||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||
@@ -2791,6 +2798,111 @@ function BattleState:queueResidual(b, opp)
|
||||
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()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
@@ -2844,6 +2956,7 @@ function BattleState:endOfTurn()
|
||||
b.trappingTurns = nil
|
||||
end
|
||||
end
|
||||
self:applyFieldResiduals()
|
||||
self:tickTokens()
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
end
|
||||
@@ -3980,7 +4093,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||
-- ChargeEffect sets INVULNERABLE for both)
|
||||
if record and record.charge and not releasing then
|
||||
local chargeRequired = record and record.charge ~= nil and not releasing
|
||||
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
|
||||
local required = Runtime.call("battle.charge_required", function(c)
|
||||
return c.charge
|
||||
end, {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
charge = true, isCalled = isCalled or false,
|
||||
})
|
||||
chargeRequired = required ~= false
|
||||
end
|
||||
if chargeRequired then
|
||||
self:cancelMoveAnim()
|
||||
user.charging = moveInst
|
||||
user.chargeReady = true
|
||||
@@ -4059,12 +4182,15 @@ function BattleState:continueTrapping(user, target)
|
||||
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||
-- animation replays each locked turn (same damage, animation shown).
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
|
||||
-- OPTIONS animation toggle.
|
||||
if user.trapMove and self:animationsOn() then
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), with the
|
||||
-- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 /
|
||||
-- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653).
|
||||
if user.trapMove then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
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
|
||||
-- the counter can sit at 0 until the END of the turn: the trapping
|
||||
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
|
||||
@@ -4239,7 +4365,7 @@ function BattleState:awardExp()
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
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;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
if #levels > 0 then
|
||||
@@ -4616,6 +4742,14 @@ function BattleState:playerMonFainted()
|
||||
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||
if not nextMon and self.result ~= "lose" 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 raw = (self.data.text and self.data.text._Rival1WinText)
|
||||
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
||||
@@ -5205,6 +5339,14 @@ function BattleState:finish()
|
||||
self.phase = "messages"
|
||||
return
|
||||
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
|
||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||
@@ -5245,6 +5387,13 @@ function BattleState:finish()
|
||||
local result = self.result or "run"
|
||||
local onFinish = self.onFinish
|
||||
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
|
||||
if onFinish then onFinish(result) end
|
||||
return
|
||||
@@ -6109,8 +6258,10 @@ function BattleState:drawHUDs(slide)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
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
|
||||
and slide == 0 then
|
||||
and slide == 0 and not self.player.fainted then
|
||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||
-- (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)
|
||||
|
||||
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
local critPending, ohkoPending = info.crit, info.ohko
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
|
||||
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
|
||||
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
|
||||
if critPending then
|
||||
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
|
||||
critPending = false
|
||||
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
|
||||
-- .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
|
||||
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||
ctx.hitSfx = hitSfx
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
|
||||
@@ -574,6 +574,8 @@ MoveEffects.full = {
|
||||
local r = ctx.rng(0, 7)
|
||||
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
||||
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
|
||||
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
||||
user.trapMove = ctx.move.id
|
||||
@@ -645,7 +647,9 @@ MoveEffects.full = {
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
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)))
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -161,8 +161,10 @@ local function drawHUDs(battle, slide)
|
||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||
-- 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
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
+258
-44
@@ -16,7 +16,7 @@
|
||||
--
|
||||
-- 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
|
||||
-- from 1-7 turns.
|
||||
-- from 2-7 turns.
|
||||
|
||||
local Damage = require("src.battle.gen2.Damage")
|
||||
local Ai = require("src.battle.gen2.Ai")
|
||||
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
||||
-- be run from or Roared away.
|
||||
Battle.BATTLETYPE_FORCESHINY = 7
|
||||
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
|
||||
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||
Battle.BATTLETYPE_CANLOSE = 1
|
||||
@@ -247,6 +250,13 @@ function Battle.new(opts)
|
||||
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
|
||||
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
|
||||
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.turn = 0
|
||||
self.over = false
|
||||
@@ -765,6 +775,9 @@ end
|
||||
function Battle:battleStat(mon, key)
|
||||
local value = (mon.stats or {})[key] or 1
|
||||
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]
|
||||
if badge and self:hasBadge("badges", badge) then
|
||||
return Battle.boostStat(value)
|
||||
@@ -783,6 +796,8 @@ end
|
||||
-- boosts the damage. Each type appears once, so this is a plain scan.
|
||||
function Battle:badgeTypeBoost(attacker, moveType)
|
||||
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
|
||||
if row.type == moveType then
|
||||
return self:hasBadge(row.store, row.badge)
|
||||
@@ -903,8 +918,16 @@ function Battle:movePriority(moveId)
|
||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||
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.
|
||||
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)
|
||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||
@@ -925,7 +948,11 @@ function Battle:canAct(mon)
|
||||
local beforeMove = record and record.beforeMove
|
||||
if beforeMove
|
||||
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
|
||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||
@@ -959,6 +986,14 @@ function Battle:canAct(mon)
|
||||
return true
|
||||
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
|
||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||
@@ -1234,10 +1269,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
||||
if opts.critical then
|
||||
self:emit({ kind = "message", text = "A critical hit!" })
|
||||
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
|
||||
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
|
||||
self:emit({ kind = "message", text = "It's not very effective..." })
|
||||
self:emit({ kind = "message",
|
||||
text = Strings("It's not very\neffective…") })
|
||||
end
|
||||
if endured then
|
||||
self:emit({ kind = "message",
|
||||
@@ -1389,9 +1429,18 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
-- free of PP and obedience in exactly the same way.
|
||||
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
|
||||
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
|
||||
end
|
||||
if move then move.pp = (move.pp or 1) - 1 end
|
||||
@@ -1471,9 +1520,38 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
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,
|
||||
-- Encore and Disable.
|
||||
state.lastMove = moveId
|
||||
-- Encore and Disable. A called move skips the write
|
||||
-- (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.usedMoves = state.usedMoves or {}
|
||||
local seen = false
|
||||
@@ -1494,6 +1572,15 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
||||
charge = nil
|
||||
end
|
||||
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
|
||||
local required = Runtime.call("battle.charge_required", function(c)
|
||||
return c.charge
|
||||
end, {
|
||||
battle = self, user = attacker, target = defender, move = def,
|
||||
charge = true, isCalled = (self.copyDepth or 0) > 0,
|
||||
})
|
||||
if required == false then charge = nil end
|
||||
end
|
||||
if charge and not charging then
|
||||
state.chargeMove = moveId
|
||||
state.vanished = charge.vanish or nil
|
||||
@@ -1507,6 +1594,13 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
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,
|
||||
-- and fail outright when nothing of the right kind landed.
|
||||
local counterKind = Effects.COUNTER[def.effect]
|
||||
@@ -1738,8 +1832,12 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
landed = landed + 1
|
||||
end
|
||||
if landed > 1 then
|
||||
self:emit({ kind = "message",
|
||||
text = ("Hit %d time(s)!"):format(landed) })
|
||||
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
||||
-- 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
|
||||
|
||||
-- move_effects/pay_day.asm:13
|
||||
@@ -1959,8 +2057,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
||||
if mine.perish and theirs.perish then return fail(self) end
|
||||
if not mine.perish then mine.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",
|
||||
text = "All POKéMON hearing the song will faint in three turns!" })
|
||||
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
||||
end
|
||||
|
||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||
@@ -2090,6 +2191,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
||||
local side = self:sideOf(defender)
|
||||
if self.spikes[side] then return fail(self) end
|
||||
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!" })
|
||||
end
|
||||
|
||||
@@ -2124,6 +2230,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
if not state.bideTurns then
|
||||
state.bideTurns = Effects.bideTurns(self.random)
|
||||
state.bideStored = 0
|
||||
-- engine/battle/core.asm:574-576
|
||||
state.bideMove = moveId
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " is storing energy!" })
|
||||
return
|
||||
@@ -2135,7 +2243,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
return
|
||||
end
|
||||
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",
|
||||
text = self:monName(attacker) .. " unleashed energy!" })
|
||||
if damage <= 0 then return fail(self) end
|
||||
@@ -2325,7 +2433,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
||||
{ move = def, moveId = def and def.id })
|
||||
landed = landed + 1
|
||||
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
|
||||
|
||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||
@@ -2362,7 +2472,7 @@ end
|
||||
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
|
||||
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
|
||||
-- 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)
|
||||
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
|
||||
if (attacker.hp or 0) >= maxHp then
|
||||
@@ -2371,7 +2481,9 @@ for effect in pairs(Effects.SUN_HEAL) do
|
||||
text = self:monName(attacker) .. "'s HP is full!" })
|
||||
return
|
||||
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:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " regained health!" })
|
||||
@@ -2408,6 +2520,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
||||
self.enemyIndex = target
|
||||
self.enemy = party[target]
|
||||
self.enemy.volatile = carried
|
||||
-- engine/battle/move_effects/baton_pass.asm:59
|
||||
self:resetParticipants()
|
||||
end
|
||||
local sent = side == "player" and self.player or self.enemy
|
||||
self:emit({ kind = "send", side = side, mon = sent,
|
||||
@@ -2670,6 +2784,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
||||
self.enemyIndex = pick
|
||||
self.enemy = incoming
|
||||
self.stages.enemy = Battle.newStages()
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
||||
self:resetParticipants()
|
||||
end
|
||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||
hp = incoming.hp or 0, status = incoming.status or false,
|
||||
@@ -2814,9 +2930,12 @@ Battle.STATUSES = {
|
||||
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
|
||||
inflictText = " fell asleep!",
|
||||
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)
|
||||
mon.statusTurns = rand(battle.random, 7) + 1
|
||||
mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2
|
||||
end,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battle, mon, name)
|
||||
@@ -3079,6 +3198,12 @@ end
|
||||
|
||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||
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
|
||||
self:emit({ kind = "faint", side = "enemy",
|
||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||
@@ -3263,7 +3388,16 @@ end
|
||||
-- `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
|
||||
-- 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
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||
@@ -3314,10 +3448,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
index = index,
|
||||
})
|
||||
end
|
||||
if not silent then
|
||||
self:emit({ kind = "experience", index = index, amount = amount,
|
||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||
text = self:monName(mon) .. " gained "
|
||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||
end
|
||||
if result.levels > 0 then
|
||||
-- "level up happiness mod", the cart's own comment, sitting right
|
||||
-- after the stat recalc and before the "grew to level" text. It fires
|
||||
@@ -3332,7 +3468,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
local moveDef = self:moveDef(moveId)
|
||||
local moveName = (moveDef and moveDef.name) or moveId
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||
elseif reason == "full" then
|
||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||
@@ -3411,22 +3549,38 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- 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
|
||||
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
||||
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
||||
-- 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`,
|
||||
-- `holders` and `halved` are the Gen 2 additions.
|
||||
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
||||
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
||||
--
|
||||
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
||||
-- 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
|
||||
local alive = {}
|
||||
for _, index in ipairs(participants) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon 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
|
||||
if candidate == mon then
|
||||
return self:giveExperiencePass(loser, def, { index },
|
||||
math.max(1, split or 1), halved)
|
||||
math.max(1, split or 1), halved, silent)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3441,8 +3595,7 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
self:resetParticipants()
|
||||
end
|
||||
|
||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||
@@ -3463,7 +3616,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
||||
end
|
||||
self:emit({ kind = "message",
|
||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned "
|
||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||
-- The forget path writes the slot itself rather than going through
|
||||
@@ -3502,6 +3657,13 @@ function Battle:switchLocked()
|
||||
return self:volatile(self.enemy).trapsTarget == true
|
||||
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
|
||||
-- (engine/battle/core.asm:2959-2961).
|
||||
function Battle:shiftSwitch(index)
|
||||
@@ -3524,6 +3686,7 @@ function Battle:switch(index)
|
||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||
-- announce its own faint again; see resolveFaints.
|
||||
self.faintAnnounced = nil
|
||||
self.participantsCleared = nil
|
||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||
self.pendingSwitch = nil
|
||||
self.player = mon
|
||||
@@ -3761,19 +3924,41 @@ function Battle:lockedInMove(mon)
|
||||
return nil
|
||||
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
|
||||
-- (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)
|
||||
local locked = self:lockedInMove(mon)
|
||||
if locked then return locked end
|
||||
local state = self:volatile(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
|
||||
-- Encore ends early when the move runs out of PP.
|
||||
state.encore, state.encoreTurns = nil, nil
|
||||
return nil
|
||||
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||
-- (engine/battle/core.asm:561-566).
|
||||
local encored = encoredMove(self:volatile(mon), mon)
|
||||
if encored then return encored end
|
||||
return self:fightLockedMove(mon)
|
||||
end
|
||||
|
||||
function Battle:moveDisabled(mon, moveId)
|
||||
@@ -3787,8 +3972,9 @@ function Battle:usableMoves(mon)
|
||||
-- .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
|
||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||
-- ends it the moment the encored move runs dry.
|
||||
local locked = self:lockedInMove(mon)
|
||||
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||
local out = {}
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||
@@ -3799,6 +3985,16 @@ function Battle:usableMoves(mon)
|
||||
return out
|
||||
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
|
||||
-- based on the speed ratio and how many times you have tried this battle.
|
||||
-- Trainers never let you run.
|
||||
@@ -3812,8 +4008,7 @@ function Battle:tryRun(pSpd)
|
||||
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
|
||||
-- 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.
|
||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||
or self.battleType == Battle.BATTLETYPE_TRAP then
|
||||
if self:noEscapeBattleType() then
|
||||
self:emit({ kind = "message", text = "Can't escape!" })
|
||||
self.runRefused = true
|
||||
return false
|
||||
@@ -3990,6 +4185,8 @@ function Battle:enemyTrySwitchOrItem()
|
||||
.. self:monName(outgoing) .. "!" })
|
||||
self.enemyIndex = target
|
||||
self.enemy = self.enemyParty[target]
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697)
|
||||
self:resetParticipants()
|
||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||
@@ -4080,9 +4277,16 @@ function Battle:vanillaEnemyMove()
|
||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||
-- 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.
|
||||
local charged = self:volatile(self.enemy).chargeMove
|
||||
local enemyState = self:volatile(self.enemy)
|
||||
local charged = enemyState.chargeMove
|
||||
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.
|
||||
local moves = self:usableMoves(self.enemy)
|
||||
if #moves == 0 then
|
||||
@@ -4176,6 +4380,7 @@ local function runTurn(self, action)
|
||||
-- (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.
|
||||
if self.runRefused then return self:takeEvents() end
|
||||
self:cancelBide(self.player)
|
||||
action = { kind = "skip" }
|
||||
end
|
||||
|
||||
@@ -4191,6 +4396,9 @@ local function runTurn(self, action)
|
||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||
Happiness.change(self.player, "USEDXITEM")
|
||||
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
|
||||
-- to rotate or drink a potion spends its whole turn on it.
|
||||
@@ -4239,7 +4447,6 @@ local function runTurn(self, action)
|
||||
|
||||
local function playerAttack()
|
||||
if action.kind ~= "move" then return end
|
||||
if not self:canAct(self.player) then return end
|
||||
local move = action.move
|
||||
-- An encored mon has no choice, whatever the menu said.
|
||||
local forced = self:forcedMove(self.player)
|
||||
@@ -4259,13 +4466,20 @@ local function runTurn(self, action)
|
||||
-- player's own mon spends the rest of the battle underground.
|
||||
local stored = self:volatile(self.player).chargeMove
|
||||
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
|
||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||
-- Struggle substitution and the obedience roll the same way the second
|
||||
-- half of a charge move is.
|
||||
local charging = self:volatile(self.player).chargeMove == 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",
|
||||
text = self:monName(self.player) .. " has no moves left!" })
|
||||
move = Battle.STRUGGLE
|
||||
@@ -4303,7 +4517,7 @@ local function runTurn(self, action)
|
||||
-- against a trainer, could not be escaped either.
|
||||
enemyMoveId = Battle.STRUGGLE
|
||||
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):
|
||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
-- 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).
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local Catching = {}
|
||||
|
||||
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
|
||||
return bonuses[status] or 0
|
||||
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).
|
||||
-- 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
|
||||
-- records carry that as `category`.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_constants.asm:78
|
||||
Damage.MAX_STAT_VALUE = 999
|
||||
|
||||
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
||||
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
||||
|
||||
@@ -60,7 +65,23 @@ end
|
||||
function Damage.applyStage(value, stage)
|
||||
local numerator, denominator = Damage.stageMultiplier(stage)
|
||||
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
|
||||
|
||||
-- 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
|
||||
-- screen -- Reflect/Light Screen active on the defender
|
||||
-- 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:
|
||||
-- effectiveness (x10), critical, physical, variation.
|
||||
@@ -204,6 +227,12 @@ function Damage.calc(opts)
|
||||
defense = defense * 2
|
||||
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
|
||||
-- Explosion halve the defence, never below 1.
|
||||
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
||||
|
||||
+30
-11
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `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)
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
@@ -356,19 +356,38 @@ function Effects.sandstormHits(types)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
|
||||
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
|
||||
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
|
||||
-- BattleCommand_TimeBasedHealContinue's .Multipliers
|
||||
-- (engine/battle/effect_commands.asm:6450-6454).
|
||||
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 = {
|
||||
EFFECT_MORNING_SUN = true,
|
||||
EFFECT_SYNTHESIS = true,
|
||||
EFFECT_MOONLIGHT = true,
|
||||
EFFECT_MORNING_SUN = 0,
|
||||
EFFECT_SYNTHESIS = 1,
|
||||
EFFECT_MOONLIGHT = 2,
|
||||
}
|
||||
|
||||
function Effects.weatherHealFraction(weather)
|
||||
if weather == "sun" then return 2 / 3 end
|
||||
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
|
||||
return 1 / 2
|
||||
function Effects.timeOfDayIndex(timeOfDay)
|
||||
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
|
||||
return Effects.TIME_OF_DAY_ID[timeOfDay]
|
||||
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
|
||||
|
||||
-- ---------------------------------------------------------------- Perish Song
|
||||
|
||||
+102
-1
@@ -17,6 +17,7 @@
|
||||
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
||||
-- GROWTH_* so this needs no hardcoded table.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
-- 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
|
||||
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
|
||||
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
||||
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)
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(a, b)
|
||||
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
|
||||
status = nil,
|
||||
-- 70 for a caught mon, 120 for a gift/hatched one.
|
||||
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
|
||||
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
||||
-- overriding the roll rather than a roll to be hooked.
|
||||
|
||||
@@ -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
|
||||
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = false
|
||||
workerReady = nil
|
||||
end
|
||||
|
||||
function ChipAudio.currentSource()
|
||||
@@ -498,6 +498,8 @@ end
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
|
||||
ticks = ticks + duration
|
||||
end
|
||||
end
|
||||
extendDrumEnvelope(segments)
|
||||
self.noiseInstruments[key] = segments
|
||||
return segments
|
||||
end
|
||||
|
||||
+63
-31
@@ -147,13 +147,16 @@ function Game:bootConfig()
|
||||
return boot
|
||||
end
|
||||
|
||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||
-- and by the START-menu QUIT confirmation
|
||||
function Game:makeTitleState()
|
||||
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
|
||||
-- can reshape it), the overworld at the skeleton's spawn, and the intro
|
||||
-- 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 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
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.sessionStartedAt = os.time()
|
||||
@@ -167,9 +170,19 @@ function Game:makeTitleState()
|
||||
self.save.player.x, self.save.player.y,
|
||||
self.save.player.facing,
|
||||
{ via = "boot", freshBoot = true })
|
||||
if not (opts and opts.intro == false) then
|
||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||
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()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
@@ -817,14 +830,6 @@ function Game:keypressed(key)
|
||||
self:writeOptions()
|
||||
end
|
||||
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
|
||||
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
||||
-- an engine display key however a mod declares it (12 §rendering
|
||||
@@ -919,16 +924,7 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
local function isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
local isAccelerometer = GamepadMap.isAccelerometer
|
||||
|
||||
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
||||
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
||||
@@ -1267,8 +1263,10 @@ function Game:applyOptions(opts)
|
||||
require("src.render.Pipelines").applyOptions(opts)
|
||||
require("src.render.Zoom").applyOptions(opts)
|
||||
require("src.render.TileRenderer").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
-- returns true when a persisted preset name no longer resolves (deleted
|
||||
-- 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)
|
||||
-- Android orientation lock (#592); no-op everywhere else
|
||||
require("src.core.Orientation").applyOptions(opts)
|
||||
@@ -1283,12 +1281,12 @@ function Game:applyOptions(opts)
|
||||
-- tier. Every heavy feature was just applied from the stored options
|
||||
-- above; here we clamp the *live* state down for a weaker device without
|
||||
-- rewriting what the player saved, so raising the tier later restores
|
||||
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- default on a normal desktop, and every options.lua predating this
|
||||
-- option) clamps nothing, so it is a no-op for the common case.
|
||||
local caps = require("src.core.Performance").applyOptions(opts)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||
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
|
||||
@@ -1298,8 +1296,7 @@ function Game:applyOptions(opts)
|
||||
end
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
if shaderfxCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
@@ -1390,4 +1387,39 @@ function Game:restoreCheckpointBattle(battle)
|
||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() 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).
|
||||
-- main.lua must not guess field names: new systems (Game.network, …) are
|
||||
-- cleared automatically because only functions (methods) are kept.
|
||||
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 and self.renderer.releaseCanvases then
|
||||
pcall(function() self.renderer:releaseCanvases() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||
if type(value) == "table" and value.release then
|
||||
pcall(value.release, value)
|
||||
end
|
||||
end
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
+280
-77
@@ -18,6 +18,8 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Clock = require("src.core.gen2.Clock")
|
||||
local FixedStep = require("src.core.FixedStep")
|
||||
local Font = require("src.render.Font")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Input = require("src.core.Input")
|
||||
local Music = require("src.core.Music")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
@@ -57,20 +59,6 @@ Game2.__index = Game2
|
||||
|
||||
local function noop() end
|
||||
|
||||
for _, name in ipairs({
|
||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||
"joystickadded",
|
||||
}) do
|
||||
Game2[name] = noop
|
||||
end
|
||||
|
||||
-- Not a noop, because the overlay has to come back on its own: a player who
|
||||
-- unplugs the only controller would otherwise have to tap a blind screen to
|
||||
-- get the pad back (src/core/Game.lua:869 does the same).
|
||||
function Game2:joystickremoved()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
-- THE FRAME AND INPUT SEAMS.
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
@@ -301,8 +289,6 @@ function Game2:continueGame(save)
|
||||
self:startWorld()
|
||||
-- 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).
|
||||
-- `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
|
||||
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
|
||||
if notice then require("src.core.Logger").warn("%s", notice) end
|
||||
@@ -367,24 +353,36 @@ function Game2:showTitle()
|
||||
})
|
||||
end
|
||||
|
||||
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
|
||||
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
|
||||
function Game2:showIntro()
|
||||
self.stack:clear()
|
||||
self.phase = "boot"
|
||||
Screens.push(self, "Gen2GoldSilverIntro", {
|
||||
local id = (GameVersion.engine() == "crystal")
|
||||
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
|
||||
Screens.push(self, id, {
|
||||
onDone = function()
|
||||
self:showTitle()
|
||||
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()
|
||||
self.stack:clear()
|
||||
self.phase = "boot"
|
||||
Screens.push(self, "Gen2GameFreakPresents", {
|
||||
local id = (GameVersion.engine() == "crystal")
|
||||
and "Gen2CrystalSplash" or "Gen2GameFreakPresents"
|
||||
Screens.push(self, id, {
|
||||
title = self.titleData or {},
|
||||
oakSpeech = self.oakSpeechData or {},
|
||||
onDone = function()
|
||||
onDone = function(skipped)
|
||||
if skipped then
|
||||
self:showTitle()
|
||||
else
|
||||
self:showIntro()
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -496,8 +494,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
if onDone then onDone(learned) end
|
||||
end
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end
|
||||
if reason ~= "full" then return finish(false) end
|
||||
local askForget, pickMove, askStop
|
||||
@@ -552,9 +552,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
-- The slot is written here rather than through Mon.learnMove, so
|
||||
-- pokemon.move_learned is raised here too.
|
||||
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!")
|
||||
:format(name, oldName, name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -719,7 +721,9 @@ function Game2:usePartyItem(itemId)
|
||||
})
|
||||
elseif action == "candy" then
|
||||
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
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
@@ -778,8 +782,10 @@ function Game2:useSelectItem()
|
||||
local name = (items[itemId] and items[itemId].name) or itemId
|
||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||
elseif outcome == "trophy_sent" then
|
||||
-- data/text/common_3.asm:372
|
||||
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
|
||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||
-- drives its own presentation off World:step -- nothing left to print here.
|
||||
@@ -791,9 +797,9 @@ end
|
||||
-- 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
|
||||
-- was a silent extra pop.
|
||||
function Game2:say(text, onDone)
|
||||
function Game2:say(text, onDone, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.stack:push(TextBox.new(self, text, onDone))
|
||||
self.stack:push(TextBox.new(self, text, onDone, opts))
|
||||
end
|
||||
|
||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||
@@ -948,6 +954,12 @@ function Game2:load()
|
||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.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
|
||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||
@@ -1094,19 +1106,27 @@ function Game2:load()
|
||||
-- The play clock only runs in the overworld, the way wGameTimerPaused is
|
||||
-- set while the intro menu is up.
|
||||
Save.tickPlayTime(self.save)
|
||||
-- START and SELECT are read only at the tail of OWPlayerInput, which
|
||||
-- PlayerEvents never reaches while a script is running or the player is
|
||||
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
|
||||
-- that arrives under one of them is dropped, not queued -- and the frame
|
||||
-- still runs, so world:step must not be skipped on the swallowed press.
|
||||
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
|
||||
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
|
||||
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
|
||||
local accepts = self.world:acceptsMenuInput()
|
||||
local latch = self.joyLatch
|
||||
if accepts then
|
||||
self.joyLatch = nil
|
||||
if self.input:wasPressed("start")
|
||||
or (latch and latch.start and self.input:isDown("start")) then
|
||||
self:openStartMenu()
|
||||
return
|
||||
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()
|
||||
return
|
||||
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)
|
||||
if self.input:wasPressed("a") then
|
||||
self.world:interact()
|
||||
@@ -1157,8 +1177,8 @@ end
|
||||
|
||||
-- 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
|
||||
-- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is
|
||||
-- being shown on, and the panel does not resize when the player zooms the map
|
||||
-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture
|
||||
-- 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
|
||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||
-- one screen pixel a cell out at survey range.
|
||||
@@ -1355,25 +1375,29 @@ end
|
||||
-- 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
|
||||
-- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there.
|
||||
-- Gold draws straight to the screen instead, which is why its GBC FX row used
|
||||
-- to change a number and nothing else: nothing ever presented a canvas for the
|
||||
-- shader to read. So compose into one here when a pass wants it, and skip the
|
||||
-- canvas entirely when none does -- the common case, and one less full-screen
|
||||
-- blit than the old path would have paid.
|
||||
-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX
|
||||
-- there. Gold draws straight to the screen instead, which is why its SHADER
|
||||
-- FX row used to change a number and nothing else (back when it was GBC FX):
|
||||
-- nothing ever presented a canvas for the shader to read. So compose into
|
||||
-- one here when a pass wants it, and skip the canvas entirely when none does
|
||||
-- -- 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
|
||||
-- IS the picture, and the screen effects are simulating the panel that picture
|
||||
-- is being shown on. Mod post-processes fold in between the two, where
|
||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
||||
-- is then drawn over, rather than something that smears the grid itself.
|
||||
-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the
|
||||
-- palette IS the picture, and the screen effects are simulating the panel
|
||||
-- that picture is being shown on. Mod post-processes fold in between the
|
||||
-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is
|
||||
-- what the LCD grid is then drawn over, rather than something that smears
|
||||
-- the grid itself.
|
||||
function Game2:drawViewportFrame()
|
||||
local G = love.graphics
|
||||
local w, h = GameViewport.dimensions()
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
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
|
||||
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
|
||||
@@ -1396,14 +1420,14 @@ function Game2:drawViewportFrame()
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
-- 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
|
||||
-- exactly as it always did.
|
||||
local composing = ModRuntime.wantsHook("render.compose")
|
||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||
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)
|
||||
end
|
||||
if not scene then
|
||||
@@ -1431,11 +1455,11 @@ function Game2:drawViewportFrame()
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- untinted one. On its own the tint rides the final blit and no second
|
||||
-- after it: SHADER FX and a post-process both sample the tinted image, not
|
||||
-- the untinted one. On its own the tint rides the final blit and no second
|
||||
-- canvas is paid for.
|
||||
local source = scene
|
||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||
local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook
|
||||
if zoned and reread then
|
||||
local tinted = self:presentCanvas(2, w, h)
|
||||
if tinted then
|
||||
@@ -1455,7 +1479,7 @@ function Game2:drawViewportFrame()
|
||||
-- Post-process pipelines run over the finished composite and before GBC
|
||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||
-- 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,
|
||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||
local outputHandled = hasOutputHook
|
||||
@@ -1469,8 +1493,30 @@ function Game2:drawViewportFrame()
|
||||
if not outputHandled then
|
||||
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
if shaderfx then
|
||||
-- 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
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
@@ -1514,6 +1560,28 @@ function Game2:drawContained(w, h)
|
||||
if not ok then error(err, 0) 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)
|
||||
local G = love.graphics
|
||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||
@@ -1583,6 +1651,7 @@ function Game2:drawScene(w, h)
|
||||
and base.drawWidescreen and base)
|
||||
if wide then
|
||||
wide:drawWidescreen(w, h)
|
||||
self:paintBattleSurround(w, h)
|
||||
self:letterbox(w, h, false)
|
||||
if wide ~= top then
|
||||
-- The pushed box blits at the same integer fit the widescreen layer
|
||||
@@ -1687,7 +1756,6 @@ end
|
||||
-- F1/F2 write / reload the save 1 GAME SPEED
|
||||
-- - = zoom one step out / in 2 COLOR
|
||||
-- 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
|
||||
-- palette packs, which a CGB-native game has no use for; what it cycles here
|
||||
@@ -1722,13 +1790,6 @@ function Game2:hotkey(key)
|
||||
options.tilt = Tilt.cycle()
|
||||
persist()
|
||||
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
|
||||
if not (self.world and self.world.map) then
|
||||
return self:pipelineHotkey(key, options, persist)
|
||||
@@ -1963,12 +2024,17 @@ end
|
||||
|
||||
-- Push the saved display options into the modules that own them. Called
|
||||
-- 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()
|
||||
local options = self.options or {}
|
||||
Music.applyOptions(options)
|
||||
require("src.core.Sound").applyOptions(options)
|
||||
require("src.render.Zoom").applyOptions(options)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.applyOptions(options)
|
||||
local caps = require("src.core.Performance").applyOptions(options)
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
require("src.render.Tilt").applyOptions(options)
|
||||
require("src.render.GbcPalette").applyOptions(options)
|
||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||
@@ -1986,13 +2052,37 @@ function Game2:applyOptions()
|
||||
require("src.core.ScreenPosition").applyOptions(options)
|
||||
require("src.core.FrameCap").applyOptions(options)
|
||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
-- returns true when a persisted preset name no longer resolves (deleted
|
||||
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||
-- 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
|
||||
end
|
||||
end
|
||||
|
||||
function Game2:_cycleSpeed(dir)
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||
if self.save then self.save.options = self.options end
|
||||
self:persistOptions()
|
||||
end
|
||||
|
||||
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
||||
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
||||
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
||||
@@ -2003,27 +2093,49 @@ end
|
||||
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
||||
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
||||
-- to find out killed the process. It reaches Input like every other button now.
|
||||
function Game2:gamepadpressed(_joystick, button)
|
||||
function Game2:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the next
|
||||
-- screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
|
||||
if button == "rightshoulder" or button == "leftshoulder" then
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local dir = button == "rightshoulder" and 1 or -1
|
||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||
if self.save then self.save.options = self.options end
|
||||
self:persistOptions()
|
||||
local selectHeld = Input:isDown("select")
|
||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||
local ok, down = pcall(function()
|
||||
return joystick:isGamepadDown("back")
|
||||
end)
|
||||
selectHeld = ok and down == true
|
||||
end
|
||||
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
|
||||
if not selectHeld then
|
||||
if button == "rightshoulder" or button == "righttrigger" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" or button == "lefttrigger" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
end
|
||||
end
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
top:onGamepadPressed(button)
|
||||
return
|
||||
end
|
||||
if selectHeld then
|
||||
local digit = GamepadMap.displayChordDigit(button)
|
||||
if digit then
|
||||
self:keypressed(digit)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- START opens the start menu in the overworld; it used to quit, from before
|
||||
-- there was a menu to open.
|
||||
|
||||
Input:gamepadpressed(_joystick, button)
|
||||
Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game2:gamepadreleased(joystick, button)
|
||||
Input:gamepadreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||
end
|
||||
|
||||
function Game2:gamepadaxis(joystick, axis, value)
|
||||
@@ -2032,4 +2144,95 @@ function Game2:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
|
||||
local function isRawStick(joystick)
|
||||
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
||||
end
|
||||
|
||||
function Game2:joystickpressed(joystick, button)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
TouchControls:noteGamepad()
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickPressed then
|
||||
top:onJoystickPressed(button)
|
||||
return
|
||||
end
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game2:joystickreleased(joystick, button)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
Input:joystickreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickReleased then
|
||||
top:onJoystickReleased(button)
|
||||
end
|
||||
end
|
||||
|
||||
function Game2:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game2:joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||
Input:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- src/core/Game.lua:1015 (#799)
|
||||
function Game2:recoverInput()
|
||||
Input:reset()
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
|
||||
self:cancelPointers()
|
||||
end
|
||||
|
||||
function Game2:joystickadded()
|
||||
self:recoverInput()
|
||||
end
|
||||
|
||||
-- The overlay comes back on its own when the last pad is unplugged
|
||||
-- (src/core/Game.lua:1044).
|
||||
function Game2:joystickremoved()
|
||||
self:recoverInput()
|
||||
TouchControls:joystickremoved()
|
||||
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.
|
||||
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 and self.renderer.releaseCanvases then
|
||||
pcall(function() self.renderer:releaseCanvases() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||
if type(value) == "table" and value.release then
|
||||
pcall(value.release, value)
|
||||
end
|
||||
end
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game2
|
||||
|
||||
+31
-4
@@ -29,11 +29,38 @@ end
|
||||
|
||||
-- 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 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)
|
||||
v = tonumber(v)
|
||||
if not v then return GameSpeed.DEFAULT end
|
||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||
local levels = GameSpeed.allowed()
|
||||
if not v then return levels[1] or GameSpeed.DEFAULT end
|
||||
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(levels) do
|
||||
local diff = math.abs(level - v)
|
||||
if diff < bestDiff then best, bestDiff = level, diff end
|
||||
end
|
||||
@@ -42,7 +69,7 @@ end
|
||||
|
||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||
function GameSpeed.cycle(v, dir)
|
||||
local levels = GameSpeed.LEVELS
|
||||
local levels = GameSpeed.allowed()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == GameSpeed.clamp(v) then cur = i break end
|
||||
|
||||
+50
-10
@@ -1,6 +1,6 @@
|
||||
-- Which game this process is running: Red (the historical default), Blue,
|
||||
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
||||
-- version -- the accepted ROM hash, the import manifest, where the
|
||||
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
|
||||
-- differs by version -- the accepted ROM hash, the import manifest, where the
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- 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,
|
||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||
-- and selected side by side. Gold and Silver are Gen 2 (see
|
||||
-- docs/gold-phase1.md).
|
||||
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
|
||||
-- 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
|
||||
-- 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",
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
-- Absent reads as "gen1" (GameVersion.engine)
|
||||
engine = "gen1",
|
||||
},
|
||||
blue = {
|
||||
id = "blue",
|
||||
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
engine = "gen1",
|
||||
},
|
||||
yellow = {
|
||||
id = "yellow",
|
||||
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
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
|
||||
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
|
||||
@@ -55,15 +60,14 @@ GameVersion.VERSIONS = {
|
||||
id = "gold",
|
||||
label = "Gold",
|
||||
displayName = "Pokemon Gold",
|
||||
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
|
||||
-- players do not treat it like the shipped Gen 1 columns.
|
||||
launcherName = "Gold (Beta)",
|
||||
launcherName = "Gold",
|
||||
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
|
||||
manifest = "tools/rom_manifest_gold.json",
|
||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||
-- Absent reads as 1 (GameVersion.generation)
|
||||
generation = 2,
|
||||
engine = "gs",
|
||||
},
|
||||
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||
-- Gold's by tools/make_silver_manifest.py.
|
||||
@@ -71,17 +75,42 @@ GameVersion.VERSIONS = {
|
||||
id = "silver",
|
||||
label = "Silver",
|
||||
displayName = "Pokemon Silver",
|
||||
launcherName = "Silver (Beta)",
|
||||
launcherName = "Silver",
|
||||
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
||||
manifest = "tools/rom_manifest_silver.json",
|
||||
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||
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",
|
||||
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.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||
local NO_FIXES = {}
|
||||
|
||||
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
@@ -115,6 +144,17 @@ function GameVersion.generation(id)
|
||||
return GameVersion.info(id).generation or 1
|
||||
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.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
|
||||
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
|
||||
return ok and isPad == true
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
function GamepadMap.isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
|
||||
function GamepadMap.mapRawButton(index)
|
||||
if nxActive() then
|
||||
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
||||
|
||||
+24
-1
@@ -376,7 +376,19 @@ end
|
||||
-- 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
|
||||
-- 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(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
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) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. 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"
|
||||
-- with no URL and no code is the report this whole change exists to fix.
|
||||
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
|
||||
return nil, fetchError(url, status, body)
|
||||
end
|
||||
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
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)
|
||||
if ok and done then return true end
|
||||
return nil, "download failed for " .. url
|
||||
|
||||
+6
-1
@@ -281,6 +281,7 @@ end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
noteCapture(self, "joy", "pressed", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
@@ -288,6 +289,7 @@ end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
noteCapture(self, "joy", "released", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
@@ -330,6 +332,7 @@ end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -343,6 +346,7 @@ end
|
||||
-- directions on top of a direction rebind.
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
@@ -380,7 +384,8 @@ function Input:reconcile()
|
||||
local ok, joysticks = pcall(js.getJoysticks)
|
||||
if not ok or type(joysticks) ~= "table" then return end
|
||||
for _, j in ipairs(joysticks) do
|
||||
if GamepadMap.ignoreRawForJoystick(j) then
|
||||
if GamepadMap.isAccelerometer(j) then
|
||||
elseif GamepadMap.ignoreRawForJoystick(j) then
|
||||
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
||||
if j.isGamepadDown then
|
||||
for button, btn in pairs(self.padBindings) do
|
||||
|
||||
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
|
||||
y = "yellow", yellow = "yellow",
|
||||
g = "gold", gold = "gold",
|
||||
s = "silver", silver = "silver",
|
||||
c = "crystal", crystal = "crystal",
|
||||
}
|
||||
v = alias[v] or v
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||
|
||||
+79
-23
@@ -1,4 +1,4 @@
|
||||
-- Screen orientation lock, Android only (#592, #716).
|
||||
-- Screen orientation lock, Android and iOS (#592, #716, #1638).
|
||||
--
|
||||
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
||||
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
||||
@@ -10,16 +10,12 @@
|
||||
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
||||
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
||||
--
|
||||
-- SDL only re-reads the hint when the window is created or its resizable
|
||||
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
|
||||
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
|
||||
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
|
||||
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
|
||||
-- flag off and back on -- each edge makes the Android backend recompute the
|
||||
-- requested orientation, so a change from the launcher or the OPTION menu
|
||||
-- takes hold immediately, and the flag ends where it started (conf.lua sets
|
||||
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
|
||||
-- Info.plist governs there) and headless stubs make this a no-op.
|
||||
-- Android only re-reads the hint at window creation or on a resizable-flag
|
||||
-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
|
||||
-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
|
||||
-- always is -- so the hint never reached a running activity (#1638).
|
||||
-- apply() sets the hint for a later window, then goes over JNI for the live
|
||||
-- one. iOS needs only the hint. Desktop and headless stubs no-op.
|
||||
|
||||
local Orientation = {}
|
||||
|
||||
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
|
||||
return love.system.getOS() == "Android"
|
||||
end
|
||||
|
||||
function Orientation.isIOS()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
return love.system.getOS() == "iOS"
|
||||
end
|
||||
|
||||
function Orientation.cycle(mode, dir)
|
||||
local cur, idx = Orientation.normalize(mode), 1
|
||||
for i, m in ipairs(Orientation.MODES) do
|
||||
@@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir)
|
||||
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- ActivityInfo constants, what setOrientationBis lands on per hint after
|
||||
-- GameActivity's *_SENSOR -> *_USER remap (#716).
|
||||
local REQUESTED = {
|
||||
auto = 13,
|
||||
portrait = 1,
|
||||
landscape = 11,
|
||||
reverseLandscape = 8,
|
||||
}
|
||||
|
||||
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
||||
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
||||
-- test interpreters), hence the pcall'd require.
|
||||
@@ -77,33 +87,79 @@ local function sdlFfi()
|
||||
if cdefOk == nil then
|
||||
cdefOk = pcall(ffi.cdef, [[
|
||||
typedef struct SDL_Window SDL_Window;
|
||||
typedef union { int32_t i; int64_t pad; } love_jvalue;
|
||||
int SDL_SetHint(const char *name, const char *value);
|
||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
||||
void *SDL_AndroidGetJNIEnv(void);
|
||||
void *SDL_AndroidGetActivity(void);
|
||||
]])
|
||||
end
|
||||
if not cdefOk then return nil end
|
||||
return ffi
|
||||
end
|
||||
|
||||
-- Push the mode into the live activity. Returns true when the hint reached
|
||||
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
|
||||
-- Slot numbers in JNINativeInterface (jni.h).
|
||||
local JNI_EXCEPTION_CLEAR = 17
|
||||
local JNI_DELETE_LOCAL_REF = 23
|
||||
local JNI_GET_OBJECT_CLASS = 31
|
||||
local JNI_GET_METHOD_ID = 33
|
||||
local JNI_CALL_VOID_METHOD_A = 63
|
||||
|
||||
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
|
||||
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
|
||||
local function setRequestedOrientation(ffi, requested)
|
||||
local env = ffi.C.SDL_AndroidGetJNIEnv()
|
||||
if env == nil then return false end
|
||||
local activity = ffi.C.SDL_AndroidGetActivity()
|
||||
if activity == nil then return false end
|
||||
local fns = ffi.cast("void***", env)[0]
|
||||
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
|
||||
local getMethodID = ffi.cast(
|
||||
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
|
||||
local callVoidMethodA = ffi.cast(
|
||||
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
|
||||
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
|
||||
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
|
||||
|
||||
local ok = false
|
||||
local cls = getObjectClass(env, activity)
|
||||
if cls ~= nil then
|
||||
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
|
||||
if mid ~= nil then
|
||||
local args = ffi.new("love_jvalue[1]")
|
||||
args[0].pad = 0
|
||||
args[0].i = requested
|
||||
callVoidMethodA(env, activity, mid, args)
|
||||
ok = true
|
||||
end
|
||||
exceptionClear(env)
|
||||
deleteLocalRef(env, cls)
|
||||
end
|
||||
deleteLocalRef(env, activity)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Returns true only when the request actually landed, never unconditionally
|
||||
-- as it once did (#1638).
|
||||
function Orientation.apply(mode)
|
||||
if not Orientation.isAndroid() then return false end
|
||||
local android = Orientation.isAndroid()
|
||||
if not (android or Orientation.isIOS()) then return false end
|
||||
local ffi = sdlFfi()
|
||||
if not ffi then return false end
|
||||
mode = Orientation.normalize(mode)
|
||||
local ok = pcall(function()
|
||||
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
|
||||
-- despite the IOS in the string, the Android backend reads it too.
|
||||
local ok, reached = pcall(function()
|
||||
-- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
|
||||
-- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
|
||||
-- engine ignores the other's key.
|
||||
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
||||
local win = ffi.C.SDL_GL_GetCurrentWindow()
|
||||
if win ~= nil then
|
||||
ffi.C.SDL_SetWindowResizable(win, 0)
|
||||
ffi.C.SDL_SetWindowResizable(win, 1)
|
||||
end
|
||||
ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
|
||||
-- On iOS the hint is the lock: UIKit re-asks on every rotation
|
||||
-- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
|
||||
if not android then return true end
|
||||
return setRequestedOrientation(ffi, REQUESTED[mode])
|
||||
end)
|
||||
return ok
|
||||
return ok and reached == true
|
||||
end
|
||||
|
||||
function Orientation.applyOptions(opts)
|
||||
|
||||
+34
-14
@@ -3,9 +3,12 @@
|
||||
--
|
||||
-- The heavy extras are all things the Game Boy never had and this port
|
||||
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||
-- ground plane), survey ZOOM (zooming out renders the connected
|
||||
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
|
||||
-- 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
|
||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||
-- 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:
|
||||
-- auto pick a default from the device (see detect())
|
||||
-- high everything on -- the historical behavior
|
||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
|
||||
--
|
||||
-- AUTO only chooses the *default* for the current device; every tier is
|
||||
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||
-- against the stored options without rewriting them, so raising the tier
|
||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
|
||||
-- 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
|
||||
-- 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
|
||||
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||
-- 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 = {
|
||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
|
||||
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
|
||||
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
|
||||
}
|
||||
|
||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||
@@ -86,12 +104,14 @@ function Performance.detect()
|
||||
local cores = processorCount()
|
||||
|
||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||
-- already ships a build for): the weakest target here.
|
||||
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||
-- already ships a build for): the weakest target here. Desktop ARM
|
||||
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
|
||||
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
|
||||
-- OPTIONS so the ZOOM row only offered IN.
|
||||
if isArm and os == "Linux" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
|
||||
if os == "Android" or os == "iOS" then
|
||||
return "balanced"
|
||||
end
|
||||
|
||||
+542
-123
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
|
||||
end
|
||||
|
||||
function ScreenPosition.label(v)
|
||||
if ScreenPosition.skinActive() then return "SKIN" end
|
||||
return LABELS[ScreenPosition.normalize(v)]
|
||||
end
|
||||
|
||||
function ScreenPosition.cycle(v, dir)
|
||||
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
|
||||
v = ScreenPosition.normalize(v)
|
||||
local modes = ScreenPosition.MODES
|
||||
local cur = 1
|
||||
@@ -44,6 +46,9 @@ end
|
||||
function ScreenPosition.skinActive(w, h)
|
||||
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
||||
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
||||
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
|
||||
w, h = love.graphics.getDimensions()
|
||||
end
|
||||
local okv, x = pcall(TouchSkin.viewport, w, h)
|
||||
return okv and x ~= nil
|
||||
end
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
-- 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")
|
||||
sdlLib = okLoad and lib or ffi.C
|
||||
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,101 @@
|
||||
-- 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
|
||||
|
||||
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
|
||||
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
|
||||
return newFileSource(def)
|
||||
end
|
||||
|
||||
local function deviceSuspended()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
return ChipAudio ~= nil and ChipAudio.isSuspended()
|
||||
end
|
||||
|
||||
local function playPath(data, key, def, pitch, tempo, plain)
|
||||
if not love.audio or not def then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
local src = cache[key]
|
||||
if src == false then return nil end -- known bad, already logged
|
||||
if not src then
|
||||
@@ -357,6 +363,14 @@ function Sound.sfxBusy()
|
||||
return true
|
||||
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
|
||||
|
||||
local function startSfx(data, name, def)
|
||||
local src = playPath(data, name, def)
|
||||
if not src then return end
|
||||
@@ -602,6 +616,7 @@ end
|
||||
-- cache carries no clips (Red/Blue) or headless.
|
||||
function Sound.playPikaCry(data, n)
|
||||
if not love.audio then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
local count = data.audio and data.audio.pikaCries
|
||||
if not count then return nil end
|
||||
n = math.max(1, math.min(count, n or 1))
|
||||
@@ -633,6 +648,7 @@ end
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species, pikaClip)
|
||||
if not love.audio then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there). Which clip is a property of the
|
||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||
|
||||
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
|
||||
-- launcher editor round-trips through config() (#806)
|
||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||
TouchSkin.setOverlayLive(self.active)
|
||||
self:selectSkin(cfg.skin)
|
||||
-- Off means off everywhere: do not leave a hidden selected skin behind to
|
||||
-- influence renderer placement on desktop or with a controller attached.
|
||||
self:selectSkin(cfg.enabled and cfg.skin or nil)
|
||||
self.layouts = cfg.layouts
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
@@ -335,7 +337,10 @@ function TouchControls:visible()
|
||||
local art = TouchSkin.active ~= nil or self.img ~= nil
|
||||
if self.preview then return art end
|
||||
if self.enabled == false or not art then return false end
|
||||
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
|
||||
-- A selected skin is also a desktop/TV bezel. Input remains gated in
|
||||
-- touchpressed, but the artwork must not disappear when a controller is
|
||||
-- connected or the platform is not touch-first.
|
||||
if TouchSkin.active then return true end
|
||||
return self.active and not self.controllerHidden
|
||||
end
|
||||
|
||||
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
local function drawStretched(img, x, y, w, h, alpha)
|
||||
local function drawCovered(img, x, y, w, h, alpha)
|
||||
if not img or alpha <= 0 then return end
|
||||
local iw, ih = img:getWidth(), img:getHeight()
|
||||
if iw <= 0 or ih <= 0 then return end
|
||||
-- Cover the assigned box with one uniform scale and crop the excess. The
|
||||
-- old independent X/Y scale made portrait art visibly squash on wide
|
||||
-- displays (and vice versa).
|
||||
local s = math.max(w / iw, h / ih)
|
||||
local dw, dh = iw * s, ih * s
|
||||
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
||||
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
|
||||
love.graphics.setScissor(x, y, w, h)
|
||||
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
|
||||
function TouchControls:drawSkin(alphaMul)
|
||||
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
|
||||
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
drawStretched(page.image, bx, by, bw, bh, opacity)
|
||||
drawCovered(page.image, bx, by, bw, bh, opacity)
|
||||
|
||||
local pressed = {}
|
||||
for _, touch in pairs(self.touches or {}) do
|
||||
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
|
||||
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
||||
local alpha = opacity
|
||||
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
||||
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+69
-9
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
|
||||
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||
end
|
||||
|
||||
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),
|
||||
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||
page.viewportFill = raw.viewport.fill == true
|
||||
page.viewportExpand = raw.viewport.expand == true
|
||||
end
|
||||
for _, c in ipairs(raw.controls or {}) do
|
||||
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,
|
||||
w = page.viewport.w, h = page.viewport.h,
|
||||
fill = page.viewportFill or nil,
|
||||
expand = page.viewportExpand or nil,
|
||||
}
|
||||
end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
@@ -636,6 +633,19 @@ local function applyPixelScale(page)
|
||||
return true
|
||||
end
|
||||
|
||||
-- An overlay image is its own design canvas. Older RetroArch cfg files often
|
||||
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
|
||||
-- all of its normalized controls on the same uniform scale.
|
||||
function TouchSkin.applyImageAspect(page)
|
||||
if not page or page.aspectFromCfg or not page.image
|
||||
or not page.image.getDimensions then return false end
|
||||
local iw, ih = page.image:getDimensions()
|
||||
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||
page.aspect = iw / ih
|
||||
page.aspectFromImage = true
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchSkin.load(root, id)
|
||||
local cfgPath, format, prefix = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||
@@ -665,6 +675,7 @@ function TouchSkin.load(root, id)
|
||||
elseif page.pdfPath then
|
||||
rasterizePdfPage(page, root)
|
||||
end
|
||||
TouchSkin.applyImageAspect(page)
|
||||
if not applyPixelScale(page) then
|
||||
return nil, "could not read " .. tostring(page.imagePath)
|
||||
.. ", which " .. page.name .. " measures its coordinates against"
|
||||
@@ -771,6 +782,28 @@ function TouchSkin.find(id)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Remove only a user-installed skin. Bundled skins are shipped with the
|
||||
-- game and intentionally have no delete affordance.
|
||||
function TouchSkin.remove(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then return nil, "no skin " .. tostring(id) end
|
||||
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
|
||||
if not (love and love.filesystem and love.filesystem.remove) then
|
||||
return nil, "no writable filesystem"
|
||||
end
|
||||
local function removeTree(path)
|
||||
if isDir(path) and love.filesystem.getDirectoryItems then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
|
||||
local ok, err = removeTree(path .. "/" .. name)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
end
|
||||
local ok, err = love.filesystem.remove(path)
|
||||
return ok and true or nil, err
|
||||
end
|
||||
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
|
||||
end
|
||||
|
||||
function TouchSkin.assetPaths(skin)
|
||||
local out, seen = {}, {}
|
||||
local function add(rel)
|
||||
@@ -886,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
|
||||
if page.viewport then
|
||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||
end
|
||||
local controls = {}
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
@@ -1284,7 +1316,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
-- full_screen means "relative to the window, not the game viewport".
|
||||
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
|
||||
and page.aspect and page.aspect > 0 and h > 0
|
||||
if fit then
|
||||
local displayAspect = w / h
|
||||
@@ -1306,6 +1338,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
by = oy + extra
|
||||
elseif anchor == "top" then
|
||||
by = oy
|
||||
elseif page.aspect < 1 then
|
||||
-- A portrait bezel with controls is a controller deck. On an
|
||||
-- unusually tall display, pin the deck to the lower edge and leave
|
||||
-- the additional room for the game above it.
|
||||
by = oy + extra
|
||||
else
|
||||
by = oy + extra * 0.5
|
||||
end
|
||||
@@ -1359,8 +1396,10 @@ function TouchSkin.decorativeOnly()
|
||||
end
|
||||
|
||||
function TouchSkin.drawable()
|
||||
if not TouchSkin.active then return false end
|
||||
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
||||
-- A selected skin is a presentation choice, not a mobile-only input mode.
|
||||
-- Its artwork and screen placement therefore belong on every platform;
|
||||
-- `overlayLive` still controls whether touch input is available.
|
||||
return TouchSkin.active ~= nil
|
||||
end
|
||||
|
||||
function TouchSkin.hasViewport()
|
||||
@@ -1391,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
return best[1], best[2], best[3], best[4]
|
||||
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)
|
||||
if not page then return nil end
|
||||
ox, oy = ox or 0, oy or 0
|
||||
@@ -1400,16 +1449,27 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
|
||||
return x, y, vw, vh, page.viewportFill == true
|
||||
end
|
||||
if page.screenFit == "remainder" then
|
||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
if not x then return nil end
|
||||
return x, y, vw, vh, false, false
|
||||
return x, y, vw, vh, false
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Centre of the page's screen cutout. The renderer fits the 160x144
|
||||
-- picture into that rect; this helper is for the studio preview.
|
||||
function TouchSkin.screenCenter(w, h, ox, oy, page)
|
||||
page = page or TouchSkin.page()
|
||||
if not page then return nil end
|
||||
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not x then return nil end
|
||||
return x + vw * 0.5, y + vh * 0.5
|
||||
end
|
||||
|
||||
function TouchSkin.viewport(w, h, ox, oy)
|
||||
local page = TouchSkin.page()
|
||||
if not page or not TouchSkin.drawable() then return nil end
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
|
||||
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local HallOfFame = require("src.core.gen2.HallOfFame")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
local BattleTower = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
|
||||
BattleTower.PARTY_LENGTH = 3
|
||||
BattleTower.STREAK_LENGTH = 7
|
||||
BattleTower.NUM_UNIQUE_MON = 21
|
||||
BattleTower.NUM_UNIQUE_TRAINERS = 70
|
||||
BattleTower.TRAINERDATALENGTH = 36
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
|
||||
BattleTower.GS_BALL_AVAILABLE = 0x0b
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
|
||||
BattleTower.NO_CHALLENGE = 0
|
||||
BattleTower.SAVED_AND_LEFT = 1
|
||||
BattleTower.CHALLENGE_IN_PROGRESS = 2
|
||||
BattleTower.WON_CHALLENGE = 3
|
||||
BattleTower.RECEIVED_REWARD = 4
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
|
||||
BattleTower.REWARD_QUANTITY = 5
|
||||
BattleTower.MIN_REWARD = "HP_UP"
|
||||
BattleTower.MAX_REWARD = "CALCIUM"
|
||||
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
|
||||
BattleTower.FALLBACK_REWARD = "POTION"
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
|
||||
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
|
||||
BattleTower.SAVEFILE_REGISTERED = 1
|
||||
BattleTower.SAVEFILE_EXPLANATION = 2
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
|
||||
BattleTower.ACTIONS = {
|
||||
CHECK_EXPLANATION_READ = 0,
|
||||
SET_EXPLANATION_READ = 1,
|
||||
GET_CHALLENGE_STATE = 2,
|
||||
SAVE_AND_QUIT = 3,
|
||||
CHALLENGECANCELED = 4,
|
||||
ACTION_05 = 5,
|
||||
ACTION_06 = 6,
|
||||
SAVELEVELGROUP = 7,
|
||||
LOADLEVELGROUP = 8,
|
||||
CHECKSAVEFILEISYOURS = 9,
|
||||
ACTION_0A = 10,
|
||||
GSBALL = 11,
|
||||
ACTION_0C = 12,
|
||||
ACTION_0D = 13,
|
||||
EGGTICKET = 14,
|
||||
ACTION_0F = 15,
|
||||
ACTION_10 = 16,
|
||||
ACTION_11 = 17,
|
||||
ACTION_12 = 18,
|
||||
ACTION_13 = 19,
|
||||
ACTION_14 = 20,
|
||||
ACTION_15 = 21,
|
||||
ACTION_16 = 22,
|
||||
ACTION_17 = 23,
|
||||
LEVEL_CHECK = 24,
|
||||
UBERS_CHECK = 25,
|
||||
RESETDATA = 26,
|
||||
GIVEREWARD = 27,
|
||||
ACTION_1C = 28,
|
||||
ACTION_1D = 29,
|
||||
CHOOSEREWARD = 30,
|
||||
SAVEOPTIONS = 31,
|
||||
}
|
||||
BattleTower.NUM_ACTIONS = 32
|
||||
|
||||
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
|
||||
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
|
||||
BattleTower.WRAM_NR_BEATEN = 0xcf64
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
|
||||
BattleTower.TEXT_INTRO = 1
|
||||
BattleTower.TEXT_WIN = 2
|
||||
BattleTower.TEXT_LOSS = 3
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
|
||||
-- BattleTower_ExecuteJumptable prints them.
|
||||
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
|
||||
BattleTower.RULE_FAIL_TEXTS = {
|
||||
"_OnlyThreeMonMayBeEnteredText",
|
||||
"_TheMonMustAllBeDifferentKindsText",
|
||||
"_TheMonMustNotHoldTheSameItemsText",
|
||||
"_YouCantTakeAnEggText",
|
||||
}
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
|
||||
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
|
||||
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
|
||||
BattleTower.UBERS = {
|
||||
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
|
||||
}
|
||||
BattleTower.UBER_MIN_LEVEL = 70
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
|
||||
-- Strings_Ll0ToL40.
|
||||
BattleTower.MAX_LEVEL_GROUP = 10
|
||||
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
|
||||
function BattleTower.state(save)
|
||||
local tower = Save.battleTowerState(save)
|
||||
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
|
||||
tower.levelGroup = counter(tower.levelGroup)
|
||||
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
|
||||
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
|
||||
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
|
||||
if type(tower.trainers) ~= "table" then tower.trainers = {} end
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
|
||||
if tower.reentry == nil then tower.reentry = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
|
||||
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
|
||||
function BattleTower.saveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
|
||||
end
|
||||
|
||||
function BattleTower.setSaveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
|
||||
tower.saveFileFlags = tower.saveFileFlags + mask
|
||||
end
|
||||
return tower.saveFileFlags
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
|
||||
function BattleTower.resetTrainers(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.trainers = {}
|
||||
tower.streak = 0
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
|
||||
function BattleTower.setChallengeState(save, state)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = counter(state)
|
||||
return tower.challenge
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
|
||||
function BattleTower.partyCountOk(party)
|
||||
return #party == BattleTower.PARTY_LENGTH
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
|
||||
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
|
||||
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
|
||||
local function valuesUnique(party, get)
|
||||
local count = #party
|
||||
for i = 1, count - 1 do
|
||||
local mon = party[i]
|
||||
if not Breeding.isEgg(mon) then
|
||||
local value = get(mon)
|
||||
if value ~= nil and value ~= 0 then
|
||||
for j = i + 1, count do
|
||||
local other = party[j]
|
||||
if not Breeding.isEgg(other) and get(other) == value then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function speciesOf(mon) return mon and mon.species or nil end
|
||||
local function itemOf(mon) return mon and mon.item or nil end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
|
||||
function BattleTower.speciesUnique(party)
|
||||
return valuesUnique(party, speciesOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
|
||||
function BattleTower.itemsUnique(party)
|
||||
return valuesUnique(party, itemOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
|
||||
function BattleTower.partyHasEgg(party)
|
||||
for _, mon in ipairs(party) do
|
||||
if Breeding.isEgg(mon) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
|
||||
-- check runs, the first failure prints the header first, and a tail line
|
||||
-- follows any failure at all. Returns the text labels in printing order.
|
||||
function BattleTower.checkRules(party)
|
||||
party = party or {}
|
||||
local failed = {
|
||||
not BattleTower.partyCountOk(party),
|
||||
not BattleTower.speciesUnique(party),
|
||||
not BattleTower.itemsUnique(party),
|
||||
BattleTower.partyHasEgg(party),
|
||||
}
|
||||
local lines, any = {}, false
|
||||
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
|
||||
if failed[index] then
|
||||
if not any then
|
||||
any = true
|
||||
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
|
||||
end
|
||||
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
|
||||
end
|
||||
end
|
||||
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
|
||||
return lines, any
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
|
||||
-- opens rooms above L40, and the last row is always CANCEL.
|
||||
function BattleTower.levelGroupCount(save)
|
||||
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
|
||||
return BattleTower.PRE_HOF_LEVEL_GROUPS
|
||||
end
|
||||
|
||||
function BattleTower.levelGroupRows(save)
|
||||
local rows = {}
|
||||
for group = 1, BattleTower.levelGroupCount(save) do
|
||||
rows[group] = { group = group, level = group * 10 }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
|
||||
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
|
||||
function BattleTower.roomOf(group)
|
||||
return math.floor((math.max(1, counter(group)) - 1) / 2)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
|
||||
function BattleTower.levelCheck(party, group)
|
||||
local cap = counter(group) * 10
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if (tonumber(mon.level) or 0) > cap then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
|
||||
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
|
||||
function BattleTower.ubersCheck(party, group)
|
||||
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if BattleTower.UBERS[mon.species]
|
||||
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
|
||||
return mon.species
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
|
||||
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
|
||||
-- bottom of the range and rerolls LUCKY_PUNCH.
|
||||
function BattleTower.rollReward(order, random)
|
||||
if type(order) ~= "table" then return nil end
|
||||
local low, high
|
||||
for index, name in pairs(order) do
|
||||
if name == BattleTower.MIN_REWARD then low = index end
|
||||
if name == BattleTower.MAX_REWARD then high = index end
|
||||
end
|
||||
if not (low and high) then return nil end
|
||||
local span = high - low + 1
|
||||
local mask = 1
|
||||
while mask < span do mask = mask * 2 end
|
||||
for _ = 1, 64 do
|
||||
local roll = random(mask) % mask
|
||||
if roll >= span then roll = roll - span end
|
||||
local item = order[low + roll]
|
||||
if item ~= BattleTower.SKIPPED_REWARD then return item end
|
||||
end
|
||||
return order[low]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
|
||||
-- the reward only fit when the ITEM pocket has a free slot, or already holds
|
||||
-- the reward with room for five more. Otherwise the desk hands over a POTION,
|
||||
-- which the script reads as "your PACK is stuffed full".
|
||||
function BattleTower.rewardFits(slots, capacity, held)
|
||||
if counter(slots) < counter(capacity) then return true end
|
||||
if held == nil then return false end
|
||||
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
|
||||
end
|
||||
|
||||
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
|
||||
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
|
||||
|
||||
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
|
||||
function BattleTower.roster(data)
|
||||
local trainers = data and (data.gen2Trainers or data.trainers)
|
||||
local roster = trainers and trainers.battleTower
|
||||
if type(roster) ~= "table" then return nil end
|
||||
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
return roster
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
|
||||
-- species and the one before that's, which the draw refuses to repeat.
|
||||
function BattleTower.prevTeams(save)
|
||||
local tower = BattleTower.state(save)
|
||||
local teams = tower.prevTeams
|
||||
if type(teams) ~= "table" then
|
||||
teams = {}
|
||||
tower.prevTeams = teams
|
||||
end
|
||||
if type(teams.prev) ~= "table" then teams.prev = {} end
|
||||
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
|
||||
return teams
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
|
||||
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
|
||||
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
|
||||
function BattleTower.opponentGroup(save, roster)
|
||||
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
|
||||
local group = counter(BattleTower.state(save).levelGroup)
|
||||
if group < 1 then return 1 end
|
||||
if group > groups then return groups end
|
||||
return group
|
||||
end
|
||||
|
||||
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
|
||||
-- from the survivors is the same distribution and cannot spin. An empty
|
||||
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
|
||||
local function drawFiltered(count, random, accept)
|
||||
local pool = {}
|
||||
for index = 1, count do
|
||||
if accept(index) then pool[#pool + 1] = index end
|
||||
end
|
||||
if #pool == 0 then return random(count) end
|
||||
return pool[random(#pool)]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
|
||||
-- is refused while it names anybody already in sBTTrainers, and the winner is
|
||||
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
|
||||
function BattleTower.chooseTrainer(save, roster, random)
|
||||
local tower = BattleTower.state(save)
|
||||
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
|
||||
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
|
||||
local ceiling = counter(roster.sampleTrainers)
|
||||
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
|
||||
local seen = {}
|
||||
for slot = 1, BattleTower.STREAK_LENGTH do
|
||||
local held = tonumber(tower.trainers[slot])
|
||||
if held then seen[held] = true end
|
||||
end
|
||||
local index = drawFiltered(ceiling, random, function(row)
|
||||
return not seen[row - 1]
|
||||
end)
|
||||
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
|
||||
+ 1] = index - 1
|
||||
return roster.trainers[index]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
|
||||
-- draws out of the chosen level group, each refusing a species this team
|
||||
-- already holds, an ITEM this team already holds, and any species from the
|
||||
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
|
||||
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
|
||||
function BattleTower.chooseTeam(save, roster, group, random)
|
||||
local rows = roster.groups[group] or {}
|
||||
local teams = BattleTower.prevTeams(save)
|
||||
local picked, species, items = {}, {}, {}
|
||||
for _ = 1, BattleTower.PARTY_LENGTH do
|
||||
local index = drawFiltered(#rows, random, function(row)
|
||||
local mon = rows[row]
|
||||
if not mon then return false end
|
||||
if species[mon.species] then return false end
|
||||
if mon.item ~= nil and items[mon.item] then return false end
|
||||
for _, seen in ipairs(teams.prev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
for _, seen in ipairs(teams.prevPrev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
return true
|
||||
end)
|
||||
local mon = rows[index]
|
||||
if not mon then break end
|
||||
picked[#picked + 1] = mon
|
||||
species[mon.species] = true
|
||||
if mon.item ~= nil then items[mon.item] = true end
|
||||
end
|
||||
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
|
||||
-- one it displaces becomes sBTMonPrevPrevTrainer.
|
||||
local prev = {}
|
||||
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
|
||||
teams.prevPrev = teams.prev
|
||||
teams.prev = prev
|
||||
return picked
|
||||
end
|
||||
|
||||
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
|
||||
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
|
||||
-- only the sBTTrainers slot and the two previous teams this walk writes are.
|
||||
function BattleTower.drawOpponent(data, save, random)
|
||||
local roster = BattleTower.roster(data)
|
||||
if not roster then return nil end
|
||||
local group = BattleTower.opponentGroup(save, roster)
|
||||
local trainer = BattleTower.chooseTrainer(save, roster, random)
|
||||
if not trainer then return nil end
|
||||
local rows = BattleTower.chooseTeam(save, roster, group, random)
|
||||
return {
|
||||
index = trainer.index,
|
||||
name = trainer.name,
|
||||
class = trainer.class,
|
||||
classId = trainer.classId,
|
||||
sprite = roster.classSprites and roster.classSprites[trainer.classId],
|
||||
group = group,
|
||||
rows = rows,
|
||||
}
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
|
||||
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
|
||||
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
|
||||
-- counter steps BEFORE the battle, which is why beating the seventh opponent
|
||||
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
|
||||
function BattleTower.beginBattle(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
|
||||
tower.streak = counter(tower.streak) + 1
|
||||
return tower.streak
|
||||
end
|
||||
|
||||
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
|
||||
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
|
||||
-- stats go on top of Mon.new's, which agree with them anyway.
|
||||
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
|
||||
-- with GetPokemonName, so the rows carry none.
|
||||
function BattleTower.battleParty(data, rows)
|
||||
local party = {}
|
||||
for _, row in ipairs(rows or {}) do
|
||||
local moves = nil
|
||||
if row.moves and #row.moves > 0 then
|
||||
moves = {}
|
||||
for slot, id in ipairs(row.moves) do
|
||||
local def = data and data.moves and data.moves[id]
|
||||
local max = (def and def.pp) or 0
|
||||
moves[slot] = {
|
||||
id = id,
|
||||
pp = (row.pp and row.pp[slot]) or max,
|
||||
maxPp = max,
|
||||
}
|
||||
end
|
||||
end
|
||||
local dvs = row.dvs or {}
|
||||
local statExp = row.statExp or {}
|
||||
local mon = Mon.new(data, row.species, row.level, {
|
||||
moves = moves,
|
||||
item = row.item,
|
||||
dvs = { attack = dvs.attack, defense = dvs.defense,
|
||||
speed = dvs.speed, special = dvs.special },
|
||||
statExp = { hp = statExp.hp, attack = statExp.attack,
|
||||
defense = statExp.defense, speed = statExp.speed,
|
||||
special = statExp.special },
|
||||
happiness = row.happiness,
|
||||
})
|
||||
if mon then
|
||||
if row.stats then
|
||||
mon.stats = {
|
||||
hp = row.stats.hp, attack = row.stats.attack,
|
||||
defense = row.stats.defense, speed = row.stats.speed,
|
||||
specialAttack = row.stats.specialAttack,
|
||||
specialDefense = row.stats.specialDefense,
|
||||
}
|
||||
end
|
||||
mon.maxHp = row.maxHp or mon.maxHp
|
||||
mon.hp = row.hp or mon.maxHp
|
||||
mon.experience = row.experience or mon.experience
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
end
|
||||
return party
|
||||
end
|
||||
|
||||
return BattleTower
|
||||
+12
-1
@@ -109,6 +109,17 @@ function Boxes.restorePP(mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so
|
||||
-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83).
|
||||
function Boxes.enterBox(mon)
|
||||
if not mon then return mon end
|
||||
Boxes.restorePP(mon)
|
||||
mon.status = nil
|
||||
mon.statusTurns = nil
|
||||
mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp)
|
||||
return mon
|
||||
end
|
||||
|
||||
function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||
if not ok then return false, reason end
|
||||
@@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
box[#box + 1] = mon
|
||||
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
|
||||
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
|
||||
Boxes.restorePP(mon)
|
||||
Boxes.enterBox(mon)
|
||||
return true, mon
|
||||
end
|
||||
|
||||
|
||||
@@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which)
|
||||
rebuilt.status = nil
|
||||
for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end
|
||||
rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel
|
||||
-- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA
|
||||
-- included -- engine/pokemon/move_mon.asm:805.
|
||||
rebuilt.caughtTime = mon.caughtTime
|
||||
rebuilt.caughtLocation = mon.caughtLocation
|
||||
rebuilt.caughtByGender = mon.caughtByGender
|
||||
rebuilt.ot, rebuilt.otId = mon.ot, mon.otId
|
||||
-- CalcExpAtLevel, which is the experience loss.
|
||||
rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel)
|
||||
@@ -1077,7 +1082,9 @@ end
|
||||
-- Returns the new record plus a table of side effects the caller owes:
|
||||
-- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the
|
||||
-- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species.
|
||||
function Breeding.hatch(data, save, index, nickname)
|
||||
-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and
|
||||
-- playerGender -- engine/pokemon/breeding.asm:228.
|
||||
function Breeding.hatch(data, save, index, nickname, where)
|
||||
local party = (save and save.party) or {}
|
||||
local egg = party[index]
|
||||
if not Breeding.isEgg(egg) then return nil end
|
||||
@@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname)
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
if Mon.hasCaughtData(save and save.version) then
|
||||
where = where or {}
|
||||
Mon.setCaughtData(hatched, {
|
||||
level = Mon.CAUGHT_EGG_LEVEL,
|
||||
timeOfDay = where.timeOfDay,
|
||||
landmark = where.landmark,
|
||||
playerGender = where.playerGender
|
||||
or (save.player and save.player.gender),
|
||||
})
|
||||
end
|
||||
party[index] = hatched
|
||||
|
||||
Breeding.markPokedex(save, egg.species)
|
||||
|
||||
@@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes)
|
||||
end
|
||||
boxes = boxes or require("src.core.gen2.Boxes")
|
||||
local box = boxes.box(save, save.currentBox or 1)
|
||||
if box then box[#box + 1] = mon end
|
||||
-- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into
|
||||
-- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90).
|
||||
if box then box[#box + 1] = boxes.enterBox(mon) end
|
||||
return BugContest.BOXED_MON, mon
|
||||
end
|
||||
|
||||
|
||||
@@ -357,6 +357,7 @@ Evolution.MON_FIELDS = {
|
||||
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
|
||||
types = true, moves = true, item = true, status = true, happiness = true,
|
||||
caughtLevel = true, shiny = true, gender = true,
|
||||
caughtTime = true, caughtLocation = true, caughtByGender = true,
|
||||
}
|
||||
|
||||
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
|
||||
@@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry)
|
||||
evolved.experience = mon.experience
|
||||
evolved.status = mon.status
|
||||
evolved.caughtLevel = mon.caughtLevel
|
||||
-- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back
|
||||
-- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293.
|
||||
evolved.caughtTime = mon.caughtTime
|
||||
evolved.caughtLocation = mon.caughtLocation
|
||||
evolved.caughtByGender = mon.caughtByGender
|
||||
-- Anything a future field adds to a party record (mail, pokerus) rides along
|
||||
-- rather than being silently dropped. Only fields Mon.new does NOT own may
|
||||
-- be carried: copying `nickname` back would undo
|
||||
|
||||
@@ -86,8 +86,11 @@ Happiness.EVENT = {
|
||||
ENERGYROOT = 16, -- 10
|
||||
REVIVALHERB = 17, -- 11
|
||||
GROOMING = 18, -- 12
|
||||
-- Crystal only; Gold's enum stops at GROOMING
|
||||
-- (pokegold constants/pokemon_data_constants.asm:205).
|
||||
GAINLEVELATHOME = 19, -- 13
|
||||
}
|
||||
Happiness.NUM_EVENTS = 18
|
||||
Happiness.NUM_EVENTS = 19
|
||||
|
||||
-- data/events/happiness_changes.asm, transcribed row for row. The three
|
||||
-- columns are the three tiers below, in order.
|
||||
@@ -110,6 +113,7 @@ Happiness.CHANGES = {
|
||||
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
|
||||
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
|
||||
{ 3, 3, 1 }, -- 12 Grooming
|
||||
{ 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal)
|
||||
}
|
||||
|
||||
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
|
||||
@@ -174,6 +178,23 @@ function Happiness.change(mon, event)
|
||||
return value
|
||||
end
|
||||
|
||||
-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK
|
||||
-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20.
|
||||
function Happiness.levelUpEvent(mon, landmark)
|
||||
local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil
|
||||
landmark = tonumber(landmark)
|
||||
if not (caught and landmark) then return "GAINLEVEL" end
|
||||
if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then
|
||||
return "GAINLEVEL"
|
||||
end
|
||||
return "GAINLEVELATHOME"
|
||||
end
|
||||
|
||||
-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19.
|
||||
function Happiness.levelUp(mon, landmark)
|
||||
return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark))
|
||||
end
|
||||
|
||||
-- The same event across a party, which is how the Gym Leader award is written
|
||||
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
|
||||
--
|
||||
|
||||
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
|
||||
used = true,
|
||||
level = newLevel,
|
||||
learned = learned,
|
||||
-- data/text/common_1.asm:86
|
||||
sfx = "Sfx_DexFanfare5079",
|
||||
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
||||
}
|
||||
end
|
||||
|
||||
+128
-18
@@ -1,5 +1,5 @@
|
||||
-- The two pieces of Gen 2 world state that override where a wild mon comes
|
||||
-- from: the three roaming legendaries, and swarms.
|
||||
-- from: the roaming legendaries, and swarms.
|
||||
--
|
||||
-- Both live in one module because they are the same KIND of thing -- a
|
||||
-- persistent record that sits in front of a map's own encounter table -- and
|
||||
@@ -26,6 +26,7 @@
|
||||
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
|
||||
-- beast is caught or beaten -- is nil.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -37,7 +38,7 @@ local Roamers = {}
|
||||
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
|
||||
-- teleport triggers) -- so a tracker mod sees every hop.
|
||||
--
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
|
||||
-- slot that roamer record, already carrying the new map
|
||||
-- species the beast's species id
|
||||
-- from the map id it left
|
||||
@@ -68,9 +69,31 @@ Roamers.SPECIES = {
|
||||
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
|
||||
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
|
||||
}
|
||||
Roamers.COUNT = 3
|
||||
Roamers.LEVEL = 40
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
|
||||
-- so the roster is read out of the cache when the cache carries one.
|
||||
local function startMapFor(species)
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
if row.species == species then return row.map end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Roamers.roster(encounters)
|
||||
local extracted = encounters and encounters.roamMons
|
||||
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
|
||||
local rows = {}
|
||||
for _, row in ipairs(extracted) do
|
||||
rows[#rows + 1] = {
|
||||
species = row.species,
|
||||
level = row.level or Roamers.LEVEL,
|
||||
map = row.map or startMapFor(row.species),
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
|
||||
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
|
||||
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
|
||||
@@ -185,7 +208,7 @@ end
|
||||
-- The save record
|
||||
--------------------------------------------------------------------------
|
||||
--
|
||||
-- save.roamers is a three-slot array in the order above, written first by the
|
||||
-- save.roamers is a slot array in the roster order above, written first by the
|
||||
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
|
||||
-- basement script fires. Each slot:
|
||||
--
|
||||
@@ -215,13 +238,15 @@ function Roamers.slot(save, index)
|
||||
end
|
||||
|
||||
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
|
||||
-- flag, but a re-init would hand the player three fresh beasts, so this only
|
||||
-- writes when there is nothing there.
|
||||
-- flag, but a re-init would hand the player fresh beasts, so this only writes
|
||||
-- when there is nothing there.
|
||||
function Roamers.init(save, opts)
|
||||
if type(save) ~= "table" then return nil end
|
||||
if save.roamers and not (opts and opts.force) then return save.roamers end
|
||||
local encounters = opts and (opts.encounters
|
||||
or (opts.data and opts.data.gen2Encounters))
|
||||
local list = {}
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
for _, row in ipairs(Roamers.roster(encounters)) do
|
||||
list[#list + 1] = {
|
||||
species = row.species,
|
||||
level = row.level,
|
||||
@@ -413,7 +438,23 @@ end
|
||||
-- -- the roaming battle is one attack long unless it is trapped. The other
|
||||
-- two lists are the same routine's 50% and 10% gates and live here so the
|
||||
-- battle engine has one place to read them from.
|
||||
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
|
||||
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
|
||||
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
|
||||
Roamers.ALWAYS_FLEE_BY_ENGINE = {
|
||||
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
|
||||
crystal = { RAIKOU = true, ENTEI = true },
|
||||
}
|
||||
|
||||
function Roamers.alwaysFleeMons(versionId)
|
||||
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
|
||||
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
|
||||
end
|
||||
|
||||
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
|
||||
Roamers.ALWAYS_FLEE = setmetatable({}, {
|
||||
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
|
||||
})
|
||||
|
||||
Roamers.OFTEN_FLEE = {
|
||||
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
|
||||
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
|
||||
@@ -433,29 +474,79 @@ local Swarm = {}
|
||||
Roamers.Swarm = Swarm
|
||||
|
||||
-- The state, on the save:
|
||||
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
|
||||
-- save.swarmMaps the stored map pairs, keyed by swarm kind
|
||||
-- save.swarmMap the Gold single pair, kept as the legacy alias
|
||||
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
|
||||
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
|
||||
-- save.dailyResetDay the day wDailyResetTimer was last restarted
|
||||
--
|
||||
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
|
||||
-- already write the first three; this module is where they are READ and where
|
||||
-- they expire.
|
||||
-- already write the flags and the alias; this module is where they are READ
|
||||
-- and where they expire.
|
||||
|
||||
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
|
||||
Swarm.FISH_NONE = 0
|
||||
Swarm.FISH_QWILFISH = 1
|
||||
Swarm.FISH_REMORAID = 2
|
||||
|
||||
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
|
||||
-- puts in front of a `swarm` map, choosing between its two stored pairs.
|
||||
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
|
||||
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
|
||||
Swarm.DEFAULT_KIND = "DUNSPARCE"
|
||||
|
||||
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
|
||||
|
||||
local function kindName(kind)
|
||||
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
|
||||
if KIND_NAMES[kind] then return kind end
|
||||
return Swarm.DEFAULT_KIND
|
||||
end
|
||||
|
||||
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
|
||||
-- (and every save written before this record was keyed) folds onto that key.
|
||||
local function goldShape(maps)
|
||||
return maps.YANMA == nil
|
||||
end
|
||||
|
||||
function Swarm.maps(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then
|
||||
maps = {}
|
||||
save.swarmMaps = maps
|
||||
end
|
||||
if save.swarmMap ~= nil and goldShape(maps) then
|
||||
maps[Swarm.DEFAULT_KIND] = save.swarmMap
|
||||
end
|
||||
return maps
|
||||
end
|
||||
|
||||
local function anyMap(save)
|
||||
if type(save) ~= "table" then return false end
|
||||
if save.swarmMap ~= nil then return true end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then return false end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] ~= nil then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
|
||||
-- writes the map pair AND the daily flag. A port that stored only the map
|
||||
-- would leave the Dunsparce call live for the rest of the game, because
|
||||
-- CheckSwarmFlag answers off the flag and clears the pair itself.
|
||||
function Swarm.set(save, mapId)
|
||||
--
|
||||
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
|
||||
function Swarm.set(save, mapId, kind)
|
||||
if type(save) ~= "table" then return false end
|
||||
save.dailyFlags = save.dailyFlags or {}
|
||||
save.dailyFlags.swarm = true
|
||||
save.swarmMap = mapId
|
||||
local name = kindName(kind)
|
||||
local maps = Swarm.maps(save)
|
||||
maps[name] = mapId
|
||||
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -475,9 +566,22 @@ function Swarm.active(save)
|
||||
and save.dailyFlags.swarm == true
|
||||
end
|
||||
|
||||
function Swarm.mapId(save)
|
||||
function Swarm.mapId(save, kind)
|
||||
if not Swarm.active(save) then return nil end
|
||||
return save.swarmMap
|
||||
local maps = Swarm.maps(save)
|
||||
return maps and maps[kindName(kind)] or nil
|
||||
end
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
|
||||
-- falls through to Yanma, so a map both are on answers Dunsparce.
|
||||
function Swarm.onMap(save, mapId)
|
||||
if mapId == nil or not Swarm.active(save) then return nil end
|
||||
local maps = Swarm.maps(save)
|
||||
if not maps then return nil end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] == mapId then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Swarm.fishing(save)
|
||||
@@ -493,6 +597,12 @@ function Swarm.check(save)
|
||||
if type(save) ~= "table" then return 1 end
|
||||
if Swarm.active(save) then return 0 end
|
||||
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
|
||||
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
|
||||
-- which strands both of Crystal's pairs on the one daily tick.
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) == "table" then
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
|
||||
end
|
||||
save.swarmMap = nil
|
||||
return 1
|
||||
end
|
||||
@@ -518,10 +628,10 @@ end
|
||||
-- rather than needing its own timer. Returns true when the swarm ended on
|
||||
-- this call.
|
||||
function Swarm.timeEvents(save, day)
|
||||
local hadMap = anyMap(save)
|
||||
local reset = Swarm.checkDailyReset(save, day)
|
||||
local hadMap = save and save.swarmMap ~= nil
|
||||
Swarm.check(save)
|
||||
return reset and hadMap and (save.swarmMap == nil)
|
||||
return reset and hadMap and not anyMap(save)
|
||||
end
|
||||
|
||||
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
|
||||
@@ -538,7 +648,7 @@ end
|
||||
-- lookup here falls through to the map's own list.
|
||||
function Swarm.entry(save, encounters, mapId, kind)
|
||||
if not encounters then return nil end
|
||||
if Swarm.mapId(save) ~= mapId then return nil end
|
||||
if not Swarm.onMap(save, mapId) then return nil end
|
||||
local table_ = (kind == "water") and encounters.swarmWater
|
||||
or encounters.swarmGrass
|
||||
return table_ and table_[mapId] or nil
|
||||
|
||||
+90
-7
@@ -133,10 +133,33 @@ local function fs()
|
||||
return love.filesystem
|
||||
end
|
||||
|
||||
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
||||
-- per edition -- data/player_names.asm:12-23.
|
||||
function Save.defaultPlayerName(version)
|
||||
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
||||
-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and
|
||||
-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35.
|
||||
Save.DEFAULT_PLAYER_NAMES = {
|
||||
gold = "GOLD",
|
||||
silver = "SILVER",
|
||||
crystal = "CHRIS",
|
||||
}
|
||||
|
||||
-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to
|
||||
-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781).
|
||||
Save.DEFAULT_PLAYER_NAMES_FEMALE = {
|
||||
crystal = "KRIS",
|
||||
}
|
||||
|
||||
-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177).
|
||||
function Save.isFemale(save)
|
||||
local player = type(save) == "table" and save.player
|
||||
return (player and player.gender) == "female"
|
||||
end
|
||||
|
||||
function Save.defaultPlayerName(version, gender)
|
||||
version = version or GameVersion.get()
|
||||
if gender == "female" then
|
||||
local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version]
|
||||
if female then return female end
|
||||
end
|
||||
return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD"
|
||||
end
|
||||
|
||||
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
||||
@@ -148,10 +171,13 @@ function Save.newGame(opts)
|
||||
version = GameVersion.get(),
|
||||
generation = 2,
|
||||
player = {
|
||||
name = opts.playerName or Save.defaultPlayerName(),
|
||||
name = opts.playerName
|
||||
or Save.defaultPlayerName(nil, opts.gender or "male"),
|
||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||
-- (engine/menus/intro_menu.asm:41-49).
|
||||
id = opts.trainerId or rand(0, 65535),
|
||||
-- InitCrystalData zeroes wPlayerGender before InitGender is even offered
|
||||
-- (engine/menus/init_gender.asm:1-6).
|
||||
gender = opts.gender or "male",
|
||||
money = 3000,
|
||||
coins = 0,
|
||||
@@ -272,9 +298,11 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- uses (src/core/SaveData.lua) and they drive the same shared modules, so
|
||||
-- a player's display and speed choices mean the same thing in both games.
|
||||
speed = 1, -- GameSpeed.LEVELS multiplier, logic only
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto" is the
|
||||
-- Gen 1 save's own default (src/core/SaveData.lua); same key, same module.
|
||||
performance = "auto",
|
||||
zoom = 0, -- Zoom offset from the window's fit scale
|
||||
tilt = 0, -- Tilt.LEVELS degrees, 0 = off
|
||||
gbcfx = 0, -- GBCFX ladder, 0 = off
|
||||
-- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the
|
||||
-- default -- this is a Game Boy Color game, so colour is ON out of the box
|
||||
-- and the other two rungs are the deliberate step DOWN to a grey or green
|
||||
@@ -283,6 +311,8 @@ Save.DEFAULT_OPTIONS = {
|
||||
color = "gbc",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
|
||||
battleBg = "white",
|
||||
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||
-- border block with the dissolve across a boundary (#1418).
|
||||
voidFill = "fade",
|
||||
@@ -379,6 +409,49 @@ local function normalizePokerus(mons)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
|
||||
Save.GS_BALL_STATES = { have = true, given = true, used = true }
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the
|
||||
-- way Mail.state creates sPartyMail, so a Crystal handler can index freely.
|
||||
function Save.crystalState(save)
|
||||
local crystal = save.crystal or {}
|
||||
save.crystal = crystal
|
||||
if crystal.celebiCaught == nil then crystal.celebiCaught = false end
|
||||
crystal.beasts = crystal.beasts or {}
|
||||
-- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance.
|
||||
local buena = crystal.buenaPassword or {}
|
||||
crystal.buenaPassword = buena
|
||||
buena.prizesToday = counter(buena.prizesToday)
|
||||
buena.streak = counter(buena.streak)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor.
|
||||
crystal.moveTutor = crystal.moveTutor or {}
|
||||
if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end
|
||||
-- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns.
|
||||
crystal.unownWords = crystal.unownWords or {}
|
||||
return crystal
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil
|
||||
-- until one is won, which is sBattleTowerReward's zero byte (:160).
|
||||
function Save.battleTowerState(save)
|
||||
local tower = save.battleTower or {}
|
||||
save.battleTower = tower
|
||||
-- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers.
|
||||
tower.streak = counter(tower.streak)
|
||||
tower.best = counter(tower.best)
|
||||
-- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower.
|
||||
tower.challenge = counter(tower.challenge)
|
||||
-- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers.
|
||||
tower.prevTeams = tower.prevTeams or {}
|
||||
if tower.inChallenge == nil then tower.inChallenge = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- Fill in anything a save (or an older save) is missing, so callers can index
|
||||
-- freely. Runs on both newGame and load.
|
||||
function Save.normalize(save)
|
||||
@@ -390,7 +463,11 @@ function Save.normalize(save)
|
||||
end
|
||||
save.generation = 2
|
||||
save.player = save.player or {}
|
||||
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
||||
-- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never
|
||||
-- writes it -- constants/ram_constants.asm:177.
|
||||
save.player.gender = save.player.gender or "male"
|
||||
save.player.name = save.player.name
|
||||
or Save.defaultPlayerName(save.version, save.player.gender)
|
||||
save.player.id = save.player.id or rand(0, 65535)
|
||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||
@@ -462,6 +539,12 @@ function Save.normalize(save)
|
||||
save.playTime = save.playTime
|
||||
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
|
||||
save.rtc = save.rtc or {}
|
||||
-- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a
|
||||
-- Gold or Silver file never grows either key.
|
||||
if GameVersion.engine(save.version) == "crystal" then
|
||||
Save.crystalState(save)
|
||||
Save.battleTowerState(save)
|
||||
end
|
||||
-- HallOfFame.record fills in the count and the roster list, and trims a
|
||||
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
|
||||
-- party gets below, for the same reason.
|
||||
|
||||
@@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version)
|
||||
end
|
||||
|
||||
-- Shallow listing so we can see if the extract tree exists at all.
|
||||
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
||||
local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets",
|
||||
"yellow/assets/generated",
|
||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||
"gold/data/generated", "silver/assets/generated",
|
||||
"silver/data/generated" }
|
||||
"silver/data/generated", "crystal/assets/generated",
|
||||
"crystal/data/generated" }
|
||||
for _, dir in ipairs(roots) do
|
||||
local info = filesystem.getInfo(dir)
|
||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
-- Engine-owned contract for generated ROM caches.
|
||||
--
|
||||
-- A cache is playable only when its versioned marker matches the ROM and every
|
||||
-- required output for that version exists. Extraction writers may differ by
|
||||
-- platform, but they must publish through this contract so partial staging
|
||||
-- cannot look ready to the runtime.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local CacheContract = {}
|
||||
|
||||
CacheContract.FORMAT = "rom-cache-v10:"
|
||||
CacheContract.MARKER_PATH = "rom-cache.complete"
|
||||
|
||||
CacheContract.REQUIRED_FILES = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/text.lua",
|
||||
"data/generated/field.lua",
|
||||
"data/generated/battle_anims.lua",
|
||||
"assets/generated/title/pokemon_logo.png",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/anims/move_anim_0.png",
|
||||
"assets/generated/battle/anims/move_anim_1.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/trade/game_boy.png",
|
||||
}
|
||||
|
||||
CacheContract.VERSION_REQUIRED_FILES = {
|
||||
yellow = {
|
||||
"assets/generated/battle/trainers/jessie_james.png",
|
||||
"assets/generated/battle/profoakb.png",
|
||||
"assets/generated/pikachu/pikapic_1.png",
|
||||
},
|
||||
}
|
||||
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
gold = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/roofs.lua",
|
||||
"data/generated/sprites.lua",
|
||||
"data/generated/scripts.lua",
|
||||
"data/generated/text.lua",
|
||||
-- The engine's label-keyed strings are separate from Gen 2 script text.
|
||||
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
|
||||
-- src/core/RomText.lua does not silently fall back to built-in wording.
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/audio.lua",
|
||||
"data/generated/marts.lua",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/fonts/frames.png",
|
||||
"assets/generated/title/pokemon_logo.png",
|
||||
"assets/generated/title/title_screen.png",
|
||||
"assets/generated/title/hooh.png",
|
||||
"assets/generated/title/hooh_5.png",
|
||||
"assets/generated/title/clouds.png",
|
||||
"assets/generated/title/copyright_splash.png",
|
||||
"data/generated/oak_speech.lua",
|
||||
"assets/generated/intro/oak.png",
|
||||
"assets/generated/intro/cal.png",
|
||||
"assets/generated/tilesets/johto.png",
|
||||
"assets/generated/tilesets/roofs/new_bark.png",
|
||||
"assets/generated/sprites/chris.png",
|
||||
"assets/generated/battle/front/chikorita.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/front/marill.png",
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
"assets/generated/pc/mail_item.png",
|
||||
-- engine/events/fishing_gfx.asm:23
|
||||
"assets/generated/emotes/fishing.png",
|
||||
},
|
||||
crystal = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/roofs.lua",
|
||||
"data/generated/sprites.lua",
|
||||
"data/generated/scripts.lua",
|
||||
"data/generated/text.lua",
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/encounters.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/landmarks.lua",
|
||||
"data/generated/audio.lua",
|
||||
"data/generated/marts.lua",
|
||||
"data/generated/oak_speech.lua",
|
||||
"data/generated/title.lua",
|
||||
"data/generated/intro.lua",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/fonts/frames.png",
|
||||
"assets/generated/title/crystal_logo.png",
|
||||
"assets/generated/title/crystal_wordmark.png",
|
||||
"assets/generated/title/crystal_suicune.png",
|
||||
"assets/generated/title/copyright_splash.png",
|
||||
"assets/generated/splash/ditto.png",
|
||||
"assets/generated/intro/chris.png",
|
||||
"assets/generated/intro/kris.png",
|
||||
"assets/generated/intro/suicune_run_sprites.png",
|
||||
"assets/generated/intro/unowns_tiles.png",
|
||||
"assets/generated/intro/oak.png",
|
||||
"assets/generated/tilesets/johto.png",
|
||||
"assets/generated/tilesets/roofs/new_bark.png",
|
||||
"assets/generated/sprites/chris.png",
|
||||
"assets/generated/sprites/kris.png",
|
||||
"assets/generated/battle/front/chikorita.png",
|
||||
"assets/generated/battle/front/wooper.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
"assets/generated/pc/mail_item.png",
|
||||
"assets/generated/trainer_card/card_f.png",
|
||||
"data/generated/mobile_gfx.lua",
|
||||
"assets/generated/battle/player_back_female.png",
|
||||
"assets/generated/battle/trainers/kris.png",
|
||||
"assets/generated/battle/trainers/chris.png",
|
||||
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
|
||||
"assets/generated/emotes/fishing.png",
|
||||
},
|
||||
}
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||
|
||||
function CacheContract.requiredFilesFor(version)
|
||||
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||
if override then return override, true end
|
||||
return CacheContract.REQUIRED_FILES, false
|
||||
end
|
||||
|
||||
function CacheContract.markerFor(version)
|
||||
return CacheContract.FORMAT .. GameVersion.info(version).sha1
|
||||
end
|
||||
|
||||
-- Keep the process-global CacheFs prefix isolated even when a filesystem
|
||||
-- adapter raises while probing or publishing. The real CacheFs methods
|
||||
-- return errors, but this also makes the contract safe for platform adapters
|
||||
-- that surface I/O failures as Lua errors.
|
||||
local function withVersionPrefix(version, fs, action)
|
||||
local saved = fs.prefix
|
||||
fs.prefix = GameVersion.cachePrefix(version)
|
||||
local ok, first, second = pcall(action)
|
||||
fs.prefix = saved
|
||||
if not ok then return false, first end
|
||||
return true, first, second
|
||||
end
|
||||
|
||||
function CacheContract.allRequiredFilesExist(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local ok, complete, missing = withVersionPrefix(version, fs, function()
|
||||
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||
local missingPath
|
||||
for _, path in ipairs(required) do
|
||||
if not fs.exists(path) then missingPath = path; break end
|
||||
end
|
||||
if not missingPath and not isOverride then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
if not fs.exists(path) then missingPath = path; break end
|
||||
end
|
||||
end
|
||||
return missingPath == nil, missingPath
|
||||
end)
|
||||
if not ok then return false, complete end
|
||||
return complete, missing
|
||||
end
|
||||
|
||||
function CacheContract.readMarker(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local ok, marker, readError = withVersionPrefix(version, fs, function()
|
||||
return fs.read(CacheContract.MARKER_PATH)
|
||||
end)
|
||||
if not ok then return nil, marker end
|
||||
-- LÖVE may return contents plus a byte count; only a nil contents result
|
||||
-- makes the auxiliary value an error. CacheFs' portable reader returns
|
||||
-- just the contents, so this remains adapter-neutral.
|
||||
if marker == nil then return nil, readError end
|
||||
return marker
|
||||
end
|
||||
|
||||
function CacheContract.isReady(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
if CacheContract.sourceTreeHasData(version) then return true end
|
||||
local marker, readError = CacheContract.readMarker(version, fs)
|
||||
if readError or marker ~= CacheContract.markerFor(version) then return false end
|
||||
return CacheContract.allRequiredFilesExist(version, fs)
|
||||
end
|
||||
|
||||
function CacheContract.publish(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||
if not complete then
|
||||
-- A caller may be retrying over a partially replaced cache. Do not
|
||||
-- leave its old marker advertising readiness after this failed check.
|
||||
local removed, removeError = withVersionPrefix(version, fs, function()
|
||||
if not fs.remove then
|
||||
error("cache filesystem cannot remove the completion marker")
|
||||
end
|
||||
return fs.remove(CacheContract.MARKER_PATH)
|
||||
end)
|
||||
if not removed then
|
||||
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||
.. "; could not remove completion marker: " .. tostring(removeError)
|
||||
end
|
||||
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||
end
|
||||
local changed, ok, err = withVersionPrefix(version, fs, function()
|
||||
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version))
|
||||
end)
|
||||
if not changed then return false, tostring(ok) end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
function CacheContract.sourceTreeHasData(version)
|
||||
if not (love and love.filesystem and love.filesystem.getInfo
|
||||
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
|
||||
return false
|
||||
end
|
||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||
local source = love.filesystem.getSource()
|
||||
for _, path in ipairs(required) do
|
||||
local fullPath = prefix .. path
|
||||
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||
return false
|
||||
end
|
||||
end
|
||||
if not isOverride then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
local fullPath = prefix .. path
|
||||
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return CacheContract
|
||||
+59
-8
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
|
||||
return love.filesystem.write(rel, data)
|
||||
end
|
||||
|
||||
-- Open a cache-relative file for streaming replacement. The returned handle
|
||||
-- has write(bytes) and close() methods and follows the same portable/save-dir
|
||||
-- routing as CacheFs.write without forcing the caller to hold the whole file
|
||||
-- in one Lua string.
|
||||
function CacheFs.openWrite(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
ensureParents(root, rel)
|
||||
local f, err = io.open(realPath(root, rel), "wb")
|
||||
if not f then return nil, err end
|
||||
return {
|
||||
write = function(_, data)
|
||||
local ok, writeErr = f:write(data)
|
||||
if not ok then return nil, writeErr end
|
||||
return true
|
||||
end,
|
||||
close = function() f:close() end,
|
||||
}
|
||||
end
|
||||
if not (love and love.filesystem and love.filesystem.newFile) then
|
||||
return nil, "streaming cache writes are unavailable"
|
||||
end
|
||||
local parent = rel:match("^(.*)/[^/]+$")
|
||||
if parent and not love.filesystem.createDirectory(parent) then
|
||||
local info = love.filesystem.getInfo(parent)
|
||||
local reason = info and ("a " .. info.type .. " already exists there")
|
||||
or "unknown reason"
|
||||
return nil, "could not create " .. parent .. ": " .. reason
|
||||
end
|
||||
local file, makeErr = love.filesystem.newFile(rel)
|
||||
if not file then return nil, makeErr or "could not create cache file" end
|
||||
local ok, openErr = file:open("w")
|
||||
if not ok then return nil, openErr or "could not open cache file" end
|
||||
return file
|
||||
end
|
||||
|
||||
-- read cache-relative `rel`; returns the bytes or nil
|
||||
function CacheFs.read(rel)
|
||||
rel = withPrefix(rel)
|
||||
@@ -577,14 +614,18 @@ function CacheFs.mountVersion(version)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on one game's save,
|
||||
-- close it, and press Play on another: with the first version's subtree
|
||||
-- still prepended, the other's require("data.generated.*") and generated
|
||||
-- art would silently resolve to the first game's files. Callers must also
|
||||
-- drop the generated modules from package.loaded
|
||||
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
|
||||
-- path, not what require already cached.
|
||||
-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
|
||||
-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
|
||||
-- the version folder. PHYSFS resolves by stack order; peeling the wrong
|
||||
-- layer first can leave another version's generated files winning a name.
|
||||
--
|
||||
-- A process normally mounts exactly one version and then boots it, but the
|
||||
-- launcher can open the save editor on one game's save, close it, and press
|
||||
-- Play on another: with the first version's subtree still prepended, the
|
||||
-- other's require("data.generated.*") and generated art would silently
|
||||
-- resolve to the first game's files. Callers must also drop the generated
|
||||
-- modules from package.loaded (src.core.Data:unloadGenerated) -- unmounting
|
||||
-- alone only fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took.
|
||||
function CacheFs.unmountVersion(version)
|
||||
@@ -596,6 +637,16 @@ function CacheFs.unmountVersion(version)
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
local done = false
|
||||
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
|
||||
if love.filesystem and love.filesystem.unmount then
|
||||
local generated = {
|
||||
prefix .. "assets/generated",
|
||||
prefix .. "data/generated",
|
||||
}
|
||||
for _, src in ipairs(generated) do
|
||||
done = love.filesystem.unmount(src) or done
|
||||
end
|
||||
end
|
||||
local fn = resolveUnmount()
|
||||
if fn and base then
|
||||
done = fn(base .. SEP .. sub) or done
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
-- Crystal's intro movie and title screen extraction (pokecrystal
|
||||
-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374).
|
||||
-- Called from RomExtractorGen2's extractIntro/extractTitle stages when
|
||||
-- edition == "crystal"; reads through the passed-in extractor and never
|
||||
-- mutates it.
|
||||
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
|
||||
local CrystalMovie = {}
|
||||
|
||||
local TILE_BYTES = 16
|
||||
local SHEET_TILES = 16
|
||||
|
||||
-- vBGMap tilemaps and attrmaps are one full 32x32 map
|
||||
-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes).
|
||||
local MAP_BYTES = 1024
|
||||
|
||||
local function padTiles(raw, count)
|
||||
local length = count * TILE_BYTES
|
||||
while #raw > length do table.remove(raw) end
|
||||
while #raw < length do raw[#raw + 1] = 0 end
|
||||
return raw
|
||||
end
|
||||
|
||||
-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target`
|
||||
-- starting at tile `at` (0-based).
|
||||
local function overlayTiles(target, source, at, from, count)
|
||||
for offset = 1, count * TILE_BYTES do
|
||||
target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function blankTiles(count)
|
||||
local out = {}
|
||||
for index = 1, count * TILE_BYTES do out[index] = 0 end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16),
|
||||
-- the same layout data/sprite_anims/oam.asm:120-136 assumes.
|
||||
local function writeSheet(self, raw, count, relative)
|
||||
padTiles(raw, count)
|
||||
local rows = math.ceil(count / SHEET_TILES)
|
||||
padTiles(raw, rows * SHEET_TILES)
|
||||
self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true)
|
||||
return "assets/generated/" .. relative
|
||||
end
|
||||
|
||||
local function readMap(self, label)
|
||||
local bytes = self:decompressLz3Symbol(label)
|
||||
while #bytes > MAP_BYTES do table.remove(bytes) end
|
||||
while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end
|
||||
return bytes
|
||||
end
|
||||
|
||||
-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies
|
||||
-- `16 palettes` over wBGPals1, which wOBPals1 follows).
|
||||
local function readPalettes(self, label)
|
||||
local symbol = self:symbol(label)
|
||||
local flat = self:colors(symbol.bank, symbol.address, 64)
|
||||
local bg, obj = {}, {}
|
||||
for pal = 0, 7 do
|
||||
local a, b = {}, {}
|
||||
for slot = 1, 4 do
|
||||
a[slot] = flat[pal * 4 + slot]
|
||||
b[slot] = flat[(pal + 8) * 4 + slot]
|
||||
end
|
||||
bg[pal + 1], obj[pal + 1] = a, b
|
||||
end
|
||||
return { bg = bg, obj = obj }
|
||||
end
|
||||
|
||||
local function readColors(self, label, count)
|
||||
local symbol = self:symbol(label)
|
||||
return self:colors(symbol.bank, symbol.address, count)
|
||||
end
|
||||
|
||||
function CrystalMovie.extractIntro(self)
|
||||
self:beginStage("Intro movie")
|
||||
local out = {
|
||||
generation = 2,
|
||||
layout = "crystal",
|
||||
source = "ROM:CrystalIntro (engine/movie/intro.asm)",
|
||||
}
|
||||
|
||||
local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128)
|
||||
local grassSym = self:symbol("IntroGrass4GFX")
|
||||
local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES)
|
||||
|
||||
local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png")
|
||||
local pulsePath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png")
|
||||
local backgroundPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroBackgroundGFX"), 128,
|
||||
"intro/background_tiles.png")
|
||||
local runPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192,
|
||||
"intro/suicune_run_sprites.png")
|
||||
local pichuPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPichuWooperGFX"), 128,
|
||||
"intro/pichu_wooper_sprites.png")
|
||||
self:tick("Intro movie", 1, 4)
|
||||
|
||||
-- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0,
|
||||
-- i.e. BG id $80 (engine/movie/intro.asm:744-753).
|
||||
local jump = blankTiles(256)
|
||||
overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128)
|
||||
overlayTiles(jump, grass4, 0x80, 0, 1)
|
||||
local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png")
|
||||
|
||||
-- The same grass tile is the whole of the SUICUNE_AWAY object's sheet
|
||||
-- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753).
|
||||
local unownBack = blankTiles(144)
|
||||
overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48)
|
||||
overlayTiles(unownBack, grass4, 0x80, 0, 1)
|
||||
local unownBackPath = writeSheet(self, unownBack, 144,
|
||||
"intro/unown_back_sprites.png")
|
||||
|
||||
-- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0
|
||||
-- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828).
|
||||
local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255)
|
||||
local close = blankTiles(256)
|
||||
for id = 0, 255 do
|
||||
local from = (id + 128) % 256
|
||||
if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end
|
||||
end
|
||||
local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png")
|
||||
|
||||
-- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and
|
||||
-- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901).
|
||||
local back = blankTiles(256)
|
||||
overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128)
|
||||
overlayTiles(back, unowns, 128, 0, 128)
|
||||
overlayTiles(back, grass4, 0xff, 0, 1)
|
||||
local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png")
|
||||
|
||||
local crystalUnownsPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32,
|
||||
"intro/crystal_unowns_tiles.png")
|
||||
|
||||
-- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3
|
||||
-- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4.
|
||||
local grassStrip = blankTiles(16)
|
||||
for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX",
|
||||
"IntroGrass3GFX" }) do
|
||||
local sym = self:symbol(label)
|
||||
overlayTiles(grassStrip,
|
||||
self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4,
|
||||
0, 4)
|
||||
end
|
||||
out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png")
|
||||
self:tick("Intro movie", 2, 4)
|
||||
|
||||
local unownPals = readPalettes(self, "IntroUnownsPalette")
|
||||
local backgroundPals = readPalettes(self, "IntroBackgroundPalette")
|
||||
local suicunePals = readPalettes(self, "IntroSuicunePalette")
|
||||
local closePals = readPalettes(self, "IntroSuicuneClosePalette")
|
||||
local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette")
|
||||
|
||||
local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes)
|
||||
return {
|
||||
tiles = tiles,
|
||||
sprites = sprites,
|
||||
tilemap = readMap(self, tilemapLabel),
|
||||
attrmap = readMap(self, attrmapLabel),
|
||||
palettes = palettes,
|
||||
}
|
||||
end
|
||||
|
||||
out.acts = {
|
||||
unownA = act(unownsPath, pulsePath,
|
||||
"IntroUnownATilemap", "IntroUnownAAttrmap", unownPals),
|
||||
unownHI = act(unownsPath, pulsePath,
|
||||
"IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals),
|
||||
unowns = act(unownsPath, pulsePath,
|
||||
"IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals),
|
||||
background = act(backgroundPath, runPath,
|
||||
"IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals),
|
||||
suicuneJump = act(jumpPath, unownBackPath,
|
||||
"IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals),
|
||||
suicuneClose = act(closePath, nil,
|
||||
"IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals),
|
||||
suicuneBack = act(backPath, unownBackPath,
|
||||
"IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals),
|
||||
crystalUnowns = act(crystalUnownsPath, nil,
|
||||
"IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap",
|
||||
crystalUnownsPals),
|
||||
}
|
||||
-- IntroScene7/10 load pichu_wooper into VRAM bank 1
|
||||
-- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet.
|
||||
out.acts.background.sprites1 = pichuPath
|
||||
self:tick("Intro movie", 3, 4)
|
||||
|
||||
out.fades = {
|
||||
toWhite = {},
|
||||
unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4),
|
||||
unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4),
|
||||
wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16),
|
||||
wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16),
|
||||
}
|
||||
-- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each
|
||||
-- (engine/movie/intro.asm:1155-1189).
|
||||
local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32)
|
||||
for row = 0, 7 do
|
||||
local pal = {}
|
||||
for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end
|
||||
out.fades.toWhite[row + 1] = pal
|
||||
end
|
||||
|
||||
self:write("intro", out)
|
||||
self:tick("Intro movie", 4, 4)
|
||||
return out
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local function shadeOf(r)
|
||||
if r > 0.9 then return 0 end
|
||||
if r > 0.5 then return 1 end
|
||||
if r > 0.2 then return 2 end
|
||||
return 3
|
||||
end
|
||||
|
||||
local function tilesFrom2bpp(raw)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do
|
||||
local one = {}
|
||||
for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
|
||||
local function blitTile(target, tile, tx, ty)
|
||||
if not tile then return end
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = tile:getPixel(x, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function colorize(image, palFor)
|
||||
local w, h = image:getDimensions()
|
||||
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, _, _, a = image:getPixel(x, y)
|
||||
if a ~= 0 then
|
||||
local pal = palFor(x, y)
|
||||
local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 }
|
||||
out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- engine/movie/splash.asm:20-30 sets SCGB_GAMEFREAK_LOGO before Copyright, so
|
||||
-- the card runs on gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG.
|
||||
local COPYRIGHT_TILES = 29
|
||||
local COPYRIGHT_BG = {
|
||||
{ 0, 0, 0 }, { 66, 90, 90 }, { 173, 173, 173 }, { 255, 255, 255 },
|
||||
}
|
||||
|
||||
-- data/copyright.asm at hlcoord 2, 7 (engine/menus/intro_menu.asm:1315-1326).
|
||||
local COPYRIGHT_LINES = {
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c },
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x7a, 0x7b, 0x7c },
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c },
|
||||
}
|
||||
|
||||
local function extractCopyright(self)
|
||||
local symbol = self:symbol("CopyrightGFX")
|
||||
local raw = self.rom:bytes(symbol.bank, symbol.address,
|
||||
COPYRIGHT_TILES * TILE_BYTES)
|
||||
self:write2bpp(raw, COPYRIGHT_TILES * 8, 8, "title/copyright.png")
|
||||
|
||||
local painted = {}
|
||||
for index, tile in ipairs(tilesFrom2bpp(raw)) do
|
||||
painted[index] = colorize(tile, function() return COPYRIGHT_BG end)
|
||||
end
|
||||
local backdrop = COPYRIGHT_BG[1]
|
||||
local splash = ImageWriter.blank(160, 144,
|
||||
backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
for row, line in ipairs(COPYRIGHT_LINES) do
|
||||
for column, id in ipairs(line) do
|
||||
blitTile(splash, painted[id - 0x60 + 1], (1 + column) * 8, (6 + row) * 8)
|
||||
end
|
||||
end
|
||||
self:save(splash, "title/copyright_splash.png")
|
||||
end
|
||||
|
||||
function CrystalMovie.extractTitle(self)
|
||||
self:beginStage("Title screen")
|
||||
|
||||
-- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and
|
||||
-- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94).
|
||||
local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX"))
|
||||
-- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f
|
||||
-- (engine/movie/title.asm:24-27).
|
||||
local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX"))
|
||||
local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX"))
|
||||
local pals = readPalettes(self, "TitleScreenPalettes")
|
||||
self:tick("Title screen", 1, 5)
|
||||
|
||||
-- Attribute regions from _TitleScreen's ByteFills
|
||||
-- (engine/movie/title.asm:39-85): logo gradient rows, the version strip,
|
||||
-- pal 7 on the window copyright line, pal 0 everywhere else.
|
||||
local function bgPalAt(col, row)
|
||||
if row == 9 and col >= 5 and col <= 15 then return 1 end
|
||||
if row >= 3 and row <= 4 then return 2 end
|
||||
if row == 5 then return 3 end
|
||||
if row == 6 then return 4 end
|
||||
if row == 7 then return 5 end
|
||||
if row >= 8 and row <= 9 then return 6 end
|
||||
if row == 17 then return 7 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0)
|
||||
-- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80
|
||||
-- (engine/movie/title.asm:107-112,275-302).
|
||||
for row = 0, 6 do
|
||||
for col = 0, 19 do
|
||||
blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8)
|
||||
end
|
||||
end
|
||||
-- Copyright line: 13 tiles from d=$c on the window's row 0, shown at
|
||||
-- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122).
|
||||
for index = 0, 12 do
|
||||
blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8)
|
||||
end
|
||||
|
||||
local colored = colorize(shade, function(x, y)
|
||||
return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
|
||||
end)
|
||||
self:save(colored, "title/crystal_screen.png")
|
||||
self:save(shade, "title/crystal_screen_gray.png")
|
||||
|
||||
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0)
|
||||
ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56)
|
||||
self:save(logo, "title/crystal_logo.png")
|
||||
local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0)
|
||||
ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8)
|
||||
self:save(wordmark, "title/crystal_wordmark.png")
|
||||
self:tick("Title screen", 2, 5)
|
||||
|
||||
-- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16;
|
||||
-- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273).
|
||||
local suicunePaths, suicuneGrayPaths = {}, {}
|
||||
for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do
|
||||
local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0)
|
||||
for row = 0, 5 do
|
||||
for col = 0, 7 do
|
||||
blitTile(frame, suicuneTiles[base + row * 16 + col + 1],
|
||||
col * 8, row * 8)
|
||||
end
|
||||
end
|
||||
local tinted = colorize(frame, function() return pals.bg[1] end)
|
||||
local rel = ("title/crystal_suicune_%d.png"):format(index)
|
||||
self:save(tinted, rel)
|
||||
suicunePaths[index] = "assets/generated/" .. rel
|
||||
local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index)
|
||||
self:save(frame, grayRel)
|
||||
suicuneGrayPaths[index] = "assets/generated/" .. grayRel
|
||||
if index == 1 then self:save(tinted, "title/crystal_suicune.png") end
|
||||
end
|
||||
self:tick("Title screen", 3, 5)
|
||||
|
||||
-- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive
|
||||
-- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338).
|
||||
local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0)
|
||||
for strip = 0, 4 do
|
||||
for slot = 0, 5 do
|
||||
local tile = strip * 12 + slot * 2
|
||||
blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16)
|
||||
blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8)
|
||||
end
|
||||
end
|
||||
local gemTinted = colorize(gem, function() return pals.obj[1] end)
|
||||
self:save(gemTinted, "title/crystal_gem.png")
|
||||
self:save(gem, "title/crystal_gem_gray.png")
|
||||
extractCopyright(self)
|
||||
self:tick("Title screen", 4, 5)
|
||||
|
||||
local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end
|
||||
|
||||
local data = {
|
||||
generation = 2,
|
||||
layout = "crystal_title",
|
||||
source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX"
|
||||
.. " + TitleScreenPalettes + CopyrightGFX",
|
||||
screen = "assets/generated/title/crystal_screen.png",
|
||||
screenGray = "assets/generated/title/crystal_screen_gray.png",
|
||||
image = "assets/generated/title/crystal_logo.png",
|
||||
wordmark = "assets/generated/title/crystal_wordmark.png",
|
||||
suicune = "assets/generated/title/crystal_suicune.png",
|
||||
suicuneFrames = suicunePaths,
|
||||
suicuneFramesGray = suicuneGrayPaths,
|
||||
-- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels.
|
||||
suicuneX = 48,
|
||||
suicuneY = 96,
|
||||
-- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243).
|
||||
suicuneEvery = 8,
|
||||
gem = "assets/generated/title/crystal_gem.png",
|
||||
gemGray = "assets/generated/title/crystal_gem_gray.png",
|
||||
copyright = "assets/generated/title/copyright.png",
|
||||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||||
copyrightBackdrop = unit(COPYRIGHT_BG[1]),
|
||||
copyrightInk = unit(COPYRIGHT_BG[4]),
|
||||
-- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen
|
||||
-- (56, 6) (engine/movie/title.asm:306-311,340-362).
|
||||
gemX = 56,
|
||||
gemY = 6,
|
||||
gemFromY = -50,
|
||||
gemStep = 2,
|
||||
-- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line
|
||||
-- signage over the logo's 80 lines; the copyright window sits at
|
||||
-- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111).
|
||||
entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 },
|
||||
entranceSfx = "Sfx_TitleScreenEntrance",
|
||||
-- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136).
|
||||
timeoutFrames = 73 * 60 + 36,
|
||||
sky = unit(pals.bg[1][1]),
|
||||
below = unit(pals.bg[1][1]),
|
||||
palettes = { bg = pals.bg, obj = pals.obj },
|
||||
}
|
||||
self:write("title", data)
|
||||
self:tick("Title screen", 5, 5)
|
||||
return data
|
||||
end
|
||||
|
||||
return CrystalMovie
|
||||
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c).
|
||||
-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8
|
||||
-- tiles for OBJ mode; this restores row-major sheet order for PNGs.
|
||||
function ImageWriter.deinterleave(raw, width, bytesPerTile)
|
||||
bytesPerTile = bytesPerTile or 16
|
||||
local widthTiles = width / 8
|
||||
local numTiles = #raw / bytesPerTile
|
||||
local out = {}
|
||||
for i = 0, numTiles - 1 do
|
||||
local row = math.floor(i / widthTiles)
|
||||
local src = i * 2 - (row % 2 == 1
|
||||
and widthTiles * (row + 1) - 1
|
||||
or widthTiles * row)
|
||||
for offset = 1, bytesPerTile do
|
||||
out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset]
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function ImageWriter.save(image, path)
|
||||
local ok, fileData = pcall(image.encode, image, "png")
|
||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
|
||||
-- next boot's applyOptions pick the values up. Every ladder mirrors
|
||||
-- OptionsMenu's semantics and stored values; when editing one, keep the two
|
||||
-- in sync. ZOOM is deliberately absent: its range depends on the live
|
||||
-- renderer's fit scale (Renderer:fitScale), which does not exist here.
|
||||
-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
|
||||
-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
|
||||
--
|
||||
-- Rows are the same descriptor idiom OptionRows draws in game:
|
||||
-- { label, value = fn() -> string, step = fn(dir) -> changed,
|
||||
@@ -266,14 +266,12 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- issue #136: GBC FX soft-bricks the mobile present shader; same gate as
|
||||
-- the in-game row.
|
||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||
if okFx and GBCFX.isSupported() then
|
||||
add(Strings("GBC FX"),
|
||||
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||
function(dir)
|
||||
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
||||
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||
return true
|
||||
end)
|
||||
end
|
||||
@@ -303,15 +301,12 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
|
||||
-- hint, which iOS reads only at startup (the Info.plist governs there) and
|
||||
-- desktop ignores. Unlike the other launcher rows this one live-applies:
|
||||
-- the window exists here too, and rotating under the player's finger is
|
||||
-- the only feedback that reads.
|
||||
-- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
|
||||
-- this one live-applies: the window exists here too, and rotating under
|
||||
-- the player's finger is the only feedback that reads.
|
||||
do
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
||||
if okOr and osName == "Android" then
|
||||
if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||
add(Strings("ORIENTATION"),
|
||||
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
||||
function(dir)
|
||||
@@ -646,6 +641,16 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||
function(dir)
|
||||
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
||||
if okFill and BorderFill.VOID_FILLS then
|
||||
add(Strings("VOID FILL"),
|
||||
@@ -661,17 +666,6 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Same #136 gate as the Gen 1 row and the in-game one.
|
||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||
if okFx and GBCFX.isSupported() then
|
||||
add(Strings("GBC FX"),
|
||||
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
|
||||
function(dir)
|
||||
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
|
||||
if okVm then
|
||||
add(Strings("VIDEO MODE"),
|
||||
@@ -692,6 +686,10 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours.
|
||||
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
|
||||
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
|
||||
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
return rows
|
||||
|
||||
+1106
-202
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user