mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +02:00
Spider Car Unleashed
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
This commit is contained in:
@@ -126,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
|
||||
|
||||
@@ -230,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
|
||||
@@ -325,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||
|
||||
### Display options
|
||||
|
||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||
`POKEPORT_GBCFX=1`.
|
||||
|
||||
### First run
|
||||
|
||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||
|
||||
@@ -66,6 +66,12 @@ function love.conf(t)
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
|
||||
-- independent of t.accelerometerjoystick below (which instead maps the
|
||||
-- accelerometer onto joystick axes and stays off -- see that flag's
|
||||
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
|
||||
-- reflective-screen look) has sensor data available without reviving #468.
|
||||
t.modules.sensor = true
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
|
||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||
},
|
||||
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
||||
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||
},
|
||||
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
||||
-- itself, flavor line only.
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||
-- shape with SEEL, PlayCry at :89.
|
||||
TEXT_POKEMONFANCLUB_SEEL = {
|
||||
{ "show_text", "_PokemonFanClubSeelText" },
|
||||
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
|
||||
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
|
||||
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
|
||||
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
||||
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||
return {
|
||||
SS_ANNE_1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||
{"face_player"},
|
||||
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
||||
-- the flavor text is ported).
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||
return {
|
||||
SS_ANNE_B1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||
{ "face_player" },
|
||||
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,10 +18,11 @@ return {
|
||||
|
||||
-- VermilionCityMachopText: cries out, then follows up with a
|
||||
-- second line about stomping the land flat.
|
||||
-- (pokered/scripts/VermilionCity.asm)
|
||||
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||
TEXT_VERMILIONCITY_MACHOP = {
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
||||
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
||||
-- flavor line is ported.
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||
|
||||
return {
|
||||
VERMILION_PIDGEY_HOUSE = {
|
||||
talk = {
|
||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||
{"face_player"},
|
||||
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||
},
|
||||
},
|
||||
|
||||
+36
-22
@@ -381,22 +381,35 @@ M.VERMILION_CITY = {
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
-- scripts/VermilionCity.asm:158 (#1651)
|
||||
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
done))
|
||||
return
|
||||
end
|
||||
-- scripts/VermilionCity.asm:195
|
||||
local p = ow and ow.player
|
||||
if not p or p.facing == "right"
|
||||
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1WelcomeToSSAnneText
|
||||
or "Welcome to S.S.\nANNE!", done))
|
||||
return
|
||||
end
|
||||
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
|
||||
and (t._VermilionCitySailor1FlashedTicketText
|
||||
or "{PLAYER} flashed\nthe S.S.TICKET!")
|
||||
or (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard.")
|
||||
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
|
||||
TEXT_SSANNE2F_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 9 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text or {}
|
||||
local p = ow and ow.player
|
||||
-- scripts/Museum1F.asm:45 (#1690)
|
||||
if p and ((p.cellY == 4 and p.cellX == 13)
|
||||
or (p.cellY == 3 and p.cellX == 12)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1DoYouKnowWhatAmberIsText
|
||||
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
|
||||
nil, { choice = function(yes)
|
||||
game.stack:push(TextBox.new(game, yes
|
||||
and (t._Museum1FScientist1TheresALabSomewhereText
|
||||
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
|
||||
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
|
||||
or "AMBER is fossil-\nized tree sap."), done))
|
||||
end }))
|
||||
return
|
||||
end
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:58
|
||||
if p and p.cellY ~= 4 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1GoToOtherSideText
|
||||
or "Please go to the\nother side!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
+48
-72
@@ -9,20 +9,30 @@ local M = {}
|
||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", flag }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "ask", askText }, -- 4
|
||||
{ "jump_if_false", 10 }, -- 5
|
||||
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||
refusedText, followText)
|
||||
local rows = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", flag },
|
||||
{ "jump_if_true", "already_got" },
|
||||
{ "ask", askText },
|
||||
{ "jump_if_false", "refused" },
|
||||
-- give-then-print like the three rod-house scripts (GiveItem fills
|
||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||
{ "give_item", rodItem, 1, false }, -- 6
|
||||
{ "show_text", receivedText }, -- 7
|
||||
{ "set_flag", flag }, -- 8
|
||||
{ "jump", 10 }, -- 9 is below
|
||||
{ "give_item", rodItem, 1, false },
|
||||
{ "set_flag", flag },
|
||||
{ "show_text", receivedText },
|
||||
}
|
||||
if followText then rows[#rows + 1] = { "show_text", followText } end
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "refused" }
|
||||
rows[#rows + 1] = { "show_text", refusedText }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "already_got" }
|
||||
rows[#rows + 1] = { "show_text", afterText }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.VERMILION_OLD_ROD_HOUSE = {
|
||||
@@ -31,11 +41,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD",
|
||||
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
|
||||
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
|
||||
},
|
||||
}
|
||||
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
|
||||
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -43,11 +53,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
|
||||
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
|
||||
},
|
||||
}
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
|
||||
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -55,11 +64,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
|
||||
@@ -893,16 +902,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")
|
||||
@@ -922,52 +921,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,
|
||||
}
|
||||
|
||||
@@ -922,10 +922,12 @@ M.SS_ANNE_2F = {
|
||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
|
||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
|
||||
@@ -573,7 +573,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
@@ -806,7 +806,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`,
|
||||
|
||||
@@ -16,6 +16,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **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
|
||||
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
# ShaderFX: runtime slang shader presets
|
||||
|
||||
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
|
||||
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
|
||||
a picker: any preset the player drops in a folder, or any preset pulled from
|
||||
the RetroArch buildbot, can be selected and run. Engine:
|
||||
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
|
||||
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
|
||||
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
|
||||
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
|
||||
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
|
||||
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
|
||||
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
|
||||
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
|
||||
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
|
||||
|
||||
This is first-party engine code, not a mod. It calls the native translator
|
||||
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
|
||||
declaration, and no mod boundary. Mods do not get that, and the distinction is
|
||||
deliberate: the upstream maintainer will not accept `native` in mods.
|
||||
|
||||
## What a player sees
|
||||
|
||||
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
|
||||
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
|
||||
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
|
||||
action row at the bottom.
|
||||
|
||||
A preset that has never been translated draws muted with a `CONVERT` hint on
|
||||
the right. `A` on that row translates it in place and stays open: converting is
|
||||
a preparation step, not a selection. `A` on a converted row activates it,
|
||||
persists the choice, and closes. `SELECT` on a converted row opens
|
||||
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
|
||||
declares and lets the player step each one (`A` wraps, Left/Right clamp,
|
||||
`SELECT` resets one row, `START` resets all behind a confirm).
|
||||
|
||||
Presets live in a plain OS folder, `shaders/` under the portable base directory
|
||||
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
|
||||
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
|
||||
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
|
||||
whatever nested layout it shipped with. These are real filesystem paths rather
|
||||
than `love.filesystem` virtual paths on purpose: the native translator does
|
||||
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
|
||||
LUT loads.
|
||||
|
||||
Persisted state, all in `save.options`:
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `shaderfx` | main slot's preset name, or absent for OFF |
|
||||
| `shaderfxSecondary` | secondary slot's preset name |
|
||||
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
|
||||
|
||||
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
|
||||
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
|
||||
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
|
||||
env var can never later override a real player choice, including a real choice
|
||||
of OFF.
|
||||
|
||||
One quiet behavior worth knowing about: if a slot wants a real preset while
|
||||
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
|
||||
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
|
||||
the saved choice was force-deactivated a few lines later on every boot, which
|
||||
looks identical to "the setting does not save" from the player's side. On
|
||||
Android and iOS that is the common case, because AUTO always resolves to
|
||||
`balanced` there. It never touches an already-explicit performance choice and
|
||||
never un-escalates.
|
||||
|
||||
## The five stages
|
||||
|
||||
| Stage | Owner |
|
||||
| --- | --- |
|
||||
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
|
||||
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
|
||||
| Fixup | `src/render/ShaderFixup.lua` |
|
||||
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
|
||||
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
|
||||
|
||||
### Fetch
|
||||
|
||||
Two acquisition paths, and they land in the same place. A player can copy a
|
||||
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
|
||||
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
|
||||
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
|
||||
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
|
||||
mod index already use.
|
||||
|
||||
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
|
||||
mirror and has no notion of this project's cache format, so a downloaded preset
|
||||
goes through the same `CONVERT` row a hand-copied one does. Every platform
|
||||
ships both convert and use; there is no asymmetry to work around.
|
||||
|
||||
Repeat downloads are conditional. The zip itself is deleted right after
|
||||
extraction, so what is cached instead is the buildbot's own ETag
|
||||
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
|
||||
match with 304 and no body, and curl writes no file at all in that case, so
|
||||
`installDownloaded(notModified)` short-circuits before touching the filesystem
|
||||
and reports "already up to date" rather than "FAILED".
|
||||
|
||||
The interesting part is what gets extracted. `handheld/` is not self-contained:
|
||||
its 78 presets carry 101 references that escape the folder (color-mod LUTs
|
||||
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
|
||||
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
|
||||
them. Extracting `handheld/` alone silently breaks about a third of its own
|
||||
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
|
||||
content nobody asked for. So `extractClosure` walks the real file-level
|
||||
dependency closure in Lua before a single file is copied, the same
|
||||
`#reference`-closure idea librashader applies internally, done up front because
|
||||
deciding what to copy has to happen before the translator ever sees these
|
||||
files. Against this zip that is 207 files and about 9.5 MB with zero broken
|
||||
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
|
||||
than all 78, after most of `color-mod/` and `console-border/` turned out either
|
||||
irrelevant (color-only, no LCD effect) or broken for this project. That list
|
||||
is a temporary trim pending wider testing and is expected to change.
|
||||
|
||||
Two mechanical details in that walk that are easy to get wrong a second time.
|
||||
`extractRefs` tries a quoted `key = "path"` match per line first, with an
|
||||
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
|
||||
delimiter and real packs ship paths with spaces and parentheses in them
|
||||
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
|
||||
quoted match falls back to a conservative character class, since an unquoted
|
||||
path has no delimiter to trust past. And `love.filesystem.write` does not
|
||||
create intermediate directories, so each destination directory is created once
|
||||
before anything is written into it; without that, every file under a subfolder
|
||||
`handheld/` never had before was silently dropped while flat writes succeeded.
|
||||
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
|
||||
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
|
||||
it returns false, leaves the zip's handle open, and the following `remove()`
|
||||
silently fails too, so every download used to leave 54 MB on disk forever.
|
||||
|
||||
### Translate: the native bridge
|
||||
|
||||
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
|
||||
named `librashader_bridge`. It wraps `librashader-presets`,
|
||||
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
|
||||
|
||||
```
|
||||
char* librashader_translate_preset(const char* preset_path, int es);
|
||||
void librashader_free_string(char* s);
|
||||
```
|
||||
|
||||
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
|
||||
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
|
||||
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
|
||||
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
|
||||
with resolved absolute paths and filter/wrap settings), its
|
||||
`parameter_overrides`, and an `error` field.
|
||||
|
||||
**What the bridge is not.** No librashader runtime backend is linked in, for
|
||||
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
|
||||
graphics context is ever touched. It is the translation step only. LOVE still
|
||||
owns every draw call, every canvas and every shader object. The bridge hands
|
||||
back text and metadata and nothing else.
|
||||
|
||||
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
|
||||
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
|
||||
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
|
||||
cached artifact and never call the library. An entry with no cached artifact
|
||||
fails `activate()` loudly instead of silently live-translating.
|
||||
|
||||
The classification is done with librashader's real semantics resolution rather
|
||||
than name matching on the Lua side, and that matters for correctness, not just
|
||||
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
|
||||
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
|
||||
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
|
||||
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
|
||||
alias resolves. The per-pass convenience API only registers the alias of the
|
||||
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
|
||||
loop and builds a preset-wide alias map first; without that,
|
||||
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
|
||||
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
|
||||
classification runs the same resolution over the pass's `uniform_semantics`
|
||||
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
|
||||
present preset uses but that the convention allows.
|
||||
|
||||
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
|
||||
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
|
||||
`love.system.getOS()`, and the same function decides which dialect
|
||||
`validateShader` is asked about at run time, so the two always agree. Convert
|
||||
and render always happen on the same device; **artifacts are not portable
|
||||
across platforms**.
|
||||
|
||||
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
|
||||
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
|
||||
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
|
||||
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
|
||||
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
|
||||
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
|
||||
the source first, and only for the ES target:
|
||||
|
||||
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
|
||||
the preset's declared static textures, with dimensions read straight out of
|
||||
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
|
||||
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
||||
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
||||
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
||||
reciprocal-size uniform. The containing block instance (`params`, `global`,
|
||||
whatever) is discovered by scanning the source's own uniform block bodies,
|
||||
never assumed. When a pass samples another pass purely through these calls it
|
||||
may never declare that `<Tex>Size` uniform at all, so one is injected first,
|
||||
before any byte offsets are computed.
|
||||
|
||||
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
|
||||
mode at out-of-range coordinates, which is not necessarily identical to
|
||||
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
|
||||
discrepancy for passes whose offsets can leave the image is unmeasured.
|
||||
|
||||
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
|
||||
`ShaderFX` looks for the library, most specific first: the
|
||||
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
|
||||
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
|
||||
finally the bare name handed to the system loader. Per-OS names are
|
||||
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
|
||||
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||
without a path. The desktop path is still a developer build sitting in cargo's
|
||||
output directory; nothing packages it next to a shipped game yet.
|
||||
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||
preset never needs any of this.
|
||||
|
||||
### Fixup
|
||||
|
||||
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
||||
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
|
||||
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
|
||||
`position()` convention and refuses a raw `main()`-shaped source outright. This
|
||||
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
|
||||
real preset in the corpus failed without it. This list is the least guessable
|
||||
part of the whole feature.
|
||||
|
||||
**`#version` line.** Stripped; LOVE prepends its own.
|
||||
|
||||
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
|
||||
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
|
||||
literals, which validation rejects with "arrayed constructor: not supported for
|
||||
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
|
||||
three real shapes in the corpus and each is handled: a `const` global (declared
|
||||
without an initializer at global scope, with per-element assignments relocated
|
||||
to the top of `main()`), a non-const local declaration with initializer
|
||||
(rewritten in place, since a function body can hold assignment statements where
|
||||
the literal was), and a bare reassignment of an array declared elsewhere (also
|
||||
in place). Splitting the element list needs `splitTopLevelCommas`, because a
|
||||
naive comma split breaks on any element containing its own parentheses
|
||||
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
|
||||
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
|
||||
whole match fails, and the literal passes through completely untouched.
|
||||
|
||||
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
|
||||
a function-parameter array before passing it on, since GLSL array arguments are
|
||||
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
|
||||
declaration plus an element-by-element copy. The size is known from the
|
||||
declaration, so no comma splitting is involved. This one only became reachable
|
||||
once the other array shapes stopped masking it in the same file.
|
||||
|
||||
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
|
||||
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
|
||||
integer operands, so routing through float `mod()` and back is exact for the
|
||||
non-negative operands this shader family uses (rotation and orientation enum
|
||||
indices). Four patterns are tried in order (paren/paren, paren/bare,
|
||||
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
|
||||
operand and leaves a simple one bare, and the balanced form must be tried
|
||||
before a plain identifier can partially match. Seen live on
|
||||
`authentic_gbc`'s subpixel-rotation math.
|
||||
|
||||
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
|
||||
`precision highp int;` pair with no toggle. That is removed and replaced with
|
||||
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
|
||||
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
|
||||
highp, and fall through to the stage default otherwise.
|
||||
|
||||
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
|
||||
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
|
||||
nor sending the whole struct as a table works, both raise "Shader uniform '...'
|
||||
does not exist." librashader emits every pass's `#pragma parameter`s and size
|
||||
uniforms as exactly that kind of struct, so as shipped the output is unusable
|
||||
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
|
||||
instance uniform and re-declares the members at top level. Scalar members are
|
||||
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
|
||||
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
|
||||
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
|
||||
budget unpacked. Non-scalar members keep their own uniform, since packing an
|
||||
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
|
||||
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
|
||||
type was `int` or `bool` gets an explicit cast back on every read, since a
|
||||
packed slot only stores floats. Vertex and fragment declare identically ordered
|
||||
structs for the same parameters, so packing both with the same prefix assigns
|
||||
the same slot and component to the same parameter in both stages, and one
|
||||
`shader:send` reaches whichever stage uses it.
|
||||
|
||||
Two ordering constraints inside that function are load bearing and look
|
||||
arbitrary from the outside. Packing must walk the members in the struct's own
|
||||
declaration order, because that order is what keeps the scalars in one
|
||||
contiguous run; an earlier version sorted them longest-name-first before
|
||||
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
|
||||
budget. Substitution, separately, must go longest-name-first, so a replacement
|
||||
can never land as a substring inside a still-pending member name that shares a
|
||||
prefix. The two orders are separate copies of the list for exactly that reason.
|
||||
|
||||
`Fixup.packValues` turns a flat `{name = value}` table back into the
|
||||
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
|
||||
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
|
||||
slots against that same 16-vector budget, samplers excluded. Its pattern uses
|
||||
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
|
||||
unlike regex `\w`, and every generated name here is full of underscores.
|
||||
|
||||
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
|
||||
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
|
||||
prefix so their groups cannot collide with the push block's numbering. `MVP` is
|
||||
the one special member: it is substituted directly to `transform_projection`
|
||||
instead of becoming a uniform, because "multiply the incoming vertex by it" is
|
||||
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
|
||||
nothing would ever supply a value for it. An earlier version assumed the UBO
|
||||
block only ever carried `MVP` and deleted the whole declaration after
|
||||
substituting it. That is true only for presets that declare their parameters in
|
||||
a push-constant block; presets that use a UBO instead (many real handheld and
|
||||
console-border presets do) had every other member reference left dangling, and
|
||||
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
|
||||
identifier" and "unknown swizzle selection" errors on real Android hardware came
|
||||
from.
|
||||
|
||||
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
|
||||
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
|
||||
precision, because LOVE forward-declares `effect()`'s prototype under its own
|
||||
header's precision default before this source runs, which can mismatch whatever
|
||||
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
|
||||
two variants (`mediump`, then unqualified) and the caller tries them in order
|
||||
against `validateShader`, taking the first that passes.
|
||||
|
||||
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
|
||||
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
|
||||
`gbFragColor`, declared at the top of the function, with a single `return`
|
||||
appended before the closing brace. Rewriting *every* occurrence regardless of
|
||||
the operator that follows is necessary, not just symmetric with the vertex side:
|
||||
an earlier assign-then-return pair assumed one write at the very end of
|
||||
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
|
||||
that write once with `=` and later accumulate with `+=`. The `+=` statement
|
||||
passed through unconverted and collided with LOVE's own `gl_FragColor` write
|
||||
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
|
||||
rewritten to `return gbFragColor;`, which holds the value assigned just before
|
||||
it on every real shape seen.
|
||||
|
||||
**Vertex entry point.** `void main()` becomes `position(mat4
|
||||
transform_projection, vec4 vertex_position)`, the source's own `attribute`
|
||||
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
|
||||
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
|
||||
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
|
||||
appended. The `Position` and `TexCoord` substitutions are frontier-matched
|
||||
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
|
||||
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
|
||||
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
|
||||
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
|
||||
|
||||
### Cache
|
||||
|
||||
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
|
||||
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
|
||||
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
|
||||
path with the extension swapped to `.lua`, so the artifact sits next to the
|
||||
preset it came from. The file is a plain `return { ... }` chunk written by
|
||||
`serializeLua`, which handles the string/number/boolean/nested-table shape
|
||||
`Json.decode` produces. Array detection walks every key rather than trusting
|
||||
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
|
||||
naive length check by accident.
|
||||
|
||||
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
|
||||
failure it says "convert this preset first" rather than falling back to a live
|
||||
translation.
|
||||
|
||||
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
|
||||
explicit, user-initiated action whose result is stable for a given preset and
|
||||
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
|
||||
and the native call off every activation, every boot and every frame. The cost
|
||||
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
|
||||
exist" check with no version or content stamp, and there is no explicit
|
||||
"reconvert" action in the UI. A preset converted by an older build never picks
|
||||
up a later translator fix on its own. This was seen on a real device, where
|
||||
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
|
||||
because that device's cache predated the fix while `pixel_transparency`'s
|
||||
happened to be fresher. Two places compensate by reconverting unconditionally:
|
||||
`ShaderFXScreen`'s explicit selection of an already-converted row, and
|
||||
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
|
||||
CPU-only work with no GPU compile, and neither is on the per-frame path.
|
||||
|
||||
`ShaderSourcePatches.lua` sits just before translation and patches the raw
|
||||
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
|
||||
reading the real files, discovers `#pragma parameter` lines and struct members;
|
||||
nothing downstream can add one. Patches are small, explicit, per-preset literal
|
||||
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
|
||||
pattern magic), re-applied idempotently on every convert so a buildbot
|
||||
re-download that replaces the upstream file wholesale does not quietly undo
|
||||
them. **The patch table ships empty on purpose and nothing registers one.** Its
|
||||
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
|
||||
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
|
||||
gap above: a new pragma can only reach an artifact that gets reconverted, and at
|
||||
the time nothing forced one. The mechanism is kept for a future preset that
|
||||
genuinely needs a new declaration, but an already-wired engine-side channel is
|
||||
preferred whenever one exists.
|
||||
|
||||
### Run: the pass graph
|
||||
|
||||
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
|
||||
player's edited parameters over the artifact's own defaults, loads the preset's
|
||||
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
|
||||
runs the chain each frame.
|
||||
|
||||
`newChainState` builds one instance of chain-local state per loaded preset,
|
||||
never module-global, so switching presets cannot leak a previous preset's
|
||||
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
|
||||
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
|
||||
player's `shaderfxParams` edits.
|
||||
|
||||
Sizes resolve through `resolveScale`, which handles all four slang scale types
|
||||
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
|
||||
input dimensions and the original frame. Size uniforms are packed as
|
||||
`{w, h, 1/w, 1/h}`, the slang convention.
|
||||
|
||||
`runPass` caches two things per `(state, pass index)`. The **shader** is
|
||||
compiled once for the state's lifetime, along with the fragment manifest it was
|
||||
compiled against, since a pass's GLSL depends only on the preset and never on
|
||||
per-frame input. The **canvas** is reallocated only when the pass's resolved
|
||||
size actually changes, a window resize or a different preset. Every harness this
|
||||
runtime was ported from ran the chain once and quit, so allocating a fresh
|
||||
canvas and compiling a fresh shader on every call was invisible there. On a real
|
||||
per-frame render path it is one GPU allocation per pass per frame, and a shader
|
||||
recompile on top. The same discipline applies to the crop canvas in
|
||||
`cropToGbSource`, which is called once per frame and whose size grows with the
|
||||
world canvas as the player zooms out; leaving it uncached was a real cost that
|
||||
scaled with zoom level even with a single preset active.
|
||||
|
||||
Sampler binding is by semantic, from the bridge's classification, never by a
|
||||
hardcoded per-preset name check: `Source` is the previous pass's output (or the
|
||||
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
|
||||
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
|
||||
real name the translation reported. A sampler that resolves to nothing asserts
|
||||
rather than drawing garbage.
|
||||
|
||||
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
|
||||
bytes with plain `io.open` and goes through `love.data.newByteData` and
|
||||
`love.image.newImageData`, because a preset's texture paths are arbitrary
|
||||
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
|
||||
those outright ("Could not open file ... Does not exist") even when the file is
|
||||
real. Wrap modes are mapped from librashader's names to LOVE's
|
||||
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
|
||||
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
|
||||
left nil; the fail-loud point is the sampler assertion in `runPass` that
|
||||
actually needed it, not the loader.
|
||||
|
||||
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
|
||||
history slot reads the current frame, both for the sampler binding and for the
|
||||
size uniform. Real per-frame history rotation has been proven out in a desktop
|
||||
harness but is not wired into this path.
|
||||
|
||||
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
|
||||
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
|
||||
sampler resolves to nothing and trips the binding assertion. No preset in the
|
||||
shipped shortlist uses it.
|
||||
|
||||
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
|
||||
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
|
||||
|
||||
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
|
||||
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
|
||||
composite (world, UI, and any post-process pipeline that already ran); `rect` is
|
||||
this frame's real playfield rectangle in physical framebuffer pixels and
|
||||
`source` is the real pixel size of the content it frames. The sequence is: crop
|
||||
`rect` out of the composite, run whichever slots are active over that crop, draw
|
||||
the untouched composite, then stretch the chain output back over `rect`. UI and
|
||||
letterbox bars outside the playfield pass through untouched. If the chain throws,
|
||||
the frame still shows the unprocessed composite; a broken preset degrades to
|
||||
"shader off", never to a crash or a blank frame.
|
||||
|
||||
Three details in that path are non-obvious:
|
||||
|
||||
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
|
||||
logical units, not raw pixels, so the viewport handed to the pass graph and
|
||||
the final draw-back position are both converted from `rect`'s physical pixels
|
||||
first. On a `dpiscale = 1` desktop the two are numerically identical and the
|
||||
bug is invisible; at dpiscale 3 on real Android hardware the chain output
|
||||
rendered about three times too large and at a pixel-valued offset in unit
|
||||
space.
|
||||
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
|
||||
caller can leave the draw color dirty (a menu's black text leaves it at
|
||||
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
|
||||
and `push("all")` saves state for `pop()` without resetting it. That was the
|
||||
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
||||
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
|
||||
`flushBatch()` on the line above is cheap insurance against a read-after-write
|
||||
ordering hazard between this draw and whatever last rendered into the canvas;
|
||||
it was never confirmed to fix anything on its own.
|
||||
- **The final blit stretches.** A slang chain's last pass is not required to
|
||||
land on the viewport size, and most presets (21 of the 78 in the corpus)
|
||||
declare their last pass `scale_type = "source"` and stay at native Game Boy
|
||||
resolution, relying on the frontend's blit exactly as RetroArch does.
|
||||
Requiring an exact size match here used to skip the draw outright for every
|
||||
such preset on every frame, which is a silent total no-op rather than a sizing
|
||||
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
|
||||
nearest or linear.
|
||||
|
||||
### What this engine feeds shaders that a libretro core does not
|
||||
|
||||
A stock libretro core hands its frontend a raw framebuffer and a frame count.
|
||||
This engine has more context available and passes some of it through.
|
||||
|
||||
| Context | How it reaches the shader |
|
||||
| --- | --- |
|
||||
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
|
||||
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
|
||||
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
|
||||
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
|
||||
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
|
||||
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
|
||||
|
||||
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
|
||||
bound to 0 because librashader's own documentation is explicit that it is
|
||||
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
|
||||
oriented arcade core, say), not device orientation. Nothing here ever rotates
|
||||
Game Boy content, so 0 is the correct answer, not a placeholder.
|
||||
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
|
||||
rest" calibration reference, no preset in the corpus reads it, and a fixed
|
||||
placeholder beats an invented value.
|
||||
|
||||
`src/core/Sensors.lua` is what makes the two real motion semantics work.
|
||||
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
|
||||
any platform including Android; it is a LOVE 12 addition. The working path is
|
||||
raw FFI into the SDL2 that LOVE already links, the same technique
|
||||
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
|
||||
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
|
||||
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
|
||||
dead code after a future LOVE 12 upgrade. Loading order matters:
|
||||
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
|
||||
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
|
||||
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
|
||||
device with no sensor is probed once and then permanently reports zeros, so a
|
||||
desktop run does not pay for it every frame.
|
||||
|
||||
SDL keeps sensor readings in the device's fixed chassis frame regardless of
|
||||
screen orientation, so `rotateForScreen` remaps x and y into
|
||||
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
|
||||
compensation is mobile-only: a desktop monitor is legitimately and permanently
|
||||
"landscape" to that query, which says something about the monitor's shape and
|
||||
nothing about how a player is holding anything.
|
||||
|
||||
The accelerometer path in `sizeTable` does three things to the raw reading
|
||||
before it becomes a uniform, all of them driven by real on-device data:
|
||||
|
||||
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
|
||||
actually holding the device in and every later reading is measured relative
|
||||
to that, rather than to an assumed idealized vertical. The shipped tilt maths
|
||||
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
|
||||
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
|
||||
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
|
||||
the effect sat near-saturated all the time instead of starting near neutral.
|
||||
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
|
||||
dominant on Z, the one axis it never reads. This engine's rest pose is
|
||||
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
|
||||
gravity back on the ignored axis.
|
||||
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
|
||||
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
|
||||
quietly damped tilt and noise alike by the same factor; calibration correctly
|
||||
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
|
||||
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
|
||||
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
|
||||
on the ignored axis to keep the denominator stable, but only when there is a
|
||||
genuine live reading to calibrate against. An all-zero raw read is
|
||||
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
|
||||
Earth, and injecting into that case would make the shader believe it had
|
||||
sensor data and silently replace its own static fallback with fake motion.
|
||||
|
||||
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
|
||||
angle, so it only becomes a usable on-screen offset by integrating over time,
|
||||
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
|
||||
is deliberately a decaying spring rather than a true integrated heading:
|
||||
gyro-only integration drifts without a magnetometer to correct it, so this
|
||||
settles back toward neutral and stays bounded by construction. It is folded onto
|
||||
the accelerometer's x component before the shader's own normalize and clamp,
|
||||
because that is the only already-compiled channel the stock upstream maths
|
||||
reads, and reaching an already-converted artifact with no reconvert was worth
|
||||
the tradeoff that the twist reads as an added simulated tilt rather than a
|
||||
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
|
||||
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
|
||||
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
|
||||
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
|
||||
looked worse, because overshooting a comfortable range reads worse than being
|
||||
subtle. Retune in small steps with real device checks, not big jumps.
|
||||
|
||||
## Two slots
|
||||
|
||||
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
|
||||
to its save key. The slots are activated and persisted independently, and the
|
||||
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
|
||||
parameter edits are keyed by *preset name*, not by slot, because a preset's
|
||||
values are a property of the preset the same way its cached artifact is;
|
||||
editing them re-activates every slot currently showing that preset and persists
|
||||
for the next load in either.
|
||||
|
||||
When both slots are active, `render` runs main's chain first and hands its
|
||||
finished output to secondary as secondary's own input frame, along with its
|
||||
dimensions, so a secondary preset that scales off its input sees main's real
|
||||
output size rather than the original crop. Either slot alone behaves exactly as
|
||||
a single-preset path; neither active is a plain passthrough.
|
||||
|
||||
**This is not how RetroArch composes multiple presets.** RetroArch merges
|
||||
presets into a *single* pass list through `#reference` and `Append`, producing
|
||||
one pass graph with one shared semantics map, where a later pass can reference
|
||||
an earlier one's output by alias and the whole thing resolves as one unit. Two
|
||||
slots here are two independent librashader chains run back to back, which is
|
||||
what stacking two separate preset chains would give you, not what merging them
|
||||
gives you. Presets that assume merged semantics will not behave the same way.
|
||||
|
||||
## Test seams
|
||||
|
||||
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
|
||||
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
|
||||
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
|
||||
canvas, which the later unconditional draw-back would otherwise mask),
|
||||
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
|
||||
actually reached the packed uniform, per slot). `Sensors.setOverride`,
|
||||
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
|
||||
readings on a machine with no hardware.
|
||||
|
||||
## Limitations
|
||||
|
||||
None of these are theoretical.
|
||||
|
||||
- **Tested on very little real hardware.** Essentially one Android phone, one
|
||||
desktop, and the automated harnesses. Anything about how a preset actually
|
||||
looks or performs elsewhere is unverified.
|
||||
- **No performance tier is actually tuned.** The chain-resolution multiplier in
|
||||
`Performance.CAPS` is a working mechanism, but every tier that permits
|
||||
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
|
||||
today. Picking a real value for weak hardware needs a device this project does
|
||||
not have.
|
||||
- **The dual-slot design does not match RetroArch.** See above. Two chains in
|
||||
sequence is not one merged pass list.
|
||||
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
|
||||
falls back to "every slot is the current frame" in the live render path, and
|
||||
is unverified there.
|
||||
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
||||
error and its sampler trips an assertion.
|
||||
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
||||
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
||||
player-supplied filename still collides with the row's right-hand hint.
|
||||
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
|
||||
Intentional, for the reason given above, but it means the mechanism has no
|
||||
live coverage.
|
||||
- **Cached artifacts have no staleness detection.** Existence is the only check.
|
||||
The two unconditional reconvert points paper over it; anything that does not
|
||||
go through them can be running a stale translation.
|
||||
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
|
||||
Copying a converted preset folder between a phone and a desktop copies a
|
||||
wrong artifact along with it.
|
||||
- **The bundled bridge is only as good as the build machine.**
|
||||
`scripts/build.sh` bundles the cdylib for mac, win and linux via
|
||||
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||
package that can run converted presets but cannot CONVERT new ones, and says
|
||||
so rather than failing. Android ships the `.so` via `jniLibs`.
|
||||
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
||||
- **Tilt direction is unverified.** Which way forward and back rocking moves the
|
||||
effect was never confirmed on a device; if it feels backwards the fix is a
|
||||
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
|
||||
landscape rotation compensation matches real RetroArch is genuinely unknown:
|
||||
RetroArch's Android input driver computes a screen rotation but does not
|
||||
visibly apply it to the accelerometer values that reach shader uniforms, so
|
||||
this project's compensation may be an improvement over upstream rather than a
|
||||
match to it.
|
||||
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
||||
in the bridge turns those calls into `texture()`, which honours the sampler's
|
||||
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
||||
implementation defined. Unmeasured.
|
||||
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
|
||||
@@ -374,6 +374,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)
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
@@ -407,15 +412,24 @@ function bootGame(version, cartId)
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
local cartHash
|
||||
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 = hash else cartId = nil 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+30
-1
@@ -168,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}"
|
||||
@@ -180,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 \
|
||||
@@ -293,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,
|
||||
@@ -391,6 +419,7 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
|
||||
|
||||
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||
#
|
||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||
# [--test-application-id ID]
|
||||
#
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --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)
|
||||
@@ -40,6 +51,7 @@ CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/
|
||||
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; }
|
||||
@@ -50,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
|
||||
@@ -86,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/.
|
||||
|
||||
@@ -348,6 +348,12 @@ local EMITTERS = {
|
||||
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
||||
}
|
||||
|
||||
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
|
||||
local function tilesetLoadFrames(data, ts)
|
||||
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
|
||||
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
|
||||
end
|
||||
|
||||
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
||||
-- with the subanimation transform applied (DrawFrameBlock).
|
||||
local function placeTile(transform, bc, t, tileset)
|
||||
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local obp0Flip = false
|
||||
|
||||
for _, row in ipairs(anim.seq) do
|
||||
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
|
||||
-- row's sound and its first frame block (#1653)
|
||||
if not row.effect then
|
||||
emit(tilesetLoadFrames(self.data, row.tileset))
|
||||
end
|
||||
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||
-- the row starts (GetMoveSound)
|
||||
|
||||
@@ -638,6 +638,9 @@ local function newBattle(game)
|
||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||
game.partyMenuSavedIndex = nil
|
||||
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
|
||||
-- (init_battle_variables.asm:7-12) #1732
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
|
||||
self.data = game.data
|
||||
-- ruleset from the merged registry (the requires above are the same
|
||||
-- records on a mod-free boot); an unknown save value falls back to the
|
||||
@@ -1918,6 +1921,8 @@ function BattleState:exit()
|
||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||
-- field party menu comes back on slot 1 after a battle. #768
|
||||
self.game.partyMenuSavedIndex = nil
|
||||
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
|
||||
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
|
||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
@@ -1942,6 +1947,7 @@ local function clearTrapping(battler)
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
battler.trapHitSfx = nil
|
||||
end
|
||||
|
||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||
@@ -4070,12 +4076,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)
|
||||
@@ -4250,7 +4259,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
|
||||
@@ -4627,6 +4636,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?")
|
||||
@@ -5216,6 +5233,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.
|
||||
@@ -5256,6 +5281,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
|
||||
@@ -6120,8 +6152,10 @@ function BattleState:drawHUDs(slide)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and
|
||||
-- nothing redraws it until the next SendOutMon (#1721)
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
and slide == 0 then
|
||||
and slide == 0 and not self.player.fainted then
|
||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||
-- the tick at (18,10) and the triangle at (9,11)
|
||||
|
||||
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
local critPending, ohkoPending = info.crit, info.ohko
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
|
||||
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
|
||||
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
|
||||
if critPending then
|
||||
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
|
||||
critPending = false
|
||||
end
|
||||
if ohkoPending then
|
||||
battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!"))
|
||||
ohkoPending = false
|
||||
end
|
||||
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
|
||||
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
|
||||
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
|
||||
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||
ctx.hitSfx = hitSfx
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
|
||||
@@ -574,6 +574,8 @@ MoveEffects.full = {
|
||||
local r = ctx.rng(0, 7)
|
||||
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
||||
user.trapDamage = ctx.rawDamage
|
||||
-- PlayApplyingAttackSound (animations.asm:2639) replays it each turn
|
||||
user.trapHitSfx = ctx.hitSfx
|
||||
-- remember the move so its animation can replay on each locked
|
||||
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
||||
user.trapMove = ctx.move.id
|
||||
|
||||
@@ -161,8 +161,10 @@ local function drawHUDs(battle, slide)
|
||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||
-- drawCommandMenu below like the classic layout's (#540).
|
||||
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721)
|
||||
if showStatus and not battle.safari and battle.player and not battle.demo
|
||||
and not battle.showPlayerBack and slide == 0 then
|
||||
and not battle.showPlayerBack and slide == 0
|
||||
and not battle.player.fainted then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
--
|
||||
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
|
||||
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
|
||||
-- from 1-7 turns.
|
||||
-- from 2-7 turns.
|
||||
|
||||
local Damage = require("src.battle.gen2.Damage")
|
||||
local Ai = require("src.battle.gen2.Ai")
|
||||
@@ -254,6 +254,9 @@ function Battle.new(opts)
|
||||
-- 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
|
||||
@@ -2469,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
|
||||
@@ -2478,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!" })
|
||||
@@ -2925,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)
|
||||
|
||||
+30
-11
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||
-- / math.random -- never hard-code 0 (that always yields Magnitude 4).
|
||||
function Effects.magnitudePower(random)
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
@@ -356,19 +356,38 @@ function Effects.sandstormHits(types)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
|
||||
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
|
||||
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
|
||||
-- BattleCommand_TimeBasedHealContinue's .Multipliers
|
||||
-- (engine/battle/effect_commands.asm:6450-6454).
|
||||
Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 }
|
||||
|
||||
-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1,
|
||||
-- NITE_F 2, DARKNESS_F 3.
|
||||
Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants
|
||||
-- (engine/battle/effect_commands.asm:6362-6371).
|
||||
Effects.SUN_HEAL = {
|
||||
EFFECT_MORNING_SUN = true,
|
||||
EFFECT_SYNTHESIS = true,
|
||||
EFFECT_MOONLIGHT = true,
|
||||
EFFECT_MORNING_SUN = 0,
|
||||
EFFECT_SYNTHESIS = 1,
|
||||
EFFECT_MOONLIGHT = 2,
|
||||
}
|
||||
|
||||
function Effects.weatherHealFraction(weather)
|
||||
if weather == "sun" then return 2 / 3 end
|
||||
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
|
||||
return 1 / 2
|
||||
function Effects.timeOfDayIndex(timeOfDay)
|
||||
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
|
||||
return Effects.TIME_OF_DAY_ID[timeOfDay]
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the
|
||||
-- wrong time of day steps it down, sun steps it up, rain and sandstorm down.
|
||||
function Effects.timeBasedHealFraction(weather, wants, timeOfDay)
|
||||
local index = 3
|
||||
local now = Effects.timeOfDayIndex(timeOfDay)
|
||||
if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end
|
||||
if weather then
|
||||
index = index + 1
|
||||
if weather ~= "sun" then index = index - 2 end
|
||||
end
|
||||
return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))]
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- Perish Song
|
||||
|
||||
+82
-13
@@ -11,7 +11,13 @@ CartManifest.SCHEMA = 1
|
||||
CartManifest.EXT = ".g1rcart"
|
||||
CartManifest.FORMAT = "g1rcart"
|
||||
CartManifest.DIR = "carts"
|
||||
CartManifest.SEALS = { sealed = true, open = true }
|
||||
-- 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"
|
||||
@@ -143,12 +149,24 @@ local function parseMod(raw, index, seen)
|
||||
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
|
||||
@@ -244,12 +262,45 @@ function CartManifest.parse(tbl)
|
||||
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 or open"
|
||||
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
|
||||
@@ -273,10 +324,13 @@ function CartManifest.parse(tbl)
|
||||
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,
|
||||
}
|
||||
@@ -364,6 +418,10 @@ local function writeValue(out, value)
|
||||
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
|
||||
@@ -375,23 +433,31 @@ local function writeField(out, name, value)
|
||||
writeValue(out, value)
|
||||
end
|
||||
|
||||
local CART_FIELDS = { "author", "base", "engine", "id", "label", "repo",
|
||||
"seal", "shell", "summary", "title", "version" }
|
||||
local MOD_FIELDS = { "file", "id", "md5", "mod", "repo", "sha256",
|
||||
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
|
||||
out[#out + 1] = "[options]"
|
||||
local keys = {}
|
||||
for key in pairs(entry.options or {}) do keys[#keys + 1] = key end
|
||||
table.sort(keys)
|
||||
for _, key in ipairs(keys) do writeField(out, key, entry.options[key]) 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
|
||||
@@ -407,11 +473,14 @@ function CartManifest.encode(cart)
|
||||
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, label = cart.label, base = cart.base,
|
||||
engine = cart.engine, seal = cart.seal, mods = cart.mods,
|
||||
load_order = cart.load_order },
|
||||
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
|
||||
|
||||
|
||||
+24
-7
@@ -10,7 +10,7 @@ CartStore.OPTIONS_KEY = "carts"
|
||||
CartStore.UNPINNED_VERSION = "0.0.0"
|
||||
|
||||
local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal",
|
||||
"shell", "summary", "hash", "file" }
|
||||
"shell", "finish", "speeds", "summary", "hash", "file" }
|
||||
|
||||
local function fsOr(fs)
|
||||
return fs or (love and love.filesystem) or nil
|
||||
@@ -52,14 +52,26 @@ 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, summary = cart.summary,
|
||||
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 a[field] ~= b[field] then return false end
|
||||
if not sameValue(a[field], b[field]) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -67,7 +79,8 @@ 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, summary = cart.summary, label = cart.label,
|
||||
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||
summary = cart.summary, label = cart.label,
|
||||
cart = cart, cartHash = hash, file = fileFor(cart.id) }
|
||||
end
|
||||
|
||||
@@ -299,7 +312,7 @@ 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 row.enabled and safeId(row.id) then
|
||||
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
|
||||
@@ -319,6 +332,9 @@ function CartStore.capture(identity, available, modOptions)
|
||||
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
|
||||
@@ -327,8 +343,9 @@ function CartStore.capture(identity, available, modOptions)
|
||||
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, label = identity.label, base = identity.base,
|
||||
engine = identity.engine, seal = identity.seal,
|
||||
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
|
||||
|
||||
@@ -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
@@ -817,14 +817,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
|
||||
@@ -1258,8 +1250,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)
|
||||
@@ -1274,12 +1268,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
|
||||
@@ -1289,8 +1283,7 @@ function Game:applyOptions(opts)
|
||||
end
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
if shaderfxCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
|
||||
+125
-50
@@ -367,14 +367,22 @@ function Game2:showIntro()
|
||||
})
|
||||
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
|
||||
@@ -1098,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
|
||||
@@ -1161,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.
|
||||
@@ -1359,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
|
||||
@@ -1400,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
|
||||
@@ -1435,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
|
||||
@@ -1459,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
|
||||
@@ -1473,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)
|
||||
@@ -1518,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
|
||||
@@ -1587,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
|
||||
@@ -1691,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
|
||||
@@ -1726,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)
|
||||
@@ -1967,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)
|
||||
@@ -1994,9 +2052,26 @@ function Game2:applyOptions()
|
||||
require("src.core.ScreenPosition").applyOptions(options)
|
||||
require("src.core.FrameCap").applyOptions(options)
|
||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
-- returns true when a persisted preset name no longer resolves (deleted
|
||||
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||
-- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for
|
||||
-- Gen 1 (SHADER FX reaches Gen 2 too)
|
||||
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options)
|
||||
-- Scale the optional presentation extras to the device's performance
|
||||
-- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see
|
||||
-- that site's comment for the full rationale.
|
||||
local caps = require("src.core.Performance").applyOptions(options)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
if caps.fpsMax then
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||
end
|
||||
if shaderfxCleared and self.save then
|
||||
-- applyOptions returns true when it had to clear an unresolved preset.
|
||||
self.save.options = options
|
||||
end
|
||||
end
|
||||
|
||||
+31
-4
@@ -29,11 +29,38 @@ end
|
||||
|
||||
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||
-- a --speed argument), so a bad number degrades to something sane
|
||||
-- A cart may narrow the levels a player can reach (CartManifest's `speeds`).
|
||||
-- nil restores the full ladder; a one-entry list pins the speed outright.
|
||||
local allowed
|
||||
|
||||
function GameSpeed.setAllowed(levels)
|
||||
if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end
|
||||
local valid, seen = {}, {}
|
||||
for _, want in ipairs(GameSpeed.LEVELS) do
|
||||
for _, have in ipairs(levels) do
|
||||
if have == want and not seen[want] then
|
||||
seen[want] = true
|
||||
valid[#valid + 1] = want
|
||||
end
|
||||
end
|
||||
end
|
||||
allowed = (#valid > 0) and valid or nil
|
||||
end
|
||||
|
||||
function GameSpeed.allowed()
|
||||
return allowed or GameSpeed.LEVELS
|
||||
end
|
||||
|
||||
function GameSpeed.isLocked()
|
||||
return allowed ~= nil and #allowed <= 1
|
||||
end
|
||||
|
||||
function GameSpeed.clamp(v)
|
||||
v = tonumber(v)
|
||||
if not v then return GameSpeed.DEFAULT end
|
||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||
local levels = GameSpeed.allowed()
|
||||
if not v then return levels[1] or GameSpeed.DEFAULT end
|
||||
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(levels) do
|
||||
local diff = math.abs(level - v)
|
||||
if diff < bestDiff then best, bestDiff = level, diff end
|
||||
end
|
||||
@@ -42,7 +69,7 @@ end
|
||||
|
||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||
function GameSpeed.cycle(v, dir)
|
||||
local levels = GameSpeed.LEVELS
|
||||
local levels = GameSpeed.allowed()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == GameSpeed.clamp(v) then cur = i break end
|
||||
|
||||
@@ -60,9 +60,7 @@ 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
|
||||
@@ -77,7 +75,7 @@ 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
|
||||
@@ -89,6 +87,8 @@ GameVersion.VERSIONS = {
|
||||
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",
|
||||
|
||||
+24
-1
@@ -376,7 +376,19 @@ end
|
||||
-- notice a quit command until curl returns. With the launcher's default 300s
|
||||
-- ceiling, closing the window during a mod download hung the process for
|
||||
-- minutes. Callers on the interactive fetch pool pass something short.
|
||||
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
--
|
||||
-- `etagPath` (optional, absolute host path) turns this into a conditional
|
||||
-- GET: curl sends `If-None-Match` from whatever ETag is already saved there
|
||||
-- (`--etag-compare`) and overwrites it with the response's own ETag on
|
||||
-- success (`--etag-save`), same file for both so a first call with no prior
|
||||
-- ETag degrades to a plain unconditional download. A server that replies 304
|
||||
-- Not Modified is reported as a third return value (`notModified`), and --
|
||||
-- confirmed empirically against the real buildbot.libretro.com endpoint
|
||||
-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT
|
||||
-- write `absPath` at all in that case (not even an empty file), matching
|
||||
-- HTTP: a 304 carries no body. A caller must not treat a missing file as an
|
||||
-- error when `notModified` comes back true.
|
||||
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
if type(etagPath) == "string" and etagPath ~= "" then
|
||||
cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " "
|
||||
.. "--etag-save " .. HostShell.quote(etagPath) .. " "
|
||||
end
|
||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
@@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
-- status is here purely so the failure can NAME itself: "download failed"
|
||||
-- with no URL and no code is the report this whole change exists to fix.
|
||||
local body, status, noise = splitCurlOutput(readOk and out or "")
|
||||
if status == 304 then
|
||||
return true, nil, true
|
||||
end
|
||||
if status and (status < 200 or status >= 300) then
|
||||
return nil, fetchError(url, status, body)
|
||||
end
|
||||
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
-- The Android/iOS bridge has no conditional-GET support -- etagPath is
|
||||
-- silently ignored here, so a downloaded preset re-fetches in full every
|
||||
-- time on those platforms. No transport-level ETag/If-None-Match hook
|
||||
-- exists on the bridge to hang one off, and it's low-priority for now.
|
||||
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
|
||||
if ok and done then return true end
|
||||
return nil, "download failed for " .. url
|
||||
|
||||
+29
-12
@@ -3,9 +3,12 @@
|
||||
--
|
||||
-- The heavy extras are all things the Game Boy never had and this port
|
||||
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||
-- ground plane), survey ZOOM (zooming out renders the connected
|
||||
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
|
||||
-- translated libretro slang-shader chain, reasoned -- not yet measured --
|
||||
-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced).
|
||||
-- A hard FPS ceiling caps present cost on
|
||||
-- top of that.
|
||||
-- None of this touches game logic, which is fixed-step off dt
|
||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||
@@ -13,14 +16,15 @@
|
||||
-- The tier is persisted as save.options.performance:
|
||||
-- auto pick a default from the device (see detect())
|
||||
-- high everything on -- the historical behavior
|
||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
|
||||
--
|
||||
-- AUTO only chooses the *default* for the current device; every tier is
|
||||
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||
-- against the stored options without rewriting them, so raising the tier
|
||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
|
||||
-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX
|
||||
-- / ZOOM / FPS the player had. Wired into both generations.
|
||||
--
|
||||
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||
-- tests, the same way src/core/GameVersion.lua does.
|
||||
@@ -40,10 +44,24 @@ Performance.LABELS = {
|
||||
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||
-- extra ceiling (the player's own MAX FPS still applies).
|
||||
--
|
||||
-- `shaderfx` is `false` (fully off) or a positive number: an internal
|
||||
-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole
|
||||
-- SHADER FX pass chain at half resolution, then stretch up for display --
|
||||
-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its
|
||||
-- final output to the real display rect regardless of the chain's own
|
||||
-- internal size, so shrinking the chain's working
|
||||
-- resolution costs nothing extra at that final blit). Every existing
|
||||
-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works
|
||||
-- unchanged: `false` stays falsy, any positive number stays truthy -- only
|
||||
-- `ShaderFX.render` itself reads the actual number. No tier is
|
||||
-- set below 1.0 yet -- tuning the actual per-tier value for real low-end
|
||||
-- hardware needs a device this project doesn't have to hand right now; this
|
||||
-- is the mechanism, not the tuning.
|
||||
Performance.CAPS = {
|
||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
|
||||
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
|
||||
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
|
||||
}
|
||||
|
||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||
@@ -93,8 +111,7 @@ function Performance.detect()
|
||||
if isArm and os == "Linux" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
|
||||
if os == "Android" or os == "iOS" then
|
||||
return "balanced"
|
||||
end
|
||||
|
||||
+128
-8
@@ -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
|
||||
@@ -768,7 +842,6 @@ local freshPlaythrough
|
||||
|
||||
local CART_PREFIX = "cart_"
|
||||
|
||||
local activeCart, activeCartHash
|
||||
local sealBroken = false
|
||||
|
||||
local function cartKey(cartId)
|
||||
@@ -1314,6 +1387,53 @@ 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
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus
|
||||
-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any
|
||||
-- platform, so the working path is raw FFI into the SDL2 LOVE already links,
|
||||
-- the same technique src/core/Orientation.lua uses. What is and is not verified
|
||||
-- on real hardware is written down in docs/shaderfx.md.
|
||||
|
||||
local Sensors = {}
|
||||
|
||||
-- Test seams: a no-device harness injects synthetic readings.
|
||||
local overrides = {}
|
||||
local orientationOverride = nil
|
||||
|
||||
function Sensors.setOverride(kind, x, y, z)
|
||||
overrides[kind] = { x, y, z }
|
||||
end
|
||||
|
||||
function Sensors.clearOverride(kind)
|
||||
overrides[kind] = nil
|
||||
end
|
||||
|
||||
function Sensors.clearAllOverrides()
|
||||
overrides = {}
|
||||
orientationOverride = nil
|
||||
end
|
||||
|
||||
-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil
|
||||
-- (nil = ask SDL for real). Test seam for rotateForScreen below.
|
||||
function Sensors.setOrientationOverride(o)
|
||||
orientationOverride = o
|
||||
end
|
||||
|
||||
-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today).
|
||||
local function safeHasSensor(kind)
|
||||
if not love or not love.sensor then return false end
|
||||
local ok, has = pcall(love.sensor.hasSensor, kind)
|
||||
return ok and has == true
|
||||
end
|
||||
|
||||
local function loveSensorRead(kind)
|
||||
if not safeHasSensor(kind) then return nil end
|
||||
local ok, x, y, z = pcall(love.sensor.getData, kind)
|
||||
if not ok then return nil end
|
||||
return x or 0, y or 0, z or 0
|
||||
end
|
||||
|
||||
-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5).
|
||||
local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2
|
||||
local SDL_INIT_SENSOR = 0x00008000
|
||||
local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO }
|
||||
|
||||
local sdlCdefOk = nil
|
||||
local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent)
|
||||
local sdlSensorTried = {}
|
||||
|
||||
-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library,
|
||||
-- then bare ffi.C, needed on Android where love-android links SDL2 statically
|
||||
-- into libmain.so and there is no standalone libSDL2.so to attach to by name
|
||||
-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716).
|
||||
local sdlLib = nil
|
||||
local function sdlFfi()
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi then return nil end
|
||||
if sdlCdefOk == nil then
|
||||
sdlCdefOk = pcall(ffi.cdef, [[
|
||||
typedef struct SDL_Window SDL_Window;
|
||||
typedef struct SDL_Sensor SDL_Sensor;
|
||||
int SDL_InitSubSystem(unsigned int flags);
|
||||
int SDL_NumSensors(void);
|
||||
int SDL_SensorGetDeviceType(int device_index);
|
||||
SDL_Sensor *SDL_SensorOpen(int device_index);
|
||||
void SDL_SensorUpdate(void);
|
||||
int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values);
|
||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||
int SDL_GetWindowDisplayIndex(SDL_Window *window);
|
||||
int SDL_GetDisplayOrientation(int displayIndex);
|
||||
]])
|
||||
if sdlCdefOk then
|
||||
local okLoad, lib = pcall(ffi.load, "SDL2")
|
||||
sdlLib = okLoad and lib or ffi.C
|
||||
end
|
||||
end
|
||||
if not sdlCdefOk or not sdlLib then return nil end
|
||||
return sdlLib
|
||||
end
|
||||
|
||||
-- Opens and caches once, and returns nil permanently once a real attempt finds
|
||||
-- none, so a no-hardware desktop run pays this cost once rather than per frame.
|
||||
local function sdlSensorHandle(lib, kind)
|
||||
if sdlSensorTried[kind] then return sdlSensorHandles[kind] end
|
||||
sdlSensorTried[kind] = true
|
||||
local wantType = KIND_TO_SDL_TYPE[kind]
|
||||
if not wantType then return nil end
|
||||
local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR)
|
||||
if not okInit then return nil end
|
||||
local okCount, count = pcall(lib.SDL_NumSensors)
|
||||
if not okCount or count <= 0 then return nil end
|
||||
for i = 0, count - 1 do
|
||||
local okType, t = pcall(lib.SDL_SensorGetDeviceType, i)
|
||||
if okType and t == wantType then
|
||||
local okOpen, handle = pcall(lib.SDL_SensorOpen, i)
|
||||
if okOpen and handle ~= nil then
|
||||
sdlSensorHandles[kind] = handle
|
||||
return handle
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function sdlSensorRead(kind)
|
||||
local lib = sdlFfi()
|
||||
if not lib then return nil end
|
||||
local handle = sdlSensorHandle(lib, kind)
|
||||
if not handle then return nil end
|
||||
pcall(lib.SDL_SensorUpdate)
|
||||
local data = require("ffi").new("float[3]")
|
||||
local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3)
|
||||
if not okGet or n ~= 0 then return nil end
|
||||
return data[0], data[1], data[2]
|
||||
end
|
||||
|
||||
-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen
|
||||
-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is
|
||||
-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation.
|
||||
local function isMobile()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local os = love.system.getOS()
|
||||
return os == "Android" or os == "iOS"
|
||||
end
|
||||
|
||||
local function sdlOrientation()
|
||||
if not isMobile() then return nil end
|
||||
local lib = sdlFfi()
|
||||
if not lib then return nil end
|
||||
local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow)
|
||||
if not okWin or win == nil then return nil end
|
||||
local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win)
|
||||
if not okIdx or idx < 0 then return nil end
|
||||
local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx)
|
||||
if not okOr then return nil end
|
||||
-- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED,
|
||||
-- 3=PORTRAIT, 4=PORTRAIT_FLIPPED.
|
||||
if o == 1 then return "landscape"
|
||||
elseif o == 2 then return "landscapeFlipped"
|
||||
elseif o == 3 then return "portrait"
|
||||
elseif o == 4 then return "portraitFlipped"
|
||||
else return nil end
|
||||
end
|
||||
|
||||
-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait,
|
||||
-- and unknown/undetectable (desktop always lands here), is a no-op.
|
||||
local function rotateForScreen(x, y)
|
||||
local o = orientationOverride or sdlOrientation()
|
||||
if o == "landscape" then return -y, x
|
||||
elseif o == "landscapeFlipped" then return y, -x
|
||||
elseif o == "portraitFlipped" then return -x, -y
|
||||
else return x, y end
|
||||
end
|
||||
|
||||
-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden
|
||||
-- that way, no hardware/module is available, or an underlying call errors.
|
||||
-- x/y are rotated to be screen-relative; z passes through unchanged.
|
||||
function Sensors.read(kind)
|
||||
local override = overrides[kind]
|
||||
if override then
|
||||
local x, y = rotateForScreen(override[1], override[2])
|
||||
return x, y, override[3]
|
||||
end
|
||||
|
||||
local x, y, z = loveSensorRead(kind)
|
||||
if not x then x, y, z = sdlSensorRead(kind) end
|
||||
if not x then return 0, 0, 0 end
|
||||
|
||||
x, y = rotateForScreen(x, y)
|
||||
return x, y, z
|
||||
end
|
||||
|
||||
return Sensors
|
||||
@@ -363,6 +363,14 @@ function Sound.sfxBusy()
|
||||
return true
|
||||
end
|
||||
|
||||
-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound`
|
||||
-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483).
|
||||
function Sound.waitSfxDone()
|
||||
if not curSfx then return end
|
||||
pcall(curSfx.src.stop, curSfx.src)
|
||||
curSfx = nil
|
||||
end
|
||||
|
||||
local function startSfx(data, name, def)
|
||||
local src = playPath(data, name, def)
|
||||
if not src then return end
|
||||
|
||||
+12
-1
@@ -109,6 +109,17 @@ function Boxes.restorePP(mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so
|
||||
-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83).
|
||||
function Boxes.enterBox(mon)
|
||||
if not mon then return mon end
|
||||
Boxes.restorePP(mon)
|
||||
mon.status = nil
|
||||
mon.statusTurns = nil
|
||||
mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp)
|
||||
return mon
|
||||
end
|
||||
|
||||
function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||
if not ok then return false, reason end
|
||||
@@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
box[#box + 1] = mon
|
||||
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
|
||||
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
|
||||
Boxes.restorePP(mon)
|
||||
Boxes.enterBox(mon)
|
||||
return true, mon
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -298,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
|
||||
@@ -309,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+324
-51
@@ -417,14 +417,27 @@ local function shellColor(hex)
|
||||
return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) }
|
||||
end
|
||||
|
||||
-- The real Crystal shell is glitter-flecked translucent plastic over a foil
|
||||
-- label, so it is the one stock cart that ships with a finish.
|
||||
local STOCK_FINISH = { crystal = "sparkle+holo" }
|
||||
|
||||
local function finishFlags(name)
|
||||
name = tostring(name or "")
|
||||
return name:find("sparkle", 1, true) ~= nil, name:find("holo", 1, true) ~= nil
|
||||
end
|
||||
|
||||
local function cartSkin(imp, version)
|
||||
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
|
||||
if not row then
|
||||
local sparkle, holo = finishFlags(STOCK_FINISH[version])
|
||||
return { cacheKey = version, color = cartColor(version),
|
||||
sparkle = sparkle, holo = holo,
|
||||
labelPath = "assets/labels/" .. tostring(version) .. ".png" }
|
||||
end
|
||||
local sparkle, holo = finishFlags(row.finish)
|
||||
return { cacheKey = "cart:" .. tostring(row.id),
|
||||
color = shellColor(row.shell) or cartColor(version),
|
||||
sparkle = sparkle, holo = holo,
|
||||
name = row.title, cart = row, cartId = row.id }
|
||||
end
|
||||
|
||||
@@ -572,8 +585,56 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
#endif
|
||||
|
||||
#ifdef PIXEL
|
||||
// finish: 0 plain, 1 sparkle (glitter suspended in the shell), 2 holographic
|
||||
// label sweep. Both are ours, not ported from anywhere.
|
||||
extern float finish;
|
||||
extern float finish_time;
|
||||
extern float finish_spin;
|
||||
|
||||
float sparkHash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(41.7321, 289.113))) * 43758.5453);
|
||||
}
|
||||
|
||||
// Flecks live on a jittered lattice so they read as suspended grains rather
|
||||
// than a regular grid, and each one twinkles on its own phase.
|
||||
vec3 sparkle(vec2 screen_coords) {
|
||||
vec2 cell = screen_coords / 19.0;
|
||||
vec2 id = floor(cell);
|
||||
vec2 f = fract(cell);
|
||||
float peak = 0.0;
|
||||
for (int oy = -1; oy <= 1; oy++) {
|
||||
for (int ox = -1; ox <= 1; ox++) {
|
||||
vec2 n = vec2(float(ox), float(oy));
|
||||
vec2 h = vec2(sparkHash(id + n), sparkHash(id + n + 17.0));
|
||||
float phase = sparkHash(id + n + 71.0) * 6.2831;
|
||||
float tw = sin(finish_time * 2.6 + phase + finish_spin * 3.0) * 0.5 + 0.5;
|
||||
float d = length(f - (n + h));
|
||||
peak = max(peak, smoothstep(0.13, 0.0, d) * pow(tw, 16.0));
|
||||
}
|
||||
}
|
||||
return vec3(peak);
|
||||
}
|
||||
|
||||
// Angle-dependent spectral sweep: a diagonal band whose hue walks with view
|
||||
// angle, so tilting the cart moves the rainbow the way real foil does.
|
||||
vec3 holo(vec2 uv) {
|
||||
float band = uv.x * 0.9 + uv.y * 0.6 + finish_spin * 0.55 + finish_time * 0.06;
|
||||
vec3 hue = 0.5 + 0.5 * cos(6.2831 * (band + vec3(0.0, 0.33, 0.67)));
|
||||
float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_time * 0.9);
|
||||
return hue * (0.55 + 0.45 * interference);
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) {
|
||||
return Texel(tex, texture_coords) * color;
|
||||
vec4 px = Texel(tex, texture_coords) * color;
|
||||
if (finish > 1.5) {
|
||||
vec3 sheen = holo(texture_coords);
|
||||
float lum = dot(px.rgb, vec3(0.299, 0.587, 0.114));
|
||||
px.rgb = mix(px.rgb, px.rgb * (0.65 + sheen), 0.30 * (0.35 + 0.65 * lum));
|
||||
px.rgb += sheen * 0.05 * px.a;
|
||||
} else if (finish > 0.5) {
|
||||
px.rgb += sparkle(screen_coords) * 0.70 * px.a;
|
||||
}
|
||||
return px;
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
@@ -601,6 +662,18 @@ local function cartSendHover(shader, mx, my, hovering, screenScale)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- FINISH_* match the shader's `finish` uniform.
|
||||
local FINISH_NONE, FINISH_SPARKLE, FINISH_HOLO = 0, 1, 2
|
||||
|
||||
local function cartSendFinish(shader, mode, spin)
|
||||
if not shader or not shader.send then return end
|
||||
pcall(function()
|
||||
shader:send("finish", mode)
|
||||
shader:send("finish_time", Kit.time or 0)
|
||||
shader:send("finish_spin", spin or 0)
|
||||
end)
|
||||
end
|
||||
|
||||
local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
|
||||
local state = cartridgeState(imp, skin.cacheKey)
|
||||
markNoDrag(imp, x, y, w, h)
|
||||
@@ -723,6 +796,10 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
|
||||
love.graphics.rotate(juiceR * 2)
|
||||
love.graphics.translate(-cx, -cy)
|
||||
if useHover then love.graphics.setShader(shader) end
|
||||
if useHover then
|
||||
cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE,
|
||||
state.spin)
|
||||
end
|
||||
|
||||
local capH = h * 3 / 65
|
||||
local mainTop = -halfH + capH
|
||||
@@ -837,6 +914,9 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
|
||||
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
|
||||
local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId)
|
||||
local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints)
|
||||
if useHover and skin.holo then
|
||||
cartSendFinish(shader, FINISH_HOLO, state.spin)
|
||||
end
|
||||
if mesh then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(mesh)
|
||||
@@ -845,6 +925,10 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
|
||||
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
|
||||
0, artScale, artScale)
|
||||
end
|
||||
if useHover and skin.holo then
|
||||
cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE,
|
||||
state.spin)
|
||||
end
|
||||
cartPolygon({
|
||||
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
||||
@@ -863,6 +947,8 @@ local function modStatusColor(status)
|
||||
if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end
|
||||
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
-- a cart pins it, but nothing on this install provides it
|
||||
if status == "missing" then return Strings("Not installed"), PAL.red end
|
||||
-- not a fault: the mod is intact, this is simply not a game it is for
|
||||
-- (src/mods/ModTargets.lua)
|
||||
if status == "other_game" then return Strings("Not for this game"), PAL.muted end
|
||||
@@ -971,6 +1057,28 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
return h + math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
-- Says whose mod list is on screen when a cart owns it, and what its seal
|
||||
-- lets the player do with it.
|
||||
local function buildModCartRow(imp, x, y, w, m, cartId, report)
|
||||
if not cartId then return 0 end
|
||||
local title = (report and report.title) or cartId
|
||||
local seal = (report and report.seal) or "sealed"
|
||||
local line
|
||||
if seal == "sealed+" then
|
||||
line = Strings("%s pins these mods. You may switch any of them on or off, but not add or remove any.",
|
||||
title)
|
||||
elseif seal == "open" then
|
||||
line = Strings("%s pins these mods. Anything it ships switched off is yours to switch on.",
|
||||
title)
|
||||
else
|
||||
line = Strings("%s is sealed: these are its mods, and they run exactly as pinned. Break the seal on the cart's own page to change that.",
|
||||
title)
|
||||
end
|
||||
local h = Kit.textWrapped("small", line, x, y, w,
|
||||
seal == "sealed" and PAL.yellow or PAL.blue, 3)
|
||||
return h + math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
local function buildSaveCartRow(imp, x, y, w, m)
|
||||
local version = imp.modScope
|
||||
local h = m.btnH
|
||||
@@ -1000,6 +1108,12 @@ local function buildSaveCartRow(imp, x, y, w, m)
|
||||
return h + gap
|
||||
end
|
||||
|
||||
-- The launcher's name for a game id, where all a row has is the id.
|
||||
local function gameLabel(version)
|
||||
local info = GameVersion.info(version)
|
||||
return (info and (info.launcherName or info.displayName)) or tostring(version)
|
||||
end
|
||||
|
||||
local function findActionFor(entry, installedVersion)
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
if not ModIndex.canInstall(entry) then
|
||||
@@ -1837,6 +1951,11 @@ local function SEAL_LABEL(armed)
|
||||
return armed and Strings("Break it") or Strings("Break the seal")
|
||||
end
|
||||
|
||||
local function FILL_LABEL(count)
|
||||
if count > 1 then return Strings("Install %d required mods", count) end
|
||||
return Strings("Install required mods")
|
||||
end
|
||||
|
||||
local function sealSlotName(slot)
|
||||
if not slot then return nil end
|
||||
if type(slot.label) == "string" and slot.label ~= "" then return slot.label end
|
||||
@@ -1849,16 +1968,25 @@ local function buildCartCard(imp, x, y, w, m, version)
|
||||
if not report then return 0 end
|
||||
local title = tostring(report.title or report.id or "")
|
||||
local broken = (slot and slot.sealBroken == true) or false
|
||||
local fillCount = imp.cartFillRows and #imp:cartFillRows(version) or 0
|
||||
local state, stateCol, body = nil, PAL.green, {}
|
||||
if report.refused then
|
||||
state, stateCol = Strings("This cart will not start"), PAL.red
|
||||
body[#body + 1] = { report.message, PAL.detail }
|
||||
if fillCount > 0 then
|
||||
body[#body + 1] = { Strings("Install the mods it pins to play it the way its author built it."),
|
||||
PAL.detail }
|
||||
end
|
||||
body[#body + 1] = { Strings("Break the seal to play it with the mods you have."),
|
||||
PAL.detail }
|
||||
elseif broken then
|
||||
state, stateCol = Strings("Seal broken"), PAL.yellow
|
||||
body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."),
|
||||
PAL.detail }
|
||||
elseif report.seal == "sealed+" then
|
||||
state = Strings("Sealed - ready to play")
|
||||
body[#body + 1] = { Strings("This cart loads only the mods it pins. You can switch any of them on or off."),
|
||||
PAL.detail }
|
||||
elseif report.sealed then
|
||||
state = Strings("Sealed - ready to play")
|
||||
body[#body + 1] = { Strings("This cart loads only the mods it pins."),
|
||||
@@ -1883,15 +2011,41 @@ local function buildCartCard(imp, x, y, w, m, version)
|
||||
PAL.yellow }
|
||||
body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow }
|
||||
end
|
||||
-- What the last install run managed, and per mod what it could not.
|
||||
local fillNotice = imp.cartFillNotice
|
||||
if fillNotice then
|
||||
body[#body + 1] = { fillNotice.text,
|
||||
fillNotice.ok and PAL.green or PAL.red }
|
||||
for _, line in ipairs(fillNotice.failures or {}) do
|
||||
body[#body + 1] = { line, PAL.red }
|
||||
end
|
||||
end
|
||||
|
||||
local pad = math.floor(14 * m.s)
|
||||
local iw = w - 2 * pad
|
||||
local chipH = offer and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
|
||||
local fillLabel = FILL_LABEL(fillCount)
|
||||
local chipGap = math.floor(8 * m.s)
|
||||
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local fillW = fillCount > 0
|
||||
and math.min(iw, chipWidth(fillLabel, m)) or 0
|
||||
local sealW = offer and math.min(iw, math.max(chipWidth(SEAL_LABEL(false), m),
|
||||
chipWidth(SEAL_LABEL(true), m))) or 0
|
||||
-- Both chips share a row when they fit; a narrow card stacks them instead.
|
||||
local sideBySide = fillW > 0 and sealW > 0
|
||||
and (fillW + chipGap + sealW) <= iw
|
||||
local chipRows = 0
|
||||
if fillW > 0 then chipRows = chipRows + 1 end
|
||||
if sealW > 0 then chipRows = chipRows + 1 end
|
||||
if sideBySide then chipRows = 1 end
|
||||
if chipRows == 0 then chipH = 0 end
|
||||
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
for _, line in ipairs(body) do
|
||||
h = h + Kit.wrapHeight("small", line[1] or "", iw, 3)
|
||||
end
|
||||
if offer then h = h + math.floor(8 * m.s) + chipH end
|
||||
if chipRows > 0 then
|
||||
h = h + math.floor(8 * m.s) + chipRows * chipH + (chipRows - 1) * chipGap
|
||||
end
|
||||
h = h + pad
|
||||
|
||||
Kit.card(x, y, w, h)
|
||||
@@ -1902,14 +2056,23 @@ local function buildCartCard(imp, x, y, w, m, version)
|
||||
cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw,
|
||||
line[2], 3)
|
||||
end
|
||||
if offer then
|
||||
if chipRows > 0 then
|
||||
cy = cy + math.floor(8 * m.s)
|
||||
local cw = math.max(chipWidth(SEAL_LABEL(false), m),
|
||||
chipWidth(SEAL_LABEL(true), m))
|
||||
btn(imp, x + pad, cy, cw, chipH, "seal-" .. scope, SEAL_LABEL(armed), {
|
||||
kind = "danger", font = "small", keepArm = true,
|
||||
action = function() imp:pressBreakSeal(version) end,
|
||||
})
|
||||
local bx = x + pad
|
||||
if fillW > 0 then
|
||||
btn(imp, bx, cy, fillW, chipH, "cart-fill-" .. scope, fillLabel, {
|
||||
kind = "primary", font = "small",
|
||||
action = function() imp:pressInstallCartMods(version) end,
|
||||
})
|
||||
if sideBySide then bx = bx + fillW + chipGap
|
||||
else cy = cy + chipH + chipGap end
|
||||
end
|
||||
if sealW > 0 then
|
||||
btn(imp, bx, cy, sealW, chipH, "seal-" .. scope, SEAL_LABEL(armed), {
|
||||
kind = "danger", font = "small", keepArm = true,
|
||||
action = function() imp:pressBreakSeal(version) end,
|
||||
})
|
||||
end
|
||||
end
|
||||
return h
|
||||
end
|
||||
@@ -2182,6 +2345,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local mods = imp.mods or {}
|
||||
local gap = m.gap
|
||||
local cy = y
|
||||
local cartId, cartReport
|
||||
if imp.modCartPlan then cartId, cartReport = imp:modCartPlan() end
|
||||
-- a cart owns its mod set: only the pins it already ships may be switched
|
||||
local bulkOk = not safeMode and cartId == nil
|
||||
|
||||
-- header: progressive action cluster. Surfaces primary/frequent actions
|
||||
-- (Import, Updates, Sort) directly on the bar across screen sizes, placing
|
||||
@@ -2210,11 +2377,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
action = function() imp:chooseMod() end })
|
||||
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
|
||||
kind = "warn", font = "small",
|
||||
enabled = not safeMode,
|
||||
enabled = bulkOk,
|
||||
action = function() imp:_setAllMods(false) end })
|
||||
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
|
||||
kind = "good", font = "small",
|
||||
enabled = not safeMode,
|
||||
enabled = bulkOk,
|
||||
action = function() imp:_setAllMods(true) end })
|
||||
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
|
||||
font = "small",
|
||||
@@ -2275,6 +2442,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
+ math.floor(8 * m.s)
|
||||
|
||||
cy = cy + buildModScopeRow(imp, x, cy, w, m)
|
||||
cy = cy + buildModCartRow(imp, x, cy, w, m, cartId, cartReport)
|
||||
cy = cy + buildSaveCartRow(imp, x, cy, w, m)
|
||||
|
||||
if #mods == 0 then
|
||||
@@ -2368,22 +2536,40 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
|
||||
-- These answer separate games, not a single shared install flag. The
|
||||
-- importer receives the game id so an experimental confirmation also
|
||||
-- applies only to the checkbox the player pressed.
|
||||
-- applies only to the checkbox the player pressed. A cart's pin answers
|
||||
-- one game -- the cart's -- so it gets one switch instead of the row.
|
||||
local flipped = false
|
||||
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
|
||||
Kit.text("micro", gamesLabel, px,
|
||||
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
|
||||
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
|
||||
if modGameCheckbox(tx, gamesY, togH,
|
||||
mod.enabledByVersion and mod.enabledByVersion[game] == true,
|
||||
game, togKey, not safeMode) then
|
||||
local version = game
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
|
||||
local rowLabel = gamesLabel
|
||||
if mod.cartPin then
|
||||
rowLabel = mod.cartTogglable and Strings("In this cart:")
|
||||
or Strings("Pinned, sealed:")
|
||||
end
|
||||
Kit.text("micro", rowLabel, px,
|
||||
gamesY + (togH - Kit.textHeight("micro")) / 2,
|
||||
mod.cartPin and not mod.cartTogglable and PAL.yellow or PAL.muted)
|
||||
local tx = px + Kit.textWidth("micro", rowLabel) + math.floor(10 * m.s)
|
||||
if mod.cartPin then
|
||||
local togKey = "mod-toggle-" .. mod.id .. "-cart"
|
||||
-- pressable even when the seal refuses it, so the panel can say why
|
||||
if modGameCheckbox(tx, gamesY, togH, mod.enabled == true,
|
||||
mod.cartBase or "red", togKey, not safeMode) then
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, nil) end)
|
||||
flipped = true
|
||||
end
|
||||
tx = tx + togH + togGap
|
||||
else
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
|
||||
if modGameCheckbox(tx, gamesY, togH,
|
||||
mod.enabledByVersion and mod.enabledByVersion[game] == true,
|
||||
game, togKey, not safeMode) then
|
||||
local version = game
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
|
||||
flipped = true
|
||||
end
|
||||
tx = tx + togH + togGap
|
||||
end
|
||||
end
|
||||
-- The checkboxes sit inside the row's rect, so their press also passes the
|
||||
-- row hit test; `flipped` gates the row action to everywhere else.
|
||||
@@ -2399,17 +2585,27 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
-- in-game manager shows (src/mods/ModTargets.lua)
|
||||
local gamesW = mod.targets
|
||||
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
|
||||
-- the cart's own list, not the player's: every row says so
|
||||
local pinLabel = mod.cartPin and Strings("PINNED") or nil
|
||||
local pinW = pinLabel
|
||||
and Kit.textWidth("micro", pinLabel) + math.floor(12 * m.s) or 0
|
||||
local nameShown = Kit.ellipsize("button", mod.name,
|
||||
textW - badgeW - gamesW - math.floor(12 * m.s))
|
||||
textW - badgeW - gamesW - pinW - math.floor(12 * m.s))
|
||||
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
|
||||
Kit.text("button", nameShown, px, ly, headingCol)
|
||||
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
|
||||
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
|
||||
mod.experimental and PAL.yellow or PAL.muted)
|
||||
tagX = tagX + badgeW + math.floor(4 * m.s)
|
||||
if mod.targets then
|
||||
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
|
||||
Kit.tag(tagX, ly, gamesW,
|
||||
Kit.textHeight("button"), mod.targets,
|
||||
mod.targetsHere == false and PAL.steel or PAL.blue)
|
||||
tagX = tagX + gamesW + math.floor(4 * m.s)
|
||||
end
|
||||
if pinLabel then
|
||||
Kit.tag(tagX, ly, pinW, Kit.textHeight("button"), pinLabel,
|
||||
mod.cartTogglable and PAL.blue or PAL.yellow)
|
||||
end
|
||||
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
|
||||
@@ -2830,14 +3026,45 @@ local function buildBugPanel(imp, x, y, w, availH, m)
|
||||
end })
|
||||
end
|
||||
|
||||
-- FIND tab: which half of the feed is on screen, in the same chip idiom the
|
||||
-- MODS tab's scope row uses. The counts say which half is worth pressing.
|
||||
local function buildFindKindRow(imp, x, y, w, m)
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Browse:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = {
|
||||
{ id = "mods", label = Strings("Mods"),
|
||||
n = #((imp.findIndex and imp.findIndex.mods) or {}) },
|
||||
{ id = "carts", label = Strings("Carts"),
|
||||
n = #((imp.findIndex and imp.findIndex.carts) or {}) },
|
||||
}
|
||||
for _, opt in ipairs(options) do
|
||||
local text = ("%s (%d)"):format(opt.label, opt.n)
|
||||
local cw = Kit.textWidth("micro", text) + math.floor(18 * m.s)
|
||||
if cx + cw > x + w then break end
|
||||
if Kit.chip(cx, y, cw, h, text, imp.findKind == opt.id, PAL.lineStrong,
|
||||
"find-kind-" .. opt.id) then
|
||||
local want = opt.id
|
||||
queueAction(imp, "find-kind-" .. want,
|
||||
function() imp:_setFindKind(want) end)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
imp:_ensureFind()
|
||||
imp:_ensureMods()
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local sources = imp.findSources or {}
|
||||
local carts = imp.findKind == "carts"
|
||||
local rows = imp:_findRows()
|
||||
local total = #((imp.findIndex and imp.findIndex.mods) or {})
|
||||
local total = #((imp.findIndex
|
||||
and (carts and imp.findIndex.carts or imp.findIndex.mods)) or {})
|
||||
local gap = m.gap
|
||||
local cy = y
|
||||
|
||||
@@ -2868,6 +3095,8 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
return (cy - y) + h
|
||||
end
|
||||
|
||||
cy = cy + buildFindKindRow(imp, x, cy, w, m)
|
||||
|
||||
-- One row: the search field, then Filter / Sort / Indexes popup buttons.
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local bgap = math.floor(6 * m.s)
|
||||
@@ -2880,22 +3109,31 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
|
||||
font = "small",
|
||||
action = function() imp._sortPopup = "find" end })
|
||||
-- The Filter button carries its state: blue while a category is active,
|
||||
-- so a filtered-down list never reads as "the index shrank".
|
||||
-- The Filter button carries its state: blue while a category (mods) or a
|
||||
-- base game (carts) is active, so a filtered-down list never reads as "the
|
||||
-- index shrank".
|
||||
local activeFilter = carts and imp.findBase or imp.findCategory
|
||||
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
||||
btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), {
|
||||
kind = imp.findCategory and "accent" or "ghost", font = "small",
|
||||
kind = activeFilter and "accent" or "ghost", font = "small",
|
||||
action = function() imp._filterPopup = true end })
|
||||
local searchW = place(0) - x - bgap
|
||||
textField(imp, x, cy, searchW, fieldH, "find-search", imp.findQuery or "",
|
||||
Strings("Search mods"), imp._findSearchFocus == true,
|
||||
carts and Strings("Search carts") or Strings("Search mods"),
|
||||
imp._findSearchFocus == true,
|
||||
function() imp:_toggleFindSearchFocus() end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
|
||||
if #rows == 0 then
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s),
|
||||
(total == 0) and Strings("This index lists no mods yet.")
|
||||
or Strings("No mods match that search."))
|
||||
local empty
|
||||
if carts then
|
||||
empty = (total == 0) and Strings("This index lists no carts yet.")
|
||||
or Strings("No carts match that search.")
|
||||
else
|
||||
empty = (total == 0) and Strings("This index lists no mods yet.")
|
||||
or Strings("No mods match that search.")
|
||||
end
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), empty)
|
||||
return (cy - y) + math.floor(110 * m.s)
|
||||
end
|
||||
|
||||
@@ -2995,7 +3233,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
if imp:_findThumbPending(entry.id) then
|
||||
Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28)
|
||||
else
|
||||
Kit.textCenter("micro", "MOD", px,
|
||||
Kit.textCenter("micro", carts and "CART" or "MOD", px,
|
||||
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
||||
end
|
||||
end
|
||||
@@ -3018,7 +3256,12 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
or nil
|
||||
local rest = {}
|
||||
if entry.author then rest[#rest + 1] = entry.author end
|
||||
if entry.categories and entry.categories[1] then
|
||||
if carts then
|
||||
-- A cart has no categories; the game it plays as and its seal are what
|
||||
-- a reader is actually choosing between.
|
||||
rest[#rest + 1] = gameLabel(entry.base)
|
||||
if entry.seal then rest[#rest + 1] = entry.seal end
|
||||
elseif entry.categories and entry.categories[1] then
|
||||
rest[#rest + 1] = entry.categories[1]
|
||||
end
|
||||
if dates then
|
||||
@@ -3300,6 +3543,8 @@ local function buildConfirmModal(imp, m)
|
||||
imp._modConfirm = nil
|
||||
if c.indexEntry then
|
||||
imp:_findInstall(c.indexEntry)
|
||||
elseif c.kind == "cartPins" then
|
||||
imp:_installCartPins(c.version, c.id)
|
||||
elseif c.kind == "update" then
|
||||
imp:_confirmModUpdate(c.id, c.release)
|
||||
elseif c.kind == "enableAll" then
|
||||
@@ -3624,12 +3869,15 @@ local function buildModHeaderActionsModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(380 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
-- same gate as the header cluster: a cart's mod set is not bulk-editable
|
||||
local bulkOk = not imp.safeMode
|
||||
and not (imp.modCartPlan and imp:modCartPlan())
|
||||
local btns = {
|
||||
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
|
||||
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
||||
{ label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode,
|
||||
{ label = Strings("Enable all mods"), kind = "good", enabled = bulkOk,
|
||||
action = function() imp:_setAllMods(true) end },
|
||||
{ label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode,
|
||||
{ label = Strings("Disable all mods"), kind = "warn", enabled = bulkOk,
|
||||
action = function() imp:_setAllMods(false) end },
|
||||
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
|
||||
}
|
||||
@@ -3760,8 +4008,10 @@ local function buildGameModal(imp, m)
|
||||
action = function() imp._gamePopup = nil end })
|
||||
end
|
||||
|
||||
local SEAL_WORD = { open = "open", ["sealed+"] = "sealed+" }
|
||||
|
||||
local function cartRowLabel(row)
|
||||
local seal = (row.seal == "open") and Strings("open") or Strings("sealed")
|
||||
local seal = Strings(SEAL_WORD[row.seal] or "sealed")
|
||||
return Strings("%s - v%s - %s", tostring(row.title or row.id),
|
||||
tostring(row.version or "?"), seal)
|
||||
end
|
||||
@@ -3828,11 +4078,12 @@ local function buildCartModal(imp, m)
|
||||
Kit.pager(px + pad, cy, pw - 2 * pad, cur, #rows, perPage, pageKey))
|
||||
cy = cy + pagerH + gap
|
||||
end
|
||||
local FilePicker = require("src.core.FilePicker")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-more",
|
||||
Strings("Get more carts"), { kind = "accent", font = "small",
|
||||
action = function()
|
||||
imp._cartNotice = Strings("Browsing for carts arrives in a later update.")
|
||||
end })
|
||||
FilePicker.available() and Strings("Import a cart")
|
||||
or Strings("Get more carts"),
|
||||
{ kind = "accent", font = "small",
|
||||
action = function() imp:importCartFile(version) end })
|
||||
cy = cy + rowH + gap
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
@@ -3949,12 +4200,17 @@ local function buildCartSaveModal(imp, m)
|
||||
PAL.muted)
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
-- Category filter for FIND MODS, base-game filter for FIND CARTS (a cart has
|
||||
-- no categories). Two columns, because an index can list enough categories
|
||||
-- to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
local cats = (imp.findIndex and imp.findIndex.categories) or {}
|
||||
local carts = imp.findKind == "carts"
|
||||
local keys = (imp.findIndex
|
||||
and (carts and imp.findIndex.baseGames or imp.findIndex.categories)) or {}
|
||||
local items = { { key = nil, label = Strings("All") } }
|
||||
for _, c in ipairs(cats) do items[#items + 1] = { key = c, label = c } end
|
||||
for _, c in ipairs(keys) do
|
||||
items[#items + 1] = { key = c, label = carts and gameLabel(c) or c }
|
||||
end
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
@@ -3963,18 +4219,20 @@ local function buildFilterModal(imp, m)
|
||||
+ nrows * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Filter by category"), px + pad, cy, PAL.heading)
|
||||
Kit.text("button", carts and Strings("Filter by base game")
|
||||
or Strings("Filter by category"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
local colW = math.floor((pw - 2 * pad - gap) / 2)
|
||||
local active = carts and imp.findBase or imp.findCategory
|
||||
for i, it in ipairs(items) do
|
||||
local bx = px + pad + ((i - 1) % 2) * (colW + gap)
|
||||
local by = cy + math.floor((i - 1) / 2) * (m.btnH + gap)
|
||||
local key = it.key
|
||||
btn(imp, bx, by, colW, m.btnH, "filterpop-" .. (key or "all"), it.label, {
|
||||
kind = (imp.findCategory == key) and "primary" or "ghost",
|
||||
kind = (active == key) and "primary" or "ghost",
|
||||
font = "small",
|
||||
action = function()
|
||||
imp.findCategory = key
|
||||
if carts then imp.findBase = key else imp.findCategory = key end
|
||||
setPage(imp, "find", 1)
|
||||
imp._filterPopup = nil
|
||||
end })
|
||||
@@ -4332,8 +4590,14 @@ local function buildFindEntryModal(imp, m)
|
||||
end
|
||||
trend = (#trend > 0) and table.concat(trend, " - ") or nil
|
||||
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
|
||||
-- What a cart actually is: a pinned mod list. Its own page installs them;
|
||||
-- this popup only ever installs the cart file.
|
||||
local pins = ModIndex.isCart(entry)
|
||||
and Strings("Pins %d mod(s) - install them from the cart's own page",
|
||||
#(entry.mods or {})) or nil
|
||||
local pinsH = pins and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
|
||||
+ Kit.textHeight("small") + trendH + pinsH + noteH + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
@@ -4342,7 +4606,10 @@ local function buildFindEntryModal(imp, m)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
||||
if entry.author then lead = lead .. " - " .. entry.author end
|
||||
if entry.categories and entry.categories[1] then
|
||||
if ModIndex.isCart(entry) then
|
||||
lead = lead .. " - " .. gameLabel(entry.base)
|
||||
if entry.seal then lead = lead .. " - " .. entry.seal end
|
||||
elseif entry.categories and entry.categories[1] then
|
||||
lead = lead .. " - " .. entry.categories[1]
|
||||
end
|
||||
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
|
||||
@@ -4359,6 +4626,12 @@ local function buildFindEntryModal(imp, m)
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small")
|
||||
end
|
||||
if pins then
|
||||
cy = cy + math.floor(2 * m.s)
|
||||
Kit.text("small", Kit.ellipsize("small", pins, pw - 2 * pad),
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small")
|
||||
end
|
||||
if note then
|
||||
cy = cy + math.floor(4 * m.s)
|
||||
Kit.text("small", note, px + pad, cy, PAL.green)
|
||||
|
||||
@@ -1386,11 +1386,9 @@ end
|
||||
-- tiles, no sheet of its own. So the rows here point at the icon sheets
|
||||
-- extractIcons already writes rather than at a second copy of them.
|
||||
--
|
||||
-- One frame, not two. _DoesSpriteHaveFacings sends everything from
|
||||
-- SPRITE_POKEMON up to .only_down, and a doll is a still object that never
|
||||
-- steps, so the only OAM set it ever uses is FacingStepDown0 -- tiles $00..$03,
|
||||
-- the icon's FIRST frame (data/sprites/facings.asm). The second frame is the
|
||||
-- party menu's bob and never reaches the map.
|
||||
-- Two frames: SPRITEMOVEDATA_POKEMON's OBJECT_ACTION_BOUNCE swaps
|
||||
-- FacingStepDown0's tiles $00..$03 for FacingStepUp0's $04..$07 (#1748).
|
||||
-- data/sprites/map_objects.asm:181-187, engine/overworld/map_object_action.asm:184
|
||||
--
|
||||
-- Palette 0 because _GetSpritePalette answers `xor a` for every mon sprite,
|
||||
-- which is PAL_OW_RED in the MapObjectPals set.
|
||||
@@ -1418,7 +1416,7 @@ function RomExtractorGen2:extractMonSprites(out)
|
||||
id = constName,
|
||||
source = ("ROM:SpriteMons[%d]"):format(row),
|
||||
image = "assets/generated/icons/gen2/" .. base .. ".png",
|
||||
frames = 1,
|
||||
frames = 2,
|
||||
walker = false,
|
||||
spriteType = "POKEMON_SPRITE",
|
||||
palette = SPRITE_PALETTE_NAME[0],
|
||||
@@ -4039,6 +4037,12 @@ function RomExtractorGen2:splashGfx()
|
||||
local fade = self:symbol("GameFreakDittoPaletteFade")
|
||||
out.dittoFade = self:colors(fade.bank, fade.address, 16)
|
||||
end
|
||||
-- gfx/splash/ditto.pal, the OBJ palette _CGB_GamefreakLogo loads
|
||||
-- (../pokecrystal/engine/gfx/cgb_layouts.asm:876-893).
|
||||
if self.symbols["_CGB_GamefreakLogo.GamefreakDittoPalette"] then
|
||||
local pal = self:symbol("_CGB_GamefreakLogo.GamefreakDittoPalette")
|
||||
out.dittoPalette = self:colors(pal.bank, pal.address, 4)
|
||||
end
|
||||
|
||||
return out
|
||||
end
|
||||
@@ -5844,6 +5848,21 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
8, 8, "emotes/grass_rustle.png", true)
|
||||
emotes.grassRustle = "assets/generated/emotes/grass_rustle.png"
|
||||
end
|
||||
-- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows
|
||||
-- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708).
|
||||
local fishing = self.symbols["FishingGFX"]
|
||||
if fishing then
|
||||
self:write2bpp(self.rom:bytes(fishing[1], fishing[2], 8 * 16),
|
||||
16, 32, "emotes/fishing.png", true)
|
||||
emotes.fishing = "assets/generated/emotes/fishing.png"
|
||||
end
|
||||
-- ../pokecrystal/engine/events/fishing_gfx.asm:41, Kris' half of that art.
|
||||
local krisFishing = self.symbols["KrisFishingGFX"]
|
||||
if krisFishing then
|
||||
self:write2bpp(self.rom:bytes(krisFishing[1], krisFishing[2], 8 * 16),
|
||||
16, 32, "emotes/fishing_female.png", true)
|
||||
emotes.fishingFemale = "assets/generated/emotes/fishing_female.png"
|
||||
end
|
||||
out.emotes = emotes
|
||||
|
||||
-- The Pokecenter heal machine's OBJ art (engine/events/heal_machine_anim
|
||||
|
||||
+719
-54
File diff suppressed because it is too large
Load Diff
@@ -1582,7 +1582,7 @@ local function buildPartyMenu()
|
||||
elseif opts.battle and opts.onSwitch then
|
||||
prompt, battleSubmenu = "choose", true
|
||||
elseif opts.pickOnly then
|
||||
prompt = "useItem"
|
||||
prompt = opts.itemUse and "useItem" or "choose"
|
||||
elseif opts.onSwitch then
|
||||
-- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself,
|
||||
-- so the field submenu must not swallow the press
|
||||
@@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
warned = "keepOpen tmhm",
|
||||
absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo "
|
||||
.. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch "
|
||||
.. "pickOnly forceSwitch",
|
||||
.. "pickOnly itemUse forceSwitch",
|
||||
notes = {
|
||||
new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts."
|
||||
.. "battle carries only its BOOLEAN sense and self.battle is left nil "
|
||||
@@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field "
|
||||
.. "Gen 2 never reads; pass it to .new instead",
|
||||
bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so "
|
||||
.. "a compare against \"Use on which one?\" will not match",
|
||||
.. "a compare against \"Use item on which\\nPOKéMON?\" will not match",
|
||||
["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold "
|
||||
.. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a "
|
||||
.. "BattleState",
|
||||
@@ -1838,7 +1838,7 @@ local function buildBattleState()
|
||||
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
|
||||
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
||||
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "uiSize",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
|
||||
"trainerSprite", "invalidate",
|
||||
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
||||
@@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = {
|
||||
backed = "update draw __index isOpaque openParty swapMoves "
|
||||
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
|
||||
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
|
||||
.. "tryRun wantsFillScale",
|
||||
.. "tryRun wantsFillScale bgMode",
|
||||
warned = "tryRun askNicknameUI",
|
||||
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
||||
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
||||
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
||||
.. "isWideBattleLayout wideLayout uiSize letterboxWhite "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
|
||||
.. "trainerTrueColor trainerSprite invalidate "
|
||||
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
||||
|
||||
+34
-6
@@ -1,6 +1,7 @@
|
||||
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")
|
||||
@@ -269,7 +270,7 @@ 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,
|
||||
@@ -369,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.
|
||||
@@ -436,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
|
||||
@@ -530,6 +538,15 @@ local function cartComplaints(report)
|
||||
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 = {},
|
||||
@@ -541,8 +558,8 @@ function Loader.planCart(cart, installed, broken)
|
||||
return report
|
||||
end
|
||||
report.id, report.title = cart.id, cart.title
|
||||
report.seal = cart.seal == "open" and "open" or "sealed"
|
||||
report.sealed = report.seal == "sealed"
|
||||
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 = {}
|
||||
@@ -599,9 +616,20 @@ function Loader:_applyCart()
|
||||
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
|
||||
if report.pins[id] then
|
||||
mod.enabled, mod.state = true, "pending"
|
||||
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
|
||||
|
||||
+143
-6
@@ -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
@@ -87,6 +87,7 @@ local function drain()
|
||||
j.status = msg.ok and "ok" or "error"
|
||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||
j.code = msg.code
|
||||
j.notModified = msg.notModified
|
||||
j.progress = msg.ok and 1 or j.progress
|
||||
end
|
||||
end
|
||||
@@ -154,12 +155,18 @@ end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
-- opts.etagRel (optional, relative to the save directory like saveRel itself)
|
||||
-- turns this into a conditional GET against a cached ETag at that path --
|
||||
-- see HostShell.httpDownload's own comment for the 304/notModified contract.
|
||||
-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a
|
||||
-- real download apart from a no-op cache hit (no file was written for the
|
||||
-- latter).
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "download", url = url, dest = saveRel,
|
||||
size = opts.size,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel })
|
||||
end
|
||||
|
||||
-- Non-blocking status. Returns a table; never nil, even for an unknown id
|
||||
|
||||
@@ -73,6 +73,12 @@ end
|
||||
-- inside the call. Where the caller knows the expected size we poll the
|
||||
-- growing file from a second pass instead; where it does not, the job simply
|
||||
-- reports indeterminate progress and the UI shows a spinner.
|
||||
--
|
||||
-- job.etagRel (optional, relative to saveDir like job.dest) turns this into
|
||||
-- a conditional GET -- see HostShell.httpDownload's own comment for the
|
||||
-- 304/notModified contract. A notModified result never writes `rel` at all
|
||||
-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so
|
||||
-- the usual "did a file actually land" check below is skipped for it.
|
||||
local function doDownload(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
@@ -84,12 +90,17 @@ local function doDownload(job)
|
||||
if dir then love.filesystem.createDirectory(dir) end
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS)
|
||||
local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil
|
||||
local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "download failed" })
|
||||
return
|
||||
end
|
||||
if notModified then
|
||||
post({ id = job.id, ok = true, path = rel, done = true, notModified = true })
|
||||
return
|
||||
end
|
||||
local info = love.filesystem.getInfo(rel)
|
||||
if not info or (info.size or 0) == 0 then
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
-- GBC Effects post-process ("Pixel Transparency" style, see
|
||||
-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder
|
||||
-- applied after palette colorization and before the CRT pass:
|
||||
-- 1 reflective screen: bright pixels blend toward a procedurally
|
||||
-- grained warm backing (the unlit-GBC "transparent whites" look)
|
||||
-- 2 + LCD subpixel grid
|
||||
-- 3 + drop shadows (dark pixels float above the backing)
|
||||
-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly
|
||||
-- drifting light source
|
||||
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
|
||||
-- Spec: docs/new-features.md (Custom Options / GBC FX)
|
||||
--
|
||||
-- One shader for all levels: features are gated by the `level` uniform
|
||||
-- (float comparisons), so cycling never recompiles. All spatial effects
|
||||
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
|
||||
-- shadow offsets and grain stay window-size independent.
|
||||
|
||||
local GBCFX = {}
|
||||
|
||||
GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
|
||||
GBCFX.level = 0
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
--
|
||||
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||
-- checkout exercise the unsupported path without stubbing love.system.
|
||||
function GBCFX.isSupported()
|
||||
local env = os.getenv("POKEPORT_GBCFX")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
extern number level;
|
||||
extern number time;
|
||||
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)
|
||||
|
||||
#define PI 3.14159265359
|
||||
|
||||
// ---- level thresholds (cumulative ladder) ----
|
||||
#define L_GRID 1.5
|
||||
#define L_SHADOW 2.5
|
||||
#define L_SUN 3.5
|
||||
|
||||
// ---- level 1: reflective backing ----
|
||||
#define BACK_BRIGHTNESS 0.48
|
||||
#define GRAIN_INTENSITY 0.065
|
||||
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
|
||||
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
|
||||
#define BASE_ALPHA 0.20
|
||||
#define WHITE_EXTRA 0.75
|
||||
// front polarizer film tint
|
||||
#define POLARIZER vec3(0.94, 1.0, 0.865)
|
||||
|
||||
// ---- level 2: LCD grid (lcd1x style) ----
|
||||
#define BRIGHTEN_SCANLINES 16.0
|
||||
#define BRIGHTEN_LCD 4.0
|
||||
|
||||
// ---- level 3: drop shadow ----
|
||||
#define SHADOW_OFFSET 3.0
|
||||
#define SHADOW_OPACITY 0.5
|
||||
|
||||
// ---- level 4: sunlight ----
|
||||
#define GLARE_INTENSITY 0.15
|
||||
#define GLARE_SIGMA 0.25
|
||||
#define SHIMMER_INTENSITY 0.25
|
||||
// chroma amplification so the bands read on the already-desaturated,
|
||||
// backing-blended image (reference applies 0.25 to raw film reflectance)
|
||||
#define SHIMMER_CHROMA_GAIN 3.0
|
||||
#define SHIMMER_SPREAD 1.8
|
||||
#define LIGHT_RANGE 0.6
|
||||
#define FILM_NOISE_AMOUNT 0.5
|
||||
#define REFLECT_FLOOR 0.03
|
||||
|
||||
float hash21(vec2 p)
|
||||
{
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
// smooth value noise, ~[0,1]
|
||||
float vnoise(vec2 p)
|
||||
{
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash21(i);
|
||||
float b = hash21(i + vec2(1.0, 0.0));
|
||||
float c = hash21(i + vec2(0.0, 1.0));
|
||||
float d = hash21(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float luma(vec3 c)
|
||||
{
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
{
|
||||
vec4 src = Texel(tex, tc);
|
||||
if (level < 0.5) {
|
||||
return src * color;
|
||||
}
|
||||
|
||||
float ps = max(pixelScale, 1.0);
|
||||
vec2 gbPix = pc / ps; // GB-pixel coordinates
|
||||
vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc
|
||||
vec2 gbTexel = texel * ps; // one GB pixel in tc
|
||||
|
||||
// Drifting light position (normalized screen coords, upper area).
|
||||
// Computed unconditionally: level 3 borrows it for shadow drift.
|
||||
vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
|
||||
0.3 + 0.2 * sin(time * 0.07));
|
||||
|
||||
// ---- level 1: procedural backing material ----
|
||||
// flat gray + 3-octave paper grain, tinted warm
|
||||
float grain = vnoise(gbPix * 0.9) * 0.5
|
||||
+ vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
|
||||
+ vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
|
||||
float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
|
||||
vec3 back = backLum * POCKET_TINT;
|
||||
|
||||
// ---- level 3: dark pixels cast a soft shadow onto the backing ----
|
||||
if (level >= L_SHADOW) {
|
||||
vec2 shOff = vec2(SHADOW_OFFSET);
|
||||
if (level >= L_SUN) {
|
||||
// subtle drift opposite the light's wander
|
||||
shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
|
||||
}
|
||||
vec2 so = tc - shOff * gbTexel;
|
||||
vec2 e = gbTexel;
|
||||
// 9-tap gaussian blur of the offset sample's brightness (unrolled)
|
||||
float s = 0.0;
|
||||
s += luma(Texel(tex, so).rgb) * 4.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
|
||||
s /= 16.0;
|
||||
float dark = 1.0 - s;
|
||||
// deadzone: near-white pixels (dark ~ 0) cast no shadow at all
|
||||
float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
|
||||
back = mix(back, back * 0.2, shadow);
|
||||
}
|
||||
|
||||
// ---- level 2: LCD subpixel grid on the lit image only ----
|
||||
vec3 lit = src.rgb;
|
||||
if (level >= L_GRID) {
|
||||
vec2 angle = 2.0 * PI * (gbPix - 0.25);
|
||||
float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
|
||||
/ (BRIGHTEN_SCANLINES + 1.0);
|
||||
float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
|
||||
lit *= yfac * xfac;
|
||||
}
|
||||
|
||||
// ---- level 1: brightness-proportional pixel transparency ----
|
||||
float lum = luma(src.rgb);
|
||||
float a = BASE_ALPHA * lum;
|
||||
// near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
|
||||
// fully transparent -- narrow smoothsteps stand in for the hard AND
|
||||
float mn = min(src.r, min(src.g, src.b));
|
||||
a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
|
||||
vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));
|
||||
|
||||
// ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
|
||||
float glare = 0.0;
|
||||
if (level >= L_SUN) {
|
||||
float aspect = love_ScreenSize.x / love_ScreenSize.y;
|
||||
vec2 p = vec2(tc.x * aspect, tc.y);
|
||||
vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
|
||||
float d = distance(p, lp);
|
||||
|
||||
// specular gaussian hotspot (added after the polarizer tint:
|
||||
// it reflects off the front glass, not the LCD)
|
||||
glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));
|
||||
|
||||
// quarter-wave-plate film: effective retardance (nm) grows with
|
||||
// distance from the light point -> concentric interference bands;
|
||||
// smooth "film thickness" noise makes the bands splotchy
|
||||
float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
|
||||
float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
|
||||
* (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
|
||||
float ph = 4.0 * PI * gammaEff;
|
||||
|
||||
// 7 wavelength samples 400..700nm with approximate spectral RGB,
|
||||
// unrolled (no const arrays in GLSL 1.20)
|
||||
vec3 rb = vec3(0.0);
|
||||
float cw;
|
||||
cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
|
||||
cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
|
||||
cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
|
||||
cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
|
||||
cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
|
||||
cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
|
||||
cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
|
||||
rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0
|
||||
|
||||
// fade with distance from the light, kill on dark pixels,
|
||||
// weight by pixel color squared
|
||||
float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
|
||||
float refl = max(lum, REFLECT_FLOOR);
|
||||
// luminance-preserving tint: add only the chroma of the rainbow
|
||||
vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
|
||||
* src.rgb * src.rgb * refl * att;
|
||||
col += shimmer * SHIMMER_INTENSITY;
|
||||
}
|
||||
|
||||
// front polarizer tint, then front-surface glare on top
|
||||
col *= POLARIZER;
|
||||
col += vec3(glare);
|
||||
|
||||
return vec4(col, src.a) * color;
|
||||
}
|
||||
]]
|
||||
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
GBCFX.level = level
|
||||
end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
-- render target (or plain if the shader is unavailable). pixelScale is
|
||||
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
|
||||
-- stay window-size independent.
|
||||
function GBCFX.present(canvas, pixelScale)
|
||||
local sh = GBCFX.shader()
|
||||
if not sh or GBCFX.level <= 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
return
|
||||
end
|
||||
local t = 0
|
||||
if love.timer and love.timer.getTime then
|
||||
t = love.timer.getTime()
|
||||
end
|
||||
sh:send("level", GBCFX.level)
|
||||
sh:send("time", t)
|
||||
sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
return GBCFX
|
||||
+60
-16
@@ -154,8 +154,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()
|
||||
@@ -594,7 +594,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
|
||||
@@ -785,8 +785,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 = {
|
||||
@@ -850,7 +850,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)
|
||||
@@ -893,12 +897,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
|
||||
@@ -1003,6 +1007,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,
|
||||
@@ -1010,6 +1025,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
|
||||
@@ -1030,8 +1052,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
|
||||
@@ -1243,9 +1277,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
|
||||
@@ -1260,9 +1294,19 @@ function Renderer:endFrame(zones, worldZones)
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
if ShaderFX.active() then
|
||||
-- The ShaderFX feature replaced GBCFX.lua's fixed level ladder
|
||||
-- with a preset picker (GBCFX.lua itself removed). fxRectPx*/fxScale/fxSrc*
|
||||
-- are this frame's REAL game rect + source size, set above by
|
||||
-- whichever branch actually ran (worldOverride, worldActive at the
|
||||
-- current survey zoom, or the UI-rect default for no-world/uiFill
|
||||
-- states) -- not a fixed 160x144-at-base-Sp reconstruction, so the
|
||||
-- chain sees true on-screen pixel geometry at any zoom/Faithful
|
||||
-- Ratio state.
|
||||
ShaderFX.render(composed,
|
||||
{ x = fxRectPxX, y = fxRectPxY, w = fxRectPxW, h = fxRectPxH, scale = fxScale },
|
||||
{ w = fxSrcW, h = fxSrcH },
|
||||
dpiX, dpiY)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
-- Mechanical GLSL rewrites over librashader's emitted output: it emits a
|
||||
-- standalone void main()/gl_FragData[0]/gl_Position shape, LOVE requires the
|
||||
-- effect()/position() convention. Not a GLSL parser -- every rule here exists
|
||||
-- because a real preset failed without it. See docs/shaderfx.md.
|
||||
local Fixup = {}
|
||||
|
||||
local function stripVersion(src)
|
||||
return (src:gsub("#version%s+%d+%s*\n", ""))
|
||||
end
|
||||
|
||||
-- ES 1.00 has no array-constructor syntax at all; SPIRV-Cross emits it anyway.
|
||||
-- Splitting needs paren-depth awareness, since an element can be vec2(a, b).
|
||||
local function splitTopLevelCommas(s)
|
||||
local parts, depth, start = {}, 0, 1
|
||||
for i = 1, #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == "(" then
|
||||
depth = depth + 1
|
||||
elseif c == ")" then
|
||||
depth = depth - 1
|
||||
elseif c == "," and depth == 0 then
|
||||
parts[#parts + 1] = s:sub(start, i - 1)
|
||||
start = i + 1
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = s:sub(start)
|
||||
return parts
|
||||
end
|
||||
|
||||
local function elementAssignLines(name, elemsStr)
|
||||
local lines = {}
|
||||
for i, elem in ipairs(splitTopLevelCommas(elemsStr)) do
|
||||
lines[#lines + 1] = ("%s[%d] = %s;"):format(name, i - 1, elem:match("^%s*(.-)%s*$"))
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function rewriteArrayLiterals(src)
|
||||
local injections = {}
|
||||
-- const, global scope: relocate the per-element assignments into main().
|
||||
src = src:gsub(
|
||||
"const%s+([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(typ, name, size, parenElems)
|
||||
for _, line in ipairs(elementAssignLines(name, parenElems:sub(2, -2))) do
|
||||
injections[#injections + 1] = " " .. line
|
||||
end
|
||||
return ("%s %s[%s];"):format(typ, name, size)
|
||||
end)
|
||||
if #injections > 0 then
|
||||
src = src:gsub("(void%s+main%s*%(%s*%)%s*{)",
|
||||
"%1\n" .. table.concat(injections, "\n"), 1)
|
||||
end
|
||||
-- non-const, local declaration+initializer: rewrite in place.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(typ, name, size, parenElems)
|
||||
local lines = elementAssignLines(name, parenElems:sub(2, -2))
|
||||
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
|
||||
end)
|
||||
-- bare reassignment of an already-declared array: rewrite in place.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
|
||||
function(name, parenElems)
|
||||
return table.concat(elementAssignLines(name, parenElems:sub(2, -2)), "\n ")
|
||||
end)
|
||||
-- array-to-array copy (`float param_1[7] = coeffs;`): ES 1.00 has no
|
||||
-- whole-array assignment either, so copy element by element.
|
||||
src = src:gsub(
|
||||
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*([%w_]+)%s*;",
|
||||
function(typ, name, size, srcName)
|
||||
local lines = {}
|
||||
for i = 0, tonumber(size) - 1 do
|
||||
lines[#lines + 1] = ("%s[%d] = %s[%d];"):format(name, i, srcName, i)
|
||||
end
|
||||
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
|
||||
end)
|
||||
return src
|
||||
end
|
||||
Fixup.rewriteArrayLiterals = rewriteArrayLiterals
|
||||
|
||||
-- ES 1.00 has no `%` operator. `%` is only ever defined for integer operands,
|
||||
-- so float mod() is exact here; balanced-paren operand shapes are tried first.
|
||||
local function rewriteIntegerModulo(src)
|
||||
local function convert(l, r)
|
||||
return ("int(mod(float(%s), float(%s)))"):format(l, r)
|
||||
end
|
||||
src = src:gsub("(%b())%s*%%%s*(%b())", convert)
|
||||
src = src:gsub("(%b())%s*%%%s*([%w_]+)", convert)
|
||||
src = src:gsub("([%w_]+)%s*%%%s*(%b())", convert)
|
||||
src = src:gsub("([%w_]+)%s*%%%s*([%w_]+)", convert)
|
||||
return src
|
||||
end
|
||||
Fixup.rewriteIntegerModulo = rewriteIntegerModulo
|
||||
|
||||
-- SPIRV-Cross hardcodes an unguarded `precision highp` pair with no toggle;
|
||||
-- claim highp only where the driver actually offers fragment highp.
|
||||
local function guardPrecision(src)
|
||||
src = src:gsub("precision%s+highp%s+float%s*;%s*\nprecision%s+highp%s+int%s*;%s*\n", "")
|
||||
local guard = [[
|
||||
#ifdef GL_ES
|
||||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
#endif
|
||||
#endif
|
||||
]]
|
||||
return guard .. src
|
||||
end
|
||||
|
||||
-- LOVE's Shader:send cannot address a member of a struct-typed uniform, so the
|
||||
-- struct is deleted and its members re-declared: scalars packed 4-at-a-time
|
||||
-- into vec4 slots (ES 1.00 guarantees only 16 fragment uniform vectors),
|
||||
-- vectors kept whole. `specialMembers` substitutes a member to fixed text
|
||||
-- instead of declaring it at all (see UBO_SPECIAL_MEMBERS).
|
||||
local SCALAR_TYPES = { float = true, int = true, bool = true }
|
||||
local COMPONENTS = { "x", "y", "z", "w" }
|
||||
|
||||
local function flattenStruct(src, structName, packedPrefix, specialMembers)
|
||||
packedPrefix = packedPrefix or "LIBRA_PACKED_"
|
||||
specialMembers = specialMembers or {}
|
||||
local body = src:match("struct%s+" .. structName .. "%s*{(.-)}%s*;")
|
||||
if not body then return src, {} end
|
||||
local instance = src:match("uniform%s+" .. structName .. "%s+([%w_]+)%s*;")
|
||||
assert(instance, "flattenStruct: found struct " .. structName .. " but no instance uniform")
|
||||
|
||||
src = src:gsub("struct%s+" .. structName .. "%s*{.-}%s*;%s*\n", "")
|
||||
src = src:gsub("uniform%s+" .. structName .. "%s+" .. instance .. "%s*;%s*\n", "")
|
||||
|
||||
local members = {}
|
||||
for typ, name in body:gmatch("([%w_]+)%s+([%w_]+)%s*;") do
|
||||
members[#members + 1] = { type = typ, name = name }
|
||||
end
|
||||
-- Declaration order matters here: packing scalars 4-at-a-time only minimizes
|
||||
-- group count against the struct's own vec4-then-scalars layout.
|
||||
|
||||
local decls, manifest, specialSubs = {}, {}, {}
|
||||
local scalarGroup, packIndex = {}, 0
|
||||
|
||||
local function flushGroup()
|
||||
if #scalarGroup == 0 then return end
|
||||
local uniformName = packedPrefix .. packIndex
|
||||
decls[#decls + 1] = ("uniform vec4 %s;"):format(uniformName)
|
||||
for i, m in ipairs(scalarGroup) do
|
||||
manifest[#manifest + 1] = { name = m.name, uniform = uniformName, component = COMPONENTS[i], type = m.type }
|
||||
end
|
||||
packIndex, scalarGroup = packIndex + 1, {}
|
||||
end
|
||||
|
||||
for _, m in ipairs(members) do
|
||||
if specialMembers[m.name] then
|
||||
specialSubs[#specialSubs + 1] = { name = m.name, replacement = specialMembers[m.name] }
|
||||
elseif SCALAR_TYPES[m.type] then
|
||||
scalarGroup[#scalarGroup + 1] = m
|
||||
if #scalarGroup == 4 then flushGroup() end
|
||||
else
|
||||
flushGroup()
|
||||
decls[#decls + 1] = ("uniform %s %s;"):format(m.type, m.name)
|
||||
manifest[#manifest + 1] = { name = m.name, uniform = m.name }
|
||||
end
|
||||
end
|
||||
flushGroup()
|
||||
|
||||
-- longest-name-first so a substitution can't land inside a longer member name
|
||||
-- sharing a prefix; a separate order from the packing above. A packed slot
|
||||
-- only stores floats, so an int/bool member needs a cast back on every read.
|
||||
local subs = {}
|
||||
for _, entry in ipairs(manifest) do
|
||||
local replacement = entry.component and (entry.uniform .. "." .. entry.component) or entry.uniform
|
||||
if entry.component and (entry.type == "int" or entry.type == "bool") then
|
||||
replacement = ("%s(%s)"):format(entry.type, replacement)
|
||||
end
|
||||
subs[#subs + 1] = { name = entry.name, replacement = replacement }
|
||||
end
|
||||
for _, entry in ipairs(specialSubs) do subs[#subs + 1] = entry end
|
||||
table.sort(subs, function(a, b) return #a.name > #b.name end)
|
||||
for _, entry in ipairs(subs) do
|
||||
src = src:gsub(instance .. "%." .. entry.name, entry.replacement)
|
||||
end
|
||||
|
||||
return table.concat(decls, "\n") .. "\n" .. src, manifest
|
||||
end
|
||||
Fixup.flattenStruct = flattenStruct
|
||||
|
||||
-- Turns a flat {member = value} table into the {uniform = value_or_vec4} shape
|
||||
-- the packed shader expects. A missing value packs as 0 in its component.
|
||||
function Fixup.packValues(manifest, values)
|
||||
local packed = {}
|
||||
local slot = { x = 1, y = 2, z = 3, w = 4 }
|
||||
for _, entry in ipairs(manifest) do
|
||||
if entry.component then
|
||||
packed[entry.uniform] = packed[entry.uniform] or { 0, 0, 0, 0 }
|
||||
packed[entry.uniform][slot[entry.component]] = values[entry.name] or 0
|
||||
else
|
||||
packed[entry.uniform] = values[entry.name]
|
||||
end
|
||||
end
|
||||
return packed
|
||||
end
|
||||
|
||||
-- GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every scalar
|
||||
-- costs a whole one; samplers do not come out of that budget.
|
||||
Fixup.SLOTS = { float = 1, int = 1, bool = 1, vec2 = 1, vec3 = 1, vec4 = 1, mat3 = 3, mat4 = 4 }
|
||||
|
||||
function Fixup.countUniformSlots(src)
|
||||
-- Lua's %w does NOT match underscore, unlike regex \w, and every name here is
|
||||
-- full of them, so this must be [%w_]+ or whole declarations go uncounted.
|
||||
local total = 0
|
||||
for typ in src:gmatch("uniform%s+([%w_]+)%s+[%w_]+%s*;") do
|
||||
if typ ~= "sampler2D" and typ ~= "Image" then
|
||||
total = total + (Fixup.SLOTS[typ] or 1)
|
||||
end
|
||||
end
|
||||
return total
|
||||
end
|
||||
|
||||
-- LIBRA_UBO_* (the uniform-buffer block) has the same
|
||||
-- LOVE-can't-address-a-struct-member problem as LIBRA_PUSH_*, so it is
|
||||
-- flattened the same way, with MVP alone special-cased. Deleting the block
|
||||
-- outright instead left every other member reference dangling.
|
||||
|
||||
-- LOVE forward-declares effect()'s prototype under its own header's precision
|
||||
-- default, which can mismatch guardPrecision's; pin the parameter list to a
|
||||
-- #define so the caller can try each variant in order.
|
||||
Fixup.PREC_HEADS = { "#define EFFECT_PREC mediump\n", "#define EFFECT_PREC\n" }
|
||||
|
||||
-- MVP is meaningless in the fragment stage but gets declared anyway; special-
|
||||
-- casing it keeps a dead field from becoming a uniform nothing would send.
|
||||
local UBO_SPECIAL_MEMBERS = { MVP = "transform_projection" }
|
||||
|
||||
function Fixup.fragment(src)
|
||||
src = stripVersion(src)
|
||||
src = rewriteArrayLiterals(src)
|
||||
src = rewriteIntegerModulo(src)
|
||||
local pushManifest, uboManifest
|
||||
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_FRAGMENT")
|
||||
src, uboManifest = flattenStruct(src, "LIBRA_UBO_FRAGMENT", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
|
||||
local manifest = pushManifest
|
||||
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
|
||||
src = guardPrecision(src)
|
||||
src = src:gsub("void%s+main%s*%(%s*%)",
|
||||
"vec4 effect(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
|
||||
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc)")
|
||||
-- gl_FragData isn't available in LOVE's effect() convention. Rewrite EVERY
|
||||
-- occurrence, whatever operator follows: real presets (ds-hybrid-sabr) write
|
||||
-- it once with `=` and later accumulate with `+=`.
|
||||
src = src:gsub("gl_FragData%[0%]", "gbFragColor")
|
||||
src = src:gsub(
|
||||
"(vec4 effect%(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
|
||||
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc%)%s*\n{)",
|
||||
"%1\n vec4 gbFragColor;")
|
||||
-- a bare early-exit `return;` needs a value now -- gbFragColor already
|
||||
-- holds whatever was assigned just before it on every real shape seen.
|
||||
src = src:gsub("return%s*;", "return gbFragColor;")
|
||||
local lastBrace = src:match("()}%s*$")
|
||||
assert(lastBrace, "fragment fixup: could not find effect()'s closing brace")
|
||||
src = src:sub(1, lastBrace - 1) .. " return gbFragColor;\n" .. src:sub(lastBrace)
|
||||
return src, manifest
|
||||
end
|
||||
|
||||
function Fixup.vertex(src)
|
||||
src = stripVersion(src)
|
||||
src = rewriteArrayLiterals(src)
|
||||
src = rewriteIntegerModulo(src)
|
||||
local pushManifest, uboManifest
|
||||
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_VERTEX")
|
||||
-- MVP means "multiply the incoming vertex by it", which on an ordinary
|
||||
-- full-screen LOVE draw is transform_projection, so it substitutes rather
|
||||
-- than being packed. A distinct prefix keeps the UBO's packed groups off
|
||||
-- LIBRA_PUSH_*'s own numbering.
|
||||
src, uboManifest = flattenStruct(src, "LIBRA_UBO_VERTEX", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
|
||||
local manifest = pushManifest
|
||||
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
|
||||
-- LOVE supplies VertexPosition/VertexTexCoord itself.
|
||||
src = src:gsub("attribute%s+vec4%s+Position%s*;%s*\n", "")
|
||||
src = src:gsub("attribute%s+vec2%s+TexCoord%s*;%s*\n", "")
|
||||
src = src:gsub("void%s+main%s*%(%s*%)",
|
||||
"vec4 position(mat4 transform_projection, vec4 vertex_position)")
|
||||
-- gl_Position isn't available in LOVE's position() convention; carry it in a
|
||||
-- local named to share no substring with "Position".
|
||||
src = src:gsub("gl_Position%s*=%s*([^;]+);", "gbClipPos = %1;")
|
||||
-- Whole-identifier frontier match, not a blind gsub: a preset's own local
|
||||
-- `vec2 vTexCoord;` would otherwise be mangled (dot.slangp pass0).
|
||||
src = src:gsub("%f[%w]Position%f[%W]", "vertex_position")
|
||||
src = src:gsub("%f[%w]TexCoord%f[%W]", "VertexTexCoord%.xy")
|
||||
src = src:gsub(
|
||||
"(vec4 position%(mat4 transform_projection, vec4 vertex_position%)%s*\n{)",
|
||||
"%1\n vec4 gbClipPos;")
|
||||
-- append the return just before the function's final closing brace
|
||||
local lastBrace = src:match("()}%s*$")
|
||||
assert(lastBrace, "vertex fixup: could not find main()'s closing brace")
|
||||
src = src:sub(1, lastBrace - 1) .. " return gbClipPos;\n" .. src:sub(lastBrace)
|
||||
return src, manifest
|
||||
end
|
||||
|
||||
return Fixup
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Local find/replace patches applied to a preset's raw .slang/.inc source on
|
||||
-- disk, before ShaderFX.convert() hands it to the bridge -- only librashader's
|
||||
-- own parser can discover a new #pragma parameter, so nothing downstream can
|
||||
-- add one. Ships with an empty table on purpose; see docs/shaderfx.md.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- entry.name -> { { relPath = <relative to the .slangp's own directory>,
|
||||
-- patches = { { old = <literal>, new = <literal> }, ... } } }
|
||||
M.PATCHES = {}
|
||||
|
||||
local function dirname(path)
|
||||
return path:match("^(.*)[/\\][^/\\]*$") or "."
|
||||
end
|
||||
|
||||
-- Plain (non-pattern) substring replace: GLSL source is full of Lua pattern
|
||||
-- magic characters, so gsub on literal source text is not safe here.
|
||||
local function replaceOnce(text, old, new)
|
||||
local s, e = text:find(old, 1, true)
|
||||
if not s then return text, false end
|
||||
return text:sub(1, s - 1) .. new .. text:sub(e + 1), true
|
||||
end
|
||||
|
||||
-- Applies every registered patch for `entry` to the real files on disk. A patch
|
||||
-- whose anchor is gone (already applied, or upstream changed) is skipped.
|
||||
function M.apply(entry)
|
||||
local list = M.PATCHES[entry.name]
|
||||
if not list then return end
|
||||
local base = dirname(entry.fullPath)
|
||||
for _, file in ipairs(list) do
|
||||
local path = base .. "/" .. file.relPath
|
||||
local f = io.open(path, "rb")
|
||||
if not f then
|
||||
Logger.warn("ShaderSourcePatches: %s: cannot open %s", entry.name, path)
|
||||
else
|
||||
local text = f:read("*a")
|
||||
f:close()
|
||||
local changed = false
|
||||
for _, p in ipairs(file.patches) do
|
||||
local already = text:find(p.new, 1, true) ~= nil
|
||||
if not already then
|
||||
local newText, ok = replaceOnce(text, p.old, p.new)
|
||||
if ok then
|
||||
text = newText
|
||||
changed = true
|
||||
else
|
||||
Logger.warn("ShaderSourcePatches: %s: patch anchor not found in %s (upstream changed?)",
|
||||
entry.name, file.relPath)
|
||||
end
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
local wf, werr = io.open(path, "wb")
|
||||
if not wf then
|
||||
Logger.warn("ShaderSourcePatches: %s: cannot write %s: %s", entry.name, path, tostring(werr))
|
||||
else
|
||||
wf:write(text)
|
||||
wf:close()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -17,6 +17,25 @@ Zoom.offset = 0
|
||||
-- close-up. Nil/true keeps the historical full range.
|
||||
Zoom.allowSurvey = true
|
||||
|
||||
-- The deepest legal survey step (offset = 1-S, effective scale s' = S+lo =
|
||||
-- 1 px/world px) plus an active SHADER FX chain crashes the app on real
|
||||
-- hardware (Pixel 10 Pro XL, "OUT7" == this exact step at that device's own
|
||||
-- fit scale of 8 -- reported by the user, 2026-08-21). A quantifying repro
|
||||
-- (TrueFX/zoom-mem-scan/) ruled out ShaderFX's own per-pass canvases as the
|
||||
-- driver (worst real-corpus case at that step: ~31MB, not crash-sized) --
|
||||
-- the actual cost is almost certainly the world canvas/tile-render path
|
||||
-- itself at that resolution (a preexisting cost of deep survey zoom,
|
||||
-- unrelated to ShaderFX), which a shader chain reading that same
|
||||
-- full-resolution canvas as its own input then pushes over some real
|
||||
-- device/driver ceiling this project cannot measure without the device
|
||||
-- in hand. Removing just the single deepest step while a preset is active
|
||||
-- is the smallest change that directly targets what was actually reported
|
||||
-- ("OUT7 + any shader", not "OUT7 alone" and not "OUT6 or shallower") --
|
||||
-- not a guessed-at general zoom restriction. Needs the user's own on-device
|
||||
-- confirmation; extend the margin (currently 1 step) if OUT6 turns out to
|
||||
-- be risky too.
|
||||
local ShaderFX
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
|
||||
@@ -37,6 +56,14 @@ function Zoom.offsetRange(S)
|
||||
elseif lo > -3 then
|
||||
lo = -3
|
||||
end
|
||||
-- SHADER FX + the single deepest survey step: see the comment above.
|
||||
-- The floor is one step above the deepest this call would otherwise
|
||||
-- allow, so the three-step minimum above still keeps a zoom-out.
|
||||
ShaderFX = ShaderFX or require("src.render.ShaderFX")
|
||||
if ShaderFX.active() then
|
||||
local floor = math.min(2 - S, lo + 1)
|
||||
if lo < floor then lo = floor end
|
||||
end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
|
||||
@@ -193,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 = {},
|
||||
},
|
||||
}
|
||||
|
||||
+15
-6
@@ -340,10 +340,8 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
-- A map script often follows a trainer battle with its own text.
|
||||
-- Keep a level evolution behind that text: otherwise afterBattle
|
||||
-- pushes the evolution screen, then this runner resumes and pushes
|
||||
-- the trainer's text on top of it.
|
||||
-- The map-side follow-up (stampClosedDoors, #372) rides behind the
|
||||
-- script's own text; the evolution now runs in BattleState:finish.
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
@@ -1211,6 +1209,16 @@ function Commands.replace_block(ctx, bx, by, blockId)
|
||||
if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end
|
||||
end
|
||||
|
||||
-- ss_anne_departs: scripts/VermilionDock.asm:80 .shift_columns_up, blocking
|
||||
-- until she has cleared her own width
|
||||
function Commands.ss_anne_departs(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow or not ow.startSsAnneDeparture then return end
|
||||
local runner = ctx.runner
|
||||
ow:startSsAnneDeparture(function() runner:resume() end)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- set_tile_anim <anim|false>: override the current tileset's animation
|
||||
-- ("TILEANIM_WATER"; false stops it) until the next map change restores
|
||||
-- the record (setMap)
|
||||
@@ -1422,7 +1430,7 @@ Commands.meta = {}
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "fade", "pan_camera" }) do
|
||||
"give_pokemon", "fade", "pan_camera", "ss_anne_departs" }) do
|
||||
Commands.meta[verb] = { foreground = true }
|
||||
end
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
@@ -1430,7 +1438,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "wait",
|
||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do
|
||||
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way",
|
||||
"ss_anne_departs" }) do
|
||||
local meta = Commands.meta[verb] or {}
|
||||
Commands.meta[verb] = meta
|
||||
meta.blocking = true
|
||||
|
||||
@@ -390,9 +390,12 @@ function H.CutDownTreeOrGrass(ctx)
|
||||
end
|
||||
|
||||
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
|
||||
-- OWCutAnimation: the same block write, the same redraw.
|
||||
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
|
||||
-- port owns as World:playWhirlpoolSound. #1717
|
||||
function H.DisappearWhirlpool(ctx)
|
||||
return H.CutDownTreeOrGrass(ctx)
|
||||
local ret = H.CutDownTreeOrGrass(ctx)
|
||||
call(ctx, "playWhirlpoolSound")
|
||||
return ret
|
||||
end
|
||||
|
||||
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
|
||||
|
||||
@@ -32,6 +32,12 @@ local FIX_FACING = 0x3b
|
||||
-- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE.
|
||||
local TREE_SHAKE = 0x56
|
||||
local TREE_SHAKE_FRAMES = 24
|
||||
-- engine/events/forced_movement.asm:25-51; step_dig's frame count is the
|
||||
-- byte after it (macros/scripts/movement.asm:163-167)
|
||||
local TURN_HEAD = 0x00
|
||||
local TURN_IN = 0x24
|
||||
local STEP_DIG = 0x4f
|
||||
local STEP_DIG_FRAMES = 16
|
||||
|
||||
function Movement.dir(nibble)
|
||||
return DIR[nibble % 4] or "down"
|
||||
@@ -57,8 +63,10 @@ function Movement.decodeByte(b)
|
||||
return { kind = "step", dir = dir }
|
||||
elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides
|
||||
return { kind = "step", dir = dir }
|
||||
elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall
|
||||
return { kind = "turn", dir = dir }
|
||||
elseif family == 0x20 or family == 0x24 or family == 0x28 then
|
||||
-- turn_away / turn_in / turn_waterfall all `jp TurningStep`, which steps a
|
||||
-- cell under OBJECT_ACTION_SPIN -- movement.asm:483-513, :693-715 (#1716)
|
||||
return { kind = "step", dir = dir, spin = true }
|
||||
elseif family == 0x2c or family == 0x30 or family == 0x34 then
|
||||
-- slow_jump_step / jump_step / fast_jump_step, all of which reach
|
||||
-- JumpStep (engine/overworld/movement.asm:741) and so run under
|
||||
@@ -141,6 +149,8 @@ Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING
|
||||
Movement.FIX_FACING = FIX_FACING
|
||||
Movement.TREE_SHAKE = TREE_SHAKE
|
||||
Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES
|
||||
Movement.STEP_DIG = STEP_DIG
|
||||
Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES
|
||||
|
||||
-- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE
|
||||
-- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count
|
||||
@@ -177,4 +187,15 @@ function Movement.stepByte(dir)
|
||||
return 0x0c + (DIR_BYTE[dir] or 0)
|
||||
end
|
||||
|
||||
-- Script_ForcedMovement's stream, `back` being the direction thrown in
|
||||
-- -- engine/events/forced_movement.asm:25-51
|
||||
function Movement.forcedMovementBytes(back)
|
||||
local d = DIR_BYTE[back] or 0
|
||||
return {
|
||||
STEP_DIG, STEP_DIG_FRAMES, TURN_IN + d,
|
||||
STEP_DIG, STEP_DIG_FRAMES, TURN_HEAD + d,
|
||||
STEP_END,
|
||||
}
|
||||
end
|
||||
|
||||
return Movement
|
||||
|
||||
@@ -1375,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
|
||||
|
||||
|
||||
+91
-19
@@ -6,6 +6,7 @@ local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local BagMenu = {}
|
||||
|
||||
@@ -26,11 +27,19 @@ local function buildItems(game)
|
||||
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
|
||||
})
|
||||
end
|
||||
-- the $ff terminator's row: CANCEL is selectable and exits like B
|
||||
-- (home/list_menu.asm:105-110, 523-528) #1685
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
local function consume(game, id)
|
||||
local function consume(game, id, list)
|
||||
Bag.remove(game.save, id, 1)
|
||||
-- engine/items/inventory.asm:131-136
|
||||
if not game.save.inventory[id] then
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
|
||||
if list then list.index, list.scroll = 1, 0 end
|
||||
end
|
||||
end
|
||||
|
||||
local function save_name(game)
|
||||
@@ -66,6 +75,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
if picker then picker:close() end
|
||||
end
|
||||
|
||||
-- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open
|
||||
-- behind the bag comes down with it (start_sub_menus.asm:400-407) #1745
|
||||
local function closeBag()
|
||||
list:close()
|
||||
if list.closeStartMenu then list.closeStartMenu() end
|
||||
end
|
||||
|
||||
-- field POKé FLUTE: play the tune, then the no-effect text
|
||||
if result == "flute_field" then
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
@@ -84,7 +100,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||
if result == "flute_wake" then
|
||||
list:close()
|
||||
closeBag()
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
showMessages(game, payload, function()
|
||||
local ow = game.overworld
|
||||
@@ -97,7 +113,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "consumed_escape" then -- Poké Doll
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
showMessages(game, payload, function()
|
||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||
@@ -120,12 +136,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
|
||||
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
|
||||
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
|
||||
-- runs, which is why it precedes list:close() here.
|
||||
-- runs, which is why it precedes the mount/dismount here.
|
||||
if game.save.forcedBike then
|
||||
showMessages(game, { Strings("You can't get off\nhere.") })
|
||||
return
|
||||
end
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local Music = require("src.core.Music")
|
||||
-- IsBikeRidingAllowed (home/overworld.asm): the tilesets of
|
||||
@@ -146,27 +161,33 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return false
|
||||
end
|
||||
if game.save.onBike then
|
||||
closeBag()
|
||||
game.save.onBike = false
|
||||
Music.playMap(game.data, ow and ow.map.id, false)
|
||||
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
|
||||
elseif bikeAllowed() then
|
||||
closeBag()
|
||||
game.save.onBike = true
|
||||
Music.playMap(game.data, ow.map.id, true)
|
||||
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
|
||||
else
|
||||
-- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and
|
||||
-- .useItem_closeMenu loops back (item_effects.asm:2305-2319)
|
||||
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if result == "fish" then
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local p = ow and ow.player
|
||||
if ow and p and ow:facingIsShoreOrWater() then
|
||||
closeBag()
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
-- FishingInit's `ret c` -> ItemUseNotTime -> ItemUseFailed, so a rod
|
||||
-- away from water leaves the bag up (item_effects.asm:1893-1901)
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
end
|
||||
@@ -177,7 +198,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
game.save.player.name) })
|
||||
return
|
||||
end
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
battle:throwBall(id)
|
||||
return
|
||||
@@ -197,21 +218,22 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) }, nil,
|
||||
game.data.pokemon[target.species].name, mdef.name) }, closePicker,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
if result == "learn" then consume(game, id) end
|
||||
if result == "learn" then consume(game, id, list) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned and result == "learn" then consume(game, id, list) end
|
||||
if learned then
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if learned then taught() end
|
||||
closePicker()
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -265,8 +287,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local ow = game.overworld
|
||||
if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
list:close()
|
||||
consume(game, id)
|
||||
closeBag()
|
||||
consume(game, id, list)
|
||||
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
@@ -292,7 +314,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "consumed" then
|
||||
consume(game, id)
|
||||
consume(game, id, list)
|
||||
-- refresh counts in the list
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
@@ -388,6 +410,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
-- .chooseMon loops on a refusal, so the TM/HM picker stays up for another
|
||||
-- pick (engine/items/item_effects.asm:2234, :2237) (#1686)
|
||||
local machineDef = game.data.items[id]
|
||||
if picker and picker.keepOpen and machineDef and machineDef.machine then
|
||||
showMessages(game, payload)
|
||||
return
|
||||
end
|
||||
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
|
||||
-- refusal closes the picker the same way (#252)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
@@ -406,10 +435,15 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
-- USE_ITEM_PARTY_MENU / EVO_STONE_PARTY_MENU, not the trade and daycare
|
||||
-- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610
|
||||
itemUse = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||
-- the party menu (item_effects.asm:1392-1418)
|
||||
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||
-- the party menu (item_effects.asm:1392-1418); TM/HM stays up through
|
||||
-- `predef LearnMove` (item_effects.asm:2238) (#1686)
|
||||
keepOpen = (not battle)
|
||||
and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil)),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
@@ -460,9 +494,15 @@ local function useItem(game, battle, id, list)
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
and romText(game.data, "_BootedUpHMText", "Booted up an HM!")
|
||||
or romText(game.data, "_BootedUpTMText", "Booted up a TM!")
|
||||
-- engine/items/item_effects.asm:2177 (#1686)
|
||||
showMessages(game, { booted,
|
||||
romText(game.data, "_TeachMachineMoveText",
|
||||
"It contained\n%s!\fTeach %s\nto a POKéMON?", moveName, moveName) },
|
||||
nil, { choice = function(yes)
|
||||
if yes then pickTargetAndUse(game, battle, id, list) end
|
||||
end })
|
||||
return
|
||||
end
|
||||
pickTargetAndUse(game, battle, id, list)
|
||||
@@ -484,7 +524,8 @@ function BagMenu.new(game, opts)
|
||||
onCancel = opts.onCancel,
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
-- attempts to swap the CANCEL row are ignored (swap_items.asm:19-22)
|
||||
if not item or item.cancel then return end
|
||||
if not l.swapIndex then
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
@@ -496,6 +537,12 @@ function BagMenu.new(game, opts)
|
||||
l.items = buildItems(game)
|
||||
end,
|
||||
onChoose = function(item)
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
if item and item.cancel then
|
||||
list:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
return
|
||||
end
|
||||
local id = item.value
|
||||
local def = game.data.items[id]
|
||||
if list.swapIndex then -- A also completes a pending swap
|
||||
@@ -510,6 +557,12 @@ function BagMenu.new(game, opts)
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
end
|
||||
-- the BICYCLE never gets the option box: StartMenu_Item jumps straight
|
||||
-- to .useOrTossItem (start_sub_menus.asm:340-342) #1705
|
||||
if id == "BICYCLE" then
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
end
|
||||
-- USE / TOSS submenu (the original's item options).
|
||||
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
|
||||
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
|
||||
@@ -548,6 +601,25 @@ function BagMenu.new(game, opts)
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||
end,
|
||||
})
|
||||
-- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and
|
||||
-- engine/menus/start_sub_menus.asm:319-323 #1732
|
||||
local n, rows = #list.items, list.cursorRows or list.rows
|
||||
if n > 0 then
|
||||
local saved = game.bagListScrollOffset or 0
|
||||
local index = math.min(saved + (game.bagSavedMenuItem or 0) + 1, n)
|
||||
local scroll = math.min(saved, index - 1, math.max(0, n - rows))
|
||||
list.index, list.scroll = index, math.max(0, scroll, index - rows)
|
||||
end
|
||||
local baseUpdate = list.update
|
||||
list.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
game.bagListScrollOffset = self.scroll
|
||||
game.bagSavedMenuItem = self.index - self.scroll - 1
|
||||
end
|
||||
list.closeStartMenu = opts.onClose
|
||||
-- the item box overlaps the kept-open START menu box, so neither docks to
|
||||
-- a screen edge on its own (start_sub_menus.asm:302-329) #1745
|
||||
list.holdsUIAnchors = true
|
||||
return list
|
||||
end
|
||||
|
||||
|
||||
+45
-5
@@ -16,6 +16,34 @@ function ListMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local BLACK = { 0, 0, 0, 1 }
|
||||
local MUTED_TEXT = { 0.55, 0.55, 0.55, 1 }
|
||||
|
||||
-- row text runs from x=16 to the 160px screen's right margin (160-8, same
|
||||
-- margin item.right right-aligns against); GAP is the blank strip kept
|
||||
-- between a truncated label and item.right so the two never touch.
|
||||
local ROW_LEFT = 16
|
||||
local ROW_RIGHT_MARGIN = 160 - 8
|
||||
local LABEL_GAP = 4
|
||||
|
||||
-- Truncate `text` to `pixels`, same convention as WideBattle.lua's own
|
||||
-- fitName (HP-bar names facing the identical "arbitrary text vs. fixed
|
||||
-- pixel budget" problem): cut on a whole glyph span, never mid-character,
|
||||
-- and mark the cut with a trailing '.'. ShaderFXScreen's preset names are
|
||||
-- player-supplied filenames of arbitrary length -- unclipped, a long one
|
||||
-- either overlapped/garbled item.right's "CONVERT" hint or ran past the
|
||||
-- screen's right edge outright.
|
||||
local function fitLabel(text, pixels)
|
||||
local spans = Font.split(text or "")
|
||||
local n = Font.spansFitting(spans, pixels)
|
||||
if n >= #spans then return text or "" end
|
||||
local out = {}
|
||||
for i = 1, math.max(0, n - 1) do
|
||||
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
|
||||
end
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
@@ -237,12 +265,13 @@ function ListMenu:drawItemBox()
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||
end
|
||||
local shown = 0
|
||||
local shown, sawCancel = 0, false
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
shown = shown + 1
|
||||
if item.cancel then sawCancel = true end
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
@@ -262,7 +291,7 @@ function ListMenu:drawItemBox()
|
||||
end
|
||||
-- the terminator prints CANCEL and returns before the '▼'
|
||||
-- (home/list_menu.asm:372, 518-524)
|
||||
if shown == self.rows then
|
||||
if shown == self.rows and not sawCancel then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -282,22 +311,33 @@ function ListMenu:draw()
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
local y = 8 + row * 16
|
||||
Font.draw(item.label, 16, y)
|
||||
-- item.muted: a real, selectable row that isn't fully "ready" yet (e.g.
|
||||
-- ShaderFXScreen's unconverted presets) -- readable, never hidden, same
|
||||
-- "always still readable" convention kit/Theme.lua's own disabled state
|
||||
-- documents, just not the generic list-item shape that lived here
|
||||
-- before ShaderFXScreen needed it.
|
||||
local textColor = item.muted and MUTED_TEXT or BLACK
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
|
||||
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
|
||||
local label = fitLabel(item.label, budget)
|
||||
Font.draw(label, 16, y)
|
||||
if item.ball then -- the Pokédex owned-ball marker tile
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(item.label) + 8 + 3
|
||||
local bx = 16 + Font.width(label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", bx, by, 1.2)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
end
|
||||
love.graphics.setColor(unpack(BLACK))
|
||||
if i == self.index then
|
||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
|
||||
|
||||
+21
-24
@@ -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
@@ -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 = {}
|
||||
|
||||
+17
-37
@@ -305,6 +305,7 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
self.itemUse = opts.itemUse -- USE_ITEM_PARTY_MENU (item_effects.asm:813)
|
||||
-- Medicine keeps the picker on screen: item_effects.asm .doneHealing
|
||||
-- animates the party HP bar and then prints the message through
|
||||
-- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and
|
||||
@@ -725,16 +726,19 @@ end
|
||||
-- the party menu always prints a message in the bottom text box. With the
|
||||
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
|
||||
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
|
||||
-- item / TM-HM ids print their own strings, which draw() handles inline.
|
||||
-- Pure (no side effects) so drivers can assert it. #147
|
||||
-- item / TM-HM ids print their own strings, and EVO_STONE shares
|
||||
-- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers).
|
||||
-- Pure (no side effects) so drivers can assert it. #147 #1610
|
||||
function PartyMenu:bottomMessage()
|
||||
if self.swapFrom then
|
||||
return "Move to where?"
|
||||
elseif self.softboiledFrom or self.pickOnly then
|
||||
return "Use on which one?"
|
||||
return self.game.data.text._PartyMenuSwapMonText
|
||||
or Strings("Move POKéMON\nwhere?")
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
elseif self.softboiledFrom or self.itemUse then
|
||||
return self.game.data.text._PartyMenuItemUseText
|
||||
or Strings("Use item on which\nPOKéMON?")
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or Strings("Bring out which\nPOKéMON?")
|
||||
@@ -858,38 +862,14 @@ function PartyMenu:draw()
|
||||
Font.drawCode(Theme.cursorHollow, 0, cursorY)
|
||||
end
|
||||
end
|
||||
if self.swapFrom then
|
||||
Font.draw(Strings("Move to where?"), 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
|
||||
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
|
||||
-- TextBox's default (rows 12-17, text on rows 14/16). #147
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local ly = 112
|
||||
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
-- every message id prints through PrintText, so it lands in the standard
|
||||
-- bottom text box, rows 12-17 (party_menu.asm:174). #147 #210 #1610
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local ly = 112
|
||||
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
|
||||
@@ -56,6 +56,9 @@ local GEN2 = {
|
||||
-- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only
|
||||
-- peer of GoldSilverIntro below.
|
||||
"CrystalIntro",
|
||||
-- ../pokecrystal/engine/movie/splash.asm:1 SplashScreen, the Crystal-only
|
||||
-- Ditto peer of GameFreakPresents below.
|
||||
"CrystalSplash",
|
||||
"DayCareMenu", "DecorationMenu", "Diploma",
|
||||
"EggHatchAnim", "ElevatorMenu", "EvolutionAnim",
|
||||
"GameFreakPresents",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Per-preset #pragma parameter editor, reached via SELECT on a converted row in
|
||||
-- ShaderFXScreen. A steps and wraps, Left/Right step and clamp, SELECT resets
|
||||
-- one row, START resets all. Edits are keyed by preset name, not by slot.
|
||||
-- See docs/shaderfx.md.
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local ShaderFXParamsScreen = setmetatable({}, { __index = ListMenu })
|
||||
ShaderFXParamsScreen.__index = ShaderFXParamsScreen
|
||||
|
||||
local function overridesFor(game, entry)
|
||||
local opts = game.save and game.save.options
|
||||
local all = opts and opts.shaderfxParams
|
||||
return all and all[entry.name]
|
||||
end
|
||||
|
||||
local function currentValue(p, overrides)
|
||||
local v = overrides and overrides[p.id]
|
||||
if v == nil then v = p.initial end
|
||||
return v
|
||||
end
|
||||
|
||||
-- Trims trailing zeros but always keeps one decimal place: many real pragma
|
||||
-- steps are 0.01-0.05, which a fixed-width format would hide.
|
||||
local function fmt(v)
|
||||
local s = ("%.3f"):format(v)
|
||||
s = s:gsub("(%..-)0+$", "%1"):gsub("%.$", ".0")
|
||||
return s
|
||||
end
|
||||
|
||||
local function buildItems(game, entry, params)
|
||||
local overrides = overridesFor(game, entry)
|
||||
local items = {}
|
||||
for _, p in ipairs(params) do
|
||||
items[#items + 1] = {
|
||||
label = Strings(p.description), param = p,
|
||||
right = fmt(currentValue(p, overrides)),
|
||||
}
|
||||
end
|
||||
return items
|
||||
end
|
||||
|
||||
function ShaderFXParamsScreen.new(game, entry)
|
||||
local params, err = ShaderFX.listParams(entry)
|
||||
if not params then
|
||||
Logger.error("ShaderFXParamsScreen: %s: %s", entry.name, tostring(err))
|
||||
params = {}
|
||||
end
|
||||
local items = buildItems(game, entry, params)
|
||||
local title = Strings((entry.name:gsub("%.slangp$", "")))
|
||||
|
||||
local self = setmetatable(ListMenu.new(game, title, items, {
|
||||
rows = 6,
|
||||
footer = Strings("A/L/R:STEP\nSELECT:RESET START:ALL"),
|
||||
}), ShaderFXParamsScreen)
|
||||
|
||||
-- Re-activates whichever slot(s) currently show this preset, so an edit
|
||||
-- reaches the running chain by the next frame. Cheap enough to call on every
|
||||
-- step: activate() re-reads the cached artifact, it never calls the bridge.
|
||||
local function applyLive(overrides)
|
||||
for _, slot in ipairs(ShaderFX.SLOTS) do
|
||||
local active = ShaderFX.activeEntry(slot)
|
||||
if active and active.name == entry.name then
|
||||
ShaderFX.activate(slot, active, overrides)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function setParam(item, value)
|
||||
local p = item.param
|
||||
value = math.max(p.minimum, math.min(p.maximum, value))
|
||||
local opts = game.save and game.save.options
|
||||
if not opts then return end
|
||||
opts.shaderfxParams = opts.shaderfxParams or {}
|
||||
opts.shaderfxParams[entry.name] = opts.shaderfxParams[entry.name] or {}
|
||||
opts.shaderfxParams[entry.name][p.id] = value
|
||||
item.right = fmt(value)
|
||||
applyLive(opts.shaderfxParams[entry.name])
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
self.onChoose = function(item)
|
||||
local overrides = overridesFor(game, entry)
|
||||
local cur = currentValue(item.param, overrides)
|
||||
local nextVal = cur + item.param.step
|
||||
if nextVal > item.param.maximum + 1e-6 then nextVal = item.param.minimum end
|
||||
setParam(item, nextVal)
|
||||
end
|
||||
self.onSelectKey = function(item)
|
||||
setParam(item, item.param.initial)
|
||||
end
|
||||
|
||||
-- dir=-1/+1; clamps at the ends instead of onChoose's wrap.
|
||||
local function stepCurrent(dir)
|
||||
local item = self.items[self.index]
|
||||
if not item then return end
|
||||
local overrides = overridesFor(game, entry)
|
||||
local cur = currentValue(item.param, overrides)
|
||||
setParam(item, cur + dir * item.param.step)
|
||||
end
|
||||
|
||||
-- One write + one applyLive for the whole preset, not one per row.
|
||||
local function resetAll()
|
||||
local opts = game.save and game.save.options
|
||||
if not opts then return end
|
||||
opts.shaderfxParams = opts.shaderfxParams or {}
|
||||
local ov = {}
|
||||
opts.shaderfxParams[entry.name] = ov
|
||||
for _, item in ipairs(items) do
|
||||
local v = item.param.initial
|
||||
ov[item.param.id] = v
|
||||
item.right = fmt(v)
|
||||
end
|
||||
applyLive(ov)
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
-- START: confirm, then reset every row.
|
||||
local function confirmResetAll()
|
||||
local hint = self.footer
|
||||
self.footer = Strings("RESET ALL PARAMS?")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
self.footer = hint
|
||||
if yes then resetAll() end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
local baseUpdate = ListMenu.update
|
||||
self.update = function(self_, dt)
|
||||
local input = self_.game.input
|
||||
if #self_.items > 0 then
|
||||
if input:wasPressed("left") then
|
||||
stepCurrent(-1)
|
||||
return
|
||||
elseif input:wasPressed("right") then
|
||||
stepCurrent(1)
|
||||
return
|
||||
elseif input:wasPressed("start") then
|
||||
confirmResetAll()
|
||||
return
|
||||
end
|
||||
end
|
||||
baseUpdate(self_, dt)
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return ShaderFXParamsScreen
|
||||
@@ -0,0 +1,159 @@
|
||||
-- The SHADER FX preset picker: OFF, every .slangp ShaderFX.list() finds, then a
|
||||
-- permanent DOWNLOAD SHADERS action row. `slot` picks which of ShaderFX.SLOTS
|
||||
-- A activates and which save.options key persists it. See docs/shaderfx.md.
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local ShaderFXScreen = setmetatable({}, { __index = ListMenu })
|
||||
ShaderFXScreen.__index = ShaderFXScreen
|
||||
|
||||
-- OFF plus the preset's display name with its extension stripped.
|
||||
local function label(entry)
|
||||
if not entry then return Strings("OFF") end
|
||||
return Strings((entry.name:gsub("%.slangp$", "")))
|
||||
end
|
||||
|
||||
-- One row's display state from its entry.converted, shared by new() and
|
||||
-- onChoose() so a successful convert updates a row exactly as building it did.
|
||||
local function applyRowState(item)
|
||||
if not item.entry then return end
|
||||
-- NOT `cond and a or b`: that idiom breaks when `a` is itself nil/false.
|
||||
if item.entry.converted then
|
||||
item.muted = false
|
||||
item.right = nil
|
||||
else
|
||||
item.muted = true
|
||||
item.right = Strings("CONVERT")
|
||||
end
|
||||
end
|
||||
|
||||
-- OFF, every real preset, then the permanent DOWNLOAD SHADERS action row.
|
||||
local function buildItems(active)
|
||||
local items = { { label = label(nil), entry = nil } }
|
||||
local selected = 1
|
||||
for _, entry in ipairs(ShaderFX.list()) do
|
||||
local item = { label = label(entry), entry = entry }
|
||||
applyRowState(item)
|
||||
items[#items + 1] = item
|
||||
if active and active.name == entry.name then selected = #items end
|
||||
end
|
||||
items[#items + 1] = { label = Strings("DOWNLOAD SHADERS"), download = true }
|
||||
return items, selected
|
||||
end
|
||||
|
||||
-- `slot` defaults to "main" so a caller that still pushes this screen with no
|
||||
-- argument keeps today's behavior rather than erroring on a nil slot key.
|
||||
function ShaderFXScreen.new(game, slot)
|
||||
slot = slot or "main"
|
||||
local optKey = ShaderFX.OPTION_KEY[slot]
|
||||
local title = (slot == "secondary") and "SHADER FX 2" or "SHADER FX"
|
||||
local items, selected = buildItems(ShaderFX.activeEntry(slot))
|
||||
|
||||
local self = setmetatable(ListMenu.new(game, title, items, {
|
||||
-- 7 rows leaves the title line and the one-line footer hint free.
|
||||
rows = 7,
|
||||
footer = Strings("SELECT:EDIT PARAMS"),
|
||||
}), ShaderFXScreen)
|
||||
-- Opens on the active preset (or OFF).
|
||||
self.index = selected
|
||||
self.downloadJob = nil
|
||||
|
||||
-- Rebuilds from ShaderFX.list() (e.g. after an install adds presets), keeping
|
||||
-- the cursor on the same row index (clamped) rather than resetting to OFF.
|
||||
local function refresh()
|
||||
local newItems = buildItems(ShaderFX.activeEntry(slot))
|
||||
self.items = newItems
|
||||
self.index = math.max(1, math.min(self.index, #newItems))
|
||||
end
|
||||
|
||||
-- Polls the in-flight buildbot download alongside ListMenu's own input
|
||||
-- handling. installDownloaded() itself is synchronous, a sub-second job.
|
||||
local baseUpdate = ListMenu.update
|
||||
self.update = function(self_, dt)
|
||||
if self_.downloadJob then
|
||||
local st = ShaderFX.downloadStatus(self_.downloadJob)
|
||||
local row = self_.items[#self_.items]
|
||||
if st.status == "pending" then
|
||||
local pct = st.progress and (" %d%%"):format(st.progress * 100) or ""
|
||||
row.right = Strings("...") .. pct
|
||||
elseif st.status == "ok" then
|
||||
local copied, err, unchanged = ShaderFX.installDownloaded(st.notModified)
|
||||
self_.downloadJob = nil
|
||||
if unchanged then
|
||||
require("src.core.Logger").info("ShaderFXScreen: buildbot presets already up to date")
|
||||
row.right = Strings("UP TO DATE")
|
||||
elseif copied then
|
||||
require("src.core.Logger").info("ShaderFXScreen: buildbot install copied %d files", copied)
|
||||
refresh()
|
||||
else
|
||||
require("src.core.Logger").error("ShaderFXScreen: buildbot install failed: %s", tostring(err))
|
||||
self_.items[#self_.items].right = Strings("FAILED")
|
||||
end
|
||||
else -- "error" or "cancelled"
|
||||
self_.downloadJob = nil
|
||||
require("src.core.Logger").error("ShaderFXScreen: buildbot download failed: %s", tostring(st.err))
|
||||
row.right = Strings("FAILED")
|
||||
end
|
||||
end
|
||||
baseUpdate(self_, dt)
|
||||
end
|
||||
|
||||
self.onChoose = function(item)
|
||||
if item.download then
|
||||
-- Ignore a repeat press while one is already in flight.
|
||||
if self.downloadJob then return end
|
||||
self.downloadJob = ShaderFX.downloadPresets()
|
||||
item.right = Strings("...")
|
||||
return
|
||||
end
|
||||
|
||||
-- A on an unconverted preset converts it in place instead of activating;
|
||||
-- the screen stays open either way, since a convert is not a selection.
|
||||
if item.entry and not item.entry.converted then
|
||||
local ok, err = ShaderFX.convert(item.entry)
|
||||
if not ok then
|
||||
require("src.core.Logger").error("ShaderFXScreen: convert failed for %s: %s",
|
||||
item.entry.name, tostring(err))
|
||||
end
|
||||
applyRowState(item)
|
||||
return
|
||||
end
|
||||
|
||||
local opts = game.save and game.save.options
|
||||
if not item.entry then
|
||||
ShaderFX.deactivate(slot)
|
||||
if opts then opts[optKey] = nil end
|
||||
else
|
||||
-- isConverted() is existence-only with no staleness check, so an explicit
|
||||
-- selection always reconverts. Human-paced, CPU-only, never per frame.
|
||||
local convOk, convErr = ShaderFX.convert(item.entry)
|
||||
if not convOk then
|
||||
require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s",
|
||||
item.entry.name, tostring(convErr))
|
||||
end
|
||||
local overrides = opts and opts.shaderfxParams and opts.shaderfxParams[item.entry.name]
|
||||
local ok = ShaderFX.activate(slot, item.entry, overrides)
|
||||
if opts then opts[optKey] = ok and item.entry.name or nil end
|
||||
end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
self:close()
|
||||
end
|
||||
|
||||
-- SELECT on a real, converted row opens its pragma-parameter editor; OFF, the
|
||||
-- action row and an unconverted preset have nothing to read metadata from.
|
||||
self.onSelectKey = function(item)
|
||||
if not item.entry or item.download or not item.entry.converted then return end
|
||||
Screens.push(game, "ShaderFXParamsScreen", item.entry)
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return ShaderFXScreen
|
||||
+90
-17
@@ -9,10 +9,12 @@
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local ShopMenu = {}
|
||||
@@ -21,7 +23,21 @@ local function txt(game, key, fallback)
|
||||
return game.data.text[key] or fallback
|
||||
end
|
||||
|
||||
local function buy(game, stock)
|
||||
-- engine/events/pokemart.asm:204 (.returnToMainPokemartMenu)
|
||||
local function anythingElse(game)
|
||||
return txt(game, "_PokemartAnythingElseText",
|
||||
Strings("Is there anything\nelse I can do?"))
|
||||
end
|
||||
|
||||
-- prompt refusals leave the list for the mart menu -- pokemart.asm:113
|
||||
local function refuse(game, menu, list, text)
|
||||
if list then list:close() end
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
menu.footer = anythingElse(game)
|
||||
end))
|
||||
end
|
||||
|
||||
local function buy(game, stock, menu)
|
||||
local items = {}
|
||||
for _, id in ipairs(stock) do
|
||||
local def = game.data.items[id]
|
||||
@@ -36,11 +52,14 @@ local function buy(game, stock)
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
|
||||
Strings("You don't have\nenough money."))
|
||||
local bagFull = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
local list
|
||||
list = ListMenu.new(game, "BUY", items, {
|
||||
dialogue = true,
|
||||
money = function() return game.save.money end,
|
||||
footer = greet,
|
||||
onCancel = function() menu.footer = anythingElse(game) end,
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
if game.save.money < def.price then
|
||||
@@ -66,18 +85,22 @@ local function buy(game, stock)
|
||||
return
|
||||
end
|
||||
if game.save.money < cost then
|
||||
list.footer = notEnough
|
||||
refuse(game, menu, list, notEnough)
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
refuse(game, menu, list, bagFull)
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
game.save.money = game.save.money - cost
|
||||
list.footer = txt(game, "_PokemartBoughtItemText",
|
||||
Strings("Here you are!\nThank you!"))
|
||||
-- SFX_PURCHASE drains before the receipt -- pokemart.asm:193
|
||||
game.stack:push(TextBox.new(game,
|
||||
txt(game, "_PokemartBoughtItemText",
|
||||
Strings("Here you are!\nThank you!")),
|
||||
function() list.footer = greet end,
|
||||
{ preSound = function()
|
||||
return require("src.core.Sound").play(game.data, "Purchase")
|
||||
end }))
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
@@ -86,7 +109,7 @@ local function buy(game, stock)
|
||||
game.stack:push(list)
|
||||
end
|
||||
|
||||
local function sell(game)
|
||||
local function sell(game, menu)
|
||||
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
|
||||
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
|
||||
-- in the quantity chooser. Stuffing "xN" into the label next to a
|
||||
@@ -100,12 +123,22 @@ local function sell(game)
|
||||
right = "x" .. game.save.inventory[id],
|
||||
})
|
||||
end
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
-- engine/events/pokemart.asm:50
|
||||
local greet = txt(game, "_PokemonSellingGreetingText",
|
||||
Strings("What would you\nlike to sell?"))
|
||||
if #items == 0 then
|
||||
refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText",
|
||||
Strings("You don't have\nanything to sell.")))
|
||||
return
|
||||
end
|
||||
local unsellable = txt(game, "_PokemartUnsellableItemText",
|
||||
Strings("I can't put a\nprice on that."))
|
||||
local list
|
||||
list = ListMenu.new(game, "SELL", items, {
|
||||
dialogue = true,
|
||||
money = function() return game.save.money end,
|
||||
footer = greet,
|
||||
onCancel = function() menu.footer = anythingElse(game) end,
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
if not l.swapIndex then
|
||||
@@ -135,8 +168,7 @@ local function sell(game)
|
||||
-- indexing nil below -- guards saves that already picked up a bogus
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
Strings("I can't put a\nprice on that."))
|
||||
refuse(game, menu, list, unsellable)
|
||||
return
|
||||
end
|
||||
local unit = math.floor(def.price / 2)
|
||||
@@ -157,6 +189,8 @@ local function sell(game)
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money + unit * qty
|
||||
-- home/inventory.asm:15 AddAmountSoldToMoney sounds SFX_PURCHASE
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
@@ -164,7 +198,8 @@ local function sell(game)
|
||||
else
|
||||
list:removeCurrent()
|
||||
end
|
||||
list.footer = txt(game, "_PokemartThankYouText", "Thank you!")
|
||||
-- a sale prints nothing -- engine/events/pokemart.asm:112
|
||||
list.footer = greet
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
@@ -173,15 +208,53 @@ local function sell(game)
|
||||
game.stack:push(list)
|
||||
end
|
||||
|
||||
-- MONEY_BOX 11,0 (data/text_boxes.asm:35) over the greeting PrintText left
|
||||
-- in the bottom box (home/text_script.asm:143)
|
||||
local function drawClerk(menu)
|
||||
local game = menu.game
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local money = ("¥%d"):format((game.save and game.save.money) or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if menu.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(TextBox.paginate(menu.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function ShopMenu.new(game, stock, onQuit)
|
||||
-- keepOpen: the mart menu stays underneath its list so closing the
|
||||
-- list lands back here; only QUIT (or B) leaves and fires onQuit
|
||||
local menu = Menu.new(game, {
|
||||
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end },
|
||||
{ label = Strings("QUIT"), onSelect = onQuit },
|
||||
local menu
|
||||
-- engine/events/pokemart.asm:220 .done, reached by QUIT and by B alike
|
||||
local function farewell()
|
||||
game.stack:push(TextBox.new(game,
|
||||
txt(game, "_PokemartThankYouText", "Thank you!"), onQuit))
|
||||
end
|
||||
menu = Menu.new(game, {
|
||||
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end },
|
||||
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end },
|
||||
{ label = Strings("QUIT"), onSelect = farewell },
|
||||
}, { tx = 0, ty = 0, tw = 8, th = 8 })
|
||||
menu.onCancel = onQuit
|
||||
menu.onCancel = farewell
|
||||
menu.footer = txt(game, "_PokemartGreetingText",
|
||||
Strings("Hi there!\nMay I help you?"))
|
||||
menu.draw = function(self)
|
||||
drawClerk(self)
|
||||
Menu.draw(self)
|
||||
end
|
||||
return menu
|
||||
end
|
||||
|
||||
|
||||
@@ -42,9 +42,14 @@ function StartMenu.new(game)
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
table.insert(items, { label = Strings("ITEM"), onSelect = function()
|
||||
Screens.push(game, "BagMenu", { onCancel = reopen })
|
||||
end })
|
||||
-- StartMenu_Item draws LIST_MENU_BOX over the still-drawn START menu and
|
||||
-- only redisplays it on the way out (start_sub_menus.asm:302-329) #1745
|
||||
table.insert(items, { label = Strings("ITEM"), keepOpen = true,
|
||||
onSelect = function()
|
||||
Screens.push(game, "BagMenu", { onClose = function()
|
||||
if menu and game.stack:top() == menu then game.stack:pop() end
|
||||
end })
|
||||
end })
|
||||
|
||||
-- the player's name opens the trainer card (StartMenu_TrainerInfo)
|
||||
table.insert(items, { label = game.save.player.name or "RED",
|
||||
|
||||
+13
-11
@@ -100,11 +100,11 @@ end
|
||||
-- The 4-colour palette the HP-bar cells draw with: white, the bar's own light
|
||||
-- colour, the state's fill colour, black -- which is HPBarPals' two colours
|
||||
-- bracketed the way every Gen 2 palette is.
|
||||
function BattleHud:barColors(key)
|
||||
function BattleHud:barColors(key, zero)
|
||||
local pal = self.palettes and self.palettes.hpBar and self.palettes.hpBar[key]
|
||||
if not pal then return nil end
|
||||
return {
|
||||
{ 255, 255, 255 },
|
||||
zero or { 255, 255, 255 },
|
||||
{ pal[1][1], pal[1][2], pal[1][3] },
|
||||
{ pal[2][1], pal[2][2], pal[2][3] },
|
||||
{ 0, 0, 0 },
|
||||
@@ -145,9 +145,9 @@ end
|
||||
-- HUD's bar minus the "HP:" prefix -- same tiles, same HPBarPals colour, same
|
||||
-- one-pixel-at-a-time fill. Sharing this method is what keeps the two screens
|
||||
-- from ever disagreeing about how full a bar looks.
|
||||
function BattleHud:drawBar(hp, maxHp, tx, ty)
|
||||
function BattleHud:drawBar(hp, maxHp, tx, ty, zero)
|
||||
local pixels = HpBar.pixels(hp, maxHp)
|
||||
local colors = self:barColors(HpBar.palette(pixels))
|
||||
local colors = self:barColors(HpBar.palette(pixels), zero)
|
||||
for cell = 0, HpBar.LENGTH_TILES - 1 do
|
||||
local remaining = pixels - cell * 8
|
||||
local filled = math.max(0, math.min(8, remaining))
|
||||
@@ -160,9 +160,11 @@ end
|
||||
-- "HP:" plus the six bar cells plus the end cap, starting at tile (tx, ty).
|
||||
-- Returns the column just past the assembly (tx + 9), so the caller can put the
|
||||
-- frame's vertical stub there.
|
||||
function BattleHud:drawHpBar(hp, maxHp, tx, ty)
|
||||
-- `zero` overrides colour 0: the stats screen puts the page tint there
|
||||
-- (engine/gfx/color.asm:386-390). #1693
|
||||
function BattleHud:drawHpBar(hp, maxHp, tx, ty, zero)
|
||||
local pixels = HpBar.pixels(hp, maxHp)
|
||||
local colors = self:barColors(HpBar.palette(pixels))
|
||||
local colors = self:barColors(HpBar.palette(pixels), zero)
|
||||
-- The "HP:" badge sits inside the bar's own attrmap region, so it wears the
|
||||
-- HP palette too: its background is HPBarPals' light colour (the cream the
|
||||
-- cart shows) and its letters are black. Drawing it white-on-black was the
|
||||
@@ -170,7 +172,7 @@ function BattleHud:drawHpBar(hp, maxHp, tx, ty)
|
||||
self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL, tx, ty, colors)
|
||||
self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL + 1, tx + 1, ty,
|
||||
colors)
|
||||
self:drawBar(hp, maxHp, tx + 2, ty)
|
||||
self:drawBar(hp, maxHp, tx + 2, ty, zero)
|
||||
self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_END,
|
||||
tx + 2 + HpBar.LENGTH_TILES, ty, colors)
|
||||
return tx + 3 + HpBar.LENGTH_TILES
|
||||
@@ -198,23 +200,23 @@ local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
|
||||
|
||||
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
|
||||
-- (engine/gfx/cgb_layouts.asm:142-145).
|
||||
function BattleHud:expColors()
|
||||
function BattleHud:expColors(zero)
|
||||
local pal = self.palettes and self.palettes.expBar
|
||||
if not pal then return nil end
|
||||
return {
|
||||
{ 255, 255, 255 },
|
||||
zero or { 255, 255, 255 },
|
||||
{ pal[1][1], pal[1][2], pal[1][3] },
|
||||
{ pal[2][1], pal[2][2], pal[2][3] },
|
||||
{ 0, 0, 0 },
|
||||
}
|
||||
end
|
||||
|
||||
function BattleHud:drawExpBar(fraction, tx, ty)
|
||||
function BattleHud:drawExpBar(fraction, tx, ty, zero)
|
||||
if not self:image("hpBar") then return false end
|
||||
fraction = math.max(0, math.min(1, fraction or 0))
|
||||
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
|
||||
-- The whole row wears the exp bar's palette, full and empty cells included.
|
||||
local colors = self:expColors()
|
||||
local colors = self:expColors(zero)
|
||||
|
||||
local remaining = pixels
|
||||
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
|
||||
|
||||
@@ -209,6 +209,12 @@ end
|
||||
function BattleState:wantsFillScale() return true end
|
||||
function BattleState:drawsWidescreen() return true end
|
||||
|
||||
-- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars.
|
||||
function BattleState:bgMode()
|
||||
local options = self.game and self.game.options
|
||||
return (options and options.battleBg) == "black" and "black" or "white"
|
||||
end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
@@ -2184,15 +2190,21 @@ function BattleState:update(_dt)
|
||||
end
|
||||
|
||||
if self.phase == "menu" then
|
||||
-- 2x2 grid: left/right swap the column, up/down the row.
|
||||
-- 2x2 grid: left/right swap the column, up/down the row. No
|
||||
-- STATICMENU_WRAP in BattleMenuHeader (engine/battle/menu.asm:31-33), so
|
||||
-- the cursor clamps at each edge (engine/menus/menu.asm:156-166) (#1706).
|
||||
-- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2
|
||||
-- on A/B only, never on D-pad.
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1
|
||||
or self.menuIndex - 1
|
||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2
|
||||
or self.menuIndex - 2
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") then
|
||||
self.menuIndex = row * 2 + math.max(0, col - 1) + 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.menuIndex = row * 2 + math.min(1, col + 1) + 1
|
||||
elseif input:wasPressed("up") then
|
||||
self.menuIndex = math.max(0, row - 1) * 2 + col + 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.menuIndex = math.min(1, row + 1) * 2 + col + 1
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
|
||||
@@ -2819,8 +2831,9 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
|
||||
table.insert(box, 1, enemy)
|
||||
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
|
||||
-- (move_mon.asm:1062-1063).
|
||||
Boxes.restorePP(enemy)
|
||||
-- (move_mon.asm:1062-1063); the box_struct it writes carries no HP and no
|
||||
-- status at all (macros/ram.asm:7-26). #1696
|
||||
Boxes.enterBox(enemy)
|
||||
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
|
||||
-- BATTLERESULT_BOX_FULL when the box has just filled
|
||||
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
|
||||
|
||||
@@ -432,7 +432,7 @@ function BoxMenu:insertMon()
|
||||
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
|
||||
-- .CopyToBox is InsertPokemonIntoBox, which tails into
|
||||
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
|
||||
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
|
||||
if not self:isParty(destIndex) then Boxes.enterBox(mon) end
|
||||
self.phase = nil
|
||||
self.moveFrom, self.backup = nil, nil
|
||||
self.index, self.scroll = 1, 0
|
||||
@@ -453,9 +453,8 @@ function BoxMenu:cancelMove()
|
||||
self:clampIndex()
|
||||
end
|
||||
|
||||
-- BillsPC_PressLeft / BillsPC_PressRight. The move screen wraps through box 0
|
||||
-- (the PARTY); the withdraw list has no party to walk into, so it wraps inside
|
||||
-- the fourteen boxes.
|
||||
-- BillsPC_PressLeft / BillsPC_PressRight, reached only from
|
||||
-- MoveMonWithoutMail_DPad: the move screen wraps through box 0 (the PARTY).
|
||||
function BoxMenu:stepBox(delta)
|
||||
local low = self.mode == "move" and PARTY_BOX or 1
|
||||
local span = Boxes.NUM_BOXES - low + 1
|
||||
@@ -531,9 +530,11 @@ function BoxMenu:update(_dt)
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < total and self.index + 1 or 1
|
||||
self:ensureVisible()
|
||||
elseif input:wasPressed("left") and self.mode ~= "deposit" then
|
||||
-- Withdraw_UpDown reads PAD_UP and PAD_DOWN and nothing else; only
|
||||
-- MoveMonWithoutMail_DPad walks the boxes (bills_pc.asm:806-820, :822-845).
|
||||
elseif input:wasPressed("left") and self.mode == "move" then
|
||||
self:stepBox(-1)
|
||||
elseif input:wasPressed("right") and self.mode ~= "deposit" then
|
||||
elseif input:wasPressed("right") and self.mode == "move" then
|
||||
self:stepBox(1)
|
||||
elseif input:wasPressed("a") then
|
||||
self:act()
|
||||
|
||||
@@ -282,12 +282,12 @@ SEQUENCES.IntroPichuWooper = function(_, st)
|
||||
end
|
||||
|
||||
-- SpriteAnimFunc_IntroUnown (engine/sprite_anims/functions.asm:797-821):
|
||||
-- circle at radius VAR1, three sine steps per frame.
|
||||
-- VAR1 is the angle, the counter is the radius (core.asm:543-545).
|
||||
SEQUENCES.IntroUnown = function(_, st)
|
||||
local angle = st.jt
|
||||
local radius = st.jt
|
||||
st.jt = (st.jt + 3) % 256
|
||||
st.yOffset = SpriteAnims.sine(angle, st.var1)
|
||||
st.xOffset = SpriteAnims.cosine(angle, st.var1)
|
||||
st.yOffset = SpriteAnims.sine(st.var1, radius)
|
||||
st.xOffset = SpriteAnims.cosine(st.var1, radius)
|
||||
end
|
||||
|
||||
-- SpriteAnimFunc_IntroUnownF (engine/sprite_anims/functions.asm:823-829):
|
||||
@@ -488,7 +488,7 @@ local function unownFade(self, pal, t)
|
||||
end
|
||||
|
||||
-- CrystalIntro_InitUnownAnim (engine/movie/intro.asm:1191-1229): four structs
|
||||
-- at one spot, radii $08/$18/$28/$38, framesets 4/3/1/2.
|
||||
-- at one spot, angles $08/$18/$28/$38, framesets 4/3/1/2.
|
||||
local UNOWN_SWIRL = {
|
||||
{ 0x08, "IntroUnown4" }, { 0x18, "IntroUnown3" },
|
||||
{ 0x28, "IntroUnown1" }, { 0x38, "IntroUnown2" },
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm:1-342):
|
||||
-- Ditto bounces in, rests, transforms into the logo, then GAME FREAK / presents.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Music = require("src.core.Music")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||
|
||||
local CrystalSplash = {}
|
||||
CrystalSplash.__index = CrystalSplash
|
||||
CrystalSplash.isOpaque = true
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- splash.asm:91 `depixel 10, 11, 4, 0` is y-first: x=e, y=d
|
||||
-- (pokecrystal engine/sprite_anims/core.asm:113).
|
||||
local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4
|
||||
-- pokecrystal constants/gfx_constants.asm:36 OAM_YCOORD_HIDDEN.
|
||||
local YCOORD_HIDDEN = 160
|
||||
|
||||
-- splash.asm:161-164 and :183-186; tile $0d is the logo's own first tile
|
||||
-- borrowed as the space.
|
||||
local GAME_FREAK = { 0x00, 0x01, 0x02, 0x03, 0x0d, 0x04, 0x05, 0x03, 0x01, 0x06 }
|
||||
local GAME_FREAK_X, GAME_FREAK_Y = 5, 10
|
||||
local PRESENTS = { 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c }
|
||||
local PRESENTS_X, PRESENTS_Y = 7, 11
|
||||
|
||||
-- splash.asm:121-122 `ld c, 16 / call DelayFrames`.
|
||||
local EXIT_FRAMES = 16
|
||||
|
||||
local BLACK = { 0, 0, 0 }
|
||||
local WHITE = { 255, 255, 255 }
|
||||
|
||||
-- pokecrystal gfx/splash/ditto.pal, loaded into OBJ pals 0 and 1 by
|
||||
-- _CGB_GamefreakLogo (engine/gfx/cgb_layouts.asm:876-893).
|
||||
local DITTO_OB = { WHITE, { 107, 90, 0 }, { 189, 99, 230 }, BLACK }
|
||||
-- pokecrystal gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG.
|
||||
local DEFAULT_BG = { BLACK, { 66, 90, 90 }, { 173, 173, 173 }, WHITE }
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Sprite-anim data, registered into SpriteAnims' shared tables
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- dbsprite (pokecrystal macros/gfx.asm): y byte first.
|
||||
local function s(xTile, yTile, xPixel, yPixel, tile, attr)
|
||||
return {
|
||||
y = (yTile * 8 + yPixel) % 256,
|
||||
x = (xTile * 8 + xPixel) % 256,
|
||||
tile = tile,
|
||||
attr = attr,
|
||||
}
|
||||
end
|
||||
|
||||
-- pokecrystal data/sprite_anims/oam.asm:1098-1108 .OAMData_GameFreakLogo1_3.
|
||||
local DITTO_SMALL = {}
|
||||
for row = 0, 2 do
|
||||
for col = 0, 2 do
|
||||
DITTO_SMALL[#DITTO_SMALL + 1] =
|
||||
s(col - 2, row - 2, 4, 0, row * 0x10 + col, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- pokecrystal data/sprite_anims/oam.asm:1110-1135 .OAMData_GameFreakLogo4_11.
|
||||
local DITTO_MORPH = {}
|
||||
for row = 0, 5 do
|
||||
for col = 0, 3 do
|
||||
DITTO_MORPH[#DITTO_MORPH + 1] =
|
||||
s(col - 2, row - 5, 4, 0, row * 0x10 + col, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- pokecrystal data/sprite_anims/oam.asm:139-149: vtile bases into the
|
||||
-- 256-tile Ditto sheet GameFreakPresentsInit loads whole (splash.asm:72-85).
|
||||
local OAMSETS = {
|
||||
CRYSTAL_GAMEFREAK_LOGO_1 = { 0xd0, DITTO_SMALL },
|
||||
CRYSTAL_GAMEFREAK_LOGO_2 = { 0xd3, DITTO_SMALL },
|
||||
CRYSTAL_GAMEFREAK_LOGO_3 = { 0xd6, DITTO_SMALL },
|
||||
CRYSTAL_GAMEFREAK_LOGO_4 = { 0x6c, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_5 = { 0x68, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_6 = { 0x64, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_7 = { 0x60, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_8 = { 0x0c, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_9 = { 0x08, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_10 = { 0x04, DITTO_MORPH },
|
||||
CRYSTAL_GAMEFREAK_LOGO_11 = { 0x00, DITTO_MORPH },
|
||||
}
|
||||
|
||||
local function f(oamset, duration, flags)
|
||||
return { oamset = oamset, duration = duration, flags = flags or 0 }
|
||||
end
|
||||
|
||||
-- pokecrystal data/sprite_anims/framesets.asm:142-158 .Frameset_GameFreakLogo.
|
||||
local FRAMESETS = {
|
||||
CrystalGameFreakLogo = {
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 1),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_3", 1), f("CRYSTAL_GAMEFREAK_LOGO_2", 4),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 12),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_3", 4), f("CRYSTAL_GAMEFREAK_LOGO_4", 32),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_5", 3), f("CRYSTAL_GAMEFREAK_LOGO_6", 3),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_7", 4), f("CRYSTAL_GAMEFREAK_LOGO_8", 4),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_9", 4), f("CRYSTAL_GAMEFREAK_LOGO_10", 10),
|
||||
f("CRYSTAL_GAMEFREAK_LOGO_11", 7),
|
||||
"end",
|
||||
},
|
||||
}
|
||||
|
||||
local SEQUENCES = {}
|
||||
|
||||
-- GameFreakLogoSpriteAnim (splash.asm:201-342): VAR1 jump height, VAR2 sine
|
||||
-- offset / frame counter; sys.flag carries the transform's NextScene call.
|
||||
SEQUENCES.CrystalGameFreakLogo = function(sys, st)
|
||||
local splash = sys.splash
|
||||
if st.jt == 0 then
|
||||
-- GameFreakLogo_Init (splash.asm:221-225).
|
||||
st.jt = 1
|
||||
elseif st.jt == 1 then
|
||||
-- GameFreakLogo_Bounce (splash.asm:227-283).
|
||||
if st.var1 == 0 then
|
||||
st.jt = 2
|
||||
st.var2 = 0
|
||||
if splash then splash:playSfx("Sfx_DittoPopUp") end
|
||||
return
|
||||
end
|
||||
local angle = st.var2 % 0x40
|
||||
if angle < 32 then angle = angle + 32 end
|
||||
st.yOffset = SpriteAnims.sine(angle, st.var1)
|
||||
local before = st.var2
|
||||
st.var2 = (st.var2 - 1) % 256
|
||||
if before % 0x20 == 0 then
|
||||
st.var1 = (st.var1 - 48) % 256
|
||||
if splash then splash:playSfx("Sfx_DittoBounce") end
|
||||
end
|
||||
elseif st.jt == 2 then
|
||||
-- GameFreakLogo_Ditto (splash.asm:285-304).
|
||||
if st.var2 >= 32 then
|
||||
st.jt = 3
|
||||
st.var2 = 0
|
||||
if splash then splash:playSfx("Sfx_DittoTransform") end
|
||||
else
|
||||
st.var2 = st.var2 + 1
|
||||
end
|
||||
elseif st.jt == 3 then
|
||||
-- GameFreakLogo_Transform (splash.asm:306-340).
|
||||
if st.var2 == 64 then
|
||||
st.jt = 4
|
||||
sys.flag = 1
|
||||
else
|
||||
local step = math.floor(st.var2 / 4)
|
||||
st.var2 = st.var2 + 1
|
||||
if splash then splash:fadeTo(step) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- pokecrystal data/sprite_anims/objects.asm:11-12 SPRITE_ANIM_OBJ_GAMEFREAK_LOGO.
|
||||
local OBJECTS = {
|
||||
CRYSTAL_GAMEFREAK_LOGO = { "CrystalGameFreakLogo", "CrystalGameFreakLogo" },
|
||||
}
|
||||
|
||||
local function register(target, entries)
|
||||
for name, value in pairs(entries) do
|
||||
if target[name] == nil then target[name] = value end
|
||||
end
|
||||
end
|
||||
|
||||
register(SpriteAnims.OAMSETS, OAMSETS)
|
||||
register(SpriteAnims.FRAMESETS, FRAMESETS)
|
||||
register(SpriteAnims.OBJECTS, OBJECTS)
|
||||
register(SpriteAnims.SEQUENCES, SEQUENCES)
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The screen
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
function CrystalSplash:wantsFillScale() return true end
|
||||
function CrystalSplash:drawsWidescreen() return true end
|
||||
|
||||
-- opts: oakSpeech (data/generated/oak_speech.lua, for its `splash` table),
|
||||
-- onDone(skipped)
|
||||
function CrystalSplash.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, CrystalSplash)
|
||||
self.game = game
|
||||
self.onDone = opts.onDone
|
||||
local splash = (opts.oakSpeech or {}).splash or {}
|
||||
local ob = splash.dittoPalette or DITTO_OB
|
||||
self.obColors = { ob[1], ob[2], ob[3], ob[4] }
|
||||
self.bgColors = splash.bgPalette or DEFAULT_BG
|
||||
self.dittoFade = splash.dittoFade
|
||||
|
||||
-- GameFreakLogoGFX's 28 1bpp tiles land at vTiles2 $00 (splash.asm:62-65):
|
||||
-- letters $00-$0c, the 3x5 logo $0d-$1b.
|
||||
self.sheets = {
|
||||
TileSheet.new({ path = splash.presents
|
||||
or "assets/generated/splash/presents.png", wide = 13, firstTile = 0x00 }),
|
||||
TileSheet.new({ path = splash.logo
|
||||
or "assets/generated/splash/logo.png", wide = 3, firstTile = 0x0d }),
|
||||
}
|
||||
self.ditto = TileSheet.new({ path = splash.ditto
|
||||
or "assets/generated/splash/ditto.png",
|
||||
wide = splash.dittoTilesWide or 16, firstTile = 0x00 })
|
||||
|
||||
self.anims = SpriteAnims.new()
|
||||
self.anims.splash = self
|
||||
-- splash.asm:91-102: spawn hidden, VAR1=96, VAR2=48.
|
||||
local st = self.anims:init("CRYSTAL_GAMEFREAK_LOGO", LOGO_X, LOGO_Y)
|
||||
if st then
|
||||
st.yOffset = YCOORD_HIDDEN
|
||||
st.var1 = 96
|
||||
st.var2 = 48
|
||||
end
|
||||
|
||||
self.scene = 0
|
||||
self.timer = 0
|
||||
self.tiles = {} -- the BG tilemap, sparse: [y][x] = tile id
|
||||
self.frames = 0
|
||||
self.exitTail = nil
|
||||
self.done = false
|
||||
self.skipped = nil
|
||||
return self
|
||||
end
|
||||
|
||||
function CrystalSplash:enter()
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime then
|
||||
Music.stop()
|
||||
end
|
||||
if Runtime.wants("intro.boot.gamefreak") then
|
||||
Runtime.emit("intro.boot.gamefreak", { screen = self, game = self.game })
|
||||
end
|
||||
end
|
||||
|
||||
function CrystalSplash:finish()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
if self.onDone then self.onDone(self.skipped) end
|
||||
end
|
||||
|
||||
function CrystalSplash:playSfx(name)
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.sfx and data.audio.sfx[name] then
|
||||
Sound.play(data, name)
|
||||
end
|
||||
end
|
||||
|
||||
-- The transform walks OBJ pal 1 colour 2 down GameFreakDittoPaletteFade
|
||||
-- (splash.asm:306-334, gfx/splash/ditto_fade.pal).
|
||||
function CrystalSplash:fadeTo(step)
|
||||
local fade = self.dittoFade
|
||||
local color = fade and fade[step + 1]
|
||||
if color then self.obColors[3] = color end
|
||||
end
|
||||
|
||||
function CrystalSplash:placeString(tiles, tx, ty)
|
||||
local row = self.tiles[ty]
|
||||
if not row then
|
||||
row = {}
|
||||
self.tiles[ty] = row
|
||||
end
|
||||
for index, tile in ipairs(tiles) do
|
||||
row[tx + index - 1] = tile
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- GameFreakPresentsScene (splash.asm:125-199)
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
function CrystalSplash:sceneWaitSpriteAnim()
|
||||
if self.anims.flag == 0 then return end
|
||||
self.scene = 1
|
||||
self.timer = 0
|
||||
end
|
||||
|
||||
function CrystalSplash:scenePlaceGameFreak()
|
||||
if self.timer < 32 then
|
||||
self.timer = self.timer + 1
|
||||
return
|
||||
end
|
||||
self.timer = 0
|
||||
self:placeString(GAME_FREAK, GAME_FREAK_X, GAME_FREAK_Y)
|
||||
self.scene = 2
|
||||
self:playSfx("Sfx_GameFreakPresents")
|
||||
end
|
||||
|
||||
function CrystalSplash:scenePlacePresents()
|
||||
if self.timer < 64 then
|
||||
self.timer = self.timer + 1
|
||||
return
|
||||
end
|
||||
self.timer = 0
|
||||
self:placeString(PRESENTS, PRESENTS_X, PRESENTS_Y)
|
||||
self.scene = 3
|
||||
end
|
||||
|
||||
function CrystalSplash:sceneWaitForTimer()
|
||||
if self.timer < 128 then
|
||||
self.timer = self.timer + 1
|
||||
return
|
||||
end
|
||||
self:beginExit()
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
CrystalSplash.sceneWaitSpriteAnim,
|
||||
CrystalSplash.scenePlaceGameFreak,
|
||||
CrystalSplash.scenePlacePresents,
|
||||
CrystalSplash.sceneWaitForTimer,
|
||||
}
|
||||
|
||||
-- GameFreakPresentsEnd (splash.asm:117-123).
|
||||
function CrystalSplash:beginExit()
|
||||
if self.exitTail then return end
|
||||
self.anims:clear()
|
||||
self.tiles = {}
|
||||
self.exitTail = 0
|
||||
end
|
||||
|
||||
function CrystalSplash:update(_dt)
|
||||
self.frames = self.frames + 1
|
||||
if self.exitTail then
|
||||
self.exitTail = self.exitTail + 1
|
||||
if self.exitTail > EXIT_FRAMES then self:finish() end
|
||||
return
|
||||
end
|
||||
local input = self.game and self.game.input
|
||||
if input and (input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") or input:wasPressed("select")) then
|
||||
-- SplashScreen.pressed_button (splash.asm:51-54) returns carry.
|
||||
self.skipped = true
|
||||
self:beginExit()
|
||||
return
|
||||
end
|
||||
local scene = SCENES[self.scene + 1]
|
||||
if scene then scene(self) end
|
||||
self.anims:playFrame()
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Drawing
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
function CrystalSplash:sheetFor(tile)
|
||||
for _, sheet in ipairs(self.sheets) do
|
||||
if tile >= sheet.firstTile and sheet:available() then
|
||||
local index = tile - sheet.firstTile
|
||||
local image = sheet:image()
|
||||
local _, height = image:getDimensions()
|
||||
if index < sheet.wide * (height / 8) then return sheet end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function CrystalSplash:drawTile(tile, tx, ty, colors)
|
||||
local sheet = self:sheetFor(tile)
|
||||
if not sheet then return end
|
||||
sheet.palette = colors
|
||||
sheet:draw(tile, tx, ty)
|
||||
end
|
||||
|
||||
function CrystalSplash:drawObjects()
|
||||
local G = love.graphics
|
||||
local sheet = self.ditto
|
||||
if not sheet:available() then return end
|
||||
local oam = self.anims.oam
|
||||
for index = #oam, 1, -1 do
|
||||
local entry = oam[index]
|
||||
local quad = sheet:quad(entry.tile)
|
||||
if quad then
|
||||
local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1
|
||||
local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1
|
||||
local function body()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(sheet:image(), quad,
|
||||
entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0),
|
||||
0, flipX and -1 or 1, flipY and -1 or 1)
|
||||
end
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(self.obColors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function CrystalSplash:drawPanel()
|
||||
local G = love.graphics
|
||||
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for ty, row in pairs(self.tiles) do
|
||||
for tx, tile in pairs(row) do
|
||||
self:drawTile(tile, tx, ty, self.bgColors)
|
||||
end
|
||||
end
|
||||
self:drawObjects()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function CrystalSplash:draw()
|
||||
self:drawPanel()
|
||||
end
|
||||
|
||||
function CrystalSplash:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
return CrystalSplash
|
||||
@@ -45,9 +45,9 @@ local SCREEN_W, SCREEN_H = 160, 144
|
||||
-- wSpriteAnimDict[SPRITE_ANIM_DICT_GS_SPLASH] = $8d (GameFreakPresentsInit).
|
||||
local DICT_VTILE = 0x8d
|
||||
|
||||
-- `depixel 10, 11, 4, 0` and `depixel 11, 11`: the macro is
|
||||
-- (x tile, y tile, x pixel, y pixel) and lands in de as x, y.
|
||||
local LOGO_X, LOGO_Y = 10 * 8 + 4, 11 * 8 + 0
|
||||
-- `depixel 10, 11, 4, 0` and `depixel 11, 11` are y-first: x=e, y=d
|
||||
-- (engine/sprite_anims/core.asm:113).
|
||||
local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4
|
||||
local SPARKLE_X, SPARKLE_Y = 11 * 8, 11 * 8
|
||||
|
||||
-- GameFreakPresents_PlaceGameFreak / _PlacePresents. $8d is the logo's own
|
||||
@@ -152,7 +152,7 @@ end
|
||||
function GameFreakPresents:finish()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
if self.onDone then self.onDone() end
|
||||
if self.onDone then self.onDone(self.skipped) end
|
||||
end
|
||||
|
||||
-- PlaceString into the sparse tilemap.
|
||||
@@ -280,6 +280,7 @@ function GameFreakPresents:update(_dt)
|
||||
if input and (input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") or input:wasPressed("select")) then
|
||||
-- .pressed_button: everything is torn down and the splash is over.
|
||||
self.skipped = true
|
||||
self:beginExit()
|
||||
return
|
||||
end
|
||||
|
||||
+49
-13
@@ -20,6 +20,7 @@
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
@@ -112,6 +113,22 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return FILTERS[(options.musicFilter or 0) + 1]
|
||||
end },
|
||||
-- Heads the port's display group, same spot src/ui/OptionsMenu.lua's own
|
||||
-- PERFORMANCE row occupies relative to ZOOM/VOID FILL/TILT/SHADER FX below
|
||||
-- (the extras this tier scales). Gen 1 row shape (`value`/`step`, not this
|
||||
-- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle
|
||||
-- answers `row.step` first, and drawPanel already reads a function
|
||||
-- `row.value` -- both written for exactly this kind of shared mod row.
|
||||
{ id = "performance", label = "PERFORMANCE", port = true,
|
||||
value = function(g)
|
||||
return Performance.label(g.options and g.options.performance)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.options
|
||||
o.performance = Performance.cycle(o.performance, dir)
|
||||
g:applyOptions()
|
||||
return true
|
||||
end },
|
||||
{ label = "GAME SPEED", key = "speed", port = true,
|
||||
cycle = function(options, delta)
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
@@ -169,19 +186,33 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
|
||||
end },
|
||||
{ label = "GBC FX", key = "gbcfx", port = true,
|
||||
cycle = function(options, delta)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if not GBCFX.isSupported() then
|
||||
options.gbcfx = 0
|
||||
return
|
||||
end
|
||||
local level = ((options.gbcfx or 0) + delta) % 5
|
||||
options.gbcfx = level
|
||||
GBCFX.setLevel(level)
|
||||
end,
|
||||
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
|
||||
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
|
||||
-- ShaderFXScreen is the shared list screen both generations push, `id`
|
||||
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
|
||||
-- reaches Gold.
|
||||
{ id = "shaderfx", label = "SHADER FX", port = true,
|
||||
text = function(options)
|
||||
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local entry = ShaderFX.activeEntry("main")
|
||||
if not entry then return "OFF" end
|
||||
return (entry.name:gsub("%.slangp$", "")):upper()
|
||||
end,
|
||||
activate = function(game)
|
||||
require("src.ui.Screens").push(game, "ShaderFXScreen", "main")
|
||||
end },
|
||||
-- Dual-shader secondary slot, same shared ShaderFXScreen as the row
|
||||
-- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's
|
||||
-- mirror of this row for the full rationale.
|
||||
{ id = "shaderfx2", label = "SHADER FX 2", port = true,
|
||||
text = function(options)
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local entry = ShaderFX.activeEntry("secondary")
|
||||
if not entry then return "OFF" end
|
||||
return (entry.name:gsub("%.slangp$", "")):upper()
|
||||
end,
|
||||
activate = function(game)
|
||||
require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary")
|
||||
end },
|
||||
{ label = "VIDEO MODE", key = "videoMode", port = true,
|
||||
cycle = function(options, delta)
|
||||
@@ -240,6 +271,11 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return require("src.core.FrameCap").label(options.fpsCap)
|
||||
end },
|
||||
-- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE
|
||||
-- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only.
|
||||
{ label = "BATTLE BG", key = "battleBg", port = true,
|
||||
values = { "white", "black" },
|
||||
display = { white = "WHITE", black = "BLACK" } },
|
||||
{ label = "CANCEL", cancel = true },
|
||||
}
|
||||
|
||||
@@ -257,7 +293,7 @@ local function sameRows(_, rows) return rows end
|
||||
-- edit into the next opening -- the Gen 1 site rebuilds its descriptors for the
|
||||
-- same reason (src/ui/OptionsMenu.lua buildRows).
|
||||
--
|
||||
-- `id` is the key a Gen 1 mod filters a row on ("gbcfx", "speed", "musicVol")
|
||||
-- `id` is the key a Gen 1 mod filters a row on ("shaderfx", "speed", "musicVol")
|
||||
-- and is added ALONGSIDE this file's own `key`, never in place of it: a mod
|
||||
-- written against Red's OPTION screen finds the shared rows where it expects
|
||||
-- them, and the rows Gold has that Red does not (PRINT, MENU ACCOUNT, FRAME,
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
-- which DrawPackGFX swaps per pocket out of PackGFX
|
||||
-- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap
|
||||
-- _CGB_PackPals loads six BG palettes and colours five rectangles with
|
||||
-- them: the two header halves, the quantity column, the
|
||||
-- pocket plaque (red) and the bag picture (green)
|
||||
-- them: the two header halves, the CURSOR column (7,2) 1x9
|
||||
-- whose colour 3 is red, the pocket plaque (red) and the bag
|
||||
-- picture (green) -- engine/gfx/cgb_layouts.asm:715-734
|
||||
--
|
||||
-- A cache from before the pack stage simply has no `pack` table; PackMenu
|
||||
-- falls back to its plain boxes then.
|
||||
|
||||
+53
-27
@@ -229,9 +229,9 @@ function PackMenu.label(itemId, def)
|
||||
return (tostring(itemId):gsub("_", " "))
|
||||
end
|
||||
|
||||
-- TMHMPocket (engine/items/tmhm.asm) writes GetMoveName's string under the
|
||||
-- TM's own name, so the second line of a TM row is the MOVE's name and not the
|
||||
-- constant the attributes row carries.
|
||||
-- TMHM_DisplayPocketItems (engine/items/tmhm.asm:381-385) places GetMoveName's
|
||||
-- string three tiles right of the row's number, so a TM/HM row reads as the
|
||||
-- MOVE's name and the TM's own item name is never printed.
|
||||
function PackMenu:moveLabel(moveId)
|
||||
if not moveId then return nil end
|
||||
local moves = self.game and self.game.data and self.game.data.moves
|
||||
@@ -239,6 +239,17 @@ function PackMenu:moveLabel(moveId)
|
||||
return (def and def.name) or (tostring(moveId):gsub("_", " "))
|
||||
end
|
||||
|
||||
-- engine/items/tmhm.asm:357-375 -- the number a TM/HM row prints: a TM with
|
||||
-- PRINTNUM_LEADINGZEROS, an HM as 'H' and its own left-aligned ordinal.
|
||||
local function tmhmLabelFor(itemId, def)
|
||||
local digits = tostring((def and def.tmLabel) or (def and def.name)
|
||||
or itemId):match("(%d+)")
|
||||
local n = tonumber(digits)
|
||||
if not n then return nil end
|
||||
if tostring(itemId):sub(1, 3) == "HM_" then return "H" .. n end
|
||||
return ("%02d"):format(n)
|
||||
end
|
||||
|
||||
function PackMenu:rebuild()
|
||||
local pocket = self:pocket().id
|
||||
local rows = {}
|
||||
@@ -260,6 +271,7 @@ function PackMenu:rebuild()
|
||||
name = PackMenu.label(itemId, def),
|
||||
teaches = self:moveLabel(def and def.teaches),
|
||||
tmNumber = def and def.tmNumber,
|
||||
tmhmLabel = (pocket == "TM_HM" and tmhmLabelFor(itemId, def)) or nil,
|
||||
-- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the
|
||||
-- count for an HM only -- a TM prints ×NN like any other stack.
|
||||
showCount = pocket == "ITEM" or pocket == "BALL"
|
||||
@@ -929,13 +941,27 @@ function PackMenu:description()
|
||||
return def and def.description or nil
|
||||
end
|
||||
|
||||
-- engine/gfx/cgb_layouts.asm:723-726 -- the cursor column (7,2) 1x9 takes
|
||||
-- palette $3, whose colour 3 is red (gfx/pack/pack.pal).
|
||||
function PackMenu:cursorAt(tx, ty, hollow)
|
||||
local palette = self.gfx and self.gfx:available()
|
||||
and self.gfx:colorsAt(tx, ty)
|
||||
if palette then
|
||||
Chrome.cursorThrough(tx, ty, palette, false, hollow)
|
||||
else
|
||||
Chrome.cursor(tx, ty, hollow)
|
||||
end
|
||||
end
|
||||
|
||||
-- The list, description and cursor, on top of whatever chrome was drawn.
|
||||
--
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row
|
||||
-- DOWN and one column RIGHT of the name -- the quantity is the entry's second
|
||||
-- line, not a right-aligned column, which is why every PACK row is two tiles
|
||||
-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked,
|
||||
-- so the ones digit sits at name + 3 whether the count is 5 or 50.
|
||||
-- ScrollingMenu_CallFunctions1and2 (engine/menus/scrolling_menu.asm:424-429)
|
||||
-- steps the coord on by the header's `db 5, 8` COLUMN count before
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:19-25) adds SCREEN_WIDTH + 1,
|
||||
-- so the ×N sits at name + 9 on the row BELOW the name, flush right in every
|
||||
-- pocket (#1425, #1693). Its `lb bc, 1, 2` is a TWO-digit field with the
|
||||
-- leading digit blanked. engine/items/tmhm.asm:392-403 writes that same
|
||||
-- column for a TM.
|
||||
--
|
||||
-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the
|
||||
-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking.
|
||||
@@ -945,27 +971,23 @@ function PackMenu:drawList(listX, listY)
|
||||
local ty = listY + (row - 1) * LIST_SPACING
|
||||
if i <= #self.rows then
|
||||
local entry = self.rows[i]
|
||||
if i == self.index then
|
||||
Chrome.cursor(listX - 1, ty)
|
||||
elseif i == self.switching then
|
||||
Chrome.cursor(listX - 1, ty, true)
|
||||
-- engine/items/tmhm.asm:355-385 (#1695)
|
||||
if entry.tmhmLabel then
|
||||
Chrome.print(entry.tmhmLabel, listX - 3, ty)
|
||||
end
|
||||
Chrome.print(entry.name, listX, ty)
|
||||
if entry.teaches then
|
||||
-- The TM pocket puts the move the TM teaches on that second line, and
|
||||
-- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's
|
||||
-- line here, since the move name owns the one below it.
|
||||
Chrome.print(entry.teaches, listX + 1, ty + 1)
|
||||
if entry.showCount then
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 9, ty)
|
||||
end
|
||||
elseif entry.showCount then
|
||||
if i == self.index then
|
||||
self:cursorAt(listX - 1, ty)
|
||||
elseif i == self.switching then
|
||||
self:cursorAt(listX - 1, ty, true)
|
||||
end
|
||||
Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name,
|
||||
listX, ty)
|
||||
if entry.showCount then
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 1, ty + 1)
|
||||
listX + 9, ty + 1)
|
||||
end
|
||||
elseif i == self:total() then
|
||||
if i == self.index then Chrome.cursor(listX - 1, ty) end
|
||||
if i == self.index then self:cursorAt(listX - 1, ty) end
|
||||
Chrome.print("CANCEL", listX, ty)
|
||||
end
|
||||
end
|
||||
@@ -979,8 +1001,12 @@ function PackMenu:drawDescription(ty)
|
||||
local lines = self.message or (self.confirm and self.confirm.prompt)
|
||||
if lines then
|
||||
local name = self:playerName()
|
||||
-- TEXTBOX_INNERY and home/text.asm:397 LineChar: rows 14 and 16 (#1725).
|
||||
-- Only a message too tall for those two packs its rows one apart.
|
||||
local top, step = ty, 2
|
||||
if #lines > 2 then top, step = ty - 1, 1 end
|
||||
for i, line in ipairs(lines) do
|
||||
Chrome.print((line:gsub("{PLAYER}", name)), 1, ty + i - 2)
|
||||
Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1056,7 +1082,7 @@ function PackMenu:drawPanel()
|
||||
Chrome.box(0, 0, 20, 3)
|
||||
Chrome.print(self:pocket().label, 2, 1)
|
||||
Chrome.box(0, 3, 20, 12)
|
||||
self:drawList(2, 4)
|
||||
self:drawList(5, 4)
|
||||
Chrome.box(0, 12, 20, 6)
|
||||
self:drawDescription(14)
|
||||
self:drawOverlays()
|
||||
|
||||
@@ -933,6 +933,7 @@ function Pokegear.new(game, opts)
|
||||
-- reject already dropped, so the cursor's skip loop is just "next row".
|
||||
self.fly = opts.fly
|
||||
self.onFly = opts.onFly
|
||||
self.flyMon = opts.flyMon
|
||||
if self.fly and #self.fly > 0 then
|
||||
self.cards = { FLY_MAP_CARD }
|
||||
self.cardIndex = 1
|
||||
@@ -956,6 +957,9 @@ function Pokegear.new(game, opts)
|
||||
-- so the RED_WALK icon keeps the overworld's own OBJ palette.
|
||||
self.sprites = opts.sprites or data.gen2Sprites
|
||||
self.palettes = opts.palettes or data.gen2Palettes
|
||||
-- TownMapMon reads wCurPartyMon's icon
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721).
|
||||
self.icons = opts.icons or data.gen2Icons
|
||||
|
||||
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
|
||||
self.gfx = gfx
|
||||
@@ -2130,7 +2134,11 @@ function Pokegear:drawMap()
|
||||
end
|
||||
end
|
||||
if current and current.x and current.y then
|
||||
self:mapCursorSprite(current.x, current.y)
|
||||
-- FlyMap's cursor is TownMapMon, the FlyMon's icon; only the MAP card's is
|
||||
-- the POKEGEAR_ARROW (../pokecrystal/engine/pokegear/pokegear.asm:2326).
|
||||
if not (self.fly and self:drawFlyMonCursor(current.x, current.y)) then
|
||||
self:mapCursorSprite(current.x, current.y)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2174,6 +2182,48 @@ function Pokegear:drawPlayerIcon(x, y)
|
||||
return true
|
||||
end
|
||||
|
||||
-- TownMapMon (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721): the
|
||||
-- FlyMon's party icon, on PAL_OW_RED like the RED_WALK icon beside it.
|
||||
function Pokegear:loadFlyMonIcon()
|
||||
if self.flyMonIcon ~= nil then return end
|
||||
self.flyMonIcon = false
|
||||
local mon = self.flyMon
|
||||
if type(mon) ~= "table" then return end
|
||||
local icons = self.icons
|
||||
local iconId = mon.isEgg and "ICON_EGG"
|
||||
or (icons and icons.species and mon.species
|
||||
and icons.species[mon.species])
|
||||
local entry = iconId and icons and icons.icons and icons.icons[iconId]
|
||||
if not (entry and entry.image) then return end
|
||||
local def = {
|
||||
id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false,
|
||||
spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0,
|
||||
species = mon.species, icon = iconId,
|
||||
}
|
||||
local ok, icon = pcall(SpriteRenderer.new, def, "flymon")
|
||||
if not (ok and icon) then return end
|
||||
local world = self.game and self.game.world
|
||||
local daytime = (world and world.daytime)
|
||||
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
|
||||
local colors = self.palettes
|
||||
and Palettes.spritePalette(self.palettes, daytime, def)
|
||||
if colors then
|
||||
icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime)))
|
||||
end
|
||||
self.flyMonIcon = icon
|
||||
end
|
||||
|
||||
-- .Frameset_PartyMon is two 8-frame icon beats and no mirror
|
||||
-- (data/sprite_anims/framesets.asm:66-69).
|
||||
function Pokegear:drawFlyMonCursor(x, y)
|
||||
self:loadFlyMonIcon()
|
||||
if not self.flyMonIcon then return false end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.flyMonIcon:draw(x - 8, y - 8, 0, -4, "down", 0, false, false, false,
|
||||
math.floor((self.iconTimer or 0) / 8) % 2)
|
||||
return true
|
||||
end
|
||||
|
||||
-- The cursor arrow. It is the same PokegearSpritesGFX sheet the mode
|
||||
-- indicator uses, at tile $04; without the sheet the port falls back to
|
||||
-- Chrome's own cursor glyph so the card is still navigable.
|
||||
|
||||
@@ -103,6 +103,14 @@ local PAGE_PALETTES = {
|
||||
{ { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } },
|
||||
}
|
||||
|
||||
-- gfx/stats/stats.pal, the colour LoadStatsScreenPals writes over colour 0 of
|
||||
-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390). #1693
|
||||
local PAGE_TINTS = {
|
||||
{ 255, 156, 255 },
|
||||
{ 173, 255, 115 },
|
||||
{ 140, 255, 255 },
|
||||
}
|
||||
|
||||
-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside
|
||||
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
|
||||
-- values start one row below the first label.
|
||||
@@ -901,13 +909,20 @@ end
|
||||
|
||||
-- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the
|
||||
-- seven shapes by hand for a cache built before menu_gfx.stats existed.
|
||||
function SummaryMenu:pageTile(id, tx, ty)
|
||||
function SummaryMenu:pageTile(id, tx, ty, colors)
|
||||
local G = love.graphics
|
||||
local px, py = tx * 8, ty * 8
|
||||
local sheet = self:statsTiles()
|
||||
if sheet and sheet.quads[id] then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(sheet.image, sheet.quads[id], px, py)
|
||||
local function body()
|
||||
G.draw(sheet.image, sheet.quads[id], px, py)
|
||||
end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
return
|
||||
end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
@@ -1106,8 +1121,26 @@ function SummaryMenu:drawHorizontalDivider()
|
||||
end
|
||||
end
|
||||
|
||||
-- BG palette 0 as the stats screen leaves it: the page tint in colour 0, black
|
||||
-- ink in colour 3 (engine/gfx/color.asm:386-390).
|
||||
function SummaryMenu:lowerColors()
|
||||
local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE]
|
||||
return { tint, tint, tint, { 0, 0, 0 } }
|
||||
end
|
||||
|
||||
-- StatsScreen_LoadGFX's .ClearBox: hlcoord 0, 8 / lb bc, 10, 20, the ten rows
|
||||
-- LoadStatsScreenPals then tints (engine/pokemon/stats_screen.asm:549-557).
|
||||
function SummaryMenu:drawPageBackground()
|
||||
local G = love.graphics
|
||||
local tint = GbcPalette.color(self:lowerColors(), 1) or { 255, 255, 255 }
|
||||
G.setColor(tint[1] / 255, tint[2] / 255, tint[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 8 * 8, Chrome.SCREEN_W * 8, 10 * 8)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function SummaryMenu:drawVerticalDivider(tx)
|
||||
for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y) end
|
||||
local colors = self:lowerColors()
|
||||
for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y, colors) end
|
||||
end
|
||||
|
||||
function SummaryMenu:drawUpperHalf()
|
||||
@@ -1125,11 +1158,12 @@ end
|
||||
function SummaryMenu:drawPinkPage()
|
||||
local mon = self.mon or {}
|
||||
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0
|
||||
local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE]
|
||||
-- DrawPlayerHP is DrawBattleHPBar with d = 6 and b = 0: "HP:" at (0,9), six
|
||||
-- bar cells, and the end cap at (8,9) -- which LoadPinkPage then rewrites as
|
||||
-- $41, the same shape from the stats sheet.
|
||||
if self.hud and self.hud:available() then
|
||||
self.hud:drawHpBar(mon.hp, maxHp, 0, 9)
|
||||
self.hud:drawHpBar(mon.hp, maxHp, 0, 9, tint)
|
||||
else
|
||||
HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font)
|
||||
end
|
||||
@@ -1141,15 +1175,16 @@ function SummaryMenu:drawPinkPage()
|
||||
-- and (19,16).
|
||||
local fraction = HpBar.expFraction(mon, self:growth(), Mon.experienceForLevel)
|
||||
if self.hud and self.hud:available() then
|
||||
self.hud:drawExpBar(fraction, 11, 16)
|
||||
self.hud:drawExpBar(fraction, 11, 16, tint)
|
||||
else
|
||||
-- No HUD sheet in the cache: the plain rule, which is HP_BAR_LENGTH_PX
|
||||
-- (48) wide rather than the exp bar's 64, so it stops two tiles short of
|
||||
-- the $41 cap. A cache old enough to hit this has no bar tiles at all.
|
||||
HpBar.drawExp(self.palettes, fraction, 11 * 8, 16 * 8 + 3)
|
||||
end
|
||||
self:pageTile(TILE_BAR_CAP_LEFT, 10, 16)
|
||||
self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16)
|
||||
local colors = self:lowerColors()
|
||||
self:pageTile(TILE_BAR_CAP_LEFT, 10, 16, colors)
|
||||
self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16, colors)
|
||||
end
|
||||
|
||||
function SummaryMenu:drawGreenPage()
|
||||
@@ -1211,6 +1246,7 @@ function SummaryMenu:drawPanel()
|
||||
self:drawMoveDetail()
|
||||
else
|
||||
Chrome.clear()
|
||||
self:drawPageBackground()
|
||||
self:drawUpperHalf()
|
||||
if self.page == GREEN_PAGE then
|
||||
self:drawGreenPage()
|
||||
@@ -1244,6 +1280,7 @@ SummaryMenu.STAT_LABELS = STAT_LABELS
|
||||
SummaryMenu.STAT_KEYS = STAT_KEYS
|
||||
SummaryMenu.TYPE_NAMES = TYPE_NAMES
|
||||
SummaryMenu.PAGE_PALETTES = PAGE_PALETTES
|
||||
SummaryMenu.PAGE_TINTS = PAGE_TINTS
|
||||
SummaryMenu.levelText = levelText
|
||||
|
||||
return SummaryMenu
|
||||
|
||||
@@ -65,7 +65,7 @@ local PAL = {
|
||||
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
|
||||
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
||||
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
|
||||
railCrystal = { 168, 120, 236 }, -- Crystal cartridge (translucent violet)
|
||||
railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue)
|
||||
}
|
||||
-- Semantic aliases kept so ported call sites read the same as before.
|
||||
PAL.cardBorder = PAL.line
|
||||
|
||||
@@ -42,6 +42,23 @@ function NPC.new(data, mapId, objDef)
|
||||
return self
|
||||
end
|
||||
|
||||
-- LoadMapHeader .loadSpriteData zeroes the sprite state data and re-seeds
|
||||
-- MAPY/MAPX from the map header -- home/overworld.asm:2133
|
||||
function NPC:resetToSpawn()
|
||||
local def = self.def
|
||||
self.cellX, self.cellY = def.x, def.y
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.facing = FACING_FROM_RANGE[def.range] or "down"
|
||||
self.targetX, self.targetY = nil, nil
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
self.hopStep = nil
|
||||
self.progress = 0
|
||||
self.animClock = 0
|
||||
self.stepFlip = false
|
||||
self.timer = love.math.random(30, 120)
|
||||
end
|
||||
|
||||
function NPC:facePlayer(player)
|
||||
local dx = player.cellX - self.cellX
|
||||
local dy = player.cellY - self.cellY
|
||||
|
||||
@@ -81,6 +81,24 @@ local HEAL_BALL_XY = {
|
||||
-- swaps the two middle shades of the monitor/ball art in place
|
||||
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
|
||||
|
||||
-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is
|
||||
-- the four blocks (5..8, 1..2) of VermilionDock.blk
|
||||
local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 }
|
||||
-- scripts/VermilionDock.asm:164 VermilionDock_SyncScrollWithLY splits rSCX at
|
||||
-- LY $50, so her top 16px -- the player's own cell row -- never scrolls
|
||||
local SS_ANNE_KEEP_PX = 16
|
||||
-- scripts/VermilionDock.asm:79 `ld e, $8`: eight 16px columns, and each
|
||||
-- .delay_between_drifts pass is eight frames per pixel
|
||||
local SS_ANNE_SAIL_PX, SS_ANNE_PX_FRAMES = 128, 8
|
||||
-- scripts/VermilionDock.asm:182 VermilionDock_EraseSSAnne: block 1 is the
|
||||
-- shoreline row she sat in, block 13 the open water below it
|
||||
local SS_ANNE_WATER = { 1, 13 }
|
||||
-- scripts/VermilionDock.asm:142 VermilionDock_EmitSmokePuff: a puff per
|
||||
-- column off the front smokestack, drifting east 2px per drift step
|
||||
local SS_ANNE_SMOKE = { dx = 64, dy = 20, drift = 2, every = 16, count = 5 }
|
||||
-- scripts/VermilionDock.asm:65 `ldh [rOBP1], a` with a = 0: the puff is white
|
||||
local SS_ANNE_SMOKE_MAP = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 }
|
||||
|
||||
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
|
||||
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
|
||||
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
||||
@@ -320,6 +338,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.parallelQueue = {}
|
||||
end
|
||||
self.marchers = {}
|
||||
self.shipAnim = nil
|
||||
local queue = self.pendingScripts
|
||||
if queue then
|
||||
for i = #queue, 1, -1 do
|
||||
@@ -413,12 +432,14 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- (home/overworld.asm), so an NPC who walked up to the player stands
|
||||
-- on her spawn cell again the next time that map loads (#1028). Only
|
||||
-- the save-side spawn flags survive, and those live in Game.save, not
|
||||
-- here. Warps rebuild the whole pool from scratch.
|
||||
-- here. Warps rebuild the whole pool from scratch; a seam crossing arms
|
||||
-- the re-seed and applyPendingSpawnResets lands it off camera (#1755).
|
||||
if not (opts and opts.seamless and self.npcPool) then
|
||||
self.npcPool = {}
|
||||
elseif fromMapId ~= mapId then
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
|
||||
local npc = self.npcPool[mapId .. "_obj_" .. obj.index]
|
||||
if npc then npc.pendingSpawnReset = true end
|
||||
end
|
||||
end
|
||||
self.npcs = {}
|
||||
@@ -624,6 +645,41 @@ function OverworldState:rebuildNeighbors()
|
||||
end
|
||||
end
|
||||
|
||||
-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's
|
||||
-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133
|
||||
function OverworldState:applyPendingSpawnResets()
|
||||
local pool = self.npcPool
|
||||
if not (pool and self.camera) then return end
|
||||
local due
|
||||
for _, npc in pairs(pool) do
|
||||
if npc.pendingSpawnReset then
|
||||
due = due or {}
|
||||
due[npc] = true
|
||||
end
|
||||
end
|
||||
if not due then return end
|
||||
local cam = self.camera
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
local function onCamera(px, py)
|
||||
return px + 16 > cam.x - 16 and px < cam.x + vw + 16
|
||||
and py + 16 > cam.y - 16 and py < cam.y + vh + 16
|
||||
end
|
||||
for _, mv in ipairs(self.scriptMoves or {}) do due[mv.entity] = nil end
|
||||
for entity in pairs(self.marchers or {}) do due[entity] = nil end
|
||||
for _, npc in ipairs(self.npcs or {}) do
|
||||
if due[npc] and onCamera(npc.px, npc.py) then due[npc] = nil end
|
||||
end
|
||||
for _, g in ipairs(self.ghosts or {}) do
|
||||
if due[g.npc] and onCamera(g.npc.px + g.ox, g.npc.py + g.oy) then
|
||||
due[g.npc] = nil
|
||||
end
|
||||
end
|
||||
for npc in pairs(due) do
|
||||
npc.pendingSpawnReset = nil
|
||||
npc:resetToSpawn()
|
||||
end
|
||||
end
|
||||
|
||||
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
|
||||
-- towns use their own palette, routes PAL_ROUTE, interiors the town or
|
||||
-- route they are in (wLastMap = our lastOutdoor), with tileset and
|
||||
@@ -1068,6 +1124,27 @@ function OverworldState:update(dt)
|
||||
if da.onDone then da.onDone() end
|
||||
end
|
||||
end
|
||||
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||
if self.shipAnim and not self.shipAnim.gone then
|
||||
local sa = self.shipAnim
|
||||
sa.frames = sa.frames + 1
|
||||
if sa.frames >= SS_ANNE_PX_FRAMES then
|
||||
sa.frames = 0
|
||||
sa.off = sa.off + 1
|
||||
if sa.off % SS_ANNE_SMOKE.every == 1
|
||||
and #sa.puffs < SS_ANNE_SMOKE.count then
|
||||
sa.puffs[#sa.puffs + 1] = { x = sa.px - sa.off + SS_ANNE_SMOKE.dx,
|
||||
y = sa.py + SS_ANNE_SMOKE.dy }
|
||||
end
|
||||
for _, p in ipairs(sa.puffs) do p.x = p.x + SS_ANNE_SMOKE.drift end
|
||||
if sa.off >= SS_ANNE_SAIL_PX then
|
||||
sa.gone, sa.puffs = true, {}
|
||||
local done = sa.onDone
|
||||
sa.onDone = nil
|
||||
if done then done() end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.cutAnim then
|
||||
local ca = self.cutAnim
|
||||
ca.frames = ca.frames - 1
|
||||
@@ -1227,6 +1304,7 @@ function OverworldState:update(dt)
|
||||
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or (self.hopLand or 0) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
if not scripted and not self.transitioning then
|
||||
@@ -1236,6 +1314,7 @@ function OverworldState:update(dt)
|
||||
-- direction handling (JoypadOverworld runs the map script first) --
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or (self.hopLand or 0) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
end
|
||||
@@ -1244,6 +1323,7 @@ function OverworldState:update(dt)
|
||||
if not scripted and not self.transitioning and Game.stack:top() == self then
|
||||
self:handleInput()
|
||||
end
|
||||
if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end
|
||||
|
||||
local stepped = self.player:update()
|
||||
-- the warp-arrival cell goes stale the instant the player's real cell
|
||||
@@ -1271,6 +1351,7 @@ function OverworldState:update(dt)
|
||||
|
||||
self.camera:follow(self.player.px, self.player.py,
|
||||
Game.renderer:worldViewSize())
|
||||
self:applyPendingSpawnResets()
|
||||
|
||||
-- pan_camera offset rides on top of the follow; the ramp resumes its
|
||||
-- runner when it lands
|
||||
@@ -1509,6 +1590,76 @@ function OverworldState:startDustAnim(cx, cy, onDone)
|
||||
self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone }
|
||||
end
|
||||
|
||||
-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her
|
||||
-- hull tiles, flood her box with water, and slide the snapshot west from there
|
||||
function OverworldState:startSsAnneDeparture(onDone)
|
||||
local map = self.map
|
||||
local tx0, ty0 = SS_ANNE_BLOCK.x * 4, SS_ANNE_BLOCK.y * 4
|
||||
local tiles = {}
|
||||
for row = 1, SS_ANNE_BLOCK.h * 4 do
|
||||
local r = {}
|
||||
for col = 1, SS_ANNE_BLOCK.w * 4 do
|
||||
r[col] = map:tileAt(tx0 + col - 1, ty0 + row - 1)
|
||||
end
|
||||
tiles[row] = r
|
||||
end
|
||||
for bx = SS_ANNE_BLOCK.x, SS_ANNE_BLOCK.x + SS_ANNE_BLOCK.w - 1 do
|
||||
for by = SS_ANNE_BLOCK.y, SS_ANNE_BLOCK.y + SS_ANNE_BLOCK.h - 1 do
|
||||
map:setBlock(bx, by, SS_ANNE_WATER[by - SS_ANNE_BLOCK.y + 1] or 13)
|
||||
end
|
||||
end
|
||||
map.renderer:rebuild()
|
||||
self.shipAnim = { px = tx0 * 8, py = ty0 * 8, tiles = tiles,
|
||||
off = 0, frames = 0, puffs = {}, onDone = onDone }
|
||||
end
|
||||
|
||||
-- scripts/VermilionDock.asm:164: the rSCX split keeps her top 16px (the
|
||||
-- shoreline row and the gangway under the player) put while the rest sails
|
||||
function OverworldState:drawShipAnim(camX, camY)
|
||||
local sa = self.shipAnim
|
||||
if not sa then return end
|
||||
local renderer = self.map.renderer
|
||||
local img, quads = renderer.image, renderer.quads
|
||||
local ox, oy = -math.floor(camX), -math.floor(camY)
|
||||
local keep = SS_ANNE_KEEP_PX / 8
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for row = 1, #sa.tiles do
|
||||
if row <= keep or not sa.gone then
|
||||
local slide = row > keep and sa.off or 0
|
||||
local wy = sa.py + (row - 1) * 8 + oy
|
||||
for col = 1, #sa.tiles[row] do
|
||||
local quad = quads[sa.tiles[row][col]]
|
||||
if quad then
|
||||
love.graphics.draw(img, quad, sa.px + (col - 1) * 8 - slide + ox, wy)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #sa.puffs == 0 then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local smoke = fxDef and fxDef.smoke
|
||||
if not smoke then return end
|
||||
if self.smokeImg == nil then
|
||||
local ok, image = pcall(love.graphics.newImage, smoke.path)
|
||||
self.smokeImg = ok and image or false
|
||||
end
|
||||
if not self.smokeImg then return end
|
||||
local shader = PaletteFX.shader()
|
||||
if shader then
|
||||
PaletteFX.sendColors(shader,
|
||||
PaletteFX.permute(PaletteFX.GRAYS, SS_ANNE_SMOKE_MAP))
|
||||
love.graphics.setShader(shader)
|
||||
end
|
||||
for _, p in ipairs(sa.puffs) do
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(self.smokeImg, p.x + i * 8 + ox, p.y + j * 8 + oy)
|
||||
end
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
|
||||
-- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile
|
||||
-- in front + matching input direction -> jump two cells.
|
||||
function OverworldState:checkLedgeHop(dir)
|
||||
@@ -1542,17 +1693,22 @@ function OverworldState:checkLedgeHop(dir)
|
||||
return false
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop
|
||||
local hop = p:stepLength() * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
|
||||
self:scriptMove(p, dir, 1, function()
|
||||
self:checkEdgeExit(dir)
|
||||
self:finishLedgeHop()
|
||||
end)
|
||||
return true
|
||||
end
|
||||
if not Collision.occupied(self.entities, lx, ly, p)
|
||||
and self.map:isWalkableCell(lx, ly) then
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop
|
||||
local hop = p:stepLength() * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 2)
|
||||
self:scriptMove(p, dir, 2, function() self:finishLedgeHop() end)
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -1560,6 +1716,13 @@ function OverworldState:checkLedgeHop(dir)
|
||||
return false
|
||||
end
|
||||
|
||||
-- _HandleMidJump .finishedJump lands with UpdateSprites + Delay3 before it
|
||||
-- clears the joypad bytes -- engine/overworld/player_animations.asm:509
|
||||
function OverworldState:finishLedgeHop()
|
||||
self.player.ledgeHop = nil
|
||||
self.hopLand = 3
|
||||
end
|
||||
|
||||
-- walking off the map edge: connection crossing or edge warp (exit mats)
|
||||
function OverworldState:checkEdgeExit(dir)
|
||||
local p = self.player
|
||||
@@ -1665,9 +1828,7 @@ function OverworldState:crossConnection(dir, conn)
|
||||
-- fresh walk-cycle clock so the seam step always shows leg frames
|
||||
-- (mid-cycle stand phase would otherwise look like a slide)
|
||||
p.animClock = 0
|
||||
p.stepFramesCur = Game.save.onBike
|
||||
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
|
||||
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
|
||||
p.stepFramesCur = p:stepLength()
|
||||
require("src.core.FixedStep"):discardCatchup()
|
||||
return true
|
||||
end
|
||||
@@ -2413,16 +2574,12 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
local adj = tc.adjacent[puz.first]
|
||||
local masked = require("bit").band(love.math.random(0, 255), #adj)
|
||||
puz.second = masked == 0 and 0 or adj[masked]
|
||||
-- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only
|
||||
-- after the text has printed (text_far ...; text_asm;
|
||||
-- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish),
|
||||
-- and DisplayTextID's WaitForTextScrollButtonPress then holds the box
|
||||
-- until the player dismisses it -- so the beep belongs on close, not
|
||||
-- open.
|
||||
-- engine/events/hidden_events/vermilion_gym_trash.asm:130 (text_asm tail:
|
||||
-- SFX_SWITCH once the text has printed, before the button wait) (#1702)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashSuccessText1
|
||||
or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Switch") end))
|
||||
nil, TextBox.soundOpts(Game, "Switch")))
|
||||
return
|
||||
end
|
||||
-- .trySecondLock
|
||||
@@ -2433,25 +2590,28 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
-- the clear floor block opens the doors (VermilionGymSetDoorTile)
|
||||
local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras",
|
||||
"trashCans", "doorBlock")
|
||||
self:replaceBlock(door.bx, door.by, door.block)
|
||||
-- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text
|
||||
-- prints, so the beep fires as the box closes, not as it opens.
|
||||
-- engine/events/hidden_events/vermilion_gym_trash.asm:153 beeps as the
|
||||
-- text finishes; scripts/VermilionGym.asm:30 beeps on the swap (#1702)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashSuccessText3
|
||||
or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Go_Inside") end))
|
||||
function()
|
||||
require("src.core.Sound").play(Game.data, "Go_Inside")
|
||||
self:replaceBlock(door.bx, door.by, door.block)
|
||||
end,
|
||||
TextBox.soundOpts(Game, "Go_Inside")))
|
||||
else
|
||||
-- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately
|
||||
-- re-roll the first switch (Random & $e)
|
||||
save.flags.EVENT_1ST_LOCK_OPENED = nil
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
puz.second = nil
|
||||
-- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the
|
||||
-- text prints, so the beep fires as the box closes, not as it opens.
|
||||
-- engine/events/hidden_events/vermilion_gym_trash.asm:162 (text_asm tail:
|
||||
-- SFX_DENIED once the text has printed, before the button wait) (#1702)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashFailText
|
||||
or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Denied") end))
|
||||
nil, TextBox.soundOpts(Game, "Denied")))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2899,10 +3059,10 @@ function OverworldState:talkTo(npc)
|
||||
if entry then
|
||||
if entry.mart then
|
||||
npc:facePlayer(self.player)
|
||||
Game.stack:push(TextBox.new(Game, romText(Game.data, "_PokemartGreetingText", "Hi there!\nMay I help you?"), function()
|
||||
Screens.push(Game, "ShopMenu", entry.mart)
|
||||
unfreeze()
|
||||
end))
|
||||
-- the greeting stays in the box under the menu, so ShopMenu owns it
|
||||
-- now -- home/text_script.asm:143
|
||||
Screens.push(Game, "ShopMenu", entry.mart)
|
||||
unfreeze()
|
||||
return
|
||||
end
|
||||
if entry.nurse then
|
||||
@@ -3365,22 +3525,18 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local stingPlayed = false
|
||||
-- home/trainers.asm:109 prints, :123 engages (BIT_SEEN_BY_TRAINER =
|
||||
-- self.engaging), then home/text_script.asm:96 waits for A (#764, #1683)
|
||||
local function playMeetSting()
|
||||
if stingPlayed or self.engaging then return end
|
||||
stingPlayed = true
|
||||
local theme = meetTrainerTheme(d.trainerClass)
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
local function startBattle(options)
|
||||
self.cancelledTrainerSight = nil
|
||||
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
||||
-- over the battle transition rather than under the dialogue. Its
|
||||
-- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging
|
||||
-- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already
|
||||
-- started the sting before the "!" bubble on the sight path, so it
|
||||
-- must not restart. Script-driven challenges (gyms.lua leaders,
|
||||
-- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122)
|
||||
-- all `call EngageMapTrainer` too, and reach this same path (#764).
|
||||
if not self.engaging then
|
||||
local theme = meetTrainerTheme(d.trainerClass)
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
playMeetSting()
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
|
||||
options)
|
||||
battle.checkpointOrigin = {
|
||||
@@ -3451,7 +3607,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
if skipBattleText then
|
||||
prepareBattle()
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
|
||||
Game.stack:push(TextBox.new(Game, battleText, prepareBattle,
|
||||
{ auto = { wait = true, delay = 0, sound = playMeetSting } }))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4395,18 +4552,11 @@ function OverworldState:afterBattle(result, battle)
|
||||
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
||||
lead and lead.species or "-", lead and lead.hp or 0,
|
||||
lead and lead.stats.hp or 0)
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local function evolutions()
|
||||
-- Only mons that gained a level this battle (EXP.ALL included).
|
||||
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
|
||||
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
|
||||
end
|
||||
if result == "lose" then
|
||||
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
|
||||
and self.map and self.map.id == "OAKS_LAB"
|
||||
if oaksLabRival then
|
||||
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
|
||||
evolutions()
|
||||
return
|
||||
end
|
||||
-- blackout: revive the party at the last heal point; half the
|
||||
@@ -4419,7 +4569,7 @@ function OverworldState:afterBattle(result, battle)
|
||||
/ (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2))
|
||||
Runtime.emit("world.blacked_out",
|
||||
{ save = Game.save, healTarget = self:healPoint() })
|
||||
self:warpToHealPoint(evolutions)
|
||||
self:warpToHealPoint()
|
||||
else
|
||||
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
|
||||
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
|
||||
@@ -4429,7 +4579,6 @@ function OverworldState:afterBattle(result, battle)
|
||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||
end
|
||||
evolutions()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4858,6 +5007,9 @@ function OverworldState:updateScriptMoves()
|
||||
e.facing = mv.dir
|
||||
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
|
||||
e.targetX, e.targetY = tx, ty
|
||||
-- a simulated d-pad press runs at the CURRENT walk/bike speed, not
|
||||
-- whatever the last real step left behind -- home/overworld.asm:276
|
||||
if e.stepLength then e.stepFramesCur = e:stepLength() end
|
||||
e.moving = true
|
||||
e.progress = 0
|
||||
mv.remaining = mv.remaining - 1
|
||||
@@ -5018,6 +5170,7 @@ function OverworldState:drawWorld()
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
end
|
||||
self:drawShipAnim(cam.x, bgY)
|
||||
end
|
||||
-- per-billboard SGB palette source; only needed (and only paid for) when
|
||||
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
||||
@@ -5374,6 +5527,7 @@ function OverworldState:drawWorld()
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
end
|
||||
self:drawShipAnim(cam.x, bgY)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+21
-16
@@ -110,6 +110,26 @@ function Player:turnWindow()
|
||||
return frames
|
||||
end
|
||||
|
||||
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||
-- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283
|
||||
function Player:stepLength()
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save
|
||||
local onBike = (save and save.onBike and not self.ledgeHop) or false
|
||||
local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES
|
||||
if Runtime.wantsHook("movement.speed") then
|
||||
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
||||
onBike = onBike,
|
||||
surfing = self.surfing and true or false,
|
||||
player = self,
|
||||
input = Game.input,
|
||||
save = save,
|
||||
})
|
||||
end
|
||||
return math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
||||
end
|
||||
|
||||
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
|
||||
function Player:tryMove(dir, map, entities)
|
||||
if self.moving or self.inputLocked then return nil end
|
||||
@@ -145,22 +165,7 @@ function Player:tryMove(dir, map, entities)
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save
|
||||
local frames = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
if Runtime.wantsHook("movement.speed") then
|
||||
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
||||
onBike = save and save.onBike or false,
|
||||
surfing = self.surfing and true or false,
|
||||
player = self,
|
||||
input = Game.input,
|
||||
save = save,
|
||||
})
|
||||
end
|
||||
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
||||
self.stepFramesCur = self:stepLength()
|
||||
return "moved"
|
||||
end
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ FieldMoves.TEXT = {
|
||||
.. "\fA #MON may be\nable to pass it."),
|
||||
ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way."
|
||||
.. "\fWant to use\nWHIRLPOOL?"),
|
||||
-- Not a cart line: the stand-in destination prompt World:askFlyPoint uses
|
||||
-- until the POKeGEAR's MAP card grows _FlyMap's cursor mode.
|
||||
-- Not a cart line: the prompt World:askFlyPoint falls back to when there is
|
||||
-- no screen at all to push -- a headless probe, never a real run.
|
||||
ASK_FLY_TO = Strings.source("Fly to %s?"),
|
||||
}
|
||||
|
||||
@@ -183,6 +183,10 @@ function FieldMoves.bindEngineFlags(order)
|
||||
-- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte
|
||||
-- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121).
|
||||
FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME]
|
||||
-- Crystal's ENGINE_MOBILE_SYSTEM (constants/engine_flags.asm:25) has no Gold
|
||||
-- row, so every id from BUG_CONTEST_TIMER up shifts one.
|
||||
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
|
||||
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
|
||||
return flags
|
||||
end
|
||||
|
||||
|
||||
+22
-1
@@ -31,6 +31,8 @@ local MOVE = {
|
||||
STANDING_LEFT = 8,
|
||||
STANDING_RIGHT = 9,
|
||||
SPINRANDOM_FAST = 10,
|
||||
-- data/sprites/map_objects.asm:181-187
|
||||
POKEMON = 0x16,
|
||||
SPINCOUNTERCLOCKWISE = 0x1e,
|
||||
SPINCLOCKWISE = 0x1f,
|
||||
-- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT`
|
||||
@@ -123,6 +125,11 @@ local SPIN_NEXT = {
|
||||
-- each quarter, no Random anywhere in the loop.
|
||||
local SPIN_TURN_FRAMES = 16
|
||||
|
||||
-- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a
|
||||
-- mon object holds each icon frame for eight -- map_object_action.asm:184-201
|
||||
local BOUNCE_PERIOD = 16
|
||||
local BOUNCE_HALF = 8
|
||||
|
||||
local function rand(a, b)
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(a, b)
|
||||
@@ -252,6 +259,8 @@ function NPC.new(mapId, objDef, spriteDef)
|
||||
bigObject = BIG_OBJECT[movement] == true,
|
||||
bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id),
|
||||
fixedFacing = FIXED_FACING_MOVE[movement] or nil,
|
||||
bouncing = movement == MOVE.POKEMON or nil,
|
||||
bounceStep = 0,
|
||||
timer = rand(30, 120),
|
||||
sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)),
|
||||
-- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the
|
||||
@@ -501,6 +510,14 @@ function NPC:walkPhase()
|
||||
return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0
|
||||
end
|
||||
|
||||
-- OBJECT_ACTION_BOUNCE's two columns, SetFacingBounce and
|
||||
-- SetFacingFreezeBounce -- engine/overworld/map_object_action.asm:184-201
|
||||
function NPC:bounceFrame()
|
||||
if not self.bouncing then return nil end
|
||||
if self.frozen then return 0 end
|
||||
return ((self.bounceStep or 0) >= BOUNCE_HALF) and 1 or 0
|
||||
end
|
||||
|
||||
-- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a
|
||||
-- mod poses the object it is FOLLOWING, not only one it built itself.
|
||||
function NPC:pose()
|
||||
@@ -522,6 +539,9 @@ function NPC:update(map, entities)
|
||||
self.spawnLatched = true
|
||||
self.inGrass = NPC.grassAt(map, self.cellX, self.cellY)
|
||||
end
|
||||
if self.bouncing and not self.frozen then
|
||||
self.bounceStep = ((self.bounceStep or 0) + 1) % BOUNCE_PERIOD
|
||||
end
|
||||
-- The teleport step type owns the object outright (it replaces
|
||||
-- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen
|
||||
-- gate the way the walk interpolation does.
|
||||
@@ -763,7 +783,8 @@ function NPC:draw(ox, oy, scale)
|
||||
else
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
self.facing, self:walkPhase(), self.stepFlip)
|
||||
self.facing, self:walkPhase(), self.stepFlip,
|
||||
false, false, self:bounceFrame())
|
||||
end
|
||||
G.pop()
|
||||
end
|
||||
|
||||
+84
-12
@@ -23,6 +23,19 @@ local JUMP_Y = {
|
||||
-11, -10, -9, -8, -6, -4, 0, 0,
|
||||
}
|
||||
|
||||
-- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which
|
||||
-- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152).
|
||||
local ROD_OAM = {
|
||||
down = { dx = 0, dy = 16, tile = 0 },
|
||||
up = { dx = 0, dy = -8, tile = 0 },
|
||||
left = { dx = -8, dy = 5, tile = 1, flip = true },
|
||||
right = { dx = 16, dy = 5, tile = 1 },
|
||||
}
|
||||
|
||||
-- The sheet row LoadFishingGFX lays over each standing frame's bottom tiles
|
||||
-- (engine/events/fishing_gfx.asm:2-20).
|
||||
local FISH_ROW = { down = 0, up = 1, left = 2, right = 2 }
|
||||
|
||||
function Player.new(cx, cy, facing, spriteDef)
|
||||
local self = setmetatable({
|
||||
cellX = cx, cellY = cy,
|
||||
@@ -138,6 +151,17 @@ function Player:scriptStep(dir)
|
||||
return true
|
||||
end
|
||||
|
||||
-- CounterclockwiseSpinAction's .facings, seeded from the current direction by
|
||||
-- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116
|
||||
local SPIN_FACINGS = { "down", "right", "up", "left" }
|
||||
local SPIN_START = { down = 0, right = 1, up = 2, left = 3 }
|
||||
|
||||
function Player:scriptSpin(frames)
|
||||
if not frames or frames <= 0 then return end
|
||||
self.spinFrames = frames
|
||||
self.spinTimer = (SPIN_START[self.facing] or 0) * 4
|
||||
end
|
||||
|
||||
-- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that
|
||||
-- wraps World:interact asks one question of either generation.
|
||||
function Player:facingCell()
|
||||
@@ -158,6 +182,11 @@ function Player:update()
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
if self.spinFrames then
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
self.spinFrames = self.spinFrames - 1
|
||||
if self.spinFrames <= 0 then self.spinFrames = nil end
|
||||
end
|
||||
if not self.moving then
|
||||
-- Re-arm turn-in-place once a poll finds no held direction (caller
|
||||
-- clears this while a dir is held; we only set it from idle).
|
||||
@@ -170,17 +199,27 @@ function Player:update()
|
||||
-- and the facing-delta math walked only half of it, leaving the sprite a
|
||||
-- cell behind where the grid said the player was.
|
||||
local frames = self.stepFrames or STEP_FRAMES
|
||||
local adv = math.floor(self.progress * 16 / frames)
|
||||
local dx = (self.targetX or self.cellX) - self.cellX
|
||||
local dy = (self.targetY or self.cellY) - self.cellY
|
||||
self.px = self.cellX * 16 + dx * adv
|
||||
self.py = self.cellY * 16 + dy * adv
|
||||
-- engine/overworld/map_objects.asm:331 -- AddStepVector moves the object
|
||||
-- every frame, so the span is the whole move, not one cell scaled by dx.
|
||||
local span = math.max(math.abs(dx), math.abs(dy), 1)
|
||||
local adv = math.floor(self.progress * 16 * span / frames)
|
||||
self.px = self.cellX * 16 + (dx / span) * adv
|
||||
self.py = self.cellY * 16 + (dy / span) * adv
|
||||
if self.jumping then
|
||||
-- engine/overworld/map_objects.asm:1815
|
||||
local idx = math.floor((self.progress - 1) / 2) + 1
|
||||
-- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame,
|
||||
-- tweened across our doubled step (#1713)
|
||||
local t = (self.progress - 1) * (#JUMP_Y - 1)
|
||||
/ math.max(frames - 1, 1) + 1
|
||||
local idx = math.floor(t)
|
||||
if idx < 1 then idx = 1 end
|
||||
if idx > #JUMP_Y then idx = #JUMP_Y end
|
||||
self.spriteYOffset = JUMP_Y[idx]
|
||||
if idx >= #JUMP_Y then
|
||||
self.spriteYOffset = JUMP_Y[#JUMP_Y]
|
||||
else
|
||||
self.spriteYOffset = math.floor(
|
||||
JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5)
|
||||
end
|
||||
end
|
||||
if self.progress >= frames then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
@@ -195,12 +234,36 @@ function Player:update()
|
||||
return false
|
||||
end
|
||||
|
||||
-- FacingFishDown/Up/Left/Right: the standing frame's bottom tile row swapped
|
||||
-- for the fishing sheet, plus the loose rod tile -- facings.asm:122-152 (#1708)
|
||||
function Player:drawFishing(yOffset)
|
||||
local sprite = self.sprite
|
||||
local py = self.py + yOffset
|
||||
local facing = self.facing
|
||||
sprite:draw(self.px, py, 0, 0, facing, 0, false, true)
|
||||
if not self.fishQuads then
|
||||
self.fishQuads = { pose = {}, rod = {} }
|
||||
for i = 0, 2 do
|
||||
self.fishQuads.pose[i] = love.graphics.newQuad(0, i * 8, 16, 8, 16, 32)
|
||||
end
|
||||
for i = 0, 1 do
|
||||
self.fishQuads.rod[i] = love.graphics.newQuad(i * 8, 24, 8, 8, 16, 32)
|
||||
end
|
||||
end
|
||||
local sx, sy = sprite:getScreenOrigin(self.px, py, 0, 0)
|
||||
sprite:drawTile(self.fishSheet, sx,
|
||||
sy + math.max(0, sprite.frameHeight - 8), facing == "right",
|
||||
self.fishQuads.pose[FISH_ROW[facing] or 0])
|
||||
local oam = ROD_OAM[facing] or ROD_OAM.down
|
||||
sprite:drawTile(self.fishSheet, sx + oam.dx, sy + oam.dy, oam.flip,
|
||||
self.fishQuads.rod[oam.tile])
|
||||
end
|
||||
|
||||
function Player:draw(ox, oy, scale)
|
||||
local G = love.graphics
|
||||
-- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so
|
||||
-- it moves the sprite without moving the player off the tile they are
|
||||
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
|
||||
-- lift both ride this one byte.
|
||||
-- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte.
|
||||
local yOffset = self.spriteYOffset or 0
|
||||
if self.jumping then
|
||||
-- engine/overworld/map_objects.asm:1995
|
||||
@@ -217,9 +280,18 @@ function Player:draw(ox, oy, scale)
|
||||
G.scale(scale, scale)
|
||||
-- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's
|
||||
-- OBJ palette current.
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
self.facing, self:walkPhase(), self.stepFlip)
|
||||
if self.fishing and self.fishSheet then
|
||||
self:drawFishing(yOffset)
|
||||
else
|
||||
local facing, phase = self.facing, self:walkPhase()
|
||||
-- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig.
|
||||
if self.spinFrames then
|
||||
facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1]
|
||||
phase = 0
|
||||
end
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip)
|
||||
end
|
||||
G.pop()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--
|
||||
-- Everything here is love-free and takes its state as arguments so the whole
|
||||
-- chain is testable without a world.
|
||||
local Bike = require("src.world.gen2.Bike")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local Happiness = require("src.core.gen2.Happiness")
|
||||
local Phone = require("src.core.gen2.Phone")
|
||||
@@ -118,7 +118,7 @@ end
|
||||
local function bikeShopCallEnabled(save)
|
||||
local flags = save.engineFlags
|
||||
if type(flags) == "table" then
|
||||
local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED]
|
||||
local set = flags[FieldMoves.BIKE_SHOP_CALL_FLAG]
|
||||
if set ~= nil then return set == true end
|
||||
end
|
||||
return save.bikeShopCall == true
|
||||
@@ -137,7 +137,7 @@ function StepEvents.bikeStep(save, opts)
|
||||
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP)
|
||||
-- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever.
|
||||
if type(save.engineFlags) == "table" then
|
||||
save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil
|
||||
save.engineFlags[FieldMoves.BIKE_SHOP_CALL_FLAG] = nil
|
||||
end
|
||||
save.bikeShopCall = false
|
||||
return true
|
||||
|
||||
+290
-65
@@ -56,6 +56,7 @@ local Roamers = require("src.core.gen2.Roamers")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local StepEvents = require("src.world.gen2.StepEvents")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local Strings = require("src.core.Strings")
|
||||
@@ -85,6 +86,7 @@ local SFX = {
|
||||
EXIT_BUILDING = 35,
|
||||
JUMP_OVER_LEDGE = 0x16,
|
||||
BUMP = 0x24,
|
||||
FLY = 0x18,
|
||||
}
|
||||
local EMOTE_SHOCK = 0
|
||||
|
||||
@@ -757,6 +759,15 @@ function World:load()
|
||||
self.tilesets = tilesets
|
||||
self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua")
|
||||
self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua")
|
||||
-- A pre-#1748 cache stamps a SpriteMons row `frames = 1`, leaving
|
||||
-- OBJECT_ACTION_BOUNCE one frame -- engine/overworld/map_object_action.asm:184
|
||||
for _, def in pairs(self.sprites or {}) do
|
||||
if type(def) == "table" and (def.frames or 1) < 2
|
||||
and type(def.source) == "string"
|
||||
and def.source:find("^ROM:SpriteMons") then
|
||||
def.frames = 2
|
||||
end
|
||||
end
|
||||
-- A cache from before the palette stage existed simply has no palettes.lua;
|
||||
-- everything below falls back to the grayscale path rather than failing.
|
||||
self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua")
|
||||
@@ -800,6 +811,14 @@ function World:load()
|
||||
local okImg, img = pcall(Assets.image, emotes.grassRustle)
|
||||
if okImg then self.grassRustleImage = img end
|
||||
end
|
||||
-- LoadFishingGFX's two sheets
|
||||
-- (../pokecrystal/engine/events/fishing_gfx.asm:7-12)
|
||||
if emotes.fishing and pcall(Assets.image, emotes.fishing) then
|
||||
self.fishingSheet = emotes.fishing
|
||||
end
|
||||
if emotes.fishingFemale and pcall(Assets.image, emotes.fishingFemale) then
|
||||
self.fishingSheetFemale = emotes.fishingFemale
|
||||
end
|
||||
end
|
||||
-- The heal machine's two OBJ tiles and their CGB palette, for the
|
||||
-- Pokecenter light show (World:startHealMachineAnim). A cache from before
|
||||
@@ -1427,6 +1446,9 @@ function World:busy()
|
||||
-- and the waterfall climb are all applymovement / pause commands inside a
|
||||
-- queued script, so nothing else may run under them.
|
||||
or self.fieldMove ~= nil
|
||||
-- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript
|
||||
-- (engine/events/overworld.asm:599, :605).
|
||||
or self.flyAnim ~= nil
|
||||
end
|
||||
|
||||
-- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput,
|
||||
@@ -1434,12 +1456,15 @@ end
|
||||
-- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates
|
||||
-- sit above it even with no script up: PlayerMovement answering
|
||||
-- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477),
|
||||
-- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are
|
||||
-- read NOWHERE else in the overworld, so a press that arrives while any of
|
||||
-- those hold is not queued or deferred, it is never read at all.
|
||||
-- and CheckStandingOnIce carrying (events.asm:479-480). The frame a step is
|
||||
-- QUEUED answers PLAYERMOVEMENT_FINISH instead (player_movement.asm:455-461),
|
||||
-- which is zero, so the poll still runs there -- and on the Cycling Road's
|
||||
-- forced roll that landing frame is the only one there is (#1718).
|
||||
function World:acceptsMenuInput()
|
||||
if self.battleActive or self:busy() then return false end
|
||||
if self.player and self.player.moving then return false end
|
||||
if self.player and self.player.moving and not self.stepFinished then
|
||||
return false
|
||||
end
|
||||
-- The same latch pair World:step's slide uses: a latched direction on an ice
|
||||
-- tile is CheckStandingOnIce's carry.
|
||||
if self.turningDirection and Permissions.isIce(self:playerCollision()) then
|
||||
@@ -1695,7 +1720,7 @@ function World:engineFlag(flag)
|
||||
-- how the two would come apart -- the officer's `setflag` and the results
|
||||
-- script's `clearflag` are the only writers, and both go through the pair
|
||||
-- below.
|
||||
if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then
|
||||
if flag == FieldMoves.BUG_CONTEST_FLAG and save then
|
||||
return BugContest.isActive(save)
|
||||
end
|
||||
-- Badges live in save.player.badges, not in the flag table: on the cart the
|
||||
@@ -1736,7 +1761,7 @@ end
|
||||
function World:setEngineFlag(flag, value)
|
||||
if flag == nil then return end
|
||||
local save = self.game and self.game.save
|
||||
if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then
|
||||
if flag == FieldMoves.BUG_CONTEST_FLAG and save then
|
||||
-- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special
|
||||
-- GiveParkBalls`, so starting here and starting again there is the cart's
|
||||
-- own order and the second start is what puts the balls on the counter.
|
||||
@@ -2042,7 +2067,7 @@ function World:breedmonSpriteDef(species)
|
||||
local def = {
|
||||
id = "SPRITE_DAY_CARE_MON",
|
||||
image = entry.image,
|
||||
frames = 1,
|
||||
frames = 2,
|
||||
walker = false,
|
||||
spriteType = "POKEMON_SPRITE",
|
||||
palette = "PAL_OW_RED",
|
||||
@@ -2311,6 +2336,9 @@ end
|
||||
-- rang the ordinary item jingle while the item argument was thrown away. An
|
||||
-- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM.
|
||||
function World:specialSound(itemIndex)
|
||||
-- The `waitsfx` above it (scripting.asm:445): SFX_READ_TEXT_2 ($08), which
|
||||
-- the box rings on its own press, outranks SFX_GET_TM ($9b) here (#1483).
|
||||
Sound.waitSfxDone()
|
||||
local id = itemIndex and self:itemIdByIndex(itemIndex)
|
||||
local items = self.game and self.game.data and self.game.data.items
|
||||
local def = id and items and items[id]
|
||||
@@ -3482,14 +3510,6 @@ function World:updateMapSetup()
|
||||
if ms.phase == "out" then
|
||||
ms.step = ms.step + 1
|
||||
self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS
|
||||
-- FlyFromAnim carries the player up and off the map under the fade. The
|
||||
-- bird's own frames are not in the cache, but the lift is: it is the same
|
||||
-- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks
|
||||
-- (src/script/gen2/Movement.lua), stepped over the fade's four levels.
|
||||
if ms.lift and self.player then
|
||||
self.player.spriteYOffset = Movement.teleportYOffset(
|
||||
Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS)
|
||||
end
|
||||
if ms.step >= FADE_STEPS then
|
||||
-- setMap clears self.fade (a map load repaints everything), so the sheet
|
||||
-- has to be re-armed at full strength on the far side for the fade in to
|
||||
@@ -3501,16 +3521,12 @@ function World:updateMapSetup()
|
||||
return
|
||||
end
|
||||
ms.step = ms.step - 1
|
||||
-- FlyToAnim, the same curve read backwards: the player comes down onto the
|
||||
-- destination tile as the fade lets go of the screen.
|
||||
if ms.lift and self.player then
|
||||
self.player.spriteYOffset = Movement.teleportYOffset(
|
||||
Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS)
|
||||
end
|
||||
if ms.step <= 0 then
|
||||
self.fade, self.fadeLevel = nil, nil
|
||||
self.mapSetup = nil
|
||||
if ms.lift and self.player then self.player.spriteYOffset = 0 end
|
||||
-- `callasm FlyToAnim` is the command straight after `newloadmap
|
||||
-- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605).
|
||||
if ms.flyIn then self:startFlyAnim("to", ms.flyIn) end
|
||||
return
|
||||
end
|
||||
self.fadeLevel = ms.step / FADE_STEPS
|
||||
@@ -3865,6 +3881,15 @@ function World:updateMovement()
|
||||
while st.i <= #st.bytes do
|
||||
local b = st.bytes[st.i]
|
||||
st.i = st.i + 1
|
||||
-- Movement_step_dig spins for the frames in the byte that follows
|
||||
-- -- engine/overworld/movement.asm:113-131 (#1716)
|
||||
if b == Movement.STEP_DIG then
|
||||
local duration = st.bytes[st.i] or 0
|
||||
st.i = st.i + 1
|
||||
if ent.scriptSpin then ent:scriptSpin(duration) end
|
||||
st.sleep = duration
|
||||
return
|
||||
end
|
||||
-- engine/overworld/movement.asm:163
|
||||
if b == 0x57 then
|
||||
local duration = st.bytes[st.i] or 0
|
||||
@@ -3890,6 +3915,11 @@ function World:updateMovement()
|
||||
elseif act.kind == "step" then
|
||||
local fromX, fromY = ent.cellX, ent.cellY
|
||||
if ent:scriptStep(act.dir) then
|
||||
-- TurningStep's OBJECT_ACTION_SPIN, for the length of the step
|
||||
-- (engine/overworld/movement.asm:693-699).
|
||||
if act.spin and ent.scriptSpin then
|
||||
ent:scriptSpin(ent.stepFrames or 16)
|
||||
end
|
||||
self:followStep(ent, fromX, fromY)
|
||||
end
|
||||
return
|
||||
@@ -4797,15 +4827,35 @@ end
|
||||
-- src/world/gen2/Bike.lua owns every decision; this is the world state those
|
||||
-- decisions read and the presentation they end in.
|
||||
|
||||
-- ../pokecrystal/constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts every
|
||||
-- later id up one: :36 ALWAYS_ON_BIKE against ../pokegold's :35.
|
||||
function World:engineFlagId(name, goldId)
|
||||
local order = self.constants and self.constants.engineFlagOrder
|
||||
if type(order) ~= "table" then return goldId end
|
||||
local ids = self.engineFlagIds
|
||||
if not ids then
|
||||
ids = {}
|
||||
for index, entry in pairs(order) do
|
||||
if type(index) == "number" and type(entry) == "string" then
|
||||
ids[entry] = index - 1
|
||||
end
|
||||
end
|
||||
self.engineFlagIds = ids
|
||||
end
|
||||
return ids[name] or goldId
|
||||
end
|
||||
|
||||
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
|
||||
-- callbacks that set them (Route16AlwaysOnBikeCallback,
|
||||
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
|
||||
function World:alwaysOnBike()
|
||||
return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE)
|
||||
return self:engineFlag(World.engineFlagId(
|
||||
self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE))
|
||||
end
|
||||
|
||||
function World:downhill()
|
||||
return self:engineFlag(Bike.ENGINE_DOWNHILL)
|
||||
return self:engineFlag(World.engineFlagId(
|
||||
self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL))
|
||||
end
|
||||
|
||||
-- GetPlayerTilePermission's operand: the collision under the player's feet.
|
||||
@@ -4963,6 +5013,11 @@ function World:beginFishing(outcome, wild)
|
||||
if self.player then
|
||||
self.player.fishing = true
|
||||
self.player.fishingState = self.fishing
|
||||
-- LoadFishingGFX reads wPlayerGender
|
||||
-- (../pokecrystal/engine/events/fishing_gfx.asm:8-12)
|
||||
self.player.fishSheet =
|
||||
(FieldMoves.isFemale(self:playerGender()) and self.fishingSheetFemale)
|
||||
or self.fishingSheet
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5345,6 +5400,9 @@ function World:fieldContext(mon)
|
||||
-- digFromMenu reads this rather than re-deriving the banked warp.
|
||||
canEscapeRope = self:escapeRopeTarget() ~= nil,
|
||||
playerState = self.playerState,
|
||||
-- SurfFunction.TrySurf and TrySurfOW both refuse while wBikeFlags'
|
||||
-- ALWAYS_ON_BIKE is set (engine/events/overworld.asm:343-345, :498-500).
|
||||
alwaysOnBike = self:alwaysOnBike(),
|
||||
strengthActive = self.strengthActive,
|
||||
-- FlashFunction tests wTimeOfDayPalset, not the map header, so a
|
||||
-- PALETTE_DARK map that FLASH has already lit refuses a second FLASH.
|
||||
@@ -5524,15 +5582,34 @@ function World:runCut(result)
|
||||
end
|
||||
|
||||
-- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and
|
||||
-- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip.
|
||||
-- PlayWhirlpoolSound in place of the snip.
|
||||
function World:runWhirlpool(result)
|
||||
self:setNickname(result.mon)
|
||||
self:showText(Strings(result.text), function()
|
||||
self:replaceBlock(result.blockIndex, result.replacement)
|
||||
self:playSfx(SFX.SURF)
|
||||
self:playWhirlpoolSound()
|
||||
end)
|
||||
end
|
||||
|
||||
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
|
||||
-- -- engine/events/field_moves.asm:5-10 (#1717)
|
||||
function World:playWhirlpoolSound()
|
||||
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
|
||||
end
|
||||
|
||||
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
|
||||
-- calls -- events.asm:786-793, forced_movement.asm:1-51 (#1716)
|
||||
local FORCED_BACK = { up = "down", down = "up", left = "right", right = "left" }
|
||||
|
||||
function World:runForcedMovement()
|
||||
local p = self.player
|
||||
if not p or p.moving or self.moveState then return false end
|
||||
local back = FORCED_BACK[p.facing]
|
||||
if not back then return false end
|
||||
self:beginMovement(0, Movement.forcedMovementBytes(back))
|
||||
return true
|
||||
end
|
||||
|
||||
-- Script_UseFlash: the text plays SFX.FLASH from inside itself
|
||||
-- (UseFlashTextScript's text_asm), and BlindingFlash then sets
|
||||
-- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there
|
||||
@@ -5685,7 +5762,7 @@ function World:runFieldMove(result)
|
||||
elseif action == "waterfall" then
|
||||
self:runWaterfall(result)
|
||||
elseif action == "fly" then
|
||||
self:openFlyMap()
|
||||
self:openFlyMap(result.mon)
|
||||
elseif action == "headbutt" then
|
||||
self:runHeadbutt(result.facingX, result.facingY, result.mon)
|
||||
elseif action == "sweetscent" then
|
||||
@@ -5869,25 +5946,138 @@ function World:flyPoints()
|
||||
self.game and self.game.save, self.landmarks, self:region())
|
||||
end
|
||||
|
||||
-- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the
|
||||
-- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens
|
||||
-- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the
|
||||
-- same pair of fades a door is, which is where the two fly animations ride:
|
||||
-- `lift` below hands them to World:updateMapSetup.
|
||||
function World:flyTo(spawnId)
|
||||
-- FlyFromAnim / FlyToAnim (engine/events/field_moves.asm:300, :334) and the two
|
||||
-- curves they run (engine/sprite_anims/functions.asm:1350, :1418).
|
||||
local FLY = {
|
||||
FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40,
|
||||
AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84,
|
||||
}
|
||||
|
||||
-- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390):
|
||||
-- the icon of the mon in wCurPartyMon, on PAL_OW_RED like every other OW OBJ.
|
||||
function World:flyIconFor(mon)
|
||||
if type(mon) ~= "table" then return nil end
|
||||
local data = self.game and self.game.data
|
||||
local icons = data and data.gen2Icons
|
||||
local iconId = mon.isEgg and "ICON_EGG"
|
||||
or (icons and icons.species and mon.species
|
||||
and icons.species[mon.species])
|
||||
local entry = iconId and icons and icons.icons and icons.icons[iconId]
|
||||
if not (entry and entry.image) then return nil end
|
||||
local def = {
|
||||
id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false,
|
||||
spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0,
|
||||
species = mon.species, icon = iconId,
|
||||
}
|
||||
local ok, icon = pcall(SpriteRenderer.new, def, "gen2fly")
|
||||
if not (ok and icon) then return nil end
|
||||
local daytime = self.daytime or Palettes.daytimeFor(
|
||||
self.map and self.map.def, self:hour(), self.flashUsed)
|
||||
local colors = Palettes.spritePalette(self.palettes, daytime, def)
|
||||
if colors then
|
||||
icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime)))
|
||||
end
|
||||
return icon
|
||||
end
|
||||
|
||||
-- False when there is no icon sheet (or no love at all): the caller then flies
|
||||
-- the way it always did rather than parking the world on an animation.
|
||||
function World:startFlyAnim(phase, mon, onDone)
|
||||
local icon = self:flyIconFor(mon)
|
||||
local p = self.player
|
||||
if not (icon and p) then return false end
|
||||
local landing = phase == "to"
|
||||
self.flyAnim = {
|
||||
phase = phase, icon = icon, onDone = onDone, t = 0,
|
||||
px = p.px, py = p.py, xoff = 0, wave = 0,
|
||||
left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES,
|
||||
hover = landing and 0 or FLY.HOVER,
|
||||
amp = landing and FLY.TO_AMP or 0,
|
||||
y = landing and -FLY.RISE or 0,
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
-- FlyFunction_FrameTimer (engine/events/field_moves.asm:409) over the two
|
||||
-- AnimSeq_Fly* curves; the wobble is Sprites_Cosine's d * cos(n * pi / 32).
|
||||
function World:stepFlyAnim()
|
||||
local fa = self.flyAnim
|
||||
if not fa then return end
|
||||
local left = fa.left
|
||||
if left <= 0 then
|
||||
local done = fa.onDone
|
||||
self.flyAnim = nil
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
fa.left = left - 1
|
||||
if left >= 0x40 and left % 8 == 0 then
|
||||
self:playSfxNamed("Sfx_Fly", SFX.FLY)
|
||||
end
|
||||
fa.t = fa.t + 1
|
||||
local amp = fa.amp
|
||||
if fa.phase == "to" then
|
||||
if fa.y >= 0 then return end
|
||||
fa.y = fa.y + 2
|
||||
if amp > 0 then fa.amp = amp - 2 end
|
||||
else
|
||||
if fa.hover > 0 then
|
||||
fa.hover = fa.hover - 1
|
||||
return
|
||||
end
|
||||
if fa.y <= -FLY.RISE then return end
|
||||
fa.y = fa.y - 2
|
||||
if amp < FLY.AMP_MAX then fa.amp = amp + 8 end
|
||||
end
|
||||
fa.xoff = math.floor(amp * math.cos((fa.wave % 64) * math.pi / 32))
|
||||
fa.wave = fa.wave + 1
|
||||
end
|
||||
|
||||
-- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored
|
||||
-- -- data/sprite_anims/framesets.asm:81-86
|
||||
function World:drawFlyAnim(s, billboard)
|
||||
local fa = self.flyAnim
|
||||
if not (fa and fa.icon) then return end
|
||||
local G = love.graphics
|
||||
local cam = self.camera
|
||||
local px = fa.px + fa.xoff
|
||||
local py = fa.py + fa.y
|
||||
local ox = math.floor((0 - cam.x) * s)
|
||||
local oy = math.floor((0 - cam.y) * s)
|
||||
local beat = math.floor(fa.t / 8) % 4
|
||||
local function body()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
G.scale(s, s)
|
||||
fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2)
|
||||
G.pop()
|
||||
end
|
||||
if billboard then
|
||||
billboard(ox + (px + 8) * s, oy + (py + 16) * s, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
end
|
||||
|
||||
-- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`,
|
||||
-- then FlyToAnim -- engine/events/overworld.asm:595-609
|
||||
function World:flyTo(spawnId, mon)
|
||||
local spawn = self.landmarks and self.landmarks.spawns
|
||||
and self.landmarks.spawns[spawnId]
|
||||
if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then
|
||||
return false
|
||||
end
|
||||
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
|
||||
local ok = self:runMapSetup(MAPSETUP.TELEPORT, function()
|
||||
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
|
||||
end)
|
||||
-- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off
|
||||
-- lift under the fade out, the landing under the fade in.
|
||||
if self.mapSetup then self.mapSetup.lift = true end
|
||||
return ok
|
||||
local function warp()
|
||||
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
|
||||
local ok = self:runMapSetup(MAPSETUP.TELEPORT, function()
|
||||
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
|
||||
end)
|
||||
if self.mapSetup then self.mapSetup.flyIn = mon end
|
||||
return ok
|
||||
end
|
||||
if self:startFlyAnim("from", mon, warp) then return true end
|
||||
return warp()
|
||||
end
|
||||
|
||||
-- _FlyMap: the town map with the cursor locked to visited flypoints, A takes
|
||||
@@ -5898,7 +6088,7 @@ end
|
||||
-- "Where?" plate over it instead of the card strip. A run with no love at all
|
||||
-- (a headless probe) has no screen to push, so the destinations are offered
|
||||
-- one at a time through the same yesorno box every other field move uses.
|
||||
function World:openFlyMap()
|
||||
function World:openFlyMap(mon)
|
||||
local points = self:flyPoints()
|
||||
if #points == 0 then return false end
|
||||
-- Loaded on demand and through pcall: a headless run has no love, and this
|
||||
@@ -5909,28 +6099,31 @@ function World:openFlyMap()
|
||||
save = self.game.save,
|
||||
currentLandmark = self:currentLandmarkId(),
|
||||
fly = points,
|
||||
-- TownMapMon draws wCurPartyMon's icon as the cursor
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721).
|
||||
flyMon = mon,
|
||||
onFly = function(spawnId)
|
||||
self.game.stack:pop()
|
||||
self:flyTo(spawnId)
|
||||
self:flyTo(spawnId, mon)
|
||||
end,
|
||||
onClose = function() self.game.stack:pop() end,
|
||||
})
|
||||
return true
|
||||
end
|
||||
self:askFlyPoint(points, 1)
|
||||
self:askFlyPoint(points, 1, mon)
|
||||
return true
|
||||
end
|
||||
|
||||
function World:askFlyPoint(points, index)
|
||||
function World:askFlyPoint(points, index, mon)
|
||||
local row = points[index]
|
||||
if not row then return end
|
||||
local name = (row.name or row.landmark):gsub("\n", " ")
|
||||
self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function()
|
||||
self:askYesNo(function(yes)
|
||||
if yes then
|
||||
self:flyTo(row.spawn)
|
||||
self:flyTo(row.spawn, mon)
|
||||
else
|
||||
self:askFlyPoint(points, index + 1)
|
||||
self:askFlyPoint(points, index + 1, mon)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -5958,6 +6151,18 @@ function World:updateFieldMove()
|
||||
st.timer = st.timer - 1
|
||||
return
|
||||
end
|
||||
if st.phase == "whirlpoolsfx" then
|
||||
st.left = (st.left or 0) - 1
|
||||
if st.waiting then
|
||||
if Sound.sfxBusy() and st.left > 0 then return end
|
||||
st.waiting = nil
|
||||
self:playSfxNamed("Sfx_Surf", SFX.SURF)
|
||||
return
|
||||
end
|
||||
if Sound.sfxBusy() and st.left > 0 then return end
|
||||
self.fieldMove = nil
|
||||
return
|
||||
end
|
||||
if st.phase == "strength" then
|
||||
self.fieldMove = nil
|
||||
if st.text then self:showText(Strings(st.text)) end
|
||||
@@ -6056,6 +6261,9 @@ function World:startBattle(opts, onDone)
|
||||
-- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/
|
||||
-- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off.
|
||||
battleTower = opts.battleTower,
|
||||
-- wTimeOfDay, for BattleCommand_TimeBasedHealContinue
|
||||
-- (engine/battle/effect_commands.asm:6401-6404).
|
||||
timeOfDay = self:timeOfDayId(),
|
||||
})
|
||||
self:playBattleMusic(opts)
|
||||
local function pushBattle()
|
||||
@@ -8639,8 +8847,10 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
-- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets
|
||||
-- them straight back again: leaving them set is how one visit to Route 17
|
||||
-- would keep the player glued to the bike for the rest of the game.
|
||||
self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false)
|
||||
self:setEngineFlag(Bike.ENGINE_DOWNHILL, false)
|
||||
self:setEngineFlag(World.engineFlagId(
|
||||
self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE), false)
|
||||
self:setEngineFlag(World.engineFlagId(
|
||||
self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL), false)
|
||||
-- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn):
|
||||
-- walking from an OUTDOOR map into an INDOOR one whose tileset is
|
||||
-- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and
|
||||
@@ -9741,6 +9951,7 @@ end
|
||||
|
||||
function World:stepBody()
|
||||
if not self.map or not self.player then return end
|
||||
self.stepFinished = false
|
||||
self:pollTimeOfDay()
|
||||
-- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is
|
||||
-- parked on the earthquake's own waitFrames while the screen is still
|
||||
@@ -9821,6 +10032,7 @@ function World:stepBody()
|
||||
-- it ticks here above the input gate the same way the emote does; its
|
||||
-- last flash's onDone is what resumes the nurse.
|
||||
if self.healAnim then self:stepHealAnim() end
|
||||
if self.flyAnim then self:stepFlyAnim() end
|
||||
|
||||
-- HandleCmdQueue sits in the overworld loop, once a frame, above the input
|
||||
-- gate: it is what drops a boulder that is already sitting on a hole, and it
|
||||
@@ -9841,8 +10053,9 @@ function World:stepBody()
|
||||
|
||||
-- Freeze player input while a script / textbox / cutscene move is up.
|
||||
if self:busy() then
|
||||
-- Keep scripted entities animating mid-step.
|
||||
if self.player and self.player.moving then
|
||||
-- Keep scripted entities animating mid-step. A step_dig spin has the
|
||||
-- player standing still, so it has to tick here too.
|
||||
if self.player and (self.player.moving or self.player.spinFrames) then
|
||||
self:playerStepGrass()
|
||||
if self.player:update() then
|
||||
self.player.inGrass =
|
||||
@@ -9856,6 +10069,7 @@ function World:stepBody()
|
||||
local p = self.player
|
||||
self:playerStepGrass()
|
||||
local landed = p:update()
|
||||
self.stepFinished = landed
|
||||
-- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step
|
||||
-- ENDS on (engine/overworld/map_objects.asm:196-208, :247).
|
||||
if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end
|
||||
@@ -9917,6 +10131,11 @@ function World:stepBody()
|
||||
local dir = self.heldDir
|
||||
if not p.moving then
|
||||
local coll = self:playerCollision()
|
||||
-- .CheckTile tests CheckWhirlpoolTile above the nybble ladder
|
||||
-- -- engine/overworld/player_movement.asm:117-123 (#1716)
|
||||
if Permissions.isWhirlpool(coll) and self:runForcedMovement() then
|
||||
return
|
||||
end
|
||||
local current = Permissions.currentDirection(coll)
|
||||
or Permissions.doorForcedDirection(coll)
|
||||
if current then
|
||||
@@ -10030,18 +10249,21 @@ function World:drawPeople(s, billboard)
|
||||
local G = love.graphics
|
||||
local p = self.player
|
||||
local cam = self.camera
|
||||
local drawList = {
|
||||
{ kind = "player", py = p.py, ox = 0, oy = 0 },
|
||||
}
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
drawList[#drawList + 1] = {
|
||||
kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py,
|
||||
}
|
||||
end
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
drawList[#drawList + 1] = {
|
||||
kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py,
|
||||
}
|
||||
-- .FlyScript hides the map's objects from HideSprites until its
|
||||
-- LoadWalkingSpritesGFX tail -- engine/events/overworld.asm:597, :608
|
||||
local drawList = {}
|
||||
if not self.flyAnim then
|
||||
drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 }
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
drawList[#drawList + 1] = {
|
||||
kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py,
|
||||
}
|
||||
end
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
drawList[#drawList + 1] = {
|
||||
kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(drawList, function(a, b) return a.py < b.py end)
|
||||
|
||||
@@ -10076,6 +10298,7 @@ function World:drawPeople(s, billboard)
|
||||
|
||||
self:drawEmote(s, billboard)
|
||||
self:drawHealAnim(s, billboard)
|
||||
self:drawFlyAnim(s, billboard)
|
||||
end
|
||||
|
||||
-- Split out of drawPeople so World:drawPipeline composites the one copy the
|
||||
@@ -10143,11 +10366,12 @@ function World:drawPipeline(id, w, h, s)
|
||||
-- the art: nil, like Gen 1 returns in its true-colour modes.
|
||||
paletteFor = function() return nil end,
|
||||
spriteColors = function() return nil end,
|
||||
-- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay,
|
||||
-- and Gen 1's `at` skips a nil body, so those keys are simply absent.
|
||||
-- Gold's only standing effects; it has no dust/cutTree/rod overlay, and
|
||||
-- Gen 1's `at` skips a nil body, so those keys are simply absent.
|
||||
fx = {
|
||||
emote = function() self:drawEmote(1, nil) end,
|
||||
heal = function() self:drawHealAnim(1, nil) end,
|
||||
bird = function() self:drawFlyAnim(1, nil) end,
|
||||
},
|
||||
}
|
||||
-- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the
|
||||
@@ -10165,6 +10389,7 @@ function World:drawPipeline(id, w, h, s)
|
||||
end
|
||||
self:drawEmote(1, at)
|
||||
self:drawHealAnim(1, at)
|
||||
self:drawFlyAnim(1, at)
|
||||
end
|
||||
local override = Pipelines.drawWorld(id, ctx)
|
||||
-- world post-processes fold in here, so they never touch the text box on top
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
-- Eye check on the CANCEL row at the foot of the item list (#1685).
|
||||
-- PrintListMenuEntries prints ListMenuCancelText on the $ff terminator and
|
||||
-- returns there (pokered home/list_menu.asm:371-372, 523-528), so the '▼'
|
||||
-- at :518-522 never shares a page with CANCEL; DisplayListMenuIDLoop treats
|
||||
-- that row as ExitListMenu (:105-110). No POKEPORT_SPEED: the arrow and
|
||||
-- the cursor step are being judged frame by frame.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bag_cancel_row_bug1685_test.lua POKEPORT_IDENTITY=bug1685 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5)
|
||||
-- and (13,5) and the lab's at (12,11), so (10,8) is open ground.
|
||||
local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8
|
||||
-- six items: page one fills all four printed rows (so the '▼' is there to
|
||||
-- lose), and CANCEL lands on the page after it
|
||||
local STOCK = { "POTION", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL",
|
||||
"AWAKENING", "PARLYZ_HEAL" }
|
||||
|
||||
for _, id in ipairs(STOCK) do
|
||||
check(id .. " exists as an item", game.data.items[id] ~= nil)
|
||||
end
|
||||
check("CANCEL has a string", Strings("CANCEL") ~= nil and Strings("CANCEL") ~= "")
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
game.save.player.name = "SEBAS"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory = {}
|
||||
game.save.bagOrder = nil
|
||||
local Bag = require("src.inventory.Bag")
|
||||
for i, id in ipairs(STOCK) do Bag.add(game.save, id, i) end
|
||||
|
||||
local function stationary()
|
||||
for _ = 1, 60 do
|
||||
if not (game.overworld and game.overworld.player.moving) then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
|
||||
local function openBag(where)
|
||||
stationary()
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
if getmetatable(menu) == Menu then
|
||||
local ITEM = Strings("ITEM")
|
||||
for _ = 1, 20 do
|
||||
if menu.items[menu.index].label == ITEM then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
local bag = game.stack:top()
|
||||
if getmetatable(bag) ~= ListMenu then
|
||||
U.log("START -> ITEM did not reach the bag " .. where
|
||||
.. ", pushing BagMenu directly")
|
||||
bag = Screens.push(game, "BagMenu")
|
||||
U.wait(20)
|
||||
end
|
||||
return getmetatable(bag) == ListMenu and bag or nil
|
||||
end
|
||||
|
||||
local function toTop(bag)
|
||||
for _ = 1, #bag.items + 2 do
|
||||
if bag.index == 1 then break end
|
||||
U.tap(game, "up")
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
|
||||
U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down")
|
||||
U.wait(20)
|
||||
|
||||
local bag = openBag("in Pallet Town")
|
||||
if check("the bag opened", bag ~= nil) then
|
||||
check("six items are in it", #bag.items == #STOCK + 1)
|
||||
local last = bag.items[#bag.items]
|
||||
check("the row after the last item is the terminator's CANCEL",
|
||||
last ~= nil and last.cancel == true)
|
||||
check("and it carries no item id", last ~= nil and last.value == nil)
|
||||
check("labelled CANCEL", last ~= nil and last.label == Strings("CANCEL"))
|
||||
|
||||
toTop(bag)
|
||||
check("page one shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1685_page_one.png"))
|
||||
|
||||
-- walk down until the cursor stops moving: it must stop ON the CANCEL
|
||||
-- row, not one row short of it
|
||||
local before
|
||||
for _ = 1, #bag.items + 4 do
|
||||
before = bag.index
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
if bag.index == before then break end
|
||||
end
|
||||
check("the cursor walked all the way onto the CANCEL row",
|
||||
bag.items[bag.index] ~= nil and bag.items[bag.index].cancel == true)
|
||||
check("and stops there", bag.index == #bag.items)
|
||||
check("with the list scrolled to its last page",
|
||||
bag.scroll == #bag.items - (bag.cursorRows or bag.rows))
|
||||
check("cancel row shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1685_cancel_row.png"))
|
||||
|
||||
-- A on CANCEL is ExitListMenu: the same exit B takes
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
local stillUp = false
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s == bag then stillUp = true end
|
||||
end
|
||||
check("A on CANCEL closed the item list", not stillUp)
|
||||
check("and left no box or submenu over it",
|
||||
getmetatable(game.stack:top()) ~= ListMenu)
|
||||
check("after-cancel shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1685_after_cancel.png"))
|
||||
end
|
||||
|
||||
for _ = 1, 10 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(12)
|
||||
end
|
||||
|
||||
-- an empty bag is a box with CANCEL alone, which is what the cart shows;
|
||||
-- the port used to print an invented "Nothing here." instead
|
||||
game.save.inventory = {}
|
||||
game.save.bagOrder = nil
|
||||
local empty = openBag("with an empty bag")
|
||||
if check("the empty bag still opens a box", empty ~= nil) then
|
||||
check("holding exactly one row", #empty.items == 1)
|
||||
check("and that row is CANCEL",
|
||||
empty.items[1] ~= nil and empty.items[1].cancel == true)
|
||||
check("empty bag shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1685_empty_bag.png"))
|
||||
end
|
||||
|
||||
U.log("")
|
||||
if ok then
|
||||
U.log("the bag has been opened, walked to the bottom and cancelled for you.")
|
||||
U.log("in bug1685_page_one.png the four item names fill the box and the")
|
||||
U.log("down arrow sits in the lower right corner. in bug1685_cancel_row.png")
|
||||
U.log("CANCEL is one row below the last item, at the same left edge as the")
|
||||
U.log("names, with the cursor on it -- and that corner arrow must be GONE.")
|
||||
U.log("an arrow still painted next to CANCEL is the near miss here: it is")
|
||||
U.log("easy to miss in a still and it is the exact thing the terminator's")
|
||||
U.log("tail jump settles.")
|
||||
U.log("bug1685_empty_bag.png is an empty bag: a box with CANCEL and nothing")
|
||||
U.log("else. that replaces the port's old \"Nothing here.\" line, which the")
|
||||
U.log("cart never printed, so it is a deliberate change, not a regression.")
|
||||
U.log("the empty bag is on screen now; A or B should both close it.")
|
||||
U.log("yellow shares this list code: rerun with POKEPORT_VERSION=yellow and")
|
||||
U.log("a yellow identity and the three shots should look the same.")
|
||||
else
|
||||
U.log("a check above failed, so nothing on screen is worth reading yet.")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -160,9 +160,8 @@ return function(game)
|
||||
if check("the bag opened", isBag) then
|
||||
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
|
||||
"BICYCLE") then
|
||||
U.tap(game, "a") -- USE / TOSS submenu
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE is the first row
|
||||
-- the BICYCLE skips USE/TOSS (start_sub_menus.asm:340-342)
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
-- Eye check that the BICYCLE never shows the USE/TOSS box (#1705).
|
||||
-- StartMenu_Item .choseItem does `cp BICYCLE / jp z, .useOrTossItem` before
|
||||
-- it loads USE_TOSS_MENU_TEMPLATE (engine/menus/start_sub_menus.asm:340-342),
|
||||
-- so mount, dismount and the Cycling Road refusal all land on one A press.
|
||||
-- No POKEPORT_SPEED: it scales only the logic clock, and the frame the box
|
||||
-- would flash on is the whole point of this run.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_option_box_bug1705_test.lua POKEPORT_IDENTITY=bug1705 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5)
|
||||
-- and (13,5) and the lab's at (12,11), so (10,8) is open ground. OVERWORLD
|
||||
-- tileset, so IsBikeRidingAllowed says yes here (home/overworld.asm).
|
||||
local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8
|
||||
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,11) is a landing cell
|
||||
-- out of the Route 16 gate's south door and arms BIT_ALWAYS_ON_BIKE.
|
||||
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
|
||||
-- pokered data/maps/objects/Route16Gate1F.asm: warps on x 0 and 7, the
|
||||
-- guard on (4,5), so (3,6) is plain floor -- and the gate script is what
|
||||
-- clears the flag again (scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
|
||||
local GATE_MAP, GATE_X, GATE_Y = "ROUTE_16_GATE_1F", 3, 6
|
||||
|
||||
-- a missing item def, an unextracted refusal line and a bag that never
|
||||
-- opens all look like the bug from the couch
|
||||
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
|
||||
local cannot = game.data.text._CannotGetOffHereText
|
||||
check("_CannotGetOffHereText was extracted",
|
||||
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
|
||||
check("POTION exists as the control item", game.data.items.POTION ~= nil)
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
game.save.player.name = "SEBAS"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.inventory.POTION = 3
|
||||
game.save.onBike = false
|
||||
game.save.forcedBike = nil
|
||||
|
||||
local function stationary()
|
||||
for _ = 1, 90 do
|
||||
game.input.state.b = true
|
||||
if not (game.overworld and game.overworld.player.moving) then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state.b = false
|
||||
end
|
||||
|
||||
-- START -> ITEM -> the bag, by real presses. Route 16/17 roll the player
|
||||
-- south whenever nothing is held, so brake into a standstill first or
|
||||
-- handleInput eats the START.
|
||||
local function openBag(where)
|
||||
stationary()
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
if getmetatable(menu) == Menu then
|
||||
local ITEM = Strings("ITEM")
|
||||
for _ = 1, 20 do
|
||||
if menu.items[menu.index].label == ITEM then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
local bag = game.stack:top()
|
||||
if getmetatable(bag) ~= ListMenu then
|
||||
-- a start menu that reordered itself would otherwise strand the run;
|
||||
-- say so, then get to the moment anyway
|
||||
U.log("START -> ITEM did not reach the bag on " .. where
|
||||
.. ", pushing BagMenu directly")
|
||||
bag = Screens.push(game, "BagMenu")
|
||||
U.wait(20)
|
||||
end
|
||||
return getmetatable(bag) == ListMenu and bag or nil
|
||||
end
|
||||
|
||||
-- the cursor comes back on the last-used row (#1732), so walk to the top
|
||||
-- before walking down or a row above the saved one is unreachable
|
||||
local function cursorTo(bag, id)
|
||||
for _ = 1, #bag.items do
|
||||
if bag.index == 1 then break end
|
||||
U.tap(game, "up")
|
||||
U.wait(5)
|
||||
end
|
||||
for _ = 1, #bag.items do
|
||||
local row = bag.items[bag.index]
|
||||
if row and row.value == id then return true end
|
||||
U.tap(game, "down")
|
||||
U.wait(5)
|
||||
end
|
||||
return bag.items[bag.index] and bag.items[bag.index].value == id
|
||||
end
|
||||
|
||||
-- StartMenu_Item keeps the START menu up behind the bag, and that is a
|
||||
-- Menu too, so only a Menu that was NOT already there counts as the
|
||||
-- option box
|
||||
local function menuSnapshot()
|
||||
local seen = {}
|
||||
for _, s in ipairs(game.stack.states) do seen[s] = true end
|
||||
return seen
|
||||
end
|
||||
|
||||
local function newMenu(before)
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if getmetatable(s) == Menu and not before[s] then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- press A and watch every frame after it: a USE/TOSS box that appears and
|
||||
-- is replaced two frames later is invisible in a still, and is the near
|
||||
-- miss this whole run exists to catch
|
||||
local function chooseAndWatch(frames)
|
||||
local before = menuSnapshot()
|
||||
U.tap(game, "a")
|
||||
local flashed = newMenu(before)
|
||||
for _ = 1, frames or 30 do
|
||||
if newMenu(before) then flashed = true end
|
||||
coroutine.yield()
|
||||
end
|
||||
return flashed, before
|
||||
end
|
||||
|
||||
local function boxSays()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local lines = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return table.concat(lines, " / ")
|
||||
end
|
||||
|
||||
local function dismissBox()
|
||||
for _ = 1, 8 do
|
||||
if getmetatable(game.stack:top()) ~= TextBox then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(12)
|
||||
end
|
||||
end
|
||||
|
||||
local function backToOverworld()
|
||||
for _ = 1, 10 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(12)
|
||||
end
|
||||
end
|
||||
|
||||
-- the control: an ordinary item DOES open the box, so a run where nothing
|
||||
-- ever appears cannot be mistaken for the fix working
|
||||
U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down")
|
||||
U.wait(20)
|
||||
local bag = openBag("Pallet Town")
|
||||
if check("the bag opened in " .. TOWN, bag ~= nil) then
|
||||
if check("found the POTION row", cursorTo(bag, "POTION")) then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("a POTION still opens USE/TOSS", getmetatable(game.stack:top()) == Menu)
|
||||
check("control shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1705_potion_usetoss.png"))
|
||||
U.tap(game, "b")
|
||||
U.wait(15)
|
||||
end
|
||||
end
|
||||
|
||||
-- mount: one press, straight to the text
|
||||
if bag and check("found the BICYCLE row", cursorTo(bag, "BICYCLE")) then
|
||||
local flashed, before = chooseAndWatch(4)
|
||||
check("no option box in the first frames after A (mount)", not flashed)
|
||||
check("early shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1705_mount_first_frames.png"))
|
||||
U.wait(60)
|
||||
check("still no option box while the text types", not newMenu(before))
|
||||
local said = boxSays()
|
||||
U.log("the box reads:", tostring(said))
|
||||
check("the single press mounted the bike", game.save.onBike == true)
|
||||
check("and the line is the mount text",
|
||||
said ~= nil and said:find("got on", 1, true) ~= nil)
|
||||
check("mount shot reached disk", U.shot(game, SHOT_DIR .. "/bug1705_mount.png"))
|
||||
end
|
||||
dismissBox()
|
||||
backToOverworld()
|
||||
|
||||
-- dismount: the same one press
|
||||
bag = openBag("Pallet Town (riding)")
|
||||
if bag and check("found the BICYCLE row again", cursorTo(bag, "BICYCLE")) then
|
||||
local flashed = chooseAndWatch(4)
|
||||
check("no option box in the first frames after A (dismount)", not flashed)
|
||||
U.wait(60)
|
||||
local said = boxSays()
|
||||
U.log("the box reads:", tostring(said))
|
||||
check("the single press dismounted", game.save.onBike == false)
|
||||
check("and the line is the dismount text",
|
||||
said ~= nil and said:find("got off", 1, true) ~= nil)
|
||||
check("dismount shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1705_dismount.png"))
|
||||
end
|
||||
dismissBox()
|
||||
backToOverworld()
|
||||
|
||||
-- the Cycling Road refusal, armed the way the game arms it: by arriving on
|
||||
-- the forced-bike cell (setMap runs checkForcedMovement on entry)
|
||||
game.save.onBike = false
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
|
||||
U.wait(25)
|
||||
check("stepping onto the Route 16 cell mounts the bike", game.save.onBike == true)
|
||||
check("and arms the forced-bike flag", game.save.forcedBike == true)
|
||||
bag = openBag("Route 16")
|
||||
if bag and check("found the BICYCLE row on the road", cursorTo(bag, "BICYCLE")) then
|
||||
local flashed = chooseAndWatch(4)
|
||||
check("no option box in the first frames after A (refusal)", not flashed)
|
||||
U.wait(60)
|
||||
local said = boxSays()
|
||||
U.log("the box reads:", tostring(said))
|
||||
check("the refusal printed, not the dismount",
|
||||
said ~= nil and said:find("get off", 1, true) ~= nil)
|
||||
check("the player is still riding", game.save.onBike == true)
|
||||
local stillUp = false
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s == bag then stillUp = true end
|
||||
end
|
||||
check("and the bag list is still on the stack under the box (#513)", stillUp)
|
||||
check("refusal shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1705_forced_refusal.png"))
|
||||
end
|
||||
dismissBox()
|
||||
backToOverworld()
|
||||
|
||||
-- walking into the gate releases the flag; the gate's tileset is not one of
|
||||
-- IsBikeRidingAllowed's (home/overworld.asm), so the map change dismounts on
|
||||
-- the way in and the release has to be shown back out on the road
|
||||
U.teleport(game, GATE_MAP, GATE_X, GATE_Y, "up")
|
||||
U.wait(25)
|
||||
check("the gate clears the forced-bike flag",
|
||||
game.save.forcedBike == nil or game.save.forcedBike == false)
|
||||
|
||||
-- a plain Route 16 cell: walkable, nobody on it, and NOT one of the two
|
||||
-- forced-bike tiles, so nothing re-arms the flag when we land
|
||||
local function plainRoadCell()
|
||||
local ow = game.overworld
|
||||
local fm = game.data.field.forcedMovement
|
||||
local forced = {}
|
||||
for _, t in ipairs((fm and fm.tiles and fm.tiles[FORCE_MAP]) or {}) do
|
||||
forced[t.x .. "," .. t.y] = true
|
||||
end
|
||||
if not ow then return nil end
|
||||
for y = 0, 35 do
|
||||
for x = 0, 19 do
|
||||
if not forced[x .. "," .. y] and ow.map:inBounds(x, y)
|
||||
and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y - 3, "up")
|
||||
U.wait(20)
|
||||
local px, py = plainRoadCell()
|
||||
if px then
|
||||
U.teleport(game, FORCE_MAP, px, py, "up")
|
||||
U.wait(20)
|
||||
end
|
||||
check("back on Route 16, off the forced tiles, flag still clear",
|
||||
game.save.forcedBike == nil or game.save.forcedBike == false)
|
||||
|
||||
-- with the flag released the bike answers again: mount, then get off, one
|
||||
-- press each, and the get-off is the very thing the road refused
|
||||
if not game.save.onBike then
|
||||
bag = openBag("Route 16 (released)")
|
||||
if bag and check("found the BICYCLE row to remount", cursorTo(bag, "BICYCLE")) then
|
||||
local flashed = chooseAndWatch(4)
|
||||
check("no option box on the remount", not flashed)
|
||||
U.wait(60)
|
||||
U.log("the box reads:", tostring(boxSays()))
|
||||
check("the remount worked", game.save.onBike == true)
|
||||
end
|
||||
dismissBox()
|
||||
backToOverworld()
|
||||
end
|
||||
|
||||
bag = openBag("Route 16 (getting off)")
|
||||
if bag and check("found the BICYCLE row again on the road",
|
||||
cursorTo(bag, "BICYCLE")) then
|
||||
local flashed = chooseAndWatch(4)
|
||||
check("no option box on the released dismount", not flashed)
|
||||
U.wait(60)
|
||||
local said = boxSays()
|
||||
U.log("the box reads:", tostring(said))
|
||||
check("the flag really is released: this one gets off",
|
||||
game.save.onBike == false)
|
||||
check("and the line is the dismount text, not the refusal",
|
||||
said ~= nil and said:find("got off", 1, true) ~= nil)
|
||||
check("released dismount shot reached disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug1705_released_dismount.png"))
|
||||
end
|
||||
|
||||
check("the BICYCLE was never consumed", (game.save.inventory.BICYCLE or 0) == 1)
|
||||
|
||||
U.log("")
|
||||
if ok then
|
||||
U.log("the bike has been used five times for you: mounted and dismounted in")
|
||||
U.log("PALLET TOWN, refused on the Route 16 forced stretch, then mounted and")
|
||||
U.log("taken off again after the gate released the flag. every one of those")
|
||||
U.log("was a single A press on the BICYCLE row, with no USE/TOSS box.")
|
||||
U.log("shots are in " .. SHOT_DIR .. ": bug1705_potion_usetoss.png is the")
|
||||
U.log("control, the box a POTION still gets. bug1705_mount_first_frames.png")
|
||||
U.log("is two frames after the A press on the bike -- that one must show")
|
||||
U.log("the mount text or a bare map, never a small box in the lower right.")
|
||||
U.log("the near miss is a box that appears and is replaced before the text")
|
||||
U.log("types, which reads as a pass in every later shot.")
|
||||
U.log("you are on Route 16 with the bag closed; open it and try the bike as")
|
||||
U.log("often as you like. it also has no TOSS any more, since the box that")
|
||||
U.log("carried it is the one that no longer opens.")
|
||||
U.log("yellow shares this code path: rerun with POKEPORT_VERSION=yellow")
|
||||
U.log("and a yellow identity, and every beat should read the same.")
|
||||
else
|
||||
U.log("a check above failed, so nothing on screen is worth reading yet.")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
-- SHADER FX preset examples on Crystal, including a stacked pair.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=crystal-dev POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/shaderfx \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_shaderfx_shots.lua love .
|
||||
--
|
||||
-- Never run this under POKEPORT_SPEED: the shots are of a live frame.
|
||||
local U = require("tests.drivers.util")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
|
||||
local WANT = {
|
||||
{ file = "gameboy-color-dot-matrix.slangp", tag = "gbc-dot-matrix" },
|
||||
{ file = "sameboy-lcd.slangp", tag = "sameboy-lcd" },
|
||||
{ file = "crt-caligari.slangp", tag = "crt-caligari" },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shaderfx"
|
||||
|
||||
U.wait(30)
|
||||
U.log(("bridge canConvert=%s"):format(tostring(ShaderFX.canConvert())))
|
||||
if not ShaderFX.canConvert() then
|
||||
U.log("FAIL no librashader bridge, nothing can be converted")
|
||||
return
|
||||
end
|
||||
|
||||
local list = ShaderFX.list()
|
||||
U.log(("found %d presets"):format(#list))
|
||||
if #list == 0 then U.log("FAIL no presets on disk") return end
|
||||
|
||||
local byName = {}
|
||||
for _, e in ipairs(list) do byName[e.name] = e end
|
||||
|
||||
local ready = {}
|
||||
for _, want in ipairs(WANT) do
|
||||
local entry = byName[want.file]
|
||||
if not entry then
|
||||
U.log(("SKIP %s not in the preset set"):format(want.file))
|
||||
else
|
||||
local ok, err = true, nil
|
||||
if not entry.converted then ok, err = ShaderFX.convert(entry) end
|
||||
if ok then
|
||||
ready[#ready + 1] = { entry = entry, tag = want.tag }
|
||||
U.log(("PASS converted %s"):format(want.file))
|
||||
else
|
||||
U.log(("FAIL convert %s: %s"):format(want.file, tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #ready == 0 then U.log("FAIL nothing converted") return end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
if world and world.setMap then
|
||||
world:setMap("CHERRYGROVE_CITY", 21, 6, "down")
|
||||
U.wait(20)
|
||||
else
|
||||
U.log("note: no gen2 world yet, shooting whatever is on screen")
|
||||
end
|
||||
|
||||
ShaderFX.deactivate("main")
|
||||
ShaderFX.deactivate("secondary")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/00-none.png")
|
||||
U.log("shot: no shader")
|
||||
|
||||
for i, r in ipairs(ready) do
|
||||
ShaderFX.deactivate("secondary")
|
||||
local ok, err = ShaderFX.activate("main", r.entry)
|
||||
U.wait(30)
|
||||
if ok then
|
||||
U.shot(game, ("%s/%02d-%s.png"):format(out, i, r.tag))
|
||||
U.log(("shot: %s"):format(r.tag))
|
||||
else
|
||||
U.log(("FAIL activate %s: %s"):format(r.tag, tostring(err)))
|
||||
end
|
||||
end
|
||||
|
||||
if #ready >= 2 then
|
||||
local a, b = ready[1], ready[2]
|
||||
local okA = ShaderFX.activate("main", a.entry)
|
||||
local okB = ShaderFX.activate("secondary", b.entry)
|
||||
U.wait(30)
|
||||
if okA and okB then
|
||||
U.shot(game, ("%s/%02d-stacked-%s-over-%s.png"):format(out, #ready + 1, a.tag, b.tag))
|
||||
U.log(("shot: stacked %s + %s"):format(a.tag, b.tag))
|
||||
else
|
||||
U.log("FAIL could not stack two slots")
|
||||
end
|
||||
end
|
||||
|
||||
U.log("done. the pad is yours; SHADER FX rows are in OPTIONS.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm), shot
|
||||
-- finely enough to catch both Ditto bounces, plus both IntroSequence exits
|
||||
-- (pokecrystal engine/menus/intro_menu.asm:964-967): watched -> CrystalIntro,
|
||||
-- skipped -> the title.
|
||||
local U = require("tests.drivers.util")
|
||||
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
|
||||
local CrystalSplash = require("src.ui.gen2.CrystalSplash")
|
||||
local TitleState = require("src.ui.gen2.TitleState")
|
||||
|
||||
local LIMIT = 900
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-splash"
|
||||
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 2
|
||||
|
||||
U.wait(10)
|
||||
local finished, skipped
|
||||
local splash = CrystalSplash.new(game, {
|
||||
oakSpeech = game.oakSpeechData or {},
|
||||
onDone = function(s)
|
||||
finished, skipped = true, s
|
||||
end,
|
||||
})
|
||||
game.stack:clear()
|
||||
game.stack:push(splash)
|
||||
|
||||
local st = splash.anims.structs[1]
|
||||
assert(st and st.index ~= 0, "no Ditto sprite anim struct was spawned")
|
||||
local minY, sawMorph, scene = 256, false, -1
|
||||
while not finished and splash.frames < LIMIT do
|
||||
U.wait(interval)
|
||||
local offset = st.yOffset
|
||||
if offset >= 128 then offset = offset - 256 end
|
||||
if st.jt == 1 and offset < minY then minY = offset end
|
||||
if st.jt >= 3 then sawMorph = true end
|
||||
if splash.scene ~= scene then
|
||||
scene = splash.scene
|
||||
U.log(("scene %d at frame %d (jt=%d var1=%d)")
|
||||
:format(scene, splash.frames, st.jt, st.var1))
|
||||
end
|
||||
if not finished then
|
||||
U.shot(game, ("%s/splash-%04d-scene%d.png")
|
||||
:format(out, splash.frames, splash.scene))
|
||||
end
|
||||
end
|
||||
assert(finished, "the splash never finished (frame "
|
||||
.. splash.frames .. ", scene " .. splash.scene .. ")")
|
||||
assert(not skipped, "an unskipped splash reported skipped")
|
||||
assert(minY <= -90, "Ditto never reached the top of its 96px bounce (min "
|
||||
.. minY .. ")")
|
||||
assert(sawMorph, "the transform scene never ran")
|
||||
U.log(("splash done at frame %d, min bounce offset %d")
|
||||
:format(splash.frames, minY))
|
||||
|
||||
-- Watched through: Game2 routes to the intro movie.
|
||||
game:showGameFreak()
|
||||
U.wait(5)
|
||||
assert(getmetatable(game.stack:top()) == CrystalSplash,
|
||||
"showGameFreak did not open the Crystal splash")
|
||||
for _ = 1, LIMIT do
|
||||
if getmetatable(game.stack:top()) == CrystalIntro then break end
|
||||
U.wait(5)
|
||||
end
|
||||
assert(getmetatable(game.stack:top()) == CrystalIntro,
|
||||
"a watched splash did not hand off to CrystalIntro (top "
|
||||
.. tostring(game.stack:top()) .. ")")
|
||||
U.log("watched splash handed off to the intro movie")
|
||||
|
||||
-- Skipped: straight to the title, no intro movie.
|
||||
game:showGameFreak()
|
||||
U.wait(40)
|
||||
U.shot(game, out .. "/skip-before.png")
|
||||
U.tap(game, "b")
|
||||
for _ = 1, 60 do
|
||||
if getmetatable(game.stack:top()) == TitleState then break end
|
||||
assert(getmetatable(game.stack:top()) ~= CrystalIntro,
|
||||
"a skipped splash still played the intro movie")
|
||||
U.wait(1)
|
||||
end
|
||||
assert(getmetatable(game.stack:top()) == TitleState,
|
||||
"a skipped splash did not land on the title (top "
|
||||
.. tostring(game.stack:top()) .. ")")
|
||||
U.wait(30)
|
||||
U.shot(game, out .. "/skip-title.png")
|
||||
U.log("PASS crystal splash shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,278 @@
|
||||
-- #1709: BATTLE BG on Gold, the WHITE / BLACK void around the battle screen,
|
||||
-- and whether it survives a menu opened over the battle. The cart has no such
|
||||
-- setting; maps/Route29.asm:432 is only where this parks the player.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_battle_bg_bug1709_test.lua \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-battle-bg love .
|
||||
--
|
||||
-- No POKEPORT_SPEED: the shots land a fixed number of frames after a
|
||||
-- transition, and a logic clock running ahead of the render moves them.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-bg"
|
||||
local failures = 0
|
||||
|
||||
local function ok(label, condition, detail)
|
||||
if condition then
|
||||
print("[battlebg] ok " .. label)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("[battlebg] FAIL " .. label .. " " .. tostring(detail))
|
||||
end
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function shot(path)
|
||||
if not U.shot(game, path) then failures = failures + 1 end
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
-- ---- what the eye cannot check -----------------------------------------
|
||||
-- Each of these fails the same way the bug did: the void just stays white.
|
||||
ok("battleBg has a default in the Gold options table",
|
||||
Save.DEFAULT_OPTIONS.battleBg == "white", Save.DEFAULT_OPTIONS.battleBg)
|
||||
|
||||
local bgRow
|
||||
for i, row in ipairs(OptionsMenu.ROWS) do
|
||||
if row.label == "BATTLE BG" then bgRow = i end
|
||||
end
|
||||
ok("OPTION carries a BATTLE BG row", bgRow ~= nil, bgRow)
|
||||
if bgRow then
|
||||
ok("and it is the last row before CANCEL",
|
||||
OptionsMenu.ROWS[bgRow + 1] and OptionsMenu.ROWS[bgRow + 1].cancel == true,
|
||||
bgRow)
|
||||
end
|
||||
|
||||
local battleModule = require("src.ui.gen2.BattleState")
|
||||
ok("the Gold battle screen answers bgMode",
|
||||
type(battleModule.bgMode) == "function", type(battleModule.bgMode))
|
||||
ok("and Game2 owns the repaint, not the battle screen",
|
||||
type(game.paintBattleSurround) == "function",
|
||||
type(game.paintBattleSurround))
|
||||
|
||||
-- At the letterbox size there is no void, so nothing below would show.
|
||||
if love.window and love.window.setMode then
|
||||
love.window.setMode(1280, 840, { resizable = true })
|
||||
U.wait(6)
|
||||
end
|
||||
local winW, winH = love.graphics.getDimensions()
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
ok(("the window leaves a void to look at (%dx%d, panel at %d,%d x%d)")
|
||||
:format(winW, winH, ox, oy, scale), ox > 8 and oy > 8, ox .. "," .. oy)
|
||||
|
||||
local player = Mon.new(game.data, "CYNDAQUIL", 12)
|
||||
local wild = Mon.new(game.data, "PIDGEY", 4)
|
||||
ok("CYNDAQUIL builds from the extracted tables",
|
||||
player ~= nil and #player.moves > 0, player and #player.moves)
|
||||
ok("and so does the wild PIDGEY", wild ~= nil and #wild.moves > 0,
|
||||
wild and #wild.moves)
|
||||
game.save.party = { player }
|
||||
game.save.inventory = { POKE_BALL = 5, POTION = 3 }
|
||||
|
||||
-- maps/Route29.asm:432, the teacher's WALK_LEFT_RIGHT lane, so (15,11) and
|
||||
-- its neighbours are floor; a map edit that moves it falls back below.
|
||||
assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed")
|
||||
U.wait(8)
|
||||
if not Permissions.isWalkable(world:playerCollision()) then
|
||||
for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
|
||||
{ 2, 0 }, { -2, 0 } }) do
|
||||
if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down")
|
||||
and Permissions.isWalkable(world:playerCollision()) then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(8)
|
||||
end
|
||||
ok("the player is standing on floor, not in a wall",
|
||||
Permissions.isWalkable(world:playerCollision()),
|
||||
tostring(world:playerCollision()))
|
||||
|
||||
print(failures == 0
|
||||
and "[battlebg] preflight PASS -- the shots below are worth looking at"
|
||||
or ("[battlebg] preflight FAIL (%d) -- fix these before judging a pixel")
|
||||
:format(failures))
|
||||
|
||||
-- ---- the run -----------------------------------------------------------
|
||||
-- Start from WHITE whatever the gold-dev identity was left on, so the first
|
||||
-- half of the run is the same every time.
|
||||
game.options.battleBg = "white"
|
||||
shot(out .. "/00-route29.png")
|
||||
|
||||
-- OPTION, walked to off the START menu the way a player gets there.
|
||||
tap("start")
|
||||
local startMenu = game.stack:top()
|
||||
ok("START opened the menu", startMenu ~= nil and startMenu.items ~= nil,
|
||||
startMenu)
|
||||
if startMenu and startMenu.list then
|
||||
for _ = 1, #startMenu.items do
|
||||
local item = startMenu.items[startMenu.list.index]
|
||||
if item and item.value == "option" then break end
|
||||
tap("down", 3)
|
||||
end
|
||||
local landed = startMenu.items[startMenu.list.index]
|
||||
ok("the cursor found OPTION", landed and landed.value == "option",
|
||||
landed and landed.value)
|
||||
end
|
||||
tap("a", 10)
|
||||
|
||||
local options = game.stack:top()
|
||||
ok("the OPTION screen is up", options ~= nil and options.rows ~= nil, options)
|
||||
if options then
|
||||
-- UP from the first row wraps onto CANCEL, so BATTLE BG is two presses
|
||||
-- away however many rows the build has.
|
||||
for _ = 1, #options.rows do
|
||||
if options:row() and options:row().key == "battleBg" then break end
|
||||
tap("up", 3)
|
||||
end
|
||||
local row = options:row()
|
||||
ok("the cursor is on BATTLE BG", row and row.key == "battleBg",
|
||||
row and row.label)
|
||||
U.log("01: the OPTION screen, cursor on BATTLE BG. it should read WHITE,")
|
||||
U.log("sitting under MAX FPS and above CANCEL.")
|
||||
shot(out .. "/01-option-white.png")
|
||||
tap("right", 6)
|
||||
ok("right stored black", game.options.battleBg == "black",
|
||||
game.options.battleBg)
|
||||
U.log("02: same row after one press right. it should read BLACK, and no")
|
||||
U.log("other row on screen should have changed.")
|
||||
shot(out .. "/02-option-black.png")
|
||||
end
|
||||
-- Gold plays with an empty stack: the world is not a state, so "back on the
|
||||
-- map" is nothing on top rather than the overworld being on top.
|
||||
tap("b", 10)
|
||||
for _ = 1, 20 do
|
||||
if game.stack:top() == nil then break end
|
||||
tap("b", 4)
|
||||
end
|
||||
ok("the menus closed", game.stack:top() == nil, game.stack:top())
|
||||
|
||||
U.log("03: Route 29 with BATTLE BG on BLACK. the overworld is not a battle,")
|
||||
U.log("so there must be NO black bars here -- the surround around the map is")
|
||||
U.log("whatever VOID FILL draws, exactly as it was before.")
|
||||
shot(out .. "/03-route29-black-set.png")
|
||||
|
||||
-- ---- the battle --------------------------------------------------------
|
||||
assert(world:startBattle({ wild = wild }), "startBattle failed")
|
||||
local battle
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
ok("the battle screen came up after the transition", battle ~= nil, battle)
|
||||
if not battle then
|
||||
print(("[battlebg] FAIL no battle to shoot (%d)"):format(failures))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
for _ = 1, 150 do
|
||||
if battle.phase == "menu" then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase)
|
||||
ok("and it reports the black surround",
|
||||
battle:bgMode() == "black", battle:bgMode())
|
||||
U.wait(10)
|
||||
|
||||
U.log("04: the battle on BLACK. the void on all four sides of the GB screen")
|
||||
U.log("is solid black; the battle's own field, HUD and message box stay")
|
||||
U.log("white. black creeping over the panel edge -- a dark frame eating a")
|
||||
U.log("row of the HUD or the box border -- is the band maths being wrong.")
|
||||
shot(out .. "/04-battle-black.png")
|
||||
|
||||
-- The 2x2 is FIGHT, <PK><MN> / PACK, RUN. Clamp onto FIGHT first and step
|
||||
-- from there: one cell off is RUN, which ends the battle instead of a menu.
|
||||
local function pointAt(index)
|
||||
tap("left", 3)
|
||||
tap("up", 3)
|
||||
if index == 2 or index == 4 then tap("right", 3) end
|
||||
if index == 3 or index == 4 then tap("down", 3) end
|
||||
return battle.menuIndex == index
|
||||
end
|
||||
|
||||
ok("the cursor is on PKMN", pointAt(2), battle.menuIndex)
|
||||
tap("a", 12)
|
||||
ok("the party list opened over the battle", battle.phase == "submenu",
|
||||
battle.phase)
|
||||
U.log("05: the party list over the same battle. this is the one that used to")
|
||||
U.log("go wrong: the surround must still be black. white returning the")
|
||||
U.log("instant the list goes up means the repaint is on the wrong layer.")
|
||||
shot(out .. "/05-party-over-black.png")
|
||||
tap("b", 12)
|
||||
|
||||
ok("the cursor is on PACK", pointAt(3), battle.menuIndex)
|
||||
tap("a", 14)
|
||||
U.log("06: the PACK over the battle, same rule -- still black behind it.")
|
||||
shot(out .. "/06-pack-over-black.png")
|
||||
tap("b", 12)
|
||||
for _ = 1, 20 do
|
||||
if battle.phase == "menu" then break end
|
||||
tap("b", 4)
|
||||
end
|
||||
|
||||
-- SCREEN POS moves the panel off centre, which is the other way the bands
|
||||
-- can stop lining up with where the panel actually landed.
|
||||
local positions = {
|
||||
{ 7, "upper", "the panel sits high, so the black above it is thin and",
|
||||
"the band below is deep. both stop dead at the panel edge." },
|
||||
{ 8, "top", "the panel is flush with the top, so there is no black",
|
||||
"above it at all. a band up there means the lift was ignored." },
|
||||
}
|
||||
for _, pos in ipairs(positions) do
|
||||
game.options.screenPos = pos[2]
|
||||
game:applyOptions()
|
||||
U.wait(8)
|
||||
local px, py = Chrome.fitOrigin(love.graphics.getDimensions())
|
||||
U.log(("0%d: SCREEN POS %s, panel at %d,%d."):format(
|
||||
pos[1], pos[2], px, py))
|
||||
U.log(pos[3])
|
||||
U.log(pos[4])
|
||||
shot(out .. ("/0%d-screenpos-%s.png"):format(pos[1], pos[2]))
|
||||
end
|
||||
game.options.screenPos = "center"
|
||||
game:applyOptions()
|
||||
U.wait(8)
|
||||
|
||||
-- The no-regression half: WHITE has to look exactly like it always did.
|
||||
game.options.battleBg = "white"
|
||||
U.wait(6)
|
||||
ok("and back to white", battle:bgMode() == "white", battle:bgMode())
|
||||
U.log("09: the same battle flipped back to WHITE. paper white all the way to")
|
||||
U.log("the window edge, no seam where the panel ends -- compare it with 04.")
|
||||
shot(out .. "/09-battle-white.png")
|
||||
|
||||
ok("back on PKMN for the white pair", pointAt(2), battle.menuIndex)
|
||||
tap("a", 12)
|
||||
U.log("10: the party list over the white battle, for the same comparison.")
|
||||
shot(out .. "/10-party-over-white.png")
|
||||
tap("b", 12)
|
||||
|
||||
game.options.battleBg = "black"
|
||||
U.wait(6)
|
||||
|
||||
print(failures == 0 and "[battlebg] PASS gold_battle_bg_bug1709"
|
||||
or ("[battlebg] FAIL gold_battle_bg_bug1709 (%d)"):format(failures))
|
||||
U.log("the battle is still up on BLACK and the controls are yours. leaving")
|
||||
U.log("the OPTION screen wrote BLACK into the gold-dev options.lua, so the")
|
||||
U.log("next boot starts there; set it back from OPTION if you want white.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user