This commit is contained in:
bryanthaboi
2026-08-24 10:00:57 -04:00
293 changed files with 69136 additions and 2235 deletions
+16 -14
View File
@@ -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.
+15 -12
View File
@@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](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 |
+1 -1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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).
+17 -7
View File
@@ -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
+3 -2
View File
@@ -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"},
},
},
+3 -4
View File
@@ -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" },
},
},
+4 -3
View File
@@ -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
View File
@@ -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
},
},
}
+23
View File
@@ -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,
+48 -72
View File
@@ -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 }
-- 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)
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)
{ "warp", "VERMILION_CITY", 18, 31, "up" },
{ "move_player", "up", 2 },
})
end
end,
}
+6 -4
View File
@@ -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
-10
View File
@@ -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,
+5 -5
View File
@@ -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
View File
@@ -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`,
+4
View File
@@ -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
+1 -1
View File
@@ -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 |
+673
View File
@@ -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.
-1
View File
@@ -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`) |
+4 -4
View File
@@ -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.
+27 -3
View File
@@ -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
View File
@@ -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
+81 -7
View 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
# --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
+2
View File
@@ -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"
+2 -1
View File
@@ -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
View File
@@ -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
+10 -4
View File
@@ -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"
+3 -1
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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)
+40 -6
View File
@@ -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)
+12 -6
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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
+36 -7
View File
@@ -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
+42
View File
@@ -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.
--
+30 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+501
View File
@@ -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
+355
View File
@@ -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
+75
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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)
+131 -53
View File
@@ -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()
self:showIntro()
onDone = function(skipped)
if skipped then
self:showTitle()
else
self:showIntro()
end
end,
})
end
@@ -1095,18 +1106,26 @@ 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
self:openStartMenu()
return
end
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
self:useSelectItem()
return
-- 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")
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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+523 -124
View File
@@ -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,8 +1876,10 @@ 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
for _, m in ipairs(coreMigrations) do
if m.from >= fmt then m.fn(save) end
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
@@ -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
+178
View File
@@ -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
+8
View File
@@ -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
+503
View File
@@ -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
View File
@@ -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
+20 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+6
View File
@@ -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
+22 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+3 -2
View File
@@ -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
+52
View File
@@ -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 =
+439
View File
@@ -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
+4 -23
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1176 -86
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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 "
+173 -4
View File
@@ -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
SaveData.setModEnabled(options, id, not self.disabled[id], scope)
-- 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
+143 -6
View File
@@ -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,8 +228,95 @@ local function parseEntry(raw)
}
end
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
-- | nil, err
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.
function ModIndex.parse(jsonText, Json)
@@ -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
View File
@@ -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
+13 -2
View File
@@ -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)
-310
View File
@@ -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
+164
View File
@@ -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
View File
@@ -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
+295
View File
@@ -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
+67
View File
@@ -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
+27
View File
@@ -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
+9 -7
View File
@@ -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
View File
@@ -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
+5 -2
View File
@@ -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
+23 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+397
View File
@@ -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
+508
View File
@@ -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
+298
View File
@@ -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
+29
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21 -24
View File
@@ -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,23 +76,19 @@ 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
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
romText(self.game.data, "_HMCantDeleteText",
"HM techniques\ncan't be deleted!")))
return
end
local mdef = self.game.data.moves[self.newMoveId]
self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp }
self.forgot = self.game.data.moves[old.id].name
self:finish(true)
local old = self.mon.moves[self.index]
if HM_MOVES[old.id] then
-- HMCantDeleteText, then back to the forget list
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
romText(self.game.data, "_HMCantDeleteText",
"HM techniques\ncan't be deleted!")))
return
end
local mdef = self.game.data.moves[self.newMoveId]
self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp }
self.forgot = self.game.data.moves[old.id].name
self:finish(true)
end
end
@@ -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
View File
@@ -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