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:
bryanthaboi
2026-08-24 07:52:05 -04:00
parent f895293217
commit 1905261c5b
180 changed files with 19567 additions and 1556 deletions
+3 -4
View File
@@ -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
-12
View File
@@ -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
+6
View File
@@ -66,6 +66,12 @@ function love.conf(t)
t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
-- independent of t.accelerometerjoystick below (which instead maps the
-- accelerometer onto joystick axes and stays off -- see that flag's
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
-- reflective-screen look) has sensor data available without reviving #468.
t.modules.sensor = true
-- love.system is not loaded during love.conf; love._os is set by the
-- engine before conf runs (LÖVE 11.x / 11.5).
+17 -7
View File
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
},
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
-- port's talk pipeline, so it's dropped like other cry-only lines).
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
TEXT_POKEMONFANCLUB_PIKACHU = {
{ "show_text", "_PokemonFanClubPikachuText" },
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
},
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
-- itself, flavor line only.
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
-- shape with SEEL, PlayCry at :89.
TEXT_POKEMONFANCLUB_SEEL = {
{ "show_text", "_PokemonFanClubSeelText" },
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
},
},
}
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
if require("src.core.GameVersion").isYellow() then
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
}
end
return M
+3 -2
View File
@@ -1,10 +1,11 @@
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
return {
SS_ANNE_1F_ROOMS = {
talk = {
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
{"face_player"},
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
},
},
+3 -4
View File
@@ -1,12 +1,11 @@
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
-- (cry playback has no equivalent Commands.lua verb in this port, so only
-- the flavor text is ported).
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
return {
SS_ANNE_B1F_ROOMS = {
talk = {
TEXT_SSANNEB1FROOMS_MACHOKE = {
{ "face_player" },
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
},
},
+4 -3
View File
@@ -18,10 +18,11 @@ return {
-- VermilionCityMachopText: cries out, then follows up with a
-- second line about stomping the land flat.
-- (pokered/scripts/VermilionCity.asm)
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
TEXT_VERMILIONCITY_MACHOP = {
{ "show_text", "_VermilionCityMachopText" }, -- 1
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
{ "show_text", "_VermilionCityMachopText" }, -- 2
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
},
},
},
@@ -1,14 +1,12 @@
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
-- TextScriptEnd. This port has no cry-playback command, so only the
-- flavor line is ported.
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
return {
VERMILION_PIDGEY_HOUSE = {
talk = {
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
{"face_player"},
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
{"show_text", "_VermilionPidgeyHousePidgeyText"},
},
},
+36 -22
View File
@@ -381,22 +381,35 @@ M.VERMILION_CITY = {
return true
end,
talk = {
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
},
-- scripts/VermilionCity.asm:158 (#1651)
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
local t = game.data.text
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
done))
return
end
-- scripts/VermilionCity.asm:195
local p = ow and ow.player
if not p or p.facing == "right"
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1WelcomeToSSAnneText
or "Welcome to S.S.\nANNE!", done))
return
end
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
and (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")
or (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard.")
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
end,
},
}
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
TEXT_SSANNE2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9 (already beaten: silent)
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
{ "jump_if_false", 9 }, -- 7
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
},
},
}
+23
View File
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local t = game.data.text or {}
local p = ow and ow.player
-- scripts/Museum1F.asm:45 (#1690)
if p and ((p.cellY == 4 and p.cellX == 13)
or (p.cellY == 3 and p.cellX == 12)) then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1DoYouKnowWhatAmberIsText
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
nil, { choice = function(yes)
game.stack:push(TextBox.new(game, yes
and (t._Museum1FScientist1TheresALabSomewhereText
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
or "AMBER is fossil-\nized tree sap."), done))
end }))
return
end
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1TakePlentyOfTimeText
or "Take your time,\nand enjoy it all!", done))
return
end
-- scripts/Museum1F.asm:58
if p and p.cellY ~= 4 then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1GoToOtherSideText
or "Please go to the\nother side!", done))
return
end
-- scripts/Museum1F.asm:72
local money = function() return game.save.money end
game.stack:push(TextBox.new(game,
+45 -69
View File
@@ -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 }
ow:queueScript({
-- scripts/VermilionDock.asm:50 zeroes the player image index and
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
{ "face_player_dir", "down" },
{ "wait", 120 },
{ "play_sound", "SS_Anne_Horn" },
-- scripts/VermilionDock.asm:80 .shift_columns_up
{ "ss_anne_departs" },
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
{ "play_sound", "SS_Anne_Horn" },
{ "wait", 120 },
{ "move_player", "up", 2 },
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
{ "warp", "VERMILION_CITY", 18, 31, "up" },
{ "move_player", "up", 2 },
})
end
end,
}
+6 -4
View File
@@ -922,10 +922,12 @@ M.SS_ANNE_2F = {
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 13 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
{ "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
-10
View File
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
## Notes
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
level carried over in an `options.lua` from another machine. The H700's Mali
GPU is in the same class as the phone GPUs that compile that present pass and
then show a black frame (issue #136), and `love.system.getOS()` reports
`"Linux"` here, so the Android gate would not have caught it. Every other
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
+1 -1
View File
@@ -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
View File
@@ -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`,
+2
View File
@@ -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
+673
View File
@@ -0,0 +1,673 @@
# ShaderFX: runtime slang shader presets
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
a picker: any preset the player drops in a folder, or any preset pulled from
the RetroArch buildbot, can be selected and run. Engine:
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
This is first-party engine code, not a mod. It calls the native translator
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
declaration, and no mod boundary. Mods do not get that, and the distinction is
deliberate: the upstream maintainer will not accept `native` in mods.
## What a player sees
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
action row at the bottom.
A preset that has never been translated draws muted with a `CONVERT` hint on
the right. `A` on that row translates it in place and stays open: converting is
a preparation step, not a selection. `A` on a converted row activates it,
persists the choice, and closes. `SELECT` on a converted row opens
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
declares and lets the player step each one (`A` wraps, Left/Right clamp,
`SELECT` resets one row, `START` resets all behind a confirm).
Presets live in a plain OS folder, `shaders/` under the portable base directory
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
whatever nested layout it shipped with. These are real filesystem paths rather
than `love.filesystem` virtual paths on purpose: the native translator does
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
LUT loads.
Persisted state, all in `save.options`:
| Key | Meaning |
| --- | --- |
| `shaderfx` | main slot's preset name, or absent for OFF |
| `shaderfxSecondary` | secondary slot's preset name |
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
env var can never later override a real player choice, including a real choice
of OFF.
One quiet behavior worth knowing about: if a slot wants a real preset while
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
the saved choice was force-deactivated a few lines later on every boot, which
looks identical to "the setting does not save" from the player's side. On
Android and iOS that is the common case, because AUTO always resolves to
`balanced` there. It never touches an already-explicit performance choice and
never un-escalates.
## The five stages
| Stage | Owner |
| --- | --- |
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
| Fixup | `src/render/ShaderFixup.lua` |
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
### Fetch
Two acquisition paths, and they land in the same place. A player can copy a
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
mod index already use.
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
mirror and has no notion of this project's cache format, so a downloaded preset
goes through the same `CONVERT` row a hand-copied one does. Every platform
ships both convert and use; there is no asymmetry to work around.
Repeat downloads are conditional. The zip itself is deleted right after
extraction, so what is cached instead is the buildbot's own ETag
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
match with 304 and no body, and curl writes no file at all in that case, so
`installDownloaded(notModified)` short-circuits before touching the filesystem
and reports "already up to date" rather than "FAILED".
The interesting part is what gets extracted. `handheld/` is not self-contained:
its 78 presets carry 101 references that escape the folder (color-mod LUTs
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
them. Extracting `handheld/` alone silently breaks about a third of its own
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
content nobody asked for. So `extractClosure` walks the real file-level
dependency closure in Lua before a single file is copied, the same
`#reference`-closure idea librashader applies internally, done up front because
deciding what to copy has to happen before the translator ever sees these
files. Against this zip that is 207 files and about 9.5 MB with zero broken
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
than all 78, after most of `color-mod/` and `console-border/` turned out either
irrelevant (color-only, no LCD effect) or broken for this project. That list
is a temporary trim pending wider testing and is expected to change.
Two mechanical details in that walk that are easy to get wrong a second time.
`extractRefs` tries a quoted `key = "path"` match per line first, with an
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
delimiter and real packs ship paths with spaces and parentheses in them
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
quoted match falls back to a conservative character class, since an unquoted
path has no delimiter to trust past. And `love.filesystem.write` does not
create intermediate directories, so each destination directory is created once
before anything is written into it; without that, every file under a subfolder
`handheld/` never had before was silently dropped while flat writes succeeded.
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
it returns false, leaves the zip's handle open, and the following `remove()`
silently fails too, so every download used to leave 54 MB on disk forever.
### Translate: the native bridge
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
named `librashader_bridge`. It wraps `librashader-presets`,
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
```
char* librashader_translate_preset(const char* preset_path, int es);
void librashader_free_string(char* s);
```
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
with resolved absolute paths and filter/wrap settings), its
`parameter_overrides`, and an `error` field.
**What the bridge is not.** No librashader runtime backend is linked in, for
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
graphics context is ever touched. It is the translation step only. LOVE still
owns every draw call, every canvas and every shader object. The bridge hands
back text and metadata and nothing else.
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
cached artifact and never call the library. An entry with no cached artifact
fails `activate()` loudly instead of silently live-translating.
The classification is done with librashader's real semantics resolution rather
than name matching on the Lua side, and that matters for correctness, not just
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
alias resolves. The per-pass convenience API only registers the alias of the
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
loop and builds a preset-wide alias map first; without that,
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
classification runs the same resolution over the pass's `uniform_semantics`
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
present preset uses but that the convention allows.
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
`love.system.getOS()`, and the same function decides which dialect
`validateShader` is asked about at run time, so the two always agree. Convert
and render always happen on the same device; **artifacts are not portable
across platforms**.
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
the source first, and only for the ES target:
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
the preset's declared static textures, with dimensions read straight out of
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
`lod`, is left alone so it fails as loudly as before instead of guessing.
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
reciprocal-size uniform. The containing block instance (`params`, `global`,
whatever) is discovered by scanning the source's own uniform block bodies,
never assumed. When a pass samples another pass purely through these calls it
may never declare that `<Tex>Size` uniform at all, so one is injected first,
before any byte offsets are computed.
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
mode at out-of-range coordinates, which is not necessarily identical to
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
discrepancy for passes whose offsets can leave the image is unmeasured.
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
`ShaderFX` looks for the library, most specific first: the
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
without a path. The desktop path is still a developer build sitting in cargo's
output directory; nothing packages it next to a shipped game yet.
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this.
### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
`position()` convention and refuses a raw `main()`-shaped source outright. This
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
real preset in the corpus failed without it. This list is the least guessable
part of the whole feature.
**`#version` line.** Stripped; LOVE prepends its own.
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
literals, which validation rejects with "arrayed constructor: not supported for
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
three real shapes in the corpus and each is handled: a `const` global (declared
without an initializer at global scope, with per-element assignments relocated
to the top of `main()`), a non-const local declaration with initializer
(rewritten in place, since a function body can hold assignment statements where
the literal was), and a bare reassignment of an array declared elsewhere (also
in place). Splitting the element list needs `splitTopLevelCommas`, because a
naive comma split breaks on any element containing its own parentheses
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
whole match fails, and the literal passes through completely untouched.
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
a function-parameter array before passing it on, since GLSL array arguments are
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
declaration plus an element-by-element copy. The size is known from the
declaration, so no comma splitting is involved. This one only became reachable
once the other array shapes stopped masking it in the same file.
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
integer operands, so routing through float `mod()` and back is exact for the
non-negative operands this shader family uses (rotation and orientation enum
indices). Four patterns are tried in order (paren/paren, paren/bare,
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
operand and leaves a simple one bare, and the balanced form must be tried
before a plain identifier can partially match. Seen live on
`authentic_gbc`'s subpixel-rotation math.
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
`precision highp int;` pair with no toggle. That is removed and replaced with
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
highp, and fall through to the stage default otherwise.
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
nor sending the whole struct as a table works, both raise "Shader uniform '...'
does not exist." librashader emits every pass's `#pragma parameter`s and size
uniforms as exactly that kind of struct, so as shipped the output is unusable
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
instance uniform and re-declares the members at top level. Scalar members are
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
budget unpacked. Non-scalar members keep their own uniform, since packing an
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
type was `int` or `bool` gets an explicit cast back on every read, since a
packed slot only stores floats. Vertex and fragment declare identically ordered
structs for the same parameters, so packing both with the same prefix assigns
the same slot and component to the same parameter in both stages, and one
`shader:send` reaches whichever stage uses it.
Two ordering constraints inside that function are load bearing and look
arbitrary from the outside. Packing must walk the members in the struct's own
declaration order, because that order is what keeps the scalars in one
contiguous run; an earlier version sorted them longest-name-first before
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
budget. Substitution, separately, must go longest-name-first, so a replacement
can never land as a substring inside a still-pending member name that shares a
prefix. The two orders are separate copies of the list for exactly that reason.
`Fixup.packValues` turns a flat `{name = value}` table back into the
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
slots against that same 16-vector budget, samplers excluded. Its pattern uses
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
unlike regex `\w`, and every generated name here is full of underscores.
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
prefix so their groups cannot collide with the push block's numbering. `MVP` is
the one special member: it is substituted directly to `transform_projection`
instead of becoming a uniform, because "multiply the incoming vertex by it" is
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
nothing would ever supply a value for it. An earlier version assumed the UBO
block only ever carried `MVP` and deleted the whole declaration after
substituting it. That is true only for presets that declare their parameters in
a push-constant block; presets that use a UBO instead (many real handheld and
console-border presets do) had every other member reference left dangling, and
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
identifier" and "unknown swizzle selection" errors on real Android hardware came
from.
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
precision, because LOVE forward-declares `effect()`'s prototype under its own
header's precision default before this source runs, which can mismatch whatever
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
two variants (`mediump`, then unqualified) and the caller tries them in order
against `validateShader`, taking the first that passes.
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
`gbFragColor`, declared at the top of the function, with a single `return`
appended before the closing brace. Rewriting *every* occurrence regardless of
the operator that follows is necessary, not just symmetric with the vertex side:
an earlier assign-then-return pair assumed one write at the very end of
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
that write once with `=` and later accumulate with `+=`. The `+=` statement
passed through unconverted and collided with LOVE's own `gl_FragColor` write
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
rewritten to `return gbFragColor;`, which holds the value assigned just before
it on every real shape seen.
**Vertex entry point.** `void main()` becomes `position(mat4
transform_projection, vec4 vertex_position)`, the source's own `attribute`
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
appended. The `Position` and `TexCoord` substitutions are frontier-matched
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
### Cache
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
path with the extension swapped to `.lua`, so the artifact sits next to the
preset it came from. The file is a plain `return { ... }` chunk written by
`serializeLua`, which handles the string/number/boolean/nested-table shape
`Json.decode` produces. Array detection walks every key rather than trusting
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
naive length check by accident.
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
failure it says "convert this preset first" rather than falling back to a live
translation.
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
explicit, user-initiated action whose result is stable for a given preset and
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
and the native call off every activation, every boot and every frame. The cost
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
exist" check with no version or content stamp, and there is no explicit
"reconvert" action in the UI. A preset converted by an older build never picks
up a later translator fix on its own. This was seen on a real device, where
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
because that device's cache predated the fix while `pixel_transparency`'s
happened to be fresher. Two places compensate by reconverting unconditionally:
`ShaderFXScreen`'s explicit selection of an already-converted row, and
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
CPU-only work with no GPU compile, and neither is on the per-frame path.
`ShaderSourcePatches.lua` sits just before translation and patches the raw
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
reading the real files, discovers `#pragma parameter` lines and struct members;
nothing downstream can add one. Patches are small, explicit, per-preset literal
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
pattern magic), re-applied idempotently on every convert so a buildbot
re-download that replaces the upstream file wholesale does not quietly undo
them. **The patch table ships empty on purpose and nothing registers one.** Its
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
gap above: a new pragma can only reach an artifact that gets reconverted, and at
the time nothing forced one. The mechanism is kept for a future preset that
genuinely needs a new declaration, but an already-wired engine-side channel is
preferred whenever one exists.
### Run: the pass graph
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
player's edited parameters over the artifact's own defaults, loads the preset's
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
runs the chain each frame.
`newChainState` builds one instance of chain-local state per loaded preset,
never module-global, so switching presets cannot leak a previous preset's
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
player's `shaderfxParams` edits.
Sizes resolve through `resolveScale`, which handles all four slang scale types
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
input dimensions and the original frame. Size uniforms are packed as
`{w, h, 1/w, 1/h}`, the slang convention.
`runPass` caches two things per `(state, pass index)`. The **shader** is
compiled once for the state's lifetime, along with the fragment manifest it was
compiled against, since a pass's GLSL depends only on the preset and never on
per-frame input. The **canvas** is reallocated only when the pass's resolved
size actually changes, a window resize or a different preset. Every harness this
runtime was ported from ran the chain once and quit, so allocating a fresh
canvas and compiling a fresh shader on every call was invisible there. On a real
per-frame render path it is one GPU allocation per pass per frame, and a shader
recompile on top. The same discipline applies to the crop canvas in
`cropToGbSource`, which is called once per frame and whose size grows with the
world canvas as the player zooms out; leaving it uncached was a real cost that
scaled with zoom level even with a single preset active.
Sampler binding is by semantic, from the bridge's classification, never by a
hardcoded per-preset name check: `Source` is the previous pass's output (or the
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
real name the translation reported. A sampler that resolves to nothing asserts
rather than drawing garbage.
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
bytes with plain `io.open` and goes through `love.data.newByteData` and
`love.image.newImageData`, because a preset's texture paths are arbitrary
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
those outright ("Could not open file ... Does not exist") even when the file is
real. Wrap modes are mapped from librashader's names to LOVE's
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
left nil; the fail-loud point is the sampler assertion in `runPass` that
actually needed it, not the loader.
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
history slot reads the current frame, both for the sampler binding and for the
size uniform. Real per-frame history rotation has been proven out in a desktop
harness but is not wired into this path.
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
sampler resolves to nothing and trips the binding assertion. No preset in the
shipped shortlist uses it.
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
composite (world, UI, and any post-process pipeline that already ran); `rect` is
this frame's real playfield rectangle in physical framebuffer pixels and
`source` is the real pixel size of the content it frames. The sequence is: crop
`rect` out of the composite, run whichever slots are active over that crop, draw
the untouched composite, then stretch the chain output back over `rect`. UI and
letterbox bars outside the playfield pass through untouched. If the chain throws,
the frame still shows the unprocessed composite; a broken preset degrades to
"shader off", never to a crash or a blank frame.
Three details in that path are non-obvious:
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
logical units, not raw pixels, so the viewport handed to the pass graph and
the final draw-back position are both converted from `rect`'s physical pixels
first. On a `dpiscale = 1` desktop the two are numerically identical and the
bug is invisible; at dpiscale 3 on real Android hardware the chain output
rendered about three times too large and at a pixel-valued offset in unit
space.
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
caller can leave the draw color dirty (a menu's black text leaves it at
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
and `push("all")` saves state for `pop()` without resetting it. That was the
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
`flushBatch()` on the line above is cheap insurance against a read-after-write
ordering hazard between this draw and whatever last rendered into the canvas;
it was never confirmed to fix anything on its own.
- **The final blit stretches.** A slang chain's last pass is not required to
land on the viewport size, and most presets (21 of the 78 in the corpus)
declare their last pass `scale_type = "source"` and stay at native Game Boy
resolution, relying on the frontend's blit exactly as RetroArch does.
Requiring an exact size match here used to skip the draw outright for every
such preset on every frame, which is a silent total no-op rather than a sizing
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
nearest or linear.
### What this engine feeds shaders that a libretro core does not
A stock libretro core hands its frontend a raw framebuffer and a frame count.
This engine has more context available and passes some of it through.
| Context | How it reaches the shader |
| --- | --- |
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
bound to 0 because librashader's own documentation is explicit that it is
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
oriented arcade core, say), not device orientation. Nothing here ever rotates
Game Boy content, so 0 is the correct answer, not a placeholder.
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
rest" calibration reference, no preset in the corpus reads it, and a fixed
placeholder beats an invented value.
`src/core/Sensors.lua` is what makes the two real motion semantics work.
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
any platform including Android; it is a LOVE 12 addition. The working path is
raw FFI into the SDL2 that LOVE already links, the same technique
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
dead code after a future LOVE 12 upgrade. Loading order matters:
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
device with no sensor is probed once and then permanently reports zeros, so a
desktop run does not pay for it every frame.
SDL keeps sensor readings in the device's fixed chassis frame regardless of
screen orientation, so `rotateForScreen` remaps x and y into
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
compensation is mobile-only: a desktop monitor is legitimately and permanently
"landscape" to that query, which says something about the monitor's shape and
nothing about how a player is holding anything.
The accelerometer path in `sizeTable` does three things to the raw reading
before it becomes a uniform, all of them driven by real on-device data:
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
actually holding the device in and every later reading is measured relative
to that, rather than to an assumed idealized vertical. The shipped tilt maths
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
the effect sat near-saturated all the time instead of starting near neutral.
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
dominant on Z, the one axis it never reads. This engine's rest pose is
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
gravity back on the ignored axis.
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
quietly damped tilt and noise alike by the same factor; calibration correctly
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
on the ignored axis to keep the denominator stable, but only when there is a
genuine live reading to calibrate against. An all-zero raw read is
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
Earth, and injecting into that case would make the shader believe it had
sensor data and silently replace its own static fallback with fake motion.
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
angle, so it only becomes a usable on-screen offset by integrating over time,
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
is deliberately a decaying spring rather than a true integrated heading:
gyro-only integration drifts without a magnetometer to correct it, so this
settles back toward neutral and stays bounded by construction. It is folded onto
the accelerometer's x component before the shader's own normalize and clamp,
because that is the only already-compiled channel the stock upstream maths
reads, and reaching an already-converted artifact with no reconvert was worth
the tradeoff that the twist reads as an added simulated tilt rather than a
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
looked worse, because overshooting a comfortable range reads worse than being
subtle. Retune in small steps with real device checks, not big jumps.
## Two slots
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
to its save key. The slots are activated and persisted independently, and the
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
parameter edits are keyed by *preset name*, not by slot, because a preset's
values are a property of the preset the same way its cached artifact is;
editing them re-activates every slot currently showing that preset and persists
for the next load in either.
When both slots are active, `render` runs main's chain first and hands its
finished output to secondary as secondary's own input frame, along with its
dimensions, so a secondary preset that scales off its input sees main's real
output size rather than the original crop. Either slot alone behaves exactly as
a single-preset path; neither active is a plain passthrough.
**This is not how RetroArch composes multiple presets.** RetroArch merges
presets into a *single* pass list through `#reference` and `Append`, producing
one pass graph with one shared semantics map, where a later pass can reference
an earlier one's output by alias and the whole thing resolves as one unit. Two
slots here are two independent librashader chains run back to back, which is
what stacking two separate preset chains would give you, not what merging them
gives you. Presets that assume merged semantics will not behave the same way.
## Test seams
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
canvas, which the later unconditional draw-back would otherwise mask),
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
actually reached the packed uniform, per slot). `Sensors.setOverride`,
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
readings on a machine with no hardware.
## Limitations
None of these are theoretical.
- **Tested on very little real hardware.** Essentially one Android phone, one
desktop, and the automated harnesses. Anything about how a preset actually
looks or performs elsewhere is unverified.
- **No performance tier is actually tuned.** The chain-resolution multiplier in
`Performance.CAPS` is a working mechanism, but every tier that permits
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
today. Picking a real value for weak hardware needs a device this project does
not have.
- **The dual-slot design does not match RetroArch.** See above. Two chains in
sequence is not one merged pass list.
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
falls back to "every slot is the current frame" in the live render path, and
is unverified there.
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
error and its sampler trips an assertion.
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
player-supplied filename still collides with the row's right-hand hint.
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
Intentional, for the reason given above, but it means the mechanism has no
live coverage.
- **Cached artifacts have no staleness detection.** Existence is the only check.
The two unconditional reconvert points paper over it; anything that does not
go through them can be running a stale translation.
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
Copying a converted preset folder between a phone and a desktop copies a
wrong artifact along with it.
- **The bundled bridge is only as good as the build machine.**
`scripts/build.sh` bundles the cdylib for mac, win and linux via
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
package that can run converted presets but cannot CONVERT new ones, and says
so rather than failing. Android ships the `.so` via `jniLibs`.
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
manual pass over `handheld/` and is expected to change, most likely to shrink.
- **Tilt direction is unverified.** Which way forward and back rocking moves the
effect was never confirmed on a device; if it feels backwards the fix is a
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
landscape rotation compensation matches real RetroArch is genuinely unknown:
RetroArch's Android input driver computes a screen rotation but does not
visibly apply it to the accelerometer values that reach shader uniforms, so
this project's compensation may be an improvement over upstream rather than a
match to it.
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
in the bridge turns those calls into `texture()`, which honours the sampler's
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
implementation defined. Unmeasured.
-1
View File
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
+16 -2
View File
@@ -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
View File
@@ -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
+23 -2
View File
@@ -2,11 +2,22 @@
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
#
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
# [--test-application-id ID]
#
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --release build the production-signed release APK (requires the
# GEN1RECOMP_ANDROID_* signing environment variables)
# --package-only zip game.love + apply branding; skip gradle
# --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/.
+11
View File
@@ -348,6 +348,12 @@ local EMITTERS = {
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
}
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
local function tilesetLoadFrames(data, ts)
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
end
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
-- with the subanimation transform applied (DrawFrameBlock).
local function placeTile(transform, bc, t, tileset)
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
local obp0Flip = false
for _, row in ipairs(anim.seq) do
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
-- row's sound and its first frame block (#1653)
if not row.effect then
emit(tilesetLoadFrames(self.data, row.tileset))
end
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
-- the row starts (GetMoveSound)
+40 -6
View File
@@ -638,6 +638,9 @@ local function newBattle(game)
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
-- (init_battle_variables.asm:7-12) #1732
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
@@ -1918,6 +1921,8 @@ function BattleState:exit()
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1942,6 +1947,7 @@ local function clearTrapping(battler)
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
battler.trapHitSfx = nil
end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
@@ -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)
+12 -6
View File
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
local totalDealt = 0
local landed, brokeSub = 0, false
local critPending, ohkoPending = info.crit, info.ohko
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
totalDealt = totalDealt + dealt
landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
if critPending then
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
critPending = false
end
if ohkoPending then
battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!"))
ohkoPending = false
end
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
ctx.brokeSub, ctx.hits = brokeSub, hits
ctx.hitSfx = hitSfx
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
+2
View File
@@ -574,6 +574,8 @@ MoveEffects.full = {
local r = ctx.rng(0, 7)
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
user.trapDamage = ctx.rawDamage
-- PlayApplyingAttackSound (animations.asm:2639) replays it each turn
user.trapHitSfx = ctx.hitSfx
-- remember the move so its animation can replay on each locked
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
user.trapMove = ctx.move.id
+3 -1
View File
@@ -161,8 +161,10 @@ local function drawHUDs(battle, slide)
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
-- drawCommandMenu below like the classic layout's (#540).
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721)
if showStatus and not battle.safari and battle.player and not battle.demo
and not battle.showPlayerBack and slide == 0 then
and not battle.showPlayerBack and slide == 0
and not battle.player.fainted then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
end
+13 -5
View File
@@ -16,7 +16,7 @@
--
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
-- from 1-7 turns.
-- from 2-7 turns.
local Damage = require("src.battle.gen2.Damage")
local Ai = require("src.battle.gen2.Ai")
@@ -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
View File
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
-- power and the magnitude number the text prints.
--
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
-- / math.random never hard-code 0 (that always yields Magnitude 4).
-- / math.random -- never hard-code 0 (that always yields Magnitude 4).
function Effects.magnitudePower(random)
local roll
if type(random) == "function" then
@@ -356,19 +356,38 @@ function Effects.sandstormHits(types)
return true
end
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
-- BattleCommand_TimeBasedHealContinue's .Multipliers
-- (engine/battle/effect_commands.asm:6450-6454).
Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 }
-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1,
-- NITE_F 2, DARKNESS_F 3.
Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants
-- (engine/battle/effect_commands.asm:6362-6371).
Effects.SUN_HEAL = {
EFFECT_MORNING_SUN = true,
EFFECT_SYNTHESIS = true,
EFFECT_MOONLIGHT = true,
EFFECT_MORNING_SUN = 0,
EFFECT_SYNTHESIS = 1,
EFFECT_MOONLIGHT = 2,
}
function Effects.weatherHealFraction(weather)
if weather == "sun" then return 2 / 3 end
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
return 1 / 2
function Effects.timeOfDayIndex(timeOfDay)
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
return Effects.TIME_OF_DAY_ID[timeOfDay]
end
-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the
-- wrong time of day steps it down, sun steps it up, rain and sandstorm down.
function Effects.timeBasedHealFraction(weather, wants, timeOfDay)
local index = 3
local now = Effects.timeOfDayIndex(timeOfDay)
if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end
if weather then
index = index + 1
if weather ~= "sun" then index = index - 2 end
end
return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))]
end
-- ---------------------------------------------------------------- Perish Song
+82 -13
View File
@@ -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
View File
@@ -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
+1
View File
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
ticks = ticks + duration
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[key] = segments
return segments
end
+7 -14
View File
@@ -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)
+119 -44
View File
@@ -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()
onDone = function(skipped)
if skipped then
self:showTitle()
else
self:showIntro()
end
end,
})
end
@@ -1098,19 +1106,27 @@ function Game2:load()
-- The play clock only runs in the overworld, the way wGameTimerPaused is
-- set while the intro menu is up.
Save.tickPlayTime(self.save)
-- START and SELECT are read only at the tail of OWPlayerInput, which
-- PlayerEvents never reaches while a script is running or the player is
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
-- that arrives under one of them is dropped, not queued -- and the frame
-- still runs, so world:step must not be skipped on the swallowed press.
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
local accepts = self.world:acceptsMenuInput()
local latch = self.joyLatch
if accepts then
self.joyLatch = nil
if self.input:wasPressed("start")
or (latch and latch.start and self.input:isDown("start")) then
self:openStartMenu()
return
end
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
if self.input:wasPressed("select")
or (latch and latch.select and self.input:isDown("select")) then
self:useSelectItem()
return
end
else
if not latch then latch = {}; self.joyLatch = latch end
if self.input:wasPressed("start") then latch.start = true end
if self.input:wasPressed("select") then latch.select = true end
end
self.world:pollInput(self.input)
if self.input:wasPressed("a") then
self.world:interact()
@@ -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
View File
@@ -29,11 +29,38 @@ end
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
-- a --speed argument), so a bad number degrades to something sane
-- A cart may narrow the levels a player can reach (CartManifest's `speeds`).
-- nil restores the full ladder; a one-entry list pins the speed outright.
local allowed
function GameSpeed.setAllowed(levels)
if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end
local valid, seen = {}, {}
for _, want in ipairs(GameSpeed.LEVELS) do
for _, have in ipairs(levels) do
if have == want and not seen[want] then
seen[want] = true
valid[#valid + 1] = want
end
end
end
allowed = (#valid > 0) and valid or nil
end
function GameSpeed.allowed()
return allowed or GameSpeed.LEVELS
end
function GameSpeed.isLocked()
return allowed ~= nil and #allowed <= 1
end
function GameSpeed.clamp(v)
v = tonumber(v)
if not v then return GameSpeed.DEFAULT end
local best, bestDiff = GameSpeed.DEFAULT, math.huge
for _, level in ipairs(GameSpeed.LEVELS) do
local levels = GameSpeed.allowed()
if not v then return levels[1] or GameSpeed.DEFAULT end
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
for _, level in ipairs(levels) do
local diff = math.abs(level - v)
if diff < bestDiff then best, bestDiff = level, diff end
end
@@ -42,7 +69,7 @@ end
-- cycle to the next/previous level, wrapping (the options row idiom)
function GameSpeed.cycle(v, dir)
local levels = GameSpeed.LEVELS
local levels = GameSpeed.allowed()
local cur = 1
for i, level in ipairs(levels) do
if level == GameSpeed.clamp(v) then cur = i break end
+4 -4
View File
@@ -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
View File
@@ -376,7 +376,19 @@ end
-- notice a quit command until curl returns. With the launcher's default 300s
-- ceiling, closing the window during a mod download hung the process for
-- minutes. Callers on the interactive fetch pool pass something short.
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
--
-- `etagPath` (optional, absolute host path) turns this into a conditional
-- GET: curl sends `If-None-Match` from whatever ETag is already saved there
-- (`--etag-compare`) and overwrites it with the response's own ETag on
-- success (`--etag-save`), same file for both so a first call with no prior
-- ETag degrades to a plain unconditional download. A server that replies 304
-- Not Modified is reported as a third return value (`notModified`), and --
-- confirmed empirically against the real buildbot.libretro.com endpoint
-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT
-- write `absPath` at all in that case (not even an empty file), matching
-- HTTP: a 304 carries no body. A caller must not treat a missing file as an
-- error when `notModified` comes back true.
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
if type(etagPath) == "string" and etagPath ~= "" then
cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " "
.. "--etag-save " .. HostShell.quote(etagPath) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
@@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
-- status is here purely so the failure can NAME itself: "download failed"
-- with no URL and no code is the report this whole change exists to fix.
local body, status, noise = splitCurlOutput(readOk and out or "")
if status == 304 then
return true, nil, true
end
if status and (status < 200 or status >= 300) then
return nil, fetchError(url, status, body)
end
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if not haveBridge() then
return nil, "no network transport on this platform"
end
-- The Android/iOS bridge has no conditional-GET support -- etagPath is
-- silently ignored here, so a downloaded preset re-fetches in full every
-- time on those platforms. No transport-level ETag/If-None-Match hook
-- exists on the bridge to hang one off, and it's low-priority for now.
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed for " .. url
+29 -12
View File
@@ -3,9 +3,12 @@
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- ground plane), survey ZOOM (zooming out renders the connected
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
-- translated libretro slang-shader chain, reasoned -- not yet measured --
-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced).
-- A hard FPS ceiling caps present cost on
-- top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
@@ -13,14 +16,15 @@
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX
-- / ZOOM / FPS the player had. Wired into both generations.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
@@ -40,10 +44,24 @@ Performance.LABELS = {
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
--
-- `shaderfx` is `false` (fully off) or a positive number: an internal
-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole
-- SHADER FX pass chain at half resolution, then stretch up for display --
-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its
-- final output to the real display rect regardless of the chain's own
-- internal size, so shrinking the chain's working
-- resolution costs nothing extra at that final blit). Every existing
-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works
-- unchanged: `false` stays falsy, any positive number stays truthy -- only
-- `ShaderFX.render` itself reads the actual number. No tier is
-- set below 1.0 yet -- tuning the actual per-tier value for real low-end
-- hardware needs a device this project doesn't have to hand right now; this
-- is the mechanism, not the tuning.
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
@@ -93,8 +111,7 @@ function Performance.detect()
if isArm and os == "Linux" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
+128 -8
View File
@@ -272,10 +272,9 @@ function SaveData.defaultOptions()
speedOverworld = 1,
speedBattle = 1,
speedMenu = 1,
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
-- port display options (OptionsMenu / hotkeys 2/3/4)
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black
@@ -290,8 +289,8 @@ function SaveData.defaultOptions()
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
-- extras); scales TILT / survey ZOOM / FPS but never game logic. See
-- src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
@@ -360,9 +359,24 @@ function SaveData.defaultOptions()
timeFormat = "device", -- device | 24h | 12h
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
pendingConflicts = {} },
-- Cart-scoped settings, beside cartSlots: cartOptions[cartId][key] layers
-- over the global value for CART_OPTION_KEYS while that cart is playing.
cartOptions = {},
-- The player's own answer for a mod a cart pins, kept out of the per-game
-- flags: cartMods[cartId][modId], absent while the cart's switch stands.
cartMods = {},
-- The carts whose shipped defaults were already seeded, so a player's
-- later change is never overwritten (compare modProfilesSeeded).
cartOptionsSeeded = {},
}
end
-- Settings that belong to a playthrough and follow the cart: pacing and the
-- battle rules. Everything else -- audio, video, bindings, touch, language,
-- launcher state -- belongs to the machine and stays global.
SaveData.CART_OPTION_KEYS = { "animations", "battleStyle", "ruleset",
"speedBattle", "speedMenu", "speedOverworld", "textSpeed" }
-- Merge loaded keys over defaults (shallow). Unknown keys are kept so
-- future options aren't dropped by older builds writing the file back.
function SaveData.mergeOptions(loaded)
@@ -437,6 +451,60 @@ end
-- ------- options
-- The cart being played, if any; SaveData.setCart owns it. Declared here
-- because the options overlay below reads it.
local activeCart, activeCartHash
local function cartBucket(opts, cartId)
local root = type(opts) == "table" and opts.cartOptions or nil
local bucket = type(root) == "table" and root[cartId] or nil
return type(bucket) == "table" and bucket or nil
end
-- Lay the active cart's values over the globals for the cart-scoped keys; a
-- key the cart never set falls through to the global value.
local function applyCartOverlay(opts)
if not (activeCart and type(opts) == "table") then return opts end
local bucket = cartBucket(opts, activeCart)
if not bucket then return opts end
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
if bucket[key] ~= nil then opts[key] = bucket[key] end
end
return opts
end
local function cartGlobals(onDisk)
local out = {}
if type(onDisk) ~= "table" then return out end
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do out[key] = onDisk[key] end
return out
end
-- The inverse, run just before a write: the cart-scoped keys go into the
-- active cart's bucket and the globals keep whatever the file already held.
local function splitCartOverlay(opts, globals, onDisk)
if not (activeCart and type(opts) == "table") then return opts end
local root = {}
local prior = type(onDisk) == "table" and onDisk.cartOptions or nil
if type(prior) == "table" then
for id, bucket in pairs(prior) do root[id] = deepCopy(bucket) end
end
for id, bucket in pairs(type(opts.cartOptions) == "table" and opts.cartOptions or {}) do
root[id] = bucket
end
local bucket = type(root[activeCart]) == "table" and root[activeCart] or {}
local defaults = SaveData.defaultOptions()
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
if opts[key] ~= nil then bucket[key] = opts[key] end
local global = globals and globals[key]
if global == nil then global = defaults[key] end
opts[key] = global
end
root[activeCart] = bucket
opts.cartOptions = root
return opts
end
-- Both take an optional fs (write/getInfo/read) defaulting to
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
@@ -458,6 +526,10 @@ function SaveData.saveOptions(opts, fs)
-- activeProfile -- not defaultOptions members -- can be deleted by their
-- sites precisely because those sites always write full tables.
local onDisk = readTable(fs, OPTIONS_FILENAME)
-- Fold from the cart's view of the file, not the globals underneath it, or
-- a partial write would push the global value into the cart's bucket.
local cartGlobalValues = cartGlobals(onDisk)
applyCartOverlay(onDisk)
local isFull = type(opts) == "table"
if isFull then
for k in pairs(SaveData.defaultOptions()) do
@@ -497,6 +569,7 @@ function SaveData.saveOptions(opts, fs)
end
opts.modOptions = merged
end
splitCartOverlay(opts, cartGlobalValues, onDisk)
local encoded = SaveSerializer.encode(opts)
-- Stage the new bytes and roll the last good file aside BEFORE the main
-- write truncates it, the same tmp/bak dance SaveData.save uses for
@@ -544,7 +617,8 @@ function SaveData.saveOptions(opts, fs)
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
-- the staged witness has served its purpose; the main file is verified
remove(fs, OPTIONS_TMP_FILENAME)
return opts
-- hand back the cart's view, matching what loadOptions would answer
return applyCartOverlay(opts)
end
function SaveData.loadOptions(fs)
@@ -572,11 +646,11 @@ function SaveData.loadOptions(fs)
if fs.write then
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
end
return SaveData.mergeOptions(recovered)
return applyCartOverlay(SaveData.mergeOptions(recovered))
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
return applyCartOverlay(SaveData.mergeOptions(data))
end
-- ------- per-game mod enablement
@@ -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
+178
View File
@@ -0,0 +1,178 @@
-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus
-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any
-- platform, so the working path is raw FFI into the SDL2 LOVE already links,
-- the same technique src/core/Orientation.lua uses. What is and is not verified
-- on real hardware is written down in docs/shaderfx.md.
local Sensors = {}
-- Test seams: a no-device harness injects synthetic readings.
local overrides = {}
local orientationOverride = nil
function Sensors.setOverride(kind, x, y, z)
overrides[kind] = { x, y, z }
end
function Sensors.clearOverride(kind)
overrides[kind] = nil
end
function Sensors.clearAllOverrides()
overrides = {}
orientationOverride = nil
end
-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil
-- (nil = ask SDL for real). Test seam for rotateForScreen below.
function Sensors.setOrientationOverride(o)
orientationOverride = o
end
-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today).
local function safeHasSensor(kind)
if not love or not love.sensor then return false end
local ok, has = pcall(love.sensor.hasSensor, kind)
return ok and has == true
end
local function loveSensorRead(kind)
if not safeHasSensor(kind) then return nil end
local ok, x, y, z = pcall(love.sensor.getData, kind)
if not ok then return nil end
return x or 0, y or 0, z or 0
end
-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5).
local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2
local SDL_INIT_SENSOR = 0x00008000
local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO }
local sdlCdefOk = nil
local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent)
local sdlSensorTried = {}
-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library,
-- then bare ffi.C, needed on Android where love-android links SDL2 statically
-- into libmain.so and there is no standalone libSDL2.so to attach to by name
-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716).
local sdlLib = nil
local function sdlFfi()
local okFfi, ffi = pcall(require, "ffi")
if not okFfi then return nil end
if sdlCdefOk == nil then
sdlCdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window;
typedef struct SDL_Sensor SDL_Sensor;
int SDL_InitSubSystem(unsigned int flags);
int SDL_NumSensors(void);
int SDL_SensorGetDeviceType(int device_index);
SDL_Sensor *SDL_SensorOpen(int device_index);
void SDL_SensorUpdate(void);
int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values);
SDL_Window *SDL_GL_GetCurrentWindow(void);
int SDL_GetWindowDisplayIndex(SDL_Window *window);
int SDL_GetDisplayOrientation(int displayIndex);
]])
if sdlCdefOk then
local okLoad, lib = pcall(ffi.load, "SDL2")
sdlLib = okLoad and lib or ffi.C
end
end
if not sdlCdefOk or not sdlLib then return nil end
return sdlLib
end
-- Opens and caches once, and returns nil permanently once a real attempt finds
-- none, so a no-hardware desktop run pays this cost once rather than per frame.
local function sdlSensorHandle(lib, kind)
if sdlSensorTried[kind] then return sdlSensorHandles[kind] end
sdlSensorTried[kind] = true
local wantType = KIND_TO_SDL_TYPE[kind]
if not wantType then return nil end
local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR)
if not okInit then return nil end
local okCount, count = pcall(lib.SDL_NumSensors)
if not okCount or count <= 0 then return nil end
for i = 0, count - 1 do
local okType, t = pcall(lib.SDL_SensorGetDeviceType, i)
if okType and t == wantType then
local okOpen, handle = pcall(lib.SDL_SensorOpen, i)
if okOpen and handle ~= nil then
sdlSensorHandles[kind] = handle
return handle
end
end
end
return nil
end
local function sdlSensorRead(kind)
local lib = sdlFfi()
if not lib then return nil end
local handle = sdlSensorHandle(lib, kind)
if not handle then return nil end
pcall(lib.SDL_SensorUpdate)
local data = require("ffi").new("float[3]")
local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3)
if not okGet or n ~= 0 then return nil end
return data[0], data[1], data[2]
end
-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen
-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is
-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation.
local function isMobile()
if not love or not love.system or not love.system.getOS then return false end
local os = love.system.getOS()
return os == "Android" or os == "iOS"
end
local function sdlOrientation()
if not isMobile() then return nil end
local lib = sdlFfi()
if not lib then return nil end
local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow)
if not okWin or win == nil then return nil end
local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win)
if not okIdx or idx < 0 then return nil end
local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx)
if not okOr then return nil end
-- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED,
-- 3=PORTRAIT, 4=PORTRAIT_FLIPPED.
if o == 1 then return "landscape"
elseif o == 2 then return "landscapeFlipped"
elseif o == 3 then return "portrait"
elseif o == 4 then return "portraitFlipped"
else return nil end
end
-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait,
-- and unknown/undetectable (desktop always lands here), is a no-op.
local function rotateForScreen(x, y)
local o = orientationOverride or sdlOrientation()
if o == "landscape" then return -y, x
elseif o == "landscapeFlipped" then return y, -x
elseif o == "portraitFlipped" then return -x, -y
else return x, y end
end
-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden
-- that way, no hardware/module is available, or an underlying call errors.
-- x/y are rotated to be screen-relative; z passes through unchanged.
function Sensors.read(kind)
local override = overrides[kind]
if override then
local x, y = rotateForScreen(override[1], override[2])
return x, y, override[3]
end
local x, y, z = loveSensorRead(kind)
if not x then x, y, z = sdlSensorRead(kind) end
if not x then return 0, 0, 0 end
x, y = rotateForScreen(x, y)
return x, y, z
end
return Sensors
+8
View File
@@ -363,6 +363,14 @@ function Sound.sfxBusy()
return true
end
-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound`
-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483).
function Sound.waitSfxDone()
if not curSfx then return end
pcall(curSfx.src.stop, curSfx.src)
curSfx = nil
end
local function startSfx(data, name, def)
local src = playPath(data, name, def)
if not src then return end
+12 -1
View File
@@ -109,6 +109,17 @@ function Boxes.restorePP(mon)
end
end
-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so
-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83).
function Boxes.enterBox(mon)
if not mon then return mon end
Boxes.restorePP(mon)
mon.status = nil
mon.statusTurns = nil
mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp)
return mon
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
@@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex)
box[#box + 1] = mon
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
Boxes.restorePP(mon)
Boxes.enterBox(mon)
return true, mon
end
+3 -1
View File
@@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes)
end
boxes = boxes or require("src.core.gen2.Boxes")
local box = boxes.box(save, save.currentBox or 1)
if box then box[#box + 1] = mon end
-- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90).
if box then box[#box + 1] = boxes.enterBox(mon) end
return BugContest.BOXED_MON, mon
end
+5 -1
View File
@@ -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",
+4 -23
View File
@@ -266,18 +266,6 @@ local function coreRows(opts, hooks)
end)
end
-- issue #136: GBC FX soft-bricks the mobile present shader; same gate as
-- the in-game row.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
add(Strings("GBC FX"),
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
function(dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
return true
end)
end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
@@ -678,17 +666,6 @@ local function gen2Rows(opts, hooks)
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
add(Strings("GBC FX"),
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
function(dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
return true
end)
end
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
@@ -709,6 +686,10 @@ local function gen2Rows(opts, hooks)
end)
end
-- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours.
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
addTouchRows(rows, add, opts, hooks)
return rows
+314 -41
View File
@@ -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,15 +2056,24 @@ 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), {
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,12 +2536,29 @@ 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)
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,
@@ -2385,6 +2570,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
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.
if not flipped
@@ -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)
+25 -6
View File
@@ -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
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1582,7 +1582,7 @@ local function buildPartyMenu()
elseif opts.battle and opts.onSwitch then
prompt, battleSubmenu = "choose", true
elseif opts.pickOnly then
prompt = "useItem"
prompt = opts.itemUse and "useItem" or "choose"
elseif opts.onSwitch then
-- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself,
-- so the field submenu must not swallow the press
@@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = {
warned = "keepOpen tmhm",
absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo "
.. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch "
.. "pickOnly forceSwitch",
.. "pickOnly itemUse forceSwitch",
notes = {
new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts."
.. "battle carries only its BOOLEAN sense and self.battle is left nil "
@@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = {
onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field "
.. "Gen 2 never reads; pass it to .new instead",
bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so "
.. "a compare against \"Use on which one?\" will not match",
.. "a compare against \"Use item on which\\nPOKéMON?\" will not match",
["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold "
.. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a "
.. "BattleState",
@@ -1838,7 +1838,7 @@ local function buildBattleState()
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
"drawClassic", "isWideBattleLayout", "wideLayout", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement",
@@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = {
backed = "update draw __index isOpaque openParty swapMoves "
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
.. "tryRun wantsFillScale",
.. "tryRun wantsFillScale bgMode",
warned = "tryRun askNicknameUI",
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
.. "isWideBattleLayout wideLayout uiSize letterboxWhite "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
.. "trainerTrueColor trainerSprite invalidate "
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
+33 -5
View File
@@ -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
-- 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
+142 -5
View File
@@ -18,6 +18,9 @@
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
-- name for something else, so an unknown version is refused outright rather
-- than parsed hopefully.
--
-- Carts ride the same feed additively at schema_version 1: `doc.carts` is a
-- second array beside `doc.mods`, and a feed without it lists no carts.
local ModIndex = {}
@@ -26,7 +29,7 @@ local ModIndex = {}
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
ModIndex.CACHE_VERSION = 2
ModIndex.CACHE_VERSION = 3
-- ------- pure: source resolution
@@ -225,7 +228,94 @@ local function parseEntry(raw)
}
end
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
local function numArray(v)
local out = {}
if type(v) == "table" then
for _, entry in ipairs(v) do
local n = tonumber(entry)
if n then out[#out + 1] = n end
end
end
return out
end
-- One pinned mod out of a cart's `mods` array, in the bundle's own cart.json
-- shape: github pins carry repo/version/sha256, gamebanana pins mod/file/md5.
local function parseCartPin(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
local source = str(raw.source)
if source ~= "github" and source ~= "gamebanana" then return nil end
local pin = {
id = raw.id,
source = source,
repo = str(raw.repo),
version = str(raw.version),
sha256 = str(raw.sha256),
mod = tonumber(raw.mod),
file = tonumber(raw.file),
md5 = str(raw.md5),
options = type(raw.options) == "table" and raw.options or nil,
}
if raw.enabled == false then pin.enabled = false end
return pin
end
-- One cart listing. The eight fields the cart schema marks required are the
-- gate: a row missing any of them is dropped rather than half-listed, because
-- every one of them is load bearing for installing or naming the cart.
local function parseCartEntry(raw)
if type(raw) ~= "table" then return nil end
if not (str(raw.id) and str(raw.title) and str(raw.author)
and str(raw.version) and str(raw.base) and str(raw.seal)
and str(raw.repo) and type(raw.mods) == "table") then
return nil
end
local pins = {}
for _, pin in ipairs(raw.mods) do
local parsed = parseCartPin(pin)
if parsed then pins[#pins + 1] = parsed end
end
if #pins == 0 then return nil end
return {
kind = "cart",
folder = str(raw.folder),
id = raw.id,
title = raw.title,
author = raw.author,
version = raw.version,
base = raw.base,
seal = raw.seal,
summary = str(raw.summary) or "",
shell = str(raw.shell),
finish = str(raw.finish),
speeds = numArray(raw.speeds),
tags = strArray(raw.tags),
repo = raw.repo,
github = str(raw.github),
downloadURL = str(raw.downloadURL),
automatic_version_check = raw.automatic_version_check ~= false,
fixed_release_tag = str(raw.fixed_release_tag),
game_version = str(raw.game_version),
license = str(raw.license),
mods = pins,
load_order = strArray(raw.load_order),
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
downloads = parseDownloads(raw.downloads),
first_release = str(raw.first_release),
last_release = str(raw.last_release),
latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending",
}
end
-- True for a listing that installs through CartStore, not the mod installer.
function ModIndex.isCart(entry)
return type(entry) == "table" and entry.kind == "cart"
end
-- parse(jsonText [, Json])
-- -> { schemaVersion, generatedAt, categories, baseGames, mods, carts }
-- | nil, err
-- Never throws: a truncated download, an HTML error page, or a feed from a
-- future schema all come back as a message the panel can print.
@@ -254,11 +344,21 @@ function ModIndex.parse(jsonText, Json)
local entry = parseEntry(raw)
if entry then mods[#mods + 1] = entry end
end
-- Absent carts is the old-feed case, not an error.
local carts = {}
if type(doc.carts) == "table" then
for _, raw in ipairs(doc.carts) do
local entry = parseCartEntry(raw)
if entry then carts[#carts + 1] = entry end
end
end
return {
schemaVersion = schema,
generatedAt = str(doc.generated_at),
categories = strArray(doc.categories),
baseGames = strArray(doc.base_games),
mods = mods,
carts = carts,
}
end)
if not ok then return nil, "could not read the index: " .. tostring(result) end
@@ -421,12 +521,14 @@ function ModIndex.matches(entry, query)
return true
end
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
-- Category and tag compare case-insensitively; feed order (already sorted by
-- title) is preserved.
-- filter(entries, opts) -> a new array. opts = { query, category, base, tag }.
-- Category, base and tag compare case-insensitively; feed order (already
-- sorted by title) is preserved. `base` is the cart-side equivalent of a
-- mod's category: a cart plays as exactly one game and has no categories.
function ModIndex.filter(mods, opts)
opts = opts or {}
local want = opts.category and tostring(opts.category):lower() or nil
local wantBase = opts.base and tostring(opts.base):lower() or nil
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
local out = {}
for _, entry in ipairs(mods or {}) do
@@ -437,6 +539,9 @@ function ModIndex.filter(mods, opts)
if tostring(c):lower() == want then keep = true; break end
end
end
if keep and wantBase then
keep = tostring(entry.base or ""):lower() == wantBase
end
if keep and wantTag then
keep = false
for _, t in ipairs(entry.tags or {}) do
@@ -469,6 +574,32 @@ function ModIndex.categoriesIn(index)
return out
end
-- Every base game the feed's carts actually play as, in the feed's declared
-- base_games order, with anything a cart names that the header forgot
-- appended. The cart-side twin of categoriesIn.
function ModIndex.baseGamesIn(index)
local out, seen = {}, {}
if type(index) ~= "table" then return out end
local used = {}
for _, entry in ipairs(index.carts or {}) do
if entry.base then used[entry.base] = true end
end
for _, base in ipairs(index.baseGames or {}) do
if used[base] and not seen[base] then
seen[base] = true
out[#out + 1] = base
end
end
for _, entry in ipairs(index.carts or {}) do
local base = entry.base
if base and not seen[base] then
seen[base] = true
out[#out + 1] = base
end
end
return out
end
-- ------- sources (options.modIndexes)
local function loadOptions()
@@ -569,7 +700,9 @@ function ModIndex.writeCache(feed, index)
version = ModIndex.CACHE_VERSION,
generatedAt = index.generatedAt,
categories = index.categories,
baseGames = index.baseGames,
mods = index.mods,
carts = index.carts,
}
SaveData.saveOptions(opts)
end)
@@ -609,7 +742,9 @@ function ModIndex.fetch(source, opts)
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
baseGames = entry.baseGames or {},
mods = entry.mods or {},
carts = entry.carts or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
@@ -674,7 +809,9 @@ local function cachedIndex(feed, stale)
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
baseGames = entry.baseGames or {},
mods = entry.mods or {},
carts = entry.carts or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
+8 -1
View File
@@ -87,6 +87,7 @@ local function drain()
j.status = msg.ok and "ok" or "error"
j.body, j.err, j.path = msg.body, msg.err, msg.path
j.code = msg.code
j.notModified = msg.notModified
j.progress = msg.ok and 1 or j.progress
end
end
@@ -154,12 +155,18 @@ end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
-- Progress is reported as a 0..1 fraction when `size` is known.
-- opts.etagRel (optional, relative to the save directory like saveRel itself)
-- turns this into a conditional GET against a cached ETag at that path --
-- see HostShell.httpDownload's own comment for the 304/notModified contract.
-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a
-- real download apart from a no-op cache hit (no file was written for the
-- latter).
function Fetch.download(url, saveRel, opts)
opts = opts or {}
return submit({ kind = "download", url = url, dest = saveRel,
size = opts.size,
userAgent = opts.userAgent or "gen1recomp",
accept = opts.accept, maxSeconds = opts.maxSeconds })
accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel })
end
-- Non-blocking status. Returns a table; never nil, even for an unknown id
+13 -2
View File
@@ -73,6 +73,12 @@ end
-- inside the call. Where the caller knows the expected size we poll the
-- growing file from a second pass instead; where it does not, the job simply
-- reports indeterminate progress and the UI shows a spinner.
--
-- job.etagRel (optional, relative to saveDir like job.dest) turns this into
-- a conditional GET -- see HostShell.httpDownload's own comment for the
-- 304/notModified contract. A notModified result never writes `rel` at all
-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so
-- the usual "did a file actually land" check below is skipped for it.
local function doDownload(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
@@ -84,12 +90,17 @@ local function doDownload(job)
if dir then love.filesystem.createDirectory(dir) end
love.filesystem.remove(rel)
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS)
local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil
local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent,
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs)
if not ok then
post({ id = job.id, ok = false, err = err or "download failed" })
return
end
if notModified then
post({ id = job.id, ok = true, path = rel, done = true, notModified = true })
return
end
local info = love.filesystem.getInfo(rel)
if not info or (info.size or 0) == 0 then
love.filesystem.remove(rel)
-310
View File
@@ -1,310 +0,0 @@
-- GBC Effects post-process ("Pixel Transparency" style, see
-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder
-- applied after palette colorization and before the CRT pass:
-- 1 reflective screen: bright pixels blend toward a procedurally
-- grained warm backing (the unlit-GBC "transparent whites" look)
-- 2 + LCD subpixel grid
-- 3 + drop shadows (dark pixels float above the backing)
-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly
-- drifting light source
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
-- Spec: docs/new-features.md (Custom Options / GBC FX)
--
-- One shader for all levels: features are gated by the `level` uniform
-- (float comparisons), so cycling never recompiles. All spatial effects
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
-- shadow offsets and grain stay window-size independent.
local GBCFX = {}
GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
--
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
-- checkout exercise the unsupported path without stubbing love.system.
function GBCFX.isSupported()
local env = os.getenv("POKEPORT_GBCFX")
if env == "1" then return true end
if env == "0" then return false end
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
extern number level;
extern number time;
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)
#define PI 3.14159265359
// ---- level thresholds (cumulative ladder) ----
#define L_GRID 1.5
#define L_SHADOW 2.5
#define L_SUN 3.5
// ---- level 1: reflective backing ----
#define BACK_BRIGHTNESS 0.48
#define GRAIN_INTENSITY 0.065
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
#define BASE_ALPHA 0.20
#define WHITE_EXTRA 0.75
// front polarizer film tint
#define POLARIZER vec3(0.94, 1.0, 0.865)
// ---- level 2: LCD grid (lcd1x style) ----
#define BRIGHTEN_SCANLINES 16.0
#define BRIGHTEN_LCD 4.0
// ---- level 3: drop shadow ----
#define SHADOW_OFFSET 3.0
#define SHADOW_OPACITY 0.5
// ---- level 4: sunlight ----
#define GLARE_INTENSITY 0.15
#define GLARE_SIGMA 0.25
#define SHIMMER_INTENSITY 0.25
// chroma amplification so the bands read on the already-desaturated,
// backing-blended image (reference applies 0.25 to raw film reflectance)
#define SHIMMER_CHROMA_GAIN 3.0
#define SHIMMER_SPREAD 1.8
#define LIGHT_RANGE 0.6
#define FILM_NOISE_AMOUNT 0.5
#define REFLECT_FLOOR 0.03
float hash21(vec2 p)
{
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
// smooth value noise, ~[0,1]
float vnoise(vec2 p)
{
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float luma(vec3 c)
{
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
{
vec4 src = Texel(tex, tc);
if (level < 0.5) {
return src * color;
}
float ps = max(pixelScale, 1.0);
vec2 gbPix = pc / ps; // GB-pixel coordinates
vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc
vec2 gbTexel = texel * ps; // one GB pixel in tc
// Drifting light position (normalized screen coords, upper area).
// Computed unconditionally: level 3 borrows it for shadow drift.
vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
0.3 + 0.2 * sin(time * 0.07));
// ---- level 1: procedural backing material ----
// flat gray + 3-octave paper grain, tinted warm
float grain = vnoise(gbPix * 0.9) * 0.5
+ vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
+ vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
vec3 back = backLum * POCKET_TINT;
// ---- level 3: dark pixels cast a soft shadow onto the backing ----
if (level >= L_SHADOW) {
vec2 shOff = vec2(SHADOW_OFFSET);
if (level >= L_SUN) {
// subtle drift opposite the light's wander
shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
}
vec2 so = tc - shOff * gbTexel;
vec2 e = gbTexel;
// 9-tap gaussian blur of the offset sample's brightness (unrolled)
float s = 0.0;
s += luma(Texel(tex, so).rgb) * 4.0;
s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
s /= 16.0;
float dark = 1.0 - s;
// deadzone: near-white pixels (dark ~ 0) cast no shadow at all
float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
back = mix(back, back * 0.2, shadow);
}
// ---- level 2: LCD subpixel grid on the lit image only ----
vec3 lit = src.rgb;
if (level >= L_GRID) {
vec2 angle = 2.0 * PI * (gbPix - 0.25);
float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
/ (BRIGHTEN_SCANLINES + 1.0);
float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
lit *= yfac * xfac;
}
// ---- level 1: brightness-proportional pixel transparency ----
float lum = luma(src.rgb);
float a = BASE_ALPHA * lum;
// near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
// fully transparent -- narrow smoothsteps stand in for the hard AND
float mn = min(src.r, min(src.g, src.b));
a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));
// ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
float glare = 0.0;
if (level >= L_SUN) {
float aspect = love_ScreenSize.x / love_ScreenSize.y;
vec2 p = vec2(tc.x * aspect, tc.y);
vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
float d = distance(p, lp);
// specular gaussian hotspot (added after the polarizer tint:
// it reflects off the front glass, not the LCD)
glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));
// quarter-wave-plate film: effective retardance (nm) grows with
// distance from the light point -> concentric interference bands;
// smooth "film thickness" noise makes the bands splotchy
float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
* (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
float ph = 4.0 * PI * gammaEff;
// 7 wavelength samples 400..700nm with approximate spectral RGB,
// unrolled (no const arrays in GLSL 1.20)
vec3 rb = vec3(0.0);
float cw;
cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0
// fade with distance from the light, kill on dark pixels,
// weight by pixel color squared
float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
float refl = max(lum, REFLECT_FLOOR);
// luminance-preserving tint: add only the chroma of the rainbow
vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
* src.rgb * src.rgb * refl * att;
col += shimmer * SHIMMER_INTENSITY;
}
// front polarizer tint, then front-surface glare on top
col *= POLARIZER;
col += vec3(glare);
return vec4(col, src.a) * color;
}
]]
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
end
return shader or nil
end
function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
GBCFX.level = level
end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end
function GBCFX.levelLabel(level)
return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
end
function GBCFX.active()
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current
-- render target (or plain if the shader is unavailable). pixelScale is
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
-- stay window-size independent.
function GBCFX.present(canvas, pixelScale)
local sh = GBCFX.shader()
if not sh or GBCFX.level <= 0 then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas, 0, 0)
return
end
local t = 0
if love.timer and love.timer.getTime then
t = love.timer.getTime()
end
sh:send("level", GBCFX.level)
sh:send("time", t)
sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas, 0, 0)
love.graphics.setShader()
end
return GBCFX
+60 -16
View File
@@ -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
+295
View File
@@ -0,0 +1,295 @@
-- Mechanical GLSL rewrites over librashader's emitted output: it emits a
-- standalone void main()/gl_FragData[0]/gl_Position shape, LOVE requires the
-- effect()/position() convention. Not a GLSL parser -- every rule here exists
-- because a real preset failed without it. See docs/shaderfx.md.
local Fixup = {}
local function stripVersion(src)
return (src:gsub("#version%s+%d+%s*\n", ""))
end
-- ES 1.00 has no array-constructor syntax at all; SPIRV-Cross emits it anyway.
-- Splitting needs paren-depth awareness, since an element can be vec2(a, b).
local function splitTopLevelCommas(s)
local parts, depth, start = {}, 0, 1
for i = 1, #s do
local c = s:sub(i, i)
if c == "(" then
depth = depth + 1
elseif c == ")" then
depth = depth - 1
elseif c == "," and depth == 0 then
parts[#parts + 1] = s:sub(start, i - 1)
start = i + 1
end
end
parts[#parts + 1] = s:sub(start)
return parts
end
local function elementAssignLines(name, elemsStr)
local lines = {}
for i, elem in ipairs(splitTopLevelCommas(elemsStr)) do
lines[#lines + 1] = ("%s[%d] = %s;"):format(name, i - 1, elem:match("^%s*(.-)%s*$"))
end
return lines
end
local function rewriteArrayLiterals(src)
local injections = {}
-- const, global scope: relocate the per-element assignments into main().
src = src:gsub(
"const%s+([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
function(typ, name, size, parenElems)
for _, line in ipairs(elementAssignLines(name, parenElems:sub(2, -2))) do
injections[#injections + 1] = " " .. line
end
return ("%s %s[%s];"):format(typ, name, size)
end)
if #injections > 0 then
src = src:gsub("(void%s+main%s*%(%s*%)%s*{)",
"%1\n" .. table.concat(injections, "\n"), 1)
end
-- non-const, local declaration+initializer: rewrite in place.
src = src:gsub(
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
function(typ, name, size, parenElems)
local lines = elementAssignLines(name, parenElems:sub(2, -2))
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
end)
-- bare reassignment of an already-declared array: rewrite in place.
src = src:gsub(
"([%w_]+)%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;",
function(name, parenElems)
return table.concat(elementAssignLines(name, parenElems:sub(2, -2)), "\n ")
end)
-- array-to-array copy (`float param_1[7] = coeffs;`): ES 1.00 has no
-- whole-array assignment either, so copy element by element.
src = src:gsub(
"([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*([%w_]+)%s*;",
function(typ, name, size, srcName)
local lines = {}
for i = 0, tonumber(size) - 1 do
lines[#lines + 1] = ("%s[%d] = %s[%d];"):format(name, i, srcName, i)
end
return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ")
end)
return src
end
Fixup.rewriteArrayLiterals = rewriteArrayLiterals
-- ES 1.00 has no `%` operator. `%` is only ever defined for integer operands,
-- so float mod() is exact here; balanced-paren operand shapes are tried first.
local function rewriteIntegerModulo(src)
local function convert(l, r)
return ("int(mod(float(%s), float(%s)))"):format(l, r)
end
src = src:gsub("(%b())%s*%%%s*(%b())", convert)
src = src:gsub("(%b())%s*%%%s*([%w_]+)", convert)
src = src:gsub("([%w_]+)%s*%%%s*(%b())", convert)
src = src:gsub("([%w_]+)%s*%%%s*([%w_]+)", convert)
return src
end
Fixup.rewriteIntegerModulo = rewriteIntegerModulo
-- SPIRV-Cross hardcodes an unguarded `precision highp` pair with no toggle;
-- claim highp only where the driver actually offers fragment highp.
local function guardPrecision(src)
src = src:gsub("precision%s+highp%s+float%s*;%s*\nprecision%s+highp%s+int%s*;%s*\n", "")
local guard = [[
#ifdef GL_ES
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
precision highp int;
#endif
#endif
]]
return guard .. src
end
-- LOVE's Shader:send cannot address a member of a struct-typed uniform, so the
-- struct is deleted and its members re-declared: scalars packed 4-at-a-time
-- into vec4 slots (ES 1.00 guarantees only 16 fragment uniform vectors),
-- vectors kept whole. `specialMembers` substitutes a member to fixed text
-- instead of declaring it at all (see UBO_SPECIAL_MEMBERS).
local SCALAR_TYPES = { float = true, int = true, bool = true }
local COMPONENTS = { "x", "y", "z", "w" }
local function flattenStruct(src, structName, packedPrefix, specialMembers)
packedPrefix = packedPrefix or "LIBRA_PACKED_"
specialMembers = specialMembers or {}
local body = src:match("struct%s+" .. structName .. "%s*{(.-)}%s*;")
if not body then return src, {} end
local instance = src:match("uniform%s+" .. structName .. "%s+([%w_]+)%s*;")
assert(instance, "flattenStruct: found struct " .. structName .. " but no instance uniform")
src = src:gsub("struct%s+" .. structName .. "%s*{.-}%s*;%s*\n", "")
src = src:gsub("uniform%s+" .. structName .. "%s+" .. instance .. "%s*;%s*\n", "")
local members = {}
for typ, name in body:gmatch("([%w_]+)%s+([%w_]+)%s*;") do
members[#members + 1] = { type = typ, name = name }
end
-- Declaration order matters here: packing scalars 4-at-a-time only minimizes
-- group count against the struct's own vec4-then-scalars layout.
local decls, manifest, specialSubs = {}, {}, {}
local scalarGroup, packIndex = {}, 0
local function flushGroup()
if #scalarGroup == 0 then return end
local uniformName = packedPrefix .. packIndex
decls[#decls + 1] = ("uniform vec4 %s;"):format(uniformName)
for i, m in ipairs(scalarGroup) do
manifest[#manifest + 1] = { name = m.name, uniform = uniformName, component = COMPONENTS[i], type = m.type }
end
packIndex, scalarGroup = packIndex + 1, {}
end
for _, m in ipairs(members) do
if specialMembers[m.name] then
specialSubs[#specialSubs + 1] = { name = m.name, replacement = specialMembers[m.name] }
elseif SCALAR_TYPES[m.type] then
scalarGroup[#scalarGroup + 1] = m
if #scalarGroup == 4 then flushGroup() end
else
flushGroup()
decls[#decls + 1] = ("uniform %s %s;"):format(m.type, m.name)
manifest[#manifest + 1] = { name = m.name, uniform = m.name }
end
end
flushGroup()
-- longest-name-first so a substitution can't land inside a longer member name
-- sharing a prefix; a separate order from the packing above. A packed slot
-- only stores floats, so an int/bool member needs a cast back on every read.
local subs = {}
for _, entry in ipairs(manifest) do
local replacement = entry.component and (entry.uniform .. "." .. entry.component) or entry.uniform
if entry.component and (entry.type == "int" or entry.type == "bool") then
replacement = ("%s(%s)"):format(entry.type, replacement)
end
subs[#subs + 1] = { name = entry.name, replacement = replacement }
end
for _, entry in ipairs(specialSubs) do subs[#subs + 1] = entry end
table.sort(subs, function(a, b) return #a.name > #b.name end)
for _, entry in ipairs(subs) do
src = src:gsub(instance .. "%." .. entry.name, entry.replacement)
end
return table.concat(decls, "\n") .. "\n" .. src, manifest
end
Fixup.flattenStruct = flattenStruct
-- Turns a flat {member = value} table into the {uniform = value_or_vec4} shape
-- the packed shader expects. A missing value packs as 0 in its component.
function Fixup.packValues(manifest, values)
local packed = {}
local slot = { x = 1, y = 2, z = 3, w = 4 }
for _, entry in ipairs(manifest) do
if entry.component then
packed[entry.uniform] = packed[entry.uniform] or { 0, 0, 0, 0 }
packed[entry.uniform][slot[entry.component]] = values[entry.name] or 0
else
packed[entry.uniform] = values[entry.name]
end
end
return packed
end
-- GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every scalar
-- costs a whole one; samplers do not come out of that budget.
Fixup.SLOTS = { float = 1, int = 1, bool = 1, vec2 = 1, vec3 = 1, vec4 = 1, mat3 = 3, mat4 = 4 }
function Fixup.countUniformSlots(src)
-- Lua's %w does NOT match underscore, unlike regex \w, and every name here is
-- full of them, so this must be [%w_]+ or whole declarations go uncounted.
local total = 0
for typ in src:gmatch("uniform%s+([%w_]+)%s+[%w_]+%s*;") do
if typ ~= "sampler2D" and typ ~= "Image" then
total = total + (Fixup.SLOTS[typ] or 1)
end
end
return total
end
-- LIBRA_UBO_* (the uniform-buffer block) has the same
-- LOVE-can't-address-a-struct-member problem as LIBRA_PUSH_*, so it is
-- flattened the same way, with MVP alone special-cased. Deleting the block
-- outright instead left every other member reference dangling.
-- LOVE forward-declares effect()'s prototype under its own header's precision
-- default, which can mismatch guardPrecision's; pin the parameter list to a
-- #define so the caller can try each variant in order.
Fixup.PREC_HEADS = { "#define EFFECT_PREC mediump\n", "#define EFFECT_PREC\n" }
-- MVP is meaningless in the fragment stage but gets declared anyway; special-
-- casing it keeps a dead field from becoming a uniform nothing would send.
local UBO_SPECIAL_MEMBERS = { MVP = "transform_projection" }
function Fixup.fragment(src)
src = stripVersion(src)
src = rewriteArrayLiterals(src)
src = rewriteIntegerModulo(src)
local pushManifest, uboManifest
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_FRAGMENT")
src, uboManifest = flattenStruct(src, "LIBRA_UBO_FRAGMENT", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
local manifest = pushManifest
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
src = guardPrecision(src)
src = src:gsub("void%s+main%s*%(%s*%)",
"vec4 effect(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc)")
-- gl_FragData isn't available in LOVE's effect() convention. Rewrite EVERY
-- occurrence, whatever operator follows: real presets (ds-hybrid-sabr) write
-- it once with `=` and later accumulate with `+=`.
src = src:gsub("gl_FragData%[0%]", "gbFragColor")
src = src:gsub(
"(vec4 effect%(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, "
.. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc%)%s*\n{)",
"%1\n vec4 gbFragColor;")
-- a bare early-exit `return;` needs a value now -- gbFragColor already
-- holds whatever was assigned just before it on every real shape seen.
src = src:gsub("return%s*;", "return gbFragColor;")
local lastBrace = src:match("()}%s*$")
assert(lastBrace, "fragment fixup: could not find effect()'s closing brace")
src = src:sub(1, lastBrace - 1) .. " return gbFragColor;\n" .. src:sub(lastBrace)
return src, manifest
end
function Fixup.vertex(src)
src = stripVersion(src)
src = rewriteArrayLiterals(src)
src = rewriteIntegerModulo(src)
local pushManifest, uboManifest
src, pushManifest = flattenStruct(src, "LIBRA_PUSH_VERTEX")
-- MVP means "multiply the incoming vertex by it", which on an ordinary
-- full-screen LOVE draw is transform_projection, so it substitutes rather
-- than being packed. A distinct prefix keeps the UBO's packed groups off
-- LIBRA_PUSH_*'s own numbering.
src, uboManifest = flattenStruct(src, "LIBRA_UBO_VERTEX", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS)
local manifest = pushManifest
for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end
-- LOVE supplies VertexPosition/VertexTexCoord itself.
src = src:gsub("attribute%s+vec4%s+Position%s*;%s*\n", "")
src = src:gsub("attribute%s+vec2%s+TexCoord%s*;%s*\n", "")
src = src:gsub("void%s+main%s*%(%s*%)",
"vec4 position(mat4 transform_projection, vec4 vertex_position)")
-- gl_Position isn't available in LOVE's position() convention; carry it in a
-- local named to share no substring with "Position".
src = src:gsub("gl_Position%s*=%s*([^;]+);", "gbClipPos = %1;")
-- Whole-identifier frontier match, not a blind gsub: a preset's own local
-- `vec2 vTexCoord;` would otherwise be mangled (dot.slangp pass0).
src = src:gsub("%f[%w]Position%f[%W]", "vertex_position")
src = src:gsub("%f[%w]TexCoord%f[%W]", "VertexTexCoord%.xy")
src = src:gsub(
"(vec4 position%(mat4 transform_projection, vec4 vertex_position%)%s*\n{)",
"%1\n vec4 gbClipPos;")
-- append the return just before the function's final closing brace
local lastBrace = src:match("()}%s*$")
assert(lastBrace, "vertex fixup: could not find main()'s closing brace")
src = src:sub(1, lastBrace - 1) .. " return gbClipPos;\n" .. src:sub(lastBrace)
return src, manifest
end
return Fixup
+67
View File
@@ -0,0 +1,67 @@
-- Local find/replace patches applied to a preset's raw .slang/.inc source on
-- disk, before ShaderFX.convert() hands it to the bridge -- only librashader's
-- own parser can discover a new #pragma parameter, so nothing downstream can
-- add one. Ships with an empty table on purpose; see docs/shaderfx.md.
local Logger = require("src.core.Logger")
local M = {}
-- entry.name -> { { relPath = <relative to the .slangp's own directory>,
-- patches = { { old = <literal>, new = <literal> }, ... } } }
M.PATCHES = {}
local function dirname(path)
return path:match("^(.*)[/\\][^/\\]*$") or "."
end
-- Plain (non-pattern) substring replace: GLSL source is full of Lua pattern
-- magic characters, so gsub on literal source text is not safe here.
local function replaceOnce(text, old, new)
local s, e = text:find(old, 1, true)
if not s then return text, false end
return text:sub(1, s - 1) .. new .. text:sub(e + 1), true
end
-- Applies every registered patch for `entry` to the real files on disk. A patch
-- whose anchor is gone (already applied, or upstream changed) is skipped.
function M.apply(entry)
local list = M.PATCHES[entry.name]
if not list then return end
local base = dirname(entry.fullPath)
for _, file in ipairs(list) do
local path = base .. "/" .. file.relPath
local f = io.open(path, "rb")
if not f then
Logger.warn("ShaderSourcePatches: %s: cannot open %s", entry.name, path)
else
local text = f:read("*a")
f:close()
local changed = false
for _, p in ipairs(file.patches) do
local already = text:find(p.new, 1, true) ~= nil
if not already then
local newText, ok = replaceOnce(text, p.old, p.new)
if ok then
text = newText
changed = true
else
Logger.warn("ShaderSourcePatches: %s: patch anchor not found in %s (upstream changed?)",
entry.name, file.relPath)
end
end
end
if changed then
local wf, werr = io.open(path, "wb")
if not wf then
Logger.warn("ShaderSourcePatches: %s: cannot write %s: %s", entry.name, path, tostring(werr))
else
wf:write(text)
wf:close()
end
end
end
end
end
return M
+27
View File
@@ -17,6 +17,25 @@ Zoom.offset = 0
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- The deepest legal survey step (offset = 1-S, effective scale s' = S+lo =
-- 1 px/world px) plus an active SHADER FX chain crashes the app on real
-- hardware (Pixel 10 Pro XL, "OUT7" == this exact step at that device's own
-- fit scale of 8 -- reported by the user, 2026-08-21). A quantifying repro
-- (TrueFX/zoom-mem-scan/) ruled out ShaderFX's own per-pass canvases as the
-- driver (worst real-corpus case at that step: ~31MB, not crash-sized) --
-- the actual cost is almost certainly the world canvas/tile-render path
-- itself at that resolution (a preexisting cost of deep survey zoom,
-- unrelated to ShaderFX), which a shader chain reading that same
-- full-resolution canvas as its own input then pushes over some real
-- device/driver ceiling this project cannot measure without the device
-- in hand. Removing just the single deepest step while a preset is active
-- is the smallest change that directly targets what was actually reported
-- ("OUT7 + any shader", not "OUT7 alone" and not "OUT6 or shallower") --
-- not a guessed-at general zoom restriction. Needs the user's own on-device
-- confirmation; extend the margin (currently 1 step) if OUT6 turns out to
-- be risky too.
local ShaderFX
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
@@ -37,6 +56,14 @@ function Zoom.offsetRange(S)
elseif lo > -3 then
lo = -3
end
-- SHADER FX + the single deepest survey step: see the comment above.
-- The floor is one step above the deepest this call would otherwise
-- allow, so the three-step minimum above still keeps a zoom-out.
ShaderFX = ShaderFX or require("src.render.ShaderFX")
if ShaderFX.active() then
local floor = math.min(2 - S, lo + 1)
if lo < floor then lo = floor end
end
return lo, hi
end
+1 -1
View File
@@ -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
View File
@@ -340,10 +340,8 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
-- A map script often follows a trainer battle with its own text.
-- Keep a level evolution behind that text: otherwise afterBattle
-- pushes the evolution screen, then this runner resumes and pushes
-- the trainer's text on top of it.
-- The map-side follow-up (stampClosedDoors, #372) rides behind the
-- script's own text; the evolution now runs in BattleState:finish.
if result == "win" then
ctx.afterScript = ctx.afterScript or {}
table.insert(ctx.afterScript, function()
@@ -1211,6 +1209,16 @@ function Commands.replace_block(ctx, bx, by, blockId)
if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end
end
-- ss_anne_departs: scripts/VermilionDock.asm:80 .shift_columns_up, blocking
-- until she has cleared her own width
function Commands.ss_anne_departs(ctx)
local ow = ctx.overworld
if not ow or not ow.startSsAnneDeparture then return end
local runner = ctx.runner
ow:startSsAnneDeparture(function() runner:resume() end)
runner:yield()
end
-- set_tile_anim <anim|false>: override the current tileset's animation
-- ("TILEANIM_WATER"; false stops it) until the next map change restores
-- the record (setMap)
@@ -1422,7 +1430,7 @@ Commands.meta = {}
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "fade", "pan_camera" }) do
"give_pokemon", "fade", "pan_camera", "ss_anne_departs" }) do
Commands.meta[verb] = { foreground = true }
end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
@@ -1430,7 +1438,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way",
"ss_anne_departs" }) do
local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta
meta.blocking = true
+5 -2
View File
@@ -390,9 +390,12 @@ function H.CutDownTreeOrGrass(ctx)
end
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
-- OWCutAnimation: the same block write, the same redraw.
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
-- port owns as World:playWhirlpoolSound. #1717
function H.DisappearWhirlpool(ctx)
return H.CutDownTreeOrGrass(ctx)
local ret = H.CutDownTreeOrGrass(ctx)
call(ctx, "playWhirlpoolSound")
return ret
end
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
+23 -2
View File
@@ -32,6 +32,12 @@ local FIX_FACING = 0x3b
-- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE.
local TREE_SHAKE = 0x56
local TREE_SHAKE_FRAMES = 24
-- engine/events/forced_movement.asm:25-51; step_dig's frame count is the
-- byte after it (macros/scripts/movement.asm:163-167)
local TURN_HEAD = 0x00
local TURN_IN = 0x24
local STEP_DIG = 0x4f
local STEP_DIG_FRAMES = 16
function Movement.dir(nibble)
return DIR[nibble % 4] or "down"
@@ -57,8 +63,10 @@ function Movement.decodeByte(b)
return { kind = "step", dir = dir }
elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides
return { kind = "step", dir = dir }
elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall
return { kind = "turn", dir = dir }
elseif family == 0x20 or family == 0x24 or family == 0x28 then
-- turn_away / turn_in / turn_waterfall all `jp TurningStep`, which steps a
-- cell under OBJECT_ACTION_SPIN -- movement.asm:483-513, :693-715 (#1716)
return { kind = "step", dir = dir, spin = true }
elseif family == 0x2c or family == 0x30 or family == 0x34 then
-- slow_jump_step / jump_step / fast_jump_step, all of which reach
-- JumpStep (engine/overworld/movement.asm:741) and so run under
@@ -141,6 +149,8 @@ Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING
Movement.FIX_FACING = FIX_FACING
Movement.TREE_SHAKE = TREE_SHAKE
Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES
Movement.STEP_DIG = STEP_DIG
Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES
-- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE
-- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count
@@ -177,4 +187,15 @@ function Movement.stepByte(dir)
return 0x0c + (DIR_BYTE[dir] or 0)
end
-- Script_ForcedMovement's stream, `back` being the direction thrown in
-- -- engine/events/forced_movement.asm:25-51
function Movement.forcedMovementBytes(back)
local d = DIR_BYTE[back] or 0
return {
STEP_DIG, STEP_DIG_FRAMES, TURN_IN + d,
STEP_DIG, STEP_DIG_FRAMES, TURN_HEAD + d,
STEP_END,
}
end
return Movement
+7
View File
@@ -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
View File
@@ -6,6 +6,7 @@ local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local romText = require("src.core.RomText")
local BagMenu = {}
@@ -26,11 +27,19 @@ local function buildItems(game)
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
})
end
-- the $ff terminator's row: CANCEL is selectable and exits like B
-- (home/list_menu.asm:105-110, 523-528) #1685
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
local function consume(game, id)
local function consume(game, id, list)
Bag.remove(game.save, id, 1)
-- engine/items/inventory.asm:131-136
if not game.save.inventory[id] then
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
if list then list.index, list.scroll = 1, 0 end
end
end
local function save_name(game)
@@ -66,6 +75,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
if picker then picker:close() end
end
-- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open
-- behind the bag comes down with it (start_sub_menus.asm:400-407) #1745
local function closeBag()
list:close()
if list.closeStartMenu then list.closeStartMenu() end
end
-- field POKé FLUTE: play the tune, then the no-effect text
if result == "flute_field" then
require("src.core.Sound").play(game.data, "Pokeflute")
@@ -84,7 +100,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
list:close()
closeBag()
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function()
local ow = game.overworld
@@ -97,7 +113,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "consumed_escape" then -- Poké Doll
consume(game, id)
consume(game, id, list)
list:close()
showMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
@@ -120,12 +136,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
-- runs, which is why it precedes list:close() here.
-- runs, which is why it precedes the mount/dismount here.
if game.save.forcedBike then
showMessages(game, { Strings("You can't get off\nhere.") })
return
end
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
-- IsBikeRidingAllowed (home/overworld.asm): the tilesets of
@@ -146,27 +161,33 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
return false
end
if game.save.onBike then
closeBag()
game.save.onBike = false
Music.playMap(game.data, ow and ow.map.id, false)
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
elseif bikeAllowed() then
closeBag()
game.save.onBike = true
Music.playMap(game.data, ow.map.id, true)
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
else
-- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and
-- .useItem_closeMenu loops back (item_effects.asm:2305-2319)
showMessages(game, { Strings("No cycling\nallowed here.") })
end
return
end
if result == "fish" then
list:close()
local ow = game.overworld
local p = ow and ow.player
if ow and p and ow:facingIsShoreOrWater() then
closeBag()
ow:goFishing(id)
return
end
-- FishingInit's `ret c` -> ItemUseNotTime -> ItemUseFailed, so a rod
-- away from water leaves the bag up (item_effects.asm:1893-1901)
showMessages(game, { Strings("No good! It's not\neven near water.") })
return
end
@@ -177,7 +198,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
game.save.player.name) })
return
end
consume(game, id)
consume(game, id, list)
list:close()
battle:throwBall(id)
return
@@ -197,21 +218,22 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) }, nil,
game.data.pokemon[target.species].name, mdef.name) }, closePicker,
TextBox.soundOpts(game, "Get_Item1"))
if result == "learn" then consume(game, id) end
if result == "learn" then consume(game, id, list) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned and result == "learn" then consume(game, id, list) end
if learned then
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
end
if learned then taught() end
closePicker()
end)
end
end
@@ -265,8 +287,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local ow = game.overworld
if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
list:close()
consume(game, id)
closeBag()
consume(game, id, list)
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
-- the last Pokémon Center town door like Fly (#196), via the shared
-- departure helper -- the same path Dig/Teleport take from the party menu
@@ -292,7 +314,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "consumed" then
consume(game, id)
consume(game, id, list)
-- refresh counts in the list
for i, it in ipairs(list.items) do
if it.value == id then
@@ -388,6 +410,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
return
end
-- .chooseMon loops on a refusal, so the TM/HM picker stays up for another
-- pick (engine/items/item_effects.asm:2234, :2237) (#1686)
local machineDef = game.data.items[id]
if picker and picker.keepOpen and machineDef and machineDef.machine then
showMessages(game, payload)
return
end
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
-- refusal closes the picker the same way (#252)
showMessages(game, payload, closePicker) -- failed
@@ -406,10 +435,15 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id]
local opts = {
pickOnly = true,
-- USE_ITEM_PARTY_MENU / EVO_STONE_PARTY_MENU, not the trade and daycare
-- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610
itemUse = true,
battle = battle,
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
-- the party menu (item_effects.asm:1392-1418)
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
-- the party menu (item_effects.asm:1392-1418); TM/HM stays up through
-- `predef LearnMove` (item_effects.asm:2238) (#1686)
keepOpen = (not battle)
and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil)),
onSwitch = function(mon, picker)
if not wantsMove then
useOn(game, battle, id, mon, list, nil, picker)
@@ -460,9 +494,15 @@ local function useItem(game, battle, id, list)
local moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM"
and "Booted up an HM!" or Strings("Booted up a TM!")
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
function() pickTargetAndUse(game, battle, id, list) end)
and romText(game.data, "_BootedUpHMText", "Booted up an HM!")
or romText(game.data, "_BootedUpTMText", "Booted up a TM!")
-- engine/items/item_effects.asm:2177 (#1686)
showMessages(game, { booted,
romText(game.data, "_TeachMachineMoveText",
"It contained\n%s!\fTeach %s\nto a POKéMON?", moveName, moveName) },
nil, { choice = function(yes)
if yes then pickTargetAndUse(game, battle, id, list) end
end })
return
end
pickTargetAndUse(game, battle, id, list)
@@ -484,7 +524,8 @@ function BagMenu.new(game, opts)
onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
if not item then return end
-- attempts to swap the CANCEL row are ignored (swap_items.asm:19-22)
if not item or item.cancel then return end
if not l.swapIndex then
l.swapIndex = l.index
return
@@ -496,6 +537,12 @@ function BagMenu.new(game, opts)
l.items = buildItems(game)
end,
onChoose = function(item)
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
if item and item.cancel then
list:close()
if opts.onCancel then opts.onCancel() end
return
end
local id = item.value
local def = game.data.items[id]
if list.swapIndex then -- A also completes a pending swap
@@ -510,6 +557,12 @@ function BagMenu.new(game, opts)
useItem(game, battle, id, list)
return
end
-- the BICYCLE never gets the option box: StartMenu_Item jumps straight
-- to .useOrTossItem (start_sub_menus.asm:340-342) #1705
if id == "BICYCLE" then
useItem(game, battle, id, list)
return
end
-- USE / TOSS submenu (the original's item options).
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
@@ -548,6 +601,25 @@ function BagMenu.new(game, opts)
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
end,
})
-- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and
-- engine/menus/start_sub_menus.asm:319-323 #1732
local n, rows = #list.items, list.cursorRows or list.rows
if n > 0 then
local saved = game.bagListScrollOffset or 0
local index = math.min(saved + (game.bagSavedMenuItem or 0) + 1, n)
local scroll = math.min(saved, index - 1, math.max(0, n - rows))
list.index, list.scroll = index, math.max(0, scroll, index - rows)
end
local baseUpdate = list.update
list.update = function(self, dt)
baseUpdate(self, dt)
game.bagListScrollOffset = self.scroll
game.bagSavedMenuItem = self.index - self.scroll - 1
end
list.closeStartMenu = opts.onClose
-- the item box overlaps the kept-open START menu box, so neither docks to
-- a screen edge on its own (start_sub_menus.asm:302-329) #1745
list.holdsUIAnchors = true
return list
end
+45 -5
View File
@@ -16,6 +16,34 @@ function ListMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local BLACK = { 0, 0, 0, 1 }
local MUTED_TEXT = { 0.55, 0.55, 0.55, 1 }
-- row text runs from x=16 to the 160px screen's right margin (160-8, same
-- margin item.right right-aligns against); GAP is the blank strip kept
-- between a truncated label and item.right so the two never touch.
local ROW_LEFT = 16
local ROW_RIGHT_MARGIN = 160 - 8
local LABEL_GAP = 4
-- Truncate `text` to `pixels`, same convention as WideBattle.lua's own
-- fitName (HP-bar names facing the identical "arbitrary text vs. fixed
-- pixel budget" problem): cut on a whole glyph span, never mid-character,
-- and mark the cut with a trailing '.'. ShaderFXScreen's preset names are
-- player-supplied filenames of arbitrary length -- unclipped, a long one
-- either overlapped/garbled item.right's "CONVERT" hint or ran past the
-- screen's right edge outright.
local function fitLabel(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local ROWS = 7
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
@@ -237,12 +265,13 @@ function ListMenu:drawItemBox()
if #self.items == 0 then
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
end
local shown = 0
local shown, sawCancel = 0, false
for row = 1, self.rows do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
shown = shown + 1
if item.cancel then sawCancel = true end
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
@@ -262,7 +291,7 @@ function ListMenu:drawItemBox()
end
-- the terminator prints CANCEL and returns before the '▼'
-- (home/list_menu.asm:372, 518-524)
if shown == self.rows then
if shown == self.rows and not sawCancel then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
love.graphics.setColor(1, 1, 1, 1)
@@ -282,22 +311,33 @@ function ListMenu:draw()
local item = self.items[i]
if not item then break end
local y = 8 + row * 16
Font.draw(item.label, 16, y)
-- item.muted: a real, selectable row that isn't fully "ready" yet (e.g.
-- ShaderFXScreen's unconverted presets) -- readable, never hidden, same
-- "always still readable" convention kit/Theme.lua's own disabled state
-- documents, just not the generic list-item shape that lived here
-- before ShaderFXScreen needed it.
local textColor = item.muted and MUTED_TEXT or BLACK
love.graphics.setColor(unpack(textColor))
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
local label = fitLabel(item.label, budget)
Font.draw(label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile
-- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(item.label) + 8 + 3
local bx = 16 + Font.width(label) + 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
love.graphics.circle("fill", bx, by, 1.2)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.setColor(unpack(textColor))
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
end
love.graphics.setColor(unpack(BLACK))
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
+9 -12
View File
@@ -1,7 +1,7 @@
-- "Which move should be forgotten?", replaces a move when a Pokémon with
-- four moves learns a new one (engine/pokemon/learn_move.asm). Opens
-- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves
-- can't be forgotten; B / CANCEL gives up on the new move.
-- can't be forgotten; B gives up on the new move.
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
@@ -66,7 +66,9 @@ end
function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
-- wMaxMenuItem = wNumMovesMinusOne, no CANCEL row
-- engine/pokemon/learn_move.asm:144 (#1686)
local n = #self.mon.moves
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or n
elseif input:wasPressed("down") then
@@ -74,9 +76,6 @@ function MoveLearnMenu:update(dt)
elseif input:wasPressed("b") then
self:confirmAbandon()
elseif input:wasPressed("a") then
if self.index > #self.mon.moves then
self:confirmAbandon()
else
local old = self.mon.moves[self.index]
if HM_MOVES[old.id] then
-- HMCantDeleteText, then back to the forget list
@@ -92,7 +91,6 @@ function MoveLearnMenu:update(dt)
self:finish(true)
end
end
end
-- AbandonLearning (learn_move.asm): "Abandon learning MOVE?" YES/NO
-- before giving up; NO returns to the TryingToLearn prompt
@@ -143,15 +141,14 @@ end
function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
-- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8
-- engine/pokemon/learn_move.asm:123-140 (#1686)
Font.drawBox(4, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.mon.moves) do
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
Font.draw(self.game.data.moves[mv.id].name, 48, (7 + i) * 8)
end
Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8)
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
Font.drawCode(CURSOR, 40, (7 + self.index) * 8)
-- WhichMoveToForgetText in the bottom dialogue box
Font.drawBox(0, 12, 20, 6)
Font.draw(Strings("Which move should"), 8, 14 * 8)
+40 -19
View File
@@ -4,7 +4,7 @@
-- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
-- SHADER FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
-- the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and stays fixed on the
@@ -13,7 +13,7 @@
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local ShaderFX = require("src.render.ShaderFX")
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed")
@@ -143,6 +143,20 @@ end
local function sameRows(_, rows) return rows end
-- "OFF" plus every entry's display name, extension stripped -- a
-- Strings() lookup like RULESET's own record.name so a translation catalog
-- could rewrite "OFF" without needing to know about arbitrary preset
-- filenames.
local function shaderfxLabel(entry)
if not entry then return Strings("OFF") end
return Strings((entry.name:gsub("%.slangp$", "")))
end
-- Dual-shader slots: "main" backs the original SHADER
-- FX row (unchanged save key, unchanged default OFF), "secondary" backs the
-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs
-- main's chain into secondary's, same as stacking two RetroArch presets.
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
@@ -323,10 +337,10 @@ local function buildRows(game)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- (TILT / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
-- the player's stored TILT / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
@@ -366,15 +380,30 @@ local function buildRows(game)
end
return true
end },
{ id = "gbcfx", label = Strings("GBC FX"),
-- The generalized slang-shader-preset feature: a picker over
-- ShaderFX.list()'s real drop-in presets -- replaced GBCFX.lua's fixed
-- level ladder entirely (GBCFX.lua removed). A pushes a real list
-- screen -- OFF plus one row per .slangp found -- the same
-- "activate, not step" shape CONTROLS/MODS already use, rather than
-- cycling in place on this row.
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
-- it and shows what is currently active.
{ id = "shaderfx", label = Strings("SHADER FX"),
value = function(g)
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
return shaderfxLabel(ShaderFX.activeEntry("main"))
end,
step = function(g, dir)
local o = g.save.options
o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5)
GBCFX.setLevel(o.gbcfx)
return true
activate = function(g)
require("src.ui.Screens").push(g, "ShaderFXScreen", "main")
end },
-- The secondary slot: same picker screen, opened on "secondary" instead
-- -- its own row so both slots are visible/settable independently
-- without a submenu inside a submenu.
{ id = "shaderfx2", label = Strings("SHADER FX 2"),
value = function(g)
return shaderfxLabel(ShaderFX.activeEntry("secondary"))
end,
activate = function(g)
require("src.ui.Screens").push(g, "ShaderFXScreen", "secondary")
end },
{ id = "zoom", label = Strings("ZOOM"),
value = function(g)
@@ -565,14 +594,6 @@ local function buildRows(game)
return true
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
end
rows = filtered
end
-- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
if not (Orientation.isAndroid() or Orientation.isIOS()) then
local filtered = {}
+11 -31
View File
@@ -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,31 +862,8 @@ 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
-- 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
@@ -890,7 +871,6 @@ function PartyMenu:draw()
Font.draw(line, 8, ly)
ly = ly + 16
end
end
if self.submenu then
local n = #self.subItems
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
+3
View File
@@ -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",
+161
View File
@@ -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
+159
View File
@@ -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
View File
@@ -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
+7 -2
View File
@@ -42,8 +42,13 @@ 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 })
-- 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)
+13 -11
View File
@@ -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
+22 -9
View File
@@ -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
+7 -6
View File
@@ -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()
+5 -5
View File
@@ -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" },
+423
View File
@@ -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
+5 -4
View File
@@ -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
View File
@@ -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,
+3 -2
View File
@@ -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.
+52 -26
View File
@@ -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 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 + 9, ty)
end
elseif 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()
+50
View File
@@ -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,9 +2134,13 @@ function Pokegear:drawMap()
end
end
if current and current.x and current.y then
-- 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
-- ChrisSpriteGFX, the sheet Pokegear_LoadGFX copies into vTiles0 $10 and $14
-- (engine/pokegear/pokegear.asm:135-144). `false` means no gen2 sprites.
@@ -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.
+43 -6
View File
@@ -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)
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
+1 -1
View File
@@ -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
+17
View File
@@ -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
+202 -48
View File
@@ -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()
-- 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()
end))
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 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 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
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
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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
+83 -11
View File
@@ -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.
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,
self.facing, self:walkPhase(), self.stepFlip)
self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip)
end
G.pop()
return
end
+3 -3
View File
@@ -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
+276 -51
View File
@@ -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,26 +5946,139 @@ 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
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)
-- 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
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
-- the one under it and B leaves.
@@ -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,9 +10249,11 @@ 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 },
}
-- .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,
@@ -10043,6 +10264,7 @@ function World:drawPeople(s, billboard)
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)
for _, entry in ipairs(drawList) do
@@ -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
+2 -3
View File
@@ -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
+94
View File
@@ -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
+86
View File
@@ -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