Compare commits

...

60 Commits

Author SHA1 Message Date
bryanthaboi 69100301a1 Merge pull request #1460 from bryanthaboi/dev 2026-08-16 22:28:20 -04:00
bryanthaboi a5b674f9da Merge pull request #1454 from ShaneMcGovernIE/feat/mod-compute-permission 2026-08-16 22:05:10 -04:00
bryanthaboi 4356b94483 Merge pull request #1404 from emre155/fix/save-editor-cycle-move-safety 2026-08-16 22:04:49 -04:00
bryanthaboi c8bd205d0c Merge pull request #1378 from syybott/experiment/fixed-extended-world-alignment 2026-08-16 22:04:28 -04:00
bryanthaboi 995444774b Merge pull request #1458 from AverageConsumer/codex/android-asymmetric-display-routing 2026-08-16 22:00:33 -04:00
AverageConsumer 360b692963 android: route asymmetric companion displays 2026-08-17 03:00:05 +02:00
bryanthaboi 051040371f Merge pull request #1439 from thibautbus/fix/stat-rise-message-translation 2026-08-16 20:43:54 -04:00
bryanthaboi c5ff95edcf Merge pull request #1450 from thibautbus/fix/translate-clock-and-day-of-week 2026-08-16 20:43:36 -04:00
bryanthaboi d6627eda4c Merge pull request #1426 from thibautbus/fix/manager-state-draw-color 2026-08-16 20:36:13 -04:00
bryanthaboi 6e28cd5dca Merge pull request #1434 from sanjinpepic/upstream-fixes 2026-08-16 20:34:55 -04:00
bryanthaboi 08ddb882af Merge pull request #1448 from AverageConsumer/codex/android-companion-contract 2026-08-16 20:33:22 -04:00
bryanthaboi 2f6f094559 Merge pull request #1446 from 1Jamie/feat/launcher-fixes-and-patch-notes 2026-08-16 20:33:11 -04:00
bryanthaboi 6ac425144d Merge pull request #1436 from ShaneMcGovernIE/feat/android-post-bridge 2026-08-16 20:32:47 -04:00
James Hall 73f561e256 Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment 2026-08-16 19:30:19 -05:00
Shane McGovern b739fa76c0 mods: gate love.thread behind a compute permission
Threads open a fresh Lua state with a full standard library, so they
stayed blocked wholesale. PotatoVoxel's prebuilder wants to run its
pure geometry phase on worker threads; the mod declares the new
"compute" permission and the sandbox hands out love.thread only
then. The worker runs the mod's own source (source-only, like every
mod file) and receives data only through channels.
2026-08-16 23:45:09 +01:00
Shane McGovern 0b00faf38e feat(android): add httpPost bridge for mod.postLog log sends
Android ships no curl and the JNI bridge was GET-only, so mod.postLog
failed there with 'no POST transport on this platform' (HostShell.lua).
Add the mirror of httpDownload: GameActivity.httpPost (https-only,
hand-followed redirects re-POSTing the body, one-way), the JNI bridge
with the same old-APK-skew tolerance, the love.system.httpPost binding,
and the HostShell arm that rides it when curl is absent. The body
crosses the JNI as raw bytes (jbyteArray) so a log ring with arbitrary
UTF-8 cannot corrupt through modified-UTF-8 jstring conversion.
2026-08-16 22:22:38 +01:00
thibautbus b20b1370ab Reset ManagerState's draw color after Font.drawBox
Font.drawBox leaves the caller's color at white; every other screen
resets to black right after calling it, but ManagerState.lua's draw()
and drawOverlay() never did. That's invisible on the vanilla tile font
(tile glyphs are black-on-transparent regardless of color) but renders
fully invisible white-on-white text once a mod's TTF font is active.
2026-08-16 23:15:59 +02:00
thibautbus 467566c799 Cover Gold's Light Screen / Reflect fix in the same test
The targeted test only covered the RBY side (ItemEffects.lua,
TrainerAI.lua); src/battle/gen2/Battle.lua's EFFECT_LIGHT_SCREEN/
EFFECT_REFLECT fix had no test at all, spotted when asked whether the
Gold changes were covered.

Adds a minimal MACHOP/TACKLE gen2 fixture (same shape as
tests/gen2_move_effects_test.lua's), calls both MOVE_EFFECTS directly,
and checks a loaded catalog reaches the whole message template (Gold
wraps the full sentence, not just the stat name). Confirmed it catches
the regression: reverting Battle.lua to its pre-fix state fails 2 of
the suite's now 8 checks.
2026-08-16 23:15:18 +02:00
thibautbus d6ddf23f97 Add targeted coverage for the stat-rise message translation fix
No existing test could tell a translated stat name apart from a raw
stat:upper() that never went through Strings() at all: every rose!
message assertion in the suite runs with no catalog loaded, where
Strings() is an identity function either way.

Loads a real catalog (Strings.load) that translates one stat name at a
time and checks it actually reaches the X-item, vitamin, and AI-trainer
X-item messages -- ROM-free, over tests/fixture_data. Confirmed this
catches the regression: reverting src/inventory/ItemEffects.lua and
src/battle/TrainerAI.lua to their pre-fix state fails 5 of 6 checks.
2026-08-16 23:15:18 +02:00
thibautbus 9423337bcc Make the rose! messages' stat name harvestable by the mod catalog tool
Strings(stat:upper()) is a dynamic argument -- tools/modkit.py's
STRINGS_CALL harvester only matches a literal string right after
Strings(/Strings.source(, so it can't discover "ATTACK"/"DEFENSE"/etc.
from these call sites. Translation coverage happened to still work
only because the same literals are independently harvested from
unrelated call sites (MoveEffects.lua's STAT_LABEL, BattleState.lua's
literal Strings("ATTACK") calls) -- real but fragile, found in review.

Reuse the codebase's existing pattern for exactly this situation
(MoveEffects.lua's STAT_LABEL): a local table built at require time
with Strings.source(...), which the harvester can see, resolved to a
translated label at use time with Strings(TABLE[key]). Adds one such
table to ItemEffects.lua (covering its X-item and vitamin call sites,
including "hp") and one to TrainerAI.lua.
2026-08-16 23:15:18 +02:00
thibautbus c280119d03 Translate Gold's Light Screen / Reflect rose! messages
Same theme as the RBY fix, found while checking whether Gold had the
same gap: EFFECT_LIGHT_SCREEN and EFFECT_REFLECT built their "'s
SPCL.DEF/DEFENSE rose!" message by raw string concatenation, bypassing
Strings() entirely -- unlike most other messages in this file (e.g.
"%s\nused %s!" a few lines up), which already go through it.

Wrap the whole message template in Strings(), matching that existing
pattern; the substituted name still comes from monName() as before.

Gold's gen2/Battle.lua has many more messages built the same
unwrapped way (fainted!, learned..., missed!, and so on) -- that is
the much larger "Battle messages" gap already tracked separately and
deliberately left out of this change.
2026-08-16 23:15:18 +02:00
thibautbus 24d0c6528d Translate the stat name in RBY's X-item and vitamin rose! messages
Both the player-side and AI-trainer stat-rise messages (X ATTACK/
DEFENSE/etc. and the vitamins) passed the raised stat's name as a raw
uppercase Lua string (stat:upper()), bypassing Strings() entirely, so
it always rendered in English regardless of the active language even
though the surrounding sentence template was already translated.

Wrap the substituted stat name in Strings() at every call site
(src/inventory/ItemEffects.lua's two player-side messages and
src/battle/TrainerAI.lua's AI-trainer X-item message, found in
review), reusing the same "ATTACK"/"DEFENSE"/"SPEED"/"SPECIAL"/"HP"
keys SummaryMenu.lua's stat labels already look up the same way.
2026-08-16 23:15:18 +02:00
AverageConsumer 1fc34da2a9 android: extend secondary display presentation 2026-08-16 22:57:10 +02:00
sanjinpepic 70b9def0b0 Make Mon.syncIdentity's shiny recompute monotonic
syncIdentity unconditionally recomputed mon.shiny from the mon's DVs,
overwriting whatever was there. It is wired into refreshStats, which
SummaryMenu.new calls on every menu open, so a forced shiny -- Mon.new's
opts.shiny path, DVs that do not themselves read as shiny -- got
un-shinied the moment the summary screen opened, even though opts.shiny
already wins over shiny.roll at construction for exactly this case (a
scripted shiny is the cart overriding the roll, not a roll to be
hooked).

mon.shiny now only ever gets PROMOTED by the DV check, never demoted:
`mon.shiny or Mon.isShiny(...)`. A naturally shiny mon and a plain one
are unaffected -- the DV check still runs and still decides the first
time -- and a mon whose DVs are edited to justify shininess later still
promotes normally; only an already-true shiny stops being able to flip
back to false on a later refresh.
2026-08-16 22:48:34 +02:00
sanjinpepic d03d2af5f8 Pass data through to ItemEffects.partyAction for Gen 2 pack items
Both call sites -- Game2:usePartyItem (the field pack) and
BattleState:useItem (the battle pack) -- asked ItemEffects.partyAction
for an item's family with no `data` argument, even though every other
call in the same functions (useOnMon, usePpItem, applyPartyItem) passed
it through correctly. partyAction resolves through recordFor, which
reads data.gen2ItemEffects when given a dataset and falls back to the
module's own built-in RECORDS table when not -- so with no data, a
mod's own item_effects record was invisible and every mod-defined Gen 2
field or battle item resolved to a nil action, falling straight through
to "isn't going to help here" / "isn't going to help here" without ever
opening the party picker.

Both now pass the live dataset (self.data on Game2, self.game.data on
the battle screen) the same way their sibling calls already did.
2026-08-16 22:48:34 +02:00
sanjinpepic c23f85cba9 Bind gen2Constants in the save editor's Gold bootstrap
bindGoldData points gen2Palettes, gen2Icons, gen2Pokedex, gen2Landmarks,
gen2Roofs and gen2Sprites at the extractor's own Gold tables through
loadGen, but never gen2Constants -- despite Schemas.GEN2 routing
`constants` to that same namespaced-and-differently-shaped category
palettes and icons are in. A save editor boot left data.gen2Constants
unset, so mod.content.constants:get(...) read an empty table instead
of the cart's ordered name lists, misreading the generation and
rejecting every record a mod shaped off it.

data.gen2Constants now goes through the same loadGen("constants") path
the other five already use, falling back the same way they do when no
ROM cache is active.
2026-08-16 22:48:33 +02:00
sanjinpepic 455ff21aff Validate a map object's pokemon field against the pokemon registry
R.maps.objects was f.opt(f.list(f.any)): a static wild encounter's
species (OverworldController.lua's d.pokemon, handed straight to
BattleState.newWild) went completely unchecked at load time, unlike an
encounter slot's species. A typo'd or removed id sat in a loaded mod
and only surfaced as a crash the moment a player reached that object.

Objects share one array across every kind -- NPCs, signs, warps and
static encounters all coexist with no field the loader could use to
tell them apart ahead of time -- so a strict f.rec covering the whole
shape would reject every kind this schema does not enumerate. Added
f.partial, an open counterpart to f.rec: it type-checks (and, through
collectRefs, cross-reference-checks) only the fields it is given and
leaves everything else on the value alone, the same extensibility
f.rec already grants at a record's top level but nowhere further in.
R.maps.objects now types just `pokemon` through it, so a bad species
id is a load-time "unresolved reference" error instead of a runtime
crash, while an NPC object's sprite/movement/range/... fields -- never
named in this schema -- still pass through untouched.
2026-08-16 22:48:33 +02:00
sanjinpepic 2da2168dac Refuse a TM/HM on a species with no tmhm list instead of crashing
ItemEffects.use walked speciesDef.tmhm with a bare ipairs() to check
whether the species could learn the machine's move. A record with no
tmhm field at all -- a mod species that never set one, or any record
missing it for whatever reason -- hit ipairs(nil) and took the whole
game down on the first TM/HM use, rather than reaching the ordinary
"can't learn that move" refusal a species whose list simply omits the
move already gets.

An absent list now reads the same as an empty one: nothing to learn,
same refusal, same sound, same text.
2026-08-16 22:48:33 +02:00
sanjinpepic f62b1268c8 Add an item.use hook around BagMenu's item-use dispatch
useOn was a plain Lua local: every result ItemEffects.use returned fell
through to one unconditional showMessages with no seam a mod could
reach, unlike menu.lua/boxmark.lua/formview.lua's screens, which wrap
their own default behavior as a table field or a Runtime hook. A mod
could not suppress a message, delay it behind a screen of its own, or
substitute a different outcome for one item id -- exactly the gap noted
against Ultra Burst's item-driven fusion, which had nowhere left to
attach a bespoke animation once TextBox.new turned out to be the only
other reachable seam.

This wraps the whole dispatch in a Runtime.call("item.use", ...) hook,
the same mechanism "battle.overlay", "ui.party.submenu" and the rest of
src/ui already use, rather than exporting BagMenu.useOn as a table
field. A hook is the smaller commitment: it is additive (a fresh
Runtime.call site needs no schema or manifest change and costs nothing
unsubscribed -- see tests/engine/gate_hooks.lua's null-object case) and
a mod can still run the vanilla flow unchanged by calling the handed-in
vanilla function, whereas a table field would fix useOn's exact
signature as public API the moment it shipped. If the maintainer would
rather match the sibling screens' convention directly, exporting
BagMenu.useOn is the alternative and does not conflict with this hook
existing alongside it.

vanillaUseOn keeps the original function body; useOn is now the thin
wrapper mods observe through, and every internal caller in this file
still goes through useOn so the hook fires on every path into it.
2026-08-16 22:48:33 +02:00
sanjinpepic 82ae667611 Update the crossValidate comment for growth_rates / evolution_methods
The comment above the gatedFor skip in Schemas.crossValidate still
described growth_rates and evolution_methods as unconfirmable Gen 2
namespaces, the way they were before each got a real Gen 2 id space:
growth_rates keeps its Gen 1 target and is seeded from the extractor's
data.pokemon.growthRates (src/battle/gen2/Mon.lua), and
evolution_methods routes to gen2EvolutionMethods, a fixed literal set
(src/core/gen2/Evolution.lua) that exists with or without a ROM
import. Schemas.GEN2 does not gate either name -- gate_gen2_mod_api.lua
pins that directly, including a case that a bad evolution method on a
Gold species is still caught -- so the two are validated like any other
reference today, not skipped.  Nothing here changes that behavior;
only the comment, which was describing an earlier state of the code,
is corrected.
2026-08-16 22:48:33 +02:00
sanjinpepic 2b5229e73f Deny require("jit.util") in the mod sandbox
DENIED_PREFIX blocked love.* and ffi.* submodule requires but had no
entry for jit, so require("jit.util") walked straight through to the
real module.  jit.util is LuaJIT's own equivalent of the debug library
this file already denies by name: funcbc, funck and the rest read the
bytecode and constants of any function a chunk can reach, which is
enough to recover upvalues -- the real _G, love, io -- that the sandbox
exists to keep out of a mod's hands.

Adding "jit" to DENIED_PREFIX blocks jit.* submodule requires the same
way love.* and ffi.* already are, while leaving the bare jit global
(env.jit, handed over directly for jit.on/off/flush) and a bare
require("jit") untouched -- jit.util is not a field of that table
without its own require, so neither route was ever a way to reach it.
2026-08-16 22:48:33 +02:00
sanjinpepic 881670db91 Clear drainHold once the HP-bar drain actually finishes
stepHPDrain counts drainHold down to 0 as the last step of every phase
(pixel slide, HP-number step, closing frames) but never let go of the
field afterward, so it sat at 0 -- not nil -- for the rest of the
battle.  BattleSafety.inspect uses drainHold ~= nil as its
settled-presentation gate for checkpoint capture, so the very first HP
change in a battle permanently refused every checkpoint after it with
battle_phase_busy, even once the bar had long since caught up.

Only nil the field when the whole drain is actually over (bar pixel,
HP number and the closing-frame hold all settled), not on every
mid-sequence 0 -- a fresh HP change still needs drainHold to read as
busy so BattleSafety keeps refusing captures until that one settles
too.
2026-08-16 22:48:33 +02:00
1jamie a542ed90ba refactor: consolidate loose constants into tables in World.lua and add GameViewport module dependency
and updated the behavior of the patch notes
also fixed manual update checking
added test to make sure no prs or build tasks are able to pass if the luajit limits ar exceeded.
2026-08-16 15:47:56 -05:00
1jamie e9a4a592a4 feat(update): cache fetched release notes and strict-match patch notes version 2026-08-16 15:47:56 -05:00
thibautbus 70f7d5c028 Translate the clock-setting screens' day names and time-of-day word
DAYS (SUNDAY..SATURDAY), the MORN/DAY/NITE word PrintHour prints, and the
"o'clock"/"min." suffixes bypassed src/core/Strings.lua entirely -- they
were plain Lua literals with no lookup, so a translation mod's `strings`
registry had nothing to catch and Oak's clock screens, the day-of-week
wheel, the main menu clock box and the Pokegear's clock card kept printing
English regardless of the loaded language (reported from a real Spanish
Gold build).

Both live in src/core/gen2/Clock.lua, which already owns weekday/hour
arithmetic and is already required by InitClock.lua, MainMenu.lua and
Pokegear.lua: Clock.DAY_NAMES + Clock.weekdayName(day) is the one place the
three screens read a weekday's name from, so a fix to it cannot land on one
screen and silently miss the other two. Clock.daytimeLabel(hour) is the
translated counterpart to Palettes.clockDaytime, which keeps answering the
untranslated MORN/DAY/NITE key every FORCED_DAYTIME lookup in Palettes.lua
compares against -- src/world/gen2/Palettes.lua itself is untouched, so
that module stays pure table/color math with no Strings coupling.
2026-08-16 22:45:03 +02:00
bryanthaboi 46f73b7bb3 Merge pull request #1445 from AverageConsumer/codex/desktop-companion-display 2026-08-16 16:40:10 -04:00
AverageConsumer 99d9908017 render: add cross-platform desktop companion display 2026-08-16 22:24:12 +02:00
bryanthaboi b72d1d34b5 Merge pull request #1438 from AverageConsumer/codex/fix-gen2-viewport-local-limit 2026-08-16 16:15:05 -04:00
AverageConsumer f3619a00c2 fix(gen2): stay below LuaJIT local limit 2026-08-16 20:43:38 +02:00
syybott 524138ff27 Fix wide battle shake test fixture 2026-08-16 11:52:00 -05:00
bryanthaboi fdffb12571 Merge pull request #1399 from AverageConsumer/codex/mod-battle-special-intents 2026-08-16 12:09:03 -04:00
bryanthaboi 0c941cecd4 Merge pull request #1402 from AverageConsumer/codex/mod-field-advanced-actions 2026-08-16 12:08:47 -04:00
bryanthaboi dd59175c71 Merge pull request #1405 from AverageConsumer/codex/mod-render-viewport 2026-08-16 12:08:03 -04:00
github-actions 114352b75f chore(ios): update app-repo.json [skip ci] 2026-08-16 10:23:08 -04:00
bryanthaboi 0e40a7a1f4 Merge pull request #1408 from bryanthaboi/dev 2026-08-16 10:14:26 -04:00
bryanthaboi fe6a580e20 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	src/world/gen2/World.lua
2026-08-16 10:11:53 -04:00
bryanthaboi a910b65434 CLOSES #1231, CLOSES #1269, CLOSES #1301 2026-08-16 10:04:54 -04:00
AverageConsumer 1f3d13adaf mods: add OS-independent game viewport composition 2026-08-16 15:17:35 +02:00
Yunus Emre Umar 393a1013e4 fix(save-editor): guard cycleMove against undefined moves in catalog
Closes #1403
2026-08-16 16:11:32 +03:00
AverageConsumer a3a20a07e1 feat(mods): expose Fly and Softboiled field actions 2026-08-16 14:57:46 +02:00
bryanthaboi 1151c188a7 CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391 2026-08-16 08:55:40 -04:00
AverageConsumer b39e11b7cd feat(mods): add special battle intents 2026-08-16 14:43:08 +02:00
bryanthaboi 65128e13a4 Update bug_report.yml 2026-08-16 07:28:08 -04:00
github-actions d74662ba99 chore(ios): update app-repo.json [skip ci] 2026-08-16 07:04:20 -04:00
bryanthaboi 32e6e4cef0 conv 2026-08-16 06:50:17 -04:00
bryanthaboi fc841f7525 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-16 06:41:58 -04:00
bryanthaboi 12fdfa1e88 CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362 2026-08-16 06:41:56 -04:00
syybott 90eb53b00c Keep WIDE battle visible beneath opaque menus 2026-08-15 23:29:04 -05:00
syybott 6780393f45 Add extended battle HUD visual coverage 2026-08-15 19:39:48 -05:00
syybott 530f2bdd15 Add optional extended widescreen battle HUD 2026-08-15 19:39:48 -05:00
137 changed files with 7128 additions and 843 deletions
+4
View File
@@ -37,12 +37,16 @@ body:
id: os id: os
attributes: attributes:
label: Which build are you running label: Which build are you running
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
options: options:
- macOS - macOS
- Windows - Windows
- Linux - Linux
- Android - Android
- iOS - iOS
- Nintendo Switch
- Xbox
- Anbernic RG34XXSP
- Multiple platforms - Multiple platforms
validations: validations:
required: true required: true
+15 -2
View File
@@ -6,18 +6,30 @@ function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1" local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1" local developer = os.getenv("POKEPORT_DEV") == "1"
local companion = nil
if arg then if arg then
for _, a in ipairs(arg) do for _, a in ipairs(arg) do
if a == "--editor" then editor = true end if a == "--editor" then editor = true end
if a == "--developer" then developer = true end if a == "--developer" then developer = true end
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
if port then companion = { port = tonumber(port), token = token } end
end end
end end
-- main.lua runs in the same Lua state right after conf.lua; stash the -- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`. -- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer _G.POKEPORT_DEV_MODE = developer
_G.POKEPORT_DISPLAY_COMPANION = companion
if editor then if companion then
t.identity = "pokemon-love2d-companion"
t.window.title = "gen1recomp Secondary Display"
t.window.width = 640
t.window.height = 576
t.window.minwidth = 160
t.window.minheight = 144
t.window.resizable = true
elseif editor then
-- Same identity as the game, deliberately: the editor edits the game's -- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this -- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an -- folder. A private editor identity would point love.filesystem at an
@@ -51,7 +63,8 @@ function love.conf(t)
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 t.window.vsync = 1
t.modules.joystick = true t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
-- love.system is not loaded during love.conf; love._os is set by the -- love.system is not loaded during love.conf; love._os is set by the
+136 -7
View File
@@ -3,9 +3,8 @@
-- guide and SUPER_NERD2 garden nerd. -- guide and SUPER_NERD2 garden nerd.
-- --
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in -- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this -- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the -- is below.
-- YES/NO-branched flavor text is here.
local M = {} local M = {}
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
game.stack:push(TextBox.new(game, s, nil, { choice = cb })) game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end end
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
local museumGuySteps = {
"up", "up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up",
"left",
}
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
local museumPlayerRle = {
"NO",
"up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up", "up", "up", "up",
}
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
local museumPreambles = {
["27,18"] = { "up", "up" },
["27,16"] = { "right", "left" },
["26,17"] = { "up", "right" },
["28,17"] = { "up", "left" },
}
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
-- pewterEscort.playerPlan in story5.lua
local function museumPlan(x, y)
local pre = museumPreambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
local function museumEscortWalk(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
if not plan then return false end
local Music = require("src.core.Music")
local t = text(game)
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
local head = plan.guyHeadStart
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
local function walkOut()
if not guy then return end
local i = 0
local function tick()
i = i + 1
if i > 4 then
guy.cellX, guy.cellY = 27, 17
guy.px, guy.py = 27 * 16, 17 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down"
return
end
ow:scriptMove(guy, "down", 1, tick)
end
tick()
end
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
local function afterWalk()
if guy then
guy.stepFrames = nil
guy.cellX, guy.cellY = 13, 8
guy.px, guy.py = 13 * 16, 8 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "up"
end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCitySuperNerd1ItsRightHereText
or "It's right here!", walkOut)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = museumGuySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
if guy then
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
return true
end
M.PEWTER_CITY = { M.PEWTER_CITY = {
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
talk = { talk = {
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you -- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
-- checked out the museum; YES -> fossils comment, NO -> "you have -- fossils comment, NO -> "you have to go" and the museum escort
-- to go" (which in pokered also kicks off the escort script).
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done) TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
or "Weren't those\nfossils from MT.\nMOON amazing?", done) or "Weren't those\nfossils from MT.\nMOON amazing?", done)
else else
push(game, t._PewterCitySuperNerd1YouHaveToGoText push(game, t._PewterCitySuperNerd1YouHaveToGoText
or "Really?\nYou absolutely\nhave to go!", done) or "Really?\nYou absolutely\nhave to go!", function()
museumEscortWalk(game, ow)
if done then done() end
end)
end end
end) end)
end, end,
+1
View File
@@ -288,6 +288,7 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port -- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596). -- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = { local rows = {
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
{ "face_player_dir", "up" }, { "face_player_dir", "up" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
+1
View File
@@ -263,6 +263,7 @@ return {
local rival = ow:npcByIndex(RIVAL) local rival = ow:npcByIndex(RIVAL)
if not rival then return false end if not rival then return false end
local rows = { local rows = {
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
{ "face_player_dir", "up" }, { "face_player_dir", "up" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
+4
View File
@@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = {
-- Daisy hands over the TOWN MAP once Oak's errand is under way -- Daisy hands over the TOWN MAP once Oak's errand is under way
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText) -- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
M.BLUES_HOUSE = { M.BLUES_HOUSE = {
-- scripts/BluesHouse.asm:12-16
onEnter = function(game, ow)
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
end,
talk = { talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = { TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" }, { "face_player" },
+12
View File
@@ -87,6 +87,18 @@ end
M.PALLET_TOWN = { M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk, talk = require("data.scripts.pallet_town").talk,
escort = escort, escort = escort,
-- scripts/PalletTown.asm:133-144
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
and not f.EVENT_DAISY_WALKING then
f.EVENT_DAISY_WALKING = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
end
end,
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4), -- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- then a wild Pikachu battle before the lab escort (pokeyellow -- then a wild Pikachu battle before the lab escort (pokeyellow
-- PalletTownPikachuBattleScript). -- PalletTownPikachuBattleScript).
+1 -6
View File
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end end
puff(3, 15) puff(3, 15)
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the -- scripts/VermilionDock.asm:182-203
-- player alone ("south of the player and won't be redrawn"), so skip
-- his own block: he must not spend the walk-out standing on water
local pbx = math.floor(ow.player.cellX / 2)
local pby = math.floor(ow.player.cellY / 2)
local rows = {} local rows = {}
local function setBlock(bx, by, block) local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end if bx < 1 or bx > 8 then return end
if bx == pbx and by == pby then return end
rows[#rows + 1] = { "replace_block", bx, by, block } rows[#rows + 1] = { "replace_block", bx, by, block }
end end
rows[#rows + 1] = { "wait", 120 } rows[#rows + 1] = { "wait", 120 }
+1 -1
View File
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
passes `game`; positions 2-4 (mon, row, trigger) match. passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`, `render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits `render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it 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 -- 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 first refusal on, the palette zone list handed to the present pass, the
+61 -14
View File
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
start at the player's current position. Both games expose `bicycle`, `fish`, start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally `cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
are valid choices. The list is empty while the world is busy, and omits an eligible `sources`; each source contains its eligible `targets`. Fishing rows
action whenever its item, move, badge, terrain, or engine state forbids it. include the owned rods that are valid choices. The list is empty while the
world is busy, and omits an action whenever its item, move, badge, terrain, or
engine state forbids it.
The optional second return is `"world is busy"` during transient input locks The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists. or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }` the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and and chooses automatically when only one rod is available. Red's `softboiled`
busy requests return `nil` plus a reason without changing game state. Mods do accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
not need generation-specific badge, terrain, bike, fishing, or field-move record. Invalid, stale, and busy requests return `nil` plus a reason without
changing game state. Mods do not need generation-specific badge, terrain,
bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length. understand and ignore unknown ids rather than assuming a fixed list length.
Red exposes FLY separately because it requires a destination picker:
`mod.world:canFly()` reports whether FLY is eligible at the current location,
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Read-only battle snapshots ## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "move", slot = 1..4 }` - `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active - `{ kind = "back" }` while the move menu is active
Red, Blue, and Yellow also expose their generation-specific choices:
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
other accepted actions)
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
Menu choices and moves use the same engine methods as the native controls; Menu choices and moves use the same engine methods as the native controls;
`party` and `item` open the native screens rather than exposing or duplicating `party` and `item` open the native screens rather than exposing or duplicating
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle their mutable logic. Tutorial, link, forced, stale, and covered battle states
states refuse these core intents. Use `mod.input` for ordinary text advance. refuse core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines ## Rendering pipelines
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
`input.pointer` delivers uncaptured gameplay pointer events -- touches and `input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev` real mouse input alike. The callback receives `(next, game, ev)` where `ev`
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"` `"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
are LOVE window units, the same space `render.hud`'s viewport and the touch `x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
overlay lay out in. The on-screen touch controls keep first refusal: a active game viewport and `insideGame` says whether the pointer is inside it.
Without a custom viewport both coordinate pairs are identical. The on-screen
touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook visible even if it later crosses a control. A real mouse reaches the hook
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state. playfield or pushing an updating game state.
`render.viewport` lets a layout mod reserve the window-space rectangle in which
the game renders. It receives `(next, ctx)` with the full window's `width`,
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
window and makes game layout, safe-area calculations, and rendering use it as
their display. Set `capture = true` to request a composition canvas even when
the rectangle fills the window. With no subscriber, no canvas is allocated and
the normal presentation path is unchanged.
When a viewport is active, `render.window` receives `(next, game, ctx)` after
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
a wrapper may instead compose that canvas with its own UI. Touch controls remain
full-size OS-window chrome and draw after this hook.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It `render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's the mod full control of the window, while calling `next` runs the engine's
@@ -699,11 +733,24 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / `secondScreen` bridge (`available()` / `detected()` / `push(...)` /
`setEnabled`) for driving a second physical display. `pollTouch()` returns the `pollTouch()` / `setEnabled`) for driving a second physical display.
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. `detected()` reports a connected target even while its output is being created;
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
arguments request an extended presentation; a preference ending in `:cover`
fills and crops the target, while other values preserve the whole frame.
Android also accepts `handheld` or `secondary` (with an optional `:cover`
suffix) as routing hints; unsupported or unavailable targets fall back to the
other connected display.
`pollTouch()` returns the 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, 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. or push one onto a second screen, without the engine knowing the layout.
On process-capable Windows, Linux and macOS hosts without a native display
bridge, enabling this facade opens a second resizable app window instead. It
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam `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 for mods that need the engine's normal composite rather than its separate
+13 -1
View File
@@ -8,6 +8,11 @@
-- opens the editor on that slot's file, and restores the launcher when -- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below) -- the editor's Close button is pressed (openEditor / closeEditor below)
if POKEPORT_DISPLAY_COMPANION then
return require("src.render.DesktopCompanion").install(
POKEPORT_DISPLAY_COMPANION)
end
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay") local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks") local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay") local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
@@ -503,24 +509,30 @@ end
function love.draw() function love.draw()
if editorMode then if editorMode then
GameViewport.reset()
HostDisplay.beginFrame("editor", EditorApp) HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw() local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp) HostDisplay.endFrame("editor", EditorApp)
return result return result
end end
if TouchEditor then if TouchEditor then
GameViewport.reset()
HostDisplay.beginFrame("touch_editor", TouchEditor) HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw() local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor) HostDisplay.endFrame("touch_editor", TouchEditor)
return result return result
end end
if Importer then if Importer then
GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer) HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw() local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer) HostDisplay.endFrame("launcher", Importer)
return result return result
end end
if not Game then return end if not Game then
GameViewport.reset()
return
end
HostDisplay.beginFrame("game", Game) HostDisplay.beginFrame("game", Game)
Game:draw() Game:draw()
@@ -31,6 +31,9 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/love" android:icon="@drawable/love"
android:label="${NAME}" > android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<activity <activity
android:name="org.love2d.android.GameActivity" android:name="org.love2d.android.GameActivity"
android:exported="true" android:exported="true"
@@ -49,5 +52,15 @@
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="${ORIENTATION}"
android:taskAffinity="${applicationId}.secondary"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application> </application>
</manifest> </manifest>
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result; return result;
} }
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
{
if (url == nullptr || body == nullptr || bodyLen < 0)
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// Same resolution rule as httpDownload: the activity's own class via
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
// the main thread (love.thread workers), whose class loader cannot see
// app classes.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" the same way a
// missing curl does, instead of aborting on a missing method (#597).
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jstring jurl = env->NewStringUTF(url);
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
// and a jstring would run it through modified UTF-8
jbyteArray jbody = env->NewByteArray(bodyLen);
if (jbody != nullptr)
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
env->DeleteLocalRef(jurl);
if (jbody != nullptr)
env->DeleteLocalRef(jbody);
if (jct != nullptr)
env->DeleteLocalRef(jct);
if (jua != nullptr)
env->DeleteLocalRef(jua);
env->DeleteLocalRef(activity);
return result;
}
/* /*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's * TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a * own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
extern "C" __attribute__((visibility("default")))
void love_android_secondary_target(int target)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity,
"setSecondaryDisplayTarget", "(I)V");
if (method)
env->CallStaticVoidMethod(activity, method, target);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
int love_android_secondary_detected()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity,
"hasSecondaryDisplayCandidate", "()Z");
jboolean detected = JNI_FALSE;
if (method)
detected = env->CallStaticBooleanMethod(activity, method);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return detected ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
int love_android_present_secondary(const void *rgba, int width, int height,
unsigned int background, int cover)
{
if (!rgba || width <= 0 || height <= 0)
return 0;
jlong size = (jlong) width * (jlong) height * 4;
if (size <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
"(Ljava/nio/ByteBuffer;IIIZ)Z");
if (!method)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return 0;
}
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
if (!frame)
{
env->DeleteLocalRef(activity);
return 0;
}
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
env->DeleteLocalRef(frame);
env->DeleteLocalRef(activity);
return shown ? 1 : 0;
}
extern "C" __attribute__((visibility("default"))) extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch() const char *love_android_poll_secondary_touch()
{ {
@@ -98,6 +98,14 @@ bool restartApp();
**/ **/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
/**
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
* mirror of httpDownload for mod.postLog log sends, which need POST and
* have no curl on Android. contentType / userAgent may be null. Returns
* whether the server accepted the send (2xx).
**/
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
/** /**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif #endif
} }
bool System::httpPost(const char *url, const char *body, int bodyLen,
const char *contentType, const char *userAgent) const
{
#ifdef LOVE_ANDROID
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(body);
LOVE_UNUSED(bodyLen);
LOVE_UNUSED(contentType);
LOVE_UNUSED(userAgent);
return false;
#endif
}
int System::tlsOpen(const char *host, int port) const int System::tlsOpen(const char *host, int port) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -151,6 +151,14 @@ public:
virtual bool httpDownload(const char *url, const char *destPath, virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const; const char *userAgent = nullptr, const char *accept = nullptr) const;
/**
* Blocking HTTPS POST of a raw byte body (Android only; false
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
* which need POST and have no curl on Android (#597).
**/
virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const;
/** /**
* TLS client sockets (Android only; every call fails elsewhere, where * TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract: * LuaSec or another provider is the answer). Non-blocking by contract:
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
return 1; return 1;
} }
int w_httpPost(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
size_t bodyLen = 0;
const char *body = luaL_checklstring(L, 2, &bodyLen);
const char *ct = luaL_optstring(L, 3, nullptr);
const char *ua = luaL_optstring(L, 4, nullptr);
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
return 1;
}
int w_hasBackgroundMusic(lua_State *L) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost },
{ "tlsOpen", w_tlsOpen }, { "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus }, { "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend }, { "tlsSend", w_tlsSend },
@@ -60,6 +60,7 @@ import android.os.Environment;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.Vibrator; import android.os.Vibrator;
import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
import android.view.*; import android.view.*;
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
onHostResume(); onHostResume();
refreshDualScreenDisplayMode();
if (secondaryEnabled) registerSecondaryDisplayListener(); if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay(); setupSecondaryDisplay();
} }
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
// gameplay button. The setting is absent on other Android devices.
if (secondaryEnabled && dualScreenDisplayMode != -1
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
return true;
}
return super.dispatchKeyEvent(event);
}
/** /**
* SDL decides the activity's requested orientation at window creation * SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no * (SDLActivity.setOrientationBis). With a resizable window and no
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
} }
} }
/**
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
* downloads; log sends need POST, and Android ships no curl, so this is
* the only POST transport the platform has. Strictly one-way, matching
* the curl branch it mirrors: the response body is drained and
* discarded, and only the 2xx verdict comes back.
*
* Same rules as httpDownload: https only, redirects followed by hand
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
* the call is blocking on the Lua/worker thread -- never the UI thread.
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
* corruption on characters outside the BMP.
*/
@Keep
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
if (url == null || body == null) return false;
HttpURLConnection conn = null;
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
conn.setRequestProperty("Content-Type",
contentType == null ? "text/plain" : contentType);
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
try {
out.write(body);
} finally {
try { out.close(); } catch (IOException ignored) {}
}
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) return false;
current = new URL(parsed, next).toString();
continue;
}
if (code < 200 || code > 299) return false;
// drain and discard, so a slow server cannot wedge the
// worker on a full socket buffer
InputStream in = new BufferedInputStream(conn.getInputStream());
try {
byte[] buf = new byte[16384];
while (in.read(buf) > 0) {}
} finally {
try { in.close(); } catch (IOException ignored) {}
}
return true;
}
return false;
} catch (Exception e) {
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
return false;
} finally {
if (conn != null) conn.disconnect();
}
}
/** /**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive / * (pending_export.sav in the app save identity) to Downloads / Drive /
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary // Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_* // physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp. // in src/jni/love/src/common/android.cpp.
private static final int SECONDARY_TARGET_AUTO = 0;
private static final int SECONDARY_TARGET_HANDHELD = 1;
private static final int SECONDARY_TARGET_EXTERNAL = 2;
// AYN keeps disabled panels registered as ON. This optional setting is the
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
private static final String AYN_SECOND_SCREEN = "Screen-2";
private static volatile SecondaryPresentation secondaryPresentation; private static volatile SecondaryPresentation secondaryPresentation;
private static volatile SecondaryActivity secondaryActivity;
private static volatile boolean secondaryActivityPending;
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame;
private static volatile int secondaryFrameWidth;
private static volatile int secondaryFrameHeight;
private static volatile int secondaryBackground;
private static volatile boolean secondaryFrameCover;
private static final Object secondaryFrameLock = new Object();
private static volatile long secondaryDetectionAt;
private static volatile boolean secondaryDetected;
private SecondaryDisplayMonitor secondaryDisplayMonitor; private SecondaryDisplayMonitor secondaryDisplayMonitor;
private boolean dualScreenModeObserverRegistered;
private final android.database.ContentObserver dualScreenModeObserver =
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
@Override public void onChange(boolean selfChange, Uri uri) {
refreshDualScreenDisplayMode();
rebindSecondaryDisplay();
}
};
private static final int MAX_SECONDARY_TOUCHES = 32; private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches = private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>(); new java.util.ArrayDeque<>();
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
self.runOnUiThread(new Runnable() { self.runOnUiThread(new Runnable() {
@Override public void run() { @Override public void run() {
if (on) { if (on) {
self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener(); self.registerSecondaryDisplayListener();
setupSecondaryDisplay(); rebindSecondaryDisplay();
} else { } else {
self.unregisterSecondaryDisplayListener(); self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay(); teardownSecondaryDisplay();
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
} }
} }
}); });
} }
@Keep
public static void setSecondaryDisplayTarget(int target) {
int normalized = target == SECONDARY_TARGET_HANDHELD
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
if (secondaryTarget == normalized) return;
secondaryTarget = normalized;
secondaryDetectionAt = 0;
rebindSecondaryDisplay();
}
private void refreshDualScreenDisplayMode() {
int mode = Settings.System.getInt(
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
dualScreenDisplayMode = mode;
}
private void registerSecondaryDisplayListener() { private void registerSecondaryDisplayListener() {
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return; if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor; if (monitor.register()) secondaryDisplayMonitor = monitor;
}
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
getContentResolver().registerContentObserver(
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
dualScreenModeObserver);
dualScreenModeObserverRegistered = true;
}
} }
private void unregisterSecondaryDisplayListener() { private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor; SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null; secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister(); if (monitor != null) monitor.unregister();
if (dualScreenModeObserverRegistered) {
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
dualScreenModeObserverRegistered = false;
}
} }
private static void refreshSecondaryDisplay() { private static boolean secondaryOutputIsPreferred(GameActivity self) {
GameActivity self = (GameActivity) mSingleton; Display preferred = findSecondaryDisplay(self, false);
if (self == null || !secondaryEnabled) return; if (preferred == null) return false;
SecondaryPresentation current = secondaryPresentation; SecondaryPresentation presentation = secondaryPresentation;
Display display = current == null ? null : current.getDisplay(); Display display = presentation == null
? null : presentation.getDisplay();
if (display == null) {
SecondaryActivity activity = secondaryActivity;
display = activity == null ? null : getActivityDisplay(activity);
}
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor; SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
if (current == null) { if (display == null || (monitor != null
setupSecondaryDisplay(); && !monitor.hasDisplay(display.getDisplayId()))) return false;
} else if (display == null || monitor == null return display.getDisplayId() == preferred.getDisplayId();
|| !monitor.hasDisplay(display.getDisplayId())) { }
private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay(); teardownSecondaryDisplay();
setupSecondaryDisplay(); setupSecondaryDisplay();
} });
} }
private static void setupSecondaryDisplay() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return; if (self == null || !secondaryEnabled || secondaryPresentation != null
|| secondaryActivity != null || secondaryActivityPending
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try { try {
android.hardware.display.DisplayManager dm = Display chosen = findSecondaryDisplay(self, true);
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) { if (chosen == null) {
Log.d("GameActivity", "no secondary display found"); Log.d("GameActivity", "no secondary display found");
return; return;
} }
if (!isPresentationDisplay(chosen)) {
if (android.os.Build.VERSION.SDK_INT < 29) return;
secondaryActivityPending = true;
secondaryActivityTarget = chosen.getDisplayId();
Intent intent = new Intent(self, SecondaryActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
options.setLaunchDisplayId(secondaryActivityTarget);
self.startActivity(intent, options.toBundle());
final int requestedDisplay = secondaryActivityTarget;
new Handler(Looper.getMainLooper()).postDelayed(() -> {
if (secondaryActivityPending
&& secondaryActivityTarget == requestedDisplay) {
secondaryActivityPending = false;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
}
}, 1000);
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen); SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.setOnDismissListener(dialog -> {
if (secondaryPresentation == p) {
secondaryPresentation = null;
rebindSecondaryDisplay();
}
});
p.show(); p.show();
secondaryPresentation = p; secondaryPresentation = p;
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) {
if (secondaryFrame != null) {
p.setBackground(secondaryBackground);
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
}
}
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) { } catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t); Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null; secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
teardownSecondaryDisplay();
} }
} }
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
Display gameDisplay = getActivityDisplay(self);
int gameDisplayId = gameDisplay == null
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
Display external = null;
Display[] presentations = dm.getDisplays(
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
for (Display d : presentations) {
if (logDisplays) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId()
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
}
if (external == null && d.getDisplayId() != gameDisplayId
&& isDisplayUsable(d)) external = d;
}
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
return handheldAvailable ? handheld : external;
}
private static Display getActivityDisplay(android.app.Activity activity) {
return android.os.Build.VERSION.SDK_INT >= 30
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
}
private static boolean isPresentationDisplay(Display display) {
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
return android.os.Build.VERSION.SDK_INT < 20
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
}
private static boolean isDisplayUsable(Display display) {
if (display == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 20
&& display.getState() == Display.STATE_OFF) return false;
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
return false;
}
return dualScreenDisplayMode != 2
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
}
private static void teardownSecondaryDisplay() { private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null; secondaryPresentation = null;
SecondaryActivity a = secondaryActivity;
secondaryActivity = null;
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
synchronized (secondaryTouches) { secondaryTouches.clear(); } synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) { if (p != null) {
try { p.dismiss(); } catch (Throwable t) {} try { p.dismiss(); } catch (Throwable t) {}
} }
if (a != null) {
try { a.finish(); } catch (Throwable t) {}
}
} }
@android.annotation.TargetApi(17) @android.annotation.TargetApi(17)
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
return manager.getDisplay(displayId) != null; return manager.getDisplay(displayId) != null;
} }
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); } private void changed() {
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); } secondaryDetectionAt = 0;
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); } rebindSecondaryDisplay();
}
@Override public void onDisplayAdded(int displayId) { changed(); }
@Override public void onDisplayRemoved(int displayId) { changed(); }
@Override public void onDisplayChanged(int displayId) { changed(); }
} }
@Keep @Keep
public static boolean hasSecondaryDisplay() { public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null; return secondaryPresentation != null || secondaryActivity != null;
}
@Keep
public static boolean hasSecondaryDisplayCandidate() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (secondaryPresentation != null || secondaryActivity != null) return true;
self.refreshDualScreenDisplayMode();
long now = android.os.SystemClock.uptimeMillis();
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
return secondaryDetected;
}
secondaryDetected = findSecondaryDisplay(self, false) != null;
secondaryDetectionAt = now;
return secondaryDetected;
}
@Keep
public static boolean presentSecondaryFrame(
java.nio.ByteBuffer rgba, int width, int height,
int backgroundColor, boolean cover) {
long bytes = (long) width * height * 4;
if (rgba == null || width <= 0 || height <= 0
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|| rgba.capacity() < bytes) return false;
synchronized (secondaryFrameLock) {
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
secondaryFrame = new byte[(int) bytes];
}
rgba.rewind();
rgba.get(secondaryFrame, 0, (int) bytes);
rgba.rewind();
secondaryFrameWidth = width;
secondaryFrameHeight = height;
secondaryBackground = backgroundColor;
secondaryFrameCover = cover;
SecondaryPresentation p = secondaryPresentation;
SecondaryActivity a = secondaryActivity;
if (p == null && a == null) return false;
try {
if (p != null) {
p.setBackground(backgroundColor);
p.updateFrame(rgba, width, height, cover);
} else {
a.setBackground(backgroundColor);
a.updateFrame(rgba, width, height, cover);
}
return true;
} catch (Throwable t) {
GameActivity self = (GameActivity) mSingleton;
if (self != null) self.runOnUiThread(() -> {
teardownSecondaryDisplay();
setupSecondaryDisplay();
});
return false;
}
}
} }
@Keep @Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) { SecondaryActivity a = secondaryActivity;
p.updateFrame(buf, w, h); if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
if (p != null) p.updateFrame(buf, w, h);
else a.updateFrame(buf, w, h);
} }
} }
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
} }
} }
private static void applySecondaryImmersive(android.view.Window w) {
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
public static class SecondaryActivity extends android.app.Activity {
private FrameView frameView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Display display = getActivityDisplay(this);
if (!secondaryEnabled || display == null
|| display.getDisplayId() != secondaryActivityTarget) {
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
finish();
return;
}
frameView = new FrameView(this);
android.view.Window w = getWindow();
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
setContentView(frameView);
applySecondaryImmersive(w);
secondaryActivity = this;
secondaryActivityPending = false;
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) {
if (secondaryFrame != null) {
setBackground(secondaryBackground);
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
}
}
}
@Override
protected void onDestroy() {
if (secondaryActivity == this) secondaryActivity = null;
super.onDestroy();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applySecondaryImmersive(getWindow());
}
@Override
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
}
@Override
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchGenericMotionEvent(event)
: super.dispatchGenericMotionEvent(event);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
frameView.updateFrame(buf, w, h, cover);
}
void setBackground(int color) {
frameView.setFrameBackground(color);
}
}
private static class SecondaryPresentation extends android.app.Presentation { private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView; private final FrameView frameView;
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
if (hasFocus) applyImmersive(); if (hasFocus) applyImmersive();
} }
@Override
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
}
@Override
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchGenericMotionEvent(event)
: super.dispatchGenericMotionEvent(event);
}
private void applyImmersive() { private void applyImmersive() {
android.view.Window w = getWindow(); applySecondaryImmersive(getWindow());
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
} }
void updateFrame(java.nio.ByteBuffer buf, int w, int h) { void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h); frameView.updateFrame(buf, w, h);
} }
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
frameView.updateFrame(buf, w, h, cover);
}
void setBackground(int color) {
frameView.setFrameBackground(color);
}
} }
private static class FrameView extends View { private static class FrameView extends View {
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
private final android.graphics.Paint paint = new android.graphics.Paint(); private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object(); private final Object lock = new Object();
private int fw, fh; private int fw, fh;
private int backgroundColor = 0xFF000000;
private int activePointer = -1; private int activePointer = -1;
private boolean cover;
FrameView(Context context) { FrameView(Context context) {
super(context); super(context);
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
} }
void updateFrame(java.nio.ByteBuffer buf, int w, int h) { void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
updateFrame(buf, w, h, false);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
synchronized (lock) { synchronized (lock) {
this.cover = cover;
if (bitmap == null || fw != w || fh != h) { if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle(); if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888); bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
postInvalidate(); postInvalidate();
} }
void setFrameBackground(int color) {
synchronized (lock) {
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
}
postInvalidate();
}
private void enqueueTouch(String event) { private void enqueueTouch(String event) {
synchronized (secondaryTouches) { synchronized (secondaryTouches) {
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
synchronized (lock) { synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return; if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight(); int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh); float fit = Math.min((float) vw / fw, (float) vh / fh);
if (s < 1) s = 1; if (fit <= 0) return;
int dw = fw * s, dh = fh * s; float scale = cover
? Math.max((float) vw / fw, (float) vh / fh)
: fit >= 2f ? (float) Math.floor(fit) : fit;
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
int dx = (vw - dw) / 2, dy = (vh - dh) / 2; int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh); dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000); canvas.drawColor(backgroundColor);
canvas.drawBitmap(bitmap, null, dst, paint); canvas.drawBitmap(bitmap, null, dst, paint);
} }
} }
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.98",
"date": "2026-08-16",
"size": 11380117,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.97",
"date": "2026-08-16",
"size": 11376813,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.97/gen1recomp++-0.1.97-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1230 Couple of sound effects missing\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1340 Color palett doesn't affect attack animations\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.1.96", "version": "0.1.96",
"date": "2026-08-16", "date": "2026-08-16",
+9 -2
View File
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
return nil, "stale battle context" return nil, "stale battle context"
end end
local kind = battle:battleKind() local kind = battle:battleKind()
if kind == "oldman" or kind == "link" or kind == "safari" then if kind == "oldman" or kind == "link" then
return nil, "battle kind is not controllable" return nil, "battle kind is not controllable"
end end
if top ~= battle then return nil, "battle menu is covered" end if top ~= battle then return nil, "battle menu is covered" end
local ok, err local ok, err
if intent.kind == "menu" then if intent.kind == "safari" then
if kind ~= "safari" then return nil, "safari menu is not active" end
ok, err = battle:chooseSafari(intent.action)
elseif kind == "safari" then
return nil, "battle kind is not controllable"
elseif intent.kind == "mimic" then
ok, err = battle:chooseMimic(intent.index)
elseif intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice" return nil, "unknown battle menu choice"
+90 -17
View File
@@ -88,6 +88,33 @@ function BattleState:wantsFillScale()
return options and options.battleFit == "fill" or false return options and options.battleFit == "fill" or false
end end
-- EXTENDED HUD configurations are admitted one at a time after their own
-- placement and screenshot review. FIXED supports the three authored battle
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
-- battles retain the paper field required by Gen 1 back sprites, while arena
-- providers may replace it with their own scene. Only the HUD moves to window
-- space.
function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg
return self:wideLayout()
and options and options.battleHud == "extended"
and ((options.battleFit == "fixed"
and (bg == "world" or bg == "white" or bg == "black"))
or (options.battleFit == "fill" and bg == "white"))
end
function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world"
end
function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black"
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids -- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle -- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode. -- screen itself is untouched: it keeps its white paper field in every mode.
@@ -583,7 +610,8 @@ end
local function stampOT(save, mon) local function stampOT(save, mon)
save.player.id = save.player.id or math.random(0, 65535) save.player.id = save.player.id or math.random(0, 65535)
mon.ot = mon.ot or save.player.name mon.ot = mon.ot or save.player.name
mon.otId = mon.otId or save.player.id -- engine/battle/experience.asm:69
if not mon.traded then mon.otId = mon.otId or save.player.id end
end end
BattleState.stampOT = stampOT BattleState.stampOT = stampOT
@@ -1102,6 +1130,15 @@ function BattleState:stepHPDrain()
if not b.shownPx then b.shownPx = targetPx end if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1 b.drainHold = b.drainHold - 1
-- Once the count runs out with nothing left pending (bar and
-- number already on the final total), the drain is over, not just
-- between steps: leave the field at 0 and BattleSafety.inspect
-- reads it as still mid-animation for the rest of the battle,
-- since drainHold ~= nil is its settled-presentation gate.
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
and not b.draining then
b.drainHold = nil
end
busy = true busy = true
elseif b.shownPx ~= targetPx then elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 / -- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
@@ -2018,6 +2055,38 @@ function BattleState:cancelMove()
return true return true
end end
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
function BattleState:chooseSafari(action)
if self.phase ~= "menu" or not self.safari then
return nil, "safari menu is not active"
end
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
local index = SAFARI_ACTION_INDEX[action]
if not index then return nil, "invalid safari action" end
self.menuIndex = index
self:safariAction(action)
return true
end
function BattleState:chooseMimic(index)
if self.phase ~= "mimicSelect" then
return nil, "mimic menu is not active"
end
if type(index) ~= "number" or index % 1 ~= 0 then
return nil, "invalid mimic slot"
end
local pick = self.mimicMoves and self.mimicMoves[index]
local ctx = self.mimicCtx
if not pick or not ctx then return nil, "invalid mimic slot" end
self.mimicIndex = index
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
return true
end
function BattleState:swapMoves(i, j) function BattleState:swapMoves(i, j)
if i == j then return end if i == j then return end
local moves = self.player.curMoves local moves = self.player.curMoves
@@ -2139,7 +2208,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1 self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end end
return return
end end
@@ -2247,12 +2316,7 @@ function BattleState:update(dt)
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex] self:chooseMimic(self.mimicIndex)
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
end end
return return
end end
@@ -5923,10 +5987,16 @@ function BattleState:drawTextArea()
Font.drawCode(Font.BORDER.h, 32, 96) Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96) Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do -- engine/battle/misc.asm:37
-- unknown ids (mod-injected moves) print raw instead of crashing for i = 1, 4 do
local def = self.data.moves[mv.id] local mv = self.player.curMoves[i]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8) if mv then
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
else
Font.draw("-", 48, 96 + i * 8)
end
end end
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row -- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the -- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
@@ -5953,13 +6023,16 @@ function BattleState:drawTextArea()
end end
end end
elseif self.phase == "mimicSelect" then elseif self.phase == "mimicSelect" then
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm: -- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names -- 16x6 box at (0,7), names from (2,8), cursor at column 1
-- single-spaced from (2,8), cursor at column 1
Font.drawBox(0, 7, 16, 6) Font.drawBox(0, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
for i, m in ipairs(self.mimicMoves) do -- engine/battle/misc.asm:37
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8) for i = 1, 4 do
local m = self.mimicMoves[i]
local def = m and self.data.moves[m.id]
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
16, (7 + i) * 8)
end end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
+5 -5
View File
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
hitSfx = { sound = "Damage", pitch = 0x20 } hitSfx = { sound = "Damage", pitch = 0x20 }
end end
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm -- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake -- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
-- the screen vertically) for a damaging move with no added effect, and -- the move has an added effect (#354)
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
-- Bubblebeam and Confusion shake instead of blinking (#354)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT" local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
local hitFx = { sfx = hitSfx, local hitFx = { sfx = hitSfx,
animType = user.isPlayer and (added and 5 or 4) animType = user.isPlayer and (added and 5 or 4)
or (added and 2 or 1), or (added and 2 or 1),
blink = battle:animationsOn() and target or nil } blink = target }
local totalDealt = 0 local totalDealt = 0
local landed, brokeSub = 0, false local landed, brokeSub = 0, false
+10 -1
View File
@@ -33,6 +33,15 @@ end
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
-- Strings.source, not Strings: harvested at require time so the catalog
-- generator can see the literal, same pattern as MoveEffects.lua's
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
-- the harvester can't discover.
local STAT_LABEL = {
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
speed = Strings.source("SPEED"),
}
-- The trainer's ai_classes record from the merged registry; the direct -- The trainer's ai_classes record from the merged registry; the direct
-- require covers battles built without a loader. A trainer record's -- require covers battles built without a loader. A trainer record's
-- aiClass field picks a record other than its own id. -- aiClass field picks a record other than its own id.
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then elseif X_STAT[item] then
local stat = X_STAT[item] local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper())) table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
elseif item == "GUARD_SPEC" then elseif item == "GUARD_SPEC" then
enemy.mist = true enemy.mist = true
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy))) table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
+51 -8
View File
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
end end
end end
local function battleIsTopState(battle)
local stack = battle.game and battle.game.stack
return not (stack and stack.top) or stack:top() == battle
end
local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer
if not (renderer and renderer.setBattleUIAnchor) then return end
x = x + (battle.extendedHUDOffsetX or 0)
y = y + (battle.extendedHUDOffsetY or 0)
local x2 = math.min(WideBattle.WIDTH, x + w)
local y2 = math.min(WideBattle.HEIGHT, y + h)
x, y = math.max(0, x), math.max(0, y)
w, h = x2 - x, y2 - y
if w > 0 and h > 0 then
renderer:setBattleUIAnchor(x, y, w, h, anchor)
end
end
-- One side's status box: name and level on the first line, a long HP bar -- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact -- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original). -- HP is never shown, like the original).
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24) x + tw * 8 - 64, y + 24)
end end
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
end end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved -- the party ball rows DrawAllPokeballs puts up with the intro text, moved
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
and not battle.showPlayerBack and slide == 0 then and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true) drawStatusPanel(battle, battle.player, 184, 56, true)
end end
drawIntroBalls(battle)
end end
local function drawMessageBox(battle) local function drawMessageBox(battle)
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
else else
Font.drawBox(0, 13, 38, 5) Font.drawBox(0, 13, 38, 5)
end end
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
end end
-- Battle animations are authored in the original 160px coordinate space. -- Battle animations are authored in the original 160px coordinate space.
@@ -309,17 +331,23 @@ end
-- The whole 304x144 composition for one frame. -- The whole 304x144 composition for one frame.
function WideBattle.draw(battle) function WideBattle.draw(battle)
local g = love.graphics local g = love.graphics
local renderer = battle.game and battle.game.renderer
local extendedHUD = battle:extendedHUD() and renderer
and renderer.beginBattleHUDPass
and renderer.endBattleHUDPass
-- The field is the display mode's paper. Under a forced-mono mode the -- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field -- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper; -- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice -- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it. -- and land a shade off the letterbox the renderer fills around it.
if monoMode() then if not (extendedHUD and battle:extendedWorldHUD()) then
g.setColor(1, 1, 1, 1) if monoMode() then
else g.setColor(1, 1, 1, 1)
g.setColor(PaletteFX.paperShade(battle.data)) else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does -- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end if battle.blankForAskName then return end
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil battle.wideRegion = nil
drawIntroBalls(battle)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally, -- shakes move IS the whole screen: PredefShakeScreenHorizontally,
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 then return fn() end if sx == 0 and sy == 0 then return fn() end
g.push() g.push()
g.translate(sx, sy) g.translate(sx, sy)
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
fn() fn()
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
g.pop() g.pop()
end end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle) drawAnimationLayer(battle)
shaken(function() drawTextArea(battle) end)
if extendedHUD then
local previous = renderer:beginBattleHUDPass()
shaken(function() drawHUDs(battle, slide) end)
shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
renderer:endBattleHUDPass(previous)
else
shaken(function() drawHUDs(battle, slide) end)
shaken(function() drawTextArea(battle) end)
end
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85) g.setColor(1, 1, 1, 0.85)
+6 -12
View File
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
end end
end end
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are -- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
-- APPENDED after whatever the script already loaded rather than replacing it, -- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
-- and they always land on the same two fixed tile ids.
--
-- (pokegold's jumptable has these two labels the other way round from the
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
-- is what a script actually writes.)
function Runner:loadBattlerGfx(rows) function Runner:loadBattlerGfx(rows)
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
local slot = 1 local slot = 1
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
self.loaded[#self.loaded + 1] = self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6, { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
battler = "player", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
battler = "enemy", rows = rows } battler = "enemy", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
battler = "player", rows = rows }
end end
-------------------------------------------------------------------------- --------------------------------------------------------------------------
+82 -33
View File
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
EFFECT_PARALYZE_HIT = "paralyze", EFFECT_PARALYZE_HIT = "paralyze",
EFFECT_SLEEP_HIT = "sleep", EFFECT_SLEEP_HIT = "sleep",
EFFECT_CONFUSE_HIT = "confuse", EFFECT_CONFUSE_HIT = "confuse",
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
} }
local function rand(random, n) local function rand(random, n)
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
if self.moveEvent then self.moveEvent.missed = true end if self.moveEvent then self.moveEvent.missed = true end
end end
-- engine/battle/effect_commands.asm:3615
Battle.AI_FAIL_STATUSES = {
sleep = true, poison = true, toxic = true, paralyze = true,
}
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
end
-- One attack, start to finish. -- One attack, start to finish.
function Battle:useMove(attacker, defender, moveId) function Battle:useMove(attacker, defender, moveId)
local move = self:findMove(attacker, moveId) local move = self:findMove(attacker, moveId)
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
if charge and not charging then if charge and not charging then
state.chargeMove = moveId state.chargeMove = moveId
state.vanished = charge.vanish or nil state.vanished = charge.vanish or nil
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table -- engine/battle/effect_commands.asm:5458
-- keyed by effect cannot tell them apart and DIG announced itself with if self.moveEvent then self.moveEvent.animParam = 1 end
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the -- BattleCommand_Charge picks the line off the MOVE, not the shared
-- effect: `cp DIG` and then the burrow text. -- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
local text = charge.text local text = charge.text
if moveId == "DIG" then text = "%s dug a hole!" end if moveId == "DIG" then text = "%s dug a hole!" end
self:emit({ kind = "message", text = text:format(name) }) self:emit({ kind = "message", text = text:format(name) })
@@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
-- Defense Curl arms Rollout as well as raising Defense. -- Defense Curl arms Rollout as well as raising Defense.
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
-- Stat changes: the primary ones always land, the *_HIT ones roll the -- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
-- move's effect chance after a hit that connected. -- :4380-4400); the *_HIT twins animate first and must stay unmarked.
--
-- A refused primary change is a failure the cart detects BEFORE its anim
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
-- after `moveanim` (AttackDownHit), so the animation has already played.
local change = Effects.STAT_CHANGES[def.effect] local change = Effects.STAT_CHANGES[def.effect]
if change then if change then
local target = change[3] == "self" and attacker or defender local target = change[3] == "self" and attacker or defender
if not self:changeStageAgainstMist(attacker, target, change[1], change[2]) -- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
local misted = target ~= attacker and (change[2] or 0) < 0
and self:volatile(target).mist
if not misted and change[3] == "foe"
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
and self:aiRandomFail(attacker, target) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
then then
self:markMissed() self:markMissed()
end end
@@ -1853,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId)
local record = Battle.moveEffectRecordFor(self.data, def.effect) local record = Battle.moveEffectRecordFor(self.data, def.effect)
local status = record and record.kind == "primary" and record.status or nil local status = record and record.kind == "primary" and record.status or nil
if status and (def.power or 0) == 0 then if status and (def.power or 0) == 0 then
-- Every status command's already-statused / immune arm ends on -- A refused primary status is a failed move (effect_commands.asm:3748,
-- AnimateFailedMove (BattleCommand_Poison's `.failed`, -- :6656); a refused secondary already animated and stays unmarked (:3752).
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay, if Battle.AI_FAIL_STATUSES[status]
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the and self:aiRandomFail(attacker, defender) then
-- success path (:3752), and the effect scripts carry no moveanim of their self:markMissed()
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS self:emit({ kind = "message", text = "But it failed!" })
-- branch below is the opposite case: that move already hit and already elseif not self:applyStatus(defender, status, attacker) then
-- animated, so a refused secondary must leave the event unmarked. self:markMissed()
if not self:applyStatus(defender, status, attacker) then self:markMissed() end end
else else
local secondary = record and record.kind == "secondary" local secondary = record and record.kind == "secondary"
and record.status or nil and record.status or nil
if secondary and (defender.hp or 0) > 0 then -- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
local chance = def.effectChance or 0 local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker) self:applyStatus(defender, secondary, attacker)
@@ -2414,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
if (side.lightScreen or 0) > 0 then return fail(self) end if (side.lightScreen or 0) > 0 then return fail(self) end
side.lightScreen = Battle.SCREEN_TURNS side.lightScreen = Battle.SCREEN_TURNS
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(attacker) .. "'s SPCL.DEF rose!" }) text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
end end
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
@@ -2422,7 +2437,16 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
if (side.reflect or 0) > 0 then return fail(self) end if (side.reflect or 0) > 0 then return fail(self) end
side.reflect = Battle.SCREEN_TURNS side.reflect = Battle.SCREEN_TURNS
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(attacker) .. "'s DEFENSE rose!" }) text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
end
-- engine/battle/move_effects/safeguard.asm:1
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
local side = self.screens[self:sideOf(attacker)]
if (side.safeguard or 0) > 0 then return fail(self) end
side.safeguard = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s covered by a veil!" })
end end
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in -- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
@@ -2910,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
return math.max(1, math.floor(value / math.max(1, penalty.div or 1))) return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
end end
-- engine/battle/effect_commands.asm:6325
function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end
-- `source` is the battler that inflicted it, carried only so -- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does. -- battle.status_inflicted can name it the way Gen 1's does.
function Battle:applyStatus(mon, status, source) function Battle:applyStatus(mon, status, source)
@@ -2917,7 +2946,14 @@ function Battle:applyStatus(mon, status, source)
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives -- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
-- in the volatile beside the major status, so a confused mon can still be -- in the volatile beside the major status, so a confused mon can still be
-- burned and a switch shakes the confusion off. -- burned and a switch shakes the confusion off.
if status == "confuse" then return self:applyConfusion(mon) end if status == "confuse" then return self:applyConfusion(mon, nil, source) end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
-- One major status at a time. -- One major status at a time.
if mon.status then if mon.status then
self:emit({ kind = "message", self:emit({ kind = "message",
@@ -2953,8 +2989,15 @@ end
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright. -- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
Battle.BERSERK_GENE_CONFUSE_TURNS = 256 Battle.BERSERK_GENE_CONFUSE_TURNS = 256
function Battle:applyConfusion(mon, turns) function Battle:applyConfusion(mon, turns, source)
if (mon.hp or 0) <= 0 then return false end if (mon.hp or 0) <= 0 then return false end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
local state = self:volatile(mon) local state = self:volatile(mon)
if (state.substitute or 0) > 0 then return false end if (state.substitute or 0) > 0 then return false end
local held = self:heldEffect(mon, "confuse") local held = self:heldEffect(mon, "confuse")
@@ -4434,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = {
function Battle:tickScreens() function Battle:tickScreens()
for _, side in ipairs({ "player", "enemy" }) do for _, side in ipairs({ "player", "enemy" }) do
local screens = self.screens[side] local screens = self.screens[side]
for _, field in ipairs({ "lightScreen", "reflect" }) do for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
if (screens[field] or 0) > 0 then if (screens[field] or 0) > 0 then
screens[field] = screens[field] - 1 screens[field] = screens[field] - 1
if screens[field] <= 0 then if screens[field] <= 0 then
screens[field] = nil screens[field] = nil
self:emit({ kind = "message", if field == "safeguard" then
text = Battle.SCREEN_SIDE_LABEL[side] -- engine/battle/core.asm:1527
.. Battle.SCREEN_FALL_TEXT[field] }) self:emit({ kind = "message",
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
else
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
end
end end
end end
end end
+12 -10
View File
@@ -72,11 +72,11 @@ function Pool:reset()
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes. -- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
-- Per-battler state the CGB paths write instead of touching wBGP: a DMG -- Per-battler state the CGB paths write instead of touching wBGP: shade
-- shade byte the view remaps that battler's pic through, whether the pic is -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
-- hidden outright, and which of the six BG squares it is drawn at.
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
self.hidden = { player = false, enemy = false } self.hidden = { player = false, enemy = false }
self.liftedRows = { player = nil, enemy = nil }
self.picSize = { player = nil, enemy = nil } self.picSize = { player = nil, enemy = nil }
self.slide = { player = 0, enemy = 0 } self.slide = { player = 0, enemy = 0 }
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
@@ -488,6 +488,7 @@ local function runPicResize(self, st, script)
self.picSize[side] = step self.picSize[side] = step
self.hidden[side] = false self.hidden[side] = false
end end
self.liftedRows[side] = nil
incJt(st) incJt(st)
elseif jt >= 1 and jt <= 2 then elseif jt >= 1 and jt <= 2 then
incJt(st) incJt(st)
@@ -545,7 +546,7 @@ end
-- The two battler-pic objects: the animation borrows the mon's own tiles as -- The two battler-pic objects: the animation borrows the mon's own tiles as
-- an OBJ so it can be moved without touching the tilemap. -- an OBJ so it can be moved without touching the tilemap.
local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
local jt = st.jt local jt = st.jt
if jt == 0 then if jt == 0 then
if self:flyDig(st) then if self:flyDig(st) then
@@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
} }
elseif jt == 1 then elseif jt == 1 then
incJt(st) incJt(st)
-- The rows the OBJ now covers are cleared out of the tilemap so the mon -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
-- is not drawn twice. -- come out of the tilemap, and .five never puts them back.
self.hidden[self:sideKey(st)] = clearRows self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
elseif jt >= 2 and jt <= 4 then elseif jt >= 2 and jt <= 4 then
incJt(st) incJt(st)
elseif jt == 5 then elseif jt == 5 then
self.hidden[self:sideKey(st)] = false
endEffect(st) endEffect(st)
end end
end end
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true) "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
{ player = { 0, 1 }, enemy = { 6, 1 } })
end end
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true) "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
{ player = { 0, 2 }, enemy = { 5, 2 } })
end end
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler -- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
+7 -1
View File
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
if mon.dvs then if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs, mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level }) { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, -- shiny is monotonic once true, the same as opts.shiny winning over
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
-- that do not themselves read as shiny) must not un-shiny the moment
-- this runs again, and it runs on every SummaryMenu open via
-- refreshStats. A mon not already shiny still promotes normally if
-- its DVs justify it, e.g. after an edit.
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level }) { species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs) mon.unownLetter = Unown.letterFromDVs(mon.dvs)
+51 -11
View File
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack") local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
@@ -305,12 +306,32 @@ function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i] local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then if state and state.bgMode and state:bgMode() == "world" then
-- The extended fixed HUD intentionally exposes the live world across
-- the whole physical window. Keep this as a world-backed battle (zero
-- is non-nil, so scaling and overlay holds remain active), but do not
-- paint the standard dim veil around the native battle rectangle.
if state.extendedWorldHUD and state:extendedWorldHUD() then
return 0
end
return state.BG_WORLD_DIM or 0.55 return state.BG_WORLD_DIM or 0.55
end end
end end
return nil return nil
end end
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
-- uses this separately from battleDim: the world remains the surround, while
-- the native-width battle field receives a paper backing from top to bottom.
function Game.extendedWorldHUDInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
return true
end
end
return false
end
-- Is a BATTLE BG "world" battle composing itself over the live map right now? -- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the -- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is -- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
@@ -403,13 +424,13 @@ function Game.uiAnchorsHeldInStack(stack)
end end
-- Where Game:draw starts drawing this frame. Normally the topmost opaque -- Where Game:draw starts drawing this frame. Normally the topmost opaque
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle -- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
-- over the LIVE map, and an opaque state pushed on top of it (the party menu, -- battle must not prevent that battle from drawing. Native WIDE battles own
-- the bag) becomes that base, cutting the overworld -- and with it the world -- the 304x144 surround around a centred classic menu, while external arena
-- pass -- out of the frame entirely. The backdrop the battle established -- providers establish their window-sized scene from BattleState:draw. If the
-- then collapses to endFrame's flat black clear for as long as the menu is -- menu becomes the draw base, neither owner runs and the menu's white field
-- up. So a world-bg battle keeps the frame starting from underneath itself -- replaces the whole presentation. BATTLE BG "world" additionally needs the
-- until it leaves the stack, the same hold uiFill and the dim already use. -- overworld below the battle, as before.
-- --
-- Only the START of the draw moves. The clear stays keyed to the real -- Only the START of the draw moves. The clear stays keyed to the real
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as -- visibleBase, so the menu still gets its opaque canvas and draws exactly as
@@ -420,9 +441,13 @@ function Game.drawBaseInStack(stack, visibleBase)
local states = stack and stack.states or {} local states = stack and stack.states or {}
for i = visibleBase - 1, 1, -1 do for i = visibleBase - 1, 1, -1 do
local state = states[i] local state = states[i]
if state and state.bgMode and state:bgMode() == "world" then local worldBattle = state and state.bgMode and state:bgMode() == "world"
local wideBattle = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if worldBattle or wideBattle then
-- restart the search from under the battle: the highest opaque state at -- restart the search from under the battle: the highest opaque state at
-- or below it (the overworld), not the menu sitting over it -- or below it (the battle itself for white/black WIDE, the overworld for
-- a non-opaque world-backed battle), not the menu sitting over it
for j = i, 1, -1 do for j = i, 1, -1 do
if states[j].isOpaque then return j end if states[j].isOpaque then return j end
end end
@@ -432,6 +457,14 @@ function Game.drawBaseInStack(stack, visibleBase)
return visibleBase return visibleBase
end end
-- A classic overlay above the approved world-backed extended HUD paints only
-- its centred area. Keep the wider owner surface transparent so its margins
-- continue to reveal the world instead of becoming an opaque white sheet.
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
return worldBelow or (worldDrawn and wideBattle ~= nil
and wideBattle.extendedHUD and wideBattle:extendedHUD())
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends -- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower -- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content. -- sprite and status zones move with the classic UI content.
@@ -452,6 +485,7 @@ local function centerClassicZones(zones, offset)
end end
function Game:draw() function Game:draw()
GameViewport.begin(1)
-- the UI canvas clears transparent when the overworld's world pass -- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic -- shows through beneath it; opaque full-screen states get the classic
-- white clear -- white clear
@@ -485,6 +519,7 @@ function Game:draw()
-- the stack for the same reason as uiFill above -- a prompt opened during -- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame. -- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack) Renderer.battleDim = Game.worldBgBattleDim(self.stack)
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
-- ...and for the same reason the UI's own scale has to know the world is -- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale -- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it, -- steps the UI down with the survey zoom only while a world is behind it,
@@ -502,7 +537,8 @@ function Game:draw()
-- menu to its top right, and the whole UI steps down with the zoom. -- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save) Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow) Renderer:beginFrame(Game.uiCanvasTransparent(
worldBelow, worldDrawn, wideBattle))
for i = drawFrom, #self.stack.states do for i = drawFrom, #self.stack.states do
local state = self.stack.states[i] local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout local wideState = state and state.isWideBattleLayout
@@ -563,7 +599,9 @@ function Game:draw()
if ModRuntime.wantsHook("render.hud") then if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport) ModRuntime.call("render.hud", function() end, self, viewport)
end end
-- on-screen mobile controls: pure screen-space, over the finished frame GameViewport.finish(self)
-- OS-window chrome: keep the pad full-size and above any composed companion
-- view instead of capturing and shrinking it with the game viewport.
TouchControls:draw() TouchControls:draw()
end end
@@ -939,8 +977,10 @@ local function pointerUnclaimed() return false end
-- coordinates are LOVE window units, the same space render.hud's viewport -- coordinates are LOVE window units, the same space render.hud's viewport
-- and the touch overlay lay out in -- and the touch overlay lay out in
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, { return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y, phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
}) })
end end
+28 -19
View File
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
-- The mod event/hook buses. Gold reaches them through Runtime like every -- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has. -- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- Only for the mod-supplied save migrations and the mods-changed report, which -- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save -- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua. -- IO is src/core/gen2/Save.lua.
@@ -73,8 +74,8 @@ end
-- --
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- 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 -- (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 eight -- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
-- hooks below never used to fire here; it is not a reason they should not. A -- 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 -- 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 -- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order: -- PAYLOAD, at the Gen 1 point in the order:
@@ -86,6 +87,8 @@ end
-- render.output* the normal composed frame (Renderer.lua:1063) -- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840) -- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521) -- render.hud screen-space UI over the frame (src/core/Game.lua:521)
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
-- render.window final OS-window composition (GameViewport.lua:145)
-- --
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the -- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and -- world pass and the UI into ONE canvas, not two -- the call site says so and
@@ -680,7 +683,10 @@ end
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent. -- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
function Game2:usePartyItem(itemId) function Game2:usePartyItem(itemId)
local ItemEffects = require("src.core.gen2.ItemEffects") local ItemEffects = require("src.core.gen2.ItemEffects")
local action = ItemEffects.partyAction(itemId) -- without the merged dataset this can only ever see RECORDS, the
-- module's own built-ins, so a mod's field item resolves to no action
-- at all and never gets past the .Oak refusal below
local action = ItemEffects.partyAction(itemId, self.data)
if not action then return end if not action then return end
local party = (self.save and self.save.party) or {} local party = (self.save and self.save.party) or {}
if #party == 0 then if #party == 0 then
@@ -1197,9 +1203,7 @@ function Game2:frameFit(w, h)
dpi = tonumber(love.window.getDPIScale()) or 1 dpi = tonumber(love.window.getDPIScale()) or 1
end end
local pw, ph = w * dpi, h * dpi local pw, ph = w * dpi, h * dpi
if love.graphics.getPixelDimensions then pw, ph = GameViewport.pixelDimensions()
pw, ph = love.graphics.getPixelDimensions()
end
return scale, ox, oy, dpi, pw, ph return scale, ox, oy, dpi, pw, ph
end end
@@ -1217,18 +1221,15 @@ function Game2:viewport(w, h)
} }
end end
-- The screen-space layer, in the Gen 1 order: render.hud and then the -- The render.hud layer, in Gen 1's order over the finished game frame. The
-- on-screen pad (src/core/Game.lua:521 and :524, either side of -- on-screen pad is drawn separately after GameViewport.finish, because it is
-- Renderer:endFrame). Both are window-space, both sit over the finished -- OS-window chrome and must not be captured or scaled with this canvas.
-- frame -- post passes, letterbox and all -- and neither ever enters the game
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
-- true of the composed frame a mod owns as well as of the plain one.
-- --
-- render.hud: persistent tool status. The call is fenced with -- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a -- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader -- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next -- bound, the canvas redirected or the colour changed must not corrupt the next
-- frame -- or, now, the pad drawn immediately after it. -- frame.
function Game2:drawHud(w, h) function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then if ModRuntime.wantsHook("render.hud") then
local G = love.graphics local G = love.graphics
@@ -1236,10 +1237,6 @@ function Game2:drawHud(w, h)
ModRuntime.call("render.hud", noop, self, self:viewport(w, h)) ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop() G.pop()
end end
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
-- forces it, and nothing ever while a controller is in use.
TouchControls:draw()
end end
-- render.letterbox: SGB borders and custom void art in the bars around the -- render.letterbox: SGB borders and custom void art in the bars around the
@@ -1363,9 +1360,9 @@ end
-- is being shown on. Mod post-processes fold in between the two, where -- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid -- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself. -- is then drawn over, rather than something that smears the grid itself.
function Game2:draw() function Game2:drawViewportFrame()
local G = love.graphics local G = love.graphics
local w, h = G.getDimensions() local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
@@ -1477,6 +1474,16 @@ function Game2:draw()
self:drawHud(w, h) self:drawHud(w, h)
end end
function Game2:draw()
GameViewport.begin(2)
GameViewport.setTarget()
self:drawViewportFrame()
GameViewport.finish(self)
-- OS-window chrome: draw after companion composition so viewport layouts
-- neither shrink nor cover the touch pad.
TouchControls:draw()
end
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from -- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0 -- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm -- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
@@ -1803,8 +1810,10 @@ end
-- coordinates are LOVE window units, the same space render.hud's viewport is in -- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, { return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y, phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
}) })
end end
+47 -3
View File
@@ -272,6 +272,38 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'" return "'" .. s:gsub("'", "'\\''") .. "'"
end end
-- Launch another instance of this packaged app without waiting for it. The
-- same path works on all process-capable desktop hosts; only the shell's
-- background spelling differs. Source checkouts include their game folder,
-- while fused releases and AppImages already carry it in the executable.
function HostShell.spawnSelfDetached(args)
if not require("src.core.Platform").canSpawnProcess() then return false end
local fs = love and love.filesystem
if not (fs and fs.getExecutablePath) then return false end
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
if type(executable) ~= "string" or executable == "" then return false end
local argv = {}
local fused = fs.isFused and fs.isFused()
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
argv[#argv + 1] = fs.getSource()
end
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
local command = HostShell.quote(executable)
for _, value in ipairs(argv) do
command = command .. " " .. HostShell.quote(value)
end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Windows" then
command = 'start "" /b ' .. command .. " >NUL 2>&1"
else
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
end
local ok, _, code = os.execute(command)
return ok == true or ok == 0 or code == 0
end
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole -- MEMOISED per Lua state (so once per thread). This used to spawn a whole
-- `curl --version` process on every single fetch -- twice for a GET through -- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock -- the Android-bridge fallback -- which doubled the number of spawns the lock
@@ -420,9 +452,11 @@ end
-- POST returning success/failure. Strictly one-way: the response body is -- POST returning success/failure. Strictly one-way: the response body is
-- discarded, only the HTTP status class is surfaced (postLog callers never -- discarded, only the HTTP status class is surfaced (postLog callers never
-- trust the reply). curl --data-binary reads the payload from a pipe, so a -- trust the reply). curl --data-binary reads the payload from a pipe, so a
-- large body never lands in the command line; the Android bridge has no POST -- large body never lands in the command line; where curl is absent (Android
-- transport, and httpPost reports that instead of half-working through -- and the other bridge-only platforms) the POST rides the JNI bridge --
-- httpDownload (a GET round-trip to a POST endpoint would be a lie). -- love.system.httpPost, the dedicated POST arm added beside httpDownload --
-- instead of half-working through httpDownload (a GET round-trip to a POST
-- endpoint would be a lie).
function HostShell.httpPost(url, body, contentType, userAgent, maxTime) function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(body) ~= "string" then return nil, "missing body" end if type(body) ~= "string" then return nil, "missing body" end
@@ -498,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if not haveBridge() then if not haveBridge() then
return nil, "no network transport on this platform" return nil, "no network transport on this platform"
end end
-- The GET bridge has no POST; the dedicated love.system.httpPost arm
-- (GameActivity.httpPost) is the transport where curl is missing. A
-- build without it reports the same "no POST transport" a missing curl
-- would -- the old-APK skew path in the JNI bridge returns false.
if love.system and type(love.system.httpPost) == "function" then
local ok, sent = pcall(love.system.httpPost, url, body, contentType,
userAgent)
if ok and sent then return true end
return nil, "log post rejected"
end
return nil, "no POST transport on this platform" return nil, "no POST transport on this platform"
end end
+3 -3
View File
@@ -387,9 +387,9 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the -- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end -- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing, fade) function Music.playMap(data, mapId, onBike, surfing, fade, song)
local song = data and data.audio and data.audio.mapSongs song = song or (data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil and mapId and data.audio.mapSongs[mapId]) or nil
state.mapSong = song state.mapSong = song
state.onBike = not not onBike state.onBike = not not onBike
state.surfing = not not surfing state.surfing = not not surfing
+8 -2
View File
@@ -7,12 +7,14 @@
-- (touch overlay, launcher) should prefer this over getDimensions; the game -- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion. -- canvas may still letterbox into the full framebuffer for immersion.
local GameViewport = require("src.render.GameViewport")
local SafeArea = {} local SafeArea = {}
function SafeArea.rect() function SafeArea.windowRect()
local ww, wh = 0, 0 local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions() ww, wh = GameViewport.fullDimensions()
end end
if ww <= 0 then ww = 1 end if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end if wh <= 0 then wh = 1 end
@@ -55,4 +57,8 @@ function SafeArea.rect()
return x, y, w, h return x, y, w, h
end end
function SafeArea.rect()
return GameViewport.localSafeRect(SafeArea.windowRect())
end
return SafeArea return SafeArea
+5
View File
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
-- scale the battle surface to the window so it fills vertically. See -- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale. -- BattleState:wantsFillScale.
battleFit = "fixed", battleFit = "fixed",
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
-- wide layouts; unsupported combinations deliberately fall back to the
-- standard composition.
battleHud = "standard",
-- BATTLE BG: what fills the screen behind and around the battle. -- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look), -- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing -- "black" = plain black bars, "world" = the frozen overworld showing
+7 -7
View File
@@ -339,7 +339,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale, -- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read. -- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket() function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect() local _, _, sw, sh = SafeArea.windowRect()
local o = orientationFor(sw, sh) local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} } self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o] local b = self.layouts[o]
@@ -363,7 +363,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's -- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633). -- size setting (#633).
function TouchControls:layout() function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.windowRect()
if self.layoutW == sw and self.layoutH == sh if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L return self.L
@@ -397,7 +397,7 @@ end
-- Move one control to a screen-space point and persist its normalized -- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging. -- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy) function TouchControls:setControlCenter(name, cx, cy)
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.windowRect()
local L = self:layout() local L = self:layout()
local zone = L[name] local zone = L[name]
if not zone then return end if not zone then return end
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale) zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end end
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by -- OS-window space, called after GameViewport.finish so the overlay rides on
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top -- top of the game, companion composition and post-processing without being
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher -- captured or scaled with any game viewport. Also used by the launcher layout
-- layout editor under preview mode. -- editor under preview mode.
function TouchControls:draw() function TouchControls:draw()
if not self:visible() then return end if not self:visible() then return end
local L = self:layout() local L = self:layout()
+43
View File
@@ -16,13 +16,56 @@
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek) -- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
-- and World only ever reads it. -- and World only ever reads it.
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local Clock = {} local Clock = {}
Clock.MINUTES_PER_DAY = 24 * 60 Clock.MINUTES_PER_DAY = 24 * 60
Clock.DAYS = 7 Clock.DAYS = 7
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
-- is the same pattern). One shared table and one lookup function, so
-- InitClock's screens, the main menu clock box and the Pokegear clock card
-- cannot drift apart on what a weekday is called.
Clock.DAY_NAMES = {
Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
Strings.source("SATURDAY"),
}
-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
-- out of range.
function Clock.weekdayName(day)
local name = Clock.DAY_NAMES[day]
return name and Strings(name)
end
-- The three words Palettes.clockDaytime can hand back, translated (never
-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
-- map, and is never printed as text). Strings.source, not Strings:
-- clockDaytime's return value is also an internal key every
-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
-- a word up, at the UI call sites that actually print it.
local DAYTIME_LABEL = {
MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
NITE = Strings.source("NITE"),
}
-- clockDaytime's word, translated -- the one InitClock's clock-setting
-- screen and the Pokegear's clock card print (DisplayHourOClock /
-- Pokegear_UpdateClock).
function Clock.daytimeLabel(hour)
local daytime = Palettes.clockDaytime(hour)
return Strings(DAYTIME_LABEL[daytime] or daytime)
end
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute -- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
-- buffer left at the zero ByteFill put there. -- buffer left at the zero ByteFill put there.
Clock.DEFAULT_HOUR = 10 Clock.DEFAULT_HOUR = 10
+64 -7
View File
@@ -138,15 +138,72 @@ local function coreRows(opts, hooks)
ladder(opts, "battleStyle", ladder(opts, "battleStyle",
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift")) { { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
add(Strings("BATTLE LAYOUT"), add(Strings("BATTLE LAYOUT"),
ladder(opts, "battleLayout", function()
{ { "og", "OG" }, { "wide", "WIDE" } }, "og")) return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
end,
function()
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE SIZE"), add(Strings("BATTLE SIZE"),
ladder(opts, "battleFit", function()
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
function()
return opts.battleLayout == "wide" and opts.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
function()
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"), add(Strings("BATTLE BG"),
ladder(opts, "battleBg", function()
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } }, if opts.battleLayout == "wide" and opts.battleFit == "fill"
"white")) and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
if opts.battleBg == mode then cur = i break end
end
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
return true
end)
add(Strings("UI LAYOUT"), add(Strings("UI LAYOUT"),
ladder(opts, "uiLayout", ladder(opts, "uiLayout",
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered")) { { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
+1 -1
View File
@@ -979,7 +979,7 @@ function LauncherView._updateControl(imp)
end end
-- idle / uptodate / error: offer a manual check, with no glow. -- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"), return status, Strings("Check for updates"),
function() pcall(imp.Check.start) end, false function() pcall(imp.Check.start, true) end, false
end end
-- ------------------------------------------------------------ game panel -- ------------------------------------------------------------ game panel
+16 -3
View File
@@ -59,6 +59,16 @@ local STONES = {
LEAF_STONE = true, MOON_STONE = true, LEAF_STONE = true, MOON_STONE = true,
} }
-- Strings.source, not Strings: harvested at require time so the catalog
-- generator can see the literal, same pattern as MoveEffects.lua's
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
-- the harvester can't discover.
local STAT_LABEL = {
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
}
-- vitamins: stat-exp boosters (ItemUseVitamin) -- vitamins: stat-exp boosters (ItemUseVitamin)
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense", local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
CARBOS = "speed", CALCIUM = "special" } CARBOS = "speed", CALCIUM = "special" }
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"Nothing happened!") } "Nothing happened!") }
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
end end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active -- the item, even when it is already active
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- Spanish ROM puts the stat before the name), so the extracted line -- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands -- cannot be filled positionally; the engine wording stands
return "consumed", { Strings("%s's %s\nrose!", monName(data, target), return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) } Strings(STAT_LABEL[vitaminStat])) }
end end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu) -- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target then return "failed", { noEffect(data) } end if not target then return "failed", { noEffect(data) } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do -- a species record with no tmhm list at all is "teaches nothing", the
-- same as one whose list just does not name this move -- not a reason
-- to crash instead of refusing normally
for _, m in ipairs(speciesDef.tmhm or {}) do
if m == itemDef.machine.move then ok = true break end if m == itemDef.machine.move then ok = true break end
end end
if not ok then if not ok then
+2
View File
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8) love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(2, ty, 16, th) Font.drawBox(2, ty, 16, th)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
drawTruncated(line, 4 * 8, (ty + i) * 8, 14) drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
end end
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18) Font.drawBox(0, 0, 20, 18)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8) Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then if self.screen == "list" then
self:drawList() self:drawList()
+1 -1
View File
@@ -12,7 +12,7 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true, engine_internals = true, steps = true,
background = true } background = true, compute = true }
-- link-relevant registries; a mod that writes into one of these while -- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader -- declaring affects_link = false gets an attributed warning from the loader
+22 -4
View File
@@ -28,8 +28,14 @@ local DENIED = {
} }
-- Same idea one level up: love.filesystem is reachable by name, and -- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over. -- love.thread starts a Lua state this sandbox has no say over. jit.util
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true } -- is the LuaJIT-specific equal of the debug library above -- funcbc,
-- funck and friends read the bytecode and constants of any function a
-- chunk can reach, which is enough to walk back to upvalues (the real
-- _G, love, io) the rest of this file exists to keep out of reach. The
-- bare `jit` table stays -- env.jit above hands it over directly for
-- jit.on/off/flush -- so only the submodule require is denied.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
-- The wire, which is what the network permission governs. -- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true, local NETWORK = { socket = true, enet = true, http = true, https = true,
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed -- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
-- by that mod's own overlay and must not be shared. -- by that mod's own overlay and must not be shared.
local function loveFacade(compat) local function loveFacade(compat, permissions)
if not _G.love then return nil end if not _G.love then return nil end
local overrides = compat and compat.love local overrides = compat and compat.love
return setmetatable({}, { return setmetatable({}, {
__index = function(_, key) __index = function(_, key)
local override = overrides and overrides[key] local override = overrides and overrides[key]
if override ~= nil then return override end if override ~= nil then return override end
if key == "thread" then
-- Threads open a fresh Lua state with a full standard library, so
-- they stay blocked unless the mod declares the `compute`
-- permission (the mod's own source runs in the worker, and the
-- worker ships source-only like every other mod file). The mod
-- must never receive arbitrary code from elsewhere: channels
-- carry data only.
if not (permissions or {}).compute then
error('love.thread needs the "compute" permission in manifest.json', 2)
end
return _G.love.thread
end
local hint = BLOCKED_LOVE[key] local hint = BLOCKED_LOVE[key]
if hint then if hint then
error(("love.%s is not available to mods%s"):format(key, error(("love.%s is not available to mods%s"):format(key,
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
opts = opts or {} opts = opts or {}
local compat = opts.compat local compat = opts.compat
local env = baseGlobals() local env = baseGlobals()
env.love = loveFacade(compat) env.love = loveFacade(compat, opts.permissions)
env.require = sandboxedRequire(opts.modId, opts.permissions, compat) env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
local loader = sandboxedLoad(env) local loader = sandboxedLoad(env)
env.load = loader env.load = loader
+62 -7
View File
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
desc = "{" .. table.concat(parts, ", ") .. "}" } desc = "{" .. table.concat(parts, ", ") .. "}" }
end end
-- An open record: the listed fields are typed (including f.id
-- cross-references) and everything else on the value passes through
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
-- and static encounters all share one array, and only a full union of
-- every kind's shape could describe it as f.rec; that is a lot of surface
-- to keep in sync with the loader for fields nothing here needs to check.
-- f.partial types just the field that actually names another registry and
-- leaves every kind-specific field around it alone.
function f.partial(fields)
local names = {}
for name in pairs(fields) do names[#names + 1] = name end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end
return { kind = "partial", fields = fields,
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
end
function f.union(alts) function f.union(alts)
local parts = {} local parts = {}
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
end end
return return
end end
if kind == "partial" then
-- the open counterpart of "rec": listed fields are checked exactly
-- like a rec's, and any key not listed is left alone rather than
-- flagged, so a heterogeneous blob (map objects) can have one field
-- typed without every other shape sharing the array being rejected
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for key, ft in pairs(t.fields) do
local sub = value[key]
if sub ~= nil then
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
elseif ft.kind ~= "opt" and not patchMode then
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
:format(path, key, ft.desc)
end
end
return
end
if kind == "union" then if kind == "union" then
for _, alt in ipairs(t.alts) do for _, alt in ipairs(t.alts) do
local scratch = {} local scratch = {}
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
for k, v in pairs(value) do for k, v in pairs(value) do
collectRefs(t.value, v, path .. "." .. tostring(k), out) collectRefs(t.value, v, path .. "." .. tostring(k), out)
end end
elseif kind == "rec" and type(value) == "table" then elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
for key, ft in pairs(t.fields) do for key, ft in pairs(t.fields) do
collectRefs(ft, value[key], path .. "." .. tostring(key), out) collectRefs(ft, value[key], path .. "." .. tostring(key), out)
end end
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
and loader.content[ref.registry] and loader.content[ref.registry]
-- A registry with no home in this generation has no id space to -- A registry with no home in this generation has no id space to
-- check against: its base view resolves to nothing, so EVERY -- check against: its base view resolves to nothing, so EVERY
-- reference into it would read as dangling. Gold's species carry a -- reference into it would read as dangling. `transitions` is the
-- growthRate and an evolution method like Red's do; the ids are -- standing example -- Gold draws its own battle intro and never
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods` -- reads the merged table, so a mod's transition id there is
-- namespaces that are not there to confirm them. Skipped for the -- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
-- same reason an undeclared registry is: unknown, not wrong. -- used to sit in that category too, back when Gold had no id space
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
-- path and is seeded from data.pokemon.growthRates (the extractor's
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
-- literal EVOLVE_* ids, present with or without a ROM import). A
-- Gold species' growthRate or evolution method is checked against
-- real ids exactly like a Red one's, so a genuine typo is still
-- caught here rather than waved through as "unknown, not wrong."
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
refRegistry = nil refRegistry = nil
end end
@@ -887,7 +934,15 @@ R.maps = {
destMap = f.str, destWarp = f.int(0), destMap = f.str, destWarp = f.int(0),
destGroup = f.opt(f.int(0)), destGroup = f.opt(f.int(0)),
destMapNum = f.opt(f.int(0)) })), destMapNum = f.opt(f.int(0)) })),
objects = f.opt(f.list(f.any)), -- NPCs, signs, items, warps and static wild encounters all share this
-- one array, with no field the loader could use to tell them apart
-- ahead of time -- an f.rec strict enough to describe every kind would
-- reject the others. f.partial types only `pokemon` (the static
-- encounter's species, OverworldController.lua's `d.pokemon` ->
-- BattleState.newWild) so a bad id is a load-time error, the same as
-- an encounter slot's species, instead of the crash newWild has no
-- guard against. Every other object field passes through untouched.
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
signs = f.opt(f.list(f.any)), signs = f.opt(f.list(f.any)),
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)), connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
}, },
+125
View File
@@ -0,0 +1,125 @@
-- Minimal second-window process for src/render/DesktopScreen.lua.
local DesktopCompanion = {}
function DesktopCompanion.install(config)
local enet = require("enet")
local host = assert(enet.host_create())
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
local image, sourceW, sourceH, preference
local background = { 0, 0, 0, 1 }
local connected, commandedQuit = false, false
local pointerDown = false
local started = love.timer.getTime()
local lastContact = started
local function send(kind, payload)
if not connected then return end
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
end
local function receiveFrame(data)
local prefix = "F" .. config.token .. "\n"
if data:sub(1, #prefix) ~= prefix then return end
local split = data:find("\n", #prefix + 1, true)
if not split then return end
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
local ok, raw = pcall(love.data.decompress, "string", "lz4",
data:sub(split + 1))
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
if not made then return end
if not image or sourceW ~= w or sourceH ~= h then
if image and image.release then image:release() end
image = love.graphics.newImage(pixels)
else
image:replacePixels(pixels)
end
sourceW, sourceH, preference = w, h, mode
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
mode:find("cover", 1, true) and "linear" or "nearest")
background = {
math.floor(rgb / 0x10000) % 0x100 / 255,
math.floor(rgb / 0x100) % 0x100 / 255,
rgb % 0x100 / 255, 1,
}
end
local function service()
while true do
local event = host:service(0)
if not event then break end
if event.type == "connect" then
connected, lastContact = true, love.timer.getTime()
send("H")
elseif event.type == "receive" then
lastContact = love.timer.getTime()
if event.data == "Q" .. config.token then
commandedQuit = true
love.event.quit()
elseif event.data ~= "P" .. config.token then
receiveFrame(event.data)
end
elseif event.type == "disconnect" then
love.event.quit()
end
end
end
local function placement()
if not image then return 0, 0, 1 end
local ww, wh = love.graphics.getDimensions()
local cover = preference and preference:find("cover", 1, true)
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
end
local function input(action, x, y)
if not image then return false end
local dx, dy, scale = placement()
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
return true
end
function love.update()
service()
local t = love.timer.getTime()
if (not connected and t - started > 5) or t - lastContact > 5 then
love.event.quit()
end
end
function love.draw()
love.graphics.clear(background[1], background[2], background[3], background[4])
if not image then return end
local x, y, scale = placement()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, x, y, 0, scale, scale)
end
function love.mousepressed(x, y, button)
if button == 1 then pointerDown = input("down", x, y) end
end
function love.mousereleased(x, y, button)
if button == 1 and pointerDown then
if not input("up", x, y) then send("I", "\ncancel,0,0") end
pointerDown = false
end
end
function love.touchpressed(_, x, y) input("down", x, y) end
function love.touchreleased(_, x, y) input("up", x, y) end
function love.keypressed(key)
if key == "escape" then love.event.quit() end
end
function love.quit()
if not commandedQuit then send("C") end
pcall(peer.disconnect_now, peer)
end
end
return DesktopCompanion
+137
View File
@@ -0,0 +1,137 @@
-- Cross-platform desktop secondary display. LOVE owns one window, so a
-- second minimal instance of this same app owns the companion window. ENet
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
local Platform = require("src.core.Platform")
local HostShell = require("src.core.HostShell")
local okEnet, enet = pcall(require, "enet")
local DesktopScreen = {}
local state = {
enabled = false, blocked = false, host = nil, peer = nil,
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
}
local function now()
return love and love.timer and love.timer.getTime and love.timer.getTime()
or os.clock()
end
local function destroy(sendQuit)
if state.peer and sendQuit then
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
end
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
if state.host then pcall(state.host.destroy, state.host) end
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
state.touches = {}
end
local function token()
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
local digest = love.data.hash("sha256", seed)
return love.data.encode("string", "hex", digest):sub(1, 24)
end
local function start()
if state.host or state.blocked or now() < state.retryAt then
return state.host ~= nil
end
local base = 49152 + math.floor(now() * 1000) % 12000
for attempt = 0, 31 do
local port = 49152 + (base - 49152 + attempt * 37) % 12000
local ok, host = pcall(enet.host_create,
("127.0.0.1:%d"):format(port), 1, 2)
if ok and host then
state.host, state.port, state.token = host, port, token()
local launched = HostShell.spawnSelfDetached({
("--display-companion=%d,%s"):format(port, state.token),
})
if launched then return true end
destroy(false)
break
end
end
state.retryAt = now() + 1
return false
end
local function service()
if not state.enabled or state.blocked then return end
if not state.host and not start() then return end
while state.host do
local ok, event = pcall(state.host.service, state.host, 0)
if not ok then
destroy(false)
state.retryAt = now() + 1
return
end
if not event then break end
if event.type == "receive" then
local data = event.data or ""
if data == "H" .. state.token then
state.peer = event.peer
elseif event.peer == state.peer
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
elseif event.peer == state.peer and data == "C" .. state.token then
state.blocked = true
destroy(false)
return
end
elseif event.type == "disconnect" and event.peer == state.peer then
destroy(false)
state.retryAt = now() + 1
return
end
end
if state.peer and now() >= state.heartbeatAt then
state.heartbeatAt = now() + 1
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
end
end
function DesktopScreen.usable()
return okEnet and enet ~= nil and Platform.canSpawnProcess()
end
function DesktopScreen.available()
return DesktopScreen.detected()
end
function DesktopScreen.detected()
service()
return state.peer ~= nil
end
function DesktopScreen.push(imageData, w, h, background, preference)
service()
if not state.peer or not imageData or not imageData.getString then return false end
w, h = tonumber(w), tonumber(h)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
local ok, raw = pcall(imageData.getString, imageData)
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
local packed = love.data.compress("string", "lz4", raw, 1)
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
return sent
end
function DesktopScreen.pollTouch()
service()
return table.remove(state.touches, 1)
end
function DesktopScreen.setEnabled(on)
on = on == true
if on == state.enabled then
if on then service() end
return
end
state.enabled = on
state.blocked = false
if on then start(); service() else destroy(true) end
end
return DesktopScreen
+185
View File
@@ -0,0 +1,185 @@
-- Optional game viewport inside the OS window. A layout mod may reserve any
-- window-space rectangle through render.viewport; the game then renders as if
-- that rectangle were its whole display. With no subscriber this module is a
-- pass-through and allocates no canvas.
local Runtime = require("src.mods.Runtime")
local Viewport = {
rect = nil,
full = nil,
canvas = nil,
generation = nil,
frameActive = false,
}
local function finite(value)
return type(value) == "number" and value == value
and value > -math.huge and value < math.huge
end
local function realMetrics()
local G = love.graphics
local w, h = G.getDimensions()
local pw, ph = w, h
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
local dpiX = w > 0 and pw / w or 1
local dpiY = h > 0 and ph / h or 1
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return math.max(1, w), math.max(1, h),
math.max(1, pw), math.max(1, ph), dpiX, dpiY
end
local function clampRect(value, w, h)
if type(value) ~= "table" then
return { x = 0, y = 0, width = w, height = h }
end
local x = finite(value.x) and math.floor(value.x) or 0
local y = finite(value.y) and math.floor(value.y) or 0
local rw = finite(value.width) and math.floor(value.width) or w
local rh = finite(value.height) and math.floor(value.height) or h
x = math.max(0, math.min(x, w - 1))
y = math.max(0, math.min(y, h - 1))
rw = math.max(1, math.min(rw, w - x))
rh = math.max(1, math.min(rh, h - y))
return { x = x, y = y, width = rw, height = rh }
end
local function sameSize(canvas, w, h)
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
end
function Viewport.begin(generation)
local w, h, pw, ph, dpiX, dpiY = realMetrics()
local context = {
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
dpiX = dpiX, dpiY = dpiY, generation = generation,
}
local requested
if Runtime.wantsHook("render.viewport") then
requested = Runtime.call("render.viewport", function(ctx)
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
end, context)
end
local rect = clampRect(requested, w, h)
Viewport.full = context
Viewport.rect = rect
Viewport.generation = generation
local active = type(requested) == "table" and requested.capture == true
or rect.x ~= 0 or rect.y ~= 0
or rect.width ~= w or rect.height ~= h
Viewport.frameActive = active
if active then
if not sameSize(Viewport.canvas, rect.width, rect.height) then
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
Viewport.canvas:setFilter("nearest", "nearest")
end
else
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return rect
end
function Viewport.active()
return Viewport.frameActive == true and Viewport.canvas ~= nil
end
function Viewport.dimensions()
if Viewport.active() then
return Viewport.rect.width, Viewport.rect.height
end
return love.graphics.getDimensions()
end
function Viewport.pixelDimensions()
if Viewport.active() then
if Viewport.canvas.getPixelDimensions then
local w, h = Viewport.canvas:getPixelDimensions()
return math.max(1, w), math.max(1, h)
end
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
end
if love.graphics.getPixelDimensions then
return love.graphics.getPixelDimensions()
end
return love.graphics.getDimensions()
end
function Viewport.fullDimensions()
if Viewport.full then return Viewport.full.width, Viewport.full.height end
return love.graphics.getDimensions()
end
function Viewport.target()
return Viewport.canvas
end
function Viewport.setTarget()
love.graphics.setCanvas(Viewport.canvas)
end
function Viewport.toLocal(x, y)
local rect = Viewport.rect
if not rect then return x, y, true end
local lx, ly = x - rect.x, y - rect.y
return lx, ly,
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
end
function Viewport.localSafeRect(x, y, w, h)
local rect = Viewport.rect
if not Viewport.active() or not rect then return x, y, w, h end
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
local x2 = math.min(x + w, rect.x + rect.width)
local y2 = math.min(y + h, rect.y + rect.height)
if x2 <= x1 or y2 <= y1 then
return 0, 0, rect.width, rect.height
end
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
end
function Viewport.finish(game)
if not Viewport.active() then return end
local G = love.graphics
local rect, full = Viewport.rect, Viewport.full
G.setCanvas()
G.push("all")
G.origin()
G.setScissor()
G.setShader()
G.setBlendMode("alpha")
G.clear(0, 0, 0, 1)
local context = {
canvas = Viewport.canvas,
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
windowWidth = full.width, windowHeight = full.height,
dpiX = full.dpiX, dpiY = full.dpiY,
generation = Viewport.generation,
}
Runtime.call("render.window", function(_, ctx)
G.setColor(1, 1, 1, 1)
G.draw(ctx.canvas, ctx.x, ctx.y)
end, game, context)
G.pop()
end
function Viewport.reset()
Viewport.frameActive = false
Viewport.rect = nil
Viewport.full = nil
Viewport.generation = nil
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return Viewport
+97 -12
View File
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas") local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle -- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes") local FaithfulRes = require("src.core.FaithfulRes")
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square). -- on BOTH axes (square).
local function displayMetrics() local function displayMetrics()
local ww, wh = love.graphics.getDimensions() local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh local pw, ph = ww, wh
if love.graphics.getPixelDimensions then pw, ph = GameViewport.pixelDimensions()
pw, ph = love.graphics.getPixelDimensions()
end
local dpiX, dpiY = 1, 1 local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end if wh > 0 and ph > 0 then dpiY = ph / wh end
@@ -94,6 +93,7 @@ function Renderer:init()
-- reason -- worldViewSize() already works in drawable pixels. -- reason -- worldViewSize() already works in drawable pixels.
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest") self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.battleHUDCanvas = nil
self.worldCanvas = nil self.worldCanvas = nil
self.worldActive = false self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world -- tilt mode only: a transparent overlay canvas the size of the world
@@ -202,10 +202,38 @@ function Renderer:setUISize(w, h)
w, h = math.floor(w), math.floor(h) w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end if self.canvas and self.canvas.release then self.canvas:release() end
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = nil
self.uiWidth, self.uiHeight = w, h self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest") self.canvas = PixelCanvas.new(w, h, "nearest")
end end
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
-- battle scene remains in `canvas`; endFrame places registered HUD regions
-- afterward in physical-window space.
function Renderer:beginBattleHUDPass()
local w, h = self:uiSize()
if not self.battleHUDCanvas
or self.battleHUDCanvas:getWidth() ~= w
or self.battleHUDCanvas:getHeight() ~= h then
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
end
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
or self.canvas
love.graphics.setCanvas(self.battleHUDCanvas)
love.graphics.clear(0, 0, 0, 0)
return previous
end
function Renderer:endBattleHUDPass(previous)
love.graphics.setCanvas(previous or self.canvas)
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer -- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel -- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies -- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
@@ -671,6 +699,17 @@ end
-- centred letterbox. Declared during the element's own draw, in UI-canvas -- centred letterbox. Declared during the element's own draw, in UI-canvas
-- pixels, and consumed by endFrame this frame only. -- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright" -- anchor: "bottom" | "topright" | "topleft" | "bottomright"
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
canvas, extract)
renderer.uiAnchors = renderer.uiAnchors or {}
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
x = x, y = y, w = w, h = h, anchor = anchor,
windowClamped = windowClamped and true or false,
canvas = canvas,
extract = extract ~= false,
}
end
function Renderer:setUIAnchor(x, y, w, h, anchor) function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from -- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the -- save.options.uiLayout): every element stays where it was drawn in the
@@ -684,9 +723,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
-- battle -- keeps every element inside it, so the box blits where it was -- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge. -- drawn in the canvas instead of being pulled to the window edge.
if self.uiAnchorHold then return end if self.uiAnchorHold then return end
self.uiAnchors = self.uiAnchors or {} addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
self.uiAnchors[#self.uiAnchors + 1] = end
{ x = x, y = y, w = w, h = h, anchor = anchor }
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
-- intentionally allowed while BattleState holds general dialogue/menu
-- anchors inside the battle surface. Callers must gate it to an explicit
-- battle HUD mode.
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
addUIAnchor(self, x, y, w, h, anchor, true,
self.battleHUDCanvas or self.canvas, false)
end end
-- zones: optional list of SGB palette regions (see PaletteFX) in -- zones: optional list of SGB palette regions (see PaletteFX) in
@@ -698,7 +744,7 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and -- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; -- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
@@ -799,6 +845,8 @@ function Renderer:endFrame(zones, worldZones)
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in -- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border. -- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0 local clearR, clearG, clearB = 0, 0, 0
local extendedBlackBand = false
local bandR, bandG, bandB = 1, 1, 1
if not self.worldActive then if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game") local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack local stack = ok and Game and Game.stack
@@ -825,7 +873,15 @@ function Renderer:endFrame(zones, worldZones)
-- screen stays black (src/core/FaithfulRes.lua); the paper surround -- screen stays black (src/core/FaithfulRes.lua); the paper surround
-- painted the whole phone white on New Game and in battle (#864), so -- painted the whole phone white on New Game and in battle (#864), so
-- the lock keeps the default black bars. -- the lock keeps the default black bars.
if state and state.letterboxWhite if state and state.extendedBlackHUD and state:extendedBlackHUD()
and not FaithfulRes.scaleCap() then
-- Extended/Black keeps the author's black surround, but extends the
-- fixed battle's paper field vertically through the physical window.
-- The band uses the exact centred fixed-width composition bounds, so
-- only vertical black bars remain at the sides.
extendedBlackBand = true
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
elseif state and state.letterboxWhite
and not (state.bgMode and state:bgMode() == "black") and not (state.bgMode and state:bgMode() == "black")
and not FaithfulRes.scaleCap() then and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
@@ -833,6 +889,10 @@ function Renderer:endFrame(zones, worldZones)
end end
love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
if extendedBlackBand then
love.graphics.setColor(bandR, bandG, bandB, 1)
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the -- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game -- 160x144 (or world) blit. Drawn after the clear and before the game
@@ -975,6 +1035,22 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
-- Back the exact fixed-width composition from physical top to bottom so only
-- the left and right sides expose the world. The battle canvas and detached
-- HUD remain transparent layers composited afterward.
-- A worldOverride is an arena provider's completed scene (for example,
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
-- field, so never cover it with the native back-sprite fallback.
if self.extendedWorldBand and not self.worldOverride
and not FaithfulRes.scaleCap() then
local ok, Game = pcall(require, "src.core.Game")
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
love.graphics.setColor(1, 1, 1, 1)
end
-- UI: anchored regions against their screen edges, the rest in the classic -- UI: anchored regions against their screen edges, the rest in the classic
-- centred letterbox. With nothing anchored this is the single blit it has -- centred letterbox. With nothing anchored this is the single blit it has
-- always been. -- always been.
@@ -997,14 +1073,23 @@ function Renderer:endFrame(zones, worldZones)
if a.anchor == "bottom" then if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh dy = wh - gapB - dh
elseif a.anchor == "top" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = a.y * Uy
elseif a.anchor == "topright" then elseif a.anchor == "topright" then
dx = ww - gapR - dw dx = ww - gapR - dw
dy = a.y * Uy dy = a.y * Uy
else -- unknown anchor: leave it where it is else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end end
if a.windowClamped then
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) if a.extract then
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
end
end end
for _, r in ipairs(rest) do for _, r in ipairs(rest) do
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
@@ -1013,7 +1098,7 @@ function Renderer:endFrame(zones, worldZones)
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
-- The zone scissors are computed from the same origin, so an SGB -- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox. -- region travels with the element instead of staying in the letterbox.
blit(self.canvas, Ux, Uy, zones, Ux, Uy, blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end end
end end
@@ -1064,7 +1149,7 @@ function Renderer:endFrame(zones, worldZones)
end end
if present then if present then
love.graphics.setCanvas() GameViewport.setTarget()
-- Post-process pipelines run over the finished composite -- world, UI -- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the -- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the -- LCD grid is then drawn over rather than something that smears the
+67 -6
View File
@@ -1,11 +1,13 @@
-- Bridge to native secondary-display output (Android Presentation). The C -- Shared secondary-display facade. Android uses its native Presentation
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp. -- bridge; process-capable desktop hosts fall back to a companion window.
-- Everything is guarded: off Android, or if the symbols cannot be resolved, -- Everything is guarded, so unsupported hosts keep the in-window layout.
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {} local SecondScreen = {}
local C = nil local C = nil
local ffi = nil local ffi = nil
local desktop = nil
local nativePresent = false
local nativeTarget = false
local function log(msg) local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -21,6 +23,10 @@ do
int love_android_secondary_ready(); int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h); void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on); void love_android_secondary_enable(int on);
int love_android_secondary_detected();
int love_android_present_secondary(const void *rgba, int w, int h,
unsigned int background, int cover);
void love_android_secondary_target(int target);
const char *love_android_poll_secondary_touch(); const char *love_android_poll_secondary_touch();
]]) ]])
local okLib, lib = pcall(ffi.load, "love") local okLib, lib = pcall(ffi.load, "love")
@@ -34,21 +40,74 @@ do
log(("bridge symbols not found (ffi.load ok=%s); second display disabled") log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib))) :format(tostring(okLib)))
end end
if C then
local okDetected, detected = pcall(function()
return C.love_android_secondary_detected
end)
local okPresent, present = pcall(function()
return C.love_android_present_secondary
end)
local okTarget, target = pcall(function()
return C.love_android_secondary_target
end)
nativePresent = okDetected and detected ~= nil
and okPresent and present ~= nil
nativeTarget = okTarget and target ~= nil
end
end
end
if not C then
local ok, backend = pcall(require, "src.render.DesktopScreen")
if ok and backend and backend.usable and backend.usable() then
desktop = backend
log("desktop companion backend ready")
end end
end end
function SecondScreen.usable() function SecondScreen.usable()
return C ~= nil return C ~= nil or desktop ~= nil
end end
function SecondScreen.available() function SecondScreen.available()
if desktop then return desktop.available() end
if not C then return false end if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready) local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0 return ok and r ~= 0
end end
function SecondScreen.push(imageData, w, h) -- A connected display is not necessarily the current Presentation yet. This
-- distinction lets a companion retry its first frame after hotplug/re-target.
function SecondScreen.detected()
if desktop then return desktop.detected() end
if nativePresent then
local ok, r = pcall(C.love_android_secondary_detected)
return ok and r ~= 0
end
return SecondScreen.available()
end
function SecondScreen.push(imageData, w, h, background, preference)
if desktop then
return desktop.push(imageData, w, h, background, preference)
end
if not C or not imageData then return false end if not C or not imageData then return false end
if nativePresent and (background ~= nil or preference ~= nil) then
if nativeTarget then
local target = 0
if preference == "handheld" or preference == "handheld:cover" then
target = 1
elseif preference == "secondary" or preference == "secondary:cover" then
target = 2
end
pcall(C.love_android_secondary_target, target)
end
local cover = type(preference) == "string"
and preference:sub(-6) == ":cover"
local ok, shown = pcall(C.love_android_present_secondary,
imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
return ok and shown ~= 0
end
return pcall(function() return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h) C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end) end)
@@ -57,6 +116,7 @@ end
-- Returns the oldest queued secondary-display event as "action,x,y", where -- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space. -- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch() function SecondScreen.pollTouch()
if desktop then return desktop.pollTouch() end
if not C then return nil end if not C then return nil end
local ok, event = pcall(function() local ok, event = pcall(function()
return C.love_android_poll_secondary_touch() return C.love_android_poll_secondary_touch()
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
end end
function SecondScreen.setEnabled(on) function SecondScreen.setEnabled(on)
if desktop then return desktop.setEnabled(on) end
if not C then return end if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end end
+23 -30
View File
@@ -358,42 +358,33 @@ end
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on: -- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2 -- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
-- (constants/script_constants.asm). -- (constants/script_constants.asm).
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the -- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
-- routine that prints it is engine code and no script bytecode points at the -- the extractor never reaches it.
-- string, so the extractor never reaches it.
local CONTEST_NICKNAME_PROMPT = local CONTEST_NICKNAME_PROMPT =
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?") Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
function Specials.askNickname(vm, mon)
nameMon(vm, mon.species)
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- InitNickname (engine/pokemon/move_mon.asm:1787)
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's blank test (home/string.asm:6-30)
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
end
H.CheckPartyFullAfterContest = function(vm) H.CheckPartyFullAfterContest = function(vm)
local Breeding = require("src.core.gen2.Breeding") local Breeding = require("src.core.gen2.Breeding")
local result, mon = local result, mon =
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE) BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the -- GiveANickname_YesNo runs on both contest arms, party and box
-- mon that joined the party and the one that went to the box -- and nowhere
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
-- in wContestMon, so this is the only place the player is ever asked.
-- GetPokemonName runs first, which is what {STRBUF} reads.
if mon and result ~= BugContest.NO_CATCH then if mon and result ~= BugContest.NO_CATCH then
nameMon(vm, mon.species) Specials.askNickname(vm, mon)
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
-- left standing.
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
-- copies the species name back over an empty entry -- so a cancelled
-- keyboard is the same as answering NO.
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
-- spaces followed by a null". The keyboard's blank cells are real
-- typeable characters, so an all-space entry has to be discarded the
-- same way an empty one is, not stored as a name of spaces.
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
end end
answer(vm, result) answer(vm, result)
end end
@@ -1005,7 +996,7 @@ end
-- check is transcribed here rather than left to the screen, because its two -- check is transcribed here rather than left to the screen, because its two
-- refusals are TEXT and the script has to see them before the machine opens: -- refusals are TEXT and the script has to see them before the machine opens:
-- no coins at all, or no COIN_CASE to hold them. -- no coins at all, or no COIN_CASE to hold them.
local COIN_CASE = 0x47 -- constants/item_constants.asm local COIN_CASE = 0x36 -- constants/item_constants.asm:62
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm. -- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
local NO_COINS_TEXT = "You have no coins." local NO_COINS_TEXT = "You have no coins."
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" }, { "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" }, { "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" }, { "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" }, -- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
-- WaitForLinkedFriend and lands on .FriendNotReady
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" }, { "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
{ "TradeCenter", nil, "link cable: no trade room" }, { "TradeCenter", nil, "link cable: no trade room" },
{ "Colosseum", nil, "link cable: no battle room" }, { "Colosseum", nil, "link cable: no battle room" },
+6 -1
View File
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5 local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0 local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then if self.givePokeFn then
self.givePokeFn(species, level, item) local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if mon and trainer == 0 then
Specials.askNickname(self, mon)
end
end end
elseif op == "checkpoke" then elseif op == "checkpoke" then
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed -- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
+16 -1
View File
@@ -4,6 +4,7 @@
local ItemEffects = require("src.inventory.ItemEffects") local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local BagMenu = {} local BagMenu = {}
@@ -46,7 +47,16 @@ end
-- the stack, so every exit that prints has to close it afterwards. For -- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity -- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252). -- check makes it a no-op (#252).
local function useOn(game, battle, id, target, list, moveIndex, picker) --
-- Every result string used to fall through to this one unconditional
-- function with no seam around it: a mod could not suppress a message,
-- delay it behind a screen of its own, or replace the outcome for one item
-- id. The "item.use" hook wraps the whole dispatch (not a name per
-- result -- a mod deciding what a Poké Doll or a stone does needs the
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
-- screen's own default behavior elsewhere in src/ui.
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld) battle, moveIndex, game.overworld)
local function closePicker() local function closePicker()
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
showMessages(game, payload, closePicker) -- failed showMessages(game, payload, closePicker) -- failed
end end
local function useOn(game, battle, id, target, list, moveIndex, picker)
return Runtime.call("item.use", vanillaUseOn,
game, battle, id, target, list, moveIndex, picker)
end
local function pickTargetAndUse(game, battle, id, list) local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party -- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon -- the ETHERs and PP UP open the move menu after picking a mon
+124 -38
View File
@@ -14,6 +14,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local DexEntryMenu = {} local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu DexEntryMenu.__index = DexEntryMenu
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
return speciesOrOpts, false return speciesOrOpts, false
end end
local function ownedFor(game, def, forceOwned)
return forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
end
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
local function descPages(game, def, forceOwned)
local e = def.dexEntry or {}
local owned = ownedFor(game, def, forceOwned)
local text = owned and e.text and game.data.text[e.text] or nil
if not text then return nil end
local pages = {}
for chunk in (text .. "\f"):gmatch("(.-)\f") do
local lines = {}
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
end
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
if #lines > 0 then pages[#pages + 1] = lines end
end
if #pages == 0 then return nil end
local last = pages[#pages]
last[#last] = last[#last] .. "."
return pages
end
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
local frameCache = {}
local function frameSheet(game)
local fx = game.data.field and game.data.field.overworldFx
local def = fx and fx.pokedexFrame
local path = def and def.path
if not path then return nil end
local hit = frameCache[path]
if hit ~= nil then return hit or nil end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then
frameCache[path] = false
return nil
end
local iw, ih = img:getDimensions()
local quads = {}
for i = 0, 17 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8, 8, 8, iw, ih)
end
frameCache[path] = { img = img, quads = quads }
return frameCache[path]
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
}
function DexEntryMenu.new(game, speciesOrOpts, onDone) function DexEntryMenu.new(game, speciesOrOpts, onDone)
local species, forceOwned = resolveArgs(speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned, local self = setmetatable({ game = game, forceOwned = forceOwned,
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local path, trueColor = require("src.pokemon.Sprites").path( local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" }) game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and -- pcall's second return has to survive the guard (#307)
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
local ok, img = false, nil local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species) require("src.core.Sound").playCry(game.data, species)
return self return self
end end
@@ -57,6 +115,11 @@ end
function DexEntryMenu:update(dt) function DexEntryMenu:update(dt)
local input = self.game.input local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
self.page = self.page + 1
return
end
self.game.stack:pop() self.game.stack:pop()
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
end end
@@ -64,64 +127,87 @@ end
function DexEntryMenu:draw() function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned, DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor) self.spriteTrueColor, self.page)
end end
-- Static entry-page renderer, shared with the printer stand-in -- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way -- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer). -- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) -- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
page = page or 1
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
if frame then
local function tile(code, tx, ty)
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
end
-- engine/menus/pokedex.asm:418
for tx = 1, 18 do
tile(0x64, tx, 0)
tile(0x6f, tx, 17)
end
for ty = 1, 16 do
tile(0x66, 0, ty)
tile(0x67, 19, ty)
end
tile(0x63, 0, 0)
tile(0x65, 19, 0)
tile(0x6c, 0, 17)
tile(0x6e, 19, 17)
-- engine/menus/pokedex.asm:445
for tx = 0, 19 do
tile(DIVIDER[tx + 1], tx, 9)
end
end
if sprite then if sprite then
local y = math.max(0, 60 - sprite:getHeight()) -- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
love.graphics.draw(sprite, 8, y) local w, h = sprite:getDimensions()
-- a full-color pic has to sit out the SGB recolor, so mark its bounds local x = 8 + math.floor((8 - w / 8) / 2) * 8
-- for the unshaded pass (#350). The printer path leaves trueColor nil: local y = 8 + (7 - h / 8) * 8
-- it renders to its own PNG canvas, and a mark left behind there would love.graphics.draw(sprite, x + w, y, 0, -1, 1)
-- bleed into the next real frame. -- the unshaded pass needs the pic bounds (#350)
if trueColor then if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions()) require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end end
end end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8) -- engine/menus/pokedex.asm:454
Font.draw(def.name, 72, 16)
local e = def.dexEntry or {} local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString). -- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm; Font.draw(e.kind or "?", 72, 32)
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999 -- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once -- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3 local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32) -- engine/menus/pokedex.asm:478
local owned = forceOwned Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
or (game.save.pokedex and game.save.pokedex.owned[def.id]) 16, 64)
-- height/weight print only once owned, like the description local owned = ownedFor(game, def, forceOwned)
-- (pokedex.asm: "if the pokemon has not been owned, don't print the -- engine/menus/pokedex.asm:449, numbers only once owned
-- height, weight, or description")
if owned and e.heightFt then if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then if e.heightM then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44) Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54) Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44) Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54) Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
end end
end end
local text = owned and e.text and game.data.text[e.text] or nil local pages = descPages(game, def, forceOwned)
local y = 72 if pages then
if text then -- engine/menus/pokedex.asm:568
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do local lines = pages[page] or pages[#pages]
if y > 132 then break end for i, line in ipairs(lines) do
Font.draw(line, 8, y) Font.draw(line, 8, 72 + i * 16)
y = y + 10 end
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end end
else else
Font.draw(Strings("Data unknown."), 8, y) Font.draw(Strings("Data unknown."), 8, 88)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+112
View File
@@ -0,0 +1,112 @@
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local HallOfFame = require("src.ui.HallOfFame")
local LeaguePC = {}
LeaguePC.__index = LeaguePC
LeaguePC.isOpaque = true
-- constants/pokemon_data_constants.asm:65
local CAPACITY = 50
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
function LeaguePC:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self:currentMon()
local c = mon and P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
function LeaguePC.new(game, onDone)
local self = setmetatable({}, LeaguePC)
self.game = game
self.onDone = onDone
self.teams = (game.save and game.save.hallOfFame) or {}
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
self.monIndex = 1
self.sprites = {}
self.spriteTrueColor = {}
self:loadMon()
return self
end
function LeaguePC:currentMon()
local team = self.teams[self.teamIndex]
return team and team[self.monIndex] or nil
end
function LeaguePC:loadMon()
local mon = self:currentMon()
if not mon then return end
local species = mon.species
if self.sprites[species] == nil then
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprites[species] = ok and img or false
self.spriteTrueColor[species] = (ok and img and trueColor) or false
end
require("src.core.Sound").playCry(self.game.data, species)
end
function LeaguePC:close()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function LeaguePC:update(dt)
local input = self.game.input
if input:wasPressed("b") then
self:close()
return
end
if input:wasPressed("a") then
if not self:currentMon() then
self:close()
return
end
local team = self.teams[self.teamIndex]
if self.monIndex < #team then
self.monIndex = self.monIndex + 1
elseif self.teamIndex < #self.teams then
self.teamIndex = self.teamIndex + 1
self.monIndex = 1
else
self:close()
return
end
self:loadMon()
end
end
function LeaguePC:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self:currentMon()
if not mon then return end
local img = self.sprites[mon.species]
if img then
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
local w, h = img:getDimensions()
local x = 96 + math.floor((8 - w / 8) / 2) * 8
local y = 40 + (7 - h / 8) * 8
love.graphics.draw(img, x, y)
if self.spriteTrueColor[mon.species] then
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
-- engine/movie/hall_of_fame.asm:159
HallOfFame.drawMonInfo(self, mon)
-- engine/menus/league_pc.asm:102
Font.drawBox(0, 13, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return LeaguePC
+11 -1
View File
@@ -79,7 +79,16 @@ end
function NamingScreen:enter() function NamingScreen:enter()
if self.presets and #self.presets > 0 then if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local items = { { label = Strings("NEW NAME") } } -- engine/movie/oak_speech/oak_speech2.asm:1
self.choosing = true
self.isOpaque = false
local items = { {
label = Strings("NEW NAME"),
onSelect = function()
self.choosing = nil
self.isOpaque = nil
end,
} }
for _, preset in ipairs(self.presets) do for _, preset in ipairs(self.presets) do
table.insert(items, { table.insert(items, {
label = preset, label = preset,
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
end end
function NamingScreen:draw() function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
+42 -1
View File
@@ -180,6 +180,11 @@ local function buildRows(game)
step = function(g) step = function(g)
local o = g.save.options local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide" o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
elseif o.battleFit == "fill" and o.battleHud == "extended" then
o.battleBg = "white"
end
return true return true
end }, end },
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a -- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
@@ -195,6 +200,31 @@ local function buildRows(game)
step = function(g) step = function(g)
local o = g.save.options local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill" o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
if o.battleFit == "fill" and o.battleLayout == "wide"
and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
{ id = "battleHud", label = Strings("BATTLE HUD"),
value = function(g)
local o = g.save.options
return o.battleLayout == "wide" and o.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
step = function(g)
local o = g.save.options
-- The extended HUD is a widescreen-only composition. Keep OG locked
-- to the author's standard HUD even if an older save says otherwise.
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
return false
end
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
if o.battleHud == "extended" and o.battleFit == "fill" then
o.battleBg = "white"
end
return true return true
end }, end },
-- What sits behind and around the battle. WHITE is the classic paper -- What sits behind and around the battle. WHITE is the classic paper
@@ -203,13 +233,24 @@ local function buildRows(game)
-- shows through everywhere the battle does not paint). -- shows through everywhere the battle does not paint).
{ id = "battleBg", label = Strings("BATTLE BG"), { id = "battleBg", label = Strings("BATTLE BG"),
value = function(g) value = function(g)
local m = g.save.options.battleBg local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return Strings("AUTO")
end
local m = o.battleBg
if m == "black" then return Strings("BLACK") end if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end if m == "world" then return Strings("WORLD") end
return Strings("WHITE") return Strings("WHITE")
end, end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return false
end
local order = { "white", "black", "world" } local order = { "white", "black", "world" }
local cur = 1 local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
+2 -16
View File
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
end end
if self.softboiledFrom then if self.softboiledFrom then
local user = party[self.softboiledFrom] local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5) self.softboiledFrom = nil
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp self.game.overworld:useSoftboiledFieldMove(user, mon)
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
end
elseif self.swapFrom then elseif self.swapFrom then
if self.swapFrom ~= self.index then if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
+32 -15
View File
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
-- the player's current location (guard: overworld may not be running) -- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
end
self.sel = 1 self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
-- fly destination (PALLET_TOWN), never the player's current town (#795). -- fly destination (PALLET_TOWN), never the player's current town (#795).
@@ -345,18 +359,16 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
return return
end end
-- the player's current location blinks (slow phase). Paint it with a -- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
if self.playerLoc and self.blink < 20 then if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc) local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0, 0, 0, 1) if self.playerSheet then
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
love.graphics.setColor(1, 1, 1, 1) else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's -- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own -- top-left; the cursor asset is a 16x16 hollow frame centered on its own
@@ -389,11 +401,16 @@ function TownMap:draw()
drawSquare(loc) drawSquare(loc)
end end
if self.playerLoc and self.blink < 20 then if self.playerLoc and self.blink < 20 then
-- palette-safe dark, same red-channel shade-remap reason as the primary -- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
-- grid path above (#152); stale-asset builds hit this fallback square if self.playerSheet then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.y * 8 + 2, 4, 4) self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
end end
if selected and self.blink % 16 < 10 then if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
+54 -16
View File
@@ -150,6 +150,10 @@ end
-- animation (most of them) skips the canvas entirely. -- animation (most of them) skips the canvas entirely.
local function needsCanvas(runner) local function needsCanvas(runner)
local bg = runner.bg local bg = runner.bg
-- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays
-- out of the BG until the next pic redraw, so those frames stay baked too.
local lifted = bg.liftedRows
if lifted and (lifted.player or lifted.enemy) then return true end
if bg.scx ~= 0 or bg.scy ~= 0 then return true end if bg.scx ~= 0 or bg.scy ~= 0 then return true end
if not bg.lcdc then return false end if not bg.lcdc then return false end
if bg.lyEnd <= bg.lyStart then return false end if bg.lyEnd <= bg.lyStart then return false end
@@ -229,13 +233,12 @@ end
-- times, and the grouping is by VALUE so a table that happens to repeat costs -- times, and the grouping is by VALUE so a table that happens to repeat costs
-- nothing extra. -- nothing extra.
local function bgpBands(bg) local function bgpBands(bg)
local base = bg.bgp or GbcPalette.BGP_IDENTITY
local order, bands = {}, {} local order, bands = {}, {}
for row = 0, SCREEN_H - 1 do for row = 0, SCREEN_H - 1 do
local inWindow = row >= bg.lyStart and row < bg.lyEnd local inWindow = row >= bg.lyStart and row < bg.lyEnd
-- Outside the window the register still reads whatever wBGP holds, which -- Outside the window the register still reads whatever wBGP holds.
-- for every effect that aims hLCDCPointer at rBGP is the identity. local byte = inWindow and (bg.lyBackup[row] or base) or base
local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY)
or GbcPalette.BGP_IDENTITY
local band = bands[byte] local band = bands[byte]
if not band then if not band then
band = { byte = byte, rows = {} } band = { byte = byte, rows = {} }
@@ -244,24 +247,56 @@ local function bgpBands(bg)
end end
band.rows[#band.rows + 1] = row band.rows[#band.rows + 1] = row
end end
-- Identity first so the fillBackground below it happens before any blit and -- The base band first so the fillBackground below it happens before any blit
-- the common band is the one drawn from the first bake. -- and the common band is the one drawn from the first bake.
table.sort(order, function(a, b) table.sort(order, function(a, b)
if a.byte == b.byte then return false end if a.byte == b.byte then return false end
if a.byte == GbcPalette.BGP_IDENTITY then return true end if a.byte == base then return true end
if b.byte == GbcPalette.BGP_IDENTITY then return false end if b.byte == base then return false end
return a.rows[1] < b.rows[1] return a.rows[1] < b.rows[1]
end) end)
return order return order
end end
-- Runs `drawBg` (the battle panel) and then puts it on screen through the -- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
-- animation's BG registers. Returns without a canvas when nothing is function BattleAnimView:panelPalettes(battle)
-- displacing anything, which is the common case and costs nothing. local list = {}
function BattleAnimView:present(runner, drawBg) local shades = {}
for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end
list[#list + 1] = shades
local function bracket(pair)
if not (pair and pair[1] and pair[2]) then return end
list[#list + 1] = {
{ 255, 255, 255 },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ 0, 0, 0 },
}
end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle and battle[side]
local colors = mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
if colors then list[#list + 1] = colors end
end
local hpBar = self.palettes and self.palettes.hpBar
if hpBar then
bracket(hpBar.green)
bracket(hpBar.yellow)
bracket(hpBar.red)
end
bracket(self.palettes and self.palettes.expBar)
return list
end
-- Runs `drawBg` (the battle panel) and puts it on screen through the
-- animation's BG registers; skips the canvas when nothing needs one.
function BattleAnimView:present(runner, drawBg, battle)
if not (love and love.graphics) then return end if not (love and love.graphics) then return end
local bg = runner.bg local bg = runner.bg
if not needsCanvas(runner) then local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY
and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil
if not invert and not needsCanvas(runner) then
drawBg() drawBg()
return return
end end
@@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg)
self:bake(drawBg, nil) self:bake(drawBg, nil)
-- A shifted scanline exposes whatever the BG map holds beside the pic, which local remapped = invert
-- outside the two pic boxes is the blank tile. Without this the exposed and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp)
-- strip is the canvas's own transparency and every shake shows a seam. -- A shifted scanline exposes the blank tile beside the pic boxes; without
-- this the exposed strip is the canvas's own transparency.
self:fillBackground() self:fillBackground()
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
-- hSCX / hSCY move the whole background; the per-scanline overrides only -- hSCX / hSCY move the whole background; the per-scanline overrides only
@@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg)
self:blitRow(row, dx, dy) self:blitRow(row, dx, dy)
end end
end end
if remapped then GbcPalette.clear() end
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- Shaderless boot: the panel is raw grayscale, so there are no palettes to
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce. -- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
if bg.lcdc == "BGP" then if bg.lcdc == "BGP" then
@@ -417,5 +454,6 @@ end
BattleAnimView.SCREEN_W = SCREEN_W BattleAnimView.SCREEN_W = SCREEN_W
BattleAnimView.SCREEN_H = SCREEN_H BattleAnimView.SCREEN_H = SCREEN_H
BattleAnimView.needsCanvas = needsCanvas
return BattleAnimView return BattleAnimView
+122 -46
View File
@@ -49,6 +49,9 @@ BattleState.isOpaque = true
-- the victory jingle can keep looping through the post-win prompts. -- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48 local MESSAGE_FRAMES = 48
-- engine/battle/effect_commands.asm:6661
local MOVE_DELAY_FRAMES = 40
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. -- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
local HM_MOVES = { local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
@@ -239,15 +242,8 @@ function BattleState.new(game, opts)
self.menuIndex = 1 self.menuIndex = 1
self.moveIndex = 1 self.moveIndex = 1
self.picCache = {} self.picCache = {}
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_ -- Which side's pic box stays EMPTY until a send-out redraws it: a catch
-- ReturnMon's last row (what swallows a mon into a thrown ball) and -- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
-- MonFaintedAnimation both clear the box and neither puts anything back: it
-- stays blank until something DRAWS a pic into it, which on the cart is only
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
-- Without this latch the pic came back the instant the animation let go of
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
-- popped back up for its own faint line. See stepAnim for why it is latched
-- at those two moments rather than off the runner's own last frame.
self.picHidden = { player = false, enemy = false } self.picHidden = { player = false, enemy = false }
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in -- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
-- from opposite sides before the first message. -- from opposite sides before the first message.
@@ -665,7 +661,7 @@ function BattleState:drawPic(mon, back)
-- the mon drawn at this frame. -- the mon drawn at this frame.
local scale = self:picScale(path, mon, back) local scale = self:picScale(path, mon, back)
if anim then if anim then
px = px + (anim.slide or 0) if not self.liftedPass then px = px + (anim.slide or 0) end
local resized = anim.size and PIC_RESIZE_TILES[anim.size] local resized = anim.size and PIC_RESIZE_TILES[anim.size]
if resized then scale = scale * (resized / boxTiles) end if resized then scale = scale * (resized / boxTiles) end
end end
@@ -718,11 +714,57 @@ function BattleState:drawPic(mon, back)
-- A mod-supplied pic that says it is already coloured is drawn as it is: -- A mod-supplied pic that says it is already coloured is drawn as it is:
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands -- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
-- back to its own draw site. -- back to its own draw site.
if colors and not trueColor and GbcPalette.available() then local function paint()
GbcPalette.with(colors, body) if colors and not trueColor and GbcPalette.available() then
else GbcPalette.with(colors, body)
body() else
body()
end
end end
local lifted = anim and anim.lifted
if not lifted then
paint()
return
end
-- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG.
local bandY = (back and BattleState.PLAYER_PIC_TILE_Y
or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8
local bandH = lifted[2] * 8
local psx, psy, psw, psh
if G.getScissor then psx, psy, psw, psh = G.getScissor() end
if self.liftedPass then
G.setScissor(0, bandY, 160, bandH)
paint()
else
if bandY > 0 then
G.setScissor(0, 0, 160, bandY)
paint()
end
local below = 144 - bandY - bandH
if below > 0 then
G.setScissor(0, bandY + bandH, 160, below)
paint()
end
end
if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the -- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
@@ -1050,10 +1092,10 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE" return "ANIM_PLAYER_DAMAGE"
end end
function BattleState:animForMove(moveId, side) function BattleState:animForMove(moveId, side, param)
local key = self.anims and self.anims.moves and self.anims.moves[moveId] local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, { local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true, turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
}) })
if started then if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
@@ -1091,14 +1133,22 @@ function BattleState:animForId(idName, side, param)
}) })
end end
-- data/moves/animations.asm:379
function BattleState:latchCaughtPic()
local anim = self.anim
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
and self.ballThrow and self.ballThrow.caught then
self.picHidden.enemy = true
end
end
-- One logic frame of a running animation. B cuts it short, the way holding B -- One logic frame of a running animation. B cuts it short, the way holding B
-- pages a text box. -- pages a text box.
function BattleState:stepAnim(input) function BattleState:stepAnim(input)
if not self.anim then return end if not self.anim then return end
if input and (input:wasPressed("b") or input:wasPressed("start")) then if input and (input:wasPressed("b") or input:wasPressed("start")) then
-- Cut short: the BG effects never reached their own last step, so the -- Cut short: only the explicit latches (a caught mon) survive a skip.
-- tilemap is whatever they had got to and nothing is latched -- the self:latchCaughtPic()
-- explicit latches (a catch) are the only ones that survive a skip.
self.anim = nil self.anim = nil
-- Cart still reaches the after-anim arm after a move script ends; a skip -- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it. -- of the move should not drop the hit shake that follows it.
@@ -1106,6 +1156,7 @@ function BattleState:stepAnim(input)
return self:endSendOutAnim() return self:endSendOutAnim()
end end
if not self.anim:step() then if not self.anim:step() then
self:latchCaughtPic()
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means -- pokegold data/moves/animations.asm .Click: anim_keepsprites means
-- the OAM outlives the script, so keep the runner for drawing too. -- the OAM outlives the script, so keep the runner for drawing too.
if not self.anim.keepSprites then self.anim = nil end if not self.anim.keepSprites then self.anim = nil end
@@ -1132,6 +1183,7 @@ function BattleState:animPicState(side)
local bg = self.anim.bg local bg = self.anim.bg
return { return {
hidden = bg.hidden[side], hidden = bg.hidden[side],
lifted = bg.liftedRows and bg.liftedRows[side] or nil,
size = bg.picSize[side], size = bg.picSize[side],
slide = bg.slide[side] or 0, slide = bg.slide[side] or 0,
shade = bg.monShade[side], shade = bg.monShade[side],
@@ -1391,16 +1443,14 @@ function BattleState:advanceQueue()
end end
if event.text then if event.text then
self.message = event.text self.message = event.text
-- Lines that must not hold the queue for A/B: -- move/level lines do not hold for A/B (battle.asm:336-343); experience
-- move UsedMoveText -> text_end, then moveanim -- keeps the wait (common_1.asm:1660-1665).
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
-- box's WaitPressAorB is the real hold
-- experience keeps the wait: _ExpPointsText ends in `prompt`
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
-- so the bar crawls under the line and A dismisses it before the battle
-- can end.
if event.kind == "move" or event.kind == "level" then if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0 self.messageTimer = 0
-- engine/battle/effect_commands.asm:1958-1961
if event.kind == "move" and event.missed then
self.messageDelay = MOVE_DELAY_FRAMES
end
else else
self.messageTimer = MESSAGE_FRAMES self.messageTimer = MESSAGE_FRAMES
end end
@@ -1422,17 +1472,12 @@ function BattleState:advanceQueue()
if event.waitSfx then self.waitSfx = event.sfx end if event.waitSfx then self.waitSfx = event.sfx end
end end
end end
-- The move's own animation plays over its "used X!" line, which is where -- engine/battle/effect_commands.asm:1958: a missed move burns the delay
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit -- and plays nothing; the after-anim chain is animForMove / stepAnim's.
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path.
if event.kind == "move" and not event.missed then if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil self.afterAnimPlayed = nil
self.pendingAfterAnim = nil self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then if not self:animForMove(event.move, event.side, event.animParam) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough). -- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options local options = self.game and self.game.options
@@ -1854,6 +1899,11 @@ function BattleState:update(_dt)
-- (core.asm:6881-6888), with that line still on screen. Run the crawl -- (core.asm:6881-6888), with that line still on screen. Run the crawl
-- before any PromptButton wait so the bar does not sit frozen until A. -- before any PromptButton wait so the bar does not sit frozen until A.
if self:stepExpAnim() then return end if self:stepExpAnim() then return end
-- engine/battle/effect_commands.asm:6661
if (self.messageDelay or 0) > 0 then
self.messageDelay = self.messageDelay - 1
return
end
if self.messageTimer > 0 then if self.messageTimer > 0 then
if self.tutorial then if self.tutorial then
-- PromptButton waits for the button; the tutorial cannot press it, so -- PromptButton waits for the button; the tutorial cannot press it, so
@@ -2463,14 +2513,6 @@ function BattleState:pushCaught(enemy, itemId)
local save = self.save local save = self.save
self.battle.over = true self.battle.over = true
self.battle.outcome = "caught" self.battle.outcome = "caught"
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
-- send-out left in a battle that is already over. Latched here as well as
-- from the animation's own last step so that a throw the player skipped with
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
-- caught mon back on the field for the "Gotcha!" line.
self.picHidden.enemy = true
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to -- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's -- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
-- whole effect; its catch rate is a plain ball's. It applies on the box -- whole effect; its catch rate is a plain ball's. It applies on the box
@@ -2932,6 +2974,7 @@ function BattleState:useItem(itemId)
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything -- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
-- pushed above is drained only once the ball has finished wobbling. -- pushed above is drained only once the ball has finished wobbling.
self:startBallAnim(self:ballAnimParam(itemId), itemId) self:startBallAnim(self:ballAnimParam(itemId), itemId)
if caught and not self.anim then self.picHidden.enemy = true end
self.message = nil self.message = nil
self.messageTimer = 0 self.messageTimer = 0
self.phase = "resolving" self.phase = "resolving"
@@ -2981,8 +3024,10 @@ function BattleState:useItem(itemId)
-- Everything else the pack can spend on a party mon runs the same -- Everything else the pack can spend on a party mon runs the same
-- item_effects.asm routine the field pack runs: the potion line and the -- item_effects.asm routine the field pack runs: the potion line and the
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and -- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
-- the ETHER / ELIXER family. -- the ETHER / ELIXER family. Without the merged dataset this can only
local action = ItemEffects.partyAction(itemId) -- ever see RECORDS, the module's own built-ins, the same gap
-- Game2:usePartyItem had for the field pack.
local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
if action then if action then
return self:useOnPartyMon(itemId, action) return self:useOnPartyMon(itemId, action)
end end
@@ -3440,6 +3485,36 @@ function BattleState:drawScene()
end end
end end
-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X,
-- outside the scanline blit, so the attacker's SCX never moves it.
function BattleState:drawLiftedRows()
local battle = self.battle
if not battle then return end
local enemy = self:animPicState("enemy")
local player = self:animPicState("player")
local enemyLift = enemy and enemy.lifted
local playerLift = player and player.lifted
if not (enemyLift or playerLift) then return end
local G = love.graphics
if not self.liftCanvas then
self.liftCanvas = G.newCanvas(160, 144)
self.liftCanvas:setFilter("nearest", "nearest")
end
local previous = G.getCanvas()
G.setCanvas(self.liftCanvas)
G.clear(0, 0, 0, 0)
G.push()
G.origin()
self.liftedPass = true
if enemyLift then self:drawPic(battle.enemy, false) end
if playerLift then self:drawPic(battle.player, true) end
self.liftedPass = nil
G.pop()
G.setCanvas(previous)
G.setColor(1, 1, 1, 1)
G.draw(self.liftCanvas, 0, 0)
end
function BattleState:drawSceneBody() function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end local panel = function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
@@ -3460,7 +3535,8 @@ function BattleState:drawSceneBody()
return return
end end
if self.anim and self.animView then if self.anim and self.animView then
self.animView:present(self.anim, panel) self.animView:present(self.anim, panel, self.battle)
self:drawLiftedRows()
self.animView:drawObjects(self.anim, self.battle) self.animView:drawObjects(self.anim, self.battle)
return return
end end
+2 -1
View File
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws. -- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims") local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end end
function BattleTransition:draw() function BattleTransition:draw()
local w, h = love.graphics.getDimensions() local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h) self:drawWidescreen(w, h)
end end
+17 -9
View File
@@ -60,10 +60,11 @@ InitClock.TEXT = TEXT
-- same three and has always had them right. -- same three and has always had them right.
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0. -- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
local DAYS = { -- table: MainMenu's clock box and the Pokegear's clock card read the same
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -- weekday off the same save and must never disagree about what it is
} -- called.
local DAYS = Clock.DAY_NAMES
InitClock.DAYS = DAYS InitClock.DAYS = DAYS
function InitClock:wantsFillScale() return true end function InitClock:wantsFillScale() return true end
@@ -88,12 +89,15 @@ function InitClock.hourString(hour)
local h = math.floor(hour or 0) % 24 local h = math.floor(hour or 0) % 24
local display = h % 12 local display = h % 12
if display == 0 then display = 12 end if display == 0 then display = 12 end
local word = require("src.world.gen2.Palettes").clockDaytime(h) -- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word,
-- translated -- this string reaches the player as-is, unlike the internal
-- MORN/DAY/NITE key other palette code compares against.
local word = Clock.daytimeLabel(h)
return ("%s %d"):format(word, display) return ("%s %d"):format(word, display)
end end
function InitClock.oclockString(hour) function InitClock.oclockString(hour)
return InitClock.hourString(hour) .. " o'clock" return Strings("%s o'clock", InitClock.hourString(hour))
end end
function InitClock.timeString(hour, minute) function InitClock.timeString(hour, minute)
@@ -187,7 +191,7 @@ function InitClock:question()
return Strings(TEXT.whoaMinutes, self.minute) return Strings(TEXT.whoaMinutes, self.minute)
end end
if self.phase == "confirm-day" then if self.phase == "confirm-day" then
return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?") return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?")
end end
if self.phase == "response" then if self.phase == "response" then
return Strings(TEXT[InitClock.responseKey(self.hour)], return Strings(TEXT[InitClock.responseKey(self.hour)],
@@ -196,11 +200,15 @@ function InitClock:question()
return "" return ""
end end
-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString),
-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above.
local MINUTES = Strings.source("%d min.")
-- The value the picker box shows, or nil while a page is up with no picker. -- The value the picker box shows, or nil while a page is up with no picker.
function InitClock:display() function InitClock:display()
if self.phase == "hour" then return InitClock.oclockString(self.hour) end if self.phase == "hour" then return InitClock.oclockString(self.hour) end
if self.phase == "minute" then return ("%d min."):format(self.minute) end if self.phase == "minute" then return Strings(MINUTES, self.minute) end
if self.phase == "day" then return DAYS[self.day + 1] or "?" end if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end
return nil return nil
end end
+6 -5
View File
@@ -29,10 +29,11 @@ local MainMenu = {}
MainMenu.__index = MainMenu MainMenu.__index = MainMenu
MainMenu.isOpaque = true MainMenu.isOpaque = true
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. -- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES
local DAYS = { -- / Clock.weekdayName is the single translated home for this table (see
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -- InitClock.lua's DAYS), so this screen's clock box cannot drift from the
} -- Pokegear's own.
local DAYS = Clock.DAY_NAMES
-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet. -- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet.
local MENU_MUSIC = "Music_MainMenu" local MENU_MUSIC = "Music_MainMenu"
@@ -164,7 +165,7 @@ function MainMenu:drawClockBox()
-- Textbox at (0,12) with 4 interior rows and 13 interior columns. -- Textbox at (0,12) with 4 interior rows and 13 interior columns.
Chrome.textbox(0, 12, 13, 4) Chrome.textbox(0, 12, 13, 4)
local hour, minute, weekday = self:clockParts() local hour, minute, weekday = self:clockParts()
Chrome.print(DAYS[weekday] or "DAY", 1, 14) Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14)
-- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded -- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded
-- minutes; the AM/PM half is drawn by PrintHour itself. -- minutes; the AM/PM half is drawn by PrintHour itself.
local display = hour % 12 local display = hour % 12
+4 -18
View File
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
self.game = game self.game = game
self.save = opts.save or (game and game.save) self.save = opts.save or (game and game.save)
local data = game and game.data or {} local data = game and game.data or {}
-- Held, not just read into the fields below. CRY resolves its sample -- engine/pokedex/pokedex.asm:447
-- through data.audio.cries and AREA resolves nests and landmark names
-- through data.maps / data.landmarks, and every one of those reads
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
-- returned before it reached Sound, and the button did nothing at all.
-- Taken by reference so a mod's merged cry or landmark is the one used.
self.data = data self.data = data
self.dex = opts.pokedex or data.gen2Pokedex self.dex = opts.pokedex or data.gen2Pokedex
self.pokemon = opts.pokemon or data.pokemon self.pokemon = opts.pokemon or data.pokemon
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1) self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
local G = love.graphics local G = love.graphics
local table_ = self.data and self.data.landmarks
local byIndex = self.landmarkByIndex
if not byIndex then
byIndex = {}
for _, entry in pairs((table_ and table_.landmarks) or {}) do
if entry and entry.index then byIndex[entry.index] = entry end
end
self.landmarkByIndex = byIndex
end
if #nests == 0 then if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart -- A species with no grass, water or roamer entry in this region. The cart
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
return return
end end
-- Blinking markers, the way the cart flashes its OBJs. -- engine/pokegear/pokegear.asm:2427
local on = ((self.areaBlink or 0) % 32) < 20 local on = ((self.areaBlink or 0) % 32) < 20
if cells and on then if cells and on then
for _, index in ipairs(nests) do for _, index in ipairs(nests) do
local mark = byIndex[index] local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then if mark and mark.x and mark.y then
G.setColor(0, 0, 0, 1) G.setColor(0, 0, 0, 1)
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5) G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
-- Name the first one in words as well as on the map: the flashing dot is -- Name the first one in words as well as on the map: the flashing dot is
-- unreadable at this size on a modern display, and the landmark name is what -- unreadable at this size on a modern display, and the landmark name is what
-- a player actually wants off this screen. -- a player actually wants off this screen.
local first = byIndex[nests[1]] local first = Nests.landmark(self.data, nests[1])
if first and first.name then if first and first.name then
local name = tostring(first.name):gsub("\n", " ") local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16) self:text(name, 1, 16)
+3 -7
View File
@@ -56,10 +56,6 @@ local CARDS = {
-- card over. One row, so `#self.cards` stays 1 and nothing pages. -- card over. One row, so `#self.cards` stays 1 and nothing pages.
local FLY_MAP_CARD = { id = "map", label = "FLY" } local FLY_MAP_CARD = { id = "map", label = "FLY" }
local DAYS = {
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
}
-- ---------------------------------------------------------------- the radio -- ---------------------------------------------------------------- the radio
-- --
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code. -- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
@@ -1878,7 +1874,7 @@ function Pokegear:drawClock()
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and -- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at -- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
-- column 12. -- column 12.
self:text(DAYS[weekday] or "", 6, 6) self:text(Clock.weekdayName(weekday) or "", 6, 6)
local display = hour % 12 local display = hour % 12
if display == 0 then display = 12 end if display == 0 then display = 12 end
self:text(Chrome.number(display, 2), 6, 8) self:text(Chrome.number(display, 2), 6, 8)
@@ -2203,13 +2199,13 @@ function Pokegear:drawPlain()
if id == "clock" then if id == "clock" then
local hour, minute, weekday = self:clockParts() local hour, minute, weekday = self:clockParts()
Chrome.box(1, 5, 18, 7) Chrome.box(1, 5, 18, 7)
Chrome.print(DAYS[weekday] or "DAY", 3, 7) Chrome.print(Clock.weekdayName(weekday) or "DAY", 3, 7)
local display = hour % 12 local display = hour % 12
if display == 0 then display = 12 end if display == 0 then display = 12 end
Chrome.print(("%s:%s %s"):format( Chrome.print(("%s:%s %s"):format(
Chrome.number(display, 2), Chrome.number(minute, 2, true), Chrome.number(display, 2), Chrome.number(minute, 2, true),
hour < 12 and "AM" or "PM"), 5, 9) hour < 12 and "AM" or "PM"), 5, 9)
Chrome.print(Palettes.clockDaytime(hour), 5, 11) Chrome.print(Clock.daytimeLabel(hour), 5, 11)
elseif id == "radio" then elseif id == "radio" then
-- Without the gear sheet there is no dial art, so the frequencies go down -- Without the gear sheet there is no dial art, so the frequencies go down
-- the screen as a list. A frequency whose test failed still gets a row: -- the screen as a list. A frequency whose test failed still gets a row:
+2 -1
View File
@@ -17,6 +17,7 @@
local Kit = require("src.ui.kit.Kit") local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme") local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea") local SafeArea = require("src.core.SafeArea")
local GameViewport = require("src.render.GameViewport")
local Layout = {} local Layout = {}
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW) function Layout.metrics(maxAppW)
local W, H = 0, 0 local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions() W, H = GameViewport.dimensions()
end end
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh) local s = Kit.layout(sw, sh)
+4 -3
View File
@@ -155,11 +155,12 @@ local function drain()
end end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame: -- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op. -- once a check is in flight or has reached a terminal state it is a no-op unless
function Check.start() -- force=true is passed (e.g. from an explicit button press).
function Check.start(force)
drain() drain()
if cache.status == "checking" or cache.status == "downloading" then return end if cache.status == "checking" or cache.status == "downloading" then return end
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" } cache = { status = "error", error = "background threads unavailable" }
return return
+42 -7
View File
@@ -16,6 +16,10 @@ PatchNotes.FILES = {
"assets/PATCH_NOTES.md", "assets/PATCH_NOTES.md",
} }
PatchNotes.CACHE_FILES = {
"updates/notes_cache.json",
}
PatchNotes.REPO_FILES = { PatchNotes.REPO_FILES = {
"mobile/ios/app-repo.json", "mobile/ios/app-repo.json",
} }
@@ -48,6 +52,29 @@ local function readPath(path)
return nonempty(text) and text or nil return nonempty(text) and text or nil
end end
function PatchNotes.fromCache(engine)
for _, path in ipairs(PatchNotes.CACHE_FILES) do
local text = readPath(path)
if text then
local ok, doc = pcall(Json.decode, text)
if ok and type(doc) == "table" then
if engine and engine ~= "0.0.0-dev" then
if doc[engine] and nonempty(doc[engine]) then
return doc[engine], engine
end
else
for ver, notes in pairs(doc) do
if nonempty(notes) then
return notes, ver
end
end
end
end
end
end
return nil, nil
end
function PatchNotes.fromFile() function PatchNotes.fromFile()
for _, path in ipairs(PatchNotes.FILES) do for _, path in ipairs(PatchNotes.FILES) do
local text = readPath(path) local text = readPath(path)
@@ -88,6 +115,7 @@ function PatchNotes.fromRepo(engine)
return row.notes, row.version return row.notes, row.version
end end
end end
return nil, nil
end end
return list[1].notes, list[1].version return list[1].notes, list[1].version
end end
@@ -96,17 +124,24 @@ function PatchNotes.fromRepo(engine)
end end
function PatchNotes.body(Check) function PatchNotes.body(Check)
local notes, ver = PatchNotes.fromCheck(Check)
if notes then return notes, ver end
notes = PatchNotes.fromFile()
if notes then return notes, ver end
local Version = require("src.core.Version") local Version = require("src.core.Version")
local engine = (Version and Version.engine) or "?" local engine = (Version and Version.engine) or "?"
local notes, ver = PatchNotes.fromCheck(Check)
if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then
return notes, ver or engine
end
notes, ver = PatchNotes.fromCache(engine)
if notes then return notes, ver end
notes = PatchNotes.fromFile()
if notes then return notes, engine end
notes, ver = PatchNotes.fromRepo(engine) notes, ver = PatchNotes.fromRepo(engine)
if notes then return notes, ver end if notes then return notes, ver end
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
.. "They appear here after the launcher checks GitHub for the latest " return "Unable to fetch patch notes.", engine
.. "release.", engine
end end
return PatchNotes return PatchNotes
+25
View File
@@ -151,6 +151,28 @@ local function gatePasses(rel)
return not (info.minShell and info.minShell > shell) return not (info.minShell and info.minShell > shell)
end end
local function cacheNotes(ver, notes)
if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
if not (love and love.filesystem) then return end
pcall(function()
love.filesystem.createDirectory("updates")
local cachePath = "updates/notes_cache.json"
local existing = {}
if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
local text = love.filesystem.read(cachePath)
if text then
local ok, doc = pcall(Json.decode, text)
if ok and type(doc) == "table" then existing = doc end
end
end
existing[ver] = notes
local ok, encoded = pcall(Json.encode, existing)
if ok and encoded then
love.filesystem.write(cachePath, encoded)
end
end)
end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- check -- check
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -177,6 +199,9 @@ local function doCheck()
return return
end end
pending = rel pending = rel
if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
cacheNotes(rel.version, rel.notes)
end
-- Unstamped dev build: the working tree always looks "newer", so never -- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design). -- pester the developer with an update (contract item, Check design).
+16 -29
View File
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
self.facing = FACING_FROM_RANGE[objDef.range] or "down" self.facing = FACING_FROM_RANGE[objDef.range] or "down"
self.moving = false self.moving = false
self.progress = 0 self.progress = 0
self.animClock = 0
self.stepFlip = false self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK" self.wanders = objDef.movement == "WALK"
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
end end
function NPC:update(map, entities) function NPC:update(map, entities)
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads -- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds -- the follower's own step length (#410, #409).
-- 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/
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
-- command: two cells of travel inside one step's frames
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
-- pixel span doubles while the frame count does not. Nothing else sets
-- either field, so every other object keeps the constant (#410, #409).
local stepLen = self.stepFrames or STEP_FRAMES local stepLen = self.stepFrames or STEP_FRAMES
local span = self.hopStep and 2 or 1 local span = self.hopStep and 2 or 1
if self.moving then if self.moving then
self.progress = self.progress + 1 self.progress = self.progress + 1
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation self.animClock = (self.animClock or 0) + 1
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay -- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
-- pinned to the current cell while walkPhase() cycles. -- place, no translation.
if self.marching then if self.marching then
if self.progress >= stepLen then if self.progress >= stepLen then
self.progress = 0 self.progress = 0
@@ -122,18 +107,20 @@ end
function NPC:walkPhase() function NPC:walkPhase()
if not self.moving then return 0 end if not self.moving then return 0 end
local stepLen = self.stepFrames or STEP_FRAMES -- engine/overworld/movement.asm:301
local p = self.progress % stepLen local p = (self.animClock or 0) % 16
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0 return (p >= 4 and p < 12) and 1 or 0
end end
-- Same contract as Player:pose -- the sheet, position, facing and step -- Same contract as Player:pose; an NPC never hops, so the trailing hop
-- phase this frame renders to -- so a render pipeline can pose an NPC -- flag is always false.
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
function NPC:pose() function NPC:pose()
local flip = self.stepFlip
if self.moving then
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
end
return self.sprite, self.px, self.py, self.facing, return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false self:walkPhase(), flip, false
end end
function NPC:draw(camX, camY) function NPC:draw(camX, camY)
+24 -3
View File
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter") local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader") local MapLoader = require("src.world.MapLoader")
@@ -827,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
return true return true
end end
function OverworldState:useSoftboiledFieldMove(user, target)
local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
if not user or not user.stats or not target or not target.stats
or target == user or target.hp <= 0
or target.hp >= target.stats.hp or user.hp <= heal then
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
return false
end
user.hp = user.hp - heal
target.hp = math.min(target.stats.hp, target.hp + heal)
require("src.core.Sound").play(Game.data, "Heal_HP")
local def = Game.data.pokemon[target.species]
Game.stack:push(TextBox.new(Game,
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in -- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions -- inclusive map-id ranges -- faithful to the original's omissions
@@ -3090,9 +3108,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end)) end))
end end
if not npc then farewell() return end if not npc then farewell() return end
npc.frameOverride = 3 -- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the
-- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88)
local yellow = GameVersion.isYellow()
npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1
-- bubble = false is the silent world hold, this port's DelayFrames -- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function() self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function()
npc.frameOverride = nil npc.frameOverride = nil
npc:facePlayer(self.player) npc:facePlayer(self.player)
farewell() farewell()
@@ -5128,7 +5149,7 @@ function OverworldState:drawWorld()
-- point projects under the pipeline's own camera. That is the direct -- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one -- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run. -- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions() local pw, ph = GameViewport.dimensions()
local pscale = Zoom.scale(Game.renderer:fitScale()) local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = { local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
+91 -2
View File
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
and party[slot] ~= nil and party[slot] ~= nil
end end
local function outside(game, ow)
return Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets"))
end
local function knows(mon, moveId)
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then return true end
end
return false
end
local function monInfo(game, mon, slot)
local def = game.data.pokemon[mon.species] or {}
return { slot = slot, species = mon.species,
name = mon.nickname or def.name or mon.species, level = mon.level,
hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
end
local function softboiledSources(game)
local party, sources = game.save.party or {}, {}
for sourceSlot, source in ipairs(party) do
local heal = source.stats and math.floor(source.stats.hp / 5) or 0
if knows(source, "SOFTBOILED") and source.hp > heal then
local info = monInfo(game, source, sourceSlot)
info.targets = {}
for targetSlot, target in ipairs(party) do
if target ~= source and target.hp > 0 and target.stats
and target.hp < target.stats.hp then
info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
end
end
if #info.targets > 0 then sources[#sources + 1] = info end
end
end
return sources
end
local function flyDestinationAvailable(game, mapId)
local field, save = game.data.field or {}, game.save
for _, id in ipairs(field.flyOrder or {}) do
if id == mapId then
local def = game.data.maps and game.data.maps[id]
return not not (save.visited and save.visited[id]
and field.flyWarps and field.flyWarps[id]
and def and Map.isFlyTown(def))
end
end
return false
end
function WorldAPI.new(game, modId) function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI) return setmetatable({ game = game, modId = modId }, WorldAPI)
end end
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
and ow:partyKnows("DIG") then and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" } out[#out + 1] = { id = "dig", label = "DIG" }
end end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def, if ow:partyKnows("TELEPORT") and outside(game, ow) then
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" } out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end end
local sources = softboiledSources(game)
if #sources > 0 then
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
sources = sources }
end
return out return out
end end
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
elseif id == "dig" or id == "teleport" then elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut() ow:beginTeleportOut()
return true return true
elseif id == "softboiled" then
local sourceSlot = opts and tonumber(opts.sourceSlot)
local targetSlot = opts and tonumber(opts.targetSlot)
local allowed
for _, source in ipairs(found.sources or {}) do
if source.slot == sourceSlot then
for _, target in ipairs(source.targets or {}) do
if target.slot == targetSlot then allowed = true break end
end
end
end
if not allowed then return nil, "softboiled target unavailable" end
if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
game.save.party[targetSlot]) then return true end
end end
return nil, "field action unavailable" return nil, "field action unavailable"
end end
-- FLY needs a destination choice, so it is exposed separately from the
-- immediate actions above. The request is still checked against the same
-- visited-town list as the native Town Map picker before the world may warp.
function WorldAPI:canFly()
local game, ow = self.game, self:overworld()
return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
end
function WorldAPI:flyTo(mapId)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self:canFly() then return nil, "fly unavailable" end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
if not flyDestinationAvailable(game, mapId) then
return nil, "destination unavailable"
end
ow:flyTo(mapId)
return true
end
-- A compact, read-only view of the active map for minimaps and companion UIs. -- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real -- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
+2 -2
View File
@@ -6,6 +6,7 @@ local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill") local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
local PixelCanvas = require("src.render.PixelCanvas")
local MapPreview = {} local MapPreview = {}
@@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime)
local blocks = tileset.blocks local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16 local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32 local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph) local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest")
if not okCanvas or not canvas then return nil end if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {} local quads = {}
local function quadFor(tile) local function quadFor(tile)
local q = quads[tile] local q = quads[tile]
+268 -297
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock"
local BEFORE = OUT .. "/battle_hud_wide_extended.png"
local AFTER = OUT .. "/battle_hud_og_locked_standard.png"
return function(game)
os.remove(BEFORE)
os.remove(AFTER)
local options = game.save.options
options.battleLayout = "wide"
options.battleHud = "extended"
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
local layoutRow, hudRow
for _, row in ipairs(menu.rows) do
if row.id == "battleLayout" then layoutRow = row end
if row.id == "battleHud" then hudRow = row end
end
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
menu.index = 6
menu.scroll = 3
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
layoutRow.step(game, 1)
assert(options.battleLayout == "og", "layout switched to OG")
assert(options.battleHud == "standard", "OG normalized HUD to STANDARD")
assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD")
assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written")
print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS")
game.driverDone = true
end
@@ -0,0 +1,53 @@
-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
love.window.setMode(1920, 1080, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "black"
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
local fitRow, bgRow
local bgIndex
for i, row in ipairs(menu.rows) do
if row.id == "battleFit" then fitRow = row end
if row.id == "battleBg" then bgRow, bgIndex = row, i end
end
assert(fitRow and bgRow and bgIndex, "battle size/background rows are present")
fitRow.step(game, 1)
assert(options.battleFit == "fill", "battle size switched to FILL")
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
menu.index = bgIndex
menu.scroll = math.max(0, bgIndex - 5)
U.wait(2)
local autoPath = DIR .. "/fill_extended_auto_menu.png"
os.remove(autoPath)
local ok = U.shot(game, autoPath)
fitRow.step(game, -1)
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
"FIXED can select BLACK")
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
"FIXED can select WORLD")
U.wait(2)
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
os.remove(fixedPath)
ok = U.shot(game, fixedPath) and ok
U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS"
or "FILL_EXTENDED_AUTO_MENU_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,64 @@
-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE.
-- The full physical-window backing remains white while the four battle HUD
-- panels move to their approved window anchors.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fill"
options.battleHud = "extended"
options.battleBg = "white"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD")
assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band")
local path = DIR .. "/fill_extended_white_separate_layer.png"
os.remove(path)
local ok = U.shot(game, path)
love.window.setMode(960, 540, { resizable = true })
U.wait(5)
local smallPath = DIR .. "/fill_extended_white_small_16x9.png"
os.remove(smallPath)
ok = U.shot(game, smallPath) and ok
love.window.setMode(2048, 1152, { resizable = true })
U.wait(5)
local NamingScreen = require("src.ui.NamingScreen")
game.stack:push(NamingScreen.new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
}))
U.wait(5)
local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png"
os.remove(overlayPath)
ok = U.shot(game, overlayPath) and ok
U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,64 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black"
local FULL = OUT .. "/fixed_extended_black_full.png"
local SMALL = OUT .. "/fixed_extended_black_small_16x9.png"
local OVERLAY = OUT .. "/fixed_extended_black_overlay.png"
return function(game)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
os.remove(FULL)
os.remove(SMALL)
os.remove(OVERLAY)
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "black"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "BLACK activates the approved extended HUD")
assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band")
assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD")
assert(U.shot(game, FULL), "full black-background screenshot was written")
love.window.setMode(960, 540, { resizable = true })
U.wait(10)
assert(U.shot(game, SMALL), "small black-background screenshot was written")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(10)
battle.blankForAskName = true
local naming = require("src.ui.NamingScreen").new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
})
game.stack:push(naming)
U.wait(10)
assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written")
print("[driver] FIXED_EXTENDED_BLACK_PASS")
love.event.quit(0)
end
@@ -0,0 +1,65 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white"
local FULL = OUT .. "/fixed_extended_white_full.png"
local SMALL = OUT .. "/fixed_extended_white_small_16x9.png"
local OVERLAY = OUT .. "/fixed_extended_white_overlay.png"
return function(game)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
os.remove(FULL)
os.remove(SMALL)
os.remove(OVERLAY)
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "white"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "WHITE activates the approved extended HUD")
assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field")
assert(U.shot(game, FULL), "full WHITE screenshot was written")
love.window.setMode(960, 540, { resizable = true })
U.wait(10)
assert(U.shot(game, SMALL), "small WHITE screenshot was written")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(10)
battle.blankForAskName = true
local naming = require("src.ui.NamingScreen").new(game, {
title = "NICKNAME?",
maxLen = 10,
initial = "",
onDone = function() end,
})
game.stack:push(naming)
U.wait(10)
assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written")
print("[driver] FIXED_EXTENDED_WHITE_PASS")
love.event.quit(0)
end
@@ -0,0 +1,44 @@
-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag
-- over the WIDE + FIXED + EXTENDED + WORLD composition.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "world"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(30)
local demo = BattleState.newWild(game, "CHARMANDER", 5)
demo:makeOldManDemo("PROF.OAK")
demo.onFinish = function() end
game.overworld:pushBattle(demo)
for _ = 1, 100 do
if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end
U.tap(game, "a")
U.wait(4)
end
for _ = 1, 180 do
if game.stack:top() ~= demo then break end
U.wait(1)
end
U.wait(3)
local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png"
os.remove(path)
local ok = game.stack:top() ~= demo and U.shot(game, path)
U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS"
or "FIXED_EXTENDED_WORLD_BAG_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,65 @@
-- Visual acceptance driver for the first EXTENDED HUD configuration only:
-- WIDE + FIXED + EXTENDED + WORLD.
-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \
-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
-- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has
-- measurable space above and below it.
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "world"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
local path = DIR .. "/fixed_extended_world_separate_layer.png"
os.remove(path)
local ok = U.shot(game, path)
love.window.setMode(960, 540, { resizable = true })
U.wait(5)
local smallPath = DIR .. "/fixed_extended_world_small_16x9.png"
os.remove(smallPath)
ok = U.shot(game, smallPath) and ok
love.window.setMode(2048, 1152, { resizable = true })
U.wait(5)
local NamingScreen = require("src.ui.NamingScreen")
game.stack:push(NamingScreen.new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
}))
U.wait(5)
local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png"
os.remove(overlayPath)
ok = U.shot(game, overlayPath) and ok
U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,35 @@
local function read(path)
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
return source
end
local function check(value, message)
if not value then error(message, 2) end
end
local java = read(
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
local manifest = read("mobile/android/app/src/main/AndroidManifest.xml")
check(manifest:find("android.allow_multiple_resumed_activities", 1, true)
and manifest:find("GameActivity$SecondaryActivity", 1, true)
and manifest:find('android:exported="false"', 1, true),
"the private companion Activity opts into Android multi-display resume")
check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true)
and java:find("options.setLaunchDisplayId", 1, true),
"the primary-display fallback is restricted to Android 10+")
check(java:find("SECONDARY_TARGET_HANDHELD", 1, true)
and java:find("SECONDARY_TARGET_EXTERNAL", 1, true)
and java:find("handheldAvailable ? handheld : external", 1, true),
"routing hints retain a safe available-display fallback")
check(java:find("dualScreenDisplayMode != %-1")
and java:find("AYN_SECOND_SCREEN", 1, true)
and java:find("dualScreenModeObserverRegistered", 1, true),
"the optional AYN state is guarded and lifecycle-bound")
check(java:find("activity.dispatchKeyEvent", 1, true)
and java:find("activity.dispatchGenericMotionEvent", 1, true),
"companion windows forward controller input to the game Activity")
print("android asymmetric display routing: ok")
@@ -0,0 +1,37 @@
local function read(path)
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
return source
end
local function check(value, message)
if not value then error(message, 2) end
end
local java = read(
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp")
check(java:find("hasSecondaryDisplayCandidate", 1, true)
and java:find("findSecondaryDisplay(self, false)", 1, true)
and java:find("now %- secondaryDetectionAt < 500"),
"Android exposes cached physical detection before Presentation is ready")
check(java:find("presentSecondaryFrame", 1, true)
and java:find("secondaryFrame = new byte", 1, true)
and java:find("rgba.get(secondaryFrame", 1, true),
"extended presentation reuses a retained frame buffer")
check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true),
"a recreated Presentation receives the retained frame")
check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true),
"RGB companion backgrounds become opaque Android colors")
check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true)
and java:find("Math.floor(fit)", 1, true),
"FrameView supports cover and pixel-friendly contain fits")
check(cpp:find("love_android_secondary_detected", 1, true)
and cpp:find("love_android_present_secondary", 1, true)
and cpp:find("love_android_secondary_target", 1, true)
and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true),
"JNI exports the optional detected, routing, and presentation calls")
print("android secondary presentation: ok")
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld", canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported") }, "settled overworld remains supported")
-- drainHold gates capture (see the refused() case above) exactly because it
-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must
-- let go of that gate too, or the very first drain of a battle leaves the
-- checkpoint contract refused for everything after it.
do
local game3, _, battle3 = makeGame()
battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5
local frames = 0
while battle3:stepHPDrain() and frames < 10000 do
frames = frames + 1
end
T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp,
"the HP bar settles on the new total")
T.eq(battle3.enemy.drainHold, nil,
"drainHold releases the checkpoint gate once the bar finishes draining")
local capability = Checkpoint.inspect(game3)
T.check(capability.canCapture == true,
"a checkpoint is capturable again after the drain settles: "
.. tostring(capability.reason))
end
T.finish() T.finish()
+9 -1
View File
@@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu
local whiteBattle = setmetatable( local whiteBattle = setmetatable(
{ game = { save = { options = { battleBg = "white" } } } }, { game = { save = { options = { battleBg = "white" } } } },
{ __index = BattleState }) { __index = BattleState })
local wideWhiteBattle = setmetatable(
{ game = { save = { options = {
battleBg = "white", battleLayout = "wide",
} } } },
{ __index = BattleState })
local function stack(...) return { states = { ... }, local function stack(...) return { states = { ... },
visibleBase = function(self) visibleBase = function(self)
for i = #self.states, 1, -1 do for i = #self.states, 1, -1 do
@@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does") T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
local s3 = stack(overworld, whiteBattle, menu) local s3 = stack(overworld, whiteBattle, menu)
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3, T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
"a white-bg battle has no map to hold, so nothing moves") "a classic white-bg battle has no presentation to hold, so nothing moves")
local s3wide = stack(overworld, wideWhiteBattle, menu)
T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2,
"an opaque WIDE battle still draws beneath its classic menu")
local s4 = stack(overworld, menu) local s4 = stack(overworld, menu)
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2, T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
"and a menu outside a battle is untouched") "and a menu outside a battle is untouched")
@@ -0,0 +1,102 @@
-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox
local drawn
local function stubFont()
drawn = {}
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end
Font.drawCode = function() end
Font.drawBox = function() end
end
local function unstubFont()
Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox
end
-- a mon with fewer than four moves: the remaining rows must be dashes, not
-- simply absent (ipairs used to stop at the last known move).
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = { { id = "TACKLE", pp = 35 } } },
data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
local ok, err = pcall(function() screen:drawTextArea() end)
T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "all four move rows are drawn, even the unused ones")
T.eq(rows[1], "TACKLE", "the one real move prints its name")
T.eq(rows[2], "-", "an empty slot is a dash")
T.eq(rows[3], "-", "so is the next one")
T.eq(rows[4], "-", "and the last one")
unstubFont()
end
-- a full four-move mon: no dashes anywhere.
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = {
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
} },
data = { moves = {
TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" },
GROWL = { name = "GROWL", pp = 40, type = "NORMAL" },
} },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
screen:drawTextArea()
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "still exactly four rows")
for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do
T.eq(rows[i], want, "row " .. i .. " keeps its own move name")
end
unstubFont()
end
-- the Mimic menu shares FormatMovesString on the cart, so it gets the same
-- dash treatment.
do
stubFont()
local screen = setmetatable({
phase = "mimicSelect",
mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } },
data = { moves = {
TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" },
} },
mimicIndex = 1, frame = 0,
}, { __index = BattleState })
local ok = pcall(function() screen:drawTextArea() end)
T.check(ok, "mimicSelect draws without error")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end
end
T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move")
T.eq(rows[2], "GROWL", "mimic row 2 is its second")
T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest")
T.eq(rows[4], "-", "including the last row")
unstubFont()
end
T.finish("battle move slot dashes bug 1343")
@@ -0,0 +1,80 @@
-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to
-- the walking one -- PalletTownDaisyScript, gated on both
-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE.
-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Story = assert(loadfile("data/scripts/story.lua"))()
local Story2 = assert(loadfile("data/scripts/story2.lua"))()
T.check(type(Story.BLUES_HOUSE.onEnter) == "function",
"M.BLUES_HOUSE.onEnter exists")
T.check(type(Story2.PALLET_TOWN.onEnter) == "function",
"M.PALLET_TOWN.onEnter exists")
-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch
-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here.
do
local game = { save = { flags = {} } }
Story.BLUES_HOUSE.onEnter(game, {})
T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true,
"onEnter sets EVENT_ENTERED_BLUES_HOUSE")
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"and does not itself start the swap")
end
-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must
-- write the toggle even though BLUES_HOUSE is not the live map (the fix's
-- own precondition, verified against src/script/Commands.lua's toggleObject
-- writing save.objectToggles before its live-NPC early return).
do
local game = {
save = {
flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true },
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(game.save.flags.EVENT_DAISY_WALKING == true,
"both prerequisites set: EVENT_DAISY_WALKING fires")
local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE
T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table")
T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false,
"the sitting Daisy (DAISY1) is hidden")
T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true,
"the walking Daisy (DAISY2) is shown")
end
-- Only having the map, without ever entering the house, must not swap her:
-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality.
do
local game = {
save = { flags = { EVENT_GOT_TOWN_MAP = true } },
}
Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } })
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"without EVENT_ENTERED_BLUES_HOUSE the swap does not fire")
end
-- Re-entering Pallet Town after she has already swapped must not re-run
-- the toggle writes (EVENT_DAISY_WALKING itself is the guard).
do
local game = {
save = {
flags = {
EVENT_GOT_TOWN_MAP = true,
EVENT_ENTERED_BLUES_HOUSE = true,
EVENT_DAISY_WALKING = true,
},
objectToggles = {},
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(next(game.save.objectToggles) == nil,
"already-walking Daisy: onEnter writes no toggle a second time")
end
T.finish("blues_house_daisy_walking_bug1338")
+56
View File
@@ -0,0 +1,56 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local clock, quit = 1, false
local sent, queue, draws = {}, {}, 0
local peer = {
send = function(_, data) sent[#sent + 1] = data end,
disconnect_now = function() end,
}
local host = {
connect = function() return peer end,
service = function()
if #queue == 0 then return nil end
return table.remove(queue, 1)
end,
}
local rendered = {
setFilter = function() end, replacePixels = function() end,
release = function() end,
}
love = {
timer = { getTime = function() return clock end },
data = { decompress = function(_, _, value) return value end },
image = { newImageData = function(w, h, format, raw)
assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba")
return {}
end },
graphics = {
newImage = function() return rendered end,
getDimensions = function() return 100, 100 end,
clear = function() end, setColor = function() end,
draw = function() draws = draws + 1 end,
},
event = { quit = function() quit = true end },
}
package.preload.enet = function()
return { host_create = function() return host end }
end
require("src.render.DesktopCompanion").install({ port = 50000, token = "token" })
queue[#queue + 1] = { type = "connect" }
love.update()
assert(sent[#sent] == "Htoken", "companion authenticates after connecting")
queue[#queue + 1] = {
type = "receive", data = "Ftoken\n1,1,0,auto\nrgba",
}
love.update()
love.draw()
assert(draws == 1, "companion draws a received frame")
love.mousepressed(50, 50, 1)
love.mousereleased(50, 50, 1)
assert(sent[#sent - 1] == "Itoken\ndown,0,0"
and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels")
queue[#queue + 1] = { type = "receive", data = "Qtoken" }
love.update()
assert(quit, "parent can close the companion")
print("desktop companion: ok")
+57
View File
@@ -0,0 +1,57 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local clock = 1
love = {
timer = { getTime = function() return clock end },
data = {
hash = function() return "digest" end,
encode = function() return "0123456789abcdef0123456789abcdef" end,
compress = function(_, _, value) return value end,
},
}
local sent, spawned, queue = {}, nil, {}
local peer = {
send = function(_, data, channel, flag)
sent[#sent + 1] = { data = data, channel = channel, flag = flag }
end,
disconnect_now = function() end,
}
local host = {
service = function()
if #queue == 0 then return nil end
return table.remove(queue, 1)
end,
destroy = function() end,
}
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
package.loaded["src.core.HostShell"] = {
spawnSelfDetached = function(args) spawned = args return true end,
}
package.preload.enet = function()
return { host_create = function() return host end }
end
local Screen = require("src.render.SecondScreen")
assert(Screen.usable(), "the shared facade selects the desktop backend")
Screen.setEnabled(true)
assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"),
"enabling launches one companion of this app")
local token = spawned[1]:match(",([%w]+)$")
queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token }
assert(Screen.detected(), "a token-authenticated companion becomes detected")
local pixels = { getString = function() return "rgba" end }
assert(Screen.push(pixels, 1, 1, 0x102030, "auto"),
"a connected companion accepts a frame")
assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"),
"frame metadata and pixels stay in one loopback packet")
queue[#queue + 1] = {
type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4",
}
assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod")
Screen.setEnabled(false)
assert(sent[#sent].data == "Q" .. token, "disabling closes the companion")
print("desktop second screen: ok")
@@ -0,0 +1,49 @@
-- engine/battle/experience.asm:69
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local function newSave()
return { player = { id = 12345, name = "RED" } }
end
do
local save = newSave()
local homegrown = {}
BattleState.stampOT(save, homegrown)
T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id")
T.eq(homegrown.ot, "RED", "and the player's own name")
end
-- The bug: a mon that arrived traded (traded = true) but whose OT id was
-- never recorded (a legacy peer, a link mon with no otId in its packet) used
-- to get save.player.id written into otId on the very first load, which then
-- reads identically to a mon the player caught -- awardExp's OT-id compare
-- (BattleState.lua ~4009) permanently loses the 1.5x boost.
do
local save = newSave()
local tradedNoId = { traded = true }
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil,
"a traded mon with no OT id is left unstamped, not silently adopted")
T.eq(tradedNoId.ot, "RED",
"the OT NAME fill still happens (cosmetic, not the boost gate)")
-- a second stampOT pass (a second save/load cycle) must not adopt it either
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it")
end
-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched
-- either way; this is the arm the regression never broke.
do
local save = newSave()
local tradedWithId = { traded = true, otId = 777 }
BattleState.stampOT(save, tradedWithId)
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
end
T.finish("exp traded ot survives reload bug 1265")
@@ -0,0 +1,82 @@
-- data/moves/animations.asm:379
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local UI = require("src.ui.gen2.BattleState")
local function newSelf(opts)
opts = opts or {}
return setmetatable({
anim = opts.anim,
ballThrow = opts.ballThrow,
picHidden = { player = false, enemy = false },
pendingAfterAnim = nil,
afterSendOut = nil,
}, { __index = UI })
end
-- pushCaught itself must never touch picHidden: only the animation's own
-- steps (stepAnim) are allowed to latch the enemy pic box.
do
local self1 = setmetatable({
battle = {}, tutorial = true, save = nil,
queue = {}, picHidden = { player = false, enemy = false },
}, { __index = UI })
self1:pushCaught({ species = "RATTATA" }, "POKE_BALL")
T.eq(self1.picHidden.enemy, false,
"pushCaught alone does not hide the enemy pic")
T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught")
end
-- stepAnim, natural end (anim:step() returns false): a caught ball throw
-- latches, everything else does not.
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end")
T.eq(s.anim, nil, "the finished runner is cleared")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "a break-free throw does not latch")
end
do
local otherAnim = { animId = "ANIM_HYDRO_PUMP",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "an unrelated animation never latches")
end
-- stepAnim, cut short with B: the property the latch exists for -- a caught
-- mon must not reappear even if the player skips past "Gotcha!".
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches")
T.eq(s.anim, nil, "B cuts the runner short")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch")
end
T.finish("gen2 ball throw pic latch bug 1232")
@@ -0,0 +1,58 @@
-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local function findLoaded(runner, gfx)
for _, entry in ipairs(runner.loaded) do
if entry.gfx == gfx then return entry end
end
return nil
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(1)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.check(head and feet, "both pseudo-sheets registered")
T.eq(head.battler, "enemy",
"GFX_PLAYERHEAD's tiles are the ENEMY's feet row")
T.eq(head.tiles, 7, "seven tiles, the enemy pic's width")
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base")
T.eq(feet.battler, "player",
"GFX_ENEMYFEET's tiles are the PLAYER's head row")
T.eq(feet.tiles, 6, "six tiles, the backpic's width")
T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base")
T.eq(head.rows, 1, "one row each")
T.eq(feet.rows, 1, "on both sheets")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(2)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.eq(head.battler, "enemy", "2ROW keeps the same crossing")
T.eq(head.tiles, 14, "two enemy rows")
T.eq(feet.battler, "player", "on both sides")
T.eq(feet.tiles, 12, "two player rows")
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base")
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
AnimRunner.COMMANDS.battlergfx_1row(runner)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
T.eq(head and head.battler, "enemy", "the script command routes the same way")
end
T.finish("gen2 battler gfx row attribution bug 1231")
@@ -0,0 +1,68 @@
-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BgEffects = require("src.battle.gen2.BgEffects")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
do
local bg = BgEffects.new(nil, { battleTurn = 0 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
"player attacking: the enemy's feet row becomes an OBJ")
T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x")
T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"an intact tilemap with no scroll skips the bake canvas")
bg:playFrame()
local lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
"frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)")
T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"a lifted row keeps the panel on the bake canvas even with scx 0 and no"
.. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space")
for _ = 1, 4 do bg:playFrame() end
T.eq(bg:activeCount(), 0, ".five ends the effect")
lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
".five never restores the row")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"and the wait frames after .five stay baked as well")
bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0)
bg:playFrame()
T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"after which the plain no-canvas path returns")
end
do
local bg = BgEffects.new(nil, { battleTurn = 1 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"enemy attacking: the player's head rows become an OBJ")
T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x")
bg:playFrame()
local lifted = bg.liftedRows.player
T.check(lifted and lifted[1] == 0 and lifted[2] == 2,
"rows 0-1 of the player box (hlcoord 2, 6, two rows)")
T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows")
end
do
local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing")
T.eq(bg:activeCount(), 0, "and the effect ends at once")
T.eq(bg.liftedRows.enemy, nil, "with no row lifted")
end
T.finish("gen2 battler row lift bug 1231")
@@ -0,0 +1,94 @@
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
GROUND = { id = "GROUND", index = 1, category = "physical" },
FLYING = { id = "FLYING", index = 2, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return (n or 1) - 1 end
local function newBattle(moveId)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll }), player, wild
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
do
local battle, player, wild = newBattle("DIG")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev = moveEvent(battle.events)
T.check(ev ~= nil, "the charge turn queues a move event")
T.eq(ev and ev.animParam, 1,
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev2 = moveEvent(battle.events)
T.check(ev2 ~= nil, "the strike turn also queues a move event")
T.eq(ev2 and ev2.animParam, nil,
"DIG's strike turn leaves animParam nil, the hit script arm")
end
do
local battle, player, wild = newBattle("FLY")
battle.events = {}
battle:useMove(player, wild, "FLY")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
end
-- a plain hit-and-run move never sets a parameter at all
do
local battle, player, wild = newBattle("DIG")
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
battle.events = {}
battle:useMove(player, wild, "TACKLE")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
end
T.finish("gen2 charge move anim param bug 1293")
+83
View File
@@ -0,0 +1,83 @@
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
-- the bag about the real COIN_CASE item id, not SILVER_WING.
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Specials = require("src.script.gen2.Specials")
local Events = require("src.world.gen2.Events")
local COIN_CASE = 0x36
local SILVER_WING = 0x47
-- 1) the id the handler actually queries the bag with
local seenId
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 100 end,
hasItem = function(id) seenId = id return true end,
gameCornerGame = function(_, done) done() end,
},
})
vm.showTextFn = function() end
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
coroutine.resume(vm.co)
T.eq(seenId, COIN_CASE,
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
T.check(seenId ~= SILVER_WING,
"and specifically not SILVER_WING ($47), the pre-fix value")
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
-- enough to open both machines: this is what would still fail if the id
-- above were merely logged and not actually used to gate the machine.
local bag = { [COIN_CASE] = true }
local slotsOpened, flipOpened
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
},
})
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
coroutine.resume(vm2.co)
T.eq(slotsOpened, "slots",
"a bag with the real COIN CASE and coins opens the slot machine")
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) flipOpened = kind done() end,
},
})
vm3.showTextFn = function() end
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
coroutine.resume(vm3.co)
T.eq(flipOpened, "cardflip",
"and the same bag opens card flip too")
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
-- is the yield the coroutine parks on, not a call, so opening never runs
-- behind it).
local wrongBag = { [SILVER_WING] = true }
local opened = false
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return wrongBag[id] == true end,
gameCornerGame = function() opened = true end,
},
})
vm4.showTextFn = function() end
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
local _, refusal = coroutine.resume(vm4.co)
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
T.check(not opened, "and the machine never opens behind it")
T.finish("gen2_coin_case_bug1251")
@@ -0,0 +1,166 @@
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local UI = require("src.ui.gen2.BattleState")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
-- a controllable roll queue; falls back to a high roll (never fails an AI
-- check) once drained
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ---------------------------------------------------------------- gap 2:
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
battle:useMove(wild, player, "GROWL")
T.check(findText(battle.events, "But it failed!"),
"enemy GROWL fails 25% of the time with the specific line")
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
"an AI-failed move is marked missed (feeds the 40 frame hold)")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 200 } -- AI roll passes (>=64): lands
battle:useMove(wild, player, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the same move lands when the AI roll passes")
end
do
local battle, player, wild = newBattle(
{ { id = "GROWL", pp = 40, maxPp = 40 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
battle:useMove(player, wild, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the player's own GROWL is exempt from the AI roll")
end
-- --------------------------------------------------------------- gap 1:
-- the reported symptom -- the "used X!" line must hold before a failure.
do
local input = { wasPressed = function() return false end }
local ui = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = {
{ kind = "move", side = "enemy", move = "GROWL",
text = "Enemy MACHOP used GROWL!", missed = true },
{ kind = "message", text = "But it failed!" },
},
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
}, { __index = UI })
ui:advanceQueue()
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
T.eq(ui.messageDelay, 40,
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
local frames = 0
for _ = 1, 100 do
if ui.message == "But it failed!" then break end
ui:update(1 / 60)
frames = frames + 1
end
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
end
do
local input = { wasPressed = function() return false end }
local ui2 = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = { { kind = "move", side = "player", move = "TACKLE",
text = "MACHOP used TACKLE!" } },
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
animForMove = function() return false end,
}, { __index = UI })
ui2:advanceQueue()
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
end
T.finish("gen2 enemy move fail text bug 1296")
@@ -0,0 +1,92 @@
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
-- group's OWN chance byte before the rod's cumulative list even runs
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
-- rod). A cache built before the extractor carried the byte has no
-- `chance` field on the group row at all and must keep fishing unconditionally.
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
MAGIKARP = {
name = "MAGIKARP", types = { "WATER", "WATER" },
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
specialAttack = 15, specialDefense = 20 },
levelMoves = {},
},
},
}
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
local function fakeMap(waterCell)
return {
id = "TEST_MAP",
def = { fishGroup = "FISHGROUP_POND" },
cellCollision = function(_, x, y)
return (x == waterCell[1] and y == waterCell[2])
and COLL_WATER or COLL_FLOOR
end,
}
end
-- Rod lists that always hand back a species once a bite happens, so only the
-- group gate (or its absence) decides the outcome below.
local function fishGroups(chance)
return {
FISHGROUP_POND = {
chance = chance,
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
},
}
end
local function fakeWorld(chance)
local game = { data = DATA, save = { party = {} } }
local world = World.new(game)
world.map = fakeMap({ 5, 4 })
world.maps = { TEST_MAP = world.map.def }
world.encounters = { fishGroups = fishGroups(chance) }
world.player = { cellX = 5, cellY = 5, facing = "up" }
return world
end
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
-- engine/events/fish.asm:24-30
do
local world = fakeWorld(0)
for rod = 1, 3 do
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
eq(outcome, "nibble",
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
end
end
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
do
local world = fakeWorld(256)
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
local outcome, wild = world:rollFishing(rod)
eq(outcome, "battle", "chance 256 always bites on " .. rod)
check(wild and wild.species == "MAGIKARP",
"and the rod's own list still resolves a species")
end
end
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
do
local world = fakeWorld(nil)
local outcome = world:rollFishing("OLD_ROD")
eq(outcome, "battle",
"a cache with no group chance byte is not gated at all")
end
T.finish("gen2 fishing bite gate bug1368")
@@ -0,0 +1,115 @@
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
-- roll its night table after dark (#1389, Gastly unobtainable), and a
-- PALETTE_NITE cave must still roll its morning/day table at noon.
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
RATTATA = {
name = "RATTATA", types = { "NORMAL", "NORMAL" },
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
levelMoves = {},
},
GASTLY = {
name = "GASTLY", types = { "GHOST", "POISON" },
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
specialAttack = 100, specialDefense = 35 },
levelMoves = {},
},
},
}
local function fullList(species)
local slots = {}
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
return slots
end
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
-- and worthless, night is the whole reason the room exists. A second,
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
-- the rates are flipped so the two fixtures cannot agree by accident.
local ENCOUNTERS = {
grass = {
SPROUT_TOWER_2F = {
rates = { MORN = 0, DAY = 0, NITE = 256 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
ILEX_FOREST = {
rates = { MORN = 256, DAY = 256, NITE = 0 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
},
}
local COLL_FLOOR = 0x00
local function fakeWorld(mapId, tod, daytime)
local game = { data = DATA,
save = { party = { { species = "RATTATA", level = 5 } } } }
local world = World.new(game)
world.map = {
id = mapId,
def = { environment = "DUNGEON" },
cellCollision = function() return COLL_FLOOR end,
}
world.maps = { [mapId] = world.map.def }
world.encounters = ENCOUNTERS
world.player = { cellX = 5, cellY = 5, facing = "down" }
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
-- clock (src/world/gen2/World.lua:8271-8326).
world.tod = tod
world.daytime = daytime
local battled
world.startBattle = function(_, opts)
battled = opts.wild and opts.wild.species
return true
end
return world, function() return battled end
end
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
eq(battled(), "GASTLY",
"the night list wins because the lookup is the clock, not the pin")
end
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
check(not world:tryWildEncounter(),
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
eq(battled(), nil, "and nothing battled")
end
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
do
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
check(world:tryWildEncounter(),
"a pinned-night map still rolls its day table at the clock's noon")
eq(battled(), "RATTATA",
"the day list wins because the lookup ignores the palette pin")
end
T.finish("gen2 grass encounter tod bug1389")
+54
View File
@@ -0,0 +1,54 @@
-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels
-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local g = love.graphics
local seen = {}
local realNewCanvas = g.newCanvas
g.newCanvas = function(w, h, settings)
seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale }
local c = realNewCanvas(w, h)
c.renderTo = function(_, fn) fn() end
c.setFilter = function() end
return c
end
local World = require("src.world.gen2.World")
local MapPreview = require("src.world.gen2.MapPreview")
local atlas = {
getDimensions = function() return 128, 128 end,
setFilter = function() end,
}
local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 }
local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18,
blocks = {}, borderBlock = 0 }
local world = setmetatable({
atlasCache = {},
atlasFor = function() return atlas, tileset end,
}, { __index = World })
World.bakeMapImage(world, map, nil, nil)
local bakeCount = #seen
T.check(bakeCount >= 1, "the map bake allocated a canvas")
World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 })
T.check(#seen > bakeCount, "the scroll strip allocated a canvas")
local stripCount = #seen
local origAtlasFor = MapPreview.atlasFor
MapPreview.atlasFor = function() return atlas, tileset end
MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY")
MapPreview.atlasFor = origAtlasFor
T.check(#seen > stripCount, "the save-editor bake allocated a canvas")
for i, c in ipairs(seen) do
T.eq(c.dpiscale, 1,
("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h))
end
g.newCanvas = realNewCanvas
T.finish("gen2 map bake dpi")
+26
View File
@@ -468,6 +468,32 @@ do
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll") T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
end) end)
-- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls
-- on every menu open) used to recompute mon.shiny from DVs unconditionally,
-- so opening the summary screen on a forced shiny -- one whose DVs do not
-- happen to match the natural pattern -- un-shinied it the moment the menu
-- opened. shiny is monotonic once true: a natural roll or a forced one
-- both stay shiny through any later refresh, the way opts.shiny already
-- wins at construction.
do
local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true })
T.eq(forced.shiny, true, "still shiny straight out of Mon.new")
Mon.syncIdentity(forced, DATA)
T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny")
Mon.refreshStats(forced, DATA)
T.eq(forced.shiny, true,
"refreshStats (SummaryMenu.new's call) does not either")
-- the natural cases are unaffected: DVs that read shiny stay shiny,
-- DVs that do not stay plain
local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs })
Mon.syncIdentity(natural, DATA)
T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny")
local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
Mon.syncIdentity(plain, DATA)
T.eq(plain.shiny, false, "a plain mon is not promoted to shiny")
end
local genderCtx local genderCtx
withHook("gender.roll", function(nextFn, ctx) withHook("gender.roll", function(nextFn, ctx)
genderCtx = ctx genderCtx = ctx
@@ -0,0 +1,88 @@
-- Gold #DEX AREA page drew no nest markers or landmark name because
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
-- the gen2Landmarks table Nests already resolves through (#1267).
-- engine/pokegear/pokegear.asm:2427
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
local Nests = require("src.core.gen2.Nests")
-- one Johto landmark (index 5), one species that nests there
local data = {
gen2Encounters = {
grass = {
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
},
},
gen2Maps = {
ROUTE_30 = { landmark = 5 },
},
gen2Landmarks = {
landmarks = {
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
},
},
}
-- sanity: Nests.landmark itself resolves the index (never broken, per the
-- verifier) so a failure below is isolated to drawArea's own lookup
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
"Nests.landmark resolves index 5 to the ROUTE 30 record")
-- capture what drawArea actually paints, without needing a real tile sheet
-- or font: fill/blank/current/monName are stubbed on the instance, which
-- Lua resolves before the PokedexMenu metatable's own methods.
local function newSelf()
local texts = {}
local rects = {}
local self = setmetatable({
game = { save = {} },
data = data,
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
areaRegion = "johto",
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
current = function() return { species = "RATTATA" } end,
monName = function() return "RATTATA" end,
fill = function() end,
blank = function() end,
text = function(_, str, tx, ty)
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
end,
}, { __index = PokedexMenu })
return self, texts, rects
end
local realRect = love.graphics.rectangle
local self, texts, rects
do
self, texts, rects = newSelf()
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
self:drawArea()
love.graphics.rectangle = realRect
end
local function hasRect(x, y)
for _, r in ipairs(rects) do
if r.x == x and r.y == y then return true end
end
return false
end
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
local function hasText(str)
for _, t in ipairs(texts) do
if t.str == str then return true end
end
return false
end
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
T.finish("gen2 pokedex area landmark bug 1267")

Some files were not shown because too many files have changed in this diff Show More