Merge pull request #1278 from bryanthaboi/dev

updates changes and modifications
This commit is contained in:
bryanthaboi
2026-08-14 10:19:00 -04:00
committed by GitHub
99 changed files with 4391 additions and 857 deletions
+47 -1
View File
@@ -252,8 +252,40 @@ jobs:
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
}
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
# it here and inject it in the release job before scripts/build.sh win.
native-tls-win:
name: build Windows gen1tls.dll
needs: version
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT)
shell: pwsh
run: |
$out = "dist/native/win-x64"
New-Item -ItemType Directory -Force -Path $out | Out-Null
dotnet publish native/tls_dial/Gen1Tls.csproj `
-c Release -r win-x64 -o $out
if (-not (Test-Path "$out/gen1tls.dll")) {
throw "gen1tls.dll missing after publish"
}
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
- name: Upload gen1tls.dll
uses: actions/upload-artifact@v7
with:
name: gen1tls-win-x64
path: dist/native/win-x64/gen1tls.dll
if-no-files-found: error
retention-days: 1
release:
needs: [version, xbox-uwp, linux-arm64]
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -269,6 +301,12 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Download Windows gen1tls dialer
uses: actions/download-artifact@v8
with:
name: gen1tls-win-x64
path: dist/native/win-x64
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -307,13 +345,21 @@ jobs:
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
if [ ! -f "$GEN1TLS_DLL" ]; then
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1
fi
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
- name: Build Android
run: |
+5 -1
View File
@@ -3,8 +3,9 @@
data/generated/
assets/generated/
# LÖVE packages
# LÖVE packages & archives
*.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
/dist/native/
/dist/win/
/.bazinga/
# Local options / preferences
/options.lua*
+45 -3
View File
@@ -242,10 +242,52 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot.
### 5. `mod.card`
### 5. What a mod's code can reach
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. What is absent:
| Absent | Use instead |
| --- | --- |
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
join to your own directory, and `..`, absolute paths and drive letters are
refused. So are `entry` and `options_schema` in your manifest.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player, and `network` now gates `require("socket")` and friends. There is no
permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root.
If your mod used one of the absent globals, the fix is almost always
`mod.storage`. Open an issue if you have a case it does not cover.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this,
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load.
@@ -263,7 +305,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview
images.
### 6. Tags
### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`,
+1 -1
View File
@@ -92,7 +92,7 @@ return {
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
+6 -5
View File
@@ -10,10 +10,10 @@
local function coinGiver(opts)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local t = game.data.text
local function push(label, fallback, onDone)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
local function push(label, fallback, onDone, popts)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
popts))
end
if game.save.flags[opts.event] then
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
@@ -30,9 +30,10 @@ local function coinGiver(opts)
end
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
game.save.flags[opts.event] = true
Sound.play(game.data, "Get_Item1")
-- the ReceivedNCoinsText strings carry sound_get_item_1
push(opts.receivedLabel,
("{PLAYER} received\n%d coins!"):format(opts.amount))
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
TextBox.soundOpts(game, "Get_Item1"))
end)
end
end
+5 -4
View File
@@ -22,8 +22,8 @@ return {
local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands")
local t = game.data.text
local function say(label, cb)
game.stack:push(TextBox.new(game, t[label] or label, cb))
local function say(label, cb, sopts)
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
end
if game.save.flags.EVENT_GOT_OLD_AMBER then
@@ -39,8 +39,9 @@ return {
game.save.flags.EVENT_GOT_OLD_AMBER = true
Commands.hide_object({ save = game.save, overworld = ow, game = game },
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
require("src.core.Sound").play(game.data, "Get_Item1")
say("_Museum1FScientist2ReceivedOldAmberText", done)
-- .ReceivedOldAmberText carries sound_get_item_1
say("_Museum1FScientist2ReceivedOldAmberText", done,
TextBox.soundOpts(game, "Get_Item1"))
end)
end,
+6 -6
View File
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
-- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens
-- (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8
-- OaksLabReceivedMonText), so the jingle fires once the box has
-- typed out and holds it (same beat as the Yellow port's starter, #668).
{ "text_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
@@ -106,8 +106,8 @@ return {
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
@@ -128,8 +128,8 @@ return {
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
+5 -5
View File
@@ -38,8 +38,8 @@ return {
{ "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
@@ -62,8 +62,8 @@ return {
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" },
@@ -113,8 +113,8 @@ return {
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
@@ -176,7 +176,7 @@ return {
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
@@ -196,7 +196,7 @@ return {
rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
+2
View File
@@ -297,6 +297,7 @@ M.BILLS_HOUSE = {
M.ROUTE_25 = {
onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
@@ -329,6 +330,7 @@ M.VERMILION_CITY = {
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
-- reachable through this map, so a fresh visit always re-rolls).
onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local puz = game.save.trashPuzzle or {}
game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2
+5 -3
View File
@@ -648,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
-- text_waitbutton -- the jingle plays after the box has typed and
-- the button wait comes after it
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
@@ -662,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end, TextBox.soundOpts(game, "Get_Key_Item")))
end }))
end
end
+7 -11
View File
@@ -8,9 +8,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- The question stays on screen under the YES/NO menu. The dojo prize
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
{ "jump_if_false", "box_full" },
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
{ "set_flag", "EVENT_GOT_LAPRAS" },
{ "play_sound", "Get_Item1" },
{ "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
{ "jump", "end" },
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end)
end)
end,
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
return
end
game.save.flags[g.flag] = true
require("src.core.Sound").play(game.data, "Get_Item1")
local subs = { player = game.save.player.name,
ram = game.data.items[g.tm].name }
local explain = fill(t[g.explain] or "", subs)
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
else
done()
end
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end)
end,
onCancel = done,
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
flags.EVENT_GOT_NUGGET = true
game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone)
end)
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return
end
battleOrDone()
+8 -7
View File
@@ -4,9 +4,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
@@ -25,8 +25,8 @@ local function gift(opts)
local t = text(game)
local itemName = game.data.items[opts.item].name
local subs = { ram = itemName, player = game.save.player.name }
local function say(label, fallback, cb)
push(game, fill(t[label] or fallback, subs), cb)
local function say(label, fallback, cb, sopts)
push(game, fill(t[label] or fallback, subs), cb, sopts)
end
if game.save.flags[opts.flag] then
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
@@ -39,15 +39,16 @@ local function gift(opts)
end
game.save.flags[opts.flag] = true
local idef = game.data.items[opts.item]
require("src.core.Sound").play(game.data,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
-- the jingle only fires once that box has typed out
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
if opts.explain then
say(opts.explain, "", done)
else
done()
end
end)
end, require("src.render.TextBox").soundOpts(game,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
+21
View File
@@ -28,6 +28,14 @@
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
-- successful give, which is what makes the leader's talk script retry
-- later (gyms.lua).
--
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
-- plays it once that page has typed out and then blocks on
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
-- under them. macros/scripts/text.asm defines sound_level_up as
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
local function range(prefix, first, last)
local t = {}
@@ -50,6 +58,8 @@ return {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
@@ -64,6 +74,8 @@ return {
gotFlag = "EVENT_GOT_TM11",
noRoom = "_CeruleanGymMistyTM11NoRoomText",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
},
@@ -76,6 +88,7 @@ return {
gotFlag = "EVENT_GOT_TM24",
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
tmSound = "Get_Key_Item",
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
},
@@ -89,6 +102,7 @@ return {
gotFlag = "EVENT_GOT_TM21",
noRoom = "_CeladonGymTM21NoRoomText",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
tmSound = "Get_Item1",
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
},
@@ -102,6 +116,7 @@ return {
gotFlag = "EVENT_GOT_TM06",
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
tmSound = "Get_Key_Item",
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
},
@@ -115,6 +130,8 @@ return {
gotFlag = "EVENT_GOT_TM46",
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
},
@@ -128,6 +145,8 @@ return {
gotFlag = "EVENT_GOT_TM38",
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
},
@@ -141,6 +160,8 @@ return {
gotFlag = "EVENT_GOT_TM27",
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
},
+12 -8
View File
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down
with it**, as a skip rather than a failure and carrying the dependency's own
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
whole chain has to cover the same games. And **the claim is yours, not the last
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
with it** (unless scoped to specific games, e.g.
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
failure and carrying the dependency's own wording (`depends on X, which does not
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
whose author never opted in, which is the only route for a mod written before
the field existed. The override is per game -- `options.modsGen2[id]` is a
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
@@ -533,8 +534,9 @@ gains a field instead of the name gaining a prefix.
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
@@ -546,11 +548,13 @@ gains a field instead of the name gaining a prefix.
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits
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
letterbox, and the finished playfield rect -- and carries the same payload.
composed frame before GBCFX, 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`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
+123
View File
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
## Manifest specification (`manifest.json`)
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
```json
{
"id": "my_mod",
"name": "My Cool Mod",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"games": ["gen1", "gen2"],
"game_version": ">=0.0.0-dev <2.0.0",
"priority": 100,
"dependencies": [
"helper_lib@^1.0.0",
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
],
"optional_dependencies": [
"gen1_modern_ui"
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
"github": "author/my_mod"
}
```
### Manifest Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
| `name` | `string` | Human-readable title shown in launcher and manager. |
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
1. **Simple string**: `"mod_id"`
2. **Version-pinned string**: `"mod_id@^1.2.0"`
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
4. **Structured object**:
```json
{
"id": "mod_id",
"range": "^1.2.0",
"games": ["gen2"],
"github": "owner/repo"
}
```
#### Version-Scoped Dependencies
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
@@ -440,6 +510,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
`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,
`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`,
`gameHeight`, `scale`, `dpiX`,
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
to `false`. Enabling the seam requires a full-window canvas for that frame.
With no `render.output` subscriber, or while `render.output_enabled` is false,
the existing presentation path is unchanged. `render.compose` takes precedence
when it owns the frame.
`screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and
palette-zone ownership. The state remains on the stack and keeps its normal
@@ -455,6 +539,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
Both hooks apply to Gen 1 and Gen 2 battles.
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
for that battle, so hiding the bottom layer cannot leave their white backing
behind under another overlay. Text boxes also pass through the hook as their
@@ -545,3 +630,41 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return
`"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
+2 -1
View File
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding**
## Pokémon Gold (Gen 2)
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
+19
View File
@@ -258,6 +258,25 @@ row on the detail screen. The launcher's dependency verdict asks the same
question of your dependencies: a mod whose hard dependency does not run on the
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### Scoping dependencies per game / generation
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
dependency can be scoped to specific games so that it is only enforced when
booting those games:
```json
"dependencies": [
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
]
```
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
For conditional integrations where the dependency is optional across the board,
`optional_dependencies` remains the standard pattern.
### One limit worth knowing
**Enablement is per game.** The overlay
+49
View File
@@ -0,0 +1,49 @@
# RFC 0008 — Read-only device power information for sandboxed mods
## Status
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
`tests/modkit/cases/device_power_info.lua`.
## Motivation
A handheld UI mod can show the player's battery state and warn before power
loss. The sandbox correctly removes `love.system` because that module also
launches URLs and exposes other host operations, but it leaves no scoped way
to read the harmless power values that LÖVE already provides.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
blocked while legitimate operations receive narrow engine-owned facades.
## The exact API delta
Add `mod.device:powerInfo() -> state, percent`.
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
returns only its first two values. `state` is one of LÖVE's standard power
states. `percent` is `0` through `100` or `nil`. When the platform has no
power-information backend, the result is `"unknown", nil`.
No permission grants access to `love.system`; URL launching, clipboard access,
OS identification, and the module table itself remain unavailable.
## Migration note for existing mods
Mods that used `love.system.getPowerInfo()` replace that call with
`mod.device:powerInfo()`. No other mod changes.
## Parity tests
- **No mod:** loading no mods does not call the platform power backend.
- **Mod API:** a fixture mod loaded through the public loader receives state
and percentage through `mod.device`, while the existing sandbox suite keeps
proving that direct `love.system` access is refused.
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
than inventing battery data or failing mod load.
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
remains in force.
+68
View File
@@ -0,0 +1,68 @@
# RFC 0009 — Permission-gated step bridge for sandboxed mods
## Status
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
## Motivation
The iOS and Android builds count the player's real-world steps natively
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
delivered as `steps_pending.json` in the save-directory root. The sandbox
correctly blocks both — `love.system` also launches URLs, and the file API
names paths — but that leaves the bridge with no consumer: the mod that
step counting was built for (Pokéwalker, steps→EXP) can no longer be
written.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
stay blocked; legitimate operations receive narrow engine-owned facades)
and the permission model `network` established: a `manifest.json`
permission the player sees in the mod manager that genuinely gates a
capability.
## The exact API delta
A new manifest permission token, `steps`, and a `mod.steps` facade:
- `mod.steps:available() -> boolean` — whether this build carries the
native bridge. Answers `false` without the permission, so a probe stays
quiet.
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
(async; the OS consent sheet still appears on first use, exactly as
before the sandbox). `false` when there is no bridge.
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
next delivery for this mod, engine-consumed from the pending file. Each
permissioned mod receives its own copy of a delivery.
Without the permission, `sync` and `poll` raise an error naming the
missing permission, the way the network gate does. The engine owns the
pending file: mods never learn its name or location, and only the three
contract fields travel. No new event, hook, or registry names.
## Migration note for existing mods
Mods that called `love.system.syncHealthSteps()` and read
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
## Parity tests
- **No mod:** with nothing installed the bridge is never called and a
pending file on disk is left untouched.
- **Mod API:** a fixture mod with the permission syncs and receives a
delivery through the public loader; two permissioned mods both receive
the same walk; a second poll returns nil.
- **No permission:** `available()` is false and the acting calls name the
missing permission; the sandbox suite keeps proving direct
`love.system` access is refused.
- **Malformed delivery:** a bad or empty pending file is dropped whole
rather than crashing a poll (the native anchor only advances on a
successful sync, so nothing is lost).
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system`
and `love.filesystem` blocks remain in force.
+1
View File
@@ -0,0 +1 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+4
View File
@@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
required on the player's machine.
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
job publishes this project and `scripts/build.sh`'s Windows packaging copies
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
## Android
On Android, the matching API is exposed as `love.system.tlsOpen` /
+18
View File
@@ -261,6 +261,21 @@ build_win() {
cp "$love_dir"/*.dll "$out_dir"/
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
local tls_dll="${GEN1TLS_DLL:-}"
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
tls_dll="$DIST/native/win-x64/gen1tls.dll"
fi
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
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)"
fi
# 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,
@@ -400,6 +415,9 @@ EOF
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
+6
View File
@@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then
fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF
chmod +x "$APPDIR/AppRun"
+6
View File
@@ -35,4 +35,10 @@ find_love() {
LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "$LOVE_BIN" "$ROOT" "$@"
+119 -69
View File
@@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
@@ -563,10 +566,6 @@ local function markOwned(game, species)
local dex = game.save.pokedex
if dex then
dex.seen[species] = true
if not dex.owned[species] then
-- new dex page registered (SFX_DEX_PAGE_ADDED)
require("src.core.Sound").play(game.data, "Dex_Page_Added")
end
dex.owned[species] = true
end
end
@@ -935,6 +934,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
-- getter the row calls at execution time when the source is only known then.
function BattleState:waitSfxNext(src)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ waitSound = type(src) == "function" and src
or function() return src end })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
@@ -1022,22 +1032,39 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1
busy = true
elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
-- call DelayFrames` apiece (:141-148), so a single HP point that
-- spans several pixels still slides instead of jumping
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
busy = true
elseif b.shownHP ~= goal then
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local cost = 0
local spent = 0
-- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
maxHP, playerSide)
b.shownHP = nextHP
repeat
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
if spent > 0 then
b.drainHold = spent - 1
elseif targetPx ~= b.shownPx then
-- the enemy HUD printed no number, so this frame is already the
-- first of the pixel step the crossing just asked for
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
else
b.drainHold = 0
end
b.drainHold = math.max(0, cost - 1)
b.draining = true
busy = true
elseif b.draining then
@@ -1431,7 +1458,7 @@ end
function BattleState:playEntranceCry(battler)
local mon = battler and battler.mon
if not mon then return end
require("src.core.Sound").playCry(self.data, mon.species,
return require("src.core.Sound").playCry(self.data, mon.species,
mon.status == "SLP" and 37 or 11)
end
@@ -1555,7 +1582,23 @@ function BattleState:enter()
-- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
-- foe's pic is RedPicFront whatever either save looks like, and
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
-- exactly as it is for a trainer.
if self.kind == "link" and not self.trainerPic then
local frontPath, frontTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "front",
{ kind = "battle", battle = self })
if frontPath and require("src.render.Assets").exists(frontPath) then
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
frontTrueColor)
end
end
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
@@ -1587,13 +1630,13 @@ function BattleState:enter()
-- a different point in each battle kind, so queue it per branch
local function queueEnemyCry()
self:act(function()
self:playEntranceCry(self.enemy)
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
@@ -1611,7 +1654,10 @@ function BattleState:enter()
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it.
if self.kind == "trainer" then
--
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
if self.kind == "trainer" or self.kind == "link" then
self:act(function()
self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared")
@@ -1629,13 +1675,17 @@ function BattleState:enter()
-- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
if self.kind == "trainer" or self.kind == "link" then
local foeName = self.trainer and self.trainer.name
or self.opponentName or Strings("FOE")
if self.showEnemyTrainer then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
end
self:act(function()
self.showEnemyTrainer = false
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
@@ -1648,7 +1698,7 @@ function BattleState:enter()
self.enemySendingOut = true
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1656,18 +1706,6 @@ function BattleState:enter()
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
@@ -1698,7 +1736,7 @@ function BattleState:enter()
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:markParticipant()
end
@@ -1876,7 +1914,10 @@ function BattleState:update(dt)
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then b.shownHP = b.mon.hp end
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
@@ -2294,10 +2335,10 @@ function BattleState:oldManThrow()
return
end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
-- the fanfare follows the caught text and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end)
end
@@ -2481,7 +2522,7 @@ function BattleState:resolveSwitch(newMon)
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end)
self:act(function()
@@ -3793,7 +3834,7 @@ function BattleState:onFaint(battler)
if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall
Sound.playCry(self.data, battler.mon.species)
self.faintCry = Sound.playCry(self.data, battler.mon.species)
elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -3808,6 +3849,11 @@ function BattleState:onFaint(battler)
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if battler.isPlayer then
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
self:waitSfxNext(function() return self.faintCry end)
end
if not battler.isPlayer and self.kind ~= "wild" then
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
-- Faint_Fall, before EnemyMonFaintedText)
@@ -3895,9 +3941,11 @@ function BattleState:awardExp()
-- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon)
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
-- 369-372); PrintStatsBox only runs once PrintText has returned
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon)
end)
-- After PrintStatsBox, experience.asm reloads the active battler's
@@ -4042,7 +4090,7 @@ function BattleState:enemyMonFainted()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:playEntranceCry(self.enemy)
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
end)
@@ -4081,7 +4129,7 @@ function BattleState:enemyMonFainted()
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end)
return
@@ -4277,7 +4325,7 @@ function BattleState:openReplacementMenu()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end,
})
@@ -4321,11 +4369,10 @@ function BattleState:safariAction(choice)
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
if caught then
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
-- the fanfare follows the caught text and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
-- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end)
else
@@ -4553,8 +4600,12 @@ function BattleState:storeCaughtMon()
markOwned(game, species)
stampOT(game.save, self.enemy.mon)
if isNew then
-- _ItemUseBallText06 + ShowPokedexData
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set
self:sayNextWaitSfx(
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function()
return self:buildScreen("DexEntryMenu", species)
end)
@@ -4704,12 +4755,10 @@ function BattleState:throwBall(ball)
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
if caught then
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
-- 608-614): the fanfare sounds with the caught message, before
-- the prompt, not after the text is dismissed
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
-- fanfare follows the caught message and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
self:act(function() self:storeCaughtMon() end)
else
self:sayNext(self:ballMissMessage(shakes))
@@ -5196,7 +5245,8 @@ function BattleState:sgbBattlePals()
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
@@ -5594,7 +5644,7 @@ function BattleState:drawHUDs(slide)
hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill)
nil, grayFill, nil, self.enemy.shownPx)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
@@ -5675,7 +5725,7 @@ function BattleState:drawHUDs(slide)
end
drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats },
1, grayFill) -- wHPBarType 1: the $6D cap
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
+1 -1
View File
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
}, nil, monoMode(), tw - 5, battler.shownPx)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
+6
View File
@@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
-- the bar's own pixel length is derived HUD state, not captured: it
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
local Timing = require("src.core.Timing")
for _, b in ipairs({ battle.player, battle.enemy }) do
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function"
+26 -13
View File
@@ -73,7 +73,7 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
-- hooks below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
@@ -83,6 +83,7 @@ end
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
-- render.compose the whole-window composite (Renderer.lua:759)
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
--
@@ -1382,12 +1383,14 @@ function Game2:draw()
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, or a render.compose subscriber about to be
-- handed the finished frame. With none of them the frame draws straight to
-- the screen exactly as it always did.
-- GBC 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() then
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
@@ -1419,7 +1422,7 @@ function Game2:draw()
-- 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()
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then
local tinted = self:presentCanvas(2, w, h)
if tinted then
@@ -1439,15 +1442,25 @@ function Game2:draw()
-- 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, _, _, dpi = self:frameFit(w, h)
local scale, ox, oy, dpi = self:frameFit(w, h)
source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
local outputHandled = hasOutputHook
and ModRuntime.call("render.output", function() return false end, {
canvas = source, width = w, height = h,
gameX = ox, gameY = oy,
gameWidth = 160 * scale, gameHeight = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
generation = 2,
}) == true
if not outputHandled then
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
end
end
end
G.pop()
+4
View File
@@ -116,6 +116,8 @@ local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
if Platform.isUWP() then return physfsMountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
if Platform.isUWP() then return physfsUnmountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
File diff suppressed because it is too large Load Diff
+127 -1
View File
@@ -1649,7 +1649,7 @@ end
function RomImporter:_installMod(source)
if self.workState == "working" then return end
self.tab = "mods"
local ok, installed, res = pcall(function()
local ok, installed, res, manifest = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source)
end)
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
if installed then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
local LauncherMods = require("src.mods.LauncherMods")
local checkTarget = manifest
if not checkTarget and type(res) == "string" then
checkTarget = { id = res }
end
if checkTarget and LauncherMods.checkDependencies then
local depCheck = LauncherMods.checkDependencies(checkTarget)
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
else
self.modNotice = { ok = false, text = tostring(res) }
end
@@ -2716,6 +2727,43 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if self._profileSavePrompt then
if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileSavePrompt and self._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
self._profileSavePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileSavePrompt = nil
self:_disarmTextInput()
end
return
end
if self._profileRenamePrompt then
if key == "backspace" then
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
self._profileRenamePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileRenamePrompt = nil
self:_disarmTextInput()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -2885,6 +2933,14 @@ function RomImporter:_commitRename()
end
function RomImporter:textinput(text)
if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._profileRenamePrompt then
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -3371,6 +3427,76 @@ function RomImporter:_pumpModInstall()
else
self.modNotice = { ok = true, text = text }
end
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
self._depPullState = self._depPullState or {}
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
self._depPullState[dep.id] = {
dep = dep,
stage = "fetching",
fetchHandle = hFetch,
}
end
-- Pump all in-flight dependency pulls
function RomImporter:_pumpDepPulls()
if not self._depPullState then return end
local ModUpdate = require("src.mods.ModUpdate")
local LauncherMods = require("src.mods.LauncherMods")
for depId, state in pairs(self._depPullState) do
if state.stage == "fetching" then
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
if done then
if err or not releases or #releases == 0 then
state.stage = "error"
state.err = err or "No downloadable releases found on GitHub"
else
local rel = releases[1]
if not rel or not rel.zip or not rel.zip.url then
state.stage = "error"
state.err = "Latest release has no downloadable .zip asset"
else
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
state.stage = "downloading"
state.targetVersion = rel.version
end
end
end
elseif state.stage == "downloading" then
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
state.progress = progress
if done then
if err or not localPath then
state.stage = "error"
state.err = err or "Download failed"
else
state.stage = "installing"
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
if okInst then
state.stage = "done"
pcall(self._refreshMods, self)
if self._modDepResolver and self._modDepResolver.targetMod then
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
self._modDepResolver = updated
end
else
state.stage = "error"
state.err = tostring(versionRes or "Installation failed")
end
end
end
end
end
end
function RomImporter:_confirmModUpdate(modId, release)
+120 -11
View File
@@ -9,15 +9,16 @@
-- cable pull.
--
-- Cable rules: no experience, no money, no items; either side may RUN
-- (a draw); a fainted mon is auto-replaced by the next healthy party
-- member (the original prompts; documented divergence). Badge stat
-- boosts don't apply on either side (divergence: Gen 1 famously kept
-- them in link battles).
-- (a draw); a fainted mon is replaced from the party menu and the chosen
-- slot rides the wire, the way ChooseNextMon hands it to
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
-- (divergence: Gen 1 famously kept them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Party = require("src.pokemon.Party")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = Strings("%s wants\nto battle!", theirName)
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
self.introText = Strings("%s wants\nto fight!", theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
end)
end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
-- runs first, a fainted pick or a cancel goes back to it
-- (.goBackToPartyMenu), and only the chosen slot rides
-- LinkBattleExchangeData.
local function chooseReplacement(s)
s.linkReplacement = nil
s:uiNext(function()
return s:buildScreen("PartyMenu", {
battle = s,
party = myParty,
forceSwitch = true,
onSwitch = function(mon)
if mon.hp > 0 then s.linkReplacement = mon end
end,
})
end)
s:actNext(function()
local mon = s.linkReplacement
s.linkReplacement = nil
if s.result then return end
if not mon then
chooseReplacement(s)
return
end
for i, m in ipairs(myParty) do
if m == mon then send({ type = "replace", index = i }) break end
end
sendOutPlayer(s, mon)
end)
end
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
-- double faint both machines commit their own slot before they block on the
-- peer's; our queue can reach the enemy's handler first, so the wait
-- pre-empts itself with the local pick rather than deadlocking on a message
-- neither side is going to send.
local function awaitReplacement(s)
s:actNext(function()
if s.result then return end
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
and Party.firstHealthy(myParty) then
s.linkChoiceQueued = true
chooseReplacement(s)
awaitReplacement(s)
return
end
local idx = s.remoteReplace
if not idx then
awaitReplacement(s)
return
end
s.remoteReplace = nil
local mon = theirParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
sendOutEnemy(s, mon)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
return decodeWireAction(s, msg, s.enemy)
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
submit(s, { type = "action", kind = "run" }, nil)
end
-- fainted mons auto-replace with the next healthy teammate, in party
-- order, identically on both machines
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
-- side's ChooseNextMon and put the slot on the wire, so the handler the
-- faint queued has nothing left to do but drop the flag
self.playerMonFainted = function(s)
if s.linkChoiceQueued then
s.linkChoiceQueued = nil
return
end
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end)
if not s.result then chooseReplacement(s) end
return
end
end
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end)
if not s.result then awaitReplacement(s) end
return
end
end
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "replace" then
local idx = math.floor(tonumber(msg.index) or 1)
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
elseif msg.type == "bye" then
-- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating)
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
end)
end
local function awaitHostReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.hostReplace, 1)
if not idx then
awaitHostReplacement(s)
return
end
local mon = hostParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
sendOutHost(s, mon)
end)
end
local function awaitGuestReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.guestReplace, 1)
if not idx then
awaitGuestReplacement(s)
return
end
local mon = guestParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
sendOutGuest(s, mon)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
if not s.result then awaitHostReplacement(s) end
return
end
end
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
if not s.result then awaitGuestReplacement(s) end
return
end
end
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
end
self.hostMsg, self.guestMsg = nil, nil
self.hostReplace, self.guestReplace = {}, {}
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "replace" then
local idx = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
else
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
+87 -17
View File
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
LinkState.isOpaque = true
local CURSOR = 0xED
local CURSOR_HOLLOW = 0xEC
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
@@ -114,6 +115,7 @@ end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
if self.game.linkNet == self.net then self.game.linkNet = nil end
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
})
self.net:send(self.trade:opening())
self.index = 1
self.theirIndex = 1
self.side = "mine"
self.pickChoice = nil
else
self.stage = "battleWait"
-- the host deals the shared RNG seed for the lockstep simulation
@@ -536,7 +541,14 @@ end
-- trade flow
-- -------------------------------------------------------------------
function LinkState:openStats(mon)
if not mon then return end
self.game.linkNet = self.net
Screens.push(self.game, "SummaryMenu", mon)
end
function LinkState:updateTrade(input)
if self.game.linkNet == self.net then self.game.linkNet = nil end
for _, msg in ipairs(self.net:poll()) do
local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
return
end
if t.stage == "picking" and input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
-- and only TRADE commits the pick, while the enemy list carries its own
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
-- the player's party and it draws YOUR mon at that slot -- an omission,
-- not behaviour, so we show the peer's mon.
if t.stage == "picking" and self.pickChoice then
if input:wasPressed("left") then
self.pickChoice = 1
elseif input:wasPressed("right") then
self.pickChoice = 2
elseif input:wasPressed("b") then
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
-- out of the trade
elseif input:wasPressed("a") then
if self.pickChoice == 1 then
self.pickChoice = nil
self:openStats(self.game.save.party[self.index])
elseif t:canPick(self.index) then
self.pickChoice = nil
self.side = "mine"
self.net:send(t:pick(self.index))
end
end
elseif t.stage == "picking" and input:wasPressed("up") then
if self.side == "theirs" then
self.theirIndex = math.max(1, self.theirIndex - 1)
else
self.index = math.max(1, self.index - 1)
end
elseif t.stage == "picking" and input:wasPressed("down") then
self.index = math.min(#self.game.save.party, self.index + 1)
if self.side == "theirs" then
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
else
self.index = math.min(#self.game.save.party, self.index + 1)
end
elseif t.stage == "picking" and input:wasPressed("right") then
if t.theirParty and #t.theirParty > 0 then
self.side = "theirs"
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
end
elseif t.stage == "picking" and input:wasPressed("left") then
self.side = "mine"
elseif self.confirmed == nil and input:wasPressed("b") then
-- once confirm=true has been sent to the peer, backing out here
-- would desync the two sides (the peer may already be committing
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
self.net:send({ type = "bye" })
self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
if self.side == "theirs" then
self:openStats((t.theirParty or {})[self.theirIndex])
else
self.pickChoice = 1
end
elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then
@@ -738,25 +793,40 @@ function LinkState:draw()
local label = (mon.nickname or def.name):sub(1, 8)
if not t:canPick(i) then label = label .. "X" end
Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
if i == self.index and self.side ~= "theirs" then
Font.drawCode(CURSOR, 8, 20 + i * 12)
end
end
Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
if self.side == "theirs" and i == self.theirIndex then
Font.drawCode(CURSOR, 84, 20 + i * 12)
elseif t.theirPick == i then
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
end
end
local hint
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
if self.pickChoice then
Font.draw(Strings("STATS"), 16, 128)
Font.draw(Strings("TRADE"), 96, 128)
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
else
local hint
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
if self.side == "theirs" then hint = Strings("A: stats")
else
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
end
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE")
+3 -19
View File
@@ -16,6 +16,7 @@
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local unpack = table.unpack or unpack
local loadstring = loadstring or load
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox
-- a relative path that cannot climb out of the root it is joined to
local function safeRelative(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
for segment in rel:gmatch("[^/]+") do
if segment == ".." or segment == "." then return nil end
end
return rel
end
local function requireRelative(rel, what)
local safe = safeRelative(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
local requireRelative = SafePath.require
-- ------- the restricted context
+334 -19
View File
@@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
local discover -- forward declaration for helper functions above line 343
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
@@ -85,26 +87,190 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
-- installed and on, but not for THIS game: the loader skips the
-- dependency and the skip is contagious (Loader:_enforceDependencies),
-- so a mod that runs everywhere still does not run here
elseif version
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
return "warn", "Needs " .. spec.id .. " (not for "
.. ModTargets.gameLabel(version) .. ")"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
if ModTargets.specApplies(spec, version) then
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
-- installed and on, but not for THIS game: the loader skips the
-- dependency and the skip is contagious (Loader:_enforceDependencies),
-- so a mod that runs everywhere still does not run here
elseif version
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
return "warn", "Needs " .. spec.id .. " (not for "
.. ModTargets.gameLabel(version) .. ")"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
end
return "ok", "Ready"
end
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
if not depId or depId == "" then return nil end
-- 1. Check spec hint if parentManifest dependencySpecs carries it
if parentManifest and parentManifest.dependencySpecs then
for _, spec in ipairs(parentManifest.dependencySpecs) do
if spec.id == depId and spec.github then
return spec.github
end
end
end
-- 2. Check parentManifest raw dependency_sources
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
local src = parentManifest.raw.dependency_sources[depId]
if src then
local ok, clean = pcall(Manifest.parseGithub, src)
if ok and clean then return clean end
end
end
-- 3. Check installed dependency manifest
if installedDep and installedDep.github then
return installedDep.github
end
-- 4. Check ModIndex entries if available
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
for _, src in ipairs(ModIndex.sources() or {}) do
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
if cached and type(cached.index) == "table" then
for _, entry in ipairs(cached.index) do
if type(entry) == "table" and entry.id == depId and entry.github then
local ok, clean = pcall(Manifest.parseGithub, entry.github)
if ok and clean then return clean end
end
end
end
end
end
return nil
end
-- Inspect manifest dependencies and conflicts against installed mods.
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
if not manifest then
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
end
local SaveData = require("src.core.SaveData")
local manifests = installedManifests or discover()
local installedMap = {}
for _, m in ipairs(manifests) do
installedMap[m.id] = m
end
local depsResult = {}
local hasIssues = false
-- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do
if not version or ModTargets.specApplies(spec, version) then
local depId = spec.id
local range = spec.range
local installedDep = installedMap[depId]
local status = "satisfied"
local installedVersion = installedDep and installedDep.version or nil
if not installedDep then
status = "missing"
hasIssues = true
elseif range and not Semver.satisfies(installedDep.version, range) then
status = "incompatible"
hasIssues = true
end
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
depsResult[#depsResult + 1] = {
id = depId,
name = (installedDep and installedDep.name) or depId,
range = range,
status = status,
kind = "dependency",
installedVersion = installedVersion,
github = ghRepo,
safeUrl = safeUrl,
}
end
end
end
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
local conflictIdsSeen = {}
local scope = SaveData.modScope and SaveData.modScope(version) or version
local isEnabled = function(modId)
if not options then return true end
local dec = SaveData.modEnabled(options, modId, scope)
if dec ~= nil then return dec == true end
local m = installedMap[modId]
return m and not m.experimental
end
-- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id
local installedOther = installedMap[conflictId]
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = conflictId,
name = installedOther.name or conflictId,
status = "conflict",
kind = "conflict",
installedVersion = installedOther.version or "?",
github = nil,
safeUrl = nil,
}
end
end
end
-- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then
for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do
if spec.id == manifest.id then
conflictIdsSeen[other.id] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = other.id,
name = other.name or other.id,
status = "conflict",
kind = "conflict",
installedVersion = other.version or "?",
github = nil,
safeUrl = nil,
}
break
end
end
end
end
end
return {
hasIssues = hasIssues,
targetMod = {
id = manifest.id,
name = manifest.name or manifest.id,
version = manifest.version or "?",
},
deps = depsResult,
}
end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
@@ -165,6 +331,8 @@ function LauncherMods.deriveList(manifests, options, version)
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
dependencySpecs = m.dependencySpecs,
manifest = m,
-- what game this mod is for, and whether it will run on the one the
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
@@ -252,7 +420,7 @@ end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
discover = function()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
@@ -386,6 +554,7 @@ function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -409,6 +578,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
end
end
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -674,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
return result, res2, res3
end
function LauncherMods._installZipInner(source, opts)
@@ -786,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
return true, manifest.id, manifest
end
-- Install (or replace) a mod from a GitHub release zip URL.
@@ -912,4 +1082,149 @@ function LauncherMods.uninstall(id)
return true
end
-- ----------------------------------------------------------- Mod Profiles (#593)
local ModProfile = require("src.mods.ModProfile")
function LauncherMods.getProfiles(options)
options = options or SaveData.loadOptions()
local manifests = discover()
ModProfile.ensureFirst(options, manifests, options.modOptions)
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
if p.name == profileName then targetProfile = p; break end
end
if not targetProfile then return false end
ModProfile.restoreVersions(targetProfile, options)
options.activeProfile = profileName
SaveData.saveOptions(options)
return true
end
function LauncherMods.saveProfile(profileName, options)
options = options or SaveData.loadOptions()
local manifests = discover()
options.modProfiles = options.modProfiles or {}
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = profileName
local existingIdx
for i, p in ipairs(options.modProfiles) do
if p.name == profileName then existingIdx = i; break end
end
if existingIdx then
options.modProfiles[existingIdx] = snap
else
options.modProfiles[#options.modProfiles + 1] = snap
end
options.activeProfile = profileName
SaveData.saveOptions(options)
return snap
end
local function copyTable(tbl)
if type(tbl) ~= "table" then return tbl end
local copy = {}
for k, v in pairs(tbl) do
copy[k] = type(v) == "table" and copyTable(v) or v
end
return copy
end
function LauncherMods.syncActiveProfile(options)
options = options or SaveData.loadOptions()
local activeName = options.activeProfile or "PROFILE 1"
local profiles = options.modProfiles or {}
local manifests = discover()
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = activeName
local found = false
for i, p in ipairs(profiles) do
if p.name == activeName then
profiles[i] = snap
found = true
break
end
end
if not found then
profiles[#profiles + 1] = snap
end
options.modProfiles = profiles
options.activeProfile = activeName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.duplicateProfile(sourceName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local sourceProfile
for _, p in ipairs(profiles) do
if p.name == sourceName then sourceProfile = p; break end
end
if not sourceProfile then return nil end
local baseName = sourceName .. " (Copy)"
local newName = baseName
local n = 1
local taken = {}
for _, p in ipairs(profiles) do taken[p.name] = true end
while taken[newName] do
n = n + 1
newName = sourceName .. " (" .. n .. ")"
end
local snap = {
name = newName,
enabled = copyTable(sourceProfile.enabled),
options = copyTable(sourceProfile.options),
slots = copyTable(sourceProfile.slots),
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
}
profiles[#profiles + 1] = snap
options.modProfiles = profiles
options.activeProfile = newName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.renameProfile(oldName, newName, options)
options = options or SaveData.loadOptions()
if not newName or newName == "" then return false end
local profiles = options.modProfiles or {}
for i, p in ipairs(profiles) do
if p.name == oldName then
p.name = newName
if options.activeProfile == oldName then
options.activeProfile = newName
end
SaveData.saveOptions(options)
return true
end
end
return false
end
function LauncherMods.deleteProfile(profileName, options)
options = options or SaveData.loadOptions()
options.modProfiles = options.modProfiles or {}
local newProfiles = {}
for _, p in ipairs(options.modProfiles) do
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
end
options.modProfiles = newProfiles
if options.activeProfile == profileName then
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
LauncherMods.applyProfile(fallback, options)
else
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+151 -62
View File
@@ -12,12 +12,15 @@ local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge")
local ModTargets = require("src.mods.ModTargets")
local Registry = require("src.mods.Registry")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local Schemas = require("src.mods.Schemas")
local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Loader = {}
Loader.__index = Loader
@@ -72,10 +75,12 @@ local function orderedIds(mods, filter)
return ids
end
-- ------- dev-mode permissions tripwire
-- Attribution only: the shim delegates unconditionally and blocks nothing.
-- Installed once per process and only when the loader runs in dev mode, so a
-- player build has zero interposition.
-- ------- the require gate
-- Two jobs in one interposition. The engine_internals/network scan is
-- attribution only and stays dev-mode: it warns and delegates. The
-- Sandbox.moduleDenial check is not -- require("io") would hand back
-- package.loaded.io and undo the whole mod environment -- so it is installed
-- in player builds too, for any boot that has mods on it.
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
@@ -135,7 +140,8 @@ end
local function scanRequire(name)
local modId = Runtime.currentMod
if not modId or type(name) ~= "string" then return end
if type(modId) ~= "string" then modId = Runtime.modRequire end
if type(modId) ~= "string" or type(name) ~= "string" then return end
local granted = devShim.permissions[modId] or {}
local function warnOnce(permission)
local key = modId .. "|" .. permission .. "|" .. name
@@ -188,6 +194,7 @@ function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet
end
devShim.dev = self.dev
if devShim.installed then return end
devShim.installed = true
local delegate = require
@@ -195,12 +202,22 @@ function Loader:_installDevShim()
-- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up
if devShim.depth == 0 then
scanRequire(name)
-- Backstop for the deny list Sandbox.envFor's require already applies:
-- an engine module requiring io is the engine wiring itself up, a mod
-- doing it is the hole this closes, and any future path that runs mod
-- code without a sandbox env still lands here.
local owner = Runtime.currentMod or Runtime.modRequire
if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name)
and callerIsMod(3) then
and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then
local key = "adapter|" .. name
@@ -235,7 +252,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {},
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -357,11 +374,13 @@ function Loader:_writeOptionSchemas()
-- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then schema = rows end
end
local ok, rows = pcall(function()
local path = SafePath.join(mod.path, mod.manifest.options_schema,
"options_schema")
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
return chunk and chunk()
end)
if ok and type(rows) == "table" then schema = rows end
end
if schema ~= nil then
mods[id] = schema
@@ -561,42 +580,46 @@ end
-- hard dependencies must exist, be enabled, have survived, and satisfy their
-- range; run to a fixpoint so failures propagate to dependents transitively
function Loader:_enforceDependencies()
local targetVersion = self:_targetVersion()
local generation = self.generation
local changed = true
while changed do
changed = false
for _, id in ipairs(orderedIds(self.mods, isActive)) do
local mod = self.mods[id]
for _, spec in ipairs(mod.manifest.dependencySpecs) do
local dep = self.mods[spec.id]
local reason, skip
if not dep then
reason = "missing dependency: " .. spec.id
elseif not dep.enabled then
reason = ("dependency %s is disabled"):format(spec.id)
elseif dep.state == "wrong_generation" then
-- the gate's skip is contagious as a SKIP, not as a failure: the
-- dependency has no bug to report and neither does this mod, so
-- nothing here lands on the boot error list
skip = true
-- carry the dependency's own reason: it names the game or the
-- missing gen2compat, and a guess here would name the wrong one
reason = ("depends on %s, which does not run here (%s)")
:format(spec.id, dep.skipReason or "not made for this game")
elseif dep.failed then
reason = ("dependency %s failed to load"):format(spec.id)
elseif spec.range
and not Semver.satisfies(dep.manifest.version, spec.range) then
reason = ("needs %s@%s, found %s")
:format(spec.id, spec.range, dep.manifest.version)
end
if reason then
if skip then
self:_skip(mod, "wrong_generation", reason)
else
self:_fail(mod, "blocked_dependency", reason)
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = self.mods[spec.id]
local reason, skip
if not dep then
reason = "missing dependency: " .. spec.id
elseif not dep.enabled then
reason = ("dependency %s is disabled"):format(spec.id)
elseif dep.state == "wrong_generation" then
-- the gate's skip is contagious as a SKIP, not as a failure: the
-- dependency has no bug to report and neither does this mod, so
-- nothing here lands on the boot error list
skip = true
-- carry the dependency's own reason: it names the game or the
-- missing gen2compat, and a guess here would name the wrong one
reason = ("depends on %s, which does not run here (%s)")
:format(spec.id, dep.skipReason or "not made for this game")
elseif dep.failed then
reason = ("dependency %s failed to load"):format(spec.id)
elseif spec.range
and not Semver.satisfies(dep.manifest.version, spec.range) then
reason = ("needs %s@%s, found %s")
:format(spec.id, spec.range, dep.manifest.version)
end
if reason then
if skip then
self:_skip(mod, "wrong_generation", reason)
else
self:_fail(mod, "blocked_dependency", reason)
end
changed = true
break
end
changed = true
break
end
end
end
@@ -606,6 +629,8 @@ end
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
-- fail, so an unrelated mod beside a cycle still loads
function Loader:_failCycles()
local targetVersion = self:_targetVersion()
local generation = self.generation
local mods = self.mods
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
local cycles = {}
@@ -616,14 +641,16 @@ function Loader:_failCycles()
onStack[id] = true
local selfEdge = false
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
local dep = mods[spec.id]
if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then
if not index[spec.id] then
connect(spec.id)
if low[spec.id] < low[id] then low[id] = low[spec.id] end
elseif onStack[spec.id] and index[spec.id] < low[id] then
low[id] = index[spec.id]
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = mods[spec.id]
if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then
if not index[spec.id] then
connect(spec.id)
if low[spec.id] < low[id] then low[id] = low[spec.id] end
elseif onStack[spec.id] and index[spec.id] < low[id] then
low[id] = index[spec.id]
end
end
end
end
@@ -674,6 +701,8 @@ end
-- Kahn over the surviving graph with the ready set kept in (priority, id)
-- order, so dependencies come first and the rest matches the v1 contract
function Loader:_order()
local targetVersion = self:_targetVersion()
local generation = self.generation
local pending, indegree, dependents = {}, {}, {}
for _, id in ipairs(orderedIds(self.mods, isActive)) do
pending[id], indegree[id] = true, 0
@@ -686,9 +715,17 @@ function Loader:_order()
dependents[depId][#dependents[depId] + 1] = id
indegree[id] = indegree[id] + 1
end
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
for _, spec in ipairs(manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
-- optional dependencies order without requiring anything
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
for _, spec in ipairs(manifest.optionalSpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
end
local ordered = {}
local function nextId()
@@ -980,6 +1017,40 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp)
end,
},
-- The read-only part of love.system that device UIs legitimately need.
-- Do not expose the module: openURL and clipboard access stay sandboxed.
device = {
powerInfo = function()
local getPowerInfo = love and love.system and love.system.getPowerInfo
if not getPowerInfo then return "unknown", nil end
local state, percent = getPowerInfo()
return state, percent
end,
},
-- The native step bridge (#1186), behind the "steps" permission the
-- player sees in the mod manager: sync asks the platform to refresh
-- its count, poll hands this mod its copy of what the bridge
-- delivered. The engine owns the pending file -- a mod never names a
-- path, it only receives { steps, from, to }. available() answers
-- false without the permission (a probe stays quiet); the calls that
-- would do something name the missing permission instead, the way the
-- network gate does.
steps = (function()
if mod.manifest.permissionSet.steps then
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
return {
available = function() return Steps.available() end,
sync = function() return Steps.sync() end,
poll = function() return Steps.poll(loader, modId) end,
}
end
local function refuse()
error(('[%s] mod.steps needs the "steps" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -1090,9 +1161,11 @@ function Loader:_api(mod)
-- assets keeps the v1 alias to the content accessors and adds the file
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
api.assets = setmetatable({
path = function(_, relative) return mod.path .. "/" .. relative end,
path = function(_, relative)
return SafePath.join(mod.path, relative, "mod.assets:path")
end,
image = function(_, relative)
local full = mod.path .. "/" .. relative
local full = SafePath.join(mod.path, relative, "mod.assets:image")
local cached = loader.imageCache[full]
if cached then return cached end
assert(love and love.graphics,
@@ -1102,9 +1175,10 @@ function Loader:_api(mod)
return image
end,
}, { __index = api.content })
-- the mod's own directory and nothing above it: PhysFS already refuses a
-- climb, but loader.fs is injectable and has no such floor
function api:read(relative)
local path = self.path .. "/" .. relative
return loader.fs.read(path)
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
end
-- mod.world materializes on first touch, like the image helper above: a
-- headless load must not drag the world stack in, and the Game the facade
@@ -1148,9 +1222,21 @@ function Loader:_game()
return engineRequire("src.core.Game")
end
-- The environment every chunk this mod authors runs in, built once per mod so
-- its entry file and its options_schema share one globals table.
function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
self.modEnv[id] = env
end
return env
end
function Loader:_loadMod(mod)
local path = mod.path .. "/" .. mod.manifest.entry
local chunk, err = self.fs.load(path)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
if not chunk then error(err or ("unable to load " .. path)) end
local api = self:_api(mod)
local result = chunk(api)
@@ -1182,6 +1268,7 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
end
-- a mod that explicitly swears it stays link-compatible while writing into a
@@ -1365,10 +1452,12 @@ function Loader:load(data)
-- every touch: a mod captures the facade at file scope, before Game2 has a
-- save or a world (src/mods/Gen2Compat.lua).
Gen2Compat.bind(function() return self:_game() end)
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
-- the Gen 1-only require report, which is the difference between "the mod
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
-- Any boot with mods on it needs the gate, because require("io") is how a
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
-- difference between "the mod does nothing" and knowing why. A boot with no
-- mods pays nothing.
if self.dev or next(self.mods) ~= nil then
self:_installDevShim()
end
for _, mod in ipairs(ordered) do
+14 -7
View File
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local ModTargets = require("src.mods.ModTargets")
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
@@ -935,13 +937,18 @@ function ManagerState:schemaFor(m)
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
if schema == nil and m.options_schema and m.path
and loader.fs and loader.fs.load then
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end
-- mod-authored code, so it runs in the same sandbox the entry chunk does
local ok, rows = pcall(function()
local path = SafePath.join(m.path, m.options_schema, "options_schema")
local mod = loader.mods and loader.mods[m.id]
local env = mod and loader._modEnv and loader:_modEnv(mod)
or Sandbox.envFor()
local chunk = Sandbox.loadFile(loader.fs, path, env)
return chunk and chunk()
end)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end
end
return schema
+80 -26
View File
@@ -3,13 +3,15 @@
-- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger")
local ModTargets = require("src.mods.ModTargets")
local SafePath = require("src.mods.SafePath")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
Logger.warn("[%s] %s", tostring(id), message)
end
-- "id" or "id@<range>"; a malformed id or range fails for every api level
-- because there is no sane fallback reading for it
local function parseSpecs(list, field)
local specs = {}
for _, entry in ipairs(list) do
assert(type(entry) == "string" and entry ~= "",
field .. " entries must be non-empty strings")
local id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
specs[#specs + 1] = { id = id, range = range }
end
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
return owner .. "/" .. repo
end
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
local function parseSpecs(list, field, sources)
local specs = {}
sources = type(sources) == "table" and sources or {}
for _, entry in ipairs(list) do
local id, range, ghHint, gamesRaw, gameVersion
if type(entry) == "table" then
id = entry.id
range = entry.range or entry.version
ghHint = entry.github or entry.repo
gamesRaw = entry.games or entry.game
gameVersion = entry.game_version
elseif type(entry) == "string" and entry ~= "" then
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
if main then
entry = main
ghHint = hashRepo
end
id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
else
error(field .. " entries must be non-empty strings or tables")
end
assert(id, ("malformed %s entry"):format(field))
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
if gameVersion then
local okV, errV = Semver.validRange(gameVersion)
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
end
local parsedGames = nil
if gamesRaw ~= nil then
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
local normalized, unknown = ModTargets.normalize(gamesRaw)
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
parsedGames = normalized
end
local parsedGh = nil
local rawGh = ghHint or sources[id]
if rawGh then
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
if okGh and cleanGh then parsedGh = cleanGh end
end
specs[#specs + 1] = {
id = id,
range = range,
github = parsedGh,
games = parsedGames,
game_version = gameVersion,
}
end
return specs
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
@@ -144,6 +189,9 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
-- every manifest path is joined to the mod's own directory, so none of them
-- may climb out of it (src/mods/SafePath.lua)
local entry = SafePath.require(raw.entry, "manifest entry")
-- absent means 1: full v1 compat, schema violations downgrade to warnings
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
@@ -229,27 +277,32 @@ function Manifest.validate(raw, path)
local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field)
local function optionalString(value, field)
if value == nil then return nil end
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value
end
local function optionalFile(value, field)
local text = optionalString(value, field)
return text and SafePath.require(text, field)
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return {
id = raw.id,
name = raw.name,
version = raw.version,
entry = raw.entry,
entry = entry,
api = api,
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
@@ -265,7 +318,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
-- an env var name, not a path, so it keeps the plain string check
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
path = path,
raw = raw,
}
+9 -3
View File
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
if type(k) == "number" then
if type(v) == "table" and type(v.id) == "string" then
fn(v.id, v.range or v.version, v.github or v.repo)
elseif type(v) == "string" then
local main, hashRepo = v:match("^([^#]+)#(.*)$")
if main then v = main end
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range, hashRepo)
end
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
+35
View File
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
ModTargets.gameLabel(version))
end
-- Does a dependency spec apply to this game / version?
-- If spec.games is provided, it must match version / generation.
-- If spec.game_version is provided, the engine version must satisfy it.
function ModTargets.specApplies(spec, version, generation)
if not spec then return false end
if type(spec.games) == "table" and #spec.games > 0 then
if version or generation then
local match = false
for _, id in ipairs(spec.games) do
if version and id == version then
match = true
break
end
if generation and GameVersion.generation(id) == generation then
match = true
break
end
end
if not match then return false end
end
end
if spec.game_version then
local ok = pcall(function()
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
return Semver.satisfies(Version.engine, spec.game_version)
end
return true
end)
if not ok then return false end
end
return true
end
return ModTargets
+5
View File
@@ -28,6 +28,11 @@ Runtime.errors = nil
-- permissions tripwire knows there is nobody to attribute to
Runtime.currentMod = nil
-- set by the sandbox's require for the duration of one mod-initiated require,
-- so the loader's gate can still attribute a lazy one made long after
-- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil
function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
+38
View File
@@ -0,0 +1,38 @@
-- One relative-path grammar for every path a mod supplies: a mod names files
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
-- injected fs that has no such floor, so the rule lives here and not in
-- whichever filesystem happens to be underneath.
local SafePath = {}
-- The normalized path, or nil when it could climb out of the root it is about
-- to be joined to. "." segments are dropped rather than rejected so a
-- manifest that says "./main.lua" still loads.
function SafePath.safe(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
if rel:match("^%a:") then return nil end -- windows drive-relative
local parts = {}
for segment in rel:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then parts[#parts + 1] = segment end
end
if #parts == 0 then return nil end
return table.concat(parts, "/")
end
function SafePath.require(rel, what)
local safe = SafePath.safe(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- root .. "/" .. rel, with the traversal check in between
function SafePath.join(root, rel, what)
return root .. "/" .. SafePath.require(rel, what or "path")
end
return SafePath
+228
View File
@@ -0,0 +1,228 @@
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
-- entry file, an options_schema, anything it hands to load() -- runs against
-- this table instead of _G, so the only paths it can name are the ones the
-- engine hands it (mod:read, mod.storage, mod.assets).
--
-- What this is and is not: raw io/os/ffi are the only way to name a file
-- outside the game tree at all, and they are absent here, so the reported
-- "any mod can rewrite anything in your home directory" hole closes by
-- construction. Inside the LÖVE tree this is defense in depth, not a security
-- boundary: an engine module reached through require, or ImageData:encode,
-- still writes in the save directory.
--
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
-- exists because AssetTransform's sandbox needed it and getting this wrong
-- silently hands the chunk the real globals.
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = {}
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
-- matter what this file removes from the environment. package.loaded.io is
-- the one call that would undo every other rule here.
local DENIED = {
io = "the filesystem", os = "the filesystem", debug = "the debug library",
package = "the module loader", ffi = "arbitrary C calls",
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
ssl = true, mime = true, ltn12 = true }
local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local root = head(name)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read and the engine API instead"):format(name, reason)
end
if DENIED_PREFIX[root] and name ~= root then
return ("%s is not available to mods; use mod.storage, mod:read and the "
.. "engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
end
return nil
end
-- ------- the love facade
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
-- (including another mod's storage), thread opens a Lua state with a full
-- standard library, system.openURL launches whatever it is handed, and event
-- lets a mod quit the game out from under the player. Everything else LÖVE
-- exposes passes through, so a new module in a future LÖVE is available
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage and mod:read", thread = true,
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
__index = function(_, key)
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
type(hint) == "string" and (", use " .. hint) or ""), 2)
end
return _G.love[key]
end,
__newindex = function(_, key)
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
-- newproxy, module. os keeps only the clock -- getenv is how the reported
-- exploit found the user's home directory.
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
-- replaces table.insert changes its own view and nobody else's. The functions
-- are the same objects, so state behind them (math.randomseed's RNG) is
-- unaffected -- only the namespace is private.
local function copy(source)
if type(source) ~= "table" then return source end
local out = {}
for key, value in pairs(source) do out[key] = value end
return out
end
local function baseGlobals()
local safeOs = {}
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
return {
assert = assert, error = error, ipairs = ipairs, next = next,
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
collectgarbage = collectgarbage, _VERSION = _VERSION,
coroutine = copy(coroutine), math = copy(math), string = copy(string),
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
}
end
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
-- a caller there compiles through Sandbox.compile instead.
function Sandbox.bind(chunk, env)
if setfenv then setfenv(chunk, env) end
return chunk
end
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
-- of environments. Mods ship source.
local function rejectBytecode(source, what)
if type(source) == "string" and source:sub(1, 1) == "\27" then
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
end
return true
end
function Sandbox.compile(source, chunkname, env)
local ok, err = rejectBytecode(source, chunkname)
if not ok then return nil, err end
if setfenv then
local chunk, compileErr = loadstring(source, chunkname)
if not chunk then return nil, compileErr end
return setfenv(chunk, env)
end
return load(source, chunkname, "t", env)
end
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
-- rather than the caller's, so without this every sandboxed mod is one
-- load(mod:read(...)) away from the real _G -- which is exactly how the
-- multi-file mods in mods/ are written.
local function sandboxedLoad(env)
return function(chunk, chunkname)
if type(chunk) == "function" then
local parts = {}
while true do
local piece = chunk()
if piece == nil or piece == "" then break end
parts[#parts + 1] = piece
end
chunk = table.concat(parts)
end
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
return Sandbox.compile(chunk, chunkname or "=(load)", env)
end
end
-- The require a mod sees: the deny list lives here rather than on a stack
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
-- Runtime.modRequire is how the loader's gate identifies the caller for the
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet)
return function(name, ...)
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
Runtime.modRequire = modId or true
local ok, result = pcall(_G.require, name, ...)
Runtime.modRequire = previous
if not ok then error(result, 0) end
return result
end
end
function Sandbox.envFor(opts)
opts = opts or {}
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
local loader = sandboxedLoad(env)
env.load = loader
env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's
env._G = env
return env
end
-- fs.load keeps the real filesystem's handling of the file; the environment is
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
-- only reason fs.read is touched here.
function Sandbox.loadFile(fs, path, env)
if fs.read then
local ok, err = rejectBytecode(fs.read(path), path)
if not ok then return nil, err end
end
if setfenv then
local chunk, err = fs.load(path)
if not chunk then return nil, err end
return setfenv(chunk, env)
end
local source = fs.read and fs.read(path)
if not source then return nil, "unable to read " .. path end
return Sandbox.compile(source, "@" .. path, env)
end
Sandbox.safePath = SafePath.safe
Sandbox.requirePath = SafePath.require
return Sandbox
+84
View File
@@ -0,0 +1,84 @@
-- The scoped seam for the native step bridge (#1186).
--
-- The iOS/Android builds count the player's real-world steps natively
-- (#452, #489) and deliver them by writing steps_pending.json into the
-- save-directory root. Before the sandbox, the Pokéwalker mod called
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
-- blocks both, which is correct -- love.system launches URLs and the file
-- API names paths -- but it left the bridge with no consumer at all.
--
-- This module is the narrow replacement, gated by the "steps" permission
-- in manifest.json (the network model: a permission the player sees that
-- genuinely gates a capability). The engine owns the file: mods never
-- learn its name or location, they receive only the three contract fields
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
-- merge-don't-overwrite anchor semantics stay on the native side where
-- they always lived.
--
-- No frame pump: the file is looked for lazily when a mod polls, so a
-- build with no permissioned mod installed never touches the bridge or
-- the disk.
local Json = require("src.link.Json")
local Steps = {}
-- The native contract's drop point, in the save-directory root (see
-- mobile/ios and mobile/android step bridges).
Steps.PENDING = "steps_pending.json"
local function bridge()
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
end
-- Whether this build carries the native bridge. Desktop builds do not;
-- a mod uses this to stay dormant without probing love.system.
function Steps.available()
return bridge() ~= nil
end
-- Ask the native side to refresh its count. Async: the result lands in
-- the pending file and comes back through a later poll. The platform's
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
-- first use, exactly as it did pre-sandbox. false when there is no
-- bridge to ask.
function Steps.sync()
local fn = bridge()
if not fn then return false end
fn()
return true
end
-- Consume the pending file, if one has appeared, and fan its payload out
-- to every permissioned mod's queue. Only the contract fields travel;
-- anything else in the file stays in the file's grave. A malformed or
-- empty delivery is dropped whole -- the native anchor only advances on a
-- successful sync, so nothing is lost to a bad write.
function Steps.pump(loader)
local fs = _G.love and _G.love.filesystem
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
local raw = fs.read(Steps.PENDING)
fs.remove(Steps.PENDING)
if not raw then return end
local ok, decoded = pcall(Json.decode, raw)
if not ok or type(decoded) ~= "table" then return end
local steps = tonumber(decoded.steps)
if not steps or steps <= 0 then return end
local payload = { steps = steps, from = decoded.from, to = decoded.to }
for _, queue in pairs(loader.stepsQueues) do
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
to = payload.to }
end
end
-- The next delivery for this mod, or nil. Each permissioned mod consumes
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
-- read the file first won).
function Steps.poll(loader, modId)
Steps.pump(loader)
local queue = loader.stepsQueues[modId]
if not queue then return nil end
return table.remove(queue, 1)
end
return Steps
+4 -2
View File
@@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
require("src.core.Sound").play(game.data, "Get_Item1")
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep))
"%s learned\n%s!", name, mdef.name), nextStep,
TextBox.soundOpts(game, "Get_Item1")))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
+9 -2
View File
@@ -141,13 +141,20 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
--
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
-- caller that is animating the bar between two HP values
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
-- thresholds do. Without it the length comes from mon.hp as before.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
if pixels then
px = math.max(0, math.floor(pixels * segments / 6))
elseif mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end
local tint
+6 -4
View File
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
return PaletteFX.darkObp(w.spritePalettes[group], group)
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
function PaletteFX.barPalName(hp, maxHp)
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
if hp > 0 and px < 1 then px = 1 end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
-- rather than let it be re-derived from hp.
function PaletteFX.barPalName(hp, maxHp, pixels)
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
if not pixels and hp > 0 and px < 1 then px = 1 end
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
end
+25 -13
View File
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
-- Post-process pipelines, GBC FX 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 present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
-- 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
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
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
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
local outputHandled = hasOutputHook
and Runtime.call("render.output", function() return false end, {
canvas = composed,
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
generation = 1,
}) == true
if not outputHandled then
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
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
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
end
self.worldActive = false
+20
View File
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
return self
end
-- soundOpts: a jingle carried at the end of the string as a trailing text
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
-- which runs PlaySound then WaitForSoundToFinish once the last page has
-- typed, so the box holds until the fanfare is over. Merges into a caller's
-- opts table; auto.wait keeps the trailing button press the plain A/B path
-- gives every other box.
function TextBox.soundOpts(game, sound, opts)
opts = opts or {}
local auto = opts.auto
-- auto = true is the no-button-wait arming (text_opts); keep that choice
if auto == true then auto = { wait = false } end
auto = auto or {}
if auto.wait == nil then auto.wait = true end
auto.delay = auto.delay or 0
auto.sound = type(sound) == "function" and sound
or function() return require("src.core.Sound").play(game.data, sound) end
opts.auto = auto
return opts
end
-- The runtime tokens substitute() knows, as handlers the tokens registry
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
+15 -2
View File
@@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
-- has typed and the box holds on WaitForSoundToFinish before the button
-- wait. play_sound stays the bare PlaySound used for the non-blocking
-- beats (Bill's teleporter, the S.S. Anne horn).
function Commands.text_sound(ctx, soundId)
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
@@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
onDone = function() runner:resume() end,
})
runner:yield()
-- TradedForText (sound_get_key_item) then the dialogset's thanks
require("src.core.Sound").play(data, "Get_Key_Item")
-- TradedForText carries sound_get_key_item after the text, so the jingle
-- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
-- dialogset's thanks
Commands.text_sound(ctx, "Get_Key_Item")
say(texts.tradedFor or "_TradedForText")
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
end
+7 -6
View File
@@ -33,12 +33,12 @@ local function save_name(game)
return game.save.player.name
end
local function showMessages(game, msgs, onDone)
local function showMessages(game, msgs, onDone, opts)
if not msgs or #msgs == 0 then
if onDone then onDone() end
return
end
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end
-- run the use-flow for an item on a chosen target. `picker` is the party
@@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
-- 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) })
game.data.pokemon[target.species].name, mdef.name) }, nil,
TextBox.soundOpts(game, "Get_Item1"))
if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
@@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
nextStep, TextBox.soundOpts(game, "Get_Item1"))
else
require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep)
+22 -8
View File
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
local ANIM_LOOP_FRAMES = 288
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
-- which pic is on screen t frames into .animLoop
local function evoShowsNew(t)
for b = 1, 8 do
local hold = 18 - 2 * b
if t < hold then return false end
t = t - hold
local swap = b * 6
if t < swap then return t % 6 < 3 end
t = t - swap
end
return true
end
local function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path(
@@ -140,14 +158,10 @@ function EvolutionState:draw()
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2)
+16 -3
View File
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local IntroMovie = {}
IntroMovie.__index = IntroMovie
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
self:finish()
return
end
-- an op that consumed frames ends ON its last frame; only the instant
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
-- calls in PlayIntroScene) chain into the next op the same frame
local timed = false
if op.sfx then
Sound.play(self.game.data, op.sfx)
elseif op.pose then
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end
timed = true
elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end
timed = true
elseif op.wait then
self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end
timed = true
elseif op.fade then
self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
end
self.opIndex = self.opIndex + 1
self.opTimer = 0
if timed then return end
end
end
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
self:fightStep()
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
if self.timer > Timing.DELAY3 then self:fightStep() end
end
end
+88 -35
View File
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
local function refuseBadge(self)
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -411,6 +420,23 @@ function PartyMenu:update(dt)
-- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
-- OVERWORLD alone, #83); both refusals loop back to the submenu
if ow and not ow:partyKnows("FLY") then
refuseBadge(self)
return
end
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotFlyHereText
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop() -- close the party menu
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end
@@ -423,9 +449,17 @@ function PartyMenu:update(dt)
-- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385).
local ow = self.game.overworld
if ow and not ow:partyKnows("FLASH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
self.game.save.flashLit = true
-- .flash prints on any map, but the light it records is this map's:
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
-- a FLASH used in daylight must not carry into Rock Tunnel
local wasDark = ow and ow.dark
if wasDark then self.game.save.flashLit = true end
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
@@ -440,13 +474,13 @@ function PartyMenu:update(dt)
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
ow:setDark(false)
if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else
@@ -503,8 +537,8 @@ function PartyMenu:update(dt)
return -- .loop: submenu stays open behind the message
elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
-- submenu when the FACING tile isn't a cuttable tree.
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
-- to the submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld
local reason = ow:useCutFieldMove()
if reason == "ok" then
@@ -522,7 +556,7 @@ function PartyMenu:update(dt)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
elseif action == "strength" then
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
-- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
@@ -532,6 +566,10 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map.
local ow = self.game.overworld
if ow and not ow:partyKnows("STRENGTH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
@@ -565,6 +603,33 @@ function PartyMenu:update(dt)
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld
if entry.move == "TELEPORT" then
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotUseTeleportNowText
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM") then
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
-- bagged ESCAPE ROPE
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._ItemUseNotTimeText
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
self.game.save.player.name)))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop()
if ow then ow:beginTeleportOut() end
return
@@ -658,42 +723,30 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
-- four moves against FieldMoveDisplayData and nothing else -- no
-- badge, no map, no tileset test. Every one of those lives in
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
-- prints and .loop returns to this still-open submenu (#1022).
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
if mv.id == "FLY" then
table.insert(items, { label = Strings("FLY"), action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
elseif mv.id == "FLASH" then
table.insert(items, { label = Strings("FLASH"), action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
elseif mv.id == "CUT" then
table.insert(items, { label = Strings("CUT"), action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
elseif mv.id == "SURF" then
table.insert(items, { label = Strings("SURF"), action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
elseif mv.id == "STRENGTH" then
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = Strings("DIG"), action = "escape" })
elseif mv.id == "TELEPORT" then
table.insert(items, { label = Strings("TELEPORT"),
action = "escape", move = "TELEPORT" })
elseif mv.id == "DIG" then
table.insert(items, { label = Strings("DIG"),
action = "escape", move = "DIG" })
end
end
end
+21 -2
View File
@@ -173,6 +173,18 @@ end
function BattleState:wantsFillScale() return true end
function BattleState:drawsWidescreen() return true 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,
self) ~= false
end
function BattleState:statusHUDVisible()
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
return Runtime.call("battle.status_hud_visible", function() return true end,
self) ~= false
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
@@ -3079,6 +3091,7 @@ end
function BattleState:drawHud()
local wasBattle = Font.useBattleExtra(true)
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
local showStatus = self:statusHUDVisible()
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
-- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1);
@@ -3087,7 +3100,7 @@ function BattleState:drawHud()
-- runs, so a shake or a slide does not drag the HP bar with it.
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564).
if self.showEnemyHud and not self:hudCleared("enemy") then
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
Chrome.print(self:name(enemy), 1, 0)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
-- the digits after it, so the glyph is pinned to column 6 whether the level
@@ -3127,7 +3140,8 @@ function BattleState:drawHud()
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
-- half of the screen is his back-pic and nothing more.
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
if not player or not self.showPlayerHud or self:hudCleared("player") then
if not showStatus or not player or not self.showPlayerHud
or self:hudCleared("player") then
Font.useBattleExtra(wasBattle)
return
end
@@ -3226,6 +3240,11 @@ function BattleState:drawPanel()
end
self:drawHud()
if not self:bottomUIVisible() then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- Message box across the bottom, with the menu window over its right half --
-- the cart draws the prompt into the full-width box and then opens the menu
-- on top, so the tail of a long name is simply covered.
+5
View File
@@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count)
end
-- ScrollingMenu's .a_button copies the row's own quantity into
-- wItemQuantity, so the selector's ceiling is how many you hold.
-- The unit price is the item's own ItemAttributes price: GetItemPrice is
-- what SelectQuantityToSell halves AND what the buy list's
-- GetMartItemPrice charges, so a TM sells for exactly half of what the
-- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price
-- (usually ¥1,500) anywhere else (issue #1243).
self.qtyItem = { id = itemId, name = def.name or itemId,
price = def.price or 0 }
self.qty = 1
+15 -12
View File
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
end
return
end
-- engine/items/tmhm.asm:73
local def = self.items and self.items[row.id]
if def and def.teaches then
self:openTeachParty(row)
return
end
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
-- FIELD pack owns that refusal: the battle pack returned above, and the
-- catch tutorial's DUDE pack carries a stub world with no useFieldItem at
-- all -- its POKE BALL is field-NOUSE and must still reach the throw.
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
-- its row to onChoose like every other item instead of opening the teach
-- party. The battle pack returned above, and the catch tutorial's DUDE
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
-- is field-NOUSE and must still reach the throw.
if world and world.useFieldItem then
local def = self.items and self.items[row.id]
if def and def.teaches then
self:openTeachParty(row)
return
end
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
self.message = OAK_THIS_ISNT_THE_TIME
return
+128 -78
View File
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
Kit._navSeen = {}
Kit._navQueue = nil
Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
-- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id
return Kit._ringShown and Kit.focusId == id
end
function Kit.navigate(dir)
Kit._navQueue = dir
Kit._ringShown = true
end
function Kit.activateFocused()
@@ -396,6 +398,7 @@ end
function Kit.setFocus(id)
Kit.focusId = id
Kit._ringShown = id ~= nil
end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +533,10 @@ Kit._audit = audit
function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis)
Theme.card(x, y, w, h, emphasis)
function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, variant)
end
-- A list row. `id` opts it into the focus ring; pass nil for decorative
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
-- caller must print with the returned ink or it will draw white on white.
function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false
@@ -560,7 +560,7 @@ end
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
Theme.card(x, y, w, h, "empty")
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
@@ -598,61 +598,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
}
Kit.KINDS = KINDS
local NO_OPTS = {}
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
opts = opts or NO_OPTS
local enabled = opts.enabled ~= false
-- Disabled buttons audit too: they stay visible, so they still must not
-- paint over a neighbour.
audit("control", x, y, w, h, label)
local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
local hot = enabled and Kit.hover(x, y, w, h)
if G then
-- The fill IS the control: a rounded, embossed, colour-coded key. A
-- disabled button keeps its shape in a dead grey rather than
-- disappearing, so a layout never reflows on state.
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
if hot or focused then
-- White ring outside the fill: legible on green, blue, yellow, red and
-- white alike, which one darker/lighter shade per colour would not be.
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
elseif opts.glow and enabled then
-- "Something is waiting for you" (the update button): a pulsing ring.
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
-- mode change.
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
Theme.radius() + 2)
local face = opts.face or "fill"
local B = Theme.BUTTON
local radius = opts.radius or B.radius
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
if face == "invert" then
invert = hot
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
stroke = opts.stroke or PAL.line
strokeA = invert and Theme.A.focus or Theme.A.hairline
doRing = focused and not hot
elseif face == "tab" then
invert = active or focused or hot
local tint = opts.color or opts.fill or PAL.ink
fill = invert and tint or PAL.surface
ink = invert and PAL.inverse or (opts.color or PAL.text)
if not invert then
stroke = tint
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
end
local fname = opts.font or "button"
local ink = enabled and kind.ink or PAL.inverse
local ty = y + (h - Kit.textHeight(fname)) / 2
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
-- Button labels are bold: they are the shortest, most-scanned text on
-- screen and sit on a saturated fill.
if opts.align == "left" then
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
elseif face == "chip" then
local c = opts.color or PAL.line
invert = active and true or false
if active then
fill = c
ink = PAL.inverse
doEmboss = true
else
Kit.textCenterBold(fname, shown, x, ty, w, ink)
fill = PAL.bg
ink = c
stroke = c
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
end
doRing = focused or hot
else
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
fill = (enabled and opts.fill) or kind.fill
ink = (enabled and opts.ink) or kind.ink
doEmboss = true
doRing = hot or focused
if opts.glow and enabled and not doRing then
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
end
end
if opts.emboss ~= nil then doEmboss = opts.emboss end
if opts.ring ~= nil then doRing = opts.ring end
if G then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
or B.embossDisabled
Theme.emboss(x, y, w, h, es)
end
if strokeA then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end
if doRing then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad)
elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
glowA, B.ringWidth, radius + B.ringPad)
end
local fname = opts.font or ((face == "chip") and "micro" or "button")
local ty = y + (h - Kit.textHeight(fname)) / 2
local image = opts.image
local drawFn = opts.drawFn
local letter = opts.letter
local hasLabel = label and label ~= ""
local bold = opts.bold
if bold == nil then bold = face ~= "tab" end
if image then
local box = h
local boxX, boxY = x, y
if not hasLabel then
box = math.min(w, h)
boxX = x + (w - box) / 2
boxY = y + (h - box) / 2
end
local iw, ih = image:getDimensions()
local pad = math.floor(box * (opts.iconPad or B.iconPad))
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
if invert then Theme.col(PAL.inverse, 1)
else Theme.col(PAL.ink, B.iconRestA) end
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif drawFn then
drawFn(x, y, w, h, invert or hot or focused)
elseif letter then
Kit.textCenter(fname, letter, x, ty, h, ink)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif hasLabel then
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
if opts.align == "left" then
local lx = x + B.labelPad * Kit.scale
if bold then Kit.textBold(fname, shown, lx, ty, ink)
else Kit.text(fname, shown, lx, ty, ink) end
elseif bold then
Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end
end
if not enabled then return false end
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
and Kit._activateId == opts.id)
end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {}
opts.kind = opts.kind or "ghost"
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
local CHIP_OPTS = { face = "chip", font = "micro" }
function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label)
local focused = id and Kit.focusable(id, x, y, w, h) or false
local c = color or PAL.line
if G then
local hot = focused or Kit.hover(x, y, w, h)
if on then
Theme.fillRounded(x, y, w, h, c, 1)
Theme.emboss(x, y, w, h, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
else
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, c,
hot and Theme.A.focus or Theme.A.hover, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, c)
end
if hot then
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
end
end
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
CHIP_OPTS.active = on
CHIP_OPTS.color = color
CHIP_OPTS.id = id
return Kit.button(x, y, w, h, label, CHIP_OPTS)
end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
+95 -20
View File
@@ -35,8 +35,8 @@ local PAL = {
-- it in the same hue, so a card reads as a raised object rather than as an
-- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
field = { 20, 20, 20 }, -- the page BEHIND the cards
bg = { 20, 20, 20 }, -- light black
surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback
@@ -82,6 +82,67 @@ Theme.A = {
disabled = 0.30,
}
Theme.BUTTON = {
radius = 8,
ringPad = 2,
ringWidth = 2,
labelInset = 16,
labelPad = 10,
iconPad = 0.24,
letterGap = 4,
disabledA = 0.45,
embossHot = 1.3,
embossRest = 1,
embossDisabled = 0.4,
glowHz = 3,
glowBase = 0.25,
glowAmp = 0.75,
iconRestA = 0.85,
}
Theme.CARD = {
radius = 14,
shadow = true,
fill = PAL.surface,
fillA = 1,
stroke = PAL.line,
strokeA = Theme.A.hairline,
}
Theme.CARD_VARIANT = {
emphasis = { strokeA = Theme.A.focus },
muted = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
},
mutedHot = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
},
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
warn = {
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
row = {
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
rowHover = {
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
rowSelected = {
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
},
hairline = {
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
badge = {
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = 0,
},
}
local G = love and love.graphics or nil
local has = {}
@@ -123,11 +184,11 @@ end
-- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button.
function Theme.radius()
return 8
return Theme.BUTTON.radius
end
function Theme.cardRadius()
return 14
return Theme.CARD.radius
end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
@@ -206,28 +267,42 @@ end
-- The design's only container: a rounded surface a few values above the
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis)
local r = Theme.cardRadius()
Theme.shadow(x, y, w, h, r)
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
function Theme.card(x, y, w, h, variant)
local spec = Theme.CARD
local v
if variant == true then
v = Theme.CARD_VARIANT.emphasis
elseif type(variant) == "string" then
v = Theme.CARD_VARIANT[variant]
elseif type(variant) == "table" then
v = variant
end
local radius = (v and v.radius) or spec.radius
if radius == "ctl" then radius = Theme.BUTTON.radius end
local fill = (v and v.fill) or spec.fill
local fillA = spec.fillA
if v and v.fillA ~= nil then fillA = v.fillA end
local stroke = (v and v.stroke) or spec.stroke
local strokeA = spec.strokeA
if v and v.strokeA ~= nil then strokeA = v.strokeA end
local shadow = spec.shadow
if v and v.shadow ~= nil then shadow = v.shadow end
local strokeW = (v and v.strokeW) or 1
if fillA > 0 and fill then
if shadow then Theme.shadow(x, y, w, h, radius) end
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
end
if strokeA > 0 and stroke then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
end
end
-- A list row. Three states, each one rect plus one outline:
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse
end
Theme.fillRounded(x, y, w, h,
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
return PAL.text
end
+15 -5
View File
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = {}
NPC.__index = NPC
local STEP_FRAMES = 16
local STEP_FRAMES = 32
local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
end
function NPC:update(map, entities)
-- self.stepFrames overrides the shared 16-frame walk for an object whose
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
return
end
local d = Collision.DELTA[self.facing]
-- 1px per frame at the default length; a shortened step scales instead,
-- 1px per 2 frames at the default length; a shortened step scales instead,
-- so the cell still lands on a 16px boundary (Player:update does the
-- same for the bicycle)
local moved = math.floor(self.progress * 16 * span / stepLen)
@@ -113,8 +122,9 @@ end
function NPC:walkPhase()
if not self.moving then return 0 end
local p = self.progress % 16
return (p >= 4 and p < 12) and 1 or 0
local stepLen = self.stepFrames or STEP_FRAMES
local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
+106 -64
View File
@@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- NPC instances persist across connection crossings in self.npcPool
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
-- objects that become the real NPCs when the player crosses the
-- seam, so nothing snaps back to its spawn point in view of the
-- survey zoom. Warps rebuild from scratch, like the original's
-- per-entry sprite init (home/overworld.asm LoadMapHeader
-- .loadSpriteData).
-- seam, so a map the player is not entering keeps its ghosts alive
-- in view of the survey zoom. The map being entered does not --
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
-- .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (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.
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
end
end
self.npcs = {}
for _, obj in ipairs(self.map.def.objects or {}) do
@@ -1986,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy)
end
save.hiddenTaken[key] = true
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
require("src.core.Sound").play(Game.data, "Get_Item2")
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
-- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name)))
Strings("%s found\n%s!", save.player.name, name),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
@@ -2001,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy)
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins)
require("src.core.Sound").play(Game.data, "Get_Item2")
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, h.coins)))
Strings("%s found\n%d coins!", save.player.name, h.coins),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
@@ -2650,10 +2661,11 @@ function OverworldState:talkTo(npc)
end
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
local ddef = Game.data.items[d.item]
require("src.core.Sound").play(Game.data,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", Game.save.player.name, name)))
Strings("%s found\n%s!", Game.save.player.name, name), nil,
TextBox.soundOpts(Game,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
return
end
@@ -3182,6 +3194,58 @@ local function giveVictoryItem(reward)
return true
end
-- A gym's reward text is not one box. Every gym script carries a sound
-- command right after the FIRST label of each reward group
-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's
-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the
-- equivalents in the other seven), and home/text.asm TextCommand_SOUND
-- plays it only once that page has typed out, then blocks on
-- WaitForSoundToFinish before the next page prints. So the pages
-- accumulate and split into separate boxes at the sound points, each box
-- chained off the previous one's button press.
local function rewardChain()
local chain = { boxes = {}, pending = {} }
function chain.flush(sound)
if #chain.pending > 0 then
table.insert(chain.boxes,
{ text = table.concat(chain.pending, "\f"), sound = sound })
chain.pending = {}
end
end
-- one reward group; `sound` rides its first page, where the scripts put it
function chain.add(labels, sound)
local text = Game.data.text or {}
local n = 0
for _, label in ipairs(labels or {}) do
if text[label] and text[label] ~= "" then
table.insert(chain.pending, text[label])
n = n + 1
if n == 1 and sound then chain.flush(sound) end
end
end
end
-- the synthetic stand-in line a reward with no `dialogue` shows
function chain.line(str, sound)
table.insert(chain.pending, str)
if sound then chain.flush(sound) end
end
function chain.push(done)
chain.flush()
local function step(i)
local box = chain.boxes[i]
if not box then
if done then done() end
return
end
local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil
Game.stack:push(TextBox.new(Game, box.text,
function() step(i + 1) end, opts))
end
step(1)
end
return chain
end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
@@ -3218,44 +3282,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
-- retries the hand-over later (offerGymTm via gyms.lua)
tmGiven = giveVictoryItem(reward)
end
local lines = {}
local chain = rewardChain()
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
chain.add(reward.dialogue, reward.badgeSound)
if reward.item then
for _, label in ipairs(reward.tmPre or {}) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
chain.add(reward.tmPre)
if tmGiven then
for _, label in ipairs(reward.tmDialogue or {}) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
table.insert(lines, text[reward.noRoom])
chain.add(reward.tmDialogue, reward.tmSound)
else
chain.add({ reward.noRoom })
end
end
elseif reward.badge or reward.item then
if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name),
reward.badgeSound)
end
if tmGiven then
local name = Game.stringBuffer or reward.item
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
chain.line(Strings("%s received\n%s!", Game.save.player.name, name),
reward.tmSound)
end
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
end
chain.push()
self:runVictoryHook()
end
@@ -3266,24 +3317,14 @@ end
-- show again, then the same GiveItem check decides between the received
-- lines and the "make room" text.
function OverworldState:offerGymTm(reward, done)
local text = Game.data.text or {}
local lines = {}
local function addLine(label)
if label and text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
local chain = rewardChain()
chain.add(reward.tmPre)
if giveVictoryItem(reward) then
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
chain.add(reward.tmDialogue, reward.tmSound)
else
addLine(reward.noRoom)
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
elseif done then
done()
chain.add({ reward.noRoom })
end
chain.push(done)
end
-- pokered reloads the map after every battle, re-running the map
@@ -3661,8 +3702,9 @@ function OverworldState:onStepComplete()
end
end
-- wild encounters in grass, on water while surfing, or -- on indoor
-- maps whose tileset is not FOREST -- on EVERY tile
-- wild encounters in grass, on water while surfing (at the map's water
-- rate, which is 0 on every indoor map), or -- on indoor maps whose
-- tileset is not FOREST -- on every other tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps.
@@ -3670,10 +3712,10 @@ function OverworldState:onStepComplete()
local encDef = Game.data.encounters[self.map.id]
local enc
local indoor = Game.data.field.indoorEncounters
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef.water }, "water")
elseif self.map:isGrassCell(p.cellX, p.cellY) then
if self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter(encDef, "grass")
elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef and encDef.water }, "water")
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
and self.map.def.tileset ~= indoor.excludedTileset then
enc = self:rollEncounter(encDef, "indoor")
@@ -3794,10 +3836,10 @@ function OverworldState:checkBadgeGate()
if Game.save.inventory[g.badge] then
if not Game.save.flags[passedFlag] then
Game.save.flags[passedFlag] = true
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
Game.stack:push(TextBox.new(Game,
t["_" .. g.passText] or Strings("Go right ahead!")))
t["_" .. g.passText] or Strings("Go right ahead!"),
nil, TextBox.soundOpts(Game, "Get_Item1")))
end
return false
end
@@ -3822,11 +3864,11 @@ function OverworldState:checkBadgeGate()
and Game.data.items[guard.badge].name or guard.badge
if Game.save.inventory[guard.badge] then
Game.save.flags[guard.event] = true
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
local text = (t["_" .. g.passText] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text))
Game.stack:push(TextBox.new(Game, text,
nil, TextBox.soundOpts(Game, "Get_Item1")))
return false
end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
@@ -4470,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
})
end
-- A step-in-place beat: the entity plays one walk-cycle animation (16
-- A step-in-place beat: the entity plays one walk-cycle animation (32
-- frames) without translating, keeping its current facing. Ports the
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
@@ -4483,7 +4525,7 @@ end
-- Advance scripted moves in two phases so a chained step (a new move
-- queued by a completing move's onDone) begins the SAME frame the
-- previous one ends -- back-to-back 16-frame tiles like the GB's
-- previous one ends -- back-to-back 32-frame tiles like the GB's
-- simulated-joypad / NPC scripted movement, with no idle frame between
-- tiles. Phase 1 retires finished moves (which may chain new ones);
-- phase 2 then starts every not-yet-moving move.
+10 -1
View File
@@ -821,11 +821,13 @@ end
function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
local starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
@@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return
end
-- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets
-- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at
-- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house
-- resumes at BillsHouseScript1's bare ret (#919).
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
if game.save.flags.EVENT_MET_BILL_2 then return end
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
-- walk while CheckPikachuStatusCondition comes back clear
@@ -191,12 +191,12 @@ return function(game)
return cond()
end
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
-- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
-- taken. EvolutionState:update only compares self.t against that constant,
-- so this ends the animation early without touching the palette logic.
local function skipToEnd()
local st = evoTop()
if st then st.t = 214 end
if st then st.t = 362 end
end
local function startEvo(species, into)
@@ -212,7 +212,7 @@ return function(game)
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
local weedle = startEvo("WEEDLE", "KAKUNA")
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
local c1 = probe("flash/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
@@ -6,7 +6,7 @@
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
-- text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
@@ -81,7 +81,7 @@ return function(game)
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
@@ -133,8 +133,8 @@ return function(game)
local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B
waitFor(function() return not evoTop() end, 400)
-- let the full flash run (FLASH_FRAMES=368) without pressing B
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
+3 -3
View File
@@ -89,8 +89,8 @@ return function(game)
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
waitFor(function() return not evoTop() end, 400)
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)")
end
@@ -124,7 +124,7 @@ return function(game)
local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400)
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
@@ -0,0 +1,188 @@
-- Driver: a trainer who walked up to the player must be back on her spawn
-- cell the next time her map loads, connection crossings included (#1028).
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
--
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
-- standing above the third Bug Catcher at (19,4) is inside her line and she
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
--
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_3"
local TARGET = 6
local SPAWN = { x = 23, y = 4 }
local STAND = { x = 19, y = 4 }
local pass = true
local function check(label, ok)
if not ok then pass = false end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function lass()
local ow = game.overworld
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.index == TARGET then return n end
end
return nil
end
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
-- hold a direction until cond() or the budget runs out, then let the
-- half-finished step land
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 900 do
if cond() then break end
if first then table.insert(game.input.pressQueue, btn); first = false end
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
for _ = 1, 40 do
local ow = game.overworld
if ow and not ow.player.moving then break end
coroutine.yield()
end
U.wait(4)
return cond()
end
local function walkTo(btn, axis, want, budget)
return holdUntil(btn, function()
local p = game.overworld and game.overworld.player
return p and p[axis] == want and not p.moving
end, budget)
end
local function onMap(id)
return game.overworld and game.overworld.map and game.overworld.map.id == id
end
-- mash A until the battle stack unwinds back to the overworld
local function mashToOverworld(budget)
for _ = 1, budget or 4000 do
if game.stack:top() == game.overworld and not game.overworld.engaging then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- absolute zoom: reset first so every shot frames the same amount of world
local function survey(n)
Zoom.reset()
U.wait(2)
for _ = 1, n do game:zoomStep(-1) end
U.wait(30)
end
-- back up to the sighting cell so the before/after shots share a camera
local function returnToStand()
walkTo("right", "cellX", 11)
walkTo("up", "cellY", 4)
walkTo("right", "cellX", STAND.x)
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 80),
Pokemon.new(game.data, "SNORLAX", 80),
}
game.save.player.name = "MATT"
U.teleport(game, MAP, STAND.x, STAND.y, "right")
U.wait(20)
-- only the target Lass may engage: the reporter has already cleared the
-- Bug Catchers and Youngsters ahead of her
local ow = game.overworld
for _, n in ipairs(ow.npcs) do
if n.def.trainerClass and n.def.index ~= TARGET then
game.save.defeatedTrainers[n.id] = true
end
end
local l = lass()
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
U.log("spawn:", at(l))
-- she sights the player and walks up, then the battle runs
for _ = 1, 600 do
if ow.engaging then break end
coroutine.yield()
end
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
check("the battle ran to completion", mashToOverworld())
U.wait(30)
l = lass()
local movedX, movedY = l and l.cellX, l and l.cellY
U.log("after the walk-up she stands at", at(l))
check("she is off her spawn cell after walking up",
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
survey(4)
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
Zoom.reset()
U.wait(5)
-- west along row 4, down the single gap at column 11, then west on row 9
-- to the Pewter City seam
walkTo("left", "cellX", 11)
walkTo("down", "cellY", 9)
check("reached the descent column", game.overworld.player.cellX == 11
and game.overworld.player.cellY == 9)
-- crossing x=0 westward is the seam: crossConnection, not a warp
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
survey(12)
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
Zoom.reset()
U.wait(5)
-- and back east into Route 3, the map load that must re-seed her
holdUntil("right", function() return onMap(MAP) end, 1200)
check("crossed back into Route 3", onMap(MAP))
U.wait(30)
l = lass()
U.log("after the return crossing she stands at", at(l))
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
check("she is still recorded as defeated",
l ~= nil and game.save.defeatedTrainers[l.id] == true)
-- same cell, same zoom as shot 1: she should now be three cells further
-- east, and being defeated she must not re-engage on the way back in
returnToStand()
check("walked back to the sighting cell",
game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
survey(4)
U.shot(game, DIR .. "/bug1028_3_reset.png")
Zoom.reset()
U.log(pass and "RESULT PASS" or "RESULT FAIL")
love.event.quit(pass and 0 or 1)
while true do coroutine.yield() end
end
+3 -1
View File
@@ -420,6 +420,7 @@ local GEN2_HOOKS = {
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
@@ -434,7 +435,8 @@ local GEN2_HOOKS = {
-- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer",
"render.zones", "render.compose", "render.letterbox", "render.hud",
"render.zones", "render.compose", "render.output_enabled", "render.output",
"render.letterbox", "render.hud",
}
local function assertShared(name, sites, kind)
+47
View File
@@ -0,0 +1,47 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game2 = require("src.core.Game2")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local received
hooks:wrap("render.output", function(_, context)
received = context
return true
end, 0, "test")
hooks:wrap("render.output_enabled", function() return false end, 0, "test")
local canvas = love.graphics.newCanvas(640, 480)
local presentCalls = 0
local game = setmetatable({
frameFit = function() return 3, 80, 24, 1, 480, 432 end,
presentCanvas = function() presentCalls = presentCalls + 1 return canvas end,
drawScene = function() end,
drawHud = function() end,
}, Game2)
Game2.draw(game)
T.eq(received, nil, "a disabled Gold output hook does not run")
T.eq(presentCalls, 0, "a disabled Gold output hook keeps the direct path")
hooks:wrap("render.output_enabled", function() return true end, 10, "test")
Game2.draw(game)
T.check(received ~= nil, "Gold raises render.output for an enabled subscriber")
T.eq(received and received.canvas, canvas,
"Gold hands render.output the finished present canvas")
T.eq(received and received.generation, 2,
"Gold identifies the output context without changing Gen 1")
T.eq(received and received.gameX, 80, "Gold output carries the fitted game X")
T.eq(received and received.gameY, 24, "Gold output carries the fitted game Y")
T.eq(received and received.gameWidth, 480,
"Gold output carries the fitted game width")
T.eq(received and received.gameHeight, 432,
"Gold output carries the fitted game height")
Runtime.hooks = savedHooks
T.finish("gen2 render output seam")
@@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu")
-- minimal stack/input doubles matching the StateStack and Input surfaces,
-- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes
-- CheckIfInOutsideMap, and the badges cover the list-time HM gates
-- CheckIfInOutsideMap
local function newGame(moves, inventory)
local game = {
data = { pokemon = { LAPRAS = { name = "LAPRAS" },
@@ -87,14 +87,15 @@ openSubmenu(pm2)
same(actions(pm2.subItems), { "stats", "switch" },
"no field moves: the submenu is just STATS/SWITCH")
-- the badge gates still filter the list: the same Lapras without the
-- badges keeps its moves but shows none of them
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
-- still lists both HMs, and .outOfBattleMovePointers refuses on
-- selection instead (#1022)
local noBadges = newGame(
{ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
local pm3 = PartyMenu.new(noBadges, {})
noBadges.stack:push(pm3)
openSubmenu(pm3)
same(actions(pm3.subItems), { "stats", "switch" },
"ungated badges keep the HM moves out of the submenu")
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
"the HM moves are listed without the badges (#1022)")
T.finish("party_fieldmove_order_bug792")
@@ -16,6 +16,7 @@ local T = require("tests.modkit")
local boxes = {}
package.loaded["src.render.TextBox"] = {
new = function(_, s, done) return { text = s, onDone = done } end,
soundOpts = function() return {} end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.inventory.Bag"] = {
+21
View File
@@ -549,6 +549,27 @@ do
dudePack:useSelected()
eq(thrown, "POKE_BALL", "the tutorial pack still throws")
eq(dudePack.message, nil, "with no Oak line in the way")
-- DepositSellPack passes the same empty world (the mart's SELL and the
-- item PC's DEPOSIT both do): a TM row must hand its id back rather than
-- open the teach party, which is issue #1243's "Teach which PKMN?" in
-- the middle of a sale.
local sellGame = { input = newInput(), save = {
player = { name = "GOLD" },
inventory = { TM01 = 1 },
options = {},
}, data = DATA, stack = newStack() }
local sold
local sellPack = PackMenu.new(sellGame, {
save = sellGame.save, items = DATA.items, world = {},
onChoose = function(id) sold = id end,
})
sellPack.pocketIndex = 4 -- TM_HM
sellPack:rebuild()
sellPack:useSelected()
eq(sold, "TM01", "a chooser pack hands a TM to its caller")
eq(sellPack.message, nil, "with no teach refusal in the way")
eq(#sellGame.stack._items, 0, "and nothing pushed over the pack")
end
-- ------------------------------------------------ say(): the teardown rule
+34
View File
@@ -1481,6 +1481,11 @@ local martItems = {
price = 100, canToss = true },
NUGGET = { id = "NUGGET", name = "NUGGET", pocket = "ITEM", index = 36,
price = 10000, canToss = true },
-- A TM carries the move it teaches; the SELL pack must hand it to the
-- mart rather than opening the teach party (issue #1243).
TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM",
index = 234, price = 2000, canToss = true, teaches = "HEADBUTT",
tmNumber = 2 },
-- Every KEY ITEM carries CANT_TOSS, which is the flag SellMenu's
-- _CheckTossableItem actually refuses on.
BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 7,
@@ -1732,6 +1737,35 @@ sellInput:press("a") sell:update(0) -- YES
check("the wallet clamps at MAX_MONEY", sellSave.player.money, 999999)
check("and the nugget is gone", sellSave.inventory.NUGGET, nil)
-- A TM is not a key item: DepositSellPack's jumptable is four ScrollingMenus
-- and has no teach arm, so picking a TM from the SELL pack must ask "How
-- many?" and price it at half of its ItemAttributes price -- never open the
-- teach party (issue #1243).
local tmSave = Save.newGame()
tmSave.inventory = { TM_HEADBUTT = 1 }
tmSave.player.money = 0
local tm, tmInput = newMart(tmSave)
tmInput:press("down") tm:update(0)
tmInput:press("a") tm:update(0) -- SELL
check("SELL builds the pack", tm.pack ~= nil, true)
-- The PACK opens on the ITEM pocket; cross to TM/HM the way the player would.
tm.pack.pocketIndex = 4
tm.pack:rebuild()
check("the TM is the TM pocket's row", tm.pack.rows[1].id, "TM_HEADBUTT")
tmInput:press("a") tm:update(0)
check("picking a TM asks how many", tm.phase, "sellQuantity")
check("with no teach screen opened", #tm.game.stack._items, 0)
check("and no pack refusal printed", tm.pack.message, nil)
check("at the TM's own price", tm.qtyItem.price, 2000)
check("and the ceiling is what you hold", tm.qtyMax, 1)
tmInput:press("a") tm:update(0)
check("the offer prices half of the TM", tm.confirm.pages[1][2],
"\xc2\xa51000.")
tmInput:press("a") tm:update(0) -- page 2
tmInput:press("a") tm:update(0) -- YES
check("the TM leaves the bag", tmSave.inventory.TM_HEADBUTT, nil)
check("and the money arrives", tmSave.player.money, 1000)
-- ------------------------------------------------- PlayTransactionSound
--
-- engine/items/mart.asm rings SFX_TRANSACTION in exactly two places, both of
+19
View File
@@ -94,6 +94,8 @@ local ITEMS = {
canToss = false },
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0xf3,
canToss = false },
TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM",
index = 0xea, canToss = true, teaches = "HEADBUTT" },
}
local function newGame(save, items)
@@ -291,6 +293,23 @@ do
eq(pc.phase, "menu", "B drops back to the item PC menu")
end
-- The same DepositSellPack chooser holds a TM: it must reach the quantity
-- selector like everything tossable, never the teach party (issue #1243).
do
local save = newSave(1)
local game, input = newGame(save)
Bag.add(save, "TM_HEADBUTT", 1, game.data)
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
press(pc, input, "down", "a") -- DEPOSIT ITEM
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
press(pc, input, "a") -- choose the TM
check(pc.qtyState ~= nil, "a TM asks how many to deposit")
eq(#game.stack._items, 0, "with no teach screen pushed over the pack")
press(pc, input, "a") -- deposit x1
eq(save.pcItems.TM_HEADBUTT, 1, "the TM lands in the PC")
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
end
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
do
local save = newSave(1)
@@ -8,11 +8,16 @@ local function quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function execOk(cmd)
local status = os.execute(cmd)
return status == 0 or status == true
end
local function full(path) return root .. "/" .. path end
local fs = {}
function fs.createDirectory(path)
return os.execute("mkdir -p " .. quote(full(path))) == 0
return execOk("mkdir -p " .. quote(full(path)))
end
function fs.write(path, body)
local parent = path:match("^(.*)/[^/]+$")
@@ -34,7 +39,7 @@ function fs.remove(path)
return true
end
function fs.getInfo(path)
if os.execute("test -d " .. quote(full(path))) == 0 then
if execOk("test -d " .. quote(full(path))) then
return { type = "directory" }
end
local handle = io.open(full(path), "rb")
@@ -77,10 +82,11 @@ local function writeProbe()
fs.write("mods/cold_start_probe/manifest.json",
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}')
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
fs.write("mods/cold_start_probe/main.lua", [[
return function(mod)
_G.COLD_STORAGE = mod.storage
_G.COLD_CHECKPOINTS = mod.checkpoints
mod.exports.storage = mod.storage
mod.exports.checkpoints = mod.checkpoints
end
]])
end
@@ -130,11 +136,12 @@ if phase == "capture" then
local game = runtime(SaveData.newGame({ version = "red" }), false)
loader.game = game
assert(loader:load({}) == true)
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
local probe = assert(loader.exports.cold_start_probe)
assert(probe.storage:write(game, "history/index", { newest = "q0001" }))
local checkpoint = assert(probe.checkpoints:capture(game))
assert(probe.storage:write(game, "history/q0001", checkpoint))
local id = assert(game.save.meta.playthroughId)
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
assert(probe.checkpoints:ensureNormalSave(game, checkpoint))
local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == id)
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
@@ -144,15 +151,16 @@ else
title.save.options = { volume = 7, bindings = {} }
loader.game = title
assert(loader:load({}) == true)
local selected = assert(_G.COLD_STORAGE:selected(title))
local probe = assert(loader.exports.cold_start_probe)
local selected = assert(probe.storage:selected(title))
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
assert(selected:context().playthroughId == witness.playthroughId)
assert(selected:read("history/index").newest == "q0001")
local checkpoint = assert(selected:read("history/q0001"))
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
assert(probe.checkpoints:resume(title, checkpoint))
assert(title.save.meta.playthroughId == witness.playthroughId)
assert(title.save.options.volume == 7)
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
assert(SaveSerializer.encode(probe.checkpoints:capture(title))
== SaveSerializer.encode(checkpoint))
local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == witness.playthroughId)
+7
View File
@@ -187,6 +187,13 @@ local function runOne(seed)
local function drive(side)
local bt = side.bt
if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
+7
View File
@@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label)
local function drive(side)
local bt = side.bt
if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
+1 -1
View File
@@ -604,7 +604,7 @@ do
local game = uiGame({ mon })
Bag.add(game.save, "RARE_CANDY", 1)
game.stack:push(BagMenu.new(game))
for _ = 1, 600 do
for _ = 1, 800 do
local top = game.stack:top()
if not top then break end
pressed = { a = true }
+27
View File
@@ -987,6 +987,33 @@ check(fallback.style == "doublecircle",
"a hook naming an unregistered style falls back to the vanilla bits")
Runtime.install(Events.new(), Hooks.new(), {})
-- ------- gated final-output ownership
local outputHooks = Hooks.new()
Runtime.install(Events.new(), outputHooks, {})
local outputCalls, outputContext = 0, nil
outputHooks:wrap("render.output", function(nextLink, context)
outputCalls, outputContext = outputCalls + 1, context
return true
end, 0, "test")
outputHooks:wrap("render.output_enabled", function() return false end, 0, "test")
Renderer:init()
Renderer.presentCanvas = nil
Renderer:beginFrame(false)
Renderer:endFrame(nil, nil)
check(outputCalls == 0 and Renderer.presentCanvas == nil,
"a disabled output hook leaves the direct render path untouched")
outputHooks:wrap("render.output_enabled", function() return true end, 10, "test")
Renderer:init()
Renderer.presentCanvas = nil
Renderer:beginFrame(false)
Renderer:endFrame(nil, nil)
check(outputCalls == 1 and outputContext and outputContext.canvas,
"an enabled output hook receives the finished frame")
check(outputContext and outputContext.generation == 1,
"the output context identifies the active generation")
-- ------- asset transforms
local function seedTransform(id, source)
+4 -6
View File
@@ -102,20 +102,19 @@ if not headlessOk then error(headlessErr) end
love = love or require("tests.love_stub")
-- ------- discovery, dependency order, merge
-- "addon" sorts before "base" so only the dependency edge can order them
_G.MOD_TEST_ORDER = {}
-- "addon" sorts before "base" so only the dependency edge can order them.
-- loader.order is the engine's own record of what ran when; a mod cannot
-- append to a shared global any more (src/mods/Sandbox.lua).
local files = {
["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'),
["mods/addon/main.lua"] = [[
return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon"
mod.content.pokemon:override("MODMON", { name = "ADDONMON" })
end
]],
["mods/base/manifest.json"] = manifestJson("base"),
["mods/base/main.lua"] = [[
return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base"
mod.content.pokemon:register("MODMON", { name = "BASEMON" })
mod.content.music:register("MOD_SONG", { file = "song.ogg" })
end
@@ -126,7 +125,7 @@ local loader = Loader.new({ fs = memfs(files) })
check(loader:load(data) == true, "headless load succeeds with injected fs")
check(loader.mods.addon ~= nil and loader.mods.base ~= nil,
"discovery finds both mods")
check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon",
check(loader.order[1] == "base" and loader.order[2] == "addon",
"topo-sort runs the dependency before its dependent")
check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON",
"registered content merges into data")
@@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack")
while StateStack:top() do StateStack:pop() end
require("src.core.Music").stop()
Runtime.install(savedEvents, savedHooks)
_G.MOD_TEST_ORDER = nil
S.finish()
+80 -11
View File
@@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"),
check(not pcall(Manifest.validate, {
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
github = "ftp://example.com/x",
}), "a bad github field fails manifest validation")
}), "an unsupported github URL fails validation")
-- ------- dependency github repo spec hints & dependency resolver
local depGh = Manifest.validate({
id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
dependency_sources = { helper = "Acme/Helper" },
})
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
"dependency spec hash hint parses github owner/repo")
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
"dependency spec hash hint without range parses github owner/repo")
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(depGh)
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
check(#depCheck.deps == 2, "dependency check lists all specs")
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")
local v1 = Manifest.validate({
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
@@ -341,7 +359,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
"a mod beside the cycle loads normally")
-- ------- inter-mod exports and find
_G.MOD_FIND_RESULTS = {}
-- probes report through mod.exports: a mod's globals are its own
-- (src/mods/Sandbox.lua)
local exportLoader = Loader.new({ fs = memfs({
["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
["mods/colorlib/main.lua"] = [[
@@ -356,7 +375,7 @@ end
}),
["mods/daynight/main.lua"] = [[
return function(mod)
local results = _G.MOD_FIND_RESULTS
local results = mod.exports
local color = mod.find("colorlib")
results.depVersion = color.version
results.tint = color.exports.tint("dusk")
@@ -371,7 +390,7 @@ end
["options.lua"] = "return { mods = { shelved = false } }",
}) })
check(exportLoader:load({}) == true, "the export fixture loads clean")
local found = _G.MOD_FIND_RESULTS
local found = exportLoader.exports.daynight
check(found.tint == "tinted:dusk", "find returns the other mod's live export table")
check(found.depVersion == "1.0.0", "the handle carries the other mod's version")
check(found.optional == true, "an enabled optional dependency is findable")
@@ -380,10 +399,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod")
check(found.method == true, "mod:find is tolerated alongside mod.find")
check(exportLoader.order[1] == "colorlib",
"a hard dependency executes before its dependent")
_G.MOD_FIND_RESULTS = nil
-- ------- the rest of the v2 mod object
_G.MOD_OBJECT_PROBE = {}
local objectLoader = Loader.new({ fs = memfs({
["mods/probe/manifest.json"] = manifestJson("probe", {
api = "2", description = '"probing"', priority = "3",
@@ -391,7 +408,7 @@ local objectLoader = Loader.new({ fs = memfs({
["mods/probe/data.txt"] = "hello from the mod dir",
["mods/probe/main.lua"] = [[
return function(mod)
local probe = _G.MOD_OBJECT_PROBE
local probe = mod.exports
probe.id, probe.version, probe.path = mod.id, mod.version, mod.path
probe.manifestApi = mod.manifest.api
mod.manifest.api = 99
@@ -423,7 +440,7 @@ end
["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
}) })
check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean")
local probe = _G.MOD_OBJECT_PROBE
local probe = objectLoader.exports.probe
check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe",
"identity fields are present")
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
@@ -449,7 +466,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true,
"events:once fires once and does not skip the listener behind it")
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
"a mod cannot emit outside its own event namespace")
_G.MOD_OBJECT_PROBE = nil
-- a failing entry chunk takes its exports, commands and migrations with it
local residueLoader = Loader.new({ fs = memfs({
@@ -519,8 +535,61 @@ local emptyLoader = Loader.new({ fs = memfs({}) })
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
check(#emptyLoader.order == 0, "no mods means an empty load order")
check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil,
"no-mod load leaves data untouched")
-- ------- dependency resolver conflict detection test
local LauncherMods = require("src.mods.LauncherMods")
local testTargetManifest = Manifest.validate({
id = "new_mod",
name = "New Mod",
version = "1.0.0",
entry = "main.lua",
incompatible = { "colorlib" },
}, "mods/new_mod")
local installedColorlib = Manifest.validate({
id = "colorlib",
name = "Color Lib",
version = "1.0.0",
entry = "main.lua",
}, "mods/colorlib")
-- ------- scoped dependency tests
local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({
id = "dual_gen_mod",
name = "Dual Gen Mod",
version = "1.0.0",
entry = "main.lua",
games = { "gen1", "gen2" },
dependencies = {
{ id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" }
},
}, "mods/dual_gen_mod")
check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed")
check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list")
local dualGenFiles = {
["mods/dual_gen_mod/manifest.json"] = Json.encode({
id = "dual_gen_mod",
name = "Dual Gen Mod",
version = "1.0.0",
entry = "main.lua",
games = { "gen1", "gen2" },
dependencies = {
{ id = "gen2_only_dep", games = { "gen2" } }
},
}),
["mods/dual_gen_mod/main.lua"] = [[
return function(mod)
mod.content.pokemon:register("DUAL_MON", { hp = 100 })
end
]],
}
local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 })
check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent")
check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1")
local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 })
check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep")
check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep")
check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2")
Runtime.install(savedEvents, savedHooks)
+31
View File
@@ -168,8 +168,11 @@ end
do
local BattleState = require("src.battle.BattleState")
local Gen2BattleState = require("src.ui.gen2.BattleState")
check(BattleState.bottomUIVisible({ phase = "menu" }),
"battle bottom UI is visible without a mod")
check(Gen2BattleState.bottomUIVisible({ phase = "menu" }),
"Gold battle bottom UI is visible without a mod")
local seen
local unsub = wrap("battle.bottom_ui_visible", function(_, state)
seen = state
@@ -177,6 +180,8 @@ do
end)
check(not BattleState.bottomUIVisible({ phase = "messages" }),
"a mod can hide the battle text and menu layer")
check(not Gen2BattleState.bottomUIVisible({ phase = "menu" }),
"the same hook hides Gold's battle text and menu layer")
local text = setmetatable({}, TextBox)
text:draw()
check(seen == text, "pushed text boxes use the same visibility hook")
@@ -200,15 +205,41 @@ do
check(BattleState.bottomUIVisible({ phase = "moveSelect" }),
"battle bottom UI returns when the hook is removed")
check(Gen2BattleState.bottomUIVisible({ phase = "moves" }),
"Gold battle bottom UI returns when the hook is removed")
local Chrome = require("src.ui.gen2.Chrome")
local clear, box, print = Chrome.clear, Chrome.box, Chrome.print
local boxes = 0
Chrome.clear = function() end
Chrome.box = function() boxes = boxes + 1 end
Chrome.print = function() end
local panel = setmetatable({
battle = { player = {}, enemy = {} }, phase = "resolving",
drawHud = function() end,
}, { __index = Gen2BattleState })
unsub = wrap("battle.bottom_ui_visible", function() return false end)
panel:drawPanel()
check(boxes == 0, "Gold skips its in-state battle box when a mod owns it")
unsub()
panel:drawPanel()
check(boxes == 1, "Gold draws its battle box again without the hook")
Chrome.clear, Chrome.box, Chrome.print = clear, box, print
check(BattleState.statusHUDVisible({}),
"battle status HUD is visible without a mod")
check(Gen2BattleState.statusHUDVisible({}),
"Gold battle status HUD is visible without a mod")
unsub = wrap("battle.status_hud_visible", function() return false end)
check(not BattleState.statusHUDVisible({}),
"a mod can hide the battle status HUD")
check(not Gen2BattleState.statusHUDVisible({}),
"the same hook hides Gold's battle status HUD")
unsub()
check(BattleState.statusHUDVisible({}),
"battle status HUD returns when the hook is removed")
check(Gen2BattleState.statusHUDVisible({}),
"Gold battle status HUD returns when the hook is removed")
end
-- ------- battle.caught_marker_visible (caught wild marker)
+21 -23
View File
@@ -87,13 +87,19 @@ end
-- ------- a mod registers two pipelines and the engine dispatches them
local trace = {}
-- The probe table is the mod's, published through mod.exports: a mod's
-- globals are its own now (src/mods/Sandbox.lua). The world/present folds
-- accept only a real Canvas, so the mod makes concrete ones to return and the
-- test pins identity through the dispatch.
local FILES = {
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
["mods/painter/main.lua"] = [[
local mod = ...
local T = _G.__RENDER_TEST
local T = mod.exports
T.trace, T.available = {}, true
T.worldOut = love.graphics.newCanvas(2, 2)
T.blurOut = love.graphics.newCanvas(2, 2)
T.gradeOut = love.graphics.newCanvas(2, 2)
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA",
levels = { "OFF", "LOW", "HIGH" },
@@ -101,8 +107,6 @@ local FILES = {
priority = 20,
available = function() return T.available end,
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
-- the folds composite only a real Canvas, so the mod hands back the
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
drawWorld = function(ctx)
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
return T.worldOut
@@ -123,17 +127,12 @@ local FILES = {
]],
}
_G.__RENDER_TEST = { trace = trace, available = true }
-- the world/present folds accept only a real Canvas, so give the mod concrete
-- ones to return and pin identity through the dispatch
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
local data = {}
local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
local RT = loader.exports.painter
local trace = RT.trace
Pipelines.install(data)
check(type(data.render_pipelines) == "table",
@@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1)
eq(Pipelines.worldPipeline(), "diorama",
"the eligible world pipeline claims the world pass")
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut,
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), RT.worldOut,
"drawWorld returns the mod's canvas")
eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut,
eq(Pipelines.worldPresent(RT.worldOut), RT.blurOut,
"worldPresent folds its canvas over the world image")
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut,
eq(Pipelines.present(RT.gradeOut), RT.gradeOut,
"present folds its canvas over the finished composite")
-- ------- the hardware gate
_G.__RENDER_TEST.available = false
RT.available = false
eq(Pipelines.worldPipeline(), nil,
"an unavailable pipeline never takes the world pass")
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
"an unavailable pipeline's worldPresent is skipped")
_G.__RENDER_TEST.available = true
RT.available = true
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
-- ------- the gate governs input, never the draw
@@ -282,7 +281,8 @@ local SLOPPY = {
["mods/sloppy/manifest.json"] = manifest("sloppy"),
["mods/sloppy/main.lua"] = [[
local mod = ...
local T = _G.__SLOPPY
local T = mod.exports
T.ran = 0
mod.content.render_pipelines:register("sloppy", {
label = "SLOPPY",
present = function(canvas)
@@ -302,20 +302,20 @@ local SLOPPY = {
})
]],
}
_G.__SLOPPY = { ran = 0 }
local sloppyData = {}
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
sloppyLoader:load(sloppyData)
local SL = sloppyLoader.exports.sloppy
Pipelines.install(sloppyData)
local composite = love.graphics.newCanvas(4, 4)
Pipelines.setLevel("sloppy", 1)
for _, bad in ipairs({ "just-a-string", true, 42 }) do
_G.__SLOPPY.ret = bad
SL.ret = bad
eq(Pipelines.present(composite), composite,
"a present returning a " .. type(bad) .. " leaves the composite untouched")
end
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame")
check(SL.ran == 3, "the present callback still ran each frame")
check(Pipelines.eligible("sloppy") == true,
"a non-canvas return does not retire the pipeline as broken")
Pipelines.setLevel("sloppy", 0)
@@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas",
eq(love.graphics.getBlendMode(), "alpha",
"a present that changed blend mode cannot leak it past the fold")
Pipelines.setLevel("dirty", 0)
_G.__SLOPPY = nil
Pipelines.reset()
Pipelines.install(nil)
_G.__RENDER_TEST = nil
-- ------- and with no mods at all, the whole subsystem is inert
+15 -1
View File
@@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1],
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
do
local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false }
map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" }
local sgame = partyGame()
sgame.overworld = owStub
sgame.data.text = {} -- the strength texts fall back to Strings sources
@@ -1162,6 +1164,18 @@ seedOpts.modProfiles = {}
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
local LauncherMods = require("src.mods.LauncherMods")
local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } }
local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts)
check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)",
"duplicateProfile creates P1 (Copy) and activates it")
check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true,
"renameProfile renames active profile")
check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename")
check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile")
check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains")
check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile")
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
+6 -7
View File
@@ -139,10 +139,11 @@ local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}',
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/probe/main.lua"] = [[
return function(mod)
_G.MOD_CHECKPOINTS = mod.checkpoints
_G.MOD_HOOKS = mod.hooks
mod.exports.checkpoints = mod.checkpoints
mod.exports.hooks = mod.hooks
end
]],
}
@@ -150,12 +151,12 @@ local game, ow = makeGame()
local loader = Loader.new({ fs = memfs(files) })
loader.game = game
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
local checkpoints = _G.MOD_CHECKPOINTS
local checkpoints = (loader.exports.probe or {}).checkpoints
local modHooks = (loader.exports.probe or {}).hooks
T.check(type(checkpoints) == "table",
"Loader exposes mod.checkpoints through the public mod object")
if type(checkpoints) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_CHECKPOINTS = nil
T.finish()
end
@@ -440,7 +441,7 @@ end
-- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command.
local auxiliaryCalls = 0
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
modHooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
auxiliaryCalls = auxiliaryCalls + 1
T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
@@ -456,8 +457,6 @@ T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cur
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
_G.MOD_HOOKS = nil
love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState
+52
View File
@@ -0,0 +1,52 @@
-- A sandboxed mod may read battery state without receiving love.system and
-- its process-launching surface.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FIXTURE = {
["mods/power_probe/manifest.json"] = [[{
"id": "power_probe",
"name": "Power Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/power_probe/main.lua"] = [[
local mod = ...
mod.exports.state, mod.exports.percent = mod.device:powerInfo()
]],
}
local saved = T.love.system.getPowerInfo
local calls = 0
T.love.system.getPowerInfo = function()
calls = calls + 1
return "charging", 42, 900
end
local vanilla = T.sdk.loadNone({})
T.eq(calls, 0, "no mod leaves the device power backend cold")
vanilla.release()
local run = T.sdk.loadMods({ "mods/power_probe" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.power_probe or {}
T.eq(out.state, "charging", "the public facade reports battery state")
T.eq(out.percent, 42, "the public facade reports battery percentage")
T.eq(calls, 1, "one facade read makes one platform call")
run.release()
T.love.system.getPowerInfo = nil
local unavailable = T.sdk.loadMods({ "mods/power_probe" },
{ fs = T.sdk.memfs(FIXTURE) })
out = unavailable.loader.exports.power_probe or {}
T.eq(out.state, "unknown", "a missing platform backend has a stable state")
T.eq(out.percent, nil, "a missing platform backend has no invented percentage")
unavailable.release()
T.love.system.getPowerInfo = saved
T.finish("device_power_info")
+15 -18
View File
@@ -23,16 +23,16 @@ local FIXTURE = {
extraPolls = extraPolls + 1
if not paused then nextFn(game, dt) end
end)
local vetoQuit = false
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
if vetoQuit then return false end
return nextFn()
end)
-- test-only knobs, read back through mod.storage-free globals since
-- this fixture never leaves the process
_G.__fixturePlatformBridge = {
setPaused = function(v) paused = v end,
extraPolls = function() return extraPolls end,
}
-- test-only knobs on mod.exports: a sandboxed mod has no shared _G to
-- smuggle them through (src/mods/Sandbox.lua)
mod.exports.setPaused = function(v) paused = v end
mod.exports.extraPolls = function() return extraPolls end
mod.exports.setVetoQuit = function(v) vetoQuit = v end
]],
}
@@ -43,23 +43,23 @@ do
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local bridge = run.loader.exports.fix_platform_bridge
local calls = 0
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
_G.__fixturePlatformBridge.setPaused(false)
bridge.setPaused(false)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
T.eq(bridge.extraPolls(), 1,
"the subscriber's wrapper runs every frame")
_G.__fixturePlatformBridge.setPaused(true)
bridge.setPaused(true)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
T.eq(bridge.extraPolls(), 2,
"the subscriber keeps polling every frame while paused")
run.release()
_G.__fixturePlatformBridge = nil
end
-- core.quit_to_launcher: a subscriber can veto without the vanilla
@@ -70,11 +70,8 @@ do
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local realGetenv = os.getenv
os.getenv = function(name)
if name == "FIXTURE_VETO_QUIT" then return "1" end
return realGetenv(name)
end
local bridge = run.loader.exports.fix_platform_bridge
bridge.setVetoQuit(true)
local vanillaCalls = 0
local vetoed = PlatformHooks.quitToLauncher(function()
vanillaCalls = vanillaCalls + 1
@@ -82,7 +79,7 @@ do
end)
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
os.getenv = realGetenv
bridge.setVetoQuit(false)
local passed = PlatformHooks.quitToLauncher(function() return true end)
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
+208
View File
@@ -0,0 +1,208 @@
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
-- an environment with no io, no os beyond the clock, and no way to name a path
-- outside its own directory, so a mod cannot reach the player's filesystem.
-- Every case here is an escape a mod would actually try.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
.. '"api":2%s}'):format(id, id, extra or "")
end
-- what a probe reports back; pcall'd so one broken assumption does not take
-- the whole entry chunk down and hide the rest
local PROBE = [[
local mod = ...
local out = mod.exports
out.io = io
out.package = package
out.dofile = dofile
out.loadfile = loadfile
out.setfenv = setfenv
out.getfenv = getfenv
out.debug = debug
out.osGetenv = os.getenv
out.osExecute = os.execute
out.osRemove = os.remove
out.osTime = type(os.time)
out.stringOk = ("a"):rep(3)
local function attempt(fn, ...)
local ok, err = pcall(fn, ...)
if ok then return false end
return tostring(err)
end
out.requireIo = attempt(require, "io")
out.requireOs = attempt(require, "os")
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
out.requireLoveFs = attempt(require, "love.filesystem")
out.requireSocket = attempt(require, "socket")
-- called from a nested Lua frame rather than straight off pcall, which is
-- the shape a stack-walking gate reads differently
out.requireIoNested = attempt(function() return require("io") end)
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
out.loveFilesystem = attempt(function() return love.filesystem end)
out.loveThread = attempt(function() return love.thread end)
out.loveSystem = attempt(function() return love.system end)
out.loveGraphics = type(love.graphics)
out.loveAssign = attempt(function() love.filesystem = {} end)
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
-- mod's own source must inherit the sandbox, not the real globals
local child = load("return io, os.getenv, _G")
local childIo, childGetenv, childG = child()
out.childIo = childIo
out.childGetenv = childGetenv
out.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
out.readOwn = mod:read("data/note.txt")
_G.SANDBOX_LEAK = "escaped"
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
-- a mod stomping the standard library must not reach the engine
table.insert = function() error("stomped") end
string.format = function() error("stomped") end
]]
local FILES = {
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
["mods/fix_sandbox/main.lua"] = PROBE,
["mods/fix_sandbox/data/note.txt"] = "own file",
}
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
T.eq(#run.errors, 0,
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.fix_sandbox or {}
-- ------- the standard library a mod does not get
T.eq(out.io, nil, "io is absent from the mod environment")
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
T.eq(out.dofile, nil, "dofile is absent")
T.eq(out.loadfile, nil, "loadfile is absent")
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
T.eq(out.debug, nil, "the debug library is absent")
T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit "
.. "found the user's home directory")
T.eq(out.osExecute, nil, "os.execute is absent")
T.eq(out.osRemove, nil, "os.remove is absent")
T.eq(out.osTime, "function", "os.time still works: the clock is not the hole")
T.eq(out.stringOk, "aaa", "the safe standard library is intact")
-- ------- require, the one call that would undo all of the above
T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true),
"require(\"io\") is refused: " .. tostring(out.requireIo))
T.check(out.requireOs ~= false, "require(\"os\") is refused")
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C")
T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused")
T.check(out.requireIoNested ~= false,
"require(\"io\") from a nested frame is refused the same way")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
"a network module names the permission it needs: " .. tostring(out.requireSocket))
T.eq(type(out.requireSemver), "table",
"the supported engine requires still resolve")
-- ------- the love facade
T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
"love.filesystem is refused and names the replacement")
T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state")
T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true),
"love.system is refused and names the scoped power replacement")
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
-- ------- env propagation and isolation
T.eq(out.childIo, nil,
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
T.eq(("%d"):format(1), "1",
"a mod stomping string.format cannot reach the engine's copy")
do
local probe = {}
table.insert(probe, "still works")
T.eq(probe[1], "still works",
"nor table.insert -- each mod gets its own standard-library namespace")
end
-- ------- paths
T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
"mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
T.eq(out.readOwn, "own file", "and the mod's own files still read")
run.release()
-- ------- the grammar itself
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
"..\\x", "a\\b", "..", ".", "" }) do
T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
end
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
"an ordinary relative path passes")
T.eq(SafePath.safe("./main.lua"), "main.lua",
"a leading ./ is normalized rather than rejected, so older manifests load")
-- ------- manifest paths are untrusted input too
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
"a manifest cannot point entry outside the mod directory")
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
options_schema = "../../options.lua" }),
"nor options_schema")
T.check(pcall(Manifest.validate,
{ id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
"an ordinary manifest still validates")
-- ------- bytecode
do
local bad = {
["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
["mods/fix_bytecode/main.lua"] = string.dump(function() end),
}
local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
{ fs = T.sdk.memfs(bad) })
T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
"and says why: " .. tostring(bytecodeRun.errors[1]))
bytecodeRun.release()
end
-- ------- the sandbox is not opt-in
do
local env = Sandbox.envFor({ modId = "probe" })
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
T.check(not pcall(env.require, "io"), "and its require refuses io")
end
T.finish("sandbox")
+141
View File
@@ -0,0 +1,141 @@
-- The "steps" permission gates the native step bridge (#1186): a
-- permissioned mod syncs and polls deliveries without ever seeing
-- love.system or the pending file; an unpermissioned mod gets a quiet
-- available() = false and loud, permission-naming refusals from the
-- calls that would act.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Steps = require("src.mods.Steps")
local WALKER = {
["mods/step_walker/manifest.json"] = [[{
"id": "step_walker",
"name": "Step Walker",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_walker/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
mod.exports.synced = mod.steps:sync()
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local SECOND = {
["mods/step_second/manifest.json"] = [[{
"id": "step_second",
"name": "Step Second",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_second/main.lua"] = [[
local mod = ...
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local UNPERMISSIONED = {
["mods/step_probe/manifest.json"] = [[{
"id": "step_probe",
"name": "Step Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/step_probe/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
local ok, err = pcall(function() return mod.steps:sync() end)
mod.exports.refused = not ok and tostring(err) or false
]],
}
local function merged(...)
local out = {}
for _, fixture in ipairs({ ... }) do
for path, body in pairs(fixture) do out[path] = body end
end
return out
end
local savedSync = T.love.system.syncHealthSteps
local syncCalls = 0
T.love.system.syncHealthSteps = function()
syncCalls = syncCalls + 1
return true
end
-- no mod: the bridge stays cold and a pending delivery stays on disk
T.love.filesystem.write(Steps.PENDING, '{"steps": 4312}')
local vanilla = T.sdk.loadNone({})
T.eq(syncCalls, 0, "no mod leaves the step bridge cold")
T.check(T.love.filesystem.read(Steps.PENDING) ~= nil,
"no mod leaves the pending delivery untouched")
vanilla.release()
-- a permissioned mod syncs and receives the delivery
local run = T.sdk.loadMods({ "mods/step_walker", "mods/step_second" },
{ fs = T.sdk.memfs(merged(WALKER, SECOND)) })
T.eq(#run.errors, 0,
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
local walker = run.loader.exports.step_walker
T.eq(walker.available, true, "available() sees the bridge")
T.eq(walker.synced, true, "sync() reaches the bridge")
T.eq(syncCalls, 1, "one sync call makes one bridge call")
local delivery = walker.poll()
T.check(delivery and delivery.steps == 4312,
"poll() hands the mod the delivered step count")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"the engine consumed the pending file, not the mod")
T.eq(walker.poll(), nil, "a delivery is handed out once per mod")
local second = run.loader.exports.step_second
local secondDelivery = second.poll()
T.check(secondDelivery and secondDelivery.steps == 4312,
"a second permissioned mod receives its own copy of the walk")
T.check(secondDelivery ~= delivery, "copies, not a shared table")
-- contract fields only, and a malformed delivery is dropped whole
T.love.filesystem.write(Steps.PENDING,
'{"steps": 12, "from": "a", "to": "b", "path": "/etc/passwd"}')
delivery = walker.poll()
T.eq(delivery.steps, 12, "the steps field travels")
T.eq(delivery.from, "a", "the from field travels")
T.eq(delivery.path, nil, "fields outside the contract do not travel")
T.love.filesystem.write(Steps.PENDING, "not json at all")
T.eq(walker.poll(), nil, "a malformed delivery is dropped, not raised")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"and the bad file does not wedge the pump")
run.release()
-- without the permission: quiet probe, loud act
local probe = T.sdk.loadMods({ "mods/step_probe" },
{ fs = T.sdk.memfs(UNPERMISSIONED) })
T.eq(#probe.errors, 0,
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
local out = probe.loader.exports.step_probe
T.eq(out.available, false, "available() is quietly false without the permission")
T.check(out.refused and out.refused:find('"steps" permission', 1, true),
"sync() without the permission names it")
probe.release()
-- no bridge on this build: sync reports it, nothing raises
T.love.system.syncHealthSteps = nil
local ashore = T.sdk.loadMods({ "mods/step_walker" },
{ fs = T.sdk.memfs(WALKER) })
local dry = ashore.loader.exports.step_walker
T.eq(dry.available, false, "available() is false without a native bridge")
T.eq(dry.synced, false, "sync() reports there was no bridge to ask")
ashore.release()
T.love.system.syncHealthSteps = savedSync
T.love.filesystem.remove(Steps.PENDING)
T.finish("steps_bridge")
+5 -5
View File
@@ -66,12 +66,13 @@ end
local files = {
["mods/alpha/manifest.json"] = manifest("alpha"),
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/alpha/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
return function(mod) mod.exports.storage = mod.storage end
]],
["mods/beta/manifest.json"] = manifest("beta"),
["mods/beta/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
return function(mod) mod.exports.storage = mod.storage end
]],
}
local fs = memfs(files)
@@ -80,12 +81,12 @@ local current = game("red", "play-a")
loader.game = current
T.check(loader:load({}) == true, "storage fixture mods load")
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
local alpha = (loader.exports.alpha or {}).storage
local beta = (loader.exports.beta or {}).storage
T.check(type(alpha) == "table" and type(beta) == "table",
"Loader exposes mod.storage through the public mod object")
if type(alpha) ~= "table" or type(beta) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()
end
@@ -176,6 +177,5 @@ T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()
@@ -57,13 +57,16 @@ local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}',
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/probe/main.lua"] = [[
return function(mod)
_G.MOD_TITLE_STORAGE = mod.storage
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
local out = mod.exports
out.storage = mod.storage
out.checkpoints = mod.checkpoints
out.restoreCount = 0
mod.events:on("checkpoint.restored", function(ev)
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
_G.MOD_TITLE_RESTORE_KIND = ev.kind
out.restoreCount = out.restoreCount + 1
out.restoreKind = ev.kind
end)
end
]],
@@ -82,7 +85,8 @@ local loader = Loader.new({ fs = fs })
loader.game = active
T.check(loader:load({}) == true, "title-context fixture mod loads")
local storage = _G.MOD_TITLE_STORAGE
local probe = loader.exports.probe or {}
local storage = probe.storage
T.check(type(storage) == "table", "loader exposes the public storage facade")
if type(storage) == "table" then
local written, writeCode, writeMessage = storage:write(active, "history/index", {
@@ -178,7 +182,7 @@ if type(storage) == "table" then
end
local runtime = makeRuntime(active.save, false)
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
local checkpoints = probe.checkpoints
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table",
@@ -238,9 +242,9 @@ if type(storage) == "table" then
end
T.same(recaptured, checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
T.eq(probe.restoreCount, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
T.eq(probe.restoreKind, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the
@@ -267,7 +271,7 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"failed title reconstruction never rewrites the normal Pokémon save")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
T.eq(probe.restoreCount, 1,
"failed title reconstruction emits no additional restored lifecycle event")
end
@@ -302,10 +306,6 @@ end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_TITLE_STORAGE = nil
_G.MOD_TITLE_CHECKPOINTS = nil
_G.MOD_TITLE_RESTORE_COUNT = nil
_G.MOD_TITLE_RESTORE_KIND = nil
SaveData.resetSlotState()
SaveData.loadOptions = originalLoadOptions
love.filesystem = realFs
+10
View File
@@ -49,6 +49,14 @@ function Link.prepare(data)
return Input
end
local function steerReplacement(game)
local top = game.stack:top()
if not (top and top.forceSwitch and top.party) then return end
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i return end
end
end
-- run a full lockstep battle over a loopback pair, mashing A on both
-- sides, and report whether any turn's hashes disagreed
function Link.lockstep(gameA, gameB, opts)
@@ -80,7 +88,9 @@ function Link.lockstep(gameA, gameB, opts)
while (resA == nil or resB == nil) and guard < limit do
guard = guard + 1
Input.pressed = { a = true }
steerReplacement(gameA)
gameA.stack:update(1 / 60)
steerReplacement(gameB)
gameB.stack:update(1 / 60)
end
+6 -4
View File
@@ -125,8 +125,11 @@ local hotFiles = {
["mods/hot_mod/main.lua"] = [[
return function(mod)
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
-- counted on mod.exports, not _G: a mod's globals are its own
-- (src/mods/Sandbox.lua), and a reload gives it a fresh table
local out = mod.exports
mod.events:on("game.ready", function()
_G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1
out.ready = (out.ready or 0) + 1
end)
end
]],
@@ -148,7 +151,6 @@ local function freshHotData()
return d
end
_G.MODKIT_TEST_READY = 0
local hotData = freshHotData()
local game = { data = hotData, save = { modData = {} } }
local bootLoader = Loader.new({ fs = hotFs })
@@ -209,7 +211,8 @@ check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B),
"untouched base record is byte-identical after reload")
check(flushed >= 1, "reload flushed the registered caches")
check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts")
check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods")
check((game.mods.exports.hot_mod or {}).ready >= 1,
"game.ready re-reaches re-subscribed mods")
ChipAudio.stopMusic = savedStopMusic
check(musicStops >= 1, "reload stops chip music through the cache bus")
Sound.play(beepData, "Fix_Beep")
@@ -228,7 +231,6 @@ local broken = HotReload.run(game, { fs = hotFs })
check(#broken.errors > 0, "broken edit lands in the error feed")
check(hotData.pokemon.FIXMON_A.baseStats.speed == 45,
"broken mod rolls back to pristine base")
_G.MODKIT_TEST_READY = nil
-- ------- dev console: repl, verbs, tracer, input isolation
+11 -4
View File
@@ -315,12 +315,19 @@ do
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
-- the reward text is a CHAIN of boxes split at each gym script's sound
-- command, so walk it: read a box's pages, close it, let its onDone push
-- the next one
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
local top = Game.stack:top()
while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end
return table.concat(parts, "\n")
end
+10 -8
View File
@@ -176,7 +176,8 @@ check(not onStack(pmSurf), "party menu closes after a successful SURF")
check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF")
-- ===========================================================================
-- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge
-- I: GetMonFieldMoves is badge-blind -- CUT/SURF/STRENGTH are listed with or
-- without the badge, and .outOfBattleMovePointers refuses on selection (#1022)
-- ===========================================================================
Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") }
Game.save.inventory = {}
@@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game)
Game.stack:push(pmNoBadge)
frame({ "a" })
local actsOff = submenuActions(pmNoBadge)
check(not actsOff.cut and not actsOff.surf and not actsOff.strength,
"no CUT/SURF/STRENGTH submenu entries without the required badges")
check(actsOff.cut and actsOff.surf and actsOff.strength,
"CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)")
popToOW()
Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true }
local pmBadge = PartyMenu.new(Game)
@@ -194,7 +195,7 @@ Game.stack:push(pmBadge)
frame({ "a" })
local actsOn = submenuActions(pmBadge)
check(actsOn.cut and actsOn.surf and actsOn.strength,
"CUT/SURF/STRENGTH submenu entries appear once the badges are held")
"CUT/SURF/STRENGTH submenu entries still there once the badges are held")
-- ===========================================================================
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
@@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby)
frame({ "a" })
local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)")
check(actsLobby.escape,
"TELEPORT still listed inside Indigo Plateau lobby (#1022)")
popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below
@@ -486,8 +488,8 @@ Game.save.inventory = {
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH
selectSubItem(pmFaintStr, 2)
-- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH
selectSubItem(pmFaintStr, 3)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
+11 -5
View File
@@ -72,13 +72,19 @@ Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
-- concatenates the pages of the TextBox checkVictoryRewards pushed
-- concatenates the pages of the box CHAIN checkVictoryRewards pushed: the
-- reward text splits into a box per gym-script sound command, so close each
-- one and let its onDone push the next
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
local top = Game.stack:top()
while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end
return table.concat(parts, "\n")
end
+1
View File
@@ -178,6 +178,7 @@ check(#moves == 0, "the parked Pikachu of the confused beat stays put")
-- BillsHouseScript0 skips the whole entry beat for a statused starter
-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
ow.pikachuBillsScene = nil
yellowGame.save.pikachuMapScriptActive = nil
moves = {}
yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
PikachuFollower.onBillsHouseEnter(yellowGame, ow)
+1 -1
View File
@@ -42,7 +42,7 @@ local ow = {
Follower.onFanClubEntered({ save = save, data = Data }, ow)
check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following")
check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag")
check(save.pikachuMapScriptActive, "Fan Club sets the map-script flag")
eq(ow.player.facing, "down", "Fan Club resets the player direction")
eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement")
eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile")
+2 -2
View File
@@ -2562,8 +2562,8 @@ do
end
eq(fanfares, 1, "one caught fanfare per capture")
eq(tinks, 3, "three wobble tinks on a $43 capture")
check(fanfareAt and caughtAt and fanfareAt < caughtAt,
"Caught_Mon sounds with the caught text, not after its dismissal")
check(fanfareAt and caughtAt and caughtAt < fanfareAt,
"Caught_Mon sounds once the caught text is out, before its prompt")
eq(cb4.result, "caught", "the capture resolved the battle")
-- the nickname AskName that follows clears it (ClearSprites), so the
-- assertion is sampled while the caught text is up
+11 -4
View File
@@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named):
"manifest.json"))
deps = manifest.get("dependencies") or []
for dep in deps if isinstance(deps, list) else []:
if not isinstance(dep, str):
dep_id = dep if isinstance(dep, str) else dep.get("id") if isinstance(dep, dict) else None
if not dep_id:
continue
found = named.get(dep) or find_mod_by_id(repo, mod_dir, dep)
if isinstance(dep, dict) and "games" in dep:
g_list = dep.get("games")
if isinstance(g_list, str):
g_list = [g_list]
if isinstance(g_list, list) and not any(g in ["gen2", "gold", "silver", "crystal", "all"] for g in g_list):
continue
found = named.get(dep_id) or find_mod_by_id(repo, mod_dir, dep_id)
if found is None:
notes.append("unresolved: dependency %s is not installed beside "
"this mod, so its games list could not be read" % dep)
"this mod, so its games list could not be read" % dep_id)
elif not declares_gen2(repo, found):
findings.append(Finding(
"MK401", "error",
f"depends on {dep}, which claims no Gen 2 game; the "
f"depends on {dep_id}, which claims no Gen 2 game; the "
f"loader disables a mod whose dependency a Gen 2 boot skipped",
"manifest.json"))
return findings, notes