mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
+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 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/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"},
|
||||
},
|
||||
},
|
||||
|
||||
+36
-22
@@ -381,22 +381,35 @@ M.VERMILION_CITY = {
|
||||
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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+45
-69
@@ -11,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 = {
|
||||
@@ -33,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 = {
|
||||
@@ -45,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 = {
|
||||
@@ -57,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
|
||||
@@ -895,16 +904,6 @@ 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")
|
||||
@@ -941,52 +940,29 @@ M.VERMILION_DOCK = {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -922,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,
|
||||
|
||||
@@ -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.
|
||||
@@ -576,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`
|
||||
|
||||
+1
-1
@@ -963,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`,
|
||||
|
||||
@@ -15,9 +15,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -287,9 +287,9 @@ 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,
|
||||
@@ -311,6 +311,11 @@ local function returnToLauncher()
|
||||
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)
|
||||
|
||||
SessionLifecycle.endMountedSession(currentVersion)
|
||||
|
||||
@@ -328,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
|
||||
@@ -341,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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
+59
-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
|
||||
@@ -77,6 +114,7 @@ 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
|
||||
@@ -87,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
|
||||
@@ -133,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)"
|
||||
@@ -163,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)
|
||||
|
||||
@@ -638,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
|
||||
@@ -1918,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
|
||||
@@ -1942,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
|
||||
@@ -4176,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)
|
||||
@@ -4356,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
|
||||
@@ -4733,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?")
|
||||
@@ -5322,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.
|
||||
@@ -5362,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
|
||||
@@ -6226,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -2457,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
|
||||
@@ -2466,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!" })
|
||||
@@ -2913,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)
|
||||
@@ -3965,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.
|
||||
@@ -3978,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
|
||||
|
||||
@@ -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
|
||||
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
|
||||
ticks = ticks + duration
|
||||
end
|
||||
end
|
||||
extendDrumEnvelope(segments)
|
||||
self.noiseInstruments[key] = segments
|
||||
return segments
|
||||
end
|
||||
|
||||
+7
-14
@@ -830,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
|
||||
@@ -1271,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)
|
||||
@@ -1287,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
|
||||
@@ -1302,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)
|
||||
|
||||
+125
-47
@@ -19,6 +19,7 @@ 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")
|
||||
@@ -288,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
|
||||
@@ -354,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
|
||||
@@ -1095,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()
|
||||
@@ -1158,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.
|
||||
@@ -1356,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
|
||||
@@ -1397,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
|
||||
@@ -1432,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
|
||||
@@ -1456,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
|
||||
@@ -1470,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)
|
||||
@@ -1515,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
|
||||
@@ -1584,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
|
||||
@@ -1688,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
|
||||
@@ -1723,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)
|
||||
@@ -1964,7 +2024,8 @@ 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)
|
||||
@@ -1991,9 +2052,26 @@ 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
|
||||
|
||||
+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]
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-12
@@ -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
|
||||
@@ -93,8 +111,7 @@ function Performance.detect()
|
||||
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
|
||||
|
||||
+521
-122
@@ -272,10 +272,9 @@ function SaveData.defaultOptions()
|
||||
speedOverworld = 1,
|
||||
speedBattle = 1,
|
||||
speedMenu = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
gbcfx = 0,
|
||||
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
@@ -290,8 +289,8 @@ function SaveData.defaultOptions()
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||
-- logic. See src/core/Performance.lua.
|
||||
-- extras); scales TILT / survey ZOOM / FPS but never game logic. See
|
||||
-- src/core/Performance.lua.
|
||||
performance = "auto",
|
||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||
@@ -360,9 +359,24 @@ function SaveData.defaultOptions()
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
|
||||
pendingConflicts = {} },
|
||||
-- Cart-scoped settings, beside cartSlots: cartOptions[cartId][key] layers
|
||||
-- over the global value for CART_OPTION_KEYS while that cart is playing.
|
||||
cartOptions = {},
|
||||
-- The player's own answer for a mod a cart pins, kept out of the per-game
|
||||
-- flags: cartMods[cartId][modId], absent while the cart's switch stands.
|
||||
cartMods = {},
|
||||
-- The carts whose shipped defaults were already seeded, so a player's
|
||||
-- later change is never overwritten (compare modProfilesSeeded).
|
||||
cartOptionsSeeded = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Settings that belong to a playthrough and follow the cart: pacing and the
|
||||
-- battle rules. Everything else -- audio, video, bindings, touch, language,
|
||||
-- launcher state -- belongs to the machine and stays global.
|
||||
SaveData.CART_OPTION_KEYS = { "animations", "battleStyle", "ruleset",
|
||||
"speedBattle", "speedMenu", "speedOverworld", "textSpeed" }
|
||||
|
||||
-- Merge loaded keys over defaults (shallow). Unknown keys are kept so
|
||||
-- future options aren't dropped by older builds writing the file back.
|
||||
function SaveData.mergeOptions(loaded)
|
||||
@@ -437,6 +451,60 @@ end
|
||||
|
||||
-- ------- options
|
||||
|
||||
-- The cart being played, if any; SaveData.setCart owns it. Declared here
|
||||
-- because the options overlay below reads it.
|
||||
local activeCart, activeCartHash
|
||||
|
||||
local function cartBucket(opts, cartId)
|
||||
local root = type(opts) == "table" and opts.cartOptions or nil
|
||||
local bucket = type(root) == "table" and root[cartId] or nil
|
||||
return type(bucket) == "table" and bucket or nil
|
||||
end
|
||||
|
||||
-- Lay the active cart's values over the globals for the cart-scoped keys; a
|
||||
-- key the cart never set falls through to the global value.
|
||||
local function applyCartOverlay(opts)
|
||||
if not (activeCart and type(opts) == "table") then return opts end
|
||||
local bucket = cartBucket(opts, activeCart)
|
||||
if not bucket then return opts end
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if bucket[key] ~= nil then opts[key] = bucket[key] end
|
||||
end
|
||||
return opts
|
||||
end
|
||||
|
||||
local function cartGlobals(onDisk)
|
||||
local out = {}
|
||||
if type(onDisk) ~= "table" then return out end
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do out[key] = onDisk[key] end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The inverse, run just before a write: the cart-scoped keys go into the
|
||||
-- active cart's bucket and the globals keep whatever the file already held.
|
||||
local function splitCartOverlay(opts, globals, onDisk)
|
||||
if not (activeCart and type(opts) == "table") then return opts end
|
||||
local root = {}
|
||||
local prior = type(onDisk) == "table" and onDisk.cartOptions or nil
|
||||
if type(prior) == "table" then
|
||||
for id, bucket in pairs(prior) do root[id] = deepCopy(bucket) end
|
||||
end
|
||||
for id, bucket in pairs(type(opts.cartOptions) == "table" and opts.cartOptions or {}) do
|
||||
root[id] = bucket
|
||||
end
|
||||
local bucket = type(root[activeCart]) == "table" and root[activeCart] or {}
|
||||
local defaults = SaveData.defaultOptions()
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if opts[key] ~= nil then bucket[key] = opts[key] end
|
||||
local global = globals and globals[key]
|
||||
if global == nil then global = defaults[key] end
|
||||
opts[key] = global
|
||||
end
|
||||
root[activeCart] = bucket
|
||||
opts.cartOptions = root
|
||||
return opts
|
||||
end
|
||||
|
||||
-- Both take an optional fs (write/getInfo/read) defaulting to
|
||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||
-- options round-trip headless (no love global).
|
||||
@@ -458,6 +526,10 @@ function SaveData.saveOptions(opts, fs)
|
||||
-- activeProfile -- not defaultOptions members -- can be deleted by their
|
||||
-- sites precisely because those sites always write full tables.
|
||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||
-- Fold from the cart's view of the file, not the globals underneath it, or
|
||||
-- a partial write would push the global value into the cart's bucket.
|
||||
local cartGlobalValues = cartGlobals(onDisk)
|
||||
applyCartOverlay(onDisk)
|
||||
local isFull = type(opts) == "table"
|
||||
if isFull then
|
||||
for k in pairs(SaveData.defaultOptions()) do
|
||||
@@ -497,6 +569,7 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
opts.modOptions = merged
|
||||
end
|
||||
splitCartOverlay(opts, cartGlobalValues, onDisk)
|
||||
local encoded = SaveSerializer.encode(opts)
|
||||
-- Stage the new bytes and roll the last good file aside BEFORE the main
|
||||
-- write truncates it, the same tmp/bak dance SaveData.save uses for
|
||||
@@ -544,7 +617,8 @@ function SaveData.saveOptions(opts, fs)
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
-- hand back the cart's view, matching what loadOptions would answer
|
||||
return applyCartOverlay(opts)
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
@@ -572,11 +646,11 @@ function SaveData.loadOptions(fs)
|
||||
if fs.write then
|
||||
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
|
||||
end
|
||||
return SaveData.mergeOptions(recovered)
|
||||
return applyCartOverlay(SaveData.mergeOptions(recovered))
|
||||
end
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
return SaveData.mergeOptions(data)
|
||||
return applyCartOverlay(SaveData.mergeOptions(data))
|
||||
end
|
||||
|
||||
-- ------- per-game mod enablement
|
||||
@@ -759,25 +833,40 @@ end
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
|
||||
-- is used, which keeps a brand-new install and every pre-slots caller
|
||||
-- working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
local activeSlotCache = {} -- scope key -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- scope key -> true once resolved this process
|
||||
-- At most one New Game can be the live candidate for a first public tool
|
||||
-- request. A single strong reference models that runtime fact without adding
|
||||
-- marker data to the save or retaining abandoned playthrough tables.
|
||||
local freshPlaythrough
|
||||
|
||||
local function slotDir(version) return "saves/" .. version end
|
||||
local CART_PREFIX = "cart_"
|
||||
|
||||
local function slotNames(version, id)
|
||||
local main = slotDir(version) .. "/" .. id .. ".lua"
|
||||
local sealBroken = false
|
||||
|
||||
local function cartKey(cartId)
|
||||
if type(cartId) ~= "string" or #cartId > 64 then return nil end
|
||||
if not cartId:match("^%w[%w%._%-]*$") then return nil end
|
||||
return CART_PREFIX .. cartId
|
||||
end
|
||||
|
||||
local function isCartKey(key)
|
||||
return key:sub(1, #CART_PREFIX) == CART_PREFIX
|
||||
end
|
||||
|
||||
local function slotDir(key) return "saves/" .. key end
|
||||
|
||||
local function slotNames(key, id)
|
||||
local main = slotDir(key) .. "/" .. id .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||
-- save_blue.lua / save_yellow.lua for the others); still the destination
|
||||
-- before any slot exists
|
||||
local function legacyNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
-- the pre-slots flat names a scope uses before any slot exists (save.lua for
|
||||
-- Red, save_blue.lua / save_yellow.lua for the other versions, and
|
||||
-- save_cart_<id>.lua for a cart)
|
||||
local function legacyNames(key)
|
||||
local suffix = isCartKey(key) and ("_" .. key) or GameVersion.saveSuffix(key)
|
||||
local main = "save" .. suffix .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
@@ -790,6 +879,37 @@ local function knownVersion(version)
|
||||
return GameVersion.info(version) ~= nil
|
||||
end
|
||||
|
||||
local function knownScope(key)
|
||||
if type(key) ~= "string" or key == "" then return false end
|
||||
if isCartKey(key) then return true end
|
||||
return knownVersion(key)
|
||||
end
|
||||
|
||||
local function activeScopeKey(version)
|
||||
if activeCart then return CART_PREFIX .. activeCart end
|
||||
return version or GameVersion.get()
|
||||
end
|
||||
|
||||
local function registryOf(opts, key)
|
||||
local root, id = opts.saveSlots, key
|
||||
if isCartKey(key) then
|
||||
root, id = opts.cartSlots, key:sub(#CART_PREFIX + 1)
|
||||
end
|
||||
if type(root) ~= "table" then return nil end
|
||||
local reg = root[id]
|
||||
return type(reg) == "table" and reg or nil
|
||||
end
|
||||
|
||||
local function putRegistry(opts, key, reg)
|
||||
if isCartKey(key) then
|
||||
opts.cartSlots = type(opts.cartSlots) == "table" and opts.cartSlots or {}
|
||||
opts.cartSlots[key:sub(#CART_PREFIX + 1)] = reg
|
||||
else
|
||||
opts.saveSlots = type(opts.saveSlots) == "table" and opts.saveSlots or {}
|
||||
opts.saveSlots[key] = reg
|
||||
end
|
||||
end
|
||||
|
||||
-- Create the parent directory of a slot path when the fs supports it.
|
||||
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
|
||||
-- stub keys files by full path and exposes no such method, so this is a
|
||||
@@ -802,8 +922,8 @@ end
|
||||
-- Decode a slot's save using the same recovery order load() uses -- main,
|
||||
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
|
||||
-- summarizes. nil when nothing readable is present.
|
||||
local function decodeSlot(fs, version, id)
|
||||
local main, bak, tmp = slotNames(version, id)
|
||||
local function decodeSlot(fs, key, id)
|
||||
local main, bak, tmp = slotNames(key, id)
|
||||
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
|
||||
if data then return data end
|
||||
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
|
||||
@@ -818,30 +938,29 @@ end
|
||||
-- active slot. Returns the new slot id, or nil when there is nothing to
|
||||
-- migrate or the copy could not be verified (originals left in place so no
|
||||
-- data is ever lost to a failed move).
|
||||
local function tryMigrateLegacy(version, fs)
|
||||
local lmain, lbak, ltmp = legacyNames(version)
|
||||
local function tryMigrateLegacy(key, fs)
|
||||
local lmain, lbak, ltmp = legacyNames(key)
|
||||
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
|
||||
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
|
||||
if not (mainBody or bakBody) then return nil end
|
||||
local id = "slot1"
|
||||
local dmain, dbak = slotNames(version, id)
|
||||
local dmain, dbak = slotNames(key, id)
|
||||
ensureParentDir(fs, dmain)
|
||||
if mainBody then fs.write(dmain, mainBody) end
|
||||
if bakBody then fs.write(dbak, bakBody) end
|
||||
-- refuse to delete the originals unless the new slot is loadable (from
|
||||
-- the main copy or, failing that, the backup)
|
||||
if not decodeSlot(fs, version, id) then return nil end
|
||||
if not decodeSlot(fs, key, id) then return nil end
|
||||
remove(fs, lmain)
|
||||
remove(fs, lbak)
|
||||
remove(fs, ltmp)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = { id }, active = id }
|
||||
putRegistry(opts, key, { list = { id }, active = id })
|
||||
-- A tool may have allocated the legacy scope before the player made their
|
||||
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||
-- unreachable after the migration even though every durable record exists.
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[key]
|
||||
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||
ids.legacy = nil
|
||||
@@ -852,9 +971,9 @@ end
|
||||
|
||||
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||
local function scanDiskSlots(version, fs)
|
||||
local function scanDiskSlots(key, fs)
|
||||
if not fs then return nil end
|
||||
local dir = "saves/" .. version
|
||||
local dir = slotDir(key)
|
||||
local slots = {}
|
||||
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||
@@ -882,49 +1001,49 @@ local function scanDiskSlots(version, fs)
|
||||
return #slots > 0 and slots or nil
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- Resolve (once per scope per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
if not knownVersion(version) then
|
||||
activeSlotCache[version] = false
|
||||
local function ensureSlots(key, fs)
|
||||
if slotsChecked[key] then return end
|
||||
slotsChecked[key] = true
|
||||
if not knownScope(key) then
|
||||
activeSlotCache[key] = false
|
||||
return
|
||||
end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
local reg = registryOf(opts, key)
|
||||
if reg and type(reg.list) == "table" and #reg.list > 0 then
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
activeSlotCache[key] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
local migrated = tryMigrateLegacy(version, fs)
|
||||
local migrated = tryMigrateLegacy(key, fs)
|
||||
if migrated then
|
||||
activeSlotCache[version] = migrated
|
||||
activeSlotCache[key] = migrated
|
||||
return
|
||||
end
|
||||
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||
local recovered = scanDiskSlots(version, fs)
|
||||
local recovered = scanDiskSlots(key, fs)
|
||||
if recovered and #recovered > 0 then
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||
putRegistry(opts, key, { list = recovered, active = recovered[1] })
|
||||
SaveData.saveOptions(opts, fs)
|
||||
activeSlotCache[version] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||
activeSlotCache[key] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, key)
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = false
|
||||
activeSlotCache[key] = false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||
-- the scope -- the active cart when one is set, otherwise the version --
|
||||
-- falling back to the flat legacy names when no slot is in use.
|
||||
function saveNames(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local key = activeScopeKey(version)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
local slot = activeSlotCache[version]
|
||||
if slot then return slotNames(version, slot) end
|
||||
return legacyNames(version)
|
||||
ensureSlots(key, fs)
|
||||
local slot = activeSlotCache[key]
|
||||
if slot then return slotNames(key, slot) end
|
||||
return legacyNames(key)
|
||||
end
|
||||
|
||||
-- Pure extraction of the launcher's per-slot summary from a decoded save,
|
||||
@@ -1005,26 +1124,33 @@ function SaveData.slotDiskPath(version, slotId)
|
||||
return base .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- Slots visible to the launcher: every registered slot for a scope, each
|
||||
-- with whether it holds a save and the cheap summary above. A fresh
|
||||
-- install with nothing registered returns an empty array; a legacy install
|
||||
-- is migrated to slot1 first.
|
||||
local function listSlotsIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureSlots(key, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
local list = (reg and type(reg.list) == "table" and reg.list) or {}
|
||||
local out = {}
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, key, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil,
|
||||
cartHash = reg.hashes and reg.hashes[id] or nil,
|
||||
sealBroken = (reg.broken and reg.broken[id] == true)
|
||||
or false }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SaveData.listSlots(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return {} end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
local list = (reg and reg.list) or {}
|
||||
local out = {}
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil }
|
||||
end
|
||||
return out
|
||||
return listSlotsIn(version)
|
||||
end
|
||||
|
||||
function SaveData.readSlotSource(version, slotId, injectedFs)
|
||||
@@ -1049,17 +1175,14 @@ end
|
||||
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||
-- false + an error string when the id is not registered.
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
local function renameSlotIn(key, slotId, name)
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
@@ -1070,47 +1193,55 @@ function SaveData.renameSlot(version, slotId, name)
|
||||
reg.names = reg.names or {}
|
||||
reg.names[slotId] = label
|
||||
if next(reg.names) == nil then reg.names = nil end
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return renameSlotIn(version, slotId, name)
|
||||
end
|
||||
|
||||
-- Point the active slot at slotId (registering it if new) and persist the
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
function SaveData.setActiveSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local function setActiveSlotIn(key, slotId)
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local reg = registryOf(opts, key) or { list = {}, active = nil }
|
||||
reg.list = type(reg.list) == "table" and reg.list or {}
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then reg.list[#reg.list + 1] = slotId end
|
||||
reg.active = slotId
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = slotId
|
||||
slotsChecked[key] = true
|
||||
activeSlotCache[key] = slotId
|
||||
return slotId
|
||||
end
|
||||
|
||||
-- Register a new empty slot for the version and return its id. Does NOT
|
||||
function SaveData.setActiveSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
return setActiveSlotIn(version, slotId)
|
||||
end
|
||||
|
||||
-- Register a new empty slot for the scope and return its id. Does NOT
|
||||
-- write a save file and does NOT change the active slot: an empty slot
|
||||
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
|
||||
-- allocated one past the highest existing number so a reused id can never
|
||||
-- collide with a lingering file.
|
||||
function SaveData.createSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local function createSlotIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
ensureSlots(key, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local reg = registryOf(opts, key) or { list = {}, active = nil }
|
||||
reg.list = type(reg.list) == "table" and reg.list or {}
|
||||
local maxN = 0
|
||||
for _, id in ipairs(reg.list) do
|
||||
local n = tonumber(tostring(id):match("^slot(%d+)$"))
|
||||
@@ -1118,21 +1249,31 @@ function SaveData.createSlot(version)
|
||||
end
|
||||
local id = "slot" .. (maxN + 1)
|
||||
reg.list[#reg.list + 1] = id
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- The active slot id in use for a version (resolved once per process like
|
||||
function SaveData.createSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
return createSlotIn(version)
|
||||
end
|
||||
|
||||
-- The active slot id in use for a scope (resolved once per process like
|
||||
-- saveNames does), or nil when none is registered and the flat legacy path is
|
||||
-- in use. Public so the launcher's save Import/Export glue can name an export
|
||||
-- after the slot it came from without reaching into the private cache.
|
||||
local function activeSlotIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureSlots(key, fs)
|
||||
return activeSlotCache[key] or nil
|
||||
end
|
||||
|
||||
function SaveData.activeSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or nil
|
||||
return activeSlotIn(version)
|
||||
end
|
||||
|
||||
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
|
||||
@@ -1142,12 +1283,10 @@ end
|
||||
-- already registered the slot via createSlot but written no bytes yet; unlike
|
||||
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
|
||||
-- options. Returns true, or false + an error string on a failed write.
|
||||
function SaveData.writeSlot(version, slotId, saveTable)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
local function writeSlotIn(key, slotId, saveTable)
|
||||
if type(slotId) ~= "string" then return false, "missing slot id" end
|
||||
if type(saveTable) ~= "table" then return false, "missing save table" end
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
local main, bak, tmp = slotNames(key, slotId)
|
||||
local encoded = SaveSerializer.encode(saveTable)
|
||||
local fs = persistFs(nil)
|
||||
ensureParentDir(fs, main)
|
||||
@@ -1164,52 +1303,300 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.writeSlot(version, slotId, saveTable)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return writeSlotIn(version, slotId, saveTable)
|
||||
end
|
||||
|
||||
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||
-- options registry, and if it was active point active at another remaining
|
||||
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||
-- an error string when the id is unknown / not registered.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
local function deleteSlotIn(key, slotId)
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
ensureSlots(key, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
|
||||
local found, idx = false, nil
|
||||
for i, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true; idx = i; break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
local main, bak, tmp = slotNames(key, slotId)
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.hashes then
|
||||
reg.hashes[slotId] = nil
|
||||
if next(reg.hashes) == nil then reg.hashes = nil end
|
||||
end
|
||||
if reg.broken then
|
||||
reg.broken[slotId] = nil
|
||||
if next(reg.broken) == nil then reg.broken = nil end
|
||||
end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = reg.active or false
|
||||
slotsChecked[key] = true
|
||||
activeSlotCache[key] = reg.active or false
|
||||
return true
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||
-- the game, which resolves each version exactly once per boot.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return deleteSlotIn(version, slotId)
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache (and the active cart) so a
|
||||
-- suite can exercise migration/resolution against a freshly injected
|
||||
-- filesystem. Unused by the game, which resolves each scope exactly once per
|
||||
-- boot.
|
||||
function SaveData.resetSlotState()
|
||||
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||
freshPlaythrough = nil
|
||||
activeCart, activeCartHash = nil, nil
|
||||
sealBroken = false
|
||||
end
|
||||
|
||||
-- ------- custom carts
|
||||
|
||||
function SaveData.setCart(cartId, cartHash)
|
||||
if cartId ~= nil and cartKey(cartId) then
|
||||
activeCart = cartId
|
||||
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
else
|
||||
activeCart, activeCartHash = nil, nil
|
||||
end
|
||||
return activeCart
|
||||
end
|
||||
|
||||
function SaveData.getCart()
|
||||
return activeCart
|
||||
end
|
||||
|
||||
-- The player's answer for one mod a cart pins, or nil while they have not
|
||||
-- given one and the cart's own switch stands.
|
||||
function SaveData.cartModEnabled(options, cartId, modId)
|
||||
if not cartKey(cartId) or type(modId) ~= "string" then return nil end
|
||||
local root = type(options) == "table" and options.cartMods or nil
|
||||
local bucket = type(root) == "table" and root[cartId] or nil
|
||||
if type(bucket) == "table" and type(bucket[modId]) == "boolean" then
|
||||
return bucket[modId]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Kept apart from options.mods so switching a cart's mod never changes what
|
||||
-- the base game runs, and vice versa.
|
||||
function SaveData.setCartModEnabled(cartId, modId, enabled, fs)
|
||||
if not cartKey(cartId) or type(modId) ~= "string" or modId == "" then
|
||||
return false
|
||||
end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local root = type(opts.cartMods) == "table" and opts.cartMods or {}
|
||||
local bucket = type(root[cartId]) == "table" and root[cartId] or {}
|
||||
bucket[modId] = enabled and true or false
|
||||
root[cartId], opts.cartMods = bucket, root
|
||||
return SaveData.saveOptions(opts, fs) ~= nil
|
||||
end
|
||||
|
||||
-- Seed the active cart's shipped settings into its overlay, once ever. Keys
|
||||
-- outside CART_OPTION_KEYS are ignored; a later player change is never redone.
|
||||
function SaveData.seedCartOptions(defaults, fs)
|
||||
if not (activeCart and type(defaults) == "table") then return false end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local seeded = type(opts.cartOptionsSeeded) == "table"
|
||||
and opts.cartOptionsSeeded or {}
|
||||
if seeded[activeCart] then return false end
|
||||
local root = type(opts.cartOptions) == "table" and opts.cartOptions or {}
|
||||
local bucket = type(root[activeCart]) == "table" and root[activeCart] or {}
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if defaults[key] ~= nil and bucket[key] == nil then
|
||||
bucket[key] = defaults[key]
|
||||
opts[key] = defaults[key]
|
||||
end
|
||||
end
|
||||
root[activeCart], opts.cartOptions = bucket, root
|
||||
seeded[activeCart], opts.cartOptionsSeeded = true, seeded
|
||||
return SaveData.saveOptions(opts, fs) ~= nil
|
||||
end
|
||||
|
||||
function SaveData.setCartHash(cartHash)
|
||||
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
return activeCartHash
|
||||
end
|
||||
|
||||
function SaveData.getCartHash()
|
||||
return activeCartHash
|
||||
end
|
||||
|
||||
function SaveData.breakSeal(save)
|
||||
sealBroken = true
|
||||
if type(save) == "table" then
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
save.meta.sealBroken = true
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.isSealBroken(save)
|
||||
if type(save) == "table" then
|
||||
return type(save.meta) == "table" and save.meta.sealBroken == true
|
||||
end
|
||||
return sealBroken
|
||||
end
|
||||
|
||||
function SaveData.listCartSlots(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return {} end
|
||||
return listSlotsIn(key)
|
||||
end
|
||||
|
||||
function SaveData.createCartSlot(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
return createSlotIn(key)
|
||||
end
|
||||
|
||||
function SaveData.setActiveCartSlot(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
if type(slotId) ~= "string" or slotId == "" then return nil end
|
||||
return setActiveSlotIn(key, slotId)
|
||||
end
|
||||
|
||||
function SaveData.activeCartSlot(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
return activeSlotIn(key)
|
||||
end
|
||||
|
||||
function SaveData.renameCartSlot(cartId, slotId, name)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
return renameSlotIn(key, slotId, name)
|
||||
end
|
||||
|
||||
function SaveData.deleteCartSlot(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
return deleteSlotIn(key, slotId)
|
||||
end
|
||||
|
||||
function SaveData.writeCartSlot(cartId, slotId, saveTable)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
local ok, err = writeSlotIn(key, slotId, saveTable)
|
||||
if not ok then return ok, err end
|
||||
local hash = type(saveTable.meta) == "table" and saveTable.meta.cartHash or nil
|
||||
if type(hash) == "string" and hash ~= "" then
|
||||
SaveData.setSlotCartHash(cartId or activeCart, slotId, hash)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.slotCartHash(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key or type(slotId) ~= "string" then return nil end
|
||||
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
|
||||
local hash = reg and type(reg.hashes) == "table" and reg.hashes[slotId] or nil
|
||||
if type(hash) ~= "string" or hash == "" then return nil end
|
||||
return hash
|
||||
end
|
||||
|
||||
function SaveData.setSlotCartHash(cartId, slotId, cartHash)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
local hash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
|
||||
if reg.hashes[slotId] == hash then return true end
|
||||
reg.hashes[slotId] = hash
|
||||
if next(reg.hashes) == nil then reg.hashes = nil end
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.slotSealBroken(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key or type(slotId) ~= "string" then return false end
|
||||
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
|
||||
local broken = reg and type(reg.broken) == "table" and reg.broken[slotId]
|
||||
return broken == true
|
||||
end
|
||||
|
||||
function SaveData.markSlotSealBroken(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
reg.broken = type(reg.broken) == "table" and reg.broken or {}
|
||||
if reg.broken[slotId] == true then return true end
|
||||
reg.broken[slotId] = true
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.adoptCartSeal(cartId)
|
||||
local id = cartId or activeCart
|
||||
if not cartKey(id) then return false end
|
||||
local slot = SaveData.activeCartSlot(id)
|
||||
if type(slot) ~= "string" or not SaveData.slotSealBroken(id, slot) then
|
||||
return false
|
||||
end
|
||||
SaveData.breakSeal()
|
||||
return true
|
||||
end
|
||||
|
||||
local function stampActiveCartHash(fs)
|
||||
if not (activeCart and activeCartHash) then return end
|
||||
local key = CART_PREFIX .. activeCart
|
||||
local slot = activeSlotCache[key]
|
||||
if type(slot) ~= "string" then return end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return end
|
||||
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
|
||||
if reg.hashes[slot] == activeCartHash then return end
|
||||
reg.hashes[slot] = activeCartHash
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
|
||||
-- ------- opaque playthrough identity
|
||||
@@ -1235,10 +1622,10 @@ function SaveData.newPlaythroughId()
|
||||
end
|
||||
|
||||
local function playthroughScope(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local key = activeScopeKey(version)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or "legacy"
|
||||
ensureSlots(key, fs)
|
||||
return activeSlotCache[key] or "legacy", key
|
||||
end
|
||||
|
||||
local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
@@ -1246,7 +1633,7 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
local id = type(meta) == "table" and meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local persisted = SaveData.loadOptions(injectedFs)
|
||||
if opts then
|
||||
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||
@@ -1254,14 +1641,15 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||
-- the freshly persisted routing and strand tool storage on next boot.
|
||||
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||
opts.cartSlots = deepCopy(persisted.cartSlots)
|
||||
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||
else
|
||||
opts = persisted
|
||||
end
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
opts.playthroughIds[version][scope] = id
|
||||
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
|
||||
local changed = opts.playthroughIds[key][scope] ~= id
|
||||
opts.playthroughIds[key][scope] = id
|
||||
return opts, changed
|
||||
end
|
||||
|
||||
@@ -1275,11 +1663,11 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local isFresh = save == freshPlaythrough
|
||||
if isFresh then freshPlaythrough = nil end
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
|
||||
local existing = byVersion and byVersion[scope]
|
||||
id = not isFresh and existing or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
@@ -1296,8 +1684,8 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
-- storage and repeating on every launch.
|
||||
if not (isFresh and type(existing) == "string" and existing ~= "") then
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
opts.playthroughIds[version][scope] = id
|
||||
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
|
||||
opts.playthroughIds[key][scope] = id
|
||||
SaveData.saveOptions(opts, injectedFs)
|
||||
end
|
||||
end
|
||||
@@ -1325,9 +1713,9 @@ function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||
-- then read options so this lookup never observes the pre-migration table.
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
|
||||
id = byVersion and byVersion[scope] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "no_selected_playthrough",
|
||||
@@ -1394,6 +1782,8 @@ function SaveData.buildMeta(mods, previous, sessionStart)
|
||||
savedAt = savedAt,
|
||||
sessionStart = started,
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
cartHash = type(previous) == "table" and previous.cartHash or nil,
|
||||
sealBroken = (type(previous) == "table" and previous.sealBroken == true) or nil,
|
||||
mods = list,
|
||||
}
|
||||
end
|
||||
@@ -1486,9 +1876,11 @@ function SaveData.runMigrations(save, modChains, activeMods)
|
||||
-- every step whose from-format the save has not passed yet runs, in
|
||||
-- (from, registration) order; a save at the current format runs none
|
||||
local fmt = (save.meta and save.meta.format) or 1
|
||||
if GameVersion.generation() == 1 then
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
end
|
||||
end
|
||||
-- a save that predates meta records an empty mod set: an old vanilla
|
||||
-- save becomes a v2 vanilla save
|
||||
save.meta = save.meta or { mods = {} }
|
||||
@@ -1617,6 +2009,12 @@ function SaveData.save(data, mods)
|
||||
if mods ~= nil or data.meta == nil then
|
||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||
end
|
||||
if activeCart and activeCartHash and type(data.meta) == "table" then
|
||||
data.meta.cartHash = activeCartHash
|
||||
end
|
||||
if sealBroken and type(data.meta) == "table" then
|
||||
data.meta.sealBroken = true
|
||||
end
|
||||
local gameOnly = {}
|
||||
for k, v in pairs(data) do
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
@@ -1644,6 +2042,7 @@ function SaveData.save(data, mods)
|
||||
return false
|
||||
end
|
||||
remove(fs, TMP_FILENAME)
|
||||
stampActiveCartHash(fs)
|
||||
Logger.info("saved game")
|
||||
return true
|
||||
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
|
||||
@@ -363,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
|
||||
|
||||
@@ -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:
|
||||
--
|
||||
|
||||
+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
|
||||
|
||||
@@ -73,6 +73,58 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
"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 =
|
||||
|
||||
@@ -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
|
||||
@@ -266,18 +266,6 @@ 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,
|
||||
function(dir)
|
||||
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
@@ -678,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"),
|
||||
@@ -709,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
|
||||
|
||||
+730
-83
File diff suppressed because it is too large
Load Diff
+998
-79
File diff suppressed because it is too large
Load Diff
+1165
-75
File diff suppressed because it is too large
Load Diff
@@ -1582,7 +1582,7 @@ local function buildPartyMenu()
|
||||
elseif opts.battle and opts.onSwitch then
|
||||
prompt, battleSubmenu = "choose", true
|
||||
elseif opts.pickOnly then
|
||||
prompt = "useItem"
|
||||
prompt = opts.itemUse and "useItem" or "choose"
|
||||
elseif opts.onSwitch then
|
||||
-- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself,
|
||||
-- so the field submenu must not swallow the press
|
||||
@@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
warned = "keepOpen tmhm",
|
||||
absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo "
|
||||
.. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch "
|
||||
.. "pickOnly forceSwitch",
|
||||
.. "pickOnly itemUse forceSwitch",
|
||||
notes = {
|
||||
new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts."
|
||||
.. "battle carries only its BOOLEAN sense and self.battle is left nil "
|
||||
@@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field "
|
||||
.. "Gen 2 never reads; pass it to .new instead",
|
||||
bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so "
|
||||
.. "a compare against \"Use on which one?\" will not match",
|
||||
.. "a compare against \"Use item on which\\nPOKéMON?\" will not match",
|
||||
["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold "
|
||||
.. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a "
|
||||
.. "BattleState",
|
||||
@@ -1838,7 +1838,7 @@ local function buildBattleState()
|
||||
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
|
||||
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
||||
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "uiSize",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
|
||||
"trainerSprite", "invalidate",
|
||||
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
||||
@@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = {
|
||||
backed = "update draw __index isOpaque openParty swapMoves "
|
||||
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
|
||||
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
|
||||
.. "tryRun wantsFillScale",
|
||||
.. "tryRun wantsFillScale bgMode",
|
||||
warned = "tryRun askNicknameUI",
|
||||
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
||||
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
||||
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
||||
.. "isWideBattleLayout wideLayout uiSize letterboxWhite "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
|
||||
.. "trainerTrueColor trainerSprite invalidate "
|
||||
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
||||
|
||||
+172
-3
@@ -1,6 +1,8 @@
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -268,8 +270,9 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
cart = opts and opts.cart or nil,
|
||||
dev = dev == true,
|
||||
safeMode = false,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -367,7 +370,11 @@ function Loader:_saveState()
|
||||
local scope = self:_enableScope()
|
||||
local version = self:_targetVersion()
|
||||
for id in pairs(self.mods) do
|
||||
-- a switch the cart owns is answered in the cart's scope by setEnabled,
|
||||
-- so it never rewrites what the base game runs
|
||||
if not self.cartSwitches[id] then
|
||||
SaveData.setModEnabled(options, id, not self.disabled[id], scope)
|
||||
end
|
||||
-- only the games this boot can answer for: another version's overrides
|
||||
-- are not this run's to rewrite. With no version (an injected-generation
|
||||
-- harness) the override stays in memory for this boot only.
|
||||
@@ -434,6 +441,9 @@ function Loader:setEnabled(id, enabled)
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
self.mods[id].enabled = enabled
|
||||
if self.cartSwitches[id] and self.cartReport then
|
||||
SaveData.setCartModEnabled(self.cartReport.id, id, enabled, self.fs)
|
||||
end
|
||||
self:_saveState()
|
||||
return true
|
||||
end
|
||||
@@ -488,6 +498,161 @@ function Loader:_discover()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- custom carts
|
||||
|
||||
local function installedVersions(installed)
|
||||
local out = {}
|
||||
for key, entry in pairs(installed or {}) do
|
||||
if type(entry) == "table" then
|
||||
local manifest = type(entry.manifest) == "table" and entry.manifest or entry
|
||||
local id = manifest.id or (type(key) == "string" and key or nil)
|
||||
if type(id) == "string" then out[id] = manifest.version end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function pinnedVersion(pin)
|
||||
local version = pin.version
|
||||
if type(version) ~= "string" or version == "" then return nil end
|
||||
if pin.source == "local" and version == CartStore.UNPINNED_VERSION then return nil end
|
||||
return version
|
||||
end
|
||||
|
||||
local function sameVersion(want, have)
|
||||
local order = Semver.compare(want, have)
|
||||
if order ~= nil then return order == 0 end
|
||||
return want == have
|
||||
end
|
||||
|
||||
local function cartComplaints(report)
|
||||
local parts = {}
|
||||
for _, row in ipairs(report.missing) do
|
||||
parts[#parts + 1] = ("%s %s is not installed")
|
||||
:format(row.id, row.version or "(any version)")
|
||||
end
|
||||
for _, row in ipairs(report.mismatched) do
|
||||
parts[#parts + 1] = ("%s is pinned at %s but %s is installed")
|
||||
:format(row.id, row.version, row.installed)
|
||||
end
|
||||
return parts
|
||||
end
|
||||
|
||||
-- Whether the player's own enable flag decides a pinned mod. "sealed+" hands
|
||||
-- every pin over; any other seal hands over only the pins the cart ships off.
|
||||
function Loader.pinTogglable(report, pin)
|
||||
if type(report) ~= "table" or type(pin) ~= "table" then return false end
|
||||
if report.seal == "sealed+" then return true end
|
||||
if pin.enabled ~= false then return false end
|
||||
return not (report.seal == "sealed" and not report.broken)
|
||||
end
|
||||
|
||||
function Loader.planCart(cart, installed, broken)
|
||||
local report = { seal = "sealed", sealed = true, broken = broken == true,
|
||||
order = {}, rank = {}, pins = {}, missing = {}, mismatched = {},
|
||||
floor = 1, refused = false }
|
||||
if type(cart) ~= "table" then
|
||||
report.enforced = true
|
||||
report.refused = true
|
||||
report.message = "this cart is not installed"
|
||||
return report
|
||||
end
|
||||
report.id, report.title = cart.id, cart.title
|
||||
report.seal = CartManifest.SEALS[cart.seal] and cart.seal or "sealed"
|
||||
report.sealed = report.seal ~= "open"
|
||||
report.enforced = report.sealed and not report.broken
|
||||
local have = installedVersions(installed)
|
||||
local pins = {}
|
||||
for _, pin in ipairs(cart.mods or {}) do
|
||||
if type(pin) == "table" and type(pin.id) == "string" then pins[pin.id] = pin end
|
||||
end
|
||||
for _, id in ipairs(cart.load_order or {}) do
|
||||
local pin = pins[id]
|
||||
if pin and not report.pins[id] then
|
||||
report.order[#report.order + 1] = id
|
||||
report.rank[id] = #report.order
|
||||
report.pins[id] = pin
|
||||
local want, got = pinnedVersion(pin), have[id]
|
||||
if got == nil then
|
||||
report.missing[#report.missing + 1] =
|
||||
{ id = id, version = want, source = pin.source }
|
||||
elseif want and not sameVersion(want, got) then
|
||||
report.mismatched[#report.mismatched + 1] =
|
||||
{ id = id, version = want, installed = got }
|
||||
end
|
||||
end
|
||||
end
|
||||
report.floor = #report.order + 1
|
||||
local parts = cartComplaints(report)
|
||||
if #parts > 0 then
|
||||
report.message = ("%s: %s"):format(cart.title or cart.id or "cart",
|
||||
table.concat(parts, "; "))
|
||||
report.refused = report.enforced
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
function Loader:cartStatus()
|
||||
return self.cartReport
|
||||
end
|
||||
|
||||
function Loader:_applyCart()
|
||||
local cartId = SaveData.getCart()
|
||||
if not cartId or self.safeMode then return end
|
||||
local cart, err = self.cart, nil
|
||||
if not cart then cart, err = CartStore.get(cartId, self.fs) end
|
||||
local report = Loader.planCart(cart, self.mods, SaveData.isSealBroken())
|
||||
report.id = cartId
|
||||
if not cart then
|
||||
report.message = ("%s: %s"):format(cartId, tostring(err or "this cart is not installed"))
|
||||
end
|
||||
self.cartReport = report
|
||||
if report.refused then
|
||||
for _, mod in pairs(self.mods) do
|
||||
mod.enabled, mod.state = false, "disabled"
|
||||
end
|
||||
self.errors[#self.errors + 1] = report.message
|
||||
Logger.error("cart %s refused: %s", cartId, report.message)
|
||||
return
|
||||
end
|
||||
if report.message then Logger.warn("cart %s: %s", cartId, report.message) end
|
||||
-- the pins whose switch the cart hands to the player: their answer lives in
|
||||
-- the cart's own scope, never in the per-game flags
|
||||
self.cartSwitches = {}
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
for id, mod in pairs(self.mods) do
|
||||
local pin = report.pins[id]
|
||||
if pin then
|
||||
local on = CartManifest.modEnabled(pin)
|
||||
if Loader.pinTogglable(report, pin) then
|
||||
local chosen = SaveData.cartModEnabled(options, cartId, id)
|
||||
if type(chosen) == "boolean" then on = chosen end
|
||||
self.cartSwitches[id] = true
|
||||
end
|
||||
mod.enabled, mod.state = on, on and "pending" or "disabled"
|
||||
elseif report.enforced then
|
||||
mod.enabled, mod.state = false, "disabled"
|
||||
end
|
||||
end
|
||||
local merged = {}
|
||||
for id, bucket in pairs(self.modOptions) do merged[id] = bucket end
|
||||
for id, pin in pairs(report.pins) do
|
||||
local bucket = {}
|
||||
for key, value in pairs(pin.options or {}) do bucket[key] = value end
|
||||
if not report.enforced then
|
||||
for key, value in pairs(self.modOptions[id] or {}) do bucket[key] = value end
|
||||
end
|
||||
merged[id] = bucket
|
||||
end
|
||||
self.modOptions = merged
|
||||
end
|
||||
|
||||
function Loader:_cartRank(id)
|
||||
local report = self.cartReport
|
||||
if not report then return 0 end
|
||||
return report.rank[id] or report.floor
|
||||
end
|
||||
|
||||
-- ------- validate and resolve
|
||||
|
||||
-- a failed mod keeps the user's enable flag (the manager still shows it as
|
||||
@@ -773,9 +938,12 @@ function Loader:_order()
|
||||
if not best then
|
||||
best = id
|
||||
else
|
||||
local ra, rb = self:_cartRank(id), self:_cartRank(best)
|
||||
local pa, pb = self.mods[id].manifest.priority,
|
||||
self.mods[best].manifest.priority
|
||||
if pa < pb or (pa == pb and id < best) then best = id end
|
||||
if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then
|
||||
best = id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1614,6 +1782,7 @@ function Loader:load(data)
|
||||
mod.enabled = not self.disabled[id]
|
||||
mod.state = mod.enabled and "pending" or "disabled"
|
||||
end
|
||||
self:_applyCart()
|
||||
-- engine call sites reach these buses -- and this error feed, for failures
|
||||
-- that only surface at play time -- through Runtime from here on
|
||||
Runtime.install(self.events, self.hooks, self.errors)
|
||||
@@ -1787,7 +1956,7 @@ function Loader:status()
|
||||
table.sort(available, function(a, b) return a.id < b.id end)
|
||||
table.sort(loaded, function(a, b) return a.id < b.id end)
|
||||
return { available = available, loaded = loaded, errors = self.errors,
|
||||
order = self.order }
|
||||
order = self.order, cart = self.cartReport }
|
||||
end
|
||||
|
||||
return Loader
|
||||
|
||||
+142
-5
@@ -18,6 +18,9 @@
|
||||
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
|
||||
-- name for something else, so an unknown version is refused outright rather
|
||||
-- than parsed hopefully.
|
||||
--
|
||||
-- Carts ride the same feed additively at schema_version 1: `doc.carts` is a
|
||||
-- second array beside `doc.mods`, and a feed without it lists no carts.
|
||||
|
||||
local ModIndex = {}
|
||||
|
||||
@@ -26,7 +29,7 @@ local ModIndex = {}
|
||||
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||
ModIndex.SCHEMA_VERSION = 1
|
||||
ModIndex.CACHE_VERSION = 2
|
||||
ModIndex.CACHE_VERSION = 3
|
||||
|
||||
-- ------- pure: source resolution
|
||||
|
||||
@@ -225,7 +228,94 @@ local function parseEntry(raw)
|
||||
}
|
||||
end
|
||||
|
||||
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
|
||||
local function numArray(v)
|
||||
local out = {}
|
||||
if type(v) == "table" then
|
||||
for _, entry in ipairs(v) do
|
||||
local n = tonumber(entry)
|
||||
if n then out[#out + 1] = n end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- One pinned mod out of a cart's `mods` array, in the bundle's own cart.json
|
||||
-- shape: github pins carry repo/version/sha256, gamebanana pins mod/file/md5.
|
||||
local function parseCartPin(raw)
|
||||
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||
local source = str(raw.source)
|
||||
if source ~= "github" and source ~= "gamebanana" then return nil end
|
||||
local pin = {
|
||||
id = raw.id,
|
||||
source = source,
|
||||
repo = str(raw.repo),
|
||||
version = str(raw.version),
|
||||
sha256 = str(raw.sha256),
|
||||
mod = tonumber(raw.mod),
|
||||
file = tonumber(raw.file),
|
||||
md5 = str(raw.md5),
|
||||
options = type(raw.options) == "table" and raw.options or nil,
|
||||
}
|
||||
if raw.enabled == false then pin.enabled = false end
|
||||
return pin
|
||||
end
|
||||
|
||||
-- One cart listing. The eight fields the cart schema marks required are the
|
||||
-- gate: a row missing any of them is dropped rather than half-listed, because
|
||||
-- every one of them is load bearing for installing or naming the cart.
|
||||
local function parseCartEntry(raw)
|
||||
if type(raw) ~= "table" then return nil end
|
||||
if not (str(raw.id) and str(raw.title) and str(raw.author)
|
||||
and str(raw.version) and str(raw.base) and str(raw.seal)
|
||||
and str(raw.repo) and type(raw.mods) == "table") then
|
||||
return nil
|
||||
end
|
||||
local pins = {}
|
||||
for _, pin in ipairs(raw.mods) do
|
||||
local parsed = parseCartPin(pin)
|
||||
if parsed then pins[#pins + 1] = parsed end
|
||||
end
|
||||
if #pins == 0 then return nil end
|
||||
return {
|
||||
kind = "cart",
|
||||
folder = str(raw.folder),
|
||||
id = raw.id,
|
||||
title = raw.title,
|
||||
author = raw.author,
|
||||
version = raw.version,
|
||||
base = raw.base,
|
||||
seal = raw.seal,
|
||||
summary = str(raw.summary) or "",
|
||||
shell = str(raw.shell),
|
||||
finish = str(raw.finish),
|
||||
speeds = numArray(raw.speeds),
|
||||
tags = strArray(raw.tags),
|
||||
repo = raw.repo,
|
||||
github = str(raw.github),
|
||||
downloadURL = str(raw.downloadURL),
|
||||
automatic_version_check = raw.automatic_version_check ~= false,
|
||||
fixed_release_tag = str(raw.fixed_release_tag),
|
||||
game_version = str(raw.game_version),
|
||||
license = str(raw.license),
|
||||
mods = pins,
|
||||
load_order = strArray(raw.load_order),
|
||||
thumbnail = str(raw.thumbnail),
|
||||
description_url = str(raw.description_url),
|
||||
downloads = parseDownloads(raw.downloads),
|
||||
first_release = str(raw.first_release),
|
||||
last_release = str(raw.last_release),
|
||||
latest = parseLatest(raw.latest),
|
||||
update_check = str(raw.update_check) or "pending",
|
||||
}
|
||||
end
|
||||
|
||||
-- True for a listing that installs through CartStore, not the mod installer.
|
||||
function ModIndex.isCart(entry)
|
||||
return type(entry) == "table" and entry.kind == "cart"
|
||||
end
|
||||
|
||||
-- parse(jsonText [, Json])
|
||||
-- -> { schemaVersion, generatedAt, categories, baseGames, mods, carts }
|
||||
-- | nil, err
|
||||
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||
-- future schema all come back as a message the panel can print.
|
||||
@@ -254,11 +344,21 @@ function ModIndex.parse(jsonText, Json)
|
||||
local entry = parseEntry(raw)
|
||||
if entry then mods[#mods + 1] = entry end
|
||||
end
|
||||
-- Absent carts is the old-feed case, not an error.
|
||||
local carts = {}
|
||||
if type(doc.carts) == "table" then
|
||||
for _, raw in ipairs(doc.carts) do
|
||||
local entry = parseCartEntry(raw)
|
||||
if entry then carts[#carts + 1] = entry end
|
||||
end
|
||||
end
|
||||
return {
|
||||
schemaVersion = schema,
|
||||
generatedAt = str(doc.generated_at),
|
||||
categories = strArray(doc.categories),
|
||||
baseGames = strArray(doc.base_games),
|
||||
mods = mods,
|
||||
carts = carts,
|
||||
}
|
||||
end)
|
||||
if not ok then return nil, "could not read the index: " .. tostring(result) end
|
||||
@@ -421,12 +521,14 @@ function ModIndex.matches(entry, query)
|
||||
return true
|
||||
end
|
||||
|
||||
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
|
||||
-- Category and tag compare case-insensitively; feed order (already sorted by
|
||||
-- title) is preserved.
|
||||
-- filter(entries, opts) -> a new array. opts = { query, category, base, tag }.
|
||||
-- Category, base and tag compare case-insensitively; feed order (already
|
||||
-- sorted by title) is preserved. `base` is the cart-side equivalent of a
|
||||
-- mod's category: a cart plays as exactly one game and has no categories.
|
||||
function ModIndex.filter(mods, opts)
|
||||
opts = opts or {}
|
||||
local want = opts.category and tostring(opts.category):lower() or nil
|
||||
local wantBase = opts.base and tostring(opts.base):lower() or nil
|
||||
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
|
||||
local out = {}
|
||||
for _, entry in ipairs(mods or {}) do
|
||||
@@ -437,6 +539,9 @@ function ModIndex.filter(mods, opts)
|
||||
if tostring(c):lower() == want then keep = true; break end
|
||||
end
|
||||
end
|
||||
if keep and wantBase then
|
||||
keep = tostring(entry.base or ""):lower() == wantBase
|
||||
end
|
||||
if keep and wantTag then
|
||||
keep = false
|
||||
for _, t in ipairs(entry.tags or {}) do
|
||||
@@ -469,6 +574,32 @@ function ModIndex.categoriesIn(index)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every base game the feed's carts actually play as, in the feed's declared
|
||||
-- base_games order, with anything a cart names that the header forgot
|
||||
-- appended. The cart-side twin of categoriesIn.
|
||||
function ModIndex.baseGamesIn(index)
|
||||
local out, seen = {}, {}
|
||||
if type(index) ~= "table" then return out end
|
||||
local used = {}
|
||||
for _, entry in ipairs(index.carts or {}) do
|
||||
if entry.base then used[entry.base] = true end
|
||||
end
|
||||
for _, base in ipairs(index.baseGames or {}) do
|
||||
if used[base] and not seen[base] then
|
||||
seen[base] = true
|
||||
out[#out + 1] = base
|
||||
end
|
||||
end
|
||||
for _, entry in ipairs(index.carts or {}) do
|
||||
local base = entry.base
|
||||
if base and not seen[base] then
|
||||
seen[base] = true
|
||||
out[#out + 1] = base
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- sources (options.modIndexes)
|
||||
|
||||
local function loadOptions()
|
||||
@@ -569,7 +700,9 @@ function ModIndex.writeCache(feed, index)
|
||||
version = ModIndex.CACHE_VERSION,
|
||||
generatedAt = index.generatedAt,
|
||||
categories = index.categories,
|
||||
baseGames = index.baseGames,
|
||||
mods = index.mods,
|
||||
carts = index.carts,
|
||||
}
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
@@ -609,7 +742,9 @@ function ModIndex.fetch(source, opts)
|
||||
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||
generatedAt = entry.generatedAt,
|
||||
categories = entry.categories or {},
|
||||
baseGames = entry.baseGames or {},
|
||||
mods = entry.mods or {},
|
||||
carts = entry.carts or {},
|
||||
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||
end
|
||||
|
||||
@@ -674,7 +809,9 @@ local function cachedIndex(feed, stale)
|
||||
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||
generatedAt = entry.generatedAt,
|
||||
categories = entry.categories or {},
|
||||
baseGames = entry.baseGames or {},
|
||||
mods = entry.mods or {},
|
||||
carts = entry.carts or {},
|
||||
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||
end
|
||||
|
||||
|
||||
+8
-1
@@ -87,6 +87,7 @@ local function drain()
|
||||
j.status = msg.ok and "ok" or "error"
|
||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||
j.code = msg.code
|
||||
j.notModified = msg.notModified
|
||||
j.progress = msg.ok and 1 or j.progress
|
||||
end
|
||||
end
|
||||
@@ -154,12 +155,18 @@ end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
-- opts.etagRel (optional, relative to the save directory like saveRel itself)
|
||||
-- turns this into a conditional GET against a cached ETag at that path --
|
||||
-- see HostShell.httpDownload's own comment for the 304/notModified contract.
|
||||
-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a
|
||||
-- real download apart from a no-op cache hit (no file was written for the
|
||||
-- latter).
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "download", url = url, dest = saveRel,
|
||||
size = opts.size,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel })
|
||||
end
|
||||
|
||||
-- Non-blocking status. Returns a table; never nil, even for an unknown id
|
||||
|
||||
@@ -73,6 +73,12 @@ end
|
||||
-- inside the call. Where the caller knows the expected size we poll the
|
||||
-- growing file from a second pass instead; where it does not, the job simply
|
||||
-- reports indeterminate progress and the UI shows a spinner.
|
||||
--
|
||||
-- job.etagRel (optional, relative to saveDir like job.dest) turns this into
|
||||
-- a conditional GET -- see HostShell.httpDownload's own comment for the
|
||||
-- 304/notModified contract. A notModified result never writes `rel` at all
|
||||
-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so
|
||||
-- the usual "did a file actually land" check below is skipped for it.
|
||||
local function doDownload(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
@@ -84,12 +90,17 @@ local function doDownload(job)
|
||||
if dir then love.filesystem.createDirectory(dir) end
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS)
|
||||
local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil
|
||||
local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "download failed" })
|
||||
return
|
||||
end
|
||||
if notModified then
|
||||
post({ id = job.id, ok = true, path = rel, done = true, notModified = true })
|
||||
return
|
||||
end
|
||||
local info = love.filesystem.getInfo(rel)
|
||||
if not info or (info.size or 0) == 0 then
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
-- GBC Effects post-process ("Pixel Transparency" style, see
|
||||
-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder
|
||||
-- applied after palette colorization and before the CRT pass:
|
||||
-- 1 reflective screen: bright pixels blend toward a procedurally
|
||||
-- grained warm backing (the unlit-GBC "transparent whites" look)
|
||||
-- 2 + LCD subpixel grid
|
||||
-- 3 + drop shadows (dark pixels float above the backing)
|
||||
-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly
|
||||
-- drifting light source
|
||||
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
|
||||
-- Spec: docs/new-features.md (Custom Options / GBC FX)
|
||||
--
|
||||
-- One shader for all levels: features are gated by the `level` uniform
|
||||
-- (float comparisons), so cycling never recompiles. All spatial effects
|
||||
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
|
||||
-- shadow offsets and grain stay window-size independent.
|
||||
|
||||
local GBCFX = {}
|
||||
|
||||
GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
|
||||
GBCFX.level = 0
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
--
|
||||
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||
-- checkout exercise the unsupported path without stubbing love.system.
|
||||
function GBCFX.isSupported()
|
||||
local env = os.getenv("POKEPORT_GBCFX")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
extern number level;
|
||||
extern number time;
|
||||
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)
|
||||
|
||||
#define PI 3.14159265359
|
||||
|
||||
// ---- level thresholds (cumulative ladder) ----
|
||||
#define L_GRID 1.5
|
||||
#define L_SHADOW 2.5
|
||||
#define L_SUN 3.5
|
||||
|
||||
// ---- level 1: reflective backing ----
|
||||
#define BACK_BRIGHTNESS 0.48
|
||||
#define GRAIN_INTENSITY 0.065
|
||||
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
|
||||
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
|
||||
#define BASE_ALPHA 0.20
|
||||
#define WHITE_EXTRA 0.75
|
||||
// front polarizer film tint
|
||||
#define POLARIZER vec3(0.94, 1.0, 0.865)
|
||||
|
||||
// ---- level 2: LCD grid (lcd1x style) ----
|
||||
#define BRIGHTEN_SCANLINES 16.0
|
||||
#define BRIGHTEN_LCD 4.0
|
||||
|
||||
// ---- level 3: drop shadow ----
|
||||
#define SHADOW_OFFSET 3.0
|
||||
#define SHADOW_OPACITY 0.5
|
||||
|
||||
// ---- level 4: sunlight ----
|
||||
#define GLARE_INTENSITY 0.15
|
||||
#define GLARE_SIGMA 0.25
|
||||
#define SHIMMER_INTENSITY 0.25
|
||||
// chroma amplification so the bands read on the already-desaturated,
|
||||
// backing-blended image (reference applies 0.25 to raw film reflectance)
|
||||
#define SHIMMER_CHROMA_GAIN 3.0
|
||||
#define SHIMMER_SPREAD 1.8
|
||||
#define LIGHT_RANGE 0.6
|
||||
#define FILM_NOISE_AMOUNT 0.5
|
||||
#define REFLECT_FLOOR 0.03
|
||||
|
||||
float hash21(vec2 p)
|
||||
{
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
// smooth value noise, ~[0,1]
|
||||
float vnoise(vec2 p)
|
||||
{
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash21(i);
|
||||
float b = hash21(i + vec2(1.0, 0.0));
|
||||
float c = hash21(i + vec2(0.0, 1.0));
|
||||
float d = hash21(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float luma(vec3 c)
|
||||
{
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
{
|
||||
vec4 src = Texel(tex, tc);
|
||||
if (level < 0.5) {
|
||||
return src * color;
|
||||
}
|
||||
|
||||
float ps = max(pixelScale, 1.0);
|
||||
vec2 gbPix = pc / ps; // GB-pixel coordinates
|
||||
vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc
|
||||
vec2 gbTexel = texel * ps; // one GB pixel in tc
|
||||
|
||||
// Drifting light position (normalized screen coords, upper area).
|
||||
// Computed unconditionally: level 3 borrows it for shadow drift.
|
||||
vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
|
||||
0.3 + 0.2 * sin(time * 0.07));
|
||||
|
||||
// ---- level 1: procedural backing material ----
|
||||
// flat gray + 3-octave paper grain, tinted warm
|
||||
float grain = vnoise(gbPix * 0.9) * 0.5
|
||||
+ vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
|
||||
+ vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
|
||||
float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
|
||||
vec3 back = backLum * POCKET_TINT;
|
||||
|
||||
// ---- level 3: dark pixels cast a soft shadow onto the backing ----
|
||||
if (level >= L_SHADOW) {
|
||||
vec2 shOff = vec2(SHADOW_OFFSET);
|
||||
if (level >= L_SUN) {
|
||||
// subtle drift opposite the light's wander
|
||||
shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
|
||||
}
|
||||
vec2 so = tc - shOff * gbTexel;
|
||||
vec2 e = gbTexel;
|
||||
// 9-tap gaussian blur of the offset sample's brightness (unrolled)
|
||||
float s = 0.0;
|
||||
s += luma(Texel(tex, so).rgb) * 4.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
|
||||
s /= 16.0;
|
||||
float dark = 1.0 - s;
|
||||
// deadzone: near-white pixels (dark ~ 0) cast no shadow at all
|
||||
float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
|
||||
back = mix(back, back * 0.2, shadow);
|
||||
}
|
||||
|
||||
// ---- level 2: LCD subpixel grid on the lit image only ----
|
||||
vec3 lit = src.rgb;
|
||||
if (level >= L_GRID) {
|
||||
vec2 angle = 2.0 * PI * (gbPix - 0.25);
|
||||
float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
|
||||
/ (BRIGHTEN_SCANLINES + 1.0);
|
||||
float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
|
||||
lit *= yfac * xfac;
|
||||
}
|
||||
|
||||
// ---- level 1: brightness-proportional pixel transparency ----
|
||||
float lum = luma(src.rgb);
|
||||
float a = BASE_ALPHA * lum;
|
||||
// near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
|
||||
// fully transparent -- narrow smoothsteps stand in for the hard AND
|
||||
float mn = min(src.r, min(src.g, src.b));
|
||||
a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
|
||||
vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));
|
||||
|
||||
// ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
|
||||
float glare = 0.0;
|
||||
if (level >= L_SUN) {
|
||||
float aspect = love_ScreenSize.x / love_ScreenSize.y;
|
||||
vec2 p = vec2(tc.x * aspect, tc.y);
|
||||
vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
|
||||
float d = distance(p, lp);
|
||||
|
||||
// specular gaussian hotspot (added after the polarizer tint:
|
||||
// it reflects off the front glass, not the LCD)
|
||||
glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));
|
||||
|
||||
// quarter-wave-plate film: effective retardance (nm) grows with
|
||||
// distance from the light point -> concentric interference bands;
|
||||
// smooth "film thickness" noise makes the bands splotchy
|
||||
float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
|
||||
float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
|
||||
* (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
|
||||
float ph = 4.0 * PI * gammaEff;
|
||||
|
||||
// 7 wavelength samples 400..700nm with approximate spectral RGB,
|
||||
// unrolled (no const arrays in GLSL 1.20)
|
||||
vec3 rb = vec3(0.0);
|
||||
float cw;
|
||||
cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
|
||||
cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
|
||||
cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
|
||||
cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
|
||||
cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
|
||||
cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
|
||||
cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
|
||||
rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0
|
||||
|
||||
// fade with distance from the light, kill on dark pixels,
|
||||
// weight by pixel color squared
|
||||
float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
|
||||
float refl = max(lum, REFLECT_FLOOR);
|
||||
// luminance-preserving tint: add only the chroma of the rainbow
|
||||
vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
|
||||
* src.rgb * src.rgb * refl * att;
|
||||
col += shimmer * SHIMMER_INTENSITY;
|
||||
}
|
||||
|
||||
// front polarizer tint, then front-surface glare on top
|
||||
col *= POLARIZER;
|
||||
col += vec3(glare);
|
||||
|
||||
return vec4(col, src.a) * color;
|
||||
}
|
||||
]]
|
||||
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
GBCFX.level = level
|
||||
end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
-- render target (or plain if the shader is unavailable). pixelScale is
|
||||
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
|
||||
-- stay window-size independent.
|
||||
function GBCFX.present(canvas, pixelScale)
|
||||
local sh = GBCFX.shader()
|
||||
if not sh or GBCFX.level <= 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
return
|
||||
end
|
||||
local t = 0
|
||||
if love.timer and love.timer.getTime then
|
||||
t = love.timer.getTime()
|
||||
end
|
||||
sh:send("level", GBCFX.level)
|
||||
sh:send("time", t)
|
||||
sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
return GBCFX
|
||||
@@ -0,0 +1,164 @@
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript
|
||||
|
||||
local MonAnim = {}
|
||||
MonAnim.__index = MonAnim
|
||||
|
||||
-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:534
|
||||
MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 }
|
||||
|
||||
-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27
|
||||
MonAnim.END = 0xff
|
||||
MonAnim.SETREPEAT = 0xfe
|
||||
MonAnim.DOREPEAT = 0xfd
|
||||
|
||||
-- .NextBit walks the row first, so bit i is row i % height, column i / height.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:746
|
||||
function MonAnim.tileMap(data, frame)
|
||||
local tiles = data and data.tiles
|
||||
if not tiles then return nil end
|
||||
local count = tiles * tiles
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = i - 1 end
|
||||
if not frame or frame <= 0 then return out end
|
||||
local row = data.frames and data.frames[frame]
|
||||
local mask = row and data.bitmasks and data.bitmasks[row.bitmask]
|
||||
if not (row and mask) then return nil end
|
||||
local cursor = 1
|
||||
for i = 0, count - 1 do
|
||||
local byte = mask[math.floor(i / 8) + 1] or 0
|
||||
if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then
|
||||
local id = row.tiles[cursor]
|
||||
if id == nil then return nil end
|
||||
out[i + 1] = id
|
||||
cursor = cursor + 1
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:413
|
||||
function MonAnim.duration(param, speed)
|
||||
param = param % 256
|
||||
local scaled = math.floor(param * (speed or 0) / 16) % 256
|
||||
return (scaled + param) % 256
|
||||
end
|
||||
|
||||
-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164
|
||||
local SCENES = {
|
||||
battle = { "setup", "play" },
|
||||
battleSlow = { "setup2", "play" },
|
||||
menu = { "setup", "play", "wait", "idle", "play" },
|
||||
}
|
||||
|
||||
MonAnim.SCENE_WAIT = 18
|
||||
|
||||
function MonAnim.scenes() return SCENES end
|
||||
|
||||
function MonAnim.new(data, scene)
|
||||
local steps = SCENES[scene or "battle"]
|
||||
if not (data and steps and data.play and #data.play > 0) then return nil end
|
||||
return setmetatable({
|
||||
data = data,
|
||||
steps = steps,
|
||||
step = 1,
|
||||
frame = 0,
|
||||
speed = 0,
|
||||
script = nil,
|
||||
pc = 1,
|
||||
repeatTimer = 0,
|
||||
waiting = false,
|
||||
waitCounter = 0,
|
||||
sceneWait = nil,
|
||||
done = false,
|
||||
}, MonAnim)
|
||||
end
|
||||
|
||||
function MonAnim:finished() return self.done end
|
||||
|
||||
-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435
|
||||
function MonAnim:currentFrame() return self.frame end
|
||||
|
||||
function MonAnim:beginScript(rows, speed)
|
||||
self.script = rows or {}
|
||||
self.speed = speed
|
||||
self.pc = 1
|
||||
self.repeatTimer = 0
|
||||
self.waiting = false
|
||||
self.waitCounter = 0
|
||||
end
|
||||
|
||||
-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411
|
||||
function MonAnim:runScript()
|
||||
if self.waiting then
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
for _ = 1, 256 do
|
||||
local row = self.script[self.pc]
|
||||
self.pc = self.pc + 1
|
||||
if row == nil then return true end
|
||||
local command = row[1]
|
||||
if command == MonAnim.END then
|
||||
return true
|
||||
elseif command == MonAnim.SETREPEAT then
|
||||
self.repeatTimer = row[2]
|
||||
elseif command == MonAnim.DOREPEAT then
|
||||
-- .DoRepeat returns on both `ret z` (:397-406).
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.repeatTimer = self.repeatTimer - 1
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.pc = row[2] + 1
|
||||
else
|
||||
self.frame = command
|
||||
self.waiting = true
|
||||
self.waitCounter = MonAnim.duration(row[2], self.speed)
|
||||
-- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame
|
||||
-- it places is the frame the counter first ticks on.
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One iteration of AnimateFrontpic's .loop: one scene command per frame.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
|
||||
function MonAnim:update()
|
||||
if self.done then return end
|
||||
local step = self.steps[self.step]
|
||||
if step == nil then
|
||||
-- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228).
|
||||
self.frame = 0
|
||||
self.done = true
|
||||
return
|
||||
end
|
||||
if step == "setup" or step == "setup2" or step == "idle" then
|
||||
local rows = (step == "idle") and self.data.idle or self.data.play
|
||||
self:beginScript(rows, step == "setup2" and 4 or 0)
|
||||
self.step = self.step + 1
|
||||
return
|
||||
end
|
||||
if step == "wait" then
|
||||
self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1
|
||||
if self.sceneWait <= 0 then
|
||||
self.sceneWait = nil
|
||||
self.step = self.step + 1
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:runScript() then
|
||||
-- PokeAnim_Play redraws the base picture as the script ends (:196-205).
|
||||
self.frame = 0
|
||||
self.step = self.step + 1
|
||||
end
|
||||
end
|
||||
|
||||
return MonAnim
|
||||
+60
-16
@@ -156,8 +156,8 @@ function Renderer:setWorldOverride(canvas)
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
-- ShaderFX / callers treat this as the crisp scale; endFrame converts to
|
||||
-- LOVE units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local w, h = self:uiSize()
|
||||
@@ -596,7 +596,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target,
|
||||
local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if zoneShader then
|
||||
love.graphics.setShader(zoneShader)
|
||||
-- same trueColor sentinel the flat blit honors (14 §trueColor)
|
||||
-- same trueColor sentinel the flat blit below honors
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
@@ -787,8 +787,8 @@ end
|
||||
-- visible map area separately), applied to the world pass; the world
|
||||
-- pass falls back to the UI zones when absent. Each zone is drawn
|
||||
-- scissored through the shade-remap shader, later zones on top.
|
||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
-- When ShaderFX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the shader chain as a final pass.
|
||||
function Renderer:frameRects()
|
||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
|
||||
local r = {
|
||||
@@ -852,7 +852,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy
|
||||
local Ux, Uy = R.Ux, R.Uy
|
||||
local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local Up = R.Up
|
||||
-- Physical-pixel numerators for ShaderFX's per-frame rect; derived from
|
||||
-- the unit rects frameRects already lifted so both agree.
|
||||
local uoxPx, uoyPx = uox * dpiX, uoy * dpiY
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
zones = PaletteFX.ensureZones(zones)
|
||||
@@ -895,12 +899,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
end
|
||||
|
||||
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||
-- Post-process pipelines, ShaderFX and an enabled final-output owner need the
|
||||
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||
-- to the screen exactly as it always did.
|
||||
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local needPresent = ShaderFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -1005,6 +1009,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
return Renderer.clipToView(R, x, y, w, h)
|
||||
end
|
||||
|
||||
-- Real per-frame ShaderFX game rect + source content size, in PHYSICAL
|
||||
-- framebuffer pixels -- what ShaderFX.render below actually draws through
|
||||
-- the chain, instead of it reconstructing a fixed 160x144-at-base-Sp
|
||||
-- approximation of its own. Defaults to this frame's real UI rect, which
|
||||
-- is already correct whenever neither branch below overrides it
|
||||
-- (title/menu/credits, no world active) -- uiFill is already folded into
|
||||
-- Up above, so that case needs no extra handling here.
|
||||
local fxRectPxX, fxRectPxY, fxRectPxW, fxRectPxH, fxScale =
|
||||
uoxPx, uoyPx, uiw * Up, uih * Up, Up
|
||||
local fxSrcW, fxSrcH = uiw, uih
|
||||
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
-- characters and its own FX overlay -- as one window-resolution image,
|
||||
@@ -1012,6 +1027,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- worldOverride is already a window-resolution image drawn 1:1 at the
|
||||
-- origin -- ShaderFX's real rect degenerates to the whole image, no
|
||||
-- crop needed (source size == rect size).
|
||||
fxRectPxX, fxRectPxY = 0, 0
|
||||
fxRectPxW, fxRectPxH = self.worldOverride:getPixelWidth(), self.worldOverride:getPixelHeight()
|
||||
fxScale = 1
|
||||
fxSrcW, fxSrcH = fxRectPxW, fxRectPxH
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
@@ -1032,8 +1054,20 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
|
||||
local woxPx = vx + math.floor((pw - wvw * sp) / 2)
|
||||
local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift
|
||||
local wox, woy = woxPx / dpiX, woyPx / dpiY
|
||||
-- The real on-screen world rect at the CURRENT survey zoom -- can be
|
||||
-- larger (zoomed out, more map revealed) or smaller than the UI's own
|
||||
-- default rect above. ShaderFX.render below now shades this real rect
|
||||
-- against this real wvw x wvh source, not a fixed 160x144 box, so a
|
||||
-- grid/LCD-style effect's own math lines up with true on-screen pixels
|
||||
-- at any zoom level. Covers both the flat blit below and the
|
||||
-- Tilt-projected blit -- both share this wox/woy/sp/wvw/wvh.
|
||||
fxRectPxX, fxRectPxY = woxPx, woyPx
|
||||
fxRectPxW, fxRectPxH = wvw * sp, wvh * sp
|
||||
fxScale = sp
|
||||
fxSrcW, fxSrcH = wvw, wvh
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -1245,9 +1279,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if present then
|
||||
GameViewport.setTarget()
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
-- grid itself. Each pass hands back a canvas; with none registered
|
||||
-- and all -- and before ShaderFX, so a blur or colour grade is what a
|
||||
-- shader preset is then drawn over rather than something that smears
|
||||
-- it. Each pass hands back a canvas; with none registered
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
@@ -1262,9 +1296,19 @@ function Renderer:endFrame(zones, worldZones)
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
if ShaderFX.active() then
|
||||
-- The ShaderFX feature replaced GBCFX.lua's fixed level ladder
|
||||
-- with a preset picker (GBCFX.lua itself removed). fxRectPx*/fxScale/fxSrc*
|
||||
-- are this frame's REAL game rect + source size, set above by
|
||||
-- whichever branch actually ran (worldOverride, worldActive at the
|
||||
-- current survey zoom, or the UI-rect default for no-world/uiFill
|
||||
-- states) -- not a fixed 160x144-at-base-Sp reconstruction, so the
|
||||
-- chain sees true on-screen pixel geometry at any zoom/Faithful
|
||||
-- Ratio state.
|
||||
ShaderFX.render(composed,
|
||||
{ x = fxRectPxX, y = fxRectPxY, w = fxRectPxW, h = fxRectPxH, scale = fxScale },
|
||||
{ w = fxSrcW, h = fxSrcH },
|
||||
dpiX, dpiY)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
-- Mechanical GLSL rewrites over librashader's emitted output: it emits a
|
||||
-- standalone void main()/gl_FragData[0]/gl_Position shape, LOVE requires the
|
||||
-- effect()/position() convention. Not a GLSL parser -- every rule here exists
|
||||
-- because a real preset failed without it. See docs/shaderfx.md.
|
||||
local Fixup = {}
|
||||
|
||||
local function stripVersion(src)
|
||||
return (src:gsub("#version%s+%d+%s*\n", ""))
|
||||
end
|
||||
|
||||
-- ES 1.00 has no array-constructor syntax at all; SPIRV-Cross emits it anyway.
|
||||
-- Splitting needs paren-depth awareness, since an element can be vec2(a, b).
|
||||
local function splitTopLevelCommas(s)
|
||||
local parts, depth, start = {}, 0, 1
|
||||
for i = 1, #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == "(" then
|
||||
depth = depth + 1
|
||||
elseif c == ")" then
|
||||
depth = depth - 1
|
||||
elseif c == "," and depth == 0 then
|
||||
parts[#parts + 1] = s:sub(start, i - 1)
|
||||
start = i + 1
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = s:sub(start)
|
||||
return parts
|
||||
end
|
||||
|
||||
local function elementAssignLines(name, elemsStr)
|
||||
local lines = {}
|
||||
for i, elem in ipairs(splitTopLevelCommas(elemsStr)) do
|
||||
lines[#lines + 1] = ("%s[%d] = %s;"):format(name, i - 1, elem:match("^%s*(.-)%s*$"))
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function rewriteArrayLiterals(src)
|
||||
local injections = {}
|
||||
-- const, global scope: relocate the per-element assignments into main().
|
||||
src = src:gsub(
|
||||
"const%s+([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(typ, name, size, parenElems)
|
||||
for _, line in ipairs(elementAssignLines(name, parenElems:sub(2, -2))) do
|
||||
injections[#injections + 1] = " " .. line
|
||||
end
|
||||
return ("%s %s[%s];"):format(typ, name, size)
|
||||
end)
|
||||
if #injections > 0 then
|
||||
src = src:gsub("(void%s+main%s*%(%s*%)%s*{)",
|
||||
"%1\n" .. table.concat(injections, "\n"), 1)
|
||||
end
|
||||
-- non-const, local declaration+initializer: rewrite in place.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(typ, name, size, parenElems)
|
||||
local lines = elementAssignLines(name, parenElems:sub(2, -2))
|
||||
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
|
||||
end)
|
||||
-- bare reassignment of an already-declared array: rewrite in place.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(name, parenElems)
|
||||
return table.concat(elementAssignLines(name, parenElems:sub(2, -2)), "\n ")
|
||||
end)
|
||||
-- array-to-array copy (`float param_1[7] = coeffs;`): ES 1.00 has no
|
||||
-- whole-array assignment either, so copy element by element.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*([%w_]+)%s*;",
|
||||
function(typ, name, size, srcName)
|
||||
local lines = {}
|
||||
for i = 0, tonumber(size) - 1 do
|
||||
lines[#lines + 1] = ("%s[%d] = %s[%d];"):format(name, i, srcName, i)
|
||||
end
|
||||
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
|
||||
end)
|
||||
return src
|
||||
end
|
||||
Fixup.rewriteArrayLiterals = rewriteArrayLiterals
|
||||
|
||||
-- ES 1.00 has no `%` operator. `%` is only ever defined for integer operands,
|
||||
-- so float mod() is exact here; balanced-paren operand shapes are tried first.
|
||||
local function rewriteIntegerModulo(src)
|
||||
local function convert(l, r)
|
||||
return ("int(mod(float(%s), float(%s)))"):format(l, r)
|
||||
end
|
||||
src = src:gsub("(%b())%s*%%%s*(%b())", convert)
|
||||
src = src:gsub("(%b())%s*%%%s*([%w_]+)", convert)
|
||||
src = src:gsub("([%w_]+)%s*%%%s*(%b())", convert)
|
||||
src = src:gsub("([%w_]+)%s*%%%s*([%w_]+)", convert)
|
||||
return src
|
||||
end
|
||||
Fixup.rewriteIntegerModulo = rewriteIntegerModulo
|
||||
|
||||
-- SPIRV-Cross hardcodes an unguarded `precision highp` pair with no toggle;
|
||||
-- claim highp only where the driver actually offers fragment highp.
|
||||
local function guardPrecision(src)
|
||||
src = src:gsub("precision%s+highp%s+float%s*;%s*\nprecision%s+highp%s+int%s*;%s*\n", "")
|
||||
local guard = [[
|
||||
#ifdef GL_ES
|
||||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
#endif
|
||||
#endif
|
||||
]]
|
||||
return guard .. src
|
||||
end
|
||||
|
||||
-- LOVE's Shader:send cannot address a member of a struct-typed uniform, so the
|
||||
-- struct is deleted and its members re-declared: scalars packed 4-at-a-time
|
||||
-- into vec4 slots (ES 1.00 guarantees only 16 fragment uniform vectors),
|
||||
-- vectors kept whole. `specialMembers` substitutes a member to fixed text
|
||||
-- instead of declaring it at all (see UBO_SPECIAL_MEMBERS).
|
||||
local SCALAR_TYPES = { float = true, int = true, bool = true }
|
||||
local COMPONENTS = { "x", "y", "z", "w" }
|
||||
|
||||
local function flattenStruct(src, structName, packedPrefix, specialMembers)
|
||||
packedPrefix = packedPrefix or "LIBRA_PACKED_"
|
||||
specialMembers = specialMembers or {}
|
||||
local body = src:match("struct%s+" .. structName .. "%s*{(.-)}%s*;")
|
||||
if not body then return src, {} end
|
||||
local instance = src:match("uniform%s+" .. structName .. "%s+([%w_]+)%s*;")
|
||||
assert(instance, "flattenStruct: found struct " .. structName .. " but no instance uniform")
|
||||
|
||||
src = src:gsub("struct%s+" .. structName .. "%s*{.-}%s*;%s*\n", "")
|
||||
src = src:gsub("uniform%s+" .. structName .. "%s+" .. instance .. "%s*;%s*\n", "")
|
||||
|
||||
local members = {}
|
||||
for typ, name in body:gmatch("([%w_]+)%s+([%w_]+)%s*;") do
|
||||
members[#members + 1] = { type = typ, name = name }
|
||||
end
|
||||
-- Declaration order matters here: packing scalars 4-at-a-time only minimizes
|
||||
-- group count against the struct's own vec4-then-scalars layout.
|
||||
|
||||
local decls, manifest, specialSubs = {}, {}, {}
|
||||
local scalarGroup, packIndex = {}, 0
|
||||
|
||||
local function flushGroup()
|
||||
if #scalarGroup == 0 then return end
|
||||
local uniformName = packedPrefix .. packIndex
|
||||
decls[#decls + 1] = ("uniform vec4 %s;"):format(uniformName)
|
||||
for i, m in ipairs(scalarGroup) do
|
||||
manifest[#manifest + 1] = { name = m.name, uniform = uniformName, component = COMPONENTS[i], type = m.type }
|
||||
end
|
||||
packIndex, scalarGroup = packIndex + 1, {}
|
||||
end
|
||||
|
||||
for _, m in ipairs(members) do
|
||||
if specialMembers[m.name] then
|
||||
specialSubs[#specialSubs + 1] = { name = m.name, replacement = specialMembers[m.name] }
|
||||
elseif SCALAR_TYPES[m.type] then
|
||||
scalarGroup[#scalarGroup + 1] = m
|
||||
if #scalarGroup == 4 then flushGroup() end
|
||||
else
|
||||
flushGroup()
|
||||
decls[#decls + 1] = ("uniform %s %s;"):format(m.type, m.name)
|
||||
manifest[#manifest + 1] = { name = m.name, uniform = m.name }
|
||||
end
|
||||
end
|
||||
flushGroup()
|
||||
|
||||
-- longest-name-first so a substitution can't land inside a longer member name
|
||||
-- sharing a prefix; a separate order from the packing above. A packed slot
|
||||
-- only stores floats, so an int/bool member needs a cast back on every read.
|
||||
local subs = {}
|
||||
for _, entry in ipairs(manifest) do
|
||||
local replacement = entry.component and (entry.uniform .. "." .. entry.component) or entry.uniform
|
||||
if entry.component and (entry.type == "int" or entry.type == "bool") then
|
||||
replacement = ("%s(%s)"):format(entry.type, replacement)
|
||||
end
|
||||
subs[#subs + 1] = { name = entry.name, replacement = replacement }
|
||||
end
|
||||
for _, entry in ipairs(specialSubs) do subs[#subs + 1] = entry end
|
||||
table.sort(subs, function(a, b) return #a.name > #b.name end)
|
||||
for _, entry in ipairs(subs) do
|
||||
src = src:gsub(instance .. "%." .. entry.name, entry.replacement)
|
||||
end
|
||||
|
||||
return table.concat(decls, "\n") .. "\n" .. src, manifest
|
||||
end
|
||||
Fixup.flattenStruct = flattenStruct
|
||||
|
||||
-- Turns a flat {member = value} table into the {uniform = value_or_vec4} shape
|
||||
-- the packed shader expects. A missing value packs as 0 in its component.
|
||||
function Fixup.packValues(manifest, values)
|
||||
local packed = {}
|
||||
local slot = { x = 1, y = 2, z = 3, w = 4 }
|
||||
for _, entry in ipairs(manifest) do
|
||||
if entry.component then
|
||||
packed[entry.uniform] = packed[entry.uniform] or { 0, 0, 0, 0 }
|
||||
packed[entry.uniform][slot[entry.component]] = values[entry.name] or 0
|
||||
else
|
||||
packed[entry.uniform] = values[entry.name]
|
||||
end
|
||||
end
|
||||
return packed
|
||||
end
|
||||
|
||||
-- GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every scalar
|
||||
-- costs a whole one; samplers do not come out of that budget.
|
||||
Fixup.SLOTS = { float = 1, int = 1, bool = 1, vec2 = 1, vec3 = 1, vec4 = 1, mat3 = 3, mat4 = 4 }
|
||||
|
||||
function Fixup.countUniformSlots(src)
|
||||
-- Lua's %w does NOT match underscore, unlike regex \w, and every name here is
|
||||
-- full of them, so this must be [%w_]+ or whole declarations go uncounted.
|
||||
local total = 0
|
||||
for typ in src:gmatch("uniform%s+([%w_]+)%s+[%w_]+%s*;") do
|
||||
if typ ~= "sampler2D" and typ ~= "Image" then
|
||||
total = total + (Fixup.SLOTS[typ] or 1)
|
||||
end
|
||||
end
|
||||
return total
|
||||
end
|
||||
|
||||
-- LIBRA_UBO_* (the uniform-buffer block) has the same
|
||||
-- LOVE-can't-address-a-struct-member problem as LIBRA_PUSH_*, so it is
|
||||
-- flattened the same way, with MVP alone special-cased. Deleting the block
|
||||
-- outright instead left every other member reference dangling.
|
||||
|
||||
-- LOVE forward-declares effect()'s prototype under its own header's precision
|
||||
-- default, which can mismatch guardPrecision's; pin the parameter list to a
|
||||
-- #define so the caller can try each variant in order.
|
||||
Fixup.PREC_HEADS = { "#define EFFECT_PREC mediump\n", "#define EFFECT_PREC\n" }
|
||||
|
||||
-- MVP is meaningless in the fragment stage but gets declared anyway; special-
|
||||
-- casing it keeps a dead field from becoming a uniform nothing would send.
|
||||
local UBO_SPECIAL_MEMBERS = { MVP = "transform_projection" }
|
||||
|
||||
function Fixup.fragment(src)
|
||||
src = stripVersion(src)
|
||||
src = rewriteArrayLiterals(src)
|
||||
src = rewriteIntegerModulo(src)
|
||||
local pushManifest, uboManifest
|
||||
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_FRAGMENT")
|
||||
src, uboManifest = flattenStruct(src, "LIBRA_UBO_FRAGMENT", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
|
||||
local manifest = pushManifest
|
||||
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
|
||||
src = guardPrecision(src)
|
||||
src = src:gsub("void%s+main%s*%(%s*%)",
|
||||
"vec4 effect(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
|
||||
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc)")
|
||||
-- gl_FragData isn't available in LOVE's effect() convention. Rewrite EVERY
|
||||
-- occurrence, whatever operator follows: real presets (ds-hybrid-sabr) write
|
||||
-- it once with `=` and later accumulate with `+=`.
|
||||
src = src:gsub("gl_FragData%[0%]", "gbFragColor")
|
||||
src = src:gsub(
|
||||
"(vec4 effect%(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
|
||||
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc%)%s*\n{)",
|
||||
"%1\n vec4 gbFragColor;")
|
||||
-- a bare early-exit `return;` needs a value now -- gbFragColor already
|
||||
-- holds whatever was assigned just before it on every real shape seen.
|
||||
src = src:gsub("return%s*;", "return gbFragColor;")
|
||||
local lastBrace = src:match("()}%s*$")
|
||||
assert(lastBrace, "fragment fixup: could not find effect()'s closing brace")
|
||||
src = src:sub(1, lastBrace - 1) .. " return gbFragColor;\n" .. src:sub(lastBrace)
|
||||
return src, manifest
|
||||
end
|
||||
|
||||
function Fixup.vertex(src)
|
||||
src = stripVersion(src)
|
||||
src = rewriteArrayLiterals(src)
|
||||
src = rewriteIntegerModulo(src)
|
||||
local pushManifest, uboManifest
|
||||
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_VERTEX")
|
||||
-- MVP means "multiply the incoming vertex by it", which on an ordinary
|
||||
-- full-screen LOVE draw is transform_projection, so it substitutes rather
|
||||
-- than being packed. A distinct prefix keeps the UBO's packed groups off
|
||||
-- LIBRA_PUSH_*'s own numbering.
|
||||
src, uboManifest = flattenStruct(src, "LIBRA_UBO_VERTEX", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
|
||||
local manifest = pushManifest
|
||||
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
|
||||
-- LOVE supplies VertexPosition/VertexTexCoord itself.
|
||||
src = src:gsub("attribute%s+vec4%s+Position%s*;%s*\n", "")
|
||||
src = src:gsub("attribute%s+vec2%s+TexCoord%s*;%s*\n", "")
|
||||
src = src:gsub("void%s+main%s*%(%s*%)",
|
||||
"vec4 position(mat4 transform_projection, vec4 vertex_position)")
|
||||
-- gl_Position isn't available in LOVE's position() convention; carry it in a
|
||||
-- local named to share no substring with "Position".
|
||||
src = src:gsub("gl_Position%s*=%s*([^;]+);", "gbClipPos = %1;")
|
||||
-- Whole-identifier frontier match, not a blind gsub: a preset's own local
|
||||
-- `vec2 vTexCoord;` would otherwise be mangled (dot.slangp pass0).
|
||||
src = src:gsub("%f[%w]Position%f[%W]", "vertex_position")
|
||||
src = src:gsub("%f[%w]TexCoord%f[%W]", "VertexTexCoord%.xy")
|
||||
src = src:gsub(
|
||||
"(vec4 position%(mat4 transform_projection, vec4 vertex_position%)%s*\n{)",
|
||||
"%1\n vec4 gbClipPos;")
|
||||
-- append the return just before the function's final closing brace
|
||||
local lastBrace = src:match("()}%s*$")
|
||||
assert(lastBrace, "vertex fixup: could not find main()'s closing brace")
|
||||
src = src:sub(1, lastBrace - 1) .. " return gbClipPos;\n" .. src:sub(lastBrace)
|
||||
return src, manifest
|
||||
end
|
||||
|
||||
return Fixup
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Local find/replace patches applied to a preset's raw .slang/.inc source on
|
||||
-- disk, before ShaderFX.convert() hands it to the bridge -- only librashader's
|
||||
-- own parser can discover a new #pragma parameter, so nothing downstream can
|
||||
-- add one. Ships with an empty table on purpose; see docs/shaderfx.md.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- entry.name -> { { relPath = <relative to the .slangp's own directory>,
|
||||
-- patches = { { old = <literal>, new = <literal> }, ... } } }
|
||||
M.PATCHES = {}
|
||||
|
||||
local function dirname(path)
|
||||
return path:match("^(.*)[/\\][^/\\]*$") or "."
|
||||
end
|
||||
|
||||
-- Plain (non-pattern) substring replace: GLSL source is full of Lua pattern
|
||||
-- magic characters, so gsub on literal source text is not safe here.
|
||||
local function replaceOnce(text, old, new)
|
||||
local s, e = text:find(old, 1, true)
|
||||
if not s then return text, false end
|
||||
return text:sub(1, s - 1) .. new .. text:sub(e + 1), true
|
||||
end
|
||||
|
||||
-- Applies every registered patch for `entry` to the real files on disk. A patch
|
||||
-- whose anchor is gone (already applied, or upstream changed) is skipped.
|
||||
function M.apply(entry)
|
||||
local list = M.PATCHES[entry.name]
|
||||
if not list then return end
|
||||
local base = dirname(entry.fullPath)
|
||||
for _, file in ipairs(list) do
|
||||
local path = base .. "/" .. file.relPath
|
||||
local f = io.open(path, "rb")
|
||||
if not f then
|
||||
Logger.warn("ShaderSourcePatches: %s: cannot open %s", entry.name, path)
|
||||
else
|
||||
local text = f:read("*a")
|
||||
f:close()
|
||||
local changed = false
|
||||
for _, p in ipairs(file.patches) do
|
||||
local already = text:find(p.new, 1, true) ~= nil
|
||||
if not already then
|
||||
local newText, ok = replaceOnce(text, p.old, p.new)
|
||||
if ok then
|
||||
text = newText
|
||||
changed = true
|
||||
else
|
||||
Logger.warn("ShaderSourcePatches: %s: patch anchor not found in %s (upstream changed?)",
|
||||
entry.name, file.relPath)
|
||||
end
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
local wf, werr = io.open(path, "wb")
|
||||
if not wf then
|
||||
Logger.warn("ShaderSourcePatches: %s: cannot write %s: %s", entry.name, path, tostring(werr))
|
||||
else
|
||||
wf:write(text)
|
||||
wf:close()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -17,6 +17,25 @@ Zoom.offset = 0
|
||||
-- close-up. Nil/true keeps the historical full range.
|
||||
Zoom.allowSurvey = true
|
||||
|
||||
-- The deepest legal survey step (offset = 1-S, effective scale s' = S+lo =
|
||||
-- 1 px/world px) plus an active SHADER FX chain crashes the app on real
|
||||
-- hardware (Pixel 10 Pro XL, "OUT7" == this exact step at that device's own
|
||||
-- fit scale of 8 -- reported by the user, 2026-08-21). A quantifying repro
|
||||
-- (TrueFX/zoom-mem-scan/) ruled out ShaderFX's own per-pass canvases as the
|
||||
-- driver (worst real-corpus case at that step: ~31MB, not crash-sized) --
|
||||
-- the actual cost is almost certainly the world canvas/tile-render path
|
||||
-- itself at that resolution (a preexisting cost of deep survey zoom,
|
||||
-- unrelated to ShaderFX), which a shader chain reading that same
|
||||
-- full-resolution canvas as its own input then pushes over some real
|
||||
-- device/driver ceiling this project cannot measure without the device
|
||||
-- in hand. Removing just the single deepest step while a preset is active
|
||||
-- is the smallest change that directly targets what was actually reported
|
||||
-- ("OUT7 + any shader", not "OUT7 alone" and not "OUT6 or shallower") --
|
||||
-- not a guessed-at general zoom restriction. Needs the user's own on-device
|
||||
-- confirmation; extend the margin (currently 1 step) if OUT6 turns out to
|
||||
-- be risky too.
|
||||
local ShaderFX
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
|
||||
@@ -37,6 +56,14 @@ function Zoom.offsetRange(S)
|
||||
elseif lo > -3 then
|
||||
lo = -3
|
||||
end
|
||||
-- SHADER FX + the single deepest survey step: see the comment above.
|
||||
-- The floor is one step above the deepest this call would otherwise
|
||||
-- allow, so the three-step minimum above still keeps a zoom-out.
|
||||
ShaderFX = ShaderFX or require("src.render.ShaderFX")
|
||||
if ShaderFX.active() then
|
||||
local floor = math.min(2 - S, lo + 1)
|
||||
if lo < floor then lo = floor end
|
||||
end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
@@ -192,7 +193,7 @@ local function defaultsSave()
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
},
|
||||
}
|
||||
@@ -228,10 +229,11 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local GEN2_SAV_UNSUPPORTED = {
|
||||
gold = "Pokemon Gold",
|
||||
silver = "Pokemon Silver",
|
||||
}
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
end
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
@@ -245,7 +247,7 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
end
|
||||
@@ -281,7 +283,7 @@ function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
|
||||
+15
-6
@@ -340,10 +340,8 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
-- A map script often follows a trainer battle with its own text.
|
||||
-- Keep a level evolution behind that text: otherwise afterBattle
|
||||
-- pushes the evolution screen, then this runner resumes and pushes
|
||||
-- the trainer's text on top of it.
|
||||
-- The map-side follow-up (stampClosedDoors, #372) rides behind the
|
||||
-- script's own text; the evolution now runs in BattleState:finish.
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
@@ -1211,6 +1209,16 @@ function Commands.replace_block(ctx, bx, by, blockId)
|
||||
if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end
|
||||
end
|
||||
|
||||
-- ss_anne_departs: scripts/VermilionDock.asm:80 .shift_columns_up, blocking
|
||||
-- until she has cleared her own width
|
||||
function Commands.ss_anne_departs(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow or not ow.startSsAnneDeparture then return end
|
||||
local runner = ctx.runner
|
||||
ow:startSsAnneDeparture(function() runner:resume() end)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- set_tile_anim <anim|false>: override the current tileset's animation
|
||||
-- ("TILEANIM_WATER"; false stops it) until the next map change restores
|
||||
-- the record (setMap)
|
||||
@@ -1422,7 +1430,7 @@ Commands.meta = {}
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "fade", "pan_camera" }) do
|
||||
"give_pokemon", "fade", "pan_camera", "ss_anne_departs" }) do
|
||||
Commands.meta[verb] = { foreground = true }
|
||||
end
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
@@ -1430,7 +1438,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "wait",
|
||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do
|
||||
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way",
|
||||
"ss_anne_departs" }) do
|
||||
local meta = Commands.meta[verb] or {}
|
||||
Commands.meta[verb] = meta
|
||||
meta.blocking = true
|
||||
|
||||
@@ -390,9 +390,12 @@ function H.CutDownTreeOrGrass(ctx)
|
||||
end
|
||||
|
||||
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
|
||||
-- OWCutAnimation: the same block write, the same redraw.
|
||||
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
|
||||
-- port owns as World:playWhirlpoolSound. #1717
|
||||
function H.DisappearWhirlpool(ctx)
|
||||
return H.CutDownTreeOrGrass(ctx)
|
||||
local ret = H.CutDownTreeOrGrass(ctx)
|
||||
call(ctx, "playWhirlpoolSound")
|
||||
return ret
|
||||
end
|
||||
|
||||
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
|
||||
|
||||
@@ -32,6 +32,12 @@ local FIX_FACING = 0x3b
|
||||
-- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE.
|
||||
local TREE_SHAKE = 0x56
|
||||
local TREE_SHAKE_FRAMES = 24
|
||||
-- engine/events/forced_movement.asm:25-51; step_dig's frame count is the
|
||||
-- byte after it (macros/scripts/movement.asm:163-167)
|
||||
local TURN_HEAD = 0x00
|
||||
local TURN_IN = 0x24
|
||||
local STEP_DIG = 0x4f
|
||||
local STEP_DIG_FRAMES = 16
|
||||
|
||||
function Movement.dir(nibble)
|
||||
return DIR[nibble % 4] or "down"
|
||||
@@ -57,8 +63,10 @@ function Movement.decodeByte(b)
|
||||
return { kind = "step", dir = dir }
|
||||
elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides
|
||||
return { kind = "step", dir = dir }
|
||||
elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall
|
||||
return { kind = "turn", dir = dir }
|
||||
elseif family == 0x20 or family == 0x24 or family == 0x28 then
|
||||
-- turn_away / turn_in / turn_waterfall all `jp TurningStep`, which steps a
|
||||
-- cell under OBJECT_ACTION_SPIN -- movement.asm:483-513, :693-715 (#1716)
|
||||
return { kind = "step", dir = dir, spin = true }
|
||||
elseif family == 0x2c or family == 0x30 or family == 0x34 then
|
||||
-- slow_jump_step / jump_step / fast_jump_step, all of which reach
|
||||
-- JumpStep (engine/overworld/movement.asm:741) and so run under
|
||||
@@ -141,6 +149,8 @@ Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING
|
||||
Movement.FIX_FACING = FIX_FACING
|
||||
Movement.TREE_SHAKE = TREE_SHAKE
|
||||
Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES
|
||||
Movement.STEP_DIG = STEP_DIG
|
||||
Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES
|
||||
|
||||
-- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE
|
||||
-- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count
|
||||
@@ -177,4 +187,15 @@ function Movement.stepByte(dir)
|
||||
return 0x0c + (DIR_BYTE[dir] or 0)
|
||||
end
|
||||
|
||||
-- Script_ForcedMovement's stream, `back` being the direction thrown in
|
||||
-- -- engine/events/forced_movement.asm:25-51
|
||||
function Movement.forcedMovementBytes(back)
|
||||
local d = DIR_BYTE[back] or 0
|
||||
return {
|
||||
STEP_DIG, STEP_DIG_FRAMES, TURN_IN + d,
|
||||
STEP_DIG, STEP_DIG_FRAMES, TURN_HEAD + d,
|
||||
STEP_END,
|
||||
}
|
||||
end
|
||||
|
||||
return Movement
|
||||
|
||||
+129
-5
@@ -173,6 +173,113 @@ local Opcodes = {
|
||||
[0xa1] = { name = "warpfacing", size = 5 },
|
||||
}
|
||||
|
||||
-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2
|
||||
Opcodes.NUM_EVENT_COMMANDS = 0xa2
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so
|
||||
-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it.
|
||||
local CRYSTAL = {}
|
||||
for byte = 0x00, 0x51 do
|
||||
local row = Opcodes[byte]
|
||||
CRYSTAL[byte] = { name = row.name, size = row.size }
|
||||
end
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against
|
||||
-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237.
|
||||
CRYSTAL[0x52] = { name = "farjumptext", size = 3 }
|
||||
CRYSTAL[0x53] = { name = "jumptext", size = 2 }
|
||||
CRYSTAL[0x54] = { name = "waitbutton", size = 0 }
|
||||
CRYSTAL[0x55] = { name = "promptbutton", size = 0 }
|
||||
CRYSTAL[0x56] = { name = "pokepic", size = 1 }
|
||||
CRYSTAL[0x57] = { name = "closepokepic", size = 0 }
|
||||
CRYSTAL[0x58] = { name = "_2dmenu", size = 0 }
|
||||
CRYSTAL[0x59] = { name = "verticalmenu", size = 0 }
|
||||
CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 }
|
||||
CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 }
|
||||
CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 }
|
||||
CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 }
|
||||
CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 }
|
||||
CRYSTAL[0x5f] = { name = "startbattle", size = 0 }
|
||||
CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 }
|
||||
CRYSTAL[0x61] = { name = "catchtutorial", size = 1 }
|
||||
CRYSTAL[0x62] = { name = "trainertext", size = 1 }
|
||||
CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 }
|
||||
CRYSTAL[0x64] = { name = "winlosstext", size = 4 }
|
||||
CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 }
|
||||
CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 }
|
||||
CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 }
|
||||
CRYSTAL[0x68] = { name = "setlasttalked", size = 1 }
|
||||
CRYSTAL[0x69] = { name = "applymovement", size = 3 }
|
||||
CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 }
|
||||
CRYSTAL[0x6b] = { name = "faceplayer", size = 0 }
|
||||
CRYSTAL[0x6c] = { name = "faceobject", size = 2 }
|
||||
CRYSTAL[0x6d] = { name = "variablesprite", size = 2 }
|
||||
CRYSTAL[0x6e] = { name = "disappear", size = 1 }
|
||||
CRYSTAL[0x6f] = { name = "appear", size = 1 }
|
||||
CRYSTAL[0x70] = { name = "follow", size = 2 }
|
||||
CRYSTAL[0x71] = { name = "stopfollow", size = 0 }
|
||||
CRYSTAL[0x72] = { name = "moveobject", size = 3 }
|
||||
CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 }
|
||||
CRYSTAL[0x74] = { name = "loademote", size = 1 }
|
||||
CRYSTAL[0x75] = { name = "showemote", size = 3 }
|
||||
CRYSTAL[0x76] = { name = "turnobject", size = 2 }
|
||||
CRYSTAL[0x77] = { name = "follownotexact", size = 2 }
|
||||
CRYSTAL[0x78] = { name = "earthquake", size = 1 }
|
||||
CRYSTAL[0x79] = { name = "changemapblocks", size = 3 }
|
||||
CRYSTAL[0x7a] = { name = "changeblock", size = 3 }
|
||||
CRYSTAL[0x7b] = { name = "reloadmap", size = 0 }
|
||||
CRYSTAL[0x7c] = { name = "refreshmap", size = 0 }
|
||||
CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 }
|
||||
CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 }
|
||||
CRYSTAL[0x7f] = { name = "playmusic", size = 2 }
|
||||
CRYSTAL[0x80] = { name = "encountermusic", size = 0 }
|
||||
CRYSTAL[0x81] = { name = "musicfadeout", size = 3 }
|
||||
CRYSTAL[0x82] = { name = "playmapmusic", size = 0 }
|
||||
CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 }
|
||||
CRYSTAL[0x84] = { name = "cry", size = 2 }
|
||||
CRYSTAL[0x85] = { name = "playsound", size = 2 }
|
||||
CRYSTAL[0x86] = { name = "waitsfx", size = 0 }
|
||||
CRYSTAL[0x87] = { name = "warpsound", size = 0 }
|
||||
CRYSTAL[0x88] = { name = "specialsound", size = 0 }
|
||||
CRYSTAL[0x89] = { name = "autoinput", size = 3 }
|
||||
CRYSTAL[0x8a] = { name = "newloadmap", size = 1 }
|
||||
CRYSTAL[0x8b] = { name = "pause", size = 1 }
|
||||
CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 }
|
||||
CRYSTAL[0x8d] = { name = "sdefer", size = 2 }
|
||||
CRYSTAL[0x8e] = { name = "warpcheck", size = 0 }
|
||||
CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 }
|
||||
CRYSTAL[0x90] = { name = "endcallback", size = 0 }
|
||||
CRYSTAL[0x91] = { name = "end", size = 0 }
|
||||
CRYSTAL[0x92] = { name = "reloadend", size = 1 }
|
||||
CRYSTAL[0x93] = { name = "endall", size = 0 }
|
||||
CRYSTAL[0x94] = { name = "pokemart", size = 3 }
|
||||
CRYSTAL[0x95] = { name = "elevator", size = 2 }
|
||||
CRYSTAL[0x96] = { name = "trade", size = 1 }
|
||||
CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 }
|
||||
CRYSTAL[0x98] = { name = "phonecall", size = 2 }
|
||||
CRYSTAL[0x99] = { name = "hangup", size = 0 }
|
||||
CRYSTAL[0x9a] = { name = "describedecoration", size = 1 }
|
||||
CRYSTAL[0x9b] = { name = "fruittree", size = 1 }
|
||||
CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 }
|
||||
CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 }
|
||||
CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 }
|
||||
CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 }
|
||||
-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare
|
||||
-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662).
|
||||
CRYSTAL[0xa0] = { name = "swarm", size = 3 }
|
||||
CRYSTAL[0xa1] = { name = "halloffame", size = 0 }
|
||||
CRYSTAL[0xa2] = { name = "credits", size = 0 }
|
||||
CRYSTAL[0xa3] = { name = "warpfacing", size = 5 }
|
||||
CRYSTAL[0xa4] = { name = "battletowertext", size = 1 }
|
||||
CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 }
|
||||
CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 }
|
||||
CRYSTAL[0xa7] = { name = "getname", size = 3 }
|
||||
CRYSTAL[0xa8] = { name = "wait", size = 1 }
|
||||
CRYSTAL[0xa9] = { name = "checksave", size = 0 }
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa
|
||||
CRYSTAL.NUM_EVENT_COMMANDS = 0xaa
|
||||
|
||||
-- Commands that end the current linear path (jumps transfer control).
|
||||
--
|
||||
-- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does
|
||||
@@ -185,9 +292,13 @@ local Opcodes = {
|
||||
-- these four regardless. `catchtutorial` is deliberately NOT here: it ends on
|
||||
-- `jp Script_reloadmap` and the script really does continue afterwards, the
|
||||
-- same way `reloadmap` itself does.
|
||||
--
|
||||
-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext`
|
||||
-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on
|
||||
-- `jp ScriptJump` into JumpTextScript.
|
||||
Opcodes.TERMINATORS = {
|
||||
sjump = true, farsjump = true, memjump = true, jumpstd = true,
|
||||
jumptext = true, jumptextfaceplayer = true,
|
||||
jumptext = true, farjumptext = true, jumptextfaceplayer = true,
|
||||
stopandsjump = true, ["end"] = true, endall = true, endcallback = true,
|
||||
reloadend = true,
|
||||
fruittree = true, describedecoration = true,
|
||||
@@ -198,10 +309,12 @@ Opcodes.TERMINATORS = {
|
||||
-- behind it. Every row above is keyed by the opcode byte the cart carries, and
|
||||
-- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte
|
||||
-- that is not in that table breaks the pointer walk into an `unknown` row. Give
|
||||
-- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start
|
||||
-- with it would decode as a mod call instead of ending the walk, so the free
|
||||
-- space stays free and the extension op is reachable only by NAME -- which is to
|
||||
-- say, only from a row a mod wrote, never from one the extractor did.
|
||||
-- a mod verb one of the free bytes and ROM data that happens to start with it
|
||||
-- would decode as a mod call instead of ending the walk, so the free space stays
|
||||
-- free and the extension op is reachable only by NAME -- which is to say, only
|
||||
-- from a row a mod wrote, never from one the extractor did. The free space is
|
||||
-- per dialect and the intersection is what matters: Gold leaves $a2..$ff
|
||||
-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068).
|
||||
--
|
||||
-- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the
|
||||
-- row shapes and the verb table is documented there.
|
||||
@@ -211,4 +324,15 @@ function Opcodes.key(bank, address)
|
||||
return string.format("%02x:%04x", bank, address)
|
||||
end
|
||||
|
||||
CRYSTAL.TERMINATORS = Opcodes.TERMINATORS
|
||||
CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND
|
||||
CRYSTAL.key = Opcodes.key
|
||||
|
||||
-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540);
|
||||
-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541).
|
||||
function Opcodes.forEdition(edition)
|
||||
if edition == "crystal" then return CRYSTAL end
|
||||
return Opcodes
|
||||
end
|
||||
|
||||
return Opcodes
|
||||
|
||||
+346
-39
@@ -1,7 +1,7 @@
|
||||
-- The `special` command's handlers (data/events/special_pointers.asm).
|
||||
--
|
||||
-- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of
|
||||
-- code: the VM is one interpreter with a shared control flow, and this is 112
|
||||
-- code: the VM is one interpreter with a shared control flow, and this is 169
|
||||
-- INDEPENDENT routines that happen to share a dispatch table. Growing them
|
||||
-- inside runList's else-chain would have buried the interpreter.
|
||||
--
|
||||
@@ -9,9 +9,9 @@
|
||||
-- which the extractor turns into constants.specialOrder, and Vm:specialName
|
||||
-- resolves the one into the other; keying on the label rather than on the
|
||||
-- number means a repointed table cannot silently call the wrong routine, and
|
||||
-- it means a test can assert the mapping against the cache. This cache's
|
||||
-- order has 112 rows -- the asm file's 113 `add_special` matches include the
|
||||
-- MACRO line itself.
|
||||
-- it means a test can assert the mapping against the cache. A Gold cache's
|
||||
-- order has 112 rows and a Crystal one 169 -- the asm files' `add_special`
|
||||
-- match counts are one higher each, because they include the MACRO line.
|
||||
--
|
||||
-- Three kinds of entry live here:
|
||||
--
|
||||
@@ -52,6 +52,7 @@
|
||||
-- it, plus the fruit trees and the daily rollover Kurt waits on.
|
||||
local Apricorns = require("src.core.gen2.Apricorns")
|
||||
local BugContest = require("src.core.gen2.BugContest")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Happiness = require("src.core.gen2.Happiness")
|
||||
local Phone = require("src.core.gen2.Phone")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
@@ -175,6 +176,20 @@ local function selectMon(vm, prompt)
|
||||
return picked.index, picked.mon
|
||||
end
|
||||
|
||||
Specials.shared = {
|
||||
TRUE = TRUE,
|
||||
FALSE = FALSE,
|
||||
block = Specials.block,
|
||||
hooks = hooks,
|
||||
party = party,
|
||||
save = save,
|
||||
data = data,
|
||||
answer = answer,
|
||||
nameMon = nameMon,
|
||||
selectMon = selectMon,
|
||||
showRawHeld = showRawHeld,
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Magikarp lengths (engine/events/magikarp.asm)
|
||||
--------------------------------------------------------------------------
|
||||
@@ -1058,7 +1073,7 @@ end
|
||||
-- -- is drawn on the cartridge itself, and only the A press farcalls
|
||||
-- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the
|
||||
-- A press nowhere, which is what a cartridge with nothing in its link port
|
||||
-- does; PrintDiploma next door has no viewer half at all and stays a stub.
|
||||
-- does; PrintDiploma next door shows its page and prints nothing either.
|
||||
--
|
||||
-- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the
|
||||
-- special returns before it draws anything. The routine never writes
|
||||
@@ -1117,6 +1132,10 @@ H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end
|
||||
H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end
|
||||
H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end
|
||||
|
||||
-- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same
|
||||
-- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand.
|
||||
H.BattleTowerFade = function(vm) fade(vm, "inWhite") end
|
||||
|
||||
-- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is
|
||||
-- blanked to the background colour under a fade that is already down. The
|
||||
-- port fades with a flat sheet, so the sheet IS the cleared screen.
|
||||
@@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(vm)
|
||||
if h.reloadSprites then h.reloadSprites(false) end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp
|
||||
-- GetSGBLayout`: the map's own palette layout, reapplied and nothing else.
|
||||
H.LoadMapPalettes = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.reloadSprites then h.reloadSprites(true) end
|
||||
end
|
||||
|
||||
-- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its
|
||||
-- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm.
|
||||
H.RefreshSprites = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.reloadSprites then h.reloadSprites(false) end
|
||||
end
|
||||
|
||||
-- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState
|
||||
-- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes
|
||||
-- getting on and off a Lapras a one-byte change rather than an animation.
|
||||
@@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(vm)
|
||||
if h.updatePlayerSprite then h.updatePlayerSprite() end
|
||||
end
|
||||
|
||||
-- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515:
|
||||
-- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette.
|
||||
H.SetPlayerPalette = function(vm)
|
||||
local value = (vm.scriptVar or 0) % 0x100
|
||||
if value < 0x80 then return end
|
||||
vm.playerPalette = math.floor(value / 0x10) % 8
|
||||
local h = hooks(vm)
|
||||
if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end
|
||||
end
|
||||
|
||||
-- ---- 58-62 sound and the water --------------------------------------------
|
||||
|
||||
-- WaitSFX: hold until the sound effect that is playing finishes. The VM has
|
||||
@@ -1332,6 +1375,13 @@ H.GiveShuckle = function(vm)
|
||||
mon.ot = Specials.MANIA_OT
|
||||
mon.otId = Specials.MANIA_OT_ID
|
||||
list[#list + 1] = mon
|
||||
-- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719
|
||||
local record = save(vm)
|
||||
if record then
|
||||
record.pokedex = record.pokedex or { seen = {}, caught = {} }
|
||||
record.pokedex.seen[mon.species] = true
|
||||
record.pokedex.caught[mon.species] = true
|
||||
end
|
||||
answer(vm, TRUE)
|
||||
end
|
||||
|
||||
@@ -1445,6 +1495,22 @@ local function trailingDigitsShared(a, b)
|
||||
end
|
||||
Specials.trailingDigitsShared = trailingDigitsShared
|
||||
|
||||
-- pokegold/constants/pokemon_data_constants.asm:122-123
|
||||
local NUM_BOXES, NUM_BOXES_JP = 14, 9
|
||||
|
||||
-- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked
|
||||
-- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES.
|
||||
local function luckyNumberBoxOrder(record)
|
||||
local current = math.floor(tonumber(record and record.currentBox) or 1)
|
||||
local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP
|
||||
local order = { current }
|
||||
for index = 1, last do
|
||||
if index ~= current then order[#order + 1] = index end
|
||||
end
|
||||
return order
|
||||
end
|
||||
Specials.luckyNumberBoxOrder = luckyNumberBoxOrder
|
||||
|
||||
local function luckyPrizeFor(shared)
|
||||
if shared >= 5 then return 1 end
|
||||
if shared >= 3 then return 2 end
|
||||
@@ -1508,8 +1574,9 @@ H.CheckForLuckyNumberWinners = function(vm)
|
||||
end
|
||||
end
|
||||
for _, mon in ipairs(party(vm)) do consider(mon, false) end
|
||||
for _, box in pairs((record and record.boxes) or {}) do
|
||||
for _, mon in ipairs(box or {}) do consider(mon, true) end
|
||||
local boxes = (record and record.boxes) or {}
|
||||
for _, index in ipairs(luckyNumberBoxOrder(record)) do
|
||||
for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end
|
||||
end
|
||||
answer(vm, best)
|
||||
if bestMon then
|
||||
@@ -1586,9 +1653,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20
|
||||
-- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no
|
||||
-- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer
|
||||
-- `xor a`, which is the FALSE `.Cancel` waits on.
|
||||
--
|
||||
-- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a
|
||||
-- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a`
|
||||
-- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what
|
||||
-- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL,
|
||||
-- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the
|
||||
-- count written here is the one apricorn Apricorns.takeApricorn tosses.
|
||||
H.SelectApricornForKurt = function(vm)
|
||||
local h = hooks(vm)
|
||||
local record = save(vm)
|
||||
if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end
|
||||
local list = Apricorns.bagList(record and record.inventory)
|
||||
if list.empty then return answer(vm, FALSE) end
|
||||
|
||||
@@ -1612,7 +1687,10 @@ H.SelectApricornForKurt = function(vm)
|
||||
local item = h.itemIndex and h.itemIndex(apricorn)
|
||||
if not item or item == 0 then return answer(vm, FALSE) end
|
||||
answer(vm, item)
|
||||
Apricorns.takeApricorn(record, apricorn)
|
||||
if Apricorns.takeApricorn(record, apricorn)
|
||||
and h.setKurtApricornQuantity then
|
||||
h.setKurtApricornQuantity(1)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 88-89 the first party slot -------------------------------------------
|
||||
@@ -1917,7 +1995,7 @@ local OAK_RATINGS = {
|
||||
-- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs
|
||||
-- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and
|
||||
-- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's
|
||||
-- `special PrintDiploma` (also stubbed: "printer: no Game Boy Printer").
|
||||
-- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua).
|
||||
-- A finished #DEX here just means every rating after this one is this
|
||||
-- same line.
|
||||
{ max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source(
|
||||
@@ -2027,7 +2105,7 @@ Specials.ROAMERS = Roamers.SPECIES
|
||||
H.InitRoamMons = function(vm)
|
||||
local record = save(vm)
|
||||
if not record then return end
|
||||
Roamers.init(record, { force = true })
|
||||
Roamers.init(record, { force = true, data = data(vm) })
|
||||
end
|
||||
|
||||
-- ---- the #DEX-completion diploma -------------------------------------------
|
||||
@@ -2328,7 +2406,7 @@ end
|
||||
-- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the
|
||||
-- serial port to a physical Game Boy Printer. There is no peripheral for it
|
||||
-- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and
|
||||
-- PrintDiploma stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz,
|
||||
-- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz,
|
||||
-- .cancel` is
|
||||
-- hardwired to the nz arm below: the portrait shows, then the print always
|
||||
-- comes back as though the printer errored, which is the honest answer for
|
||||
@@ -2370,11 +2448,137 @@ H.PhotoStudio = function(vm)
|
||||
vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto))
|
||||
end
|
||||
|
||||
-- ---- 21 the quick save -----------------------------------------------------
|
||||
--
|
||||
-- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is
|
||||
-- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and
|
||||
-- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame
|
||||
-- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary
|
||||
-- write, so the FALSE arm is a refusal at the overwrite prompt -- which is
|
||||
-- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on,
|
||||
-- and what the four PokeCenter2F cable rows share.
|
||||
--
|
||||
-- Two differences from the cart, neither observable in the answer.
|
||||
-- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before
|
||||
-- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the
|
||||
-- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than
|
||||
-- after the saved page has typed.
|
||||
|
||||
-- data/text/common_2.asm:1279-1299.
|
||||
local SAVE_TEXT = {
|
||||
already = Strings.source(
|
||||
"There is already a\nsave file. Is it\vOK to overwrite?"),
|
||||
another = Strings.source(
|
||||
"There is another\nsave file. Is it\vOK to overwrite?"),
|
||||
saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."),
|
||||
saved = Strings.source("%s saved\nthe game."),
|
||||
}
|
||||
|
||||
-- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the
|
||||
-- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the
|
||||
-- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s
|
||||
-- because the world does not tick while a box owns the stack (Vm:showRaw).
|
||||
local SAVING_HOLD = 16 + 32
|
||||
local SAVED_HOLD = 30 + 30
|
||||
|
||||
H.TryQuickSave = function(vm)
|
||||
local h = hooks(vm)
|
||||
-- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then
|
||||
-- CompareLoadedAndSavedPlayerID (:212) picking which question is asked.
|
||||
local exists, sameId = false, false
|
||||
if h.saveFileState then exists, sameId = h.saveFileState() end
|
||||
if exists then
|
||||
showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another))
|
||||
if not coroutine.yield({ kind = "yesorno" }) then
|
||||
return answer(vm, FALSE)
|
||||
end
|
||||
end
|
||||
vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD)
|
||||
-- _SaveGameData (:273). A veto from the save.write mod hook is the one way
|
||||
-- this port can refuse a write the cart always completes, and a refusal is
|
||||
-- the same FALSE the overwrite prompt's NO gives.
|
||||
if not (h.writeSave and h.writeSave() ~= false) then
|
||||
return answer(vm, FALSE)
|
||||
end
|
||||
if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end
|
||||
local record = save(vm)
|
||||
local name = (record and record.player and record.player.name) or ""
|
||||
vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD)
|
||||
answer(vm, TRUE)
|
||||
end
|
||||
|
||||
-- ---- 111 the dummy --------------------------------------------------------
|
||||
-- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a
|
||||
-- handler rather than to the unimplemented ledger.
|
||||
H.UnusedDummySpecial = function() end
|
||||
|
||||
-- ---- 109-165 the Crystal rows ---------------------------------------------
|
||||
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes,
|
||||
-- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`.
|
||||
local function ownsMonAnywhere(vm, wanted)
|
||||
local h = hooks(vm)
|
||||
local list = party(vm)
|
||||
if #list == 0 then return false end
|
||||
local record = save(vm)
|
||||
local player = record and record.player
|
||||
local function owns(mon)
|
||||
if not mon then return false end
|
||||
if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end
|
||||
if player and player.id and mon.otId and mon.otId ~= player.id then
|
||||
return false
|
||||
end
|
||||
if player and player.name and mon.ot and mon.ot ~= player.name then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
for _, mon in ipairs(list) do
|
||||
if owns(mon) then return true end
|
||||
end
|
||||
for _, box in pairs((record and record.boxes) or {}) do
|
||||
for _, mon in ipairs(box or {}) do
|
||||
if owns(mon) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:31
|
||||
H.MonCheck = function(vm)
|
||||
answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright
|
||||
-- splash, which is Game2:softReset rather than Game2:returnToTitle.
|
||||
H.Reset = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.softReset then h.softReset() end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind
|
||||
-- it as dead code, so a no-op is the whole routine.
|
||||
H.StubbedTrainerRankings_Healings = function() end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which
|
||||
-- is what sends every Pokecenter 2F mobile branch down its cable arm.
|
||||
H.CheckMobileAdapterStatusSpecial = function(vm)
|
||||
answer(vm, FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`.
|
||||
H.UnusedBattleTowerDummySpecial1 = function() end
|
||||
H.UnusedBattleTowerDummySpecial2 = function() end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown:
|
||||
-- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer,
|
||||
-- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK.
|
||||
H.SampleKenjiBreakCountdown = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The deliberate stubs
|
||||
--------------------------------------------------------------------------
|
||||
@@ -2394,7 +2598,6 @@ local STUB_ROWS = {
|
||||
{ "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" },
|
||||
{ "WaitForLinkedFriend", 0, "link cable: nobody ever connects" },
|
||||
{ "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" },
|
||||
{ "TryQuickSave", 0, "link cable: the cable club's own save is not ported" },
|
||||
{ "CheckBothSelectedSameRoom", 0, "link cable: no second player" },
|
||||
{ "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" },
|
||||
{ "CloseLink", nil, "link cable: nothing to close" },
|
||||
@@ -2415,45 +2618,149 @@ local STUB_ROWS = {
|
||||
{ "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" },
|
||||
{ "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" },
|
||||
{ "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" },
|
||||
-- The Game Boy Printer. A second peripheral again, and two of the three
|
||||
-- specials that want it are not in this table: PhotoStudio ports the
|
||||
-- conversation and the portrait screen (H.PhotoStudio above) and
|
||||
-- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the
|
||||
-- print itself stubbed inside each. PrintDiploma is the one that is
|
||||
-- nothing BUT the print -- the diploma's own page is `special Diploma`, a
|
||||
-- separate row -- so it is all that is left here.
|
||||
-- The Game Boy Printer. A second peripheral again, and none of the three
|
||||
-- specials that want it is stubbed any more: PhotoStudio ports the
|
||||
-- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter
|
||||
-- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens
|
||||
-- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print
|
||||
-- itself going nowhere inside each. The row below is superseded by that
|
||||
-- handler and survives only as the reason.
|
||||
{ "PrintDiploma", nil, "printer: no Game Boy Printer" },
|
||||
-- Screens the port has not built. Each names what it needs; the value is
|
||||
-- the arm a cancel takes, so the script backs out rather than proceeding
|
||||
-- through a transaction that never happened.
|
||||
-- OverworldTownMap's row is superseded by H.OverworldTownMap
|
||||
-- (specials/crystal_extras.lua) and survives only as the reason.
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both
|
||||
-- carry `add_special UnusedMemoryGame ; unused`, and no script names it.
|
||||
{ "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" },
|
||||
{ "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" },
|
||||
{ "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" },
|
||||
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
|
||||
{ "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" },
|
||||
{ "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" },
|
||||
{ "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" },
|
||||
{ "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" },
|
||||
{ "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" },
|
||||
{ "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" },
|
||||
{ "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" },
|
||||
{ "Function1700ba", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "Function170114", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "Function1704e1", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" },
|
||||
{ "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" },
|
||||
{ "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" },
|
||||
{ "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" },
|
||||
{ "Function101225", 0, "Mobile System GB: mobile trade room teardown" },
|
||||
{ "Function101231", 0, "Mobile System GB: mobile battle room teardown" },
|
||||
{ "Function102142", nil, "Mobile System GB: mobile news feed" },
|
||||
{ "Function103780", 0, "Mobile System GB: the mobile save never happens" },
|
||||
{ "Function1037c2", 0, "Mobile System GB: no rematch on same settings" },
|
||||
{ "Function1037eb", 0, "Mobile System GB: no battle time is left" },
|
||||
{ "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" },
|
||||
{ "Function10387b", nil, "Mobile System GB: adapter status readback" },
|
||||
{ "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" },
|
||||
{ "Function11ac3e", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b5e8", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b7e5", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b879", 0, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b920", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b93b", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11ba38", 0, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" },
|
||||
{ "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" },
|
||||
{ "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" },
|
||||
{ "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" },
|
||||
{ "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" },
|
||||
{ "DisplayUnownWords", nil, "needs the Unown wall word box" },
|
||||
{ "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" },
|
||||
{ "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" },
|
||||
{ "PokeSeer", nil, "needs the Seer's caught-data page" },
|
||||
{ "BeastsCheck", 0, "the three beasts cannot all be owned this early" },
|
||||
{ "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" },
|
||||
{ "BuenaPrize", nil, "Buena's prize counter is not ported" },
|
||||
{ "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" },
|
||||
{ "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" },
|
||||
{ "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" },
|
||||
{ "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" },
|
||||
{ "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" },
|
||||
}
|
||||
|
||||
Specials.HANDLERS = H
|
||||
|
||||
-- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one
|
||||
-- module per owner under src/script/gen2/specials/, merged into HANDLERS here.
|
||||
Specials.MODULES = {
|
||||
"crystal_story",
|
||||
"battle_tower",
|
||||
"crystal_extras",
|
||||
"unown_words",
|
||||
}
|
||||
|
||||
Specials.HANDLER_SOURCE = {}
|
||||
for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end
|
||||
|
||||
Specials.STUBS = {}
|
||||
Specials.STUB_REASONS = {}
|
||||
|
||||
for _, row in ipairs(STUB_ROWS) do
|
||||
local name, value, reason = row[1], row[2], row[3]
|
||||
Specials.STUB_REASONS[name] = reason
|
||||
Specials.STUBS[name] = function(vm)
|
||||
if value ~= nil then vm.scriptVar = value end
|
||||
end
|
||||
end
|
||||
Specials.SUPERSEDED_STUBS = {}
|
||||
|
||||
-- The dispatch table Vm.SPECIALS is. Built rather than written out so the two
|
||||
-- sets cannot drift, and so a name that ends up in both is a hard error here
|
||||
-- rather than a silent shadow at runtime.
|
||||
Specials.ALL = {}
|
||||
for name, fn in pairs(Specials.HANDLERS) do
|
||||
Specials.ALL[name] = fn
|
||||
end
|
||||
for name, fn in pairs(Specials.STUBS) do
|
||||
if Specials.ALL[name] then
|
||||
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
|
||||
end
|
||||
Specials.ALL[name] = fn
|
||||
|
||||
local function clear(t)
|
||||
for key in pairs(t) do t[key] = nil end
|
||||
end
|
||||
|
||||
local function rebuild()
|
||||
clear(Specials.STUBS)
|
||||
clear(Specials.STUB_REASONS)
|
||||
clear(Specials.SUPERSEDED_STUBS)
|
||||
clear(Specials.ALL)
|
||||
for _, row in ipairs(STUB_ROWS) do
|
||||
local name, value, reason = row[1], row[2], row[3]
|
||||
if H[name] then
|
||||
if Specials.HANDLER_SOURCE[name] == "Specials.lua" then
|
||||
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
|
||||
end
|
||||
Specials.SUPERSEDED_STUBS[name] = reason
|
||||
else
|
||||
Specials.STUB_REASONS[name] = reason
|
||||
Specials.STUBS[name] = function(vm)
|
||||
if value ~= nil then vm.scriptVar = value end
|
||||
end
|
||||
end
|
||||
end
|
||||
for name, fn in pairs(H) do Specials.ALL[name] = fn end
|
||||
for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end
|
||||
end
|
||||
|
||||
function Specials.merge(handlers, source)
|
||||
if type(handlers) ~= "table" then
|
||||
error("gen2 specials module '" .. tostring(source) .. "' returned "
|
||||
.. type(handlers) .. ", expected a table of name -> function", 0)
|
||||
end
|
||||
for name, fn in pairs(handlers) do
|
||||
if type(name) ~= "string" or type(fn) ~= "function" then
|
||||
error("gen2 specials module '" .. tostring(source)
|
||||
.. "' entry [" .. tostring(name) .. "] is not name -> function", 0)
|
||||
end
|
||||
local owner = Specials.HANDLER_SOURCE[name]
|
||||
if owner then
|
||||
error("gen2 special '" .. name .. "' is defined twice: "
|
||||
.. owner .. " and " .. tostring(source), 0)
|
||||
end
|
||||
H[name] = fn
|
||||
Specials.HANDLER_SOURCE[name] = source
|
||||
end
|
||||
rebuild()
|
||||
return handlers
|
||||
end
|
||||
|
||||
package.loaded["src.script.gen2.Specials"] = Specials
|
||||
|
||||
for _, name in ipairs(Specials.MODULES) do
|
||||
Specials.merge(require("src.script.gen2.specials." .. name),
|
||||
"specials/" .. name .. ".lua")
|
||||
end
|
||||
|
||||
rebuild()
|
||||
|
||||
return Specials
|
||||
|
||||
+84
-6
@@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999
|
||||
local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b
|
||||
-- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff.
|
||||
local EMOTE_FROM_MEM = 0xff
|
||||
-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff
|
||||
local ITEM_FROM_MEM = 0xff
|
||||
-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args.
|
||||
local SWARM_DUNSPARCE = 0
|
||||
-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values.
|
||||
local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait
|
||||
local WAIT_FRAMES_PER_UNIT = 6
|
||||
-- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver.
|
||||
local GS_VERSION_GOLD = 0
|
||||
-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and
|
||||
@@ -204,7 +212,9 @@ local function runCmd(self, cmd, op)
|
||||
local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2))
|
||||
if value ~= nil then self.scriptVar = value % 256 end
|
||||
end
|
||||
elseif op == "jumptext" then
|
||||
elseif op == "jumptext" or op == "farjumptext" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext:
|
||||
-- Script_jumptext with a `dba` for the `dw`, same JumpTextScript.
|
||||
self:emitFace(false)
|
||||
self:showText(cmd.text)
|
||||
return "end"
|
||||
@@ -519,6 +529,36 @@ local function runCmd(self, cmd, op)
|
||||
-- town map draws them two rows deep).
|
||||
local name = self.getLandmarkNameFn and self.getLandmarkNameFn()
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "getlandmarkname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id
|
||||
-- comes off the script, then ConvertLandmarkToText and a buffer byte.
|
||||
local id = cmd.landmark or arg1(cmd) or 0
|
||||
local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id)
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "gettrainerclassname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is
|
||||
-- preset, so the one id byte ContinueToGetName reads is a trainer group.
|
||||
if self.getTrainerClassNameFn then
|
||||
local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0)
|
||||
if name then self:setStringBuffer(name) end
|
||||
end
|
||||
elseif op == "getname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1633-1641: a
|
||||
-- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte.
|
||||
local args = cmd.args or {}
|
||||
local kind = cmd.kind or args[1] or 0
|
||||
local id = cmd.id or args[2] or 0
|
||||
local name
|
||||
if kind == NAMED_MON and self.getMonNameFn then
|
||||
name = self.getMonNameFn(id)
|
||||
elseif kind == NAMED_ITEM and self.getItemNameFn then
|
||||
name = self.getItemNameFn(id)
|
||||
elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then
|
||||
name = self.getTrainerClassNameFn(id)
|
||||
elseif self.getNameFn then
|
||||
name = self.getNameFn(kind, id)
|
||||
end
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "getnum" then
|
||||
-- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte,
|
||||
-- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into
|
||||
@@ -599,9 +639,17 @@ local function runCmd(self, cmd, op)
|
||||
else
|
||||
self.scriptVar = POKEMAIL_REFUSED
|
||||
end
|
||||
elseif op == "giveitem" or op == "verbosegiveitem" then
|
||||
elseif op == "giveitem" or op == "verbosegiveitem"
|
||||
or op == "verbosegiveitemvar" then
|
||||
local item = cmd.item or arg1(cmd) or 0
|
||||
local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1
|
||||
if op == "verbosegiveitemvar" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM
|
||||
-- takes the item from wScriptVar, and byte two is a VAR_* id.
|
||||
if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end
|
||||
local varId = cmd.var or (cmd.args and cmd.args[2]) or 0
|
||||
qty = self.readVarFn and self.readVarFn(varId) or 0
|
||||
end
|
||||
-- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is
|
||||
-- what the standalone `specialsound` inside GiveItemScript reads back:
|
||||
-- CheckItemPocket runs on wCurItem, not on anything the opcode carries.
|
||||
@@ -611,7 +659,7 @@ local function runCmd(self, cmd, op)
|
||||
ok = self.giveItemFn(item, qty) ~= false
|
||||
end
|
||||
self.scriptVar = ok and 1 or 0
|
||||
if op == "verbosegiveitem" then
|
||||
if op == "verbosegiveitem" or op == "verbosegiveitemvar" then
|
||||
local name = self.getItemNameFn and self.getItemNameFn(item) or "?"
|
||||
self:setStringBuffer(name)
|
||||
-- GiveItemScript (engine/overworld/scripting.asm:441-449), command for
|
||||
@@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op)
|
||||
-- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag
|
||||
-- is what makes the swarm expire, so a port that only stores the map
|
||||
-- leaves the Dunsparce call permanently live.
|
||||
--
|
||||
-- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte
|
||||
-- and specials.asm:290-298 picks the index pair off it, so three operand
|
||||
-- bytes means the map_id has moved along one.
|
||||
local args = cmd.args or {}
|
||||
local group = cmd.group or args[1]
|
||||
local mapNum = cmd.map or args[2]
|
||||
if self.setSwarmFn then self.setSwarmFn(group, mapNum) end
|
||||
local kind, group, mapNum
|
||||
if #args >= 3 then
|
||||
kind, group, mapNum = args[1], args[2], args[3]
|
||||
else
|
||||
kind = SWARM_DUNSPARCE
|
||||
group = cmd.group or args[1]
|
||||
mapNum = cmd.map or args[2]
|
||||
end
|
||||
if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end
|
||||
elseif op == "reloadmapafterbattle" or op == "reloadmap"
|
||||
or op == "refreshmap" then
|
||||
-- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult
|
||||
@@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op)
|
||||
coroutine.yield({ kind = "credits" })
|
||||
end
|
||||
return "end"
|
||||
-- ---- Crystal-only verbs ------------------------------------------------
|
||||
elseif op == "wait" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX
|
||||
-- frames of DelayFrames per operand unit, not Script_pause's two.
|
||||
self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT)
|
||||
elseif op == "checksave" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's
|
||||
-- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20).
|
||||
local ok = true
|
||||
if self.checkSaveFn then ok = self.checkSaveFn() and true or false end
|
||||
self.scriptVar = ok and 1 or 0
|
||||
elseif op == "battletowertext" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText.
|
||||
-- Unported: the table consumes the operand and the verb warns once.
|
||||
self:noteUnknownOp(op)
|
||||
-- ---- commands with no engine behind them yet ---------------------------
|
||||
elseif op == "deactivatefacing" then
|
||||
-- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the
|
||||
@@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks)
|
||||
givePokeMailFn = hooks.givePokeMail,
|
||||
checkPokeMailFn = hooks.checkPokeMail,
|
||||
getLandmarkNameFn = hooks.getLandmarkName,
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353.
|
||||
-- Absent on a Gold boot, where no opcode reaches them.
|
||||
getTrainerClassNameFn = hooks.getTrainerClassName,
|
||||
getNameFn = hooks.getName,
|
||||
checkSaveFn = hooks.checkSave,
|
||||
-- loadmenu stashes a header for the verticalmenu / _2dmenu that follows;
|
||||
-- openMenu is the blocking half, modelled on yesorno.
|
||||
openMenuFn = hooks.openMenu,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and
|
||||
-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local S = Specials.shared
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local RomText = require("src.core.RomText")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local M = {}
|
||||
|
||||
local TRUE, FALSE = S.TRUE, S.FALSE
|
||||
local A = BattleTower.ACTIONS
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196
|
||||
-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the
|
||||
-- room script `readmem`s, so it is zeroed in the VM's own memory store.
|
||||
local function initChallengeRam(vm)
|
||||
vm.btBattleEnded = 0
|
||||
vm.btBeaten = 0
|
||||
if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels
|
||||
-- data/generated/rom_text.lua carries.
|
||||
local RULE_FALLBACKS = {
|
||||
_ExcuseMeYoureNotReadyText =
|
||||
Strings.source("Excuse me.\nYou're not ready."),
|
||||
_OnlyThreeMonMayBeEnteredText =
|
||||
Strings.source("Only three POKéMON\nmay be entered."),
|
||||
_TheMonMustAllBeDifferentKindsText =
|
||||
Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."),
|
||||
_TheMonMustNotHoldTheSameItemsText =
|
||||
Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."),
|
||||
_YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"),
|
||||
_BattleTowerReturnWhenReadyText =
|
||||
Strings.source("Please return when\nyou're ready."),
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
|
||||
-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded
|
||||
-- save here, so CompareLoadedAndSavedPlayerID can only match.
|
||||
local function saveFileIsYours(vm)
|
||||
local record = S.save(vm)
|
||||
if not (record and record.version) then return FALSE end
|
||||
return Save.exists(record.version) and TRUE or FALSE
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and
|
||||
-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile
|
||||
-- System GB adapter. A cartridge that never linked reads them as zero, and
|
||||
-- these are the values that zero produces.
|
||||
local MOBILE_ARMS = {
|
||||
[A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" },
|
||||
[A.ACTION_06] = { why = "clears the mobile challenge bytes" },
|
||||
[A.ACTION_0C] = { why = "stamps the mobile challenge day" },
|
||||
[A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" },
|
||||
[A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" },
|
||||
[A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" },
|
||||
[A.ACTION_16] = { why = "stamps the mobile news day" },
|
||||
[A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" },
|
||||
[A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
|
||||
[A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953
|
||||
-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches.
|
||||
local UNHOOKED_ARMS = {
|
||||
[A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks",
|
||||
}
|
||||
|
||||
local ACTIONS = {}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990
|
||||
ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record)
|
||||
local yours = saveFileIsYours(vm)
|
||||
S.answer(vm, yours)
|
||||
if yours == 0 then return end
|
||||
S.answer(vm, BattleTower.saveFileFlag(record,
|
||||
BattleTower.SAVEFILE_EXPLANATION))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008
|
||||
ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record)
|
||||
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999
|
||||
ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower)
|
||||
S.answer(vm, tower.challenge)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012
|
||||
ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022
|
||||
ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141
|
||||
ACTIONS[A.SAVELEVELGROUP] = function(vm, tower)
|
||||
tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155
|
||||
ACTIONS[A.LOADLEVELGROUP] = function(vm, tower)
|
||||
vm.btLevelGroup = tower.levelGroup
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
|
||||
ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm)
|
||||
S.answer(vm, saveFileIsYours(vm))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the
|
||||
-- pending fade is dropped and the volume goes back to full, which at both
|
||||
-- call sites follows a `musicfadeout MUSIC_NONE`.
|
||||
ACTIONS[A.ACTION_0A] = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if h.stopMusic then h.stopMusic() end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185:
|
||||
-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered,
|
||||
-- and the Goldenrod scene gates itself on its own event flag afterwards.
|
||||
ACTIONS[A.GSBALL] = function(vm, _tower, record)
|
||||
local crystal = record and Save.crystalState(record)
|
||||
local flag = crystal and crystal.gsBall
|
||||
S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312
|
||||
-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg.
|
||||
local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142"
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the
|
||||
-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP.
|
||||
ACTIONS[A.EGGTICKET] = function(vm, _tower, record)
|
||||
S.answer(vm, FALSE)
|
||||
local held = record and record.inventory and record.inventory.EGG_TICKET
|
||||
if not held or held <= 0 then return end
|
||||
for _, mon in ipairs(S.party(vm)) do
|
||||
if mon.isEgg and mon.otName == MYSTERY_OT then
|
||||
mon.otName = ""
|
||||
Bag.remove(record, "EGG_TICKET", 1)
|
||||
return S.answer(vm, TRUE)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461
|
||||
ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end
|
||||
ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469
|
||||
ACTIONS[A.ACTION_13] = function(vm, tower)
|
||||
S.answer(vm, tower.reentry and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483
|
||||
ACTIONS[A.ACTION_14] = function(vm, _tower, record)
|
||||
local yours = saveFileIsYours(vm)
|
||||
S.answer(vm, yours)
|
||||
if yours == 0 then return end
|
||||
S.answer(vm, BattleTower.saveFileFlag(record,
|
||||
BattleTower.SAVEFILE_REGISTERED))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492
|
||||
ACTIONS[A.ACTION_15] = function(_vm, _tower, record)
|
||||
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
|
||||
ACTIONS[A.RESETDATA] = function(_vm, _tower, record)
|
||||
BattleTower.resetTrainers(record)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933
|
||||
ACTIONS[A.GIVEREWARD] = function(vm, tower, record)
|
||||
local h = S.hooks(vm)
|
||||
local reward = tower.reward or BattleTower.FALLBACK_REWARD
|
||||
local data = S.data(vm)
|
||||
local fits = false
|
||||
if record then
|
||||
record.inventory = record.inventory or {}
|
||||
fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"),
|
||||
Bag.capacity(data, "ITEM"), record.inventory[reward])
|
||||
end
|
||||
if not fits then reward = BattleTower.FALLBACK_REWARD end
|
||||
S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949
|
||||
ACTIONS[A.ACTION_1C] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE)
|
||||
end
|
||||
ACTIONS[A.ACTION_1D] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976.
|
||||
-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1
|
||||
-- byte `maskbits` leaves in a.
|
||||
ACTIONS[A.CHOOSEREWARD] = function(vm, tower)
|
||||
local data = S.data(vm)
|
||||
local order = data and data.gen2Constants and data.gen2Constants.itemOrder
|
||||
tower.reward = BattleTower.rollReward(order, Specials.random)
|
||||
or BattleTower.FALLBACK_REWARD
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the
|
||||
-- jumptable `special BattleTowerAction` dispatches on wScriptVar.
|
||||
M.BattleTowerAction = function(vm)
|
||||
local id = math.floor(tonumber(vm.scriptVar) or 0)
|
||||
-- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds.
|
||||
local record = S.save(vm) or {}
|
||||
local tower = BattleTower.state(record)
|
||||
local run = ACTIONS[id]
|
||||
if run then return run(vm, tower, record) end
|
||||
local mobile = MOBILE_ARMS[id]
|
||||
if mobile then
|
||||
if mobile.value ~= nil then S.answer(vm, mobile.value) end
|
||||
return
|
||||
end
|
||||
if UNHOOKED_ARMS[id] then return end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the
|
||||
-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is
|
||||
-- what the script's `ifnotequal FALSE` reads as "stop here".
|
||||
M.CheckForBattleTowerRules = function(vm)
|
||||
local lines, failed = BattleTower.checkRules(S.party(vm))
|
||||
local data = S.data(vm)
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2
|
||||
vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT)
|
||||
for _, label in ipairs(lines) do
|
||||
vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label))
|
||||
end
|
||||
S.answer(vm, failed and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495.
|
||||
-- wScriptVar comes in TRUE for the English rows and leaves holding the row
|
||||
-- number, or 4 for a B press.
|
||||
local CHALLENGE_MENU_ROWS = {
|
||||
Strings.source("Challenge"),
|
||||
Strings.source("Explanation"),
|
||||
Strings.source("Cancel"),
|
||||
}
|
||||
local CHALLENGE_MENU_CANCEL = 4
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0,
|
||||
-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP.
|
||||
local CHALLENGE_MENU_FLAGS = 0x80 + 0x20
|
||||
|
||||
M.Menu_ChallengeExplanationCancel = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
local choice = Specials.block(vm, function(done)
|
||||
if not h.scriptMenu then return done(0) end
|
||||
h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0,
|
||||
right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 },
|
||||
done)
|
||||
end)
|
||||
choice = math.floor(tonumber(choice) or 0)
|
||||
if choice < 1 or choice > #CHALLENGE_MENU_ROWS then
|
||||
return S.answer(vm, CHALLENGE_MENU_CANCEL)
|
||||
end
|
||||
S.answer(vm, choice)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose
|
||||
-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the
|
||||
-- three mons and the two SRAM tables that stop either repeating. wScriptVar
|
||||
-- comes in holding the object the opponent walks in as and is NOT written.
|
||||
M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm)
|
||||
local record = S.save(vm)
|
||||
if not record then return end
|
||||
-- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not
|
||||
-- the save; only the sBTTrainers slot and the two previous teams persist.
|
||||
local opponent = BattleTower.drawOpponent(
|
||||
S.data(vm), record, Specials.random)
|
||||
vm.btOpponent = opponent
|
||||
if not opponent then return end
|
||||
-- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object
|
||||
-- wScriptVar names, and GetUsedSprite loads the sheet.
|
||||
local h = S.hooks(vm)
|
||||
if h.setObjectSprite and opponent.sprite then
|
||||
h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the
|
||||
-- RunBattleTowerTrainer arm its jumptable runs at :214-259.
|
||||
--
|
||||
-- The order there is: SET battle mode forced on, wInBattleTowerBattle set,
|
||||
-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and
|
||||
-- arms the challenge), StartBattle, HealParty again, and wBattleResult into
|
||||
-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into
|
||||
-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to
|
||||
-- decide whether all seven are done -- and leaves `count + 1` as the digit
|
||||
-- Text_NextUpOpponentNo prints.
|
||||
M.BattleTowerBattle = function(vm)
|
||||
vm.btBattleEnded = 0
|
||||
local record = S.save(vm)
|
||||
local data = S.data(vm)
|
||||
local h = S.hooks(vm)
|
||||
local opponent = vm.btOpponent
|
||||
if not opponent and record then
|
||||
opponent = BattleTower.drawOpponent(data, record, Specials.random)
|
||||
vm.btOpponent = opponent
|
||||
end
|
||||
-- A cache with no `battleTower` block on trainers.lua has nobody to send
|
||||
-- out. LOSE is the room script's own back-out arm
|
||||
-- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge
|
||||
-- ends rather than looping on an opponent that never appears.
|
||||
if not (opponent and record) then
|
||||
vm.btBattleEnded = 1
|
||||
return S.answer(vm, 1)
|
||||
end
|
||||
-- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570)
|
||||
local streak = BattleTower.beginBattle(record)
|
||||
-- :228 farcall HealParty, before the send-out.
|
||||
if h.healParty then h.healParty() end
|
||||
local classes = data and data.trainers and data.trainers.classes
|
||||
local class = classes and classes[opponent.classId]
|
||||
local className = (class and class.name) or opponent.classId
|
||||
local party = BattleTower.battleParty(data, opponent.rows)
|
||||
local outcome = Specials.block(vm, function(done)
|
||||
if not h.startTowerBattle then return done("lose") end
|
||||
local started = h.startTowerBattle({
|
||||
class = opponent.class,
|
||||
classId = opponent.classId,
|
||||
className = className,
|
||||
-- PlaceEnemysName prints the class then the trainer's own name, the
|
||||
-- same pair World:startScriptedBattle builds for an overworld trainer.
|
||||
name = className and (className .. " " .. opponent.name) or opponent.name,
|
||||
trainerName = opponent.name,
|
||||
party = party,
|
||||
-- wOtherTrainerClass is a real class here, so the AI weights, the two
|
||||
-- item slots and the payout all come off its own attributes row.
|
||||
attributes = class and class.attributes,
|
||||
baseMoney = class and class.baseMoney,
|
||||
items = class and class.items,
|
||||
}, done)
|
||||
if not started then done("lose") end
|
||||
end)
|
||||
-- :235 farcall HealParty, on the way out whichever way it went.
|
||||
if h.healParty then h.healParty() end
|
||||
-- :236-237 wBattleResult: WIN is 0.
|
||||
local won = outcome ~= "lose"
|
||||
S.answer(vm, won and 0 or 1)
|
||||
if won and vm.mem then
|
||||
-- :240-250
|
||||
vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256
|
||||
vm:setStringBuffer(tostring(streak + 1))
|
||||
end
|
||||
-- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle.
|
||||
vm.btBattleEnded = 1
|
||||
vm.btOpponent = nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is
|
||||
-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu
|
||||
-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a
|
||||
-- chosen room and $a for the cancel the desk loops back on.
|
||||
M.BattleTowerRoomMenu = function(vm)
|
||||
initChallengeRam(vm)
|
||||
local h = S.hooks(vm)
|
||||
local record = S.save(vm)
|
||||
local result = Specials.block(vm, function(done)
|
||||
if not h.pushScreen then return done(nil) end
|
||||
local ok = h.pushScreen("Gen2BattleTowerMenu", {
|
||||
save = record,
|
||||
party = S.party(vm),
|
||||
rows = BattleTower.levelGroupRows(record),
|
||||
monName = h.monName,
|
||||
onDone = done,
|
||||
})
|
||||
if not ok then done(nil) end
|
||||
end)
|
||||
local group = math.floor(tonumber(result) or 0)
|
||||
if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610
|
||||
return S.answer(vm, 0x0a)
|
||||
end
|
||||
vm.btLevelGroup = group
|
||||
S.answer(vm, 0)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,508 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer,
|
||||
-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword,
|
||||
-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share:
|
||||
-- :57 OverworldTownMap and :126 PrintDiploma.
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BugContest = require("src.core.gen2.BugContest")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local S = Specials.shared
|
||||
|
||||
local M = {}
|
||||
|
||||
-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword.
|
||||
local VAR_BLUECARDBALANCE = 0x18
|
||||
local VAR_BUENASPASSWORD = 0x19
|
||||
|
||||
-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP.
|
||||
local BLUE_CARD_POINT_CAP = 30
|
||||
|
||||
local function hooks(vm)
|
||||
return (vm and vm.specials) or {}
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps
|
||||
-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves.
|
||||
local function tutorMove(vm, index)
|
||||
local d = S.data(vm)
|
||||
local list = d and d.pokemon and d.pokemon.tutorMoves
|
||||
if type(list) ~= "table" then return nil end
|
||||
if index ~= 1 and index ~= 2 then index = 3 end
|
||||
return list[index]
|
||||
end
|
||||
|
||||
M.MoveTutor = function(vm)
|
||||
local h = hooks(vm)
|
||||
local moveId = tutorMove(vm, vm.scriptVar or 0)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is
|
||||
-- maps/GoldenrodCity.asm:72 .Incompatible.
|
||||
if not (moveId and h.pushScreen) then
|
||||
vm.scriptVar = 255
|
||||
return
|
||||
end
|
||||
local d = S.data(vm)
|
||||
local moveDef = d and d.moves and d.moves[moveId]
|
||||
local learned = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2MoveTutor", {
|
||||
move = moveId,
|
||||
moveName = (moveDef and moveDef.name) or moveId,
|
||||
onDone = function(taught) done(taught and true or false) end,
|
||||
})
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm.
|
||||
vm.scriptVar = learned and 0 or 255
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_*
|
||||
-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the
|
||||
-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds.
|
||||
local BUENA_PASSWORDS = {
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } },
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "PIKACHU", "RATTATA", "GEODUDE" } },
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } },
|
||||
{ kind = "string", points = 16,
|
||||
words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"),
|
||||
Strings.source("AZALEA TOWN") } },
|
||||
{ kind = "string", points = 6,
|
||||
words = { Strings.source("FLYING"), Strings.source("BUG"),
|
||||
Strings.source("GRASS") } },
|
||||
{ kind = "move", points = 12,
|
||||
words = { "TACKLE", "GROWL", "MUD_SLAP" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } },
|
||||
{ kind = "string", points = 13,
|
||||
words = { Strings.source("#MON Talk"), Strings.source("#MON Music"),
|
||||
Strings.source("Lucky Channel") } },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/constants/radio_constants.asm:120-121.
|
||||
local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS
|
||||
local NUM_PASSWORDS_PER_CATEGORY = 3
|
||||
|
||||
-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534).
|
||||
local function passwordWord(vm, group, index)
|
||||
local row = BUENA_PASSWORDS[group + 1]
|
||||
if not row then return "?" end
|
||||
local word = row.words[index + 1]
|
||||
if not word then return "?" end
|
||||
if row.kind == "string" then return Strings(word) end
|
||||
local d = S.data(vm)
|
||||
if row.kind == "mon" then
|
||||
local def = d and d.pokemon and d.pokemon[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
if row.kind == "item" then
|
||||
local def = d and d.items and d.items[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
local def = d and d.moves and d.moves[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
|
||||
-- BuenasPassword4's two rejection rolls, packed group-high over word-low
|
||||
-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487).
|
||||
local function rollPassword(save, day)
|
||||
local buena = Save.crystalState(save).buenaPassword
|
||||
buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16
|
||||
+ (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1)
|
||||
buena.day = day
|
||||
return buena.word
|
||||
end
|
||||
|
||||
-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day
|
||||
-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it.
|
||||
local function currentPassword(vm)
|
||||
local record = S.save(vm)
|
||||
if not record then return 0 end
|
||||
local buena = Save.crystalState(record).buenaPassword
|
||||
local today = BugContest.now().day
|
||||
if buena.word == nil or buena.day ~= today then rollPassword(record, today) end
|
||||
if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end
|
||||
return buena.word % 256
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The
|
||||
-- question is the script's own writetext at maps/RadioTower2F.asm:119.
|
||||
M.AskRememberPassword = function(vm)
|
||||
local yes = coroutine.yield({ kind = "yesorno" })
|
||||
S.answer(vm, yes and 1 or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`,
|
||||
-- and :44-49 .PasswordIndices, which makes the menu's answer zero based.
|
||||
M.BuenasPassword = function(vm)
|
||||
local h = hooks(vm)
|
||||
local packed = currentPassword(vm)
|
||||
local group = math.floor(packed / 16) % 16
|
||||
if group >= NUM_PASSWORD_CATEGORIES then group = 0 end
|
||||
local answer = packed % 4
|
||||
local words = {}
|
||||
for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do
|
||||
words[row + 1] = passwordWord(vm, group, row)
|
||||
end
|
||||
if not h.pushScreen then
|
||||
S.answer(vm, 0)
|
||||
return
|
||||
end
|
||||
local picked = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2BuenaPassword", {
|
||||
mode = "password",
|
||||
words = words,
|
||||
width = BUENA_PASSWORDS[group + 1].points,
|
||||
onDone = function(index) done(index or -1) end,
|
||||
})
|
||||
if not ok then done(-1) end
|
||||
end)
|
||||
S.answer(vm, (picked == answer) and 1 or 0)
|
||||
end
|
||||
|
||||
-- data/items/buena_prizes.asm.
|
||||
local BUENA_PRIZES = {
|
||||
{ item = "ULTRA_BALL", cost = 2 },
|
||||
{ item = "FULL_RESTORE", cost = 2 },
|
||||
{ item = "NUGGET", cost = 3 },
|
||||
{ item = "RARE_CANDY", cost = 3 },
|
||||
{ item = "PROTEIN", cost = 5 },
|
||||
{ item = "IRON", cost = 5 },
|
||||
{ item = "CARBOS", cost = 5 },
|
||||
{ item = "CALCIUM", cost = 5 },
|
||||
{ item = "HP_UP", cost = 5 },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles.
|
||||
local PRIZE_TEXT = {
|
||||
which = Strings.source("Which prize would\nyou like?"),
|
||||
confirm = Strings.source("{STRBUF}?\nIs that right?"),
|
||||
hereYouGo = Strings.source("Here you go!"),
|
||||
notEnough = Strings.source("You don't have\nenough points."),
|
||||
noRoom = Strings.source("You have no room\nfor it."),
|
||||
comeAgain = Strings.source("Oh. Please come\nback again!"),
|
||||
}
|
||||
|
||||
-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at
|
||||
-- maps/RadioTower2F.asm:144-146 and this counter read one store.
|
||||
local function blueCardBalance(vm)
|
||||
if not vm.readVarFn then return 0 end
|
||||
local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0)
|
||||
if value < 0 then value = 0 end
|
||||
return math.min(value, BLUE_CARD_POINT_CAP)
|
||||
end
|
||||
|
||||
local function setBlueCardBalance(vm, value)
|
||||
if vm.writeVarFn then
|
||||
vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256)
|
||||
end
|
||||
end
|
||||
|
||||
-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110).
|
||||
local function receiveItem(vm, itemId)
|
||||
local record = S.save(vm)
|
||||
if not record then return false end
|
||||
record.inventory = record.inventory or {}
|
||||
return Bag.add(record, itemId, 1, S.data(vm)) and true or false
|
||||
end
|
||||
|
||||
M.BuenaPrize = function(vm)
|
||||
local h = hooks(vm)
|
||||
if not h.pushScreen then return end
|
||||
local d = S.data(vm)
|
||||
local items = d and d.items
|
||||
local rows = {}
|
||||
for i, prize in ipairs(BUENA_PRIZES) do
|
||||
local def = items and items[prize.item]
|
||||
rows[i] = {
|
||||
item = prize.item,
|
||||
cost = prize.cost,
|
||||
name = (def and def.name) or prize.item,
|
||||
}
|
||||
end
|
||||
|
||||
while true do
|
||||
-- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens
|
||||
-- over it, so the box is held the way a `yesorno` page is held.
|
||||
S.showRawHeld(vm, Strings(PRIZE_TEXT.which))
|
||||
local pick = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2BuenaPassword", {
|
||||
mode = "prize",
|
||||
prizes = rows,
|
||||
balance = blueCardBalance(vm),
|
||||
onDone = function(index) done(index or 0) end,
|
||||
})
|
||||
if not ok then done(0) end
|
||||
end)
|
||||
-- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press.
|
||||
if not (type(pick) == "number" and rows[pick]) then break end
|
||||
|
||||
local row = rows[pick]
|
||||
vm:setStringBuffer(row.name)
|
||||
S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm))
|
||||
local sure = coroutine.yield({ kind = "yesorno" })
|
||||
if sure then
|
||||
-- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem,
|
||||
-- and only a delivered item spends the points.
|
||||
local balance = blueCardBalance(vm)
|
||||
if balance < row.cost then
|
||||
vm:showRaw(Strings(PRIZE_TEXT.notEnough))
|
||||
else
|
||||
if not receiveItem(vm, row.item) then
|
||||
vm:showRaw(Strings(PRIZE_TEXT.noRoom))
|
||||
else
|
||||
setBlueCardBalance(vm, balance - row.cost)
|
||||
if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end
|
||||
vm:showRaw(Strings(PRIZE_TEXT.hereYouGo))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ../pokecrystal/engine/events/buena.asm:138-145 .done.
|
||||
vm:showRaw(Strings(PRIZE_TEXT.comeAgain))
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289)
|
||||
-- in jumptable order, with each text_ram buffer spelled as a directive.
|
||||
local SEER_TEXT = {
|
||||
intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"),
|
||||
cantTell = Strings.source(
|
||||
"Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"),
|
||||
nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"),
|
||||
timeLevel = Strings.source(
|
||||
"The time was\n%s!\fIts level was %s!\fAm I good or what?"),
|
||||
trade = Strings.source(
|
||||
"Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"),
|
||||
noLocation = Strings.source(
|
||||
"What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n"
|
||||
.. "You are special.\fI can't tell where\nyou met it, but it\v"
|
||||
.. "was at level %s.\fAm I good or what?"),
|
||||
egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"),
|
||||
doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"),
|
||||
}
|
||||
|
||||
-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field
|
||||
-- three marks the rows that splice the nickname in.
|
||||
local SEER_ADVICE = {
|
||||
{ 9, Strings.source(
|
||||
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
|
||||
{ 29, Strings.source(
|
||||
"Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v"
|
||||
.. "more confident."), true },
|
||||
{ 59, Strings.source(
|
||||
"Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true },
|
||||
{ 89, Strings.source(
|
||||
"Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f"
|
||||
.. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true },
|
||||
{ 100, Strings.source(
|
||||
"Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n"
|
||||
.. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n"
|
||||
.. "seeing %s\fin battle would\nexcite anyone."), true },
|
||||
{ 255, Strings.source(
|
||||
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
|
||||
}
|
||||
|
||||
-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and
|
||||
-- UnknownCaughtData's "Unknown@" (:215).
|
||||
local SEER_TIMES = {
|
||||
Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"),
|
||||
}
|
||||
local SEER_UNKNOWN = Strings.source("Unknown")
|
||||
local SEER_NO_LEVEL = Strings.source("???")
|
||||
|
||||
-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and
|
||||
-- constants/battle_constants.asm:4 EGG_LEVEL.
|
||||
local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5
|
||||
|
||||
-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179).
|
||||
local function caughtLevel(byte0)
|
||||
local level = byte0 % 0x40
|
||||
if level == 0 then return nil, Strings(SEER_NO_LEVEL) end
|
||||
if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end
|
||||
return level, tostring(level)
|
||||
end
|
||||
|
||||
-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer
|
||||
-- is the SEERACTION_* its two sentinel arms override wSeerAction with.
|
||||
local function caughtLocation(vm, byte1)
|
||||
local landmark = byte1 % 0x80
|
||||
if landmark == 0 then return Strings(SEER_UNKNOWN), nil end
|
||||
if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end
|
||||
if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end
|
||||
local record = Nests.landmark(S.data(vm), landmark)
|
||||
local name = record and record.name
|
||||
if not name then return Strings(SEER_UNKNOWN), nil end
|
||||
-- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte
|
||||
-- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it.
|
||||
return (tostring(name):gsub("\n", " ")), nil
|
||||
end
|
||||
|
||||
-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte.
|
||||
local function seerAdvice(vm, mon, level)
|
||||
local diff = ((mon.level or 0) - (level or 0)) % 256
|
||||
local name = Mon.displayName(mon)
|
||||
for _, row in ipairs(SEER_ADVICE) do
|
||||
if diff <= row[1] then
|
||||
if row[3] then return vm:showRaw(Strings(row[2], name, name)) end
|
||||
return vm:showRaw(Strings(row[2]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.PokeSeer = function(vm)
|
||||
vm:showRaw(Strings(SEER_TEXT.intro))
|
||||
local _, mon = S.selectMon(vm, "choose")
|
||||
-- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel.
|
||||
if not mon then
|
||||
vm:showRaw(Strings(SEER_TEXT.doNothing))
|
||||
return
|
||||
end
|
||||
-- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31.
|
||||
if mon.isEgg then
|
||||
vm:showRaw(Strings(SEER_TEXT.egg))
|
||||
return
|
||||
end
|
||||
|
||||
local byte0, byte1 = Mon.packCaughtData(mon)
|
||||
-- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133).
|
||||
if byte0 == 0 and byte1 == 0 then
|
||||
vm:showRaw(Strings(SEER_TEXT.cantTell))
|
||||
return
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second
|
||||
-- byte is commented out, so only the HIGH byte decides "traded".
|
||||
local record = S.save(vm)
|
||||
local playerId = (record and record.player and record.player.id) or 0
|
||||
local traded = math.floor((mon.otId or 0) / 256) % 256
|
||||
~= math.floor(playerId / 256) % 256
|
||||
|
||||
local level, levelText = caughtLevel(byte0)
|
||||
local place, override = caughtLocation(vm, byte1)
|
||||
local name = Mon.displayName(mon)
|
||||
|
||||
-- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89).
|
||||
if override == "cant_tell" then
|
||||
vm:showRaw(Strings(SEER_TEXT.cantTell))
|
||||
return
|
||||
end
|
||||
-- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95).
|
||||
if override == "level_only" then
|
||||
vm:showRaw(Strings(SEER_TEXT.noLocation, levelText))
|
||||
return seerAdvice(vm, mon, level)
|
||||
end
|
||||
|
||||
local time = math.floor(byte0 / 0x40)
|
||||
local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN)
|
||||
|
||||
if traded then
|
||||
-- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79).
|
||||
local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN)
|
||||
vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name))
|
||||
else
|
||||
-- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70).
|
||||
vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place))
|
||||
end
|
||||
vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText))
|
||||
seerAdvice(vm, mon, level)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194,
|
||||
-- :201); either hit is TRUE.
|
||||
M.UnusedFindItemInPCOrBag = function(vm)
|
||||
local h = hooks(vm)
|
||||
local index = vm.scriptVar or 0
|
||||
local record = S.save(vm)
|
||||
local d = S.data(vm)
|
||||
local id
|
||||
for key, def in pairs((d and d.items) or {}) do
|
||||
if type(def) == "table" and def.index == index then id = key break end
|
||||
end
|
||||
local pc = record and record.pcItems
|
||||
if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then
|
||||
S.answer(vm, 1)
|
||||
return
|
||||
end
|
||||
if h.hasItem and h.hasItem(index) then
|
||||
S.answer(vm, 1)
|
||||
return
|
||||
end
|
||||
S.answer(vm, 0)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The wall map -- engine/events/specials.asm:100
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it
|
||||
-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145
|
||||
-- TownMapScript and engine/overworld/decorations.asm:1005
|
||||
-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`.
|
||||
--
|
||||
-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the
|
||||
-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for
|
||||
-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is
|
||||
-- src/ui/gen2/Pokegear.lua's `townMap` mode.
|
||||
M.OverworldTownMap = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if not h.pushScreen then return end
|
||||
Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2Pokegear", {
|
||||
townMap = true,
|
||||
onClose = function() done(true) end,
|
||||
})
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The printed diploma -- engine/events/specials.asm:448
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page
|
||||
-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`,
|
||||
-- engine/events/diploma.asm:12 -- and only then reaches for the serial port.
|
||||
-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap
|
||||
-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on
|
||||
-- the cartridge either: page 1 IS the whole visible routine.
|
||||
--
|
||||
-- The two SendScreenToPrinter passes are the same missing peripheral
|
||||
-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting
|
||||
-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for
|
||||
-- a failed print and neither does this.
|
||||
M.PrintDiploma = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if not h.showDiploma then return end
|
||||
Specials.block(vm, function(done)
|
||||
h.showDiploma(function() done(true) end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,298 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148
|
||||
-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160
|
||||
-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
local S = Specials.shared
|
||||
local M = {}
|
||||
|
||||
-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE.
|
||||
local VAR_BATTLETYPE = 0x03
|
||||
-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI.
|
||||
local BATTLETYPE_CELEBI = 11
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the
|
||||
-- routine writes into wScriptVar before each CheckOwnMonAnywhere.
|
||||
local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" }
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is
|
||||
-- CheckOwnMonAnywhere (:48) with the answer already written.
|
||||
M.BeastsCheck = function(vm)
|
||||
local monCheck = Specials.HANDLERS.MonCheck
|
||||
local h = S.hooks(vm)
|
||||
for _, name in ipairs(BEASTS) do
|
||||
vm.scriptVar = (h.monIndex and h.monIndex(name)) or name
|
||||
monCheck(vm)
|
||||
if vm.scriptVar ~= S.TRUE then
|
||||
S.answer(vm, S.FALSE)
|
||||
return
|
||||
end
|
||||
end
|
||||
S.answer(vm, S.TRUE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1.
|
||||
local DRATINI = "DRATINI"
|
||||
local DRATINI_MOVESETS = {
|
||||
[0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" },
|
||||
[1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16
|
||||
-- .CheckForDratini walks the party BACKWARDS from the last slot.
|
||||
M.GiveDratini = function(vm)
|
||||
local set = DRATINI_MOVESETS[vm.scriptVar or 0]
|
||||
if not set then return end
|
||||
local list = S.party(vm)
|
||||
local target
|
||||
for index = #list, 1, -1 do
|
||||
local mon = list[index]
|
||||
if mon and mon.species == DRATINI then
|
||||
target = mon
|
||||
break
|
||||
end
|
||||
end
|
||||
if not target then return end
|
||||
-- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from
|
||||
-- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped.
|
||||
local defs = S.data(vm)
|
||||
local moves = defs and defs.moves
|
||||
target.moves = target.moves or {}
|
||||
for index, id in ipairs(set) do
|
||||
local pp = (moves and moves[id] and moves[id].pp) or 0
|
||||
target.moves[index] = { id = id, pp = pp, maxPp = pp }
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in
|
||||
-- order; the macro (:5) accumulates them and stores total * $ffff / 100.
|
||||
local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 }
|
||||
|
||||
local ODD_EGG_PROBABILITIES = {}
|
||||
do
|
||||
local total = 0
|
||||
for index, percent in ipairs(ODD_EGG_PERCENTS) do
|
||||
total = total + percent
|
||||
ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per
|
||||
-- NICKNAMED_MON_STRUCT.
|
||||
local ODD_EGGS = {
|
||||
{ species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 30, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 17, attack = 9, defense = 6, speed = 11,
|
||||
specialAttack = 8, specialDefense = 8 } },
|
||||
{ species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 30, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 17, attack = 9, defense = 7, speed = 12,
|
||||
specialAttack = 9, specialDefense = 9 } },
|
||||
{ species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 35, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 20, attack = 7, defense = 7, speed = 6,
|
||||
specialAttack = 9, specialDefense = 10 } },
|
||||
{ species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 35, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 20, attack = 7, defense = 8, speed = 7,
|
||||
specialAttack = 10, specialDefense = 11 } },
|
||||
{ species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 15, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 24, attack = 8, defense = 6, speed = 6,
|
||||
specialAttack = 9, specialDefense = 7 } },
|
||||
{ species = "IGGLYBUFF", otId = 768, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 15, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 24, attack = 8, defense = 7, speed = 7,
|
||||
specialAttack = 10, specialDefense = 8 } },
|
||||
{ species = "SMOOCHUM", otId = 3584, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
|
||||
pp = { 35, 30, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 8, defense = 6, speed = 11,
|
||||
specialAttack = 13, specialDefense = 11 } },
|
||||
{ species = "SMOOCHUM", otId = 512, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
|
||||
pp = { 35, 30, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 8, defense = 7, speed = 12,
|
||||
specialAttack = 14, specialDefense = 12 } },
|
||||
{ species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "EMBER", "DIZZY_PUNCH" },
|
||||
pp = { 25, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 12, defense = 8, speed = 13,
|
||||
specialAttack = 12, specialDefense = 10 } },
|
||||
{ species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "EMBER", "DIZZY_PUNCH" },
|
||||
pp = { 25, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 12, defense = 9, speed = 14,
|
||||
specialAttack = 13, specialDefense = 11 } },
|
||||
{ species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
|
||||
pp = { 30, 30, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 11, defense = 8, speed = 14,
|
||||
specialAttack = 11, specialDefense = 10 } },
|
||||
{ species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
|
||||
pp = { 30, 30, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 11, defense = 9, speed = 15,
|
||||
specialAttack = 12, specialDefense = 11 } },
|
||||
{ species = "TYROGUE", otId = 2560, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "TACKLE", "DIZZY_PUNCH" },
|
||||
pp = { 35, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 18, attack = 8, defense = 8, speed = 8,
|
||||
specialAttack = 8, specialDefense = 8 } },
|
||||
{ species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "TACKLE", "DIZZY_PUNCH" },
|
||||
pp = { 35, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 18, attack = 8, defense = 9, speed = 9,
|
||||
specialAttack = 9, specialDefense = 9 } },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME
|
||||
-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname.
|
||||
local ODD_EGG_OT = "ODD"
|
||||
local ODD_EGG_NICKNAME = "EGG"
|
||||
local EGG_TICKET = "EGG_TICKET"
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the
|
||||
-- cumulative table; the $ffff break is :17.
|
||||
local function oddEggIndex(roll)
|
||||
for index = 1, #ODD_EGG_PROBABILITIES do
|
||||
local probability = ODD_EGG_PROBABILITIES[index]
|
||||
if probability >= 0xffff then return index end
|
||||
if roll <= probability then return index end
|
||||
end
|
||||
return #ODD_EGG_PROBABILITIES
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH
|
||||
-- bytes copied verbatim.
|
||||
local function buildOddEgg(data, row, save)
|
||||
if not (data and row) then return nil end
|
||||
local moves = {}
|
||||
for index, id in ipairs(row.moves) do
|
||||
local pp = row.pp[index] or 0
|
||||
moves[index] = { id = id, pp = pp, maxPp = pp }
|
||||
end
|
||||
local dvs = {
|
||||
attack = row.dvs.attack, defense = row.dvs.defense,
|
||||
speed = row.dvs.speed, special = row.dvs.special,
|
||||
}
|
||||
local egg = Mon.new(data, row.species, row.level, {
|
||||
dvs = dvs,
|
||||
moves = moves,
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP`
|
||||
hp = 0,
|
||||
nickname = ODD_EGG_NICKNAME,
|
||||
})
|
||||
if not egg then return nil end
|
||||
egg.experience = row.experience
|
||||
egg.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,
|
||||
}
|
||||
egg.maxHp = row.stats.hp
|
||||
egg.hp = 0
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct
|
||||
-- keeps the hatchling's own species.
|
||||
egg.isEgg = true
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`,
|
||||
-- the byte MON_HAPPINESS holds while the thing is an egg.
|
||||
egg.eggSteps = row.eggSteps
|
||||
egg.ot = ODD_EGG_OT
|
||||
egg.otName = ODD_EGG_OT
|
||||
egg.otId = row.otId
|
||||
if save then Mon.stampOT(save, egg) end
|
||||
return egg
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the
|
||||
-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's.
|
||||
M.GiveOddEgg = function(vm)
|
||||
local record = S.save(vm)
|
||||
local data = S.data(vm)
|
||||
if not (record and data) then return end
|
||||
local party = record.party or {}
|
||||
record.party = party
|
||||
if #party >= Mon.PARTY_SIZE then return end
|
||||
local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)]
|
||||
local egg = buildOddEgg(data, row, record)
|
||||
if not egg then return end
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET,
|
||||
-- ahead of the party write.
|
||||
local h = S.hooks(vm)
|
||||
local ticket = h.itemIndex and h.itemIndex(EGG_TICKET)
|
||||
if ticket and h.takeItem then h.takeItem(ticket, 1) end
|
||||
party[#party + 1] = egg
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:1, run by
|
||||
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10.
|
||||
M.HoOhChamber = function(vm)
|
||||
if not UnownWords.leadIsHoOh(S.party(vm)) then return end
|
||||
UnownWords.openWall(vm.events, "HO_OH")
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:13, run by
|
||||
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38
|
||||
-- MON_ITEM.
|
||||
M.OmanyteChamber = function(vm)
|
||||
if UnownWords.wallOpened(vm.events, "OMANYTE") then return end
|
||||
local h = S.hooks(vm)
|
||||
local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE)
|
||||
local inPack = index and h.hasItem and h.hasItem(index)
|
||||
if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end
|
||||
UnownWords.openWall(vm.events, "OMANYTE")
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is
|
||||
-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that.
|
||||
M.CelebiShrineEvent = function(vm)
|
||||
if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end
|
||||
vm.celebiArmed = true
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit
|
||||
-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542.
|
||||
M.CheckCaughtCelebi = function(vm)
|
||||
local caught = vm.celebiArmed == true and vm.battleOutcome == "caught"
|
||||
vm.celebiArmed = nil
|
||||
if caught then
|
||||
local record = S.save(vm)
|
||||
if record then
|
||||
local Save = require("src.core.gen2.Save")
|
||||
Save.crystalState(record).celebiCaught = true
|
||||
end
|
||||
end
|
||||
S.answer(vm, caught and S.TRUE or S.FALSE)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four
|
||||
-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123,
|
||||
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86,
|
||||
-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
local S = Specials.shared
|
||||
|
||||
local M = {}
|
||||
|
||||
M.DisplayUnownWords = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
local world = h.world
|
||||
local game = world and world.game
|
||||
if not (game and game.stack) then return end
|
||||
local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0)
|
||||
if not wall then return end
|
||||
S.block(vm, function(done)
|
||||
local screen = UnownWords.new(game, {
|
||||
wall = wall, world = world, onClose = function() done(true) end,
|
||||
})
|
||||
local ok = pcall(game.stack.push, game.stack, screen)
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -7,7 +7,7 @@ SyncMods.MAX_OPTION_TEXT = 256
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||
return { "red", "blue", "yellow", "gold", "silver" }
|
||||
return { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
end
|
||||
|
||||
local function defaultDeps()
|
||||
|
||||
+91
-19
@@ -6,6 +6,7 @@ local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local BagMenu = {}
|
||||
|
||||
@@ -26,11 +27,19 @@ local function buildItems(game)
|
||||
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
|
||||
})
|
||||
end
|
||||
-- the $ff terminator's row: CANCEL is selectable and exits like B
|
||||
-- (home/list_menu.asm:105-110, 523-528) #1685
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
local function consume(game, id)
|
||||
local function consume(game, id, list)
|
||||
Bag.remove(game.save, id, 1)
|
||||
-- engine/items/inventory.asm:131-136
|
||||
if not game.save.inventory[id] then
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
|
||||
if list then list.index, list.scroll = 1, 0 end
|
||||
end
|
||||
end
|
||||
|
||||
local function save_name(game)
|
||||
@@ -66,6 +75,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
if picker then picker:close() end
|
||||
end
|
||||
|
||||
-- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open
|
||||
-- behind the bag comes down with it (start_sub_menus.asm:400-407) #1745
|
||||
local function closeBag()
|
||||
list:close()
|
||||
if list.closeStartMenu then list.closeStartMenu() end
|
||||
end
|
||||
|
||||
-- field POKé FLUTE: play the tune, then the no-effect text
|
||||
if result == "flute_field" then
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
@@ -84,7 +100,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||
if result == "flute_wake" then
|
||||
list:close()
|
||||
closeBag()
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
showMessages(game, payload, function()
|
||||
local ow = game.overworld
|
||||
@@ -97,7 +113,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "consumed_escape" then -- Poké Doll
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
showMessages(game, payload, function()
|
||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||
@@ -120,12 +136,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
|
||||
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
|
||||
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
|
||||
-- runs, which is why it precedes list:close() here.
|
||||
-- runs, which is why it precedes the mount/dismount here.
|
||||
if game.save.forcedBike then
|
||||
showMessages(game, { Strings("You can't get off\nhere.") })
|
||||
return
|
||||
end
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local Music = require("src.core.Music")
|
||||
-- IsBikeRidingAllowed (home/overworld.asm): the tilesets of
|
||||
@@ -146,27 +161,33 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return false
|
||||
end
|
||||
if game.save.onBike then
|
||||
closeBag()
|
||||
game.save.onBike = false
|
||||
Music.playMap(game.data, ow and ow.map.id, false)
|
||||
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
|
||||
elseif bikeAllowed() then
|
||||
closeBag()
|
||||
game.save.onBike = true
|
||||
Music.playMap(game.data, ow.map.id, true)
|
||||
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
|
||||
else
|
||||
-- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and
|
||||
-- .useItem_closeMenu loops back (item_effects.asm:2305-2319)
|
||||
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if result == "fish" then
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local p = ow and ow.player
|
||||
if ow and p and ow:facingIsShoreOrWater() then
|
||||
closeBag()
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
-- FishingInit's `ret c` -> ItemUseNotTime -> ItemUseFailed, so a rod
|
||||
-- away from water leaves the bag up (item_effects.asm:1893-1901)
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
end
|
||||
@@ -177,7 +198,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
game.save.player.name) })
|
||||
return
|
||||
end
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
battle:throwBall(id)
|
||||
return
|
||||
@@ -197,21 +218,22 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) }, nil,
|
||||
game.data.pokemon[target.species].name, mdef.name) }, closePicker,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
if result == "learn" then consume(game, id) end
|
||||
if result == "learn" then consume(game, id, list) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned and result == "learn" then consume(game, id, list) end
|
||||
if learned then
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if learned then taught() end
|
||||
closePicker()
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -265,8 +287,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local ow = game.overworld
|
||||
if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
list:close()
|
||||
consume(game, id)
|
||||
closeBag()
|
||||
consume(game, id, list)
|
||||
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
@@ -292,7 +314,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "consumed" then
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
-- refresh counts in the list
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
@@ -388,6 +410,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
-- .chooseMon loops on a refusal, so the TM/HM picker stays up for another
|
||||
-- pick (engine/items/item_effects.asm:2234, :2237) (#1686)
|
||||
local machineDef = game.data.items[id]
|
||||
if picker and picker.keepOpen and machineDef and machineDef.machine then
|
||||
showMessages(game, payload)
|
||||
return
|
||||
end
|
||||
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
|
||||
-- refusal closes the picker the same way (#252)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
@@ -406,10 +435,15 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
-- USE_ITEM_PARTY_MENU / EVO_STONE_PARTY_MENU, not the trade and daycare
|
||||
-- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610
|
||||
itemUse = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||
-- the party menu (item_effects.asm:1392-1418)
|
||||
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||
-- the party menu (item_effects.asm:1392-1418); TM/HM stays up through
|
||||
-- `predef LearnMove` (item_effects.asm:2238) (#1686)
|
||||
keepOpen = (not battle)
|
||||
and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil)),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
@@ -460,9 +494,15 @@ local function useItem(game, battle, id, list)
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
and romText(game.data, "_BootedUpHMText", "Booted up an HM!")
|
||||
or romText(game.data, "_BootedUpTMText", "Booted up a TM!")
|
||||
-- engine/items/item_effects.asm:2177 (#1686)
|
||||
showMessages(game, { booted,
|
||||
romText(game.data, "_TeachMachineMoveText",
|
||||
"It contained\n%s!\fTeach %s\nto a POKéMON?", moveName, moveName) },
|
||||
nil, { choice = function(yes)
|
||||
if yes then pickTargetAndUse(game, battle, id, list) end
|
||||
end })
|
||||
return
|
||||
end
|
||||
pickTargetAndUse(game, battle, id, list)
|
||||
@@ -484,7 +524,8 @@ function BagMenu.new(game, opts)
|
||||
onCancel = opts.onCancel,
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
-- attempts to swap the CANCEL row are ignored (swap_items.asm:19-22)
|
||||
if not item or item.cancel then return end
|
||||
if not l.swapIndex then
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
@@ -496,6 +537,12 @@ function BagMenu.new(game, opts)
|
||||
l.items = buildItems(game)
|
||||
end,
|
||||
onChoose = function(item)
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
if item and item.cancel then
|
||||
list:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
return
|
||||
end
|
||||
local id = item.value
|
||||
local def = game.data.items[id]
|
||||
if list.swapIndex then -- A also completes a pending swap
|
||||
@@ -510,6 +557,12 @@ function BagMenu.new(game, opts)
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
end
|
||||
-- the BICYCLE never gets the option box: StartMenu_Item jumps straight
|
||||
-- to .useOrTossItem (start_sub_menus.asm:340-342) #1705
|
||||
if id == "BICYCLE" then
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
end
|
||||
-- USE / TOSS submenu (the original's item options).
|
||||
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
|
||||
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
|
||||
@@ -548,6 +601,25 @@ function BagMenu.new(game, opts)
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||
end,
|
||||
})
|
||||
-- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and
|
||||
-- engine/menus/start_sub_menus.asm:319-323 #1732
|
||||
local n, rows = #list.items, list.cursorRows or list.rows
|
||||
if n > 0 then
|
||||
local saved = game.bagListScrollOffset or 0
|
||||
local index = math.min(saved + (game.bagSavedMenuItem or 0) + 1, n)
|
||||
local scroll = math.min(saved, index - 1, math.max(0, n - rows))
|
||||
list.index, list.scroll = index, math.max(0, scroll, index - rows)
|
||||
end
|
||||
local baseUpdate = list.update
|
||||
list.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
game.bagListScrollOffset = self.scroll
|
||||
game.bagSavedMenuItem = self.index - self.scroll - 1
|
||||
end
|
||||
list.closeStartMenu = opts.onClose
|
||||
-- the item box overlaps the kept-open START menu box, so neither docks to
|
||||
-- a screen edge on its own (start_sub_menus.asm:302-329) #1745
|
||||
list.holdsUIAnchors = true
|
||||
return list
|
||||
end
|
||||
|
||||
|
||||
+45
-5
@@ -16,6 +16,34 @@ function ListMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local BLACK = { 0, 0, 0, 1 }
|
||||
local MUTED_TEXT = { 0.55, 0.55, 0.55, 1 }
|
||||
|
||||
-- row text runs from x=16 to the 160px screen's right margin (160-8, same
|
||||
-- margin item.right right-aligns against); GAP is the blank strip kept
|
||||
-- between a truncated label and item.right so the two never touch.
|
||||
local ROW_LEFT = 16
|
||||
local ROW_RIGHT_MARGIN = 160 - 8
|
||||
local LABEL_GAP = 4
|
||||
|
||||
-- Truncate `text` to `pixels`, same convention as WideBattle.lua's own
|
||||
-- fitName (HP-bar names facing the identical "arbitrary text vs. fixed
|
||||
-- pixel budget" problem): cut on a whole glyph span, never mid-character,
|
||||
-- and mark the cut with a trailing '.'. ShaderFXScreen's preset names are
|
||||
-- player-supplied filenames of arbitrary length -- unclipped, a long one
|
||||
-- either overlapped/garbled item.right's "CONVERT" hint or ran past the
|
||||
-- screen's right edge outright.
|
||||
local function fitLabel(text, pixels)
|
||||
local spans = Font.split(text or "")
|
||||
local n = Font.spansFitting(spans, pixels)
|
||||
if n >= #spans then return text or "" end
|
||||
local out = {}
|
||||
for i = 1, math.max(0, n - 1) do
|
||||
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
|
||||
end
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
@@ -237,12 +265,13 @@ function ListMenu:drawItemBox()
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||
end
|
||||
local shown = 0
|
||||
local shown, sawCancel = 0, false
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
shown = shown + 1
|
||||
if item.cancel then sawCancel = true end
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
@@ -262,7 +291,7 @@ function ListMenu:drawItemBox()
|
||||
end
|
||||
-- the terminator prints CANCEL and returns before the '▼'
|
||||
-- (home/list_menu.asm:372, 518-524)
|
||||
if shown == self.rows then
|
||||
if shown == self.rows and not sawCancel then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -282,22 +311,33 @@ function ListMenu:draw()
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
local y = 8 + row * 16
|
||||
Font.draw(item.label, 16, y)
|
||||
-- item.muted: a real, selectable row that isn't fully "ready" yet (e.g.
|
||||
-- ShaderFXScreen's unconverted presets) -- readable, never hidden, same
|
||||
-- "always still readable" convention kit/Theme.lua's own disabled state
|
||||
-- documents, just not the generic list-item shape that lived here
|
||||
-- before ShaderFXScreen needed it.
|
||||
local textColor = item.muted and MUTED_TEXT or BLACK
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
|
||||
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
|
||||
local label = fitLabel(item.label, budget)
|
||||
Font.draw(label, 16, y)
|
||||
if item.ball then -- the Pokédex owned-ball marker tile
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(item.label) + 8 + 3
|
||||
local bx = 16 + Font.width(label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", bx, by, 1.2)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
end
|
||||
love.graphics.setColor(unpack(BLACK))
|
||||
if i == self.index then
|
||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- "Which move should be forgotten?", replaces a move when a Pokémon with
|
||||
-- four moves learns a new one (engine/pokemon/learn_move.asm). Opens
|
||||
-- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves
|
||||
-- can't be forgotten; B / CANCEL gives up on the new move.
|
||||
-- can't be forgotten; B gives up on the new move.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
@@ -66,7 +66,9 @@ end
|
||||
function MoveLearnMenu:update(dt)
|
||||
if not self.selecting then return end
|
||||
local input = self.game.input
|
||||
local n = #self.mon.moves + 1 -- moves + CANCEL
|
||||
-- wMaxMenuItem = wNumMovesMinusOne, no CANCEL row
|
||||
-- engine/pokemon/learn_move.asm:144 (#1686)
|
||||
local n = #self.mon.moves
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or n
|
||||
elseif input:wasPressed("down") then
|
||||
@@ -74,9 +76,6 @@ function MoveLearnMenu:update(dt)
|
||||
elseif input:wasPressed("b") then
|
||||
self:confirmAbandon()
|
||||
elseif input:wasPressed("a") then
|
||||
if self.index > #self.mon.moves then
|
||||
self:confirmAbandon()
|
||||
else
|
||||
local old = self.mon.moves[self.index]
|
||||
if HM_MOVES[old.id] then
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
@@ -92,7 +91,6 @@ function MoveLearnMenu:update(dt)
|
||||
self:finish(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- AbandonLearning (learn_move.asm): "Abandon learning MOVE?" YES/NO
|
||||
-- before giving up; NO returns to the TryingToLearn prompt
|
||||
@@ -143,15 +141,14 @@ end
|
||||
|
||||
function MoveLearnMenu:draw()
|
||||
if not self.selecting then return end
|
||||
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
|
||||
-- plus the port's extra CANCEL row
|
||||
Font.drawBox(4, 5, 16, 7)
|
||||
-- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8
|
||||
-- engine/pokemon/learn_move.asm:123-140 (#1686)
|
||||
Font.drawBox(4, 7, 16, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.mon.moves) do
|
||||
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
|
||||
Font.draw(self.game.data.moves[mv.id].name, 48, (7 + i) * 8)
|
||||
end
|
||||
Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8)
|
||||
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
|
||||
Font.drawCode(CURSOR, 40, (7 + self.index) * 8)
|
||||
-- WhichMoveToForgetText in the bottom dialogue box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw(Strings("Which move should"), 8, 14 * 8)
|
||||
|
||||
+40
-19
@@ -4,7 +4,7 @@
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
|
||||
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- SHADER FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
-- add their own; CANCEL is appended after the hook and stays fixed on the
|
||||
@@ -13,7 +13,7 @@
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
@@ -143,6 +143,20 @@ end
|
||||
|
||||
local function sameRows(_, rows) return rows end
|
||||
|
||||
-- "OFF" plus every entry's display name, extension stripped -- a
|
||||
-- Strings() lookup like RULESET's own record.name so a translation catalog
|
||||
-- could rewrite "OFF" without needing to know about arbitrary preset
|
||||
-- filenames.
|
||||
local function shaderfxLabel(entry)
|
||||
if not entry then return Strings("OFF") end
|
||||
return Strings((entry.name:gsub("%.slangp$", "")))
|
||||
end
|
||||
|
||||
-- Dual-shader slots: "main" backs the original SHADER
|
||||
-- FX row (unchanged save key, unchanged default OFF), "secondary" backs the
|
||||
-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs
|
||||
-- main's chain into secondary's, same as stacking two RetroArch presets.
|
||||
|
||||
-- the vanilla rows as descriptors; each step body is the old per-index
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
@@ -323,10 +337,10 @@ local function buildRows(game)
|
||||
return true
|
||||
end },
|
||||
-- Heads the port's display group: one tier that scales the heavy extras
|
||||
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
|
||||
-- (TILT / survey ZOOM) and the FPS ceiling for weaker devices.
|
||||
-- AUTO picks a default from the hardware; every tier is overridable.
|
||||
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
|
||||
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
|
||||
-- the player's stored TILT / ZOOM) the moment the row changes.
|
||||
{ id = "performance", label = Strings("PERFORMANCE"),
|
||||
value = function(g)
|
||||
return Strings(Performance.label(g.save.options.performance))
|
||||
@@ -366,15 +380,30 @@ local function buildRows(game)
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "gbcfx", label = Strings("GBC FX"),
|
||||
-- The generalized slang-shader-preset feature: a picker over
|
||||
-- ShaderFX.list()'s real drop-in presets -- replaced GBCFX.lua's fixed
|
||||
-- level ladder entirely (GBCFX.lua removed). A pushes a real list
|
||||
-- screen -- OFF plus one row per .slangp found -- the same
|
||||
-- "activate, not step" shape CONTROLS/MODS already use, rather than
|
||||
-- cycling in place on this row.
|
||||
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
|
||||
-- it and shows what is currently active.
|
||||
{ id = "shaderfx", label = Strings("SHADER FX"),
|
||||
value = function(g)
|
||||
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
|
||||
return shaderfxLabel(ShaderFX.activeEntry("main"))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "ShaderFXScreen", "main")
|
||||
end },
|
||||
-- The secondary slot: same picker screen, opened on "secondary" instead
|
||||
-- -- its own row so both slots are visible/settable independently
|
||||
-- without a submenu inside a submenu.
|
||||
{ id = "shaderfx2", label = Strings("SHADER FX 2"),
|
||||
value = function(g)
|
||||
return shaderfxLabel(ShaderFX.activeEntry("secondary"))
|
||||
end,
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "ShaderFXScreen", "secondary")
|
||||
end },
|
||||
{ id = "zoom", label = Strings("ZOOM"),
|
||||
value = function(g)
|
||||
@@ -565,14 +594,6 @@ local function buildRows(game)
|
||||
return true
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
|
||||
if not (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||
local filtered = {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user