From bdfac727aaccfea696be49a23c5f501451be50d5 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 18 Sep 2026 15:42:32 -0400 Subject: [PATCH] bing bang bong --- mod_option_schemas.json | 2 +- .../assets/mew_back_inverted_64.png | Bin 0 -> 1404 bytes .../assets/mew_front_inverted_64.png | Bin 0 -> 1319 bytes mods/example_mew_starter/main.lua | 46 +- mods/example_mew_starter/manifest.json | 2 + mods/example_mew_starter/mod.card | 4 +- src/battle/game3/BattleAPI.lua | 290 +++ src/core/Game3.lua | 214 +- src/core/game3/battle/INTRO_PLAN.md | 530 ---- src/core/game3/battle/PARITY.md | 60 - src/core/game3/battle/abilities.lua | 81 +- src/core/game3/battle/adapter.lua | 53 +- src/core/game3/battle/ai.lua | 373 ++- src/core/game3/battle/ai_cmds.lua | 35 +- src/core/game3/battle/ai_items.lua | 231 ++ src/core/game3/battle/ai_switch.lua | 377 +++ src/core/game3/battle/anim.lua | 219 +- src/core/game3/battle/anim_coords.lua | 230 ++ src/core/game3/battle/anim_ctx.lua | 22 +- src/core/game3/battle/anim_port/g1_pret.lua | 71 +- .../game3/battle/anim_port/g1_task_base.lua | 5 +- src/core/game3/battle/anim_port/g1_tasks.lua | 24 +- .../game3/battle/anim_port/g1_tasks_b.lua | 20 +- src/core/game3/battle/anim_port/g2_pret.lua | 26 +- src/core/game3/battle/anim_port/g3_dark.lua | 7 +- src/core/game3/battle/anim_port/g3_e3c.lua | 3 +- src/core/game3/battle/anim_port/g3_ghost.lua | 35 +- src/core/game3/battle/anim_port/g3_pret.lua | 39 +- src/core/game3/battle/anim_port/g4_cb_b.lua | 4 +- src/core/game3/battle/anim_port/g4_pret.lua | 31 +- .../game3/battle/anim_port/g4_tasks_a.lua | 13 +- .../game3/battle/anim_port/g4_tasks_b.lua | 5 +- src/core/game3/battle/anim_seq.lua | 292 ++- src/core/game3/battle/anim_sprites.lua | 4 +- src/core/game3/battle/anim_tasks.lua | 5 +- src/core/game3/battle/anim_vm.lua | 257 +- src/core/game3/battle/ball_open.lua | 7 +- src/core/game3/battle/catch_seq.lua | 19 +- src/core/game3/battle/catching.lua | 84 +- src/core/game3/battle/commands.lua | 197 +- src/core/game3/battle/damage.lua | 48 +- src/core/game3/battle/effects/healing.lua | 14 +- src/core/game3/battle/effects/hit.lua | 98 +- src/core/game3/battle/effects/setup.lua | 50 +- src/core/game3/battle/effects/special.lua | 24 +- src/core/game3/battle/effects/volatiles.lua | 3 +- src/core/game3/battle/engine.lua | 912 ++++++- src/core/game3/battle/exp_seq.lua | 11 +- src/core/game3/battle/experience.lua | 79 +- src/core/game3/battle/healthbox.lua | 185 +- src/core/game3/battle/held_items.lua | 4 +- src/core/game3/battle/init.lua | 1033 +++++++- src/core/game3/battle/intro_seq.lua | 354 ++- src/core/game3/battle/items.lua | 144 +- src/core/game3/battle/moves.lua | 27 + src/core/game3/battle/party_view.lua | 19 + src/core/game3/battle/pic_coords.lua | 17 +- src/core/game3/battle/residual_handlers.lua | 90 +- src/core/game3/battle/residuals.lua | 55 +- src/core/game3/battle/state.lua | 242 +- src/core/game3/battle/switch_seq.lua | 173 +- src/core/game3/battle/ui.lua | 830 +++++- src/core/game3/battle_bridge.lua | 61 + src/core/game3/encounters.lua | 51 +- src/core/game3/evolution.lua | 83 +- src/core/game3/field.lua | 67 +- src/core/game3/item_use.lua | 30 +- src/core/game3/items_data.lua | 4 + src/core/game3/map.lua | 18 + src/core/game3/objects.lua | 5 + src/core/game3/party.lua | 27 +- src/core/game3/player.lua | 17 + src/core/game3/pokemon.lua | 59 + src/core/game3/save_schema_firered.lua | 10 + src/core/game3/scripting/adapters.lua | 5 +- src/core/game3/scripting/ctx.lua | 18 + src/core/game3/scripting/flags.lua | 6 + src/core/game3/scripting/ops_a.lua | 104 +- src/core/game3/scripting/space.lua | 3 +- src/core/game3/scripting/trainers.lua | 45 +- src/core/game3/scripting/vm.lua | 22 +- src/core/game3/step_events.lua | 7 + src/core/game3/trainer_sight.lua | 41 +- src/core/game3/warp.lua | 29 + src/import/CacheContract.lua | 6 +- src/import/gba/battle_chrome_extract.lua | 81 +- src/import/gba/summary_chrome_extract.lua | 10 + src/import/gba/versions.lua | 5 + src/mods/Builtins.lua | 3 + src/mods/DatasetViews.lua | 76 +- src/mods/Gen3Compat.lua | 2278 +++++++++++++++++ src/mods/Loader.lua | 65 +- src/mods/Schemas.lua | 984 ++++++- src/ui/game3/bag_menu.lua | 5 + src/ui/game3/battle_chrome.lua | 124 +- src/ui/game3/evolution_scene.lua | 3 +- src/ui/game3/new_game_scene.lua | 39 +- src/ui/game3/party_menu.lua | 357 ++- src/ui/game3/start_menu.lua | 11 +- src/ui/game3/summary_chrome.lua | 6 +- src/world/game3/WorldAPI.lua | 662 +++++ tests/drivers/game3_doubles.lua | 231 ++ tests/drivers/game3_mew_starter.lua | 160 ++ tests/engine/gate_gen2_mod_api.lua | 33 +- tests/engine/gate_gen3_mod_api.lua | 466 ++++ tests/engine/gate_gen3_mod_facade.lua | 378 +++ tests/engine/gate_meta_coverage.lua | 1 - tests/engine/gen3_shim_engine_require.lua | 93 + tests/engine/mod_targets_tests.lua | 21 +- tests/game3_battle_ai_test.lua | 186 ++ tests/game3_battle_doubles_engine_test.lua | 183 ++ tests/game3_battle_switch_and_faint_test.lua | 24 +- tests/game3_quest_log_integration_test.lua | 1 + tests/modkit/cases/gen3check.lua | 73 + tests/modkit/sdk.lua | 96 +- tools/generate_example_mod_sprite.py | 53 +- 116 files changed, 13941 insertions(+), 1814 deletions(-) create mode 100644 mods/example_mew_starter/assets/mew_back_inverted_64.png create mode 100644 mods/example_mew_starter/assets/mew_front_inverted_64.png create mode 100644 src/battle/game3/BattleAPI.lua delete mode 100644 src/core/game3/battle/INTRO_PLAN.md delete mode 100644 src/core/game3/battle/PARITY.md create mode 100644 src/core/game3/battle/ai_items.lua create mode 100644 src/core/game3/battle/ai_switch.lua create mode 100644 src/core/game3/battle/anim_coords.lua create mode 100644 src/mods/Gen3Compat.lua create mode 100644 src/world/game3/WorldAPI.lua create mode 100644 tests/drivers/game3_doubles.lua create mode 100644 tests/drivers/game3_mew_starter.lua create mode 100644 tests/engine/gate_gen3_mod_api.lua create mode 100644 tests/engine/gate_gen3_mod_facade.lua create mode 100644 tests/engine/gen3_shim_engine_require.lua create mode 100644 tests/game3_battle_doubles_engine_test.lua create mode 100644 tests/modkit/cases/gen3check.lua diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 592ffeba..4002f364 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"mods":[],"schema_version":1} \ No newline at end of file +{"schema_version":1,"mods":[]} \ No newline at end of file diff --git a/mods/example_mew_starter/assets/mew_back_inverted_64.png b/mods/example_mew_starter/assets/mew_back_inverted_64.png new file mode 100644 index 0000000000000000000000000000000000000000..c8fb75172556fbde514d76c738f0fdc7afa758c7 GIT binary patch literal 1404 zcmV-?1%vvDP)1 zEmRvp5Z=3Y0tt>fDld-8Na`xnDv-p1qg5cO;^BfzWU2sHTm6^ZOQ*(D#4BaV zcpjwfme{O?0frV76 zln9pn#@{!4LC2BU$WMpk@XIUM%G}xa^nk5ftvQ)Hg-~%IhV?&u{;1|+Qz=h(|7AkVm2As1y^xEsM9cxot4;>hrvd9hpaxLWR11Pt zJvR|^0=#Qf<9rYY#{Dnm1BU04GK3rtA};|R=kRO~E;}8LWv9R6cj87@kuM~kA>Y5* zQ(4W`BVr!)o9^spB0P~g8I&GR#}GLfYG-&Z{#^Wew&6FbBv)=O)$y)%LWFmOX0ust znWi!U2kwVYn>Sz_JZ6v>MPtJ?VL*P4OHC7t1)ywDoP2tCq=#MR!weyVuu}eh;T@^E8RrvTbuzOUZtUSKQmV*0}8 z4+auywM7HsbF<9tiF}<>+O?{U`5)X1ni4@ze>!=ScOb1b_5!9XnyU@Q1I$rem)}dj zFXq3#As)EI1fLintO2V55p?1d@brLa5`ea{@#wjBEi_wsMSYZ;R(57#1ZiLhu-plgtK1@uqBrSR?| zfKQ*04C7ioCa(A6gWuiT&c(0+q-h|-g8lj%CspA~+}i`1LjC~OM+DX#B12Sdk;P)c z$UO<9Z0&E_&&WeK?qy5e5-S5`myu(50r?P5t)oRx0h$KX^+A3I^bBGX4X7$av}gg4 z3G~GeQXPsUTed-V;pdqOqgKQK9n_#jdZZMpTU{q#XYrPZ1p`6sMOQitbl9hh0IxuM zo&H+|pz~E#c{1>NbtYQ$6nOC>cFAkF`I^F%Ivr~NrJMo>T2YnmY^lxEsiNj64}c(! z<|%oI&s2!T5`aH1c=r3wma?>|v#UhF3KirtvFOw18D}h(1gek6t;$f;uMF`pHx^3- zB1Es}5sJKkMAMJQG$?x>u#^I%m0 zEpXdN6n@$<2#i^v18tULRauYaSbNBZHd9$zbb%@A9ZQ%4UDTM>?80i;FbO88Qt*6q4s0)xd|-oJZG2ai8i zH+p{UcM94w;uFNgkAorg`+e_!&47VH{8RTRxvr!Bd&2<#@F-1uEdB62s3m>C+x2P? zVeb0<+clfFK|HV@QNVC^wuo<)0kC7J^3?aeO47gjlp_3}$46_#x5fZ=ViD~3EC`@sOvAm$g0#E)qufbt~eUaZt4DRo}TO; z(VP2-!hV0_dl24kiRjhvv@znXGl0`nWNHT)^SF=s$>C9HGpHNq8)v{IuEto!IaVj> zYDV&+!rBI30B6?*yls5|2nW%eq3Hyc!txH)m{%qWu=n4_c{Zv6_Zx_Z7CfE2CVE(| z&3&H@6@f)WVeYKkm4k~b^&8jvybY#wwtWz`c@5$i8AJjHcXK-a`!X?)s(=F=2SbVV zm=VT4WF2OBb;4j2MTvAw1|Zz=#*$Rl!1&{3<-|yLit61NiBcs-$eP_5)tMOvs}Xl- z!Y~9^<;^Lnzk2s3HHJa0ZVm?4!qC^i@3PMes)?*H1u4MB8P_jgW5Np#u-QET;Q8_g_QUW}fZkclljI(f}doe?m*GEDGnq{1m*06Z+I|JV6y zZiaj=8{1TMdU;%wR%1`Uz4@KK-TYg)Mu#i_3xwyvGZT7#F5%D?R{JJXT)cZU{me{| zd24lSE(j)=o>|R}UL{=}5Wk6VR$bGZ=QT(2pq`(8mhcJVoXoRB7QyAJ9-C3oJuSYe z!Ui}CMoK*~hfER(PhWePL>ik`xrau)3V9{>mg{U~Lx&eC6modfhkqh*u#p!E)pPNE zJ)EUNX~po#p*rgsK@9=YQPBYjl0M*|K3u49g`rDFIn&9gSc0+itAtPf->hdkWEq^T z8#Y&7c4v*gO#7Fj>X<=?&GQ=LEHYEG@_bOG`fu;-*L0 z^*C`V^YMhBmcUHNvu%Lq>Kuh7#Fy+hdToCnz?C4YITOtI<7(AR#gSN5Z;$sP#op8e z*@+J%A}#&ZH;el^6N>r={0=4@s6zbCej&nyvE$UcNDQ;xj$C4!vjJ}x8L-BPoZ?D9 zd)T%(@^+a4c?TkZt+PUJ9~jUi1UU{tjzf^+5ac)nISxUNLy+ST 0 and lines or nil +end + +local function ballCopies(catchable) + local out = {} + local s = session() + local Bag = package.loaded["src.core.game3.bag"] + if not (s and s.bag and Bag and Bag.listPocket) then return out end + local ok, rows = pcall(Bag.listPocket, s.bag, "POKE_BALLS") + if not ok or type(rows) ~= "table" then return out end + for _, row in ipairs(rows) do + out[#out + 1] = { id = row.id, name = row.name or tostring(row.id), + count = row.qty, ball = true, needsTarget = false, + catchable = catchable and true or false } + end + table.sort(out, function(a, b) return tostring(a.name) < tostring(b.name) end) + return out +end + +local function slot(st, id) + if id == 0 then return st.player end + if id == 1 then return st.enemy end + return st.battlers and st.battlers[id] or nil +end + +local function activeId(st) + if not st.double then return 0 end + local U = ui() + local id = (U and tonumber(U._active)) or tonumber(st.activeBattler) or 0 + if id ~= 0 and id ~= 2 then id = 0 end + return id +end + +local function battlerCopies(st) + local out = {} + for id = 0, (st.double and 3 or 1) do + local b = slot(st, id) + local absent = st.absent and st.absent[id] or false + if b then + local copy = monCopy(b.mon, not absent) or {} + copy.id, copy.side = id, b.side or (id % 2 == 0 and "player" or "enemy") + copy.absent = absent and true or false + copy.partyIndex = b.partyIndex + out[#out + 1] = copy + end + end + return out +end + +local function moveCopies(st) + local out = {} + local b = slot(st, activeId(st)) + local mon = b and b.mon + if not mon then return out end + local Moves = package.loaded["src.core.game3.battle.moves"] + for slot = 1, 4 do + local id = mon.moves and mon.moves[slot] + if id and id ~= 0 and id ~= "" then + local def = Moves and Moves.get and Moves.get(id) or {} + out[#out + 1] = { slot = slot, id = id, + name = Moves and Moves.displayName and Moves.displayName(id) or tostring(id), + pp = mon.pp and mon.pp[slot], + maxPp = (mon.maxPp and mon.maxPp[slot]) or def.pp, + type = def.type, power = def.power, accuracy = def.accuracy } + end + end + return out +end + +local function prompt(B) + local U = ui() + if B._phase == "command" and U then + if U._mode == "menu" then return "menu" end + if U._mode == "moves" then return "moves" end + if U._mode == "target" then return "target" end + if U._mode == "party" then return "party" end + end + local M = message() + if M and M.isWaiting and M.isWaiting() then return "advance" end + return "locked" +end + +local function signature(st, B) + if not st then return "none" end + local U = ui() or {} + local parts = { tostring(st), tostring(B._phase), tostring(U._mode), + tostring(U._menuIndex), tostring(U._moveIndex), tostring(st.turn), + tostring(st.over), tostring(st.result), tostring(U._active), + tostring(U._target and U._target.cursor), + table.concat(messageCopy() or {}, "\n") } + for id = 0, (st.double and 3 or 1) do + local battler = slot(st, id) + local mon = battler and battler.mon + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon and mon.hp) + parts[#parts + 1] = tostring(mon and mon.status) + parts[#parts + 1] = tostring(st.absent and st.absent[id]) + end + for _, mon in ipairs(st.playerParty or {}) do + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon.hp) + parts[#parts + 1] = tostring(mon.status) + end + for _, item in ipairs(ballCopies(false)) do + parts[#parts + 1] = tostring(item.id) .. "=" .. tostring(item.count) + end + return table.concat(parts, "|") +end + +function BattleAPI:_revision(st, B) + local nextSignature = signature(st, B) + if nextSignature ~= self.signature then + self.signature = nextSignature + self.revision = self.revision + 1 + end + return self.revision +end + +function BattleAPI:snapshot() + local st, B = activeBattle() + if not st then return nil end + local catchable = st.wild and not st.ghost and not st.noCatch + local party = {} + local b2 = st.double and slot(st, 2) or nil + for i, mon in ipairs(st.playerParty or {}) do + party[i] = monCopy(mon, (st.player and st.player.mon == mon) or (b2 and b2.mon == mon)) + party[i].slot = i + end + local active = activeId(st) + local U = ui() + local targets + if U and U._mode == "target" then + targets = {} + for id = 0, 3 do + local b = slot(st, id) + if b and not (st.absent and st.absent[id]) then targets[#targets + 1] = id end + end + end + local snap = { revision = self:_revision(st, B), kind = st.kind or (st.wild and "wild" or "trainer"), + catchable = catchable and true or false, prompt = prompt(B), + message = messageCopy(), turn = st.turn or 0, + double = st.double and true or false, active = active, + player = monCopy(slot(st, active) and slot(st, active).mon, true), + enemy = monCopy(st.enemy and st.enemy.mon, true), + battlers = battlerCopies(st), targets = targets, + target = U and U._target and U._target.cursor or nil, + party = party, moves = moveCopies(st), items = ballCopies(catchable) } + return snap +end + +local MENU_INDEX = { fight = 1, item = 2, party = 3, run = 4 } + +local function validSlot(slot) + return type(slot) == "number" and slot % 1 == 0 and slot >= 1 +end + +local function press(key) + return { wasPressed = function(_, k) return k == key end, + isDown = function() return false end } +end + +function BattleAPI:submit(intent) + if type(intent) ~= "table" then return nil, "intent must be a table" end + if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then + return nil, "intent id must be a positive integer" + end + if self.lastIntentId and intent.id <= self.lastIntentId then + return nil, "replayed intent" + end + local st, B = activeBattle() + if not st then return nil, "no battle" end + if intent.revision ~= self:_revision(st, B) then + return nil, "stale battle context" + end + if B._auto then return nil, "battle kind is not controllable" end + local U = ui() + if not (U and U.handleInput) or B._phase ~= "command" then + return nil, "battle menu is covered" + end + if intent.kind == "menu" then + if U._mode ~= "menu" then return nil, "battle menu is not active" end + local index = MENU_INDEX[intent.choice] + if not index then return nil, "unknown battle menu choice" end + U._menuIndex = index + U.handleInput(press("a")) + elseif intent.kind == "move" then + if U._mode ~= "moves" then return nil, "move menu is not active" end + local user = slot(st, activeId(st)) + local mon = user and user.mon + local move = validSlot(intent.slot) and mon and mon.moves + and mon.moves[intent.slot] + if not move or move == 0 or move == "" then return nil, "invalid move slot" end + local pp = mon.pp and tonumber(mon.pp[intent.slot]) + if pp and pp <= 0 then return nil, "move has no PP" end + U._moveIndex = intent.slot + U.handleInput(press("a")) + elseif intent.kind == "target" then + if U._mode ~= "target" or not U._target then return nil, "target cursor is not active" end + local id = intent.target + if type(id) ~= "number" or id % 1 ~= 0 or id < 0 or id > 3 then + return nil, "target must be a battler id 0-3" + end + if not slot(st, id) or (st.absent and st.absent[id]) then + return nil, "no battler in that slot" + end + if id == U._target.battler then + local mon = slot(st, id).mon + local mv = mon and mon.moves and mon.moves[U._target.slot] + local Moves = package.loaded["src.core.game3.battle.moves"] + local def = Moves and mv and Moves.get(mv) + if math.floor((tonumber(def and def.target) or 0) / 2) % 2 ~= 1 then + return nil, "that move cannot target its user" + end + end + U._target.cursor = id + U.handleInput(press("a")) + elseif intent.kind == "back" then + if U._mode ~= "moves" and U._mode ~= "target" then return nil, "move menu is not active" end + U.handleInput(press("b")) + else + return nil, "unknown battle intent" + end + self.lastIntentId = intent.id + self.signature = nil + return true +end + +return BattleAPI diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 677d1379..d409e1ea 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -15,10 +15,13 @@ local Help = require("src.ui.game3.help_system") local QuestLog = require("src.ui.game3.quest_log") local QuestRecorder = require("src.core.game3.quest_log_recorder") +local ModRuntime = require("src.mods.Runtime") local Game3 = {} Game3.__index = Game3 +local function noop() end + Game3.SKIN_FAST_FORWARD = 4 function Game3.new() @@ -75,6 +78,7 @@ function Game3:_enterField(session, reason) end session._questNewScene=true if reason == "continue" then session._questMap=session.map end + require("src.core.game3.map")._announced = nil Runtime.start(nil, self, session, { reason = reason or "new_game" }) -- Runtime.start already Map.loads unless alreadyOnMap; keep explicit reload for -- session x/y/facing in case start opts change. @@ -156,6 +160,9 @@ function Game3:load(opts) self.options = options self:applyOptions(options) + self:_exposeModData() + self:_loadMods(opts) + -- Never auto-skip boot into a legacy Sevii sidecar. local continueOk = self:_hasContinueSave() require("src.ui.game3.start_menu").resetCursor() -- pokefirered/src/main.c:134 @@ -175,6 +182,67 @@ function Game3:load(opts) pcall(function() require("src.core.PresentSync").applyFixedStepPeriod() end) + if ModRuntime.wants("game.ready") then + ModRuntime.emit("game.ready", { game = self }) + end +end + +function Game3:_exposeModData() + local data = self.data + if type(data) ~= "table" then return end + data.gen3Pokemon = require("src.core.game3.pokemon") + local Moves = require("src.core.game3.battle.moves") + if not Moves._romLoaded then pcall(Moves.loadRomPack, Dataset.cache()) end + data.gen3Moves = Moves + local ItemsData = require("src.core.game3.items_data") + pcall(ItemsData.ensureLoaded) + data.gen3Items = ItemsData + local Encounters = require("src.core.game3.encounters") + data.gen3Encounters = Encounters._tables + local Trainers = require("src.core.game3.scripting.trainers") + local okT, pack = pcall(Trainers.pack) + data.gen3Trainers = okT and type(pack) == "table" and pack or nil + local Space = require("src.core.game3.scripting.space") + local bundle = Space.bundle + data.gen3Text = bundle and bundle.text or nil + data.gen3Scripts = bundle and bundle.scripts or nil +end + +function Game3:_loadMods(opts) + local modOpts = opts and opts.modOpts or nil + local ok, loader = pcall(function() + local mods = require("src.mods.Loader").new() + mods.game = self + mods:load(self.data, modOpts) + return mods + end) + if ok and loader then + self.mods = loader + self.modStatus = loader:status() + else + require("src.core.Logger").error( + "mods failed to load, continuing without them: %s", tostring(loader)) + end + local okC, Gen3Compat = pcall(require, "src.mods.Gen3Compat") + if okC and type(Gen3Compat) == "table" and Gen3Compat.applyMerged then + local okA, err = pcall(Gen3Compat.applyMerged, self) + if not okA then + require("src.core.Logger").error("Gen3Compat.applyMerged failed: %s", tostring(err)) + end + end +end + +function Game3:adoptSave(session, seedBuckets) + if type(session) ~= "table" then return end + if type(session.modData) ~= "table" then session.modData = {} end + local loader = self.mods + if not loader then return end + if seedBuckets then + for id, bucket in pairs(loader.modSave or {}) do + if session.modData[id] == nil then session.modData[id] = bucket end + end + end + loader.modSave = session.modData end function Game3:writeOptions() @@ -279,6 +347,14 @@ function Game3:_handleBootAction(action) if action.action == "continue" then local ok, save = pcall(SaveData.load) if ok and save and save.engine == "game3" then + if ModRuntime.wants("save.loading") then + ModRuntime.emit("save.loading", { raw = save }) + end + local activeMods = self.modStatus and self.modStatus.loaded + if SaveData.runMigrations then + SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods) + end + local modsDiff = SaveData.modsDiff and SaveData.modsDiff(save, activeMods) or nil local session = Schema.fromSaveTable(save) Options.bind(session, self.options) -- Refuse Sevii leftovers. @@ -286,6 +362,9 @@ function Game3:_handleBootAction(action) print("[game3] ignoring legacy Sevii save map " .. session.map) session = Schema.newGame({ gender = 0 }) end + self:adoptSave(session, not self._modSaveAdopted) + self._modSaveAdopted = true + self.sessionStartedAt = os.time() self.questPlayback = QuestLog.begin(session) if self.questPlayback then self.session=session @@ -294,6 +373,13 @@ function Game3:_handleBootAction(action) else self:_enterField(session, "continue") end + if modsDiff and SaveData.modsDiffNotice then + local notice = SaveData.modsDiffNotice(modsDiff, save.meta) + if notice then require("src.core.Logger").warn("%s", notice) end + end + if ModRuntime.wants("save.loaded") then + ModRuntime.emit("save.loaded", { save = session, meta = session.meta, modsDiff = modsDiff }) + end end return end @@ -304,6 +390,12 @@ function Game3:_handleBootAction(action) gender = action.gender or 0, start = action.start, }) + self:adoptSave(session, not self._modSaveAdopted) + self._modSaveAdopted = true + self.sessionStartedAt = os.time() + if ModRuntime.wants("save.created") then + ModRuntime.emit("save.created", { save = session }) + end self:_enterField(session, "new_game") return end @@ -322,6 +414,9 @@ end function Game3:fixedUpdate(dt) self:_aliasLA() + if ModRuntime.wantsHook("input.step") then + ModRuntime.call("input.step", noop, self, dt or FixedStep.STEP) + end if self.input and self.input.step then self.input:step() end if self.input and self.input.softResetStep and self.input:softResetStep() then self.input:reset() @@ -441,6 +536,20 @@ function Game3:update(dt) pcall(function() require("src.render.Tilt").update(dt) end) end +function Game3:_drawHud(w, h) + if not ModRuntime.wantsHook("render.hud") then return end + local scale, ox, oy, _, _, scaleY = Display.fit(w, h) + local viewport = { + width = w, height = h, + gameX = ox, gameY = oy, + gameWidth = Display.W * scale, gameHeight = Display.H * (scaleY or scale), + scale = scale, + } + love.graphics.push("all") + pcall(function() ModRuntime.call("render.hud", noop, self, viewport) end) + love.graphics.pop() +end + function Game3:draw() local w = love.graphics.getWidth() local h = love.graphics.getHeight() @@ -469,6 +578,7 @@ function Game3:draw() drawBootFrame() end end + self:_drawHud(w, h) if self.touchControls then self.touchControls:draw() end @@ -477,6 +587,7 @@ function Game3:draw() if Runtime.isActive() then if Display.present(self, w, h) then + self:_drawHud(w, h) if self.touchControls then self.touchControls:draw() end @@ -488,6 +599,7 @@ function Game3:draw() love.graphics.setColor(0.4, 0.8, 0.4) local map = self.session and self.session.map or "?" love.graphics.printf("Fire Red field: " .. tostring(map), 0, h * 0.45, w, "center") + self:_drawHud(w, h) if self.touchControls then self.touchControls:draw() end @@ -543,11 +655,19 @@ function Game3:_hotkey(key) end function Game3:keypressed(key) - if self:_hotkey(key) then return end - if self.input and self.input.keypressed then self.input:keypressed(key) end + local function vanilla() + if self:_hotkey(key) then return end + if self.input and self.input.keypressed then self.input:keypressed(key) end + end + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key }) end function Game3:keyreleased(key) - if self.input and self.input.keyreleased then self.input:keyreleased(key) end + local function vanilla() + if self.input and self.input.keyreleased then self.input:keyreleased(key) end + end + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key }) end function Game3:_padPressedBody(joystick, button) @@ -585,21 +705,45 @@ function Game3:_padReleasedBody(joystick, button) end function Game3:gamepadpressed(joystick, button) - self:_padPressedBody(joystick, button) + local function vanilla() self:_padPressedBody(joystick, button) end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "pressed", joystick = joystick, button = button }) end function Game3:gamepadreleased(joystick, button) - self:_padReleasedBody(joystick, button) + local function vanilla() self:_padReleasedBody(joystick, button) end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "released", joystick = joystick, button = button }) end function Game3:saveGame() if not self.session or self.phase == "quest_log" then return end + if ModRuntime.wantsHook("save.write") + and ModRuntime.call("save.write", function() return true end, self) == false then + return false + end if Runtime.getSession then local s = Runtime.getSession() if s then self.session = s end end + pcall(function() + require("src.core.game3.scripting.space").persistSession(nil, self) + end) QuestRecorder.save(self) - self.save = Schema.toSaveTable(self.session) - if SaveData.save then pcall(SaveData.save, self.save) end + local save = Schema.toSaveTable(self.session) + if SaveData.buildMeta then + save.meta = SaveData.buildMeta( + self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt) + self.session.meta = save.meta + end + self.save = save + if ModRuntime.wants("save.writing") then + ModRuntime.emit("save.writing", { save = save, meta = save.meta }) + end + if not SaveData.save then return false end + local ok, written = pcall(SaveData.save, save) + return ok and written ~= false end function Game3:resize() end @@ -627,11 +771,15 @@ end function Game3:wheelmoved(_, dy) if type(dy) ~= "number" then return end - if dy > 0 then - self:zoomStep(1) - elseif dy < 0 then - self:zoomStep(-1) + local function vanilla() + if dy > 0 then + self:zoomStep(1) + elseif dy < 0 then + self:zoomStep(-1) + end end + if not ModRuntime.wantsHook("input.wheel") then return vanilla() end + return ModRuntime.call("input.wheel", vanilla, self, dy) end function Game3:textinput() end function Game3:filedropped() end @@ -649,21 +797,26 @@ function Game3:touchreleased(id, x, y, dx, dy, pressure) end function Game3:gamepadaxis(joystick, axis, value) - if math.abs(value) > 0.5 and self.touchControls then - self.touchControls:noteGamepad() - end - if self.input and self.input.triggerAxis then - local trigger, phase = self.input:triggerAxis(axis, value) - if trigger then - if phase == "pressed" then - self:_padPressedBody(joystick, trigger) - elseif phase == "released" then - self:_padReleasedBody(joystick, trigger) - end - return + local function vanilla() + if math.abs(value) > 0.5 and self.touchControls then + self.touchControls:noteGamepad() end + if self.input and self.input.triggerAxis then + local trigger, phase = self.input:triggerAxis(axis, value) + if trigger then + if phase == "pressed" then + self:_padPressedBody(joystick, trigger) + elseif phase == "released" then + self:_padReleasedBody(joystick, trigger) + end + return + end + end + if self.input and self.input.gamepadaxis then self.input:gamepadaxis(joystick, axis, value) end end - if self.input and self.input.gamepadaxis then self.input:gamepadaxis(joystick, axis, value) end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "axis", joystick = joystick, axis = axis, value = value }) end function Game3:joystickpressed(joystick, button) @@ -701,15 +854,23 @@ function Game3:joystickhat(joystick, hat, direction) if self.input and self.input.joystickhat then self.input:joystickhat(joystick, hat, direction) end end -function Game3:joystickadded() end +function Game3:_releaseModInput() + if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end +end + +function Game3:joystickadded() + self:_releaseModInput() +end function Game3:joystickremoved(joystick) + self:_releaseModInput() if self.touchControls then self.touchControls:joystickremoved() end end function Game3:focus(f) if self.input then self.input:reset() end if self.touchControls then self.touchControls:reset() end + self:_releaseModInput() if f then if self.input then self.input:reconcile() end Audio.onFocusGained() @@ -796,6 +957,9 @@ function Game3:reset() self.boot = nil self.session = nil self.data = nil + self.mods = nil + self.modStatus = nil + self._modSaveAdopted = nil self.phase = "boot" self.returnToLauncher = nil self.onExit = nil diff --git a/src/core/game3/battle/INTRO_PLAN.md b/src/core/game3/battle/INTRO_PLAN.md deleted file mode 100644 index f253a382..00000000 --- a/src/core/game3/battle/INTRO_PLAN.md +++ /dev/null @@ -1,530 +0,0 @@ -# Battle intro + trainer presentation — implementation plan - -Target: FRLG `BeginBattleIntro` presentation for the owned game3 battle engine, built the -same way move anims were (ROM/pret-derived assets → cache → host present state → runtime -sequencer). Reference tree: `/home/autumn/src/pokefirered` (`$POKEFIRERED`). - ---- - -## 1. Scope - -### Goals - -1. **In-battle intro** for wild and single trainer battles: - BG intro slide → sprite slide-in → party summary (trainer) → intro message → - opponent send-out → player throw + send-out → healthbox slide-in → `command`. -2. **Trainer front pics** and **player back pics** resolved from the ROM (lazy decode with - cache write-through, mirroring `Pokemon.frontPic`), plus a small `trainers.lua` data - extract for trainer class / name / pic id. -3. **Soft field fade** into the battle scene (`src/ui/game3/fade.lua`), which already ticks - and draws around `Battle.draw` for free. -4. **Party status summary** bar + balls during the trainer intro. Cheap: the balls are - already in the extracted healthbox element sheet; only the 128×8 bar needs a new offset. -5. Fix the trainer intro string, which currently prints the **lead mon's** name instead of - the trainer class + name. - -### Non-goals (explicitly deferred) - -- `battle_transition.c` mugshots / VS wipe / all 15 `BattleTransition_*` effects. The field - side keeps a plain fade. -- Double battles, link/multi, Safari, Ghost, Old Man tutorial, Pokédude intro variants. -- Shiny sparkle anim (`TryShinyAnimation`), `Intro_WaitForShinyAnimAndHealthbox`. -- Real GBA scanline/window effects in the BG slide (`gScanlineEffectRegBuffers`, `WIN0V`). - Approximated — see §7. -- Non-Poké Ball ball graphics (`ItemIdToBallId`); MVP always uses the Poké Ball sheet. -- Real cries (`Audio.playCry` is already a timing stub; the sequencer only waits on it). - ---- - -## 2. Why a new sequencer, not `AnimSeq` - -`anim_seq.lua` is the per-hit loop (`anim → wait VM → tweenHp → wait`) driven from -`Engine.resolveMove` output. The intro is a different shape: a fixed script of -presentation beats with no hit list, exactly like `exp_seq.lua` (award → bar → level → -learn) and `evo_seq.lua`. So: **new `intro_seq.lua`, same module contract** -(`reset` / `busy` / `begin(...) -> bool` / `update() -> done`). - -Likewise the intro send-out is **not** an `AnimVm` script. In pret, `Special_BallThrow` / -`Special_BallThrowWithTrainer` are the *catch* throw (`AnimTask_ThrowBall`, -`AnimTask_IsBallBlockedByTrainerOrDodged`) — they are the wrong animation and they are also -tasks the VM currently stubs. The intro send-out is `DoPokeballSendOutAnimation` in -`src/pokeball.c`: pure C sprite callbacks with no battle-anim script at all. `pack.special` -has no send-out entry to wire up. **Therefore the send-out is implemented as host tweens in -`anim.lua` driven by `intro_seq.lua`**, using pret's exact frame counts (§5). This is the -same call the repo already made for `HorizontalLunge` (a `noGfx` template routed to a host -visual task). - ---- - -## 3. Architecture - -``` - ROM (FireRed USA 1.0, sha1 41cb23d8…) - │ - ┌──────────────────────────────────┴────────────────────────────────────┐ - │ src/import/gba/trainer_extract.lua (NEW) │ - │ gTrainers / gTrainerClassNames → data/generated/gba/trainers.lua │ - │ gTrainerBackPicTable[0..1] → …/trainers/back_<0|1>.rgba │ - │ src/import/gba/battle_chrome_extract.lua (EXTEND) │ - │ gBattleInterface_PartySummaryBar_Gfx → …/pokemon/battle/ │ - │ party_summary_bar.rgba │ - └──────────────────────────────────┬────────────────────────────────────┘ - │ CacheFs (firered/ prefix) - ┌──────────────────────────────────┴────────────────────────────────────┐ - │ src/core/game3/trainer_pic.lua (NEW) │ - │ TrainerPic.front(picId) – lazy ROM LZ decode + cache write-through │ - │ TrainerPic.back(gender) – 5-frame 64×320 strip │ - │ src/core/game3/scripting/trainers.lua (REWRITE) │ - │ Trainers.info(id) -> { class, className, name, picId, partySize } │ - └──────────────────────────────────┬────────────────────────────────────┘ - │ - ┌──────────────────────────────────┴────────────────────────────────────┐ - │ src/core/game3/battle/anim.lua (EXTEND) = the HOST │ - │ Anim._stage : { slide, trainer[side], ball, healthbox[side], │ - │ partyBar[side] } │ - │ Anim.stage() / Anim.introSlideDone() / Anim.tweenStage(...) │ - │ Anim.present(side).visible / .darken (mon hidden until release) │ - │ Anim._introTweening feeds Anim.busy() │ - └──────────────────────────────────┬────────────────────────────────────┘ - │ - ┌──────────────────────────────────┴────────────────────────────────────┐ - │ src/core/game3/battle/intro_seq.lua (NEW) = SEQUENCER │ - │ step table (wild | trainer), one beat per entry (§5) │ - └──────────────────────────────────┬────────────────────────────────────┘ - │ - ┌──────────────────────────────────┴────────────────────────────────────┐ - │ src/core/game3/battle/init.lua phase "intro" delegates to IntroSeq │ - │ src/core/game3/battle/ui.lua draws trainers / ball / gated boxes │ - │ src/core/game3/battle/healthbox.lua accepts visible + ox/oy │ - └───────────────────────────────────────────────────────────────────────┘ -``` - -Nothing new is required in `anim_vm.lua` / `anim_sprites.lua` / `anim_tasks.lua`. The intro -draws from `Anim._stage` in `ui.lua`, in the same z-order slot the mon sprites already use. - ---- - -## 4. Verified ROM offsets (FireRed USA 1.0, file offsets) - -All of these were confirmed against the local dump (sha1 -`41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc`) — structural scan plus byte-exact comparison -against pret data where a reference file exists. - -| pret symbol | file offset | layout | notes | -|---|---|---|---| -| `gTrainerFrontPicTable` | `0x23957C` | 148 × 8B `{u32 ptr, u16 size, u16 tag}` | `tag == index`; size `0x800` mostly, `0x1000` for a few (2-frame sheets — use frame 0 only) | -| `gTrainerFrontPicPaletteTable` | `0x239A1C` | 148 × 8B `{u32 ptr, u16 tag, u16 pad}` | LZ → 32B (16 colors) | -| `gTrainerBackPicTable` | `0x239FA4` | 6 × 8B | `[0]`=Red `0x2800`, `[1]`=Leaf `0x2800` → **5 frames** of 64×64 4bpp | -| `gTrainerBackPicPaletteTable` | `0x239FD4` | 6 × 8B | | -| `gTrainers` | `0x23EAC8` | stride `0x28` | `+1` trainerClass, `+3` trainerPic, `+4` trainerName[12], `+0x20` partySize (u32), `+0x24` party ptr | -| `gTrainerClassNames` | `0x23E558` | 107 × 13B | GBA charmap, `0xFF`-terminated; `0x53 0x54` = `{PKMN}` | -| `gBattleInterface_PartySummaryBar_Gfx` | `0xE7BB04` | LZ → 512B = 128×8 4bpp | byte-exact vs `graphics/battle_interface/party_summary_bar.png`; palette = `healthbox_pal` @ `0xD11B84` (already extracted) | - -Spot checks that passed: trainer `326/327/328` (Oak's Lab rival) → class `81`, pic `106`, -name `"TERRY"`, partySize `1`; class `81` = `"RIVAL"`, `86` = `"COOLTRAINER"`, `88` = -`"GENTLEMAN"`; pic `106` LZ-decodes to exactly 2048 tile bytes + 32 palette bytes. - -Two things that **need no extract at all**: - -- **Party summary balls** — `sPartySummaryBallSpriteSheets` is - `gBattleInterface_Gfx + B_INTERFACE_GFX_BALL_PARTY_SUMMARY` where the constant is `66` - (`src/battle_interface.c:46`). That is tile 66 of the already-extracted - `pokemon/battle/elements.rgba` (320×24), and `BattleChrome`'s existing - `elements_tile_quad(ti)` indexes it directly. Four tiles: 66..69. -- **The Poké Ball sprite** — `graphics/interface/ball/poke.png` is 16×48 (3 frames of - 16×16) and is already extracted as `ball_poke.png` from - `Versions.OAK_SPEECH.ball_poke_tiles = 0xD01724` / `ball_poke_pal = 0xD017E0`, loaded by - `src/ui/game3/boot.lua` as `assets.ballPoke` and already drawn frame-by-frame in - `src/ui/game3/oak_scene.lua`. Reuse that loader; do not add a new one. - -### Trainer pic placement (no coords table needed) - -`OpponentHandleDrawTrainerPic` computes `y = (8 - gTrainerFrontPicCoords[id].size) * 4 + 40` -and every front-pic entry has `size == 8`, so the opponent trainer sprite **center is always -(176, 40)**. Same for `PlayerHandleDrawTrainerPic`: -`(8 - gTrainerBackPicCoords[id].size) * 4 + 80` with `size == 8` for Red/Leaf → **player back -center (80, 80)** (note: `80`, not the mon's `72`). So `gTrainerFrontPicCoords` / -`gTrainerBackPicCoords` do not need extracting. Sprites are 64×64, so top-left = center − 32. - ---- - -## 5. Sequencer step tables - -Frame counts are pret's, at 60 Hz. `f` = frames. - -### Shared beat vocabulary (`intro_seq.lua` step kinds) - -| kind | data | effect | -|---|---|---| -| `fade` | `mode, speed` | `Fade.begin`; wait on `Fade.isActive()` | -| `bgslide` | `frames` | tween `stage.slide` 0→1; unlock sprite motion partway (`gIntroSlideFlags`) | -| `slidein` | `who, from, to, frames` | tween `stage.trainer[side].ox` or `Anim.present(side).ox` | -| `undarken` | `side, frames` | tween `Anim.present(side).darken` 10/16 → 0 | -| `partybar` | `side, dir` | tween `stage.partyBar[side].ox`, set `.balls` from party | -| `msg` | `text` | `pushMsg`; the `Ui.pump()` gate in `Battle.update` blocks until drained | -| `trainerexit` | `side, dx, frames` | linear translate the trainer sprite off-screen; hide its party bar | -| `throwpose` | `frames[]` | drive `stage.trainer.player.frame` through the back-pic anim | -| `ballarc` | `from, to, frames, arcH` | tween `stage.ball.{x,y}`; parabolic `y` | -| `ballwait` | `frames` | plain delay (opponent ball hangs before opening) | -| `release` | `side` | ball frame 1, `present.visible = true`, `oy` 16→0 over 14f, cry | -| `cry` | `side` | `Audio.playCry`; wait `Audio.isCryFinished()` | -| `healthbox` | `side` | tween `stage.healthbox[side].ox` ±115 → 0 over 23f, `visible = true` | -| `wait` | `frames` | delay | - -### Wild - -| # | step | pret source | frames | -|---|---|---|---| -| 1 | `fade` FROM_BLACK | (repo-side; not pret) | 16 | -| 2 | `bgslide` | `BattleIntroSlide1/2/3` by terrain | 24 (unlock sprites at 8) | -| 3 | `slidein` enemy mon `ox −240 → 0` @ +2/f, `darken = 10/16` | `OpponentHandleLoadMonSprite` sets `x2 = -DISPLAY_WIDTH`; `SpriteCB_MoveWildMonToRight` does `x2 += 2`; `SpriteCB_EnemyMon` does `BeginNormalPaletteFade(…, 10, 10, RGB(8,8,8))` | 120 | -| 4 | `cry` enemy (pan 25) | `PlayCry_Normal` at `x2 == 0` | — | -| 5 | `undarken` enemy | `BeginNormalPaletteFade(…, 10, 0, …)` in `SpriteCB_WildMonShowHealthbox` | 10 | -| 6 | `healthbox` enemy | `StartHealthboxSlideIn` | 23 | -| 7 | `msg` `Wild {MON} appeared!` | `STRINGID_INTROMSG` → `sText_WildPkmnAppeared` | — | -| 8 | `msg` `Go! {MON}!` | `STRINGID_INTROSENDOUT` → `sText_GoPkmn` | — | -| 9 | player throw block (§5.1) | `PlayerHandleIntroTrainerBallThrow` | ~120 | -| 10 | `healthbox` player | `Intro_TryShinyAnimShowHealthbox` | 23 | - -Note the ordering: in pret the wild mon's healthbox appears **before** the "appeared!" -message, because the sprite callback chain and `gBattleMainFunc` advance in parallel. The -sequencer is serial, so slotting the healthbox at step 6 reproduces the visible result. - -### Trainer - -| # | step | pret source | frames | -|---|---|---|---| -| 1 | `fade` FROM_BLACK | — | 16 | -| 2 | `bgslide` | `BattleIntroPrepareBackgroundSlide` | 24 | -| 3 | `slidein` **both**: enemy trainer `ox −240 → 0` @ +2/f, player back `ox +240 → 0` @ −2/f | `SpriteCB_TrainerSlideIn`, both `DrawTrainerPic` handlers | 120 | -| 4 | `partybar` enemy `(104, 40)`, `ox −100 → 0` @ +5/f, and player `(136, 96)`, `ox +100 → 0` @ −5/f | `CreatePartyStatusSummarySprites` (`battle_interface.c:1080`) | 20 | -| 5 | `msg` `{CLASS} {NAME}\nwould like to battle!` | `sText_Trainer1WantsToBattle` | — | -| 6 | `msg` `{CLASS} {NAME} sent\nout {MON}!` | `sText_Trainer1SentOutPkmn` | — | -| 7 | `trainerexit` enemy `176 → 280`, hide enemy party bar | `OpponentHandleIntroTrainerBallThrow` (`data[0]=35`, `data[2]=280`) + `Task_HidePartyStatusSummary` | 35 | -| 8 | `ballwait` then `release` enemy | `SpriteCB_OpponentMonSendOut` waits `data[0] > 15`; ball spawns at `(enemy cx, enemy cy + 24)` | 16 + 14 | -| 9 | `cry` enemy, `healthbox` enemy | `Intro_TryShinyAnimShowHealthbox` | 23 | -| 10 | `msg` `Go! {MON}!` | `sText_GoPkmn` | — | -| 11 | player throw block (§5.1), hide player party bar | `PlayerHandleIntroTrainerBallThrow` | ~120 | -| 12 | `healthbox` player | | 23 | - -### 5.1 Player throw block (shared by wild and trainer) - -From `PlayerHandleIntroTrainerBallThrow` + `Task_StartSendOutAnim` + -`SpriteCB_PlayerMonSendOut_1/2` + `SpriteCB_ReleaseMonFromBall` + `HandleBallAnimEnd`: - -1. `throwpose` — player back pic anim 1, verified byte-exact against - `src/data/trainer_graphics/back_pic_anims.h` (`sAnimCmd_Red_1` / `sAnimCmd_Leaf_1`, and - the same bytes read back from the ROM anim table at `0x239EBC`): - frame 1 (20f) → 2 (6f) → 3 (6f) → 4 (24f) → 0 (1f) = **57f total**. - Concurrently `trainerexit` player `80 → −40` over **50f** (`data[0]=50`, `data[2]=-40`). -2. After **31f** (`Task_StartSendOutAnim` gate `data[1] < 31`), the ball spawns at - **(48, 70)** and arcs to `(player cx, player cy_pic_offset + 24)` over **25f** with arc - height **−30** (`SpriteCB_PlayerMonSendOut_1`: `data[0]=25`, `data[5]=-30`). -3. `release`: ball anim frame 1, ball-open particles, mon `visible = true`, mon `oy` 16 → 0 - over ~**14f** (`data[1] = 0x1000`, `−288`/f, `y2 = data[1] >> 8`), affine - `BATTLER_AFFINE_EMERGE` scale-up, cry (pan −25). - -For MVP the "affine emerge" can be a `Task.tween` on a new `present.scale` (0.2 → 1.0 over -14f) rather than a real affine matrix; the `oy` rise is already 1:1. - -### 5.2 Exact strings (fixing the current bug) - -`Battle.start` currently pushes `State.displayName(st.enemy) .. " wants\nto battle!"` — the -**mon** name, and Gen-1 wording. FRLG (`src/battle_message.c`): - -| case | pret string | -|---|---| -| wild intro | `Wild {B_OPPONENT_MON1_NAME} appeared!` | -| trainer intro | `{B_TRAINER1_CLASS} {B_TRAINER1_NAME}\nwould like to battle!` | -| trainer send-out | `{B_TRAINER1_CLASS} {B_TRAINER1_NAME} sent\nout {B_OPPONENT_MON1_NAME}!` | -| player send-out | `Go! {B_PLAYER_MON1_NAME}!` | - -`{B_TRAINER1_CLASS}` = `gTrainerClassNames[gTrainers[id].trainerClass]`, -`{B_TRAINER1_NAME}` = `gTrainers[id].trainerName`. When `trainerId` is absent, fall back to -`"{PKMN} TRAINER"` (class 0) and no name, collapsing the double space. - -Rival trainers (class `81` = `RIVAL`) carry the placeholder name `"TERRY"` in the struct; the -real name lives in the save. If the session has a rival name, prefer it — otherwise the -struct name is the correct display fallback. - ---- - -## 6. Ordered work slices - -Each slice is independently landable and leaves the game runnable. - -### Slice 1 — trainer data (no visuals) - -- `src/import/gba/versions.lua`: add - ```lua - Versions.TRAINER_FRONT_PIC_TABLE = 0x23957C - Versions.TRAINER_FRONT_PIC_PAL_TABLE = 0x239A1C - Versions.TRAINER_BACK_PIC_TABLE = 0x239FA4 - Versions.TRAINER_BACK_PIC_PAL_TABLE = 0x239FD4 - Versions.TRAINERS_TABLE = 0x23EAC8 - Versions.TRAINER_STRIDE = 0x28 - Versions.TRAINER_CLASS_NAMES = 0x23E558 - Versions.TRAINER_CLASS_NAME_STRIDE = 13 - Versions.TRAINER_CLASS_COUNT = 107 - Versions.TRAINER_PIC_COUNT = 148 - Versions.TRAINERS_COUNT = 743 -- pret NUM_TRAINERS - ``` - and bump the format-version comment block at the top like the other entries. -- **New** `src/import/gba/trainer_extract.lua`, modelled on - `battle_moves_extract.lua` / `battle_chrome_extract.lua` - (`FORMAT_VERSION`, `CACHE_SUB = "trainers"`, `run(rom, cache, opts)`, `ready(cache, root)`): - - decode names via `TextIR.CHARMAP` exactly as `pokemon_extract.lua`'s `decode_name` does - (it already handles the `0xBB`/`0xD5` letter ranges and `0xFF` EOS); - - write `data/generated/gba/trainers.lua` → `{ classNames = {...}, trainers = { [id] = { class, pic, name, partySize } } }`; - - write `data/generated/gba/trainers/manifest.lua`. -- Wire into `PokemonExtract.run` next to the existing sub-extracts - (`src/import/gba/pokemon_extract.lua:567-591` — same `progress("trainers", 0, 1)` shape), - and return it in the detail table so `RomExtractorGen3:runPokemonExtract` reports it. -- Add `--trainers` to `src/import/gba/cli_extract.lua` following the `--battle-anims` branch. -- **Rewrite** `src/core/game3/scripting/trainers.lua`: keep `Trainers.foeFromId` (the - hardcoded Oak's Lab parties stay as the party source until a `gTrainers` party extract - lands) and add `Trainers.info(id)` reading the generated pack, with the current - hardcoded table as the fallback when the cache is missing. -- Thread `trainerId` through: `ops_a.lua:718-751` already has it → - `adapters.lua:980` `startTrainerBattle(foe, done, battleOpts)` → `battle_bridge.lua:181` - `Battle.start{...}`. Add `trainerId = opts.trainerId` and let `Battle.start` resolve - class/name/pic through `Trainers.info`. Store on `st` as - `st.trainerId / st.trainerClassName / st.trainerName / st.trainerPicId`. -- **Fix the strings now** (§5.2), still pushed synchronously. This alone is a visible win - and keeps the diff reviewable. - -### Slice 2 — trainer pics - -- **New** `src/core/game3/trainer_pic.lua`. Copy the shape of - `src/core/game3/pokemon.lua:761-880`: - - `decode_pic_rgba(index, picTable, palTable, cacheRel, frames)` — the mon version is - hardcoded to 8×8 tiles; generalise to `frames` so the back pic decodes as a 64×320 - vertical strip (5 × 64×64); - - `TrainerPic.front(picId)` → `{ image, w = 64, h = 64 }`, cache - `data/generated/gba/trainers/front/.rgba`; - - `TrainerPic.back(gender)` → `{ image, w = 64, h = 320, frames = 5 }`, cache - `data/generated/gba/trainers/back/.rgba`; - - reuse `Versions.gbaToFile` and `src/import/gba/lz77.lua` as the mon path does; - - for `0x1000`-sized front entries take the first 2048 bytes only. -- The 2 player back sheets are needed in **every** battle, so also bake them eagerly in - `trainer_extract.run` (2 × 5 × 16 KB = 160 KB) so a shipped cache never needs the ROM. - Front pics stay lazy, matching the existing mon-pic tradeoff. -- `src/import/CacheContract.lua`: add to `VERSION_REQUIRED_FILES_OVERRIDE.firered` - (after the existing `pokemon/party/slot_main.rgba` line): - ``` - "data/generated/gba/trainers.lua", - "data/generated/gba/trainers/back_0.rgba", - "data/generated/gba/trainers/back_1.rgba", - ``` - Adding required files invalidates existing caches by design, so bump - `CacheContract.VERSION_FORMAT.firered` from `"rom-cache-v1-firered:"` to - `"rom-cache-v2-firered:"` in the same commit. - -### Slice 3 — host stage state (`anim.lua`) - -- Add `Anim._stage` and `Anim.resetStage()` (called from `Anim.reset`): - ```lua - Anim._stage = { - slide = 0, slideDone = false, - trainer = { player = { visible=false, ox=0, oy=0, frame=0, gender=0 }, - enemy = { visible=false, ox=0, oy=0, picId=nil } }, - ball = { visible=false, x=0, y=0, frame=0, side=nil }, - healthbox = { player = { visible=false, ox=0 }, enemy = { visible=false, ox=0 } }, - partyBar = { player = { visible=false, ox=0, balls={} }, - enemy = { visible=false, ox=0, balls={} } }, - } - ``` -- `Anim.stage()`, `Anim.introSlideDone()`. -- `Anim.tweenStage(frames, onStep, onComplete)` — thin wrapper over `Task.tween` that - raises/lowers a new `Anim._introTweening` counter. -- Add `Anim._introTweening > 0` to `Anim.busy()`, next to `_hpTweening` / `_expTweening`. - Keep the existing comment's warning in mind: this must gate *in-flight tweens only*, not - "the sequencer has steps left", or `Battle.update` soft-locks. -- Extend `default_present(side)` with `darken = 0` and `scale = 1`; both must be honoured in - `ui.lua`'s `draw_mon_sprite`. -- Headless: `Anim.reset({ headless = true })` must leave `slideDone = true` and everything - `visible = true` so `runToEnd` never sees a hidden battler. - -### Slice 4 — sequencer (`intro_seq.lua`) - -- **New** `src/core/game3/battle/intro_seq.lua`, structurally a sibling of `exp_seq.lua`: - `IntroSeq.reset()`, `IntroSeq.busy()`, `IntroSeq.begin(st, opts) -> bool`, - `IntroSeq.update() -> done`, module-local `_steps/_i/_waiting/advance()/finish()`. -- `begin` builds the step list from `st.wild` (§5) and returns `false` when - `opts.headless` — the caller then pushes the strings directly. -- `update` follows `ExpSeq.update`'s orphan-wait guard: if `_waiting` and - `not Anim.busy()`, `advance()`. -- `opts`: `{ pushMsg, headless, playerGender, trainerClassName, trainerName, trainerPicId, playerParty, foeParty }`. - -### Slice 5 — engine phase (`init.lua`) - -- `require` `IntroSeq`; add `IntroSeq.reset()` to `finish()` and `Battle.runToEnd()` - alongside the other `*.reset()` calls. -- In `Battle.start`, replace the `if not opts.headless then … Ui.push(…) end` block - (`init.lua:175-183`) with `IntroSeq.begin(st, {...})`. Keep the headless branch pushing - the four strings in the same order so text-log assertions stay stable. -- Replace the `Battle._phase == "intro"` branch (`init.lua:469-477`). It currently sits - *after* the shared `if Anim.busy() then return end; if not Ui.pump() then return end` - gate, which is exactly the gate the sequencer wants, so the body becomes: - ```lua - if Battle._phase == "intro" then - if not IntroSeq.update() then return end - Battle._phase = "command" - if Battle._auto then - begin_turn_with(Commands.playerAction(Battle._st, 1, 1)) - else - Ui.openMenu() - end - return - end - ``` -- Accept and store the new `opts` (`trainerId`, `trainerClass`, `trainerName`, - `trainerPicId`, `playerGender`) on `st`. - -### Slice 6 — drawing (`ui.lua`, `healthbox.lua`) - -- `ui.lua` `draw_mon_sprite`: honour `pres.darken` (lerp toward `RGB(8,8,8)` = `(8/255)`) - and `pres.scale` (scale about the sprite centre). `pres.visible == false` is already - respected at line 271. -- New locals in `ui.lua`: - - `draw_trainer_sprite(side)` — `TrainerPic.front(stage.trainer.enemy.picId)` at centre - `(176 + ox, 40)`; `TrainerPic.back(gender)` quad-clipped to frame `stage.trainer.player.frame` - at centre `(80 + ox, 80)`. - - `draw_intro_ball()` — `assets.ballPoke` quad `(0, frame*16, 16, 16)` at - `(stage.ball.x − 8, stage.ball.y − 8)`. Pull the image through - `src/ui/game3/boot.lua`'s `ballPoke` rather than adding a second loader. - - `draw_party_bar(side)` — `party_summary_bar.rgba` (128×8) at `(x + ox, y)` plus up to 6 - balls from `BattleChrome` element tile 66 at `(x + 8*i, y − 4)`. -- Insert into `Ui.draw` between the terrain and the mon sprites, matching pret subpriority: - BG → enemy trainer → enemy mon → particles → player mon → player trainer → ball → - healthboxes → party bars → panel. -- `Healthbox.draw(side, battler)` gains an `opts` or reads `Anim.stage().healthbox[side]`: - skip entirely when `visible == false`, and offset both the box and its text/bars by `ox`. - Today `Ui.draw` calls it unconditionally at lines 398-399. -- `BattleChrome.drawTerrain` / `BattleBg.draw` take an optional `x` offset so `bgslide` can - scroll the terrain (pret scrolls `gBattle_BG1_X += 6`/frame). - -### Slice 7 — party summary asset - -- `src/import/gba/versions.lua`: `Versions.BATTLE_CHROME.party_summary_bar = 0xE7BB04`. -- `battle_chrome_extract.lua`: LZ → 512 B, decode as 16 tiles → 128×8, palette - `healthbox_pal`, write `pokemon/battle/party_summary_bar.rgba`; add - `partySummaryBar = { file = ..., w = 128, h = 8 }` plus - `partyBarPlayer = { x = 136, y = 96 }`, `partyBarOpponent = { x = 104, y = 40 }` - to the generated `manifest.lua`. Bump `BattleChromeExtract.FORMAT_VERSION` 3 → 4. -- `battle_chrome.lua`: load it in `install`, expose `BattleChrome.drawPartyBar(x, y)` and - `BattleChrome.drawPartyBall(x, y, kind)` (element tiles 66..69). - -### Slice 8 — field fade + docs - -- `BattleBridge.start` (`battle_bridge.lua:149`): before `Battle.start`, run - `Fade.begin(Fade.MODE.TO_BLACK, 1, cb)` and start the battle in the callback. `Runtime` - already ticks `Fade` (`runtime.lua:149`) and `Gfx.drawUi` already draws it after - `Battle.draw` (`display.lua:132-141`, `gfx.lua:121-124`), so the FROM_BLACK half needs - nothing beyond being step 1 of the sequencer. -- Update `src/core/game3/battle/PARITY.md`: - - Layers table: new row **Battle intro / send-out** — *Owned MVP: BG slide approximation, - trainer slide-in, party summary, ROM trainer pics, pret-timed ball send-out. Deferred: - battle_transition mugshot/VS, shiny anim, doubles.* - - "Battle anim architecture" list: add - `**Intro sequencer** intro_seq.lua: bgslide → slide-in → party summary → msgs → - send-out → healthbox (pret BeginBattleIntro + DoPokeballSendOutAnimation)`. - - "Pret 1:1 (later pass)": add battle transitions, shiny anim, real affine emerge, - scanline BG slide. - - Note that the send-out is deliberately **not** an `AnimVm` script, with the reason. - ---- - -## 7. Risks and accepted MVP shortcuts - -| # | risk / shortcut | mitigation | -|---|---|---| -| 1 | **BG intro slide is not reproducible.** `BattleIntroSlide1/2/3` drive `WIN0V`, `BLDCNT`, and per-scanline `gScanlineEffectRegBuffers` — no equivalent in the LÖVE compositor. | Approximate: horizontal terrain scroll (pret's `gBattle_BG1_X += 6`) plus a vertical wipe. What actually matters for parity is the *gate*: sprites must not move until the slide releases them, which `stage.slideDone` reproduces exactly. Flag it in `PARITY.md`. | -| 2 | **Total intro is long** (~7 s for a trainer battle at pret timings). | Keep the frame counts (they are the real game), but let `A`/`B` skip the remaining tweens by fast-forwarding the sequencer — the same affordance `Message` already gives. | -| 3 | **Lazy ROM decode needs the `.gba` in cwd.** `Pokemon.load_rom_bytes` scans three hardcoded filenames. A shipped cache with no ROM gets no trainer front pic. | Eager-bake the 2 player back sheets (always needed). For fronts, `TrainerPic.front` returning `nil` must degrade to *no trainer sprite* (skip straight to the send-out), never a placeholder rectangle — the "don't invent FX" rule. A follow-up slice can bake all 148 (≈2.4 MB). | -| 4 | **`Anim.busy()` soft-lock.** The comment at `anim.lua:99-101` documents that folding "sequencer has work left" into `busy()` deadlocks `Battle.update`. | `_introTweening` must be incremented only for in-flight `Task.tween`s and decremented in their `onComplete`. Cover with a test that drives 2000 frames and asserts the phase reaches `command`. | -| 5 | **Affine emerge** (`BATTLER_AFFINE_EMERGE`) is a real OAM matrix anim. | `present.scale` linear 0.2 → 1.0 over 14f. Visually close; note in `PARITY.md`. | -| 6 | **Trainer party for the summary bar** is unknown — `scripting/trainers.lua` only hardcodes three Oak's Lab parties and `st.foeParty` holds one mon. | `gTrainers[id].partySize` (offset `+0x20`) is in the extract, so the bar can show the right *count* of full balls without a party extract. Fall back to 1 ball. | -| 7 | **Cries are stubs.** `Audio.playCry` just sets a 64-frame timer. | Fine — the sequencer only needs the timing. | -| 8 | **`{PKMN}` in class names** is the 2-byte `0x53 0x54` charmap pair. | Decode to the literal `"POKéMON"` that `FrlgFont` can render, as the rest of the repo's strings do. | -| 9 | **Rival name.** Class 81 trainers carry the placeholder `"TERRY"`. | Prefer a session rival name when present; the struct name is the correct fallback. | -| 10 | **Cache invalidation.** New `CacheContract` required files reject every existing FireRed cache. | Intended; bump `VERSION_FORMAT.firered` in the same commit so the launcher explains the re-import instead of reporting a corrupt cache. | - ---- - -## 8. Test plan - -### Automated (`luajit tests/.lua`, the CI convention) - -**New `tests/game3_battle_intro_test.lua`**, following the plain -`check(cond, msg)`-and-`print("[test] N. …")` style of `tests/game3_intro_test.lua`: - -1. **Offsets** — assert every `Versions.TRAINER_*` constant equals the value in §4. Cheap - regression guard; `tests/game3_intro_test.lua` already does exactly this for - `Versions.INTRO_MOVIE`. -2. **Step table shape** — `IntroSeq.begin` on a synthetic wild `st` produces steps in the - §5 order; same for a trainer `st`, asserting the trainer path has `partybar` and - `trainerexit` and the wild path does not. -3. **Strings** — a trainer battle with `trainerClassName = "RIVAL"`, `trainerName = "TERRY"` - yields `"RIVAL TERRY\nwould like to battle!"` and - `"RIVAL TERRY sent\nout SQUIRTLE!"`; a wild battle yields - `"Wild PIDGEY appeared!"`. This is the bug-fix regression test. -4. **Headless skip** — `Battle.start{ headless = true }` leaves `IntroSeq.busy() == false` - and `Ui.log()` contains the intro strings in order; `Battle.runToEnd()` still terminates - within the existing 800-iteration guard. -5. **No soft-lock** — non-headless start, then drive `Battle.update(1/60)` up to 2000 times - with a stub input; assert `Battle._phase == "command"` and - `Anim.stage().healthbox.player.visible == true`. -6. **Visibility gating** — immediately after a non-headless `Battle.start`, - `Anim.present("player").visible == false` and - `Anim.stage().healthbox.enemy.visible == false`. - -Also re-run the neighbours that touch the same modules: `tests/game3_summary_test.lua` -(shares `Pokemon` pic decode) and the `parity_battle_intro_chrome.lua` / -`parity_battle_intro_cry.lua` suites (Gen-2 paths — they must stay green, i.e. untouched). - -### Manual - -1. `luajit src/import/gba/cli_extract.lua --trainers` → verify - `data/generated/gba/trainers.lua` has 743 rows, `classNames[81] == "RIVAL"`, - `trainers[326] == { class = 81, pic = 106, name = "TERRY", partySize = 1 }`. -2. `luajit src/import/gba/cli_extract.lua --pokemon` → confirm - `pokemon/battle/party_summary_bar.rgba` is 128×8×4 = 4096 bytes. -3. In-game **wild**: walk into Route 1 grass. Expect fade → BG slide → darkened Pidgey - sliding in from the left → cry → un-darken → enemy healthbox → "Wild PIDGEY appeared!" - → "Go! …!" → back-pic throw pose + slide-off-left → ball arc → mon rises → player - healthbox → FIGHT menu. -4. In-game **trainer**: Oak's Lab rival battle. Expect both trainer sprites sliding in from - opposite edges, both party bars, "RIVAL TERRY would like to battle!", trainer exiting - right, opponent ball opening, then the player throw. Confirm the message says the - trainer's class and name, **not** "SQUIRTLE". -5. **Degraded path** — rename the `.gba` out of cwd with a warm cache and start a trainer - battle: no trainer sprite, no crash, intro still reaches `command`. -6. **Battle end** — win, lose, and run to confirm `finish()` clears the stage and the field - redraws with no leftover trainer sprite or ball. - ---- - -## 9. File-by-file summary - -| file | action | -|---|---| -| `src/import/gba/versions.lua` | + `TRAINER_*` offsets, + `BATTLE_CHROME.party_summary_bar` | -| `src/import/gba/trainer_extract.lua` | **new** — trainers.lua + eager player back sheets | -| `src/import/gba/pokemon_extract.lua` | wire `TrainerExtract.run` into the sub-extract chain | -| `src/import/gba/battle_chrome_extract.lua` | + party summary bar; `FORMAT_VERSION` 3 → 4 | -| `src/import/gba/cli_extract.lua` | + `--trainers` | -| `src/import/CacheContract.lua` | + 3 firered required files; `VERSION_FORMAT.firered` v1 → v2 | -| `src/core/game3/trainer_pic.lua` | **new** — lazy front / eager back pic decode | -| `src/core/game3/scripting/trainers.lua` | + `Trainers.info(id)` from the generated pack | -| `src/core/game3/battle/anim.lua` | + `_stage`, `stage()`, `tweenStage`, `_introTweening`, `present.darken/.scale` | -| `src/core/game3/battle/intro_seq.lua` | **new** — the sequencer | -| `src/core/game3/battle/init.lua` | `Battle.start` builds the intro; `"intro"` phase delegates; resets | -| `src/core/game3/battle/ui.lua` | + trainer / ball / party-bar draws; gate healthboxes; honour darken/scale | -| `src/core/game3/battle/healthbox.lua` | + visibility and `ox` offset | -| `src/core/game3/battle/bg.lua` | + slide `x` offset passthrough | -| `src/ui/game3/battle_chrome.lua` | + `drawPartyBar` / `drawPartyBall` | -| `src/core/game3/battle_bridge.lua` | + `trainerId` passthrough, TO_BLACK fade before start | -| `src/core/game3/scripting/adapters.lua` | + `trainerId` in `startTrainerBattle` opts | -| `src/core/game3/battle/PARITY.md` | intro row + architecture bullet + deferred list | -| `tests/game3_battle_intro_test.lua` | **new** | diff --git a/src/core/game3/battle/PARITY.md b/src/core/game3/battle/PARITY.md deleted file mode 100644 index 715ca5ca..00000000 --- a/src/core/game3/battle/PARITY.md +++ /dev/null @@ -1,60 +0,0 @@ -# Game3 battle core — ownership & pret target - -Owned under `src/core/game3/battle/`. **No** live `require` of KR `battle/core` or host `Battle` / `BattleState`. - -## Goal - -Replicate pret FireRed/LeafGreen battle behavior. KR `mods/Kanto-Reforged/battle/core/effects/*` is a **port farm** for adapter-pure handler bodies only. **ROM `gBattleMoves[].effect`** (extracted pack) is the dispatch key via [`effect_ids.lua`](effect_ids.lua) `STATUS_SETUP` / `AFTER_HIT` / `STAT_CHANGES`. - -## Layers - -| Layer | Status | -|-------|--------| -| Rules / capabilities / type chart / damage | Owned MVP (Gen3-shaped) | -| Residuals scheduler + status/weather/seed/trap/perish/wish | Owned | -| Effect registry (FRLG-legal; KR-sourced bodies) | Growing; ROM effect-byte driven | -| Turn / commands (FIGHT/BAG/POKéMON/RUN) | Owned; BAG/switch placeholder | -| UI | ROM chrome extract + pret layout (healthboxes, textbox panels, 2×2 cursors) | -| Battle anims | **Owned MVP**: pret IR extract + ANIM_TAG PNGs → VM; template callbacks (HitSplat, RoarNoiseLine, …); stub rare AnimTask_* | -| Battle intro / send-out | **Owned MVP**: `intro_seq.lua` (BeginBattleIntro); ROM trainer pics + class/name; pret-timed ball send-out host tweens; party summary bar. Deferred: battle_transition mugshot/VS, shiny anim, doubles, real affine emerge / scanline BG slide | -| EXP / level-up | **Owned MVP**: ROM `expYield` + `growthRate`; pret tables; Cmd_getexp; EXP bar; **learn-move** (ROM learnsets, YES/NO forget, HM block); **EVO_LEVEL** after win (Everstone). Exp.Share split per pret Cmd_getexp. Deferred: stones/trade/friendship evo, evo animation, Summary forget UI | -| ROM `gBattleMoves` extract | Owned (`data/generated/gba/pokemon/battle_moves.lua`) | -| Base stats / abilities on battlers | From species pack; ability effects in [`abilities.lua`](abilities.lua) | -| Held items | [`held_items.lua`](held_items.lua): pret hold effects (berries, Leftovers, Focus Band, Choice Band, type boosts, King's Rock, Shell Bell, Quick Claw, Smoke Ball) | - -## Battle anim architecture - -- **Host** [`anim.lua`](anim.lua): present state (ox/oy/alpha/z), HP display tween + `onComplete`, composite `busy()` -- **Event sequencer** [`anim_seq.lua`](anim_seq.lua): plays the adapter event stream (msg / move / anim general·status·special / hit / hp / faint / switch / end) in pret battle-script order; Battle Scene OFF follows Cmd_attackanimation / Cmd_playanimation / Cmd_statusanimation -- **VM context** [`anim_ctx.lua`](anim_ctx.lua): gAnimMovePower / gAnimMoveDmg / gAnimFriendship / gWeatherMoveAnim / gAnimDisableStructPtr / behindSubstitute filled at every launch -- **Intro sequencer** [`intro_seq.lua`](intro_seq.lua): bgslide → slide-in → party summary → msgs → send-out → healthbox (pret `BeginBattleIntro` + `DoPokeballSendOutAnimation` host tweens — **not** catch `Special_BallThrow`) -- **EXP sequencer** [`exp_seq.lua`](exp_seq.lua) + [`experience.lua`](experience.lua): faint → award → gained text → bar fill → level-up → [`learn_move.lua`](learn_move.lua) (ROM learnset) -- **Evo sequencer** [`evo_seq.lua`](evo_seq.lua) + [`../evolution.lua`](../evolution.lua): after win, `EVO_LEVEL` from ROM evolutions.lua → learn moves at level for new species -- **VM** [`anim_vm.lua`](anim_vm.lua): portable IR opcodes; **`isReversed`** on enemy attackers; palette shader draw -- **Pools** [`anim_sprites.lua`](anim_sprites.lua) / [`anim_tasks.lua`](anim_tasks.lua): fixed 128 sprites + 48 tasks (no GC churn) -- **Extract** → `pokemon/battle_anims/pack.lua` (named template/task IDs, not live ROM pointers) -- **Palettes** [`anim_pal.lua`](anim_pal.lua): every ANIM_TAG sheet also ships as a 4bpp index PNG + its gBattleAnimPaletteTable colours (`tags[T].pal`, `tagPals`); per-tag unfaded/faded palettes live in `AnimPal`, and rotations / LoadPalette / greyscale / allocated palettes write real indices; sprites and anim BGs draw through a palette-lookup shader (RGBA PNGs remain for other consumers) -- **Anim BGs** `pack.animBgs`: the 27 gBattleAnimBackgroundTable entries plus task-loaded BG1/BG2 layers (ATTRACT, SCARY_FACE_*, MORNING_SUN, METAL_SHINE, CURE_BUBBLES, CURSE, FOG, SANDSTORM, SURF_*; `bgPals.MUDDY_WATER`), drawn with exact BLDALPHA eva/evb; mon-window masks go through `present.statMask` -- Unknown `AnimTask_*` stub to 1-frame finish so scripts reach `end` -- Headless / `runToEnd`: VM + tweens instant-complete - -## Dispatch - -1. `Moves.get(id)` merges ROM row (power/type/accuracy/pp/**effect**/…). -2. Status/setup: `EffectIds.STATUS_SETUP[effect]` → registry handler. -3. Stat ups/downs: `STATUS_SETUP` → `EXP_STAT_FROM_EFFECT` + `STAT_CHANGES[effect]`. -4. Damaging secondaries: `AFTER_HIT[effect]`. - -Gen4+ MODERN leftovers (Stealth Rock, Toxic Spikes, Trick Room, Aqua Ring, Tailwind) are **not** registered. - -## Pret 1:1 (later pass) - -- Exact damage formula / crit / accuracy / damage variance from pret -- Remaining move effects (two-turn, OHKO, multi-hit polish, Metronome/Mirror Move/Sleep Talk, …) -- Fill visual-task long tail; full ANIM_TAG bank from ROM; BG affine / monbg fidelity -- Trainer AI scripts -- Double battles out of scope until singles match - -## Wire-in - -`BattleBridge` → `Battle.start` (session party only). Scripts wait on `nativePoll`. diff --git a/src/core/game3/battle/abilities.lua b/src/core/game3/battle/abilities.lua index 0af5526d..311ca220 100644 --- a/src/core/game3/battle/abilities.lua +++ b/src/core/game3/battle/abilities.lua @@ -168,29 +168,36 @@ function Abilities.switchIn(ad, b) end -- pokefirered/data/battle_scripts_1.s:3983 +local function intimidate_one(ad, b, foe) + if not (foe and not ad:isFainted(foe) and (foe.substituteHP or 0) <= 0) then return end + local fab = ad:abilityOf(foe) + if fab == "CLEAR_BODY" or fab == "HYPER_CUTTER" or fab == "WHITE_SMOKE" then + ad:say(name(ad, foe) .. "'s " .. Abilities.name(fab) .. "\nprevented " .. name(ad, b) + .. "'s\nINTIMIDATE from working!") + else + local side = ad:ownSide(foe) + if side and (side.expMistTurns or 0) > 0 then + if not foe._statLoweredMsg then + foe._statLoweredMsg = true + ad:say(name(ad, foe) .. " is protected\nby MIST!") + end + elseif (foe.stages.attack or 0) > -6 then + foe.stages.attack = foe.stages.attack - 1 + ad:playAnim("general", "STATS_CHANGE", foe, foe, Secondary.statAnimArg("attack", -1)) + ad:say(name(ad, b) .. "'s INTIMIDATE\ncuts " .. name(ad, foe) .. "'s ATTACK!") + end + end +end + function Abilities.runIntimidate(ad) for _, b in ipairs(ad:activeBattlers()) do if b.expIntimidatePending and ad:abilityOf(b) == "INTIMIDATE" then b.expIntimidatePending = nil - local foe = ad:foeOf(b) - if foe and not ad:isFainted(foe) and (foe.substituteHP or 0) <= 0 then - local fab = ad:abilityOf(foe) - if fab == "CLEAR_BODY" or fab == "HYPER_CUTTER" or fab == "WHITE_SMOKE" then - ad:say(name(ad, foe) .. "'s " .. Abilities.name(fab) .. "\nprevented " .. name(ad, b) - .. "'s\nINTIMIDATE from working!") - else - local side = ad:ownSide(foe) - if side and (side.expMistTurns or 0) > 0 then - if not foe._statLoweredMsg then - foe._statLoweredMsg = true - ad:say(name(ad, foe) .. " is protected\nby MIST!") - end - elseif (foe.stages.attack or 0) > -6 then - foe.stages.attack = foe.stages.attack - 1 - ad:playAnim("general", "STATS_CHANGE", foe, foe, Secondary.statAnimArg("attack", -1)) - ad:say(name(ad, b) .. "'s INTIMIDATE\ncuts " .. name(ad, foe) .. "'s ATTACK!") - end - end + if ad._st and ad._st.double then + -- pokefirered/src/battle_script_commands.c:9174 + for _, foe in ipairs(ad:foesOf(b)) do intimidate_one(ad, b, foe) end + else + intimidate_one(ad, b, ad:foeOf(b)) end return true end @@ -203,6 +210,24 @@ function Abilities.runTrace(ad) for _, b in ipairs(ad:activeBattlers()) do if b.expTracePending and ad:abilityOf(b) == "TRACE" then local foe = ad:foeOf(b) + local st = ad._st + if st and st.double then + -- pokefirered/src/battle_util.c:2243 + local State = require("src.core.game3.battle.state") + local side = (b.id % 2 == 0) and 1 or 0 + local t1, t2 = State.battler(st, side), State.battler(st, side + 2) + local ok1 = t1 and ad:abilityOf(t1) and ad:hp(t1) > 0 + local ok2 = t2 and ad:abilityOf(t2) and ad:hp(t2) > 0 + if ok1 and ok2 then + foe = State.battler(st, ad:roll(0, 1) * 2 + side) + elseif ok1 then + foe = t1 + elseif ok2 then + foe = t2 + else + foe = nil + end + end local fab = foe and ad:abilityOf(foe) if fab and ad:hp(foe) > 0 then b.expTracePending = nil @@ -467,6 +492,24 @@ end -- pokefirered/src/battle_main.c:3002 function Abilities.escapeBlocker(ad, b) + if ad._st and ad._st.double then + for _, foe in ipairs(ad:foesOf(b)) do + if not ad:isFainted(foe) then + local fab = ad:abilityOf(foe) + if fab == "SHADOW_TAG" then return foe, fab end + if fab == "ARENA_TRAP" and ad:abilityOf(b) ~= "LEVITATE" and not is_type(b, Types.ID.FLYING) then + return foe, fab + end + end + end + -- pokefirered/src/battle_main.c:3036 + if is_type(b, Types.ID.STEEL) then + for _, o in ipairs(ad:activeBattlers()) do + if o ~= b and not ad:isFainted(o) and ad:abilityOf(o) == "MAGNET_PULL" then return o, "MAGNET_PULL" end + end + end + return nil + end local foe = ad:foeOf(b) if not foe or ad:isFainted(foe) then return nil end local fab = ad:abilityOf(foe) diff --git a/src/core/game3/battle/adapter.lua b/src/core/game3/battle/adapter.lua index f045fcbb..1f9a0cd0 100644 --- a/src/core/game3/battle/adapter.lua +++ b/src/core/game3/battle/adapter.lua @@ -2,6 +2,7 @@ local State = require("src.core.game3.battle.state") local Rules = require("src.core.game3.battle.rules") +local ModRuntime = require("src.mods.Runtime") local Adapter = {} @@ -45,9 +46,16 @@ Adapter.ABILITY_BY_ID = ABILITY_BY_ID local function side_of(battler) if type(battler) == "table" then return battler.side end if type(battler) == "string" then return battler end + if type(battler) == "number" then return State.sideOf(battler) end return nil end +local function id_of(battler) + if battler == nil then return nil end + return State.idOf(battler) +end +Adapter.idOf = id_of + function Adapter.new(battleState, sayFn) local a = { _st = battleState, @@ -77,6 +85,8 @@ function Adapter.new(battleState, sayFn) name = name, attacker = side_of(attacker), target = side_of(target), + attackerId = id_of(attacker), + targetId = id_of(target), arg = arg, }) end @@ -175,6 +185,14 @@ function Adapter.new(battleState, sayFn) local Engine = package.loaded["src.core.game3.battle.engine"] if Engine and Engine.cancelMultiTurnMoves then Engine.cancelMultiTurnMoves(battler) end end + -- pokefirered/src/battle_script_commands.c:2110 + if ModRuntime.wants("battle.status_inflicted") then + ModRuntime.emit("battle.status_inflicted", { + battle = self._st, target = battler, status = status, source = source, + side = battler.side, battlerId = id_of(battler), + sourceId = type(source) == "table" and id_of(source) or nil, + }) + end return true end function a:clearStatus(battler) @@ -210,6 +228,7 @@ function Adapter.new(battleState, sayFn) return self:pushEvent({ kind = kind or "hp", side = battler.side, + battler = id_of(battler), from = from, to = to, maxHp = self:maxHp(battler), @@ -246,6 +265,14 @@ function Adapter.new(battleState, sayFn) if battler then battler.fainted = true if battler.mon then battler.mon.hp = 0 end + -- pokefirered/src/battle_script_commands.c:2831 + if ModRuntime.wants("battle.fainted") and battler._modFainted ~= (battler.mon or true) then + battler._modFainted = battler.mon or true + ModRuntime.emit("battle.fainted", { + battle = self._st, battler = battler, side = self:ownSide(battler), + sideName = battler.side, battlerId = id_of(battler), + }) + end end end function a:displayName(battler) return State.displayName(battler) end @@ -264,17 +291,33 @@ function Adapter.new(battleState, sayFn) if ok and type(v) == "number" then return v end return math.random(lo, hi) end - function a:activeBattlers() + function a:battlers() return State.present(self._st) end + function a:activeBattlers() return State.present(self._st) end + function a:aliveBattlers() local out = {} - if self._st.player then out[#out + 1] = self._st.player end - if self._st.enemy then out[#out + 1] = self._st.enemy end + for _, b in ipairs(State.present(self._st)) do + if not State.isFainted(b) then out[#out + 1] = b end + end return out end + function a:battler(id) return State.battler(self._st, id) end + function a:isDouble() return self._st.double == true end function a:foeOf(battler) if not battler then return nil end - if battler.side == "player" then return self._st.enemy end - return self._st.player + local st = self._st + if not st.double then + if battler.side == "player" then return st.enemy end + return st.player + end + local opp = State.OPPOSITE(State.idOf(battler)) + if State.isPresent(st, opp) then return State.battler(st, opp) end + local alt = State.PARTNER(opp) + if State.isPresent(st, alt) then return State.battler(st, alt) end + return State.battler(st, opp) end + function a:foesOf(battler) return State.foes(self._st, battler) end + function a:alliesOf(battler) return State.allies(self._st, battler) end + function a:partnerOf(battler) return State.partner(self._st, battler) end function a:ownSide(battler) if not battler then return nil end if battler.side == "player" then return self._st.playerSide end diff --git a/src/core/game3/battle/ai.lua b/src/core/game3/battle/ai.lua index 04ebee42..08b728a2 100644 --- a/src/core/game3/battle/ai.lua +++ b/src/core/game3/battle/ai.lua @@ -2,6 +2,8 @@ local AiVm = require("src.core.game3.battle.ai_vm") +local choose_move_core + local Ai = {} Ai._pack = nil @@ -146,36 +148,6 @@ local function roll(rng, lo, hi) return math.random(lo, hi) end -local function first_usable(mon) - if not mon or not mon.moves then return nil end - for i = 1, 4 do - local mv = mon.moves[i] - local p = mon.pp and mon.pp[i] - if mv and mv ~= 0 and mv ~= "" and (p == nil or tonumber(p) > 0) then - return { kind = "move", move = mv, slot = i, user = "enemy" } - end - end - return { kind = "move", move = "STRUGGLE", slot = nil, user = "enemy" } -end - -local function random_usable(mon, rng) - local usable = {} - if mon and mon.moves then - for i = 1, 4 do - local mv = mon.moves[i] - local p = mon.pp and mon.pp[i] - if mv and mv ~= 0 and mv ~= "" and (p == nil or tonumber(p) > 0) then - usable[#usable + 1] = { move = mv, slot = i } - end - end - end - if #usable == 0 then - return { kind = "move", move = "STRUGGLE", slot = nil, user = "enemy" } - end - local pick = usable[roll(rng, 1, #usable)] - return { kind = "move", move = pick.move, slot = pick.slot, user = "enemy" } -end - local function bit_and_flags(a, b) a = math.floor(a or 0) b = math.floor(b or 0) @@ -187,59 +159,12 @@ local function bit_and_flags(a, b) return r end ---- Choose enemy move via pret AI scripts. --- @return { kind="move", move=..., slot=i, user="enemy", scores=... } -function Ai.chooseMove(st, opts) - opts = opts or {} - local mon = st and st.enemy and st.enemy.mon - local rng = rng_fn(st, opts) - - local aiFlags = opts.aiFlags - if aiFlags == nil then - if st and st.aiFlags ~= nil then - aiFlags = st.aiFlags - elseif st and st.wild then - aiFlags = 0 - else - aiFlags = st and st.aiFlags or 0 - end - end - aiFlags = tonumber(aiFlags) or 0 - - if aiFlags == 0 then - -- Wild / no scripts: match prior fallback (first usable). Random also acceptable. - local act = first_usable(mon) - act.scores = { 0, 0, 0, 0 } - return act - end - - local pack = opts.pack or Ai.loadPack() - if not pack or not pack.table or not pack.scripts then - return first_usable(mon) - end - - local scores = { 100, 100, 100, 100 } - local simulatedRNG = {} - for i = 1, 4 do - local mv = mon and mon.moves and mon.moves[i] - local pp = mon and mon.pp and mon.pp[i] - if not mv or mv == 0 or mv == "" or (pp ~= nil and tonumber(pp) <= 0) then - scores[i] = 0 - end - simulatedRNG[i] = 100 - (roll(rng, 0, 15)) - end - -- pokefirered/src/battle_ai_script_commands.c:302 - local Engine = package.loaded["src.core.game3.battle.engine"] - local Battle = package.loaded["src.core.game3.battle"] - if Engine and Engine.moveLimitations and st and st.enemy then - local okL, bad = pcall(Engine.moveLimitations, st.enemy, opts.adapter or (Battle and Battle._adapter)) - if okL and type(bad) == "table" then - for i = 1, 4 do - if bad[i] then scores[i] = 0 end - end - end - end +local function bit_or_flags(a, b) + return a + b - bit_and_flags(a, b) +end +local function run_scripts(pack, aiFlags, st, user, target, userSide, targetSide, scores, simulatedRNG, rng) + local aiAction = 0 local logicId = 0 local flags = aiFlags while flags ~= 0 do @@ -247,25 +172,21 @@ function Ai.chooseMove(st, opts) local scriptName = pack.table[logicId + 1] -- Lua 1-based; pret index 0 if scriptName and pack.scripts[scriptName] then for movesetIndex = 1, 4 do - if scores[movesetIndex] ~= 0 or true then - -- Still run; empty/no-PP moves get score 0 inside VM - local vm = AiVm.new({ - pack = pack, - st = st, - user = st.enemy, - target = st.player, - userSide = st.enemySide, - targetSide = st.playerSide, - scores = scores, - simulatedRNG = simulatedRNG, - movesetIndex = movesetIndex, - rng = rng, - }) - AiVm.run(vm, scriptName) - if vm.aiAction and vm.aiAction ~= 0 then - -- flee/watch: ignore for MVP move choice - end - end + local vm = AiVm.new({ + pack = pack, + st = st, + user = user, + target = target, + userSide = userSide, + targetSide = targetSide, + scores = scores, + simulatedRNG = simulatedRNG, + movesetIndex = movesetIndex, + rng = rng, + }) + AiVm.run(vm, scriptName) + aiAction = bit_or_flags(aiAction, vm.aiAction or 0) + if bit_and_flags(aiAction, 0x8) ~= 0 then break end end end end @@ -273,33 +194,257 @@ function Ai.chooseMove(st, opts) logicId = logicId + 1 if logicId > 31 then break end end + return aiAction +end - -- Pick max score; ties → Random() % numBest +local MOVE_TARGET_BOTH = 0x08 +local MOVE_TARGET_SELF = 0x12 + +local function move_num(mv) + local n = tonumber(mv) + if n then return n end + if mv == nil or mv == "" then return 0 end + local Moves = require("src.core.game3.battle.moves") + return Moves.numForName and Moves.numForName(mv) or 0 +end + +local function move_target_byte(mv) + if move_num(mv) == 0 then return 0 end + local Moves = require("src.core.game3.battle.moves") + local m = Moves.get(mv) + return tonumber(m and m.target) or 0 +end + +local function random_u16(rng) + return roll(rng, 0, 65535) +end + +local ad_cache = setmetatable({}, { __mode = "k" }) +local function adapter_for(st, opts) + if opts and opts.adapter then return opts.adapter end + local Battle = package.loaded["src.core.game3.battle"] + local ad = Battle and Battle._adapter + if ad and ad._st == st then return ad end + ad = ad_cache[st] + if not ad then + ad = require("src.core.game3.battle.adapter").new(st, function() end) + ad_cache[st] = ad + end + return ad +end + +-- src/battle_ai_script_commands.c:370 +local function pret_pick(scores, rng) local best = scores[1] or 0 local considered = { 1 } for i = 2, 4 do local s = scores[i] or 0 - if s > best then + if best < s then best = s considered = { i } - elseif s == best then - considered[#considered + 1] = i + end + if best == s then considered[#considered + 1] = i end + end + return considered[roll(rng, 1, #considered)], best +end + +local function double_first_usable(mon, id, bad) + for i = 1, 4 do + local mv = mon and mon.moves and mon.moves[i] + if move_num(mv) ~= 0 and not (bad and bad[i]) then + return { kind = "move", move = mv, slot = i, user = "enemy", battler = id } end end - local pickSlot = considered[roll(rng, 1, #considered)] - local mv = mon and mon.moves and mon.moves[pickSlot] - if not mv or mv == 0 or mv == "" or best <= 0 then - local fallback = first_usable(mon) - fallback.scores = scores - return fallback + return { kind = "move", move = "STRUGGLE", slot = nil, user = "enemy", battler = id } +end + +local AI_SCRIPT_ROAMING = 0x20000000 +local AI_SCRIPT_SAFARI = 0x40000000 + +local function uses_ai(st) + return not st.wild or st.roamer or st.safari or st.firstBattle +end + +-- src/battle_controller_opponent.c:1350 +function choose_move_core(st, id, opts) + local State = require("src.core.game3.battle.state") + local Engine = require("src.core.game3.battle.engine") + local b = State.battler(st, id) + local mon = b and b.mon + if not mon then return nil end + local rng = rng_fn(st, opts) + local ad = adapter_for(st, opts) + local double = st.double and true or false + local function shape(act) + if not double then + act.target = nil + if opts.battler == nil then act.battler = nil end + end + return act end - return { + + local bad = {} + local okL, lim = pcall(Engine.moveLimitations, b, ad) + if okL and type(lim) == "table" then bad = lim end + -- src/battle_main.c:3147 + if bad[1] and bad[2] and bad[3] and bad[4] then + return shape({ kind = "move", move = "STRUGGLE", slot = nil, user = "enemy", battler = id }) + end + + if not uses_ai(st) then + -- src/battle_controller_opponent.c:1389 + local slot, mv + for _ = 1, 1000 do + slot = roll(rng, 0, 3) + 1 + mv = mon.moves and mon.moves[slot] + if move_num(mv) ~= 0 then break end + end + if move_num(mv) == 0 then return shape(double_first_usable(mon, id, bad)) end + local tid + if bit_and_flags(move_target_byte(mv), MOVE_TARGET_SELF) ~= 0 then + tid = id + elseif double then + tid = bit_and_flags(random_u16(rng), 2) + else + tid = State.OPPOSITE(id) + end + return shape({ kind = "move", move = mv, slot = slot, user = "enemy", battler = id, target = tid, + scores = { 0, 0, 0, 0 } }) + end + + -- src/battle_ai_script_commands.c:331 + local aiFlags + if st.safari then + aiFlags = AI_SCRIPT_SAFARI + elseif st.roamer then + aiFlags = AI_SCRIPT_ROAMING + else + aiFlags = tonumber(opts.aiFlags or st.aiFlags) or 0 + end + local pack = opts.pack + if aiFlags ~= 0 and not pack then pack = Ai.loadPack() end + + -- src/battle_ai_script_commands.c:301 + local scores = { 100, 100, 100, 100 } + local simulatedRNG = {} + for i = 1, 4 do + if bad[i] then scores[i] = 0 end + simulatedRNG[i] = 100 - roll(rng, 0, 15) + end + -- src/battle_ai_script_commands.c:317 + local tid + if double then + tid = bit_and_flags(random_u16(rng), 2) + if State.isAbsent(st, tid) then tid = 2 - tid end + else + tid = State.OPPOSITE(id) + end + local target = State.battler(st, tid) + local userSide = (b.side == "player") and st.playerSide or st.enemySide + local targetSide = (b.side == "player") and st.enemySide or st.playerSide + + local aiAction = 0 + if aiFlags ~= 0 and pack and pack.table and pack.scripts and target then + aiAction = run_scripts(pack, aiFlags, st, b, target, userSide, targetSide, scores, simulatedRNG, rng) + end + -- src/battle_ai_script_commands.c:383 + if bit_and_flags(aiAction, 0x2) ~= 0 then + return shape({ kind = "run", user = "enemy", battler = id, scores = scores }) + end + if bit_and_flags(aiAction, 0x4) ~= 0 then + return shape({ kind = "watch", user = "enemy", battler = id, scores = scores }) + end + + local slot = pret_pick(scores, rng) + local mv = mon.moves and mon.moves[slot] + if move_num(mv) == 0 then + local fb = double_first_usable(mon, id, bad) + fb.scores = scores + return shape(fb) + end + -- src/battle_controller_opponent.c:1370 + local tt = move_target_byte(mv) + if bit_and_flags(tt, MOVE_TARGET_SELF) ~= 0 then tid = id end + if bit_and_flags(tt, MOVE_TARGET_BOTH) ~= 0 then + tid = double and 0 or State.OPPOSITE(id) + if double and State.isAbsent(st, tid) then tid = 2 end + end + return shape({ kind = "move", move = mv, - slot = pickSlot, + slot = slot, user = "enemy", + battler = id, + target = tid, scores = scores, - } + }) +end + +--- Choose enemy move via pret AI scripts. +-- @return { kind="move", move=..., slot=i, user="enemy", scores=... } +function Ai.chooseMove(st, opts) + opts = opts or {} + if not st then return nil end + local id = opts.battler or 1 + return choose_move_core(st, id, opts) +end + +-- src/battle_main.c:3125 +function Ai.chooseAction(st, id, opts) + opts = opts or {} + id = id or 1 + if not st then return nil end + local State = require("src.core.game3.battle.state") + local b = State.battler(st, id) + if not b or not b.mon then return nil end + if b.expLockedMove or b.expMustRecharge then + local act = { kind = "move", move = b.expLockedMove, slot = b.expLockedSlot, user = "enemy", battler = id } + if not act.move then act = double_first_usable(b.mon, id) end + return act + end + local rng = rng_fn(st, opts) + local ad = adapter_for(st, opts) + -- src/battle_ai_switch_items.c:358 + if not st.wild and not st.pokedude then + local AiSwitch = require("src.core.game3.battle.ai_switch") + local pick = AiSwitch.trySwitch(st, ad, id, rng) + if pick then + st.monToSwitchInto = st.monToSwitchInto or {} + st.monToSwitchInto[id] = pick + return { kind = "switch", slot = pick, user = "enemy", battler = id } + end + local AiItems = require("src.core.game3.battle.ai_items") + local use = AiItems.shouldUseItem(st, id) + if use then + return { + kind = "item", + item = use.item, + aiItemType = use.aiItemType, + aiItemFlags = use.aiItemFlags, + user = "enemy", + battler = id, + target = id, + } + end + end + return choose_move_core(st, id, { + battler = id, + rng = rng, + adapter = ad, + pack = opts.pack, + aiFlags = opts.aiFlags, + }) +end + +-- src/battle_controllers.c:59 +function Ai.battleStart(st, opts) + opts = opts or {} + if not st then return end + st._aiHistory = nil + require("src.core.game3.battle.ai_items").history(st) + local rng = rng_fn(st, opts) + for _ = 1, 4 do roll(rng, 0, 15) end + if st.double then random_u16(rng) end end return Ai diff --git a/src/core/game3/battle/ai_cmds.lua b/src/core/game3/battle/ai_cmds.lua index 46885275..be9c3763 100644 --- a/src/core/game3/battle/ai_cmds.lua +++ b/src/core/game3/battle/ai_cmds.lua @@ -272,12 +272,25 @@ local function pret_stat_level(battler, statId) return s + 6 -- pret 0..12 with 6 neutral end +-- src/pokemon.c:2552 +local function spread_hit(vm, moveId) + local st = vm.st + if not (st and st.double) then return nil end + local m = Moves.get(moveId) + if bit_and_local(tonumber(m and m.target) or 0, 0x08) == 0 then return nil end + local State = require("src.core.game3.battle.state") + local id = State.idOf(vm.target) + if id == nil then return nil end + return State.countPresentOnSide(st, State.sideOf(id)) == 2 or nil +end + local function ai_damage(vm, moveId, movesetIndex) local dmg = Damage.calc(vm.user, vm.target, moveId, { forceCrit = false, forceRoll = 100, weather = vm.st and vm.st.weather, rng = function() return 100 end, + spread = spread_hit(vm, moveId), }) local sim = vm.simulatedRNG and vm.simulatedRNG[movesetIndex] or 100 dmg = math.floor(dmg * sim / 100) @@ -605,12 +618,21 @@ function CMD.count_alive_pokemon(vm, op) local b = AiCmds.battler(vm, op.battler) local party = (b == vm.user) and (vm.st and vm.st.foeParty) or (vm.st and vm.st.playerParty) local onField = b and b.partyIndex or 1 + local onField2 = onField local n = 0 - if party then - for i, mon in ipairs(party) do - if i ~= onField and mon and (tonumber(mon.hp) or 0) > 0 and (mon.species or mon.id) then - n = n + 1 - end + -- src/battle_ai_script_commands.c:1099 + if vm.st and vm.st.double then + local State = require("src.core.game3.battle.state") + local id = State.idOf(b) + local partner = id and State.battler(vm.st, State.PARTNER(id)) + onField2 = partner and partner.partyIndex or onField + end + for i = 1, 6 do + local mon = party and party[i] + local sp = mon and (mon.species or mon.id) + if i ~= onField and i ~= onField2 and (tonumber(mon and mon.hp) or 0) ~= 0 + and sp and sp ~= 0 and not mon.isEgg then + n = n + 1 end end vm.funcResult = n @@ -854,8 +876,9 @@ function CMD.get_stockpile_count(vm, op) next_ip(vm) end +-- src/battle_ai_script_commands.c:1806 function CMD.is_double_battle(vm, op) - vm.funcResult = 0 + vm.funcResult = (vm.st and vm.st.double) and 1 or 0 next_ip(vm) end diff --git a/src/core/game3/battle/ai_items.lua b/src/core/game3/battle/ai_items.lua new file mode 100644 index 00000000..69fdc4e7 --- /dev/null +++ b/src/core/game3/battle/ai_items.lua @@ -0,0 +1,231 @@ +-- FireRed battle AI trainer item use (port of battle_ai_switch_items.c ShouldUseItem). + +local State = require("src.core.game3.battle.state") + +local AiItems = {} + +AiItems.TYPE = { + FULL_RESTORE = 1, HEAL_HP = 2, CURE_CONDITION = 3, X_STAT = 4, GUARD_SPECS = 5, NOT_RECOGNIZABLE = 6, +} +local T = AiItems.TYPE + +AiItems.ITEM_FULL_RESTORE = 19 +AiItems.HEAL_HP_FULL = 0xFF +AiItems.HEAL_HP_HALF = 0xFE +AiItems.HEAL_HP_LVL_UP = 0xFD + +-- src/data/pokemon/item_effects.h:338 +local EFFECTS = { + [13] = { 0, 0, 0, 0, 0x04, 20 }, + [14] = { 0, 0, 0, 0x10, 0 }, + [15] = { 0, 0, 0, 0x08, 0 }, + [16] = { 0, 0, 0, 0x04, 0 }, + [17] = { 0, 0, 0, 0x20, 0 }, + [18] = { 0, 0, 0, 0x02, 0 }, + [19] = { 0, 0, 0, 0x3F, 0x04, 0xFF }, + [20] = { 0, 0, 0, 0, 0x04, 0xFF }, + [21] = { 0, 0, 0, 0, 0x04, 200 }, + [22] = { 0, 0, 0, 0, 0x04, 50 }, + [23] = { 0, 0, 0, 0x3F, 0 }, + [24] = { 0, 0, 0, 0, 0x44, 0xFE }, + [25] = { 0, 0, 0, 0, 0x44, 0xFF }, + [26] = { 0, 0, 0, 0, 0x04, 50 }, + [27] = { 0, 0, 0, 0, 0x04, 60 }, + [28] = { 0, 0, 0, 0, 0x04, 80 }, + [29] = { 0, 0, 0, 0, 0x04, 100 }, + [30] = { 0, 0, 0, 0, 0x04, 50 }, + [31] = { 0, 0, 0, 0, 0x04, 200 }, + [32] = { 0, 0, 0, 0x3F, 0 }, + [33] = { 0, 0, 0, 0, 0x44, 0xFF }, + [34] = { 0, 0, 0, 0, 0x18, 10 }, + [35] = { 0, 0, 0, 0, 0x18, 0x7F }, + [36] = { 0, 0, 0, 0, 0x08, 10 }, + [37] = { 0, 0, 0, 0, 0x08, 0x7F }, + [38] = { 0, 0, 0, 0x3F, 0 }, + [39] = { 0, 0, 0, 0x20, 0 }, + [40] = { 0, 0, 0, 0x01, 0 }, + [41] = { 0x80, 0, 0, 0, 0 }, + [44] = { 0, 0, 0, 0, 0x04, 20 }, + [45] = { 0x40, 0, 0, 0, 0x44, 0xFF }, + [63] = { 0, 0, 0, 0, 0x01 }, + [64] = { 0, 0, 0, 0, 0x02 }, + [65] = { 0, 0, 0, 0, 0 }, + [66] = { 0, 0, 0, 0, 0 }, + [67] = { 0, 0, 0, 0, 0 }, + [68] = { 0, 0, 0, 0x40, 0x44, 0xFD }, + [69] = { 0, 0, 0, 0, 0x20 }, + [70] = { 0, 0, 0, 0, 0 }, + [71] = { 0, 0, 0, 0, 0 }, + [73] = { 0, 0, 0, 0x80, 0 }, + [74] = { 0x20, 0, 0, 0, 0 }, + [75] = { 0x01, 0, 0, 0, 0 }, + [76] = { 0, 0x10, 0, 0, 0 }, + [77] = { 0, 0x01, 0, 0, 0 }, + [78] = { 0, 0, 0x10, 0, 0 }, + [79] = { 0, 0, 0x01, 0, 0 }, + [93] = { 0, 0, 0, 0, 0x80 }, + [94] = { 0, 0, 0, 0, 0x80 }, + [95] = { 0, 0, 0, 0, 0x80 }, + [96] = { 0, 0, 0, 0, 0x80 }, + [97] = { 0, 0, 0, 0, 0x80 }, + [98] = { 0, 0, 0, 0, 0x80 }, + [133] = { 0, 0, 0, 0x02, 0 }, + [134] = { 0, 0, 0, 0x20, 0 }, + [135] = { 0, 0, 0, 0x10, 0 }, + [136] = { 0, 0, 0, 0x08, 0 }, + [137] = { 0, 0, 0, 0x04, 0 }, + [138] = { 0, 0, 0, 0, 0x18, 10 }, + [139] = { 0, 0, 0, 0, 0x04, 10 }, + [140] = { 0, 0, 0, 0x01, 0 }, + [141] = { 0, 0, 0, 0x3F, 0 }, + [142] = { 0, 0, 0, 0, 0x04, 30 }, +} + +local function band(a, b) + a, b = math.floor(tonumber(a) or 0), math.floor(tonumber(b) or 0) + local r, bit = 0, 1 + while a > 0 and b > 0 do + if a % 2 == 1 and b % 2 == 1 then r = r + bit end + a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 + end + return r +end +AiItems.band = band + +local function item_num(item) + local n = tonumber(item) + if n then return n end + if item == nil or item == "" then return 0 end + local ok, ItemsData = pcall(require, "src.core.game3.items_data") + if ok and ItemsData.toNumericId then return ItemsData.toNumericId(item) or 0 end + return 0 +end +AiItems.itemNum = item_num + +function AiItems.effect(item) + local e = EFFECTS[item_num(item)] + if not e then return nil end + return { e[1], e[2], e[3], e[4], e[5], hp = e[6] } +end + +-- src/battle_ai_switch_items.c:546 +function AiItems.itemType(item, e) + if item_num(item) == AiItems.ITEM_FULL_RESTORE then return T.FULL_RESTORE end + if band(e[5], 0x04) ~= 0 then return T.HEAL_HP end + if band(e[4], 0x3F) ~= 0 then return T.CURE_CONDITION end + if band(e[1], 0x3F) ~= 0 or e[2] ~= 0 or e[3] ~= 0 then return T.X_STAT end + if band(e[4], 0x80) ~= 0 then return T.GUARD_SPECS end + return T.NOT_RECOGNIZABLE +end + +-- src/pokemon.c:4843 +function AiItems.hpParam(e) + if band(e[5], 0x04) == 0 then return 0 end + return e.hp or 0 +end + +-- src/battle_ai_script_commands.c:262 +function AiItems.history(st) + if st._aiHistory then return st._aiHistory end + local h = { items = {}, itemsNo = 0 } + if st and not st.wild and not st.safari then + for i = 1, 4 do + local it = item_num(st.trainerItems and st.trainerItems[i]) + if it ~= 0 then + h.itemsNo = h.itemsNo + 1 + h.items[h.itemsNo] = it + end + end + end + for i = h.itemsNo + 1, 4 do h.items[i] = 0 end + st._aiHistory = h + return h +end + +local function status_name(b) + local s = b and (b.status or (b.mon and b.mon.status)) + if not s or s == 0 then return nil end + s = tostring(s):upper() + if s == "SLEEP" then return "SLP" end + if s == "POISON" then return "PSN" end + if s == "TOXIC" then return "TOX" end + if s == "BURN" then return "BRN" end + if s == "FREEZE" then return "FRZ" end + if s == "PARALYSIS" then return "PAR" end + return s +end +AiItems.statusName = status_name + +local function mon_valid(mon) + if not mon or mon.isEgg then return false end + local sp = mon.species or mon.speciesId or mon.id + return (tonumber(mon.hp) or 0) ~= 0 and sp ~= nil and sp ~= 0 +end + +-- src/battle_ai_switch_items.c:562 +function AiItems.shouldUseItem(st, id) + local b = State.battler(st, id) + if not b or not b.mon then return nil end + local h = AiItems.history(st) + local validMons = 0 + for i = 1, 6 do + if mon_valid(st.foeParty and st.foeParty[i]) then validMons = validMons + 1 end + end + local hp = tonumber(b.mon.hp) or 0 + local maxHp = tonumber(b.mon.maxHp) or 0 + for i = 0, 3 do + if not (i > 0 and validMons > (h.itemsNo - i) + 1) then + local item = h.items[i + 1] or 0 + local e = item ~= 0 and AiItems.effect(item) or nil + if e then + local kind = AiItems.itemType(item, e) + st.aiItemType = st.aiItemType or {} + st.aiItemType[id] = kind + local flags = 0 + local shouldUse = false + if kind == T.FULL_RESTORE then + if hp < math.floor(maxHp / 4) and hp ~= 0 then shouldUse = true end + elseif kind == T.HEAL_HP then + local param = AiItems.hpParam(e) + if param ~= 0 and hp ~= 0 then + if hp < math.floor(maxHp / 4) or maxHp - hp > param then shouldUse = true end + end + elseif kind == T.CURE_CONDITION then + local s = status_name(b) + if band(e[4], 0x20) ~= 0 and s == "SLP" then flags = flags + 0x20; shouldUse = true end + if band(e[4], 0x10) ~= 0 and (s == "PSN" or s == "TOX") then flags = flags + 0x10; shouldUse = true end + if band(e[4], 0x08) ~= 0 and s == "BRN" then flags = flags + 0x08; shouldUse = true end + if band(e[4], 0x04) ~= 0 and s == "FRZ" then flags = flags + 0x04; shouldUse = true end + if band(e[4], 0x02) ~= 0 and s == "PAR" then flags = flags + 0x02; shouldUse = true end + if band(e[4], 0x01) ~= 0 and (tonumber(b.confusionTurns) or 0) > 0 then flags = flags + 0x01; shouldUse = true end + elseif kind == T.X_STAT then + if (tonumber(b.isFirstTurn) or 0) ~= 0 then + if band(e[1], 0x0F) ~= 0 then flags = flags + 0x01 end + if band(e[2], 0xF0) ~= 0 then flags = flags + 0x02 end + if band(e[2], 0x0F) ~= 0 then flags = flags + 0x04 end + if band(e[3], 0x0F) ~= 0 then flags = flags + 0x08 end + if band(e[3], 0xF0) ~= 0 then flags = flags + 0x20 end + if band(e[1], 0x30) ~= 0 then flags = flags + 0x80 end + shouldUse = true + end + elseif kind == T.GUARD_SPECS then + local side = (b.side == "player") and st.playerSide or st.enemySide + if (tonumber(b.isFirstTurn) or 0) ~= 0 and (tonumber(side and side.expMistTurns) or 0) == 0 then + shouldUse = true + end + else + return nil + end + if shouldUse then + st.aiItemFlags = st.aiItemFlags or {} + st.aiItemFlags[id] = flags + h.items[i + 1] = 0 + return { item = item, aiItemType = kind, aiItemFlags = flags } + end + end + end + end + return nil +end + +return AiItems diff --git a/src/core/game3/battle/ai_switch.lua b/src/core/game3/battle/ai_switch.lua new file mode 100644 index 00000000..55e43262 --- /dev/null +++ b/src/core/game3/battle/ai_switch.lua @@ -0,0 +1,377 @@ +-- FireRed battle AI switch decision (port of battle_ai_switch_items.c). + +local State = require("src.core.game3.battle.state") +local Moves = require("src.core.game3.battle.moves") +local Types = require("src.core.game3.battle.types") +local Pokemon = require("src.core.game3.pokemon") + +local AiSwitch = {} + +local AB = { + VOLT_ABSORB = 10, WATER_ABSORB = 11, FLASH_FIRE = 18, SHADOW_TAG = 23, + WONDER_GUARD = 25, LEVITATE = 26, NATURAL_CURE = 30, MAGNET_PULL = 42, ARENA_TRAP = 71, +} +local MOVE_STRUGGLE = 165 +local LAST_HIT_NONE = 0xFF + +local ABILITY_BY_NAME +local function ability_id(v) + if v == nil then return 0 end + local n = tonumber(v) + if n then return n end + if type(v) ~= "string" or v == "" then return 0 end + if not ABILITY_BY_NAME then + ABILITY_BY_NAME = {} + local ok, Adapter = pcall(require, "src.core.game3.battle.adapter") + if ok and Adapter and Adapter.ABILITY_BY_ID then + for id, name in pairs(Adapter.ABILITY_BY_ID) do ABILITY_BY_NAME[name] = id end + end + end + return ABILITY_BY_NAME[(v:upper():gsub("%s+", "_"))] or 0 +end + +local function battler_ability(b) + if not b then return 0 end + if b.expTracedAbility then return ability_id(b.expTracedAbility) end + local a = b.ability + if a == nil and b.mon then a = b.mon.ability or b.mon.abilityId end + return ability_id(a) +end + +local function move_num(mv) + local n = tonumber(mv) + if n then return n end + if mv == nil or mv == "" then return 0 end + return Moves.numForName and Moves.numForName(mv) or 0 +end + +local function move_def(mv) + local n = move_num(mv) + if n == 0 then return nil end + return Moves.get(mv) +end + +local function move_power(mv) + local m = move_def(mv) + return m and tonumber(m.power) or 0 +end + +local function move_type(mv) + local m = move_def(mv) + return m and tonumber(m.type) or 0 +end + +local function species_of(mon) + if not mon then return 0 end + local s = mon.species or mon.speciesId or mon.id + if type(s) == "number" then return s end + if type(s) == "string" then + return (Pokemon.speciesFromName and Pokemon.speciesFromName(s)) or tonumber(s) or 0 + end + return 0 +end + +local function mon_usable(mon) + return mon ~= nil and (tonumber(mon.hp) or 0) ~= 0 and species_of(mon) ~= 0 and not mon.isEgg +end + +-- src/battle_ai_switch_items.c:112 +local function party_ability(mon) + local explicit = mon and (mon.ability or mon.abilityId) + if explicit ~= nil then return ability_id(explicit) end + local pair = Pokemon.abilities(species_of(mon)) + local num = mon and mon.abilityNum + if num == nil then + num = ((pair[2] or 0) ~= 0) and ((tonumber(mon and mon.personality) or 0) % 2) or 0 + end + if num ~= 0 then return pair[2] or 0 end + return pair[1] or 0 +end + +-- src/battle_script_commands.c:1513 +local function ai_type_calc(mv, species, ability) + local f = { super = false, notVery = false, noEffect = false } + if move_num(mv) == MOVE_STRUGGLE or move_num(mv) == 0 then return f end + local mt = move_type(mv) + local power = move_power(mv) + if ability == AB.LEVITATE and mt == Types.ID.GROUND then + f.noEffect = true + else + local ty = Pokemon.types(species) + local t1, t2 = ty[1] or 0, ty[2] or 0 + local function modulate(mult) + if mult == 0 then + f.noEffect, f.notVery, f.super = true, false, false + elseif mult == 5 then + if power ~= 0 and not f.noEffect then + if f.super then f.super = false else f.notVery = true end + end + elseif mult == 20 then + if power ~= 0 and not f.noEffect then + if f.notVery then f.notVery = false else f.super = true end + end + end + end + local t = Types.TABLE + for i = 1, #t, 3 do + local a, d, m = t[i], t[i + 1], t[i + 2] + if a ~= -1 and a == mt then + if d == t1 then modulate(m) end + if d == t2 and t1 ~= t2 then modulate(m) end + end + end + end + if ability == AB.WONDER_GUARD and (not f.super or (f.super and f.notVery)) and power ~= 0 then + f.noEffect = true + end + return f +end +AiSwitch.aiTypeCalc = ai_type_calc + +local function roll(rng, lo, hi) + local ok, v = pcall(rng, lo, hi) + if ok and type(v) == "number" then return v end + return math.random(lo, hi) +end + +local function status_bits(b) + local s = b and (b.status or (b.mon and b.mon.status)) + if type(s) == "number" then return s end + s = s and tostring(s):upper() or "" + if s == "SLP" or s == "SLEEP" then return 0x7 end + return 0 +end + +local function moves_of(b) + return (b and b.mon and b.mon.moves) or {} +end + +local function last_landed(b) + return move_num(b and b.expLastLandedMove) +end + +local function last_hit_by(b) + local id = b and b.expLastHitById + if id == nil then return LAST_HIT_NONE end + return id +end + +local function battlers_in(st, id) + local in2 = id + if st.double then + local pid = State.PARTNER(id) + if not State.isAbsent(st, pid) and State.battler(st, pid) then in2 = pid end + end + return id, in2 +end + +local function excluded(st, i, in1, in2) + local b1, b2 = State.battler(st, in1), State.battler(st, in2) + local pend = st.monToSwitchInto or {} + return (b1 and b1.partyIndex == i) or (b2 and b2.partyIndex == i) + or pend[in1] == i or pend[in2] == i +end + +local function num_battlers(st) + return st.double and 4 or 2 +end + +-- src/battle_ai_switch_items.c:17 +local function if_perish_song(st, b) + if b.perishSong and tonumber(b.expPerishTurns) == 0 then return true, nil end + return false +end + +-- src/battle_ai_switch_items.c:32 +local function if_wonder_guard(st, b, id, rng) + if st.double then return false end + local opp = State.battler(st, 0) + if not opp or battler_ability(opp) ~= AB.WONDER_GUARD then return false end + for i = 1, 4 do + local mv = moves_of(b)[i] + if move_num(mv) ~= 0 and ai_type_calc(mv, opp.species, battler_ability(opp)).super then return false end + end + for i = 1, 6 do + local mon = st.foeParty and st.foeParty[i] + if mon_usable(mon) and i ~= b.partyIndex then + for j = 1, 4 do + local mv = mon.moves and mon.moves[j] + if move_num(mv) ~= 0 and ai_type_calc(mv, opp.species, battler_ability(opp)).super + and roll(rng, 0, 2) < 2 then + return true, i + end + end + end + end + return false +end + +-- src/battle_ai_switch_items.c:176 +local function has_super_effective(st, b, noRng, rng) + local function scan(oid) + local opp = State.battler(st, oid) + if State.isAbsent(st, oid) or not opp then return false end + for i = 1, 4 do + local mv = moves_of(b)[i] + if move_num(mv) ~= 0 and ai_type_calc(mv, opp.species, battler_ability(opp)).super then + if noRng or roll(rng, 0, 9) ~= 0 then return true end + end + end + return false + end + if scan(0) then return true end + if not st.double then return false end + return scan(2) +end + +-- src/battle_ai_switch_items.c:82 +local function absorbs_opponents_move(st, b, id, rng) + if (has_super_effective(st, b, true, rng) and roll(rng, 0, 2) ~= 0) or last_landed(b) == 0 then + return false + end + local last = b.expLastLandedMove + if last_landed(b) == 0xFFFF or move_power(last) == 0 then return false end + local mt = move_type(last) + local absorb + if mt == Types.ID.FIRE then absorb = AB.FLASH_FIRE + elseif mt == Types.ID.WATER then absorb = AB.WATER_ABSORB + elseif mt == Types.ID.ELECTRIC then absorb = AB.VOLT_ABSORB + else return false end + if battler_ability(b) == absorb then return false end + local in1, in2 = battlers_in(st, id) + for i = 1, 6 do + local mon = st.foeParty and st.foeParty[i] + if mon_usable(mon) and not excluded(st, i, in1, in2) then + if absorb == party_ability(mon) and roll(rng, 0, 1) == 1 then return true, i end + end + end + return false +end + +-- src/battle_ai_switch_items.c:236 +local function find_mon_with_flags(st, b, id, flag, modulo, rng) + local last = last_landed(b) + if last == 0 then return false end + if last == 0xFFFF or last_hit_by(b) == LAST_HIT_NONE or move_power(b.expLastLandedMove) == 0 then + return false + end + local in1, in2 = battlers_in(st, id) + for i = 1, 6 do + local mon = st.foeParty and st.foeParty[i] + if mon_usable(mon) and not excluded(st, i, in1, in2) then + local f = ai_type_calc(b.expLastLandedMove, species_of(mon), party_ability(mon)) + if f[flag] then + local opp = State.battler(st, last_hit_by(b)) + for j = 1, 4 do + local mv = mon.moves and mon.moves[j] + if move_num(mv) ~= 0 and opp + and ai_type_calc(mv, opp.species, battler_ability(opp)).super + and roll(rng, 0, modulo - 1) == 0 then + return true, i + end + end + end + end + end + return false +end + +-- src/battle_ai_switch_items.c:146 +local function if_natural_cure(st, b, id, rng) + local hp = tonumber(b.mon and b.mon.hp) or 0 + local maxHp = tonumber(b.mon and b.mon.maxHp) or 1 + if status_bits(b) % 8 == 0 or battler_ability(b) ~= AB.NATURAL_CURE or hp < math.floor(maxHp / 2) then + return false + end + local last = last_landed(b) + if (last == 0 or last == 0xFFFF) and roll(rng, 0, 1) == 1 then + return true, nil + elseif move_power(last) == 0 and roll(rng, 0, 1) == 1 then + return true, nil + end + local ok, pick = find_mon_with_flags(st, b, id, "noEffect", 1, rng) + if ok then return true, pick end + ok, pick = find_mon_with_flags(st, b, id, "notVery", 1, rng) + if ok then return true, pick end + if roll(rng, 0, 1) == 1 then return true, nil end + return false +end + +-- src/battle_ai_switch_items.c:223 +local function stats_raised(b) + local total = 0 + for _, v in pairs(b.stages or {}) do + v = tonumber(v) or 0 + if v > 0 then total = total + v end + end + return total > 3 +end + +local function is_steel(b) + return b.type1 == Types.ID.STEEL or b.type2 == Types.ID.STEEL +end + +-- src/battle_ai_switch_items.c:302 +function AiSwitch.shouldSwitch(st, id, rng) + local b = State.battler(st, id) + if not b or not b.mon then return false end + if b.expTrapped or b.escapePrevention or (tonumber(b.expTrapTurns) or 0) > 0 or b.expIngrain then + return false + end + for oid = 0, num_battlers(st) - 1 do + local o = State.battler(st, oid) + if o and State.sideOf(oid) ~= State.sideOf(id) then + local a = battler_ability(o) + if a == AB.SHADOW_TAG or a == AB.ARENA_TRAP then return false end + end + end + for oid = 0, num_battlers(st) - 1 do + local o = State.battler(st, oid) + if o and battler_ability(o) == AB.MAGNET_PULL and is_steel(b) then return false end + end + local in1, in2 = battlers_in(st, id) + local available = 0 + for i = 1, 6 do + local mon = st.foeParty and st.foeParty[i] + if mon_usable(mon) and not excluded(st, i, in1, in2) then available = available + 1 end + end + if available == 0 then return false end + + local ok, pick = if_perish_song(st, b) + if ok then return true, pick end + ok, pick = if_wonder_guard(st, b, id, rng) + if ok then return true, pick end + ok, pick = absorbs_opponents_move(st, b, id, rng) + if ok then return true, pick end + ok, pick = if_natural_cure(st, b, id, rng) + if ok then return true, pick end + if has_super_effective(st, b, false, rng) or stats_raised(b) then return false end + ok, pick = find_mon_with_flags(st, b, id, "noEffect", 2, rng) + if ok then return true, pick end + ok, pick = find_mon_with_flags(st, b, id, "notVery", 3, rng) + if ok then return true, pick end + return false +end + +-- src/battle_ai_switch_items.c:358 +function AiSwitch.trySwitch(st, ad, id, rng) + local ok, pick = AiSwitch.shouldSwitch(st, id, rng) + if not ok then return nil end + if pick == nil then + local Engine = require("src.core.game3.battle.engine") + if Engine.mostSuitableMon then pick = Engine.mostSuitableMon(st, ad, id) end + if pick == nil then + local in1, in2 = 1, st.double and 3 or 1 + for i = 1, 6 do + local mon = st.foeParty and st.foeParty[i] + if mon and (tonumber(mon.hp) or 0) ~= 0 and not excluded(st, i, in1, in2) then + pick = i + break + end + end + end + end + return pick +end + +return AiSwitch diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index ae14cf27..2062da6d 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -6,10 +6,12 @@ local AnimVm = require("src.core.game3.battle.anim_vm") local AnimSprites = require("src.core.game3.battle.anim_sprites") local BallOpen = require("src.core.game3.battle.ball_open") local AnimPal = require("src.core.game3.battle.anim_pal") +local AnimCoords = require("src.core.game3.battle.anim_coords") local Anim = {} Anim.Z = AnimVm.Z +Anim.Coords = AnimCoords -- pret sBattlerCoords (singles) — CreateSprite CENTER Anim.ENEMY_MON = { x = 176, y = 40 } @@ -24,10 +26,7 @@ Anim._expTweening = false Anim._introTweening = 0 Anim._seqBusy = false Anim._statusQueue = {} -Anim._present = { - player = nil, - enemy = nil, -} +Anim._present = AnimCoords.idTable() Anim._stage = nil Anim._screenEffect = { type = "none", @@ -112,9 +111,11 @@ function Anim.endScreenEffect() end end -local function default_present(side) +local function default_present(id) + local side = AnimCoords.sideOf(id) local z = (side == "player") and Anim.Z.PLAYER or Anim.Z.ENEMY return { + id = id, side = side, ox = 0, oy = 0, @@ -144,10 +145,13 @@ local function default_stage(headless) enemy = { visible = false, ox = 0, oy = 0, picId = nil }, }, ball = { visible = false, x = 0, y = 0, frame = 0, side = nil }, - healthbox = { - player = { visible = headless and true or false, ox = 0 }, - enemy = { visible = headless and true or false, ox = 0 }, - }, + balls = {}, + healthbox = AnimCoords.idTable({ + [0] = { visible = headless and true or false, ox = 0 }, + [1] = { visible = headless and true or false, ox = 0 }, + [2] = { visible = headless and true or false, ox = 0 }, + [3] = { visible = headless and true or false, ox = 0 }, + }), partyBar = { player = { visible = false, ox = 0, balls = {} }, enemy = { visible = false, ox = 0, balls = {} }, @@ -165,20 +169,69 @@ function Anim.x(v) return tonumber(v) or 0 end -function Anim.present(side) - if side == "attacker_side" and Anim._vm then - side = Anim._vm:attackerSide() +function Anim.idOf(key) + if key == "attacker_side" then + local vm = Anim._vm + return vm and vm.attackerId and vm:attackerId() or 0 end - if side ~= "player" and side ~= "enemy" then return nil end - if not Anim._present[side] then - Anim._present[side] = default_present(side) - end - return Anim._present[side] + return AnimCoords.idOf(key) end -function Anim.battlerCenter(side) - local base = (side == "player") and Anim.PLAYER_MON or Anim.ENEMY_MON - local p = Anim.present(side) +function Anim.sideOf(id) + return AnimCoords.sideOf(id) +end + +function Anim.isDouble(st) + return AnimCoords.isDouble(st) +end + +function Anim.setDouble(v) + AnimCoords.setDouble(v) +end + +function Anim.present(key) + local id = Anim.idOf(key) + if id == nil then return nil end + local p = rawget(Anim._present, id) + if not p then + if id >= 2 and not AnimCoords.isDouble() then return nil end + p = default_present(id) + if id >= 2 and not Anim._headless then p.visible = false end + rawset(Anim._present, id, p) + end + return p +end + +-- pokefirered/src/battle_anim_mons.c:105 +function Anim.coords(st, key) + return AnimCoords.coords(st, key) +end + +-- pokefirered/src/battle_anim_mons.c:1908 +function Anim.subpriority(key) + return AnimCoords.subpriority(key) +end + +-- pokefirered/src/battle_anim_mons.c:1934 +function Anim.bgPriorityRank(key) + return AnimCoords.bgPriorityRank(key) +end + +function Anim.monDrawOrder(st) + return AnimCoords.monDrawOrder(st) +end + +function Anim.particleBand(k, st) + return AnimCoords.particleBand(k, st) +end + +function Anim.battlerIds(st) + return AnimCoords.ids(st) +end + +function Anim.battlerCenter(key) + local base = Anim.coords(nil, key) or Anim.ENEMY_MON + local p = Anim.present(key) local cx = base.x + (p and p.ox or 0) local cy = base.y + (p and p.oy or 0) return cx, cy @@ -192,13 +245,15 @@ function Anim.reset(opts) Anim._introTweening = 0 Anim._seqBusy = false Anim._statusQueue = {} - Anim._present.player = default_present("player") - Anim._present.enemy = default_present("enemy") - Anim._stage = default_stage(Anim._headless) - if not Anim._headless then - Anim._present.player.visible = false - Anim._present.enemy.visible = false + AnimCoords.setDouble(opts.double) + AnimCoords.bind(nil) + for id = 0, 3 do rawset(Anim._present, id, nil) end + for id = 0, AnimCoords.isDouble() and 3 or 1 do + local p = default_present(id) + if not Anim._headless then p.visible = false end + rawset(Anim._present, id, p) end + Anim._stage = default_stage(Anim._headless) if not Anim._vm then Anim._vm = AnimVm.new() end @@ -220,11 +275,81 @@ function Anim.reset(opts) end -- pokefirered/src/pokeball.c:769 -function Anim.ballOpen(side, x, y) +function Anim.ballOpen(key, x, y) if Anim._headless then return nil end - local Battle = package.loaded["src.core.game3.battle"] - local b = Battle and Battle._st and Battle._st[side] - return BallOpen.start(side, x, y, b and b.mon and b.mon.pokeball) + local id = Anim.idOf(key) or 1 + local b = AnimCoords.battler(nil, id) + return BallOpen.start(id, x, y, b and b.mon and b.mon.pokeball) +end + +local function play_se(name, pan) + pcall(function() + local SE = require("src.core.game3.se_ids") + require("src.core.game3.audio").playSe(SE[name], { pan = pan }) + end) +end + +-- pokefirered/src/pokeball.c:349 +function Anim.sendOutMon(key, opts) + opts = opts or {} + local id = Anim.idOf(key) or 1 + local side = AnimCoords.sideOf(id) + local p = Anim.present(id) + local done = opts.onComplete + if Anim._headless or not p then + if p then + p.visible = true + p.ox, p.oy, p.scale = 0, 0, 1 + end + if done then done() end + return nil + end + local base = Anim.coords(nil, id) or Anim.ENEMY_MON + local stage = Anim.stage() + stage.balls = stage.balls or {} + local ball = { visible = true, frame = 0, rot = 0, side = side, battler = id, x = 0, y = 0 } + stage.balls[id] = ball + local pan = (side == "player") and -64 or 63 + local function reveal() + ball.frame = 1 + ball.rot = 0 + play_se("SE_BALL_OPEN", pan) + Anim.ballOpen(id, ball.x, ball.y) + p.visible = true + p.ox = 0 + p.oy = 16 + p.scale = 0.16 + p.darken = 0 + Anim.tweenStage(12, function(u) + p.oy = 16 * (1 - u) + p.scale = 0.16 + 0.84 * u + ball.frame = (u < 0.5) and 1 or 2 + end, function() + p.oy = 0 + p.scale = 1 + ball.visible = false + if stage.balls[id] == ball then stage.balls[id] = nil end + if done then done() end + end) + end + if side == "player" then + -- pokefirered/src/pokeball.c:912 + local sx, sy = 48, 70 + local tx, ty = base.x, base.y + 24 + ball.x, ball.y = sx, sy + play_se("SE_BALL_THROW", pan) + Anim.tweenStage(25, function(u, t) + local f = t and t.frames or (u * 25) + ball.x = sx + (tx - sx) * u + ball.y = sy + (ty - sy) * u + (-30 * 4 * u * (1 - u)) + ball.rot = f * ((25 / 256) * math.pi * 2) + end, reveal) + else + -- pokefirered/src/pokeball.c:406 + ball.x, ball.y = base.x, base.y + 24 + Anim.tweenStage(16, function() end, reveal) + end + return ball end function Anim.setSeqBusy(v) @@ -261,14 +386,15 @@ end --- Pret faint presentation: SE_FAINT + sink/slide off, then hide mon + healthbox. -- Opponent: SpriteCB_AnimFaintOpponent — +8px every 2 frames, ~8 steps. -- Player: SpriteCB_FaintSlideAnim — +5px/frame until below screen. -function Anim.faintMon(side, opts) +function Anim.faintMon(key, opts) opts = opts or {} - side = side or "enemy" - local p = Anim.present(side) + local id = Anim.idOf(key or "enemy") or 1 + local side = AnimCoords.sideOf(id) + local p = Anim.present(id) local stage = Anim.stage() - local hb = stage and stage.healthbox and stage.healthbox[side] + local hb = stage and stage.healthbox and stage.healthbox[id] local AnimSprites = require("src.core.game3.battle.anim_sprites") - AnimSprites.clearHost(side) + AnimSprites.clearHost(id) local function hide_all() if p then @@ -311,7 +437,8 @@ function Anim.faintMon(side, opts) local ok, Display = pcall(require, "src.core.game3.display") if ok and Display and Display.H then screenH = Display.H end end - local need = math.max(1, math.ceil((screenH - (Anim.PLAYER_MON.y or 80) + 32) / 5)) + local base = Anim.coords(nil, id) or Anim.PLAYER_MON + local need = math.max(1, math.ceil((screenH - (base.y or 80) + 32) / 5)) local frames = math.max(16, need + 2) Anim.tweenStage(frames, function(_, t) p.oy = fromOy + 5 * (t.frames or 1) @@ -348,9 +475,9 @@ end function Anim.syncDisplayFromState(st) if not st then return end local Experience = require("src.core.game3.battle.experience") - for _, side in ipairs({ "player", "enemy" }) do - local b = st[side] - local p = Anim.present(side) + for _, id in ipairs(AnimCoords.ids(st)) do + local b = AnimCoords.battler(st, id) + local p = Anim.present(id) if b and b.mon and p then p.displayHp = tonumber(b.mon.hp) or 0 p.displayMaxHp = tonumber(b.mon.maxHp) or 1 @@ -671,6 +798,7 @@ local function launch_table(kind, name, opts) local o = {} for k, v in pairs(opts) do o[k] = v end if o.targetSide == nil then o.targetSide = o.attackerSide end + if o.targetId == nil then o.targetId = o.attackerId end if kind == "status" and o.statusAnim == nil then o.statusAnim = true end if Anim._vm.launchScript then return Anim._vm:launchScript(script, o) end return Anim._vm:launch(script, o) @@ -726,16 +854,19 @@ function Anim.shownBattler(side, battler) end -- pokefirered/src/battle_anim_mons.c:286 -function Anim.substituteY(side) - if side == "player" then return Anim.PLAYER_MON.y + 17 end - return Anim.ENEMY_MON.y + 16 +function Anim.substituteY(key) + local id = Anim.idOf(key) or 1 + local base = Anim.coords(nil, id) or Anim.ENEMY_MON + if AnimCoords.sideOf(id) == "player" then return base.y + 17 end + return base.y + 16 end -- pokefirered/src/battle_gfx_sfx_util.c:762 -function Anim.substituteImage(side) +function Anim.substituteImage(key) local pack = load_pack() local tags = pack and pack.tags - local info = tags and tags[(side == "player") and "SUBSTITUTE_DOLL_BACK" or "SUBSTITUTE_DOLL_FRONT"] + local id = Anim.idOf(key) or 1 + local info = tags and tags[(AnimCoords.sideOf(id) == "player") and "SUBSTITUTE_DOLL_BACK" or "SUBSTITUTE_DOLL_FRONT"] return info and info.image or nil end diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 00000000..104f5131 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,230 @@ +local bit = require("bit") + +local AnimCoords = {} + +local function xy(x, y) return { x = x, y = y, x, y } end + +-- pokefirered/src/battle_anim_mons.c:31 +AnimCoords.SINGLES = { [0] = xy(72, 80), xy(176, 40), xy(48, 40), xy(112, 80) } +AnimCoords.DOUBLES = { [0] = xy(32, 80), xy(200, 40), xy(90, 88), xy(152, 32) } + +-- pokefirered/src/battle_anim_mons.c:1908 +AnimCoords.SUBPRIORITY = { [0] = 30, 40, 20, 50 } + +-- pokefirered/src/battle_anim_mons.c:1934 +AnimCoords.BG_PRIORITY_RANK = { [0] = 2, 1, 1, 2 } + +AnimCoords.DRAW_ORDER_SINGLES = { 1, 0 } +AnimCoords.DRAW_ORDER_DOUBLES = { 3, 1, 0, 2 } + +AnimCoords._double = nil +AnimCoords._bind = nil + +local function battle_state() + local Battle = package.loaded["src.core.game3.battle"] + return Battle and Battle._st +end +AnimCoords.battleState = battle_state + +function AnimCoords.setDouble(v) + if v == nil then AnimCoords._double = nil else AnimCoords._double = v and true or false end +end + +function AnimCoords.isDouble(st) + if AnimCoords._double ~= nil then return AnimCoords._double end + st = st or battle_state() + return type(st) == "table" and st.double == true +end + +function AnimCoords.sideOf(id) + id = tonumber(id) or 0 + return (id % 2 == 0) and "player" or "enemy" +end + +function AnimCoords.partner(id) + return bit.bxor(tonumber(id) or 0, 2) +end + +function AnimCoords.fixedId(key) + if type(key) == "number" then return key end + if key == "enemy" then return 1 end + if key == "player" then return 0 end + return nil +end + +function AnimCoords.idOf(key) + local t = type(key) + if t == "number" then + if key >= 0 and key <= 3 then return math.floor(key) end + return nil + end + if key == "player" then + local b = AnimCoords._bind + return (b and b.player) or 0 + end + if key == "enemy" then + local b = AnimCoords._bind + return (b and b.enemy) or 1 + end + if t == "table" then + if type(key.id) == "number" then return key.id end + if key.side then return AnimCoords.idOf(key.side) end + end + return nil +end + +function AnimCoords.bind(atkId, tgtId) + if atkId == nil then + AnimCoords._bind = nil + return + end + tgtId = tgtId or atkId + local b = {} + b[AnimCoords.sideOf(tgtId)] = tgtId + b[AnimCoords.sideOf(atkId)] = atkId + if b.player == nil then b.player = 0 end + if b.enemy == nil then b.enemy = 1 end + AnimCoords._bind = b +end + +function AnimCoords.withBattler(id, fn, ...) + local prev = AnimCoords._bind + local b = { player = (prev and prev.player) or 0, enemy = (prev and prev.enemy) or 1 } + b[AnimCoords.sideOf(id)] = id + AnimCoords._bind = b + local r = { pcall(fn, AnimCoords.sideOf(id), ...) } + AnimCoords._bind = prev + if not r[1] then error(r[2], 0) end + return unpack(r, 2) +end + +function AnimCoords.sideArg(fn, idx) + return function(...) + local args = { n = select("#", ...), ... } + local v = args[idx] + if type(v) ~= "number" or v < 0 or v > 3 then return fn(...) end + return AnimCoords.withBattler(v, function(side) + args[idx] = side + return fn(unpack(args, 1, args.n)) + end) + end +end + +function AnimCoords.coords(st, key) + local id = AnimCoords.idOf(key) + if id == nil then return nil end + local t = AnimCoords.isDouble(st) and AnimCoords.DOUBLES or AnimCoords.SINGLES + return t[id] +end + +function AnimCoords.subpriority(key) + local id = AnimCoords.idOf(key) + return AnimCoords.SUBPRIORITY[id or 1] +end + +function AnimCoords.bgPriorityRank(key) + local id = AnimCoords.idOf(key) + return AnimCoords.BG_PRIORITY_RANK[id or 1] +end + +function AnimCoords.battler(st, id) + st = st or battle_state() + if type(st) ~= "table" or id == nil then return nil end + local bs = rawget(st, "battlers") or st.battlers + local b = bs and bs[id] + if b == nil and id == 0 then b = st.player end + if b == nil and id == 1 then b = st.enemy end + return b +end + +function AnimCoords.ids(st) + if AnimCoords.isDouble(st) then return { 0, 1, 2, 3 } end + return { 0, 1 } +end + +-- pokefirered/src/battle_anim_mons.c:841 +function AnimCoords.spritePresent(st, id) + if id == nil then return false end + st = st or battle_state() + if id >= 2 and not AnimCoords.isDouble(st) then return false end + if type(st) ~= "table" then return id < 2 end + if st.absent and st.absent[id] then return false end + local b = AnimCoords.battler(st, id) + if not b then return id < 2 end + local mon = b.mon + if mon and (tonumber(mon.hp) or 0) <= 0 then return false end + return true +end + +function AnimCoords.monDrawOrder(st) + if AnimCoords.isDouble(st) then return AnimCoords.DRAW_ORDER_DOUBLES end + return AnimCoords.DRAW_ORDER_SINGLES +end + +function AnimCoords.monBehindZ(key, st) + local id = AnimCoords.idOf(key) or 1 + local order = AnimCoords.monDrawOrder(st) + for k, v in ipairs(order) do + if v == id then return (k - 1) * 100 + 95 end + end + return 95 +end + +function AnimCoords.particleBand(k, st) + local n = #AnimCoords.monDrawOrder(st) + if k <= 0 then return 0, 99 end + if k >= n then return k * 100 + 1, 999 end + return k * 100 + 1, k * 100 + 99 +end + +-- pokefirered/src/battle_anim.c:630 +function AnimCoords.layerZ(sub, monbg, bgPrio, st) + sub = tonumber(sub) or 0 + local order = AnimCoords.monDrawOrder(st) + local layer = 0 + for k = #order, 1, -1 do + local id = order[k] + local front + if monbg and rawget(monbg, id) then + front = ((bgPrio and bgPrio[AnimCoords.BG_PRIORITY_RANK[id]]) or 2) >= 2 + else + front = sub < AnimCoords.SUBPRIORITY[id] + end + if front then + layer = k + break + end + end + local rank = math.max(0, math.min(98, 98 - sub)) + if layer == 0 then return rank end + return layer * 100 + 1 + rank +end + +function AnimCoords.zFor(pri, sub, vm, st) + pri = tonumber(pri) or 2 + sub = tonumber(sub) or 0 + if pri <= 1 then return 900 + (255 - sub) % 99 end + if pri >= 3 then return math.max(1, math.min(98, 98 - sub)) end + return AnimCoords.layerZ(sub, vm and vm._monbg, vm and vm._bgPrio, st) +end + +local SIDE_KEY_MT = { + __index = function(t, k) + if k == "player" or k == "enemy" then return rawget(t, AnimCoords.idOf(k)) end + return nil + end, + __newindex = function(t, k, v) + if k == "player" or k == "enemy" then k = AnimCoords.idOf(k) end + rawset(t, k, v) + end, +} + +function AnimCoords.idTable(init) + local t = setmetatable({}, SIDE_KEY_MT) + if init then + for k, v in pairs(init) do t[k] = v end + end + return t +end + +return AnimCoords diff --git a/src/core/game3/battle/anim_ctx.lua b/src/core/game3/battle/anim_ctx.lua index b9f0f928..68e643ff 100644 --- a/src/core/game3/battle/anim_ctx.lua +++ b/src/core/game3/battle/anim_ctx.lua @@ -1,4 +1,5 @@ local Anim = require("src.core.game3.battle.anim") +local AnimCoords = require("src.core.game3.battle.anim_coords") local AnimCtx = {} @@ -7,8 +8,8 @@ local function battle_state() return Battle and Battle._st end -local function battler_id(side) - return (side == "enemy") and 1 or 0 +local function battler_id(key) + return AnimCoords.fixedId(key) or ((key == "enemy") and 1 or 0) end local function terrain_id(st) @@ -19,10 +20,11 @@ local function terrain_id(st) end function AnimCtx.behindSubstitute(lowered) - local out = {} - for _, side in ipairs({ "player", "enemy" }) do - local p = Anim._present[side] - out[side] = ((p and p.substitute) or (lowered and lowered[side])) and true or false + local out = AnimCoords.idTable() + for id = 0, 3 do + local p = rawget(Anim._present, id) + local low = lowered and (lowered[id] or (id < 2 and lowered[AnimCoords.sideOf(id)])) + out[id] = ((p and p.substitute) or low) and true or false end return out end @@ -33,14 +35,16 @@ function AnimCtx.build(attacker, target, opts) local st = battle_state() attacker = attacker or "player" target = target or attacker - local a = st and st[attacker] + local atkId = battler_id(attacker) + local a = AnimCoords.battler(st, atkId) local mon = a and a.mon or {} - local ap = Anim._present[attacker] + local ap = rawget(Anim._present, atkId) local ctx = { behindSubstitute = AnimCtx.behindSubstitute(opts.lowered), - battlerAttacker = battler_id(attacker), + battlerAttacker = atkId, battlerTarget = battler_id(target), effectBattler = battler_id(opts.effectBattler or target), + isDouble = AnimCoords.isDouble(st), animArg = tonumber(opts.animArg) or 0, movePower = 0, moveDmg = tonumber(opts.moveDmg) or 0, diff --git a/src/core/game3/battle/anim_port/g1_pret.lua b/src/core/game3/battle/anim_port/g1_pret.lua index d14801eb..e1ba52b3 100644 --- a/src/core/game3/battle/anim_port/g1_pret.lua +++ b/src/core/game3/battle/anim_port/g1_pret.lua @@ -1,6 +1,7 @@ local bit = require("bit") local Trig = require("src.core.game3.trig") local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimCoords = require("src.core.game3.battle.anim_coords") local band, bor, bxor, rshift, arshift, lshift = bit.band, bit.bor, bit.bxor, bit.rshift, bit.arshift, bit.lshift local floor = math.floor @@ -27,7 +28,7 @@ P.SOUND_PAN_ATTACKER = -64 P.SOUND_PAN_TARGET = 63 -- pokefirered/src/battle_anim_mons.c:31 -P.COORDS = { player = { x = 72, y = 80 }, enemy = { x = 176, y = 40 } } +P.COORDS = setmetatable({}, { __index = function(_, k) return AnimCoords.coords(nil, k) end }) function P.s16(v) v = band(floor(tonumber(v) or 0), 0xFFFF) @@ -110,11 +111,13 @@ end function P.atk(vm) vm = vm or P.vm() + if vm and vm.allyPair and vm:allyPair() then return vm:attackerId() end return vm and vm.attackerSide and vm:attackerSide() or "player" end function P.tgt(vm) vm = vm or P.vm() + if vm and vm.allyPair and vm:allyPair() then return vm:targetId() end return vm and vm.targetSide and vm:targetSide() or "enemy" end @@ -129,9 +132,35 @@ end -- pokefirered/src/battle_anim_mons.c:821 function P.isOpponent(side) + if type(side) == "number" then return AnimCoords.sideOf(side) ~= "player" end return side ~= "player" end +function P.atkId(vm) + vm = vm or P.vm() + return vm and vm.attackerId and vm:attackerId() or 0 +end + +function P.tgtId(vm) + vm = vm or P.vm() + return vm and vm.targetId and vm:targetId() or 1 +end + +-- pokefirered/src/battle_anim.c:617 +function P.spriteVisible(id) + if not AnimCoords.spritePresent(nil, id) then return false end + local p = P.anim().present(id) + return p ~= nil +end + +function P.visibleIds(except1, except2) + local out = {} + for _, id in ipairs(AnimCoords.ids()) do + if id ~= except1 and id ~= except2 and (id < 2 or P.spriteVisible(id)) then out[#out + 1] = id end + end + return out +end + function P.species(vm, side) vm = vm or P.vm() if not vm then return nil end @@ -219,9 +248,18 @@ function P.attr(vm, side, attr) return 0 end +function P.bySide(fn) + return AnimCoords.sideArg(fn, 2) +end +P.coord = P.bySide(P.coord) +P.coord2 = P.coord +P.yDelta = P.bySide(P.yDelta) +P.yWithElevation = P.bySide(P.yWithElevation) +P.attr = P.bySide(P.attr) + -- pokefirered/src/battle_anim_mons.c:1908 function P.subpriorityOf(side) - return side == "player" and 30 or 40 + return AnimCoords.subpriority(side) end -- pokefirered/src/battle_anim_mons.c:1924 @@ -266,6 +304,7 @@ end function P.zFor(pri, sub) pri = tonumber(pri) or 2 sub = tonumber(sub) or 0 + if AnimCoords.isDouble() then return AnimCoords.zFor(pri, sub, P.anim()._vm) end local key = (3 - math.max(0, math.min(3, pri))) * 100 + (99 - math.max(0, math.min(99, sub))) local off = floor(key * 97 / 400) local fp = in_front_of(pri, sub, "player") @@ -282,7 +321,7 @@ end -- pokefirered/src/battle_anim.c:349 local function op_subpriority(vm, op) local raw = tonumber(op.subpriority) or 0 - local side = (op.animBattler == "target") and P.tgt(vm) or P.atk(vm) + local side = (op.animBattler == "target") and P.tgtId(vm) or P.atkId(vm) local sub if raw >= 64 then sub = P.subpriorityOf(side) + (raw - 64) else sub = P.subpriorityOf(side) - raw end if sub < 3 then sub = 3 end @@ -328,8 +367,8 @@ end P.Pal = {} local Pal = P.Pal -Pal.faded = {} -Pal.unfaded = {} +Pal.faded = AnimCoords.idTable() +Pal.unfaded = AnimCoords.idTable() Pal.remap = {} Pal.backup = {} Pal.tagsTouched = {} @@ -403,8 +442,8 @@ function Pal.fromBackup(slot, key) end function Pal.reset() - Pal.faded = {} - Pal.unfaded = {} + Pal.faded = AnimCoords.idTable() + Pal.unfaded = AnimCoords.idTable() Pal.remap = {} Pal.backup = {} Pal.dirty = true @@ -422,9 +461,9 @@ function Pal.flush() if not Pal.dirty then return end Pal.dirty = false local Anim = P.anim() - for _, side in ipairs({ "player", "enemy" }) do - local p = Anim.present(side) - local st = Pal.faded[side] or Pal.unfaded[side] + for id = 0, 3 do + local p = (id < 2) and Anim.present(id) or rawget(Anim._present, id) + local st = rawget(Pal.faded, id) or rawget(Pal.unfaded, id) if p and (st or p._g1Blend) then local k, r, g, b = to_lerp(st) p.blendCoeff = k @@ -479,8 +518,16 @@ end function P.palettesMask(vm, bg, atk, tgt, atkPartner, tgtPartner, anim1, anim2) local keys = {} if bg then keys[#keys + 1] = "bg" end - if atk then keys[#keys + 1] = P.atk(vm) end - if tgt then keys[#keys + 1] = P.tgt(vm) end + if atk then keys[#keys + 1] = P.atkId(vm) end + if tgt then keys[#keys + 1] = P.tgtId(vm) end + if atkPartner then + local id = AnimCoords.partner(P.atkId(vm)) + if P.spriteVisible(id) then keys[#keys + 1] = id end + end + if tgtPartner then + local id = AnimCoords.partner(P.tgtId(vm)) + if P.spriteVisible(id) then keys[#keys + 1] = id end + end if anim1 then keys[#keys + 1] = "anim1" end if anim2 then keys[#keys + 1] = "anim2" end return keys diff --git a/src/core/game3/battle/anim_port/g1_task_base.lua b/src/core/game3/battle/anim_port/g1_task_base.lua index 53d003e0..92fddcab 100644 --- a/src/core/game3/battle/anim_port/g1_task_base.lua +++ b/src/core/game3/battle/anim_port/g1_task_base.lua @@ -40,6 +40,7 @@ function K.battlerSide(vm, animBattler) animBattler = tonumber(animBattler) or 0 if animBattler == 0 then return P.atk(vm) end if animBattler == 1 then return P.tgt(vm) end + if (animBattler == 2 or animBattler == 3) and vm and vm.battlerId then return vm:battlerId(animBattler) end return nil end @@ -66,8 +67,8 @@ end function K.sideFromCtx(v) if v == "player" or v == "enemy" then return v end local n = tonumber(v) - if n == nil then return nil end - return (n % 2 == 0) and "player" or "enemy" + if n == nil or n < 0 or n > 3 then return nil end + return n end function K.battleState() diff --git a/src/core/game3/battle/anim_port/g1_tasks.lua b/src/core/game3/battle/anim_port/g1_tasks.lua index 55dfa277..72cd7dae 100644 --- a/src/core/game3/battle/anim_port/g1_tasks.lua +++ b/src/core/game3/battle/anim_port/g1_tasks.lua @@ -217,8 +217,10 @@ end T.BlendBattleAnimPal = K.wrap(function(t, vm) local sel = t._A[0] local keys = P.unpackSelected(vm, sel) - if band(sel, 0x80) ~= 0 then keys[#keys + 1] = "player" end - if band(sel, 0x200) ~= 0 then keys[#keys + 1] = "enemy" end + if band(sel, 0x80) ~= 0 then keys[#keys + 1] = 0 end + if band(sel, 0x100) ~= 0 and P.spriteVisible(2) then keys[#keys + 1] = 2 end + if band(sel, 0x200) ~= 0 then keys[#keys + 1] = 1 end + if band(sel, 0x400) ~= 0 and P.spriteVisible(3) then keys[#keys + 1] = 3 end start_blend_anim_sprite_color(t, keys) end) @@ -229,18 +231,20 @@ T.BlendBattleAnimPalExclude = K.wrap(function(t, vm) local ex1, ex2 if cmd == 2 or cmd == 0 then if cmd == 2 then keys = {} end - ex1 = P.atk(vm) + ex1 = P.atkId(vm) elseif cmd == 3 or cmd == 1 then if cmd == 3 then keys = {} end - ex1 = P.tgt(vm) + ex1 = P.tgtId(vm) elseif cmd == 4 then - ex1, ex2 = P.atk(vm), P.tgt(vm) - elseif cmd == 6 or cmd == 7 then + ex1, ex2 = P.atkId(vm), P.tgtId(vm) + elseif cmd == 6 then keys = {} + ex1 = bxor(P.atkId(vm), 2) + elseif cmd == 7 then + keys = {} + ex1 = bxor(P.tgtId(vm), 2) end - for _, side in ipairs({ "player", "enemy" }) do - if side ~= ex1 and side ~= ex2 then keys[#keys + 1] = side end - end + for _, id in ipairs(P.visibleIds(ex1, ex2)) do keys[#keys + 1] = id end start_blend_anim_sprite_color(t, keys) end) @@ -413,6 +417,7 @@ end) -- pokefirered/src/battle_anim_effects_1.c:5289 T.MusicNotesRainbowBlend = K.wrap(function(t, vm) local AnimPal = require("src.core.game3.battle.anim_pal") + require("src.core.game3.battle.anim_port.g1_callbacks_b") for j = 0, 3 do local row = P.PARTICLES_COLOR_BLEND[j] local f = nil @@ -432,6 +437,7 @@ end) -- pokefirered/src/battle_anim_effects_1.c:5317 T.MusicNotesClearRainbowBlend = K.wrap(function(t, vm) local AnimPal = require("src.core.game3.battle.anim_pal") + require("src.core.game3.battle.anim_port.g1_callbacks_b") for j = 1, 3 do AnimPal.free(P.PARTICLES_COLOR_BLEND[j][1]) end diff --git a/src/core/game3/battle/anim_port/g1_tasks_b.lua b/src/core/game3/battle/anim_port/g1_tasks_b.lua index 153a8bce..961b9c19 100644 --- a/src/core/game3/battle/anim_port/g1_tasks_b.lua +++ b/src/core/game3/battle/anim_port/g1_tasks_b.lua @@ -81,7 +81,8 @@ T.ShakeMon2 = K.wrap(function(t, vm) if A[0] < 4 then side = K.battlerSide(vm, A[0]) elseif A[0] ~= 8 then - if A[0] == 4 then side = "player" elseif A[0] == 6 then side = "enemy" end + if A[0] == 4 then side = 0 elseif A[0] == 5 then side = 2 elseif A[0] == 6 then side = 1 else side = 3 end + if side >= 2 and not P.spriteVisible(side) then side = nil end else side = P.atk(vm) end @@ -972,8 +973,7 @@ local function flash_step(t) d[1] = 0 d[2] = d[2] - 1 P.Pal.blend("bg", d[2], 0x7FFF) - P.Pal.blend("player", d[2], 0) - P.Pal.blend("enemy", d[2], 0) + for _, id in ipairs(P.visibleIds()) do P.Pal.blend(id, d[2], 0) end P.Pal.flush() if d[2] == 0 then d[0] = d[0] + 1 end end @@ -983,8 +983,7 @@ local function flash_step(t) end T.Flash = K.wrap(function(t, vm) - P.Pal.setFaded("player", { m = 0, r = 0, g = 0, b = 0 }) - P.Pal.setFaded("enemy", { m = 0, r = 0, g = 0, b = 0 }) + for _, id in ipairs(P.visibleIds()) do P.Pal.setFaded(id, { m = 0, r = 0, g = 0, b = 0 }) end P.Pal.setFaded("bg", { m = 0, r = 1, g = 1, b = 1 }) P.Pal.flush() t.data[0] = 0 @@ -1026,7 +1025,10 @@ end) T.BlendNonAttackerPalettes = K.wrap(function(t, vm) local A = t._A for j = 5, 1, -1 do A[j] = A[j - 1] end - local keys = { P.other(P.atk(vm)) } + local keys = {} + for id = 0, 3 do + if id ~= P.atkId(vm) and (id < 2 or P.spriteVisible(id)) then keys[#keys + 1] = id end + end t._keys = keys local d = t.data d[2] = A[1] @@ -1052,8 +1054,10 @@ end) -- pokefirered/src/battle_anim_utility_funcs.c:719 T.SetAllNonAttackersInvisiblity = K.wrap(function(t, vm) - local p = P.present(P.other(P.atk(vm))) - if p then p.visible = (t._A[0] == 0) end + for _, id in ipairs(P.visibleIds(P.atkId(vm))) do + local p = P.present(id) + if p then p.visible = (t._A[0] == 0) end + end K.destroy(t) end) diff --git a/src/core/game3/battle/anim_port/g2_pret.lua b/src/core/game3/battle/anim_port/g2_pret.lua index 68b5324c..83895fd3 100644 --- a/src/core/game3/battle/anim_port/g2_pret.lua +++ b/src/core/game3/battle/anim_port/g2_pret.lua @@ -1,5 +1,6 @@ local AnimSprites = require("src.core.game3.battle.anim_sprites") local AnimPal = require("src.core.game3.battle.anim_pal") +local AnimCoords = require("src.core.game3.battle.anim_coords") local P = {} @@ -124,11 +125,13 @@ end function P.atk() local vm = P.vm + if vm and vm.allyPair and vm:allyPair() then return vm:attackerId() end return vm and vm:attackerSide() or "player" end function P.tgt() local vm = P.vm + if vm and vm.allyPair and vm:allyPair() then return vm:targetId() end return vm and vm:targetSide() or "enemy" end @@ -185,7 +188,7 @@ function P.species(b) return tonumber(sp) end -local BASE = { player = { 72, 80 }, enemy = { 176, 40 } } +local BASE = setmetatable({}, { __index = function(_, k) return AnimCoords.coords(nil, k) end }) P.BASE = BASE -- pokefirered/src/battle_anim_mons.c:233 @@ -257,10 +260,14 @@ function P.coordAttr(b, attr) return 0 end +P.coord = AnimCoords.sideArg(P.coord, 1) +P.defaultY = AnimCoords.sideArg(P.defaultY, 1) +P.yWithElevation = AnimCoords.sideArg(P.yWithElevation, 1) +P.coordAttr = AnimCoords.sideArg(P.coordAttr, 1) + -- pokefirered/src/battle_anim_mons.c:1908 function P.subpriorityOf(b) - if b == "player" then return 30 end - return 40 + return AnimCoords.subpriority(b) end -- pokefirered/src/battle_anim_mons.c:1924 @@ -268,15 +275,14 @@ function P.bgPriority(b) local vm = P.vm local bp = vm and vm._bgPrio if bp then - if b == "player" then return bp[2] or 2 end - return bp[1] or 2 + return bp[AnimCoords.bgPriorityRank(b)] or 2 end return 2 end +-- pokefirered/src/battle_anim_mons.c:1934 function P.bgPriorityRank(b) - if b == "player" then return 2 end - return 1 + return AnimCoords.bgPriorityRank(b) end function P.present(b) @@ -1517,7 +1523,11 @@ end function P.SetAverageBattlerPositions(b, respect) local xt, yt = 0, 1 if respect then xt, yt = 2, 3 end - return P.coord(b, xt), P.coord(b, yt) + local id = AnimCoords.idOf(b) or 1 + local x, y = P.coord(id, xt), P.coord(id, yt) + if not AnimCoords.isDouble() then return x, y end + local partner = AnimCoords.partner(id) + return P.div(x + P.coord(partner, xt), 2), P.div(y + P.coord(partner, yt), 2) end -- pokefirered/src/battle_anim_mons.c:1482 diff --git a/src/core/game3/battle/anim_port/g3_dark.lua b/src/core/game3/battle/anim_port/g3_dark.lua index b98e1108..58f653c5 100644 --- a/src/core/game3/battle/anim_port/g3_dark.lua +++ b/src/core/game3/battle/anim_port/g3_dark.lua @@ -407,7 +407,7 @@ local function setGreyscaleOrOriginal(p, restore) end end -local POSITION_SIDE = { [4] = "player", [6] = "enemy" } +local POSITION_ID = { [4] = 0, [5] = 2, [6] = 1, [7] = 3 } -- pokefirered/src/battle_anim_dark.c:869 T.SetGrayscaleOrOriginalPal = P.task(function(t, vm) @@ -416,8 +416,9 @@ T.SetGrayscaleOrOriginalPal = P.task(function(t, vm) if a >= 0 and a <= 3 then p = P.monSprite(vm, a) elseif a >= 4 and a <= 7 then - local side = POSITION_SIDE[a] - if side and not P.monHidden(vm, side) then p = P.monPresent(side) end + local id = POSITION_ID[a] + local ok = id ~= nil and (id < 2 or require("src.core.game3.battle.anim_coords").spritePresent(nil, id)) + if ok and not P.monHidden(vm, id) then p = P.monPresent(id) end end if p then setGreyscaleOrOriginal(p, t.ga[1] ~= 0) end P.DestroyAnimVisualTask(t) diff --git a/src/core/game3/battle/anim_port/g3_e3c.lua b/src/core/game3/battle/anim_port/g3_e3c.lua index c63e6b2c..4dfe4861 100644 --- a/src/core/game3/battle/anim_port/g3_e3c.lua +++ b/src/core/game3/battle/anim_port/g3_e3c.lua @@ -12,8 +12,7 @@ end function H.bgPriority(vm, side) local bp = vm and vm._bgPrio - if side == "player" then return (bp and bp[2]) or 2 end - return (bp and bp[1]) or 2 + return (bp and bp[require("src.core.game3.battle.anim_coords").bgPriorityRank(side)]) or 2 end function H.monX(vm, side) diff --git a/src/core/game3/battle/anim_port/g3_ghost.lua b/src/core/game3/battle/anim_port/g3_ghost.lua index 9f1a3b37..ab041680 100644 --- a/src/core/game3/battle/anim_port/g3_ghost.lua +++ b/src/core/game3/battle/anim_port/g3_ghost.lua @@ -411,7 +411,7 @@ T.GrudgeFlames = P.task(function(t, vm) d[11] = P.div(P.coordAttr(vm, atk, P.ATTR_WIDTH), 2) + 8 d[7] = 0 local bp = vm._bgPrio - if atk == "player" then d[5] = (bp and bp[2]) or 2 else d[5] = (bp and bp[1]) or 2 end + d[5] = (bp and bp[require("src.core.game3.battle.anim_coords").bgPriorityRank(atk)]) or 2 d[6] = P.subpriorityOf(atk) - 2 d[3] = 0 d[4] = 16 @@ -715,25 +715,26 @@ T.DestinyBondWhiteShadow = P.task(function(t, vm) d[5], d[6], d[7], d[8] = 0, 0, 0, 0 d[9] = 16 d[10] = t.ga[0] - local atk = P.atk(vm) + local atk = P.atkId(vm) local baseX = P.coord(vm, atk, P.COORD_X_2) local baseY = P.coordAttr(vm, atk, P.ATTR_BOTTOM) t._sprites = {} - local other = P.tgt(vm) - if other ~= atk and not P.monHidden(vm, other) then - local s = P.CreateSprite(vm, "gDestinyBondWhiteShadowSpriteTemplate", baseX, baseY, 55, destinyBondShadowStep) - if s then - local x = P.coord(vm, other, P.COORD_X_2) - local y = P.coordAttr(vm, other, P.ATTR_BOTTOM) - s.data[0] = P.s16(baseX * 16) - s.data[1] = P.s16(baseY * 16) - s.data[2] = P.s16(P.div((x - baseX) * 16, t.ga[1])) - s.data[3] = P.s16(P.div((y - baseY) * 16, t.ga[1])) - s.data[4] = t.ga[1] - s.data[5] = x - s.data[6] = y - t._sprites[#t._sprites + 1] = s - d[12] = d[12] + 1 + for other = 0, 3 do + if other ~= atk and other ~= P.bxor(atk, 2) and not P.monHidden(vm, other) then + local s = P.CreateSprite(vm, "gDestinyBondWhiteShadowSpriteTemplate", baseX, baseY, 55, destinyBondShadowStep) + if s then + local x = P.coord(vm, other, P.COORD_X_2) + local y = P.coordAttr(vm, other, P.ATTR_BOTTOM) + s.data[0] = P.s16(baseX * 16) + s.data[1] = P.s16(baseY * 16) + s.data[2] = P.s16(P.div((x - baseX) * 16, t.ga[1])) + s.data[3] = P.s16(P.div((y - baseY) * 16, t.ga[1])) + s.data[4] = t.ga[1] + s.data[5] = x + s.data[6] = y + t._sprites[#t._sprites + 1] = s + d[12] = d[12] + 1 + end end end t.fn = dbwsTaskStep diff --git a/src/core/game3/battle/anim_port/g3_pret.lua b/src/core/game3/battle/anim_port/g3_pret.lua index 40e78b92..d3af5a98 100644 --- a/src/core/game3/battle/anim_port/g3_pret.lua +++ b/src/core/game3/battle/anim_port/g3_pret.lua @@ -2,6 +2,7 @@ local P = {} local bit = require("bit") local AnimPal = require("src.core.game3.battle.anim_pal") +local AnimCoords = require("src.core.game3.battle.anim_coords") P.band, P.bor, P.bxor, P.lshift, P.rshift = bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift local SINE = { @@ -91,8 +92,14 @@ function P.vm() return Anim()._vm end function P.AnimSprites() return require("src.core.game3.battle.anim_sprites") end function P.AnimTasks() return package.loaded["src.core.game3.battle.anim_tasks"] or require("src.core.game3.battle.anim_tasks") end -function P.atk(vm) return vm:attackerSide() end -function P.tgt(vm) return vm:targetSide() end +function P.atk(vm) + if vm.allyPair and vm:allyPair() then return vm:attackerId() end + return vm:attackerSide() +end +function P.tgt(vm) + if vm.allyPair and vm:allyPair() then return vm:targetId() end + return vm:targetSide() +end function P.side(vm, animBattler) animBattler = tonumber(animBattler) or 0 if animBattler == 0 then return vm:attackerSide() end @@ -112,7 +119,7 @@ local function picCoords() return PicCoords or nil end -local BASE = { player = { 72, 80 }, enemy = { 176, 40 } } +local BASE = setmetatable({}, { __index = function(_, k) return AnimCoords.coords(nil, k) end }) function P.species(vm, side) local sp = vm and vm.speciesForSide and vm:speciesForSide(side) @@ -160,11 +167,19 @@ function P.yWithElevation(vm, side) return y end +P.yDelta = AnimCoords.sideArg(P.yDelta, 2) +P.elevation = AnimCoords.sideArg(P.elevation, 2) +P.coord = AnimCoords.sideArg(P.coord, 2) +P.yWithElevation = AnimCoords.sideArg(P.yWithElevation, 2) + -- pokefirered/src/battle_anim_mons.c:1908 function P.subpriorityOf(side) - return side == "player" and 30 or 40 + return AnimCoords.subpriority(side) end +function P.atkId(vm) return vm:attackerId() end +function P.tgtId(vm) return vm:targetId() end + function P.monPresent(side) return Anim().present(side) end @@ -375,15 +390,7 @@ function P.zFor(pri, sub, vm) elseif pri >= 3 then return 2 + (100 - sub) * 0.01 end - local monbg = vm and vm._monbg or {} - local bgPrio = vm and vm._bgPrio or {} - local frontEnemy, frontPlayer - if monbg.enemy then frontEnemy = (bgPrio[1] or 2) >= 2 else frontEnemy = sub < 40 end - if monbg.player then frontPlayer = (bgPrio[2] or 2) >= 2 else frontPlayer = sub < 30 end - local rank = math.max(0, math.min(98, 98 - sub)) - if frontPlayer then return 201 + rank end - if frontEnemy then return 101 + rank end - return rank + return AnimCoords.layerZ(sub, vm and vm._monbg, vm and vm._bgPrio) end -- pokefirered/src/sprite.c:905 @@ -816,7 +823,7 @@ function P.CloneMon(vm, side) local cx, cy = P.monCenter(vm, side) local img = P.monImage(vm, side) local s = AnimSprites.acquire({ - x = cx, y = cy, z = (side == "player") and 195 or 95, hostId = side, + x = cx, y = cy, z = AnimCoords.monBehindZ(side), hostId = side, w = 64, h = 64, image = img, callback = genericCallback, }) if not s then return nil end @@ -1525,4 +1532,8 @@ function P.ensureColorOverlay(vm) return s end +P.coordAttr = AnimCoords.sideArg(P.coordAttr, 2) +P.monCenter = AnimCoords.sideArg(P.monCenter, 2) +P.monImage = AnimCoords.sideArg(P.monImage, 2) + return P diff --git a/src/core/game3/battle/anim_port/g4_cb_b.lua b/src/core/game3/battle/anim_port/g4_cb_b.lua index 70c93dbb..bb4b35a5 100644 --- a/src/core/game3/battle/anim_port/g4_cb_b.lua +++ b/src/core/game3/battle/anim_port/g4_cb_b.lua @@ -228,7 +228,7 @@ return function(C) s.ox = s.ox + P.Sin(s.data[5], s.data[6]) s.oy = s.oy + P.Cos(s.data[5], -6) local side = s._fogSide - local bgp = (s._vm and s._vm._bgPrio and s._vm._bgPrio[side == "enemy" and 1 or 2]) or 2 + local bgp = (s._vm and s._vm._bgPrio and s._vm._bgPrio[require("src.core.game3.battle.anim_coords").bgPriorityRank(side)]) or 2 if P.u16(s.data[5] - 64) <= 0x7F then P.setPriority(s, bgp, s.subpriority) else @@ -355,7 +355,7 @@ return function(C) end s.x = P.coord(vm, P.atk(vm), P.X_2) s.y = P.coord(vm, P.atk(vm), P.Y_PIC_OFFSET) - local bgp = (vm._bgPrio and vm._bgPrio[P.tgt(vm) == "enemy" and 1 or 2]) or 2 + local bgp = (vm._bgPrio and vm._bgPrio[require("src.core.game3.battle.anim_coords").bgPriorityRank(P.tgt(vm))]) or 2 if a[7] ~= 0 then s.data[1] = s.x + a[1] s.data[2] = P.coord(vm, P.tgt(vm), P.X_2) + a[3] diff --git a/src/core/game3/battle/anim_port/g4_pret.lua b/src/core/game3/battle/anim_port/g4_pret.lua index 3d15f84d..0c2893ff 100644 --- a/src/core/game3/battle/anim_port/g4_pret.lua +++ b/src/core/game3/battle/anim_port/g4_pret.lua @@ -2,6 +2,7 @@ local bit = require("bit") local Trig = require("src.core.game3.trig") local AnimSprites = require("src.core.game3.battle.anim_sprites") local AnimPal = require("src.core.game3.battle.anim_pal") +local AnimCoords = require("src.core.game3.battle.anim_coords") local band, bor, bxor, rshift, arshift, lshift = bit.band, bit.bor, bit.bxor, bit.rshift, bit.arshift, bit.lshift local floor = math.floor @@ -24,7 +25,7 @@ P.Y_PIC_OFFSET = 3 P.Y_PIC_OFFSET_DEFAULT = 4 -- pokefirered/src/battle_anim_mons.c:31 -P.COORDS = { player = { x = 72, y = 80 }, enemy = { x = 176, y = 40 } } +P.COORDS = setmetatable({}, { __index = function(_, k) return AnimCoords.coords(nil, k) end }) function P.s16(v) v = band(floor(tonumber(v) or 0), 0xFFFF) @@ -87,14 +88,17 @@ function P.other(side) end function P.atk(vm) + if vm and vm.allyPair and vm:allyPair() then return vm:attackerId() end return vm and vm.attackerSide and vm:attackerSide() or "player" end function P.tgt(vm) + if vm and vm.allyPair and vm:allyPair() then return vm:targetId() end return vm and vm.targetSide and vm:targetSide() or "enemy" end function P.sideId(side) + if type(side) == "number" then return side % 2 end return side == "player" and 0 or 1 end @@ -102,6 +106,7 @@ function P.battlerSide(vm, animBattler) animBattler = tonumber(animBattler) or 0 if animBattler == 0 then return P.atk(vm) end if animBattler == 1 then return P.tgt(vm) end + if (animBattler == 2 or animBattler == 3) and vm and vm.battlerId then return vm:battlerId(animBattler) end return nil end @@ -147,19 +152,27 @@ function P.yWithElevation(vm, side) return y end +P.coord = AnimCoords.sideArg(P.coord, 2) +P.yWithElevation = AnimCoords.sideArg(P.yWithElevation, 2) + -- pokefirered/src/battle_anim_mons.c:286 function P.substituteY(side) - return P.COORDS[side].y + (side ~= "player" and 16 or 17) + local id = AnimCoords.idOf(side) or 1 + return P.COORDS[id].y + (AnimCoords.sideOf(id) ~= "player" and 16 or 17) end -- pokefirered/src/battle_anim_mons.c:1908 function P.subpriorityOf(side) - return side == "player" and 30 or 40 + return AnimCoords.subpriority(side) end function P.zFor(priority, subpriority) priority = tonumber(priority) or 2 subpriority = tonumber(subpriority) or 0 + if AnimCoords.isDouble() then + local Anim = package.loaded["src.core.game3.battle.anim"] + return AnimCoords.zFor(priority, subpriority, Anim and Anim._vm) + end if priority <= 1 then return 900 + (255 - subpriority) % 100 end local z = 500 - 10 * subpriority - 1 if priority >= 3 then z = math.min(z, 99) end @@ -221,10 +234,14 @@ end -- pokefirered/src/battle_anim_mons.c:2098 function P.averagePositions(vm, side, respect) - if respect then - return P.coord(vm, side, P.X_2), P.coord(vm, side, P.Y_PIC_OFFSET) - end - return P.coord(vm, side, P.X), P.coord(vm, side, P.Y) + local xt, yt = P.X, P.Y + if respect then xt, yt = P.X_2, P.Y_PIC_OFFSET end + local id = AnimCoords.idOf(side) or 1 + local x, y = P.coord(vm, id, xt), P.coord(vm, id, yt) + if not AnimCoords.isDouble() then return x, y end + local partner = AnimCoords.partner(id) + local px, py = P.coord(vm, partner, xt), P.coord(vm, partner, yt) + return P.cdiv(x + px, 2), P.cdiv(y + py, 2) end function P.destroy(s) diff --git a/src/core/game3/battle/anim_port/g4_tasks_a.lua b/src/core/game3/battle/anim_port/g4_tasks_a.lua index 495c1138..856ff954 100644 --- a/src/core/game3/battle/anim_port/g4_tasks_a.lua +++ b/src/core/game3/battle/anim_port/g4_tasks_a.lua @@ -114,13 +114,13 @@ return function(K) if d[0] == 0 then local x = P.coord(vm, side, P.X) local y = P.coord(vm, side, P.Y) + local id = vm.attackerId and vm:attackerId() or P.sideId(side) local item = vm.ctx and (vm.ctx.ballItem and vm.ctx.ballItem[side] or vm.ctx.pokeball) if not item then - local Battle = package.loaded["src.core.game3.battle"] - local b = Battle and Battle._st and Battle._st[side] + local b = require("src.core.game3.battle.anim_coords").battler(nil, id) item = b and b.mon and b.mon.pokeball end - if BallOpen then BallOpen.start(side, x, y + 32 + 5, item, false) end + if BallOpen then BallOpen.start(id, x, y + 32 + 5, item, false) end d[0] = d[0] + 1 elseif d[0] == 1 then if not BallOpen or #BallOpen._tasks == 0 then D(t) end @@ -304,15 +304,10 @@ return function(K) D(t) end - local function side_of_battler(id) - id = P.band(tonumber(id) or 0, 0xFF) - return (P.band(id, 1) == 0) and "player" or "enemy" - end - -- pokefirered/src/battle_anim_special.c:2299 TK.GetBattlersFromArg = function(t, vm) local m = P.u16(vm.animArg or 0) - P.setBattlers(vm, side_of_battler(P.band(m, 0xFF)), side_of_battler(P.rshift(m, 8))) + P.setBattlers(vm, P.band(m, 3), P.band(P.rshift(m, 8), 3)) D(t) end diff --git a/src/core/game3/battle/anim_port/g4_tasks_b.lua b/src/core/game3/battle/anim_port/g4_tasks_b.lua index 9d06fe65..ac257bbd 100644 --- a/src/core/game3/battle/anim_port/g4_tasks_b.lua +++ b/src/core/game3/battle/anim_port/g4_tasks_b.lua @@ -482,8 +482,9 @@ return function(K) t.func = shakeTerrain elseif a[0] == 4 then t._mons = {} - for _, side in ipairs({ "player", "enemy" }) do - local p = P.present(side) + local AnimCoords = require("src.core.game3.battle.anim_coords") + for _, id in ipairs(AnimCoords.ids()) do + local p = (id < 2 or AnimCoords.spritePresent(nil, id)) and P.present(id) or nil if p and p.visible ~= false then t._mons[#t._mons + 1] = p end end t.func = shakeBattlers diff --git a/src/core/game3/battle/anim_seq.lua b/src/core/game3/battle/anim_seq.lua index aedecdc1..4d6052ba 100644 --- a/src/core/game3/battle/anim_seq.lua +++ b/src/core/game3/battle/anim_seq.lua @@ -1,5 +1,7 @@ +local bit = require("bit") local Anim = require("src.core.game3.battle.anim") local AnimCtx = require("src.core.game3.battle.anim_ctx") +local AnimCoords = require("src.core.game3.battle.anim_coords") local AnimSeq = {} @@ -31,12 +33,23 @@ local SUB_EXEMPT_GENERAL = { -- pokefirered/data/battle_scripts_1.s:3913 local TARGET_ACTIVE_GENERAL = { ITEM_STEAL = true, ITEM_KNOCKOFF = true, SNATCH_MOVE = true } -local function battler_id(side) - return (side == "enemy") and 1 or 0 +local function to_id(v) + if type(v) == "number" then return v end + return AnimCoords.fixedId(v) end -local function other(side) - return (side == "player") and "enemy" or "player" +local function side_of(id) + return AnimCoords.sideOf(id) +end + +local function opposite(id) + return bit.bxor(id or 0, 1) +end + +local function ev_id(ev, idKey, sideKey) + local v = ev[idKey] + if type(v) == "number" then return v end + return to_id(ev[sideKey]) end local function battle_state() @@ -44,15 +57,15 @@ local function battle_state() return Battle and Battle._st end -local function battler_of(side) - local st = battle_state() - return st and st[side] +local function battler_of(id) + return AnimCoords.battler(battle_state(), to_id(id)) end -local function species_of(side) - local b = Anim.shownBattler(side, battler_of(side)) +local function species_of(id) + id = to_id(id) + local b = Anim.shownBattler(id, battler_of(id)) if type(b) ~= "table" then return nil end - local p = Anim._present[side] + local p = rawget(Anim._present, id) if b.expTransform and p and not p.pendingTransform then return p.transformSpecies or b.expTransform.species end @@ -79,10 +92,10 @@ local function effectiveness_se(eff) end -- pokefirered/src/battle_script_commands.c:1883 -local function play_effectiveness_se(eff, side) +local function play_effectiveness_se(eff, battler) local id = effectiveness_se(eff) if not id then return end - local pan = (side == "player") and -64 or 63 + local pan = (side_of(to_id(battler) or 1) == "player") and -64 or 63 pcall(function() require("src.core.game3.audio").playSe(id, { pan = pan }) end) @@ -92,17 +105,18 @@ local function flush_pending_eff() local p = AnimSeq._pendingEff AnimSeq._pendingEff = nil if p and p.effectiveness ~= nil then - play_effectiveness_se(p.effectiveness, p.side) + play_effectiveness_se(p.effectiveness, p.battler or p.side) end end -local function stand_in(side, slot) +local function stand_in(id, slot) local st = battle_state() + local side = side_of(id) local party = st and ((side == "player") and st.playerParty or st.foeParty) local mon = party and slot and party[slot] if not mon then return nil end local State = require("src.core.game3.battle.state") - local ok, b = pcall(State.makeBattler, mon, side, { partyIndex = slot }) + local ok, b = pcall(State.makeBattler, mon, side, { partyIndex = slot, id = id }) return ok and b or nil end @@ -131,8 +145,8 @@ end local function reconcile_sprites() local st = battle_state() if not st or Anim._headless then return end - for _, side in ipairs({ "player", "enemy" }) do - local p = Anim._present[side] + for _, id in ipairs(AnimCoords.ids(st)) do + local p = rawget(Anim._present, id) if p then p.shown = nil p.blinkHidden = false @@ -140,8 +154,8 @@ local function reconcile_sprites() p.invisible = nil p.battlerInvisible = nil end - local b = st[side] - local alive = b and b.mon and (tonumber(b.mon.hp) or 0) > 0 + local b = AnimCoords.battler(st, id) + local alive = b and b.mon and (tonumber(b.mon.hp) or 0) > 0 and not (st.absent and st.absent[id]) if p and alive and not st.over then if b.semiInvulnerable and AnimSeq._scene then p.visible = false @@ -150,8 +164,8 @@ local function reconcile_sprites() p.alpha = 1 end local subbed = (b.substituteHP or 0) > 0 - if subbed ~= (p.substitute == true) and not AnimSeq._subLowered[side] then - Anim.setSubstitute(side, subbed) + if subbed ~= (p.substitute == true) and not AnimSeq._subLowered[id] then + Anim.setSubstitute(id, subbed) end end end @@ -201,8 +215,9 @@ function AnimSeq.buildSteps(events, meta) end if lastMove and tostring(ev.text or ""):find("SUBSTITUTE took damage") then -- pokefirered/src/battle_script_commands.c:5300 - add("hitfx", { side = lastMove.target or other(lastMove.attacker or "player"), - effectiveness = meta.effectiveness or 1 }) + local a = ev_id(lastMove, "attackerId", "attacker") or 0 + local t = ev_id(lastMove, "targetId", "target") or opposite(a) + add("hitfx", { side = side_of(t), battler = t, effectiveness = meta.effectiveness or 1 }) end add("msg", { text = ev.text, wait = ev.wait }) lastMsg = ev.text @@ -214,10 +229,12 @@ function AnimSeq.buildSteps(events, meta) lastMove = ev add("move", { moveId = ev.moveId, attacker = ev.attacker, target = ev.target, + attackerId = ev_id(ev, "attackerId", "attacker"), targetId = ev_id(ev, "targetId", "target"), turn = ev.turn or 0, calledBy = meta.calledBy, damage = ev.damage, power = ev.power, }) elseif k == "anim" then - local d = { anim = ev.anim, name = ev.name, attacker = ev.attacker, target = ev.target, arg = ev.arg } + local d = { anim = ev.anim, name = ev.name, attacker = ev.attacker, target = ev.target, arg = ev.arg, + attackerId = ev_id(ev, "attackerId", "attacker"), targetId = ev_id(ev, "targetId", "target") } if ev.anim == "special" and (ev.name == "SUBSTITUTE_TO_MON" or ev.name == "MON_TO_SUBSTITUTE") then if ev.name == "SUBSTITUTE_TO_MON" then for j = i + 1, n do @@ -229,34 +246,41 @@ function AnimSeq.buildSteps(events, meta) end add("anim", d) elseif k == "hit" then + local b = ev_id(ev, "battler", "side") local eff = 1 - if prevKind == "move" or (lastMove and prevKind ~= "hit" and prevKind ~= "hp") then + if ev.effectiveness ~= nil then + eff = ev.effectiveness + elseif prevKind == "move" or (lastMove and prevKind ~= "hit" and prevKind ~= "hp") then eff = meta.effectiveness or 1 end - add("hitfx", { side = ev.side, effectiveness = eff }) - add("hp", { side = ev.side, from = ev.from, to = ev.to, maxHp = ev.maxHp }) + add("hitfx", { side = ev.side, battler = b, effectiveness = eff }) + add("hp", { side = ev.side, battler = b, from = ev.from, to = ev.to, maxHp = ev.maxHp }) elseif k == "hp" then + local b = ev_id(ev, "battler", "side") if prevKind == "msg" and tostring(lastMsg or ""):find("hurt itself in its") then -- pokefirered/data/battle_scripts_1.s:3741 - add("hitfx", { side = ev.side, effectiveness = 1 }) + add("hitfx", { side = ev.side, battler = b, effectiveness = 1 }) end - add("hp", { side = ev.side, from = ev.from, to = ev.to, maxHp = ev.maxHp }) + add("hp", { side = ev.side, battler = b, from = ev.from, to = ev.to, maxHp = ev.maxHp }) elseif k == "faint" then + local b = ev_id(ev, "battler", "side") -- pokefirered/data/battle_scripts_1.s:2810 - add("faint_cry", { side = ev.side }) + add("faint_cry", { side = ev.side, battler = b }) add("pause", { frames = 64 }) - add("faint", { side = ev.side }) + add("faint", { side = ev.side, battler = b }) elseif k == "switch_out" then - add("switch_out", { side = ev.side, reason = ev.reason }) + add("switch_out", { side = ev.side, battler = ev_id(ev, "battler", "side"), reason = ev.reason }) elseif k == "switch" then - add("switch_out", { side = ev.side, reason = ev.reason, slot = ev.from }) + local b = ev_id(ev, "battler", "side") + add("switch_out", { side = ev.side, battler = b, reason = ev.reason, slot = ev.from }) local hp = nil for j = i + 1, n do local e2 = events[j] - if (e2.kind == "hp" or e2.kind == "hit") and e2.side == ev.side then hp = e2.from break end - if e2.kind == "switch" and e2.side == ev.side then break end + local b2 = ev_id(e2, "battler", "side") + if (e2.kind == "hp" or e2.kind == "hit") and b2 == b then hp = e2.from break end + if e2.kind == "switch" and b2 == b then break end end - local d = { side = ev.side, slot = ev.to, hp = hp, reason = ev.reason } + local d = { side = ev.side, battler = b, slot = ev.to, hp = hp, reason = ev.reason } if ev.reason == "baton_pass" and events[i + 1] and events[i + 1].kind == "msg" then -- pokefirered/data/battle_scripts_1.s:1690 deferredIn = d @@ -285,7 +309,7 @@ local function legacy_steps(result) restStart = 2 end local us = result.user and result.user.side or "player" - local ts = result.target and result.target.side or other(us) + local ts = result.target and result.target.side or ((us == "player") and "enemy" or "player") if result.hits and #result.hits > 0 then for hi, hit in ipairs(result.hits) do add("move", { moveId = result.moveId, attacker = us, target = ts, turn = 0 }) @@ -320,12 +344,16 @@ local function start(steps, pushMsg) end local held = {} for _, step in ipairs(steps) do - if step.kind == "move" and tonumber(step.data.moveId) == MOVE_TRANSFORM then - local p = Anim.present(step.data.attacker or "player") + local d = step.data or {} + if step.kind == "move" and tonumber(d.moveId) == MOVE_TRANSFORM then + local p = Anim.present(ev_id(d, "attackerId", "attacker") or 0) if p then p.pendingTransform = true end - elseif step.kind == "switch_out" and step.data.slot and not held[step.data.side] then - held[step.data.side] = true - Anim.setShown(step.data.side, stand_in(step.data.side, step.data.slot)) + elseif step.kind == "switch_out" and d.slot then + local b = ev_id(d, "battler", "side") + if b ~= nil and not held[b] then + held[b] = true + Anim.setShown(b, stand_in(b, d.slot)) + end end end AnimSeq._steps = steps @@ -374,9 +402,40 @@ local function ctx_for(attacker, target, opts) return AnimCtx.build(attacker, target, opts) end +local function species_by_id(a, t) + if not AnimCoords.isDouble(battle_state()) then return nil end + local out = {} + for id = 0, 3 do + if id ~= a and id ~= t then out[id] = species_of(id) end + end + return out +end + +local function launch_opts(a, t, extra) + local o = { + attackerSide = side_of(a), + targetSide = side_of(t), + attackerId = a, + targetId = t, + isReversed = side_of(a) == "enemy", + attackerSpecies = species_of(a), + targetSpecies = species_of(t), + speciesById = species_by_id(a, t), + } + for k, v in pairs(extra or {}) do o[k] = v end + return o +end + +local function move_ids(d) + local a = ev_id(d, "attackerId", "attacker") or 0 + local t = ev_id(d, "targetId", "target") + if t == nil then t = opposite(a) end + return a, t +end + local function move_ctx(d) - local attacker = d.attacker or "player" - local ctx = ctx_for(attacker, d.target or other(attacker), { moveId = d.moveId, moveDmg = d.damage }) + local attacker, target = move_ids(d) + local ctx = ctx_for(attacker, target, { moveId = d.moveId, moveDmg = d.damage }) if tonumber(d.power) then ctx.movePower = tonumber(d.power) end local okM, Moves = pcall(require, "src.core.game3.battle.moves") local mv = okM and Moves.get and Moves.get(d.moveId) @@ -384,15 +443,15 @@ local function move_ctx(d) end local function after_move(d, mv) - local side = d.attacker or "player" - local p = Anim.present(side) + local id = move_ids(d) + local p = Anim.present(id) if not p then return end if tonumber(d.moveId) == MOVE_TRANSFORM then p.pendingTransform = nil end if not AnimSeq._scene then return end if tonumber(d.moveId) == MOVE_SUBSTITUTE then - local b = battler_of(side) + local b = battler_of(id) if b and (b.substituteHP or 0) > 0 and not p.substitute then - Anim.setSubstitute(side, true) + Anim.setSubstitute(id, true) p.visible = true p.ox, p.oy = 0, 0 end @@ -416,36 +475,31 @@ local function run_move(d) end local ctx, mv = move_ctx(d) wait_anim() - local attacker = d.attacker or "player" - local target = d.target or other(attacker) - Anim.launchMove(d.moveId, { - attackerSide = attacker, - targetSide = target, - isReversed = attacker == "enemy", - attackerSpecies = species_of(attacker), - targetSpecies = species_of(target), + local attacker, target = move_ids(d) + Anim.launchMove(d.moveId, launch_opts(attacker, target, { moveTurn = d.turn or 0, ctx = ctx, onEnd = function() after_move(d, mv) if AnimSeq._waiting then advance() end end, - }) + })) launch_done() end local function general_arg(d) if d.name == "LEECH_SEED_DRAIN" then -- pokefirered/src/battle_util.c:798 - local seeder = d.target or other(d.attacker or "player") - return battler_id(seeder) + battler_id(d.attacker or "player") * 256 + local a, seeder = move_ids(d) + return seeder + a * 256 end return tonumber(d.arg) or 0 end local function run_general(d) local name = d.name - local active = (TARGET_ACTIVE_GENERAL[name] and d.target) or d.attacker or "player" + local a = move_ids(d) + local active = (TARGET_ACTIVE_GENERAL[name] and ev_id(d, "targetId", "target")) or a local p = Anim.present(active) local castform = name == "CASTFORM_CHANGE" if castform then @@ -482,12 +536,7 @@ local function run_general(d) return end wait_anim() - Anim.launchGeneral(name, { - attackerSide = active, - targetSide = active, - isReversed = active == "enemy", - attackerSpecies = species_of(active), - targetSpecies = species_of(active), + Anim.launchGeneral(name, launch_opts(active, active, { animArg = general_arg(d), ctx = ctx_for(active, active, { animArg = general_arg(d) }), onEnd = function() @@ -506,37 +555,33 @@ local function run_general(d) end if AnimSeq._waiting then advance() end end, - }) + })) launch_done() end -- pokefirered/src/battle_script_commands.c:5494 local function run_status(d) - local side = d.attacker or d.target or "player" - local b = battler_of(side) + local id = ev_id(d, "attackerId", "attacker") + if id == nil then id = ev_id(d, "targetId", "target") or 0 end + local b = battler_of(id) if not AnimSeq._scene or (b and (b.semiInvulnerable or (b.substituteHP or 0) > 0)) then advance() return end wait_anim() - Anim.launchStatus(d.name, { + Anim.launchStatus(d.name, launch_opts(id, id, { force = true, - attackerSide = side, - targetSide = side, - isReversed = side == "enemy", - attackerSpecies = species_of(side), - targetSpecies = species_of(side), - ctx = ctx_for(side, side), + ctx = ctx_for(id, id), onEnd = function() if AnimSeq._waiting then advance() end end, - }) + })) launch_done() end local function run_special(d) - local side = d.attacker or "player" - local p = Anim.present(side) + local id = ev_id(d, "attackerId", "attacker") or 0 + local p = Anim.present(id) if d.name == "SUBSTITUTE_TO_MON" or d.name == "MON_TO_SUBSTITUTE" then local mid = tonumber(d.moveId) if not AnimSeq._scene and mid ~= MOVE_TRANSFORM and mid ~= MOVE_SUBSTITUTE then @@ -546,42 +591,38 @@ local function run_special(d) -- pokefirered/src/battle_controller_player.c:2338 if d.name == "SUBSTITUTE_TO_MON" then if not (p and p.substitute) then advance() return end - AnimSeq._subLowered[side] = true + AnimSeq._subLowered[id] = true else - if not AnimSeq._subLowered[side] then advance() return end - AnimSeq._subLowered[side] = nil - local b = battler_of(side) + if not AnimSeq._subLowered[id] then advance() return end + AnimSeq._subLowered[id] = nil + local b = battler_of(id) if b and (b.substituteHP or 0) <= 0 then advance() return end end end wait_anim() - Anim.launchSpecial(d.name, { - attackerSide = side, - targetSide = side, - isReversed = side == "enemy", - attackerSpecies = species_of(side), - targetSpecies = species_of(side), - ctx = ctx_for(side, side), + Anim.launchSpecial(d.name, launch_opts(id, id, { + ctx = ctx_for(id, id), onEnd = function() if d.name == "SUBSTITUTE_TO_MON" then - Anim.setSubstitute(side, false) + Anim.setSubstitute(id, false) elseif d.name == "MON_TO_SUBSTITUTE" then - Anim.setSubstitute(side, true) + Anim.setSubstitute(id, true) end - local pp = Anim.present(side) + local pp = Anim.present(id) if pp then pp.ox = 0 end if AnimSeq._waiting then advance() end end, - }) + })) launch_done() end -- pokefirered/src/battle_controller_player.c:2144 local function run_switch_out(d) - local side = d.side or "enemy" - local p = Anim.present(side) + local id = ev_id(d, "battler", "side") or 1 + local side = side_of(id) + local p = Anim.present(id) local function hide() - local pp = Anim.present(side) + local pp = Anim.present(id) if pp then pp.visible = false pp.switchedOut = true @@ -589,37 +630,33 @@ local function run_switch_out(d) pp.sx, pp.sy = 1, 1 end local s = Anim.stage() - if s and s.healthbox and s.healthbox[side] then s.healthbox[side].visible = false end + if s and s.healthbox and s.healthbox[id] then s.healthbox[id].visible = false end if AnimSeq._waiting then advance() end end - if d.slot then Anim.setShown(side, stand_in(side, d.slot)) end + if d.slot then Anim.setShown(id, stand_in(id, d.slot)) end if not p or p.visible == false then AnimSeq._waiting = true hide() return end local function out() - Anim.launchSpecial((side == "player") and "SWITCH_OUT_PLAYER_MON" or "SWITCH_OUT_OPPONENT_MON", { - attackerSide = side, - targetSide = side, - isReversed = side == "enemy", - attackerSpecies = species_of(side), - targetSpecies = species_of(side), - ctx = ctx_for(side, side), + Anim.launchSpecial((side == "player") and "SWITCH_OUT_PLAYER_MON" or "SWITCH_OUT_OPPONENT_MON", launch_opts(id, id, { + ctx = ctx_for(id, id), onEnd = hide, - }) + })) end wait_anim() if p.substitute then - Anim.launchSpecial("SUBSTITUTE_TO_MON", { - attackerSide = side, targetSide = side, isReversed = side == "enemy", - ctx = ctx_for(side, side), + local o = launch_opts(id, id, { + ctx = ctx_for(id, id), onEnd = function() - Anim.setSubstitute(side, false) + Anim.setSubstitute(id, false) p.ox = 0 out() end, }) + o.attackerSpecies, o.targetSpecies, o.speciesById = nil, nil, nil + Anim.launchSpecial("SUBSTITUTE_TO_MON", o) else out() end @@ -627,16 +664,17 @@ local function run_switch_out(d) end local function run_switch_in(d) - local side = d.side or "enemy" + local id = ev_id(d, "battler", "side") or 1 + local side = side_of(id) local st = battle_state() - local p = Anim.present(side) - Anim.setShown(side, nil) + local p = Anim.present(id) + Anim.setShown(id, nil) if p then p.switchedOut = nil -- pokefirered/src/battle_gfx_sfx_util.c:997 p.castformForm, p.castformMon = nil, nil - Anim.setSubstitute(side, false) - local b = st and st[side] + Anim.setSubstitute(id, false) + local b = AnimCoords.battler(st, id) if b and b.mon then p.displayHp = tonumber(d.hp) or tonumber(b.mon.hp) or 0 p.displayMaxHp = tonumber(b.mon.maxHp) or 1 @@ -645,7 +683,8 @@ local function run_switch_in(d) end local SwitchSeq = require("src.core.game3.battle.switch_seq") AnimSeq._waitSwitch = true - local started = SwitchSeq.beginEventSwitchIn(st, side, { + local started = SwitchSeq.beginEventSwitchIn(st, (id < 2) and side or id, { + battler = id, headless = Anim._headless, pushMsg = AnimSeq._pushMsg, onDone = function() @@ -671,8 +710,9 @@ local function run_step(step) return end if kind == "faint_cry" then - local side = d.side or "enemy" - local sp = species_of(side) + local id = ev_id(d, "battler", "side") or 1 + local side = side_of(id) + local sp = species_of(id) if sp and not Anim._headless then -- pokefirered/src/battle_controller_player.c:2696 pcall(function() @@ -697,28 +737,30 @@ local function run_step(step) end if kind == "hitfx" then -- pokefirered/src/battle_controller_player.c:2658 - if d.effectiveness ~= nil then play_effectiveness_se(d.effectiveness, d.side) end + local id = ev_id(d, "battler", "side") + if d.effectiveness ~= nil then play_effectiveness_se(d.effectiveness, id) end wait_anim() - Anim.blinkMon(d.side, { onComplete = function() if AnimSeq._waiting then advance() end end }) + Anim.blinkMon(id, { onComplete = function() if AnimSeq._waiting then advance() end end }) launch_done() return end if kind == "hp" then wait_anim() - Anim.tweenHp(d.side, d.from, d.to, d.maxHp, { + Anim.tweenHp(ev_id(d, "battler", "side"), d.from, d.to, d.maxHp, { onComplete = function() if AnimSeq._waiting then advance() end end, }) launch_done() return end if kind == "faint" then - local p = Anim.present(d.side or "enemy") + local id = ev_id(d, "battler", "side") or 1 + local p = Anim.present(id) if p then - Anim.setSubstitute(d.side or "enemy", false) + Anim.setSubstitute(id, false) p.blinkHidden = false end wait_anim() - Anim.faintMon(d.side or "enemy", { + Anim.faintMon(id, { onComplete = function() if AnimSeq._waiting then advance() end end, }) launch_done() diff --git a/src/core/game3/battle/anim_sprites.lua b/src/core/game3/battle/anim_sprites.lua index a23f94b2..6177916c 100644 --- a/src/core/game3/battle/anim_sprites.lua +++ b/src/core/game3/battle/anim_sprites.lua @@ -136,9 +136,11 @@ end function AnimSprites.clearHost(hostId) if not hostId then return end AnimSprites.init() + local AnimCoords = require("src.core.game3.battle.anim_coords") + local want = AnimCoords.idOf(hostId) for i = 1, AnimSprites.MAX do local s = AnimSprites._pool[i] - if s.active and s.hostId == hostId then + if s.active and s.hostId ~= nil and (s.hostId == hostId or (want ~= nil and AnimCoords.idOf(s.hostId) == want)) then clear_slot(s) end end diff --git a/src/core/game3/battle/anim_tasks.lua b/src/core/game3/battle/anim_tasks.lua index 85377ca3..d3096039 100644 --- a/src/core/game3/battle/anim_tasks.lua +++ b/src/core/game3/battle/anim_tasks.lua @@ -2865,8 +2865,11 @@ end AnimTasks.REGISTRY.GetTargetSide = AnimTasks.GetTargetSide AnimTasks.REGISTRY.AnimTask_GetTargetSide = AnimTasks.GetTargetSide +-- pokefirered/src/battle_anim_utility_funcs.c:712 function AnimTasks.GetTargetIsAttackerPartner(t, vm) - vm.args[7] = 0 -- In singles, target is never partner + local atk = vm.attackerId and vm:attackerId() or 0 + local tgt = vm.targetId and vm:targetId() or 1 + vm.args[7] = (require("bit").bxor(atk, 2) == tgt) and 1 or 0 destroy_task(t) end diff --git a/src/core/game3/battle/anim_vm.lua b/src/core/game3/battle/anim_vm.lua index 0bb8c67e..8865651c 100644 --- a/src/core/game3/battle/anim_vm.lua +++ b/src/core/game3/battle/anim_vm.lua @@ -5,6 +5,7 @@ local bit = require("bit") local AnimSprites = require("src.core.game3.battle.anim_sprites") local AnimTasks = require("src.core.game3.battle.anim_tasks") local AnimPal = require("src.core.game3.battle.anim_pal") +local AnimCoords = require("src.core.game3.battle.anim_coords") local band, rshift = bit.band, bit.rshift @@ -140,6 +141,8 @@ function AnimVm.new() isReversed = false, _attackerSide = "player", _targetSide = "enemy", + _atkId = 0, + _tgtId = 1, pc = 1, script = nil, callStack = {}, @@ -158,7 +161,7 @@ function AnimVm.new() _shader = nil, _cbMode = "run", _phase = "cb1", - _monbg = {}, + _monbg = AnimCoords.idTable(), _bgPrio = { [1] = 2, [2] = 2 }, _tagBlend = {}, } @@ -174,10 +177,87 @@ function AnimVm:targetSide() return self._targetSide or (self._attackerSide == "player" and "enemy" or "player") end -function AnimVm:setBattlers(atkSide, tgtSide) - if atkSide then self._attackerSide = atkSide end - if tgtSide then self._targetSide = tgtSide end +function AnimVm:attackerId() + local id = self._atkId + if id ~= nil and AnimCoords.sideOf(id) == self:attackerSide() then return id end + return AnimCoords.fixedId(self:attackerSide()) or 0 +end + +function AnimVm:targetId() + local id = self._tgtId + if id ~= nil and AnimCoords.sideOf(id) == self:targetSide() then return id end + return AnimCoords.fixedId(self:targetSide()) or 1 +end + +function AnimVm:allyPair() + local a, t = self:attackerId(), self:targetId() + return a ~= t and AnimCoords.sideOf(a) == AnimCoords.sideOf(t) +end + +-- pokefirered/src/battle_anim_mons.c:860 +function AnimVm:isDouble() + return AnimCoords.isDouble() +end + +-- pokefirered/src/battle_anim_mons.c:831 +function AnimVm:battlerAtPosition(position) + local id = tonumber(position) + if id == nil or id < 0 or id > 3 then return nil end + if id >= 2 and not AnimCoords.isDouble() then return nil end + return id +end + +local function key_to_id(key, cur) + if type(key) == "number" then return AnimCoords.idOf(key) end + if key == "player" or key == "enemy" then + if cur ~= nil and AnimCoords.sideOf(cur) == key then return cur end + return AnimCoords.idOf(key) + end + return nil +end + +function AnimVm:applyBind() + if self.active then AnimCoords.bind(self:attackerId(), self:targetId()) end +end + +function AnimVm:setBattlers(atk, tgt) + local a = key_to_id(atk, self._atkId) + local t = key_to_id(tgt, self._tgtId) + if a ~= nil then + self._atkId = a + self._attackerSide = AnimCoords.sideOf(a) + elseif atk then + self._attackerSide = atk + end + if t ~= nil then + self._tgtId = t + self._targetSide = AnimCoords.sideOf(t) + elseif tgt then + self._targetSide = tgt + end self.isReversed = (self._attackerSide == "enemy") + self:applyBind() +end + +-- pokefirered/src/battle_anim_mons.c:333 +function AnimVm:battlerId(token) + if token == nil then return self:targetId() end + local n = tonumber(token) + local s = type(token) == "string" and token:lower() or nil + if n == 0 or s == "attacker" or s == "anim_attacker" then return self:attackerId() end + if n == 1 or s == "target" or s == "anim_target" then return self:targetId() end + local id + if n == 2 or s == "atk_partner" or s == "anim_atk_partner" then + id = AnimCoords.partner(self:attackerId()) + elseif n == 3 or s == "def_partner" or s == "anim_def_partner" then + id = AnimCoords.partner(self:targetId()) + elseif s == "player" or s == "enemy" then + return AnimCoords.idOf(s) + else + return self:targetId() + end + if AnimCoords.spritePresent(nil, id) then return id end + return nil end function AnimVm:resolveBattlerSide(token) @@ -185,6 +265,7 @@ function AnimVm:resolveBattlerSide(token) if type(token) == "number" then if token == 0 then return self:attackerSide() elseif token == 1 then return self:targetSide() + elseif token == 2 or token == 3 then return self:battlerId(token) else return nil end end local s = tostring(token):lower() @@ -196,7 +277,7 @@ function AnimVm:resolveBattlerSide(token) end if s == "player" or s == "enemy" then return s end if s == "atk_partner" or s == "anim_atk_partner" or s == "def_partner" or s == "anim_def_partner" then - return nil + return self:battlerId(s) end return self:targetSide() end @@ -207,11 +288,25 @@ function AnimVm:x(v) return v end +local function live_species(id) + local b = AnimCoords.battler(nil, id) + if type(b) ~= "table" then return nil end + if b.expTransform and b.expTransform.species then return b.expTransform.species end + return b.species or (b.mon and (b.mon.species or b.mon.speciesId)) +end + function AnimVm:speciesForSide(side) if side == nil then return nil end + local id = AnimCoords.idOf(side) if self._speciesBySide and self._speciesBySide[side] ~= nil then return self._speciesBySide[side] end - if side == self:attackerSide() then return self._attackerSpecies end - if side == self:targetSide() then return self._targetSpecies end + if id ~= nil and id == self:attackerId() and self._attackerSpecies ~= nil then return self._attackerSpecies end + if id ~= nil and id == self:targetId() and self._targetSpecies ~= nil then return self._targetSpecies end + if id == nil then + if side == self:attackerSide() then return self._attackerSpecies end + if side == self:targetSide() then return self._targetSpecies end + return nil + end + if id >= 2 then return live_species(id) end return nil end @@ -231,6 +326,8 @@ end function AnimVm:battlerCenter(side) local Anim = require("src.core.game3.battle.anim") + if side == "attacker" then side = self:attackerId() + elseif side == "target" then side = self:targetId() end return Anim.battlerCenter(side) end @@ -291,8 +388,9 @@ function AnimVm:reset() self._attackerSpecies = nil self._targetSpecies = nil self._speciesBySide = nil - self._monbg = {} + self._monbg = AnimCoords.idTable() self._bgPrio = { [1] = 2, [2] = 2 } + AnimCoords.bind(nil) self._tagBlend = {} self.bldAlpha = nil self.statusAnimActive = false @@ -320,7 +418,8 @@ local function finish(self) self._onEnd = nil AnimSprites.reset() AnimTasks.reset() - self._monbg = {} + self._monbg = AnimCoords.idTable() + AnimCoords.bind(nil) if cb then pcall(cb) end end @@ -342,22 +441,33 @@ local function begin(self, script, opts) self.script = script self.pc = 1 self.isReversed = opts.isReversed and true or false - self._attackerSide = opts.attackerSide or (self.isReversed and "enemy" or "player") - self._targetSide = opts.targetSide or (self.isReversed and "player" or "enemy") - if opts.attackerSide and opts.isReversed == nil then + local atk, tgt = opts.attackerSide, opts.targetSide + local atkId = tonumber(opts.attackerId) or AnimCoords.fixedId(atk) + local tgtId = tonumber(opts.targetId) or AnimCoords.fixedId(tgt) + if type(atk) ~= "string" then atk = atkId and AnimCoords.sideOf(atkId) or nil end + if type(tgt) ~= "string" then tgt = tgtId and AnimCoords.sideOf(tgtId) or nil end + self._attackerSide = atk or (self.isReversed and "enemy" or "player") + self._targetSide = tgt or (self.isReversed and "player" or "enemy") + if atk and opts.isReversed == nil then self.isReversed = (self._attackerSide == "enemy") end + self._atkId = atkId or AnimCoords.fixedId(self._attackerSide) + self._tgtId = tgtId or AnimCoords.fixedId(self._targetSide) + AnimCoords.bind(self._atkId, self._tgtId) self._attackerSpecies = opts.attackerSpecies self._targetSpecies = opts.targetSpecies - self._speciesBySide = {} + self._speciesBySide = AnimCoords.idTable() + if opts.speciesById then + for k, v in pairs(opts.speciesById) do self._speciesBySide[k] = v end + end if opts.speciesBySide then for k, v in pairs(opts.speciesBySide) do self._speciesBySide[k] = v end end - if opts.attackerSpecies ~= nil and self._speciesBySide[self._attackerSide] == nil then - self._speciesBySide[self._attackerSide] = opts.attackerSpecies + if opts.attackerSpecies ~= nil and rawget(self._speciesBySide, self._atkId) == nil then + rawset(self._speciesBySide, self._atkId, opts.attackerSpecies) end - if opts.targetSpecies ~= nil and self._speciesBySide[self._targetSide] == nil then - self._speciesBySide[self._targetSide] = opts.targetSpecies + if opts.targetSpecies ~= nil and rawget(self._speciesBySide, self._tgtId) == nil then + rawset(self._speciesBySide, self._tgtId, opts.targetSpecies) end self._onEnd = opts.onEnd self._turn = tonumber(opts.moveTurn or opts.turn) or 0 @@ -487,13 +597,7 @@ local function effective_z(vm, s) local sub = tonumber(s.subpriority) or 0 if pri <= 1 then return 900 + (255 - sub) % 99 end if pri >= 3 then return math.max(1, math.min(98, 98 - sub)) end - local frontEnemy, frontPlayer - if vm._monbg.enemy then frontEnemy = (vm._bgPrio[1] or 2) >= 2 else frontEnemy = sub < 40 end - if vm._monbg.player then frontPlayer = (vm._bgPrio[2] or 2) >= 2 else frontPlayer = sub < 30 end - local rank = math.max(0, math.min(98, 98 - sub)) - if frontPlayer then return 201 + rank end - if frontEnemy then return 101 + rank end - return rank + return AnimCoords.layerZ(sub, vm._monbg, vm._bgPrio) end local function draw_anim_bg(vm) @@ -770,9 +874,8 @@ local function pret_subpriority(vm, op) local raw = tonumber(op.subpriority) or 0 local argVar = band(raw, 0x7F) if argVar >= 64 then argVar = argVar - 64 else argVar = -argVar end - local side = (op.animBattler == "target") and vm:targetSide() or vm:attackerSide() - local base = (side == "player") and 30 or 40 - local sub = base + argVar + local id = (op.animBattler == "target") and vm:targetId() or vm:attackerId() + local sub = AnimCoords.SUBPRIORITY[id] + argVar if sub < 3 then sub = 3 end return sub end @@ -878,36 +981,37 @@ local function run_createsprite(vm, op) ) local isDynamicArg1 = (isSlash or cbName == "EndureEnergy") - local anchorSide = vm:resolveBattlerSide(op.animBattler or "attacker") + local role = (op.animBattler == "target") and "target" or "attacker" local hFlip = false if isAttackerAlways then - anchorSide = vm:resolveBattlerSide("attacker") + role = "attacker" elseif isTravelDiagonally then local battlerArg = args[6] if battlerArg == 1 or battlerArg == "target" then - anchorSide = vm:resolveBattlerSide("target") + role = "target" else - anchorSide = vm:resolveBattlerSide("attacker") + role = "attacker" end elseif isTargetAlways then - anchorSide = vm:resolveBattlerSide("target") + role = "target" elseif isDynamicArg3 then local which = args[3] if which == 0 or which == "attacker" then - anchorSide = vm:resolveBattlerSide("attacker") + role = "attacker" elseif which == 1 or which == 2 or which == "target" or (which and which ~= 0) then - anchorSide = vm:resolveBattlerSide("target") + role = "target" end elseif isDynamicArg1 then if args[1] == 0 or args[1] == "attacker" then - anchorSide = vm:resolveBattlerSide("attacker") + role = "attacker" else - anchorSide = vm:resolveBattlerSide("target") + role = "target" end end - anchorSide = anchorSide or vm:targetSide() + local anchorId = (role == "attacker") and vm:attackerId() or vm:targetId() + local anchorSide = (role == "attacker") and vm:attackerSide() or vm:targetSide() - local cx, cy = vm:battlerCenter(anchorSide) + local cx, cy = vm:battlerCenter(anchorId) if isCutting and anchorSide == "player" then cy = cy + 8 end local ox, oy, dir = 0, 0, 0 @@ -948,7 +1052,7 @@ local function run_createsprite(vm, op) z = AnimSprites.Z.MID_FIELD, priority = 2, subpriority = subpri, - hostId = anchorSide, + hostId = anchorId, blendMode = "alpha", image = (not noGfx) and img or nil, w = bw, @@ -972,6 +1076,7 @@ local function run_createsprite(vm, op) spr._reversed = vm.isReversed spr._args = args spr._anchorSide = anchorSide + spr._anchorId = anchorId spr._cbName = cbName if op.z or op.depth then spr._pz = nil @@ -983,8 +1088,8 @@ local function run_createsprite(vm, op) spr.data[0] = 0 spr.data[1] = 0 spr.data[2] = isCutting and dir or (cbName == "RoarNoiseLine" and dir or (tonumber(args[3]) or 0)) - local tx, ty = vm:battlerCenter(vm:targetSide()) - local ax, ay = vm:battlerCenter(vm:attackerSide()) + local tx, ty = vm:battlerCenter(vm:targetId()) + local ax, ay = vm:battlerCenter(vm:attackerId()) spr._attackerX, spr._attackerY = ax, ay if isProjectile then spr._targetX = tx + vm:x(tonumber(args[3]) or 0) @@ -1141,10 +1246,13 @@ end local function task_clear_monbg(t, vm) t._n = (t._n or 0) + 1 if t._n ~= 1 then - if t._side then vm._monbg[t._side] = nil end - local p = t._side and require("src.core.game3.battle.anim").present(t._side) - if p and t._origZ then p.z = t._origZ end - if p then p.monbg = false end + local Anim = require("src.core.game3.battle.anim") + for _, e in ipairs(t._ids or {}) do + rawset(vm._monbg, e.id, nil) + local p = Anim.present(e.id) + if p and e.origZ then p.z = e.origZ end + if p then p.monbg = false end + end AnimTasks.destroy(t) end end @@ -1152,8 +1260,16 @@ AnimTasks.REGISTRY._G4ClearMonBg = task_clear_monbg local function battler_from_monbg_token(vm, token) local s = tostring(token or "target") - if s == "attacker" or s == "atk_partner" then return vm:attackerSide() end - return vm:targetSide() + if s == "attacker" or s == "atk_partner" then return vm:attackerId() end + return vm:targetId() +end + +local function monbg_ids(vm, token) + local id = battler_from_monbg_token(vm, token) + local out = { id } + local partner = AnimCoords.partner(id) + if AnimCoords.spritePresent(nil, partner) then out[2] = partner end + return out end local OPS = {} @@ -1350,37 +1466,42 @@ OPS.panse_adjustall = function(vm, op) return panse(vm, op, "adjustall") end -- pokefirered/src/battle_anim.c:531 OPS.monbg = function(vm, op) local Anim = require("src.core.game3.battle.anim") - local side = battler_from_monbg_token(vm, op.battler) - local p = Anim.present(side) - if p and p.visible ~= false then - vm._monbg[side] = true - vm._bgPrio[side == "enemy" and 1 or 2] = 2 - if p._g4OrigZ == nil then p._g4OrigZ = p.z end - p.z = AnimVm.Z.BEHIND - p.monbg = true + for _, id in ipairs(monbg_ids(vm, op.battler)) do + local p = Anim.present(id) + if p and p.visible ~= false then + rawset(vm._monbg, id, true) + vm._bgPrio[AnimCoords.BG_PRIORITY_RANK[id]] = 2 + if p._g4OrigZ == nil then p._g4OrigZ = p.z end + p.z = AnimVm.Z.BEHIND + p.monbg = true + end end return true end OPS.monbg_static = function(vm, op) - local side = battler_from_monbg_token(vm, op.battler) - vm._bgPrio[side == "enemy" and 1 or 2] = 2 + local id = battler_from_monbg_token(vm, op.battler) + vm._bgPrio[AnimCoords.BG_PRIORITY_RANK[id]] = 2 return true end -- pokefirered/src/battle_anim.c:754 OPS.clearmonbg = function(vm, op) local Anim = require("src.core.game3.battle.anim") - local side = battler_from_monbg_token(vm, op.battler) - local p = Anim.present(side) + local ids = {} + for _, id in ipairs(monbg_ids(vm, op.battler)) do + local p = Anim.present(id) + local e = { id = id } + e.origZ = p and p._g4OrigZ or ((AnimCoords.sideOf(id) == "player") and AnimVm.Z.PLAYER or AnimVm.Z.ENEMY) + if rawget(vm._monbg, id) then ids[#ids + 1] = e end + if p then p._g4OrigZ = nil end + end local t = AnimTasks.spawn("_G4ClearMonBg", 5, {}, vm) if t then t._g4kind = "aux" - t._side = vm._monbg[side] and side or nil - t._origZ = p and p._g4OrigZ or ((side == "player") and AnimVm.Z.PLAYER or AnimVm.Z.ENEMY) - elseif vm._monbg[side] then - vm._monbg[side] = nil + t._ids = ids + else + for _, e in ipairs(ids) do rawset(vm._monbg, e.id, nil) end end - if p then p._g4OrigZ = nil end return true end OPS.clearmonbg_static = OPS.nop @@ -1503,9 +1624,9 @@ end -- pokefirered/src/battle_anim.c:1574 OPS.splitbgprio = function(vm, op) - local side = (op.battler == "attacker") and vm:attackerSide() or vm:targetSide() + local id = (op.battler == "attacker") and vm:attackerId() or vm:targetId() if op.mode == "foes" and vm:attackerSide() == vm:targetSide() then return true end - if op.mode == "all" or side == "player" then + if op.mode == "all" or AnimCoords.BG_PRIORITY_RANK[id] == 2 then vm._bgPrio[1] = 1 vm._bgPrio[2] = 2 end @@ -1526,8 +1647,8 @@ end -- pokefirered/src/battle_anim.c:1631 local function set_visible(vm, op, visible) local Anim = require("src.core.game3.battle.anim") - local side = vm:resolveBattlerSide(op.battler or "attacker") - local p = side and Anim.present(side) + local id = vm:battlerId(op.battler or "attacker") + local p = id and Anim.present(id) if p then p.visible = visible end return true end diff --git a/src/core/game3/battle/ball_open.lua b/src/core/game3/battle/ball_open.lua index c622da93..4f1bc361 100644 --- a/src/core/game3/battle/ball_open.lua +++ b/src/core/game3/battle/ball_open.lua @@ -1,3 +1,5 @@ +local AnimCoords = require("src.core.game3.battle.anim_coords") + local BallOpen = {} BallOpen.CACHE_SUB = "pokemon/battle/ball_open" @@ -36,7 +38,7 @@ function BallOpen.reset() BallOpen._sprites = {} BallOpen._particles = {} BallOpen._tasks = {} - BallOpen._mon = {} + BallOpen._mon = AnimCoords.idTable() BallOpen._fade = new_fade() end @@ -398,6 +400,7 @@ end -- pokefirered/src/pokeball.c:763 function BallOpen.start(side, x, y, ballItem, unfadeLater) + side = AnimCoords.fixedId(side) or side local ballId = BallOpen.ballIdForItem(ballItem) -- pokefirered/src/battle_anim_special.c:1427 local tasks = BallOpen._tasks @@ -505,7 +508,7 @@ function BallOpen.fadeActive() end function BallOpen.monBlend(side) - local m = BallOpen._mon[side] + local m = BallOpen._mon[AnimCoords.fixedId(side) or side] if not m or m.coeff <= 0 then return 0 end local d = BallOpen.data() local c = d and d.fadeColors and d.fadeColors[m.ballId + 1] diff --git a/src/core/game3/battle/catch_seq.lua b/src/core/game3/battle/catch_seq.lua index c446a901..7dacc7ca 100644 --- a/src/core/game3/battle/catch_seq.lua +++ b/src/core/game3/battle/catch_seq.lua @@ -43,6 +43,7 @@ function CatchSeq.reset() CatchSeq._catchResult = nil CatchSeq._ball = nil CatchSeq._waitingBall = false + CatchSeq._target = 1 end function CatchSeq.busy() @@ -86,6 +87,7 @@ function CatchSeq.begin(st, itemId, caught, shakes, opts) CatchSeq._headless = opts.headless and true or false CatchSeq._session = opts.session CatchSeq._result = caught and "catch" or "fail_catch" + CatchSeq._target = tonumber(opts.target) or 1 local session = opts.session if not session then @@ -348,7 +350,7 @@ function CB.arcFlight(b) for i = 0, 7 do b.data[i] = 0 end b.cb = CB.tenFrameDelay -- pokefirered/src/battle_anim_special.c:857 - BallOpen.start("enemy", b.x, b.y, b.itemId, false) + BallOpen.start(b.target or 1, b.x, b.y, b.itemId, false) play_se(SE.SE_BALL_OPEN) end @@ -552,7 +554,7 @@ function CB.beginBreakOut(b) start_anim(b, 1) affine_start(b.aff, 0) b.cb = CB.runBreakOut - BallOpen.start("enemy", b.x, b.y, b.itemId, true) + BallOpen.start(b.target or 1, b.x, b.y, b.itemId, true) play_se(SE.SE_BALL_OPEN) b.mon.visible = true b.monAff = { paused = false } @@ -684,22 +686,24 @@ end -- pokefirered/src/battle_anim_special.c:734 local function start_ball(d) local st = CatchSeq._st - local enemy = st and st.enemy + local target = tonumber(d.target) or CatchSeq._target or 1 + local enemy = Anim.Coords.battler(st, target) local sp = enemy and enemy.species if not sp and enemy and enemy.mon then sp = Pokemon.speciesOf and Pokemon.speciesOf(enemy.mon) or enemy.mon.species or enemy.mon.speciesId end + local base = Anim.coords(st, target) or Anim.ENEMY_MON local Ui = require("src.core.game3.battle.ui") - local monY = Anim.ENEMY_MON.y + local monY = base.y if Ui.battlerSpriteCenter then - local _, cy = Ui.battlerSpriteCenter("enemy", sp, Anim.ENEMY_MON) + local _, cy = Ui.battlerSpriteCenter("enemy", sp, { x = base.x, y = base.y }) monY = cy end local stage = Anim.stage().ball stage.visible, stage.darken, stage.flash, stage.side = false, 0, 0, "enemy" local b = { x = 32, y = 80, x2 = 0, y2 = 0, - data = { [0] = 34, Anim.ENEMY_MON.x, Anim.ENEMY_MON.y - 16, 0, 0, 0, 0, 0 }, + data = { [0] = 34, base.x, base.y - 16, 0, 0, 0, 0, 0 }, anims = BALL_ANIMS, animNum = 0, animBeginning = true, animEnded = false, animPaused = false, frame = 0, hFlip = false, delay = 0, cmd = 1, aff = { paused = false }, @@ -708,7 +712,8 @@ local function start_ball(d) subpx = 0, caseId = d.caseId or 0, itemId = d.itemId, - mon = Anim.present("enemy"), + mon = Anim.present(target), + target = target, monY = monY, stage = stage, cb = CB.init, diff --git a/src/core/game3/battle/catching.lua b/src/core/game3/battle/catching.lua index d30ae66b..2bb09d88 100644 --- a/src/core/game3/battle/catching.lua +++ b/src/core/game3/battle/catching.lua @@ -3,15 +3,13 @@ -- Pure functions for catch calculations, ball multipliers, shake factor math, -- and caught Pokémon persistence into party or PC storage. -- --- Emits standard mod event bus signals: --- - "battle.ball_thrown" { ball, target } --- - "catch.rate" { ball, rate, target } --- - "mon.caught" { mon, location, firstTime } +-- Emits battle.ball_thrown and pokemon.caught; the roll is hooked as catch.rate. local ItemsData = require("src.core.game3.items_data") local Pokemon = require("src.core.game3.pokemon") local Types = require("src.core.game3.battle.types") local Dex = require("src.core.game3.dex") +local ModRuntime = require("src.mods.Runtime") local Catching = {} @@ -32,13 +30,6 @@ Catching.BALL_BONUS = { [12] = 10, -- PREMIER_BALL } -local function emit(event, payload) - local okR, Runtime = pcall(require, "src.mods.Runtime") - if okR and Runtime and Runtime.emit then - pcall(Runtime.emit, event, payload) - end -end - local function roll_rng(rng, lo, hi) lo = lo or 0 hi = hi or 255 @@ -56,6 +47,14 @@ local function roll_rng(rng, lo, hi) return math.random(lo, hi) end +-- pokefirered/src/battle_script_commands.c:9471 +function Catching.targetFor(st, attackerId) + local id = (tonumber(attackerId) or 0) + local t = (id % 2 == 0) and (id + 1) or (id - 1) + if t == 1 or not st then return st and st.enemy end + return st.battlers and st.battlers[t] or st.enemy +end + function Catching.isBall(id) local num = ItemsData.toNumericId(id) or tonumber(id) return num and num >= 1 and num <= 12 @@ -140,18 +139,13 @@ function Catching.catchOdds(itemId, foeBattler, st, session) return math.max(1, math.min(255, odds)) end ---- Attempt to catch the foe. --- Returns: caught (bool), shakes (0..4). -function Catching.tryCatch(itemId, foeBattler, st, session, rng) - emit("battle.ball_thrown", { ball = itemId, target = foeBattler }) +local function vanilla_catch(itemId, foeBattler, st, session, rng) local num = ItemsData.toNumericId(itemId) or tonumber(itemId) or 4 if num == 1 then - emit("catch.rate", { ball = itemId, rate = 255, target = foeBattler }) return true, 4 end local odds = Catching.catchOdds(itemId, foeBattler, st, session) - emit("catch.rate", { ball = itemId, rate = odds, target = foeBattler }) if odds >= 255 then return true, 4 @@ -174,6 +168,49 @@ function Catching.tryCatch(itemId, foeBattler, st, session, rng) return true, 4 end +local function battle_state(st) + if st then return st end + local B = package.loaded["src.core.game3.battle.init"] or package.loaded["src.core.game3.battle"] + return B and B.getState and B.getState() or nil +end + +--- Attempt to catch the foe. +-- Returns: caught (bool), shakes (0..4). +-- pokefirered/src/battle_script_commands.c:9463 +function Catching.tryCatch(itemId, foeBattler, st, session, rng) + local caught, shakes + if ModRuntime.wantsHook("catch.rate") then + local G3 = require("src.mods.Gen3Compat") + local mon = foeBattler and foeBattler.mon + local species = foeBattler and (foeBattler.species or (mon and (mon.species or mon.speciesId))) + local ballNum = ItemsData.toNumericId(itemId) or tonumber(itemId) or 4 + caught, shakes = ModRuntime.call("catch.rate", function(b, _, _, o) + local id = (b ~= nil and G3.itemId(b)) or ballNum + return vanilla_catch(id, o.target, st, o.session, o.rng) + end, G3.itemName(ballNum) or "POKE_BALL", mon, G3.speciesView(species), { + battle = battle_state(st), target = foeBattler, session = session, rng = rng, + ballId = ballNum, species = G3.speciesName(species), speciesId = tonumber(species), + rate = Catching.catchOdds(itemId, foeBattler, st, session), + }) + caught = caught and true or false + shakes = tonumber(shakes) or (caught and 4 or 0) + else + caught, shakes = vanilla_catch(itemId, foeBattler, st, session, rng) + end + if ModRuntime.wants("battle.ball_thrown") then + local G3 = require("src.mods.Gen3Compat") + local mon = foeBattler and foeBattler.mon + local species = foeBattler and (foeBattler.species or (mon and (mon.species or mon.speciesId))) + local ballNum = ItemsData.toNumericId(itemId) or tonumber(itemId) or 4 + ModRuntime.emit("battle.ball_thrown", { + battle = battle_state(st), ball = G3.itemName(ballNum), ballId = ballNum, + caught = caught, shakes = shakes, mon = mon, target = foeBattler, + species = G3.speciesName(species), speciesId = tonumber(species), + }) + end + return caught, shakes +end + local function clone_mon(mon) if type(mon) ~= "table" then return nil end local copy = {} @@ -240,7 +277,18 @@ function Catching.storeCaught(session, foeBattler, ballId) end end - emit("mon.caught", { mon = mon, location = location, firstTime = firstTimeCaught, box = boxId, slot = boxSlot }) + -- pokefirered/src/battle_script_commands.c:9617 + if ModRuntime.wants("pokemon.caught") then + local G3 = require("src.mods.Gen3Compat") + local R = package.loaded["src.core.game3.runtime"] + ModRuntime.emit("pokemon.caught", { + battle = battle_state(nil), mon = mon, species = G3.speciesName(species), + speciesId = tonumber(species), isNew = firstTimeCaught, + ball = G3.itemName(mon.pokeball), ballId = mon.pokeball, + destination = location == "pc" and "box" or "party", + box = boxId, slot = boxSlot, game = R and R._game or nil, + }) + end return { success = true, diff --git a/src/core/game3/battle/commands.lua b/src/core/game3/battle/commands.lua index fe24a4b8..5aa85481 100644 --- a/src/core/game3/battle/commands.lua +++ b/src/core/game3/battle/commands.lua @@ -1,5 +1,7 @@ -- Battle commands: FIGHT / BAG / POKéMON / RUN (+ move slots). +local ModRuntime = require("src.mods.Runtime") + local Commands = {} Commands.MENU = { "FIGHT", "BAG", "POKEMON", "RUN" } @@ -8,9 +10,36 @@ function Commands.defaultMenuIndex() return 1 -- FIGHT end +local function battler_of(st, id) + if id == nil or id == 0 then return st and st.player end + if id == 1 then return st and st.enemy end + return st and st.battlers and st.battlers[id] +end +Commands.battlerOf = battler_of + +local function move_target_type(mv) + local ok, Moves = pcall(require, "src.core.game3.battle.moves") + if not ok or mv == nil then return nil end + local m = Moves.get(mv) + return tonumber(m and m.target) or 0 +end + +local function tag(act, id, targetId) + if not act then return act end + act.battler = id or 0 + if targetId ~= nil then act.target = targetId end + if act.kind == "move" then act.targetType = move_target_type(act.move) end + return act +end + --- Build a player action from menu selection. -- menuIndex 1..4; moveSlot 1..4 when FIGHT. -function Commands.playerAction(st, menuIndex, moveSlot) +function Commands.playerAction(st, menuIndex, moveSlot, battlerId, targetId) + if battlerId ~= nil or targetId ~= nil then + local b = battler_of(st, battlerId) + local act = Commands.playerAction({ player = b }, menuIndex, moveSlot, nil, nil) + return tag(act, battlerId or 0, targetId) + end menuIndex = menuIndex or 1 local kind = Commands.MENU[menuIndex] or "FIGHT" if kind == "FIGHT" then @@ -67,11 +96,21 @@ local function foe_of(st, b) return (b.side == "enemy") and st.player or st.enemy end +local function foes_of(st, b) + if st and st.double then + local State = require("src.core.game3.battle.state") + return State.foes(st, b) + end + return { foe_of(st, b) } +end + local function imprisoned(st, b, num) - local foe = foe_of(st, b) - if not (foe and foe.expImprison and foe.mon and foe.mon.moves) then return false end - for i = 1, 4 do - if move_num(foe.mon.moves[i]) == num and num ~= 0 then return true end + for _, foe in ipairs(foes_of(st, b)) do + if foe and foe.expImprison and foe.mon and foe.mon.moves then + for i = 1, 4 do + if move_num(foe.mon.moves[i]) == num and num ~= 0 then return true end + end + end end return false end @@ -84,8 +123,8 @@ local function choiced(b) end -- pokefirered/src/battle_util.c:302 -function Commands.selectionError(st, slot) - local b = st and st.player +function Commands.selectionError(st, slot, battlerId) + local b = battler_of(st, battlerId) local mon = b and b.mon if not mon or not slot then return nil end local mv = mon.moves and mon.moves[slot] @@ -123,24 +162,26 @@ function Commands.selectionError(st, slot) end -- pokefirered/src/battle_util.c:361 -function Commands.moveUsable(st, slot) - local mon = st and st.player and st.player.mon +function Commands.moveUsable(st, slot, battlerId) + local b = battler_of(st, battlerId) + local mon = b and b.mon local mv = mon and mon.moves and mon.moves[slot] if move_num(mv) == 0 then return false end - return Commands.selectionError(st, slot) == nil + return Commands.selectionError(st, slot, battlerId) == nil end -- pokefirered/src/battle_main.c:3146 -function Commands.fightShortcut(st) - local b = st and st.player +function Commands.fightShortcut(st, battlerId) + local b = battler_of(st, battlerId) if not b or not b.mon then return nil end local any = false for i = 1, 4 do - if Commands.moveUsable(st, i) then any = true break end + if Commands.moveUsable(st, i, battlerId) then any = true break end end if not any then - return { kind = "move", move = "STRUGGLE", slot = nil, user = "player" }, - string.format("%s has no\nmoves left!", battler_name(b)) + local act = { kind = "move", move = "STRUGGLE", slot = nil, user = "player" } + if battlerId ~= nil then tag(act, battlerId) end + return act, string.format("%s has no\nmoves left!", battler_name(b)) end if b.expEncoreMove and (tonumber(b.expEncoreTurns) or 0) > 0 then local slot = b.expEncoreSlot @@ -150,14 +191,16 @@ function Commands.fightShortcut(st) end end if slot then - return { kind = "move", move = b.mon.moves[slot], slot = slot, user = "player" } + local act = { kind = "move", move = b.mon.moves[slot], slot = slot, user = "player" } + if battlerId ~= nil then tag(act, battlerId) end + return act end end return nil end -- pokefirered/src/party_menu.c:5916 -function Commands.switchError(st, slot, forced) +function Commands.switchError(st, slot, forced, battlerId) local party = st and st.playerParty local mon = party and party[slot] if not mon then return nil end @@ -165,35 +208,131 @@ function Commands.switchError(st, slot, forced) local name = Pokemon.displayMonName and Pokemon.displayMonName(mon) or "POKéMON" if (tonumber(mon.hp) or 0) <= 0 then return name .. " has no energy\nleft to battle!" end if st.player and st.player.partyIndex == slot then return name .. " is already\nin battle!" end + if st.double then + -- pokefirered/src/party_menu.c:5934 + local b2 = battler_of(st, 2) + if b2 and b2.partyIndex == slot and not (st.absent and st.absent[2]) then + return name .. " is already\nin battle!" + end + local pend = st.monToSwitchInto or {} + local partner = (battlerId == 2) and 0 or 2 + if battlerId ~= nil and pend[partner] == slot then + return name .. " has already been\nselected." + end + end if mon.isEgg then return "An EGG can't battle!" end if forced then return nil end local Engine = package.loaded["src.core.game3.battle.engine"] local Battle = package.loaded["src.core.game3.battle"] local ad = Battle and Battle._adapter if Engine and Engine.canSwitch and ad then - local ok, why = Engine.canSwitch(st, ad, st.player) + local ok, why = Engine.canSwitch(st, ad, battler_of(st, battlerId)) if not ok then return why end end return nil end -function Commands.enemyAction(st) - local ok, act = pcall(function() - local Ai = require("src.core.game3.battle.ai") - return Ai.chooseMove(st) - end) - if ok and act and act.kind == "move" then - return act - end - local mon = st.enemy and st.enemy.mon +Commands.aiHook = nil + +function Commands.setAiHook(fn) Commands.aiHook = fn end + +local function first_usable_action(b, id) + local mon = b and b.mon for i = 1, 4 do local mv = mon and mon.moves and mon.moves[i] local p = mon and mon.pp and mon.pp[i] if mv and mv ~= 0 and mv ~= "" and (p == nil or tonumber(p) > 0) then - return { kind = "move", move = mv, slot = i, user = "enemy" } + return { kind = "move", move = mv, slot = i, user = "enemy", battler = id } end end - return { kind = "move", move = "STRUGGLE", slot = nil, user = "enemy" } + return { kind = "move", move = "STRUGGLE", slot = nil, user = "enemy", battler = id } +end + +local function vanilla_enemy_action(st, battlerId) + local id = battlerId or 1 + if st and st.double then + local b = battler_of(st, id) + local act + local hook = Commands.aiHook + local ok, res = pcall(function() + if hook then return hook(st, id) end + local Ai = require("src.core.game3.battle.ai") + if Ai.chooseAction then return Ai.chooseAction(st, id) end + return Ai.chooseMove(st, { battler = id }) + end) + if ok and res and res.kind and (res.battler == id or (res.battler == nil and id == 1)) then act = res end + act = act or first_usable_action(b, id) + return tag(act, id, act.target) + end + local ok, act = pcall(function() + local Ai = require("src.core.game3.battle.ai") + if Ai.chooseAction then return Ai.chooseAction(st, 1) end + return Ai.chooseMove(st) + end) + if ok and act and act.kind == "move" then + act.battler = (battlerId ~= nil) and 1 or nil + return act + end + if ok and act and (act.kind == "switch" or act.kind == "item" or act.kind == "run" or act.kind == "watch") then + act.battler = 1 + return act + end + local fb = first_usable_action(st.enemy, nil) + fb.battler = nil + if battlerId ~= nil then fb.battler = 1 end + return fb +end + +local function normalize_enemy_action(st, res, battlerId) + local id = battlerId or 1 + if type(res) == "string" or type(res) == "number" then res = { kind = "move", move = res } end + if type(res) ~= "table" then return nil end + local act = {} + for k, v in pairs(res) do act[k] = v end + act.kind = act.kind or "move" + if act.kind == "move" then + local ref = act.move or act.id + local num = ref ~= nil and require("src.mods.Gen3Compat").moveId(ref) or nil + if not num then return nil end + local b = battler_of(st, id) + local moves = b and b.mon and b.mon.moves or {} + act.move, act.id = num, nil + if not act.slot or tonumber(moves[act.slot]) ~= num then + act.slot = nil + for i = 1, 4 do + if tonumber(moves[i]) == num then act.slot = i break end + end + end + end + act.user = act.user or "enemy" + if st and st.double then return tag(act, id, act.target) end + act.battler = (battlerId ~= nil) and 1 or nil + return act +end + +-- pokefirered/src/battle_controller_opponent.c:1350 +function Commands.enemyAction(st, battlerId) + if not ModRuntime.wantsHook("battle.enemy_action") then + return vanilla_enemy_action(st, battlerId) + end + local vanilla + local res = ModRuntime.call("battle.enemy_action", function(battle, bid) + vanilla = vanilla_enemy_action(battle, bid) + return vanilla + end, st, battlerId) + if res ~= nil and res == vanilla then return vanilla end + return normalize_enemy_action(st, res, battlerId) or vanilla or vanilla_enemy_action(st, battlerId) +end + +function Commands.enemyActions(st) + local out = {} + for _, id in ipairs({ 1, 3 }) do + local b = battler_of(st, id) + if b and not (st.absent and st.absent[id]) and (id == 1 or st.double) then + out[id] = Commands.enemyAction(st, id) + end + end + return out end --- Wild flee: pret-ish odds from speed (simplified). diff --git a/src/core/game3/battle/damage.lua b/src/core/game3/battle/damage.lua index c0b00569..c7dfff2a 100644 --- a/src/core/game3/battle/damage.lua +++ b/src/core/game3/battle/damage.lua @@ -4,6 +4,7 @@ local Rules = require("src.core.game3.battle.rules") local Types = require("src.core.game3.battle.types") local Moves = require("src.core.game3.battle.moves") local EffectIds = require("src.core.game3.battle.effect_ids") +local ModRuntime = require("src.mods.Runtime") local Damage = {} @@ -272,7 +273,15 @@ function Damage.base(attacker, defender, move, opts) damage = math.floor(damage / helper) damage = math.floor(damage / 50) if status_of(attacker) == "BRN" and aAb ~= "GUTS" then damage = math.floor(damage / 2) end - if opts.reflect and not crit then damage = math.floor(damage / 2) end + if opts.reflect and not crit then + if opts.doubleScreens then + damage = 2 * math.floor(damage / 3) + else + damage = math.floor(damage / 2) + end + end + -- pokefirered/src/pokemon.c:2552 + if opts.spread then damage = math.floor(damage / 2) end if damage == 0 then damage = 1 end end @@ -289,7 +298,15 @@ function Damage.base(attacker, defender, move, opts) local helper = math.max(1, Damage.applyStage(spDefense, defStage)) damage = math.floor(damage / helper) damage = math.floor(damage / 50) - if opts.lightScreen and not crit then damage = math.floor(damage / 2) end + if opts.lightScreen and not crit then + if opts.doubleScreens then + damage = 2 * math.floor(damage / 3) + else + damage = math.floor(damage / 2) + end + end + -- pokefirered/src/pokemon.c:2604 + if opts.spread then damage = math.floor(damage / 2) end local weather = opts.weatherKind if weather == "RAIN" then if moveType == Types.ID.FIRE then damage = math.floor(damage / 2) @@ -393,8 +410,13 @@ function Damage.calc(attacker, defender, moveId, opts) end elseif effectByte == EffectIds.REVENGE and not opts.dmgMultiplier then -- pokefirered/src/battle_script_commands.c:8946 - if (attacker.damageTakenThisTurn or 0) > 0 - and (attacker.expHurtBy == nil or attacker.expHurtBy == defender.side) then + local hurtOk + if opts.adapter and opts.adapter._st and opts.adapter._st.double then + hurtOk = attacker.expHurtById == nil or attacker.expHurtById == defender.id + else + hurtOk = attacker.expHurtBy == nil or attacker.expHurtBy == defender.side + end + if (attacker.damageTakenThisTurn or 0) > 0 and hurtOk then dmgMultiplier = dmgMultiplier * 2 end elseif effectByte == EffectIds.SMELLINGSALT and not opts.dmgMultiplier then @@ -468,7 +490,19 @@ function Damage.calc(attacker, defender, moveId, opts) if opts.forceCrit ~= nil then crit = opts.forceCrit and true or false elseif defAb ~= "BATTLE_ARMOR" and defAb ~= "SHELL_ARMOR" and not opts.noCrit then - crit = Rules.crit.roll(attacker, move, opts.highCrit, rng) + -- pokefirered/src/battle_script_commands.c:1170 + if opts.adapter and ModRuntime.wantsHook("battle.crit") then + local G3 = require("src.mods.Gen3Compat") + local num = tonumber(move.numId) or G3.moveId(move.id) + crit = ModRuntime.call("battle.crit", function(c) + return Rules.crit.roll(c.attacker, move, c.highCrit, c.rng) + end, { battle = opts.adapter._st, attacker = attacker, target = defender, + moveId = G3.moveName(num) or move.id, moveNum = num, rng = rng, + highCrit = opts.highCrit, + stage = Rules.crit.stage(attacker, move, opts.highCrit) }) and true or false + else + crit = Rules.crit.roll(attacker, move, opts.highCrit, rng) + end end local critMul = crit and Rules.crit.multiplier() or 1 @@ -479,6 +513,8 @@ function Damage.calc(attacker, defender, moveId, opts) adapter = opts.adapter, reflect = opts.reflect, lightScreen = opts.lightScreen, + doubleScreens = opts.doubleScreens, + spread = opts.spread, weatherKind = weatherKind, isSolarBeam = effectByte == EffectIds.SOLAR_BEAM, mudSport = opts.mudSport, @@ -489,6 +525,8 @@ function Damage.calc(attacker, defender, moveId, opts) if attacker.expCharged and tonumber(move.type) == Types.ID.ELECTRIC then dmg = dmg * 2 end + -- pokefirered/src/battle_script_commands.c:1219 + if attacker.expHelpingHand then dmg = math.floor(dmg * 15 / 10) end local stab = 1 local aT1, aT2 = attacker.type1, attacker.type2 diff --git a/src/core/game3/battle/effects/healing.lua b/src/core/game3/battle/effects/healing.lua index e214ad8a..c2bcbecc 100644 --- a/src/core/game3/battle/effects/healing.lua +++ b/src/core/game3/battle/effects/healing.lua @@ -113,13 +113,15 @@ function Healing.wish(ctx) local side = ctx.adapter:ownSide(ctx.user) if not side then return H.sayFail(ctx) end side.tokens = side.tokens or {} + local double = ctx.adapter._st and ctx.adapter._st.double for _, tok in ipairs(side.tokens) do - if tok.id == "EXP_WISH" then return H.sayFail(ctx) end + if tok.id == "EXP_WISH" and (not double or tok.battlerId == ctx.user.id) then return H.sayFail(ctx) end end side.tokens[#side.tokens + 1] = { id = "EXP_WISH", turns = 2, wisher = name(ctx, ctx.user), + battlerId = ctx.user.id, } H.attackAnim(ctx) end @@ -133,8 +135,16 @@ function Healing.healBell(ctx) local active = State.partyMon(user) local blocked = isBell and ad:abilityOf(user) == "SOUNDPROOF" if not blocked then ad:clearStatus(user) end + local partner = ad._st and ad._st.double and ad:partnerOf(user) or nil + local partnerBlocked = partner and isBell and ad:abilityOf(partner) == "SOUNDPROOF" + -- pokefirered/src/battle_script_commands.c:8023 + if partner and not partnerBlocked then + ad:clearStatus(partner) + partner.expNightmare = nil + end + local partnerMon = partner and State.partyMon(partner) for _, mon in ipairs(ad:partyMons(user)) do - if mon and mon ~= active and mon.status then + if mon and mon ~= active and mon ~= partnerMon and mon.status then mon.status = nil mon.sleep = nil end diff --git a/src/core/game3/battle/effects/hit.lua b/src/core/game3/battle/effects/hit.lua index 8ad38c8f..70d2c5ef 100644 --- a/src/core/game3/battle/effects/hit.lua +++ b/src/core/game3/battle/effects/hit.lua @@ -5,6 +5,7 @@ local Types = require("src.core.game3.battle.types") local Rules = require("src.core.game3.battle.rules") local Secondary = require("src.core.game3.battle.effects.secondary") local HeldItems = require("src.core.game3.battle.held_items") +local ModRuntime = require("src.mods.Runtime") local E = EffectIds local Hit = {} @@ -18,6 +19,24 @@ local function move_is_sleep_talk(ref) return tonumber(m and m.effect) == E.SLEEP_TALK or tonumber(ref) == MOVE_SLEEP_TALK end +local function dealt_event(M, target, dealt, info, sub) + if not ModRuntime.wants("battle.damage_dealt") then return end + local State = require("src.core.game3.battle.state") + local G3 = require("src.mods.Gen3Compat") + local view = G3.moveView(M.move) + local eff = tonumber(info.effectiveness) + local mult = eff and math.floor(eff * 10 + 0.5) or 10 + local physical = info.physical + if physical == nil then physical = Types.isPhysical(M.moveType or M.move.type) end + ModRuntime.emit("battle.damage_dealt", { + battle = M.st, user = M.user, target = target, move = view, + moveId = view and view.id, moveNum = M.mnum, damage = dealt, + crit = info.critical and true or false, typeMult = mult, effectiveness = mult, + side = target.side, userId = State.idOf(M.user), targetId = State.idOf(target), + kind = physical and "physical" or "special", substitute = sub or nil, + }) +end + -- pokefirered/src/battle_script_commands.c:1744 function Hit.dealDamage(M, dmg, info) local ad, user, target = M.adapter, M.user, M.target @@ -37,6 +56,7 @@ function Hit.dealDamage(M, dmg, info) if (M.firstDmg or 0) == 0 then M.firstDmg = dmg end M.hpDealt = dealt M.hitsLanded = (M.hitsLanded or 0) + 1 + dealt_event(M, target, dealt, info, true) return dealt end local before = ad:hp(target) @@ -45,6 +65,7 @@ function Hit.dealDamage(M, dmg, info) local dealt = before - after M.anim.hits[#M.anim.hits + 1] = { side = target.side or "enemy", + battler = target.id, from = before, to = after, maxHp = ad:maxHp(target), @@ -53,12 +74,17 @@ function Hit.dealDamage(M, dmg, info) if (M.firstDmg or 0) == 0 then M.firstDmg = dealt end if dealt > 0 then M.targetDamaged = true end target.expHurtBy = user.side + target.expHurtById = user.id + target.expLastHitById = user.id local physical = info.physical if physical == nil then physical = Types.isPhysical(M.moveType or M.move.type) end + -- pokefirered/src/battle_script_commands.c:1824 if physical then target.lastPhysicalDamageTaken = dealt + target.lastPhysicalById = user.id else target.lastSpecialDamageTaken = dealt + target.lastSpecialById = user.id M.specialHit = true end if (target.bideTurns or 0) > 0 then @@ -67,6 +93,7 @@ function Hit.dealDamage(M, dmg, info) end M.hpDealt = dealt M.hitsLanded = (M.hitsLanded or 0) + 1 + dealt_event(M, target, dealt, info, false) return dealt end @@ -120,10 +147,20 @@ local function field_sport(M, key) return M.st and M.st[key == "mudSport" and "expMudSport" or "expWaterSport"] and true or false end +local MOVE_TARGET_BOTH = 8 + local function calc_opts(M, extra) local ad, target = M.adapter, M.target local defSide = ad:ownSide(target) + local st = M.st + local twoDef = false + if st and st.double and target then + local State = require("src.core.game3.battle.state") + twoDef = State.countPresentOnSide(st, target.side) == 2 + end local o = { + doubleScreens = twoDef, + spread = twoDef and tonumber(M.move and M.move.target) == MOVE_TARGET_BOTH, rng = ad:rng(), weather = Rules.weather.effective(M.st, ad), adapter = ad, @@ -166,6 +203,18 @@ local function pre_checks(M) -- pokefirered/src/battle_script_commands.c:7568 local taken = (eff == E.COUNTER) and user.lastPhysicalDamageTaken or user.lastSpecialDamageTaken local foe = ad:foeOf(user) + if M.st and M.st.double then + local State = require("src.core.game3.battle.state") + local src = (eff == E.COUNTER) and user.lastPhysicalById or user.lastSpecialById + foe = src ~= nil and State.battler(M.st, src) or nil + if foe and src % 2 == State.idOf(user) % 2 then foe = nil end + if foe and ad:hp(foe) > 0 then + local Engine = require("src.core.game3.battle.engine") + local fm = Engine.followMeId(M.st, ad, user) + local fb = fm ~= nil and State.battler(M.st, fm) or nil + if fb and ad:hp(fb) > 0 then foe = fb end + end + end if not taken or taken <= 0 or not foe or ad:isFainted(foe) or (user.expHurtBy and user.expHurtBy == user.side) then fail(M) @@ -200,9 +249,10 @@ local function pre_checks(M) M.anim.missed = true return true end - if eff == E.EXPLOSION then + if eff == E.EXPLOSION and not M.explosionStarted then for _, b in ipairs(ad:activeBattlers()) do if ad:abilityOf(b) == "DAMP" then + M.stopTargets = true M:attackString() M:ppReduce() M:say(ad:displayName(b) .. "'s DAMP\nprevents " .. M.uname .. "\nfrom using " .. M.moveName .. "!") @@ -376,10 +426,35 @@ local function fury_cutter_power(M) return power end +-- pokefirered/src/battle_script_commands.c:1209 +local function damage_calc(M, target, calcOpts) + if not ModRuntime.wantsHook("battle.damage") then + return Damage.calc(M.user, target, M.move, calcOpts) + end + local G3 = require("src.mods.Gen3Compat") + local view = G3.moveView(M.move) + local vanillaInfo + local dmg, info = ModRuntime.call("battle.damage", function(c) + local d, i = Damage.calc(c.user, c.target, M.move, c.opts) + vanillaInfo = i + return d, i + end, { battle = M.st, user = M.user, target = target, move = view, + moveId = view and view.id, moveNum = M.mnum, opts = calcOpts, + rng = calcOpts and calcOpts.rng }) + if type(info) ~= "table" then + info = vanillaInfo or { + move = M.move, effectiveness = 1, critical = false, + moveType = tonumber(M.move.type), power = tonumber(M.move.power), + typeFlags = { super = false, notVery = false, immune = false }, + } + end + return math.max(0, math.floor(tonumber(dmg) or 0)), info +end + local function hit_once(M, opts) local ad, user, target = M.adapter, M.user, M.target opts = opts or {} - local dmg, info = Damage.calc(user, target, M.move, calc_opts(M, opts.calc)) + local dmg, info = damage_calc(M, target, calc_opts(M, opts.calc)) M.moveType = info.moveType or M.moveType if info.failed then M.failed = true @@ -426,17 +501,18 @@ function Hit.run(M) -- pokefirered/data/battle_scripts_1.s:376 M:attackString() M:ppReduce() + M.explosionStarted = true ad:setHp(user, 0) - local dmg, info = Damage.calc(user, M.target, M.move, calc_opts(M)) + local dmg, info = damage_calc(M, M.target, calc_opts(M)) if not M:accuracyCheck("normal", true) then M.anim.missed = true M.noEffect = true - M:tryFaintUser() + if not M.deferUserFaint then M:tryFaintUser() end return end if flags_immune(M, info) then M.noEffect = true - M:tryFaintUser() + if not M.deferUserFaint then M:tryFaintUser() end return end local target = M.target @@ -453,18 +529,19 @@ function Hit.run(M) if line then M:say(line) end end M:tryFaintTarget() - M:tryFaintUser() + if not M.deferUserFaint then M:tryFaintUser() end return end M:attackString() M:ppReduce() - local magnitude - if eff == E.MAGNITUDE then + local magnitude = M.magnitude + if eff == E.MAGNITUDE and not magnitude then local r = roll(ad, 0, 99) local _, info = Damage.calc(user, M.target, M.move, calc_opts(M, { magnitudeRoll = r, forceCrit = false, noRandom = true })) magnitude = { power = info.power, value = info.magnitude } + M.magnitude = magnitude M:say(string.format("MAGNITUDE %d!", magnitude.value or 4)) end @@ -517,7 +594,10 @@ function Hit.run(M) local base = Damage.base(user, target, M.move, { adapter = ad, weatherKind = Rules.weather.effective(M.st, ad), reflect = calc_opts(M).reflect, lightScreen = calc_opts(M).lightScreen, + doubleScreens = calc_opts(M).doubleScreens, }) * n + -- pokefirered/src/battle_script_commands.c:6603 + if user.expHelpingHand then base = math.floor(base * 15 / 10) end local aT1, aT2 = user.type1, user.type2 if aT1 == tonumber(M.move.type) or aT2 == tonumber(M.move.type) then base = math.floor(base * 15 / 10) end local dmg, flags = Types.typeCalc(M.move.type, target.type1, target.type2, base, target.expIdentified) @@ -659,6 +739,8 @@ function Hit.beatUp(M) local dmg = atk * (tonumber(M.move.power) or 10) * (math.floor(lvl * 2 / 5) + 2) dmg = math.floor(dmg / math.max(1, def)) dmg = math.floor(dmg / 50) + 2 + -- pokefirered/src/battle_script_commands.c:8606 + if user.expHelpingHand then dmg = math.floor(dmg * 15 / 10) end local name = (mon.nickname and mon.nickname ~= "") and mon.nickname or Pokemon.name(sp) M:say(tostring(name) .. "'s attack!") local crit = Rules.crit.roll(user, M.move, nil, ad:rng()) diff --git a/src/core/game3/battle/effects/setup.lua b/src/core/game3/battle/effects/setup.lua index f62c1a8f..c0931bc1 100644 --- a/src/core/game3/battle/effects/setup.lua +++ b/src/core/game3/battle/effects/setup.lua @@ -9,6 +9,9 @@ local Setup = {} local function name(ctx, b) return ctx.adapter:displayName(b) end local function moved_last(ctx) + local st = ctx.adapter and ctx.adapter._st + -- pokefirered/src/battle_script_commands.c:9148 + if st and st.double then return ctx.user and ctx.user.expTurnOrder == 4 end return ctx.user and ctx.user.expTurnOrder == 2 end @@ -91,6 +94,7 @@ function Setup.lockOn(ctx) if not H.accuracy(ctx, "normal") then return end ctx.target.expLockedOn = 2 ctx.target.expLockedOnBy = ctx.user.side + ctx.target.expLockedOnById = ctx.user.id H.attackAnim(ctx) ctx.adapter:say(name(ctx, ctx.user) .. " took aim\nat " .. name(ctx, ctx.target) .. "!") end @@ -115,15 +119,18 @@ end function Setup.imprison(ctx) local user = ctx.user if user.expImprison then return H.sayFail(ctx) end - local foe = ctx.adapter:foeOf(user) + local st = ctx.adapter._st + local foes = (st and st.double) and ctx.adapter:foesOf(user) or { ctx.adapter:foeOf(user) } local shared = false local um = user.mon and user.mon.moves or {} - local fm = foe and foe.mon and foe.mon.moves or {} - for i = 1, 4 do - local a = H.moveNum(um[i]) - if a and a ~= 0 then - for j = 1, 4 do - if H.moveNum(fm[j]) == a then shared = true end + for _, foe in ipairs(foes) do + local fm = foe and foe.mon and foe.mon.moves or {} + for i = 1, 4 do + local a = H.moveNum(um[i]) + if a and a ~= 0 then + for j = 1, 4 do + if H.moveNum(fm[j]) == a then shared = true end + end end end end @@ -202,8 +209,11 @@ function Setup.futureSight(ctx) local side = ad:foeSide(ctx.user) if not side then return H.sayFail(ctx) end side.tokens = side.tokens or {} + local double = ad._st and ad._st.double for _, tok in ipairs(side.tokens) do - if tok.id == "EXP_FUTURE_SIGHT" then return H.sayFail(ctx) end + if tok.id == "EXP_FUTURE_SIGHT" and (not double or tok.targetId == ctx.target.id) then + return H.sayFail(ctx) + end end local Damage = require("src.core.game3.battle.damage") local Rules = require("src.core.game3.battle.rules") @@ -213,7 +223,10 @@ function Setup.futureSight(ctx) weatherKind = Rules.weather.effective(ad._st, ad), reflect = defSide and (defSide.expReflectTurns or 0) > 0, lightScreen = defSide and (defSide.expLightScreenTurns or 0) > 0, + doubleScreens = double and ad._st and require("src.core.game3.battle.state").countPresentOnSide(ad._st, ctx.target.side) == 2, }) + -- pokefirered/src/battle_script_commands.c:8558 + if ctx.user.expHelpingHand then dmg = math.floor(dmg * 15 / 10) end side.tokens[#side.tokens + 1] = { id = "EXP_FUTURE_SIGHT", turns = 3, @@ -221,6 +234,8 @@ function Setup.futureSight(ctx) moveId = ctx.moveId, moveName = ctx.opts and ctx.opts.moveName or "FUTURE SIGHT", attackerSide = ctx.user.side, + attackerId = ctx.user.id, + targetId = ctx.target.id, } H.attackAnim(ctx) local tok = side.tokens[#side.tokens] @@ -270,7 +285,24 @@ end -- pokefirered/src/battle_script_commands.c:8779 function Setup.helpingHand(ctx) - return H.sayFail(ctx) + local ad, user = ctx.adapter, ctx.user + local st = ad._st + local State = require("src.core.game3.battle.state") + local pid = State.PARTNER(State.idOf(user)) + local partner = State.battler(st, pid) + if not (st and st.double) or not State.isPresent(st, pid) or not partner + or user.expHelpingHand or partner.expHelpingHand then + return H.sayFail(ctx) + end + partner.expHelpingHand = true + ctx.target = partner + local M = H.move(ctx) + if M then + M.target = partner + M.tname = ad:displayName(partner) + end + H.attackAnim(ctx) + ad:say(name(ctx, user) .. " is ready to\nhelp " .. name(ctx, partner) .. "!") end function Setup.splash(ctx) diff --git a/src/core/game3/battle/effects/special.lua b/src/core/game3/battle/effects/special.lua index 52668442..105a515e 100644 --- a/src/core/game3/battle/effects/special.lua +++ b/src/core/game3/battle/effects/special.lua @@ -39,7 +39,7 @@ function Special.roar(ctx) end if not H.accuracy(ctx, "lockon") then return end if not H.accuracy(ctx, "normal") then return end - local candidates = engine().switchCandidates(st, target.side) + local candidates = engine().switchCandidates(st, st.double and target.id or target.side) if not st.wild and #candidates < 1 then return H.sayFail(ctx) end local uLvl = tonumber(user.mon and user.mon.level) or 1 local tLvl = tonumber(target.mon and target.mon.level) or 1 @@ -57,7 +57,7 @@ function Special.roar(ctx) return end local slot = candidates[ad:roll(1, #candidates)] - local nb = engine().performSwitch(st, ad, target.side, slot, { reason = "roar" }) + local nb = engine().performSwitch(st, ad, st.double and target.id or target.side, slot, { reason = "roar" }) if nb then local M = H.move(ctx) if M then M.target = nb end @@ -94,6 +94,9 @@ function Special.conversion2(ctx) local lastType = user.expLastHitByType if not last or last == 0 or lastType == nil then return H.sayFail(ctx) end local foe = ad:foeOf(user) + if ad._st.double and user.expLastHitById ~= nil then + foe = require("src.core.game3.battle.state").battler(ad._st, user.expLastHitById) or foe + end if engine().isTwoTurnMove(last) and foe and foe.twoTurnMove then return H.sayFail(ctx) end local valid = {} local t = Types.TABLE @@ -228,7 +231,7 @@ end function Special.batonPass(ctx) local ad, user = ctx.adapter, ctx.user local st = ad._st - local candidates = engine().switchCandidates(st, user.side) + local candidates = engine().switchCandidates(st, st.double and user.id or user.side) if #candidates == 0 then return H.sayFail(ctx) end H.attackAnim(ctx) local pick @@ -237,16 +240,16 @@ function Special.batonPass(ctx) if ok then pick = tonumber(v) end elseif user.side == "player" and st.interactiveChoices and coroutine.running() then -- pokefirered/src/battle_script_commands.c:4626 - pick = tonumber(coroutine.yield({ kind = "baton_pass", side = user.side, candidates = candidates })) + pick = tonumber(coroutine.yield({ kind = "baton_pass", side = user.side, battler = user.id, candidates = candidates })) elseif user.side == "enemy" then -- pokefirered/src/battle_controller_opponent.c:1410 - pick = engine().mostSuitableMon(st, ad, "enemy") + pick = engine().mostSuitableMon(st, ad, st.double and user.id or "enemy") end local slot = candidates[1] for _, c in ipairs(candidates) do if c == pick then slot = pick end end - local nb = engine().performSwitch(st, ad, user.side, slot, { batonPass = true, reason = "baton_pass" }) + local nb = engine().performSwitch(st, ad, st.double and user.id or user.side, slot, { batonPass = true, reason = "baton_pass" }) if nb then local M = H.move(ctx) if M then M.user = nb end @@ -267,6 +270,10 @@ function Special.teleport(ctx) local ab = ad:abilityOf(user) local foe = ad:foeOf(user) local fab = foe and ad:abilityOf(foe) + if ad._st.double then + local Abilities = require("src.core.game3.battle.abilities") + foe, fab = Abilities.escapeBlocker(ad, user) + end if not (ab == "RUN_AWAY" or item == 194) then if fab == "SHADOW_TAG" or (fab == "ARENA_TRAP" and not H.hasType(ctx, user, Types.ID.FLYING) and ab ~= "LEVITATE") or (fab == "MAGNET_PULL" and H.hasType(ctx, user, Types.ID.STEEL)) then @@ -284,7 +291,10 @@ end -- pokefirered/src/battle_script_commands.c:8702 function Special.followMe(ctx) local side = ctx.adapter:ownSide(ctx.user) - if side then side.expFollowMe = ctx.user end + if side then + side.expFollowMe = ctx.user + side.expFollowMeId = ctx.user.id + end H.attackAnim(ctx) ctx.adapter:say(name(ctx, ctx.user) .. " became the\ncenter of attention!") end diff --git a/src/core/game3/battle/effects/volatiles.lua b/src/core/game3/battle/effects/volatiles.lua index d01e6d16..54fad03b 100644 --- a/src/core/game3/battle/effects/volatiles.lua +++ b/src/core/game3/battle/effects/volatiles.lua @@ -12,7 +12,8 @@ local function protect_like(ctx, onSuccess) local last = user.expLastResulting and H.moveNum(user.expLastResulting) if last ~= 182 and last ~= 197 and last ~= 203 then user.expProtectStreak = 0 end local streak = user.expProtectStreak or 0 - local ok = user.expTurnOrder ~= 2 + local st = ctx.adapter._st + local ok = user.expTurnOrder ~= ((st and st.double) and 4 or 2) if ok and streak > 0 then local denom = 2 ^ math.min(streak, 3) ok = ctx.adapter:roll(0, denom - 1) == 0 diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 60022652..d43bfe83 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -13,6 +13,7 @@ local Rules = require("src.core.game3.battle.rules") local Secondary = require("src.core.game3.battle.effects.secondary") local HeldItems = require("src.core.game3.battle.held_items") local Abilities = require("src.core.game3.battle.abilities") +local ModRuntime = require("src.mods.Runtime") local Engine = {} @@ -26,7 +27,20 @@ local FLAG_MAGIC_COAT_AFFECTED = 4 local FLAG_SNATCH_AFFECTED = 8 local FLAG_MIRROR_MOVE_AFFECTED = 16 +local MOVE_TARGET_SELECTED = 0 +local MOVE_TARGET_DEPENDS = 1 +local MOVE_TARGET_USER_OR_SELECTED = 2 +local MOVE_TARGET_RANDOM = 4 +local MOVE_TARGET_BOTH = 8 local MOVE_TARGET_USER = 16 +local MOVE_TARGET_FOES_AND_ALLY = 32 +local MOVE_TARGET_OPPONENTS_FIELD = 64 +Engine.MOVE_TARGET = { + SELECTED = MOVE_TARGET_SELECTED, DEPENDS = MOVE_TARGET_DEPENDS, + USER_OR_SELECTED = MOVE_TARGET_USER_OR_SELECTED, RANDOM = MOVE_TARGET_RANDOM, + BOTH = MOVE_TARGET_BOTH, USER = MOVE_TARGET_USER, FOES_AND_ALLY = MOVE_TARGET_FOES_AND_ALLY, + OPPONENTS_FIELD = MOVE_TARGET_OPPONENTS_FIELD, +} local MOVE_SNORE, MOVE_SLEEP_TALK, MOVE_STRUGGLE, MOVE_CURSE = 173, 214, 165, 174 local MOVE_SKY_ATTACK, MOVE_BOUNCE, MOVE_FLY, MOVE_DIG, MOVE_DIVE = 143, 340, 19, 91, 291 @@ -109,17 +123,26 @@ end -- pokefirered/src/battle_main.c:2373 function Engine.refreshLinks(st) if not st then return end - for _, key in ipairs({ "player", "enemy" }) do - local b = st[key] - local foe = st[key == "player" and "enemy" or "player"] + local onField = {} + for id = 0, 3 do + local b = State.battler(st, id) + if b then onField[b] = true end + end + for id = 0, 3 do + local b = State.battler(st, id) if b then - if b.expInfatuatedWith and b.expInfatuatedWith ~= foe then + local foe = (not st.double) and State.battler(st, State.OPPOSITE(id)) or nil + local function gone(src) + if st.double then return not onField[src] end + return src ~= foe + end + if b.expInfatuatedWith and gone(b.expInfatuatedWith) then b.expInfatuated, b.expInfatuatedWith = nil, nil end - if b.expTrapSource and b.expTrapSource ~= foe then + if b.expTrapSource and gone(b.expTrapSource) then b.expTrapTurns, b.expTrapSource, b.expTrapMove, b.wrapped = nil, nil, nil, nil end - if b.expTrappedBy and b.expTrappedBy ~= foe then + if b.expTrappedBy and gone(b.expTrappedBy) then b.expTrapped, b.expTrappedBy, b.escapePrevention = nil, nil, nil end end @@ -184,6 +207,10 @@ local function clear_turn_flags(battler) battler.lastPhysicalDamageTaken = nil battler.lastSpecialDamageTaken = nil battler.expHurtBy = nil + battler.expHurtById = nil + battler.lastPhysicalById = nil + battler.lastSpecialById = nil + battler.expLightningRodRedirected = nil battler.expMovedThisTurn = nil battler.expTurnOrder = nil battler.expUnableToMove = nil @@ -217,10 +244,22 @@ Ctx.__index = Ctx function Ctx:say(text) self.adapter:say(text) end +-- pokefirered/src/battle_script_commands.c:1108 function Ctx:attackString() if self.printedUsed then return end self.printedUsed = true self:say(self.uname .. " used\n" .. self.moveName .. "!") + if ModRuntime.wants("battle.move_used") then + local G3 = require("src.mods.Gen3Compat") + local view = G3.moveView(self.move) + ModRuntime.emit("battle.move_used", { + battle = self.st, user = self.user, target = self.target, move = view, + isCalled = self.opts.called and true or false, + moveId = view and view.id, moveNum = self.mnum, side = self.user and self.user.side, + userId = self.user and State.idOf(self.user), + targetId = self.target and State.idOf(self.target), + }) + end end -- pokefirered/src/battle_script_commands.c:1122 @@ -235,7 +274,16 @@ function Ctx:ppReduce() if not pp then return end local cost = 1 local target = self.target - if target and target ~= self.user and tonumber(self.move.target) ~= MOVE_TARGET_USER + local ttype = tonumber(self.move.target) or 0 + if self.st and self.st.double and (ttype == MOVE_TARGET_FOES_AND_ALLY or ttype == MOVE_TARGET_BOTH + or ttype == MOVE_TARGET_OPPONENTS_FIELD) then + for _, b in ipairs(State.present(self.st)) do + if b ~= self.user and self.adapter:abilityOf(b) == "PRESSURE" + and (ttype == MOVE_TARGET_FOES_AND_ALLY or b.side ~= self.user.side) then + cost = cost + 1 + end + end + elseif target and target ~= self.user and ttype ~= MOVE_TARGET_USER and self.adapter:abilityOf(target) == "PRESSURE" then cost = 2 end @@ -245,6 +293,13 @@ end -- pokefirered/src/battle_controller_player.c:2330 function Ctx:attackAnimation(turn, multihitLeft) local ad, user = self.adapter, self.user + if self.st and self.st.double and (self.animTargetsHit or 0) > 0 then + local ttype = tonumber(self.move.target) or 0 + -- pokefirered/src/battle_script_commands.c:1677 + if ttype == MOVE_TARGET_BOTH or ttype == MOVE_TARGET_FOES_AND_ALLY or ttype == MOVE_TARGET_DEPENDS then + return nil + end + end local behindSub = user and (user.substituteHP or 0) > 0 if behindSub and not self._subLowered then self._subLowered = true @@ -255,11 +310,14 @@ function Ctx:attackAnimation(turn, multihitLeft) moveId = self.moveId, attacker = user and user.side, target = self.target and self.target.side, + attackerId = user and State.idOf(user), + targetId = self.target and State.idOf(self.target), turn = turn or self.animTurn or 0, damage = self._animDmg, power = self._animPower, }) self.animTurn = (self.animTurn or 0) + 1 + self.animTargetsHit = (self.animTargetsHit or 0) + 1 if behindSub and (multihitLeft or 0) < 2 then self._subLowered = false ad:playAnim("special", "MON_TO_SUBSTITUTE", user, user) @@ -277,8 +335,11 @@ end function Ctx:lockOnActive() local t = self.target - return t and (t.expLockedOn or 0) ~= 0 and t.expLockedOn ~= false - and (t.expLockedOnBy == nil or t.expLockedOnBy == self.user.side) + if not (t and (t.expLockedOn or 0) ~= 0 and t.expLockedOn ~= false) then return false end + if self.st and self.st.double and t.expLockedOnById ~= nil then + return t.expLockedOnById == State.idOf(self.user) + end + return t.expLockedOnBy == nil or t.expLockedOnBy == self.user.side end -- pokefirered/src/battle_script_commands.c:1003 @@ -342,7 +403,18 @@ function Ctx:accuracyCheck(mode, printFail) end local tHe, tParam = HeldItems.of(target) if tHe == HeldItems.HOLD.EVASION_UP then calc = math.floor(calc * (100 - tParam) / 100) end - if roll(ad, 1, 100) > calc then + local hit + if ModRuntime.wantsHook("battle.accuracy") then + local G3 = require("src.mods.Gen3Compat") + local view = G3.moveView(self.move) + hit = ModRuntime.call("battle.accuracy", function(c) + return roll(ad, 1, 100) <= c.accuracy + end, { battle = self.st, move = view, moveId = view and view.id, moveNum = self.mnum, + user = user, target = target, accuracy = calc, rng = ad:rng() }) + else + hit = roll(ad, 1, 100) <= calc + end + if not hit then failMsg("miss") return false end @@ -363,10 +435,10 @@ function Ctx:faintMessage(battler) if not battler or battler._faintAnnounced then return false end if not ad:isFainted(battler) then return false end battler._faintAnnounced = true - ad:pushEvent({ kind = "faint", side = battler.side }) + ad:pushEvent({ kind = "faint", side = battler.side, battler = State.idOf(battler) }) self:say(ad:displayName(battler) .. " fainted!") self.anim.fainted = true - self.anim.faints[#self.anim.faints + 1] = { side = battler.side or "enemy" } + self.anim.faints[#self.anim.faints + 1] = { side = battler.side or "enemy", battler = State.idOf(battler) } ad:emitFaint(battler) return true end @@ -527,17 +599,11 @@ local function canceller(M) return false end - local foe = ad:foeOf(user) - if foe and foe.expImprison and M.mnum then - local fm = foe.mon and foe.mon.moves or {} - for i = 1, 4 do - if move_num(fm[i]) == M.mnum then - Engine.cancelMultiTurnMoves(user) - M:say(uname .. " can't use the\nsealed " .. M.moveName .. "!") - user.expUnableToMove = true - return false - end - end + if M.mnum and Engine.isImprisoned(ad, user, M.mnum) then + Engine.cancelMultiTurnMoves(user) + M:say(uname .. " can't use the\nsealed " .. M.moveName .. "!") + user.expUnableToMove = true + return false end if (user.confusionTurns or 0) > 0 then @@ -584,7 +650,7 @@ local function canceller(M) end if user.expInfatuated then - local lover = ad:foeOf(user) + local lover = (M.st and M.st.double and user.expInfatuatedWith) or ad:foeOf(user) M:say(uname .. " is in love\nwith " .. ad:displayName(lover) .. "!") ad:playAnim("status", "INFATUATION", user, user) if roll(ad, 0, 1) == 0 then @@ -628,7 +694,17 @@ local function bide_attack(M) ad:sayFail() return end - if src and src.side and M.st[src.side] then M.target = M.st[src.side] end + if src and src.side and M.st and not M.st.double and M.st[src.side] then M.target = M.st[src.side] end + if src and M.st and M.st.double then + local sid = State.idOf(src) + -- pokefirered/src/battle_util.c:1500 + if State.isPresent(M.st, sid) then + M.target = State.battler(M.st, sid) + else + M.target = State.battler(M.st, Engine.getMoveTarget(M.st, ad, user, M.moveId, MOVE_TARGET_SELECTED + 1)) + end + M.tname = ad:displayName(M.target) + end local target = M.target if not M:accuracyCheck("normal", true) then return end local _, flags = Types.typeCalc(M.move.type, target.type1, target.type2, nil, target.expIdentified) @@ -652,7 +728,9 @@ local function run_called(M, calledId, opts) M.anim.calledBy = M.anim.calledBy or M.moveId local target = M.target local cmove = Moves.get(calledId) - if tonumber(cmove.target) == MOVE_TARGET_USER then target = M.user else target = M.adapter:foeOf(M.user) end + if M.st and M.st.double then + target = State.battler(M.st, Engine.getMoveTarget(M.st, M.adapter, M.user, calledId)) + elseif tonumber(cmove.target) == MOVE_TARGET_USER then target = M.user else target = M.adapter:foeOf(M.user) end return Engine.resolveMove(M.user, target, calledId, opts.slot, M.adapter, M.st, M.out, sub) end @@ -806,6 +884,18 @@ local function charge_turn(M) M.noPP = true return false end + if ModRuntime.wantsHook("battle.charge_required") then + local required = ModRuntime.call("battle.charge_required", function(c) + return c.charge + end, { battle = M.st, user = user, target = M.target, + move = require("src.mods.Gen3Compat").moveView(M.move), + charge = true, isCalled = M.opts.called and true or false }) + if required == false then + M:ppReduce() + M.noPP = true + return false + end + end M:ppReduce() M:attackAnimation(0) user.twoTurnMove = M.moveId @@ -909,6 +999,13 @@ local function try_bounce(M) return true end local foe = ad:foeOf(user) + if M.st and M.st.double then + foe = nil + for _, id in ipairs(Engine.turnOrderIds(M.st)) do + local b = State.battler(M.st, id) + if b and b ~= user and b.expSnatch and State.isPresent(M.st, id) then foe = b break end + end + end if foe and foe ~= user and foe.expSnatch and has_flag(M.move, FLAG_SNATCH_AFFECTED) then foe.expSnatch = nil M:attackString() @@ -925,6 +1022,163 @@ local function try_bounce(M) return false end +-- pokefirered/src/battle_util.c:427 +function Engine.isImprisoned(ad, b, num) + if not (ad and b and num) then return false end + local st = ad._st + local foes = (st and st.double) and State.foes(st, b) or { ad:foeOf(b) } + for _, foe in ipairs(foes) do + if foe and foe.expImprison then + local fm = foe.mon and foe.mon.moves or {} + for j = 1, 4 do + if fm[j] and move_num(fm[j]) == num then return true end + end + end + end + return false +end + +function Engine.turnOrderIds(st) + if st and st.turnOrder and #st.turnOrder > 0 then return st.turnOrder end + return State.presentIds(st) +end + +-- pokefirered/src/battle_script_commands.c:2092 +function Engine.turnOrderNum(st, id) + local order = Engine.turnOrderIds(st) + for i, v in ipairs(order) do + if v == id then return i - 1 end + end + return 4 +end + +local function has_bit(v, b) return math.floor((tonumber(v) or 0) / b) % 2 == 1 end + +local function follow_me_id(st, ad, attacker) + local side = ad:foeSide(attacker) + if not side then return nil end + if side.expFollowMeId ~= nil then return side.expFollowMeId end + if side.expFollowMe then return State.idOf(side.expFollowMe) end + return nil +end +Engine.followMeId = follow_me_id + +local function foe_left(aid) return (aid % 2 == 0) and 1 or 0 end + +local function random_foe_flank(st, ad, aid) + if roll(ad, 0, 1) == 1 then return foe_left(aid) end + return foe_left(aid) + 2 +end + +-- pokefirered/src/battle_util.c:3054 +function Engine.getMoveTarget(st, ad, attacker, moveId, setTarget) + local aid = State.idOf(attacker) + local mv = Moves.get(moveId) + local ttype = setTarget and (setTarget - 1) or (tonumber(mv and mv.target) or 0) + local count = (st and st.double) and 4 or 2 + local target + if ttype == MOVE_TARGET_SELECTED then + local fm = follow_me_id(st, ad, attacker) + if fm ~= nil and ad:hp(State.battler(st, fm)) > 0 then + target = fm + else + for _ = 1, 256 do + target = roll(ad, 0, count - 1) % count + if target ~= aid and target % 2 ~= aid % 2 and State.isPresent(st, target) then break end + target = nil + end + target = target or foe_left(aid) + local lr = 0 + for id = 0, count - 1 do + local b = State.battler(st, id) + if b and id % 2 ~= aid % 2 and ad:abilityOf(b) == "LIGHTNING_ROD" then lr = lr + 1 end + end + if tonumber(mv and mv.type) == Types.ID.ELECTRIC and lr > 0 + and ad:abilityOf(State.battler(st, target)) ~= "LIGHTNING_ROD" then + target = State.PARTNER(target) + local rb = State.battler(st, target) + if rb then rb.expLightningRodRedirected = true end + end + end + elseif ttype == MOVE_TARGET_DEPENDS or ttype == MOVE_TARGET_BOTH or ttype == MOVE_TARGET_FOES_AND_ALLY + or ttype == MOVE_TARGET_OPPONENTS_FIELD then + target = foe_left(aid) + if not State.isPresent(st, target) and count == 4 then target = State.PARTNER(target) end + elseif ttype == MOVE_TARGET_RANDOM then + local fm = follow_me_id(st, ad, attacker) + if fm ~= nil and ad:hp(State.battler(st, fm)) > 0 then + target = fm + elseif st and st.double then + target = random_foe_flank(st, ad, aid) + if not State.isPresent(st, target) then target = State.PARTNER(target) end + else + target = foe_left(aid) + end + else + target = aid + end + if st then + st.moveTarget = st.moveTarget or {} + st.moveTarget[aid] = target + end + return target +end + +-- pokefirered/src/battle_main.c:4024 +function Engine.resolveTarget(st, ad, attacker, moveId, chosenId, info) + info = info or {} + local aid = State.idOf(attacker) + local mv = Moves.get(moveId) + local ttype = tonumber(mv and mv.target) or 0 + local chosenMv = info.chosenMove and Moves.get(info.chosenMove) or mv + local chosenRandom = has_bit(chosenMv and chosenMv.target, MOVE_TARGET_RANDOM) + local mt = chosenId + if mt == nil or info.recompute then mt = Engine.getMoveTarget(st, ad, attacker, moveId) end + st.moveTarget = st.moveTarget or {} + st.moveTarget[aid] = mt + local function absent_fix(t) + if not State.isPresent(st, t) then + if t % 2 ~= aid % 2 then + t = State.PARTNER(t) + else + t = foe_left(aid) + if not State.isPresent(st, t) then t = State.PARTNER(t) end + end + end + return t + end + local fm = follow_me_id(st, ad, attacker) + if fm ~= nil and ttype == MOVE_TARGET_SELECTED and fm % 2 ~= aid % 2 + and ad:hp(State.battler(st, fm)) > 0 then + return fm + end + if st.double and fm == nil and ((tonumber(mv and mv.power) or 0) ~= 0 or ttype ~= MOVE_TARGET_USER) + and ad:abilityOf(State.battler(st, mt)) ~= "LIGHTNING_ROD" + and tonumber(mv and mv.type) == Types.ID.ELECTRIC then + local best, bestNum = nil, 4 + for id = 0, 3 do + local b = State.battler(st, id) + if b and id % 2 ~= aid % 2 and id ~= mt and ad:abilityOf(b) == "LIGHTNING_ROD" then + local n = Engine.turnOrderNum(st, id) + if n < bestNum then best, bestNum = id, n end + end + end + if best == nil then + local t = mt + if chosenRandom then t = random_foe_flank(st, ad, aid) end + return absent_fix(t) + end + State.battler(st, best).expLightningRodRedirected = true + return best + end + if st.double and chosenRandom then + local t = random_foe_flank(st, ad, aid) + if not State.isPresent(st, t) then t = State.PARTNER(t) end + return t + end + return absent_fix(mt) +end + -- pokefirered/src/battle_util.c:361 function Engine.moveLimitations(b, ad) local bad = {} @@ -941,7 +1195,9 @@ function Engine.moveLimitations(b, ad) if b.expDisabledMove and n == b.expDisabledMove then bad[i] = true end if b.expTormented and n == move_num(b.lastMoveId) then bad[i] = true end if (b.expTauntedTurns or 0) > 0 and (tonumber(Moves.get(mv).power) or 0) == 0 then bad[i] = true end - if foe and foe.expImprison then + if ad and ad._st and ad._st.double then + if Engine.isImprisoned(ad, b, n) then bad[i] = true end + elseif foe and foe.expImprison then local fm = foe.mon and foe.mon.moves or {} for j = 1, 4 do if fm[j] and move_num(fm[j]) == n then bad[i] = true end @@ -1189,48 +1445,131 @@ local function run(M) return end - try_bounce(M) + local function body() + try_bounce(M) + Engine.lightningRodTook(M) - if eff == E.OHKO then - ohko(M) - move_end(M) - return - end + if eff == E.OHKO then + ohko(M) + move_end(M) + return + end - if eff == E.BIDE then - -- pokefirered/data/battle_scripts_1.s:575 + if eff == E.BIDE then + -- pokefirered/data/battle_scripts_1.s:575 + M:attackString() + M:ppReduce() + M:attackAnimation() + user.bideTurns = 2 + user.expBideDamage = 0 + user.expBideTarget = nil + user.expLockedMove = M.moveId + user.expLockedSlot = M.slot + M.anim.statusOnly = true + move_end(M) + return + end + + local power = tonumber(M.move.power) or 0 + if power > 0 then + local Hit = require("src.core.game3.battle.effects.hit") + Hit.run(M) + move_end(M) + return + end + + M.anim.statusOnly = true M:attackString() M:ppReduce() - M:attackAnimation() - user.bideTurns = 2 - user.expBideDamage = 0 - user.expBideTarget = nil - user.expLockedMove = M.moveId - user.expLockedSlot = M.slot - M.anim.statusOnly = true + local handled = Effects.runForMove(ad, M.user, M.target, M.moveId, M) + if not handled then + M:attackAnimation() + M:say("But nothing happened!") + end + M:tryFaintTarget() + M:tryFaintUser() move_end(M) - return end - local power = tonumber(M.move.power) or 0 - if power > 0 then - local Hit = require("src.core.game3.battle.effects.hit") - Hit.run(M) - move_end(M) - return - end + Engine.forEachTarget(M, body) +end - M.anim.statusOnly = true +-- pokefirered/src/battle_script_commands.c:888 +function Engine.lightningRodTook(M) + local t = M.target + if not (t and t.expLightningRodRedirected) then return false end + t.expLightningRodRedirected = nil M:attackString() - M:ppReduce() - local handled = Effects.runForMove(ad, M.user, M.target, M.moveId, M) - if not handled then - M:attackAnimation() - M:say("But nothing happened!") + M:say(M.adapter:displayName(t) .. "'s " .. Abilities.name("LIGHTNING_ROD") .. "\ntook the attack!") + return true +end + +-- pokefirered/src/battle_script_commands.c:3469 +function Engine.resetTargetValues(M) + M.noEffect, M.missReason, M.failed, M.hitSubstitute = nil, nil, nil, nil + M.hpDealt, M.firstDmg, M.hitsLanded, M.targetDamaged, M.specialHit = nil, nil, nil, nil, nil + M.dmgMultiplier = 1 + M.ignoreOnAir, M.ignoreUnderground, M.ignoreUnderwater = nil, nil, nil + M.brokeWall, M._animDmg, M._animPower = nil, nil, nil + if M.anim then M.anim.missed = false end +end + +local function set_target(M, b) + M.target = b + M.tname = M.adapter:displayName(b) + if b then b._statLoweredMsg = nil end +end + +-- pokefirered/src/battle_script_commands.c:4308 +function Engine.forEachTarget(M, body) + local st, ad = M.st, M.adapter + local ttype = tonumber(M.move and M.move.target) or 0 + M.targets = { M.target and State.idOf(M.target) or nil } + M.targetIndex = 1 + if not (st and st.double) or M.noSpread then return body(M) end + if ttype == MOVE_TARGET_FOES_AND_ALLY then + -- pokefirered/src/battle_script_commands.c:8532 + local uid = State.idOf(M.user) + local ids = {} + for id = 0, 3 do + if id ~= uid and State.isPresent(st, id) then ids[#ids + 1] = id end + end + if #ids == 0 then return body(M) end + M.targets = ids + M.deferUserFaint = (M.effect == E.EXPLOSION) + local anyLanded = false + for i, id in ipairs(ids) do + if i > 1 then Engine.resetTargetValues(M) end + M.targetIndex = i + set_target(M, State.battler(st, id)) + body(M) + if not M.anim.missed then anyLanded = true end + if M.stopTargets then break end + end + M.anim.missed = not anyLanded + if M.deferUserFaint then + M.deferUserFaint = nil + M:tryFaintUser() + end + return end - M:tryFaintTarget() - M:tryFaintUser() - move_end(M) + if ttype == MOVE_TARGET_BOTH then + body(M) + local firstMissed = M.anim.missed + if M.stopTargets or not M.target then return end + local pid = State.PARTNER(State.idOf(M.target)) + local nb = State.battler(st, pid) + if nb and State.isPresent(st, pid) and ad:hp(nb) > 0 then + Engine.resetTargetValues(M) + M.targets[2] = pid + M.targetIndex = 2 + set_target(M, nb) + body(M) + M.anim.missed = firstMissed and M.anim.missed + end + return + end + return body(M) end --- Resolve a move. Returns message list. @@ -1238,10 +1577,11 @@ end function Engine.resolveMove(user, target, moveId, slot, adapter, st, out, opts) out = out or {} opts = opts or {} - if type(user) == "string" and st and st[user] then user = st[user] end - if type(target) == "string" and st and st[target] then target = st[target] end - if user and user.side and st and st[user.side] then user = st[user.side] end - if target and target.side and st and st[target.side] then target = st[target.side] end + local chosenTargetId = (target ~= nil and type(target) ~= "string") and State.idOf(target) or nil + if st then + user = State.occupant(st, user) + target = State.occupant(st, target) + end local nested = opts.called and opts.anim ~= nil local prevSay = adapter._say @@ -1250,7 +1590,8 @@ function Engine.resolveMove(user, target, moveId, slot, adapter, st, out, opts) adapter._say = function(text) out[#out + 1] = text end end - if not opts.called and st and st.over then + local absentUser = st and st.double and user and State.isAbsent(st, State.idOf(user)) + if not opts.called and st and (st.over or absentUser) then local anim = { moveId = moveId, user = user, target = target, hits = {}, heals = {}, faints = {}, missed = false, statusOnly = true, msgs = {}, events = {} } out._anim = anim @@ -1258,22 +1599,44 @@ function Engine.resolveMove(user, target, moveId, slot, adapter, st, out, opts) return out end + local chosenMoveId = moveId + local retarget = false + local locked = false if not opts.called and user then if user.expLockedMove and not opts.pursuitSwitch then moveId = user.expLockedMove slot = user.expLockedSlot - if user.twoTurnTarget and user.twoTurnTarget.side and st and st[user.twoTurnTarget.side] then - target = st[user.twoTurnTarget.side] + locked = true + if user.twoTurnTarget and st and State.occupant(st, user.twoTurnTarget) then + target = State.occupant(st, user.twoTurnTarget) end elseif user.expEncoreMove and (user.expEncoreTurns or 0) > 0 then local H = require("src.core.game3.battle.effects._helpers") local es = H.slotOf(user, user.expEncoreMove) if es then + if move_num(user.mon.moves[es]) ~= move_num(moveId) then retarget = true end moveId = user.mon.moves[es] slot = es end end end + -- pokefirered/src/battle_main.c:3963 + if st and st.double and user and not opts.called and not opts.pursuitSwitch and not opts.noRetarget + and not absentUser then + for id = 0, 3 do + local b = State.battler(st, id) + if b then b.expLightningRodRedirected = nil end + end + local uid = State.idOf(user) + local tid = chosenTargetId + if locked then + tid = (st.moveTarget and st.moveTarget[uid]) or (user.twoTurnTarget and State.idOf(user.twoTurnTarget)) or tid + end + local rid = Engine.resolveTarget(st, adapter, user, moveId, tid, { + recompute = retarget or tid == nil, chosenMove = locked and moveId or chosenMoveId, + }) + target = State.battler(st, rid) or target + end local anim = opts.anim or { moveId = moveId, @@ -1371,13 +1734,29 @@ end -- pokefirered/src/battle_ai_switch_items.c:428 function Engine.mostSuitableMon(st, adapter, side) + local id = (type(side) == "number") and side or State.idOf(side) + if type(side) == "table" then side = side.side end + if type(side) ~= "string" then side = State.sideOf(id) end local party = (side == "player") and st.playerParty or st.foeParty - local active = st[side] - local opp = st[(side == "player") and "enemy" or "player"] + local active = State.battler(st, id) + local opp = State.battler(st, State.OPPOSITE(id)) + local pending = st.monToSwitchInto or {} + if pending[id] then return pending[id] end + local in2 = id + if st.double then + if State.isPresent(st, State.PARTNER(id)) then in2 = State.PARTNER(id) end + -- pokefirered/src/battle_ai_switch_items.c:448 + local oid = (math.floor(roll(adapter, 0, 65535) / 2) % 2) * 2 + (1 - id % 2) + if not State.isPresent(st, oid) then oid = State.PARTNER(oid) end + opp = State.battler(st, oid) + end if not party or not opp then return nil end local activeIdx = active and active.partyIndex + local partner = State.battler(st, in2) + local in2Idx = partner and partner.partyIndex local function valid(i, mon) - return mon and not mon.isEgg and (tonumber(mon.hp) or 0) > 0 and i ~= activeIdx + return mon and not mon.isEgg and (tonumber(mon.hp) or 0) > 0 and i ~= activeIdx and i ~= in2Idx + and i ~= pending[id] and i ~= pending[in2] and (tonumber(mon.species or mon.speciesId) or 0) ~= 0 end -- pokefirered/src/battle_ai_switch_items.c:404 @@ -1447,19 +1826,40 @@ function Engine.mostSuitableMon(st, adapter, side) return bestId end +-- pokefirered/src/battle_main.c:4150 +function Engine.performEnemyItem(st, adapter, act) + local BattleItems = require("src.core.game3.battle.items") + return BattleItems.enemyUse(st, adapter, act) +end + -- pokefirered/src/battle_script_commands.c:4467 +-- pokefirered/src/battle_script_commands.c:4467 +local function switched_event(st, adapter, id, nb, old, opts) + if not ModRuntime.wants("battle.battler_switched") then return end + local mon = nb and nb.mon + local sp = mon and tonumber(mon.species or mon.speciesId) + ModRuntime.emit("battle.battler_switched", { + battle = st, side = adapter.ownSide and adapter:ownSide(nb), sideName = nb and nb.side, battler = nb, + previous = old, battlerId = id, reason = opts and opts.reason, + species = require("src.mods.Gen3Compat").speciesName(sp), speciesId = sp, + partyIndex = nb and nb.partyIndex, + }) +end + function Engine.performSwitch(st, adapter, side, slot, opts) opts = opts or {} - local old = st[side] + local id = (type(side) == "number") and side or State.idOf(side) + side = State.sideOf(id) + local old = State.battler(st, id) local party = (side == "player") and st.playerParty or st.foeParty if not old or not party or not party[slot] then return nil end local oldSlot = old.partyIndex - if side == "player" then + if side == "player" and not st.double then State.trackParticipant(st, st.enemy, oldSlot or 1) end Engine.switchOutEffects(st, adapter, old) State.syncBattlerToParty(old, party) - local nb = State.makeBattler(party[slot], side, { partyIndex = slot }) + local nb = State.makeBattler(party[slot], side, { partyIndex = slot, id = id }) if opts.batonPass then -- pokefirered/src/battle_main.c:2350 for k, v in pairs(old.stages or {}) do nb.stages[k] = v end @@ -1477,7 +1877,33 @@ function Engine.performSwitch(st, adapter, side, slot, opts) nb.expLockedOn = old.expLockedOn nb.expLockedOnBy = old.expLockedOnBy end - st[side] = nb + if st.battlers then st.battlers[id] = nb else st[side] = nb end + if st.absent then st.absent[id] = nil end + if st.monToSwitchInto then st.monToSwitchInto[id] = nil end + Engine.cancelPendingAction(st, id) + if st.double then + -- pokefirered/src/battle_script_commands.c:4966 + State.updateSentPokes(st, nb) + for oid = 0, 3 do + local b = State.battler(st, oid) + if b and oid ~= id then + if b.expSeedSource == old then b.expSeedSource = nb end + if b.expTrapSource == old then + b.expTrapTurns, b.expTrapSource, b.wrapped = nil, nil, nil + end + if b.expTrappedBy == old then + b.expTrapped, b.expTrappedBy, b.escapePrevention = nil, nil, nil + end + if b.expInfatuatedWith == old then + b.expInfatuated, b.expInfatuatedWith, b.expInfatuatedBy = nil, nil, nil + end + if b.expLockedOnById == id then b.expLockedOn, b.expLockedOnById, b.expLockedOnBy = nil, nil, nil end + end + end + adapter:pushEvent({ kind = "switch", side = side, battler = id, from = oldSlot, to = slot, reason = opts.reason }) + switched_event(st, adapter, id, nb, old, opts) + return nb + end local foe = (side == "player") and st.enemy or st.player if side == "player" then State.trackParticipant(st, st.enemy, slot) @@ -1499,10 +1925,24 @@ function Engine.performSwitch(st, adapter, side, slot, opts) foe.expInfatuated = nil if foe.expLockedOnBy == side then foe.expLockedOn = nil end end - adapter:pushEvent({ kind = "switch", side = side, from = oldSlot, to = slot, reason = opts.reason }) + adapter:pushEvent({ kind = "switch", side = side, battler = id, from = oldSlot, to = slot, reason = opts.reason }) + switched_event(st, adapter, id, nb, old, opts) return nb end +-- pokefirered/src/battle_script_commands.c:5013 +function Engine.cancelPendingAction(st, id) + for _, act in ipairs(st and st.turnActions or {}) do + if act.battler == id and not act.done then act.finished = true end + end +end + +function Engine.actionRunnable(st, act) + if not act or act.finished or act.done then return false end + if act.battler ~= nil and State.isAbsent(st, act.battler) then return false end + return true +end + -- pokefirered/src/battle_script_commands.c:9197 function Engine.switchOutEffects(st, adapter, battler) if not battler or not adapter then return false end @@ -1624,11 +2064,23 @@ function Engine.tryFlee(st, adapter, battler) local ok = false local mySpe = tonumber(battler.mon and battler.mon.speed) or 0 local foeSpe = tonumber(foe and foe.mon and foe.mon.speed) or 0 - if mySpe < foeSpe then - local speedVar = (math.floor(mySpe * 128 / math.max(1, foeSpe)) + (st.fleeAttempts or 0) * 30) % 256 - ok = speedVar > roll(adapter, 0, 255) + local function vanilla_run(pSpd, eSpd) + if st.double then + -- pokefirered/src/battle_main.c:4259 + return false + elseif pSpd < eSpd then + local speedVar = (math.floor(pSpd * 128 / math.max(1, eSpd)) + (st.fleeAttempts or 0) * 30) % 256 + return speedVar > roll(adapter, 0, 255) + end + return true + end + if ModRuntime.wantsHook("battle.run") then + ok = ModRuntime.call("battle.run", function(c) + return vanilla_run(c.pSpd, c.eSpd) + end, { battle = st, pSpd = mySpe, eSpd = foeSpe, attempts = st.fleeAttempts or 0, + rng = adapter:rng(), battler = battler }) and true or false else - ok = true + ok = vanilla_run(mySpe, foeSpe) end st.fleeAttempts = (st.fleeAttempts or 0) + 1 if ok then @@ -1640,17 +2092,252 @@ function Engine.tryFlee(st, adapter, battler) return false end +-- pokefirered/src/battle_script_commands.c:4536 function Engine.switchCandidates(st, side) + local id = (type(side) == "number") and side or State.idOf(side) + if type(side) ~= "string" then side = State.sideOf(id) end local party = (side == "player") and st.playerParty or st.foeParty - local active = st[side] and st[side].partyIndex + local excl = {} + local ids = st.double and State.positionsOnSide(side) or { id } + for _, bid in ipairs(ids) do + local b = State.battler(st, bid) + if b and b.partyIndex then excl[b.partyIndex] = true end + local pend = st.monToSwitchInto and st.monToSwitchInto[bid] + if pend and bid ~= id then excl[pend] = true end + end local out = {} for i, mon in ipairs(party or {}) do - if i ~= active and mon and (tonumber(mon.hp) or 0) > 0 and not mon.isEgg then out[#out + 1] = i end + if not excl[i] and mon and (tonumber(mon.hp) or 0) > 0 and not mon.isEgg then out[#out + 1] = i end end return out end +-- pokefirered/src/battle_util.c:1542 +function Engine.hasNoMonsToSwitch(st, id) + if not (st and st.double) then return false end + return #Engine.replacementCandidates(st, id) == 0 +end + +-- pokefirered/src/party_menu.c:5916 +function Engine.replacementCandidates(st, id) + local side = State.sideOf(id) + local party = (side == "player") and st.playerParty or st.foeParty + local excl = {} + for _, bid in ipairs(st.double and State.positionsOnSide(side) or { id }) do + local b = State.battler(st, bid) + if b and b.partyIndex then excl[b.partyIndex] = true end + local pend = st.monToSwitchInto and st.monToSwitchInto[bid] + if pend then excl[pend] = true end + end + local out = {} + for i, mon in ipairs(party or {}) do + if not excl[i] and mon and not mon.isEgg and (tonumber(mon.hp) or 0) > 0 + and (tonumber(mon.species or mon.speciesId) or 0) ~= 0 then + out[#out + 1] = i + end + end + return out +end + +function Engine.faintedBattlers(st) + local out = {} + for id = 0, 3 do + local b = State.battler(st, id) + if b and not State.isAbsent(st, id) and State.isFainted(b) then out[#out + 1] = id end + end + return out +end + +-- pokefirered/src/battle_script_commands.c:4870 +function Engine.markAbsent(st, id) + st.absent = st.absent or {} + st.absent[id] = true + Engine.cancelPendingAction(st, id) +end + +-- pokefirered/src/battle_util.c:1156 +function Engine.refreshAbsent(st) + local back = {} + if not (st and st.double and st.absent) then return back end + for id = 0, 3 do + if st.absent[id] and State.battler(st, id) and #Engine.replacementCandidates(st, id) > 0 then + st.absent[id] = nil + back[#back + 1] = id + end + end + return back +end + +-- pokefirered/src/battle_util.c:1144 +function Engine.pendingReplacements(st) + local out = {} + for _, id in ipairs(Engine.faintedBattlers(st)) do + local cands = Engine.replacementCandidates(st, id) + out[#out + 1] = { battler = id, side = State.sideOf(id), candidates = cands, noMons = #cands == 0 } + end + return out +end + +-- pokefirered/src/battle_script_commands.c:3113 +function Engine.expAwardOrder(st) + local out = {} + for _, id in ipairs(Engine.faintedBattlers(st)) do + if State.sideOf(id) == "enemy" then out[#out + 1] = id end + end + return out +end + +local function is_chosen_map(t) + if type(t) ~= "table" or t.kind ~= nil then return false end + for id = 0, 3 do + if t[id] ~= nil then return true end + end + return next(t) == nil +end + +local function forced_move(b, mv, slot) + if not b then return mv, slot end + if b.expLockedMove then return b.expLockedMove, slot end + if b.expEncoreMove and (b.expEncoreTurns or 0) > 0 then return b.expEncoreMove, slot end + if b.choicedMove and HeldItems.has(b, HeldItems.HOLD.CHOICE_BAND) and move_num(mv) ~= b.choicedMove + and move_num(mv) ~= MOVE_STRUGGLE then + local H = require("src.core.game3.battle.effects._helpers") + local cs = H.slotOf(b, b.choicedMove) + if cs and (tonumber(b.mon.pp and b.mon.pp[cs]) or 1) > 0 then return b.mon.moves[cs], cs end + end + return mv, slot +end + +local function is_meta_first(kind) return kind == "bag" or kind == "switch" or kind == "item" end + +local function order_hook(st, adapter, a, aMove, b, bMove, vanilla) + local G3 = require("src.mods.Gen3Compat") + return ModRuntime.call("battle.turn_order", function() + return vanilla() + end, a, aMove and G3.moveView(aMove) or nil, b, bMove and G3.moveView(bMove) or nil, + { battle = st, rng = adapter:rng(), playerMove = aMove and G3.moveName(move_num(aMove)), + enemyMove = bMove and G3.moveName(move_num(bMove)), + firstId = State.idOf(a), secondId = State.idOf(b) }) and true or false +end + +-- pokefirered/src/battle_main.c:3532 +function Engine.planTurnActions(st, adapter, chosen) + chosen = chosen or {} + st.chosen = chosen + st.monToSwitchInto = st.monToSwitchInto or {} + for id = 0, 3 do clear_turn_flags(State.battler(st, id)) end + Engine.refreshLinks(st) + if st.playerSide then st.playerSide.expFollowMe, st.playerSide.expFollowMeId = nil, nil end + if st.enemySide then st.enemySide.expFollowMe, st.enemySide.expFollowMeId = nil, nil end + local rows = {} + for id = 0, 3 do + local act = chosen[id] + local b = State.battler(st, id) + if b and State.isAbsent(st, id) then + -- pokefirered/src/battle_main.c:3115 + rows[id] = { battler = id, user = b, kind = "nothing", finished = true } + elseif act and b then + local row = {} + for k, v in pairs(act) do row[k] = v end + row.battler = id + row.user = b + row.kind = act.kind or "move" + if row.kind == "move" then + row.move, row.slot = forced_move(b, act.move, act.slot) + local mv = Moves.get(row.move) + row.targetType = tonumber(mv and mv.target) or 0 + end + rows[id] = row + end + end + -- pokefirered/src/battle_util.c:1223 + for id, row in pairs(rows) do + local b = row.user + local mv = b.expLockedMove or (row.kind == "move" and row.move) or nil + if b.rage and move_num(mv) ~= 99 then b.rage = nil end + end + local order = {} + local sortable = 0 + if rows[0] and rows[0].kind == "run" then + order[1] = 0 + for id = 1, 3 do if rows[id] then order[#order + 1] = id end end + else + for id = 0, 3 do + if rows[id] and is_meta_first(rows[id].kind) then order[#order + 1] = id end + end + for id = 0, 3 do + if rows[id] and not is_meta_first(rows[id].kind) then + order[#order + 1] = id + sortable = sortable + 1 + end + end + end + if sortable >= 2 then + -- pokefirered/src/battle_main.c:2926 + st.randomTurnNumber = roll(adapter, 0, 0xFFFF) + for i = 1, #order - 1 do + for j = i + 1, #order do + local r1, r2 = rows[order[i]], rows[order[j]] + if not is_meta_first(r1.kind) and not is_meta_first(r2.kind) then + local p1 = r1.kind == "move" and Moves.priority(r1.move) or 0 + local p2 = r2.kind == "move" and Moves.priority(r2.move) or 0 + local s1 = speed_of(r1.user, st, adapter) + local s2 = speed_of(r2.user, st, adapter) + local function vanilla_swap() + -- pokefirered/src/battle_main.c:3505 + if p1 ~= p2 then + return p1 < p2 + elseif s1 == s2 then + return roll(adapter, 0, 1) == 1 + end + return s1 < s2 + end + local swap + if ModRuntime.wantsHook("battle.turn_order") then + swap = not order_hook(st, adapter, r1.user, r1.kind == "move" and r1.move or nil, + r2.user, r2.kind == "move" and r2.move or nil, + function() return not vanilla_swap() end) + else + swap = vanilla_swap() + end + if swap then order[i], order[j] = order[j], order[i] end + end + end + end + end + -- pokefirered/src/battle_main.c:3669 + local focus = {} + for id = 0, 3 do + local row = rows[id] + if row and row.kind == "move" then + local mv = Moves.get(row.move) + if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not row.user.expLockedMove + and not adapter:hasStatus(row.user, "SLP") then + focus[#focus + 1] = row.user + end + end + end + st._focusPunchSetup = (#focus > 0) and focus or nil + local actions = {} + for i, id in ipairs(order) do + rows[id].user.expTurnOrder = i + actions[i] = rows[id] + end + st.turnOrder = order + st.turnActions = actions + return actions, nil +end + function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct) + if is_chosen_map(playerAct) and enemyAct == nil then + return Engine.planTurnActions(st, adapter, playerAct) + end + if st and st.double then + return Engine.planTurnActions(st, adapter, { + [0] = playerAct or Commands.playerAction(st, 1, 1), + [1] = enemyAct or Commands.enemyAction(st, 1), + }) + end playerAct = playerAct or Commands.playerAction(st, 1, 1) enemyAct = enemyAct or Commands.enemyAction(st) @@ -1672,19 +2359,50 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct) if st.enemy and st.enemy.rage and move_num(eChosen) ~= 99 then st.enemy.rage = nil end local actions = {} + local enemyMeta = enemyAct.kind == "switch" or enemyAct.kind == "item" + local function enemy_meta_row() + local row = {} + for k, v in pairs(enemyAct) do row[k] = v end + row.user, row.battler = st.enemy, 1 + return row + end if playerAct.kind == "run" or playerAct.kind == "bag" or playerAct.kind == "switch" then if st.player then st.player.expTurnOrder = 1 end - if enemyAct.kind == "move" then + if enemyMeta then + -- pokefirered/src/battle_main.c:3586 + if st.enemy then st.enemy.expTurnOrder = 2 end + actions[#actions + 1] = enemy_meta_row() + elseif enemyAct.kind == "move" then if st.enemy then st.enemy.expTurnOrder = 2 end actions[#actions + 1] = { user = st.enemy, target = st.player, move = enemyAct.move, slot = enemyAct.slot, + battler = 1, kind = "move", } end + st.turnOrder = { 0, 1 } + st.turnActions = actions return actions, playerAct end local pMove, pSlot = playerAct.move, playerAct.slot + if enemyMeta then + -- pokefirered/src/battle_main.c:3586 + pMove, pSlot = forced_move(st.player, pMove, pSlot) + if st.enemy then st.enemy.expTurnOrder = 1 end + if st.player then st.player.expTurnOrder = 2 end + actions[1] = enemy_meta_row() + actions[2] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" } + local mv = Moves.get(pMove) + st._focusPunchSetup = nil + if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not st.player.expLockedMove + and not adapter:hasStatus(st.player, "SLP") then + st._focusPunchSetup = { st.player } + end + st.turnOrder = { 1, 0 } + st.turnActions = actions + return actions, nil + end local eMove, eSlot = enemyAct.move, enemyAct.slot local function forced(b, mv, slot) if not b then return mv, slot end @@ -1707,13 +2425,19 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct) local pSpe = speed_of(st.player, st, adapter) local eSpe = speed_of(st.enemy, st, adapter) -- pokefirered/src/battle_main.c:3400 + local function vanilla_first() + if pPri ~= ePri then + return pPri > ePri + elseif pSpe ~= eSpe then + return pSpe > eSpe + end + return roll(adapter, 0, 1) == 0 + end local playerFirst - if pPri ~= ePri then - playerFirst = pPri > ePri - elseif pSpe ~= eSpe then - playerFirst = pSpe > eSpe + if ModRuntime.wantsHook("battle.turn_order") then + playerFirst = order_hook(st, adapter, st.player, pMove, st.enemy, eMove, vanilla_first) else - playerFirst = roll(adapter, 0, 1) == 0 + playerFirst = vanilla_first() end -- pokefirered/src/battle_main.c:3682 local focus = {} @@ -1727,13 +2451,16 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct) st._focusPunchSetup = (#focus > 0) and focus or nil if playerFirst then st.player.expTurnOrder, st.enemy.expTurnOrder = 1, 2 - actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot } - actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot } + actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" } + actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" } + st.turnOrder = { 0, 1 } else st.player.expTurnOrder, st.enemy.expTurnOrder = 2, 1 - actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot } - actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot } + actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" } + actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" } + st.turnOrder = { 1, 0 } end + st.turnActions = actions return actions, nil end @@ -1796,6 +2523,11 @@ function Engine.checkEnd(st, adapter) if st.enemy and st.foeParty then State.syncBattlerToParty(st.enemy, st.foeParty) end + if st.double then + local b2, b3 = State.battler(st, 2), State.battler(st, 3) + if b2 and st.playerParty then State.syncBattlerToParty(b2, st.playerParty) end + if b3 and st.foeParty then State.syncBattlerToParty(b3, st.foeParty) end + end local playerAlive = Engine.hasLivingMons(st.playerParty) if not playerAlive then diff --git a/src/core/game3/battle/exp_seq.lua b/src/core/game3/battle/exp_seq.lua index 48b5da0c..ab40205a 100644 --- a/src/core/game3/battle/exp_seq.lua +++ b/src/core/game3/battle/exp_seq.lua @@ -100,11 +100,14 @@ function ExpSeq.begin(awards, pushMsg, thenMsgs, opts) local gained = result.gained or entry.amount or 0 local pi = entry.partyIndex or 1 local isBench = (entry.battler == nil) + local key = "player" + if opts.double and entry.battler and entry.battler.id ~= nil then key = entry.battler.id end if gained > 0 then -- pokefirered/src/battle_message.c:53 add("msg", { text = name .. " gained" .. (entry.boosted and " a boosted" or "") .. "\n" .. tostring(gained) .. " EXP. Points!" }) for _, step in ipairs(result.steps or {}) do - if not isBench then + -- pokefirered/src/battle_controller_player.c:1034 + if not isBench and not opts.double then add("exp", { side = "player", level = step.level, @@ -115,7 +118,7 @@ function ExpSeq.begin(awards, pushMsg, thenMsgs, opts) if step.grewTo then ExpSeq._leveled[pi] = true add("level", { - side = "player", + side = key, isBench = isBench, mon = mon, level = step.grewTo, @@ -215,10 +218,14 @@ local function run_step(step) -- pokefirered/src/battle_controller_player.c:1143 d._lvlAnim = true local side = d.side or "player" + local bid = (type(side) == "number") and side or nil + if bid then side = State.sideOf(bid) end ExpSeq._lvlAnimWait = true Anim.launchSpecial("LVL_UP", { attackerSide = side, targetSide = side, + attackerId = bid, + targetId = bid, onEnd = function() ExpSeq._lvlAnimWait = false end, }) return diff --git a/src/core/game3/battle/experience.lua b/src/core/game3/battle/experience.lua index 9e4f3651..5d5d6d01 100644 --- a/src/core/game3/battle/experience.lua +++ b/src/core/game3/battle/experience.lua @@ -4,6 +4,7 @@ local Pokemon = require("src.core.game3.pokemon") local SummaryData = require("src.core.game3.summary_data") +local ModRuntime = require("src.mods.Runtime") local Experience = {} @@ -164,6 +165,19 @@ function Experience.apply(mon, amount) curExp = nextThresh levels[#levels + 1] = curLevel apply_level_stats(mon, curLevel) + -- pokefirered/src/battle_script_commands.c:3298 + if ModRuntime.wants("pokemon.level_up") then + local G3 = require("src.mods.Gen3Compat") + local learnable, learnableIds = {}, {} + for _, mv in ipairs(Pokemon.movesLearnedAt(tonumber(mon.species or mon.speciesId), curLevel)) do + learnable[#learnable + 1] = G3.moveName(mv) + learnableIds[#learnableIds + 1] = mv + end + ModRuntime.emit("pokemon.level_up", { + mon = mon, level = curLevel, prevLevel = curLevel - 1, + learnable = learnable, learnableIds = learnableIds, + }) + end local newStats = get_mon_stats(mon) steps[#steps].hp = tonumber(mon.hp) steps[#steps].maxHp = tonumber(mon.maxHp) @@ -236,9 +250,27 @@ function Experience.awardFoe(st, foeBattler, opts) local sentIn = {} if opts.partyIndices then for _, pi in ipairs(opts.partyIndices) do sentIn[pi] = true end + elseif st.double and foeBattler.participants then + -- pokefirered/src/battle_script_commands.c:3123 + for pi in pairs(foeBattler.participants) do sentIn[pi] = true end elseif st.player and st.player.mon and (tonumber(st.player.mon.hp) or 0) > 0 then sentIn[st.player.partyIndex or 1] = true end + local b0 = st.player + local b2 = st.double and st.battlers and st.battlers[2] or nil + local absent = st.absent or {} + local function on_field(pi) + if b0 and b0.partyIndex == pi and not absent[0] then return b0 end + if b2 and b2.partyIndex == pi and not absent[2] then return b2 end + return nil + end + -- pokefirered/src/battle_script_commands.c:3248 + local function getter_id(pi) + if not st.double then return 0 end + if b2 and b2.partyIndex == pi and not absent[2] then return 2 end + if not absent[0] then return 0 end + return 2 + end local party = st.playerParty or {} local function alive(mon) return mon and (tonumber(mon.species or mon.speciesId) or 0) ~= 0 and (tonumber(mon.hp) or 0) > 0 @@ -273,20 +305,53 @@ function Experience.awardFoe(st, foeBattler, opts) local share = mon and has_share(mon) if mon and (sentIn[pi] or share) and (tonumber(mon.level) or 1) < Experience.MAX_LEVEL and alive(mon) then local per = opts.getOpts and opts.getOpts(mon, pi) or Experience.recipientOpts(st, mon) - local amount = sentIn[pi] and exp or 0 - if share then amount = amount + shareExp end - if per.luckyEgg then amount = math.floor(amount * 150 / 100) end - if isTrainer then amount = math.floor(amount * 150 / 100) end - if per.traded then amount = math.floor(amount * 150 / 100) end + local function vanilla_amount() + local amount = sentIn[pi] and exp or 0 + if share then amount = amount + shareExp end + if per.luckyEgg then amount = math.floor(amount * 150 / 100) end + if isTrainer then amount = math.floor(amount * 150 / 100) end + if per.traded then amount = math.floor(amount * 150 / 100) end + return amount + end + local amount + -- pokefirered/src/battle_script_commands.c:3230 + if ModRuntime.wantsHook("exp.gain") then + local G3 = require("src.mods.Gen3Compat") + amount = ModRuntime.call("exp.gain", function() return vanilla_amount() end, { + defeatedDef = G3.speciesView(foeSpecies), level = foeLevel, isTrainer = isTrainer, + participants = viaSentIn, traded = per.traded, luckyEgg = per.luckyEgg, + expShare = share and true or false, mon = mon, index = pi, + battle = st, loser = foeBattler, + }) + amount = math.max(0, math.floor(tonumber(amount) or 0)) + else + amount = vanilla_amount() + end local result = Experience.apply(mon, amount) - if st.player and st.player.partyIndex == pi then + local fieldB + if st.double then + fieldB = on_field(pi) + if fieldB then + fieldB.mon = mon + fieldB.fainted = (tonumber(mon.hp) or 0) <= 0 + end + elseif st.player and st.player.partyIndex == pi then st.player.mon = mon st.player.fainted = (tonumber(mon.hp) or 0) <= 0 + fieldB = st.player + end + -- pokefirered/src/battle_script_commands.c:3278 + if ModRuntime.wants("battle.exp_gained") then + ModRuntime.emit("battle.exp_gained", { + battle = st, mon = mon, gained = result.gained, levels = result.levels, + index = pi, battler = fieldB, battlerId = getter_id(pi), + }) end out[#out + 1] = { mon = mon, partyIndex = pi, - battler = (st.player and st.player.partyIndex == pi) and st.player or nil, + battler = fieldB, + expGetterBattlerId = getter_id(pi), amount = amount, boosted = per.traded and true or false, result = result, diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index b62f9d82..4ba541b2 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -16,6 +16,23 @@ local Healthbox = {} Healthbox.ENEMY_CENTER = { x = 44, y = 30 } Healthbox.PLAYER_CENTER = { x = 158, y = 88 } +-- pokefirered/src/battle_interface.c:726 +Healthbox.CENTERS = { + [false] = { [0] = Healthbox.PLAYER_CENTER, [1] = Healthbox.ENEMY_CENTER }, + [true] = { + [0] = { x = 159, y = 75 }, + [1] = { x = 44, y = 19 }, + [2] = { x = 171, y = 100 }, + [3] = { x = 32, y = 44 }, + }, +} + +function Healthbox.center(st, id) + id = tonumber(id) or 0 + local t = Healthbox.CENTERS[(st and st.double) and true or false] + return t[id] or t[id % 2] +end + local function c5(v) return math.floor(v * 255 / 31 + 0.5) / 255 end @@ -39,9 +56,9 @@ local HB_FEMALE = { shadow = { 156 / 255, 65 / 255, 57 / 255, 1 }, } --- Inner cream right edge of assembled sheets (exclusive text end X). -local PLAYER_TEXT_RIGHT = 96 -local ENEMY_TEXT_RIGHT = 89 +-- pokefirered/src/battle_interface.c:773 +local PLAYER_LVL_X = 72 +local ENEMY_LVL_X = 64 -- pret AddTextPrinterAndCreateWindowOnHealthbox(..., y=3) for nick / level. local TEXT_Y = 3 @@ -168,12 +185,11 @@ local function draw_name_gender(name, gender, x, y) end end ---- Pret UpdateLvlInHealthbox: {LV_2}+digits, right-aligned in the level slot. -local function draw_level(lv, boxX, y, textRight) - local digits = tostring(lv or 1) +-- pokefirered/src/battle_interface.c:759 +local function draw_level(lv, boxX, y, winX) + local digits = tostring(math.max(0, math.min(999, math.floor(tonumber(lv) or 1)))) local lvW = FrlgFont.advance(FrlgFont.CHAR_LV_2, { small = true }) - local digW = FrlgFont.measure(digits, { small = true }) - local x = boxX + textRight - (lvW + digW) + local x = boxX + winX + 5 * (3 - #digits) FrlgFont.drawGlyph(FrlgFont.CHAR_LV_2, x, y, small_opts(HB_TEXT)) FrlgFont.draw(digits, x + lvW, y, small_opts(HB_TEXT)) end @@ -229,16 +245,140 @@ local function set_level_up_shader(blend) return true end -function Healthbox.draw(side, battler) +local function live_st() + local Battle = package.loaded["src.core.game3.battle"] + return Battle and Battle._st +end + +local function anim_key(id) + if id == 0 then return "player" elseif id == 1 then return "enemy" end + return id +end + +local function stage_entry(tbl, id) + if type(tbl) ~= "table" then return nil end + local e = tbl[id] + if e == nil then e = tbl[anim_key(id)] end + return e +end + +-- pokefirered/src/battle_interface.c:992 +function Healthbox.hpTextShown(st, id) + return (st and st.double and st._hpNumbersNoBars and st._hpNumbersNoBars[id]) and true or false +end + +-- pokefirered/src/battle_interface.c:992 +function Healthbox.swapHpBarsWithHpText(st) + if not (st and st.double) then return end + st._hpNumbersNoBars = st._hpNumbersNoBars or {} + for _, id in ipairs({ 0, 2 }) do + if st.battlers and st.battlers[id] then + st._hpNumbersNoBars[id] = not st._hpNumbersNoBars[id] + end + end +end + +local BAR_FG = { c5(7), c5(7), c5(7), 1 } +local BAR_SHADOW = { c5(26), c5(25), c5(23), 1 } +local BOTTOM_RIGHT_CORNER_HP_AS_TEXT = 116 + +-- pokefirered/src/battle_interface.c:864 +local function draw_hp_text_doubles(bx, by, cur, maxHp) + local opts = { small = true, colors = { fg = BAR_FG, shadow = BAR_SHADOW } } + local left = string.format("%3d/", math.max(0, math.min(999, math.floor(cur or 0)))) + local right = string.format("%3d", math.max(0, math.min(999, math.floor(maxHp or 0)))) + if BattleChrome.hasHpBoldDigits() then + for i = 1, 4 do + local ch = left:sub(i, i) + if ch ~= " " then BattleChrome.drawHpBoldChar(ch, bx + 8 * i, by) end + end + for i = 1, 3 do + local ch = right:sub(i, i) + if ch ~= " " then BattleChrome.drawHpBoldChar(ch, bx + 32 + 8 * i, by) end + end + return + end + -- pokefirered/src/text.c:1268 + local ty = by - 3 + for i = 1, 4 do + local ch = left:sub(i, i) + if ch ~= " " then FrlgFont.draw(ch, bx + 8 * i, ty, opts) end + end + for i = 1, 3 do + local ch = right:sub(i, i) + if ch ~= " " then FrlgFont.draw(ch, bx + 32 + 8 * i, ty, opts) end + end +end + +local function draw_doubles(id, battler, st, opts) + local Anim = require("src.core.game3.battle.anim") + local stage = Anim.stage and Anim.stage() + local hb = stage and stage_entry(stage.healthbox, id) + if hb and hb.visible == false then return end + local ox = ((hb and hb.ox) or 0) + ((opts and opts.ox) or 0) + local oy = ((hb and hb.oy) or 0) + ((opts and opts.oy) or 0) + local isPlayer = (id % 2) == 0 + local c = Healthbox.center(st, id) + local cx, cy = c.x + ox, c.y + oy + local tlX, tlY = cx - 32, cy - 16 + + local lvl = isPlayer and love and love.graphics and set_level_up_shader(hb and hb.levelUpBlend) + BattleChrome.drawDoublesBox(isPlayer, tlX, tlY) + if lvl then love.graphics.setShader() end + erase_placeholder_ink(tlX, tlY, isPlayer and PLAYER_PLACEHOLDER_INK or ENEMY_PLACEHOLDER_INK) + + local SummaryChrome = require("src.ui.game3.summary_chrome") + local SummaryData = require("src.core.game3.summary_data") + local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)) + local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp }) + local statused = ailment >= 1 and ailment <= 6 + local hpText = isPlayer and Healthbox.hpTextShown(st, id) + + local barCx = cx + (isPlayer and 16 or 8) + local bx, by = hp_bar_top_left(barCx, cy) + local hp, maxHp = hp_values(id, battler) + if hpText then + draw_hp_text_doubles(bx, by, hp, maxHp) + -- pokefirered/src/battle_interface.c:925 + BattleChrome.drawElementTile(BOTTOM_RIGHT_CORNER_HP_AS_TEXT, tlX + 96, tlY + 16, true) + else + BattleChrome.drawHpBar(bx, by, hp, maxHp, statused) + end + + local name = State.displayName(battler) + local lv = battler.mon and battler.mon.level or 1 + local p = Anim.present and Anim.present(id) + if p and p.displayLevel then lv = p.displayLevel end + local ty = tlY + TEXT_Y + local gender = healthbox_gender(battler.mon) + -- pokefirered/src/battle_interface.c:1531 + draw_name_gender(name, gender, tlX + (isPlayer and 16 or 8), ty) + draw_level(lv, tlX, ty, isPlayer and PLAYER_LVL_X or ENEMY_LVL_X) + if statused then + -- pokefirered/src/battle_interface.c:1608 + SummaryChrome.drawStatusIcon(tlX + (isPlayer and 10 or 2), tlY + 16, ailment) + end +end + +function Healthbox.draw(side, battler, opts) if not battler then return end + if type(side) == "number" then + local st = (opts and opts.st) or live_st() + if st and st.double then + return draw_doubles(side, battler, st, opts) + end + side = (side % 2 == 0) and "player" or "enemy" + end local Anim = require("src.core.game3.battle.anim") local stage = Anim.stage and Anim.stage() local hb = stage and stage.healthbox and stage.healthbox[side] if hb and hb.visible == false then return end - local ox = (hb and hb.ox) or 0 + local ox = ((hb and hb.ox) or 0) + ((opts and opts.ox) or 0) + local oy = (opts and opts.oy) or 0 local isPlayer = side == "player" - local c = isPlayer and Healthbox.PLAYER_CENTER or Healthbox.ENEMY_CENTER + local c0 = isPlayer and Healthbox.PLAYER_CENTER or Healthbox.ENEMY_CENTER + local c = { x = c0.x, y = c0.y + oy } local tlX, tlY if isPlayer then tlX, tlY = player_top_left(c.x + ox, c.y) @@ -255,7 +395,16 @@ function Healthbox.draw(side, battler) local barCx, barCy = hp_bar_center(side, c.x + ox, c.y) local bx, by = hp_bar_top_left(barCx, barCy) - BattleChrome.drawHpBar(bx, by, hp_values(side, battler)) + local statusBorder = false + if not isPlayer then + local SummaryData = require("src.core.game3.summary_data") + local st1 = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)) + local a = SummaryData.statusAilment({ status = st1, hp = battler.mon and battler.mon.hp }) + -- pokefirered/src/battle_interface.c:1668 + statusBorder = a >= 1 and a <= 6 + end + local hpNow, hpMax = hp_values(side, battler) + BattleChrome.drawHpBar(bx, by, hpNow, hpMax, statusBorder) local name = State.displayName(battler) local lv = battler.mon and battler.mon.level or 1 @@ -268,7 +417,7 @@ function Healthbox.draw(side, battler) end local ty = tlY + TEXT_Y - local textRight = isPlayer and PLAYER_TEXT_RIGHT or ENEMY_TEXT_RIGHT + local lvlX = isPlayer and PLAYER_LVL_X or ENEMY_LVL_X local gender = healthbox_gender(battler.mon) -- pokefirered/src/battle_interface.c:1506 local Battle = package.loaded["src.core.game3.battle"] @@ -292,9 +441,10 @@ function Healthbox.draw(side, battler) if isPlayer then draw_name_gender(name, gender, tlX + 16, ty) - draw_level(lv, tlX, ty, textRight) + draw_level(lv, tlX, ty, lvlX) if ailment >= 1 and ailment <= 6 then - SummaryChrome.drawStatusIcon(tlX + 16, tlY + 19, ailment) + -- pokefirered/src/battle_interface.c:1608 + SummaryChrome.drawStatusIcon(tlX + 10, tlY + 24, ailment) end local mon = battler.mon if mon then @@ -315,9 +465,10 @@ function Healthbox.draw(side, battler) BattleChrome.drawExpBar(tlX + 32, tlY + 32, expRatio) else draw_name_gender(name, gender, tlX + 8, ty) - draw_level(lv, tlX, ty, textRight) + draw_level(lv, tlX, ty, lvlX) if ailment >= 1 and ailment <= 6 then - SummaryChrome.drawStatusIcon(tlX - 2, tlY + 17, ailment) + -- pokefirered/src/battle_interface.c:1614 + SummaryChrome.drawStatusIcon(tlX + 2, tlY + 16, ailment) end end end diff --git a/src/core/game3/battle/held_items.lua b/src/core/game3/battle/held_items.lua index b72e7da7..00215dba 100644 --- a/src/core/game3/battle/held_items.lua +++ b/src/core/game3/battle/held_items.lua @@ -360,7 +360,9 @@ end -- pokefirered/src/battle_util.c:2851 function HeldItems.moveEnd(ad) local any = false - for _, b in ipairs({ ad._st.player, ad._st.enemy }) do + local list = { ad._st.player, ad._st.enemy } + if ad._st.double then list = ad:activeBattlers() end + for _, b in ipairs(list) do if b and not ad:isFainted(b) then local he, _, item = HeldItems.of(b) local did = false diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 64b0982a..7f61da98 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -20,6 +20,7 @@ local LearnMove = require("src.core.game3.battle.learn_move") local Task = require("src.core.game3.task") local Trainers = require("src.core.game3.scripting.trainers") local SwitchSeq = require("src.core.game3.battle.switch_seq") +local ModRuntime = require("src.mods.Runtime") local Battle = {} @@ -40,6 +41,8 @@ Battle._residualEvents = nil Battle._residualIndex = 1 Battle._residualStepState = nil +local D = {} + -- pokefirered/src/battle_interface.c:2168 local function hp_bar_red(hp, maxHp) local ok, BattleChrome = pcall(require, "src.ui.game3.battle_chrome") @@ -82,6 +85,16 @@ local function update_low_hp_music() end end +local function party_menu_input(PartyMenu, input) + local SummaryMenu = package.loaded["src.ui.game3.summary_menu"] + if SummaryMenu and SummaryMenu.isOpen and SummaryMenu.isOpen() then + if SummaryMenu.update then SummaryMenu.update(1 / 60) end + SummaryMenu.handleInput(input) + return + end + PartyMenu.handleInput(input) +end + -- pokefirered/src/battle_script_commands.c:1108 local function seq_push(text, wait) local t = tostring(text or "") @@ -185,6 +198,43 @@ local function overworld_weather() return nil end +local function action_view(act) + if type(act) ~= "table" then return nil end + local G3 = require("src.mods.Gen3Compat") + local num = act.kind == "move" and act.move ~= nil and Engine.moveNum(act.move) or nil + local name = num and G3.moveName(num) or nil + return { + kind = act.kind, id = name, move = name, moveNum = num, slot = act.slot, + index = act.kind == "switch" and act.slot or nil, + item = act.itemId and G3.itemName(act.itemId) or nil, itemId = act.itemId, + battler = act.battler, target = act.target, + } +end + +-- pokefirered/src/battle_main.c:3532 +local function open_turn(st, playerAct, enemyAct, chosen) + st._modTurnOpen = true + if not ModRuntime.wants("battle.turn_started") then return end + local actions + if chosen then + actions = {} + for id = 0, 3 do actions[id] = action_view(chosen[id]) end + end + ModRuntime.emit("battle.turn_started", { + battle = st, turn = st.turn, playerAction = action_view(playerAct), + enemyAction = action_view(enemyAct), actions = actions, + }) +end + +-- pokefirered/src/battle_main.c:2953 +local function close_turn(st) + if not (st and st._modTurnOpen) then return end + st._modTurnOpen = nil + if ModRuntime.wants("battle.turn_ended") then + ModRuntime.emit("battle.turn_ended", { battle = st, turn = st.turn }) + end +end + function Battle.isActive() return Battle._active == true end @@ -206,7 +256,9 @@ local function finish(result) Battle._residualIndex = 1 Battle._residualStepState = nil Battle._pendingChoice = nil + D.reset() local st = Battle._st + close_turn(st) if st then st.over = true st.result = result or st.result or "win" @@ -263,13 +315,17 @@ function Battle.start(opts) foeMon = foeParty[1] end Moves.loadRomPack(opts.cache) + local double = (opts.double == true) and not opts.wild local st = State.new({ wild = opts.wild, + double = double or nil, + playerIndex = double and State.firstUsable(playerParty) or nil, playerParty = playerParty, foeMon = foeMon, foeParty = foeParty, rng = opts.rng, }) + D.reset() st.ghostBattle = ghost_battle(opts, st) if st.ghostBattle then -- pokefirered/src/battle_setup.c:326 @@ -292,9 +348,11 @@ function Battle.start(opts) and not (st.ghostBattle and not st.ghostUnveiled) then local Dex = require("src.core.game3.dex") Dex.setSeen(session.dex, foeMon.species or foeMon.speciesId) + local b3 = st.double and not st.absent[3] and st.battlers[3] + if b3 and b3.mon then Dex.setSeen(session.dex, b3.mon.species or b3.mon.speciesId) end end end - Anim.reset({ headless = opts.headless }) + Anim.reset({ headless = opts.headless, double = st.double }) AnimSeq.reset() CatchSeq.reset() ExpSeq.reset() @@ -361,6 +419,11 @@ function Battle.start(opts) or { 0, 0, 0, 0 } st.playerGender = playerGender st.overworldWeather = opts.overworldWeather or overworld_weather() + do + -- pokefirered/src/battle_controllers.c:59 + local okAi, Ai = pcall(require, "src.core.game3.battle.ai") + if okAi and Ai and Ai.battleStart then Ai.battleStart(st) end + end -- Battle BGM (if not already playing from transition start) do @@ -412,14 +475,20 @@ function Battle.start(opts) for _, t in ipairs(IntroSeq.headlessGhostIntro(st)) do Ui.push(t) end elseif st.wild then Ui.push("Wild " .. ename .. " appeared!") + elseif st.double then + for _, t in ipairs(D.headlessIntro(st, trainerId, rivalName)) do Ui.push(t) end else local strings = Trainers.introStrings(trainerId, ename, { rivalName = rivalName }) Ui.push(strings.wants) Ui.push(strings.sentOut) end - Ui.push("Go! " .. State.displayName(st.player) .. "!") + if not st.double then + Ui.push("Go! " .. State.displayName(st.player) .. "!") + end end + if opts.onStarted then opts.onStarted(st) end + if opts.headless and opts.autoFight ~= false then Battle._auto = true Battle.runToEnd() @@ -497,9 +566,11 @@ end local function begin_turn_with(playerAct) local st = Battle._st local ad = Battle._adapter + if st and st.double then return D.startSelection() end st.turn = st.turn + 1 local enemyAct = Commands.enemyAction(st) local actions, meta = Engine.planTurnFromActions(st, ad, playerAct, enemyAct) + open_turn(st, playerAct, enemyAct, nil) Battle._actions = actions Battle._actionI = 1 Battle._metaAct = meta @@ -649,6 +720,107 @@ local function handle_player_faint(opts) }) end +local function begin_trainer_win(st) + Battle._pendingEnd = "win" + local Audio = require("src.core.game3.audio") + local role = (st and st.wild) and "victoryWild" or "victoryTrainer" + local song = Audio.role(role) or ((st and st.wild) and 311 or 310) + Audio.playSong(song) + + local pname = st.playerName or "PLAYER" + local function push_defeated() + local trName = (st.trainerClassName and st.trainerClassName ~= "") + and (st.trainerClassName .. " " .. (st.trainerName or "")) + or (st.trainerName or "TRAINER") + -- pokefirered/data/battle_scripts_1.s:2912 + Ui.push(string.format("%s defeated\n%s!", pname, trName)) + end + local function push_lose_text_and_money() + local Trainers = require("src.core.game3.scripting.trainers") + local dialogs = Trainers.dialogs(st.trainerId) + local defeatSpeech = st.defeatText or (dialogs and dialogs.defeat) + -- pokefirered/data/battle_scripts_1.s:2915 + if defeatSpeech and defeatSpeech ~= "" then + Ui.push(defeatSpeech) + end + local Prize = require("src.core.game3.battle.prize") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + if session then + local info = Trainers.info(st.trainerId) + local lastLevel = info and tonumber(info.lastLevel) + if not lastLevel or lastLevel < 1 then + lastLevel = st.enemy and st.enemy.mon and tonumber(st.enemy.mon.level) or 1 + end + local gained = Prize.awardTrainerWin(session, st.trainerId, { + lastLevel = lastLevel, + double = st.double or false, + moneyMultiplier = st.moneyMultiplier or 1, + }) + if gained > 0 then + Ui.push(Prize.moneyMessage(session.name or pname, gained)) + end + end + end + + local function give_payday_money_and_pickup() + local Prize = require("src.core.game3.battle.prize") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = (Runtime and Runtime.getSession and Runtime.getSession()) or st.session + -- pokefirered/data/battle_scripts_1.s:2920 + local bonus = Prize.payDay(session, st.payDayCoins, { moneyMultiplier = st.moneyMultiplier or 1 }) + if bonus > 0 then + Ui.push(Prize.payDayMessage((session and session.name) or pname, bonus)) + end + Prize.pickup((session and session.party) or st.playerParty) + end + + if not st.wild and st.trainerId and not Battle._headless then + push_defeated() + SwitchSeq.beginTrainerSlideIn(st, { + headless = false, + onDone = function() + push_lose_text_and_money() + give_payday_money_and_pickup() + begin_evo_or_end() + end, + }) + Battle._phase = "switching" + else + if not st.wild and st.trainerId then + push_defeated() + push_lose_text_and_money() + end + -- pokefirered/src/battle_main.c:3764 + give_payday_money_and_pickup() + begin_evo_or_end() + end +end + +local function push_awards_headless(awards) + local Pokemon = require("src.core.game3.pokemon") + for _, entry in ipairs(awards or {}) do + local r = entry.result or {} + if (r.gained or 0) > 0 then + local name = entry.battler and State.displayName(entry.battler) + or Pokemon.displayMonName(entry.mon) + Ui.push(name .. " gained" .. (entry.boosted and " a boosted" or "") .. "\n" .. tostring(r.gained) .. " EXP. Points!") + for _, lv in ipairs(r.levels or {}) do + Ui.push(name .. " grew to\nLV. " .. tostring(lv) .. "!") + Battle._leveledUp[entry.partyIndex or 1] = true + end + for _, lv in ipairs(r.levels or {}) do + for _, mv in ipairs(Pokemon.movesLearnedAt( + tonumber(entry.mon and entry.mon.species), lv)) do + if Pokemon.teachMove(entry.mon, mv) then + Ui.push(name .. " learned\n" .. Pokemon.moveName(mv) .. "!") + end + end + end + end + end +end + local function handle_enemy_faint(opts) opts = opts or {} local st = Battle._st @@ -683,80 +855,7 @@ local function handle_enemy_faint(opts) local onAwardsFinished = function() if not nextEnemyIdx then - Battle._pendingEnd = "win" - local Audio = require("src.core.game3.audio") - local role = (st and st.wild) and "victoryWild" or "victoryTrainer" - local song = Audio.role(role) or ((st and st.wild) and 311 or 310) - Audio.playSong(song) - - local pname = st.playerName or "PLAYER" - local function push_defeated() - local trName = (st.trainerClassName and st.trainerClassName ~= "") - and (st.trainerClassName .. " " .. (st.trainerName or "")) - or (st.trainerName or "TRAINER") - -- pokefirered/data/battle_scripts_1.s:2912 - Ui.push(string.format("%s defeated\n%s!", pname, trName)) - end - local function push_lose_text_and_money() - local Trainers = require("src.core.game3.scripting.trainers") - local dialogs = Trainers.dialogs(st.trainerId) - local defeatSpeech = st.defeatText or (dialogs and dialogs.defeat) - -- pokefirered/data/battle_scripts_1.s:2915 - if defeatSpeech and defeatSpeech ~= "" then - Ui.push(defeatSpeech) - end - local Prize = require("src.core.game3.battle.prize") - local Runtime = package.loaded["src.core.game3.runtime"] - local session = Runtime and Runtime.getSession and Runtime.getSession() - if session then - local info = Trainers.info(st.trainerId) - local lastLevel = info and tonumber(info.lastLevel) - if not lastLevel or lastLevel < 1 then - lastLevel = st.enemy and st.enemy.mon and tonumber(st.enemy.mon.level) or 1 - end - local gained = Prize.awardTrainerWin(session, st.trainerId, { - lastLevel = lastLevel, - double = st.double or false, - moneyMultiplier = st.moneyMultiplier or 1, - }) - if gained > 0 then - Ui.push(Prize.moneyMessage(session.name or pname, gained)) - end - end - end - - local function give_payday_money_and_pickup() - local Prize = require("src.core.game3.battle.prize") - local Runtime = package.loaded["src.core.game3.runtime"] - local session = (Runtime and Runtime.getSession and Runtime.getSession()) or st.session - -- pokefirered/data/battle_scripts_1.s:2920 - local bonus = Prize.payDay(session, st.payDayCoins, { moneyMultiplier = st.moneyMultiplier or 1 }) - if bonus > 0 then - Ui.push(Prize.payDayMessage((session and session.name) or pname, bonus)) - end - Prize.pickup((session and session.party) or st.playerParty) - end - - if not st.wild and st.trainerId and not Battle._headless then - push_defeated() - SwitchSeq.beginTrainerSlideIn(st, { - headless = false, - onDone = function() - push_lose_text_and_money() - give_payday_money_and_pickup() - begin_evo_or_end() - end, - }) - Battle._phase = "switching" - else - if not st.wild and st.trainerId then - push_defeated() - push_lose_text_and_money() - end - -- pokefirered/src/battle_main.c:3764 - give_payday_money_and_pickup() - begin_evo_or_end() - end + begin_trainer_win(st) else if opts.onFinished then send_out_enemy_next(nextEnemyIdx) @@ -787,27 +886,7 @@ local function handle_enemy_faint(opts) local hooks = choice_hooks() if Battle._headless then - local Pokemon = require("src.core.game3.pokemon") - for _, entry in ipairs(awards) do - local r = entry.result or {} - if (r.gained or 0) > 0 then - local name = entry.battler and State.displayName(entry.battler) - or Pokemon.displayMonName(entry.mon) - Ui.push(name .. " gained" .. (entry.boosted and " a boosted" or "") .. "\n" .. tostring(r.gained) .. " EXP. Points!") - for _, lv in ipairs(r.levels or {}) do - Ui.push(name .. " grew to\nLV. " .. tostring(lv) .. "!") - Battle._leveledUp[entry.partyIndex or 1] = true - end - for _, lv in ipairs(r.levels or {}) do - for _, mv in ipairs(Pokemon.movesLearnedAt( - tonumber(entry.mon and entry.mon.species), lv)) do - if Pokemon.teachMove(entry.mon, mv) then - Ui.push(name .. " learned\n" .. Pokemon.moveName(mv) .. "!") - end - end - end - end - end + push_awards_headless(awards) onAwardsFinished() return end @@ -872,8 +951,10 @@ end local function after_actions() local st = Battle._st local ad = Battle._adapter + if st and st.double then return D.afterActions() end if end_if_over() then return end local events = Engine.collectResidualEvents(st, ad) + close_turn(st) Battle._residualEvents = events Battle._residualIndex = 1 Battle._residualStepState = "start" @@ -904,6 +985,7 @@ end local function step_action() local st = Battle._st local ad = Battle._adapter + if st and st.double then return D.stepAction() end if end_if_over() then return end if Battle._metaAct then @@ -1108,6 +1190,8 @@ local function step_action() if not Battle._actions[Battle._actionI] then after_actions() end return end + if act.kind == "switch" and act.battler == 1 then return D.singleEnemySwitch(act) end + if act.kind == "item" then return D.enemyItem(act) end local uBattler = (type(act.user) == "string" and st and st[act.user]) or act.user local tBattler = (type(act.target) == "string" and st and st[act.target]) or act.target @@ -1147,6 +1231,8 @@ local function open_pending_choice() local st, ad = Battle._st, Battle._adapter local req = Battle._pendingChoice Battle._pendingChoice = nil + local pc = st.pendingChoice + local uid = (pc and pc.M and pc.M.user and pc.M.user.id) or 0 local function resume(slot) st.interactiveChoices = true local out = Engine.resumeChoice(st, ad, slot) @@ -1158,6 +1244,7 @@ local function open_pending_choice() else for _, t in ipairs(out or {}) do Ui.push(t) end Battle._phase = "actions" + if st.double and not Battle._pendingChoice then D.afterEach() end end end if Battle._headless or Battle._auto or not (req and req.kind == "baton_pass") then @@ -1170,12 +1257,21 @@ local function open_pending_choice() State.syncBattlerToParty(st.player, st.playerParty) if Ui.clearLinger then Ui.clearLinger() end Battle._phase = "switching" + if st.double and Ui.openPartyMenu then + return Ui.openPartyMenu(st, uid, { + forced = true, + validate = function(slot) return Commands.switchError(st, slot, true, uid) end, + onSelect = function(slot) resume(slot) end, + }) + end PartyMenu.show(st.playerParty or (session and session.party), session and session.move_overlay, { mode = "battle_faint", session = session, activeSlot = st.player.partyIndex, + activeSlots = st.double and D.activeSlots(st) or nil, + battlerId = st.double and uid or nil, battle = true, - validate = function(slot) return Commands.switchError(st, slot, true) end, + validate = function(slot) return Commands.switchError(st, slot, true, st.double and uid or nil) end, onSelect = function(slot) resume(slot) end, }) end @@ -1186,6 +1282,19 @@ local function after_anim_sequence() open_pending_choice() return end + if Battle._singleAfterAnim then + local scont = Battle._singleAfterAnim + Battle._singleAfterAnim = nil + if end_if_over() then return end + return scont() + end + if Battle._st and Battle._st.double then + local cont = Battle._dblAfterAnim + Battle._dblAfterAnim = nil + if cont then return cont() end + if end_if_over() then return end + return D.afterEach() + end if end_if_over() then return end if check_faints_and_end() then return @@ -1196,6 +1305,719 @@ local function after_anim_sequence() end end +local SEL_ORDER = { 0, 2 } + +local function has_flag(t, f) + return math.floor((tonumber(t) or 0) / f) % 2 == 1 +end + +function D.reset() + Battle._dblSel = nil + Battle._dblFaint = nil + Battle._dblSwitch = nil + Battle._dblAfterAnim = nil + Battle._dblLeveled = nil + Battle._singleAfterAnim = nil +end + +function D.activeSlots(st) + local out = {} + for _, id in ipairs(SEL_ORDER) do + local b = State.battler(st, id) + if b and not State.isAbsent(st, id) then out[#out + 1] = b.partyIndex end + end + return out +end + +function D.capture(fn) + local ad = Battle._adapter + if not ad then return {} end + local mark = ad:eventMark() + local prev = ad._say + ad._say = function() end + pcall(fn) + ad._say = prev + return ad:eventsSince(mark) +end + +-- pokefirered/src/battle_message.c:1591 +function D.headlessIntro(st, trainerId, rivalName) + local strings = Trainers.introStrings(trainerId, State.displayName(st.enemy), { rivalName = rivalName }) + local out = { strings.wants } + local b3 = not State.isAbsent(st, 3) and State.battler(st, 3) + if b3 then + local who = strings.sentOut:match("^(.-) sent\n") or "" + out[#out + 1] = who .. " sent\nout " .. State.displayName(st.enemy) .. " and " .. State.displayName(b3) .. "!" + else + out[#out + 1] = strings.sentOut + end + local b2 = not State.isAbsent(st, 2) and State.battler(st, 2) + if b2 then + out[#out + 1] = "Go! " .. State.displayName(st.player) .. " and\n" .. State.displayName(b2) .. "!" + else + out[#out + 1] = "Go! " .. State.displayName(st.player) .. "!" + end + return out +end + +-- pokefirered/src/battle_main.c:3125 +function D.lockedAction(st, id) + local b = State.battler(st, id) + if not b or not (b.expLockedMove or b.expMustRecharge) then return nil end + local mon = b.mon or {} + local slot = b.expLockedSlot or 1 + local mv = b.expLockedMove or b.lastMoveId or b.lastMove or (mon.moves and mon.moves[slot]) + return { kind = "move", battler = id, move = mv, slot = slot, target = st.moveTarget and st.moveTarget[id] } +end + +function D.autoAction(st, id) + local act = Commands.fightShortcut(st, id) + if act then + act.battler = id + return act + end + for slot = 1, 4 do + if Commands.moveUsable(st, slot, id) then return Commands.playerAction(st, 1, slot, id) end + end + return Commands.playerAction(st, 1, 1, id) +end + +-- pokefirered/src/battle_controller_player.c:447 +function D.targetPlan(st, id, cmd) + local b = State.battler(st, id) + local mv = Moves.get(cmd.move) + local tt = tonumber(mv and mv.target) or 0 + local num = tonumber(cmd.move) or tonumber(mv and mv.id) + if num == 174 then + local Types = require("src.core.game3.battle.types") + local ad = Battle._adapter + local ghost = ad and b and Types.ID and Types.ID.GHOST and ad:hasType(b, Types.ID.GHOST) + tt = ghost and 0 or 0x10 + end + local opposing = (id % 2 == 0) and 1 or 0 + local cursor = has_flag(tt, 0x10) and id or opposing + local can = not (has_flag(tt, 0x04) or has_flag(tt, 0x08) or has_flag(tt, 0x01) + or has_flag(tt, 0x20) or has_flag(tt, 0x40) or has_flag(tt, 0x10)) + local pp = b and b.mon and b.mon.pp and cmd.slot and tonumber(b.mon.pp[cmd.slot]) + if pp == 0 then + can = false + elseif not (has_flag(tt, 0x10) or has_flag(tt, 0x02)) then + local others = 0 + for oid = 0, 3 do + if oid ~= id and State.isPresent(st, oid) then others = others + 1 end + end + if others <= 1 then + -- pokefirered/src/pokemon.c:2700 + cursor = State.isAbsent(st, opposing) and State.PARTNER(opposing) or opposing + can = false + end + end + local start = cursor + if can then + if has_flag(tt, 0x10) or has_flag(tt, 0x02) then + start = id + elseif State.isAbsent(st, opposing) then + start = State.PARTNER(opposing) + else + start = opposing + end + end + return can, cursor, start, tt +end + +function D.startSelection() + local st = Battle._st + st.monToSwitchInto = {} + Battle._phase = "command" + Battle._dblSel = { chosen = {}, pos = 1 } + return D.advanceSelection() +end + +-- pokefirered/src/battle_main.c:3097 +function D.advanceSelection() + local st, sel = Battle._st, Battle._dblSel + if not sel then return end + while sel.pos <= #SEL_ORDER do + local id = SEL_ORDER[sel.pos] + local b = State.battler(st, id) + if not b or State.isAbsent(st, id) then + sel.pos = sel.pos + 1 + else + local locked = D.lockedAction(st, id) + if locked then + sel.chosen[id] = locked + sel.pos = sel.pos + 1 + elseif Battle._auto then + sel.chosen[id] = D.autoAction(st, id) + sel.pos = sel.pos + 1 + else + sel.active = id + st.activeBattler = id + return D.openMenu(id) + end + end + end + return D.finishSelection() +end + +-- pokefirered/src/battle_controller_player.c:286 +function D.cancelPartner() + local st, sel = Battle._st, Battle._dblSel + local c0 = sel and sel.chosen[0] + if c0 and c0.kind == "bag" then + local Catching = require("src.core.game3.battle.catching") + if not Catching.isBall(c0.itemId) then return end + end + pcall(function() + require("src.core.game3.audio").playSe(require("src.core.game3.se_ids").SE_SELECT) + end) + sel.chosen[0] = nil + if st.monToSwitchInto then st.monToSwitchInto[0] = nil end + sel.pos = 1 + return D.advanceSelection() +end + +function D.openMenu(id) + local sel = Battle._dblSel + return Ui.openMenu(id, { partnerAction = (id == 2 and sel) and sel.chosen[0] or nil }) +end + +function D.commit(cmd) + local sel = Battle._dblSel + if not sel then return end + sel.chosen[cmd.battler] = cmd + sel.pos = sel.pos + 1 + return D.advanceSelection() +end + +function D.onCommand(cmd) + local st, sel = Battle._st, Battle._dblSel + if not sel or not sel.active then return end + local id = sel.active + if cmd.kind == "cancel_partner" then + if id == 2 and not State.isAbsent(st, 0) then return D.cancelPartner() end + return D.openMenu(id) + end + local b = State.battler(st, id) + if cmd.battler ~= id then + if cmd.kind == "move" and cmd.slot and b and b.mon and b.mon.moves and b.mon.moves[cmd.slot] then + cmd.move = b.mon.moves[cmd.slot] + end + cmd.battler = id + end + if cmd.kind == "move" then + if cmd.target == nil and cmd.move ~= "STRUGGLE" then + local can, cursor, start = D.targetPlan(st, id, cmd) + if not can then + cmd.target = cursor + elseif Ui.chooseTarget then + sel.targeting = true + Ui.chooseTarget(st, id, cmd.slot, function(targetId) + sel.targeting = false + if targetId == nil then + if Ui.openMoveMenu then return Ui.openMoveMenu(id) end + return D.openMenu(id) + end + cmd.target = targetId + return D.commit(cmd) + end) + return + else + cmd.target = start + end + end + elseif cmd.kind == "switch" then + local err = cmd.slot and Commands.switchError(st, cmd.slot, false, id) + if err or not cmd.slot then + if err then Ui.push(err, function() D.openMenu(id) end) else D.openMenu(id) end + return + end + st.monToSwitchInto[id] = cmd.slot + end + return D.commit(cmd) +end + +function D.commandUpdate(input) + local sel = Battle._dblSel + if not sel then return D.startSelection() end + if sel.targeting then + if input then Ui.handleInput(input) end + return + end + local BagMenu = require("src.ui.game3.bag_menu") + if BagMenu.isOpen and BagMenu.isOpen() then + if input then BagMenu.handleInput(input) end + return + end + local PartyMenu = require("src.ui.game3.party_menu") + if PartyMenu.isOpen and PartyMenu.isOpen() then + if input then party_menu_input(PartyMenu, input) end + return + end + if Ui.selectionPump() then + local scmd = Ui.takeCommand() + if scmd then D.onCommand(scmd) end + return + end + if input then Ui.handleInput(input) end + local cmd = Ui.takeCommand() + if cmd then D.onCommand(cmd) end +end + +-- pokefirered/src/battle_main.c:3532 +function D.finishSelection() + local st, ad = Battle._st, Battle._adapter + local sel = Battle._dblSel + local chosen = sel and sel.chosen or {} + Battle._dblSel = nil + st.activeBattler = nil + for _, id in ipairs({ 1, 3 }) do + if State.battler(st, id) and not State.isAbsent(st, id) then + chosen[id] = Commands.enemyAction(st, id) + end + end + st.turn = st.turn + 1 + Battle._actions = Engine.planTurnActions(st, ad, chosen) + open_turn(st, chosen[0], chosen[1], chosen) + Battle._actionI = 1 + Battle._metaAct = nil + Battle._phase = "actions" + local evs = focus_punch_prelude() + if evs and #evs > 0 then + if Battle._headless then + for _, e in ipairs(evs) do + if e.kind == "msg" then Ui.push(e.text) end + end + else + AnimSeq.beginEvents(evs, seq_push) + Battle._phase = "preturn" + end + end +end + +-- pokefirered/src/battle_main.c:3704 +function D.stepAction() + local st, ad = Battle._st, Battle._adapter + if end_if_over() then return end + local acts = Battle._actions or {} + local act + while true do + act = acts[Battle._actionI] + if not act then return D.afterActions() end + Battle._actionI = Battle._actionI + 1 + if Engine.actionRunnable(st, act) then break end + end + act.done = true + local user = State.battler(st, act.battler) + if act.kind == "move" then + if not user or State.isFainted(user) then return D.afterEach() end + local out = {} + st.interactiveChoices = not (Battle._headless or Battle._auto) + Engine.resolveMove(act.battler, act.target, act.move, act.slot, ad, st, out) + st.interactiveChoices = nil + Battle._pendingChoice = out.pendingChoice + if Battle._headless or not out._anim then + push_msgs(out) + if Battle._pendingChoice then return open_pending_choice() end + return D.afterEach() + end + AnimSeq.begin(out._anim, seq_push) + Battle._phase = "animating" + return + elseif act.kind == "switch" then + return D.beginSwitch(act) + elseif act.kind == "bag" then + return D.useBag(act) + elseif act.kind == "run" then + return D.run(act) + elseif act.kind == "item" then + return D.enemyItem(act) + end + return D.afterEach() +end + +-- pokefirered/src/battle_main.c:4433 +function D.afterEach() + if end_if_over() then return end + return D.faintFlow(function() + Battle._phase = "actions" + end) +end + +-- pokefirered/src/battle_main.c:2953 +function D.afterActions() + local st, ad = Battle._st, Battle._adapter + if end_if_over() then return end + local events = Engine.collectResidualEvents(st, ad) + close_turn(st) + if Battle._headless then + for _, evt in ipairs(events or {}) do push_msgs(evt.msgs) end + Ui.pump() + return D.endTurn() + end + local stream = {} + for _, evt in ipairs(events or {}) do + for _, e in ipairs(evt.events or {}) do stream[#stream + 1] = e end + end + AnimSeq.beginEvents(stream, seq_push) + Battle._phase = "residuals" +end + +function D.endTurn() + return D.faintFlow(function() + Battle._st.monToSwitchInto = {} + return D.startSelection() + end) +end + +-- pokefirered/src/battle_util.c:1144 +function D.faintFlow(onDone) + Engine.refreshAbsent(Battle._st) + Battle._dblFaint = { stage = "exp", onDone = onDone } + return D.faintStep() +end + +function D.presentAwards(awards) + if not awards or #awards == 0 then return false end + Battle._dblLeveled = Battle._dblLeveled or {} + Battle._leveledUp = Battle._dblLeveled + if Battle._headless then + push_awards_headless(awards) + return false + end + local hooks = choice_hooks() + hooks.double = true + if not ExpSeq.begin(awards, hooks.pushMsg, nil, hooks) then return false end + Battle._onExpDone = function() + for k, v in pairs(ExpSeq.leveledSet() or {}) do Battle._dblLeveled[k] = v end + Battle._leveledUp = Battle._dblLeveled + Battle._phase = "actions" + return D.faintStep() + end + Battle._phase = "awarding" + return true +end + +function D.faintStep() + local st, ad = Battle._st, Battle._adapter + local F = Battle._dblFaint + if not F or not st then return end + if F.stage == "exp" then + for _, id in ipairs(Engine.expAwardOrder(st)) do + local foe = State.battler(st, id) + if foe and not foe._expGiven then + foe._expGiven = true + if st.foeParty then State.syncBattlerToParty(foe, st.foeParty) end + local awards = Experience.awardFoe(st, foe, { trainer = not st.wild }) + -- pokefirered/src/battle_util.c:1181 + State.opponentSwitchInResetSentPokes(st, foe) + if D.presentAwards(awards) then return end + end + end + F.stage = "check" + end + if F.stage == "check" then + -- pokefirered/data/battle_scripts_1.s:2825 + local res = Engine.checkEnd(st, ad) + if res == "win" or res == "lose" then + Battle._dblFaint = nil + Battle._actions = {} + stop_low_hp_song() + if res == "win" then + Battle._leveledUp = Battle._dblLeveled or {} + return begin_trainer_win(st) + end + return D.lose() + end + F.stage = "repl" + end + if F.stage == "repl" then + for id = 0, 3 do + local b = State.battler(st, id) + if b and not State.isAbsent(st, id) and State.isFainted(b) then + local cands = Engine.replacementCandidates(st, id) + if #cands == 0 then + -- pokefirered/src/battle_script_commands.c:4870 + Engine.markAbsent(st, id) + else + return D.pickReplacement(id, cands) + end + end + end + F.stage = "after" + end + Battle._dblFaint = nil + -- pokefirered/src/battle_util.c:1208 + local evs = D.capture(function() Engine.afterAction(st, ad) end) + if #evs > 0 and not Battle._headless then + AnimSeq.beginEvents(evs, seq_push) + Battle._phase = "animating" + Battle._dblAfterAnim = F.onDone + return + end + for _, e in ipairs(evs) do + if e.kind == "msg" then Ui.push(e.text) end + end + if F.onDone then return F.onDone() end +end + +function D.lose() + Battle._pendingEnd = "lose" + Battle._phase = "ending" + Ui.push("You have no more\nPOKéMON left!") + Ui.push(string.format("%s blacked out!", (Battle._st and Battle._st.playerName or "PLAYER"))) +end + +-- pokefirered/src/battle_script_commands.c:4855 +function D.pickReplacement(id, cands) + local st, ad = Battle._st, Battle._adapter + local function go(slot) + st.monToSwitchInto[id] = slot + return D.sendOut(id, slot) + end + if State.sideOf(id) == "enemy" then + -- pokefirered/src/battle_controller_opponent.c:1410 + local ok, pick = pcall(Engine.mostSuitableMon, st, ad, id) + for _, c in ipairs(cands) do + if ok and c == pick then return go(pick) end + end + return go(cands[1]) + end + if Battle._headless or Battle._auto then return go(cands[1]) end + local PartyMenu = require("src.ui.game3.party_menu") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + for _, pid in ipairs(SEL_ORDER) do + local pb = State.battler(st, pid) + if pb then State.syncBattlerToParty(pb, st.playerParty) end + end + if Ui.clearLinger then Ui.clearLinger() end + Battle._phase = "switching" + if Ui.openPartyMenu then + return Ui.openPartyMenu(st, id, { + forced = true, + validate = function(slot) return Commands.switchError(st, slot, true, id) end, + onSelect = function(slot) + if slot == nil then return D.pickReplacement(id, cands) end + return go(slot) + end, + }) + end + local b0 = State.battler(st, 0) + PartyMenu.show(st.playerParty or (session and session.party), session and session.move_overlay, { + mode = "battle_faint", + session = session, + activeSlot = b0 and b0.partyIndex, + activeSlots = D.activeSlots(st), + battlerId = id, + battle = true, + validate = function(slot) return Commands.switchError(st, slot, true, id) end, + onSelect = function(slot) + if slot == nil then return D.pickReplacement(id, cands) end + return go(slot) + end, + }) +end + +function D.sendOut(id, slot) + local started = SwitchSeq.beginDoubleSwitch(Battle._st, id, slot, { + headless = Battle._headless or Battle._auto, + reason = "replace", + pushMsg = function(t) Ui.push(t) end, + onDone = function() return D.faintStep() end, + }) + if started then Battle._phase = "switching" end +end + +-- pokefirered/data/battle_scripts_1.s:3046 +function D.beginSwitch(act) + local st = Battle._st + local id = act.battler + local slot = act.slot or (st.monToSwitchInto and st.monToSwitchInto[id]) + if not slot then return D.afterEach() end + Ui.push(SwitchSeq.returnText(st, id)) + Battle._dblSwitch = { id = id, slot = slot, pursuers = (State.sideOf(id) == "player") and { 3, 1 } or { 2, 0 }, i = 1 } + return D.switchStep() +end + +-- pokefirered/src/battle_script_commands.c:8337 +function D.pursuitRow(st, pid, targetId) + if State.isAbsent(st, pid) then return nil end + local ad = Battle._adapter + local user = State.battler(st, pid) + local target = State.battler(st, targetId) + if not user or State.isFainted(user) or not target or State.isFainted(target) then return nil end + for _, row in ipairs(st.turnActions or {}) do + if row.battler == pid and row.kind == "move" and not row.done and not row.finished + and Engine.isPursuit(row.move) then + local tgt = row.target + if type(tgt) == "table" then tgt = tgt.id end + if tgt == nil and st.moveTarget then tgt = st.moveTarget[pid] end + if tgt == targetId and not ad:hasStatus(user, "SLP") and not ad:hasStatus(user, "FRZ") + and (tonumber(user.expTruantCounter) or 0) == 0 then + return row + end + end + end + return nil +end + +function D.switchStep() + local st, ad = Battle._st, Battle._adapter + local sw = Battle._dblSwitch + if not sw then return end + while sw.i <= #sw.pursuers do + local pid = sw.pursuers[sw.i] + sw.i = sw.i + 1 + local row = D.pursuitRow(st, pid, sw.id) + if row then + row.done = true + local out = {} + Engine.resolveMove(pid, sw.id, row.move, row.slot, ad, st, out, { pursuitSwitch = true }) + if Battle._headless or not out._anim then + push_msgs(out) + else + AnimSeq.begin(out._anim, seq_push) + Battle._phase = "animating" + Battle._dblAfterAnim = D.switchStep + return + end + end + end + Battle._dblSwitch = nil + local b = State.battler(st, sw.id) + if not b or State.isFainted(b) then + if st.monToSwitchInto then st.monToSwitchInto[sw.id] = nil end + return D.afterEach() + end + local started = SwitchSeq.beginDoubleSwitch(st, sw.id, sw.slot, { + withdraw = true, + noWithdrawMsg = true, + reason = "switch", + headless = Battle._headless, + pushMsg = function(t) Ui.push(t) end, + onDone = function() return D.afterEach() end, + }) + if started then Battle._phase = "switching" end +end + +-- pokefirered/data/battle_scripts_2.s:134 +function D.enemyItem(act) + local st, ad = Battle._st, Battle._adapter + local evs = D.capture(function() Engine.performEnemyItem(st, ad, act) end) + if Battle._headless or #evs == 0 then + for _, e in ipairs(evs) do + if e.kind == "msg" then Ui.push(e.text) end + end + Anim.syncDisplayFromState(st) + if st.double then return D.afterEach() end + Battle._phase = "actions" + if not Battle._actions[Battle._actionI] then after_actions() end + return + end + AnimSeq.beginEvents(evs, seq_push) + Battle._phase = "animating" +end + +-- pokefirered/data/battle_scripts_1.s:3046 +function D.singleEnemySwitch(act) + local st, ad = Battle._st, Battle._adapter + local slot = act.slot or (st.monToSwitchInto and st.monToSwitchInto[1]) + local function cont() + Battle._phase = "actions" + if not (Battle._actions and Battle._actions[Battle._actionI]) then after_actions() end + end + if not slot or State.isFainted(st.enemy) then return cont() end + Ui.push(SwitchSeq.returnText(st, 1)) + local function do_switch() + if State.isFainted(st.enemy) then + if st.monToSwitchInto then st.monToSwitchInto[1] = nil end + if check_faints_and_end() then return end + return cont() + end + local started = SwitchSeq.beginDoubleSwitch(st, 1, slot, { + withdraw = true, + noWithdrawMsg = true, + reason = "switch", + headless = Battle._headless, + pushMsg = function(t) Ui.push(t) end, + onDone = cont, + }) + if started then Battle._phase = "switching" end + end + local prow = D.pursuitRow(st, 0, 1) + if not prow then return do_switch() end + for i = #(Battle._actions or {}), 1, -1 do + if Battle._actions[i] == prow then table.remove(Battle._actions, i) end + end + prow.done = true + local out = {} + Engine.resolveMove(st.player, st.enemy, prow.move, prow.slot, ad, st, out, { pursuitSwitch = true }) + if Battle._headless or not out._anim then + push_msgs(out) + return do_switch() + end + AnimSeq.begin(out._anim, seq_push) + Battle._phase = "animating" + Battle._singleAfterAnim = do_switch +end + +function D.useBag(act) + local st, ad = Battle._st, Battle._adapter + if State.sideOf(act.battler) ~= "player" then return D.afterEach() end + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + local bag = session and session.bag + local Catching = require("src.core.game3.battle.catching") + if Catching.isBall(act.itemId) then + local Bag = require("src.core.game3.bag") + if bag and Bag.has(bag, act.itemId, 1) then Bag.remove(bag, act.itemId, 1) end + local okI, Items = pcall(require, "src.core.game3.items") + local iname = okI and Items.displayName and Items.displayName(act.itemId) or "POKé BALL" + -- pokefirered/data/battle_scripts_2.s:54 + Ui.push(string.format("%s used\n%s!", st.playerName or "PLAYER", iname)) + -- pokefirered/data/battle_scripts_2.s:116 + Ui.push("The TRAINER blocked the BALL!") + Ui.push("Don't be a thief!") + return D.afterEach() + end + local BattleItems = require("src.core.game3.battle.items") + local result = BattleItems.use(st, ad, bag, session, act.itemId, act.partySlot, act.battler) + if result == "heal" then + for _, id in ipairs(SEL_ORDER) do + local b = State.battler(st, id) + if b and b.partyIndex == act.partySlot and not State.isAbsent(st, id) then + local p = Anim.present(id) + local logical = tonumber(b.mon and b.mon.hp) or 0 + if p and p.displayHp ~= nil and math.abs(logical - p.displayHp) >= 1 then + Anim.tweenHp(id, p.displayHp, logical, b.mon.maxHp) + end + end + end + end + return D.afterEach() +end + +function D.run(act) + local st, ad = Battle._st, Battle._adapter + local fled = false + local evs = D.capture(function() + fled = Engine.tryFlee(st, ad, State.battler(st, act.battler)) and true or false + end) + for _, e in ipairs(evs) do + if e.kind == "msg" then Ui.push(e.text) end + end + if fled then + st.over = true + st.result = "run" + st.endReason = "flee" + Battle._pendingEnd = "run" + Battle._phase = "ending" + return + end + return D.afterEach() +end + function Battle.update(dt, game) if not Battle._active then return end @@ -1217,6 +2039,11 @@ function Battle.update(dt, game) end end + if Battle._phase == "command" and not Battle._auto and Battle._st and Battle._st.double then + D.commandUpdate(input) + return + end + if Battle._phase == "command" and not Battle._auto then local BagMenu = require("src.ui.game3.bag_menu") if BagMenu.isOpen and BagMenu.isOpen() then @@ -1225,7 +2052,7 @@ function Battle.update(dt, game) end local PartyMenu = require("src.ui.game3.party_menu") if PartyMenu.isOpen and PartyMenu.isOpen() then - if input then PartyMenu.handleInput(input) end + if input then party_menu_input(PartyMenu, input) end return end if Ui.selectionPump() then @@ -1361,7 +2188,7 @@ function Battle.update(dt, game) if Battle._phase == "switching" then local PartyMenu = package.loaded["src.ui.game3.party_menu"] if PartyMenu and PartyMenu.isOpen and PartyMenu.isOpen() then - if input then PartyMenu.handleInput(input) end + if input then party_menu_input(PartyMenu, input) end return end if Anim.busy() then return end @@ -1466,6 +2293,10 @@ function Battle.update(dt, game) Battle._residualEvents = nil Battle._residualIndex = 1 Battle._residualStepState = nil + if Battle._st and Battle._st.double then + D.endTurn() + return + end if check_faints_and_end() then return end @@ -1531,7 +2362,7 @@ function Battle.runToEnd() Ui._log[#Ui._log + 1] = t end end - Anim.reset({ headless = true }) + Anim.reset({ headless = true, double = Battle._st and Battle._st.double }) AnimSeq.reset() CatchSeq.reset() ExpSeq.reset() diff --git a/src/core/game3/battle/intro_seq.lua b/src/core/game3/battle/intro_seq.lua index 30361604..9995a989 100644 --- a/src/core/game3/battle/intro_seq.lua +++ b/src/core/game3/battle/intro_seq.lua @@ -26,6 +26,7 @@ function IntroSeq.reset() IntroSeq._pendingSlideIn = nil IntroSeq._pushMsg = nil IntroSeq._opts = nil + IntroSeq._cryQueue = nil end function IntroSeq.busy() @@ -51,6 +52,54 @@ local function stage() return Anim.stage() end +local function present_of(key) + if type(key) ~= "number" then return Anim.present(key) end + return Anim.present(key) or (key < 2 and Anim.present(State.sideOf(key))) or nil +end + +local function healthbox_of(s, key) + local hb = s and s.healthbox + if not hb then return nil end + if type(key) ~= "number" then return hb[key] end + return hb[key] or (key < 2 and hb[State.sideOf(key)]) or nil +end + +local function center_of(st, key) + if type(key) == "number" and Anim.coords then + local a, b = Anim.coords(st, key) + if type(a) == "table" then return a.x or a[1], a.y or a[2] end + if a then return a, b end + end + local side = (type(key) == "number") and State.sideOf(key) or key + local base = (side == "player") and Anim.PLAYER_MON or Anim.ENEMY_MON + return base.x, base.y +end + +local function battler_of(st, key) + if not st then return nil end + if type(key) == "number" then return State.battler(st, key) end + return st[key] +end + +local function ball_for(s, key) + if type(key) ~= "number" then return s.ball end + s.balls = s.balls or {} + local b = s.balls[key] + if not b then + b = { visible = false, x = 0, y = 0, frame = 0, rot = 0, battler = key, side = State.sideOf(key) } + s.balls[key] = b + end + return b +end + +local function present_ids(st, ids) + local out = {} + for _, id in ipairs(ids or {}) do + if st and not State.isAbsent(st, id) and State.battler(st, id) then out[#out + 1] = id end + end + return out +end + local function ball_status(mon) if not mon then return "empty" end local hp = tonumber(mon.hp) or 0 @@ -210,6 +259,33 @@ local function build_trainer(st, opts) playerBalls = playerBalls, frames = 20, }) + if st.double then + local foeIds = present_ids(st, { 1, 3 }) + local plIds = present_ids(st, { 0, 2 }) + local sentOut = strings.sentOut + if #foeIds == 2 then + -- pokefirered/src/battle_message.c:392 + local who = sentOut:match("^(.-) sent\n") or "" + sentOut = who .. " sent\nout " .. State.displayName(State.battler(st, 1)) .. " and " + .. State.displayName(State.battler(st, 3)) .. "!" + end + local goText = "Go! " .. pname .. "!" + if #plIds == 2 then + -- pokefirered/src/battle_message.c:400 + goText = "Go! " .. State.displayName(State.battler(st, 0)) .. " and\n" + .. State.displayName(State.battler(st, 2)) .. "!" + end + add("msg", { text = strings.wants }) + add("msg", { text = sentOut }) + add("opponent_sendout", { toX = 280, frames = 35, ids = foeIds }) + add("cry", { side = "enemy", release = true, ids = foeIds }) + add("healthbox", { side = "enemy", frames = 23, from = -115, ids = foeIds }) + add("msg", { text = goText, linger = true }) + add("player_throw", { ids = plIds }) + add("healthbox", { side = "player", frames = 23, from = 115, ids = plIds }) + add("wait", { frames = 3 }) + return steps + end add("msg", { text = strings.wants }) add("msg", { text = strings.sentOut }) add("opponent_sendout", { toX = 280, frames = 35 }) @@ -251,6 +327,15 @@ function IntroSeq.begin(st, opts) Anim.present("enemy").darken = 0 Anim.present("player").scale = 1 Anim.present("enemy").scale = 1 + if st.double then + s.balls = {} + for id = 0, 3 do + local p = present_of(id) + if p then p.visible, p.ox, p.darken, p.scale = false, 0, 0, 1 end + local hb = healthbox_of(s, id) + if hb then hb.visible = false end + end + end -- Park terrain and sliding sprites off-screen immediately so the first -- rendered frame (and fade-in) starts with them in initial slide positions. @@ -528,17 +613,25 @@ local function run_step(step) end if kind == "opponent_sendout" then - local cx, cy = Anim.ENEMY_MON.x, Anim.ENEMY_MON.y + local Battle = package.loaded["src.core.game3.battle"] + local st = Battle and Battle._st + local keys = d.ids or { "enemy" } + local mons = {} + for n, key in ipairs(keys) do + local cx, cy = center_of(st, key) + local ball = ball_for(s, key) + ball.visible = true + ball.frame = 0 + ball.rot = 0 + ball.side = "enemy" + ball.x = cx + ball.y = cy + 24 + mons[n] = { key = key, ball = ball } + end local tr = s.trainer.enemy local exitFrom = tr.ox or 0 local exitTo = (d.toX or 280) - 176 s.partyBar.enemy.visible = false - s.ball.visible = true - s.ball.frame = 0 - s.ball.rot = 0 - s.ball.side = "enemy" - s.ball.x = cx - s.ball.y = cy + 24 wait_busy() -- pret OpponentHandleIntroTrainerBallThrow: starts linear slide-out (35 frames) -- AND StartSendOutAnim (16f delay + 12f emergence). @@ -551,48 +644,64 @@ local function run_step(step) if f >= totalFrames then tr.visible = false end - -- Ball opens after 16 frames delay (SpriteCB_OpponentMonSendOut) - if f == 16 then - s.ball.frame = 1 - if not openedSe then - openedSe = true - pcall(function() Audio.playSe(SE.SE_BALL_OPEN, { pan = 63 }) end) + for _, m in ipairs(mons) do + local ball = m.ball + -- Ball opens after 16 frames delay (SpriteCB_OpponentMonSendOut) + if f == 16 then + ball.frame = 1 + if not openedSe then + openedSe = true + pcall(function() Audio.playSe(SE.SE_BALL_OPEN, { pan = 63 }) end) + end + local p = present_of(m.key) + if p then + p.visible = true + p.ox = 0 + p.oy = 16 + p.scale = 0.16 + p.darken = 0 + end + Anim.ballOpen(m.key, ball.x, ball.y) + end + -- Emergence over 12 frames (frames 16..28) matching pret BATTLER_AFFINE_EMERGE + if f > 16 and f <= 28 then + local eu = (f - 16) / 12 + local p = present_of(m.key) + if p then + p.oy = 16 * (1 - eu) + p.scale = 0.16 + 0.84 * eu + end + ball.frame = (eu < 0.5) and 1 or 2 + end + if f > 28 then + local p = present_of(m.key) + if p then + p.oy = 0 + p.scale = 1 + end + ball.visible = false end - local p = Anim.present("enemy") - p.visible = true - p.ox = 0 - p.oy = 16 - p.scale = 0.16 - p.darken = 0 - Anim.ballOpen("enemy", s.ball.x, s.ball.y) - end - -- Emergence over 12 frames (frames 16..28) matching pret BATTLER_AFFINE_EMERGE - if f > 16 and f <= 28 then - local eu = (f - 16) / 12 - local p = Anim.present("enemy") - p.oy = 16 * (1 - eu) - p.scale = 0.16 + 0.84 * eu - s.ball.frame = (eu < 0.5) and 1 or 2 - end - if f > 28 then - local p = Anim.present("enemy") - p.oy = 0 - p.scale = 1 - s.ball.visible = false end end, function() tr.visible = false tr.ox = exitTo - s.ball.visible = false - local p = Anim.present("enemy") - p.oy = 0 - p.scale = 1 + for _, m in ipairs(mons) do + m.ball.visible = false + local p = present_of(m.key) + if p then + p.oy = 0 + p.scale = 1 + end + end advance() end) return end if kind == "player_throw" then + local Battle = package.loaded["src.core.game3.battle"] + local st = Battle and Battle._st + local keys = d.ids or { "player" } local tr = s.trainer.player if not tr.visible then tr.visible = true @@ -605,7 +714,11 @@ local function run_step(step) local pose = { { 1, 20 }, { 2, 6 }, { 3, 6 }, { 4, 24 }, { 0, 1 } } local poseFrame, poseLeft, poseI = 0, 0, 0 local exitTo = -120 - local pcx, pcy = Anim.PLAYER_MON.x, Anim.PLAYER_MON.y + local mons = {} + for n, key in ipairs(keys) do + local pcx, pcy = center_of(st, key) + mons[n] = { key = key, ball = ball_for(s, key), tx = pcx, ty = pcy + 24 } + end local threwSe, openedSe = false, false wait_busy() Anim.tweenStage(57, function(u, t) @@ -626,63 +739,84 @@ local function run_step(step) tr.visible = false tr.ox = exitTo end - -- pret Task_StartSendOutAnim (31f delay) + Task_DoPokeballSendOutAnim (1f delay) -> spawn at frame 32 - if f == 32 then - s.ball.visible = true - s.ball.frame = 0 - s.ball.rot = 0 - s.ball.side = "player" - s.ball.x = 48 - s.ball.y = 70 - s.ball._sx, s.ball._sy = 48, 70 - s.ball._tx, s.ball._ty = pcx, pcy + 24 - if not threwSe then - threwSe = true - pcall(function() Audio.playSe(SE.SE_BALL_THROW, { pan = -64 }) end) + for _, m in ipairs(mons) do + local ball = m.ball + -- pret Task_StartSendOutAnim (31f delay) + Task_DoPokeballSendOutAnim (1f delay) -> spawn at frame 32 + if f == 32 then + ball.visible = true + ball.frame = 0 + ball.rot = 0 + ball.side = "player" + ball.x = 48 + ball.y = 70 + ball._sx, ball._sy = 48, 70 + ball._tx, ball._ty = m.tx, m.ty + if not threwSe then + threwSe = true + pcall(function() Audio.playSe(SE.SE_BALL_THROW, { pan = -64 }) end) + end + end + -- pret SpriteCB_PlayerMonSendOut_1 / 2: 25 frames arc flight with affine rotation + if f > 32 and f <= 57 and ball.visible then + local bu = (f - 32) / 25 + local sx, sy = ball._sx, ball._sy + local tx, ty = ball._tx, ball._ty + ball.x = sx + (tx - sx) * bu + ball.y = sy + (ty - sy) * bu + (-30 * 4 * bu * (1 - bu)) + -- pret sAffineAnim_BallRotate_4: 25 units per frame (approx 0.613 rad/frame) + ball.rot = (f - 32) * ((25 / 256) * math.pi * 2) end - end - -- pret SpriteCB_PlayerMonSendOut_1 / 2: 25 frames arc flight with affine rotation - if f > 32 and f <= 57 and s.ball.visible then - local bu = (f - 32) / 25 - local sx, sy = s.ball._sx, s.ball._sy - local tx, ty = s.ball._tx, s.ball._ty - s.ball.x = sx + (tx - sx) * bu - s.ball.y = sy + (ty - sy) * bu + (-30 * 4 * bu * (1 - bu)) - -- pret sAffineAnim_BallRotate_4: 25 units per frame (approx 0.613 rad/frame) - s.ball.rot = (f - 32) * ((25 / 256) * math.pi * 2) end end, function() tr.visible = false tr.ox = exitTo - s.ball.frame = 1 - s.ball.rot = 0 if not openedSe then openedSe = true pcall(function() Audio.playSe(SE.SE_BALL_OPEN, { pan = -64 }) end) end - Anim.ballOpen("player", s.ball.x, s.ball.y) - local Battle = package.loaded["src.core.game3.battle"] - local st = Battle and Battle._st - local species = st and st.player and (st.player.species or (st.player.mon and (st.player.mon.species or st.player.mon.speciesId))) - if species then - -- pokefirered/src/pokeball.c:782 - pcall(function() Audio.playCry(species, release_cry_mode(st.player.mon), -25) end) + for _, m in ipairs(mons) do + m.ball.frame = 1 + m.ball.rot = 0 + Anim.ballOpen(m.key, m.ball.x, m.ball.y) + local p = present_of(m.key) + if p then + p.visible = true + p.ox = 0 + p.oy = 16 + p.scale = 0.16 + end + end + if not d.ids then + local b = st and st.player + local species = b and (b.species or (b.mon and (b.mon.species or b.mon.speciesId))) + if species then + -- pokefirered/src/pokeball.c:782 + pcall(function() Audio.playCry(species, release_cry_mode(b.mon), -25) end) + end end - local p = Anim.present("player") - p.visible = true - p.ox = 0 - p.oy = 16 - p.scale = 0.16 -- pret BATTLER_AFFINE_EMERGE: 12 frames scaling 40/256 to 256/256 Anim.tweenStage(12, function(uu) - p.oy = 16 * (1 - uu) - p.scale = 0.16 + 0.84 * uu - s.ball.frame = (uu < 0.5) and 1 or 2 + for _, m in ipairs(mons) do + local p = present_of(m.key) + if p then + p.oy = 16 * (1 - uu) + p.scale = 0.16 + 0.84 * uu + end + m.ball.frame = (uu < 0.5) and 1 or 2 + end end, function() - p.oy = 0 - p.scale = 1 - s.ball.visible = false - s.ball.rot = 0 + for _, m in ipairs(mons) do + local p = present_of(m.key) + if p then + p.oy = 0 + p.scale = 1 + end + m.ball.visible = false + m.ball.rot = 0 + end + if d.ids and #d.ids > 0 then + IntroSeq._cryQueue = { side = "player", ids = d.ids } + end advance() end) end) @@ -723,6 +857,12 @@ local function run_step(step) return end + if kind == "cry" and d.ids then + IntroSeq._cryQueue = { side = d.side or "enemy", ids = d.ids } + advance() + return + end + if kind == "cry" then local side = d.side or "enemy" local Battle = package.loaded["src.core.game3.battle"] @@ -740,6 +880,27 @@ local function run_step(step) return end + if kind == "healthbox" and d.ids then + local from = d.from or ((d.side == "player") and 115 or -115) + local boxes = {} + for _, id in ipairs(d.ids) do + local hb = healthbox_of(s, id) + if hb then + hb.visible = true + hb.ox = from + boxes[#boxes + 1] = hb + end + end + wait_busy() + Anim.tweenStage(d.frames or 23, function(u) + for _, hb in ipairs(boxes) do hb.ox = from * (1 - u) end + end, function() + for _, hb in ipairs(boxes) do hb.ox = 0 end + advance() + end) + return + end + if kind == "healthbox" then local side = d.side or "enemy" local hb = s.healthbox[side] @@ -767,9 +928,34 @@ local function run_step(step) advance() end +-- pokefirered/src/pokeball.c:680 +local function run_cry_queue() + local q = IntroSeq._cryQueue + if not q then return true end + if q.waiting and Audio.isCryFinished and not Audio.isCryFinished() then return false end + q.i = (q.i or 0) + 1 + local id = q.ids[q.i] + if id == nil then + IntroSeq._cryQueue = nil + return true + end + local Battle = package.loaded["src.core.game3.battle"] + local b = Battle and Battle._st and State.battler(Battle._st, id) + local species = b and (b.species or (b.mon and (b.mon.species or b.mon.speciesId))) + if species then + local weak = release_cry_mode(b.mon) ~= 0 + local mode + if #q.ids > 1 and q.i == 1 then mode = weak and 12 or 1 else mode = weak and 11 or 0 end + pcall(function() Audio.playCry(species, mode, (State.sideOf(id) == "player") and -25 or 25) end) + end + q.waiting = true + return false +end + function IntroSeq.update() if not IntroSeq._steps then return true end if IntroSeq._waitingGen then return false end + if IntroSeq._cryQueue and not run_cry_queue() then return false end if IntroSeq._pendingSlideIn and Anim.introSlideDone() then local fn = IntroSeq._pendingSlideIn @@ -817,7 +1003,7 @@ function IntroSeq.update() while IntroSeq._steps and IntroSeq._i <= #IntroSeq._steps do run_step(IntroSeq._steps[IntroSeq._i]) if IntroSeq._waiting or IntroSeq._waitingFade or IntroSeq._waitingCry - or IntroSeq._waitingMsg or IntroSeq._pendingSlideIn then + or IntroSeq._waitingMsg or IntroSeq._pendingSlideIn or IntroSeq._cryQueue then return false end end diff --git a/src/core/game3/battle/items.lua b/src/core/game3/battle/items.lua index d0e73925..10f9e2e1 100644 --- a/src/core/game3/battle/items.lua +++ b/src/core/game3/battle/items.lua @@ -91,8 +91,13 @@ function BattleItems.storeCaught(session, foeBattler, ballId) return res.location end -local function sync_player_battler(st) - local b = st.player +local function user_battler(st, battlerId) + if battlerId == nil or battlerId == 0 then return st.player end + return st.battlers and st.battlers[battlerId] or st.player +end + +local function sync_player_battler(st, battlerId) + local b = user_battler(st, battlerId) if not b or not b.mon then return end b.fainted = (tonumber(b.mon.hp) or 0) <= 0 b.status = b.mon.status @@ -103,7 +108,7 @@ end -- msgs: string list -- endsTurn: bool (enemy may still move unless endsBattle) -- endsBattle: bool -function BattleItems.use(st, adapter, bag, session, itemId, partySlot) +function BattleItems.use(st, adapter, bag, session, itemId, partySlot, battlerId) local msgs = {} local function say(t) msgs[#msgs + 1] = t @@ -142,11 +147,12 @@ function BattleItems.use(st, adapter, bag, session, itemId, partySlot) Bag.remove(bag, itemId, 1) say(tostring(session and session.name or "RED") .. " used\nthe " .. name .. "!") local rng = adapter and adapter.rng and adapter:rng() or math.random - local caught, shakes = BattleItems.tryCatch(itemId, st.enemy, st, rng, session) + local foe = Catching.targetFor(st, battlerId) + local caught, shakes = BattleItems.tryCatch(itemId, foe, st, rng, session) if caught then - local res = Catching.storeCaught(session, st.enemy, itemId) - local ename = (st.enemy and st.enemy.mon and (st.enemy.mon.nickname or st.enemy.mon.name)) - or Pokemon.name(st.enemy and st.enemy.species) or "POKéMON" + local res = Catching.storeCaught(session, foe, itemId) + local ename = (foe and foe.mon and (foe.mon.nickname or foe.mon.name)) + or Pokemon.name(foe and foe.species) or "POKéMON" say("Gotcha!\n" .. ename .. " was caught!") if res and res.firstTimeCaught then say(ename .. "'s data was\nadded to the POKéDEX.") @@ -171,7 +177,7 @@ function BattleItems.use(st, adapter, bag, session, itemId, partySlot) -- X items if num and X_STAT[num] then local stat = X_STAT[num] - local battler = st.player + local battler = user_battler(st, battlerId) if not battler or not battler.stages then return "error", msgs, false, false end @@ -235,6 +241,8 @@ function BattleItems.use(st, adapter, bag, session, itemId, partySlot) if st.player and st.player.partyIndex == partySlot then sync_player_battler(st) end + local b2 = st.double and st.battlers and st.battlers[2] + if b2 and b2.partyIndex == partySlot then sync_player_battler(st, 2) end return "heal", msgs, true, false end @@ -242,4 +250,124 @@ function BattleItems.use(st, adapter, bag, session, itemId, partySlot) return "error", msgs, false, false end +local ENEMY_CURE_TEXT = { + [0] = "\nsnapped it out of confusion!", + [1] = "\ncured paralysis!", + [2] = "\ndefrosted it!", + [3] = "\nhealed its burn!", + [4] = "\ncured poison!", + [5] = "\nwoke it from its sleep!", +} + +local ENEMY_STAT_NAME = { [1] = "ATTACK", [2] = "DEFENSE", [3] = "SPEED", [4] = "SP. ATK", [5] = "SP. DEF", [6] = "accuracy" } + +-- pokefirered/src/pokemon.c:4001 +local function enemy_item_effects(st, ad, b, e) + local AiItems = require("src.core.game3.battle.ai_items") + local band = AiItems.band + if band(e[1], 0x80) ~= 0 and b.expInfatuated then b.expInfatuated, b.expInfatuatedWith = nil, nil end + if band(e[1], 0x30) ~= 0 and not b.expFocusEnergy then + b.expFocusEnergy, b.focusEnergy = true, true + end + local function raise(key, n) + local cur = b.stages and b.stages[key] or 0 + if n > 0 and cur < 6 then ad:changeStages(b, { [key] = n }) end + end + raise("attack", band(e[1], 0x0F)) + raise("defense", math.floor(band(e[2], 0xF0) / 16)) + raise("speed", band(e[2], 0x0F)) + raise("accuracy", math.floor(band(e[3], 0xF0) / 16)) + raise("spAtk", band(e[3], 0x0F)) + local side = ad:ownSide(b) + if band(e[4], 0x80) ~= 0 and side and (tonumber(side.expMistTurns) or 0) == 0 then + side.expMistTurns = 5 + end + local s = AiItems.statusName(b) + local cure = (band(e[4], 0x20) ~= 0 and s == "SLP") or (band(e[4], 0x10) ~= 0 and (s == "PSN" or s == "TOX")) + or (band(e[4], 0x08) ~= 0 and s == "BRN") or (band(e[4], 0x04) ~= 0 and s == "FRZ") + or (band(e[4], 0x02) ~= 0 and s == "PAR") + if cure then + if s == "SLP" then b.expNightmare = nil end + ad:clearStatus(b) + end + if band(e[4], 0x01) ~= 0 and (tonumber(b.confusionTurns) or 0) > 0 then b.confusionTurns = nil end + if band(e[5], 0x04) ~= 0 then + local hp, maxHp = ad:hp(b), ad:maxHp(b) + local revive = band(e[5], 0x40) ~= 0 + if (revive and hp == 0) or (not revive and hp ~= 0) then + local data = e.hp or 0 + if data == AiItems.HEAL_HP_FULL then + data = maxHp - hp + elseif data == AiItems.HEAL_HP_HALF then + data = math.floor(maxHp / 2) + if data == 0 then data = 1 end + elseif data == AiItems.HEAL_HP_LVL_UP then + data = 0 + end + if maxHp ~= hp then ad:heal(b, data) end + end + end + if b.mon then b.status = b.mon.status end +end + +-- pokefirered/src/battle_main.c:4150 +function BattleItems.enemyUse(st, adapter, act) + local AiItems = require("src.core.game3.battle.ai_items") + local State = require("src.core.game3.battle.state") + local id = act and (act.battler or 1) or 1 + local b = State.battler(st, id) + if not b or not b.mon or not act.item then return false end + local item = act.item + local e = AiItems.effect(item) + local kind = act.aiItemType or (e and AiItems.itemType(item, e)) + local flags = tonumber(act.aiItemFlags) or 0 + b.expFuryCutter, b.destinyBond, b.expDestinyBond, b.expGrudge = 0, nil, nil, nil + local iname = ItemsData.displayName(item) + local bname = adapter:displayName(b) + local trainer = (st.trainerClassName and st.trainerClassName ~= "") + and (st.trainerClassName .. " " .. (st.trainerName or "")) or (st.trainerName or "TRAINER") + -- pokefirered/data/battle_scripts_2.s:134 + adapter:pushEvent({ kind = "item_use", battler = id, side = b.side, item = item, se = "SE_USE_ITEM" }) + adapter:say(trainer .. "\nused " .. iname .. "!") + if e then enemy_item_effects(st, adapter, b, e) end + local T = AiItems.TYPE + if kind == T.FULL_RESTORE or kind == T.HEAL_HP then + adapter:say(bname .. "'s " .. iname .. "\nrestored health!") + adapter:pushEvent({ kind = "status", battler = id, side = b.side }) + elseif kind == T.CURE_CONDITION then + local chooser = 0 + if flags % 2 == 1 then + if AiItems.band(flags, 0x3E) ~= 0 then chooser = 5 end + else + local f = flags + while f > 0 and f % 2 == 0 do + f = math.floor(f / 2) + chooser = chooser + 1 + end + end + adapter:say(bname .. "'s " .. iname .. (ENEMY_CURE_TEXT[chooser] or ENEMY_CURE_TEXT[0])) + adapter:pushEvent({ kind = "status", battler = id, side = b.side }) + elseif kind == T.X_STAT then + if AiItems.band(flags, 0x80) ~= 0 then + adapter:say(bname .. " used\n" .. iname .. " to hustle!") + else + local stat, f = 1, flags + while f > 0 and f % 2 == 0 do + f = math.floor(f / 2) + stat = stat + 1 + end + adapter:say("Using " .. iname .. ", the " .. (ENEMY_STAT_NAME[stat] or "ATTACK") + .. "\nof " .. bname .. " rose!") + end + elseif kind == T.GUARD_SPECS then + -- pokefirered/src/battle_main.c:4216 + if st.double then + adapter:say(bname .. " is getting\npumped!") + else + adapter:say(((b.side == "player") and "Ally" or "Foe") .. " became\nshrouded in MIST!") + end + end + return true +end + return BattleItems diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index ea895454..8542b79a 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -174,12 +174,39 @@ function Moves.loadRomPack(cache) if not pack then pack = load_lua(root) end if pack and pack.moves then Moves._rom = pack.moves + Moves._runReloadHooks() return true end Moves._rom = nil return false end +Moves._reloadHooks = {} + +function Moves.onReload(fn, key) + if type(fn) ~= "function" then return function() end end + local hooks = Moves._reloadHooks + for i = #hooks, 1, -1 do + local h = hooks[i] + if h.fn == fn or (key ~= nil and h.key == key) then + table.remove(hooks, i) + end + end + local entry = { fn = fn, key = key } + hooks[#hooks + 1] = entry + return function() + for i = #hooks, 1, -1 do + if hooks[i] == entry then table.remove(hooks, i) end + end + end +end + +function Moves._runReloadHooks() + local snapshot = {} + for i, h in ipairs(Moves._reloadHooks) do snapshot[i] = h end + for _, h in ipairs(snapshot) do pcall(h.fn, Moves) end +end + function Moves.romReady() if not Moves._romLoaded then Moves.loadRomPack(nil) end return Moves._rom ~= nil diff --git a/src/core/game3/battle/party_view.lua b/src/core/game3/battle/party_view.lua index 601744f8..c0c1181e 100644 --- a/src/core/game3/battle/party_view.lua +++ b/src/core/game3/battle/party_view.lua @@ -62,4 +62,23 @@ function PartyView.firstAliveIndex(party) return nil end +-- pokefirered/src/battle_setup.c:542 +function PartyView.doubleTransitionLevels(playerParty, foeParty) + local pSum, need = 0, 2 + for _, mon in ipairs(playerParty or {}) do + local sp = tonumber(mon.species or mon.speciesId) or 0 + if sp ~= 0 and sp ~= 412 and not mon.isEgg and (tonumber(mon.hp) or 0) ~= 0 then + pSum = (pSum + (tonumber(mon.level or mon.lvl) or 0)) % 256 + need = need - 1 + if need == 0 then break end + end + end + -- pokefirered/src/battle_setup.c:561 + local eSum = 0 + for i = 1, math.min(2, #(foeParty or {})) do + eSum = (eSum + (tonumber(foeParty[i].level or foeParty[i].lvl) or 0)) % 256 + end + return pSum, eSum +end + return PartyView diff --git a/src/core/game3/battle/pic_coords.lua b/src/core/game3/battle/pic_coords.lua index ab6810f3..7a5f7c5e 100644 --- a/src/core/game3/battle/pic_coords.lua +++ b/src/core/game3/battle/pic_coords.lua @@ -1,6 +1,6 @@ -- Auto-derived from pret gMonFront/BackPicCoords + gEnemyMonElevation (FRLG). -- y_offset per internal SPECIES id. Do not edit by hand. -return { +local PicCoords = { front = { [0] = 0, [1] = 16, @@ -894,3 +894,18 @@ return { [411] = 12, }, } + +local AnimCoords = require("src.core.game3.battle.anim_coords") + +-- pokefirered/src/battle_anim_mons.c:31 +PicCoords.BATTLER_COORDS = { [false] = AnimCoords.SINGLES, [true] = AnimCoords.DOUBLES } +PicCoords.SUBPRIORITY = AnimCoords.SUBPRIORITY +PicCoords.BG_PRIORITY_RANK = AnimCoords.BG_PRIORITY_RANK +PicCoords.DRAW_ORDER = AnimCoords.DRAW_ORDER_DOUBLES + +function PicCoords.battlerCoords(double, id) + local t = PicCoords.BATTLER_COORDS[double and true or false] + return t[tonumber(id) or 0] or t[0] +end + +return PicCoords diff --git a/src/core/game3/battle/residual_handlers.lua b/src/core/game3/battle/residual_handlers.lua index d056e7a1..bc2dd9e2 100644 --- a/src/core/game3/battle/residual_handlers.lua +++ b/src/core/game3/battle/residual_handlers.lua @@ -18,6 +18,17 @@ local function side_battler(ad, sideKey) return sideKey == "player" and st.player or st.enemy end +local function slot_battler(ad, id) + local State = require("src.core.game3.battle.state") + if id == nil or not State.isPresent(ad._st, id) then return nil end + return State.battler(ad._st, id) +end + +local function token_battler(ad, key, tok, field) + if ad._st.double and tok[field] ~= nil then return slot_battler(ad, tok[field]) end + return side_battler(ad, key) +end + -- pokefirered/src/battle_util.c:505 local function side_timer(field, label) return function(ctx) @@ -102,7 +113,7 @@ function Handlers.tickWeather(ad) ad:applyHpLoss(b, dmg) if ad:isFainted(b) then b._faintAnnounced = true - ad:pushEvent({ kind = "faint", side = b.side }) + ad:pushEvent({ kind = "faint", side = b.side, battler = b.id }) ad:say(name(ad, b) .. " fainted!") ad:emitFaint(b) end @@ -123,6 +134,7 @@ function Handlers.registerAll() -- pokefirered/src/battle_util.c:603 Residuals.register("wish", function(ctx) local ad = ctx.adapter + if ad._st.double then return Handlers.wishDoubles(ad) end for _, key in ipairs({ "player", "enemy" }) do local side = key == "player" and ad._st.playerSide or ad._st.enemySide if side and side.tokens then @@ -156,6 +168,43 @@ function Handlers.registerAll() end end) + -- pokefirered/src/battle_util.c:603 + function Handlers.wishDoubles(ad) + local st = ad._st + for _, b0 in ipairs(Residuals.sortedBattlers(ad)) do + local id = b0.id + local side = (id % 2 == 0) and st.playerSide or st.enemySide + if side and side.tokens then + local keep = {} + for _, tok in ipairs(side.tokens) do + if tok.id == "EXP_WISH" and (tok.battlerId == nil or tok.battlerId == id) then + tok.turns = (tok.turns or 1) - 1 + if tok.turns <= 0 then + local b = slot_battler(ad, id) + if b and ad:hp(b) > 0 then + ad:playAnim("general", "WISH_HEAL", b, b) + ad:say(tostring(tok.wisher or name(ad, b)) .. "'s WISH\ncame true!") + if ad:hp(b) >= ad:maxHp(b) then + ad:say(name(ad, b) .. "'s\nHP is full!") + else + local heal = math.floor(ad:maxHp(b) / 2) + if heal == 0 then heal = 1 end + ad:heal(b, heal) + ad:say(name(ad, b) .. " regained\nhealth!") + end + end + else + keep[#keep + 1] = tok + end + else + keep[#keep + 1] = tok + end + end + side.tokens = keep + end + end + end + Residuals.register("weather_continue", function(ctx) Handlers.tickWeather(ctx.adapter) end) @@ -197,7 +246,11 @@ function Handlers.registerAll() local ad, b = ctx.adapter, ctx.target if not b or not b.expSeeded then return end local src = b.expSeedSource - if src and src.side then src = side_battler(ad, src.side) end + if src and ad._st.double then + src = slot_battler(ad, src.id) + elseif src and src.side then + src = side_battler(ad, src.side) + end if not src or ad:isFainted(src) or ad:isFainted(b) then return end local dmg = math.floor(ad:maxHp(b) / 8) if dmg == 0 then dmg = 1 end @@ -401,12 +454,13 @@ function Handlers.registerAll() -- pokefirered/src/battle_util.c:1081 Residuals.register("future_sight", function(ctx) local ad = ctx.adapter + if ad._st.double then return Handlers.futureSightDoubles(ad) end for _, key in ipairs({ "player", "enemy" }) do local side = key == "player" and ad._st.playerSide or ad._st.enemySide - local target = side_battler(ad, key) if side and side.tokens then local keep = {} for _, tok in ipairs(side.tokens) do + local target = token_battler(ad, key, tok, "targetId") if tok.id == "EXP_FUTURE_SIGHT" then tok.turns = (tok.turns or 1) - 1 if tok.turns <= 0 then @@ -444,10 +498,40 @@ function Handlers.registerAll() end) end +-- pokefirered/src/battle_util.c:1081 +function Handlers.futureSightDoubles(ad) + local st = ad._st + for id = 0, 3 do + local side = (id % 2 == 0) and st.playerSide or st.enemySide + if side and side.tokens then + local keep = {} + for _, tok in ipairs(side.tokens) do + local tid = tok.targetId or ((id % 2 == 0) and 0 or 1) + if tok.id == "EXP_FUTURE_SIGHT" and tid == id then + tok.turns = (tok.turns or 1) - 1 + if tok.turns <= 0 then + local target = slot_battler(ad, id) + if target and ad:hp(target) > 0 then Handlers.futureSightHit(ad, tok, target) end + else + keep[#keep + 1] = tok + end + else + keep[#keep + 1] = tok + end + end + side.tokens = keep + end + end +end + -- pokefirered/data/battle_scripts_1.s:3461 function Handlers.futureSightHit(ad, tok, target) local Engine = require("src.core.game3.battle.engine") local attacker = side_battler(ad, tok.attackerSide or (target.side == "player" and "enemy" or "player")) + if ad._st.double and tok.attackerId ~= nil then + local State = require("src.core.game3.battle.state") + attacker = State.battler(ad._st, tok.attackerId) or attacker + end ad:say(name(ad, target) .. " took the\n" .. tostring(tok.moveName or "FUTURE SIGHT") .. " attack!") local anim = { moveId = tok.moveId, user = attacker, target = target, hits = {}, heals = {}, faints = {} } local M = Engine.newContext(attacker, target, tok.moveId or 248, nil, ad, ad._st, {}, anim, { futureSight = true }) diff --git a/src/core/game3/battle/residuals.lua b/src/core/game3/battle/residuals.lua index c4ddb1c1..8fda6f6d 100644 --- a/src/core/game3/battle/residuals.lua +++ b/src/core/game3/battle/residuals.lua @@ -28,6 +28,27 @@ end -- pokefirered/src/battle_util.c:494 local function sortedBattlers(adapter) + local st = adapter and adapter._st + if st and st.double then + local State = require("src.core.game3.battle.state") + local ids = st._endTurnOrder + if not ids then + local pri + if st.turnActions then + local Moves = require("src.core.game3.battle.moves") + pri = {} + for _, act in ipairs(st.turnActions) do + if act.battler ~= nil and act.kind == "move" then pri[act.battler] = Moves.priority(act.move) end + end + end + ids = State.speedOrder(st, adapter, { priority = pri }) + end + local out = {} + for _, id in ipairs(ids) do + if State.isPresent(st, id) then out[#out + 1] = State.battler(st, id) end + end + return out + end local list = adapter:activeBattlers() or {} local a, b = list[1], list[2] if a and b then @@ -48,7 +69,7 @@ local function runStepAndRecord(adapter, battler, phase, fn, events) local hpBefore = {} for _, b in ipairs(active) do if b and b.side then - hpBefore[b.side] = adapter:hp(b) + hpBefore[b] = adapter:hp(b) end end @@ -68,21 +89,22 @@ local function runStepAndRecord(adapter, battler, phase, fn, events) local faints = {} for _, b in ipairs(active) do if b and b.side then - local before = hpBefore[b.side] or 0 + local before = hpBefore[b] or 0 local after = adapter:hp(b) if before ~= after then hpChanges[#hpChanges + 1] = { side = b.side, + battler = b.id, from = before, to = after, maxHp = adapter:maxHp(b), } end if adapter:isFainted(b) and before > 0 then - faints[#faints + 1] = { side = b.side } + faints[#faints + 1] = { side = b.side, battler = b.id } if not b._faintAnnounced then b._faintAnnounced = true - if adapter.pushEvent then adapter:pushEvent({ kind = "faint", side = b.side }) end + if adapter.pushEvent then adapter:pushEvent({ kind = "faint", side = b.side, battler = b.id }) end adapter:say(adapter:displayName(b) .. " fainted!") end adapter:emitFaint(b) @@ -134,23 +156,36 @@ function Residuals.collectEvents(adapter) b.expEnduring = nil end + local st = adapter._st + if st and st.double then + st._endTurnOrder = nil + -- pokefirered/src/battle_util.c:484 + local order = {} + for _, b in ipairs(sortedBattlers(adapter)) do order[#order + 1] = b.id end + st._endTurnOrder = order + st.turnOrder = order + end + for _, phase in ipairs(Rules.FIELD_PHASES_ORDER) do - if adapter:isBattleDecided() then return events end + if adapter:isBattleDecided() then break end run_phase(adapter, phase, nil, events) end - for _, battler in ipairs(sortedBattlers(adapter)) do - for _, phase in ipairs(Rules.BATTLER_PHASES_ORDER) do - if adapter:isBattleDecided() or adapter:isFainted(battler) then break end - if run_phase(adapter, phase, battler, events) then break end + if not adapter:isBattleDecided() then + for _, battler in ipairs(sortedBattlers(adapter)) do + for _, phase in ipairs(Rules.BATTLER_PHASES_ORDER) do + if adapter:isBattleDecided() or adapter:isFainted(battler) then break end + if run_phase(adapter, phase, battler, events) then break end + end end end for _, phase in ipairs(Rules.POST_PHASES_ORDER) do - if adapter:isBattleDecided() then return events end + if adapter:isBattleDecided() then break end run_phase(adapter, phase, nil, events) end + if st then st._endTurnOrder = nil end return events end diff --git a/src/core/game3/battle/state.lua b/src/core/game3/battle/state.lua index 219e4b21..a39d6798 100644 --- a/src/core/game3/battle/state.lua +++ b/src/core/game3/battle/state.lua @@ -30,6 +30,7 @@ end function State.makeBattler(mon, side, opts) opts = opts or {} + local id = tonumber(opts.id) or ((side == "enemy") and 1 or 0) mon = Damage.ensureStats(mon, mon and mon.level) local species = species_id(mon) local t1, t2 = types_for(species) @@ -39,7 +40,9 @@ function State.makeBattler(mon, side, opts) end return { mon = mon, + id = id, side = side, -- "player" | "enemy" + flank = (id < 2) and "left" or "right", partyIndex = opts.partyIndex or 1, species = species, type1 = t1, @@ -57,6 +60,174 @@ function State.makeBattler(mon, side, opts) } end +function State.PARTNER(id) return (id + 2) % 4 end +function State.OPPOSITE(id) return (id % 2 == 0) and (id + 1) or (id - 1) end +function State.sideOf(id) return (id % 2 == 0) and "player" or "enemy" end +function State.flankOf(id) return (id < 2) and "left" or "right" end +function State.positionsOnSide(side) return (side == "player") and { 0, 2 } or { 1, 3 } end + +function State.idOf(b) + if type(b) == "number" then return b end + if type(b) == "string" then return (b == "enemy") and 1 or 0 end + if type(b) ~= "table" then return nil end + if b.id then return b.id end + if b.side == "enemy" then return 1 end + if b.side == "player" then return 0 end + return nil +end + +function State.battler(st, id) + if not st or id == nil then return nil end + if st.battlers then return st.battlers[id] end + if id == 0 then return st.player end + if id == 1 then return st.enemy end + return nil +end + +function State.occupant(st, b) + if b == nil or not st then return b end + if type(b) == "string" then return st[b] end + local id = State.idOf(b) + if id == nil then return b end + return State.battler(st, id) or b +end + +function State.isAbsent(st, id) + return st and st.absent and st.absent[id] and true or false +end + +function State.isPresent(st, id) + return State.battler(st, id) ~= nil and not State.isAbsent(st, id) +end + +function State.isAlive(st, id) + return State.isPresent(st, id) and not State.isFainted(State.battler(st, id)) +end + +function State.partner(st, b) + local id = State.idOf(b) + if id == nil or not (st and st.double) then return nil end + local p = State.PARTNER(id) + if not State.isPresent(st, p) then return nil end + return State.battler(st, p) +end + +function State.opposite(st, b) + local id = State.idOf(b) + if id == nil then return nil end + return State.battler(st, State.OPPOSITE(id)) +end + +function State.presentIds(st) + local out = {} + for id = 0, 3 do + if State.isPresent(st, id) then out[#out + 1] = id end + end + return out +end + +function State.present(st) + local out = {} + for id = 0, 3 do + if State.isPresent(st, id) then out[#out + 1] = State.battler(st, id) end + end + return out +end + +function State.foes(st, b) + local id = State.idOf(b) + local out = {} + if id == nil then return out end + for i = 0, 3 do + if i % 2 ~= id % 2 and State.isPresent(st, i) then out[#out + 1] = State.battler(st, i) end + end + return out +end + +function State.allies(st, b) + local id = State.idOf(b) + local out = {} + if id == nil then return out end + for i = 0, 3 do + if i % 2 == id % 2 and State.isPresent(st, i) then out[#out + 1] = State.battler(st, i) end + end + return out +end + +-- pokefirered/src/pokemon.c:2651 +function State.countPresentOnSide(st, side) + local n = 0 + for _, id in ipairs(State.positionsOnSide(side)) do + if State.isPresent(st, id) then n = n + 1 end + end + return n +end + +-- pokefirered/src/battle_main.c:3400 +function State.speedOrder(st, adapter, opts) + opts = opts or {} + local Engine = package.loaded["src.core.game3.battle.engine"] + local ids = State.presentIds(st) + local spe = {} + for _, id in ipairs(ids) do + local b = State.battler(st, id) + if Engine and Engine.speedOf then + spe[id] = Engine.speedOf(b, st, adapter) + else + spe[id] = tonumber(b.mon and (b.mon.speed or b.mon.spe)) or 50 + end + end + local function coin() + if adapter and adapter.roll then return adapter:roll(0, 1) end + return math.random(0, 1) + end + for i = 1, #ids - 1 do + for j = i + 1, #ids do + local a, b = ids[i], ids[j] + local pa = opts.priority and opts.priority[a] or 0 + local pb = opts.priority and opts.priority[b] or 0 + local swap + if pa ~= pb then + swap = pa < pb + elseif spe[a] == spe[b] then + swap = coin() == 1 + else + swap = spe[a] < spe[b] + end + if swap then ids[i], ids[j] = b, a end + end + end + return ids +end + +local function battler_slots(st) + return setmetatable({}, { + __index = function(_, k) + if k == 0 then return st.player end + if k == 1 then return st.enemy end + return nil + end, + __newindex = function(t, k, v) + if k == 0 then st.player = v + elseif k == 1 then st.enemy = v + else rawset(t, k, v) end + end, + }) +end +State.newSlots = battler_slots + +local function first_usable(party, exclude) + for i = 1, #(party or {}) do + local m = party[i] + if i ~= exclude and m and not m.isEgg and (tonumber(m.hp) or 0) > 0 + and (tonumber(m.species or m.speciesId) or 0) ~= 0 then + return i + end + end + return nil +end +State.firstUsable = first_usable + function State.new(opts) opts = opts or {} local playerParty = opts.playerParty or {} @@ -81,14 +252,79 @@ function State.new(opts) fleeAttempts = 0, log = {}, } + st.double = opts.double and true or false + st.battlersCount = st.double and 4 or 2 + st.battlers = battler_slots(st) + st.absent = {} + st.chosen = {} + st.turnOrder = {} + st.monToSwitchInto = {} + st.moveTarget = {} local pMon = playerParty[pi] - st.player = State.makeBattler(pMon, "player", { partyIndex = pi }) + st.player = State.makeBattler(pMon, "player", { partyIndex = pi, id = 0 }) local eMon = foeMon or st.foeParty[1] - st.enemy = State.makeBattler(eMon, "enemy", { partyIndex = 1 }) - State.trackParticipant(st, st.enemy, pi) + st.enemy = State.makeBattler(eMon, "enemy", { partyIndex = 1, id = 1 }) + if not st.double then + State.trackParticipant(st, st.enemy, pi) + return st + end + -- pokefirered/src/battle_controllers.c:290 + local p2 = opts.partnerIndex or first_usable(playerParty, pi) + if p2 and playerParty[p2] then + st.battlers[2] = State.makeBattler(playerParty[p2], "player", { partyIndex = p2, id = 2 }) + else + st.absent[2] = true + end + local e2 = opts.foePartnerIndex or first_usable(st.foeParty, st.enemy.partyIndex) + if e2 and st.foeParty[e2] then + st.battlers[3] = State.makeBattler(st.foeParty[e2], "enemy", { partyIndex = e2, id = 3 }) + else + st.absent[3] = true + end + State.resetSentPokes(st) return st end +-- pokefirered/src/battle_util.c:239 +function State.resetSentPokes(st) + local sent = {} + for _, id in ipairs({ 0, 2 }) do + local b = State.battler(st, id) + if b and b.partyIndex then sent[#sent + 1] = b.partyIndex end + end + for _, id in ipairs({ 1, 3 }) do + local foe = State.battler(st, id) + if foe then + foe.participants = {} + for _, pi in ipairs(sent) do foe.participants[pi] = true end + end + end +end + +-- pokefirered/src/battle_util.c:254 +function State.opponentSwitchInResetSentPokes(st, foeBattler) + if not foeBattler then return end + foeBattler.participants = {} + for _, id in ipairs({ 0, 2 }) do + local b = State.battler(st, id) + if b and not State.isAbsent(st, id) and b.partyIndex then + foeBattler.participants[b.partyIndex] = true + end + end +end + +-- pokefirered/src/battle_util.c:273 +function State.updateSentPokes(st, battler) + if not battler then return end + if battler.side == "enemy" then + return State.opponentSwitchInResetSentPokes(st, battler) + end + for _, id in ipairs({ 1, 3 }) do + local foe = State.battler(st, id) + if foe then State.trackParticipant(st, foe, battler.partyIndex) end + end +end + function State.displayName(battler) if not battler then return "POKéMON" end local mon = battler.mon diff --git a/src/core/game3/battle/switch_seq.lua b/src/core/game3/battle/switch_seq.lua index b304af8b..d4cfd265 100644 --- a/src/core/game3/battle/switch_seq.lua +++ b/src/core/game3/battle/switch_seq.lua @@ -57,6 +57,44 @@ local function stage() return Anim.stage() end +local function step_battler(st, d) + if d.id ~= nil then return st and State.battler(st, d.id) end + return st and st[d.side or "player"] +end + +local function step_side(d) + if d.id ~= nil then return State.sideOf(d.id) end + return d.side or "player" +end + +local function step_present(d) + if d.id == nil then return Anim.present(d.side or "player") end + return Anim.present(d.id) or (d.id < 2 and Anim.present(State.sideOf(d.id))) or nil +end + +local function step_healthbox(s, d) + local hb = s and s.healthbox + if not hb then return nil end + if d.id == nil then return hb[d.side or "player"] end + return hb[d.id] or (d.id < 2 and hb[State.sideOf(d.id)]) or nil +end + +local function step_center(st, d) + if d.id ~= nil and Anim.coords then + local a, b = Anim.coords(st, d.id) + if type(a) == "table" then return a.x or a[1], a.y or a[2] end + if a then return a, b end + end + local base = (step_side(d) == "player") and Anim.PLAYER_MON or Anim.ENEMY_MON + return base.x, base.y +end + +local function trainer_label(st) + return (st and st.trainerClassName and st.trainerClassName ~= "") + and (st.trainerClassName .. " " .. (st.trainerName or "")) + or (st and st.trainerName or "TRAINER") +end + local function withdraw_text(battler) local name = battler and State.displayName(battler) or "POKéMON" local hp = tonumber(battler and battler.mon and battler.mon.hp) or 0 @@ -149,7 +187,7 @@ local function engine_entry_events(st, sides) if not (Engine and Engine.switchInEffects and battle_adapter()) then return nil end return capture_events(function(ad) for _, side in ipairs(sides) do - local b = st and st[side] + local b = st and ((type(side) == "number") and State.battler(st, side) or st[side]) if b and b.mon and (tonumber(b.mon.hp) or 0) > 0 then Engine.switchInEffects(st, ad, b, { spikes = true }) end @@ -273,6 +311,72 @@ function SwitchSeq.beginSendOut(st, side, newSlot, opts) return true end +-- pokefirered/src/battle_message.c:1633 +function SwitchSeq.returnText(st, id) + local old = State.battler(st, id) + if State.sideOf(id) == "player" then + return State.displayName(old) .. ", come back!" + end + return trainer_label(st) .. "\nwithdrew " .. State.displayName(old) .. "!" +end + +-- pokefirered/data/battle_scripts_1.s:3046 +function SwitchSeq.beginDoubleSwitch(st, id, newSlot, opts) + opts = opts or {} + SwitchSeq.reset() + SwitchSeq._st = st + SwitchSeq._headless = opts.headless and true or false + SwitchSeq._pushMsg = opts.pushMsg + SwitchSeq._onDone = opts.onDone + local side = State.sideOf(id) + local withdrawMsg + if opts.withdraw and not opts.noWithdrawMsg then + withdrawMsg = SwitchSeq.returnText(st, id) + end + local reason = opts.reason or (opts.withdraw and "switch" or "replace") + + if SwitchSeq._headless then + if withdrawMsg and SwitchSeq._pushMsg then SwitchSeq._pushMsg(withdrawMsg) end + local Engine = package.loaded["src.core.game3.battle.engine"] + local party = (side == "player") and st.playerParty or st.foeParty + if Engine and Engine.performSwitch and battle_adapter() and party and party[newSlot] then + capture_events(function(ad) + Engine.performSwitch(st, ad, id, newSlot, { batonPass = opts.batonPass, reason = reason }) + end) + end + Anim.syncDisplayFromState(st) + local nb = State.battler(st, id) + if SwitchSeq._pushMsg then + if side == "player" then + SwitchSeq._pushMsg("Go! " .. State.displayName(nb) .. "!") + else + SwitchSeq._pushMsg(trainer_label(st) .. " sent\nout " .. State.displayName(nb) .. "!") + end + end + headless_entry(st, { id }) + finish() + return false + end + + local steps = {} + if withdrawMsg then + steps[#steps + 1] = { kind = "msg", data = { text = withdrawMsg } } + end + if opts.withdraw then + steps[#steps + 1] = { kind = "withdraw", data = { id = id } } + end + steps[#steps + 1] = { kind = "swap_data", data = { id = id, newSlot = newSlot, batonPass = opts.batonPass, reason = reason } } + steps[#steps + 1] = { kind = "msg_sendout", data = { id = id } } + steps[#steps + 1] = { kind = (side == "player") and "sendout_player" or "sendout_enemy", data = { id = id, slot = newSlot } } + steps[#steps + 1] = { kind = "shiny_check", data = { id = id } } + steps[#steps + 1] = { kind = "cry", data = { id = id } } + steps[#steps + 1] = { kind = "healthbox", data = { id = id } } + steps[#steps + 1] = { kind = "entry_triggers", data = { id = id } } + SwitchSeq._steps = steps + SwitchSeq._i = 1 + return true +end + -- pokefirered/src/battle_controller_player.c:2105 function SwitchSeq.beginEventSwitchIn(st, side, opts) opts = opts or {} @@ -285,11 +389,15 @@ function SwitchSeq.beginEventSwitchIn(st, side, opts) finish() return false end + local id = tonumber(opts.battler) or ((type(side) == "number") and side or nil) + if id ~= nil and not (st and st.double) and id < 2 then id = nil end + if type(side) == "number" then side = State.sideOf(side) end + local d = (id ~= nil) and { id = id } or { side = side } SwitchSeq._steps = { - { kind = (side == "player") and "sendout_player" or "sendout_enemy", data = { side = side } }, - { kind = "shiny_check", data = { side = side } }, - { kind = "cry", data = { side = side } }, - { kind = "healthbox", data = { side = side } }, + { kind = (side == "player") and "sendout_player" or "sendout_enemy", data = d }, + { kind = "shiny_check", data = d }, + { kind = "cry", data = d }, + { kind = "healthbox", data = d }, } SwitchSeq._i = 1 return true @@ -397,15 +505,12 @@ local function run_step(step) end if kind == "msg_sendout" then - local side = d.side or "player" + local side = step_side(d) local text = "" if side == "player" then - text = "Go! " .. State.displayName(st and st.player) .. "!" + text = "Go! " .. State.displayName(step_battler(st, d)) .. "!" else - local tname = (st and st.trainerClassName and st.trainerClassName ~= "") - and (st.trainerClassName .. " " .. (st.trainerName or "")) - or (st and st.trainerName or "TRAINER") - text = tname .. " sent\nout " .. State.displayName(st and st.enemy) .. "!" + text = trainer_label(st) .. " sent\nout " .. State.displayName(step_battler(st, d)) .. "!" end if side == "player" and not SwitchSeq._headless then -- pokefirered/src/battle_message.c:399 @@ -419,9 +524,8 @@ local function run_step(step) end if kind == "withdraw" then - local side = d.side or "player" - local p = Anim.present(side) - local hb = s.healthbox[side] + local p = step_present(d) + local hb = step_healthbox(s, d) if hb then hb.visible = false end pcall(function() Audio.playSe(SE.SE_BALL_OPEN) end) wait_busy() @@ -440,16 +544,17 @@ local function run_step(step) end if kind == "swap_data" then - local side = d.side or "player" + local side = step_side(d) local newSlot = d.newSlot or 1 local Engine = package.loaded["src.core.game3.battle.engine"] local party = st and ((side == "player") and st.playerParty or st.foeParty) - local pres = Anim.present(side) + local pres = step_present(d) -- pokefirered/src/battle_gfx_sfx_util.c:997 if pres then pres.castformForm, pres.castformMon = nil, nil end - if Engine and Engine.performSwitch and battle_adapter() and st and st[side] and party and party[newSlot] then + if Engine and Engine.performSwitch and battle_adapter() and step_battler(st, d) and party and party[newSlot] then capture_events(function(ad) - Engine.performSwitch(st, ad, side, newSlot, { batonPass = d.batonPass, reason = "switch" }) + Engine.performSwitch(st, ad, d.id ~= nil and d.id or side, newSlot, + { batonPass = d.batonPass, reason = d.reason or "switch" }) end) Anim.syncDisplayFromState(st) advance() @@ -477,7 +582,7 @@ local function run_step(step) end if kind == "sendout_player" then - local pcx, pcy = Anim.PLAYER_MON.x, Anim.PLAYER_MON.y + local pcx, pcy = step_center(st, d.id ~= nil and d or { side = "player" }) s.ball.visible = true s.ball.frame = 0 s.ball.rot = 0 @@ -497,8 +602,8 @@ local function run_step(step) s.ball.frame = 1 s.ball.rot = 0 pcall(function() Audio.playSe(SE.SE_BALL_OPEN, { pan = -64 }) end) - Anim.ballOpen("player", s.ball.x, s.ball.y) - local p = Anim.present("player") + Anim.ballOpen(d.id ~= nil and d.id or "player", s.ball.x, s.ball.y) + local p = step_present(d.id ~= nil and d or { side = "player" }) or {} p.visible = true p.ox = 0 p.oy = 16 @@ -520,7 +625,7 @@ local function run_step(step) end if kind == "sendout_enemy" then - local cx, cy = Anim.ENEMY_MON.x, Anim.ENEMY_MON.y + local cx, cy = step_center(st, d.id ~= nil and d or { side = "enemy" }) s.ball.visible = true s.ball.frame = 0 s.ball.side = "enemy" @@ -530,8 +635,8 @@ local function run_step(step) Anim.tweenStage(16, function() end, function() s.ball.frame = 1 pcall(function() Audio.playSe(SE.SE_BALL_OPEN, { pan = 63 }) end) - Anim.ballOpen("enemy", s.ball.x, s.ball.y) - local p = Anim.present("enemy") + Anim.ballOpen(d.id ~= nil and d.id or "enemy", s.ball.x, s.ball.y) + local p = step_present(d.id ~= nil and d or { side = "enemy" }) or {} p.visible = true p.ox = 0 p.oy = 16 @@ -552,8 +657,7 @@ local function run_step(step) end if kind == "shiny_check" then - local side = d.side or "player" - local b = st and st[side] + local b = step_battler(st, d) local mon = b and b.mon local isShiny = mon and SummaryData.isShiny(mon) if isShiny then @@ -569,8 +673,8 @@ local function run_step(step) end if kind == "cry" then - local side = d.side or "player" - local b = st and st[side] + local side = step_side(d) + local b = step_battler(st, d) local sp = b and (b.species or (b.mon and (b.mon.species or b.mon.speciesId))) if sp then -- pokefirered/src/pokeball.c:782 @@ -583,8 +687,8 @@ local function run_step(step) end if kind == "healthbox" then - local side = d.side or "player" - local hb = s.healthbox[side] + local side = step_side(d) + local hb = step_healthbox(s, d) or {} local from = (side == "player") and 115 or -115 hb.visible = true hb.ox = from @@ -600,6 +704,7 @@ local function run_step(step) if kind == "entry_triggers" then local sides = d.sides or { d.side or "player" } + if d.id ~= nil then sides = { d.id } end if #sides > 1 then table.sort(sides, function(a, bSide) local spA = st and st[a] and (st[a].speed or (st[a].mon and st[a].mon.speed)) or 0 @@ -635,6 +740,14 @@ local function run_step(step) if p then p.visible = false end local hb = s.healthbox and s.healthbox.enemy if hb then hb.visible = false end + if st and st.double then + for _, id in ipairs({ 1, 3 }) do + local pp = step_present({ id = id }) + if pp then pp.visible = false end + local hh = step_healthbox(s, { id = id }) + if hh then hh.visible = false end + end + end local Trainers = require("src.core.game3.scripting.trainers") local info = st and st.trainerId and Trainers.info(st.trainerId) local picId = (st and st.trainerPicId) or (info and info.pic) or 0 diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 0ad9e805..3438d091 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -36,6 +36,14 @@ Ui._st = nil Ui._pendingCommand = nil Ui._pendingYesNo = nil Ui._session = nil +Ui._active = 0 +Ui._actionCursor = {} +Ui._moveCursor = {} +Ui._moveCursorMon = {} +Ui._target = nil +Ui._bounce = { hb = {}, mon = {} } +Ui._preview = nil +Ui._partnerAction = nil -- pokefirered/src/battle_script_commands.c:5149 local BATTLE_YESNO = { left = 24, top = 9, style = "battle" } @@ -62,7 +70,12 @@ local function live_battler(side) local Battle = package.loaded["src.core.game3.battle"] local st = Battle and Battle._st local Anim = package.loaded["src.core.game3.battle.anim"] - local b = st and st[side] + local b + if type(side) == "number" then + b = st and ((side == 0 and st.player) or (side == 1 and st.enemy) or (st.battlers and st.battlers[side])) + else + b = st and st[side] + end if Anim and Anim.shownBattler then b = Anim.shownBattler(side, b) end return b, st end @@ -88,7 +101,26 @@ local function shows_ghost(side, st) end Ui.showsGhost = shows_ghost +local function is_double(st) + st = st or Ui._st + return type(st) == "table" and st.double == true +end + local function battler_sprite_center(side, species, base, form, ghost) + if type(side) == "number" then + local id = side + side = (id % 2 == 0) and "player" or "enemy" + local _, st = live_battler(id) + base = base or (PicCoords and PicCoords.battlerCoords and PicCoords.battlerCoords(is_double(st), id)) + if is_double(st) and side == "player" and PicCoords and species then + local sp = tonumber(species) or 0 + local yo = (PicCoords.back and PicCoords.back[sp]) or 0 + if sp == SPECIES_CASTFORM then yo = CASTFORM_BACK_Y[form or 0] or 0 end + -- pokefirered/src/battle_anim_mons.c:252 + local y = math.min(base.y + yo + 8, 160 - 64 + 8) + return base.x, y - 4 + end + end local cx, cy = base.x, (side == "player") and (base.y - 4) or base.y if not PicCoords or not species then return cx, cy end local sp = tonumber(species) or 0 @@ -119,6 +151,7 @@ Ui.battlerSpriteCenter = battler_sprite_center function Ui.battlerPic(side, battler, species) local b, st = live_battler(side) + if type(side) == "number" then side = (side % 2 == 0) and "player" or "enemy" end battler = battler or b if shows_ghost(side, st) and Pokemon.ghostPic then local g = Pokemon.ghostPic() @@ -148,6 +181,14 @@ function Ui.reset(opts) Ui._st = nil Ui._pendingCommand = nil Ui._pendingYesNo = nil + Ui._active = 0 + Ui._actionCursor = {} + Ui._moveCursor = {} + Ui._moveCursorMon = {} + Ui._target = nil + Ui._bounce = { hb = {}, mon = {} } + Ui._preview = nil + Ui._partnerAction = nil if not Ui._headless then pcall(BattleChrome.install, nil) end @@ -280,7 +321,7 @@ function Ui.choiceActive() end function Ui.waitingForCommand() - return Ui._mode == "menu" or Ui._mode == "moves" or Ui._mode == "bag" + return Ui._mode == "menu" or Ui._mode == "moves" or Ui._mode == "bag" or Ui._mode == "target" end local function open_battle_bag() @@ -309,6 +350,7 @@ local function open_battle_bag() itemId = itemId, partySlot = partySlot, } + if is_double() then Ui._pendingCommand.battler = Ui._active or 0 end Ui._mode = "none" end, onClose = function() @@ -321,7 +363,84 @@ function Ui.isShowing() return Ui._showing or Ui.busy() or (Message and Message.isOpen and Message.isOpen()) end +local function active_battler(st) + st = st or Ui._st + if not st then return nil end + local id = Ui._active or 0 + if id == 0 then return st.player end + return st.battlers and st.battlers[id] +end +Ui.activeBattlerObject = active_battler + +function Ui.activeBattler() + return Ui._active or 0 +end + +function Ui.battlePartyOrder(st) + return require("src.ui.game3.party_menu").battleOrder(st) +end + +function Ui.openPartyMenu(st, battlerId, opts) + opts = opts or {} + st = st or Ui._st + local PartyMenu = require("src.ui.game3.party_menu") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Ui._session + or (Runtime and Runtime.getSession and Runtime.getSession()) + local party = (st and st.playerParty) or (session and session.party) or {} + local overlay = session and session.move_overlay + local id = tonumber(battlerId) or 0 + local forced = opts.forced and true or false + if st and st.playerParty then + for _, bid in ipairs({ 0, 2 }) do + local b = (bid == 0 and st.player) or (st.double and st.battlers and st.battlers[bid]) + if b then State.syncBattlerToParty(b, st.playerParty) end + end + end + PartyMenu.show(party, overlay, { + mode = forced and "battle_faint" or "battle_switch", + layout = (st and st.double) and "double" or nil, + battleOrder = st and st.playerParty and Ui.battlePartyOrder(st) or nil, + session = session, + activeSlot = (st and st.player and st.player.partyIndex) or 1, + battle = true, + validate = function(pi) + if opts.validate then return opts.validate(pi) end + if st and st.double then return Commands.switchError(st, pi, forced, id) end + return Commands.switchError(st, pi, forced) + end, + onSelect = function(pi) + if opts.onSelect then opts.onSelect(pi) end + end, + onClose = opts.onClose, + }) +end + +local function open_battle_party_double() + local id = Ui._active or 0 + Ui._mode = "party" + Ui.openPartyMenu(Ui._st, id, { + onSelect = function(slot) + if slot == nil then + Ui._mode = "menu" + return + end + Ui._pendingCommand = { + kind = "switch", + user = "player", + battler = id, + slot = slot, + } + Ui._mode = "none" + end, + onClose = function() + Ui._mode = "menu" + end, + }) +end + local function open_battle_party() + if is_double() then return open_battle_party_double() end local PartyMenu = require("src.ui.game3.party_menu") local Runtime = package.loaded["src.core.game3.runtime"] local session = Ui._session @@ -358,11 +477,19 @@ local function open_battle_party() }) end -function Ui.openMenu() +function Ui.openMenu(battlerId, opts) Ui._linger = false Ui._timed = nil Ui._mode = "menu" - Ui._menuIndex = 1 + Ui._target = nil + Ui._active = tonumber(battlerId) or 0 + Ui._partnerAction = opts and opts.partnerAction or nil + if is_double() then + -- pokefirered/src/battle_controller_player.c:2421 + Ui._menuIndex = Ui._actionCursor[Ui._active] or 1 + else + Ui._menuIndex = 1 + end Ui._pendingCommand = nil if Message and Message.open then Message.open = false @@ -392,6 +519,12 @@ end function Ui.takeCommand() local c = Ui._pendingCommand Ui._pendingCommand = nil + if c then + -- pokefirered/src/battle_controller_player.c:2809 + Ui._bounce = { hb = {}, mon = {} } + Ui._target = nil + Ui._preview = nil + end return c end @@ -503,6 +636,23 @@ end -- pokefirered/src/battle_main.c:2380 local function open_move_menu() + if is_double() then + local id = Ui._active or 0 + local battler = active_battler() + local mon = battler and battler.mon + if mon ~= Ui._moveCursorMon[id] then + Ui._moveCursorMon[id] = mon + Ui._moveCursor[id] = 1 + end + local n = move_count(mon) + local idx = tonumber(Ui._moveCursor[id]) or 1 + if idx < 1 then idx = 1 end + if idx > n then idx = n end + Ui._moveCursor[id] = idx + Ui._moveIndex = idx + Ui._mode = "moves" + return + end local battler = Ui._st and Ui._st.player if battler ~= Ui._moveIndexBattler then Ui._moveIndexBattler = battler @@ -516,8 +666,540 @@ local function open_move_menu() Ui._mode = "moves" end +local MT = { SELECTED = 0, DEPENDS = 1, USER_OR_SELECTED = 2, RANDOM = 4, BOTH = 8, USER = 16, FOES_AND_ALLY = 32, OPPONENTS_FIELD = 64 } +local MOVE_CURSE = 174 +local TYPE_GHOST = 7 +local ITEM_PREMIER_BALL = 12 +-- pokefirered/src/battle_controller_player.c:171 +local TARGET_IDENTITIES = { 0, 2, 3, 1 } + +local function band(a, b) + local bit = require("bit") + return bit.band(tonumber(a) or 0, tonumber(b) or 0) +end + +local function move_num(mv) + local n = tonumber(mv) + if n then return n end + if mv == nil or mv == "" then return 0 end + if Moves.numForName then + local norm = Moves.normalizeId and Moves.normalizeId(mv) or mv + return Moves.numForName(norm) or 0 + end + return 0 +end + +local function is_type(b, t) + return b and (tonumber(b.type1) == t or tonumber(b.type2) == t) +end + +-- pokefirered/src/battle_controller_player.c:444 +local function move_target_type(battler, mv) + if move_num(mv) == MOVE_CURSE then + return is_type(battler, TYPE_GHOST) and MT.SELECTED or MT.USER + end + local def = mv and Moves.get(mv) + return tonumber(def and def.target) or 0 +end +Ui.moveTargetType = move_target_type + +local function absent(st, id) + if not st then return true end + if st.absent and st.absent[id] then return true end + local b = (id == 0 and st.player) or (id == 1 and st.enemy) or (st.battlers and st.battlers[id]) + return b == nil +end + +-- pokefirered/src/pokemon.c:2651 +local function count_except_active(st, id) + local n = 0 + for i = 0, 3 do + if i ~= id and not absent(st, i) then n = n + 1 end + end + return n +end + +-- pokefirered/src/battle_controller_player.c:437 +function Ui.targetSelection(st, id, slot) + st = st or Ui._st + id = tonumber(id) or 0 + local b = (id == 0 and st.player) or (st.battlers and st.battlers[id]) + local mon = b and b.mon + local mv = mon and mon.moves and mon.moves[slot] + local tt = move_target_type(b, mv) + local opposingLeft = (id % 2 == 0) and 1 or 0 + local cursor = (band(tt, MT.USER) ~= 0) and id or opposingLeft + if not (st and st.double) then return false, cursor, cursor end + local can = band(tt, MT.RANDOM + MT.BOTH + MT.DEPENDS + MT.FOES_AND_ALLY + MT.OPPONENTS_FIELD + MT.USER) == 0 + local pp = mon and mon.pp and tonumber(mon.pp[slot]) + if pp ~= nil and pp == 0 then + can = false + elseif band(tt, MT.USER + MT.USER_OR_SELECTED) == 0 and count_except_active(st, id) <= 1 then + -- pokefirered/src/pokemon.c:2686 + cursor = absent(st, opposingLeft) and (opposingLeft + 2) or opposingLeft + can = false + end + if not can then return false, cursor, cursor end + local start + if band(tt, MT.USER + MT.USER_OR_SELECTED) ~= 0 then + start = id + elseif absent(st, opposingLeft) then + start = opposingLeft + 2 + else + start = opposingLeft + end + return true, cursor, start +end + +-- pokefirered/src/battle_main.c:2091 +local function start_bounce(kind, id, delta, amp) + local t = Ui._bounce[kind] + if t[id] then return end + t[id] = { idx = (kind == "hb") and 128 or 192, delta = delta, amp = amp, y = 0, fresh = true } +end + +-- pokefirered/src/battle_main.c:2132 +local function end_bounce(kind, id) + Ui._bounce[kind][id] = nil +end + +local function end_all_bounces() + Ui._bounce = { hb = {}, mon = {} } +end + +-- pokefirered/src/trig.c:4 +local function sin_q8(idx, amp) + local v = math.floor(math.sin((idx % 256) * math.pi / 128) * 256 + 0.5) + return math.floor(v * amp / 256) +end + +-- pokefirered/src/battle_main.c:2159 +local function tick_bounces() + for _, kind in ipairs({ "hb", "mon" }) do + for _, bo in pairs(Ui._bounce[kind]) do + if bo.fresh then + bo.fresh = false + bo.y = 0 + else + bo.y = sin_q8(bo.idx, bo.amp) + bo.amp + bo.idx = (bo.idx + bo.delta) % 256 + end + end + end +end + +function Ui.bounceOffset(kind, id) + local bo = Ui._bounce[kind] and Ui._bounce[kind][id] + return bo and bo.y or 0 +end + +local PREVIEW_ALL = { [114] = true, [201] = true, [195] = true, [240] = true, [241] = true, [258] = true, [300] = true, [346] = true } +local PREVIEW_ALLIES = { [219] = true, [115] = true, [113] = true, [54] = true, [215] = true, [312] = true } +local MOVE_HELPING_HAND = 270 + +-- pokefirered/src/battle_controller_player.c:2889 +local function preview_targets(st, id, slot) + local b = active_battler(st) + local mv = b and b.mon and b.mon.moves and b.mon.moves[slot] + local tt = move_target_type(b, mv) + local num = move_num(mv) + local partner = (id + 2) % 4 + if tt == MT.SELECTED or tt == MT.DEPENDS or tt == MT.USER_OR_SELECTED or tt == MT.RANDOM then + return { [0] = true, [1] = true, [2] = true, [3] = true }, 0 + elseif tt == MT.BOTH or tt == MT.OPPONENTS_FIELD then + return { [1] = true, [3] = true }, 8 + elseif tt == MT.USER then + if PREVIEW_ALL[num] then return { [0] = true, [1] = true, [2] = true, [3] = true }, 8 end + if PREVIEW_ALLIES[num] then return { [0] = true, [2] = true }, 8 end + if num == MOVE_HELPING_HAND then return { [partner] = true }, 8 end + return { [id] = true }, 8 + elseif tt == MT.FOES_AND_ALLY then + return { [1] = true, [partner] = true, [3] = true }, 8 + end + return {}, 0 +end + +local ALL_BATTLERS = { [0] = true, [1] = true, [2] = true, [3] = true } + +local function fade_state() + local f = Ui._preview + if not f then + f = { active = false, objY = {}, objToggle = false, mask = {}, y = 0, target = 0, + delay = 0, delayCounter = 0, finishing = false, finCounter = 0 } + Ui._preview = f + end + return f +end + +-- pokefirered/src/palette.c:393 +local function fade_update(f) + if not f.active then return end + if f.finishing then + -- pokefirered/src/palette.c:757 + if f.finCounter == 4 then + f.active, f.finishing, f.finCounter = false, false, 0 + else + f.finCounter = f.finCounter + 1 + end + return + end + if not f.objToggle then + if f.delayCounter < f.delay then + f.delayCounter = f.delayCounter + 1 + return + end + f.delayCounter = 0 + else + for id in pairs(f.mask) do f.objY[id] = f.y end + end + f.objToggle = not f.objToggle + if not f.objToggle then + if f.y == f.target then + f.mask = {} + f.finishing = true + elseif f.y > f.target then + f.y = math.max(f.target, f.y - 2) + else + f.y = math.min(f.target, f.y + 2) + end + end +end + +-- pokefirered/src/palette.c:151 +local function fade_begin(mask, delay, startY, targetY) + local f = fade_state() + if f.active then return false end + f.mask, f.delay, f.delayCounter = mask, delay, delay + f.y, f.target = startY, targetY + f.active = true + fade_update(f) + return true +end + +-- pokefirered/src/palette.c:349 +local function fade_reset_clear() + local f = fade_state() + f.active, f.finishing, f.finCounter, f.delayCounter, f.y, f.target = false, false, 0, 0, 0, 0 + fade_begin(ALL_BATTLERS, 0, 0, 0) +end + +local function tick_preview() + if not is_double() then return end + local f = fade_state() + if Ui._mode == "moves" then + -- pokefirered/src/battle_controller_player.c:442 + local mask, y = preview_targets(Ui._st, Ui._active or 0, Ui._moveIndex) + fade_begin(mask, 8, y, 0) + end + fade_update(f) +end + +function Ui.previewCoeff(id) + local f = Ui._preview + return (f and f.objY[id]) or 0 +end + +-- pokefirered/src/battle_main.c:2019 +local function blink_start(t) + t.blinkCounter = 8 + t.hidden = false +end + +local function tick_target() + local t = Ui._target + if not t then return end + t.blinkCounter = (t.blinkCounter or 8) - 1 + if t.blinkCounter <= 0 then + t.hidden = not t.hidden + t.blinkCounter = 8 + end +end + +function Ui.targetHidden(id) + local t = Ui._target + return t ~= nil and t.cursor == id and t.hidden == true +end + +function Ui.targetCursor() + return Ui._target and Ui._target.cursor or nil +end + +function Ui.tick() + local m = Ui._mode + if m == "menu" or m == "moves" or m == "target" or m == "selmsg" then + local id = Ui._active or 0 + if m == "menu" then + -- pokefirered/src/battle_controller_player.c:223 + start_bounce("hb", id, 7, 1) + start_bounce("mon", id, 7, 1) + elseif m == "target" and Ui._target then + -- pokefirered/src/battle_controller_player.c:326 + local cur = Ui._target.cursor + start_bounce("hb", cur, 15, 1) + for i = 0, 3 do + if i ~= cur then end_bounce("hb", i) end + end + end + tick_bounces() + tick_target() + elseif m ~= "bag" and m ~= "party" then + end_all_bounces() + end + tick_preview() +end + +local function finish_move_choice(id, slot, target) + local err = Commands.selectionError(Ui._st, slot, id) + if err then + -- pokefirered/src/battle_main.c:3277 + Ui._selCmd = nil + Ui._selReturn = "moves" + Ui._mode = "selmsg" + Ui.push(err) + return + end + Ui._pendingCommand = Commands.playerAction(Ui._st, 1, slot, id, target) + Ui._mode = "none" + end_all_bounces() +end + +local function enter_target_mode(id, slot, start, cb) + Ui._target = { battler = id, slot = slot, cursor = start, cb = cb } + blink_start(Ui._target) + Ui._mode = "target" +end + +-- pokefirered/src/battle_controller_player.c:493 +function Ui.chooseTarget(st, battlerId, moveSlot, cb) + if st then Ui._st = st end + local id = tonumber(battlerId) or 0 + Ui._active = id + local needs, target, start = Ui.targetSelection(Ui._st, id, moveSlot) + if not needs then + if cb then cb(target) end + return false + end + Ui._moveIndex = moveSlot + enter_target_mode(id, moveSlot, start, cb or false) + return true +end + +-- pokefirered/src/battle_controller_player.c:355 +local function cycle_target(dir) + local t = Ui._target + local st = Ui._st + local b = active_battler(st) + local mv = b and b.mon and b.mon.moves and b.mon.moves[t.slot] + local def = mv and Moves.get(mv) + local userOrSel = band(def and def.target, MT.USER_OR_SELECTED) ~= 0 + local pos = 1 + for i = 1, 4 do + if TARGET_IDENTITIES[i] == t.cursor then pos = i break end + end + for _ = 1, 8 do + pos = pos + dir + if pos < 1 then pos = 4 elseif pos > 4 then pos = 1 end + local cand = TARGET_IDENTITIES[pos] + local ok + if cand % 2 == 0 then + ok = (cand ~= t.battler) or userOrSel + else + ok = true + end + if absent(st, cand) then ok = false end + if ok then + t.cursor = cand + break + end + end + blink_start(t) +end + +local function handle_target_input(input) + local t = Ui._target + if not t then + Ui._mode = "moves" + return true + end + if input:wasPressed("a") then + play_select() + local cur, cb, id, slot = t.cursor, t.cb, t.battler, t.slot + Ui._target = nil + end_bounce("hb", cur) + if cb then + Ui._mode = "none" + cb(cur) + else + finish_move_choice(id, slot, cur) + end + return true + elseif input:wasPressed("b") then + play_select() + local cur, cb, id = t.cursor, t.cb, t.battler + Ui._target = nil + -- pokefirered/src/battle_controller_player.c:346 + start_bounce("hb", id, 7, 1) + start_bounce("mon", id, 7, 1) + end_bounce("hb", cur) + if cb then + Ui._mode = "none" + cb(nil) + else + Ui._mode = "moves" + end + return true + elseif input:wasPressed("left") or input:wasPressed("up") then + play_select() + cycle_target(-1) + return true + elseif input:wasPressed("right") or input:wasPressed("down") then + play_select() + cycle_target(1) + return true + end + return true +end + +local function handle_double_input(input) + local st = Ui._st + local id = Ui._active or 0 + if Ui._mode == "target" then + return handle_target_input(input) + end + if Ui._mode == "menu" then + -- pokefirered/src/battle_controller_player.c:219 + local c = (Ui._menuIndex or 1) - 1 + local nc = c + if input:wasPressed("a") then + play_select() + Ui._actionCursor[id] = Ui._menuIndex + local kind = Commands.MENU[Ui._menuIndex] + if kind == "FIGHT" then + local act, msg = Commands.fightShortcut(st, id) + if act and msg then + Ui._selCmd = act + Ui._mode = "selmsg" + Ui.push(msg) + elseif act then + Ui._pendingCommand = act + Ui._mode = "none" + end_all_bounces() + else + open_move_menu() + end + elseif kind == "BAG" then + open_battle_bag() + elseif kind == "POKEMON" or kind == "POKéMON" then + open_battle_party() + else + local Engine = package.loaded["src.core.game3.battle.engine"] + local BattleMod = package.loaded["src.core.game3.battle"] + local ad = BattleMod and BattleMod._adapter + local canRun, why = true, nil + if Engine and Engine.canRun and ad and st then + canRun, why = Engine.canRun(st, ad, active_battler(st)) + end + if not canRun and why then + Ui._selCmd = nil + Ui._selReturn = "menu" + Ui._mode = "selmsg" + Ui.push(why) + else + Ui._pendingCommand = Commands.playerAction(st, Ui._menuIndex, nil, id) + Ui._mode = "none" + end_all_bounces() + end + end + return true + elseif input:wasPressed("left") then + if c % 2 == 1 then nc = c - 1 end + elseif input:wasPressed("right") then + if c % 2 == 0 then nc = c + 1 end + elseif input:wasPressed("up") then + if c >= 2 then nc = c - 2 end + elseif input:wasPressed("down") then + if c < 2 then nc = c + 2 end + elseif input:wasPressed("b") then + -- pokefirered/src/battle_controller_player.c:286 + if id == 2 and not (st.absent and st.absent[0]) then + local pa = Ui._partnerAction + local refund = nil + if pa and pa.kind == "bag" then + local item = tonumber(pa.itemId or pa.item) + if item and item <= ITEM_PREMIER_BALL then + refund = item + else + return true + end + end + play_select() + Ui._pendingCommand = { kind = "cancel_partner", battler = id, refundItem = refund } + Ui._mode = "none" + end_all_bounces() + end + return true + elseif input:wasPressed("start") then + -- pokefirered/src/battle_controller_player.c:306 + local Healthbox = require("src.core.game3.battle.healthbox") + Healthbox.swapHpBarsWithHpText(st) + return true + end + if nc ~= c then + play_select() + Ui._menuIndex = nc + 1 + Ui._actionCursor[id] = Ui._menuIndex + end + return true + elseif Ui._mode == "bag" or Ui._mode == "party" then + return true + elseif Ui._mode == "moves" then + local b = active_battler(st) + local n = move_count(b and b.mon) + local c = (Ui._moveIndex or 1) - 1 + local nc = c + -- pokefirered/src/battle_controller_player.c:511 + if input:wasPressed("left") then + if c % 2 == 1 then nc = c - 1 end + elseif input:wasPressed("right") then + if c % 2 == 0 and c + 1 < n then nc = c + 1 end + elseif input:wasPressed("up") then + if c >= 2 then nc = c - 2 end + elseif input:wasPressed("down") then + if c < 2 and c + 2 < n then nc = c + 2 end + end + local idx, moved = nc + 1, nc ~= c + if moved then + Ui._moveIndex = idx + Ui._moveCursor[id] = idx + play_select() + -- pokefirered/src/battle_controller_player.c:521 + fade_begin(ALL_BATTLERS, 0, 0, 0) + return true + end + if input:wasPressed("a") then + play_select() + local slot = Ui._moveIndex + local needs, target, start = Ui.targetSelection(st, id, slot) + fade_reset_clear() + if needs then + enter_target_mode(id, slot, start, false) + else + finish_move_choice(id, slot, target) + end + return true + elseif input:wasPressed("b") then + play_select() + fade_reset_clear() + Ui._mode = "menu" + return true + end + end + return false +end + function Ui.handleInput(input) if not input then return false end + Ui.tick() -- Learn-move / evo YES-NO and forget list if Choice and Choice.active then @@ -538,6 +1220,7 @@ function Ui.handleInput(input) end if not Ui.waitingForCommand() then return false end + if is_double() then return handle_double_input(input) end if Ui._mode == "menu" then local idx, moved = grid_nav(Ui._menuIndex, input, 4) if moved then @@ -807,13 +1490,15 @@ end --- Draw mon pic at GetBattlerSpriteFinal_Y center (64×64 → TL = center−32). -- Applies Anim present offsets / alpha / visibility / z (Dig/Fly hide). -local function draw_mon_sprite(battler, base, back) +local function draw_mon_sprite(battler, base, back, id) if not battler then return end local side = back and "player" or "enemy" + local key = id or side local Anim = require("src.core.game3.battle.anim") - local pres = Anim.present(side) + local pres = Anim.present(key) if pres and (pres.visible == false or pres.blinkHidden or pres.battlerInvisible or pres.invisible) then return end - battler = Anim.shownBattler(side, battler) or battler + if id and Ui.targetHidden(id) then return end + battler = Anim.shownBattler(key, battler) or battler local sp = battler.species if not sp and battler.mon and Pokemon.speciesOf then @@ -829,20 +1514,21 @@ local function draw_mon_sprite(battler, base, back) if tf then sp = tf end local ghost = shows_ghost(side, Ui._st) local form = (tonumber(sp) == SPECIES_CASTFORM) and castform_form(side, battler) or 0 - local cx, cy = battler_sprite_center(side, sp, base, form, ghost) + local cx, cy = battler_sprite_center(id or side, sp, base, form, ghost) if pres then cx = cx + (pres.ox or 0) cy = cy + (pres.oy or 0) end + cy = cy + Ui.bounceOffset("mon", id or (back and 0 or 1)) local scale = (pres and pres.scale) or 1 local darken = (pres and pres.darken) or 0 local entry - local dollImg = pres and pres.substitute and Anim.substituteImage(side) + local dollImg = pres and pres.substitute and Anim.substituteImage(key) if dollImg then -- pokefirered/src/battle_gfx_sfx_util.c:794 entry = { image = dollImg } cx = base.x + (pres.ox or 0) - cy = (pres.substituteY or Anim.substituteY(side)) + (pres.oy or 0) + cy = (pres.substituteY or Anim.substituteY(key)) + (pres.oy or 0) end if not entry and ghost and Pokemon.ghostPic then entry = Pokemon.ghostPic() @@ -866,7 +1552,13 @@ local function draw_mon_sprite(battler, base, back) local sx = (hFlip and -1 or 1) * scale * ((pres and pres.sx) or 1) local sy = scale * ((pres and pres.sy) or 1) local rot = (pres and pres.rotation) or 0 - local blended = BallOpen.setBlendShader(BallOpen.monBlend(side)) + local blended + if id then + blended = BallOpen.setBlendShader(BallOpen.monBlend(id)) + if not blended and id < 2 then blended = BallOpen.setBlendShader(BallOpen.monBlend(side)) end + else + blended = BallOpen.setBlendShader(BallOpen.monBlend(side)) + end if not blended and pres then if pres.palAffine then blended = set_affine_shader(pres.palAffine) @@ -883,6 +1575,11 @@ local function draw_mon_sprite(battler, base, back) -- pokefirered/src/battle_gfx_sfx_util.c:747 blended = BallOpen.setBlendShader(6, 31, 31, 31) end + if not blended and id then + local pc = Ui.previewCoeff(id) + -- pokefirered/src/battle_controller_player.c:2987 + if pc > 0 then blended = BallOpen.setBlendShader(pc, 31, 31, 31) end + end if pres and type(pres.hShift) == "table" and rot == 0 and sy == 1 then local img = entry.image local iw, ih = img:getDimensions() @@ -932,7 +1629,8 @@ local function draw_action_menu(st) -- B_WIN_ACTION_MENU @ (17,15) → (136,120); printer (0,2) → (136,122) -- ActionSelectionCreateCursorAt: tile (16+7*col, 35+row) → after scroll (128,120); -- cursor is a 1×2 BG pip whose ink lines up with printer y=2 text → draw at text Y. - local name = st and st.player and State.displayName(st.player) or "POKéMON" + local ab = st and (is_double(st) and active_battler(st) or st.player) + local name = ab and State.displayName(ab) or "POKéMON" draw_prompt_text(string.format("What will\n%s do?", name), 10, 122) local labels = { "FIGHT", "BAG", "POKéMON", "RUN" } local positions = { @@ -952,7 +1650,8 @@ local function draw_action_menu(st) end local function draw_move_menu(st) - local mon = st and st.player and st.player.mon + local ab = st and (is_double(st) and active_battler(st) or st.player) + local mon = ab and ab.mon local positions = { { 16, 122 }, { 88, 122 }, { 16, 138 }, { 88, 138 }, @@ -1026,17 +1725,17 @@ local function draw_player_trainer(stage) end end -local function draw_intro_ball(stage) - if not stage or not stage.ball or not stage.ball.visible then return end - local bx = (stage.ball.x or 0) + (stage.ball.ox or 0) - local by = (stage.ball.y or 0) + (stage.ball.oy or 0) - local rot = tonumber(stage.ball.rot) or 0 - local frame = math.max(0, math.min(2, tonumber(stage.ball.frame) or 0)) - local darken = tonumber(stage.ball.darken) or 0 - local flash = tonumber(stage.ball.flash) or 0 +local function draw_ball_entry(ball) + if not ball or not ball.visible then return end + local bx = (ball.x or 0) + (ball.ox or 0) + local by = (ball.y or 0) + (ball.oy or 0) + local rot = tonumber(ball.rot) or 0 + local frame = math.max(0, math.min(2, tonumber(ball.frame) or 0)) + local darken = tonumber(ball.darken) or 0 + local flash = tonumber(ball.flash) or 0 local shade = math.max(0, math.min(1, 1 - darken * (1 - 8 / 255))) - local alpha = tonumber(stage.ball.alpha) or 1 + local alpha = tonumber(ball.alpha) or 1 if flash > 0 and flash % 2 == 0 then love.graphics.setColor(1, 1, 1, alpha) else @@ -1070,7 +1769,7 @@ local function draw_intro_ball(stage) local iw, ih = img:getDimensions() Ui._ballQuads[key] = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih) end - local blend = stage.ball.blend + local blend = ball.blend local blended = blend and BallOpen.setBlendShader(blend.coeff, blend.r, blend.g, blend.b) love.graphics.draw(img, Ui._ballQuads[key], bx, by, rot, 1, 1, 8, 8) if blended then love.graphics.setShader() end @@ -1095,13 +1794,79 @@ local function draw_intro_ball(stage) love.graphics.setColor(1, 1, 1, 1) end +-- pokefirered/src/battle_controller_player.c:2105 +local function draw_intro_ball(stage) + if not stage then return end + draw_ball_entry(stage.ball) + local balls = stage.balls + if type(balls) == "table" then + for id = 0, 3 do + local e = rawget(balls, id) + if e and e ~= stage.ball then draw_ball_entry(e) end + end + end +end + +local function battler_at(st, id) + if id == 0 then return st.player end + if id == 1 then return st.enemy end + return st.battlers and st.battlers[id] +end + +-- pokefirered/src/battle_anim_mons.c:1908 +local function draw_double_mons(st, stage, Anim, screenFxActive) + local order = (Anim.monDrawOrder and Anim.monDrawOrder(st)) or PicCoords.DRAW_ORDER + local band = Anim.particleBand + local function particles(k) + local lo, hi + if band then lo, hi = band(k, st) end + if not lo then + lo = (k == 0) and 0 or (k * 100 + 1) + hi = (k >= #order) and 999 or (k * 100 + 99) + end + Anim.drawParticles(lo, hi) + end + draw_enemy_trainer(stage) + particles(0) + for k, id in ipairs(order) do + if id % 2 == 0 and k > 1 and order[k - 1] % 2 == 1 then + draw_player_trainer(stage) + end + if not (st.absent and st.absent[id]) then + local b = battler_at(st, id) + local base = (Anim.coords and Anim.coords(st, id)) or PicCoords.battlerCoords(true, id) + draw_mon_sprite(b, base, id % 2 == 0, id) + end + if screenFxActive then Anim.beginScreenEffect() end + particles(k) + end +end + +-- pokefirered/src/battle_interface.c:540 +local function draw_double_healthboxes(st, Anim) + for id = 3, 0, -1 do + local b = battler_at(st, id) + if b and not (st.absent and st.absent[id]) then + Healthbox.draw(id, Anim.shownBattler(id, b), { st = st, oy = Ui.bounceOffset("hb", id) }) + end + end +end + +-- pokefirered/src/battle_interface.c:1080 +function Ui.partySummaryCoords(st, battlerId, isSwitchingMons) + local id = tonumber(battlerId) or 0 + if id % 2 == 0 then return 136, 96 end + if isSwitchingMons and is_double(st) and id ~= 3 then return 104, 16 end + return 104, 40 +end + local function draw_party_bars(stage) if not stage or not stage.partyBar then return end local m = BattleChrome.manifest and BattleChrome.manifest() or {} local enemy = stage.partyBar.enemy if enemy and enemy.visible then local pos = m.partyBarOpponent or { x = 104, y = 40 } - BattleChrome.drawPartyBar(pos.x, pos.y, enemy.balls, enemy.ox, true) + BattleChrome.drawPartyBar(enemy.x or pos.x, enemy.y or pos.y, enemy.balls, enemy.ox, true) end local player = stage.partyBar.player if player and player.visible then @@ -1178,6 +1943,10 @@ function Ui.draw(w, h) -- 3. In front of Enemy / Behind Player / Mid-field (Z: 101 .. 199) -- 4. Player Mon (Z: 200) -- 5. In front of Player & Global Foreground (Z: 201 .. 999) + local dbl = st and is_double(st) + if dbl then + draw_double_mons(st, stage, Anim, screenFxActive) + else draw_enemy_trainer(stage) Anim.drawParticles(0, 99) if st then @@ -1192,27 +1961,30 @@ function Ui.draw(w, h) end if screenFxActive then Anim.beginScreenEffect() end Anim.drawParticles(201, 999) + end draw_intro_ball(stage) -- pokefirered/src/pokeball.c:770 BallOpen.draw() if screenFxActive then Anim.endScreenEffect() end - if st then - Healthbox.draw("enemy", Anim.shownBattler("enemy", st.enemy)) - Healthbox.draw("player", Anim.shownBattler("player", st.player)) + if dbl then + draw_double_healthboxes(st, Anim) + elseif st then + Healthbox.draw("enemy", Anim.shownBattler("enemy", st.enemy), { oy = Ui.bounceOffset("hb", 1) }) + Healthbox.draw("player", Anim.shownBattler("player", st.player), { oy = Ui.bounceOffset("hb", 0) }) end draw_party_bars(stage) local panelMode = "none" if Ui._mode == "menu" then panelMode = "menu" - elseif Ui._mode == "moves" then + elseif Ui._mode == "moves" or Ui._mode == "target" then panelMode = "moves" end BattleChrome.drawPanel(panelMode) if Ui._mode == "menu" then draw_action_menu(st) - elseif Ui._mode == "moves" then + elseif Ui._mode == "moves" or Ui._mode == "target" then draw_move_menu(st) end diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 63566025..6acd7a98 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -4,6 +4,7 @@ local Party = require("src.core.game3.party") local PartyView = require("src.core.game3.battle.party_view") local Downgrade = require("src.core.game3.battle_downgrade") +local ModRuntime = require("src.mods.Runtime") local BattleBridge = {} @@ -118,6 +119,41 @@ function BattleBridge.installWhiteoutIntercept(mod, game) end end +local LEAD_FIELDS = { "species", "level", "rawIv", "iv", "ivs", "evs", "heldItem", + "moves", "personality" } + +-- pokefirered/src/battle_main.c:1539 +local function hooked_trainer_party(foe, trainerId) + if type(foe) ~= "table" or type(foe.party) ~= "table" or #foe.party == 0 then return foe end + local G3 = require("src.mods.Gen3Compat") + local named = G3.partyNames(foe.party) + local out = ModRuntime.call("trainer.party", function(_, _, party) + return party + end, foe.trainerClass, trainerId, named) + if type(out) ~= "table" or #out == 0 then return foe end + local copy = {} + for k, v in pairs(foe) do copy[k] = v end + copy.party = G3.partyNums(out) + local lead = copy.party[1] + for _, key in ipairs(LEAD_FIELDS) do copy[key] = lead[key] end + return copy +end + +local function battle_payload(Battle, opts, foe, isDouble) + local st = Battle.getState and Battle.getState() + local G3 = require("src.mods.Gen3Compat") + local enemy = st and st.enemy and st.enemy.mon + local sp = enemy and tonumber(enemy.species or enemy.speciesId) + local tid = opts.trainerId or (foe and foe.trainerId) + return { + battle = st, kind = opts.wild and "wild" or "trainer", + trainerId = (not opts.wild) and tid or nil, + trainerClass = (not opts.wild) and foe and foe.trainerClass or nil, + species = G3.speciesName(sp), speciesId = sp, + level = enemy and enemy.level, double = isDouble and true or false, + } +end + local function writeback(session, battleParty, remap, result, save, opts) opts = opts or {} if not session then return end @@ -178,6 +214,10 @@ function BattleBridge.start(mod, game, foe, opts) local battleParty, remap = PartyView.fromSession(session.party, session.move_overlay) if #battleParty == 0 then return nil, "empty party" end + local isDouble = (not opts.wild) and (opts.double or (foe and foe.doubleBattle)) and true or false + if isDouble and Party.monsStateToDoubles(session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS then + return nil, "need two mons" + end BattleBridge._remap = remap BattleBridge._battleParty = battleParty @@ -185,8 +225,19 @@ function BattleBridge.start(mod, game, foe, opts) local save = game and game.save local done = opts.done + if not opts.wild and ModRuntime.wantsHook("trainer.party") then + foe = hooked_trainer_party(foe, opts.trainerId or (foe and foe.trainerId)) + end + local function finish(result) writeback(session, battleParty, remap, result, save, opts) + -- pokefirered/src/battle_main.c:3861 + if ModRuntime.wants("battle.ended") then + local B = package.loaded["src.core.game3.battle"] + ModRuntime.emit("battle.ended", { + battle = B and B.getState and B.getState() or nil, result = result or "win", + }) + end BattleBridge._remap = nil BattleBridge._battleParty = nil BattleBridge._finish = nil @@ -216,6 +267,7 @@ function BattleBridge.start(mod, game, foe, opts) local startOpts = { wild = opts.wild, + double = isDouble, playerParty = battleParty, foe = foe, headless = opts.headless, @@ -231,6 +283,12 @@ function BattleBridge.start(mod, game, foe, opts) onDone = function(result) finish(result) end, + onStarted = function() + -- pokefirered/src/battle_main.c:612 + if ModRuntime.wants("battle.started") then + ModRuntime.emit("battle.started", battle_payload(Battle, opts, foe, isDouble)) + end + end, } local function resolve_battle_song(o, so) @@ -298,6 +356,9 @@ function BattleBridge.start(mod, game, foe, opts) local leadMon = battleParty and battleParty[1] local playerLv = leadMon and (leadMon.level or leadMon.lvl) or 5 local foeLv = (foe and foe.level) or (foe and foe.party and foe.party[1] and (foe.party[1].level or foe.party[1].lvl)) or 3 + if isDouble then + playerLv, foeLv = PartyView.doubleTransitionLevels(battleParty, foe and foe.party) + end local pickOpts = { wild = opts.wild, diff --git a/src/core/game3/encounters.lua b/src/core/game3/encounters.lua index f8a5d83e..6be918d3 100644 --- a/src/core/game3/encounters.lua +++ b/src/core/game3/encounters.lua @@ -3,6 +3,7 @@ -- RNG: pret wild_encounter.c — Random() for gate/slot/level, WildEncounterRandom for rate. local Rng = require("src.core.game3.rng") +local ModRuntime = require("src.mods.Runtime") local Encounters = {} @@ -218,7 +219,7 @@ function Encounters.rollWater(mapId, enterFromOther) return roll_area(mapId, "water", WATER_WEIGHTS, enterFromOther, 15) end -function Encounters.onStep(mapId, terrain, opts) +local function vanilla_step(mapId, terrain, opts) Encounters.ensureLoaded() opts = opts or {} local enterFromOther = opts.enterFromOther @@ -231,6 +232,54 @@ function Encounters.onStep(mapId, terrain, opts) return Encounters.rollLand(mapId, nil, enterFromOther) end +local function mod_encounter(enc) + if type(enc) ~= "table" then return enc end + local Pokemon = require("src.core.game3.pokemon") + local id = tonumber(enc.species) + return { + species = (id and Pokemon.keyName(id)) or enc.species, + speciesId = id or Pokemon.speciesFromName(enc.species), + level = enc.level, + item = enc.item, + } +end + +local function engine_encounter(enc) + if type(enc) ~= "table" then return nil end + local id = tonumber(enc.species) + if not id and enc.species ~= nil then + local Pokemon = require("src.core.game3.pokemon") + id = Pokemon.speciesFromName(enc.species) + end + id = id or tonumber(enc.speciesId) + if not id then return nil end + return { species = id, level = tonumber(enc.level) or 5, item = enc.item } +end + +local function same_encounter(enc) return enc end + +function Encounters.onStep(mapId, terrain, opts) + local wantsRoll = ModRuntime.wantsHook("encounter.roll") + local wantsSpecies = ModRuntime.wantsHook("encounter.species") + if not (wantsRoll or wantsSpecies) then + return vanilla_step(mapId, terrain, opts) + end + Encounters.ensureLoaded() + local ctx = { mapId = mapId, terrain = terrain, rng = Rng.Random, opts = opts } + local enc + if wantsRoll then + enc = ModRuntime.call("encounter.roll", function() + return mod_encounter(vanilla_step(mapId, terrain, opts)) + end, table_for(mapId), ctx) + else + enc = mod_encounter(vanilla_step(mapId, terrain, opts)) + end + if enc and wantsSpecies then + enc = ModRuntime.call("encounter.species", same_encounter, enc, ctx) + end + return engine_encounter(enc) +end + function Encounters.noteGrass(onGrass) Encounters._prevGrass = onGrass and true or false end diff --git a/src/core/game3/evolution.lua b/src/core/game3/evolution.lua index 2bbd23ad..cb6aed95 100644 --- a/src/core/game3/evolution.lua +++ b/src/core/game3/evolution.lua @@ -2,6 +2,7 @@ -- MVP: ROM EVO_LEVEL (method 4) only. Stones/trade/friendship later. local Pokemon = require("src.core.game3.pokemon") +local ModRuntime = require("src.mods.Runtime") local Evolution = {} @@ -31,31 +32,66 @@ local function is_national_unlocked(session) return PokedexData.isNationalUnlocked(session) end +local function level_row(mon, evo, session) + local level = tonumber(mon.level) or 1 + local method = tonumber(evo.method or evo[1]) or 0 + local param = tonumber(evo.param or evo[2]) or 0 + local target = tonumber(evo.target or evo[3]) or 0 + if method == Evolution.EVO_LEVEL and target > 0 and level >= param then + -- National Dex gating: prevent evolving into non-Kanto species (target > 151) if locked + if target > 151 and not is_national_unlocked(session) then + return "stop" + end + return "match", target, param + elseif (method == Evolution.EVO_FRIENDSHIP or method == Evolution.EVO_FRIENDSHIP_DAY or method == Evolution.EVO_FRIENDSHIP_NIGHT) and target > 0 then + local friendship = tonumber(mon.friendship) or 220 + if friendship >= 220 then + if target > 151 and not is_national_unlocked(session) then + return "stop" + end + return "match", target, 0 + end + end + return nil +end + +local function evo_view(evo) + local G3 = require("src.mods.Gen3Compat") + local okS, Schemas = pcall(require, "src.mods.Schemas") + local methods = okS and Schemas.gen3View and Schemas.gen3View.EVOLUTIONS or {} + local method = tonumber(evo.method or evo[1]) or 0 + local param = tonumber(evo.param or evo[2]) or 0 + local target = tonumber(evo.target or evo[3]) or 0 + return { + method = methods[method] or method, methodId = method, param = param, + level = param, species = G3.speciesName(target), speciesId = target, + } +end + --- Target species for level-up evolution, or nil. +-- pokefirered/src/pokemon.c:5025 function Evolution.levelTarget(mon, session) if not mon then return nil end if held_is_everstone(mon) then return nil end local species = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId) - local level = tonumber(mon.level) or 1 if not species then return nil end + local hooked = ModRuntime.wantsHook("evolution.check") + local R = hooked and package.loaded["src.core.game3.runtime"] or nil for _, evo in ipairs(Pokemon.evolutions(species)) do - local method = tonumber(evo.method or evo[1]) or 0 - local param = tonumber(evo.param or evo[2]) or 0 - local target = tonumber(evo.target or evo[3]) or 0 - if method == Evolution.EVO_LEVEL and target > 0 and level >= param then - -- National Dex gating: prevent evolving into non-Kanto species (target > 151) if locked - if target > 151 and not is_national_unlocked(session) then - return nil - end - return target, param - elseif (method == Evolution.EVO_FRIENDSHIP or method == Evolution.EVO_FRIENDSHIP_DAY or method == Evolution.EVO_FRIENDSHIP_NIGHT) and target > 0 then - local friendship = tonumber(mon.friendship) or 220 - if friendship >= 220 then - if target > 151 and not is_national_unlocked(session) then - return nil - end - return target, 0 + local kind, target, param = level_row(mon, evo, session) + if hooked then + local view = evo_view(evo) + local ok = ModRuntime.call("evolution.check", function() + return kind == "match" + end, R and R._game or nil, mon, view, { kind = "levelup", session = session }) + if ok then + if kind == "match" then return target, param end + if view.speciesId > 0 then return view.speciesId, view.param end end + if kind == "stop" then return nil end + else + if kind == "stop" then return nil end + if kind == "match" then return target, param end end end return nil @@ -103,7 +139,7 @@ function Evolution.renameMon(mon, preSpecies, postSpecies) end --- Apply species change + stats. Point of no return. -function Evolution.apply(mon, newSpecies, session, bag) +function Evolution.apply(mon, newSpecies, session, bag, via) newSpecies = tonumber(newSpecies) if not mon or not newSpecies then return false end local preSpecies = Pokemon.speciesOf(mon) or tonumber(mon.species or mon.speciesId) or 1 @@ -185,6 +221,17 @@ function Evolution.apply(mon, newSpecies, session, bag) end end + if ModRuntime.wants("pokemon.evolved") then + ModRuntime.emit("pokemon.evolved", { + mon = mon, + fromSpecies = Pokemon.keyName(preSpecies) or preSpecies, + toSpecies = Pokemon.keyName(newSpecies) or newSpecies, + fromSpeciesId = preSpecies, + toSpeciesId = newSpecies, + via = via or "level", + }) + end + return true end diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index aa7f0021..724db1c8 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -1,6 +1,7 @@ -- Game3 field loop coordinator (scripts, player input, heal/respawn). local Player = require("src.core.game3.player") +local ModRuntime = require("src.mods.Runtime") local Field = {} @@ -55,6 +56,9 @@ function Field.update(_dt) if not Field.running then return end local game = Field._game + local Compat = package.loaded["src.mods.Gen3Compat"] + if Compat and Compat.worldTick then Compat.worldTick(_dt) end + local Space = package.loaded["src.core.game3.scripting.space"] if Space and Space.vm then local ad = Space.vm.adapters @@ -233,9 +237,30 @@ end --- A-button field interact: NPC talk → bgEvent → metatile interaction → Surf. -- Returns true if a script (or handled action) started. +local function interacted(fx, fy, kind, target) + if not ModRuntime.wants("world.interacted") then return end + local Map = package.loaded["src.core.game3.map"] + local session = Field._session + ModRuntime.emit("world.interacted", { + mapId = (session and session.map) or (Map and Map.current), + x = fx, y = fy, kind = kind, target = target, + }) +end + +local inInteract = false + function Field.interact(game) game = game or Field._game if not Field.running then return false end + local Compat = package.loaded["src.mods.Gen3Compat"] + local replaced = not inInteract and Compat and Compat.interactWrapper and Compat.interactWrapper() + if replaced then + inInteract = true + local ok, res = pcall(replaced, Compat.resolve("src.world.OverworldController")) + inInteract = false + if not ok then error(res, 0) end + return res + end local Runtime = package.loaded["src.core.game3.runtime"] if Runtime and Runtime.uiBusy and Runtime.uiBusy() then return false end @@ -320,9 +345,22 @@ function Field.interact(game) end elseif eo.def.scriptKey then local lid = eo.localId or eo.def.localId or eo.def.index or 0 - Objects.freeze(lid) - Objects.facePlayer(lid, game) - Space.startScript(eo.def.scriptKey, lid, facingDir) + local talkTo = Compat and Compat.talkToWrapper and Compat.talkToWrapper() + if talkTo and talkTo(Compat.resolve("src.world.OverworldController"), eo) then + interacted(ox, oy, "npc", eo) + return true + end + local function talk() + Objects.freeze(lid) + Objects.facePlayer(lid, game) + Space.startScript(eo.def.scriptKey, lid, facingDir) + end + if ModRuntime.wantsHook("world.talk") then + ModRuntime.call("world.talk", talk, game, eo) + else + talk() + end + interacted(ox, oy, "npc", eo) return true end end @@ -333,14 +371,20 @@ function Field.interact(game) if elevation==0 then elevation=P.elevation or 0 end local sign = bg_event_at(game, fx, fy, elevation, facingDir) if sign and sign.scriptKey then - if Space.startScript(sign.scriptKey, nil, facingDir) then return true end + if Space.startScript(sign.scriptKey, nil, facingDir) then + interacted(fx, fy, "sign", sign) + return true + end end -- 3) Original metatile interactions follow objects and map-specific scripts. local behavior=Collision.behavior(fx,fy) local key=require("src.core.game3.scripting.interaction_scripts").scriptFor(behavior,P.facing) if behavior==nil then key=CollisionStd.scriptFor(Collision.cell(fx,fy)) end - if key and Space.startScript(key,nil,facingDir) then return true end + if key and Space.startScript(key,nil,facingDir) then + interacted(fx, fy, "script", key) + return true + end -- 4) Water / Surf interact on facing water tile if not P.surfing and Collision.isWater and Collision.isWater(fx, fy) then @@ -489,7 +533,7 @@ function Field.executeFieldMove(payload) if payload.se then Audio.playSe(payload.se) end FieldEffects.startWarpSpin(act, function() Field.locked = false - Field.respawnAtHeal() + Field.respawnAtHeal({ fieldMove = true }) end) if payload.text then Message.show(payload.text, function() Message.close() end) @@ -512,11 +556,17 @@ function Field.executeFieldMove(payload) end --- White-out / heal respawn via game3 map loader (H7). -function Field.respawnAtHeal() +function Field.respawnAtHeal(opts) local session = Field._session if not session then return end local HealLocations = require("src.core.game3.heal_locations") HealLocations.normalizeSession(session) + if not (opts and opts.fieldMove) and ModRuntime.wants("world.blacked_out") then + ModRuntime.emit("world.blacked_out", { + save = session, + healTarget = { map = session.healMap, x = session.healX, y = session.healY }, + }) + end local Party = require("src.core.game3.party") Party.healAll(session.party) local Map = require("src.core.game3.map") @@ -563,6 +613,9 @@ function Field.setMetatile(x, y, metatile, isImpassable) } local session = Field._session local mapId = session and session.map + if ModRuntime.wants("world.block_replaced") then + ModRuntime.emit("world.block_replaced", { mapId = mapId, bx = x, by = y, block = metatile }) + end local game = Field._game local data = game and game.data and game.data.maps local mapDef = mapId and data and data[mapId] diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index 0e157a00..1a64b5fc 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -3,6 +3,7 @@ local ItemsData = require("src.core.game3.items_data") local Bag = require("src.core.game3.bag") local Pokemon = require("src.core.game3.pokemon") +local ModRuntime = require("src.mods.Runtime") local ItemUse = {} @@ -343,6 +344,21 @@ function ItemUse.needsPartyTarget(id) return false end +-- pokefirered/src/party_menu.c:5018 +function ItemUse.levelUpEvent(mon, level) + if not ModRuntime.wants("pokemon.level_up") then return end + local G3 = require("src.mods.Gen3Compat") + local learnable, learnableIds = {}, {} + for _, mv in ipairs(Pokemon.movesLearnedAt(tonumber(mon.species or mon.speciesId), level)) do + learnable[#learnable + 1] = G3.moveName(mv) + learnableIds[#learnableIds + 1] = mv + end + ModRuntime.emit("pokemon.level_up", { + mon = mon, level = level, prevLevel = level - 1, + learnable = learnable, learnableIds = learnableIds, via = "item", + }) +end + function ItemUse.useRareCandy(session, mon) if not mon then return false, "none", "There's no POKéMON!" end local lvl = tonumber(mon.level) or 1 @@ -356,6 +372,7 @@ function ItemUse.useRareCandy(session, mon) Pokemon.applyStats(mon) local newMax = tonumber(mon.maxHp) or tonumber(mon.maxhp) or oldMax mon.hp = math.min(newMax, oldHp + math.max(0, newMax - oldMax)) + ItemUse.levelUpEvent(mon, mon.level) local t = string.format("%s grew to\nLv. %d!", Pokemon.displayMonName(mon), mon.level) return true, "level", t end @@ -377,10 +394,11 @@ function ItemUse.useEvolutionStone(session, mon, itemId, bag) session = session, bag = bag, savedSong = Audio._mapSong, + via = "item", }) return true, "evo", "Evolving..." else - Evolution.apply(mon, target, session, bag) + Evolution.apply(mon, target, session, bag, "item") local t = string.format("%s evolved into\n%s!", oldName, newName) return true, "evo", t end @@ -522,7 +540,15 @@ local function useField(session, bag, id, partySlot) end function ItemUse.useField(session,bag,id,partySlot) - local ok,kind,text=useField(session,bag,id,partySlot) + local ok,kind,text + if ModRuntime.wantsHook("item.use") then + local Runtime=package.loaded["src.core.game3.runtime"] + ok,kind,text=ModRuntime.call("item.use",function(_,_,hid,hslot) + return useField(session,bag,hid,hslot) + end,Runtime and Runtime._game,nil,id,partySlot,bag) + else + ok,kind,text=useField(session,bag,id,partySlot) + end if ok and kind~="tm" and kind~="tm_case" and kind~="berry_pouch" then local Items=require("src.core.game3.items") local Pokemon=require("src.core.game3.pokemon") diff --git a/src/core/game3/items_data.lua b/src/core/game3/items_data.lua index 6414a1c5..9845be42 100644 --- a/src/core/game3/items_data.lua +++ b/src/core/game3/items_data.lua @@ -238,6 +238,10 @@ local function load_pack() return ItemsData._byId end +function ItemsData.ensureLoaded() + return load_pack() +end + function ItemsData.install(_cache) ItemsData._pack = nil ItemsData._byId = nil diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 21703331..c1452070 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -3,9 +3,11 @@ -- MAPSETUP.WARP races ON_FRAME and wipes applymovement tracks (Bill intro). local MapIds = require("src.core.game3.map_ids") +local ModRuntime = require("src.mods.Runtime") local Map = {} Map.current = nil +Map._announced = nil Map.neighbors = {} Map._loadedLayouts = {} Map._def = nil @@ -263,9 +265,14 @@ function Map.load(mod, game, mapId, opts) return nil, "not a game3 map" end local Ghosts = require("src.core.game3.ghosts") + local fromMapId = Map._announced if Map.current and Map.current ~= mapId then Ghosts.capture(Map.current) end + if fromMapId and fromMapId ~= mapId and ModRuntime.wants("map.exited") then + ModRuntime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) + end + Map._announced = mapId Map.current = mapId Map._loadedLayouts = { [mapId] = true } @@ -399,6 +406,17 @@ function Map.load(mod, game, mapId, opts) if not opts.seamless then require("src.core.game3.audio").setSavedSong(nil) end + if fromMapId == mapId then + if opts.reason and ModRuntime.wants("map.reloaded") then + ModRuntime.emit("map.reloaded", { mapId = mapId, reason = opts.reason or "reload" }) + end + elseif ModRuntime.wants("map.entered") then + ModRuntime.emit("map.entered", { + mapId = mapId, map = def, fromMapId = fromMapId, + via = opts.via or (opts.seamless and "connection") + or (opts.heal and "respawn") or (fromMapId and "warp" or "boot"), + }) + end if Space and Space.runEnterScripts then Space.runEnterScripts(mod or Runtime._mod, mapId, game, world) elseif Space and Space.onMapEnter then diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index bebc4469..cea157eb 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -5,6 +5,7 @@ local Movement = require("src.core.game3.scripting.movement") local Opcodes = require("src.core.game3.scripting.opcodes") local GfxIds = require("src.core.game3.scripting.gfx_ids") +local ModRuntime = require("src.mods.Runtime") local Objects = {} @@ -302,12 +303,16 @@ function Objects.loadMap(game, mapId, mapDef) end end end + local announce = ModRuntime.wants("world.npc_spawned") for _, def in ipairs(Objects._defs) do local eo = newEventObject(def) if eo.localId > 0 then applyPerm(eo, mapId) Objects._byId[eo.localId] = eo Objects._order[#Objects._order + 1] = eo.localId + if announce then + ModRuntime.emit("world.npc_spawned", { mapId = mapId, npcId = eo.localId, runtime = eo }) + end end end if not Objects._logged then diff --git a/src/core/game3/party.lua b/src/core/game3/party.lua index 65871bf2..982e47b7 100644 --- a/src/core/game3/party.lua +++ b/src/core/game3/party.lua @@ -127,9 +127,31 @@ function Party.size(sessionParty) return #sessionParty end +Party.PLAYER_HAS_TWO_USABLE_MONS = 0 +Party.PLAYER_HAS_ONE_MON = 1 +Party.PLAYER_HAS_ONE_USABLE_MON = 2 + +-- pokefirered/src/pokemon.c:3769 +function Party.monsStateToDoubles(sessionParty) + if type(sessionParty) ~= "table" then return Party.PLAYER_HAS_ONE_MON end + local count = 0 + for _, mon in ipairs(sessionParty) do + if (tonumber(mon.species or mon.speciesId) or 0) ~= 0 then count = count + 1 end + end + if count <= 1 then return Party.PLAYER_HAS_ONE_MON end + local usable = 0 + for _, mon in ipairs(sessionParty) do + local sp = tonumber(mon.species or mon.speciesId) or 0 + if (tonumber(mon.hp) or 0) ~= 0 and sp ~= 0 and sp ~= 412 and not mon.isEgg and not mon.egg then + usable = usable + 1 + end + end + return (usable > 1) and Party.PLAYER_HAS_TWO_USABLE_MONS or Party.PLAYER_HAS_ONE_USABLE_MON +end + --- Append a Gen3-shaped opaque mon for script givemon (starter / gifts). -- Returns true if added to party (slot available). -function Party.giveMon(session, species, level) +function Party.giveMon(session, species, level, nickname) if type(session) ~= "table" then return false end session.party = session.party or {} if #session.party >= 6 then return false end @@ -170,8 +192,9 @@ function Party.giveMon(session, species, level) species = species, speciesId = species, name = name, - nickname = "", + nickname = type(nickname) == "string" and nickname or "", level = level, + metLevel = level, growthRate = growthRate, exp = SummaryData.expForLevel(growthRate, level), status = nil, diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index dc50cf82..46b6639f 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -3,6 +3,7 @@ -- Collision via game3.collision (owned COLL_* grid from extract bake). local Collision = require("src.core.game3.collision") +local ModRuntime = require("src.mods.Runtime") local Player = {} @@ -361,6 +362,12 @@ function Player.tryMove(dir, game, run) obj.x = destBx obj.y = destBy if obj.def then obj.def.x = destBx; obj.def.y = destBy end + if ModRuntime.wants("world.boulder_moved") then + local Map = package.loaded["src.core.game3.map"] + ModRuntime.emit("world.boulder_moved", { + mapId = Map and Map.current, npcId = obj.localId, x = destBx, y = destBy, + }) + end beginStep(tx, ty, false, false) return "step" end @@ -466,6 +473,16 @@ local function finishStep(game) session.x, session.y, session.facing = Player.cellX, Player.cellY, Player.facing end + if ModRuntime.wants("world.stepped") then + local Map = package.loaded["src.core.game3.map"] + ModRuntime.emit("world.stepped", { + mapId = (session and session.map) or (Map and Map.current), + x = Player.cellX, y = Player.cellY, + tile = Collision.behavior and Collision.behavior(Player.cellX, Player.cellY), + facing = Player.facing, + }) + end + local onDoneCb = Player._onStepDone Player._onStepDone = nil if onDoneCb then diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index 0d856e12..26efeb40 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -3,6 +3,7 @@ local Extract = require("src.import.gba.extract_island1") local PokemonExtract = require("src.import.gba.pokemon_extract") local Versions = require("src.import.gba.versions") +local ModRuntime = require("src.mods.Runtime") local Pokemon = {} @@ -154,6 +155,36 @@ function Pokemon.install(cache) else log("species pack missing — re-import FireRed ROM") end + Pokemon._runReloadHooks() +end + +Pokemon._reloadHooks = {} + +function Pokemon.onReload(fn, key) + if type(fn) ~= "function" then return function() end end + local hooks = Pokemon._reloadHooks + for i = #hooks, 1, -1 do + local h = hooks[i] + if h.fn == fn or (key ~= nil and h.key == key) then + table.remove(hooks, i) + end + end + local entry = { fn = fn, key = key } + hooks[#hooks + 1] = entry + return function() + for i = #hooks, 1, -1 do + if hooks[i] == entry then table.remove(hooks, i) end + end + end +end + +function Pokemon._runReloadHooks() + local snapshot = {} + for i, h in ipairs(Pokemon._reloadHooks) do snapshot[i] = h end + for _, h in ipairs(snapshot) do + local ok, err = pcall(h.fn, Pokemon) + if not ok then log("onReload callback failed: " .. tostring(err)) end + end end function Pokemon.invalidate() @@ -196,6 +227,19 @@ function Pokemon.name(species) return string.format("POKéMON %03d", species) end +function Pokemon.keyName(species) + species = tonumber(species) + if not species or species < 1 then return nil end + if not Pokemon._names then Pokemon.install(Pokemon._cache) end + local n = Pokemon._names and Pokemon._names[species] + if type(n) ~= "string" or n == "" or n == "??????????" then return nil end + n = n:upper() + n = n:gsub("♀", "_F"):gsub("♂", "_M") + n = n:gsub("[%.']", "") + n = n:gsub("[%s%-]+", "_") + return n +end + function Pokemon.speciesFromName(name) if name == nil then return nil end if not Pokemon._byName then Pokemon.install(Pokemon._cache) end @@ -649,6 +693,13 @@ function Pokemon.teachMove(mon, moveId) local max = move_max_pp(moveId) mon.pp[i] = max mon.maxPp[i] = max + -- pokefirered/src/pokemon.c:2208 + if ModRuntime.wants("pokemon.move_learned") then + ModRuntime.emit("pokemon.move_learned", { + mon = mon, moveId = require("src.mods.Gen3Compat").moveName(moveId), + moveNum = moveId, slot = i, + }) + end return true, i end end @@ -669,6 +720,14 @@ function Pokemon.replaceMove(mon, slot, newMoveId) local max = move_max_pp(newMoveId) mon.pp[slot] = max mon.maxPp[slot] = max + -- pokefirered/src/pokemon.c:2248 + if ModRuntime.wants("pokemon.move_learned") then + local G3 = require("src.mods.Gen3Compat") + ModRuntime.emit("pokemon.move_learned", { + mon = mon, moveId = G3.moveName(newMoveId), moveNum = newMoveId, slot = slot, + forgotten = G3.moveName(old), forgottenNum = tonumber(old), + }) + end return old end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index ea0da475..3b5adae0 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -2,6 +2,7 @@ local MapIds = require("src.core.game3.map_ids") local Options = require("src.core.game3.options") +local ModRuntime = require("src.mods.Runtime") local Schema = {} @@ -74,6 +75,11 @@ function Schema.newGame(opts) if type(opts.engineOptions) == "table" then Options.bind(session, opts.engineOptions) end + session.modData = {} + if ModRuntime.wantsHook("save.new_game") then + local hooked = ModRuntime.call("save.new_game", function(s) return s end, session) + if type(hooked) == "table" then session = hooked end + end return session end @@ -114,6 +120,8 @@ function Schema.toSaveTable(session) trainerId = session.trainerId, rng = session.rng, questLog = require("src.core.game3.quest_log").export(session), + modData = session.modData, + meta = session.meta, } end @@ -155,6 +163,8 @@ function Schema.fromSaveTable(save) trainerId = save.trainerId, rng = save.rng, questLog = require("src.core.game3.quest_log").restore(save.questLog), + modData = type(save.modData) == "table" and save.modData or {}, + meta = save.meta, } Schema.ensureMonBalls(session) if type(save.options) == "table" then diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index 23adbc40..aff5606d 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -469,12 +469,12 @@ function Adapters.host(mod, game, world) local MonPic = require("src.ui.game3.mon_pic") MonPic.hide() end, - giveMon = function(species, level) + giveMon = function(species, level, _, _, _, nickname) local Runtime = package.loaded["src.core.game3.runtime"] local session = Runtime and Runtime.getSession and Runtime.getSession() if not session then return false end local Party = require("src.core.game3.party") - return Party.giveMon(session, species, level) + return Party.giveMon(session, species, level, nickname) end, freezeLocal = function(localId, snap) local G3 = useGame3Objects() @@ -1214,6 +1214,7 @@ function Adapters.host(mod, game, world) earlyRival = battleOpts.earlyRival, rivalFlags = battleOpts.rivalFlags, noWhiteout = battleOpts.noWhiteout, + double = battleOpts.double, done = function(result) if done then done(result or "win") end tick_vm() diff --git a/src/core/game3/scripting/ctx.lua b/src/core/game3/scripting/ctx.lua index 416afc9a..b1fd13bf 100644 --- a/src/core/game3/scripting/ctx.lua +++ b/src/core/game3/scripting/ctx.lua @@ -86,4 +86,22 @@ function Ctx.clearTemps(store) end end +function Ctx.modCtx(vm) + local ok, Gen3Compat = pcall(require, "src.mods.Gen3Compat") + if ok and type(Gen3Compat) == "table" and type(Gen3Compat.scriptCtx) == "function" then + local okC, c = pcall(Gen3Compat.scriptCtx, vm) + if okC and type(c) == "table" then return c end + end + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + local Map = package.loaded["src.core.game3.map"] + return { + game = Runtime and Runtime._game, + save = session, + session = session, + overworld = { map = { id = Map and Map.current } }, + runner = vm, + } +end + return Ctx diff --git a/src/core/game3/scripting/flags.lua b/src/core/game3/scripting/flags.lua index 148116fe..83f2bdd2 100644 --- a/src/core/game3/scripting/flags.lua +++ b/src/core/game3/scripting/flags.lua @@ -3,6 +3,7 @@ local Ctx = require("src.core.game3.scripting.ctx") local FlagsTable = require("src.core.game3.scripting.flags_table") +local ModRuntime = require("src.mods.Runtime") local Flags = {} @@ -247,6 +248,8 @@ end function Flags.setFlag(store, ctx, id, on) id = tonumber(id) or (type(id) == "string" and Flags.IDS[id]) or 0 if not store or not store.flags then return end + local announce = ModRuntime.wants("flag.changed") + and Flags.getFlag(store, ctx, id) ~= (on and true or false) local strId = tostring(id) if on then store.flags[id] = true @@ -261,6 +264,9 @@ function Flags.setFlag(store, ctx, id, on) if name then store.flags[name] = nil end end end + if announce then + ModRuntime.emit("flag.changed", { name = Flags.NAMES[id] or id, id = id, value = on and true or false }) + end end function Flags.getVar(store, ctx, id) diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index 66ab3799..8f685c4d 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -6,6 +6,7 @@ local Flags = require("src.core.game3.scripting.flags") local TextIR = require("src.core.game3.scripting.text_ir") local Natives = require("src.core.game3.scripting.natives") local Movement = require("src.core.game3.scripting.movement") +local ModRuntime = require("src.mods.Runtime") local Ops = {} @@ -101,7 +102,7 @@ local function show_message(vm, ptr, stay) return false end -function Ops.dispatch(vm, row) +local function dispatch(vm, row) local op = row.op local ctx = vm.ctx local store = vm.store @@ -394,15 +395,39 @@ function Ops.dispatch(vm, row) local species = var_get(store, ctx, row[1] or row.species) local level = var_get(store, ctx, row[2] or row.level) if level < 1 then level = 5 end + local nickname + if ModRuntime.wants("pokemon.before_give") then + local Pokemon = require("src.core.game3.pokemon") + local gift = { + ctx = Ctx.modCtx(vm), + species = Pokemon.keyName(species) or species, + speciesId = species, + level = level, + } + ModRuntime.emit("pokemon.before_give", gift) + local id = type(gift.species) == "number" and gift.species + or Pokemon.speciesFromName(gift.species) + if tonumber(id) and tonumber(id) >= 1 and tonumber(id) ~= species then + species = tonumber(id) + local src = tonumber(row[1] or row.species) or 0 + if src >= 0x4000 then Flags.setVar(store, ctx, src, species) end + end + if tonumber(gift.level) then + level = math.max(1, math.min(100, math.floor(tonumber(gift.level)))) + end + if type(gift.nickname) == "string" and gift.nickname ~= "" then + nickname = gift.nickname + end + end local ok = false if a.giveMon then - ok = a.giveMon(species, level, row[3], row[4], row[5]) + ok = a.giveMon(species, level, row[3], row[4], row[5], nickname) else local Party = require("src.core.game3.party") local Runtime = package.loaded["src.core.game3.runtime"] local session = Runtime and Runtime.getSession and Runtime.getSession() if session then - ok = Party.giveMon(session, species, level) + ok = Party.giveMon(session, species, level, nickname) end end Flags.setVar(store, ctx, Ctx.VAR_RESULT, ok and 0 or 2) -- 0=party, 2=fail @@ -812,6 +837,37 @@ function Ops.dispatch(vm, row) return false end + local isDouble = battleType == 4 or battleType == 6 or battleType == 7 or battleType == 8 + if op == "trainerbattle" and isDouble and a.startTrainerBattle then + local Party = require("src.core.game3.party") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + -- pokefirered/data/scripts/trainer_battle.inc:30 + if Party.monsStateToDoubles(session and session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS then + local dialogs = Trainers.dialogs(trainerId) or {} + local cantText = (row.notEnoughText and resolve_text(vm, row.notEnoughText)) or dialogs.notEnough + if cantText and cantText ~= "" and a.openMessageAsync then + ctx.mode = "native" + ctx.status = "waiting" + local shown = false + ctx.nativePoll = function() + if not shown then return false end + ctx.status = "halted" + return false + end + a.openMessageAsync(cantText, function() shown = true end) + if shown then + ctx.mode = "bytecode" + ctx.status = "halted" + ctx.nativePoll = nil + end + return true + end + ctx.status = "halted" + return true + end + end + if a.startTrainerBattle then local foe = Trainers.foeFromId(trainerId) if not foe then @@ -866,7 +922,7 @@ function Ops.dispatch(vm, row) if eventScript and (battleType == 1 or battleType == 2 or battleType == 6 or battleType == 8) then pendingGoto = eventScript - elseif battleType == 0 then + elseif battleType == 0 or battleType == 4 then -- Single standard trainer: script ends after encounter shouldHalt = true end @@ -879,6 +935,7 @@ function Ops.dispatch(vm, row) noWhiteout = earlyRival and (rivalFlags % 2 == 1), defeatText = defeatText, victoryText = victoryText, + double = (foe.doubleBattle == true) or nil, }) end @@ -1096,10 +1153,49 @@ function Ops.dispatch(vm, row) or op == "trywondercardscript" or op == "erasebox" then return false else + local Runtime = package.loaded["src.core.game3.runtime"] + local game = Runtime and Runtime._game + local commands = game and game.data and game.data.commands + local record = type(commands) == "table" and commands[op] + local fn = type(record) == "table" and record.fn or record + if type(fn) == "function" then + local okCall, res = pcall(fn, Ctx.modCtx(vm), unpack(row)) + if not okCall then + if a.log then a.log("[game3] command " .. tostring(op) .. " failed: " .. tostring(res)) end + return false + end + if type(res) == "string" and vm.scripts and vm.scripts[res] then + jump(vm, res) + elseif res == "end" then + vm:halt() + return true + end + return false + end -- Unknown / Tier C: skip if a.log then a.log("[game3] skip op " .. tostring(op)) end return false end end +local function commandVanilla(vm) + return function(_, name, hrow) + if type(hrow) ~= "table" then hrow = {} end + if name ~= nil and name ~= hrow.op then + local copy = {} + for k, v in pairs(hrow) do copy[k] = v end + copy.op = name + hrow = copy + end + return dispatch(vm, hrow) + end +end + +function Ops.dispatch(vm, row) + if not ModRuntime.wantsHook("script.command") then + return dispatch(vm, row) + end + return ModRuntime.call("script.command", commandVanilla(vm), Ctx.modCtx(vm), row.op, row) +end + return Ops diff --git a/src/core/game3/scripting/space.lua b/src/core/game3/scripting/space.lua index 01f2a69c..9aaa33fc 100644 --- a/src/core/game3/scripting/space.lua +++ b/src/core/game3/scripting/space.lua @@ -133,6 +133,7 @@ local function persist_sidecar(mod, game) if session then session.flags = snap.flags session.vars = snap.vars + return end game = resolve_game(mod, game) if not (game and game.save) then return end @@ -237,7 +238,7 @@ end function Space.deactivate(mod) if Space.vm then - Space.vm:halt() + Space.vm:halt(Space.vm:isRunning()) end persist_sidecar(mod or Space._mod) if Space.store then diff --git a/src/core/game3/scripting/trainers.lua b/src/core/game3/scripting/trainers.lua index e56d7850..30b203a1 100644 --- a/src/core/game3/scripting/trainers.lua +++ b/src/core/game3/scripting/trainers.lua @@ -80,6 +80,10 @@ local function decompose_ai_flags(flags) } end +function Trainers.pack() + return load_pack() or nil +end + --- Get full trainer definition record by trainerId. function Trainers.get(trainerId) trainerId = tonumber(trainerId) @@ -146,6 +150,42 @@ function Trainers.get(trainerId) return nil end +local GBA_CHAR = { + [" "] = 0x00, ["é"] = 0x1B, ["&"] = 0x2D, ["+"] = 0x2E, ["!"] = 0xAB, ["?"] = 0xAC, + ["."] = 0xAD, ["-"] = 0xAE, ["…"] = 0xB0, ["“"] = 0xB1, ["”"] = 0xB2, ["‘"] = 0xB3, + ["’"] = 0xB4, ["'"] = 0xB4, ["♂"] = 0xB5, ["♀"] = 0xB6, [","] = 0xB8, ["/"] = 0xBA, +} + +local function gba_char_sum(text) + local sum = 0 + for ch in tostring(text or ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + local b = ch:byte() + local v = GBA_CHAR[ch] + if not v then + if #ch == 1 and b >= 48 and b <= 57 then v = 0xA1 + (b - 48) + elseif #ch == 1 and b >= 65 and b <= 90 then v = 0xBB + (b - 65) + elseif #ch == 1 and b >= 97 and b <= 122 then v = 0xD5 + (b - 97) + else v = 0 end + end + sum = sum + v + end + return sum +end + +-- pokefirered/src/battle_main.c:1555 +local function double_personalities(t) + local Pokemon = require("src.core.game3.pokemon") + local nameHash = 0 + local out = {} + for i, m in ipairs(t.party or {}) do + nameHash = (nameHash + gba_char_sum(t.name)) % 0x100000000 + nameHash = (nameHash + gba_char_sum(Pokemon.name(tonumber(m.species) or 0))) % 0x100000000 + out[i] = (0x80 + (nameHash * 256) % 0x100000000) % 0x100000000 + end + return out +end +Trainers._doublePersonalities = double_personalities + --- Resolve a foe battler struct + full party for battle runtime. -- Guarantees: -- 1. Uniform Flat IV scaling: actualIv = (rawIv * 31) / 255 across all 6 stats @@ -161,7 +201,8 @@ function Trainers.foeFromId(trainerId) end local foeParty = {} - for _, m in ipairs(t.party) do + local pers = t.doubleBattle and double_personalities(t) or {} + for pi, m in ipairs(t.party) do local rawIv = tonumber(m.rawIv) or tonumber(m.iv) or 0 local iv = tonumber(m.iv) or math.floor((rawIv * 31) / 255) local mon = { @@ -175,6 +216,7 @@ function Trainers.foeFromId(trainerId) heldItem = tonumber(m.heldItem) or nil, moves = m.moves, trainerId = trainerId, + personality = pers[pi], } foeParty[#foeParty + 1] = mon end @@ -189,6 +231,7 @@ function Trainers.foeFromId(trainerId) evs = lead.evs, heldItem = lead.heldItem, moves = lead.moves, + personality = lead.personality, trainerId = trainerId, aiFlags = t.aiFlags, ai = t.ai, diff --git a/src/core/game3/scripting/vm.lua b/src/core/game3/scripting/vm.lua index eb3fadcf..cf72eb25 100644 --- a/src/core/game3/scripting/vm.lua +++ b/src/core/game3/scripting/vm.lua @@ -4,6 +4,7 @@ local Ctx = require("src.core.game3.scripting.ctx") local Flags = require("src.core.game3.scripting.flags") local Ops = require("src.core.game3.scripting.ops_a") local Adapters = require("src.core.game3.scripting.adapters") +local ModRuntime = require("src.mods.Runtime") local Vm = {} Vm.__index = Vm @@ -49,7 +50,16 @@ function Vm:setPc(listKey, index) self.ctx.pc = { listKey = listKey, index = index or 1 } end -function Vm:halt() +function Vm:_scriptEnded(completed) + if not self._scriptKey then return end + local key = self._scriptKey + self._scriptKey = nil + if ModRuntime.wants("script.ended") then + ModRuntime.emit("script.ended", { ctx = Ctx.modCtx(self), completed = completed and true or false, key = key }) + end +end + +function Vm:halt(aborted) local a = self.adapters local ctx = self.ctx if a and a.unfreezeLocal then @@ -58,6 +68,7 @@ function Vm:halt() end end Ctx.haltCleanup(self.ctx) + self:_scriptEnded(not aborted) end function Vm:isRunning() @@ -80,6 +91,7 @@ function Vm:start(scriptKey, facing) end return false end + if self._scriptKey then self:_scriptEnded(false) end self.ctx.mode = "bytecode" self.ctx.status = "running" self.ctx.stack = {} @@ -103,6 +115,10 @@ function Vm:start(scriptKey, facing) self.ctx.specialVars[Ctx.VAR_FACING] = keptFacing end self:setPc(scriptKey, 1) + self._scriptKey = scriptKey + if ModRuntime.wants("script.started") then + ModRuntime.emit("script.started", { ctx = Ctx.modCtx(self), key = scriptKey }) + end self:resume() return true end @@ -114,7 +130,7 @@ function Vm:resume() guard = guard + 1 if guard > 10000 then self.adapters.log("[game3] runaway script") - self:halt() + self:halt(true) return end if ctx.mode == "native" then @@ -134,7 +150,7 @@ function Vm:resume() local list = self.scripts[pc.listKey] if not list then self.adapters.log("[game3] bad list " .. tostring(pc.listKey)) - self:halt() + self:halt(true) return end local row = list[pc.index] diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index bc0f2494..11447e71 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -8,6 +8,7 @@ -- 6. Repel Counter: Decrements steps -> "Repel's effect wore off..." on expiration. local Pokemon = require("src.core.game3.pokemon") +local ModRuntime = require("src.mods.Runtime") local StepEvents = {} @@ -80,6 +81,12 @@ local function trigger_white_out(session, game) local healX = (session and session.healX) or 6 local healY = (session and session.healY) or 6 local healFacing = (session and session.healFacing) or "down" + if ModRuntime.wants("world.blacked_out") then + ModRuntime.emit("world.blacked_out", { + save = session, + healTarget = { map = healMap, x = healX, y = healY }, + }) + end -- Heal all party Pokémon if session and session.party then diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index bbd6529f..a1bac7cd 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -4,6 +4,7 @@ local Flags = require("src.core.game3.scripting.flags") local Ctx = require("src.core.game3.scripting.ctx") +local ModRuntime = require("src.mods.Runtime") local TrainerSight = {} @@ -82,6 +83,7 @@ function TrainerSight.getTrainerId(eo) local tid = tonumber(row.trainer or row[1]) if tid then eo.trainerId = tid + eo.trainerBattleType = tonumber(row.type) or 0 return tid end end @@ -110,6 +112,32 @@ function TrainerSight.isDefeated(eo, store, ctx) return false end +function TrainerSight.battleType(eo) + if not eo then return nil end + if eo.trainerBattleType == nil then + local Sp = Space() + local scriptKey = eo.scriptKey or (eo.def and eo.def.scriptKey) + local list = scriptKey and Sp and Sp.bundle and Sp.bundle.scripts and Sp.bundle.scripts[scriptKey] + eo.trainerBattleType = false + for _, row in ipairs(type(list) == "table" and list or {}) do + if row.op == "trainerbattle" or row.op == "dotrainerbattle" then + eo.trainerBattleType = tonumber(row.type) or 0 + break + end + end + end + return eo.trainerBattleType or nil +end + +-- pokefirered/src/trainer_see.c:114 +function TrainerSight.blockedByDoubles(eo) + if TrainerSight.battleType(eo) ~= 4 then return false end + local Party = require("src.core.game3.party") + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + return Party.monsStateToDoubles(session and session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS +end + --- Test if a metatile behavior byte represents a one-way ledge hop. local function is_ledge_tile(game, cx, cy) local Coll = Collision() @@ -222,6 +250,15 @@ function TrainerSight.engage(game, eo, dist) -- 3. Play encounter music immediately when trainer spots player (pret PlayTrainerEncounterMusic / EventScript_DoTrainerBattleFromApproach) local tid = TrainerSight.getTrainerId(eo) local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers") + -- pokefirered/src/trainer_see.c:105 + if ModRuntime.wants("world.trainer_engaged") then + local info = okT and Trainers and tid and Trainers.info and Trainers.info(tid) or nil + local Map = package.loaded["src.core.game3.map"] + ModRuntime.emit("world.trainer_engaged", { + npc = eo, trainerClass = info and info.class, partyIndex = tid, trainerId = tid, + mapId = Map and Map.current, sight = { distance = dist, facing = eo.facing }, + }) + end local musicId = okT and Trainers and Trainers.getEncounterMusic and Trainers.getEncounterMusic(tid) if not musicId then musicId = 285 -- MUS_ENCOUNTER_BOY fallback @@ -308,7 +345,7 @@ function TrainerSight.check(game, specificTrainer) local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game) - if spotted then + if spotted and not TrainerSight.blockedByDoubles(eo) then TrainerSight.engage(game, eo, dist) return true end @@ -325,7 +362,7 @@ function TrainerSight.check(game, specificTrainer) local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game) - if spotted then + if spotted and not TrainerSight.blockedByDoubles(eo) then -- Immediately engage and break iterator to suppress any other simultaneous spots TrainerSight.engage(game, eo, dist) return true diff --git a/src/core/game3/warp.lua b/src/core/game3/warp.lua index c2d53fb2..26d689cc 100644 --- a/src/core/game3/warp.lua +++ b/src/core/game3/warp.lua @@ -1,5 +1,7 @@ -- Warp / door / fade sequencing helpers for game3 field. +local ModRuntime = require("src.mods.Runtime") + local Warp = {} Warp._pending = nil @@ -9,9 +11,30 @@ function Warp.isBusy() return Warp._busy == true end +local function sameDestination(map, x, y) return map, x, y end + +local function announce(game, destMap, destX, destY, kind) + local Map = package.loaded["src.core.game3.map"] + local fromMap = Map and Map.current + local warp = { kind = kind, map = destMap, x = destX, y = destY } + if ModRuntime.wantsHook("warp.destination") then + local m, nx, ny = ModRuntime.call("warp.destination", sameDestination, destMap, destX, destY, + { warp = warp, lastMap = fromMap, data = game and game.data }) + if m then + destMap, destX, destY = m, tonumber(nx) or destX, tonumber(ny) or destY + end + end + if ModRuntime.wants("player.warped") then + ModRuntime.emit("player.warped", { fromMap = fromMap, toMap = destMap, + x = destX, y = destY, warp = warp }) + end + return destMap, destX, destY +end + --- Complete door entrance sequence (walking UP into a building) function Warp.startDoorEntrance(mod, game, destMap, destX, destY, doorX, doorY) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "door") Warp._busy = true local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") @@ -65,6 +88,7 @@ end --- Complete door exit sequence (walking DOWN off exit mat out to town) function Warp.startDoorExit(mod, game, destMap, destX, destY, exitX, exitY) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "exit_door") Warp._busy = true local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") @@ -123,6 +147,7 @@ end function Warp.startEscalator(mod, game, destMap, destX, destY, dir, approachDir, escX, escY) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "escalator") Warp._busy = true Warp._isEscalatorActive = true @@ -270,6 +295,7 @@ end -- pokefirered/src/field_fadetransition.c:794 function Warp.startStairWarp(mod, game, destMap, destX, destY, behavior) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "stairs") Warp._busy = true local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") @@ -376,6 +402,7 @@ end --- Complete teleport spin sequence (Silph Co, Sabrina's Gym warp pads) function Warp.startTeleport(mod, game, destMap, destX, destY) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "teleport") Warp._busy = true local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") @@ -420,6 +447,7 @@ end --- Complete fall hole sequence (Mt. Moon, Seafoam drop holes) function Warp.startFall(mod, game, destMap, destX, destY) if Warp._busy then return false end + destMap, destX, destY = announce(game, destMap, destX, destY, "fall") Warp._busy = true local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") @@ -480,6 +508,7 @@ function Warp.request(mod, game, mapId, x, y, facing, opts) if opts.fall then return Warp.startFall(mod, game, mapId, x, y) end + mapId, x, y = announce(game, mapId, x, y, "warp") Warp._pending = { mapId = mapId, diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 06700d84..e9ff5b22 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -20,7 +20,7 @@ CacheContract.VERSION_FORMAT = { -- engine/overworld/map_sprites.asm:181 yellow = "rom-cache-v11-yellow1:", -- v8: M4A tracks retain reachable patterns and explicit entry offsets. - firered = "rom-cache-v10-firered:", + firered = "rom-cache-v12-firered:", } CacheContract.MARKER_PATH = "rom-cache.complete" @@ -193,6 +193,9 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/pokemon/battle_moves.lua", "data/generated/gba/pokemon/battle/manifest.lua", "data/generated/gba/pokemon/battle/healthbox_player.rgba", + "data/generated/gba/pokemon/battle/healthbox_doubles_player.rgba", + "data/generated/gba/pokemon/battle/healthbox_doubles_opponent.rgba", + "data/generated/gba/pokemon/battle/hp_bold_digits.rgba", "data/generated/gba/pokemon/battle/terrain_building.rgba", "data/generated/gba/pokemon/battle/ball_open/manifest.lua", "data/generated/gba/pokemon/battle/ball_open/particles.rgba", @@ -208,6 +211,7 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/doors/pallet.rgba", "data/generated/gba/native/manifest.lua", "data/generated/gba/pokemon/summary/manifest.lua", + "data/generated/gba/pokemon/summary/menu_info.rgba", "data/generated/gba/pokemon/storage/manifest.lua", "data/generated/gba/pokedex/manifest.lua", "data/generated/gba/chrome/manifest.lua", diff --git a/src/import/gba/battle_chrome_extract.lua b/src/import/gba/battle_chrome_extract.lua index a6b22f4f..009a896d 100644 --- a/src/import/gba/battle_chrome_extract.lua +++ b/src/import/gba/battle_chrome_extract.lua @@ -7,7 +7,7 @@ local Lz77 = require("src.import.gba.lz77") local BattleChromeExtract = {} -BattleChromeExtract.FORMAT_VERSION = 4 +BattleChromeExtract.FORMAT_VERSION = 5 BattleChromeExtract.CACHE_SUB = "pokemon/battle" local function default_cache_root() @@ -140,6 +140,82 @@ local function bake_enemy_healthbox(gfx, pal) return indices_to_rgba(indices, pal, w, h), w, h end +-- pokefirered/src/battle_interface.c:568 +local function bake_doubles_healthbox(gfx, pal) + return bake_enemy_healthbox(gfx, pal) +end + +BattleChromeExtract.DOUBLES_FILES = { + player = "healthbox_doubles_player.rgba", + opponent = "healthbox_doubles_opponent.rgba", +} + +function BattleChromeExtract.bakeDoubles(get, cfg) + cfg = cfg or Versions.BATTLE_UI + if not (cfg.healthbox_doubles_player and cfg.healthbox_doubles_opponent) then return nil end + local raw = {} + for i = 0, 31 do raw[i + 1] = get(cfg.healthbox_pal + i) end + local hbPal = load_pal(raw, 16) + local playerRgba = bake_doubles_healthbox(Lz77.decompress(get, cfg.healthbox_doubles_player), hbPal) + local opponentRgba = bake_doubles_healthbox(Lz77.decompress(get, cfg.healthbox_doubles_opponent), hbPal) + return playerRgba, opponentRgba +end + +BattleChromeExtract.HP_BOLD_FILE = "hp_bold_digits.rgba" +BattleChromeExtract.HP_BOLD_CHARS = "0123456789/" +BattleChromeExtract.HP_BOLD_W = 88 +BattleChromeExtract.HP_BOLD_H = 8 + +-- pokefirered/src/text_printer.c:187 +local function decode_bold_half_rows(get, base, dest, destX, stride) + local map = { [0] = 0, 1, 3, 0 } + for row = 0, 7 do + local lo = get(base + row * 2) or 0 + local hi = get(base + row * 2 + 1) or 0 + for half = 0, 1 do + local b = half == 0 and hi or lo + for k = 0, 3 do + local v = math.floor(b / 4 ^ (3 - k)) % 4 + dest[row * stride + destX + half * 4 + k + 1] = map[v] + end + end + end +end + +-- pokefirered/src/text.c:1688 +function BattleChromeExtract.bakeHpBoldDigits(get, cfg) + cfg = cfg or Versions.BATTLE_UI + if not cfg.font_bold_glyphs then return nil end + local raw = {} + for i = 0, 31 do raw[i + 1] = get(cfg.healthbar_pal + i) end + local barPal = load_pal(raw, 16) + local w, h = BattleChromeExtract.HP_BOLD_W, BattleChromeExtract.HP_BOLD_H + local indices = {} + for i = 1, w * h do indices[i] = 0 end + local codes = { 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xBA } + for n, id in ipairs(codes) do + local glyph = cfg.font_bold_glyphs + 2 * (0x100 * math.floor(id / 16) + 8 * (id % 16)) + -- pokefirered/src/battle_interface.c:900 + decode_bold_half_rows(get, glyph + 2 * 0x80, indices, (n - 1) * 8, w) + end + return indices_to_rgba(indices, barPal, w, h) +end + +function BattleChromeExtract.runDoubles(rom, cache, opts) + opts = opts or {} + local root = (opts.cacheRoot or default_cache_root()) .. "/" .. BattleChromeExtract.CACHE_SUB + local get = function(i) return rom:get(i) end + local playerRgba, opponentRgba = BattleChromeExtract.bakeDoubles(get, opts.cfg) + if not playerRgba then return false end + cache:write(root .. "/" .. BattleChromeExtract.DOUBLES_FILES.player, playerRgba) + cache:write(root .. "/" .. BattleChromeExtract.DOUBLES_FILES.opponent, opponentRgba) + local boldRgba = BattleChromeExtract.bakeHpBoldDigits(get, opts.cfg) + if boldRgba then + cache:write(root .. "/" .. BattleChromeExtract.HP_BOLD_FILE, boldRgba) + end + return true +end + local function bake_sheet_rgba(gfx, pal, w, h) gfx = bytes_to_array(gfx) local tilesW, tilesH = math.floor(w / 8), math.floor(h / 8) @@ -322,6 +398,7 @@ function BattleChromeExtract.run(rom, cache, opts) local enemyRgba = bake_enemy_healthbox(enemyGfx, hbPal) cache:write(root .. "/healthbox_player.rgba", playerRgba) cache:write(root .. "/healthbox_enemy.rgba", enemyRgba) + BattleChromeExtract.runDoubles(rom, cache, { cacheRoot = cacheRoot }) local elGfx = read_raw(rom, cfg.healthbox_elements, 320 * 24 / 2) -- HP bar sprite uses TAG_HEALTHBAR_PAL; EXP is blitted into the healthbox @@ -378,6 +455,8 @@ function BattleChromeExtract.run(rom, cache, opts) -- Player TL uses stale 64x32 centerToCorner (−32,−16) even though shape is 64x64 playerBox = { w = 128, h = 64, x = 158, y = 88 }, enemyBox = { w = 128, h = 32, x = 44, y = 30 }, + doublesPlayerBox = { w = 128, h = 32, file = "healthbox_doubles_player.rgba" }, + doublesOpponentBox = { w = 128, h = 32, file = "healthbox_doubles_opponent.rgba" }, -- Sprite centers before pic y_offset; final Y = base + y_offset [+8 player] playerSprite = { x = 72, y = 80 }, enemySprite = { x = 176, y = 40 }, diff --git a/src/import/gba/summary_chrome_extract.lua b/src/import/gba/summary_chrome_extract.lua index aa87422e..004ca20d 100644 --- a/src/import/gba/summary_chrome_extract.lua +++ b/src/import/gba/summary_chrome_extract.lua @@ -472,6 +472,16 @@ function SummaryChromeExtract.run(rom, cache, opts) cache:write(root .. "/pokerus.rgba", pkrsRgba) end + -- pokefirered/src/list_menu.c:738 + if rom and Versions.MENU_INFO_GFX and Versions.MENU_INFO_PAL then + local miGfx = read_bytes(Versions.MENU_INFO_GFX, 128 * 128 / 2) + local miPal = read_bytes(Versions.MENU_INFO_PAL, 64) + local caught = bake_sheet_rgba(miGfx, miPal, 16, 16, 0) + local types = bake_sheet_rgba(miGfx, miPal, 16, 16, 1) + local split = 16 * 128 * 4 + cache:write(root .. "/menu_info.rgba", caught:sub(1, split) .. types:sub(split + 1)) + end + local menuInfoBin = read_bin({ "src/import/gba/chrome/menus/menu_info.png", "data/generated/gba/pokemon/summary/menu_info.png", diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 80c43021..a3bf24e7 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -1178,8 +1178,11 @@ Versions.BATTLE_UI = { healthbox_elements = 0xD11BC4, -- uncompressed 320×24 4bpp healthbox_player = 0xD1F340, -- gHealthboxSinglesPlayerGfx LZ → 4096 healthbox_enemy = 0xD1F604, -- gHealthboxSinglesOpponentGfx LZ → 2048 + healthbox_doubles_player = 0xD1F794, -- gHealthboxDoublesPlayerGfx LZ → 2048 + healthbox_doubles_opponent = 0xD1F928, -- gHealthboxDoublesOpponentGfx LZ → 2048 healthbox_pal = 0xD11B84, -- uncompressed 32 (gBattleInterface_Healthbox_Pal) healthbar_pal = 0xD11BA4, -- uncompressed 32 (gBattleInterface_Healthbar_Pal) + font_bold_glyphs = 0x22FC48, -- uncompressed 8192 2bpp (sFontBoldJapaneseGlyphs, src/text.c:384) terrain_grass = { pal = 0x248400, -- LZ → 96 (3 banks) tiles = 0x24844C, -- LZ → 3136 @@ -1273,6 +1276,8 @@ Versions.SUMMARY_POKERUS_GFX = 0x463B20 -- sPokerus_Gfx (64 bytes) Versions.SUMMARY_POKERUS_PAL = 0x463B00 -- sPokerus_Pal (32 bytes) Versions.SUMMARY_HP_BAR_YELLOW_PAL = 0x463AAC -- sHpBar_Yellow_Pal (32 bytes) Versions.SUMMARY_HP_BAR_RED_PAL = 0x463ACC -- sHpBar_Red_Pal (32 bytes) +Versions.MENU_INFO_GFX = 0xE95DDC -- gMenuInfoElements_Gfx (4bpp 128x128, 8192 bytes) +Versions.MENU_INFO_PAL = 0xE95D9C -- gMenuInfoElements1_Pal + gMenuInfoElements2_Pal (64 bytes) -- Bag / item-menu chrome + item icons (LZ; FireRed USA 1.0). Versions.BAG_BG_GFX = 0xE830CC -- gBagBg_Gfx diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index b5cd4143..b110cf5f 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -118,7 +118,10 @@ local GEN2_REGISTRANTS = { local GEN2_ONLY_ORDER = { "item_effects", "phone_contacts", "decorations", "apricorns", "radio_channels" } +local GEN3_REGISTRANTS = {} + local function registrantsFor(generation) + if generation == 3 then return GEN3_REGISTRANTS end if generation ~= 2 then return REGISTRANTS end local out, taken = {}, {} for _, entry in ipairs(REGISTRANTS) do diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua index 69d02fea..3af8399f 100644 --- a/src/mods/DatasetViews.lua +++ b/src/mods/DatasetViews.lua @@ -48,6 +48,44 @@ local GEN2_ROOTS = { gen2Constants = "constants", gen2Landmarks = "landmarks", } +local GEN3_ROOTS = { + maps = "maps", + gen3Pokemon = { + names = "gba/pokemon/names", types = "gba/pokemon/types", + stats = "gba/pokemon/stats", abilities = "gba/pokemon/abilities", + abilityNames = "gba/pokemon/ability_names", meta = "gba/pokemon/meta", + learnsets = "gba/pokemon/learnsets", evolutions = "gba/pokemon/evolutions", + dex = "gba/pokemon/dex", moveNames = "gba/pokemon/move_names", + }, + gen3Moves = { rom = "gba/pokemon/battle_moves", names = "gba/pokemon/move_names" }, + gen3Items = "gba/items/pack", + gen3Encounters = "gba/encounters", + gen3Text = "gba/scripts/text", + gen3Scripts = "gba/scripts/scripts", +} + +local ROOTS = { [1] = GEN1_ROOTS, [2] = GEN2_ROOTS, [3] = GEN3_ROOTS } + +local function rootsFor(generation) + return ROOTS[generation] or GEN1_ROOTS +end + +local function extraModules(generation) + local names, seen = {}, {} + if generation ~= 3 then return names end + for _, entry in pairs(GEN3_ROOTS) do + local parts = type(entry) == "table" and entry or { entry } + for _, name in pairs(parts) do + if name:find("/", 1, true) and not seen[name] then + seen[name] = true + names[#names + 1] = name + end + end + end + table.sort(names) + return names +end + local function resolvePath(root, suffix) local node = root for key in suffix:gmatch("[^.]+") do @@ -141,6 +179,10 @@ function DatasetViews:_preflight(version, inspected) local path = inspected.prefix .. "data/generated/" .. name .. ".lua" if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end end + for _, name in ipairs(extraModules(GameVersion.generation(version))) do + local path = inspected.prefix .. "data/generated/" .. name .. ".lua" + if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end + end for _, name in ipairs(modules) do local path = inspected.prefix .. "data/generated/" .. name .. ".lua" local info = self.fs.getInfo(path, "file") @@ -200,15 +242,14 @@ function DatasetViews:_ready(view) return not view.invalid end -function DatasetViews:_module(view, root) - local moduleName = view.modules[root] - if not moduleName then return nil end +function DatasetViews:_decode(view, moduleName, soft) local cached = view.moduleCache[moduleName] if cached then return cached.value end local path = view.plan.paths[moduleName] if not path then return nil end local source = self.fs.read(path) if type(source) ~= "string" then + if soft then return nil end return self:_reject(view, moduleName, source, moduleName .. ": unreadable generated module") end @@ -219,6 +260,11 @@ function DatasetViews:_module(view, root) end local value, err = self.decoder(source, DECODE_LIMITS) if type(value) ~= "table" then + if soft then + Logger.warn("dataset %s: %s skipped: %s", view.version, moduleName, + tostring(err or "non-table root")) + return nil + end return self:_reject(view, moduleName, source, moduleName .. ": " .. tostring(err or "non-table root")) end @@ -226,6 +272,26 @@ function DatasetViews:_module(view, root) return value end +function DatasetViews:_module(view, root) + local moduleName = view.modules[root] + if not moduleName then return nil end + local soft = view.generation == 3 + if type(moduleName) ~= "table" then + return self:_decode(view, moduleName, soft and moduleName:find("/", 1, true) ~= nil) + end + local composite, any = {}, false + for key, name in pairs(moduleName) do + local value = self:_decode(view, name, true) + if view.invalid then return nil end + if value ~= nil then + composite[key] = value + any = true + end + end + if not any then return nil end + return composite +end + function DatasetViews:_data(view) if view.data then return view.data end local data = {} @@ -236,6 +302,7 @@ function DatasetViews:_data(view) return value end, }) + if view.generation == 3 then Schemas.bindGen3(data) end DatasetHydration.apply(data, view.version, self.engineRequire) if view.invalid then error(view.invalid.detail, 0) end view.data = data @@ -284,6 +351,7 @@ function DatasetViews:_registry(view, name) or Schemas.targetFor(registry.name, registry.spec, view.generation) local root = target and target:match("^[^%.]+") local moduleName = root and view.modules[root] + if type(moduleName) ~= "string" then moduleName = nil end if root == "gen2HeldItems" then moduleName = "items" end local cached = moduleName and view.moduleCache[moduleName] service:_reject(view, moduleName, cached and cached.source, @@ -391,7 +459,7 @@ function DatasetViews:open(version) generation = GameVersion.generation(version), prefix = inspected.prefix, plan = plan, - modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS, + modules = rootsFor(GameVersion.generation(version)), moduleCache = {}, } self.datasets[version] = internal diff --git a/src/mods/Gen3Compat.lua b/src/mods/Gen3Compat.lua new file mode 100644 index 00000000..cfbda4c8 --- /dev/null +++ b/src/mods/Gen3Compat.lua @@ -0,0 +1,2278 @@ +-- Gen 1 module facades for a FireRed boot: the Gen 1 API backed by src/core/game3. +-- Contract mirrors src/mods/Gen2Compat.lua; docs/mod-api-gen3-compat.md. + +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local Gen3Compat = {} + +local rawRequire = require + +local resolveGame = nil +local lastGame = nil + +local built = {} +local claimants = {} +local warned = {} + +local function warnOnce(key, fmt, ...) + if warned[key] then return end + warned[key] = true + Logger.warn(fmt, ...) +end + +local function who(name) + local ids = claimants[name] + if not ids or #ids == 0 then return "a gen3 mod" end + return table.concat(ids, ", ") +end + +local function live() + local g = resolveGame and resolveGame() or nil + return g or lastGame +end + +local function unbacked(module, member, why) + return function() + warnOnce(module .. "." .. member, + "[%s] %s.%s has no Gen 3 backing: %s", who(module), module, member, why) + return nil + end +end + +local function g3(name) + local ok, module = pcall(rawRequire, "src.core.game3." .. name) + if ok then return module end + return nil +end + +local function ui3(name) + local ok, module = pcall(rawRequire, "src.ui.game3." .. name) + if ok then return module end + return nil +end + +local function session() + local R = package.loaded["src.core.game3.runtime"] + local s = R and R.getSession and R.getSession() + if s then return s end + local g = live() + return g and g.session or nil +end + +local function space() + return package.loaded["src.core.game3.scripting.space"] +end + +local function flagStore() + local S = space() + return S and S.store or nil +end + +local function inField() + local g = live() + if g and g.phase ~= nil and g.phase ~= "field" then return false end + return session() ~= nil +end + +local COVERAGE = {} + +Gen3Compat.COVERAGE_VERSION = 1 +Gen3Compat.STATUS = { BACKED = "backed", WARNED = "warned", ABSENT = "absent" } + +local function words(s) + local out = {} + for w in tostring(s or ""):gmatch("%S+") do out[#out + 1] = w end + return out +end + +-- ------- ids + +local MAP_PREFIX = "FR_" + +function Gen3Compat.gen1MapId(id) + if type(id) ~= "string" then return id end + if id:sub(1, #MAP_PREFIX) == MAP_PREFIX then return id:sub(#MAP_PREFIX + 1) end + return id +end + +function Gen3Compat.gen3MapId(id) + if type(id) ~= "string" then return id end + local g = live() + local maps = g and g.data and g.data.maps + if maps then + if maps[id] then return id end + if maps[MAP_PREFIX .. id] then return MAP_PREFIX .. id end + return nil + end + if id:sub(1, #MAP_PREFIX) == MAP_PREFIX then return id end + return MAP_PREFIX .. id +end + +local function canonicalName(display) + if type(display) ~= "string" then return nil end + local s = display:upper() + s = s:gsub("♂", "_M"):gsub("♀", "_F") + s = s:gsub("[%.']", ""):gsub("[%s%-]+", "_") + s = s:gsub("[^%w_]", ""):gsub("_+", "_"):gsub("^_", ""):gsub("_$", "") + return s +end + +function Gen3Compat.speciesId(ref) + if type(ref) == "number" then return ref end + if type(ref) ~= "string" then return nil end + local n = tonumber(ref) + if n then return n end + local P = g3("pokemon") + return P and P.speciesFromName and P.speciesFromName(ref) or nil +end + +function Gen3Compat.speciesName(sp) + if type(sp) == "string" and not tonumber(sp) then return sp end + sp = tonumber(sp) + if not sp then return nil end + local P = g3("pokemon") + if P and P.keyName then + local key = P.keyName(sp) + if key then return key end + end + if not (P and P.name) then return nil end + return canonicalName(P.name(sp)) +end + +function Gen3Compat.itemId(ref) + if type(ref) == "number" then return ref end + local D = g3("items_data") + local num = D and D.toNumericId and D.toNumericId(ref) + if num then return num end + if type(ref) == "string" and D and D.toNumericId then + return D.toNumericId((ref:gsub("_", " "))) + end + return nil +end + +function Gen3Compat.moveId(ref) + if type(ref) == "number" then return ref end + if type(ref) ~= "string" then return nil end + local n = tonumber(ref) + if n then return n end + local M = g3("battle.moves") + if not M then return nil end + local norm = M.normalizeId and M.normalizeId(ref) or ref + local num = M.numForName and M.numForName(norm) or nil + if num then return num end + local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") + if okS and Schemas and Schemas.gen3View then + local n = Schemas.gen3View.moveNum(M, ref) + if type(n) == "number" then return n end + end + return nil +end + +local function schemaId(display) + if type(display) ~= "string" or display == "" then return nil end + local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") + if okS and Schemas and Schemas.gen3View then + return Schemas.gen3View.idOf(display) + end + return canonicalName(display) +end + +function Gen3Compat.moveName(ref) + if type(ref) == "table" then ref = ref.numId or ref.id or ref.move end + if type(ref) == "string" and not tonumber(ref) then return ref end + local num = tonumber(ref) + if not num or num < 1 then return nil end + local P = g3("pokemon") + local name = P and P.moveName and P.moveName(num) + if type(name) ~= "string" or name == "" or name:match("^MOVE ") then + local M = g3("battle.moves") + name = M and M.BY_NUM and M.BY_NUM[num] or nil + end + return schemaId(name) or tostring(num) +end + +function Gen3Compat.itemName(ref) + if ref == nil or ref == 0 then return nil end + if type(ref) == "string" and not tonumber(ref) then return ref end + local num = tonumber(ref) + if not num or num < 1 then return nil end + local D = g3("items_data") + local name = D and D.displayName and D.displayName(num) + return schemaId(name) or tostring(num) +end + +local function typeName(t) + local okS, Schemas = pcall(rawRequire, "src.mods.Schemas") + local types = okS and Schemas and Schemas.gen3View and Schemas.gen3View.TYPES + local n = tonumber(t) + return (types and n and types[n]) or t +end + +function Gen3Compat.moveView(ref) + local M = g3("battle.moves") + if not M then return nil end + local def = type(ref) == "table" and ref.effect ~= nil and ref or M.get(ref) + if not def then return nil end + local num = tonumber(def.numId) or Gen3Compat.moveId(type(ref) == "table" and def.id or ref) + return { + id = Gen3Compat.moveName(num) or def.id, index = num, num = num, + name = M.displayName and M.displayName(num or def.id) or def.id, + type = typeName(def.type), gen3Type = tonumber(def.type), + power = def.power, accuracy = def.accuracy, pp = def.pp, + priority = def.priority, category = def.category, effect = def.effect, + target = def.target, secondaryChance = def.secondaryChance, + } +end + +function Gen3Compat.speciesView(sp) + sp = Gen3Compat.speciesId(sp) + if not sp then return nil end + local P = g3("pokemon") + local meta = P and P.speciesMeta and P.speciesMeta(sp) or {} + return { + id = Gen3Compat.speciesName(sp), index = sp, + catchRate = tonumber(meta.catchRate), expYield = tonumber(meta.expYield), + growthRate = tonumber(meta.growthRate), + } +end + +local function nameList(list, toName) + if type(list) ~= "table" then return list end + local out = {} + for _, v in ipairs(list) do + local name = (tonumber(v) or 0) ~= 0 and toName(v) or nil + if name then out[#out + 1] = name end + end + return out +end + +function Gen3Compat.partyNames(party) + local out = {} + for i, mon in ipairs(party or {}) do + local row = {} + for k, v in pairs(mon) do row[k] = v end + local sp = tonumber(mon.species or mon.speciesId) + row.species = Gen3Compat.speciesName(sp) or mon.species + row.speciesId = sp + if type(mon.moves) == "table" then + row.moves = nameList(mon.moves, Gen3Compat.moveName) + row.moveIds = {} + for j, v in ipairs(mon.moves) do row.moveIds[j] = v end + end + if mon.heldItem ~= nil then + row.heldItem = Gen3Compat.itemName(mon.heldItem) + row.heldItemId = tonumber(mon.heldItem) + end + out[i] = row + end + return out +end + +function Gen3Compat.partyNums(party) + local out = {} + for i, mon in ipairs(party or {}) do + local row = {} + for k, v in pairs(mon) do row[k] = v end + row.species = Gen3Compat.speciesId(mon.species) or tonumber(mon.speciesId) or 0 + row.speciesId = nil + if type(mon.moves) == "table" then + local list = {} + for j, v in ipairs(mon.moves) do list[j] = Gen3Compat.moveId(v) or 0 end + row.moves = list + end + row.moveIds = nil + if mon.heldItem ~= nil then + row.heldItem = Gen3Compat.itemId(mon.heldItem) + end + row.heldItemId = nil + out[i] = row + end + return out +end + +-- ------- live views over the session + +local function flagId(name) + if type(name) == "number" then return name end + if type(name) ~= "string" then return nil end + local n = tonumber(name) + if n then return n end + local Flags = g3("scripting.flags") + return Flags and Flags.IDS and Flags.IDS[name] or nil +end + +local function varId(name) + if type(name) == "number" then return name end + if type(name) ~= "string" then return nil end + local n = tonumber(name) + if n then return n end + local Flags = g3("scripting.flags") + return Flags and Flags.VAR_IDS and Flags.VAR_IDS[name] or nil +end + +function Gen3Compat.getFlag(name) + local id = flagId(name) + if not id then return nil end + local Flags = g3("scripting.flags") + local store = flagStore() + if store and Flags then + return Flags.getFlag(store, nil, id) and true or nil + end + local s = session() + local flags = s and s.flags + if not flags then return nil end + return (flags[id] or flags[tostring(id)]) and true or nil +end + +function Gen3Compat.setFlag(name, value) + local id = flagId(name) + if not id then return nil, "unknown FireRed flag: " .. tostring(name) end + local Flags = g3("scripting.flags") + local store = flagStore() + if store and Flags then + Flags.setFlag(store, nil, id, value and true or false) + local Objects = package.loaded["src.core.game3.objects"] + if Objects and Objects.syncFlagVisibility then + Objects.syncFlagVisibility(id, value and true or false) + end + return true + end + local s = session() + if not s then return nil, "no session" end + s.flags = s.flags or {} + s.flags[tostring(id)] = value and true or nil + return true +end + +function Gen3Compat.getVar(name) + local id = varId(name) + if not id then return nil end + local Flags = g3("scripting.flags") + local S = space() + if S and S.store and Flags then + return Flags.getVar(S.store, S.vm and S.vm.ctx, id) + end + local s = session() + local vars = s and s.vars + return vars and tonumber(vars[id] or vars[tostring(id)]) or 0 +end + +function Gen3Compat.setVar(name, value) + local id = varId(name) + if not id then return nil, "unknown FireRed var: " .. tostring(name) end + local Flags = g3("scripting.flags") + local S = space() + if S and S.store and Flags then + Flags.setVar(S.store, S.vm and S.vm.ctx, id, value) + return true + end + local s = session() + if not s then return nil, "no session" end + s.vars = s.vars or {} + s.vars[tostring(id)] = tonumber(value) or 0 + return true +end + +local FLAGS_VIEW = setmetatable({}, { + __index = function(_, key) return Gen3Compat.getFlag(key) end, + __newindex = function(_, key, value) + local ok, err = Gen3Compat.setFlag(key, value) + if not ok then + warnOnce("save.flags." .. tostring(key), + "[%s] save.flags.%s: %s", who("src.core.Game"), tostring(key), + tostring(err)) + end + end, +}) + +local VARS_VIEW = setmetatable({}, { + __index = function(_, key) return Gen3Compat.getVar(key) end, + __newindex = function(_, key, value) Gen3Compat.setVar(key, value) end, +}) + +local INVENTORY_VIEW = setmetatable({}, { + __index = function(_, key) + local s = session() + local Bag = g3("bag") + if not (s and s.bag and Bag) then return nil end + local id = Gen3Compat.itemId(key) or key + local n = Bag.get(s.bag, id) + if not n or n <= 0 then return nil end + return n + end, + __newindex = function(_, key, value) + local s = session() + local Bag = g3("bag") + if not (s and s.bag and Bag) then return end + local id = Gen3Compat.itemId(key) + if not id then + warnOnce("save.inventory." .. tostring(key), + "[%s] save.inventory.%s: no FireRed item of that name", + who("src.core.Game"), tostring(key)) + return + end + Bag.set(s.bag, id, tonumber(value) or 0) + end, +}) + +local function dexSide(field) + return setmetatable({}, { + __index = function(_, key) + local s = session() + local dex = s and s.dex and s.dex[field] + local sp = Gen3Compat.speciesId(key) + if not (dex and sp) then return nil end + return (dex[sp] or dex[tostring(sp)]) and true or nil + end, + __newindex = function(_, key, value) + local s = session() + local sp = Gen3Compat.speciesId(key) + if not (s and sp) then return end + s.dex = s.dex or {} + s.dex[field] = s.dex[field] or {} + s.dex[field][sp] = value and true or nil + end, + }) +end + +local POKEDEX_VIEW = { seen = dexSide("seen"), caught = dexSide("owned") } + +local PLAYER_VIEW = setmetatable({}, { + __index = function(_, key) + local s = session() + if not s then return nil end + if key == "name" then return s.name end + if key == "rival" then return s.rivalName end + if key == "money" then return s.money end + if key == "map" then return Gen3Compat.gen1MapId(s.map) end + if key == "gen3Map" then return s.map end + if key == "x" or key == "y" or key == "facing" then + local P = package.loaded["src.core.game3.player"] + if P and inField() then + if key == "x" then return P.cellX end + if key == "y" then return P.cellY end + return P.facing + end + return s[key] + end + return nil + end, + __newindex = function(_, key, value) + local s = session() + if not s then return end + if key == "name" then s.name = value + elseif key == "rival" then s.rivalName = value + elseif key == "money" then s.money = value + else + warnOnce("save.player.write." .. tostring(key), + "[%s] save.player.%s is read-only on FireRed; move the player with " + .. "mod.world:warpTo", who("src.core.Game"), tostring(key)) + end + end, +}) + +local SAVE_ABSENT = { + boxes = "FireRed's PC is session.storage (14 boxes of 30 sparse slots); " + .. "require src.pokemon.Boxes, which is served", + box = "FireRed has no Gen 1 save.box", + objectToggles = "an object's visibility IS its hide flag on FireRed", + defeatedTrainers = "trainer defeats are flags 0x500 + trainer id", + hiddenTaken = "hidden items are flags on FireRed", +} + +local SAVE_VIEW = setmetatable({}, { + __index = function(_, key) + local s = session() + if not s then return nil end + if key == "flags" then return FLAGS_VIEW end + if key == "vars" then return VARS_VIEW end + if key == "inventory" then return INVENTORY_VIEW end + if key == "player" then return PLAYER_VIEW end + if key == "pokedex" then return POKEDEX_VIEW end + if key == "gen3" then return s end + local why = SAVE_ABSENT[key] + if why then + warnOnce("save.absent." .. key, "[%s] save.%s has no Gen 3 backing: %s", + who("src.core.Game"), key, why) + return nil + end + return s[key] + end, + __newindex = function(_, key, value) + local s = session() + if not s then return end + if key == "flags" or key == "vars" or key == "inventory" + or key == "player" or key == "pokedex" then + warnOnce("save.replace." .. key, + "[%s] save.%s cannot be replaced wholesale on FireRed; write its keys", + who("src.core.Game"), key) + return + end + s[key] = value + end, +}) + +Gen3Compat.saveView = SAVE_VIEW + +-- ------- data views + +local recordCache = setmetatable({}, { __mode = "k" }) + +local function pokemonRecord(ref) + local P = g3("pokemon") + local sp = Gen3Compat.speciesId(ref) + if not (P and sp) then return nil end + local names = P._names + if not names and P.install then pcall(P.install, P._cache) names = P._names end + if not (names and names[sp]) then return nil end + local byModule = recordCache[P] + if not byModule or byModule.names ~= names then + byModule = { names = names, rows = {} } + recordCache[P] = byModule + end + local hit = byModule.rows[sp] + if hit then return hit end + local base = P.stats and P.stats(sp) or {} + local meta = P.speciesMeta and P.speciesMeta(sp) or {} + local root = "data/generated/gba/pokemon/" + hit = { + id = (P.keyName and P.keyName(sp)) or canonicalName(names[sp]), + index = sp, + gen3Species = sp, + name = names[sp], + types = P.types and P.types(sp) or nil, + baseStats = { hp = base.hp, attack = base.atk, defense = base.def, + speed = base.spe, specialAttack = base.spa, specialDefense = base.spd }, + catchRate = tonumber(meta.catchRate), + growthRate = P.growthRate and P.growthRate(sp) or nil, + abilities = P.abilities and P.abilities(sp) or nil, + learnset = P.learnset and P.learnset(sp) or nil, + evolutions = P.evolutions and P.evolutions(sp) or nil, + dexEntry = P.dexEntry and P.dexEntry(sp) or nil, + national = P.national and P.national(sp) or nil, + spriteFront = root .. "front/" .. sp .. ".rgba", + spriteBack = root .. "back/" .. sp .. ".rgba", + trueColor = true, + } + byModule.rows[sp] = hit + return hit +end + +Gen3Compat.pokemonRecord = pokemonRecord + +local function moveRecord(ref) + local M = g3("battle.moves") + local num = Gen3Compat.moveId(ref) + if not (M and num and M.get) then return nil end + local row = M.get(num) + if not row then return nil end + local out = {} + for k, v in pairs(row) do out[k] = v end + out.index = num + out.name = out.name or (M.displayName and M.displayName(num)) + return out +end + +Gen3Compat.moveRecord = moveRecord + +local function itemRecord(ref) + local D = g3("items_data") + local num = Gen3Compat.itemId(ref) + if not (D and num) then return nil end + return D.info(num) +end + +Gen3Compat.itemRecord = itemRecord + +local function registryOverride(name, id) + local g = live() + local content = g and g.mods and g.mods.content + local reg = content and content[name] + if reg and reg.ops and type(id) == "string" and reg.ops[id] then + return reg:get(id) + end + return nil +end + +local function namedView(registry, record) + return setmetatable({}, { + __index = function(_, key) + local over = registryOverride(registry, key) + if over ~= nil then return over end + return record(key) + end, + __newindex = function(_, key) + warnOnce("data." .. registry .. ".write", + "[%s] game.data.%s.%s: FireRed's %s are numeric ROM tables; change " + .. "them through mod.content.%s", who("src.core.Game"), registry, + tostring(key), registry, registry) + end, + }) +end + +local POKEMON_VIEW = namedView("pokemon", pokemonRecord) +local MOVES_VIEW = namedView("moves", moveRecord) +local ITEMS_VIEW = namedView("items", itemRecord) + +local DATA_RENAMES = { + encounters = "gen3Encounters", trainers = "gen3Trainers", + text = "gen3Text", map_scripts = "gen3Scripts", scripts = "gen3Scripts", +} + +local DATA_UNBACKED = { + sprites = "FireRed's overworld sprites are baked by the extractor, not a " + .. "data.sprites registry", + field = "FireRed's field rules are src/core/game3/collision.lua and the " + .. "ROM's metatile behaviours, not a data.field record", + constants = "no Gen 1 rule table exists on FireRed", + text_pointers = "the extractor's Gen 1 pointer table has no FireRed " + .. "counterpart", + trainer_headers = "the extractor's Gen 1 header table has no FireRed " + .. "counterpart", + palettes = "FireRed palettes are baked into the extracted RGBA art", + icons = "FireRed icons are src/core/game3/pokemon.lua Pokemon.icon", + battle_anims = "FireRed's battle animations are the pret script VM under " + .. "src/core/game3/battle/anim_*", + type_chart = "FireRed's type chart is src/core/game3/battle/types.lua", +} + +local dataProxies = setmetatable({}, { __mode = "kv" }) + +local function mapsView(data) + return setmetatable({}, { + __index = function(_, key) + local maps = data.maps + if not maps then return nil end + local hit = maps[key] + if hit ~= nil then return hit end + if type(key) == "string" then return maps[MAP_PREFIX .. key] end + return nil + end, + __newindex = function(_, key, value) + if data.maps then data.maps[Gen3Compat.gen3MapId(key) or key] = value end + end, + }) +end + +local function dataProxy(data) + if not data then return nil end + local hit = dataProxies[data] + if hit then return hit end + local maps = mapsView(data) + hit = setmetatable({}, { + __index = function(_, key) + if key == "pokemon" then return POKEMON_VIEW end + if key == "moves" then return MOVES_VIEW end + if key == "items" then return ITEMS_VIEW end + if key == "maps" then return maps end + local renamed = DATA_RENAMES[key] + if renamed then return data[renamed] end + local why = DATA_UNBACKED[key] + if why then + warnOnce("data." .. key, "[%s] game.data.%s is Gen 1 only: %s", + who("src.core.Game"), key, why) + return nil + end + return data[key] + end, + __newindex = function(_, key, value) data[DATA_RENAMES[key] or key] = value end, + }) + dataProxies[data] = hit + return hit +end + +Gen3Compat.dataView = dataProxy + +-- ------- src.core.Game + +local GAME_UNBACKED = { + renderer = "FireRed composites through src/core/game3/display.lua at " + .. "240x160 and never inits the Renderer singleton", + load = "calling it would re-run FireRed's whole boot on top of a running " + .. "game", + bootConfig = "FireRed's new-game inputs are Schema.newGame's opts", + makeTitleState = "src/ui/game3/boot.lua owns the title; nothing returns a " + .. "state to push", + step = "FireRed's logic tick is Game3:fixedUpdate; the input.step hook is " + .. "the replacement", + restoreSave = "FireRed continues through the boot menu " + .. "(Game3:_handleBootAction), not a save table handed in", + linkNet = "FireRed has no link play", + linkSession = "FireRed has no link play", +} + +local GAME_STACK_STATICS = { + "worldBgBattleDim", "worldBgBattleInStack", "fillScaleInStack", + "wideBattleInStack", "uiAnchorsHeldInStack", "drawBaseInStack", "dynamicUI", +} + +local function buildGame() + local proxy = {} + local statics = nil + + local function gen1Static(key) + if statics == nil then + local ok, module = pcall(rawRequire, "src.core.Game") + statics = ok and module or false + end + return statics and statics[key] or nil + end + + local translate = {} + + function translate.overworld() + if not inField() then return nil end + return Gen3Compat.resolve("src.world.OverworldController") + end + function translate.data(g) return dataProxy(g.data) end + function translate.save() + if not session() then return nil end + return SAVE_VIEW + end + function translate.stack() + warnOnce("game.stack", + "[%s] Game.stack on FireRed is src/ui/game3/stack.lua: push(id, module, " + .. "opts) / pop(id) / top(), not a StateStack of state objects", + who("src.core.Game")) + return ui3("stack") + end + function translate.fixedStep() return rawRequire("src.core.FixedStep") end + function translate.generation() return 3 end + + function translate.writeOptions() + return function() + local g = live() + if g and g.writeOptions then return g:writeOptions() end + end + end + + function translate.logicSpeed() + return function() + local g = live() + if not g or not g.logicSpeed then return 1 end + return g:logicSpeed() + end + end + + function translate.applyOptions() + return function(opts) + local g = live() + if not g or not g.applyOptions then return end + if opts == proxy then opts = nil end + return g:applyOptions(opts or g.options) + end + end + + function translate.saveGame() + return function() + local g = live() + if g and g.saveGame then return g:saveGame() end + end + end + + function translate.writeSave() return translate.saveGame() end + + function translate.restartWithMods() + return function() return rawRequire("src.core.HostShell").restart() end + end + + function translate.recoverInput() + return function() + local g = live() + if not g then return end + local Input = rawRequire("src.core.Input") + Input:reset() + if Input.reconcile then Input:reconcile() end + local okT, Touch = pcall(rawRequire, "src.core.TouchControls") + if okT and Touch and Touch.reset then Touch:reset() end + if g.mods and g.mods.releaseModInput then g.mods:releaseModInput() end + end + end + + function translate.zoomStep() + return function(delta) + local g = live() + if g and g.zoomStep then g:zoomStep(delta) end + end + end + + local LIVE_FREE = { writeOptions = true, logicSpeed = true, fixedStep = true, + restartWithMods = true, generation = true } + + setmetatable(proxy, { + __index = function(_, key) + local why = GAME_UNBACKED[key] + if why then + warnOnce("game." .. key, "[%s] Game.%s has no Gen 3 backing: %s", + who("src.core.Game"), key, why) + return nil + end + local made = translate[key] + if made then + local g = live() + if not g and not LIVE_FREE[key] then return nil end + return made(g) + end + for _, name in ipairs(GAME_STACK_STATICS) do + if name == key then return gen1Static(key) end + end + local g = live() + if not g then return nil end + local value = g[key] + if value == nil then + warnOnce("game.unknown." .. tostring(key), + "[%s] Game.%s is not on the FireRed service owner; it reads nil " + .. "because Game3 has no member of that name", + who("src.core.Game"), tostring(key)) + return nil + end + if type(value) == "function" then + return function(first, ...) + if first == proxy then return value(g, ...) end + return value(first, ...) + end + end + return value + end, + __newindex = function(_, key, value) + local g = live() + if not g then return end + if key == "overworld" or key == "save" or key == "data" then + warnOnce("game.write." .. key, + "[%s] Game.%s cannot be replaced on FireRed", who("src.core.Game"), key) + return + end + g[key] = value + end, + }) + return proxy +end + +COVERAGE["src.core.Game"] = { + kind = "facade", target = "src.core.Game3", + backed = "overworld data data.pokemon data.moves data.items data.maps " + .. "data.encounters data.trainers data.text data.map_scripts data.tilesets " + .. "save save.flags save.vars save.party save.money save.inventory " + .. "save.player save.player.map save.player.x save.player.y " + .. "save.player.facing save.player.rival save.pokedex save.options " + .. "save.modData mods modStatus input touchControls options phase " + .. "fixedStep generation writeOptions logicSpeed applyOptions saveGame " + .. "writeSave restartWithMods recoverInput zoomStep returnToTitle update " + .. "draw keypressed keyreleased gamepadpressed gamepadreleased " + .. "gamepadaxis joystickpressed joystickreleased joystickaxis joystickhat " + .. "joystickremoved focus visible onResume touchpressed touchmoved " + .. "touchreleased mousepressed mousemoved mousereleased wheelmoved " + .. "worldBgBattleDim worldBgBattleInStack fillScaleInStack " + .. "wideBattleInStack uiAnchorsHeldInStack drawBaseInStack dynamicUI", + warned = "stack renderer load bootConfig makeTitleState step restoreSave " + .. "linkNet linkSession data.sprites data.field data.constants " + .. "data.text_pointers data.trainer_headers data.palettes data.icons " + .. "data.battle_anims data.type_chart save.boxes save.box " + .. "save.objectToggles save.defeatedTrainers save.hiddenTaken", + absent = "saveReport save.meta", + notes = { + identity = "the proxy never compares equal to the Game3 instance in the " + .. "game.ready payload; mod.game is the raw Game3", + iteration = "pairs/next/rawget see an EMPTY table on the proxy and on " + .. "every save / data view below it", + save = "a LIVE view over the FireRed session (Runtime.getSession), not " + .. "Game3.save, which is a snapshot rebuilt at each save", + ["save.flags"] = "keyed by FireRed flag NAME (FLAG_SYS_POKEMON_GET, or " + .. "the name without FLAG_) or number; a Gen 1 name such as " + .. "EVENT_GOT_STARTER reads nil because FireRed has no such flag", + ["save.inventory"] = "item name or FireRed item id -> count; answered " + .. "through src/core/game3/bag.lua, so a write lands in the right pocket", + ["save.player.map"] = "the Gen 1 spelling (FR_ prefix stripped); " + .. "save.player.gen3Map is the raw id", + ["save.pokedex"] = "seen / caught keyed by species name or FireRed " + .. "species number", + ["data.pokemon"] = "a NAME-keyed view assembled from the numeric FireRed " + .. "tables; spriteFront/spriteBack name the cache .rgba, and a " + .. "registry override wins", + ["data.maps"] = "keyed by the FR_ id; a Gen 1 spelling without the " + .. "prefix also resolves", + stack = "src/ui/game3/stack.lua, a module-level id stack, warned once", + applyOptions = "Game3:applyOptions(opts) takes the table directly, the " + .. "same contract as Gen 1", + }, +} + +-- ------- src.world.Map + +local MapView = {} + +local function collision() + return package.loaded["src.core.game3.collision"] +end + +function MapView.__index(self, key) + local method = rawget(MapView, key) + if method ~= nil then return method end + local raw = rawget(self, "gen3Id") + local g = live() + local def = g and g.data and g.data.maps and g.data.maps[raw] + if key == "def" then return def end + if key == "tileset" then return def and def.tileset end + if key == "widthCells" then return def and def.width end + if key == "heightCells" then return def and def.height end + if key == "warps" then return def and def.warps end + if key == "connections" then return def and def.connections end + return nil +end + +local function isActiveMap(self) + local M = package.loaded["src.core.game3.map"] + return M and M.current == rawget(self, "gen3Id") +end + +function MapView:inBounds(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return false end + return C.inBounds(cx, cy) and true or false +end + +function MapView:isWalkableCell(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return false end + return C.isWalkable(cx, cy) and true or false +end + +function MapView:isWaterCell(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return false end + return C.isWater(cx, cy) and true or false +end + +function MapView:isGrassCell(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return false end + return C.isGrass(cx, cy) and true or false +end + +function MapView:warpAtCell(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return nil end + return C.warpAt(cx, cy) +end + +function MapView:cellBehavior(cx, cy) + local C = collision() + if not (C and isActiveMap(self)) then return nil end + return C.behavior(cx, cy) +end + +function MapView:isOutdoor() + local def = self.def + local pair = def and (def.pair or (def.midLayout and def.midLayout.pair)) + return type(pair) == "string" and pair:find("outdoor", 1, true) ~= nil +end +MapView.isOutside = MapView.isOutdoor + +function MapView.new() + warnOnce("map.new", + "[%s] src.world.Map.new: FireRed maps are loaded by " + .. "src/core/game3/map.lua Map.load; take the live one from " + .. "OverworldController.map", who("src.world.Map")) + return nil +end + +local function mapView(rawId) + if not rawId then return nil end + return setmetatable({ id = Gen3Compat.gen1MapId(rawId), gen3Id = rawId }, + MapView) +end + +Gen3Compat.mapView = mapView + +local function buildMap() return MapView end + +COVERAGE["src.world.Map"] = { + kind = "facade", target = "src.core.game3.map", + backed = "id gen3Id def tileset widthCells heightCells warps connections " + .. "inBounds isWalkableCell isWaterCell isGrassCell warpAtCell " + .. "cellBehavior isOutdoor isOutside", + warned = "new", + absent = "cellTile isCounterCell blockAt setBlock tileAt isDoorTileCell " + .. "isWarpTileCell signAtCell connection isPushable defCellTile " + .. "defIsWalkableCell defIsWaterCell defPassable DELTA isFlyTown " + .. "ghostBattles warpPadOrHoleAt walkable doorTiles warpTiles waterTiles " + .. "renderer signAt warpAt", + notes = { + id = "the Gen 1 spelling (FR_OAKS_LAB -> OAKS_LAB); gen3Id is the raw id", + inBounds = "cell queries answer only for the ACTIVE map: FireRed binds one " + .. "collision grid at a time (src/core/game3/collision.lua)", + blockAt = "FireRed has 16px metatiles and no 32px blocks", + }, +} + +-- ------- src.world.NPC + +local function buildNpc() + local NPC = {} + NPC.new = unbacked("src.world.NPC", "new", + "FireRed event objects are plain records built by " + .. "src/core/game3/objects.lua from the map's object templates; " + .. "mod.world:npc hands out a handle onto a live one") + function NPC.facePlayer(npc) + local Objects = package.loaded["src.core.game3.objects"] + if not (Objects and type(npc) == "table" and npc.localId) then return nil end + return Objects.facePlayer(npc.localId, live()) + end + function NPC.pose(npc) + if type(npc) ~= "table" then return nil end + return npc.facing, npc.moving + end + return NPC +end + +COVERAGE["src.world.NPC"] = { + kind = "facade", target = "src.core.game3.objects", + backed = "facePlayer pose", + warned = "new", + absent = "update walkPhase draw hopStep MOVE __index", + notes = { + instance = "a live FireRed object carries localId cellX cellY px py " + .. "facing moving progress frozen passable sprite def; it has no " + .. "metatable, so getmetatable(npc) == NPC is false", + new = "no constructor: FireRed object templates come from the extracted " + .. "map events", + }, +} + +-- ------- src.world.Collision + +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +local function buildCollision() + local Collision = { DELTA = DELTA } + + function Collision.target(cx, cy, dir) + local d = DELTA[dir] + if not d then return cx, cy end + return cx + d[1], cy + d[2] + end + + function Collision.occupied(entities, cx, cy, ignore) + for _, e in ipairs(entities or {}) do + if e ~= ignore and not e.passable then + if (e.cellX == cx and e.cellY == cy) + or (e.targetX == cx and e.targetY == cy) then + return e + end + end + end + return nil + end + + function Collision.load(_data) + warnOnce("collision.load", + "[%s] Collision.load: FireRed has no data.field.tilePairs; passability " + .. "is the ROM's metatile behaviour (src/core/game3/collision.lua)", + who("src.world.Collision")) + return nil + end + + local function passthrough(allowed) return allowed end + + function Collision.canMove(map, entities, mover, dir) + local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) + local C = collision() + local allowed, why = true, nil + if not C then + allowed, why = false, "bounds" + else + local ok, reason = C.canEnter(live(), tx, ty, + { surfing = mover.surfing and true or false }) + if not ok then + allowed = false + why = (reason == "bounds" or reason == "entity") and reason or "tile" + elseif Collision.occupied(entities, tx, ty, mover) then + allowed, why = false, "entity" + end + end + if Runtime.wantsHook("movement.collision") then + local ctx = { map = map, mover = mover, dir = dir, + fromX = mover.cellX, fromY = mover.cellY, + toX = tx, toY = ty, reason = why } + allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) + why = ctx.reason + end + if allowed then return true end + return false, why + end + + return Collision +end + +COVERAGE["src.world.Collision"] = { + kind = "facade", target = "src.core.game3.collision", + backed = "DELTA target occupied canMove", + warned = "load", + absent = "", + notes = { + canMove = "the verdict src/core/game3/collision.lua Collision.canEnter " + .. "gives the active map, so `map` is ignored and only the loaded grid " + .. "answers", + target = "returns (cx, cy) unchanged for an unknown dir where Gen 1 errors", + }, +} + +-- ------- src.world.FieldDefaults + +local WORLD_CONSTANTS = { + stepFrames = 16, + turnFrames = 4, + runStepFrames = 8, + bikeStepFrames = 4, +} + +local function buildFieldDefaults() + local FieldDefaults = { CONSTANTS = { world = WORLD_CONSTANTS } } + + function FieldDefaults.field(_data, key) + warnOnce("fieldDefaults.field." .. tostring(key), + "[%s] src.world.FieldDefaults.field(%s): FireRed has no data.field", + who("src.world.FieldDefaults"), tostring(key)) + return nil + end + + function FieldDefaults.fieldValue(_data, key, ...) + local path = tostring(key) + for i = 1, select("#", ...) do path = path .. "." .. tostring((select(i, ...))) end + warnOnce("fieldDefaults.fieldValue." .. path, + "[%s] src.world.FieldDefaults: FireRed has no data.field %s", + who("src.world.FieldDefaults"), path) + return nil + end + + function FieldDefaults.constant(_data, key) + if key == "world" then return WORLD_CONSTANTS end + warnOnce("fieldDefaults.constant." .. tostring(key), + "[%s] FieldDefaults.constant(%s): the Gen 1 default is Kanto Red's rule " + .. "and FireRed does not carry it", who("src.world.FieldDefaults"), + tostring(key)) + return nil + end + + function FieldDefaults.world(_data, key) + local value = WORLD_CONSTANTS[key] + if value ~= nil then return value end + warnOnce("fieldDefaults.world." .. tostring(key), + "[%s] FieldDefaults.world(%s): FireRed's step events do not read a " + .. "shared constant table", who("src.world.FieldDefaults"), tostring(key)) + return nil + end + + FieldDefaults.seed = unbacked("src.world.FieldDefaults", "seed", + "seeding Kanto Red's FIELD into a FireRed dataset would put Gen 1 map ids " + .. "into data.field") + + return FieldDefaults +end + +COVERAGE["src.world.FieldDefaults"] = { + kind = "facade", + backed = "CONSTANTS CONSTANTS.world constant world", + warned = "field fieldValue seed", + absent = "FIELD CONSTANTS.encounterBuckets CONSTANTS.hmBadges", + notes = { + ["CONSTANTS.world"] = "stepFrames 16, turnFrames 4, runStepFrames 8, " + .. "bikeStepFrames 4, the frame counts src/core/game3/player.lua uses", + }, +} + +-- ------- src.pokemon.Boxes + +local function sessionOf(save) + if save == nil or save == SAVE_VIEW then return session() end + if type(save) == "table" and save.storage ~= nil then return save end + return session() +end + +local function buildBoxes() + local Storage = g3("storage") + local Boxes = { + COUNT = Storage and Storage.TOTAL_BOXES_COUNT or 14, + CAPACITY = Storage and Storage.IN_BOX_COUNT or 30, + } + + function Boxes.ensure(save) + local s = sessionOf(save) + if not (s and Storage) then return {} end + local storage = Storage.ensure(s) + local out = {} + for i = 1, Boxes.COUNT do + out[i] = storage.boxes[i] and storage.boxes[i].mons or {} + end + return out + end + + function Boxes.active(save) + local s = sessionOf(save) + if not (s and Storage) then return nil end + local storage = Storage.ensure(s) + local box = storage.boxes[storage.currentBox or 1] + return box and box.mons or nil + end + + function Boxes.deposit(save, mon) + local s = sessionOf(save) + if not (s and Storage and mon) then return nil end + local ok, boxId = Storage.depositCaught(s, mon) + if not ok then return nil end + return boxId + end + + function Boxes.count(save, index) + local s = sessionOf(save) + if not (s and Storage) then return 0 end + local storage = Storage.ensure(s) + return Storage.countBoxMons(storage, index or storage.currentBox or 1) + end + + function Boxes.isFull(save) + local s = sessionOf(save) + if not (s and Storage) then return true end + return Storage.findOpenSlot(Storage.ensure(s)) == nil + end + + function Boxes.setCurrent(save, index) + local s = sessionOf(save) + if not (s and Storage) then return nil end + local storage = Storage.ensure(s) + storage.currentBox = math.max(1, math.min(Boxes.COUNT, tonumber(index) or 1)) + return storage.currentBox + end + + return Boxes +end + +COVERAGE["src.pokemon.Boxes"] = { + kind = "facade", target = "src.core.game3.storage", + backed = "COUNT CAPACITY ensure active deposit count isFull setCurrent", + warned = "", + absent = "withdraw release move name rename box", + notes = { + COUNT = "14 boxes on FireRed", + CAPACITY = "30 slots per box on FireRed", + ensure = "each box is session.storage.boxes[i].mons, a SPARSE 30-slot " + .. "table: iterate 1..CAPACITY and skip nil, never trust #box", + deposit = "Storage.depositCaught: first open slot from the current box, " + .. "and depositing heals the mon as the Gen 3 PC does", + }, +} + +-- ------- src.world.OverworldController + +local overworld + +local function defaultUpdate() end +local function defaultInteract() + local Field = package.loaded["src.core.game3.field"] + if Field and Field.interact then return Field.interact(live()) end + return false +end +local function defaultTalkTo() return false end + +local OW = "src.world.OverworldController" + +local function objectsList() + local Objects = package.loaded["src.core.game3.objects"] + local out = {} + if not (Objects and Objects._order and Objects._byId) then return out end + for _, lid in ipairs(Objects._order) do + local eo = Objects._byId[lid] + if eo and eo.visible and not eo.hidden then out[#out + 1] = eo end + end + return out +end + +local function playerModule() + return package.loaded["src.core.game3.player"] +end + +local function localIdOf(entity) + local P = playerModule() + local Objects = package.loaded["src.core.game3.objects"] + if entity == nil then return nil end + if entity == P then return Objects and Objects.PLAYER_LOCAL_ID or 0xFF end + if type(entity) == "table" and entity.localId then return entity.localId end + return nil +end + +function Gen3Compat.worldBusy() + local Field = package.loaded["src.core.game3.field"] + if not (Field and Field.running) then return true, "no overworld" end + if Field.locked then return true, "world is busy" end + local R = package.loaded["src.core.game3.runtime"] + if R and R.uiBusy then + local ok, busy = pcall(R.uiBusy) + if ok and busy then return true, "world is busy" end + end + local S = space() + if S and S.vm and S.vm.isRunning and S.vm:isRunning() then + return true, "a script is running" + end + local B = package.loaded["src.core.game3.battle.init"] + or package.loaded["src.core.game3.battle"] + if B and B.isActive and B.isActive() then return true, "a battle is running" end + local W = package.loaded["src.core.game3.warp"] + if W and W.isBusy and W.isBusy() then return true, "the world is mid-warp" end + local P = playerModule() + if P and P.moving then return true, "world is busy" end + return false +end + +local function buildOverworld() + local ow = { + update = defaultUpdate, + interact = defaultInteract, + talkTo = defaultTalkTo, + } + + local function ready(member) + if inField() then return true end + warnOnce(OW .. "." .. member .. ".nofield", + "[%s] %s.%s: no FireRed field is up yet; the honest replacement is the " + .. "game.ready / map.entered event", who(OW), OW, member) + return false + end + + function ow.npcAtCell(cx, cy) + if not ready("npcAtCell") then return nil end + local Objects = package.loaded["src.core.game3.objects"] + return Objects and Objects.at(cx, cy) or nil + end + + function ow.npcByIndex(index) + if not ready("npcByIndex") then return nil end + local Objects = package.loaded["src.core.game3.objects"] + if type(index) ~= "number" or not Objects then return nil end + local eo = Objects._byId and Objects._byId[index] + return eo + end + + function ow.setMap(mapId, x, y, facing, _opts) + if not ready("setMap") then return nil end + local raw = Gen3Compat.gen3MapId(mapId) + if not raw then return nil, "unknown map: " .. tostring(mapId) end + local M = g3("map") + return M and M.load(nil, live(), raw, { x = x, y = y, + facing = facing or "down", depth1Connections = true }) or nil + end + + function ow.reloadMap(mapId, _reason) + if not ready("reloadMap") then return nil end + local M = g3("map") + local P = playerModule() + if not (M and M.current and P) then return nil end + local raw = mapId and Gen3Compat.gen3MapId(mapId) or M.current + if raw ~= M.current then return true end + M.load(nil, live(), raw, { x = P.cellX, y = P.cellY, facing = P.facing, + depth1Connections = true }) + Runtime.emit("map.reloaded", { mapId = raw, reason = "invalidate" }) + return true + end + + function ow.startWarpTo(mapId, x, y, facing, onDone, opts) + if not ready("startWarpTo") then return nil end + local raw = Gen3Compat.gen3MapId(mapId) + if not raw then return nil, "unknown map: " .. tostring(mapId) end + if onDone then + warnOnce("ow.startWarpTo.onDone", + "[%s] OverworldState.startWarpTo drops onDone on FireRed " + .. "(src/core/game3/warp.lua Warp.request has no completion callback)", + who(OW)) + end + local W = g3("warp") + local P = playerModule() + return W and W.request(nil, live(), raw, x, y, + facing or (P and P.facing) or "down", + { teleport = opts and opts.arrive == "teleport" or nil }) or nil + end + + function ow.canCollisionWarp() + local W = package.loaded["src.core.game3.warp"] + return not (W and W.isBusy and W.isBusy()) + end + + function ow.healPoint() + local s = session() + if not s then return nil end + return { map = Gen3Compat.gen1MapId(s.healMap), gen3Map = s.healMap, + x = s.healX, y = s.healY } + end + + function ow.warpToHealPoint(onDone) + if not ready("warpToHealPoint") then return nil end + local Field = g3("field") + if not Field then return nil end + Field.respawnAtHeal() + if onDone then onDone() end + return true + end + + function ow.stepForwardOrCrossEdge(dir) + if not ready("stepForwardOrCrossEdge") then return nil end + local P = playerModule() + return P and P.tryMove(dir, live(), false) or nil + end + + function ow.checkLedgeHop(dir) + local C = collision() + local P = playerModule() + if not (C and P and inField()) then return false end + return C.ledgeLanding(live(), P.cellX, P.cellY, dir) ~= nil + end + + function ow.checkEdgeExit(dir) + local C = collision() + local P = playerModule() + if not (C and P and inField()) then return false end + return C.tryConnection(live(), P.cellX, P.cellY, dir, false) and true or false + end + ow.crossConnection = function(dir) return ow.checkEdgeExit(dir) end + + function ow.scriptMove(entity, dir, tiles, onDone, _opts) + if not ready("scriptMove") then return nil, "no overworld" end + local Objects = package.loaded["src.core.game3.objects"] + if not Objects then return nil, "no overworld" end + if not DELTA[dir] then return nil, "unknown direction: " .. tostring(dir) end + local lid = localIdOf(entity) + if not lid then + return nil, "no FireRed localId for that entity: only the player and a " + .. "live map object can be moved" + end + local actions = {} + for _ = 1, math.max(0, tiles or 1) do + actions[#actions + 1] = { kind = "step", dir = dir } + end + Objects.startTrack(lid, actions, onDone) + return true + end + + function ow.timeOfDay() + warnOnce("ow.timeOfDay", + "[%s] OverworldState.timeOfDay: FireRed has no clock and no time of " + .. "day", who(OW)) + return nil + end + + function ow.replaceBlock(bx, by, block) + if not ready("replaceBlock") then return nil end + local Field = g3("field") + if not Field then return nil end + warnOnce("ow.replaceBlock", + "[%s] OverworldState.replaceBlock on FireRed sets one 16px METATILE at " + .. "(x, y) in metatile coordinates; FireRed has no 32px blocks", who(OW)) + Field.setMetatile(bx, by, block) + local M = package.loaded["src.core.game3.map"] + Runtime.emit("world.block_replaced", + { mapId = M and M.current, bx = bx, by = by, block = block }) + return true + end + + function ow.partyKnows(moveId) + local s = session() + local P = g3("pokemon") + local num = Gen3Compat.moveId(moveId) + if not (s and P and num) then return false end + for _, mon in ipairs(s.party or {}) do + if P.knowsMove(mon, num) then return true end + end + return false + end + + function ow.openPC(onDone) + if not ready("openPC") then return nil end + local Hud = ui3("hud") + if not Hud then return nil end + if onDone then + warnOnce("ow.openPC.onDone", + "[%s] OverworldState.openPC drops onDone on FireRed: the PC closes " + .. "itself with no callback", who(OW)) + end + Hud.openPc(live(), session()) + return true + end + + function ow.nurseHeal(onDone) + local s = session() + local Party = g3("party") + if not (s and Party) then return nil end + Party.healAll(s.party) + if onDone then onDone() end + return true + end + + function ow.showMapText(key, _npc, onDone) + if not ready("showMapText") then return nil end + local S = space() + local g = live() + local body = (S and S.bundle and S.bundle.text and S.bundle.text[key]) + or (g and g.data and g.data.gen3Text and g.data.gen3Text[key]) + if body == nil then + warnOnce("ow.showMapText." .. tostring(key), + "[%s] OverworldState.showMapText(%s): FireRed text keys are the " + .. "extracted script labels; a Gen 1 TEXT_* constant resolves to " + .. "nothing", who(OW), tostring(key)) + return nil + end + local Message = ui3("message") + if not Message then return nil end + Message.show(body, function() + Message.close() + if onDone then onDone() end + end) + return true + end + + local queueApi + function ow.queueScript(rows, extra) + local g = live() + if not g then return nil, "no overworld" end + if not queueApi or queueApi.game ~= g then + queueApi = rawRequire("src.world.game3.WorldAPI").new(g, OW) + end + return queueApi:queueScript(rows, extra) + end + + local SEAMS = { update = true, interact = true, talkTo = true } + for key, fn in pairs(ow) do + if type(fn) == "function" and not SEAMS[key] then + ow[key] = function(first, ...) + if first == overworld then return fn(...) end + return fn(first, ...) + end + end + end + + local LIVE_FIELD = { map = true, player = true, npcs = true, entities = true } + + setmetatable(ow, { + __index = function(_, key) + if not LIVE_FIELD[key] then + if key == "isOverworld" then return true end + return nil + end + if not inField() then return nil end + if key == "map" then + local M = package.loaded["src.core.game3.map"] + return mapView(M and M.current or (session() and session().map)) + end + if key == "player" then return playerModule() end + local list = objectsList() + if key == "entities" then + local P = playerModule() + if P then list[#list + 1] = P end + end + return list + end, + __newindex = function(t, key, value) + if LIVE_FIELD[key] then + warnOnce("ow.write." .. key, + "[%s] OverworldState.%s is owned by src/core/game3 and cannot be " + .. "replaced; move the player with warpTo", who(OW), key) + return + end + rawset(t, key, value) + end, + }) + + overworld = ow + return ow +end + +function Gen3Compat.worldTick(dt) + if not overworld or overworld.update == defaultUpdate then return end + overworld.update(overworld, dt) +end + +function Gen3Compat.interactWrapper() + if not overworld or overworld.interact == defaultInteract then return nil end + return overworld.interact +end + +function Gen3Compat.talkToWrapper() + if not overworld or overworld.talkTo == defaultTalkTo then return nil end + return overworld.talkTo +end + +COVERAGE[OW] = { + kind = "facade", target = "src.core.game3.field", + backed = "map player npcs entities isOverworld npcAtCell npcByIndex setMap " + .. "reloadMap startWarpTo canCollisionWarp healPoint warpToHealPoint " + .. "stepForwardOrCrossEdge checkLedgeHop checkEdgeExit crossConnection " + .. "scriptMove partyKnows openPC nurseHeal showMapText queueScript " + .. "update interact talkTo", + warned = "timeOfDay replaceBlock startWarpTo openPC", + absent = "pushableAtCell pooledNPC computeNeighbors rebuildNeighbors " + .. "takeWarp refreshStandingOnWarp dirHeld handleInput connectionLanding " + .. "checkBoulderPush checkForcedMovement updateScriptMoves setDark " + .. "bikeAllowed addRuntimeObject removeRuntimeObject tryHiddenObject " + .. "hasHiddenItemLeft facingIsShoreOrWater facingIsLandDismount trySurf " + .. "tryCut goFishing flyTo trainerDefeated engageTrainer " + .. "checkTrainerSight startTrainerApproach applyFieldPoison neighbors " + .. "ghosts npcPool camera runner tod dark draw", + notes = { + map = "a view with the Gen 1 id (FR_ stripped) plus gen3Id; the cell " + .. "queries answer against the active collision grid", + player = "src/core/game3/player.lua itself: cellX cellY px py facing " + .. "moving surfing biking carry, and writes land on the live avatar", + npcs = "a fresh array per read of the visible FireRed event objects", + update = "a replacement runs each field tick via Gen3Compat.worldTick", + interact = "a replacement owns the A press; the original is Field.interact", + talkTo = "a replacement returning true consumes a scripted NPC talk", + startWarpTo = "Warp.request with the FireRed fade; onDone is dropped", + replaceBlock = "sets one 16px metatile (Field.setMetatile), not a 32px " + .. "Gen 1 block", + npcByIndex = "keyed by the FireRed localId the map's object template " + .. "carries", + timeOfDay = "FireRed has no clock", + flyTo = "the FireRed fly map is src/ui/game3/region_map.lua; no seam", + }, +} + +-- ------- src.world.PikachuFollower + +local function buildFollower() + local F = {} + local why = "FireRed has no walking follower" + F.setShouldSpawn = unbacked("src.world.PikachuFollower", "setShouldSpawn", why) + F.onMapEntered = unbacked("src.world.PikachuFollower", "onMapEntered", why) + F.update = unbacked("src.world.PikachuFollower", "update", why) + F.talk = function() return false end + F.current = function() return nil end + F.starterInParty = function() return false end + F.setVisible = unbacked("src.world.PikachuFollower", "setVisible", why) + return F +end + +COVERAGE["src.world.PikachuFollower"] = { + kind = "facade", + backed = "current starterInParty talk", + warned = "setShouldSpawn onMapEntered update setVisible", + absent = "shouldSpawn rebase at SPRITE onStep bumpHappiness modifyHappiness " + .. "picLift hopToCounter updateHop", + notes = { + current = "always nil: FireRed has no follower", + talk = "returns false and never calls done", + }, +} + +-- ------- UI facades + +local PATCHED_NIL = {} + +local function passThroughProxy(target, overrides, absent, moduleName) + local patched = {} + return setmetatable({}, { + __index = function(_, key) + local mine = patched[key] + if mine == PATCHED_NIL then return nil end + if mine ~= nil then return mine end + local made = overrides[key] + if made ~= nil then return made end + local why = absent[key] + if why then + warnOnce(moduleName .. "." .. key, + "[%s] %s.%s has no Gen 3 backing: %s", + who(moduleName), moduleName, key, why) + return nil + end + return target[key] + end, + __newindex = function(_, key, value) + patched[key] = (value == nil) and PATCHED_NIL or value + target[key] = value + end, + }) +end + +local UI_ABSENT = "FireRed's screens are module singletons under src/ui/game3 " + .. "drawn at 240x160; there is no Gen 1 state object to reach into" + +local function buildPartyMenu() + local PM = ui3("party_menu") or {} + local overrides, absent = {}, {} + local showOrig, closeOrig = PM.show, PM.close + for _, name in ipairs({ "drawIcon", "frameFor", "mirrorsIcon", "iconFrames", + "sgbPalettes", "animateTo", "entryY" }) do + absent[name] = UI_ABSENT + end + + function overrides.new(_game, opts) + opts = opts or {} + local s = session() + if not (s and showOrig) then return nil end + local handle = {} + function handle.close() if closeOrig then closeOrig() end end + handle.party = opts.party or s.party + showOrig(handle.party, s.move_overlay, { + session = s, + mode = "list", + onSelect = opts.onSwitch and function(slot) + local mon = slot and handle.party[slot] + if mon then opts.onSwitch(mon, handle) end + end or nil, + onClose = opts.onCancel, + }) + return handle + end + + function overrides.close() if closeOrig then return closeOrig() end end + + return passThroughProxy(PM, overrides, absent, "src.ui.PartyMenu") +end + +COVERAGE["src.ui.PartyMenu"] = { + kind = "facade", target = "src.ui.game3.party_menu", + backed = "new close show isOpen update draw handleInput", + warned = "", + absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo " + .. "entryY bottomMessage index submenu", + notes = { + new = "opens the FireRed party screen over session.party and returns a " + .. "handle { party, close }, not a state object; onSwitch(mon, handle) " + .. "and onCancel are honoured, the rest of opts is not", + }, +} + +local function buildStartMenu() + local SM = ui3("start_menu") or {} + local overrides = {} + function overrides.new(_game) + local Hud = ui3("hud") + if not (Hud and inField()) then return nil end + if not (SM.isOpen and SM.isOpen()) then Hud.openStartMenu(live(), session()) end + return SM + end + return passThroughProxy(SM, overrides, {}, "src.ui.StartMenu") +end + +COVERAGE["src.ui.StartMenu"] = { + kind = "facade", target = "src.ui.game3.start_menu", + backed = "new show close isOpen draw", + warned = "", + absent = "items ITEMS lastIndex", + notes = { + new = "Hud.openStartMenu, which closes any other field menu first; " + .. "returns the FireRed start menu module", + ["hook ui.start_menu.items"] = "not raised on FireRed yet", + }, +} + +local function buildOptionsMenu() + local OM = ui3("option_menu") or {} + local overrides = {} + local showOrig = OM.show + function overrides.new(_game, opts) + opts = opts or {} + if not showOrig then return nil end + showOrig({ session = session(), game = live(), onClose = opts.onCancel }) + return OM + end + return passThroughProxy(OM, overrides, { sgbPalettes = UI_ABSENT }, + "src.ui.OptionsMenu") +end + +COVERAGE["src.ui.OptionsMenu"] = { + kind = "facade", target = "src.ui.game3.option_menu", + backed = "new show close isOpen draw handleInput", + warned = "", + absent = "sgbPalettes rows index scroll", + notes = { + new = "OptionMenu.show with opts.onCancel as onClose", + }, +} + +local function buildBoxMenu() + local PC = ui3("pc_menu") or {} + local overrides = {} + function overrides.new(_game) + local Hud = ui3("hud") + if not (Hud and inField()) then return nil end + Hud.openPc(live(), session()) + return PC + end + return passThroughProxy(PC, overrides, {}, "src.ui.BoxMenu") +end + +COVERAGE["src.ui.BoxMenu"] = { + kind = "facade", target = "src.ui.game3.pc_menu", + backed = "new show close isOpen draw handleInput", + warned = "", + absent = "items th tx tw scroll clampScroll", + notes = { + new = "Hud.openPc: Bill's / the player's PC top menu, the Gen 1 BoxMenu's " + .. "counterpart", + }, +} + +-- ------- src.battle.BattleState + +local function battleModule() + return package.loaded["src.core.game3.battle.init"] + or package.loaded["src.core.game3.battle"] +end + +local function buildBattleState() + local B = {} + local MOVED = "FireRed's battle engine is src/core/game3/battle, over pret's " + .. "battle_main.c; no Gen 1 BattleState object exists" + local absent = {} + for _, name in ipairs({ + "newWild", "newTrainer", "makeSafari", "makeGhost", "makeBattler", + "resolveTurn", "executeAction", "performMove", "computeDamage", + "catchAttempt", "runRoll", "enter", "exit", "sgbPalettes", "awardExp", + "throwBall", "storeCaughtMon", "tryRun", + }) do + absent[name] = MOVED + end + + function B.isActive() + local M = battleModule() + return M and M.isActive and M.isActive() or false + end + + function B.current() + local M = battleModule() + return M and M.isActive and M.isActive() and M.getState and M.getState() or nil + end + + function B.say(_self, text) + local Ui = package.loaded["src.core.game3.battle.ui"] + if not (Ui and Ui.push and B.isActive()) then return nil end + Ui.push(tostring(text or "")) + return true + end + B.sayAuto = B.say + + local LIVE = { player = true, enemy = true, turnCount = true, phase = true, + result = true, kind = true, wild = true } + + return setmetatable(B, { + __index = function(_, key) + if LIVE[key] then + local st = B.current() + if not st then return nil end + if key == "turnCount" then return st.turn end + if key == "phase" then + local M = battleModule() + return M and M._phase + end + return st[key] + end + local why = absent[key] + if why then + warnOnce("battle." .. key, "[%s] BattleState.%s has no Gen 3 backing: %s", + who("src.battle.BattleState"), key, why) + end + return nil + end, + }) +end + +COVERAGE["src.battle.BattleState"] = { + kind = "facade", target = "src.core.game3.battle", + backed = "isActive current say sayAuto player enemy turnCount phase result " + .. "kind wild", + warned = "", + absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " + .. "executeAction performMove computeDamage catchAttempt runRoll enter " + .. "exit sgbPalettes awardExp throwBall storeCaughtMon tryRun update draw", + notes = { + player = "the live FireRed battler { mon, side, species, type1, type2, " + .. "ability, stages, status }; mon carries NUMERIC species and moves", + newWild = "ABSENT: start a wild battle with mod.world:startWildBattle, or " + .. "rewrite the species through the encounter.species hook", + say = "queues onto the FireRed battle textbox (battle/ui.lua Ui.push)", + events = "battle.* events and hooks fire from src/core/game3/battle for every " + .. "battler, with battlerId 0-3 in doubles; species, moves and items are " + .. "NAME strings with the FireRed number alongside (speciesId, moveNum, ballId)", + }, +} + +-- ------- src.script.ScriptRunner + +local SR = "src.script.ScriptRunner" + +local function buildScriptRunner() + local okGen1, Gen1 = pcall(rawRequire, "src.script.ScriptRunner") + local adapter = {} + adapter.scanLabels = okGen1 and Gen1.scanLabels or nil + + function adapter.validate(script, lookup) + if not (okGen1 and Gen1.validate) then return nil end + return Gen1.validate(script, lookup or function(verb) + local g = live() + local commands = g and g.data and g.data.commands + return commands ~= nil and commands[verb] ~= nil + end) + end + + local Handle = {} + Handle.__index = function(self, key) + if key == "game" then return live() end + local S = space() + local vm = S and S.vm + if key == "vm" then return vm end + if key == "ctx" then return vm and vm.ctx end + return rawget(Handle, key) + end + + function Handle:isRunning() + local S = space() + return S and S.vm and S.vm:isRunning() or false + end + + function Handle:run(script, extra) + if extra ~= nil then + warnOnce("runner.run.extra", + "[%s] runner:run's `extra` is unserved on FireRed", who(SR)) + end + local S = space() + if not (S and S.vm) then return false end + if type(script) ~= "string" then + warnOnce("runner.run.rows", + "[%s] runner:run on FireRed takes an extracted script KEY (g3:...); a " + .. "Gen 1 row list runs through mod.world:queueScript", who(SR)) + return false + end + if S.vm:isRunning() then return false end + return S.startScript(script) and true or false + end + + Handle.resume = unbacked(SR, "runner:resume", + "the FireRed VM is ticked by src/core/game3/field.lua; a second drive " + .. "double-steps it") + Handle.update = unbacked(SR, "runner:update", + "the FireRed VM is ticked by src/core/game3/field.lua every frame") + Handle.exec = unbacked(SR, "runner:exec", + "the script.command hook is the supported patch point") + Handle.yield = unbacked(SR, "runner:yield", + "FireRed scripts are pret bytecode, not coroutines") + Handle.makeContext = unbacked(SR, "runner:makeContext", + "Gen3Compat.scriptCtx(vm) builds the Gen 1-shaped ctx events carry") + + function adapter.new(_game, _overworld) + return setmetatable({}, Handle) + end + + return adapter +end + +COVERAGE[SR] = { + kind = "facade", target = "src.core.game3.scripting.vm", + backed = "scanLabels validate new isRunning run game vm ctx", + warned = "resume update exec yield makeContext", + absent = "co waitingFrames parallel waitingCheck overworld __index", + notes = { + run = "takes an extracted FireRed script key and starts it on the one " + .. "Space VM; returns false while another script runs", + new = "a thin handle onto src/core/game3/scripting/space.lua's VM, which " + .. "is replaced on every map change, so the handle resolves it per call", + }, +} + +-- ------- src.world.WorldAPI + +COVERAGE["src.world.WorldAPI"] = { + kind = "alias", target = "src.world.game3.WorldAPI", + backed = "new __index overworld current activeBlockAt canReorderParty " + .. "reorderParty availableFieldActions useFieldAction canFly flyTo " + .. "mapOverview warpTo toggleObject setFlag getFlag effectiveEncounters " + .. "replaceBlock npc queueScript startWildBattle invalidateMap", + warned = "spawnNpc removeNpc", + absent = "", + notes = { + current = "mapId is the raw FR_ id; warpTo accepts either spelling", + setFlag = "a FireRed flag NAME (with or without FLAG_) or number", + replaceBlock = "one 16px metatile, not a 32px block", + queueScript = "the five Gen 2 verbs (start_battle wild, warp, text, " + .. "setflag, clearflag); anything else is refused by name up front", + }, +} + +-- ------- sprites + +local spriteOverrides = { front = {}, back = {} } +local imageCache = {} +local wrappedModules = setmetatable({}, { __mode = "k" }) +local reloadRegistered = setmetatable({}, { __mode = "k" }) + +local function isVanillaPic(path) + return type(path) ~= "string" or path == "" or path:find("%.rgba$") ~= nil +end + +local function readImageData(path) + if not (love and love.image and love.image.newImageData) then return nil end + local okA, Assets = pcall(rawRequire, "src.render.Assets") + if okA and Assets and Assets.imageData then + local ok, data = pcall(Assets.imageData, path) + if ok and data then return data end + end + local ok, data = pcall(love.image.newImageData, path) + if ok and data then return data end + return nil +end + +local function centredEntry(path) + local hit = imageCache[path] + if hit ~= nil then return hit or nil end + local data = readImageData(path) + if not (data and love.graphics and love.graphics.newImage) then + warnOnce("sprite." .. tostring(path), + "[gen3] sprite override %s could not be loaded", tostring(path)) + imageCache[path] = false + return nil + end + local w, h = data:getDimensions() + local out = data + if w ~= 64 or h ~= 64 then + out = love.image.newImageData(64, 64) + local cw, ch = math.min(w, 64), math.min(h, 64) + local sx = math.floor((w - cw) / 2) + local sy = math.floor((h - ch) / 2) + local dx = math.floor((64 - cw) / 2) + local dy = math.floor((64 - ch) / 2) + out:paste(data, dx, dy, sx, sy, cw, ch) + end + local image = love.graphics.newImage(out) + if image.setFilter then image:setFilter("nearest", "nearest") end + hit = { image = image, w = 64, h = 64, path = path } + imageCache[path] = hit + return hit +end + +Gen3Compat.centredSprite = centredEntry + +local function samePath(path) return path end + +local function hookedEntry(side, species, form, vanilla) + if not Runtime.wantsHook("pokemon.sprite") then return vanilla end + local P = g3("pokemon") + local path = spriteOverrides[side][species] + or ("data/generated/gba/pokemon/" .. side .. "/" .. species .. ".rgba") + local g = live() + local ctx = { data = g and dataProxy(g.data), species = Gen3Compat.speciesName(species), + gen3Species = species, form = form, side = side, kind = "battle", + trueColor = true, path = path } + local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx) + if type(hooked) ~= "string" or hooked == path or isVanillaPic(hooked) then + return vanilla + end + return centredEntry(hooked) or vanilla +end + +local function wrapPics(P) + if not P or wrappedModules[P] then return end + wrappedModules[P] = true + local frontOrig, backOrig = P.frontPic, P.backPic + if frontOrig then + P.frontPic = function(species, form) + local sp = tonumber(species) + local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.front[sp] + local entry = path and centredEntry(path) + if not entry then entry = frontOrig(species, form) end + if sp then return hookedEntry("front", sp, form, entry) end + return entry + end + P.frontSprite = P.frontPic + end + if backOrig then + P.backPic = function(species, form) + local sp = tonumber(species) + local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.back[sp] + local entry = path and centredEntry(path) + if not entry then entry = backOrig(species, form) end + if sp then return hookedEntry("back", sp, form, entry) end + return entry + end + end +end + +local function seed(P) + if not P then return end + P._front = P._front or {} + P._back = P._back or {} + for sp, path in pairs(spriteOverrides.front) do + local entry = centredEntry(path) + if entry then P._front[sp] = entry end + end + for sp, path in pairs(spriteOverrides.back) do + local entry = centredEntry(path) + if entry then P._back[sp] = entry end + end +end + +function Gen3Compat.reseedSprites() + local P = package.loaded["src.core.game3.pokemon"] + if P then + wrapPics(P) + seed(P) + end +end + +local function collectOverrides(game) + spriteOverrides = { front = {}, back = {} } + local content = game and game.mods and game.mods.content + local reg = content and content.pokemon + if not (reg and reg.ops) then return end + for id in pairs(reg.ops) do + local ok, rec = pcall(reg.get, reg, id) + if ok and type(rec) == "table" then + local sp = Gen3Compat.speciesId(id) + if not sp and type(rec.gen3Species) == "number" then sp = rec.gen3Species end + if sp then + if not isVanillaPic(rec.spriteFront) then + spriteOverrides.front[sp] = rec.spriteFront64 or rec.spriteFront + end + if not isVanillaPic(rec.spriteBack) then + spriteOverrides.back[sp] = rec.spriteBack64 or rec.spriteBack + end + end + end + end +end + +function Gen3Compat.spriteOverrides() + local out = { front = {}, back = {} } + for side, map in pairs(spriteOverrides) do + for sp, path in pairs(map) do out[side][sp] = path end + end + return out +end + +local function reapplyMoves(M) + local g = live() + local content = g and g.mods and g.mods.content + local reg = content and content.moves + if not (reg and reg.ops and next(reg.ops) ~= nil) then return end + local spec = reg.spec + if spec and type(spec.write) == "function" then + local ok, err = pcall(spec.write, M, reg) + if not ok then + warnOnce("moves.reapply", "[gen3] mod move data not re-applied after reload: %s", + tostring(err)) + end + end + for key in pairs(recordCache) do recordCache[key] = nil end +end + +function Gen3Compat.applyMerged(game) + if game then lastGame = game end + imageCache = {} + for key in pairs(recordCache) do recordCache[key] = nil end + local okM, Moves = pcall(rawRequire, "src.core.game3.battle.moves") + if okM and type(Moves) == "table" and type(Moves.onReload) == "function" + and not reloadRegistered[Moves] then + reloadRegistered[Moves] = true + Moves.onReload(reapplyMoves, "gen3compat") + end + collectOverrides(game or live()) + local okP, P = pcall(rawRequire, "src.core.game3.pokemon") + if not (okP and P) then return end + wrapPics(P) + seed(P) + if type(P.onReload) == "function" and not reloadRegistered[P] then + reloadRegistered[P] = true + P.onReload(function() + for key in pairs(recordCache) do recordCache[key] = nil end + wrapPics(P) + seed(P) + end, "gen3compat") + end +end + +-- ------- script ctx + +function Gen3Compat.scriptCtx(vm) + local S = space() + return { + game = live(), + save = SAVE_VIEW, + overworld = inField() and Gen3Compat.resolve(OW) or nil, + runner = vm or (S and S.vm), + vm = vm or (S and S.vm), + generation = 3, + } +end + +-- ------- the table + +local ADAPTERS = { + ["src.core.Game"] = buildGame, + ["src.world.NPC"] = buildNpc, + ["src.world.Collision"] = buildCollision, + ["src.world.FieldDefaults"] = buildFieldDefaults, + ["src.pokemon.Boxes"] = buildBoxes, + ["src.world.OverworldController"] = buildOverworld, + ["src.ui.PartyMenu"] = buildPartyMenu, + ["src.ui.StartMenu"] = buildStartMenu, + ["src.ui.OptionsMenu"] = buildOptionsMenu, + ["src.battle.BattleState"] = buildBattleState, + ["src.script.ScriptRunner"] = buildScriptRunner, + ["src.world.PikachuFollower"] = buildFollower, + ["src.world.Map"] = buildMap, + ["src.world.WorldAPI"] = "src.world.game3.WorldAPI", + ["src.ui.BoxMenu"] = buildBoxMenu, +} + +Gen3Compat.ADAPTERS = ADAPTERS + +function Gen3Compat.bind(fn) + resolveGame = fn +end + +function Gen3Compat.serves(name) + return ADAPTERS[name] ~= nil +end + +function Gen3Compat.modules() + local out = {} + for name in pairs(ADAPTERS) do out[#out + 1] = name end + table.sort(out) + return out +end + +function Gen3Compat.coverage(name) + local row = COVERAGE[name] + if not row then return nil end + local members = {} + for _, status in ipairs({ "backed", "absent", "warned" }) do + for _, member in ipairs(words(row[status])) do + members[member] = status + end + end + local notes = {} + for key, value in pairs(row.notes or {}) do notes[key] = value end + return { module = name, kind = row.kind, target = row.target, + members = members, notes = notes } +end + +function Gen3Compat.memberStatus(name, member) + local row = Gen3Compat.coverage(name) + return row and row.members[member] or nil +end + +function Gen3Compat.resolve(name, modId) + local spec = ADAPTERS[name] + if not spec then return nil end + local module = built[name] + if not module then + module = type(spec) == "string" and rawRequire(spec) or spec() + built[name] = module + end + if modId then + local ids = claimants[name] + if not ids then ids = {} claimants[name] = ids end + local seen = false + for _, id in ipairs(ids) do if id == modId then seen = true break end end + if not seen then ids[#ids + 1] = modId end + end + return module +end + +return Gen3Compat diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index eff0c25f..74b65802 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -21,6 +21,7 @@ local Schemas = require("src.mods.Schemas") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Gen2Compat = require("src.mods.Gen2Compat") +local Gen3Compat = require("src.mods.Gen3Compat") local Hooks = require("src.mods.Hooks") local LegacyCompat = require("src.mods.LegacyCompat") local Runtime = require("src.mods.Runtime") @@ -121,17 +122,34 @@ local GEN1_ONLY_MODULES = { ["src.ui.OptionsMenu"] = true, } -local function crossGenerationDenial(name, generation) - if type(name) ~= "string" or generation ~= 1 then return nil end - if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then - return nil +local function moduleGeneration(name) + if name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2" then + return 2 end - return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs " + if name:find("^src%.[%w_]+%.game3%.") or name == "src.core.Game3" then + return 3 + end + return nil +end + +local function crossGenerationDenial(name, generation) + if type(name) ~= "string" then return nil end + local owner = moduleGeneration(name) + if owner == nil or owner == generation then return nil end + if owner == 2 and generation ~= 1 and generation ~= 3 then return nil end + if owner == 3 and generation ~= 1 and generation ~= 2 then return nil end + return ("%s is a Gen %d engine module and this is a Gen %d game; the structs " .. "it reads and writes are not this game's, so anything it stores lands " .. "on the save in the wrong shape. Take the game from mod.game and the " - .. "world from mod.world, which resolve per generation"):format(name) + .. "world from mod.world, which resolve per generation") + :format(name, owner, generation) end +local COMPAT = { + [2] = { module = Gen2Compat, file = "src/mods/Gen2Compat.lua", tag = "gen2" }, + [3] = { module = Gen3Compat, file = "src/mods/Gen3Compat.lua", tag = "gen3" }, +} + -- the src.* modules the mod surface points authors at: another mod's -- exports carry a version string that wants range-checking before use, and -- ChipAsm is the authoring path for chip music and sfx @@ -176,15 +194,17 @@ local function scanRequire(name) -- A Gen 1-only module on a Gold boot is not a permissions question, it is a -- dead patch: reported once, attributed, and onto the boot error feed the -- manager shows the player rather than a dev-only log line. + local compat = COMPAT[devShim.generation] if devShim.generation ~= 1 and GEN1_ONLY_MODULES[name] - and not Gen2Compat.serves(name) then - local key = modId .. "|gen2|" .. name + and not (compat and compat.module.serves(name)) then + local key = modId .. "|" .. (compat and compat.tag or "gen?") .. "|" .. name if not devShim.warned[key] then devShim.warned[key] = true - local message = ("%s: requires %s, which a Gen 2 game never runs and " - .. "src/mods/Gen2Compat.lua has no adapter for; take the game from " + local message = ("%s: requires %s, which a Gen %s game never runs and " + .. "%s has no adapter for; take the game from " .. "the game.ready payload and mod.world") - :format(modId, name) + :format(modId, name, tostring(devShim.generation), + compat and compat.file or "no compat layer") local errors = devShim.errors if errors then errors[#errors + 1] = message end Logger.error("%s", message) @@ -243,18 +263,19 @@ function Loader:_installDevShim() if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end - -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. + -- The Gen 1 name a mod asked for, answered by this generation's compat arm. -- Engine code keeps the real module: src/render/PaletteFX.lua:776 -- requires src.core.Game on both generations and means it. - if devShim.generation == 2 and Gen2Compat.serves(name) + local compat = COMPAT[devShim.generation] + if compat and compat.module.serves(name) and (owner or callerIsMod(3)) then - local adapter = Gen2Compat.resolve(name, Runtime.currentMod) + local adapter = compat.module.resolve(name, Runtime.currentMod) if adapter then - local key = "adapter|" .. name + local key = "adapter|" .. compat.tag .. "|" .. name if not devShim.warned[key] then devShim.warned[key] = true - Logger.info("gen2 facade: %s -> %s", name, - tostring(Gen2Compat.ADAPTERS[name])) + Logger.info("%s facade: %s -> %s", compat.tag, name, + tostring(compat.module.ADAPTERS[name])) end return adapter end @@ -1266,6 +1287,7 @@ function Loader:_api(mod) -- entry chunk can decide whether to register developer-only diagnostics -- without receiving the process environment or the loader itself. developer = loader.dev == true, + generation = loader.generation, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), datasets = { @@ -1641,7 +1663,8 @@ function Loader:_api(mod) local game = loader:_game() if key == "battle" then if battle then return battle end - local module = game and engineRequire(loader.generation == 2 + local module = game and engineRequire(loader.generation == 3 + and "src.battle.game3.BattleAPI" or loader.generation == 2 and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI") if not module then return nil end battle = module.new(game) @@ -1652,7 +1675,8 @@ function Loader:_api(mod) -- one facade name, one arm per generation: Gold's world is not a stack -- state and its flags are a bitfield, so the resolution differs even -- where the method set does not (src/world/gen2/WorldAPI.lua) - local module = game and engineRequire(loader.generation == 2 + local module = game and engineRequire(loader.generation == 3 + and "src.world.game3.WorldAPI" or loader.generation == 2 and "src.world.gen2.WorldAPI" or "src.world.WorldAPI") if not module then return nil end world = module.new(game, modId) @@ -1770,6 +1794,7 @@ end function Loader:_validateScripts() local registry = self.content.map_scripts if not registry or next(registry.ops) == nil then return end + if registry.spec.semantics ~= "compose" then return end local MapScripts = engineRequire("src.script.MapScripts") if not MapScripts then return end local commands = self.content.commands @@ -1850,6 +1875,7 @@ function Loader:load(data, opts) self.arenaCartId = mode == "cartOnly" and opts.cartId or nil self.arenaSealBroken = opts.sealBroken == true self.baseData = data + if self.generation == 3 then Schemas.bindGen3(data) end -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for name, registry in pairs(self.content) do @@ -1939,6 +1965,7 @@ function Loader:load(data, opts) -- every touch: a mod captures the facade at file scope, before Game2 has a -- save or a world (src/mods/Gen2Compat.lua). Gen2Compat.bind(function() return self:_game() end) + Gen3Compat.bind(function() return self:_game() end) -- Any boot with mods on it needs the gate, because require("io") is how a -- mod would walk out of Sandbox.envFor. Dev mode adds the permissions -- tripwire on top, and a Gold boot the Gen 1-only require report -- the diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index a5e2115e..81e23de4 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -643,12 +643,31 @@ Schemas.GEN1 = { rom_text = false, } +Schemas.GEN3 = { + pokemon = "gen3Pokemon", moves = "gen3Moves", items = "gen3Items", + encounters = "gen3Encounters", trainers = "gen3Trainers", + text = "gen3Text", map_scripts = "gen3Scripts", + tilesets = false, sprites = false, rom_text = false, + palettes = false, icons = false, battle_anims = false, constants = false, + statuses = false, move_effects = false, item_effects = false, + balls = false, ai_classes = false, evolution_methods = false, + growth_rates = false, type_chart = false, + rulesets = false, transitions = false, field = false, + text_pointers = false, link_fields = false, + battle_sprite_scales = false, render_pipelines = false, + font = false, audio = false, music = false, sfx = false, cries = false, + map_songs = false, screens = false, tokens = false, + held_items = false, phone_contacts = false, decorations = false, + apricorns = false, landmarks = false, radio_channels = false, +} + -- The routing table for a generation: which one is consulted is the only -- difference between the two directions. An unknown generation routes -- nothing, so every registry keeps its catalog target. local NO_ROUTING = {} function Schemas.routing(generation) + if generation == 3 then return Schemas.GEN3 end if generation == 2 then return Schemas.GEN2 end if generation == 1 then return Schemas.GEN1 end return NO_ROUTING @@ -701,15 +720,24 @@ end -- resolve is one table lookup after the first call. Everything downstream -- -- Schemas.check, Registry's fold and baseAt, the loader's merge and write -- -- then reads one spec and never learns about generations. -local GEN2_SHAPE = { - gen2Value = "value", gen2Fields = "fields", gen2Keys = "keys", - gen2KeyValue = "keyValue", gen2Extra = "extra", - gen2Semantics = "semantics", gen2Write = "write", - gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", - gen2ReservedIds = "reservedIds", - gen2Example = "example", gen2Notes = "notes", +local SHAPE_SLOTS = { + Value = "value", Fields = "fields", Keys = "keys", + KeyValue = "keyValue", Extra = "extra", + Semantics = "semantics", Write = "write", + BaseAt = "baseAt", BaseIds = "baseIds", + ReservedIds = "reservedIds", + Example = "example", Notes = "notes", } +local SHAPES = {} +for _, generation in ipairs({ 2, 3 }) do + local map = {} + for suffix, slot in pairs(SHAPE_SLOTS) do + map["gen" .. generation .. suffix] = slot + end + SHAPES[generation] = map +end + -- Schemas.check reads these four in a fixed order (keys/keyValue, then value, -- then fields), so a Gen 2 shape that describes its records with `keys` must -- clear the Gen 1 `value` rather than sit beside it: otherwise the first @@ -722,34 +750,49 @@ local VALUE_SLOTS = { value = true, fields = true, keys = true, keyValue = true -- registry its own table -- the two ALIASES resolve to the canonical name -- before anything reaches here, and `target` is the only name-dependent -- field a derived spec carries. -local derivedSpecs = setmetatable({}, { __mode = "k" }) +local derivedSpecs = {} +for generation in pairs(SHAPES) do + derivedSpecs[generation] = setmetatable({}, { __mode = "k" }) +end --- does this registry describe its Gen 2 records differently at all? -function Schemas.hasGen2Shape(spec) - if type(spec) ~= "table" then return false end - for source in pairs(GEN2_SHAPE) do +function Schemas.hasShape(spec, generation) + local map = SHAPES[generation] + if type(spec) ~= "table" or not map then return false end + for source in pairs(map) do if spec[source] ~= nil then return true end end return false end +-- does this registry describe its Gen 2 records differently at all? +function Schemas.hasGen2Shape(spec) + return Schemas.hasShape(spec, 2) +end + +function Schemas.hasGen3Shape(spec) + return Schemas.hasShape(spec, 3) +end + -- The spec to validate and merge `name` with under `generation`. Idempotent: -- a derived spec carries no gen2* keys, so resolving one again returns it. function Schemas.shapeFor(name, spec, generation) - if generation ~= 2 or not Schemas.hasGen2Shape(spec) then return spec end - local hit = derivedSpecs[spec] + local map = SHAPES[generation] + if not map or not Schemas.hasShape(spec, generation) then return spec end + local hit = derivedSpecs[generation][spec] if hit then return hit end local out = {} for key, value in pairs(spec) do out[key] = value end local replacesValue = false - for source, slot in pairs(GEN2_SHAPE) do + for source, slot in pairs(map) do if spec[source] ~= nil and VALUE_SLOTS[slot] then replacesValue = true end end if replacesValue then for slot in pairs(VALUE_SLOTS) do out[slot] = nil end end - for source, slot in pairs(GEN2_SHAPE) do - out[source] = nil + for _, other in pairs(SHAPES) do + for source in pairs(other) do out[source] = nil end + end + for source, slot in pairs(map) do -- `or nil` is the clear: gen2Write = false leaves the slot empty if spec[source] ~= nil then out[slot] = spec[source] or nil end end @@ -757,7 +800,7 @@ function Schemas.shapeFor(name, spec, generation) -- holding it alone never reads the Gen 1 path by accident. targetFor stays -- authoritative and stays idempotent over the result. out.target = Schemas.targetFor(name, spec, generation) - derivedSpecs[spec] = out + derivedSpecs[generation][spec] = out return out end @@ -790,6 +833,810 @@ local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds = local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds = recordMapExcept("generation", "source", "pockets") +local G3 = {} +Schemas.gen3View = G3 + +G3.TYPES = { + [0] = "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", + "GHOST", "STEEL", "MYSTERY", "FIRE", "WATER", "GRASS", "ELECTRIC", + "PSYCHIC", "ICE", "DRAGON", "DARK", +} +G3.GROWTH = { + [0] = "MEDIUM_FAST", "ERRATIC", "FLUCTUATING", "MEDIUM_SLOW", "FAST", "SLOW", +} +G3.EVOLUTIONS = { + "EVO_FRIENDSHIP", "EVO_FRIENDSHIP_DAY", "EVO_FRIENDSHIP_NIGHT", + "EVO_LEVEL", "EVO_TRADE", "EVO_TRADE_ITEM", "EVO_ITEM", + "EVO_LEVEL_ATK_GT_DEF", "EVO_LEVEL_ATK_EQ_DEF", "EVO_LEVEL_ATK_LT_DEF", + "EVO_LEVEL_SILCOON", "EVO_LEVEL_CASCOON", "EVO_LEVEL_NINJASK", + "EVO_LEVEL_SHEDINJA", "EVO_BEAUTY", +} +local EVO_LEVEL_METHODS = { [4] = true, [8] = true, [9] = true, [10] = true, + [11] = true, [12] = true, [13] = true, [14] = true } +local EVO_ITEM_METHODS = { [6] = true, [7] = true } +local PHYSICAL_TYPES = { [0] = true, [1] = true, [2] = true, [3] = true, + [4] = true, [5] = true, [6] = true, [7] = true, + [8] = true } +G3.SPRITE_ROOT = "data/generated/gba/pokemon" + +local function reverse(list) + local out = {} + for num, name in pairs(list) do out[name] = num end + return out +end +local TYPE_NUMS = reverse(G3.TYPES) +local GROWTH_NUMS = reverse(G3.GROWTH) +local EVO_NUMS = reverse(G3.EVOLUTIONS) + +function G3.idOf(name) + if type(name) ~= "string" then return nil end + local s = name:gsub("\195\169", "E"):gsub("\195\137", "E") + :gsub("\226\153\128", "_F"):gsub("\226\153\130", "_M"):gsub("'", "") + s = s:upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + if s == "" then return nil end + return s +end + +function G3.vanillaSprite(side, species) + return ("%s/%s/%d.rgba"):format(G3.SPRITE_ROOT, + side == "back" and "back" or "front", species) +end + +local function tableAt(base, ...) + if type(base) ~= "table" then return nil end + for i = 1, select("#", ...) do + local value = rawget(base, (select(i, ...))) + if value == nil then value = base[(select(i, ...))] end + if type(value) == "table" then return value end + end + return nil +end + +local bound = setmetatable({}, { __mode = "k" }) +local ROOT_KEYS = { "gen3Pokemon", "gen3Moves", "gen3Items", "gen3Encounters", + "gen3Trainers", "gen3Text", "gen3Scripts" } +local LIVE_MODULES = { + gen3Pokemon = "src.core.game3.pokemon", + gen3Moves = "src.core.game3.battle.moves", + gen3Items = "src.core.game3.items_data", + gen3Encounters = "src.core.game3.encounters", + gen3Trainers = "src.core.game3.scripting.trainers", +} + +function Schemas.bindGen3(data) + if type(data) == "table" then bound[data] = true end +end + +local function sibling(base, key) + for data in pairs(bound) do + for _, root in ipairs(ROOT_KEYS) do + if base ~= nil and rawget(data, root) == base then + local value = data[key] + if value ~= nil then return value end + end + end + end + return package.loaded[LIVE_MODULES[key] or ""] +end + +local indexCache = { species = setmetatable({}, { __mode = "k" }), + moves = setmetatable({}, { __mode = "k" }), + items = setmetatable({}, { __mode = "k" }), + abilities = setmetatable({}, { __mode = "k" }) } +local EMPTY_INDEX = { ids = {}, num = {}, id = {} } + +local function nameIndex(kind, source, field) + if type(source) ~= "table" then return EMPTY_INDEX end + local cache = indexCache[kind] + local hit = cache[source] + if hit then return hit end + local nums = {} + for key in pairs(source) do + if type(key) == "number" and key > 0 then nums[#nums + 1] = key end + end + table.sort(nums) + local out = { ids = {}, num = {}, id = {} } + for _, num in ipairs(nums) do + local entry = source[num] + local id = G3.idOf(field and type(entry) == "table" and entry[field] or entry) + if id and not out.num[id] then + out.num[id], out.id[num] = num, id + out.ids[#out.ids + 1] = id + end + end + cache[source] = out + return out +end + +local function toNum(index, id) + if type(id) == "number" then return id end + return index.num[id] or tonumber(id) +end + +local function toId(index, num) + num = tonumber(num) + if not num or num == 0 then return nil end + return index.id[num] or tostring(num) +end + +local function monTables(base) + return { + names = tableAt(base, "_names", "names"), + types = tableAt(base, "_types", "types"), + stats = tableAt(base, "_stats", "stats"), + abilities = tableAt(base, "_abilities", "abilities"), + abilityNames = tableAt(base, "_abilityNames", "abilityNames"), + meta = tableAt(base, "_speciesMeta", "speciesMeta", "meta"), + learnsets = tableAt(base, "_learnsets", "learnsets"), + evolutions = tableAt(base, "_evolutions", "evolutions"), + tmhm = tableAt(base, "_tmhm", "tmhm"), + dex = tableAt(base, "_dex", "dex"), + national = tableAt(base, "_national", "national"), + moveNames = tableAt(base, "_moveNames", "moveNames"), + battleMoves = tableAt(base, "_battleMoves", "battleMoves"), + } +end + +local function itemRows(base) + if type(base) ~= "table" then return nil end + if type(base.ensureLoaded) == "function" or type(base.info) == "function" then + if type(rawget(base, "_byId")) ~= "table" then + pcall(base.ensureLoaded or base.info, 1) + end + return tableAt(base, "_byId") + end + local byId = rawget(base, "_byId") + if type(byId) == "table" then return byId end + local items = rawget(base, "items") + if type(items) == "table" then return items end + return base +end + +local function moveRows(base) + if type(base) ~= "table" then return nil end + if type(base.romReady) == "function" then + if type(rawget(base, "_rom")) ~= "table" then pcall(base.romReady) end + return tableAt(base, "_rom") + end + local rom = rawget(base, "_rom") + if type(rom) == "table" then return rom end + local moves = rawget(base, "moves") + if type(moves) == "table" then return moves end + rom = rawget(base, "rom") + if type(rom) == "table" then + return type(rom.moves) == "table" and rom.moves or rom + end + return base +end + +local function trainerRows(base) + if type(base) ~= "table" then return nil end + if type(base.pack) == "function" then + local pack = rawget(base, "_pack") + if pack == nil then + local ok, loaded = pcall(base.pack) + pack = ok and loaded or nil + end + return type(pack) == "table" and pack.trainers or nil + end + local pack = rawget(base, "_pack") + if type(pack) == "table" then return pack.trainers end + local trainers = rawget(base, "trainers") + if type(trainers) == "table" then return trainers end + return base +end + +local function encounterRows(base) + if type(base) ~= "table" then return nil end + local tables = rawget(base, "_tables") + if type(tables) == "table" then return tables end + return base +end + +local function speciesIndex(base) + local pokemon = sibling(base, "gen3Pokemon") + return nameIndex("species", monTables(pokemon).names) +end + +local function moveNamesFor(base, moveNames) + if moveNames then return moveNames end + local own = tableAt(base, "_moveNames", "moveNames", "names") + if own then return own end + return monTables(sibling(base, "gen3Pokemon")).moveNames +end + +local function moveIndexFor(base, moveNames) + return nameIndex("moves", moveNamesFor(base, moveNames)) +end + +local function itemIndex(base) + return nameIndex("items", itemRows(sibling(base, "gen3Items")), "name") +end + +G3.itemRows, G3.moveRows, G3.trainerRows = itemRows, moveRows, trainerRows +G3.encounterRows, G3.monTables = encounterRows, monTables + +function G3.speciesNum(pokemonBase, id) + return toNum(nameIndex("species", monTables(pokemonBase).names), id) +end + +function G3.speciesId(pokemonBase, num) + return toId(nameIndex("species", monTables(pokemonBase).names), num) +end + +function G3.moveNum(base, id) return toNum(moveIndexFor(base), id) end +function G3.moveId(base, num) return toId(moveIndexFor(base), num) end +function G3.itemNum(base, id) return toNum(itemIndex(base), id) end +function G3.itemId(base, num) return toId(itemIndex(base), num) end + +local function bitSet(lo, hi, bit) + local word = bit < 32 and (lo or 0) or (hi or 0) + local shift = bit < 32 and bit or bit - 32 + return math.floor(word / 2 ^ shift) % 2 == 1 +end + +local function monRecord(base, id) + local t = monTables(base) + local index = nameIndex("species", t.names) + local num = index.num[id] + local stats = num and t.stats and t.stats[num] + local meta = num and t.meta and t.meta[num] + if not (stats and meta) then return nil end + local moveIndex = moveIndexFor(sibling(base, "gen3Moves"), t.moveNames) + local items = itemIndex(base) + local national = t.national and t.national.toNational + and t.national.toNational[num] + if national == nil and num <= 251 then national = num end + local record = { + id = id, name = t.names[num], index = num, dex = national, + baseStats = { hp = stats.hp, attack = stats.atk, defense = stats.def, + speed = stats.spe, specialAttack = stats.spa, + specialDefense = stats.spd }, + catchRate = meta.catchRate, baseExp = meta.expYield, + growthRate = G3.GROWTH[meta.growthRate] or tostring(meta.growthRate), + genderRatio = meta.genderRatio, eggCycles = meta.eggCycles, + friendship = meta.friendship, + eggGroups = { meta.eggGroup1 or 0, meta.eggGroup2 or 0 }, + itemCommon = toId(items, meta.itemCommon), + itemRare = toId(items, meta.itemRare), + spriteFront = G3.vanillaSprite("front", num), + spriteBack = G3.vanillaSprite("back", num), + } + local pair = t.types and t.types[num] + local types = {} + if pair then + types[1] = G3.TYPES[pair[1]] or tostring(pair[1]) + if pair[2] ~= nil and pair[2] ~= pair[1] then + types[2] = G3.TYPES[pair[2]] or tostring(pair[2]) + end + end + record.types = types + local abilityPair = t.abilities and t.abilities[num] + if abilityPair then + local abilityIndex = nameIndex("abilities", t.abilityNames) + local abilities = {} + for slot = 1, 2 do + local ability = abilityPair[slot] + if ability and ability ~= 0 then + abilities[#abilities + 1] = abilityIndex.id[ability] or ability + end + end + record.abilities = abilities + end + local learnset = {} + for _, row in ipairs(t.learnsets and t.learnsets[num] or {}) do + local level = row[1] or row.level + local move = row[2] or row.move + if level and move and move ~= 0 then + learnset[#learnset + 1] = { level = level, move = toId(moveIndex, move) } + end + end + record.learnset = learnset + local evolutions = {} + for _, row in ipairs(t.evolutions and t.evolutions[num] or {}) do + local method = row.method or row[1] + local param = row.param or row[2] or 0 + local entry = { + method = G3.EVOLUTIONS[method] or tostring(method), + species = toId(index, row.target or row[3]) or "0", + } + if EVO_LEVEL_METHODS[method] then + entry.level = param + elseif EVO_ITEM_METHODS[method] then + entry.item = toId(items, param) + else + entry.param = param + end + evolutions[#evolutions + 1] = entry + end + record.evolutions = evolutions + local tmhm = t.tmhm + if tmhm and type(tmhm.machines) == "table" and type(tmhm.learnsets) == "table" then + local bits = tmhm.learnsets[num] + local list = {} + if bits then + for bit = 0, 63 do + local move = tmhm.machines[bit] + if move == nil then break end + if move ~= 0 and bitSet(bits.lo, bits.hi, bit) then + list[#list + 1] = toId(moveIndex, move) + end + end + end + record.tmhm = list + end + local entry = national and t.dex and t.dex[national] + if entry then + record.dexEntry = { kind = entry.category or "", height = entry.height or 0, + weight = entry.weight or 0 } + end + return record +end + +local function monIds(base) + local t = monTables(base) + local ids = {} + for _, id in ipairs(nameIndex("species", t.names).ids) do + local num = nameIndex("species", t.names).num[id] + if t.stats and t.stats[num] and t.meta and t.meta[num] then + ids[#ids + 1] = id + end + end + return ids +end + +local function sideTable(target, key) + local side = rawget(target, key) + if type(side) ~= "table" then + side = {} + rawset(target, key, side) + end + return side +end + +local function ensureSlot(t, key) + if t[key] == nil then t[key] = {} end + return t[key] +end + +local function writeMon(target, t, num, value) + local moveIndex = moveIndexFor(sibling(target, "gen3Moves"), t.moveNames) + local items = itemIndex(target) + if type(value.name) == "string" and t.names then t.names[num] = value.name end + if type(value.types) == "table" and t.types then + local first = TYPE_NUMS[value.types[1]] or tonumber(value.types[1]) or 0 + local second = TYPE_NUMS[value.types[2]] or tonumber(value.types[2]) or first + t.types[num] = { first, second } + end + if type(value.baseStats) == "table" and t.stats then + local old = t.stats[num] or {} + local s = value.baseStats + t.stats[num] = { + hp = s.hp or old.hp, atk = s.attack or old.atk, def = s.defense or old.def, + spe = s.speed or old.spe, spa = s.specialAttack or old.spa, + spd = s.specialDefense or old.spd, + } + end + if t.meta then + local meta = {} + for key, v in pairs(t.meta[num] or {}) do meta[key] = v end + if value.catchRate ~= nil then meta.catchRate = value.catchRate end + if value.baseExp ~= nil then meta.expYield = value.baseExp end + if value.growthRate ~= nil then + meta.growthRate = GROWTH_NUMS[value.growthRate] + or tonumber(value.growthRate) or meta.growthRate + end + for _, key in ipairs({ "genderRatio", "eggCycles", "friendship" }) do + if value[key] ~= nil then meta[key] = value[key] end + end + if type(value.eggGroups) == "table" then + meta.eggGroup1 = value.eggGroups[1] or 0 + meta.eggGroup2 = value.eggGroups[2] or meta.eggGroup1 + end + meta.itemCommon = value.itemCommon and toNum(items, value.itemCommon) or 0 + meta.itemRare = value.itemRare and toNum(items, value.itemRare) or 0 + t.meta[num] = meta + end + if type(value.abilities) == "table" and t.abilities then + local abilityIndex = nameIndex("abilities", t.abilityNames) + local pair = {} + for slot = 1, 2 do + pair[slot] = toNum(abilityIndex, value.abilities[slot]) or 0 + end + t.abilities[num] = pair + end + if type(value.learnset) == "table" and t.learnsets then + local rows = {} + for _, row in ipairs(value.learnset) do + rows[#rows + 1] = { row.level, toNum(moveIndex, row.move) or 0 } + end + t.learnsets[num] = rows + end + if type(value.evolutions) == "table" and t.evolutions then + local rows = {} + for _, row in ipairs(value.evolutions) do + local method = EVO_NUMS[row.method] or tonumber(row.method) or 0 + local param = row.param or 0 + if row.level ~= nil then param = row.level end + if row.item ~= nil then param = toNum(items, row.item) or 0 end + rows[#rows + 1] = { method = method, param = param, + target = G3.speciesNum(target, row.species) or 0 } + end + t.evolutions[num] = rows + end + local tmhm = t.tmhm + if type(value.tmhm) == "table" and tmhm and type(tmhm.machines) == "table" + and type(tmhm.learnsets) == "table" then + local slotOf = {} + for bit, move in pairs(tmhm.machines) do slotOf[move] = bit end + local lo, hi = 0, 0 + for _, move in ipairs(value.tmhm) do + local bit = slotOf[toNum(moveIndex, move)] + if bit and bit < 32 then lo = lo + 2 ^ bit + elseif bit then hi = hi + 2 ^ (bit - 32) end + end + tmhm.learnsets[num] = { lo = lo, hi = hi } + end + local national = value.dex or (num <= 251 and num or nil) + if type(value.dexEntry) == "table" and t.dex and national then + local old = t.dex[national] or {} + t.dex[national] = { + category = value.dexEntry.kind or old.category, + height = value.dexEntry.height or old.height, + weight = value.dexEntry.weight or old.weight, + } + end + local sprites = sideTable(target, "spriteOverrides") + local front = value.spriteFront ~= G3.vanillaSprite("front", num) + and value.spriteFront or nil + local back = value.spriteBack ~= G3.vanillaSprite("back", num) + and value.spriteBack or nil + if front or back then + sprites[num] = { front = front, back = back } + else + sprites[num] = nil + end +end + +local function monWrite(target, registry) + local t = monTables(target) + local index = nameIndex("species", t.names) + local records = sideTable(target, "modRecords") + for id in pairs(registry.ops) do + local value = registry:get(id) + local num = index.num[id] + if value == nil then + records[id] = nil + else + if not num and type(value.index) == "number" and index ~= EMPTY_INDEX then + num = value.index + index.num[id], index.id[num] = id, id + index.ids[#index.ids + 1] = id + end + if num then writeMon(target, t, num, value) end + records[id] = value + end + end +end + +local function moveRecord(base, id) + local names = moveNamesFor(base) + local index = nameIndex("moves", names) + local num = index.num[id] + local rows = moveRows(base) + local row = num and rows and rows[num] + if type(row) ~= "table" then return nil end + local category = PHYSICAL_TYPES[row.type] and "physical" or "special" + if (row.power or 0) == 0 then category = "status" end + return { + id = id, name = names[num] or id, index = num, + type = G3.TYPES[row.type] or tostring(row.type), + power = row.power or 0, accuracy = row.accuracy or 0, pp = row.pp or 0, + effect = row.effect, secondaryChance = row.secondaryChance, + target = row.target, priority = row.priority, flags = row.flags, + category = category, + } +end + +local function moveIds(base) + local rows = moveRows(base) + local ids = {} + if not rows then return ids end + local index = moveIndexFor(base) + for _, id in ipairs(index.ids) do + if type(rows[index.num[id]]) == "table" then ids[#ids + 1] = id end + end + return ids +end + +local function moveWrite(target, registry) + local rows = moveRows(target) + if not rows then return end + local names = moveNamesFor(target) + local index = nameIndex("moves", names) + local pokemon = monTables(sibling(target, "gen3Pokemon")) + local extra = pokemon.battleMoves ~= rows and pokemon.battleMoves or nil + local records = sideTable(target, "modRecords") + for id in pairs(registry.ops) do + local value = registry:get(id) + local num = index.num[id] + if value == nil then + records[id] = nil + else + if not num and type(value.index) == "number" and index ~= EMPTY_INDEX then + num = value.index + index.num[id], index.id[num] = id, id + index.ids[#index.ids + 1] = id + end + if num then + local row = {} + for key, v in pairs(type(rows[num]) == "table" and rows[num] or {}) do + row[key] = v + end + for _, key in ipairs({ "power", "accuracy", "pp", "effect", + "secondaryChance", "target", "priority", + "flags" }) do + if value[key] ~= nil then row[key] = value[key] end + end + if value.type ~= nil then + row.type = TYPE_NUMS[value.type] or tonumber(value.type) or row.type + end + rows[num] = row + if extra then extra[num] = row end + if names and type(value.name) == "string" then names[num] = value.name end + end + records[id] = value + end + end +end + +local function itemRecord(base, id) + local rows = itemRows(base) + local index = nameIndex("items", rows, "name") + local num = index.num[id] + local row = num and rows and rows[num] + if type(row) ~= "table" then return nil end + local record = Merge.deepCopy(row) + record.id, record.index = id, num + record.price = record.price or 0 + return record +end + +local function itemIds(base) + return nameIndex("items", itemRows(base), "name").ids +end + +local function itemWrite(target, registry) + local rows = itemRows(target) + if not rows then return end + local index = nameIndex("items", rows, "name") + local records = sideTable(target, "modRecords") + for id in pairs(registry.ops) do + local value = registry:get(id) + local num = index.num[id] + if value == nil then + records[id] = nil + else + if not num and type(value.index) == "number" and index ~= EMPTY_INDEX then + num = value.index + index.num[id], index.id[num] = id, id + index.ids[#index.ids + 1] = id + end + if num then + local row = Merge.deepCopy(value) + row.id, row.index = nil, nil + rows[num] = row + end + records[id] = value + end + end +end + +local function encounterArea(area, translate) + if type(area) ~= "table" then return area end + local out = Merge.deepCopy(area) + local slots = out.slots or out.mons or (#out > 0 and out) or nil + for _, slot in ipairs(slots or {}) do + if type(slot) == "table" then + if slot.species ~= nil then slot.species = translate(slot.species) + elseif slot[1] ~= nil then slot[1] = translate(slot[1]) end + end + end + return out +end + +local AREA_KEYS = { "land", "grass", "water", "rocks", "fishing" } + +local function encounterRecord(base, id) + local rows = encounterRows(base) + local row = rows and rows[id] + if type(row) ~= "table" then return nil end + local index = speciesIndex(base) + local record = Merge.deepCopy(row) + for _, key in ipairs(AREA_KEYS) do + if record[key] ~= nil then + record[key] = encounterArea(row[key], function(num) + return toId(index, num) or "0" + end) + end + end + return record +end + +local function encounterIds(base) + local ids = {} + for key, value in pairs(encounterRows(base) or {}) do + if type(key) == "string" and type(value) == "table" then ids[#ids + 1] = key end + end + table.sort(ids) + return ids +end + +local function encounterWrite(target, registry) + local rows = encounterRows(target) + if not rows then return end + local index = speciesIndex(target) + local function toRow(value) + local row = Merge.deepCopy(value) + for _, key in ipairs(AREA_KEYS) do + if row[key] ~= nil then + row[key] = encounterArea(value[key], function(id) + return toNum(index, id) or 0 + end) + end + end + return row + end + local function aliasesOf(id, row) + local out = {} + if type(row) ~= "table" or row.mapGroup == nil or row.mapNum == nil then + return out + end + for key, other in pairs(rows) do + if key ~= id and registry.ops[key] == nil and type(other) == "table" + and other.mapGroup == row.mapGroup and other.mapNum == row.mapNum then + out[#out + 1] = key + end + end + return out + end + local writes, tombstones = {}, {} + for id in pairs(registry.ops) do + local value = registry:get(id) + if value == nil then + tombstones[#tombstones + 1] = id + else + writes[id] = toRow(value) + end + end + for id, row in pairs(writes) do + for _, alias in ipairs(aliasesOf(id, rows[id] or row)) do rows[alias] = row end + rows[id] = row + end + for _, id in ipairs(tombstones) do + for _, alias in ipairs(aliasesOf(id, rows[id])) do rows[alias] = nil end + rows[id] = nil + end +end + +local function trainerRecord(base, id) + local rows = trainerRows(base) + local num = tonumber(id) + local row = num and tostring(num) == id and rows and rows[num] + if type(row) ~= "table" then return nil end + local species = speciesIndex(base) + local moves = moveIndexFor(sibling(base, "gen3Moves")) + local items = itemIndex(base) + local record = Merge.deepCopy(row) + record.id = id + record.name = record.name or "" + record.party = record.party or {} + for _, mon in ipairs(record.party) do + mon.species = toId(species, mon.species) or "0" + mon.heldItem = toId(items, mon.heldItem) + if type(mon.moves) == "table" then + local list = {} + for _, move in ipairs(mon.moves) do + local moveId = toId(moves, move) + if moveId then list[#list + 1] = moveId end + end + mon.moves = list + end + end + return record +end + +local function trainerIds(base) + local nums = {} + for key, value in pairs(trainerRows(base) or {}) do + if type(key) == "number" and type(value) == "table" then nums[#nums + 1] = key end + end + table.sort(nums) + local ids = {} + for i, num in ipairs(nums) do ids[i] = tostring(num) end + return ids +end + +local function trainerWrite(target, registry) + local rows = trainerRows(target) + if not rows then return end + local species = speciesIndex(target) + local moves = moveIndexFor(sibling(target, "gen3Moves")) + local items = itemIndex(target) + for id in pairs(registry.ops) do + local num = tonumber(id) + if num then + local value = registry:get(id) + if value == nil then + rows[num] = nil + else + local row = Merge.deepCopy(value) + row.id = nil + for _, mon in ipairs(row.party or {}) do + mon.species = toNum(species, mon.species) or 0 + mon.heldItem = mon.heldItem and toNum(items, mon.heldItem) or nil + if type(mon.moves) == "table" then + local list = {} + for i = 1, math.max(4, #mon.moves) do + list[i] = mon.moves[i] and toNum(moves, mon.moves[i]) or 0 + end + mon.moves = list + end + end + row.partySize = #(row.party or {}) + rows[num] = row + end + end + end +end + +function G3.textIr(value) + if type(value) ~= "string" then return value end + local ir = {} + local rest = value + while rest ~= "" do + local cut, token, width + local para = rest:find("\n\n", 1, true) + local line = rest:find("\n", 1, true) + if para and para == line then + cut, token, width = para, "para", 2 + elseif line then + cut, token, width = line, "nl", 1 + end + if not cut then + ir[#ir + 1] = { t = "text", s = rest } + break + end + if cut > 1 then ir[#ir + 1] = { t = "text", s = rest:sub(1, cut - 1) } end + ir[#ir + 1] = { t = token } + rest = rest:sub(cut + width) + end + ir[#ir + 1] = { t = "eos" } + return ir +end + +local function textWrite(target, registry) + local tombstones = {} + for id in pairs(registry.ops) do + local value = registry:get(id) + if value == nil then + tombstones[#tombstones + 1] = id + else + target[id] = G3.textIr(value) + end + end + for _, id in ipairs(tombstones) do target[id] = nil end +end + +G3.monRecord, G3.monIds, G3.monWrite = monRecord, monIds, monWrite +G3.moveRecord, G3.moveIds, G3.moveWrite = moveRecord, moveIds, moveWrite +G3.itemRecord, G3.itemIds, G3.itemWrite = itemRecord, itemIds, itemWrite +G3.encounterRecord, G3.encounterIds = encounterRecord, encounterIds +G3.encounterWrite = encounterWrite +G3.trainerRecord, G3.trainerIds, G3.trainerWrite = + trainerRecord, trainerIds, trainerWrite +G3.textWrite = textWrite + -- ------- shared Gen 2 leaves -- -- The ROM name spaces Gold's tables key by. They are enums rather than @@ -907,6 +1754,36 @@ R.pokemon = { example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })', gen2Example = 'mod.content.pokemon:patch("TOTODILE", ' .. '{ baseStats = { specialAttack = 80 } })', + gen3BaseAt = G3.monRecord, gen3BaseIds = G3.monIds, gen3Write = G3.monWrite, + gen3Fields = { + id = f.str, name = f.str, dex = f.opt(f.int(1)), + index = f.opt(f.int(1, 1023)), + types = f.list(f.id("type_chart")), + baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255), + defense = f.int(1, 255), speed = f.int(1, 255), + specialAttack = f.int(1, 255), + specialDefense = f.int(1, 255) }, + catchRate = f.int(0, 255), baseExp = f.int(0, 255), + growthRate = f.id("growth_rates"), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + friendship = f.opt(f.int(0, 255)), + eggGroups = f.opt(f.list(f.int(0, 255))), + itemCommon = f.opt(f.id("items")), itemRare = f.opt(f.id("items")), + abilities = f.opt(f.list(f.union{ f.str, f.int(0, 255) })), + learnset = f.list(f.rec{ level = f.int(1, 100), move = f.id("moves") }), + tmhm = f.opt(f.list(f.id("moves"))), + evolutions = f.list(f.rec{ method = f.id("evolution_methods"), + species = f.id("pokemon"), + level = f.opt(f.int(0, 100)), + item = f.opt(f.id("items")), + param = f.opt(f.int(0)) }), + dexEntry = f.opt(f.rec{ kind = f.str, height = f.int(0), + weight = f.int(0) }), + spriteFront = f.path, spriteBack = f.path, + trueColor = f.opt(f.bool), + }, + gen3Example = 'mod.content.pokemon:patch("MEW", ' + .. '{ baseStats = { specialAttack = 120 } })', } R.moves = { @@ -933,6 +1810,17 @@ R.moves = { counterable = f.opt(f.bool), }, example = 'mod.content.moves:patch("BLIZZARD", { accuracy = 70 })', + gen3BaseAt = G3.moveRecord, gen3BaseIds = G3.moveIds, gen3Write = G3.moveWrite, + gen3Fields = { + id = f.str, name = f.str, + index = f.opt(f.int(1, 1023)), + type = f.id("type_chart"), + power = f.int(0, 255), accuracy = f.int(0, 100), pp = f.int(0, 64), + effect = f.opt(f.int(0, 255)), secondaryChance = f.opt(f.int(0, 100)), + target = f.opt(f.int(0, 255)), priority = f.opt(f.int(-7, 7)), + flags = f.opt(f.int(0, 255)), + category = f.opt(f.enum{ "physical", "special", "status" }), + }, } R.items = { @@ -951,6 +1839,17 @@ R.items = { needsTarget = f.opt(f.bool), }, example = 'mod.content.items:patch("POTION", { price = 100 })', + gen3BaseAt = G3.itemRecord, gen3BaseIds = G3.itemIds, gen3Write = G3.itemWrite, + gen3Fields = { + id = f.str, name = f.str, + index = f.opt(f.int(1, 1023)), + price = f.int(0), + pocket = f.opt(f.str), fieldUse = f.opt(f.str), + holdEffect = f.opt(f.int(0, 255)), holdEffectParam = f.opt(f.int(0, 255)), + importance = f.opt(f.int(0, 255)), registrability = f.opt(f.int(0, 255)), + battleUsage = f.opt(f.int(0, 255)), secondaryId = f.opt(f.int(0)), + description = f.opt(f.str), + }, } R.maps = { @@ -1000,6 +1899,15 @@ R.maps = { end end, example = 'mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... })', + gen3Fields = { + id = f.opt(f.str), name = f.opt(f.str), + width = f.opt(f.int(0)), height = f.opt(f.int(0)), + tileset = f.opt(f.any), + warps = f.opt(f.list(f.any)), objects = f.opt(f.list(f.any)), + connections = f.opt(f.any), + }, + gen3Extra = false, + gen3Example = 'mod.content.maps:patch("FR_PALLET_TOWN", { weather = 2 })', } R.tilesets = { @@ -1061,6 +1969,13 @@ local gen2WaterRow = f.rec{ map = f.opt(f.str), rate = f.int(0, 255), slots = f.list(gen2Slot), } +local gen3Area = f.partial{ + rate = f.int(0, 255), + slots = f.list(f.partial{ species = f.id("pokemon"), + minLevel = f.opt(f.int(0, 255)), + maxLevel = f.opt(f.int(0, 255)) }), +} + R.encounters = { semantics = "record", target = "encounters", fields = { @@ -1119,6 +2034,16 @@ R.encounters = { example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })', gen2Example = 'mod.content.encounters:patch("grass", ' .. '{ ROUTE_29 = { rates = { NITE = 40 } } })', + gen3BaseAt = G3.encounterRecord, gen3BaseIds = G3.encounterIds, + gen3Write = G3.encounterWrite, + gen3Fields = { + id = f.opt(f.str), + mapGroup = f.opt(f.int(0)), mapNum = f.opt(f.int(0)), + land = f.opt(gen3Area), grass = f.opt(gen3Area), water = f.opt(gen3Area), + rocks = f.opt(gen3Area), fishing = f.opt(gen3Area), + }, + gen3Example = 'mod.content.encounters:patch("FR_ROUTE_1", ' + .. '{ land = { rate = 30 } })', } R.trainers = { @@ -1215,6 +2140,22 @@ R.trainers = { }, example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })', gen2Example = 'mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })', + gen3BaseAt = G3.trainerRecord, gen3BaseIds = G3.trainerIds, + gen3Write = G3.trainerWrite, + gen3Fields = { + id = f.opt(f.str), name = f.str, + class = f.opt(f.int(0, 255)), className = f.opt(f.str), + pic = f.opt(f.int(0, 255)), gender = f.opt(f.int(0, 255)), + doubleBattle = f.opt(f.bool), aiFlags = f.opt(f.int(0)), + items = f.opt(f.list(f.int(0))), + party = f.list(f.partial{ species = f.id("pokemon"), + level = f.int(1, 100), + heldItem = f.opt(f.id("items")), + moves = f.opt(f.list(f.id("moves"))) }), + dialogs = f.opt(f.any), + }, + gen3Example = 'mod.content.trainers:patch("326", ' + .. '{ party = { { species = "MEW", level = 5 } } })', } R.sprites = { @@ -1290,6 +2231,9 @@ R.text = { semantics = "record", target = "text", value = f.str, example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', + gen3Value = f.union{ f.str, f.list(f.any) }, + gen3Write = G3.textWrite, + gen3Example = 'mod.content.text:override("Text_BootedUpPC", "HELLO!")', } -- Gen 2's data/generated/text.lua is VM script text keyed by bank:address; @@ -1370,6 +2314,10 @@ R.map_scripts = { priority = f.opt(f.num), }, example = 'mod.content.map_scripts:register("PALLET_TOWN", { talk = { ... } })', + gen3Semantics = "record", + gen3Value = f.list(f.any), + gen3Example = 'mod.content.map_scripts:override("EventScript_BufferItemsPocket", ' + .. '{ { op = "return" } })', } R.screens = { diff --git a/src/ui/game3/bag_menu.lua b/src/ui/game3/bag_menu.lua index 2e05c5a5..5e3a3c63 100644 --- a/src/ui/game3/bag_menu.lua +++ b/src/ui/game3/bag_menu.lua @@ -236,11 +236,16 @@ function BagMenu.handleInput(input) local BattleItems = require("src.core.game3.battle.items") if BattleItems.needsPartySelect(row.id) then local PartyMenu = require("src.ui.game3.party_menu") + local Battle = package.loaded["src.core.game3.battle"] + local st = Battle and Battle._st + -- pokefirered/src/party_menu.c:5878 PartyMenu.show(party, BagMenu._session and BagMenu._session.moveOverlay, { session = BagMenu._session, bag = BagMenu._bag, item = row.id, mode = "use", + battleOrder = st and st.playerParty and PartyMenu.battleOrder(st) or nil, + layout = (st and st.double) and "double" or nil, onClose = function() BagMenu.mode = "list" clamp_cursor() diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index ab141362..4340e8fa 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -10,6 +10,8 @@ BattleChrome._manifest = nil BattleChrome._textbox = nil BattleChrome._playerBox = nil BattleChrome._enemyBox = nil +BattleChrome._doublesPlayerBox = nil +BattleChrome._doublesOpponentBox = nil BattleChrome._elements = nil BattleChrome._partyBar = nil BattleChrome._terrains = {} @@ -111,6 +113,12 @@ function BattleChrome.install(cache) BattleChrome._textbox = nil BattleChrome._playerBox = nil BattleChrome._enemyBox = nil + BattleChrome._doublesPlayerBox = nil + BattleChrome._doublesOpponentBox = nil + BattleChrome._doublesTried = false + BattleChrome._hpBold = nil + BattleChrome._hpBoldQuads = {} + BattleChrome._hpBoldTried = false BattleChrome._elements = nil BattleChrome._elementsExp = nil BattleChrome._partyBar = nil @@ -166,6 +174,98 @@ function BattleChrome.install(cache) end end +local function rom_bytes() + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon and type(Pokemon._romBytes) == "string" then return Pokemon._romBytes end + for _, p in ipairs({ "1636 - Pokemon Fire Red (U)(Squirrels).gba", "firered.gba", "Pokemon FireRed.gba" }) do + local f = io.open(p, "rb") + if f then + local d = f:read("*a") + f:close() + if d and #d >= 0x1000000 then return d end + end + end + return nil +end + +local function load_doubles_boxes() + if BattleChrome._doublesTried then return end + BattleChrome._doublesTried = true + local root = battle_root() + local files = BattleChromeExtract.DOUBLES_FILES or {} + local pRgba = read_bytes(root .. "/" .. (files.player or "healthbox_doubles_player.rgba")) + local oRgba = read_bytes(root .. "/" .. (files.opponent or "healthbox_doubles_opponent.rgba")) + if not (pRgba and oRgba) then + local rom = rom_bytes() + if rom then + local ok, p2, o2 = pcall(BattleChromeExtract.bakeDoubles, function(i) return rom:byte(i + 1) or 0 end) + if ok and p2 and o2 then pRgba, oRgba = p2, o2 end + end + end + BattleChrome._doublesPlayerBox = rgba_to_image(pRgba, 128, 32) + BattleChrome._doublesOpponentBox = rgba_to_image(oRgba, 128, 32) + if not (BattleChrome._doublesPlayerBox and BattleChrome._doublesOpponentBox) then + print("[game3/battle_chrome] doubles healthboxes missing; re-import the ROM to extract them") + end +end + +local function load_hp_bold() + if BattleChrome._hpBoldTried then return end + BattleChrome._hpBoldTried = true + local w, h = BattleChromeExtract.HP_BOLD_W or 88, BattleChromeExtract.HP_BOLD_H or 8 + local rgba = read_bytes(battle_root() .. "/" .. (BattleChromeExtract.HP_BOLD_FILE or "hp_bold_digits.rgba")) + if not rgba then + local rom = rom_bytes() + if rom then + local ok, r2 = pcall(BattleChromeExtract.bakeHpBoldDigits, function(i) return rom:byte(i + 1) or 0 end) + if ok and r2 then rgba = r2 end + end + end + BattleChrome._hpBold = rgba_to_image(rgba, w, h) + BattleChrome._hpBoldQuads = {} + if not BattleChrome._hpBold then + print("[game3/battle_chrome] bold HP digits missing; re-import the ROM to extract them") + end +end + +function BattleChrome.hasHpBoldDigits() + load_hp_bold() + return BattleChrome._hpBold ~= nil +end + +-- pokefirered/src/battle_interface.c:889 +function BattleChrome.drawHpBoldChar(ch, x, y) + load_hp_bold() + local img = BattleChrome._hpBold + if not img then return false end + local chars = BattleChromeExtract.HP_BOLD_CHARS or "0123456789/" + local n = chars:find(ch, 1, true) + if not n then return false end + local q = BattleChrome._hpBoldQuads[n] + if not q then + q = love.graphics.newQuad((n - 1) * 8, 0, 8, 8, img:getDimensions()) + BattleChrome._hpBoldQuads[n] = q + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, q, x, y) + return true +end + +function BattleChrome.hasDoublesBoxes() + load_doubles_boxes() + return BattleChrome._doublesPlayerBox ~= nil and BattleChrome._doublesOpponentBox ~= nil +end + +-- pokefirered/src/battle_gfx_sfx_util.c:39 +function BattleChrome.drawDoublesBox(isPlayer, x, y) + load_doubles_boxes() + local img = isPlayer and BattleChrome._doublesPlayerBox or BattleChrome._doublesOpponentBox + img = img or BattleChrome._enemyBox + if not img then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, x, y) +end + function BattleChrome.ready() if BattleChrome._playerBox and next(BattleChrome._terrains) then return true end return BattleChromeExtract.ready(BattleChrome._cache, cache_root()) @@ -320,6 +420,7 @@ end -- Element tile bases (pret B_INTERFACE_GFX_*) local HP_TEXT_TILE = 1 +local HP_BAR_LEFT_BORDER = 65 local HP_BAR_BASE = { green = 3, yellow = 47, red = 56 } local HP_BAR_TILES = 6 local HP_BAR_PIXELS = 48 @@ -391,16 +492,22 @@ local function filled_pixels_for_bar(ratio, numTiles) end --- Draw pret HP bar: HP label tiles + 6 fill tiles (48px). Top-left of 64×8 strip. -function BattleChrome.drawHpBar(x, y, hp, maxHp) +function BattleChrome.drawHpBar(x, y, hp, maxHp, statusBorder) if not BattleChrome._elements then return end local color = BattleChrome.hpColor(hp, maxHp) local base = HP_BAR_BASE[color] or HP_BAR_BASE.green local pix = split_bar_pixels(BattleChrome.scaledHpFraction(hp, maxHp, HP_BAR_PIXELS), HP_BAR_TILES) love.graphics.setColor(1, 1, 1, 1) - for i = 0, 1 do - local q = elements_tile_quad(HP_TEXT_TILE + i) - if q then love.graphics.draw(BattleChrome._elements, q, x + i * 8, y) end + if statusBorder then + -- pokefirered/src/battle_interface.c:1672 + local q = elements_tile_quad(HP_BAR_LEFT_BORDER) + if q then love.graphics.draw(BattleChrome._elements, q, x + 8, y) end + else + for i = 0, 1 do + local q = elements_tile_quad(HP_TEXT_TILE + i) + if q then love.graphics.draw(BattleChrome._elements, q, x + i * 8, y) end + end end for i = 0, HP_BAR_TILES - 1 do local q = elements_tile_quad(base + (pix[i + 1] or 0)) @@ -408,6 +515,15 @@ function BattleChrome.drawHpBar(x, y, hp, maxHp) end end +function BattleChrome.drawElementTile(ti, x, y, healthboxPal) + local sheet = healthboxPal and (BattleChrome._elementsExp or BattleChrome._elements) or BattleChrome._elements + if not sheet then return end + local q = elements_tile_quad(ti, sheet) + if not q then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet, q, x, y) +end + function BattleChrome.drawHpFill(x, y, hp, maxHp) BattleChrome.drawHpBar(x - 16, y, hp, maxHp) end diff --git a/src/ui/game3/evolution_scene.lua b/src/ui/game3/evolution_scene.lua index 4bbbe392..47cb1142 100644 --- a/src/ui/game3/evolution_scene.lua +++ b/src/ui/game3/evolution_scene.lua @@ -92,6 +92,7 @@ function EvolutionScene.start(mon, postSpecies, opts) EvolutionScene._canStop = opts.canStop ~= false EvolutionScene._session = opts.session EvolutionScene._bag = opts.bag + EvolutionScene._via = opts.via EvolutionScene._onDone = opts.onDone EvolutionScene._isBattle = opts.isBattle and true or false EvolutionScene._headless = opts.headless and true or false @@ -489,7 +490,7 @@ function EvolutionScene.update(dt) EvolutionScene._postScale = 1.0 -- POINT OF NO RETURN: Mutate species, stats, nickname, dex, Shedinja now - Evolution.apply(EvolutionScene._mon, EvolutionScene._postSpecies, EvolutionScene._session, EvolutionScene._bag) + Evolution.apply(EvolutionScene._mon, EvolutionScene._postSpecies, EvolutionScene._session, EvolutionScene._bag, EvolutionScene._via) pcall(function() Audio.playSe(SE.SE_EXP or 1) end) spawn_flash_spray() diff --git a/src/ui/game3/new_game_scene.lua b/src/ui/game3/new_game_scene.lua index 63a0ffea..146f325c 100644 --- a/src/ui/game3/new_game_scene.lua +++ b/src/ui/game3/new_game_scene.lua @@ -10,6 +10,7 @@ local Chrome = require("src.ui.game3.chrome") local Window = require("src.ui.game3.window") local Naming = require("src.ui.game3.naming") local BallOpen = require("src.core.game3.battle.ball_open") +local ModRuntime = require("src.mods.Runtime") local Scene = {} Scene.__index = Scene @@ -380,9 +381,25 @@ function Scene:printerActive() end -- pokefirered/src/oak_speech.c:1124 +function Scene:_answered(label, value, saveKey) + if not ModRuntime.wants("intro.oak_speech.answered") then return end + ModRuntime.emit("intro.oak_speech.answered", { + speech = self, step = { id = label }, index = self._oakStep or 0, + label = label, value = value, saveKey = saveKey, + }) +end + function Scene:oakPrint(key, speed) local text = OAK_TEXT[key] or key text = text:gsub("{PLAYER}", self.playerName):gsub("{RIVAL}", self.rivalName) + if self.section == "oak" then + self._oakStep = (self._oakStep or 0) + 1 + if ModRuntime.wants("intro.oak_speech.step") then + ModRuntime.emit("intro.oak_speech.step", { + speech = self, step = { id = key, text = text }, index = self._oakStep, + }) + end + end self.win.dialog = true self.printer = newPrinter(text, speed == nil and self.textSpeed or speed, true) end @@ -890,6 +907,10 @@ function Scene.Task_OakSpeech_Init(self, t) return end self.section = "oak" + self._oakStep = 0 + if ModRuntime.wants("intro.oak_speech.started") then + ModRuntime.emit("intro.oak_speech.started", { speech = self, steps = {} }) + end self.bg1 = { image = self.assets.oakSpeechBg } d.nidoran = createSprite(self, { dims = Oam.SQUARE_64, priority = 1, image = self.assets.nidoranFront, @@ -1062,6 +1083,7 @@ function Scene.Task_OakSpeech_HandleGenderInput(self, t) else return end + self:_answered("gender", self.gender, "gender") t.func = Scene.Task_OakSpeech_ClearGenderWindows end @@ -1147,8 +1169,10 @@ function Scene:getDefaultName(choice) local list = self.gender == MALE and MALE_NAMES or FEMALE_NAMES local r = require("src.core.game3.rng").Random() self.playerName = list[(r % #list) + 1] + self:_answered("name", self.playerName, "name") else self.rivalName = RIVAL_NAMES[choice + 1] + self:_answered("rivalName", self.rivalName, "rivalName") end end @@ -1197,6 +1221,11 @@ function Scene:enterNaming(rival) onDone = function(name) if name and name ~= "" then if rival then scene.rivalName = name else scene.playerName = name end + if rival then + scene:_answered("rivalName", name, "rivalName") + else + scene:_answered("name", name, "name") + end end scene.naming.stage = "fade_out" scene.naming.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.BLACK) @@ -1467,11 +1496,15 @@ end function Scene.Task_OakSpeech_FreeResources(self, t) -- pokefirered/src/oak_speech.c:1777 destroyTask(t) + local answers = { gender = self.gender, name = self.playerName, rivalName = self.rivalName } + if ModRuntime.wants("intro.oak_speech.finished") then + ModRuntime.emit("intro.oak_speech.finished", { speech = self, answers = answers }) + end self.result = { action = "new_game", - gender = self.gender, - name = self.playerName, - rivalName = self.rivalName, + gender = tonumber(answers.gender) or self.gender, + name = type(answers.name) == "string" and answers.name ~= "" and answers.name or self.playerName, + rivalName = type(answers.rivalName) == "string" and answers.rivalName ~= "" and answers.rivalName or self.rivalName, start = MapIds.NEW_GAME_START, } end diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 13505115..346a1582 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -122,6 +122,123 @@ local INFO_RIGHT = { hp = { 102, 12 }, hpMax = { 117, 12 }, hpBar = { 88, 10 }, } +-- pokefirered/src/data/party_menu.h:192 +local SLOT_WIN_DOUBLE = { + { left = 1, top = 1, w = 10, h = 7, kind = "main" }, + { left = 1, top = 8, w = 10, h = 7, kind = "main" }, + { left = 12, top = 1, w = 18, h = 3, kind = "wide" }, + { left = 12, top = 5, w = 18, h = 3, kind = "wide" }, + { left = 12, top = 9, w = 18, h = 3, kind = "wide" }, + { left = 12, top = 13, w = 18, h = 3, kind = "wide" }, +} + +-- pokefirered/src/data/party_menu.h:81 +local SLOT_SPRITES_DOUBLE = { + { 16, 24, 20, 34, 56, 36, 16, 18 }, + { 16, 80, 20, 90, 56, 92, 16, 74 }, + { 104, 18, 108, 28, 144, 27, 102, 25 }, + { 104, 50, 108, 60, 144, 59, 102, 57 }, + { 104, 82, 108, 92, 144, 91, 102, 89 }, + { 104, 114, 108, 124, 144, 123, 102, 121 }, +} + +local function is_double() + return PartyMenu._layout == "double" +end + +local function slot_win(i) + return (is_double() and SLOT_WIN_DOUBLE or SLOT_WIN)[i] +end + +local function slot_sprites(i) + return (is_double() and SLOT_SPRITES_DOUBLE or SLOT_SPRITES)[i] +end + +-- pokefirered/src/party_menu.c:735 +local function slot_info(i) + if i == 1 or (i == 2 and is_double()) then return INFO_LEFT end + return INFO_RIGHT +end + +local function slot_filled(i) + local mon = PartyMenu._party and PartyMenu._party[i] + return mon ~= nil and (tonumber(mon.species or mon.speciesId) or 1) ~= 0 +end + +-- pokefirered/src/party_menu.c:1499 +local function double_next_slot(slot, dir) + while true do + slot = slot + dir + if slot < 1 or slot > 6 then return nil end + if slot_filled(slot) then return slot end + end +end + +-- pokefirered/src/party_menu.c:1402 +local function nav_double(cur, dir) + local last = PartyMenu._lastSelectedSlot + if dir == "up" then + if cur == 1 then return 7 end + local from = cur + if cur == 7 then from = 7 end + return double_next_slot(from, -1) or cur + elseif dir == "down" then + if cur == 7 then return 1 end + return double_next_slot(cur, 1) or 7 + elseif dir == "right" then + if cur == 1 then + if last == 4 then + if slot_filled(4) then return 4 end + elseif slot_filled(3) then + return 3 + end + elseif cur == 2 then + if last == 6 then + if slot_filled(6) then return 6 end + elseif slot_filled(5) then + return 5 + end + end + return cur + elseif dir == "left" then + if cur == 3 or cur == 4 then + PartyMenu._lastSelectedSlot = cur + return 1 + elseif cur == 5 or cur == 6 then + PartyMenu._lastSelectedSlot = cur + return 2 + end + end + return cur +end + +local function battle_nav_double(input) + local oldCur = PartyMenu.cursor + for _, dir in ipairs({ "up", "down", "left", "right" }) do + if input:wasPressed(dir) then + PartyMenu.cursor = nav_double(PartyMenu.cursor, dir) + break + end + end + if PartyMenu.cursor ~= oldCur then se(5) end +end + +-- pokefirered/src/party_menu.c:5905 +local function open_battle_actions_double(prevMode) + local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] + se(5) + if not slot_filled(2) or (mon and mon.isEgg) then + PartyMenu.ACTIONS = { "SUMMARY", "CANCEL" } + elseif prevMode == "battle_faint" then + PartyMenu.ACTIONS = { "SEND OUT", "SUMMARY", "CANCEL" } + else + PartyMenu.ACTIONS = { "SHIFT", "SUMMARY", "CANCEL" } + end + PartyMenu._previousMode = prevMode + PartyMenu.mode = "action" + PartyMenu.actionCursor = 1 +end + local function party_print(text, px, py, maxW) FrlgFont.draw(tostring(text or ""), px, py, { maxWidth = maxW or 56, @@ -191,7 +308,7 @@ local function get_hp_bar_level(hp, maxHp, isEgg) end local function idle_mon_offset(slotIndex) - local spr = SLOT_SPRITES[slotIndex] + local spr = slot_sprites(slotIndex) if spr and spr[1] == 16 then return 0, -4 end @@ -262,7 +379,7 @@ local function ensure_slot_sprites(i, mon, selected) slot = {} PartyMenu._oam[i] = slot end - local spr = SLOT_SPRITES[i] + local spr = slot_sprites(i) if not spr or not mon then destroy_id(slot.mon); slot.mon = nil destroy_id(slot.ball); slot.ball = nil @@ -384,12 +501,100 @@ local function sync_all_oam() end end +-- pokefirered/src/party_menu.c:6005 +function PartyMenu.battleOrder(st) + local party = st and st.playerParty or {} + local n = 0 + for i = 1, 6 do if party[i] then n = i end end + local order = st._partyOrder + local valid = type(order) == "table" and #order == n + if valid then + local seen = {} + for i = 1, n do + local v = order[i] + if type(v) ~= "number" or v < 1 or v > n or seen[v] then valid = false break end + seen[v] = true + end + end + local b0 = (st.battlers and st.battlers[0]) or st.player + local b2 = st.double and st.battlers and st.battlers[2] or nil + if not valid then + order = {} + local used = {} + for _, b in ipairs({ b0, b2 }) do + local pi = b and tonumber(b.partyIndex) + if pi and pi >= 1 and pi <= n and not used[pi] then + order[#order + 1] = pi + used[pi] = true + end + end + for i = 1, n do + if not used[i] then order[#order + 1] = i end + end + st._partyOrder = order + end + -- pokefirered/src/party_menu.c:5972 + for pos, b in ipairs({ b0, b2 }) do + local want = b and tonumber(b.partyIndex) + if want and order[pos] ~= want then + for j = 1, n do + if order[j] == want then + order[pos], order[j] = order[j], order[pos] + break + end + end + end + end + return order +end + +-- pokefirered/src/party_menu.c:6199 +local function apply_battle_order(party, overlay, opts) + local order = opts.battleOrder + if order == nil then + local Battle = package.loaded["src.core.game3.battle"] + local st = Battle and Battle._st + if not (st and st.playerParty and st.playerParty == party) then return party, overlay, opts end + order = PartyMenu.battleOrder(st) + end + local view = {} + for i, pi in ipairs(order) do view[i] = party[pi] end + local viewOverlay = overlay + if type(overlay) == "table" then + viewOverlay = {} + for i, pi in ipairs(order) do viewOverlay[i] = overlay[pi] end + end + local o = {} + for k, v in pairs(opts) do o[k] = v end + local active = opts.activeSlot + for i, pi in ipairs(order) do + if pi == active then o.activeSlot = i break end + end + local onSelect, validate = opts.onSelect, opts.validate + if onSelect then + o.onSelect = function(d, mon) return onSelect(d and order[d], mon) end + end + if validate then + o.validate = function(d) return validate(d and order[d]) end + end + PartyMenu._order = order + return view, viewOverlay, o +end + function PartyMenu.show(sessionParty, moveOverlay, opts) if type(moveOverlay) == "table" and opts == nil and (moveOverlay.mode or moveOverlay.session or moveOverlay.battle or moveOverlay.onSelect or moveOverlay.activeSlot) then opts = moveOverlay moveOverlay = nil end opts = opts or {} + PartyMenu._order = nil + if opts.mode == "battle_switch" or opts.mode == "battle_faint" or (opts.mode == "use" and opts.battleOrder) then + local party0 = sessionParty or (opts.session and opts.session.party) + local ov0 = moveOverlay or (opts.session and (opts.session.move_overlay or opts.session.moveOverlay)) + if party0 then + sessionParty, moveOverlay, opts = apply_battle_order(party0, ov0, opts) + end + end destroy_party_oam() PartyMenu.open = true PartyMenu._party = sessionParty or (opts.session and opts.session.party) @@ -398,8 +603,9 @@ function PartyMenu.show(sessionParty, moveOverlay, opts) PartyMenu._bag = opts.bag or (opts.session and (opts.session.bag or opts.session.inventory)) PartyMenu._item = opts.item PartyMenu._activeSlot = opts.activeSlot or 1 + PartyMenu._layout = (opts.layout == "double") and "double" or "single" PartyMenu._battle = opts.battle or (opts.mode == "battle_switch" or opts.mode == "battle_faint") - PartyMenu.cursor = (opts.mode == "battle_switch" and PartyMenu._activeSlot == 1 and #(PartyMenu._party or {}) > 1) and 2 or 1 + PartyMenu.cursor = 1 PartyMenu.mode = opts.mode or "list" PartyMenu._previousMode = PartyMenu.mode PartyMenu.summaryPage = 1 @@ -743,7 +949,7 @@ function PartyMenu.handleInput(input) se(5) elseif input:wasPressed("a") then local act = actions[PartyMenu.actionCursor] - if act == "SHIFT" or (PartyMenu._previousMode == "battle_switch" and act == "SWITCH") then + if act == "SHIFT" or act == "SEND OUT" or (PartyMenu._previousMode == "battle_switch" and act == "SWITCH") then se(5) local cb = PartyMenu._onSelect local chosen = PartyMenu.cursor @@ -883,100 +1089,34 @@ function PartyMenu.handleInput(input) return end - -- Battle switch mode - if PartyMenu.mode == "battle_switch" then - local oldCur = PartyMenu.cursor - if input:wasPressed("up") then - PartyMenu.cursor = nav_up(PartyMenu.cursor, n) - elseif input:wasPressed("down") then - PartyMenu.cursor = nav_down(PartyMenu.cursor, n) - elseif input:wasPressed("left") then - PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) - elseif input:wasPressed("right") then - PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) - end - if PartyMenu.cursor ~= oldCur then - se(5) - end - if input:wasPressed("a") then - if PartyMenu.cursor == 7 then - se(9) - PartyMenu.close() - else - local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - local activeSlot = PartyMenu._activeSlot or 1 - local hp = tonumber(mon and mon.hp) or 0 - local name = mon and Pokemon.displayName(mon) or "POKéMON" - if PartyMenu.cursor == activeSlot then - se(9) - PartyMenu.showMessage(name .. " is already in\nbattle!", function() - PartyMenu.mode = "battle_switch" - end) - elseif hp <= 0 then - se(9) - PartyMenu.showMessage("There's no will to\nfight!", function() - PartyMenu.mode = "battle_switch" - end) - elseif mon and mon.isEgg then - se(9) - PartyMenu.showMessage("An EGG can't battle!", function() - PartyMenu.mode = "battle_switch" - end) - else - se(5) - PartyMenu.ACTIONS = { "SHIFT", "SUMMARY", "CANCEL" } - PartyMenu._previousMode = "battle_switch" - PartyMenu.mode = "action" - PartyMenu.actionCursor = 1 - end + if PartyMenu.mode == "battle_switch" or PartyMenu.mode == "battle_faint" then + local sendOut = PartyMenu.mode == "battle_faint" + if is_double() then + battle_nav_double(input) + else + local oldCur = PartyMenu.cursor + if input:wasPressed("up") then + PartyMenu.cursor = nav_up(PartyMenu.cursor, n) + elseif input:wasPressed("down") then + PartyMenu.cursor = nav_down(PartyMenu.cursor, n) + elseif input:wasPressed("left") then + PartyMenu.cursor, PartyMenu._lastSelectedSlot = nav_left(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) + elseif input:wasPressed("right") then + PartyMenu.cursor = nav_right(PartyMenu.cursor, n, PartyMenu._lastSelectedSlot) end - elseif input:wasPressed("b") or input:wasPressed("start") then - se(9) - PartyMenu.close() + if PartyMenu.cursor ~= oldCur then se(5) end end - return - end - - -- Battle faint forced replacement mode - if PartyMenu.mode == "battle_faint" then - if input:wasPressed("up") then - PartyMenu.cursor = ((PartyMenu.cursor - 2) % n) + 1 - se(5) - elseif input:wasPressed("down") then - PartyMenu.cursor = (PartyMenu.cursor % n) + 1 - se(5) - elseif input:wasPressed("a") then - local mon = PartyMenu._party and PartyMenu._party[PartyMenu.cursor] - local activeSlot = PartyMenu._activeSlot - local hp = tonumber(mon and mon.hp) or 0 - local name = mon and Pokemon.displayName(mon) or "POKéMON" - if activeSlot and PartyMenu.cursor == activeSlot and hp <= 0 then - se(9) - PartyMenu.showMessage(name .. " has no will\nto fight!", function() - PartyMenu.mode = "battle_faint" - end) - elseif hp <= 0 then - se(9) - PartyMenu.showMessage("There's no will to\nfight!", function() - PartyMenu.mode = "battle_faint" - end) - elseif mon and mon.isEgg then - se(9) - PartyMenu.showMessage("An EGG can't battle!", function() - PartyMenu.mode = "battle_faint" - end) + local cancel = input:wasPressed("b") or (input:wasPressed("a") and PartyMenu.cursor == 7) + if cancel then + -- pokefirered/src/party_menu.c:1229 + if sendOut then + se(26) else se(5) - PartyMenu.ACTIONS = { "SHIFT", "SUMMARY", "CANCEL" } - PartyMenu._previousMode = "battle_faint" - PartyMenu.mode = "action" - PartyMenu.actionCursor = 1 + PartyMenu.close() end - elseif input:wasPressed("b") or input:wasPressed("start") then - se(9) - PartyMenu.showMessage("Choose a POKéMON.", function() - PartyMenu.mode = "battle_faint" - end) + elseif input:wasPressed("a") then + open_battle_actions_double(PartyMenu.mode) end return end @@ -984,7 +1124,10 @@ function PartyMenu.handleInput(input) -- Selection mode for item USE if PartyMenu.mode == "use" then local oldCur = PartyMenu.cursor - if input:wasPressed("up") then + if is_double() then + battle_nav_double(input) + oldCur = PartyMenu.cursor + elseif input:wasPressed("up") then PartyMenu.cursor = nav_up(PartyMenu.cursor, n) elseif input:wasPressed("down") then PartyMenu.cursor = nav_down(PartyMenu.cursor, n) @@ -1124,6 +1267,7 @@ function PartyMenu.handleInput(input) local newMax = newStats.maxHp local newHp = math.min(newMax, oldHp + math.max(0, newMax - oldMax)) mon.hp = newHp + ItemUse.levelUpEvent(mon, mon.level) pcall(function() require("src.core.game3.audio").playFanfare(257) end) -- MUS_LEVEL_UP (257) @@ -1242,7 +1386,8 @@ function PartyMenu.handleInput(input) -- Case 4: General Medicine / Potions / Status local startHp = tonumber(mon and mon.hp) or 0 local maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1 - local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, PartyMenu.cursor) + local realSlot = (PartyMenu._order and PartyMenu._order[PartyMenu.cursor]) or PartyMenu.cursor + local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, realSlot) local endHp = tonumber(mon and mon.hp) or startHp if ok then se(2) @@ -1433,11 +1578,11 @@ end -- BG + text only; OAM sprites flushed by Display.present. local function draw_filled_slot(i, mon, selected) - local win = SLOT_WIN[i] + local win = slot_win(i) if not win then return end local T = Display.TILE or 8 local baseX, baseY = win.left * T, win.top * T - local info = (i == 1) and INFO_LEFT or INFO_RIGHT + local info = slot_info(i) PartyChrome.drawSlot(win.kind, win.left, win.top, selected) @@ -1471,23 +1616,27 @@ function PartyMenu.draw() if not PartyMenu.open then return end local party = PartyMenu._party or {} - PartyChrome.drawBg() - sync_all_oam() - - if PartyMenu.mode == "summary" then - SummaryMenu.draw() + if PartyMenu.mode == "summary" or SummaryMenu.isOpen() then + destroy_party_oam() + if PartyMenu.mode == "summary" then + PartyChrome.drawBg() + SummaryMenu.draw() + end return end + PartyChrome.drawBg() + sync_all_oam() + destroy_id(PartyMenu._summaryIcon) PartyMenu._summaryIcon = nil for i = 1, 6 do local mon = party[i] - local win = SLOT_WIN[i] + local win = slot_win(i) if mon then draw_filled_slot(i, mon, i == PartyMenu.cursor or PartyMenu.switchFrom == i) - elseif i > 1 and win then + elseif i > 1 and win and win.kind ~= "main" then PartyChrome.drawSlot("empty", win.left, win.top, false) end end diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index 853134d9..927bfefa 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -6,6 +6,7 @@ local Window = require("src.ui.game3.window") local Chrome = require("src.ui.game3.chrome") local FrlgFont = require("src.ui.game3.frlg_font") local Strings = require("src.core.Strings") +local ModRuntime = require("src.mods.Runtime") local StartMenu = {} @@ -58,6 +59,11 @@ function StartMenu.show(opts) StartMenu._game = opts.game StartMenu._onClose = opts.onClose StartMenu.ENTRIES = build_entries(opts.session) + if ModRuntime.wantsHook("ui.start_menu.items") then + local hooked = ModRuntime.call("ui.start_menu.items", function(_, items) return items end, + opts.game, StartMenu.ENTRIES) + if type(hooked) == "table" then StartMenu.ENTRIES = hooked end + end local pos = tonumber(StartMenu.cursor) or 1 if pos < 1 or pos > #StartMenu.ENTRIES then pos = 1 end -- pokefirered/src/menu.c:276 StartMenu.cursor = pos -- pokefirered/src/start_menu.c:329 @@ -119,7 +125,10 @@ function StartMenu.confirm() local e = StartMenu.ENTRIES[StartMenu.cursor] if not e then return end local session = StartMenu._session - if e.id == "exit" then + if type(e.onSelect) == "function" then + local ok, err = pcall(e.onSelect, StartMenu._game, session) + if not ok then print("[game3/start_menu] onSelect failed: " .. tostring(err)) end + elseif e.id == "exit" then StartMenu._confirmExit = true StartMenu._confirmCursor = 2 -- Default to NO elseif e.id == "bag" then diff --git a/src/ui/game3/summary_chrome.lua b/src/ui/game3/summary_chrome.lua index 3697e49a..d8b5bfe0 100644 --- a/src/ui/game3/summary_chrome.lua +++ b/src/ui/game3/summary_chrome.lua @@ -231,7 +231,11 @@ end function SummaryChrome.menuInfoImage() if SummaryChrome._menuInfo then return SummaryChrome._menuInfo end - local img = load_png(summary_root() .. "/menu_info.png") + local raw = read_bytes(summary_root() .. "/menu_info.rgba") + local img = raw and rgba_to_image(raw, 128, 128) + if not img then + img = load_png(summary_root() .. "/menu_info.png") + end if not img then img = load_png("src/import/gba/chrome/menus/menu_info.png") end diff --git a/src/world/game3/WorldAPI.lua b/src/world/game3/WorldAPI.lua new file mode 100644 index 00000000..8c5416aa --- /dev/null +++ b/src/world/game3/WorldAPI.lua @@ -0,0 +1,662 @@ +-- mod.world for Gen 3 (FireRed): the Gen 1 / Gen 2 WorldAPI method set, +-- resolved against src/core/game3 at call time. + +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Gen3Compat = require("src.mods.Gen3Compat") + +local WorldAPI = {} +WorldAPI.__index = WorldAPI + +local NO_OVERWORLD = "no overworld" +local UNSUPPORTED = "not supported on FireRed yet" +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +local warned = {} +local function warnOnce(key, fmt, ...) + if warned[key] then return end + warned[key] = true + Logger.warn(fmt, ...) +end + +local function loaded(name) + return package.loaded["src.core.game3." .. name] +end + +local function g3(name) + local ok, module = pcall(require, "src.core.game3." .. name) + if ok then return module end + return nil +end + +local function session() + local R = loaded("runtime") + local s = R and R.getSession and R.getSession() + return s +end + +local function validPartySlot(party, slot) + return type(slot) == "number" and slot == math.floor(slot) + and party[slot] ~= nil +end + +local function monInfo(mon, slot) + local P = loaded("pokemon") + local name = mon.nickname + if name == nil or name == "" then name = mon.name end + if (name == nil or name == "") and P and P.name then name = P.name(mon.species) end + return { slot = slot, species = Gen3Compat.speciesName(mon.species) or mon.species, + gen3Species = mon.species, name = name, level = mon.level, hp = mon.hp, + maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp } +end + +function WorldAPI.new(game, modId) + return setmetatable({ game = game, modId = modId }, WorldAPI) +end + +function WorldAPI:_field() + local F = loaded("field") + if not (F and F.running and session()) then return nil end + local game = self.game + if game and game.phase ~= nil and game.phase ~= "field" then return nil end + return F +end + +function WorldAPI:overworld() + if not self:_field() then return nil end + return Gen3Compat.resolve("src.world.OverworldController") +end + +function WorldAPI:current() + if not self:_field() then return nil, NO_OVERWORLD end + local M = loaded("map") + local P = loaded("player") + local s = session() + local mapId = (M and M.current) or s.map + return { mapId = mapId, gen1MapId = Gen3Compat.gen1MapId(mapId), + x = P and P.cellX or s.x, y = P and P.cellY or s.y, + facing = P and P.facing or s.facing } +end + +local function validCoordinate(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge + and value == math.floor(value) +end + +function WorldAPI:activeBlockAt(mapId, bx, by) + if not self:_field() then return nil, NO_OVERWORLD end + local M = loaded("map") + if not (M and M.current) then return nil, NO_OVERWORLD end + if Gen3Compat.gen3MapId(mapId) ~= M.current then return nil, "map is not active" end + if not validCoordinate(bx) or not validCoordinate(by) then + return nil, "invalid block coordinates" + end + local C = loaded("collision") + if not (C and C.inBounds(bx, by)) then return nil, "block coordinates out of bounds" end + local behavior = C.behavior(bx, by) + if not validCoordinate(behavior) then return nil, "block unavailable" end + warnOnce("activeBlockAt", + "[%s] mod.world:activeBlockAt on FireRed answers the 16px metatile's " + .. "BEHAVIOUR byte; FireRed has no 32px blocks", tostring(self.modId)) + return behavior +end + +function WorldAPI:canReorderParty() + local s = session() + local party = s and s.party or {} + return #party > 1 and self:_field() ~= nil and not Gen3Compat.worldBusy() +end + +function WorldAPI:reorderParty(fromSlot, toSlot) + if not self:_field() then return nil, NO_OVERWORLD end + if Gen3Compat.worldBusy() then return nil, "world is busy" end + local s = session() + local party = s.party or {} + if not validPartySlot(party, fromSlot) or not validPartySlot(party, toSlot) then + return nil, "invalid party slot" + end + if fromSlot ~= toSlot then + party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot] + local overlay = s.move_overlay + if type(overlay) == "table" then + overlay[fromSlot], overlay[toSlot] = overlay[toSlot], overlay[fromSlot] + end + local A = loaded("audio") + if A and A.playSe then pcall(A.playSe, 5) end + end + return true +end + +local FIELD_ACTIONS = { + { id = "cut", move = "CUT" }, + { id = "surf", move = "SURF" }, + { id = "strength", move = "STRENGTH" }, + { id = "flash", move = "FLASH" }, + { id = "rock_smash", move = "ROCK_SMASH" }, + { id = "waterfall", move = "WATERFALL" }, + { id = "dig", move = "DIG" }, + { id = "teleport", move = "TELEPORT" }, + { id = "sweet_scent", move = "SWEET_SCENT" }, +} + +local HEAL_ACTIONS = { + { id = "softboiled", move = "SOFTBOILED" }, + { id = "milk_drink", move = "MILK_DRINK" }, +} + +local function fieldContext(mon) + local P = loaded("player") + local C = loaded("collision") + local Objects = loaded("objects") + local M = loaded("map") + local S = package.loaded["src.core.game3.scripting.space"] + local s = session() + local d = DELTA[P and P.facing or "down"] or DELTA.down + local fx, fy = P.cellX + d[1], P.cellY + d[2] + local mapDef = M and M.currentDef and M.currentDef() + return { + party = s.party, + mon = mon, + store = S and S.store, + session = s, + facingObject = Objects and Objects.at(fx, fy), + isFacingWater = C and C.isWater and C.isWater(fx, fy), + isSurfing = P.surfing == true, + hasCuttableGrass = C and C.isGrass + and (C.isGrass(fx, fy) or C.isGrass(P.cellX, P.cellY)), + mapType = mapDef and mapDef.type, + } +end + +local function moveResult(move) + local FM = g3("field_moves") + local s = session() + if not (FM and s) then return nil end + local mon = FM.partyMoveUser(s.party, move) + if not mon then return nil end + local ok, res = pcall(FM.fromMenu, move, fieldContext(mon)) + if ok and res and res.ok then return res, mon end + return nil +end + +local function healSources(move) + local FM = g3("field_moves") + local s = session() + local out = {} + if not (FM and s) then return out end + local P = loaded("pokemon") + local num = FM.MOVES[move] + for sourceSlot, source in ipairs(s.party or {}) do + local maxHp = source.maxHp or source.hp or 0 + local cost = math.floor(maxHp / 5) + if P and num and P.knowsMove(source, num) and (source.hp or 0) > cost then + local info = monInfo(source, sourceSlot) + info.cost = cost + info.targets = {} + for targetSlot, target in ipairs(s.party) do + if FM.softboiledTargetOk(source, target) then + info.targets[#info.targets + 1] = monInfo(target, targetSlot) + end + end + if #info.targets > 0 then out[#out + 1] = info end + end + end + return out +end + +local function bikeId() + return Gen3Compat.itemId("BICYCLE") or 360 +end + +function WorldAPI:availableFieldActions() + local out = {} + if not self:_field() then return out, NO_OVERWORLD end + if Gen3Compat.worldBusy() then return out, "world is busy" end + local s = session() + local Bag = g3("bag") + local P = loaded("player") + if Bag and s.bag and Bag.has(s.bag, bikeId(), 1) and not (P and P.surfing) then + out[#out + 1] = { id = "bicycle", + label = (P and P.biking) and "BIKE OFF" or "BICYCLE" } + end + for _, row in ipairs(FIELD_ACTIONS) do + if moveResult(row.move) then + out[#out + 1] = { id = row.id, label = (row.move:gsub("_", " ")) } + end + end + for _, row in ipairs(HEAL_ACTIONS) do + local sources = healSources(row.move) + if #sources > 0 then + out[#out + 1] = { id = row.id, label = (row.move:gsub("_", " ")), + sources = sources } + end + end + return out +end + +function WorldAPI:useFieldAction(id, opts) + local F = self:_field() + if not F then return nil, NO_OVERWORLD end + if Gen3Compat.worldBusy() then return nil, "world is busy" end + local found + for _, action in ipairs(self:availableFieldActions()) do + if action.id == id then found = action break end + end + if not found then return nil, "field action unavailable" end + local s = session() + local Message = package.loaded["src.ui.game3.message"] + if id == "bicycle" then + local IU = g3("item_use") + if not IU then return nil, "field action unavailable" end + local ok, _, text = IU.useBike(s) + if text and Message then Message.show(text, function() Message.close() end) end + return ok and true or nil, (not ok) and "field action unavailable" or nil + end + if id == "softboiled" or id == "milk_drink" then + local FM = g3("field_moves") + local sourceSlot = opts and tonumber(opts.sourceSlot) + local targetSlot = opts and tonumber(opts.targetSlot) + local cost + 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 cost = source.cost break end + end + end + end + if not (cost and FM) then return nil, "softboiled target unavailable" end + local ok = FM.softboiledTransfer(s.party[sourceSlot], s.party[targetSlot], cost) + if not ok then return nil, "softboiled target unavailable" end + return true + end + for _, row in ipairs(FIELD_ACTIONS) do + if row.id == id then + local res = moveResult(row.move) + if not (res and F.executeFieldMove) then return nil, "field action unavailable" end + F.executeFieldMove(res) + return true + end + end + return nil, "field action unavailable" +end + +function WorldAPI:canFly() + if not self:_field() then return false end + return moveResult("FLY") ~= nil +end + +function WorldAPI:flyTo(mapId) + if not self:_field() then return nil, NO_OVERWORLD end + if not self:canFly() then return nil, "fly unavailable" end + warnOnce("flyTo", + "[%s] mod.world:flyTo(%s): FireRed's fly destinations are the region " + .. "map's town spawn points (src/ui/game3/region_map.lua), which have no " + .. "seam yet", tostring(self.modId), tostring(mapId)) + return nil, UNSUPPORTED +end + +function WorldAPI:mapOverview() + if not self:_field() then return nil, NO_OVERWORLD end + local M = loaded("map") + local C = loaded("collision") + local def = M and M.currentDef and M.currentDef() + if not (def and C) then return nil, NO_OVERWORLD end + local view = { + id = M.current, + widthCells = tonumber(def.width) or 0, + heightCells = tonumber(def.height) or 0, + } + function view:isWarpTileCell(x, y) return C.warpAt(x, y) ~= nil end + function view:isWaterCell(x, y) return C.isWater(x, y) end + function view:isWalkableCell(x, y) return C.isWalkable(x, y) end + local markers = {} + for _, warp in ipairs(def.warps or {}) do + markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y } + end + local Objects = loaded("objects") + for _, lid in ipairs(Objects and Objects._order or {}) do + local eo = Objects._byId[lid] + local script = eo and eo.def and eo.def.scriptKey + if eo and eo.visible and not eo.hidden and eo.def and eo.def.item then + markers[#markers + 1] = { kind = "item", x = eo.cellX, y = eo.cellY, + script = script } + end + end + return require("src.world.MapOverview").build(view, markers) +end + +function WorldAPI:warpTo(mapId, x, y, facing, opts) + if not self:_field() then return nil, NO_OVERWORLD end + local raw = Gen3Compat.gen3MapId(mapId) + if not raw then return nil, "unknown map: " .. tostring(mapId) end + if not (x and y) then return nil, "warpTo needs x and y" end + local W = g3("warp") + if not W then return nil, NO_OVERWORLD end + local P = loaded("player") + local warpOpts = {} + if opts and opts.arrive == "teleport" then warpOpts.teleport = true end + if opts and opts.fade == false then warpOpts.fade = false end + local ok, err = W.request(nil, self.game, raw, x, y, + facing or (P and P.facing) or "down", warpOpts) + if not ok then return nil, err or "warp failed" end + return true +end + +function WorldAPI:toggleObject(mapId, objRef, visible) + if not self:_field() then return nil, NO_OVERWORLD end + local M = loaded("map") + local raw = Gen3Compat.gen3MapId(mapId) + if not (M and raw and M.current == raw) then + return nil, "map is not active: " .. tostring(mapId) + end + local Objects = loaded("objects") + if not Objects then return nil, "map has no objects" end + local lid + for _, def in ipairs(Objects._defs or {}) do + local id = tonumber(def.localId or def.index) + if id == objRef or def.name == objRef or def.id == objRef then lid = id break end + end + if not lid then return nil, "no such object: " .. tostring(objRef) end + if visible then Objects.addObject(lid) else Objects.removeObject(lid) end + Runtime.emit("world.object_toggled", + { mapId = raw, objName = objRef, visible = visible and true or false }) + return true +end + +function WorldAPI:setFlag(name, value) + if not session() then return nil, "no save" end + return Gen3Compat.setFlag(name, value) +end + +function WorldAPI:getFlag(name) + if not session() then return nil, "no save" end + return Gen3Compat.getFlag(name) +end + +local ENCOUNTER_TERRAIN = { grass = true, water = true, indoor = true } +local LAND_WEIGHTS = { 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 } +local WATER_WEIGHTS = { 60, 30, 5, 4, 1 } + +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + if not ENCOUNTER_TERRAIN[terrain] then + return nil, "invalid terrain: " .. tostring(terrain) + end + local E = g3("encounters") + if not E then return { chance = 0, dist = {} } end + if E.ensureLoaded then pcall(E.ensureLoaded) end + local raw = Gen3Compat.gen3MapId(mapId) or mapId + local tables = E._tables or {} + local t = tables[raw] or tables[tostring(raw)] + local keys = terrain == "water" and { "water" } or { "land", "grass" } + local weights = terrain == "water" and WATER_WEIGHTS or LAND_WEIGHTS + local area + for _, key in ipairs(keys) do + area = t and t[key] + if area then break end + end + local slots = area and (area.slots or area.mons or (#area > 0 and area)) + local rate = tonumber(area and area.rate) or 0 + local dist = {} + for i, weight in ipairs(weights) do + local slot = slots and slots[i] + local sp = type(slot) == "table" and (slot.species or slot[1]) + if sp then + local name = Gen3Compat.speciesName(sp) or sp + dist[name] = (dist[name] or 0) + weight + end + end + if Runtime.wantsHook("encounter.table") then + local transformed = Runtime.call("encounter.table", + function(d) return d end, dist, + { mapId = raw, terrain = terrain, preview = true, opts = opts }) + if type(transformed) == "table" then dist = transformed end + end + return { chance = math.min(rate * 16, 2880) / 2880, dist = dist } +end + +function WorldAPI:replaceBlock(bx, by, block) + local F = self:_field() + if not F then return nil, NO_OVERWORLD end + warnOnce("replaceBlock", + "[%s] mod.world:replaceBlock on FireRed sets one 16px METATILE at (x, y); " + .. "FireRed has no 32px blocks", tostring(self.modId)) + F.setMetatile(bx, by, block) + local M = loaded("map") + Runtime.emit("world.block_replaced", + { mapId = M and M.current, bx = bx, by = by, block = block }) + return true +end + +function WorldAPI:spawnNpc(_mapId, _objDef) + warnOnce("spawnNpc", + "[%s] mod.world:spawnNpc: FireRed event objects come from the map's " + .. "extracted templates only", tostring(self.modId)) + return nil, UNSUPPORTED +end + +function WorldAPI:removeNpc(npcId) + if not self:_field() then return nil, NO_OVERWORLD end + local Objects = loaded("objects") + if not Objects then return nil, NO_OVERWORLD end + warnOnce("removeNpc", + "[%s] mod.world:removeNpc on FireRed hides a template object by localId " + .. "and sets its hide flag", tostring(self.modId)) + return Objects.removeObject(npcId) and true or nil +end + +local Handle = {} +Handle.__index = Handle + +function Handle:scriptMove(dir, tiles, onDone) + local Objects = loaded("objects") + if not Objects then return nil, NO_OVERWORLD end + if not DELTA[dir] then return nil, "unknown direction: " .. tostring(dir) end + local actions = {} + for _ = 1, math.max(0, tiles or 1) do + actions[#actions + 1] = { kind = "step", dir = dir } + end + Objects.startTrack(self.id, actions, onDone) + return true +end + +function Handle:marchInPlace(onDone) + local Objects = loaded("objects") + if not Objects then return nil, NO_OVERWORLD end + Objects.startTrack(self.id, { { kind = "turn", dir = self.npc.facing }, + { kind = "sleep", frames = 16 } }, onDone) + return true +end + +function Handle:face(dir) + local Objects = loaded("objects") + if not (Objects and DELTA[dir]) then return nil, "unknown direction" end + Objects.scriptFace(self.npc, dir) + return true +end + +function Handle:position() + return self.npc.cellX, self.npc.cellY +end + +function Handle:stepNow(dir) + local npc = self.npc + local d = DELTA[dir] + if not d then return nil, "bad direction: " .. tostring(dir) end + if npc.moving then return nil, "already moving" end + local Objects = loaded("objects") + if not Objects then return nil, NO_OVERWORLD end + local wasFrozen = npc.frozen + Objects.scriptStep(npc, dir) + npc.frozen = wasFrozen + npc.scriptBusy = false + return true +end + +function Handle:canStep(dir) + local d = DELTA[dir] + local C = loaded("collision") + if not (d and C) then return false end + local tx, ty = self.npc.cellX + d[1], self.npc.cellY + d[2] + return C.canEnter(nil, tx, ty, {}) and true or false +end + +function Handle:placeAt(x, y, facing) + local npc = self.npc + npc.moving = false + npc.progress = 0 + npc.cellX, npc.cellY = x, y + npc.targetX, npc.targetY = x, y + npc.px, npc.py = x * 16, y * 16 + if facing then npc.facing = facing end + return true +end + +function Handle:isMoving() + return self.npc.moving and true or false +end + +function Handle:setPassable(passable) + self.npc.passable = passable and true or false + return true +end + +function WorldAPI:npc(mapId, indexOrName) + if not self:_field() then return nil, NO_OVERWORLD end + local M = loaded("map") + local raw = Gen3Compat.gen3MapId(mapId) + if not (M and M.current == raw) then return nil, "map is not active" end + local Objects = loaded("objects") + for _, lid in ipairs(Objects and Objects._order or {}) do + local eo = Objects._byId[lid] + local def = eo and eo.def + if eo and (lid == indexOrName or (def and (def.name == indexOrName + or def.id == indexOrName))) then + return setmetatable({ npc = eo, id = lid }, Handle) + end + end + return nil, "no such object: " .. tostring(indexOrName) +end + +local VERBS = {} + +function VERBS.start_battle(api, row, resume) + if row[2] ~= "wild" then + return nil, "only start_battle \"wild\" is supported on FireRed" + end + return api:startWildBattle(row[3], row[4], resume) +end + +function VERBS.warp(api, row, resume) + local ok, err = api:warpTo(row[2], row[3], row[4], row[5]) + if not ok then return nil, err end + resume() + return true +end + +function VERBS.text(_api, row, resume) + local Message = package.loaded["src.ui.game3.message"] + or select(2, pcall(require, "src.ui.game3.message")) + if type(Message) ~= "table" then return nil, NO_OVERWORLD end + Message.show(tostring(row[2] or ""), function() + Message.close() + resume() + end) + return true +end + +function VERBS.setflag(api, row, resume) + local ok, err = api:setFlag(row[2], true) + if not ok then return nil, err end + resume() + return true +end + +function VERBS.clearflag(api, row, resume) + local ok, err = api:setFlag(row[2], false) + if not ok then return nil, err end + resume() + return true +end + +function WorldAPI:queueScript(rows, extra) + if not self:_field() then return nil, NO_OVERWORLD end + if type(rows) ~= "table" then return nil, "queueScript wants a row list" end + if self.queue then return nil, "a script is already running" end + for i, row in ipairs(rows) do + local name = type(row) == "table" and row[1] + if not VERBS[name] then + return nil, ("unsupported script command on FireRed: %s (row %d)") + :format(tostring(name), i) + end + end + self.queue = true + local pc = 0 + local step + local function finish(err) + self.queue = nil + if err then + Logger.warn("[%s] queueScript stopped: %s", tostring(self.modId), err) + end + if extra and extra.onDone then extra.onDone(err == nil) end + end + step = function() + pc = pc + 1 + local row = rows[pc] + if not row then return finish(nil) end + local ok, err = VERBS[row[1]](self, row, function() step() end) + if not ok then finish(err or "row failed") end + end + step() + return true +end + +function WorldAPI:startWildBattle(species, level, onDone) + if not self:_field() then return nil, NO_OVERWORLD end + local sp = Gen3Compat.speciesId(species) + local P = g3("pokemon") + if not (sp and P and P.name and P._names and P._names[sp]) then + return nil, "unknown species: " .. tostring(species) + end + level = tonumber(level) + if not level or level % 1 ~= 0 or level < 1 or level > 100 then + return nil, "level must be a whole number 1..100" + end + local B = package.loaded["src.core.game3.battle.init"] + if B and B.isActive and B.isActive() then return nil, "a battle is already running" end + local W = loaded("warp") + if W and W.isBusy and W.isBusy() then return nil, "the world is mid-warp" end + local s = session() + local healthy = false + for _, mon in ipairs(s.party or {}) do + if (tonumber(mon.hp) or 0) > 0 then healthy = true break end + end + if not healthy then return nil, "no healthy party" end + local BB = g3("battle_bridge") + if not BB then return nil, NO_OVERWORLD end + local ok, err = BB.startWild(nil, self.game, { species = sp, level = level }, + { done = onDone and function() onDone() end or nil }) + if not ok then return nil, err or "battle failed" end + return true +end + +function WorldAPI:invalidateMap(mapId) + local raw = Gen3Compat.gen3MapId(mapId) or mapId + local M = loaded("map") + if not (self:_field() and M and M.current == raw) then + Runtime.emit("map.reloaded", { mapId = raw, reason = "invalidate" }) + return true + end + local P = loaded("player") + local ok, err = pcall(M.load, nil, self.game, raw, { x = P and P.cellX, + y = P and P.cellY, facing = P and P.facing, depth1Connections = true }) + if not ok then + Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId), + tostring(mapId), tostring(err)) + return nil, tostring(err) + end + Runtime.emit("map.reloaded", { mapId = raw, reason = "invalidate" }) + return true +end + +return WorldAPI diff --git a/tests/drivers/game3_doubles.lua b/tests/drivers/game3_doubles.lua new file mode 100644 index 00000000..8acc8578 --- /dev/null +++ b/tests/drivers/game3_doubles.lua @@ -0,0 +1,231 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_doubles" + +local TRAINER_TWINS_ELI_ANNE = 484 +local MAGIKARP, IVYSAUR, WARTORTLE = 129, 2, 8 +local TURN_CAP = 30 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_doubles") + love.event.quit(0) + else + print("FAIL game3_doubles failures=" .. failures) + love.event.quit(1) + end +end + +local function log_has(Ui, needle) + for _, t in ipairs(Ui.log and Ui.log() or {}) do + if type(t) == "string" and t:find(needle, 1, true) then return true end + end + return false +end + +local function find_trainer_object(TrainerSight, Objects, tid) + local found = {} + for _, lid in ipairs(Objects.listActive()) do + local eo = Objects.find(lid) + if eo and TrainerSight.getTrainerId(eo) == tid then found[#found + 1] = eo end + end + table.sort(found, function(a, b) return (a.cellX or 0) < (b.cellX or 0) end) + return found[1], found[2] +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Party = require("src.core.game3.party") + local Objects = require("src.core.game3.objects") + local TrainerSight = require("src.core.game3.trainer_sight") + local Trainers = require("src.core.game3.scripting.trainers") + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + + local info = Trainers.info(TRAINER_TWINS_ELI_ANNE) + result(info and info.doubleBattle == true, "trainer 484 ELI & ANNE is flagged doubleBattle") + + session.party = {} + Party.giveMon(session, MAGIKARP, 5) + result(Party.monsStateToDoubles(session.party) == Party.PLAYER_HAS_ONE_MON, "one mon: PLAYER_HAS_ONE_MON") + + local okC, MapCatalog = pcall(require, "src.import.gba.map_catalog") + local mapId = (okC and MapCatalog.pretToEngine and MapCatalog.pretToEngine("Route8")) or "FR_ROUTE_8" + Map.load(nil, game, mapId, { x = 40, y = 6, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 40, 6, "up" + U.wait(90) + session = Runtime.getSession() + + local eli, anne = find_trainer_object(TrainerSight, Objects, TRAINER_TWINS_ELI_ANNE) + if not result(eli ~= nil, "found a TRAINER_TWINS_ELI_ANNE object on " .. tostring(mapId)) then return finish() end + result(anne ~= nil, "found the second twin object sharing the trainer id") + result(TrainerSight.battleType(eli) == 4, "twin script is trainerbattle type 4 (got " .. tostring(TrainerSight.battleType(eli)) .. ")") + result(TrainerSight.blockedByDoubles(eli) == true, "trainer sight blocks a one-mon player (trainer_see.c:114)") + + local tx, ty = eli.cellX, eli.cellY + 1 + Map.load(nil, game, mapId, { x = tx, y = ty, facing = "up" }) + game.session.x, game.session.y, game.session.facing = tx, ty, "up" + U.wait(60) + local Battle = require("src.core.game3.battle") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + U.tap(game, "a") + local sawMsg = false + for _ = 1, 240 do + if Message.isOpen and Message.isOpen() then sawMsg = true break end + U.wait(1) + end + result(sawMsg, "talking with one mon shows the not-enough-mons line") + U.wait(30) + U.shot(game, DIR .. "/00_not_enough.png") + for _ = 1, 200 do + if not (Message.isOpen and Message.isOpen()) and not (Space.vm and Space.vm:isRunning()) then break end + U.tap(game, "a") + U.wait(4) + end + local fid = Flags.trainerFlagId(TRAINER_TWINS_ELI_ANNE) + result(not Battle.isActive(), "no battle with one mon") + result(not Flags.getFlag(Space.store, Space.vm and Space.vm.ctx, fid), "trainer flag still clear after the refusal") + + Party.giveMon(session, IVYSAUR, 30) + Party.giveMon(session, WARTORTLE, 30) + result(Party.monsStateToDoubles(session.party) == Party.PLAYER_HAS_TWO_USABLE_MONS, "three mons: PLAYER_HAS_TWO_USABLE_MONS") + result(TrainerSight.blockedByDoubles(eli) == false, "trainer sight allows the pair once two mons are usable") + local moneyBefore = tonumber(session.money) or 0 + + local Ui = require("src.core.game3.battle.ui") + local Anim = require("src.core.game3.battle.anim") + local PartyMenu = require("src.ui.game3.party_menu") + for _ = 1, 600 do + if Battle.isActive() then break end + U.tap(game, "a") + U.wait(3) + end + if not result(Battle.isActive(), "trainerbattle script started the battle") then return finish() end + + local st = Battle._st + result(st and st.double == true, "battle state is double") + result(st and st.battlersCount == 4, "battlersCount is 4") + result(st and st.battlers[2] ~= nil and not st.absent[2], "player right flank present") + result(st and st.battlers[3] ~= nil and not st.absent[3], "opponent right flank present") + + local shots = {} + local function shot_once(key, name) + if shots[key] then return end + shots[key] = true + U.wait(2) + result(U.shot(game, DIR .. "/" .. name .. ".png"), "screenshot " .. name) + end + + for _ = 1, 400 do + if Battle._phase ~= "intro" then break end + if log_has(Ui, " and ") and Ui.dialogPending and Ui.dialogPending() then + U.wait(20) + shot_once("intro", "01_intro_sent_out") + end + if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + end + result(log_has(Ui, "sent\nout ") and log_has(Ui, " and "), "intro used the two-mon send-out line") + result(log_has(Ui, "Go! ") and log_has(Ui, " and\n"), "intro used Go! X and Y!") + + local sawFaint, sawReplacement = false, false + local guard = 0 + while Battle.isActive() and guard < 20000 do + guard = guard + 1 + st = Battle._st + if st and (st.turn or 0) > TURN_CAP then break end + local phase = Battle._phase + if st then + for id = 0, 3 do + local b = st.battlers[id] + if b and b.mon and (tonumber(b.mon.hp) or 0) <= 0 and not sawFaint then + sawFaint = true + U.wait(10) + shot_once("faint", "05_faint") + end + end + end + if PartyMenu.isOpen and PartyMenu.isOpen() then + sawReplacement = sawReplacement or phase == "switching" + shot_once("replace", "06_replacement_party") + local pick + for i = 1, #(PartyMenu._party or {}) do + local ok = PartyMenu._validate == nil or PartyMenu._validate(i) == nil + local mon = PartyMenu._party[i] + if ok and mon and (tonumber(mon.hp) or 0) > 0 then pick = i break end + end + result(pick ~= nil, "a valid replacement is listed") + if not pick then return finish() end + PartyMenu.cursor = pick + U.wait(30) + U.tap(game, "a") + U.wait(20) + result(PartyMenu.mode == "action" and PartyMenu.ACTIONS[1] == "SEND OUT", + "replacement action menu offers SEND OUT") + shot_once("replace_action", "06b_send_out_menu") + PartyMenu.actionCursor = 1 + U.tap(game, "a") + U.wait(20) + result(not PartyMenu.isOpen(), "SEND OUT closed the party menu") + elseif phase == "command" and Ui._mode == "menu" and not Anim.busy() then + local who = Ui.activeBattler and Ui.activeBattler() or 0 + shot_once("cmd" .. tostring(who), who == 0 and "02_command_left" or "02_command_right") + U.tap(game, "a") + U.wait(8) + elseif phase == "command" and Ui._mode == "moves" then + U.tap(game, "a") + U.wait(8) + elseif phase == "command" and Ui._mode == "target" then + shot_once("target", "03_target_select") + U.tap(game, "a") + U.wait(8) + elseif phase == "animating" or phase == "residuals" then + if not shots.mid then + U.wait(12) + shot_once("mid", "04_mid_turn") + end + if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + else + if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + if Ui.choiceActive and Ui.choiceActive() then U.tap(game, "b") U.wait(4) end + end + end + + local res = Battle.getResult() or (st and st.result) + result(not Battle.isActive(), "battle ended within " .. TURN_CAP .. " turns (turn=" .. tostring(st and st.turn) .. ")") + result(res == "win", "player won the double battle (result=" .. tostring(res) .. ")") + result(sawFaint, "a battler fainted during the fight") + if sawReplacement then + result(true, "player picked a replacement for a fainted slot") + else + print("NOTE no player replacement was needed this run") + end + for _ = 1, 600 do + if not (Space.vm and Space.vm:isRunning()) and not (Message.isOpen and Message.isOpen()) then break end + U.tap(game, "a") + U.wait(3) + end + result(Flags.getFlag(Space.store, Space.vm and Space.vm.ctx, fid), "trainer flag set after the win") + local gained = (tonumber(session.money) or 0) - moneyBefore + local Prize = require("src.core.game3.battle.prize") + local expected = Prize.calc(TRAINER_TWINS_ELI_ANNE, { double = true }) + result(res ~= "win" or gained == expected, "prize money doubled (gained=" .. tostring(gained) .. " expected=" .. tostring(expected) .. ")") + U.shot(game, DIR .. "/07_after.png") + finish() +end diff --git a/tests/drivers/game3_mew_starter.lua b/tests/drivers/game3_mew_starter.lua new file mode 100644 index 00000000..37178053 --- /dev/null +++ b/tests/drivers/game3_mew_starter.lua @@ -0,0 +1,160 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_mew_starter" + +local MEW, CHARMANDER = 151, 4 +local VAR_TEMP_2 = 0x4002 +local VAR_OAKS_LAB_SCENE = 0x4055 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_mew_starter") + love.event.quit(0) + else + print("FAIL game3_mew_starter failures=" .. failures) + love.event.quit(1) + end +end + +local function endsWith(s, tail) + return type(s) == "string" and s:sub(-#tail) == tail +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + + local loader = game.mods + local mod = loader and loader.mods and loader.mods.example_mew_starter + if not result(mod ~= nil and mod.enabled and mod.state == "loaded", + "example_mew_starter loaded on firered (state=" .. tostring(mod and mod.state) + .. " skip=" .. tostring(mod and mod.skipReason) .. ")") then + return finish() + end + + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Pokemon = require("src.core.game3.pokemon") + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + + Map.load(nil, game, "FR_OAKS_LAB", { x = 8, y = 5, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 8, 5, "up" + U.wait(90) + session = Runtime.getSession() + + result(Space.vm ~= nil and Space.store ~= nil, "script VM active in FR_OAKS_LAB") + Flags.setVar(Space.store, Space.vm.ctx, VAR_OAKS_LAB_SCENE, 2) + + local Objects = require("src.core.game3.objects") + local ball + for _, lid in ipairs(Objects.listActive()) do + local eo = Objects.find(lid) + if eo and eo.scriptKey == "g3:08169dae" then ball = eo end + end + if not result(ball ~= nil, "Charmander ball object on the table") then return finish() end + Map.load(nil, game, "FR_OAKS_LAB", { x = ball.cellX, y = ball.cellY + 1, facing = "up" }) + game.session.x, game.session.y, game.session.facing = ball.cellX, ball.cellY + 1, "up" + U.wait(60) + Flags.setVar(Space.store, Space.vm.ctx, VAR_OAKS_LAB_SCENE, 2) + U.tap(game, "a") + U.wait(10) + result(Space.vm ~= nil and Space.vm:isRunning(), "Charmander ball script started by A press") + + local confirmShot = false + for _ = 1, 900 do + if session.party and session.party[1] then break end + local Choice = package.loaded["src.ui.game3.choice"] + if not confirmShot and Choice and Choice.active and Choice.kind == "yesno" then + U.wait(30) + U.shot(game, DIR .. "/00_confirm.png") + confirmShot = true + end + U.tap(game, "a") + U.wait(3) + end + result(Flags.getVar(Space.store, Space.vm.ctx, VAR_TEMP_2) == MEW, + "the starter species var follows the gift (VAR_TEMP_2 == MEW)") + + U.wait(150) + U.shot(game, DIR .. "/00_received.png") + U.wait(2) + local mon = session.party and session.party[1] + if not result(mon ~= nil, "a starter was given") then return finish() end + result(mon.species == MEW, "party[1] species is MEW (got " .. tostring(mon.species) .. ")") + result(mon.level == 20, "party[1] level is 20 (got " .. tostring(mon.level) .. ")") + result(mon.nickname == "HOGHEAD", + "party[1] nickname is HOGHEAD (got " .. tostring(mon.nickname) .. ")") + result(#session.party == 1, "exactly one mon in the party") + + for _ = 1, 1500 do + if not (Space.vm and Space.vm:isRunning()) then break end + U.tap(game, "b") + U.wait(3) + end + result(not (Space.vm and Space.vm:isRunning()), "starter script finished") + result(session.party[1] and session.party[1].nickname == "HOGHEAD", + "nickname survived the rest of the script") + local gone = Objects.find(ball.localId) + result(gone == nil or gone.hidden or not gone.visible, "Charmander ball removed from the table") + + local front = Pokemon.frontPic(MEW) + local back = Pokemon.backPic(MEW) + result(front and endsWith(front.path, "mew_front_inverted_64.png"), + "MEW front pic is the inverted 64x64 (" .. tostring(front and front.path) .. ")") + result(back and endsWith(back.path, "mew_back_inverted_64.png"), + "MEW back pic is the inverted 64x64 (" .. tostring(back and back.path) .. ")") + local char = Pokemon.frontPic(CHARMANDER) + result(char and char.path == nil, "CHARMANDER front pic stays vanilla") + + local PartyMenu = require("src.ui.game3.party_menu") + PartyMenu.show(session.party, { session = session }) + U.wait(150) + result(U.shot(game, DIR .. "/01_party.png"), "party screenshot") + PartyMenu.close() + U.wait(10) + + local SummaryMenu = require("src.ui.game3.summary_menu") + SummaryMenu.openMenu(session.party, 1, { session = session }) + U.wait(150) + result(U.shot(game, DIR .. "/02_summary_front.png"), "summary screenshot") + SummaryMenu.close() + U.wait(20) + + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Anim = require("src.core.game3.battle.anim") + local Ui = require("src.core.game3.battle.ui") + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 3 }, { fade = false }) + if not result(ok == true, "wild battle started " .. tostring(err or "")) then return finish() end + + local ready = false + for _ = 1, 3000 do + if Ui._mode == "menu" and not Anim.busy() then ready = true break end + if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + end + result(ready, "battle reached the action menu") + U.wait(20) + local st = Battle._st + local pSpecies = st and st.player and (st.player.species or (st.player.mon and st.player.mon.species)) + result(pSpecies == MEW, "player battler is MEW (got " .. tostring(pSpecies) .. ")") + result(U.shot(game, DIR .. "/03_battle_back.png"), "battle screenshot (inverted back pic)") + + if BattleBridge.finishPending then BattleBridge.finishPending("run") end + U.wait(30) + finish() +end diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index a9da044f..00360125 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -362,11 +362,21 @@ local Catalog = T.catalog -- Gold's modules live under a gen2/ directory, except the two that own the -- boot and the extractor and carry the generation in their name +local function isGen3Site(path) + return path:match("game3") ~= nil or path:match("Game3") ~= nil + or path:match("Gen3") ~= nil +end + local function isGen2Site(path) + if isGen3Site(path) then return false end return path:match("gen2") ~= nil or path:match("Gen2") ~= nil or path:match("Game2") ~= nil end +local function isGen1Site(path) + return not isGen2Site(path) and not isGen3Site(path) +end + local GEN2_EVENTS = { -- overworld "map.entered", "map.exited", "map.reloaded", "player.warped", @@ -456,7 +466,8 @@ local GEN2_HOOKS = { local function assertShared(name, sites, kind) local gen2, gen1 = 0, 0 for _, path in ipairs(sites) do - if isGen2Site(path) then gen2 = gen2 + 1 else gen1 = gen1 + 1 end + if isGen2Site(path) then gen2 = gen2 + 1 + elseif isGen1Site(path) then gen1 = gen1 + 1 end end T.check(gen2 > 0, ("Gold raises the %s: %s"):format(kind, name)) T.check(gen1 > 0, @@ -502,7 +513,8 @@ local function assertListed(names, catalogNames, sites, kind) if not Catalog.isModEvent(name) then local gen2, gen1 = false, false for _, path in ipairs(sites(name)) do - if isGen2Site(path) then gen2 = true else gen1 = true end + if isGen2Site(path) then gen2 = true + elseif isGen1Site(path) then gen1 = true end end if gen2 and gen1 then T.check(listed[name], @@ -591,13 +603,17 @@ local function sourceOf(path) end local function assertGen2Only(name, sites, kind, guard) - T.check(#sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name)) + local gen2Sites = 0 + for _, path in ipairs(sites) do + if isGen2Site(path) then gen2Sites = gen2Sites + 1 end + end + T.check(gen2Sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name)) local guarded = false for _, path in ipairs(sites) do - T.check(isGen2Site(path), + T.check(not isGen1Site(path), ("a Gen 2-only %s is raised from a Gen 2 module (%s is not one): %s") :format(kind, path, name)) - local body = sourceOf(path) + local body = isGen2Site(path) and sourceOf(path) if body and body:find(('%s("%s")'):format(guard, name), 1, true) then guarded = true end @@ -623,11 +639,12 @@ local function assertGen2OnlyListed(names, catalogNames, sites, kind) for _, name in ipairs(names) do listed[name] = true end for _, name in ipairs(catalogNames) do if not Catalog.isModEvent(name) then - local anyGen1 = false + local anyGen1, anyGen2 = false, false for _, path in ipairs(sites(name)) do - if not isGen2Site(path) then anyGen1 = true end + if isGen1Site(path) then anyGen1 = true end + if isGen2Site(path) then anyGen2 = true end end - if not anyGen1 then + if anyGen2 and not anyGen1 then T.check(listed[name], ("%s %s is raised from Gen 2 modules alone but is not listed as a " .. "Gen 2-only seam; add it here and to " diff --git a/tests/engine/gate_gen3_mod_api.lua b/tests/engine/gate_gen3_mod_api.lua new file mode 100644 index 00000000..bddb89b5 --- /dev/null +++ b/tests/engine/gate_gen3_mod_api.lua @@ -0,0 +1,466 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local GameVersion = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local ModTargets = require("src.mods.ModTargets") +local Schemas = require("src.mods.Schemas") + +T.eq(GameVersion.generation("firered"), 3, "FireRed is Gen 3") +T.eq(table.concat(ModTargets.expand("gen3"), ","), "firered", + "gen3 is every Gen 3 game") + +local function manifest(extra) + local raw = { id = "fix", name = "Fixture", version = "1.0.0", + entry = "main.lua", api = 2 } + for key, value in pairs(extra or {}) do raw[key] = value end + return Manifest.validate(raw, "mods/fix") +end + +T.check(ModTargets.supports(manifest({ games = { "gen3" } }), "firered"), + "games = [gen3] claims FireRed") +T.check(ModTargets.supports(manifest({ games = { "firered" } }), "firered"), + "games = [firered] claims FireRed") +T.check(not ModTargets.supports(manifest(), "firered"), + "a manifest that says nothing is not a FireRed mod") +T.check(not ModTargets.supports(manifest({ gen2compat = true }), "firered"), + "and gen2compat is not a Gen 3 claim") +T.eq(manifest({ games = { "gen3" } }).gen2compat, false, + "a Gen 3 claim is not a Gen 2 claim either") + +local ROUTED = { + pokemon = "gen3Pokemon", moves = "gen3Moves", items = "gen3Items", + encounters = "gen3Encounters", trainers = "gen3Trainers", + text = "gen3Text", map_scripts = "gen3Scripts", +} + +for name, path in pairs(ROUTED) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 3), path, + "available under Gen 3 at its Gen 3 path: " .. name) + T.eq(Schemas.gatedFor(name, 3), false, "not gated under Gen 3: " .. name) + T.eq(Schemas.targetFor(name, spec, 1), spec.target, + "and Gen 1 is untouched by the routing: " .. name) +end + +for _, name in ipairs({ "maps", "commands", "strings" }) do + local spec = Schemas.REGISTRIES[name] + T.eq(Schemas.targetFor(name, spec, 3), spec.target, + "available under Gen 3 at its shared path: " .. name) +end + +for _, name in ipairs({ "tilesets", "sprites", "type_chart", "statuses", + "move_effects", "balls", "ai_classes", "growth_rates", + "evolution_methods", "transitions", "rulesets", + "field", "text_pointers", "link_fields", "screens", + "tokens", "music", "sfx", "cries", "map_songs", + "audio", "font", "palettes", "icons", "battle_anims", + "held_items", "phone_contacts", "decorations", + "apricorns", "landmarks", "radio_channels", + "rom_text" }) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 3), nil, + "gated registry has no Gen 3 target: " .. name) + T.eq(Schemas.gatedFor(name, 3), true, "gated under Gen 3: " .. name) +end + +for name in pairs(Schemas.GEN3) do + T.check(Schemas.REGISTRIES[name] ~= nil, + "Schemas.GEN3 names a real registry: " .. name) +end + +do + local claimed = {} + for name, spec in pairs(Schemas.REGISTRIES) do + local path = Schemas.targetFor(name, spec, 3) + if path then + T.check(claimed[path] == nil or claimed[path] == name, + ("two registries share one Gen 3 path (%s): %s and %s") + :format(path, tostring(claimed[path]), name)) + claimed[path] = name + end + end +end + +do + local spec = Schemas.REGISTRIES.pokemon + local gen3 = Schemas.shapeFor("pokemon", spec, 3) + T.check(gen3 ~= spec, "Gen 3 gets a derived spec") + T.eq(Schemas.shapeFor("pokemon", gen3, 3), gen3, + "resolving a derived spec again is a no-op") + T.check(gen3.fields.learnset ~= nil and gen3.fields.baseStats.fields.specialAttack ~= nil, + "the Gen 3 species shape is folded onto `fields`") + T.eq(gen3.gen3Fields, nil, "the gen3* keys are gone from the derived spec") + T.eq(gen3.gen2Fields, nil, "and so are the gen2* keys") + T.eq(gen3.target, "gen3Pokemon", "the derived spec carries the routed path") + T.check(gen3.baseAt ~= nil and gen3.baseIds ~= nil and gen3.write ~= nil, + "and reaches the numeric tables through baseAt/baseIds/write") + local gen2 = Schemas.shapeFor("pokemon", spec, 2) + T.eq(gen2.gen3Fields, nil, "a Gen 2 derived spec carries no gen3* keys") + T.eq(gen2.baseAt, Schemas.REGISTRIES.pokemon.gen2BaseAt, + "and keeps its own Gen 2 baseAt") + local scripts = Schemas.shapeFor("map_scripts", Schemas.REGISTRIES.map_scripts, 3) + T.eq(scripts.semantics, "record", + "Gen 3 scripts are whole VM row lists, not compose chains") +end + +for name, spec in pairs(Schemas.REGISTRIES) do + if Schemas.hasGen3Shape(spec) then + T.eq(Schemas.gatedFor(name, 3), false, + "a registry with a Gen 3 shape is not gated: " .. name) + end +end + +do + local G3 = Schemas.gen3View + T.eq(G3.idOf("NIDORAN\226\153\128"), "NIDORAN_F", "female glyph id") + T.eq(G3.idOf("NIDORAN\226\153\130"), "NIDORAN_M", "male glyph id") + T.eq(G3.idOf("FARFETCH'D"), "FARFETCHD", "apostrophes drop") + T.eq(G3.idOf("MR. MIME"), "MR_MIME", "punctuation folds to one underscore") + T.eq(G3.idOf("HO-OH"), "HO_OH", "hyphens fold") + T.eq(G3.idOf("POK\195\169 BALL"), "POKE_BALL", "the accented E folds") + T.eq(G3.idOf("??????????"), nil, "a placeholder name has no id") + local ir = G3.textIr("A\nB\n\nC") + local kinds = {} + for i, token in ipairs(ir) do kinds[i] = token.t end + T.eq(table.concat(kinds, ","), "text,nl,text,para,text,eos", + "a plain string becomes the VM's text IR") +end + +local Catalog = T.catalog + +local function isGen3Site(path) + return path:match("game3") ~= nil or path:match("Game3") ~= nil + or path:match("Gen3") ~= nil +end + +local function isFacadeSite(path) + return path:match("Gen3Compat") ~= nil or path:match("WorldAPI") ~= nil + or path:match("BattleAPI") ~= nil +end + +local GEN3_EVENTS = { + "game.ready", "save.created", "save.loaded", "save.loading", "save.writing", + "map.entered", "map.exited", "map.reloaded", "player.warped", + "world.stepped", "world.interacted", "world.npc_spawned", + "world.blacked_out", "world.block_replaced", "world.boulder_moved", + "world.object_toggled", + "flag.changed", "script.started", "script.ended", + "pokemon.before_give", "pokemon.evolved", + "battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended", + "battle.move_used", "battle.damage_dealt", "battle.status_inflicted", + "battle.fainted", "battle.battler_switched", "battle.exp_gained", + "battle.ball_thrown", "pokemon.caught", "pokemon.level_up", + "pokemon.move_learned", "world.trainer_engaged", + "intro.oak_speech.started", "intro.oak_speech.step", + "intro.oak_speech.answered", "intro.oak_speech.finished", +} + +local GEN3_HOOKS = { + "encounter.roll", "encounter.species", "encounter.table", + "movement.collision", "warp.destination", "world.talk", "item.use", + "script.command", "save.write", "save.new_game", + "ui.start_menu.items", "pokemon.sprite", + "input.step", "input.key", "input.gamepad", "input.wheel", "render.hud", + "trainer.party", "catch.rate", "exp.gain", "evolution.check", + "battle.damage", "battle.crit", "battle.accuracy", "battle.charge_required", + "battle.run", "battle.turn_order", "battle.enemy_action", +} + +local function assertShared(name, sites, kind) + local gen3, other = 0, 0 + for _, path in ipairs(sites) do + if isGen3Site(path) then gen3 = gen3 + 1 else other = other + 1 end + end + T.check(gen3 > 0, ("FireRed raises the %s: %s"):format(kind, name)) + T.check(other > 0, + ("the %s %s is shared, not a Gen 3 invention (no Gen 1 or Gen 2 site)") + :format(kind, name)) +end + +for _, name in ipairs(GEN3_EVENTS) do + assertShared(name, Catalog.eventSites(name), "event") +end +for _, name in ipairs(GEN3_HOOKS) do + assertShared(name, Catalog.hookSites(name), "hook") +end + +local function assertListed(names, catalogNames, sites, kind) + local listed = {} + for _, name in ipairs(names) do listed[name] = true end + for _, name in ipairs(catalogNames) do + if not Catalog.isModEvent(name) then + local gen3, other = false, false + for _, path in ipairs(sites(name)) do + if isGen3Site(path) then gen3 = true else other = true end + end + if gen3 then + T.check(other, + ("%s %s is raised only from Gen 3 modules; Gen 3 reuses the " + .. "Gen 1 / Gen 2 names and invents none"):format(kind, name)) + T.check(listed[name], + ("%s %s has a Gen 3 site but is not in this gate's list; add it " + .. "here and to docs/mod-api-gen3-compat.md"):format(kind, name)) + end + end + end +end + +assertListed(GEN3_EVENTS, Catalog.events(), Catalog.eventSites, "event") +assertListed(GEN3_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook") + +for _, name in ipairs(Catalog.events()) do + T.check(name:sub(1, 5) ~= "gen3.", "no generation-prefixed event: " .. name) +end +for _, name in ipairs(Catalog.hooks()) do + T.check(name:sub(1, 5) ~= "gen3.", "no generation-prefixed hook: " .. name) +end + +do + local cache = {} + local function sourceOf(path) + if cache[path] == nil then + local handle = io.open(path, "r") + cache[path] = handle and handle:read("*a") or false + if handle then handle:close() end + end + return cache[path] or nil + end + for _, name in ipairs(Catalog.events()) do + for _, path in ipairs(Catalog.eventSites(name)) do + if isGen3Site(path) and not isFacadeSite(path) then + local body = sourceOf(path) + T.check(body and body:find(('Runtime.wants("%s")'):format(name), 1, true), + ("the Gen 3 emit of %s in %s is guarded by Runtime.wants") + :format(name, path)) + end + end + end +end + +local function files(...) + local out = {} + for _, set in ipairs({ ... }) do + for path, body in pairs(set) do out[path] = body end + end + return out +end + +local function fixture(id, games, body) + local gamesJson = games and (',"games":["' .. table.concat(games, '","') .. '"]') or "" + return { + ["mods/" .. id .. "/manifest.json"] = ([[{ + "id": "%s", "name": "%s", "version": "1.0.0", "entry": "main.lua", + "api": 2%s + }]]):format(id, id, gamesJson), + ["mods/" .. id .. "/main.lua"] = body, + } +end + +local function statusOf(run, id) + for _, entry in ipairs(run.loader:status().available) do + if entry.id == id then return entry end + end + return nil +end + +local GEN1_ONLY = fixture("fix_gen1_only", nil, [[ + local mod = ... + mod.content.pokemon:patch("MEW", { catchRate = 1 }) +]]) + +local GEN3_READY = fixture("fix_gen3_ready", { "gen1", "gen3" }, [[ + local mod = ... + mod.exports.api = mod + local mew = mod.content.pokemon:get("MEW") + mod.exports.mewIndex = mew and mew.index + mod.exports.mewStats = mew and mew.baseStats.specialAttack + local copy = {} + for key, value in pairs(mew) do copy[key] = value end + copy.spriteFront = mod.path .. "/front.png" + mod.content.pokemon:override("MEW", copy) + mod.content.pokemon:patch("CHARMANDER", { + catchRate = 3, + learnset = { { level = 1, move = "SURF" } }, + evolutions = { { method = "EVO_ITEM", item = "THUNDERSTONE", + species = "CHARMELEON" } }, + }) + mod.content.moves:patch("EMBER", { power = 50 }) + mod.content.items:patch("POTION", { price = 1 }) + mod.content.encounters:patch("FR_ROUTE_1", { land = { rate = 99 } }) + mod.content.trainers:patch("326", { + party = { { species = "MEW", level = 7, moves = { "SURF" } } }, + }) + mod.content.text:override("Text_BootedUpPC", "HELLO") + mod.content.map_scripts:override("EventScript_Fixture", { { op = "end" } }) + mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 }) + mod.exports.species = {} + for id in mod.content.pokemon:each() do mod.exports.species[#mod.exports.species + 1] = id end +]]) + +do + local data = T.sdk.gen3Data() + local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen3_ready" }, { + fs = T.sdk.memfs(files(GEN1_ONLY, GEN3_READY)), + data = data, + generation = 3, + }) + + local skipped = statusOf(run, "fix_gen1_only") + T.eq(skipped.state, "wrong_generation", + "Gen 3: a mod that never claimed FireRed is not loaded") + T.eq(skipped.error, nil, "Gen 3: and the skip is not a failure") + T.eq(statusOf(run, "fix_gen3_ready").state, "loaded", + "Gen 3: the declared mod loads (" .. tostring(run.errors[1]) .. ")") + + local exports = run.loader.exports.fix_gen3_ready or {} + T.eq(exports.mewIndex, 151, "Gen 3: MEW resolves by name to species 151") + T.eq(exports.mewStats, 100, "Gen 3: with the split special stats") + local seen = {} + for _, id in ipairs(exports.species or {}) do seen[id] = true end + T.check(seen.NIDORAN_F and seen.CHARMANDER and not seen["?"], + "Gen 3: each() yields name ids and skips placeholder species") + + local P = data.gen3Pokemon + T.eq(P._speciesMeta[4].catchRate, 3, "Gen 3: a patch lands in the numeric meta table") + T.eq(P._speciesMeta[151].catchRate, 45, "Gen 3: the skipped mod's patch left no trace") + T.eq(P._learnsets[4][1][2], 57, "Gen 3: a learnset move name writes back as its number") + T.eq(P._evolutions[4][1].method, 7, "Gen 3: an evolution method writes back as EVO_ITEM") + T.eq(P._evolutions[4][1].param, 96, "Gen 3: and its item as the item number") + T.eq(P._evolutions[4][1].target, 5, "Gen 3: and its species as the species number") + T.check(P.spriteOverrides and P.spriteOverrides[151] + and P.spriteOverrides[151].front == "mods/fix_gen3_ready/front.png", + "Gen 3: a sprite path override is recorded for the pic seeder") + T.eq(P.spriteOverrides[151].back, nil, "Gen 3: the untouched back pic stays vanilla") + + T.eq(data.gen3Moves._rom[52].power, 50, "Gen 3: a move patch lands in the ROM row") + T.eq(data.gen3Items._byId[13].price, 1, "Gen 3: an item patch lands in the item row") + T.eq(data.gen3Encounters.FR_ROUTE_1.land.rate, 99, "Gen 3: an encounter patch lands") + T.eq(data.gen3Encounters["3:19"].land.rate, 99, + "Gen 3: and reaches the group:num alias of the same map") + T.eq(data.gen3Encounters.FR_ROUTE_1.land.slots[1].species, 16, + "Gen 3: encounter species stay numeric in the engine table") + local rival = data.gen3Trainers.trainers[326] + T.eq(rival.party[1].species, 151, "Gen 3: a trainer party species writes back as a number") + T.eq(rival.party[1].moves[1], 57, "Gen 3: and its moves as numbers") + T.eq(rival.partySize, 1, "Gen 3: partySize follows the party") + local text = data.gen3Text.Text_BootedUpPC + T.check(text[1].t == "text" and text[1].s == "HELLO" and text[2].t == "eos", + "Gen 3: a string text override becomes the VM's IR") + T.eq(data.gen3Scripts.EventScript_Fixture[1].op, "end", + "Gen 3: a script override replaces the VM row list") + + T.check(data.transitions == nil or data.transitions.FIXTURE_WIPE == nil, + "Gen 3: a gated registry merges nothing") + local told = false + for _, message in ipairs(run.errors) do + if message:match("transitions") and message:match("Gen 3") then told = true end + end + T.check(told, "Gen 3: the dropped registration is reported in Gen 3's words") + + local api = exports.api + T.check(api ~= nil, "Gen 3: the mod handed its api back for the facade check") + if api then + run.loader.game = {} + local world = api.world + T.eq(getmetatable(world), require("src.world.game3.WorldAPI"), + "Gen 3: mod.world resolves to src.world.game3.WorldAPI") + local battle = api.battle + T.eq(getmetatable(battle), require("src.battle.game3.BattleAPI"), + "Gen 3: mod.battle resolves to src.battle.game3.BattleAPI") + run.loader.game = nil + end + run.release() +end + +do + local BAD = fixture("fix_refs", { "gen3" }, [[ + local mod = ... + mod.content.pokemon:patch("PIDGEY", { + evolutions = { { method = "EVO_LEVEL", level = 18, species = "PIDGEOTTO_TYPO" } }, + }) + mod.content.pokemon:patch("MEW", { learnset = { { level = 1, move = "NOT_A_MOVE" } } }) + ]]) + local run = T.sdk.loadMods({ "mods/fix_refs" }, { + fs = T.sdk.memfs(BAD), data = T.sdk.gen3Data(), generation = 3, + }) + local pokemon, moves = false, false + for _, message in ipairs(run.errors) do + if message:match("unresolved reference to pokemon") then pokemon = true end + if message:match("unresolved reference to moves") then moves = true end + end + T.check(pokemon, "Gen 3: an evolution into an unknown species is reported") + T.check(moves, "Gen 3: a learnset move that does not exist is reported") + run.release() +end + +do + local GOOD = fixture("fix_refs_ok", { "firered" }, [[ + local mod = ... + mod.content.pokemon:patch("PIDGEY", { + evolutions = { { method = "EVO_LEVEL", level = 18, species = "CHARMELEON" } }, + itemRare = "POTION", + }) + ]]) + local run = T.sdk.loadMods({ "mods/fix_refs_ok" }, { + fs = T.sdk.memfs(GOOD), data = T.sdk.gen3Data(), generation = 3, + }) + local dangling = {} + for _, message in ipairs(run.errors) do + if message:match("unresolved reference") then dangling[#dangling + 1] = message end + end + T.eq(#dangling, 0, "Gen 3: refs that resolve report nothing (" + .. table.concat(dangling, "; ") .. ")") + T.eq(statusOf(run, "fix_refs_ok").state, "loaded", "Gen 3: games = [firered] loads") + run.release() +end + +do + local run = T.sdk.loadNone({ generation = 3, data = T.sdk.gen3Data() }) + local content = run.loader.content + T.eq(content.balls:get("GREAT_BALL"), nil, "Gen 3 seeds none of Red's balls") + T.eq(content.statuses:get("BRN"), nil, "Gen 3 seeds none of Red's statuses") + T.eq(content.ai_classes:get("LAYER_1"), nil, "or Red's AI layers") + T.eq(content.commands:get("show_text"), nil, "or Gen 1's row-list verbs") + T.eq(run.data.statuses, nil, "a mod-free FireRed boot writes no Gen 1 namespace") + T.eq(run.data.commands, nil, "and leaves data.commands absent") + T.eq(#require("src.mods.Builtins").registries(3), 0, + "the Gen 3 registrant list is empty") + run.release() +end + +do + local PROBE = [[ + local mod = ... + local function attempt(name) + local ok, result = pcall(require, name) + if ok then return nil end + return tostring(result) + end + mod.exports.gen2Err = attempt("src.battle.gen2.Mon") + mod.exports.game2Err = attempt("src.core.Game2") + mod.exports.game3Err = attempt("src.core.game3.pokemon") + ]] + local FILES = fixture("fix_probe", { "all" }, PROBE) + local onRed = T.sdk.loadMods({ "mods/fix_probe" }, + { fs = T.sdk.memfs(files(FILES)), data = {}, generation = 1 }) + local red = onRed.loader.exports.fix_probe or {} + T.check(red.game3Err and red.game3Err:find("Gen 3 engine module", 1, true), + "Gen 1: a Gen 3 engine module is refused: " .. tostring(red.game3Err)) + onRed.release() + + local onFireRed = T.sdk.loadMods({ "mods/fix_probe" }, + { fs = T.sdk.memfs(files(FILES)), data = T.sdk.gen3Data(), generation = 3 }) + local fr = onFireRed.loader.exports.fix_probe or {} + T.check(fr.gen2Err and fr.gen2Err:find("Gen 2 engine module", 1, true), + "Gen 3: a Gen 2 engine module is refused: " .. tostring(fr.gen2Err)) + T.check(fr.game2Err and fr.game2Err:find("src.core.Game2", 1, true), + "Gen 3: and so is src.core.Game2") + T.check(not (fr.game3Err and fr.game3Err:find("engine module and this is", 1, true)), + "Gen 3: its own engine modules are not refused: " .. tostring(fr.game3Err)) + onFireRed.release() +end + +T.finish("gate_gen3_mod_api") diff --git a/tests/engine/gate_gen3_mod_facade.lua b/tests/engine/gate_gen3_mod_facade.lua new file mode 100644 index 00000000..3480154a --- /dev/null +++ b/tests/engine/gate_gen3_mod_facade.lua @@ -0,0 +1,378 @@ +-- Gate for the Gen 1 module facades a FireRed mod's require resolves to +-- (src/mods/Gen3Compat.lua), plus mod.world / mod.battle for Gen 3. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen3 mod facade") +local check, eq = S.check, S.eq + +local session = { + map = "FR_OAKS_LAB", x = 6, y = 5, facing = "up", + party = {}, money = 3000, name = "RED", rivalName = "BLUE", + bag = { pockets = {} }, dex = { seen = {}, owned = { [4] = true } }, + flags = {}, vars = {}, + healMap = "FR_PLAYERS_HOUSE_1F", healX = 8, healY = 5, +} +local sessionLive = false + +package.loaded["src.core.game3.runtime"] = { + getSession = function() return sessionLive and session or nil end, +} + +local Player = { cellX = 6, cellY = 5, facing = "up", moving = false } +package.loaded["src.core.game3.player"] = Player + +local maps = { + FR_OAKS_LAB = { id = "FR_OAKS_LAB", width = 13, height = 13, warps = {} }, + FR_PALLET_TOWN = { id = "FR_PALLET_TOWN", width = 24, height = 20, warps = {} }, +} + +local mapLoads = {} +local MapStub = { current = "FR_OAKS_LAB" } +function MapStub.currentDef() return maps[MapStub.current] end +function MapStub.load(_mod, _game, mapId, opts) + mapLoads[#mapLoads + 1] = { mapId = mapId, opts = opts } + MapStub.current = mapId + return true +end +package.loaded["src.core.game3.map"] = MapStub + +local metatiles = {} +package.loaded["src.core.game3.field"] = { + running = true, locked = false, + setMetatile = function(x, y, m) metatiles[#metatiles + 1] = { x, y, m } end, + respawnAtHeal = function() end, +} + +local warps = {} +package.loaded["src.core.game3.warp"] = { + isBusy = function() return false end, + request = function(_mod, _game, mapId, x, y, facing, opts) + warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing, opts = opts } + return true + end, +} + +local store = { flags = {}, vars = {} } +local FLAG_IDS = { FLAG_SYS_POKEMON_GET = 0x828, SYS_POKEMON_GET = 0x828 } +package.loaded["src.core.game3.scripting.flags"] = { + IDS = FLAG_IDS, + VAR_IDS = { VAR_STARTER_MON = 0x4031 }, + getFlag = function(st, _, id) return st.flags[id] == true end, + setFlag = function(st, _, id, on) st.flags[id] = on and true or nil end, + getVar = function(st, _, id) return st.vars[id] or 0 end, + setVar = function(st, _, id, v) st.vars[id] = v end, +} + +local started = {} +local vm = { ctx = {}, isRunning = function() return false end } +package.loaded["src.core.game3.scripting.space"] = { + store = store, vm = vm, + bundle = { text = { ["g3:hello"] = "HELLO" } }, + startScript = function(key) started[#started + 1] = key return true end, +} + +local tracks = {} +local npc = { localId = 1, cellX = 3, cellY = 4, facing = "down", + visible = true, hidden = false, def = { localId = 1 } } +package.loaded["src.core.game3.objects"] = { + PLAYER_LOCAL_ID = 0xFF, + _order = { 1 }, _byId = { [1] = npc }, _defs = { { localId = 1 } }, + at = function(x, y) if x == 3 and y == 4 then return npc end end, + startTrack = function(lid, actions, onDone) + tracks[#tracks + 1] = { lid = lid, actions = actions } + if onDone then onDone() end + end, + addObject = function() return true end, + removeObject = function() return true end, + facePlayer = function() end, + scriptFace = function(eo, dir) eo.facing = dir end, +} + +package.loaded["src.core.game3.collision"] = { + inBounds = function(x, y) return x >= 0 and y >= 0 and x < 13 and y < 13 end, + isWalkable = function() return true end, + isWater = function() return false end, + isGrass = function() return false end, + warpAt = function() return nil end, + behavior = function() return 0 end, + canEnter = function(_g, x, y) + if x < 0 or y < 0 or x >= 13 or y >= 13 then return false, "bounds" end + if x == 7 and y == 5 then return false, "water" end + return true + end, +} + +local reloadFns = {} +local vanillaFront = { image = "vanilla-front", w = 64, h = 64 } +local Pokemon = { + _names = { [4] = "CHARMANDER", [29] = "NIDORAN♀", [122] = "MR. MIME", + [151] = "MEW" }, + _front = {}, _back = {}, + _stats = { [151] = { hp = 100, atk = 100, def = 100, spe = 100, spa = 100, + spd = 100 } }, +} +function Pokemon.name(sp) return Pokemon._names[sp] or "?????" end +function Pokemon.speciesFromName(name) + local key = tostring(name):upper():gsub("[^%w]", "") + for id, n in pairs(Pokemon._names) do + if n:upper():gsub("[^%w]", "") == key then return id end + end + return nil +end +function Pokemon.stats(sp) return Pokemon._stats[sp] end +function Pokemon.speciesMeta() return { catchRate = 45 } end +function Pokemon.types() return { 14, 14 } end +function Pokemon.frontPic(sp) return Pokemon._front[sp] or vanillaFront end +function Pokemon.backPic(sp) return Pokemon._back[sp] or vanillaFront end +function Pokemon.knowsMove() return false end +function Pokemon.onReload(fn) reloadFns[#reloadFns + 1] = fn end +package.loaded["src.core.game3.pokemon"] = Pokemon + +local Gen3Compat = require("src.mods.Gen3Compat") +local Gen2Compat = require("src.mods.Gen2Compat") + +-- ------- 1. every served name resolves, once, to a table + +local SERVED = Gen2Compat.modules() +for _, name in ipairs(SERVED) do + check(Gen3Compat.serves(name), "served on Gen 3 as on Gen 2: " .. name) + local a = Gen3Compat.resolve(name, "fixture") + eq(type(a), "table", "resolves to a table: " .. name) + check(a == Gen3Compat.resolve(name, "fixture"), + "one stable table for the run: " .. name) +end +eq(#Gen3Compat.modules(), #SERVED, "Gen 3 serves exactly the Gen 2 names") +eq(Gen3Compat.resolve("src.world.WorldAPI"), require("src.world.game3.WorldAPI"), + "WorldAPI is the game3 WorldAPI itself, so a patch lands on every mod.world") +check(not Gen3Compat.serves("src.script.Commands"), + "src.script.Commands stays unserved, as on Gold") +check(Gen3Compat.resolve("src.battle.BattleState").newWild == nil, + "no invented newWild on FireRed") + +-- ------- 2. coverage + +eq(Gen3Compat.COVERAGE_VERSION, 1, "the coverage contract is versioned") +for _, name in ipairs(Gen3Compat.modules()) do + local row = Gen3Compat.coverage(name) + check(row ~= nil, "coverage for " .. name) + eq(row.module, name, "coverage names itself: " .. name) + check(row.kind == "facade" or row.kind == "alias", + "coverage kind is facade or alias: " .. name) + for member, status in pairs(row.members) do + check(status == "backed" or status == "warned" or status == "absent", + ("%s.%s carries one of the three statuses"):format(name, member)) + end +end +eq(Gen3Compat.memberStatus("src.world.Collision", "load"), "warned", + "Collision.load is present, answers nil and says so") +eq(Gen3Compat.memberStatus("src.battle.BattleState", "makeSafari"), "absent", + "makeSafari is published absent") +eq(Gen3Compat.coverage("nope.nope"), nil, "an unserved name has no coverage") +local first = Gen3Compat.coverage("src.world.Collision") +first.members.canMove = "absent" +eq(Gen3Compat.memberStatus("src.world.Collision", "canMove"), "backed", + "coverage hands back a fresh table each call") + +local worldApiRow = Gen3Compat.coverage("src.world.WorldAPI") +local WorldAPI = require("src.world.game3.WorldAPI") +for member, status in pairs(worldApiRow.members) do + if status ~= "absent" and member ~= "__index" then + eq(type(WorldAPI[member]), "function", + "published WorldAPI member exists: " .. member) + end +end + +-- ------- 3. the Game facade proxies a LIVE game + +local writes = 0 +local current = nil +local liveGame = { + phase = "field", options = { textSpeed = 1 }, + data = { maps = maps, gen3Trainers = { tag = "trainers" } }, + writeOptions = function() writes = writes + 1 end, +} +Gen3Compat.bind(function() return current end) + +local Game = Gen3Compat.resolve("src.core.Game", "fixture") +eq(Game.save, nil, "captured before a game exists, the facade reads nil") +current = liveGame +sessionLive = true +eq(Game.generation, 3, "the facade reports its generation") +check(Game.save ~= nil, "save fills in the moment a session is live") +eq(Game.save.party, session.party, "save.party IS the session party") +eq(Game.save.money, 3000, "save.money reads the session") +Game.save.money = 1234 +eq(session.money, 1234, "a write through save lands on the session") +eq(Game.save.player.map, "OAKS_LAB", "save.player.map is the Gen 1 spelling") +eq(Game.save.player.gen3Map, "FR_OAKS_LAB", "and gen3Map is the raw id") +eq(Game.save.player.x, 6, "player x reads the live avatar") +eq(Game.save.flags.FLAG_SYS_POKEMON_GET, nil, "an unset flag reads nil") +Game.save.flags.SYS_POKEMON_GET = true +eq(store.flags[0x828], true, "a flag write by name lands in the live store") +eq(Game.save.flags.FLAG_SYS_POKEMON_GET, true, "and reads back under either name") +eq(Game.save.flags.EVENT_GOT_STARTER, nil, + "a Gen 1 flag name FireRed lacks reads nil") +eq(Game.save.pokedex.caught.CHARMANDER, true, "pokedex.caught by species name") +eq(Game.data.trainers, liveGame.data.gen3Trainers, "data.trainers is gen3Trainers") +eq(Game.data.maps.PALLET_TOWN, maps.FR_PALLET_TOWN, + "data.maps resolves a Gen 1 spelling to the FR_ id") +eq(Game.data.maps.FR_OAKS_LAB, maps.FR_OAKS_LAB, "and the raw id") +eq(Game.data.pokemon.MEW.index, 151, "data.pokemon is name keyed") +eq(Game.data.pokemon.MEW.baseStats.hp, 100, "with Gen 1 baseStats field names") +eq(Game.data.pokemon.MEW.spriteFront, + "data/generated/gba/pokemon/front/151.rgba", "and the cache sprite path") +eq(Game.data.sprites, nil, "data.sprites has no Gen 3 backing and says so") +Game.writeOptions(Game) +eq(writes, 1, "writeOptions reaches Game3:writeOptions") +eq(Game.renderer, nil, "renderer is named unbacked, not the Gen 1 singleton") + +eq(Gen3Compat.speciesName(122), "MR_MIME", "display names canonicalise") +eq(Gen3Compat.speciesName(29), "NIDORAN_F", "gender glyphs become _F / _M") +eq(Gen3Compat.gen3MapId("OAKS_LAB"), "FR_OAKS_LAB", "Gen 1 id to FR_ id") +eq(Gen3Compat.gen1MapId("FR_OAKS_LAB"), "OAKS_LAB", "FR_ id to Gen 1 id") + +-- ------- 4. OverworldController + +local OW = Gen3Compat.resolve("src.world.OverworldController") +eq(Game.overworld, OW, "Game.overworld is the facade while in the field") +eq(OW.map.id, "OAKS_LAB", "ow.map.id is the Gen 1 spelling") +eq(OW.map.gen3Id, "FR_OAKS_LAB", "ow.map.gen3Id is the raw id") +eq(OW.map.widthCells, 13, "ow.map answers the live def") +eq(OW.player, Player, "ow.player IS the game3 avatar") +eq(#OW.npcs, 1, "ow.npcs lists the visible event objects") +eq(OW.npcAtCell(3, 4), npc, "npcAtCell finds the object") +eq(OW:npcAtCell(3, 4), npc, "and answers a colon call the same") +check(OW.isOverworld, "isOverworld answers true") +eq(OW.healPoint().map, "PLAYERS_HOUSE_1F", "healPoint in the Gen 1 spelling") +OW.startWarpTo("PALLET_TOWN", 5, 6, "down") +eq(warps[#warps].mapId, "FR_PALLET_TOWN", "startWarpTo maps the id") +check(OW.scriptMove(Player, "left", 2), "scriptMove moves the player") +eq(tracks[#tracks].lid, 0xFF, "the player is localId 0xFF") +eq(#tracks[#tracks].actions, 2, "two step actions") +local refused = OW.scriptMove({}, "left", 1) +eq(refused, nil, "an entity with no localId is refused, not guessed") +liveGame.phase = "boot" +eq(Game.overworld, nil, "no overworld outside the field") +liveGame.phase = "field" + +local Collision = Gen3Compat.resolve("src.world.Collision") +local mover = { cellX = 6, cellY = 5 } +check(Collision.canMove(nil, {}, mover, "up"), "an open cell is enterable") +local ok, why = Collision.canMove(nil, {}, mover, "right") +check(not ok and why == "tile", "water folds to reason 'tile'") +local ok2, why2 = Collision.canMove(nil, { { cellX = 6, cellY = 4 } }, mover, "up") +check(not ok2 and why2 == "entity", "an occupied cell is refused as 'entity'") + +-- ------- 5. script ctx + +local ctx = Gen3Compat.scriptCtx(vm) +eq(ctx.overworld.map.id, "OAKS_LAB", "ctx.overworld.map.id is the Gen 1 id") +eq(ctx.overworld.map.gen3Id, "FR_OAKS_LAB", "ctx.overworld.map.gen3Id is raw") +eq(ctx.save.party, session.party, "ctx.save.party is the live party") +eq(ctx.save.flags.FLAG_SYS_POKEMON_GET, true, "ctx.save.flags by name") +eq(ctx.runner, vm, "ctx.runner is the VM that raised it") +eq(ctx.game, liveGame, "ctx.game is the live Game3") +MapStub.current = "FR_PALLET_TOWN" +eq(ctx.overworld.map.id, "PALLET_TOWN", "answered at read time, not built") +MapStub.current = "FR_OAKS_LAB" + +-- ------- 6. sprite overrides + +local merged = { MEW = { spriteFront = "mods/x/assets/mew_front.png", + spriteBack = "mods/x/assets/mew_back.png" }, + CHARMANDER = { spriteFront = "data/generated/gba/pokemon/front/4.rgba" } } +liveGame.mods = { content = { pokemon = { + ops = { MEW = {}, CHARMANDER = {} }, + get = function(_, id) return merged[id] end, +} } } +Gen3Compat.applyMerged(liveGame) +local front = Pokemon.frontPic(151) +check(front ~= vanillaFront and front.w == 64 and front.h == 64, + "Mew's front pic is the mod image on a 64x64 entry") +eq(front.path, "mods/x/assets/mew_front.png", "and it is the mod's path") +eq(Pokemon._front[151], front, "the cache entry is seeded") +eq(Pokemon.backPic(151).path, "mods/x/assets/mew_back.png", "back pic too") +eq(Pokemon.frontPic(4), vanillaFront, "a vanilla .rgba path leaves the pic alone") +eq(#reloadFns, 1, "Gen3Compat registered with Pokemon.onReload once") +Pokemon._front = {} +Pokemon._back = nil +reloadFns[1]() +eq(Pokemon._front[151] and Pokemon._front[151].path, + "mods/x/assets/mew_front.png", "a reload re-seeds the cache") +Gen3Compat.applyMerged(liveGame) +eq(#reloadFns, 1, "a second applyMerged does not register twice") +local wrapped = Pokemon.frontPic +Gen3Compat.applyMerged(liveGame) +eq(Pokemon.frontPic, wrapped, "and does not wrap twice") +eq(Game.data.pokemon.MEW, merged.MEW, "a registry override wins in data.pokemon") + +-- ------- 7. mod.world + +local api = WorldAPI.new(liveGame, "fixture") +local here = api:current() +eq(here.mapId, "FR_OAKS_LAB", "current().mapId is the raw id") +eq(here.gen1MapId, "OAKS_LAB", "with the Gen 1 spelling alongside") +eq(here.x, 6, "current().x is the live avatar") +check(api:warpTo("PALLET_TOWN", 1, 2), "warpTo takes a Gen 1 spelling") +eq(warps[#warps].mapId, "FR_PALLET_TOWN", "and warps to the FR_ map") +local nope, reason = api:warpTo("NOWHERE", 1, 2) +check(nope == nil and reason:find("unknown map"), "an unknown map is refused") +check(api:setFlag("FLAG_SYS_POKEMON_GET", false), "setFlag by name") +eq(api:getFlag("SYS_POKEMON_GET"), nil, "getFlag reads it back cleared") +local bad, err = api:setFlag("EVENT_GOT_STARTER", true) +check(bad == nil and err:find("unknown FireRed flag"), + "a Gen 1 flag name is refused by name") +local q, qerr = api:queueScript({ { "text", "hi" }, { "give_item", "POTION" } }) +check(q == nil and qerr:find("give_item"), "queueScript refuses an unknown verb up front") +local handle = api:npc("OAKS_LAB", 1) +check(handle ~= nil, "npc resolves by localId on the active map") +check(handle:scriptMove("up", 1), "a handle walks its object") +eq(tracks[#tracks].lid, 1, "through Objects.startTrack with its localId") +eq(select(1, handle:position()), 3, "position reads the live object") +local wild, werr = api:startWildBattle("MISSINGNO", 5) +check(wild == nil and werr:find("unknown species"), "an unknown species is refused") +check(api:replaceBlock(2, 3, 0x2A), "replaceBlock sets a metatile") +eq(metatiles[#metatiles][3], 0x2A, "through Field.setMetatile") + +-- ------- 8. mod.battle + +local BattleAPI = require("src.battle.game3.BattleAPI") +local battle = BattleAPI.new(liveGame) +eq(battle:snapshot(), nil, "no snapshot without a battle") +local handled = {} +local Ui = { _mode = "menu", _menuIndex = 1 } +function Ui.handleInput(input) + handled[#handled + 1] = input:wasPressed("a") and "a" + or input:wasPressed("b") and "b" or "?" +end +package.loaded["src.core.game3.battle.ui"] = Ui +local mon = { species = 151, level = 20, hp = 50, maxHp = 60, moves = { 1 }, pp = { 35 } } +local foe = { species = 4, level = 5, hp = 19, maxHp = 19, moves = {} } +local battleState = { kind = "wild", wild = true, turn = 2, + playerParty = { mon }, player = { mon = mon }, enemy = { mon = foe } } +package.loaded["src.core.game3.battle.init"] = { + _phase = "command", + isActive = function() return true end, + getState = function() return battleState end, +} +local snap = battle:snapshot() +check(snap ~= nil, "a snapshot while a battle runs") +eq(snap.kind, "wild", "kind") +eq(snap.prompt, "menu", "prompt reads the FireRed command menu") +eq(snap.player.species, "MEW", "species is the Gen 1-style name") +eq(snap.player.gen3Species, 151, "with the FireRed number alongside") +eq(snap.enemy.hp, 19, "enemy hp") +check(battle:submit({ id = 1, revision = snap.revision, kind = "menu", + choice = "fight" }), "a menu intent submits") +eq(Ui._menuIndex, 1, "FIGHT is menu index 1") +eq(handled[#handled], "a", "and is confirmed with A") +local stale = battle:submit({ id = 2, revision = -1, kind = "menu", choice = "run" }) +eq(stale, nil, "a stale revision is refused") +local replay = battle:submit({ id = 1, revision = snap.revision, kind = "menu", + choice = "run" }) +eq(replay, nil, "a replayed id is refused") + +S.finish() diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index a0cc16d0..52742bd6 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -123,7 +123,6 @@ local DEBT = { ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", - ["event:pokemon.before_give"] = "M7", ["event:trade.completed"] = "M12", } diff --git a/tests/engine/gen3_shim_engine_require.lua b/tests/engine/gen3_shim_engine_require.lua new file mode 100644 index 00000000..78b21fe9 --- /dev/null +++ b/tests/engine/gen3_shim_engine_require.lua @@ -0,0 +1,93 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen3 shim engine require") +local check, eq = S.check, S.eq + +local Loader = require("src.mods.Loader") +local Gen3Compat = require("src.mods.Gen3Compat") +local GameVersion = require("src.core.GameVersion") +local SessionLifecycle = require("src.core.SessionLifecycle") + +local NAME = "src.core.Game" +local REAL = { load = function() end } + +local function memfs(files) + return { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function requireFrom(source, name) + local chunk = assert(loadstring( + "local name = ... local mod = require(name) return mod", "@" .. source)) + return chunk(name or NAME) +end + +local savedVersion = GameVersion.get() +local savedModule = package.loaded[NAME] +package.loaded[NAME] = REAL + +GameVersion.set("firered") +local files = { + ["mods/facade/manifest.json"] = + [[{"id":"facade","name":"facade","version":"1.0.0","entry":"main.lua",]] + .. [["games":["gen3"]}]], + ["mods/facade/main.lua"] = "return function(mod) end", +} +local loader = Loader.new({ fs = memfs(files) }) +loader:load({ maps = {} }) + +eq(loader.generation, 3, "the fixture loader is a Gen 3 one") +check(Gen3Compat.serves(NAME), "the facade serves " .. NAME) + +local facade = Gen3Compat.resolve(NAME) +eq(requireFrom("mods/facade/main.lua"), facade, + "a mod's require resolves to the Gen 3 facade") +eq(requireFrom("mods/facade/main.lua", "src.world.WorldAPI"), + require("src.world.game3.WorldAPI"), + "src.world.WorldAPI from a mod is the FireRed WorldAPI") +eq(requireFrom("main.lua"), REAL, + "main.lua's require is the engine's own, never the facade") +eq(requireFrom("conf.lua"), REAL, "conf.lua's require is the engine's own") + +SessionLifecycle.endMountedSession() +eq(requireFrom("mods/facade/main.lua"), REAL, + "after the session ends the facade stops answering for the next boot") + +package.loaded[NAME] = savedModule +GameVersion.set(savedVersion) + +S.finish() diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index a04c9069..4dffe96d 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -43,6 +43,10 @@ do "and each of them names itself") eq(table.concat(ModTargets.expand("crystal"), ","), "crystal", "Crystal included, the day its VERSIONS row landed") + eq(table.concat(ModTargets.expand("gen3"), ","), "firered", + "gen3 is every Gen 3 game") + eq(table.concat(ModTargets.expand("FireRed"), ","), "firered", + "and FireRed names itself, case-insensitive") eq(table.concat(ModTargets.expand("all"), ","), table.concat(GameVersion.ORDER, ","), "all is the launcher order itself") eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for") @@ -109,6 +113,13 @@ do check(not ModTargets.supports(gen1, "gold"), "and not Gold") check(ModTargets.supports(gen2, "gold"), "a Gen 2 mod supports Gold") check(not ModTargets.supports(gen2, "red"), "and not Red") + local gen3 = mf({ id = "three", games = { "gen3" } }) + check(ModTargets.supports(gen3, "firered"), "a Gen 3 mod supports FireRed") + check(not ModTargets.supports(gen3, "red"), "and not Red") + check(not ModTargets.supports(gen1, "firered"), "a Gen 1 mod does not support FireRed") + check(not ModTargets.supports(gen2, "firered"), "and neither does a Gen 2 one") + check(ModTargets.supports(gen3, nil, 3), "Gen 3 can be asked directly") + eq(gen3.gen2compat, false, "a Gen 3 claim is not a Gen 2 claim") check(ModTargets.supports(gen1, nil, 1), "a generation can be asked directly") check(not ModTargets.supports(gen1, nil, 2), "and answers the same way") check(not ModTargets.runsHere(gen1, "gold"), "no claim, no run") @@ -120,7 +131,15 @@ end do eq(ModTargets.label(mf({})), "Gen 1", "whole generations read as generations") - eq(ModTargets.label(mf({ games = { "all" } })), "Gen 1+2", "both of them") + eq(ModTargets.label(mf({ games = { "all" } })), "Gen 1+2+3", "all of them") + eq(ModTargets.label(mf({ games = { "gen1", "gen2" } })), "Gen 1+2", "both of them") + eq(ModTargets.label(mf({ games = { "gen3" } })), "Gen 3", "FireRed's generation") + eq(ModTargets.label(mf({ games = { "firered" } })), "Gen 3", + "one game that is its whole generation reads as the generation") + eq(ModTargets.label(mf({ games = { "gen1", "gen3" } })), "Gen 1+3", + "generations need not be contiguous") + eq(ModTargets.detail(mf({}), "firered"), "For Gen 1, not FireRed", + "and the launcher line names FireRed") eq(ModTargets.label(mf({ games = { "gen2" } })), "Gen 2", "or just the one") eq(ModTargets.label(mf({ games = { "red", "gold" } })), "Red/Gold", "part of a generation reads as the games themselves") diff --git a/tests/game3_battle_ai_test.lua b/tests/game3_battle_ai_test.lua index 905f6ee6..ee6b50c8 100644 --- a/tests/game3_battle_ai_test.lua +++ b/tests/game3_battle_ai_test.lua @@ -148,6 +148,192 @@ local ea = Commands.enemyAction(stKo) check(ea and ea.kind == "move", "enemyAction returns move") check(ea.user == "enemy", "enemyAction user=enemy") +print("[test] 8. Doubles: flank target, absent flip, BOTH / USER targets, partner-aware scans") +local AiCmds = require("src.core.game3.battle.ai_cmds") +local function dstate(foeMoves) + local pp = { 20, 20, 20, 20 } + local st = State.new({ + wild = false, + double = true, + playerParty = { + { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } }, + { species = 19, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } }, + }, + foeParty = { + { species = 74, level = 20, hp = 50, maxHp = 50, moves = foeMoves, pp = pp }, + { species = 74, level = 20, hp = 50, maxHp = 50, moves = foeMoves, pp = pp }, + { species = 4, level = 20, hp = 50, maxHp = 50, moves = { 52 }, pp = { 25 } }, + }, + }) + st.aiFlags = 1 + return st +end +local function flankRng(flank) + return function(lo, hi) + if lo == 0 and hi == 65535 then return flank end + if lo and hi then return lo end + return 0 + end +end + +local stD = dstate({ 33 }) +local aD = Ai.chooseMove(stD, { battler = 1, pack = pack, aiFlags = 1, rng = flankRng(2) }) +check(aD and aD.battler == 1 and aD.target == 2, "doubles: Random() & BIT_FLANK picks player right (target=" .. tostring(aD and aD.target) .. ")") +stD.absent[2] = true +aD = Ai.chooseMove(stD, { battler = 1, pack = pack, aiFlags = 1, rng = flankRng(2) }) +check(aD and aD.target == 0, "doubles: absent flank flips to player left") + +local stG = dstate({ 45 }) +local aG = Ai.chooseMove(stG, { battler = 1, pack = pack, aiFlags = 1, rng = flankRng(2) }) +check(aG and aG.target == 0, "doubles: BOTH move targets player left") +stG.absent[0] = true +aG = Ai.chooseMove(stG, { battler = 1, pack = pack, aiFlags = 1, rng = flankRng(2) }) +check(aG and aG.target == 2, "doubles: BOTH move falls back to player right when left absent") + +local stH = dstate({ 270, 33 }) +local aH = Ai.chooseMove(stH, { battler = 3, pack = pack, aiFlags = 1, rng = flankRng(0) }) +check(aH and aH.slot == 1 and aH.target == 3, "doubles: Helping Hand (USER) targets self") +local stHs = State.new({ + wild = false, + playerParty = { { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } } }, + foeMon = { species = 74, level = 20, hp = 50, maxHp = 50, moves = { 270, 33 }, pp = { 20, 20 } }, +}) +local aHs = Ai.chooseMove(stHs, { pack = pack, aiFlags = 1, rng = flankRng(0) }) +check(aH.scores and aHs.scores and aH.scores[1] > aHs.scores[1], + string.format("Helping Hand not penalized in doubles (%s) vs singles (%s)", + tostring(aH.scores and aH.scores[1]), tostring(aHs.scores and aHs.scores[1]))) + +local vmD = AiVm.new({ + pack = pack, st = stH, user = stH.battlers[3], target = stH.battlers[0], + userSide = stH.enemySide, targetSide = stH.playerSide, + scores = { 100, 100, 100, 100 }, simulatedRNG = { 100, 100, 100, 100 }, + movesetIndex = 1, rng = flankRng(0), +}) +AiCmds.dispatch(vmD, { op = "is_double_battle" }) +check(vmD.funcResult == 1, "is_double_battle = 1 in doubles") +AiCmds.dispatch(vmD, { op = "count_alive_pokemon", battler = 1 }) +check(vmD.funcResult == 1, "count_alive_pokemon excludes both on-field foes (got " .. tostring(vmD.funcResult) .. ")") + +local aA = Ai.chooseAction(dstate({ 33 }), 3, { pack = pack, aiFlags = 1, rng = flankRng(0) }) +check(aA and aA.kind == "move" and aA.battler == 3 and (aA.target == 0 or aA.target == 2), + "chooseAction returns {kind, battler, move, slot, target}") +local ea3 = Commands.enemyAction((function() local s = dstate({ 33 }); s.rng = flankRng(2); return s end)(), 3) +check(ea3 and ea3.battler == 3 and ea3.target == 2, "Commands.enemyAction(st, 3) routes through Ai.chooseAction") + +print("[test] 9. Singles pret paths: tie-break quirk, wild random move, zero-flag trainer setup") +local function recRng(fixed) + local calls = {} + local f = function(lo, hi) + calls[#calls + 1] = { lo, hi } + if fixed and fixed[lo .. ":" .. hi] ~= nil then return fixed[lo .. ":" .. hi] end + if lo and hi then return lo end + return 0 + end + return f, calls +end +local stQ = State.new({ + wild = false, + playerParty = { { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } } }, + foeMon = { species = 74, level = 20, hp = 50, maxHp = 50, moves = { 33, 33 }, pp = { 0, 20 } }, +}) +local qRng, qCalls = recRng() +local aQ = Ai.chooseMove(stQ, { pack = pack, aiFlags = 0, rng = qRng }) +local lastPick = qCalls[#qCalls] +check(aQ and aQ.slot == 2, "zero-flag trainer picks the only usable move") +check(lastPick and lastPick[1] == 1 and lastPick[2] == 2, "new best counted twice (Random() % 2)") +local draws = 0 +for _, c in ipairs(qCalls) do if c[1] == 0 and c[2] == 15 then draws = draws + 1 end end +check(draws == 4, "zero-flag trainer still rolls 4 simulatedRNG draws") + +local stW = State.new({ + wild = true, + playerParty = { { species = 1, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 } } }, + foeMon = { species = 16, level = 3, hp = 15, maxHp = 15, moves = { 16, 33 }, pp = { 35, 35 } }, +}) +local wRng, wCalls = recRng({ ["0:3"] = 1 }) +local aW = Ai.chooseMove(stW, { pack = pack, rng = wRng }) +check(aW and aW.slot == 2, "wild: Random() & 3 picks slot 2") +check(#wCalls == 1, "wild: one RNG draw, no AI setup") + +local stE = State.new({ + wild = false, + playerParty = { + { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } }, + { species = 16, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 }, isEgg = true }, + { species = 19, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } }, + }, + foeMon = { species = 74, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 20 } }, +}) +local vmE = AiVm.new({ + pack = pack, st = stE, user = stE.enemy, target = stE.player, + userSide = stE.enemySide, targetSide = stE.playerSide, + scores = { 100, 100, 100, 100 }, simulatedRNG = { 100, 100, 100, 100 }, movesetIndex = 1, + rng = function(lo) return lo or 0 end, +}) +AiCmds.dispatch(vmE, { op = "count_alive_pokemon", battler = 0 }) +check(vmE.funcResult == 1, "count_alive_pokemon skips eggs in singles (got " .. tostring(vmE.funcResult) .. ")") + +print("[test] 10. Trainer items: ShouldUseItem + enemy item execution") +local Engine = require("src.core.game3.battle.engine") +local Adapter = require("src.core.game3.battle.adapter") +local function itemState(opts) + local st = State.new({ + wild = opts.wild or false, + playerParty = { { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } } }, + foeMon = { species = 74, level = 20, hp = opts.hp or 5, maxHp = 40, moves = { 33 }, pp = { 20 }, + status = opts.status }, + }) + st.trainerItems = opts.items + st.trainerClassName, st.trainerName = "LEADER", "BROCK" + st.aiFlags = 1 + st.rng = function(lo, hi) if lo and hi then return lo end return 0 end + return st +end +local stP = itemState({ items = { 13, 0, 0, 0 } }) +local aP = Ai.chooseAction(stP, 1, { pack = pack }) +check(aP and aP.kind == "item" and aP.item == 13 and aP.battler == 1 and aP.target == 1, + "low HP trainer mon uses POTION (kind=" .. tostring(aP and aP.kind) .. ")") +check(stP._aiHistory.items[1] == 0, "POTION removed from battle history") +local aP2 = Ai.chooseAction(stP, 1, { pack = pack }) +check(aP2 and aP2.kind == "move", "no second POTION") +local adP = Adapter.new(stP, function() end) +local msgs = {} +adP._say = function(t) msgs[#msgs + 1] = t end +Engine.performEnemyItem(stP, adP, aP) +check(tonumber(stP.enemy.mon.hp) == 25, "POTION heals 20 (hp=" .. tostring(stP.enemy.mon.hp) .. ")") +check(msgs[1] and msgs[1]:find("used POTION!", 1, true) ~= nil, "prints ' used POTION!'") +check(msgs[2] and msgs[2]:find("restored health!", 1, true) ~= nil, "prints 'restored health!'") + +local stF = itemState({ items = { 23, 0, 0, 0 }, hp = 40, status = "PSN" }) +local aF = Ai.chooseAction(stF, 1, { pack = pack }) +check(aF and aF.kind == "item" and aF.item == 23 and aF.aiItemFlags == 0x10, "poisoned mon uses FULL HEAL") +Engine.performEnemyItem(stF, Adapter.new(stF, function() end), aF) +check(stF.enemy.mon.status == nil, "FULL HEAL cures poison") + +local stWi = itemState({ items = { 13, 0, 0, 0 }, wild = true }) +local aWi = Ai.chooseAction(stWi, 1, { pack = pack }) +check(aWi and aWi.kind == "move", "wild mons never use items") + +print("[test] 11. Singles ShouldSwitch: Perish Song count 0 switches out") +local stS = State.new({ + wild = false, + playerParty = { { species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } } }, + foeParty = { + { species = 74, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 20 } }, + { species = 4, level = 20, hp = 50, maxHp = 50, moves = { 52 }, pp = { 25 } }, + }, + foeMon = nil, +}) +stS.aiFlags = 1 +stS.rng = function(lo, hi) if lo and hi then return lo end return 0 end +stS.enemy.perishSong, stS.enemy.expPerishTurns = true, 0 +local aS = Commands.enemyAction(stS) +check(aS and aS.kind == "switch" and aS.slot == 2 and aS.battler == 1, "Perish Song 0 -> switch to slot 2") +stS.wild = true +stS.monToSwitchInto = {} +local aS2 = Ai.chooseAction(stS, 1, { pack = pack }) +check(aS2 and aS2.kind == "move", "wild mons never switch") + if failed > 0 then print(string.format("\n%d FAILED", failed)) os.exit(1) diff --git a/tests/game3_battle_doubles_engine_test.lua b/tests/game3_battle_doubles_engine_test.lua new file mode 100644 index 00000000..34dd65c0 --- /dev/null +++ b/tests/game3_battle_doubles_engine_test.lua @@ -0,0 +1,183 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Moves = require("src.core.game3.battle.moves") + +-- pokefirered/src/data/battle_moves.h:1 +local ROM = { + [33] = { effect = 0, power = 35, type = 0, accuracy = 95, pp = 35, secondaryChance = 0, target = 0, priority = 0, flags = 51 }, + [57] = { effect = 0, power = 95, type = 11, accuracy = 100, pp = 15, secondaryChance = 0, target = 8, priority = 0, flags = 50 }, + [89] = { effect = 147, power = 100, type = 4, accuracy = 100, pp = 10, secondaryChance = 0, target = 32, priority = 0, flags = 50 }, + [98] = { effect = 103, power = 40, type = 0, accuracy = 100, pp = 30, secondaryChance = 0, target = 0, priority = 1, flags = 51 }, + [266] = { effect = 172, power = 0, type = 0, accuracy = 0, pp = 20, secondaryChance = 0, target = 16, priority = 3, flags = 0 }, + [270] = { effect = 176, power = 0, type = 0, accuracy = 0, pp = 20, secondaryChance = 0, target = 16, priority = 5, flags = 0 }, +} +Moves._romLoaded = true +Moves._rom = ROM + +local State = require("src.core.game3.battle.state") +local Engine = require("src.core.game3.battle.engine") +local Adapter = require("src.core.game3.battle.adapter") +local Types = require("src.core.game3.battle.types") +local T = Types.ID + +local failed, passed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function mon(o) + return { + species = o.species or 1, level = o.level or 50, hp = o.hp or 200, maxHp = o.maxHp or 200, + attack = o.attack or 50, defense = o.defense or 50, spAtk = o.spAtk or 50, spDef = o.spDef or 50, + speed = o.speed or 50, ability = o.ability or 0, nickname = o.nickname, + moves = o.moves or { 33 }, pp = { 20, 20, 20, 20 }, + } +end + +local function rng(lo, hi) + if lo == 1 and hi == 100 then return 1 end + return hi +end + +local function battle(p, e) + local party, foes = {}, {} + for i, o in ipairs(p) do + o.nickname = o.nickname or ("P" .. i) + party[i] = mon(o) + end + for i, o in ipairs(e) do + o.nickname = o.nickname or ("E" .. i) + foes[i] = mon(o) + end + local st = State.new({ wild = false, double = true, playerParty = party, foeParty = foes }) + for id = 0, 3 do + local b = st.battlers[id] + if b then b.type1, b.type2 = T.NORMAL, nil end + end + st.rng = rng + return st, Adapter.new(st) +end + +local function hp(st, id) return tonumber(st.battlers[id].mon.hp) end + +local function run_turn(st, ad, chosen) + local actions = Engine.planTurnActions(st, ad, chosen) + for _, act in ipairs(actions) do + if Engine.actionRunnable(st, act) and act.kind == "move" then + act.done = true + Engine.resolveMove(act.user, act.target, act.move, act.slot, ad, st, {}) + end + end + return actions +end + +print("=== state: four slots, aliases, helpers ===") +do + local st = battle({ {}, {}, {} }, { {}, {} }) + check(st.double and st.battlersCount == 4, "double state has four battlers") + check(st.battlers[0] == st.player and st.battlers[1] == st.enemy, "slots 0/1 alias st.player/st.enemy") + check(st.battlers[2].partyIndex == 2 and st.battlers[3].partyIndex == 2, "slots 2/3 take the next usable mon") + check(State.PARTNER(1) == 3 and State.OPPOSITE(2) == 3 and State.sideOf(3) == "enemy", "PARTNER/OPPOSITE/sideOf") + check(st.battlers[1].participants[1] and st.battlers[3].participants[2], "both flanks start with both player mons sent") +end + +print("=== turn order: speed and priority ===") +do + local st, ad = battle({ { speed = 10, moves = { 33, 98 } }, { speed = 30 } }, { { speed = 40 }, { speed = 20 } }) + local order = run_turn(st, ad, { + [0] = { kind = "move", move = 33, slot = 1, target = 1 }, + [1] = { kind = "move", move = 33, slot = 1, target = 0 }, + [2] = { kind = "move", move = 33, slot = 1, target = 1 }, + [3] = { kind = "move", move = 33, slot = 1, target = 0 }, + }) + check(table.concat(st.turnOrder, ",") == "1,2,3,0", "speed order 1,2,3,0") + check(#order == 4, "four actions planned") + Engine.planTurnActions(st, ad, { + [0] = { kind = "move", move = 98, slot = 2, target = 1 }, + [1] = { kind = "move", move = 33, slot = 1, target = 0 }, + [2] = { kind = "switch", partySlot = 3 }, + [3] = { kind = "move", move = 33, slot = 1, target = 0 }, + }) + check(st.turnOrder[1] == 2 and st.turnOrder[2] == 0, "switch first, then priority move") +end + +print("=== spread damage halves with two defenders ===") +do + local st, ad = battle({ { moves = { 57 } }, {} }, { {}, {} }) + Engine.resolveMove(st.battlers[0], nil, 57, 1, ad, st, {}) + local both1, both3 = 200 - hp(st, 1), 200 - hp(st, 3) + check(both1 > 0 and both3 > 0 and hp(st, 2) == 200, "Surf hits both foes, not the ally") + local st2, ad2 = battle({ { moves = { 57 } }, {} }, { {}, {} }) + Engine.markAbsent(st2, 3) + Engine.resolveMove(st2.battlers[0], nil, 57, 1, ad2, st2, {}) + local solo = 200 - hp(st2, 1) + check(solo > both1 and math.abs(solo - 2 * both1) <= 2, "one present defender takes full damage") +end + +print("=== Earthquake hits ally and foes ===") +do + local st, ad = battle({ { moves = { 89 } }, {} }, { {}, {} }) + Engine.resolveMove(st.battlers[0], nil, 89, 1, ad, st, {}) + check(hp(st, 1) < 200 and hp(st, 2) < 200 and hp(st, 3) < 200, "Earthquake damages battlers 1, 2 and 3") + check((200 - hp(st, 1)) == (200 - hp(st, 3)), "FOES_AND_ALLY is not halved") + check(st.battlers[0].mon.pp[1] == 19, "PP deducted once") +end + +print("=== Follow Me redirects ===") +do + local st, ad = battle({ {}, {} }, { {}, { moves = { 266 } } }) + run_turn(st, ad, { + [0] = { kind = "move", move = 33, slot = 1, target = 1 }, + [1] = { kind = "move", move = 33, slot = 1, target = 0 }, + [2] = { kind = "move", move = 33, slot = 1, target = 1 }, + [3] = { kind = "move", move = 266, slot = 1 }, + }) + check(hp(st, 1) == 200 and hp(st, 3) < 200, "both player attacks go into the Follow Me user") +end + +print("=== Helping Hand boosts the partner ===") +do + local st, ad = battle({ {}, { moves = { 270 } } }, { {}, {} }) + run_turn(st, ad, { + [0] = { kind = "move", move = 33, slot = 1, target = 1 }, + [2] = { kind = "move", move = 270, slot = 1 }, + }) + local helped = 200 - hp(st, 1) + local st2, ad2 = battle({ {}, {} }, { {}, {} }) + run_turn(st2, ad2, { [0] = { kind = "move", move = 33, slot = 1, target = 1 } }) + local plain = 200 - hp(st2, 1) + check(st.battlers[0].expHelpingHand == true and helped > plain, "Helping Hand boosts the partner's damage") +end + +print("=== Intimidate hits both foes ===") +do + local st, ad = battle({ { ability = 22 }, {} }, { {}, {} }) + Engine.battleStartEffects(st, ad) + check(st.battlers[1].stages.attack == -1 and st.battlers[3].stages.attack == -1, "both foes lose one Attack stage") + check(st.battlers[2].stages.attack == 0, "ally untouched") +end + +print("=== faint bookkeeping and absent slots ===") +do + local st, ad = battle({ {}, {} }, { {}, {} }) + ad:setHp(st.battlers[3], 0) + local fainted = Engine.faintedBattlers(st) + check(#fainted == 1 and fainted[1] == 3, "battler 3 reported fainted") + local pend = Engine.pendingReplacements(st) + check(pend[1] and pend[1].noMons, "no replacement left for battler 3") + Engine.markAbsent(st, 3) + check(not State.isPresent(st, 3) and ad:foeOf(st.battlers[2]) == st.battlers[1], "absent flank flips foeOf") + Engine.resolveMove(st.battlers[0], 3, 33, 1, ad, st, {}) + check(hp(st, 1) < 200, "a move aimed at an absent foe hits its partner") +end + +print(string.format("\n%d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_battle_switch_and_faint_test.lua b/tests/game3_battle_switch_and_faint_test.lua index 56de6e64..966c2c4a 100644 --- a/tests/game3_battle_switch_and_faint_test.lua +++ b/tests/game3_battle_switch_and_faint_test.lua @@ -261,20 +261,28 @@ do } local selectedSlot = nil + local function validate(slot) + local mon = party[slot] + if mon and mon.hp <= 0 then return "BULBASAUR has no energy\nleft to battle!" end + return nil + end PartyMenu.show(party, nil, { mode = "battle_switch", activeSlot = 1, + validate = validate, onSelect = function(slot) selectedSlot = slot end, }) - -- In battle_switch mode, cursor started at 2 (since 1 was active) - -- Try to pick fainted slot 2 local fakeInput = { wasPressed = function(self, key) return key == "a" end, } + PartyMenu.cursor = 2 PartyMenu.handleInput(fakeInput) - eq(PartyMenu.mode, "message", "picking fainted mon shows warning message") - check(PartyMenu._messageText:find("no will"), "message states 'There\\'s no will to fight!'") + eq(PartyMenu.mode, "action", "A on a fainted mon opens the action menu") + PartyMenu.actionCursor = 1 + PartyMenu.handleInput(fakeInput) + eq(PartyMenu.mode, "message", "SHIFT on a fainted mon shows the warning") + check(PartyMenu._messageText:find("no energy"), "message is pret's no-energy line") PartyMenu.dismissMessage() -- Move to slot 3 and pick @@ -303,22 +311,20 @@ do wasPressed = function(self, key) return key == "b" end, } PartyMenu.handleInput(cancelInput) - eq(PartyMenu.mode, "message", "pressing B shows warning in battle_faint mode") - check(PartyMenu._messageText:find("Choose a POKéMON"), "message requires choosing a Pokémon") - PartyMenu.dismissMessage() + eq(PartyMenu.mode, "battle_faint", "pressing B cannot leave battle_faint mode") -- Pick valid conscious slot 3 -> opens action menu PartyMenu.cursor = 3 PartyMenu.handleInput(fakeInput) eq(PartyMenu.mode, "action", "battle_faint opens action menu for conscious slot 3") - eq(PartyMenu.ACTIONS[1], "SHIFT", "action menu has SHIFT") + eq(PartyMenu.ACTIONS[1], "SEND OUT", "action menu has SEND OUT") eq(PartyMenu.ACTIONS[2], "SUMMARY", "action menu has SUMMARY") eq(PartyMenu.ACTIONS[3], "CANCEL", "action menu has CANCEL") -- Confirm SHIFT PartyMenu.actionCursor = 1 PartyMenu.handleInput(fakeInput) - eq(selectedSlot, 3, "SHIFT confirms slot 3 selection in battle_faint") + eq(selectedSlot, 3, "SEND OUT confirms slot 3 selection in battle_faint") eq(PartyMenu.isOpen(), false, "PartyMenu closed after faint replacement") end diff --git a/tests/game3_quest_log_integration_test.lua b/tests/game3_quest_log_integration_test.lua index 10fd9064..a83b5c73 100644 --- a/tests/game3_quest_log_integration_test.lua +++ b/tests/game3_quest_log_integration_test.lua @@ -1,4 +1,5 @@ package.path='./?.lua;./?/init.lua;'..package.path +require('src.core.GameVersion').set('firered') local Q=require('src.core.game3.quest_log') local UI=require('src.ui.game3.quest_log') local Game=require('src.core.Game3') diff --git a/tests/modkit/cases/gen3check.lua b/tests/modkit/cases/gen3check.lua new file mode 100644 index 00000000..eec0721f --- /dev/null +++ b/tests/modkit/cases/gen3check.lua @@ -0,0 +1,73 @@ +-- T4: FireRed mod API parity, read from src/mods/Gen3Compat.lua and the +-- per-generation mod.world / mod.battle modules at run time. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local Gen2Compat = require("src.mods.Gen2Compat") +local Gen3Compat = require("src.mods.Gen3Compat") + +for _, fn in ipairs({ "serves", "resolve", "bind", "coverage", "modules", + "memberStatus", "applyMerged", "scriptCtx" }) do + T.eq(type(Gen3Compat[fn]), "function", "Gen3Compat." .. fn .. " exists") +end +T.eq(type(Gen3Compat.ADAPTERS), "table", "Gen3Compat.ADAPTERS is published") +T.eq(Gen3Compat.COVERAGE_VERSION, Gen2Compat.COVERAGE_VERSION, + "both compat layers speak one coverage contract version") + +for name in pairs(Gen2Compat.ADAPTERS) do + T.check(Gen3Compat.serves(name), + "a module Gold adapts is adapted on FireRed too: " .. name) +end +for name in pairs(Gen3Compat.ADAPTERS) do + T.check(Gen2Compat.serves(name), + "FireRed adapts nothing Gold does not: " .. name) + local row = Gen3Compat.coverage(name) + T.check(row ~= nil, "coverage row for " .. name) + if row then + T.check(row.kind == "facade" or row.kind == "alias", + "coverage kind is facade or alias: " .. name) + for member, status in pairs(row.members) do + T.check(status == "backed" or status == "warned" or status == "absent", + ("%s.%s carries one of the three statuses"):format(name, member)) + end + end +end + +local function publicMethods(module) + local out = {} + for key, value in pairs(module) do + if type(value) == "function" and not key:find("^_") then out[#out + 1] = key end + end + table.sort(out) + return out +end + +local function surface(label, gen3Name, others) + local ok3, gen3 = pcall(require, gen3Name) + T.check(ok3, label .. ": " .. gen3Name .. " loads headless") + if not ok3 then return end + for _, otherName in ipairs(others) do + local ok, other = pcall(require, otherName) + if ok and type(other) == "table" then + for _, key in ipairs(publicMethods(other)) do + T.eq(type(gen3[key]), "function", + ("%s: %s.%s has a FireRed arm"):format(label, otherName, key)) + end + else + T.check(true, label .. ": " .. otherName .. " not loadable headless, skipped") + end + end +end + +surface("mod.world", "src.world.game3.WorldAPI", + { "src.world.WorldAPI", "src.world.gen2.WorldAPI" }) +surface("mod.battle", "src.battle.game3.BattleAPI", + { "src.battle.BattleAPI", "src.battle.gen2.BattleAPI" }) + +local worldRow = Gen3Compat.coverage("src.world.WorldAPI") +T.eq(worldRow and worldRow.target, "src.world.game3.WorldAPI", + "the WorldAPI coverage row names the FireRed module") + +T.finish("gen3check") diff --git a/tests/modkit/sdk.lua b/tests/modkit/sdk.lua index 1745d655..2beee827 100644 --- a/tests/modkit/sdk.lua +++ b/tests/modkit/sdk.lua @@ -115,9 +115,10 @@ end -- opts.fs override the filesystem entirely (e.g. Sdk.memfs) -- opts.root repo root the real paths are relative to -- opts.dev force the dev tripwire on --- opts.generation 1 (default) or 2; loads as if Gold were the running game, --- which is the seam the gen2compat gate and the registry --- target routing are tested through without booting Gold +-- opts.generation 1 (default), 2 or 3; loads as if Gold or FireRed were the +-- running game, which is the seam the generation gate and the +-- registry target routing are tested through without a boot; +-- a Gen 3 run wants opts.data = Sdk.gen3Data() function Sdk.loadMods(paths, opts) opts = opts or {} local data = opts.data or require("tests.modkit.fixtures").fresh() @@ -155,6 +156,95 @@ function Sdk.loadMods(paths, opts) } end +local function mon(hp, atk, def, spe, spa, spd) + return { hp = hp, atk = atk, def = def, spe = spe, spa = spa, spd = spd } +end + +local function meta(catchRate, expYield, growthRate) + return { catchRate = catchRate, expYield = expYield, genderRatio = 31, + eggCycles = 20, friendship = 70, growthRate = growthRate, + eggGroup1 = 1, eggGroup2 = 1, itemCommon = 0, itemRare = 0 } +end + +local function move(power, typeId, accuracy, pp) + return { effect = 0, power = power, type = typeId, accuracy = accuracy, + pp = pp, secondaryChance = 0, target = 0, priority = 0, flags = 0 } +end + +function Sdk.gen3Data() + return { + maps = { + FR_OAKS_LAB = { id = "FR_OAKS_LAB", name = "OAKS LAB", + width = 13, height = 12 }, + }, + tilesets = {}, + gen3Pokemon = { + _names = { [4] = "CHARMANDER", [5] = "CHARMELEON", [16] = "PIDGEY", + [29] = "NIDORAN\226\153\128", [151] = "MEW", + [252] = "?" }, + _types = { [4] = { 10, 10 }, [5] = { 10, 10 }, [16] = { 0, 2 }, + [29] = { 3, 3 }, [151] = { 14, 14 } }, + _stats = { [4] = mon(39, 52, 43, 65, 60, 50), + [5] = mon(58, 64, 58, 80, 80, 65), + [16] = mon(40, 45, 40, 56, 35, 35), + [29] = mon(55, 47, 52, 41, 40, 40), + [151] = mon(100, 100, 100, 100, 100, 100) }, + _speciesMeta = { [4] = meta(45, 65, 3), [5] = meta(45, 142, 3), + [16] = meta(255, 55, 3), [29] = meta(235, 59, 3), + [151] = meta(45, 64, 3) }, + _abilities = { [4] = { 66, 0 }, [5] = { 66, 0 }, [16] = { 51, 0 }, + [29] = { 38, 0 }, [151] = { 28, 0 } }, + _abilityNames = { [28] = "SYNCHRONIZE", [38] = "POISON POINT", + [51] = "KEEN EYE", [66] = "BLAZE" }, + _learnsets = { [4] = { { 1, 10 }, { 7, 52 } }, [5] = { { 1, 10 } }, + [16] = { { 1, 33 } }, [29] = { { 1, 33 } }, + [151] = { { 1, 1 } } }, + _evolutions = { [4] = { { method = 4, param = 16, target = 5 } } }, + _dex = { [4] = { category = "LIZARD", height = 6, weight = 85 }, + [151] = { category = "NEW SPECIES", height = 4, weight = 40 } }, + _moveNames = { [0] = "-", [1] = "POUND", [10] = "SCRATCH", + [33] = "TACKLE", [52] = "EMBER", [57] = "SURF" }, + }, + gen3Moves = { + _rom = { [1] = move(40, 0, 100, 35), [10] = move(40, 0, 100, 35), + [33] = move(35, 0, 95, 35), [52] = move(40, 10, 100, 25), + [57] = move(95, 11, 100, 15) }, + }, + gen3Items = { + _byId = { + [0] = { name = "????????", pocket = "ITEMS", price = 0 }, + [4] = { name = "POK\195\169 BALL", pocket = "POKE_BALLS", price = 200 }, + [13] = { name = "POTION", pocket = "ITEMS", price = 300 }, + [96] = { name = "THUNDERSTONE", pocket = "ITEMS", price = 2100 }, + }, + }, + gen3Encounters = { + FR_ROUTE_1 = { mapGroup = 3, mapNum = 19, land = { rate = 21, slots = { + { species = 16, minLevel = 2, maxLevel = 3 }, + { species = 29, minLevel = 2, maxLevel = 4 } } } }, + ["3:19"] = { mapGroup = 3, mapNum = 19, land = { rate = 21, slots = { + { species = 16, minLevel = 2, maxLevel = 3 }, + { species = 29, minLevel = 2, maxLevel = 4 } } } }, + }, + gen3Trainers = { + classNames = { [81] = "RIVAL" }, + trainers = { + [326] = { class = 81, className = "RIVAL", name = "TERRY", + partySize = 1, party = { { species = 4, level = 5 } }, + dialogs = {} }, + }, + }, + gen3Text = { + Text_BootedUpPC = { { t = "player" }, { t = "text", s = " booted up the PC." }, + { t = "eos" } }, + }, + gen3Scripts = { + EventScript_Fixture = { { op = "msgbox", text = "Text_BootedUpPC" }, + { op = "end" } }, + }, + } +end + function Sdk.loadMod(path, opts) local result = Sdk.loadMods({ path }, opts) result.mod = select(2, next(result.mods)) diff --git a/tools/generate_example_mod_sprite.py b/tools/generate_example_mod_sprite.py index 4948641f..5ef11922 100644 --- a/tools/generate_example_mod_sprite.py +++ b/tools/generate_example_mod_sprite.py @@ -6,26 +6,59 @@ import sys from PIL import Image, ImageOps +GBA_PIC = 64 +MEW = 151 -def invert(source: Path, destination: Path) -> None: - image = Image.open(source).convert("RGBA") + +def invert_image(image: Image.Image) -> Image.Image: + image = image.convert("RGBA") r, g, b, a = image.split() inverted = ImageOps.invert(Image.merge("RGB", (r, g, b))) inverted.putalpha(a) + return inverted + + +def save(image: Image.Image, destination: Path) -> None: destination.parent.mkdir(parents=True, exist_ok=True) - inverted.save(destination) + image.save(destination) print(f"generated {destination} ({image.width}x{image.height})") +def invert(source: Path, destination: Path) -> None: + save(invert_image(Image.open(source)), destination) + + +def invert_rgba(source: Path, destination: Path) -> None: + raw = source.read_bytes() + size = GBA_PIC * GBA_PIC * 4 + if len(raw) < size: + raise SystemExit(f"{source}: expected {size} bytes of 64x64 RGBA, got {len(raw)}") + image = Image.frombytes("RGBA", (GBA_PIC, GBA_PIC), raw[:size]) + save(invert_image(image), destination) + + def main() -> int: - if len(sys.argv) != 3: - print("usage: generate_example_mod_sprite.py ") + args = sys.argv[1:] + if len(args) not in (2, 3): + print("usage: generate_example_mod_sprite.py " + "[]") + print(" gen1-source-dir: an assets/generated dir (battle/front/mew.png); '-' skips it") + print(" firered-gba-cache-dir: /firered/data/generated/gba " + "(pokemon/front/151.rgba)") return 2 - source_dir, output_dir = map(Path, sys.argv[1:]) - invert(source_dir / "battle/front/mew.png", - output_dir / "assets/mew_front_inverted.png") - invert(source_dir / "battle/back/mewb.png", - output_dir / "assets/mew_back_inverted.png") + source_dir, output_dir = args[0], Path(args[1]) + if source_dir != "-": + source = Path(source_dir) + invert(source / "battle/front/mew.png", + output_dir / "assets/mew_front_inverted.png") + invert(source / "battle/back/mewb.png", + output_dir / "assets/mew_back_inverted.png") + if len(args) == 3: + gba = Path(args[2]) + invert_rgba(gba / f"pokemon/front/{MEW}.rgba", + output_dir / "assets/mew_front_inverted_64.png") + invert_rgba(gba / f"pokemon/back/{MEW}.rgba", + output_dir / "assets/mew_back_inverted_64.png") return 0