diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index ea587822..01a62b53 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC" (cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \ main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ + tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -if unzip -Z1 "$WORK/game-payload.zip" \ - | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then - fail "payload unexpectedly contains generated ROM data" -fi +payload_list="$(unzip -Z1 "$WORK/game-payload.zip")" +printf '%s\n' "$payload_list" \ + | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \ + && fail "payload unexpectedly contains generated ROM data" +printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \ + || fail "payload is missing tools/rom_manifest_gold.json" unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC" rm -f "$WORK/game-payload.zip" diff --git a/docs/new-features.md b/docs/new-features.md index 4870e740..87f6dc49 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -27,8 +27,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge: -* **Separate Gold save file** beside the Gen 1 ones -* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1 +* **COLOR, zoom, tilt, GBC FX, and quick save/load** * **UI that stays fixed while the overworld zooms** * **Border-block surrounds** for maps smaller than the screen * **Gold-specific launcher options** @@ -39,5 +38,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam * **Followers** for mods, plus Gen 2-only registries and hooks * **On-screen touch pad** and controller SELECT for registered items -Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`. diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 1607c476..6644f3d5 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -27,10 +27,9 @@ APPLICATION_ID="com.theboisclub.pokemonred" LOVE_ANDROID_VERSION="11.5a" NDK_VERSION="25.2.9519653" YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json" -# A fresh source checkout normally supplies this through Git. This URL is -# deliberately only a last resort for incomplete source exports: the manifest -# contains extraction metadata, never a ROM or extracted game data. YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}" +GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json" +GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}" VERSION="" PACKAGE_ONLY=false @@ -130,6 +129,54 @@ ensure_yellow_manifest() { fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL" } +gold_manifest_is_valid() { + local path="$1" + python3 - "$path" <<'PY' +import json, pathlib, sys + +try: + manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +except (OSError, ValueError): + raise SystemExit(1) + +raise SystemExit(0 if manifest.get("romSha1") == + "d8b8a3600a465308c9953dfa04f0081c05bdcb94" else 1) +PY +} + +ensure_gold_manifest() { + local manifest="$ROOT/$GOLD_MANIFEST_RELATIVE" + local staged + staged="$(mktemp)" + + if gold_manifest_is_valid "$manifest"; then + rm -f "$staged" + return + fi + + warn "Gold import manifest is missing or invalid; recovering it before packaging" + if git -C "$ROOT" show "HEAD:$GOLD_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \ + && gold_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "restored Gold import manifest from this checkout's Git data" + return + fi + + if command -v curl >/dev/null 2>&1 \ + && curl --fail --location --retry 2 --connect-timeout 15 \ + --output "$staged" "$GOLD_MANIFEST_URL" \ + && gold_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "downloaded Gold import manifest from the project repository" + return + fi + + rm -f "$staged" + fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL" +} + # --------------------------------------------------------------- branding # love-android 11.5+ reads app id / name / orientation from gradle.properties. # Manifest still gets permission trims. Re-applied every build so refreshing @@ -194,6 +241,7 @@ PY pack_game_love() { say "packing game.love for love-android embed flavor" ensure_yellow_manifest + ensure_gold_manifest mkdir -p "$EMBED_ASSETS" rm -f "$LOVE_FILE" # tools/save-editor ships with the app: the launcher's Edit button on a save diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index f5bcdbcc..ac74892e 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -357,6 +357,9 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/" # --------------------------------------------------------------- branding cp "$IN/game.love" "$APPDIR/game.love" +unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt" +grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \ + || fail "game.love is missing tools/rom_manifest_gold.json" # The .desktop's Icon= resolves against the AppDir root by basename, and # .DirIcon is what appimaged and file-manager thumbnailers read. cp "$IN/icon.png" "$APPDIR/$APP_NAME.png" diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh index 81ac3008..5b4ab651 100755 --- a/scripts/linux-arm64/selftest_build_linux_arm64.sh +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -167,5 +167,7 @@ trap 'rm -rf "$temp_dir"' EXIT unzip -p "$temp_dir/game.love" src/core/Version.lua \ | grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \ || fail "shared payload version was not stamped" +grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \ + || fail "shared payload is missing tools/rom_manifest_gold.json" say "Linux arm64 self-test passed" diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index b83fb6d6..691ada53 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -217,7 +217,7 @@ function Battle.new(opts) opts = opts or {} local self = setmetatable({}, Battle) self.data = opts.data or {} - self.random = opts.random + self.random = opts.random or function(n) return rand(nil, n) end self.party = opts.party or {} self.trainer = opts.trainer self.save = opts.save @@ -271,6 +271,13 @@ function Battle.new(opts) self.enemy = self.enemyParty[self.enemyIndex] end + for _, mon in ipairs(self.party) do + Mon.refreshStats(mon, self.data) + end + for _, mon in ipairs(self.enemyParty or {}) do + Mon.refreshStats(mon, self.data) + end + -- Battle RAM opens empty on both sides: NewBattleMonStatus and -- NewEnemyMonStatus run at the first send-out of every battle. self:clearAllVolatiles() @@ -1655,7 +1662,7 @@ function Battle:useMove(attacker, defender, moveId) state.rolloutLock = nil end - local hits = Effects.hitCount(def.effect, self.random) + local hits = Effects.hitCount(def.effect, self:roller()) local landed = 0 for hit = 1, hits do if (defender.hp or 0) <= 0 then break end @@ -3178,7 +3185,8 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved) -- ChangeHappiness is outside the level loop. Happiness.change(mon, "GAINLEVEL") self:emit({ kind = "level", index = index, level = mon.level, - text = self:monName(mon) .. " grew to level " .. mon.level .. "!" }) + text = self:monName(mon) .. " grew to level " .. mon.level .. "!", + sfx = "Sfx_DexFanfare5079", waitSfx = true }) for _, moveId in ipairs(result.learned) do local ok, reason, entry = Mon.learnMove(mon, moveId, self.data) local moveDef = self:moveDef(moveId) @@ -3644,7 +3652,7 @@ end -- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are -- based on the speed ratio and how many times you have tried this battle. -- Trainers never let you run. -function Battle:tryRun() +function Battle:tryRun(pSpd) -- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone, -- which is what BattleMenu_Run reads to decide whether the turn was spent -- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the @@ -3676,7 +3684,8 @@ function Battle:tryRun() return false end self.runAttempts = (self.runAttempts or 0) + 1 - if self:runRoll(self:effectiveSpeed(self.player), + -- engine/battle/core.asm:2614 + if self:runRoll(pSpd or self:effectiveSpeed(self.player), self:effectiveSpeed(self.enemy)) then self:emit({ kind = "run", text = "Got away safely!" }) self:endBattle("run") diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 12a3dcd3..19f0b302 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -100,21 +100,28 @@ end -- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step -- fallthrough in .DetermineNumberOfHits produces. function Effects.multiHitCount(random) - local roll = random and random(4) or 0 - if roll < 2 then return roll + 2 end - -- Hits 4 and 5 take a second roll, so each ends up half as likely. - local second = random and random(2) or 0 - return second + 4 + local function roll(n) + if random then return random(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 + end + -- engine/battle/effect_commands.asm:5228 + local first = roll(4) + if first < 2 then return first + 2 end + return roll(4) + 2 end Effects.HIT_COUNTS = { EFFECT_DOUBLE_HIT = 2, + EFFECT_POISON_MULTI_HIT = 2, -- Triple Kick stops early if a hit misses; Battle rolls that per hit. EFFECT_TRIPLE_KICK = 3, } function Effects.hitCount(effect, random) - if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then + if effect == "EFFECT_MULTI_HIT" then return Effects.multiHitCount(random) end return Effects.HIT_COUNTS[effect] or 1 diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 5ae7f8df..89a37e0c 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -68,7 +68,16 @@ function Mon.stats(baseStats, dvs, level, statExp) baseStats = baseStats or {} dvs = dvs or {} statExp = statExp or {} - local hpDv = dvs.hp or Mon.hpDV(dvs) + -- engine/pokemon/move_mon.asm:1540 + local specialDv = dvs.special + if specialDv == nil then + specialDv = dvs.specialAttack or dvs.specialDefense + end + -- engine/pokemon/move_mon.asm:1496 + local hpDv = Mon.hpDV({ + attack = dvs.attack, defense = dvs.defense, + speed = dvs.speed, special = specialDv, + }) local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2 + math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100) + level + 10 @@ -82,13 +91,27 @@ function Mon.stats(baseStats, dvs, level, statExp) -- box_struct ends them at SpcExp), so SpA and SpD grow together. The -- per-stat keys are still read as a fallback for a record written before -- the shared word existed. - specialAttack = statValue(baseStats.specialAttack, dvs.special, level, + specialAttack = statValue(baseStats.specialAttack, specialDv, level, statExp.special or statExp.specialAttack), - specialDefense = statValue(baseStats.specialDefense, dvs.special, level, + specialDefense = statValue(baseStats.specialDefense, specialDv, level, statExp.special or statExp.specialDefense), } end +function Mon.refreshStats(mon, data) + if type(mon) ~= "table" then return mon end + local def = data and data.pokemon and data.pokemon[mon.species] + if not (def and def.baseStats) then return mon end + -- engine/pokemon/move_mon.asm:1402 + local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp) + mon.stats = stats + mon.maxHp = stats.hp + if mon.hp == nil or mon.hp > stats.hp then + mon.hp = stats.hp + end + return mon +end + -- The five stat exp words, in struct order. There is no sixth: see Mon.stats. Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" } diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 25ae0f8a..28deaeaa 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1906,11 +1906,10 @@ function Game2:applyOptions() 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 - -- player laid out for Red is already the pad Gold draws. - TouchControls:applyOptions(options) + TouchControls:applyOptions({ + touchControls = options.touchControls, + haptics = options.haptics, + }) local GBCFX = require("src.render.GBCFX") if GBCFX.applyOptions(options) and self.save then -- applyOptions returns true when it had to clear an unsupported level. diff --git a/src/core/Input.lua b/src/core/Input.lua index 49616f1e..5ff73007 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -119,6 +119,34 @@ function Input:reset() self.stickAxis = { x = 0, y = 0 } self.stickDir = nil self.hatDirs = {} + self.captureArmed = false + self.captureEvents = nil +end + +function Input:armCapture() + self.captureArmed = true + self.captureEvents = {} +end + +function Input:disarmCapture() + self.captureArmed = false + self.captureEvents = nil +end + +function Input:takeCaptureEvents() + local ev = self.captureEvents + self.captureEvents = self.captureArmed and {} or nil + return ev +end + +local function noteCapture(self, kind, phase, value) + if not self.captureArmed then return end + local ev = self.captureEvents + if not ev then + ev = {} + self.captureEvents = ev + end + ev[#ev + 1] = { kind = kind, phase = phase, value = value } end -- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the @@ -154,6 +182,7 @@ local function release(self, btn, source) end function Input:keypressed(key) + noteCapture(self, "key", "pressed", key) local btn = self.keyBindings[key] if btn then press(self, btn, "key:" .. key) @@ -161,6 +190,7 @@ function Input:keypressed(key) end function Input:keyreleased(key) + noteCapture(self, "key", "released", key) local btn = self.keyBindings[key] if btn then release(self, btn, "key:" .. key) @@ -221,6 +251,7 @@ function Input:sourceRelease(btn, source) end function Input:gamepadpressed(joystick, button) + noteCapture(self, "pad", "pressed", button) local btn = self.padBindings[button] if btn then press(self, btn, "pad:" .. button) @@ -228,6 +259,7 @@ function Input:gamepadpressed(joystick, button) end function Input:gamepadreleased(joystick, button) + noteCapture(self, "pad", "released", button) local btn = self.padBindings[button] if btn then release(self, btn, "pad:" .. button) @@ -249,12 +281,14 @@ end function Input:joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + noteCapture(self, "joy", "pressed", button) local btn = self.joyBindings[button] if btn then press(self, btn, "joy:" .. button) end end function Input:joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + noteCapture(self, "joy", "released", button) local btn = self.joyBindings[button] if btn then release(self, btn, "joy:" .. button) end end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 0fd3fc2e..d4d645bf 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -211,6 +211,10 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) local abW = dpadW * 0.46 local ssW = dpadW * 0.30 local margin = dpadW * 0.12 + local ok, GameVersion = pcall(require, "src.core.GameVersion") + if ok and GameVersion.isGold and GameVersion.isGold() then + margin = math.max(margin, math.min(ww * 0.10, 72)) + end return { dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW }, diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index bfce17e0..6affceaa 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -275,6 +275,8 @@ Save.DEFAULT_OPTIONS = { musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off + haptics = "light", + touchControls = { enabled = true }, } function Save.defaultOptions() @@ -719,6 +721,13 @@ end -- copy is the witness that survives a crash mid-replace. function Save.save(save) if type(save) ~= "table" then return false, "no save" end + if (save.version or "gold") == "gold" then + local ok, SaveData = pcall(require, "src.core.SaveData") + if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then + local id = SaveData.createSlot and SaveData.createSlot("gold") + if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end + end + end local main, backup, tmp = saveNames(save.version) local f = fs() if not f then return false, "no filesystem" end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2f13993e..da44939f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1370,6 +1370,10 @@ function RomImporter.new(onComplete, opts) end end + if GameVersion.VERSIONS[self.tab] then + self.modScope = self.tab + end + return self end @@ -2627,6 +2631,9 @@ function RomImporter:_switchTab(id) self.tab = id self._findSearchFocus = false self:_disarmTextInput() + if GameVersion.VERSIONS[id] then + self:_setModScope(id) + end end function RomImporter:_toggleFindSearchFocus() @@ -2937,6 +2944,13 @@ function RomImporter:_refreshMods() end end self.mods = LauncherMods.list(self.modScope) or {} + if self.modScope then + local kept = {} + for _, m in ipairs(self.mods) do + if m.targetsHere ~= false then kept[#kept + 1] = m end + end + self.mods = kept + end self:_syncModUpdateInfo(false) end @@ -3586,7 +3600,7 @@ function RomImporter:_findRows() -- index, query, or category actually changed. local c = self._findRowsCache if c and c.src == all and c.query == self.findQuery - and c.category == self.findCategory then + and c.category == self.findCategory and c.scope == self.modScope then return c.rows end local ModIndex = require("src.mods.ModIndex") @@ -3594,8 +3608,33 @@ function RomImporter:_findRows() query = self.findQuery, category = self.findCategory, }) + if self.modScope then + local gen = GameVersion.generation(self.modScope) + local kept = {} + for _, entry in ipairs(rows) do + local has1, has2 = false, false + local function note(s) + s = tostring(s or ""):lower() + if s == "gen1" or s == "gen 1" or s == "red" or s == "blue" + or s == "yellow" then + has1 = true + end + if s == "gen2" or s == "gen 2" or s == "gold" then + has2 = true + end + end + for _, cat in ipairs(entry.categories or {}) do note(cat) end + for _, tag in ipairs(entry.tags or {}) do note(tag) end + if (not has1 and not has2) + or (gen == 2 and has2) + or (gen ~= 2 and has1) then + kept[#kept + 1] = entry + end + end + rows = kept + end self._findRowsCache = { src = all, query = self.findQuery, - category = self.findCategory, rows = rows } + category = self.findCategory, scope = self.modScope, rows = rows } return rows end diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index a4999706..eb9924f1 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -456,7 +456,13 @@ end -- AskRockSmashScript reads it with `ifequal 1, .no`. Transcribing it the -- obvious way round refuses the move for every party that has it. function H.HasRockSmash(ctx) - return call(ctx, "partyMoveUser", "ROCK_SMASH") and 0 or 1 + local mon = call(ctx, "partyMoveUser", "ROCK_SMASH") + if mon then + -- engine/events/overworld.asm:1339 + ctx.curPartyMon = mon + return 0 + end + return 1 end -- PutTheRodAway: ClearBox over the text window, then wPlayerAction back to diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 606a5ead..c0a4fc33 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -127,6 +127,7 @@ function BindingsMenu:beginCapture(item) self.onKeyReleased = BindingsMenu.captureKeyRelease self.onGamepadReleased = BindingsMenu.capturePadRelease self.onJoystickReleased = BindingsMenu.captureJoyRelease + if Input.armCapture then Input:armCapture() end end function BindingsMenu:endCapture() @@ -138,6 +139,7 @@ function BindingsMenu:endCapture() self.onKeyReleased = nil self.onGamepadReleased = nil self.onJoystickReleased = nil + if Input.disarmCapture then Input:disarmCapture() end end -- Escape is the capture's way out, so it is never captured: every other @@ -232,7 +234,11 @@ function BindingsMenu:storeBinding(slot, value) b[slot] = value opts.bindings[item.button.id] = b item.right = boundRight(opts.bindings, item.button) - if game.writeOptions then game:writeOptions() end + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end end -- SELECT: forget one row's rebind and fall back to the defaults. #510's @@ -246,7 +252,11 @@ function BindingsMenu:clearBinding(item) end opts.bindings[item.button.id] = nil item.right = boundRight(opts.bindings, item.button) - if game.writeOptions then game:writeOptions() end + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end end -- START: confirm, then drop the whole overlay (#589). The footer doubles @@ -264,12 +274,39 @@ function BindingsMenu:confirmReset() for _, it in ipairs(self.items) do it.right = boundRight(nil, it.button) end - if game.writeOptions then game:writeOptions() end + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end end, { defaultNo = true })) end +function BindingsMenu:drainCapture() + local events = Input.takeCaptureEvents and Input:takeCaptureEvents() + if not events then return end + for i = 1, #events do + local ev = events[i] + if ev.phase == "pressed" then + if ev.kind == "key" then self:captureKey(ev.value) + elseif ev.kind == "pad" then self:capturePad(ev.value) + elseif ev.kind == "joy" then self:captureJoy(ev.value) + end + else + if ev.kind == "key" then self:captureKeyRelease(ev.value) + elseif ev.kind == "pad" then self:capturePadRelease(ev.value) + elseif ev.kind == "joy" then self:captureJoyRelease(ev.value) + end + end + if not self.capture then return end + end +end + function BindingsMenu:update(dt) - if self.capture then return end -- the raw capture owns the input + if self.capture then + self:drainCapture() + return + end if self.game.input:wasPressed("start") then return self:confirmReset() end diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 909a172e..be9e0553 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -49,17 +49,29 @@ end function Editor.load(opts) opts = opts or {} Editor.onClose = opts.onClose + Editor.version = opts.version + Editor.hostPoll = opts.hostPoll == true Editor.drag = nil Editor.rects = {} + Editor._hostMouse = false + Editor._hostTouches = nil Editor.fonts = { title = love.graphics.newFont(28), body = love.graphics.newFont(16), btn = love.graphics.newFont(18), } local optsTbl = SaveData.loadOptions() + local applied = optsTbl + if opts.version == "gold" then + local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {} + applied = { + touchControls = gold.touchControls, + haptics = gold.haptics or optsTbl.haptics, + } + end TouchControls:init() TouchControls:ensureImages() - TouchControls:applyOptions(optsTbl) + TouchControls:applyOptions(applied) TouchControls:setPreview(true) Editor.enabled = TouchControls.enabled ~= false PadCursor.reset() @@ -71,17 +83,24 @@ function Editor.unload() PadCursor.reset() Editor.drag = nil Editor.onClose = nil + Editor.version = nil + Editor._hostMouse = false + Editor._hostTouches = nil end local function persist() local opts = SaveData.loadOptions() local cfg = TouchControls:config() - -- replaces the whole table, so a pre-#633 top-level positions key is - -- dropped once the player saves under the new shape - opts.touchControls = { + local block = { enabled = cfg.enabled, layouts = cfg.layouts, } + if Editor.version == "gold" then + opts.gold = type(opts.gold) == "table" and opts.gold or {} + opts.gold.touchControls = block + else + opts.touchControls = block + end SaveData.saveOptions(opts) end @@ -104,9 +123,7 @@ end function Editor.update(dt) PadCursor.update(dt or 0) - -- drag follows the live pointer when love.touch / mouse / pad is available; - -- touchmoved / mousemoved also update, so this is a belt-and-suspenders - -- path for Android where move events can be thin + if Editor.hostPoll then Editor.pollHostPointers() end if not Editor.drag then return end local x, y if Editor.drag.touchId == "pad" then @@ -297,6 +314,45 @@ local function endDrag(id) Editor.drag = nil end +function Editor.pollHostPointers() + local down = love.mouse and love.mouse.isDown and love.mouse.isDown(1) + if down then + local x, y = love.mouse.getPosition() + if not Editor._hostMouse then + Editor._hostMouse = true + beginDrag("mouse", x, y) + else + moveDrag("mouse", x, y) + end + elseif Editor._hostMouse then + Editor._hostMouse = false + endDrag("mouse") + end + if not (love.touch and love.touch.getTouches and love.touch.getPosition) then + return + end + Editor._hostTouches = Editor._hostTouches or {} + local seen = {} + for _, id in ipairs(love.touch.getTouches()) do + seen[id] = true + local ok, tx, ty = pcall(love.touch.getPosition, id) + if ok and tx then + if not Editor._hostTouches[id] then + Editor._hostTouches[id] = true + beginDrag(id, tx, ty) + else + moveDrag(id, tx, ty) + end + end + end + for id in pairs(Editor._hostTouches) do + if not seen[id] then + Editor._hostTouches[id] = nil + endDrag(id) + end + end +end + function Editor.mousepressed(x, y, button) if button ~= 1 then return end -- Finger / mouse tap yields the Joy-Con pointer so the click lands where @@ -391,4 +447,35 @@ function Editor.keypressed(key) end end +function Editor.new(game) + local state = { game = game, isOpaque = true } + Editor.hostPoll = true + Editor.load({ + version = "gold", + hostPoll = true, + onClose = function() + Editor.hostPoll = false + if game.options then + game.options.touchControls = TouchControls:config() + end + if game.stack and game.stack:top() == state then + game.stack:pop() + end + if game.applyOptions then game:applyOptions() end + end, + }) + function state:wantsFillScale() return true end + function state:drawsWidescreen() return true end + function state:update(dt) + Editor.update(dt) + local input = self.game and self.game.input + if input and input:wasPressed("b") then close() end + end + function state:draw() end + function state:drawWidescreen(_w, _h) + Editor.draw() + end + return state +end + return Editor diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 0fc31b26..47a9ab31 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -92,6 +92,8 @@ local FAINT_SLIDE_FRAMES_PER_ROW = 2 -- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249). local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!" local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!" +-- data/text/battle.asm:207 +local TEXT_USE_NEXT_MON = "Use next POKéMON?" -- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove -- (data/text/battle.asm:315-322). @@ -1146,11 +1148,11 @@ function BattleState:advanceQueue() -- 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. + -- engine/battle/core.asm:7044 if mon and mon == battle.player then event.text = nil + event.sfx = nil + event.waitSfx = nil if self.shownHp then self.shownHp.player = mon.hp or 0 if self.hpAnim and self.hpAnim.side == "player" then @@ -1270,6 +1272,14 @@ function BattleState:advanceQueue() return self:askNickname(event.mon) end if event.kind == "choose-switch" then + -- engine/battle/core.asm:2590 + if self.battle and self.battle.wild then + self.nextMonIndex = 1 + self.phase = "ask-next-mon" + self.message = TEXT_USE_NEXT_MON + self.messageTimer = 0 + return + end -- A fainted lead: force a switch before anything else runs. self.phase = "forced-switch" self.message = "Choose a POKéMON." @@ -1876,6 +1886,36 @@ function BattleState:update(_dt) return end + -- engine/battle/core.asm:2590 + if self.phase == "ask-next-mon" then + if self.messageTimer > 0 then + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.nextMonIndex = self.nextMonIndex == 1 and 2 or 1 + elseif input:wasPressed("b") then + return self:answerUseNextMon(false) + elseif input:wasPressed("a") then + return self:answerUseNextMon(self.nextMonIndex == 1) + end + return + end + + if self.phase == "cant-escape-then-switch" then + if self.messageTimer > 0 then + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self.message = "Choose a POKéMON." + self.phase = "forced-switch" + return + end + if self.phase == "refuse-shift" then if self.messageTimer > 0 then if input:wasPressed("a") or input:wasPressed("b") then @@ -2433,6 +2473,31 @@ function BattleState:offerShiftSwitch(mon) self.messageTimer = MESSAGE_FRAMES end +function BattleState:answerUseNextMon(yes) + if yes then + self.phase = "forced-switch" + self.message = "Choose a POKéMON." + return + end + local battle = self.battle + if not battle then + self.phase = "forced-switch" + return + end + local lead = battle.party and battle.party[1] + local pSpd = (lead and lead.stats and lead.stats.speed) or 0 + -- engine/battle/core.asm:2614 + if battle:tryRun(pSpd) then + self:pushAll(battle:takeEvents()) + self.phase = "resolving" + return self:advanceQueue() + end + battle:takeEvents() + self.message = "Can't escape!" + self.messageTimer = MESSAGE_FRAMES + self.phase = "cant-escape-then-switch" +end + -- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is -- PARTYMENUACTION_SWITCH and carries no submenu; a cancel is `.canceled_switch` -- and answers exactly like NO (:3327). @@ -3173,15 +3238,18 @@ function BattleState:drawPanel() -- stands. local asking = self.phase == "ask-nickname" or self.phase == "ask-forget" or self.phase == "stop-learning" or self.phase == "ask-shift" + or self.phase == "ask-next-mon" if asking and (self.messageTimer or 0) <= 0 then -- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at -- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410). - local left = self.phase == "ask-shift" and 1 or 14 + local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon") + and 1 or 14 Chrome.box(left, 7, 6, 5) Chrome.print("YES", left + 2, 8) Chrome.print("NO", left + 2, 10) local index = self.phase == "ask-nickname" and self.nicknameIndex or self.phase == "ask-shift" and self.shiftIndex + or self.phase == "ask-next-mon" and self.nextMonIndex or self.forgetChoice Chrome.cursor(left + 1, index == 1 and 8 or 10) end @@ -3201,7 +3269,13 @@ local STATS_BOX_ROWS = { -- 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 mon then return end + local stats = mon.stats + local data = self.game and self.game.data + local def = data and data.pokemon and data.pokemon[mon.species] + if def and def.baseStats then + stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp) + end if not stats then return end Chrome.textbox(9, 0, 9, 10) for i, row in ipairs(STATS_BOX_ROWS) do diff --git a/src/ui/gen2/MartMenu.lua b/src/ui/gen2/MartMenu.lua index 42f98839..acc63b6e 100644 --- a/src/ui/gen2/MartMenu.lua +++ b/src/ui/gen2/MartMenu.lua @@ -75,7 +75,8 @@ local SFX_TRANSACTION = "Sfx_Transaction" local MartMenu = {} MartMenu.__index = MartMenu -MartMenu.isOpaque = true +-- engine/items/mart.asm:54 +MartMenu.isOpaque = false -- constants/mart_constants.asm. The `pokemart` macro emits this as one byte -- ahead of the word mart id, and MartTypeDialogs is indexed by it. @@ -330,9 +331,6 @@ local function extractedText(text, base, labels) return out end -function MartMenu:wantsFillScale() return true end -function MartMenu:drawsWidescreen() return true end - -- data/generated/marts.lua is what the ROM extractor will write out of `Marts` -- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each -- entry an array of item ids, and `bargain` is BargainShopData's own @@ -896,7 +894,8 @@ local function printPriceOpaque(amount, ty) end function MartMenu:drawBuyList() - -- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list + -- engine/items/mart.asm:542 + Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = LIST_Y + (row - 1) * LIST_SPACING @@ -953,18 +952,14 @@ end function MartMenu:drawUnder() local phase = self.phase if phase == "top" then - Chrome.clear() self:drawTopMenu() self:drawTextBox(self.topLines) elseif phase == "buy" or phase == "buyQuantity" then - Chrome.clear() self:drawMoneyBox() self:drawBuyList() self:drawDescription() elseif phase == "sell" or phase == "sellQuantity" then if self.pack then self.pack:drawPanel() end - else - Chrome.clear() end end @@ -1003,17 +998,4 @@ function MartMenu:draw() self:drawPanel() end -function MartMenu:drawWidescreen(winW, winH) - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) - local scale = Chrome.fitScale(winW, winH) - G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) - G.scale(scale, scale) - self:drawPanel() - G.pop() -end - return MartMenu diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 01e07c07..4857499c 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -119,7 +119,13 @@ function NamingScreen.new(game, opts) -- The header icon is an OBJ on the cart, so it wears a real palette; without -- one it would draw in raw DMG shades next to a colored world. self.iconColors = opts.iconColors - self.gfx = opts.menuGfx + local data = game and game.data or {} + self.gfx = opts.menuGfx or data.gen2MenuGfx + if self.gfx and self.gfx.naming then self.gfx = self.gfx.naming end + -- engine/menus/naming_screen.asm:47 + -- engine/gfx/cgb_layouts.asm:488 + local diploma = data.gen2Diploma + self.palette = diploma and diploma.palettes and diploma.palettes[1] self.tiles = {} if self.gfx then for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do @@ -315,6 +321,10 @@ function NamingScreen:update(_dt) end end +function NamingScreen:paperColor() + return GbcPalette.color(self.palette, 1) +end + -- The backdrop: one patterned tile repeated over the whole screen. Without -- menu_gfx.lua (older cache) fall back to a flat mid gray, which keeps the -- cleared panels readable. @@ -322,17 +332,25 @@ function NamingScreen:drawBackdrop() local G = love.graphics local tile = self.tiles.border if not tile then - G.setColor(0.62, 0.62, 0.62, 1) + local paper = self:paperColor() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) G.rectangle("fill", 0, 0, 160, 144) G.setColor(1, 1, 1, 1) return end - G.setColor(1, 1, 1, 1) - for ty = 0, Chrome.SCREEN_H - 1 do - for tx = 0, Chrome.SCREEN_W - 1 do - G.draw(tile, tx * 8, ty * 8) + local function blit() + G.setColor(1, 1, 1, 1) + for ty = 0, Chrome.SCREEN_H - 1 do + for tx = 0, Chrome.SCREEN_W - 1 do + G.draw(tile, tx * 8, ty * 8) + end end end + if self.palette and GbcPalette.available() then + GbcPalette.with(self.palette, blit) + else + blit() + end end -- The cursor (data/sprite_anims/oam.asm .OAMData_TextEntryCursor and @@ -410,7 +428,8 @@ end function NamingScreen:clearPanel(tx, ty, tw, th) local G = love.graphics - G.setColor(1, 1, 1, 1) + local paper = self:paperColor() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) G.setColor(0, 0, 0, 1) end @@ -472,12 +491,13 @@ function NamingScreen:drawPanel() G.draw(self.iconImage, quad, 16, 16) end end + local pal = self.palette if self.monName then -- Nickname header is two lines: "'S" then "NICKNAME?". - Chrome.print(self.monName .. "'S", 5, 2) - Chrome.print("NICKNAME?", 5, 4) + Chrome.printThrough(self.monName .. "'S", 5, 2, pal) + Chrome.printThrough("NICKNAME?", 5, 4, pal) else - Chrome.print(self.prompt, 5, 2) + Chrome.printThrough(self.prompt, 5, 2, pal) end self:drawEntry(5, self.isBox and 4 or 6) @@ -489,17 +509,22 @@ function NamingScreen:drawPanel() for col = 0, 8 do local ch = line[col + 1] if ch and ch ~= " " and ch ~= "" then - Chrome.print(ch, 2 + col * 2, keyboardTop + row * 2) + Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal) end end end local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS local bottomY = keyboardTop + bottom * 2 for i, label in ipairs(labels) do - Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY) + Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal) end - self:drawCursorBox(self:cursorTile()) + local function cursor() self:drawCursorBox(self:cursorTile()) end + if pal and GbcPalette.available() then + GbcPalette.with(pal, cursor) + else + cursor() + end G.setColor(1, 1, 1, 1) end @@ -511,23 +536,32 @@ function NamingScreen:drawWidescreen(winW, winH) local G = love.graphics -- The naming screen's own patterned backdrop is the surround: extend it to -- the window edges so a widescreen boot has no black pillarbox. - self:drawBackdrop() local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.setColor(0.62, 0.62, 0.62, 1) + local paper = self:paperColor() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) G.rectangle("fill", 0, 0, winW, winH) G.setColor(1, 1, 1, 1) if self.tiles.border then local tilesX = math.ceil(winW / (8 * scale)) local tilesY = math.ceil(winH / (8 * scale)) - G.push() - G.scale(scale, scale) - for ty = 0, tilesY do - for tx = 0, tilesX do - G.draw(self.tiles.border, tx * 8, ty * 8) + local tile = self.tiles.border + local function blit() + G.setColor(1, 1, 1, 1) + G.push() + G.scale(scale, scale) + for ty = 0, tilesY do + for tx = 0, tilesX do + G.draw(tile, tx * 8, ty * 8) + end end + G.pop() + end + if self.palette and GbcPalette.available() then + GbcPalette.with(self.palette, blit) + else + blit() end - G.pop() end G.push() G.translate(ox, oy) diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 041084d0..c6cf7dce 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -172,6 +172,34 @@ local ROWS = { text = function(options) return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) end }, + { id = "touchControls", label = "TOUCH PAD", port = true, + text = function(options) + local tc = options.touchControls + local on = not (type(tc) == "table" and tc.enabled == false) + return on and "ON" or "OFF" + end, + cycle = function(options, _delta, game) + local tc = type(options.touchControls) == "table" and options.touchControls or {} + tc.enabled = tc.enabled == false + options.touchControls = tc + require("src.core.TouchControls"):applyOptions(options) + if game and game.persistOptions then game:persistOptions() end + end }, + { id = "touchLayout", label = "TOUCH LAYOUT", port = true, + activate = function(game) + game.stack:push(require("src.ui.TouchControlsEditor").new(game)) + end }, + { id = "haptics", label = "VIBRATION", port = true, + text = function(options) + return require("src.core.TouchControls").hapticLabel(options.haptics) + end, + cycle = function(options, delta, game) + local TC = require("src.core.TouchControls") + options.haptics = TC.cycleHaptics(options.haptics, delta) + TC:applyOptions(options) + TC.buzz(options.haptics) + if game and game.persistOptions then game:persistOptions() end + end }, { label = "CANCEL", cancel = true }, } @@ -196,11 +224,18 @@ local function sameRows(_, rows) return rows end -- COLOR) simply appear in the list the hook receives. local function buildRows() local rows = {} + local env = os.getenv("POKEPORT_TOUCH") + local osName = love.system and love.system.getOS and love.system.getOS() + local showTouch = env == "1" + or (env ~= "0" and (osName == "Android" or osName == "iOS")) for i, row in ipairs(ROWS) do - local copy = {} - for key, value in pairs(row) do copy[key] = value end - copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil - rows[i] = copy + if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout" + and row.id ~= "haptics") then + local copy = {} + for key, value in pairs(row) do copy[key] = value end + copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil + rows[#rows + 1] = copy + end end return rows end @@ -291,6 +326,9 @@ end function OptionsMenu:leave_() if self.onDone then self.onDone(self.options) end + if self.game and self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end end function OptionsMenu:update(_dt) diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 1b0e019c..7c84f3c4 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -354,6 +354,12 @@ function PackMenu:useSelected() end return end + -- engine/items/tmhm.asm:73 + local def = self.items and self.items[row.id] + if def and def.teaches then + self:openTeachParty(row) + return + end -- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first -- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the -- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the @@ -586,6 +592,66 @@ function PackMenu:giveToSlot(slot, row) held:giveItem(row.id) end +-- engine/items/tmhm.asm:73 +function PackMenu:openTeachParty(row) + local game = self.game + local party = (self.save and self.save.party) or {} + if #party == 0 then + self.message = NO_POKEMON + return + end + if not (game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2PartyMenu") then return end + local def = self.items and self.items[row.id] + local moveId = def and def.teaches + local moves = game.data and game.data.moves + local moveDef = moves and moves[moveId] + local moveName = (moveDef and moveDef.name) or moveId + self.staleRows = true + Screens.push(game, "Gen2PartyMenu", { + save = self.save, + prompt = "teach", + tmhm = { move = moveId }, + onCancel = function() + game.stack:pop() + self:rebuild() + end, + onChoose = function(_slot, mon) + game.stack:pop() + local species = game.data and game.data.pokemon + and game.data.pokemon[mon.species] + local allowed = false + for _, id in ipairs((species and species.tmhm) or {}) do + if id == moveId then allowed = true end + end + if not allowed then + if game.say then + game:say(("%s can't learn %s!"):format( + mon.nickname or mon.species or "?", moveName)) + end + return + end + for _, move in ipairs(mon.moves or {}) do + if move.id == moveId then + if game.say then + game:say(("%s already knows %s!"):format( + mon.nickname or mon.species or "?", moveName)) + end + return + end + end + if not game.learnMoveOn then return end + game:learnMoveOn(mon, moveId, function(learned) + if not learned then return end + if tostring(row.id):sub(1, 3) == "HM_" then return end + require("src.core.gen2.Happiness").change(mon, "LEARNMOVE") + if game.consumeItem then game:consumeItem(row.id) end + self:rebuild() + end) + end, + }) +end + function PackMenu:update(_dt) local input = self.game and self.game.input if not input then return end diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index ffcb0488..f765fd09 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -22,6 +22,7 @@ local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") +local Mon = require("src.battle.gen2.Mon") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -90,11 +91,20 @@ function PartyMenu.new(game, opts) self.save = save self.party = opts.party or (save and save.party) or {} local data = game and game.data or {} + -- engine/pokemon/move_mon.asm:1402 + for i = 1, #self.party do + Mon.refreshStats(self.party[i], data) + end self.icons = opts.icons or data.gen2Icons self.palettes = opts.palettes or data.gen2Palettes self.pokemon = opts.pokemon or data.pokemon self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] or opts.prompt or PartyMenu.PROMPTS.choose + -- engine/pokemon/party_menu.asm:297 + self.tmhm = opts.tmhm + if self.tmhm and (opts.prompt == nil or opts.prompt == "teach") then + self.prompt = PartyMenu.PROMPTS.teach + end self.onChoose = opts.onChoose self.onCancel = opts.onCancel self.moves = opts.moves or data.moves @@ -469,7 +479,14 @@ function PartyMenu:update(_dt) elseif self.wantsSubmenu or self.wantsBattleSubmenu then self:openSubmenu() elseif self.onChoose then - self.onChoose(self.index, self.party[self.index]) + local mon = self.party[self.index] + if self.tmhm and mon and mon.isEgg then + -- engine/items/tmhm.asm:104 + local world = self.game and self.game.world + if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end + return + end + self.onChoose(self.index, mon) end elseif input:wasPressed("b") then self:storeCursor() @@ -674,6 +691,18 @@ function PartyMenu.rowFor(mon) } end +-- engine/pokemon/party_menu.asm:331 +function PartyMenu:tmhmAble(mon) + if not mon or mon.isEgg then return nil end + local move = self.tmhm and self.tmhm.move + if not move then return nil end + local species = self.pokemon and self.pokemon[mon.species] + for _, id in ipairs((species and species.tmhm) or {}) do + if id == move then return "ABLE" end + end + return "NOT ABLE" +end + -- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate -- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each -- steps 2 * SCREEN_WIDTH per mon: @@ -712,10 +741,15 @@ function PartyMenu:drawPanel() self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i)) local row = PartyMenu.rowFor(mon) Chrome.print(row.name, 3, nameY) - if row.hp then Chrome.print(row.hp, 13, nameY) end + if self.tmhm then + local able = self:tmhmAble(mon) + if able then Chrome.print(able, 12, dataY) end + else + if row.hp then Chrome.print(row.hp, 13, nameY) end + if row.hp then self:drawHpBar(mon, 11, dataY) end + end if row.status then Chrome.print(row.status, 5, dataY) end if row.level then Chrome.print(row.level, 8, dataY) end - if row.hp then self:drawHpBar(mon, 11, dataY) end end -- .end does `dec hl` twice from the row past the last nickname, so CANCEL diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 8c91ccc6..0413cdaa 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -1084,16 +1084,20 @@ function Pokegear:update(_dt) -- answers -1 rather than backing out to the strip. if self.fly then return self:updateFlyMap(input) end if self.mode == "strip" then - if input:wasPressed("left") then - self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards - elseif input:wasPressed("right") then - self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1 - elseif input:wasPressed("a") then - self.mode = "card" - elseif input:wasPressed("b") then - if self.onClose then self.onClose() end + local stripCard = self:card() + if not (stripCard and stripCard.id == "phone") then + if input:wasPressed("left") then + self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards + elseif input:wasPressed("right") then + self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1 + elseif input:wasPressed("a") then + self.mode = "card" + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end + return end - return + self.mode = "card" end -- Inside a card. local card = self:card() @@ -1105,6 +1109,19 @@ function Pokegear:update(_dt) -- submenu is waiting for. local phoneBusy = card and card.id == "phone" and (self.call ~= nil or self.phoneSubmenu ~= nil) + -- engine/pokegear/pokegear.asm:799 + if card and card.id == "phone" and not phoneBusy then + if input:wasPressed("b") then + if self.onClose then self.onClose() end + return + elseif input:wasPressed("left") then + self:switchCard("map", "clock") + return + elseif input:wasPressed("right") then + self:switchCard("radio") + return + end + end if not phoneBusy and input:wasPressed("b") then self.mode = "strip" self:stopRadio() @@ -2057,6 +2074,7 @@ function Pokegear:drawPlayerIcon(x, y) local beat = math.floor((self.iconTimer or 0) / 8) -- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles -- at -8,-8; camY of -4 undoes the world's sprite lift. + love.graphics.setColor(1, 1, 1, 1) self.playerIcon:draw(x - 8, y - 8, 0, -4, "down", beat % 2, beat == 3) return true diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index d65ad8e6..52f9377f 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -261,6 +261,8 @@ function SummaryMenu.new(game, opts) math.min(opts.index or 1, math.max(1, #self.party))) self.mon = self.party[self.index] end + -- engine/pokemon/move_mon.asm:1402 + Mon.refreshStats(self.mon, data) self.page = opts.page or PINK_PAGE -- ManagePokemonMoves opens straight onto MoveScreenLoop's screen; SELECT off -- the green page reaches the same view with the stats pages still behind it. @@ -678,6 +680,8 @@ function SummaryMenu:switchMon(delta) if next_ < 1 or next_ > #self.party then return false end self.index = next_ self.mon = self.party[next_] + -- engine/pokemon/move_mon.asm:1402 + Mon.refreshStats(self.mon, self.game and self.game.data) self.moveIndex = 1 self:playCry() return true @@ -694,6 +698,8 @@ function SummaryMenu:switchMonPastEggs(delta) if next_ < 1 or next_ > #self.party then return false end self.index = next_ self.mon = self.party[next_] + -- engine/pokemon/move_mon.asm:1402 + Mon.refreshStats(self.mon, self.game and self.game.data) self.moveIndex = 1 self:playCry() return true diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index b3deb62b..f0af7576 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -45,6 +45,7 @@ local Anim = require("src.core.gen2.TradeAnim") local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") @@ -202,6 +203,15 @@ local TEMPLATE_ROWS = { { row = 6, text = "№." }, } +-- gfx/sgb/predef.pal:29 +local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end +local TRADE_TUBE_PAL = { + { scale5(31), scale5(31), scale5(31) }, + { scale5(18), scale5(20), scale5(27) }, + { scale5(11), scale5(15), scale5(23) }, + { 0, 0, 0 }, +} + -------------------------------------------------------------------------- -- Construction -------------------------------------------------------------------------- @@ -367,8 +377,22 @@ end -- reading through. -- `index` is a GB shade, 0 (white) to 3 (black); GbcPalette.color counts from -- 1, as the palettes themselves do. +function TradeAnimView:bgColors() + local id = (self.beat or {}).id or "" + local pan = id:find("_pan_", 1, true) + if not pan then + return Palettes.textColors(self.palettes) + end + local colors = TRADE_TUBE_PAL + -- engine/movie/trade_animation.asm:1271 + if math.floor((self.frame or 0) / 8) % 2 == 1 then + colors = { colors[1], colors[3], colors[2], colors[4] } + end + return colors +end + function TradeAnimView:shade(index) - local colors = Palettes.textColors(self.palettes) + local colors = self:bgColors() local rgb = GbcPalette.color(colors, index + 1) or ({ { 255, 255, 255 }, { 168, 168, 168 }, { 96, 96, 96 }, { 0, 0, 0 } })[index + 1] @@ -384,7 +408,7 @@ end -- and GbcPalette maps them. A driver with no shader draws the greys, which -- is the DMG ramp and not a black frame. function TradeAnimView:through(body) - local colors = Palettes.textColors(self.palettes) + local colors = self:bgColors() love.graphics.setColor(1, 1, 1, 1) if colors and GbcPalette.available() then GbcPalette.with(colors, body) @@ -543,7 +567,7 @@ function TradeAnimView:drawStats(record, offset) 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) + Chrome.print(Strings(row.text), PANEL_X + 1, row.row) end Chrome.print(Chrome.number(record.dex or 0, 3, true), PANEL_X + 7, 0) Chrome.print(record.name, PANEL_X + 1, 2) @@ -767,6 +791,8 @@ end function TradeAnimView:drawPanel() local id = (self.beat or {}).id local t = self.offset or 0 + -- engine/movie/trade_animation.asm:151 + local wasBattle = Font.useBattleExtra(true) Chrome.clear() if GIVE_BEATS[id] then @@ -794,6 +820,7 @@ function TradeAnimView:drawPanel() end end love.graphics.setColor(1, 1, 1, 1) + Font.useBattleExtra(wasBattle) end function TradeAnimView:drawTubeBeat(id, t) diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index 8d1dfd75..ac21731e 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -442,6 +442,27 @@ function NPC:updateTreeShake() return true end +function NPC:scriptRockSmash(frames) + -- engine/overworld/map_objects.asm:1462 + self.rockSmash = { + frame = 0, + frames = frames or 10, + } + self.frozen = true + return true +end + +function NPC:updateRockSmash() + local st = self.rockSmash + if not st then return false end + st.frame = st.frame + 1 + if st.frame >= st.frames then + self.rockSmash = nil + return false + end + return true +end + -- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same -- name and meaning src/world/Collision.lua:20 gives it under Gen 1. local function occupied(entities, tx, ty, self) @@ -514,6 +535,10 @@ function NPC:update(map, entities) self:updateTreeShake() return end + if self.rockSmash then + self:updateRockSmash() + return + end -- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no -- translation. Above the moving arm because it has no targetX to reach, -- and the arm below would assign cellX = nil a frame later. @@ -726,6 +751,15 @@ function NPC:draw(ox, oy, scale) self.sprite:draw( self.px, self.py + yOffset, 0, 0, facing, 0, false, false, q == 3) + elseif self.rockSmash then + -- engine/overworld/map_objects.asm:1462 + if (self.rockSmash.frame % 2) == 0 then + G.pop() + return + end + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, + self.facing, self:walkPhase(), self.stepFlip) else self.sprite:draw( self.px, self.py + yOffset, 0, 0, diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 0ba5b0e8..4e4153fe 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -17,6 +17,12 @@ local TURN_FRAMES = 4 -- at the walking rate, which is what stops a bike step flickering the legs. Player.STEP_FRAMES = STEP_FRAMES +-- engine/overworld/map_objects.asm:1815 +local JUMP_Y = { + -4, -6, -8, -10, -11, -12, -12, -12, + -11, -10, -9, -8, -6, -4, 0, 0, +} + function Player.new(cx, cy, facing, spriteDef) local self = setmetatable({ cellX = cx, cellY = cy, @@ -170,9 +176,11 @@ function Player:update() self.px = self.cellX * 16 + dx * adv self.py = self.cellY * 16 + dy * adv if self.jumping then - -- 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)) + -- engine/overworld/map_objects.asm:1815 + local idx = math.floor((self.progress - 1) / 2) + 1 + if idx < 1 then idx = 1 end + if idx > #JUMP_Y then idx = #JUMP_Y end + self.spriteYOffset = JUMP_Y[idx] end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY @@ -180,6 +188,7 @@ function Player:update() self.px, self.py = self.cellX * 16, self.cellY * 16 self.moving = false self.jumping = nil + self.spriteYOffset = 0 self.stepFlip = not self.stepFlip return true end @@ -193,6 +202,15 @@ function Player:draw(ox, oy, scale) -- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off -- lift both ride this one byte. local yOffset = self.spriteYOffset or 0 + if self.jumping then + -- engine/overworld/map_objects.asm:1995 + local gx = ox + self.px * scale + local gy = oy + self.py * scale + local s = 16 * scale + G.setColor(0, 0, 0, 0.4) + G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12) + G.setColor(1, 1, 1, 1) + end if self.sprite then G.push() G.translate(ox, oy) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ae667a22..087b7bfe 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -621,6 +621,8 @@ function World.new(game) -- which is why a cut tree is back the next time you walk in. Restoring -- these at the top of setMap is that refill. blockEdits = {}, + -- engine/overworld/map_setup.asm:78 + objectSpawns = {}, -- A field move that is mid-flow (the used-X text, then its effect). fieldMove = nil, -- ---- state the script VM owns ------------------------------------------ @@ -1819,6 +1821,15 @@ function World:moveObject(objectId, cellX, cellY) local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] if not (obj and cellX and cellY) then return end + local mapId = self.map and self.map.id + local key = obj.index or index + if mapId then + self.objectSpawns = self.objectSpawns or {} + self.objectSpawns[mapId] = self.objectSpawns[mapId] or {} + if not self.objectSpawns[mapId][key] then + self.objectSpawns[mapId][key] = { obj.x, obj.y } + end + end obj.x, obj.y = cellX, cellY local npc = self:objectEntity(objectId) if npc and npc ~= self.player then @@ -3715,6 +3726,16 @@ function World:updateMovement() while st.i <= #st.bytes do local b = st.bytes[st.i] st.i = st.i + 1 + -- engine/overworld/movement.asm:163 + if b == 0x57 then + local duration = st.bytes[st.i] or 0 + st.i = st.i + 1 + if ent.scriptRockSmash then + ent:scriptRockSmash(duration) + end + st.sleep = duration + return + end local act = Movement.decodeByte(b) if act.kind == "end" then -- SLIDING_F is an object flag, not a stream one, so a stream that never @@ -5237,6 +5258,38 @@ function World:restoreBlocks() return any end +function World:restoreObjectSpawns() + -- engine/overworld/map_setup.asm:78 + local spawns = self.objectSpawns + if not spawns then return end + for mapId, byIndex in pairs(spawns) do + local def = self.maps and self.maps[mapId] + local objects = def and def.objects + if objects then + for key, xy in pairs(byIndex) do + local obj + for _, row in ipairs(objects) do + if (row.index or 0) == key then obj = row break end + end + if obj then + obj.x, obj.y = xy[1], xy[2] + end + local npc = self.npcPool + and self.npcPool[string.format("%s_obj_%d", mapId, key)] + if npc then + npc.cellX, npc.cellY = xy[1], xy[2] + npc.px, npc.py = xy[1] * 16, xy[2] * 16 + npc.homeX, npc.homeY = xy[1], xy[2] + npc.moving = false + npc.progress = 0 + npc.targetX, npc.targetY = nil, nil + end + end + end + spawns[mapId] = nil + end +end + -- Drop the loaded map's baked canvases and bake again. Same shape as what -- pollTimeOfDay does when the clock rolls the palette over; a block edit -- invalidates the bake for the same reason a palette change does. A world with @@ -5360,8 +5413,10 @@ end -- picked, and TextBox reads it back off game.stringBuffer. function World:setNickname(mon) if not self.game then return end - self.game.stringBuffer = - (mon and (mon.nickname or mon.name or mon.species)) or "" + local name = (mon and (mon.nickname or mon.name or mon.species)) or "" + self.game.stringBuffer = name + -- engine/events/overworld.asm:1339 + if self.vm then self.vm.stringBuffer = name end end function World:playMonCry(mon) @@ -8304,6 +8359,7 @@ function World:setMap(mapId, cx, cy, facing, opts) -- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next -- time the map is loaded, and this has to happen before Map.new reads them. self:restoreBlocks() + self:restoreObjectSpawns() -- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before -- anything else that touches state: event flags 0-7 -- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what @@ -8784,7 +8840,8 @@ function World:tryLedgeJump(dir) -- ShakeGrass (engine/overworld/movement.asm:741-770). p.inGrass, p.grassShake = false, nil p.progress = 0 - p.stepFrames = Player.STEP_FRAMES + -- engine/overworld/map_objects.asm:1163 + p.stepFrames = Player.STEP_FRAMES * 2 self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE) return true end diff --git a/tests/drivers/gold_save_slot_bug1107_test.lua b/tests/drivers/gold_save_slot_bug1107_test.lua new file mode 100644 index 00000000..de6b3d87 --- /dev/null +++ b/tests/drivers/gold_save_slot_bug1107_test.lua @@ -0,0 +1,49 @@ +-- In-game Gold SAVE with no launcher slot must leave a file CONTINUE can see. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 \ +-- POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . + +local U = require("tests.drivers.util") +local SaveData = require("src.core.SaveData") + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local before = SaveData.listSlots("gold") + local hadFile = false + for _, slot in ipairs(before) do + if slot.exists then hadFile = true break end + end + if not hadFile then + local opts = SaveData.loadOptions() + opts.saveSlots = opts.saveSlots or {} + opts.saveSlots.gold = nil + SaveData.saveOptions(opts) + SaveData.resetSlotState() + end + + local ok, err = game:writeSave() + if not ok then + U.log("FAIL gold writeSave:", tostring(err)) + else + local after = SaveData.listSlots("gold") + local found, path + for _, slot in ipairs(after) do + if slot.exists then + found = slot.id + path = SaveData.slotDiskPath("gold", slot.id) + break + end + end + if found then + U.log("PASS gold save is launcher-visible:", found, path or "") + else + U.log("FAIL gold save wrote but listSlots has no file") + end + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/herbshop_bug1169_test.lua b/tests/drivers/herbshop_bug1169_test.lua new file mode 100644 index 00000000..65080a9c --- /dev/null +++ b/tests/drivers/herbshop_bug1169_test.lua @@ -0,0 +1,49 @@ +-- Herb shop intro over the Goldenrod Underground map (#1169). +-- pokegold engine/items/mart.asm:54 HerbShop, maps/GoldenrodUnderground.asm:158 +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . + +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/bug1169" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- maps/GoldenrodUnderground.asm:52 + world.clockDay = 0 + world.mapScenes = world.mapScenes or {} + -- maps/GoldenrodUnderground.asm:679 + assert(world:setMap("GOLDENROD_UNDERGROUND", 6, 21, "right"), + "setMap GOLDENROD_UNDERGROUND failed") + U.wait(8) + + local granny + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_GRANNY" then granny = npc break end + end + if not granny then + U.log("FAIL granny not on the map (need Sunday)") + end + + local MartMenu = require("src.ui.gen2.MartMenu") + U.tap(game, "a") + U.wait(6) + for _ = 1, 90 do + if getmetatable(game.stack:top()) == MartMenu then break end + U.tap(game, "a") + U.wait(4) + end + local top = game.stack:top() + if getmetatable(top) ~= MartMenu then + U.log("FAIL mart did not open") + elseif top.isOpaque then + U.log("FAIL mart is opaque") + else + U.log("herb shop intro over the map") + end + U.shot(game, out .. "/herbshop_intro.png") + while true do U.wait(60) end +end diff --git a/tests/drivers/kurt_well_bug1184_test.lua b/tests/drivers/kurt_well_bug1184_test.lua new file mode 100644 index 00000000..89ff8da6 --- /dev/null +++ b/tests/drivers/kurt_well_bug1184_test.lua @@ -0,0 +1,88 @@ +-- Kurt stays at moveobject (11, 6) for the visit, then ROM spawn (16, 14) +-- on the next load. +-- #1184 +-- maps/SlowpokeWellB1F.asm:48 / engine/overworld/map_setup.asm:78 +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . + +local U = require("tests.drivers.util") + +local KURT_ID = 8 +local ROM_X, ROM_Y = 16, 14 +local MOVE_X, MOVE_Y = 11, 6 +local FLAG_KURT = 1856 +local FLAG_ROCKETS = 1788 + +local function findKurt(world) + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_KURT" then return npc end + end + return nil +end + +local function kurtDef(world) + local objects = world.map and world.map.def and world.map.def.objects + return objects and objects[KURT_ID - 1] +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + world.mapScenes = world.mapScenes or {} + world.mapScenes.PLAYERS_HOUSE_1F = 1 + world.mapScenes.NEW_BARK_TOWN = 1 + world.events:set(FLAG_KURT, false) + world.events:set(FLAG_ROCKETS, true) + + assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"), + "SLOWPOKE_WELL_B1F did not load") + U.wait(4) + + local pass, fail = 0, 0 + local function claim(ok, text) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", text) + end + + local kurt = findKurt(world) + claim(kurt ~= nil, "Kurt is on the well map") + claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y, + ("Kurt ROM spawn is (%d, %d), saw (%s, %s)") + :format(ROM_X, ROM_Y, + tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY))) + + world:moveObject(KURT_ID, MOVE_X, MOVE_Y) + world:appearObject(KURT_ID) + U.wait(4) + + kurt = findKurt(world) + claim(kurt and kurt.cellX == MOVE_X and kurt.cellY == MOVE_Y, + ("after moveobject Kurt is at (%d, %d), saw (%s, %s)") + :format(MOVE_X, MOVE_Y, + tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY))) + + assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"), + "PLAYERS_HOUSE_1F did not load") + U.wait(2) + world.events:set(FLAG_KURT, false) + assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"), + "SLOWPOKE_WELL_B1F did not reload") + U.wait(4) + + local def = kurtDef(world) + kurt = findKurt(world) + claim(def and def.x == ROM_X and def.y == ROM_Y, + ("reload restored Kurt def to (%d, %d), saw (%s, %s)") + :format(ROM_X, ROM_Y, + tostring(def and def.x), tostring(def and def.y))) + claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y, + ("reload put Kurt at (%d, %d), not the blocking cell (%d, %d); saw (%s, %s)") + :format(ROM_X, ROM_Y, MOVE_X, MOVE_Y, + tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY))) + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + U.log("Kurt should be at the well entrance, not on the inner path.") + + while true do U.wait(60) end +end diff --git a/tests/drivers/multihit_bug1168_test.lua b/tests/drivers/multihit_bug1168_test.lua new file mode 100644 index 00000000..66039066 --- /dev/null +++ b/tests/drivers/multihit_bug1168_test.lua @@ -0,0 +1,143 @@ +-- Fury Attack / Barrage hit 2-5 times, Twineedle stays at 2. Issue #1168. +-- pokegold engine/battle/effect_commands.asm:5228 (BattleCommand_EndLoop). +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . +-- Do not add POKEPORT_SPEED: the per-strike HP steps are what you are judging. + +local U = require("tests.drivers.util") +local Battle = require("src.battle.gen2.Battle") +local Effects = require("src.battle.gen2.Effects") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + local function claim(ok, text) + print((ok and "[multihit] PASS " or "[multihit] FAIL ") .. text) + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + print("[multihit] FAIL gold world did not boot") + while true do U.wait(60) end + end + + local fury = game.data.moves and game.data.moves.FURY_ATTACK + local twine = game.data.moves and game.data.moves.TWINEEDLE + claim(fury ~= nil, "FURY_ATTACK is in the move table") + claim(fury and fury.effect == "EFFECT_MULTI_HIT", + "FURY_ATTACK is EFFECT_MULTI_HIT") + claim(twine ~= nil, "TWINEEDLE is in the move table") + claim(twine and twine.effect == "EFFECT_POISON_MULTI_HIT", + "TWINEEDLE is EFFECT_POISON_MULTI_HIT") + claim(Effects.hitCount("EFFECT_DOUBLE_HIT") == 2, "DOUBLE_HIT is 2") + claim(Effects.hitCount("EFFECT_POISON_MULTI_HIT") == 2, + "POISON_MULTI_HIT (Twineedle) is 2") + + local seq, si = { 0, 1, 2, 0, 3, 3 }, 0 + local function scripted() + si = si + 1 + return seq[si] or 0 + end + claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2, "roll 0 -> 2 hits") + claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 3, "roll 1 -> 3 hits") + claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2, + "roll 2 then 0 -> 2 hits") + claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 5, + "roll 3 then 3 -> 5 hits") + + local function fiveHitRandom(n) + if n == 4 then return 3 end + return 0 + end + + local headP = Mon.new(game.data, "CYNDAQUIL", 20) + local headW = Mon.new(game.data, "SNORLAX", 20) + if headP and headW then + headP.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } } + local b = Battle.new({ + data = game.data, party = { headP }, wild = headW, + random = fiveHitRandom, + }) + local landed, hitLine = 0, nil + for _, ev in ipairs(b:takeTurn({ kind = "move", move = "FURY_ATTACK" })) do + if ev.kind == "damage" and ev.side == "enemy" then + landed = landed + 1 + end + if ev.kind == "message" and ev.text then + local n = ev.text:match("Hit (%d+) time") + if n then hitLine, landed = ev.text, tonumber(n) end + end + end + claim(landed == 5, + ("scripted Fury Attack hit %d times (want 5, not 2)"):format(landed)) + if hitLine then print("[multihit] " .. hitLine) end + else + claim(false, "could not build a headless Fury Attack pair") + end + + local player = Mon.new(game.data, "CYNDAQUIL", 20) + if not player then + print("[multihit] FAIL could not build CYNDAQUIL") + while true do U.wait(60) end + end + player.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } } + game.save.party = { player } + + local wild = Mon.new(game.data, "SNORLAX", 20) + if not wild then + print("[multihit] FAIL could not build SNORLAX") + while true do U.wait(60) end + end + if not world:startBattle({ wild = wild }) then + print("[multihit] FAIL startBattle failed") + while true do U.wait(60) end + end + + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + if not (screen and screen.battle) then + print("[multihit] FAIL battle screen never came up") + while true do U.wait(60) end + end + + local left = 2 + screen.battle.random = function(n) + if n == 4 and left > 0 then + left = left - 1 + return 3 + end + if left > 0 then return 0 end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 + end + + for _ = 1, 200 do + if screen.phase == "menu" then break end + tap("a", 3) + end + if screen.phase ~= "menu" then + print("[multihit] FAIL never reached the battle menu") + while true do U.wait(60) end + end + + tap("a") + U.wait(6) + tap("a") + print("[multihit] Fury Attack should strike five times on this turn.") + print("[multihit] later turns are 2-5. Twineedle would stay at 2.") + + while true do U.wait(60) end +end diff --git a/tests/drivers/radiotower_softlock_bug1164_test.lua b/tests/drivers/radiotower_softlock_bug1164_test.lua new file mode 100644 index 00000000..c2affd26 --- /dev/null +++ b/tests/drivers/radiotower_softlock_bug1164_test.lua @@ -0,0 +1,92 @@ +-- Radio Tower 5F director returns to the office after a downstairs/upstairs +-- reload, not on the stair warp at (12, 0). +-- #1164 / #1188 +-- maps/RadioTower5F.asm:115 / engine/overworld/map_setup.asm:78 +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . + +local U = require("tests.drivers.util") + +local DIRECTOR_ID = 2 +local OFFICE_X, OFFICE_Y = 3, 6 +local STAIR_X, STAIR_Y = 12, 0 +local FLAG_ROCKETS = 1742 +local FLAG_CIVILIANS = 1744 + +local function findDirector(world) + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_GENTLEMAN" then return npc end + end + return nil +end + +local function directorDef(world) + local objects = world.map and world.map.def and world.map.def.objects + return objects and objects[DIRECTOR_ID - 1] +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + world.mapScenes = world.mapScenes or {} + world.mapScenes.RADIO_TOWER_5F = 2 + world.mapScenes.RADIO_TOWER_4F = 0 + world.events:set(FLAG_ROCKETS, true) + world.events:set(FLAG_CIVILIANS, false) + + assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"), + "RADIO_TOWER_5F did not load") + U.wait(4) + + local pass, fail = 0, 0 + local function claim(ok, text) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", text) + end + + local director = findDirector(world) + claim(director ~= nil, "director is on 5F") + claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y, + ("director office spawn is (%d, %d), saw (%s, %s)") + :format(OFFICE_X, OFFICE_Y, + tostring(director and director.cellX), + tostring(director and director.cellY))) + + world:moveObject(DIRECTOR_ID, STAIR_X, STAIR_Y) + world:appearObject(DIRECTOR_ID) + U.wait(4) + + director = findDirector(world) + claim(director and director.cellX == STAIR_X and director.cellY == STAIR_Y, + ("after moveobject director is at the stairs (%d, %d), saw (%s, %s)") + :format(STAIR_X, STAIR_Y, + tostring(director and director.cellX), + tostring(director and director.cellY))) + + assert(world:setMap("RADIO_TOWER_4F", 12, 4, "down"), + "RADIO_TOWER_4F did not load") + U.wait(2) + assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"), + "RADIO_TOWER_5F did not reload") + U.wait(4) + + local def = directorDef(world) + director = findDirector(world) + claim(def and def.x == OFFICE_X and def.y == OFFICE_Y, + ("reload restored director def to (%d, %d), saw (%s, %s)") + :format(OFFICE_X, OFFICE_Y, + tostring(def and def.x), tostring(def and def.y))) + claim(not (director and director.cellX == STAIR_X and director.cellY == STAIR_Y), + "director is not standing on the stair warp") + claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y, + ("reload put director in the office (%d, %d); saw (%s, %s)") + :format(OFFICE_X, OFFICE_Y, + tostring(director and director.cellX), + tostring(director and director.cellY))) + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + U.log("The gentleman should be in the office. The stairs must be clear.") + + while true do U.wait(60) end +end diff --git a/tests/drivers/rocksmash_shift_bug1173_test.lua b/tests/drivers/rocksmash_shift_bug1173_test.lua new file mode 100644 index 00000000..940966d0 --- /dev/null +++ b/tests/drivers/rocksmash_shift_bug1173_test.lua @@ -0,0 +1,85 @@ +-- Route 40 smashable rocks stay on their cell through rock_smash, then a +-- seamless Olivine round trip. +-- #1173 +-- engine/overworld/movement.asm:163 / maps/Route40.asm:291 +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . + +local U = require("tests.drivers.util") + +local ROCK_ID = 6 +local ROCK_X, ROCK_Y = 12, 8 +local SMASH = { 0x57, 10, 0x47 } + +local function findRock(world, x, y) + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_ROCK" + and npc.cellX == x and npc.cellY == y then + return npc + end + end + return nil +end + +local function rockAt(world, x, y) + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_ROCK" + and npc.cellX == x and npc.cellY == y then + return true + end + end + return false +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + world.mapScenes = world.mapScenes or {} + world.mapScenes.ROUTE_40 = 0 + world.mapScenes.OLIVINE_CITY = 0 + + assert(world:setMap("ROUTE_40", 12, 9, "up"), "ROUTE_40 did not load") + U.wait(4) + + local pass, fail = 0, 0 + local function claim(ok, text) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", text) + end + + local rock = findRock(world, ROCK_X, ROCK_Y) + claim(rock ~= nil, ("rock is at (%d, %d)"):format(ROCK_X, ROCK_Y)) + + world:beginMovement(ROCK_ID, SMASH) + for _ = 1, 40 do + if not world.moveState then break end + U.wait(1) + end + claim(world.moveState == nil, "rock_smash stream finished") + + rock = findRock(world, ROCK_X, ROCK_Y) + claim(rock ~= nil, + ("after rock_smash the rock is still at (%d, %d), not one cell left") + :format(ROCK_X, ROCK_Y)) + claim(not rockAt(world, ROCK_X - 1, ROCK_Y), + "no smashable rock slid one cell left") + + assert(world:setMap("OLIVINE_CITY", 18, 15, "down", { seamless = true }), + "OLIVINE_CITY did not load") + U.wait(2) + assert(world:setMap("ROUTE_40", 12, 9, "up", { seamless = true }), + "ROUTE_40 did not reload") + U.wait(4) + + claim(rockAt(world, ROCK_X, ROCK_Y), + ("after Olivine round trip the rock is still at (%d, %d)") + :format(ROCK_X, ROCK_Y)) + claim(not rockAt(world, ROCK_X - 1, ROCK_Y), + "seamless reload did not shift the rock left") + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + U.log("The three rocks should sit on (12, 8), (11, 7), (13, 6).") + + while true do U.wait(60) end +end diff --git a/tests/drivers/use_next_mon_bug1152_test.lua b/tests/drivers/use_next_mon_bug1152_test.lua new file mode 100644 index 00000000..2b6b0e5f --- /dev/null +++ b/tests/drivers/use_next_mon_bug1152_test.lua @@ -0,0 +1,94 @@ +-- After a wild faint, "Use next POKéMON?" yes/no; NO tries to run. Issue #1152. +-- pokegold engine/battle/core.asm:2590 (AskUseNextPokemon). +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . +-- Do not add POKEPORT_SPEED: you need the yes/no box to sit there. + +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + local function claim(ok, text) + print((ok and "[use-next] PASS " or "[use-next] FAIL ") .. text) + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + print("[use-next] FAIL gold world did not boot") + while true do U.wait(60) end + end + + local lead = Mon.new(game.data, "SENTRET", 5) + local backup = Mon.new(game.data, "CYNDAQUIL", 12) + if not (lead and backup) then + print("[use-next] FAIL could not build the party") + while true do U.wait(60) end + end + lead.hp = 1 + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + backup.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + game.save.party = { lead, backup } + + local wild = Mon.new(game.data, "PIDGEY", 20) + if not wild then + print("[use-next] FAIL could not build a wild PIDGEY") + while true do U.wait(60) end + end + if not world:startBattle({ wild = wild }) then + print("[use-next] FAIL startBattle failed") + while true do U.wait(60) end + end + + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + if not (screen and screen.battle) then + print("[use-next] FAIL battle screen never came up") + while true do U.wait(60) end + end + + for _ = 1, 200 do + if screen.phase == "menu" then break end + tap("a", 3) + end + if screen.phase ~= "menu" then + print("[use-next] FAIL never reached the battle menu") + while true do U.wait(60) end + end + + tap("a") + U.wait(6) + tap("a") + + local sawAsk, sawForced = false, false + for _ = 1, 400 do + if screen.phase == "ask-next-mon" then sawAsk = true break end + if screen.phase == "forced-switch" or screen.phase == "submenu" then + sawForced = true + break + end + if screen.battle.over then break end + tap("a", 3) + end + + claim(sawAsk, "wild faint opened Use next POKéMON?") + claim(not sawForced, "wild faint did not skip to the party list") + claim(screen.phase == "ask-next-mon", + "phase is ask-next-mon (YES switches, NO/B tries to run)") + print("[use-next] YES sends you to the party. NO or B tries to run.") + print("[use-next] a failed run still opens the party. trainers skip this.") + + while true do U.wait(60) end +end