From 0136429d3e374e97fdc253684d1f7dd0c9f4ae69 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 11 Aug 2026 21:19:36 -0400 Subject: [PATCH] CLOSES #1090, CLOSES #1093, CLOSES #1094, CLOSES #1095, CLOSES #1098, CLOSES #1102, CLOSES #1105, CLOSES #1106, CLOSES #1108, CLOSES #1109, CLOSES #1110, CLOSES #1111, CLOSES #1113, CLOSES #1114, CLOSES #1117, CLOSES #1118, CLOSES #1121, CLOSES #1122, CLOSES #1123, CLOSES #1124, CLOSES #1126, CLOSES #1127, CLOSES #1128, CLOSES #1131, CLOSES #1132, CLOSES #1134, CLOSES #1137, CLOSES #1141 --- src/battle/gen2/Battle.lua | 36 ++++- src/core/ChipAudio.lua | 15 +- src/core/ChipSynth.lua | 23 ++- src/core/Game2.lua | 41 ++---- src/core/Music.lua | 2 + src/core/chip_worker.lua | 4 + src/core/gen2/Nests.lua | 4 +- src/core/gen2/Save.lua | 3 + src/import/RomExtractorGen2.lua | 5 +- src/render/TextBox.lua | 5 +- src/script/gen2/Vm.lua | 11 +- src/ui/ChoiceBox.lua | 5 +- src/ui/gen2/BattleState.lua | 187 ++++++++++++++++++++----- src/ui/gen2/BoxMenu.lua | 74 +++++++--- src/ui/gen2/MartMenu.lua | 2 +- src/ui/gen2/OptionsMenu.lua | 4 + src/ui/gen2/PokedexMenu.lua | 24 +++- src/ui/gen2/Pokegear.lua | 19 ++- src/ui/gen2/TradeAnim.lua | 4 + src/world/gen2/Permissions.lua | 14 ++ src/world/gen2/Player.lua | 9 +- src/world/gen2/World.lua | 192 +++++++++++++++++++------- tests/drivers/gold_pipeline_shots.lua | 159 +++++++++++++++++++++ tools/modkit.py | 19 ++- 24 files changed, 694 insertions(+), 167 deletions(-) create mode 100644 tests/drivers/gold_pipeline_shots.lua diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 120dbad5..b83fb6d6 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1246,14 +1246,14 @@ function Battle:dealDamage(attacker, defender, damage, opts) return damage end -function Battle:heal(mon, amount) +function Battle:heal(mon, amount, opts) local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 local before = mon.hp or 0 mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0))) local healed = mon.hp - before if healed > 0 then self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed, - hp = mon.hp }) + hp = mon.hp, anim = opts and opts.anim }) end return healed end @@ -2600,6 +2600,35 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender, self.forcedSwitch = true end +-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails +-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any +-- TRAINER battle; in a WILD battle the level ladder is identical to +-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to +-- the (0-power) damage path and never ended the battle. +Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender) + if self.battleType == Battle.BATTLETYPE_FORCESHINY + or self.battleType == Battle.BATTLETYPE_TRAP + or self:volatile(defender).trapsTarget then + return fail(self) + end + if not self.wild then return fail(self) end + + local userLevel = attacker.level or 1 + local targetLevel = defender.level or 1 + local succeeds = userLevel >= targetLevel + if not succeeds then + local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1)) + succeeds = roll >= math.floor(targetLevel / 4) + end + if not succeeds then return fail(self) end + + self.over = true + self.outcome = "fled" + self.forcedSwitch = true + self:emit({ kind = "run", side = self:sideOf(attacker), + text = self:monName(attacker) .. " fled from battle!" }) +end + -- -------------------------------------------------------- the move effects -- -- The three tables above as records, in the shape src/mods/Schemas.lua's @@ -4442,7 +4471,8 @@ function Battle:tickHeldItem(mon) end if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then - self:heal(mon, parameter > 0 and parameter or 10) + -- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim + self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" }) mon.item = nil self:emit({ kind = "message", text = name .. " ate the " .. (def.name or "BERRY") .. "!" }) diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index cb33bbb6..1a57d155 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -203,7 +203,8 @@ function ChipAudio.playMusic(data, header, allowLoops) cmdCh:push({ cmd = "play", gen = gen, header = header, allowLoops = allowLoops, audio = slimAudio(data), channelVolumes = ChipSynth.getChannelVolumes(), - channelPitches = ChipSynth.getChannelPitches() }) + channelPitches = ChipSynth.getChannelPitches(), + stereo = ChipSynth.getStereo() }) currentMusic = { source = source, gen = gen, threaded = true, started = false, finished = false } -- playback starts in update() once the first buffer arrives (~1 frame) @@ -214,7 +215,8 @@ local function pushChannelMix() if workerReady and cmdCh then cmdCh:push({ cmd = "channelMix", volumes = ChipSynth.getChannelVolumes(), - pitches = ChipSynth.getChannelPitches() }) + pitches = ChipSynth.getChannelPitches(), + stereo = ChipSynth.getStereo() }) end end @@ -352,6 +354,15 @@ function ChipAudio.shutdown() workerReady = false end +function ChipAudio.setStereo(enabled) + ChipSynth.setStereo(enabled) + pushChannelMix() +end + +function ChipAudio.getStereo() + return ChipSynth.getStereo() +end + -- Runtime mix for one hardware channel (1..4). Takes effect on the next -- synthesized buffer (live music) and on any SFX/cry rendered after the call. function ChipAudio.setChannelVolume(hw, scale) diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1b4737b3..1cc77f6b 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -29,6 +29,18 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT +-- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song +-- panning byte (audio/engine.asm:1987 wOptions STEREO bit). +local stereoEnabled = false + +function ChipSynth.setStereo(enabled) + stereoEnabled = not not enabled +end + +function ChipSynth.getStereo() + return stereoEnabled +end + -- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise). -- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave, -- 0.5 = -1 octave. Applied at sample time so a live change reaches the next @@ -760,11 +772,14 @@ function Channel:nextEventGen2() -- no-op for the PCM renderer elseif command == 0xEE then -- unknownmusic0xee self:word() - elseif command == 0xEF then -- stereo_panning (honor always; options.stereo) + elseif command == 0xEF then + -- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on local packed = self:byte() - local mask = bit.lshift(1, self.hardware - 1) - local default = bit.bor(bit.lshift(mask, 4), mask) - self.tracks = bit.band(packed, default) + if stereoEnabled then + local mask = bit.lshift(1, self.hardware - 1) + local default = bit.bor(bit.lshift(mask, 4), mask) + self.tracks = bit.band(packed, default) + end elseif command == 0xF0 then -- sfx_toggle_noise if self.noiseSampling then self.noiseSampling = false diff --git a/src/core/Game2.lua b/src/core/Game2.lua index ed5ca6c0..25ae0f8a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -140,7 +140,7 @@ Game2.anchorNewGameClock = anchorNewGameClock function Game2.new() local self = setmetatable({ - speedOverride = 1, + speedOverride = nil, capturePath = nil, world = nil, status = nil, @@ -975,27 +975,9 @@ function Game2:load() local Pipelines = require("src.render.Pipelines") Pipelines.install(self.data) Pipelines.applyOptions(self.options) - -- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the - -- world half: its overworld draws straight to the window rather than into a - -- canvas the way src/world/OverworldController.lua:4827 hands one to - -- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet. - -- A restored level for a world-only pipeline is retired rather than left - -- switched on, because on Gold it would render nothing AND hold TILT off - -- (Pipelines.setLevel's tilt exclusion). The stored level in - -- options.pipelines is left untouched, so the mode comes back the day Gold - -- grows a world canvas; Tilt is re-applied from the option the exclusion just - -- cleared. - local retired = false - for _, entry in ipairs(Pipelines.list()) do - if entry.def.drawWorld and not entry.def.present - and Pipelines.level(entry.id) > 0 then - Pipelines.setLevel(entry.id, 0) - retired = true - end - end - if retired then - require("src.render.Tilt").applyOptions(self.options) - end + -- Both halves run on Gold now: `present` folds over the composite in + -- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a + -- restored world level stays switched on, as it does for Gen 1. -- After the merge, so a font override and a translation mod's catalog -- (#501) are both in Data before the first screen draws a glyph. Gen 1 @@ -1112,19 +1094,20 @@ function Game2:update(dt) -- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265): -- they are presentational, so fast-forward must not speed them up. require("src.render.Pipelines").update(dt) - if self.phase == "boot" then - FixedStep.maxAccum = 0.25 - FixedStep:update(dt) - return - end - if not self.world or not self.world.map then return end -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: -- audio runs off its own real-time accumulator, so music and sfx keep their -- tempo at every multiplier. speedOverride is the driver/CLI hook and wins -- over the saved option. + -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld local speed = math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) + if self.phase == "boot" then + FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) + FixedStep:update(dt * speed) + return + end + if not self.world or not self.world.map then return end FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) FixedStep:update(dt * speed) end @@ -1921,6 +1904,8 @@ function Game2:applyOptions() -- the mod pipeline ladder rides options.pipelines and restores with the rest -- of the display block, as it does in src/core/Game.lua:1041 require("src.render.Pipelines").applyOptions(options) + -- src/core/Game.lua:1121 mirrors this call for Gen 1 + Input:applyBindings(options.bindings) -- options.touchControls (the launcher editor's per-orientation layouts) and -- options.haptics, the same two keys Gen 1 hands over here -- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a diff --git a/src/core/Music.lua b/src/core/Music.lua index 48f87cde..2c3523e9 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -460,6 +460,8 @@ end function Music.applyOptions(opts) Music.setVolumeLevel(opts and opts.musicVol or 7) Music.setFilterLevel(opts and opts.musicFilter or 0) + -- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit) + require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO") end local function sourceStopped(src) diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index fccba224..c3bce302 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -53,6 +53,9 @@ local function handle(cmd) if cmd.channelPitches ~= nil then ChipSynth.setChannelPitches(cmd.channelPitches) end + if cmd.stereo ~= nil then + ChipSynth.setStereo(cmd.stereo) + end local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header, { allowLoops = cmd.allowLoops }) if ok then @@ -69,6 +72,7 @@ local function handle(cmd) elseif cmd.cmd == "channelMix" then if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end + if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end elseif cmd.cmd == "invalidate" then ChipSynth.invalidateBanks() elseif cmd.cmd == "quit" then diff --git a/src/core/gen2/Nests.lua b/src/core/gen2/Nests.lua index ee44b33e..9b1399d7 100644 --- a/src/core/gen2/Nests.lua +++ b/src/core/gen2/Nests.lua @@ -96,7 +96,7 @@ function Nests.landmark(data, index) end local function landmarkOfMap(data, mapId) - local def = data and data.maps and data.maps[mapId] + local def = data and data.gen2Maps and data.gen2Maps[mapId] return def and def.landmark end @@ -131,7 +131,7 @@ function Nests.find(data, species, region, save) out[#out + 1] = landmark end - local enc = data and data.encounters + local enc = data and data.gen2Encounters for _, key in ipairs({ "grass", "water" }) do for mapId, entry in pairs((enc and enc[key]) or {}) do if tableHasSpecies(entry, species) then diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 0a4e7b1c..bfce17e0 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -127,6 +127,9 @@ end Save.filenames = saveNames local function fs() + -- portable.txt: same standard/portable root as Gen 1 (SaveData.persistenceFs). + local ok, SaveData = pcall(require, "src.core.SaveData") + if ok and SaveData.persistenceFs then return SaveData.persistenceFs() end return love.filesystem end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 42847e1a..c919e104 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -241,7 +241,10 @@ function RomExtractorGen2:writeCompressedPic(label, tiles, relative) while #pixels < byteLength do pixels[#pixels + 1] = 0 end while #pixels > byteLength do table.remove(pixels) end pixels = ImageWriter.columnsToRows(pixels, tiles, tiles) - self:write2bpp(pixels, size, size, relative) + -- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking, + -- so matte the white backdrop like Gen 1's writeCompressedPic does. + self:save(ImageWriter.matteColor0( + ImageWriter.decode2bpp(pixels, size, size)), relative) end function RomExtractorGen2:extractConstants() diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 46f454d4..bfad6102 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -19,6 +19,8 @@ TextBox.isTextBox = true -- construction time, so an unthemed boot stays byte-identical local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 local MAX_COLS = 18 +-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5 +local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } -- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops -- up over the still-visible text (YesNoChoicePokeCenter and friends); @@ -352,7 +354,8 @@ function TextBox:update(dt) -- typewriter cadence: one character every N frames, N = the OPTION -- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B -- prints every frame like the original's held-button fast path - local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3 + local rawSpeed = self.game.save.options and self.game.save.options.textSpeed + local delay = NAME_DELAYS[rawSpeed] or rawSpeed or 3 if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end if input:isDown("a") or input:isDown("b") then delay = 1 end self.charTimer = (self.charTimer or 0) + 1 diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index c3f53c89..b1138802 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -236,6 +236,12 @@ local function runCmd(self, cmd, op) if self.hidePicFn then self.hidePicFn() end elseif op == "writetext" or op == "farwritetext" then self:showText(cmd.text) + if self.nextOp == "playsound" then + -- pokegold home/joypad.asm PromptButton: the real press this box's + -- own close absorbed plays SFX_READ_TEXT_2; drain it before the + -- script's own playsound or Sound.lua's priority gate drops it. + coroutine.yield({ kind = "waitsfx" }) + end elseif op == "rawtext" then -- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and -- text.lua only holds strings the extractor reached through a script @@ -384,9 +390,12 @@ local function runCmd(self, cmd, op) local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum) self.scriptVar = scene or 0xff elseif op == "turnobject" then + -- engine/events/std_scripts.asm: turnobject LAST_TALKED resolves to the NPC last talked to local facing = Movement.dir(cmd.facing or 0) + local object = cmd.object or 0 + if object == LAST_TALKED then object = self.lastTalked end if self.turnObjectFn then - self.turnObjectFn(cmd.object or 0, facing) + self.turnObjectFn(object, facing) end elseif op == "applymovement" or op == "applymovementlasttalked" then local object = cmd.object or 0 diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f8..0677fadc 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -74,7 +74,10 @@ function ChoiceBox:draw() if r and r.setUIAnchor then r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor) end - Font.drawBox(tx, ty, tw, th) + -- pokegold home/menu.asm YesNoBox: font-page tiles take the screen's own + -- BG palette 0 colour 0, same as TextBox.lua's paper fold. + local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() + Font.drawBox(tx, ty, tw, th, paper) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index b7410f4f..0fc31b26 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -43,8 +43,9 @@ local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true --- How long a message stays before the next event runs, in logic steps. The --- cart waits for A on most lines; holding A skips faster, same as text boxes. +-- Armed while a battle line waits for PromptButton (home/text.asm). Any +-- positive value means "hold until A/B"; the cart never times these out, so +-- the victory jingle can keep looping through the post-win prompts. local MESSAGE_FRAMES = 48 -- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. @@ -573,8 +574,10 @@ function BattleState:drawPic(mon, back) local px, py local boxTiles if back then - px = BattleState.PLAYER_PIC_TILE_X * 8 - py = BattleState.PLAYER_PIC_TILE_Y * 8 + -- pokegold engine/battle/core.asm:8569: 6x6 box, bottom-aligned/centred + local box = BattleState.PLAYER_PIC_TILES * 8 + px = BattleState.PLAYER_PIC_TILE_X * 8 + math.floor((box - w) / 2) + py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h) boxTiles = BattleState.PLAYER_PIC_TILES else -- Bottom-aligned and horizontally centred inside the 7x7 box. @@ -881,8 +884,12 @@ function BattleState:startAnim(key, opts) if not self.anims.scripts[key] then return false end -- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the -- move animation entirely, which is the OPTION screen's BATTLE SCENE row. + -- The check only applies to a real move id (wFXAnimID+1 == 0); non-move + -- ids (isMove unset here) branch straight to .not_move and always run. local options = self.game and self.game.options - if options and options.battleScene == false then return false end + if options and options.battleScene == false and opts and opts.isMove then + return false + end opts = opts or {} local data = (self.game and self.game.data) or {} local audio = data.audio or {} @@ -939,11 +946,38 @@ function BattleState:startAnim(key, opts) return true end +-- wBattleAfterAnim target for this attacker's turn +-- (effect_commands.asm:1963-1972): player swing -> enemy shake, and reverse. +function BattleState:afterAnimFor(side) + if side == "player" then return "ANIM_ENEMY_DAMAGE" end + return "ANIM_PLAYER_DAMAGE" +end + function BattleState:animForMove(moveId, side) local key = self.anims and self.anims.moves and self.anims.moves[moveId] - return self:startAnim(key, { + local started = self:startAnim(key, { turn = self:turnFor(side), animId = moveId, isMove = true, }) + if started then + -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script + -- restores HUDs it immediately runs wBattleAfterAnim (the hit shake). + -- Queue it so stepAnim chains without waiting on the next event. + self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side } + end + return started +end + +-- Kick off a queued after-anim; returns true when one is now running. +function BattleState:startPendingAfterAnim() + local pending = self.pendingAfterAnim + if not pending then return false end + self.pendingAfterAnim = nil + if self:animForId(pending.name, pending.side) then + -- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there. + self.afterAnimPlayed = true + return true + end + return false end -- True while BattleAnimClearHud has that side's HUD blanked. @@ -969,10 +1003,16 @@ function BattleState:stepAnim(input) -- tilemap is whatever they had got to and nothing is latched -- the -- explicit latches (a catch) are the only ones that survive a skip. self.anim = nil + -- Cart still reaches the after-anim arm after a move script ends; a skip + -- of the move should not drop the hit shake that follows it. + if self:startPendingAfterAnim() then return end return self:endSendOutAnim() end if not self.anim:step() then - self.anim = nil + -- pokegold data/moves/animations.asm .Click: anim_keepsprites means + -- the OAM outlives the script, so keep the runner for drawing too. + if not self.anim.keepSprites then self.anim = nil end + if self:startPendingAfterAnim() then return end return self:endSendOutAnim() end end @@ -1103,7 +1143,14 @@ function BattleState:advanceQueue() -- until the next damage or heal event moved it. local battle = self.battle local mon = battle and battle.party and battle.party[event.index] + -- pokegold engine/battle/core.asm:7057-7069: every mon that leveled + -- gets the stats box, not just the mon currently on the field. + self.pendingStatsMon = mon + -- BattleText_StringBuffer1GrewToLevel ends in text_end (battle.asm:336-343), + -- and the active mon never even prints it (core.asm:7044-7056 jumps to the + -- stats box). Either way there is no PromptButton before the stats box. if mon and mon == battle.player then + event.text = nil if self.shownHp then self.shownHp.player = mon.hp or 0 if self.hpAnim and self.hpAnim.side == "player" then @@ -1257,7 +1304,19 @@ function BattleState:advanceQueue() end if event.text then self.message = event.text - self.messageTimer = MESSAGE_FRAMES + -- Lines that must not hold the queue for A/B: + -- move UsedMoveText -> text_end, then moveanim + -- level GrewToLevel is text_end (battle.asm:336-343), then the stats + -- box's WaitPressAorB is the real hold + -- experience keeps the wait: _ExpPointsText ends in `prompt` + -- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait, + -- so the bar crawls under the line and A dismisses it before the battle + -- can end. + if event.kind == "move" or event.kind == "level" then + self.messageTimer = 0 + else + self.messageTimer = MESSAGE_FRAMES + end -- BattleStartMessage's own line: the enemy HUD comes up on the step after -- it returns (engine/battle/core.asm:7808-7817), not with it. if event.intro then self.introTextShown = true end @@ -1277,14 +1336,25 @@ function BattleState:advanceQueue() end end -- The move's own animation plays over its "used X!" line, which is where - -- PlayBattleAnim sits in the effect command list. A damage event that - -- follows gets the shared hit animation instead. + -- PlayBattleAnim sits in the effect command list. Its after-anim (the hit + -- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript. -- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens -- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a -- move that missed burns the delay and plays nothing. Battle:markMissed sets -- event.missed on every wAttackMissed path. if event.kind == "move" and not event.missed then - self:animForMove(event.move, event.side) + self.afterAnimPlayed = nil + self.pendingAfterAnim = nil + if not self:animForMove(event.move, event.side) then + -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim + -- (anim_commands.asm:55-72 .disabled fallthrough). + local options = self.game and self.game.options + if options and options.battleScene == false then + if self:animForId(self:afterAnimFor(event.side), event.side) then + self.afterAnimPlayed = true + end + end + end elseif event.kind == "damage" and event.side then -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). @@ -1293,10 +1363,25 @@ function BattleState:advanceQueue() if event.animMove then self:animForMove(event.animMove, from) elseif event.anim ~= false then - self:animForId(event.anim + local hit = event.anim or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE" - or "ANIM_PLAYER_DAMAGE"), from) + or "ANIM_PLAYER_DAMAGE") + -- Already played as the move's after-anim; do not shake twice. + if self.afterAnimPlayed + and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then + self.afterAnimPlayed = nil + else + self:animForId(hit, from) + end end + else + -- Status moves still chain the after-anim but emit no damage event to + -- consume the latch; drop it before the next unrelated line. + self.afterAnimPlayed = nil + end + if event.kind == "heal" and event.anim and event.side then + -- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim + self:animForMove(event.anim, event.side) elseif event.kind == "send" and event.side then -- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation -- (engine/battle/core.asm:3354) -- the faint replacement out of @@ -1593,7 +1678,9 @@ function BattleState:update(_dt) -- An animation owns the screen for as long as it runs, exactly the way -- RunBattleAnimScript owns the main loop. - if self.anim then + -- pokegold data/moves/animations.asm .Click: a finished keepsprites run + -- no longer owns the loop, just the OAM the draw path still reads. + if self.anim and not (self.anim:done() and self.anim.keepSprites) then self:stepAnim(input) return end @@ -1611,32 +1698,45 @@ function BattleState:update(_dt) if Sound.isPlaying(self.waitSfx) then return end self.waitSfx = nil end + -- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint + -- (core.asm:6881-6888), with that line still on screen. Run the crawl + -- before any PromptButton wait so the bar does not sit frozen until A. + if self:stepExpAnim() then return end if self.messageTimer > 0 then if self.tutorial then - -- PromptButton really does wait for the button; MESSAGE_FRAMES is this - -- screen's shortcut for that, and the tutorial cannot take it. The - -- DUDE's A lands on frame 0x51 (DudeAutoInput_A), so a line that timed - -- out at 48 would hand his press to whichever screen came next. + -- PromptButton waits for the button; the tutorial cannot press it, so + -- DudeAutoInput_A (frame 0x51) answers. Never auto-timeout here: a + -- 48-frame skip would hand his press to the next screen. self:dudeInput(CatchTutorial.PROMPT_STREAM, "prompt:" .. tostring(self.message)) - else - self.messageTimer = self.messageTimer - 1 end - -- A is the page-advance, exactly like a text box. + -- PromptButton (home/text.asm): A/B pages; no frame countdown. if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end return end - -- AnimateExpBar is called from GiveExperiencePoints AFTER - -- Text_MonGainedExpPoint has been read (engine/battle/core.asm:6884-6888), - -- so the crawl runs with that line still standing and the "grew to level" - -- line waits behind it. - if self:stepExpAnim() then return end + -- pokegold engine/battle/core.asm:7057-7069: the stats box shows once + -- the "grew to level" line has finished, held for A/B. + if self.pendingStatsMon then + self.statsBoxMon = self.pendingStatsMon + self.pendingStatsMon = nil + self.phase = "stats-box" + return + end self:advanceQueue() return end + -- pokegold engine/battle/core.asm:7069 (WaitPressAorB_BlinkCursor). + if self.phase == "stats-box" then + if input:wasPressed("a") or input:wasPressed("b") then + self.statsBoxMon = nil + self.phase = "resolving" + end + return + end + -- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the -- cart falls straight into ParsePlayerAction, whose .locked_in arm has no -- MoveSelectionScreen in front of it. @@ -1653,6 +1753,8 @@ function BattleState:update(_dt) if self.phase == "menu" then -- 2x2 grid: left/right swap the column, up/down the row. + -- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2 + -- on A/B only, never on D-pad. if input:wasPressed("left") or input:wasPressed("right") then self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1 or self.menuIndex - 1 @@ -1660,6 +1762,7 @@ function BattleState:update(_dt) self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2 or self.menuIndex - 2 elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") local choice = MENU[self.menuIndex] if choice == "FIGHT" then -- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm @@ -1710,11 +1813,13 @@ function BattleState:update(_dt) end elseif input:wasPressed("b") then -- B leaves the list, and a mark never survives it + self:playSfx("Sfx_ReadText2") self.moveSwapIndex = nil self.phase = "menu" elseif input:wasPressed("a") then -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move -- cancels a pending swap rather than performing it + self:playSfx("Sfx_ReadText2") self.moveSwapIndex = nil local move = moves[self.moveIndex] if not move then return end @@ -1733,7 +1838,6 @@ function BattleState:update(_dt) -- AskGiveNicknameText ends on `done`, so the line stands while the box is -- up rather than paging away from under it. if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1754,7 +1858,6 @@ function BattleState:update(_dt) -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). if self.phase == "ask-shift" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1775,7 +1878,6 @@ function BattleState:update(_dt) if self.phase == "refuse-shift" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1797,7 +1899,6 @@ function BattleState:update(_dt) -- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry. if self.phase == "refuse-switch" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1816,7 +1917,6 @@ function BattleState:update(_dt) if self.phase == "refuse-move" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1829,7 +1929,6 @@ function BattleState:update(_dt) if self.phase == "learn-intro" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1843,7 +1942,6 @@ function BattleState:update(_dt) if self.phase == "ask-forget" or self.phase == "stop-learning" then if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -1864,7 +1962,6 @@ function BattleState:update(_dt) -- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints -- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197). if self.messageTimer > 0 then - self.messageTimer = self.messageTimer - 1 if input:wasPressed("a") or input:wasPressed("b") then self.messageTimer = 0 end @@ -3089,9 +3186,31 @@ function BattleState:drawPanel() Chrome.cursor(left + 1, index == 1 and 8 or 10) end end + if self.phase == "stats-box" and self.statsBoxMon then + self:drawStatsBox(self.statsBoxMon) + end love.graphics.setColor(1, 1, 1, 1) end +-- pokegold engine/pokemon/mon_stats.asm:118-124 (PrintTempMonStats.StatNames). +local STATS_BOX_ROWS = { + { "ATTACK", "attack" }, { "DEFENSE", "defense" }, + { "SPCL.ATK", "specialAttack" }, { "SPCL.DEF", "specialDefense" }, + { "SPEED", "speed" }, +} + +-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y). +function BattleState:drawStatsBox(mon) + local stats = mon and mon.stats + if not stats then return end + Chrome.textbox(9, 0, 9, 10) + for i, row in ipairs(STATS_BOX_ROWS) do + local ty = 1 + (i - 1) * 2 + Chrome.print(Strings(row[1]), 11, ty) + Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1) + end +end + -- The BG layer, plus whatever the animation is doing to it, plus the OBJ -- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn -- after the scanline blit rather than into the canvas with everything else. diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 9b8e883b..8271c4fc 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -43,6 +43,7 @@ local Assets = require("src.render.Assets") local Boxes = require("src.core.gen2.Boxes") local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Mail = require("src.core.gen2.Mail") local Palettes = require("src.world.gen2.Palettes") @@ -74,6 +75,14 @@ local PARTY_BOX = 0 -- can destroy a mon. local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" } +-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows. +local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" } + +function BoxMenu:submenuRows() + if self.mode == "move" then return MOVE_SUBMENU end + return WITHDRAW_SUBMENU +end + -- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while -- the mon is written into its new home. It stays up here until a button -- clears it, because it is also the only confirmation the player gets that the @@ -164,14 +173,16 @@ end -- The cart's own prompts (PCString_*): short, because the box they print in -- is one row of 18 columns. function BoxMenu:prompt() - if self.mode == "deposit" then return "Deposit which one?" end + -- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp. + if self.phase == "submenu" then return "What's up?" end if self.mode == "move" then - -- .Init, .PrepSubmenu and .PrepInsertCursor each place their own string. + -- .Init and .PrepInsertCursor each place their own string. if self.phase == "insert" then return "Move to where?" end - if self.phase == "submenu" then return "What's up?" end return "Choose a ." end - return "Choose a POKéMON." + -- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both + -- place this exact string (engine/pokemon/bills_pc.asm:2185). + return "Choose a ." end function BoxMenu:total() @@ -208,21 +219,15 @@ function BoxMenu:act() if self.onClose then self.onClose() end return end - -- .a_button: the move screen never acts on the list itself. It checks that - -- the row really is a mon and steps to $2, .PrepSubmenu. - if self.mode == "move" then + -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu. + if self.mode == "move" or self.mode == "withdraw" then if not self:selected() then return end self.phase = "submenu" -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE. self.submenuIndex = 1 return end - local ok, result - if self.mode == "deposit" then - ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) - else - ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) - end + local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) if not ok then self.message = result return @@ -294,12 +299,28 @@ function BoxMenu:openStats() }) end +-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu. +function BoxMenu:doWithdraw() + local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) + if not ok then + self.message = result + return + end + self.message = nil + self.phase = nil + self:clampIndex() +end + function BoxMenu:chooseSubmenu() - local row = MOVE_SUBMENU[self.submenuIndex] + local row = self:submenuRows()[self.submenuIndex] if row == "MOVE" then self:beginMove() + elseif row == "WITHDRAW" then + self:doWithdraw() elseif row == "STATS" then self:openStats() + elseif row == "RELEASE" then + self:askRelease() else -- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`. self.phase = nil @@ -417,11 +438,12 @@ function BoxMenu:update(_dt) -- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry. if self.phase == "submenu" then + local submenu = self:submenuRows() if input:wasPressed("up") then self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1 - or #MOVE_SUBMENU + or #submenu elseif input:wasPressed("down") then - self.submenuIndex = self.submenuIndex < #MOVE_SUBMENU + self.submenuIndex = self.submenuIndex < #submenu and self.submenuIndex + 1 or 1 elseif input:wasPressed("a") then self:chooseSubmenu() @@ -518,6 +540,7 @@ function BoxMenu:askRelease() return end self.message = name .. " was released." + self.phase = nil self:clampIndex() end, { defaultNo = true })) end @@ -644,12 +667,12 @@ end -- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor -- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top --- left at pixel (71, 31), stepping 16 pixels per row. Those cursor tiles are +-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are -- not extracted, so the frame is drawn as an outline at exactly those pixels, -- which is what the sprite frame looks like. function BoxMenu:drawSelectionFrame(row) local G = love.graphics - local x, y = 71, 31 + (row - 1) * 16 + local x, y = 71, 25 + (row - 1) * 16 G.setColor(0, 0, 0, 1) G.setLineWidth(1) G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1) @@ -679,6 +702,9 @@ function BoxMenu:panelMon() end function BoxMenu:drawPanel() + -- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and + -- never restores the standard font (engine/pokemon/bills_pc.asm:2169). + local wasBattle = Font.useBattleExtra(true) Chrome.clear() -- Box name header, then the list box hanging off it. BillsPC_BoxName is a @@ -686,6 +712,11 @@ function BoxMenu:drawPanel() Chrome.box(8, 0, 12, 3) Chrome.print(self:title(), 10, 1) Chrome.box(8, 2, 12, 12) + -- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘' + -- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging + -- off the name box above it. + Font.drawCode(Font.BORDER.bl, 8 * 8, 2 * 8) + Font.drawCode(Font.BORDER.br, 19 * 8, 2 * 8) local list = self:list() local inserting = self.phase == "insert" @@ -724,7 +755,9 @@ function BoxMenu:drawPanel() self:drawEggPic(mon) else self:drawPic(mon) - Chrome.print(":L" .. tostring(mon.level or 1), PIC_X, 12) + -- PrintLevel always writes the single bold glyph, not ":L" + -- (home/pokemon.asm:178-183). + Chrome.print("" .. tostring(mon.level or 1), PIC_X, 12) if mon.gender == "male" then Chrome.print("\xe2\x99\x82", 5, 12) elseif mon.gender == "female" then @@ -756,13 +789,14 @@ function BoxMenu:drawPanel() -- top spacing puts MOVE at (11,6), one row per two tiles. if self.phase == "submenu" then Chrome.box(9, 4, 11, 10) - for i, label in ipairs(MOVE_SUBMENU) do + for i, label in ipairs(self:submenuRows()) do local ty = 6 + (i - 1) * 2 if i == self.submenuIndex then Chrome.cursor(10, ty) end Chrome.print(label, 11, ty) end end love.graphics.setColor(1, 1, 1, 1) + Font.useBattleExtra(wasBattle) end function BoxMenu:draw() diff --git a/src/ui/gen2/MartMenu.lua b/src/ui/gen2/MartMenu.lua index 4b733c76..42f98839 100644 --- a/src/ui/gen2/MartMenu.lua +++ b/src/ui/gen2/MartMenu.lua @@ -896,7 +896,7 @@ local function printPriceOpaque(amount, ty) end function MartMenu:drawBuyList() - Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) + -- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = LIST_Y + (row - 1) * LIST_SPACING diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 2bbba54c..041084d0 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -86,6 +86,10 @@ local ROWS = { -- -- The two volume rows clamp at the ends rather than wrapping, the way -- pokered's text-speed cursor does, so holding left reaches OFF and stays. + { id = "controls", label = "CONTROLS", port = true, + activate = function(game) + require("src.ui.Screens").push(game, "BindingsMenu") + end }, { label = "MUSIC VOL", key = "musicVol", port = true, cycle = function(options, delta) options.musicVol = stepVolume(options.musicVol, delta) diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 089f7048..53487347 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -188,6 +188,22 @@ function PokedexMenu.new(game, opts) self.dexPalette = gfx.palette end + -- pokegold engine/pokegear/pokegear.asm Pokedex_GetArea: the AREA page + -- draws through the Pokegear's own town-map tiles and TownMapPals, not the + -- dex's PokedexLZ sheet. + local mapGfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear + self.mapGfx = mapGfx + if mapGfx then + self.mapSheet = TileSheet.new({ + path = mapGfx.tiles, wide = mapGfx.tilesWide or 16, firstTile = 0, + paletteFor = function(tile) + if not mapGfx.palettes then return nil end + if tile >= 0x60 then return mapGfx.palettes[1] end + return mapGfx.palettes[(mapGfx.palMap and mapGfx.palMap[tile + 1]) or 1] + end, + }) + end + -- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live -- only while UNOWN MODE is on screen. It is a sheet rather than a font -- page (see PokedexMenu:unownGlyph), and it draws through the dex palette @@ -793,7 +809,7 @@ end function PokedexMenu:playerLandmark() local save = self.game and self.game.save local mapId = save and save.position and save.position.map - local def = mapId and self.data and self.data.maps and self.data.maps[mapId] + local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId] return def and def.landmark end @@ -803,11 +819,13 @@ end -- substitutes them), and borrowing Pokegear's would freeze the map's ink. function PokedexMenu:drawTilemap(cells) if type(cells) ~= "table" then return end + local sheet = self.mapSheet + if not sheet then return end local i = 1 for ty = 0, Chrome.SCREEN_H - 1 do for tx = 0, Chrome.SCREEN_W - 1 do local id = cells[i] - if id then self:tile(id, tx, ty) end + if id then sheet:draw(id, tx, ty) end i = i + 1 end end @@ -837,7 +855,7 @@ function PokedexMenu:drawArea() -- uses. Without it (a cache imported before the town map was extracted) the -- page still lists the landmark NAMES, which is the information the screen -- exists to convey. - local maps = self.gfx and self.gfx.maps + local maps = self.mapGfx and self.mapGfx.maps local cells = maps and maps[region] if cells then self:drawTilemap(cells) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index ccedc9ad..8c91ccc6 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -1537,6 +1537,9 @@ function Pokegear:callContact(id) text = self:phoneText("GearOutOfService") } return end + -- pokegold engine/pokegear/pokegear.asm:883-889: SFX_CALL rings before the call connects. + local world = self.game and self.game.world + if world then world:playSfxNamed("Sfx_Call", 106) end local call = Phone.call(self.save, id, context) local name, className = Phone.contactName(id, self.trainers) call.name, call.className = name, className @@ -1562,6 +1565,11 @@ end -- HangUp: the click, the boops, and back to "Whom do you want to call?". function Pokegear:hangUp() + -- pokegold engine/phone/phone.asm:517-519: HangUp_Beep plays SFX_HANG_UP. + if self.call and self.call.kind ~= "nosignal" then + local world = self.game and self.game.world + if world then world:playSfxNamed("Sfx_HangUp", 107) end + end self.call = nil end @@ -1768,14 +1776,13 @@ function Pokegear:loadArrowSheet() self.arrow = false local gfx = self.gfx if gfx and gfx.sprites then + self:loadPlayerIcon() self.arrow = TileSheet.new({ path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0, - -- The icon strip's palette (cream / orange / brown / black), not BG - -- palette 0's greys: the arrow is an OBJ and the cart tints it to match - -- the card icons it points at. The extract carries the gear's BG - -- palettes only, and palMap gives every icon-strip tile this same index, - -- so it is the one that reproduces the cart rather than a guess. - palette = gfx.palettes and (gfx.palettes[4] or gfx.palettes[1]), + -- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's + -- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED. + palette = (self.playerIcon and self.playerIcon.objColors) + or (gfx.palettes and gfx.palettes[1]), }) end end diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index 9f34d5c1..b3deb62b 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -538,6 +538,10 @@ function TradeAnimView:drawStats(record, offset) G.push() G.translate(offset, WINDOW_Y) Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H) + -- pokegold engine/movie/trade_animation.asm:883-897,925-929: PlaceString + -- and PrintNum overwrite the border's own tile at cols 4-12, row 0. + G.setColor(1, 1, 1, 1) + G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8) for _, row in ipairs(TEMPLATE_ROWS) do Chrome.print(row.text, PANEL_X + 1, row.row) end diff --git a/src/world/gen2/Permissions.lua b/src/world/gen2/Permissions.lua index 22b79a16..2f731cd8 100644 --- a/src/world/gen2/Permissions.lua +++ b/src/world/gen2/Permissions.lua @@ -175,6 +175,20 @@ function Permissions.currentDirection(coll) return CURRENT_DIR[coll % 4] end +-- DoPlayerMovement .CheckTile, HI_NYBBLE_WARPS arm (.warps): landing on a +-- door/staircase/cave forces a walk DOWN off it (engine/overworld/player_movement.asm). +local DOOR_FORCED = { + [0x71] = true, -- COLL_DOOR + [0x79] = true, -- COLL_DOOR_79 (unused) + [0x7a] = true, -- COLL_STAIRCASE + [0x7b] = true, -- COLL_CAVE +} + +function Permissions.doorForcedDirection(coll) + if DOOR_FORCED[coll] then return "down" end + return nil +end + -- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is -- allowed to swing at. Both grasses are in it, which is why CUT mows a patch -- of tall grass down to bare ground and not only trees. diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 43f3106a..0ba5b0e8 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -140,6 +140,9 @@ function Player:facingCell() end function Player:walkPhase() + -- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the + -- walking leg frame for the whole 4-frame turn-in-place. + if self.turnTimer > 0 then return 1 end if not self.moving then return 0 end local p = self.animClock % STEP_FRAMES return (p >= 4 and p < 12) and 1 or 0 @@ -167,9 +170,9 @@ function Player:update() self.px = self.cellX * 16 + dx * adv self.py = self.cellY * 16 + dy * adv if self.jumping then - -- The hop arc. Cosmetic: the grid position is the straight-line - -- interpolation above, only the drawn pixels rise. - self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames)) + -- pokegold engine/overworld/map_objects.asm: UpdateJumpPosition's + -- y_offsets table peaks at -12. + self.py = self.py - math.floor(12 * math.sin(math.pi * self.progress / frames)) end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 18853bee..ae667a22 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -46,6 +46,7 @@ local Music = require("src.core.Music") local NPC = require("src.world.gen2.Npc") local Party = require("src.pokemon.Party") local Permissions = require("src.world.gen2.Permissions") +local Pipelines = require("src.render.Pipelines") local Player = require("src.world.gen2.Player") local Pokerus = require("src.core.gen2.Pokerus") local Roamers = require("src.core.gen2.Roamers") @@ -9555,11 +9556,13 @@ function World:stepBody() -- is a scripted step under World:busy, which returns above this line. local dir = self.heldDir if not p.moving then - local current = Permissions.currentDirection(self:playerCollision()) + local coll = self:playerCollision() + local current = Permissions.currentDirection(coll) + or Permissions.doorForcedDirection(coll) if current then dir = current elseif self.turningDirection - and Permissions.isIce(self:playerCollision()) then + and Permissions.isIce(coll) then dir = self.turningDirection elseif not dir then self.turningDirection = nil @@ -9615,7 +9618,17 @@ function World:drawGround(s) -- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and -- the connection strips and the map draw straight over the top of it. if self.map then - local bw, bh = G.getDimensions() + -- Destination size must be the CURRENT canvas (tilt grows it past the + -- window). getDimensions() is always the window, so a grown tilt capture + -- used to tile the void against the wrong view and the fill drifted off + -- the map grid as the camera moved. + local canvas = G.getCanvas() + local bw, bh + if canvas then + bw, bh = canvas:getDimensions() + else + bw, bh = G.getDimensions() + end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) end @@ -9691,45 +9704,51 @@ function World:drawPeople(s, billboard) end end - if self.emote and self.emote.image then - local e = self.emote - local ex = math.floor((e.entity.px - cam.x) * s) - local ey = math.floor((e.entity.py - 16 - cam.y) * s) - -- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the - -- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the - -- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB - -- 13,13,13 / black). That row is byte-identical in all four daytime - -- blocks, so the bubble is the same at any hour, but it still goes through - -- the daytime lookup because that is what LoadMapPals does and it keeps the - -- emote on the same path as every other OW sprite. Blitting the extracted - -- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead - -- of white: the Gen 2 repeat of #505. - local emoteColors = Palettes.spritePalette(self.palettes, - self.daytime or Palettes.daytimeFor(self.map and self.map.def, - self:hour(), self.flashUsed), - { paletteId = 5 }) - local function blit() - G.setColor(1, 1, 1, 1) - G.draw(e.image, ex, ey, 0, s, s) - end - local function body() - -- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still - -- have to collapse the row to their own ramps, and it restores whatever - -- shader the billboard pass had set rather than assuming none. - if emoteColors and GbcPalette.available() then - GbcPalette.with(emoteColors, blit) - else - blit() - end - end - if billboard then - billboard(ex + 8 * s, ey + 32 * s, body) + self:drawEmote(s, billboard) + self:drawHealAnim(s, billboard) +end + +-- Split out of drawPeople so World:drawPipeline composites the one copy the +-- flat and tilt paths draw, not a second transcription of it. +function World:drawEmote(s, billboard) + if not (self.emote and self.emote.image) then return end + local G = love.graphics + local cam = self.camera + local e = self.emote + local ex = math.floor((e.entity.px - cam.x) * s) + local ey = math.floor((e.entity.py - 16 - cam.y) * s) + -- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the + -- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the + -- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB + -- 13,13,13 / black). That row is byte-identical in all four daytime + -- blocks, so the bubble is the same at any hour, but it still goes through + -- the daytime lookup because that is what LoadMapPals does and it keeps the + -- emote on the same path as every other OW sprite. Blitting the extracted + -- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead + -- of white: the Gen 2 repeat of #505. + local emoteColors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 5 }) + local function blit() + G.setColor(1, 1, 1, 1) + G.draw(e.image, ex, ey, 0, s, s) + end + local function body() + -- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still + -- have to collapse the row to their own ramps, and it restores whatever + -- shader the billboard pass had set rather than assuming none. + if emoteColors and GbcPalette.available() then + GbcPalette.with(emoteColors, blit) else - body() + blit() end end - - self:drawHealAnim(s, billboard) + if billboard then + billboard(ex + 8 * s, ey + 32 * s, body) + else + body() + end end function World:drawWorldBody(s) @@ -9737,6 +9756,52 @@ function World:drawWorldBody(s) self:drawPeople(s) end +-- Gold's half of the world-pipeline seam: same ctx keys, same order and the +-- same nil-falls-back-to-2D rule as src/world/OverworldController.lua:4867. +function World:drawPipeline(id, w, h, s) + local G = love.graphics + local cam = self.camera + local ctx = { + state = self, cam = cam, + vw = self.viewW, vh = self.viewH, + -- No BG-only shake here: World:draw slides the whole frame through + -- camera.y, so the ground row IS the camera row. + bgY = cam.y, + width = w, height = h, scale = s, + level = Pipelines.level(id), + -- imageFor keys its bakes by GbcPalette.mode, so the colour is already in + -- the art: nil, like Gen 1 returns in its true-colour modes. + paletteFor = function() return nil end, + spriteColors = function() return nil end, + -- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay, + -- and Gen 1's `at` skips a nil body, so those keys are simply absent. + fx = { + emote = function() self:drawEmote(1, nil) end, + heal = function() self:drawHealAnim(1, nil) end, + }, + } + -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the + -- closures out in world pixels off the flat foot, the unit Gen 1 uses. + ctx.drawFx = function(project, scale) + scale = scale or s + local function at(fx, fy, body) + local sx, sy = project(fx + cam.x, fy + cam.y) + if not sx then return end -- behind the camera + G.push() + G.scale(scale, scale) + G.translate(sx / scale - fx, sy / scale - fy) + body() + G.pop() + end + self:drawEmote(1, at) + self:drawHealAnim(1, at) + end + local override = Pipelines.drawWorld(id, ctx) + -- world post-processes fold in here, so they never touch the text box on top + if override then override = Pipelines.worldPresent(override, ctx) end + return override +end + -- The perspective quad TILT draws the ground onto. The shader and the -- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1 -- world pass uses, so there is no reason for a second copy of either. @@ -9748,21 +9813,25 @@ function World:tiltMesh() return mesh, shader end -function World:drawTilted(w, h, s) +-- `gw, gh` are the grown capture size from World:draw (Tilt.viewGrowth), +-- matching Renderer:worldViewSize. Camera is already followed for that view. +function World:drawTilted(w, h, s, gw, gh) local mesh, shader = self:tiltMesh() if not mesh then self:drawWorldBody(s) return end local G = love.graphics + gw = gw or w + gh = gh or h -- Linear sampling on the tilt canvas softens the shimmer the perspective -- warp would otherwise put on every pixel edge; the flat path keeps nearest. - if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= w - or self.tiltCanvas:getHeight() ~= h then + if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= gw + or self.tiltCanvas:getHeight() ~= gh then if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end - self.tiltCanvas = G.newCanvas(w, h) + self.tiltCanvas = G.newCanvas(gw, gh) self.tiltCanvas:setFilter("linear", "linear") end @@ -9778,11 +9847,14 @@ function World:drawTilted(w, h, s) G.setCanvas(previous) mesh:setTexture(self.tiltCanvas) - mesh:setVertices(Tilt.meshCorners(w, h)) + mesh:setVertices(Tilt.meshCorners(gw, gh)) + G.push() + G.translate((w - gw) / 2, (h - gh) / 2) G.setColor(1, 1, 1, 1) G.setShader(shader) G.draw(mesh) G.setShader() + G.pop() -- ...and the standing things over it, each translated from its flat foot -- onto that foot's projection. Nothing here is sheared or resized: tilt @@ -9791,10 +9863,10 @@ function World:drawTilted(w, h, s) -- The ground quad carries the flat canvas and nothing else, so a foot -- outside it has no ground under it; drawing it anyway put NPCs from two -- screens away over the border fill, where the map stops being drawn. - if not Tilt.onGround(fx, fy, w, h, 32 * s) then return end - local sx, sy = Tilt.groundPoint(fx, fy, w, h) + if not Tilt.onGround(fx, fy, gw, gh, 32 * s) then return end + local sx, sy = Tilt.groundPoint(fx, fy, gw, gh) G.push() - G.translate(sx - fx, sy - fy) + G.translate(sx - fx + (w - gw) / 2, sy - fy + (h - gh) / 2) body() G.pop() end) @@ -9832,8 +9904,18 @@ function World:draw() end local s = self:zoomScale() - local vw = math.ceil(w / s) - local vh = math.ceil(h / s) + -- Decided before sizing the view: a world pipeline wins over tilt, and tilt + -- grows the capture the way Renderer:worldViewSize does on Gen 1 so the + -- camera, BorderFill and tilt canvas all share one grid. + local pipelineId = Pipelines.worldPipeline() + local tilt = (not pipelineId) and Tilt.active() and self:tiltMesh() ~= nil + local gw, gh = w, h + if tilt then + local g = Tilt.viewGrowth() + gw, gh = math.ceil(w * g), math.ceil(h * g) + end + local vw = math.ceil(gw / s) + local vh = math.ceil(gh / s) if vw % 2 ~= 0 then vw = vw + 1 end if vh % 2 ~= 0 then vh = vh + 1 end if vw ~= self.viewW or vh ~= self.viewH then @@ -9850,12 +9932,18 @@ function World:draw() self.camera.y = self.camera.y + (self.shake.phase or 0) end + local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil + -- TILT projects the finished world frame, so with it on the map, people and -- emote go into a canvas first and that canvas is drawn as a perspective -- quad. Everything after -- the encounter pic and the survey HUD -- stays - -- flat, the same split the Gen 1 renderer makes. - if Tilt.active() and self:tiltMesh() then - self:drawTilted(w, h, s) + -- flat, the same split the Gen 1 renderer makes; a pipeline's finished image + -- lands in exactly the same place. + if override then + G.setColor(1, 1, 1, 1) + G.draw(override, 0, 0) + elseif tilt then + self:drawTilted(w, h, s, gw, gh) else self:drawWorldBody(s) end diff --git a/tests/drivers/gold_pipeline_shots.lua b/tests/drivers/gold_pipeline_shots.lua new file mode 100644 index 00000000..2d28bf59 --- /dev/null +++ b/tests/drivers/gold_pipeline_shots.lua @@ -0,0 +1,159 @@ +-- Gold's world-pipeline seam (World:drawPipeline), the Gen 2 peer of the +-- render_pipelines path src/world/OverworldController.lua:4867 gives Gen 1. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_pipeline_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-pipeline (default) +-- +-- Registers a pipeline that paints an unmistakable magenta field, switches it +-- on, and asserts what the seam is supposed to guarantee: drawWorld owns the +-- frame, ctx carries the Gen 1 keys, ctx.drawFx anchors the standing FX, +-- worldPresent folds over the result, tilt is forced off, and a declined +-- frame falls back to the vanilla 2D draw instead of a blank screen. +local U = require("tests.drivers.util") + +local Pipelines = require("src.render.Pipelines") +local Tilt = require("src.render.Tilt") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pipeline" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[pipeline] ok " .. label) + else + failures = failures + 1 + print("[pipeline] FAIL " .. label .. " " .. tostring(detail)) + end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + world:setMap("ROUTE_30", 10, 10, "down") + U.wait(10) + U.shot(game, out .. "/00-vanilla.png") + + -- ---------------------------------------------------------------- record + local seen, canvas = {}, nil + local decline = false + local pipeline = { + label = "TESTPIPE", + levels = { "OFF", "ON" }, + drawWorld = function(ctx) + seen.ctx = ctx + seen.drawWorld = (seen.drawWorld or 0) + 1 + if decline then return nil end + local G = love.graphics + local w, h = ctx.width, ctx.height + if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then + canvas = G.newCanvas(w, h) + end + local previous = G.getCanvas() + G.push("all") + G.origin() + G.setCanvas(canvas) + G.clear(0.8, 0.1, 0.6, 1) + G.setColor(1, 1, 1, 1) + for x = 0, w, 32 do G.rectangle("fill", x, 0, 1, h) end + for y = 0, h, 32 do G.rectangle("fill", 0, y, w, 1) end + -- the standing FX, anchored under this pipeline's own (identity) camera + ctx.drawFx(function(wx, wy) + return (wx - ctx.cam.x) * ctx.scale, (wy - ctx.cam.y) * ctx.scale + end, ctx.scale) + G.setCanvas(previous) + G.pop() + seen.drew = true + return canvas + end, + worldPresent = function(image, ctx) + seen.worldPresent = (seen.worldPresent or 0) + 1 + seen.presentCtx = ctx + return image + end, + } + + -- A fresh table so Pipelines.list()'s identity-keyed memo re-sorts; the + -- mod merge hands it a new one for the same reason. + game.data.render_pipelines = { testpipe = pipeline } + Pipelines.install(game.data) + ok("registered", Pipelines.get("testpipe") ~= nil, "not in the registry") + + -- ------------------------------------------------------------ switched on + Tilt.setLevel(1) + Pipelines.setLevel("testpipe", 1) + ok("tilt forced off", Tilt.level == 0, "tilt still " .. tostring(Tilt.level)) + ok("world pipeline claimed", Pipelines.worldPipeline() == "testpipe", + tostring(Pipelines.worldPipeline())) + U.wait(5) + U.shot(game, out .. "/01-pipeline-on.png") + + ok("drawWorld ran", (seen.drawWorld or 0) > 0, "never called") + ok("drawWorld drew", seen.drew == true, "declined every frame") + ok("worldPresent ran", (seen.worldPresent or 0) > 0, "never called") + + local ctx = seen.ctx + ok("ctx.state is the world", ctx and ctx.state == world, "wrong state") + ok("ctx.cam is the camera", ctx and ctx.cam == world.camera, "wrong camera") + ok("ctx.scale is zoomScale", ctx and ctx.scale == world:zoomScale(), + ctx and tostring(ctx.scale)) + ok("ctx.bgY is the camera row", ctx and ctx.bgY == world.camera.y, + ctx and tostring(ctx.bgY)) + ok("ctx.vw/vh are the view", ctx and ctx.vw == world.viewW + and ctx.vh == world.viewH, ctx and tostring(ctx.vw)) + ok("ctx.level is the ladder", ctx and ctx.level == 1, ctx and tostring(ctx.level)) + ok("ctx.width/height are the window", + ctx and ctx.width == love.graphics.getWidth() + and ctx.height == love.graphics.getHeight(), "mismatch") + ok("ctx.paletteFor is nil-valued (art is baked)", + ctx and ctx.paletteFor and ctx.paletteFor(world.map) == nil, "returned colours") + ok("ctx.spriteColors is nil-valued", + ctx and ctx.spriteColors and ctx.spriteColors() == nil, "returned colours") + ok("ctx.fx has Gold's two effects", + ctx and ctx.fx and type(ctx.fx.emote) == "function" + and type(ctx.fx.heal) == "function", "missing fx") + ok("ctx.drawFx is callable", ctx and type(ctx.drawFx) == "function", "missing") + ok("worldPresent got the same ctx", seen.presentCtx == seen.ctx, "different ctx") + + -- ------------------------------------------------- the FX composite path + -- An emote over the player exercises ctx.drawFx end to end: it must be the + -- pipeline that composites it, and the vanilla drawPeople must not also. + local sheet + for _, img in pairs(world.emoteImages or {}) do sheet = img break end + if sheet then + world.emote = { image = sheet, entity = world.player, left = 240 } + end + U.wait(4) + ok("emote is up", world.emote ~= nil, "no emote sheet loaded") + local fxOk = pcall(function() + -- the same call the pipeline made, run again outside the guard so a throw + -- surfaces here rather than only retiring the pipeline + seen.ctx.drawFx(function(wx, wy) return wx, wy end, 1) + end) + ok("drawFx composites without throwing", fxOk, "threw") + U.shot(game, out .. "/02-pipeline-emote.png") + + -- ----------------------------------------------------- a declined frame + decline = true + U.wait(5) + U.shot(game, out .. "/03-pipeline-declined.png") + ok("declined frames still call drawWorld", (seen.drawWorld or 0) > 1, "stopped") + decline = false + + -- ------------------------------------------------------------ switched off + Pipelines.setLevel("testpipe", 0) + local before = seen.drawWorld + U.wait(5) + ok("off means not called", seen.drawWorld == before, + "still drawing at level 0") + U.shot(game, out .. "/04-pipeline-off.png") + + if failures == 0 then + print("[pipeline] PASS") + else + print("[pipeline] FAILURES: " .. failures) + end + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tools/modkit.py b/tools/modkit.py index 80cd1796..3de6c841 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -649,7 +649,9 @@ for name, registry in pairs(loader.content) do end end if patcher and not defined then - row("ORPHAN", name, id, patcher) + local gen2Routed = Schemas.targetFor(name, registry.spec, 2) + ~= registry.spec.target + row("ORPHAN", name, id, patcher, tostring(gen2Routed)) end end end @@ -712,7 +714,8 @@ def resolve_base(repo, choice): return "imported" if os.path.isfile(imported) else "fixture" -def run_loader(repo, mod_dir, findings, base="fixture", notes=None): +def run_loader(repo, mod_dir, findings, base="fixture", notes=None, + manifest=None): """Drive the engine loader headlessly with the mod mounted; the base dataset is the ROM-free fixture, or the imported cache with --base imported (for mods that reference vanilla Red content). @@ -775,8 +778,9 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None): if "unknown permission" in parts[1]: continue add(Finding(classify_error(parts[1], "MK001"), "error", parts[1])) - elif kind == "ORPHAN" and len(parts) >= 4: + elif kind == "ORPHAN" and len(parts) >= 5: registry, target, owner = parts[1], parts[2], parts[3] + gen2_routed = parts[4] == "true" # only the imported dataset owns the real vanilla id space. The # fixture stands in for three species, so "not in base data" there # is a fact about the fixture, not about the mod -- MK103 has no @@ -786,6 +790,11 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None): if base != "imported": skipped.add("MK103") continue + if gen2_routed and declares_gen2(repo, manifest): + # tools/build_data.py never writes a Gen 2 cache, so the + # imported dataset has no Gold/Crystal ground truth either. + skipped.add("MK103") + continue add(Finding( "MK103", "error", f"{owner}: patch target {target!r} exists in neither " @@ -834,7 +843,7 @@ def cmd_validate(args, repo): findings.extend(gh_findings) notes.extend(gh_notes) findings.extend(check_permissions(repo, manifest)) - run_loader(repo, mod_dir, findings, args.base, notes) + run_loader(repo, mod_dir, findings, args.base, notes, manifest) findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest)) name = manifest.get("id") if manifest else os.path.basename(mod_dir) @@ -1130,7 +1139,7 @@ def cmd_pack(args, repo): return 1 findings = list(check_permissions(repo, manifest)) notes = [] - run_loader(repo, mod_dir, findings, args.base, notes) + run_loader(repo, mod_dir, findings, args.base, notes, manifest) findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest)) # pack runs validate --strict (20-developer-tooling.md 5), so a warning