From 6eda75cc92eb9da617feeb6e93f5fd1e72ab18c5 Mon Sep 17 00:00:00 2001 From: Shane McGovern <45065431+Nexhas28@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:54:55 +0100 Subject: [PATCH] fix(emerald): recover Matt encounter after early Mossdeep Gym completion --- docs/emerald-matt-2748.md | 81 +++++++ src/core/game3/profiles/emerald_rules.lua | 16 ++ tests/emerald_matt_2748_test.lua | 248 ++++++++++++++++++++++ tests/quick.list | 1 + 4 files changed, 346 insertions(+) create mode 100644 docs/emerald-matt-2748.md create mode 100644 tests/emerald_matt_2748_test.lua diff --git a/docs/emerald-matt-2748.md b/docs/emerald-matt-2748.md new file mode 100644 index 00000000..f20f60a7 --- /dev/null +++ b/docs/emerald-matt-2748.md @@ -0,0 +1,81 @@ +# Emerald: Matt missing in Aqua Hideout (#2748) + +## Reproduced cause + +[Issue #2748](https://github.com/bryanthaboi/gen1recomp/issues/2748) reports +Matt missing and uninteractable in Aqua Hideout B2F on Android v3.60. The +maintainer's comment asks whether Tate and Liza were defeated. No reporter +save is attached, so this is a reproduced cause, not confirmation of the +reporter's exact flag state. + +The canonical Emerald scripts have an ordering dependency: + +- `MossdeepCity_Gym_EventScript_TateAndLizaDefeated` sets both + `FLAG_BADGE07_GET` (0x86D) and `FLAG_HIDE_AQUA_HIDEOUT_GRUNTS` (0x39C). +- Aqua Hideout B2F's Matt template (local ID 1, graphics 117, tile 23,19) + uses that same hide flag. The other hideout grunts share it. +- Matt's trainer battle (trainer 30, continuation type 2) runs + `AquaHideout_B2F_EventScript_SubmarineEscape` on victory. This removes the + submarine (local ID 4) and sets + `FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE` (0x70). +- The gym script does not set the escape flag. Completing the gym first + therefore hides Matt while his escape event is still pending. On the next + hideout visit, Matt is absent from both `Objects.forDraw()` and + `Objects.at()`, so field interaction cannot start his battle. + +A fresh hideout template with the flag clear is drawable and interactable; +this reproducer did not require broken graphics extraction or an Android +renderer defect. It reproduces a sequence break regardless of how the +player obtained early access to Mossdeep. It does not establish how the +reporter reached that state or attribute it to either previously used mod. + +## Recovery boundary + +`src/core/game3/profiles/emerald_rules.lua` implements `repairSaveState` +through the existing edition-specific lifecycle: + +- `Schema.fromSaveTable` repairs decoded Lua sessions. +- `Flags.loadInto` repairs script-side saved flag stores. +- `Flags.onMapLoad` repairs map-entry/continue stores before objects spawn. + +Only when the Mind Badge is earned, the shared hideout hide flag is +synchronized with the submarine-escape flag. If escape is pending, NPCs +remain available; once escape is complete, normal late-story hiding resumes +on map entry/load. Normal story ordering has already completed escape when +the badge is earned, so its hide behavior is unchanged. + +The recovery does not award a badge, mark Matt defeated, set the escape +flag, or rewrite imported ROM scripts. A player still needs to fight Matt +and finish his continuation. Without the Mind Badge, explicit hide flags +are left alone. Ruby, Sapphire, FireRed and LeafGreen do not use this rule. + +This is not a general recovery for interrupted win continuations, arbitrary +object-removal snapshots, or unrelated mod-written flags. + +## Regression + +`tests/emerald_matt_2748_test.lua` runs a ROM-free canonical fixture by +default and is registered in `tests/quick.list`. With +`POKEPORT_EMERALD_CACHE` set, it validates the actual imported template and +gym flag writes, then executes the full imported Matt/escape scripts: + +```sh +luajit tests/emerald_matt_2748_test.lua +POKEPORT_EMERALD_CACHE=/path/to/emerald luajit tests/emerald_matt_2748_test.lua +``` + +Coverage includes fresh and early-gym visits, legacy Lua/script-side saves, +object snapshots, A-button interaction through `Field.interact`, win/loss +callbacks, submarine local-ID resolution, all four badge/escape ordering +states, repeated entry, unrelated flags/vars, alternate numeric key +encodings, and other-edition routing. + +Messages and battle/movement completion are headless adapters. Tests prove +VM continuation and lifecycle behavior, not battle simulation, actual +sprite pixels, or execution on an Android device. No benchmarks are used. + +Canonical sources: + +- [B2F template](https://github.com/pret/pokeemerald/blob/master/data/maps/AquaHideout_B2F/map.json) +- [B2F scripts](https://github.com/pret/pokeemerald/blob/master/data/maps/AquaHideout_B2F/scripts.inc) +- [Mossdeep Gym scripts](https://github.com/pret/pokeemerald/blob/master/data/maps/MossdeepCity_Gym/scripts.inc) diff --git a/src/core/game3/profiles/emerald_rules.lua b/src/core/game3/profiles/emerald_rules.lua index 34962c5d..514d39e6 100644 --- a/src/core/game3/profiles/emerald_rules.lua +++ b/src/core/game3/profiles/emerald_rules.lua @@ -25,6 +25,22 @@ function Rules.newGameFlags() return {} end +-- #2748: MossdeepCity_Gym_EventScript_TateAndLizaDefeated sets the shared +-- hideout flag. If the gym was completed out of order, this hides Matt before +-- AquaHideout_B2F_EventScript_SubmarineEscape can advance the story. Recover +-- at load/map entry, not by granting a battle win or rewriting ROM scripts. +-- In the normal order the escape flag is already set when the badge is earned. +function Rules.repairSaveState(store) + local Flags = require("src.core.game3.scripting.flags") + local F = constants().flags.byName + if not Flags.getFlag(store, nil, F.FLAG_BADGE07_GET) then return end + local escaped = Flags.getFlag(store, nil, F.FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE) + local hide = F.FLAG_HIDE_AQUA_HIDEOUT_GRUNTS + if Flags.getFlag(store, nil, hide) ~= escaped then + Flags.setFlag(store, nil, hide, escaped) + end +end + function Rules.newVsSeeker() return nil end diff --git a/tests/emerald_matt_2748_test.lua b/tests/emerald_matt_2748_test.lua new file mode 100644 index 00000000..7442dfcc --- /dev/null +++ b/tests/emerald_matt_2748_test.lua @@ -0,0 +1,248 @@ +-- #2748: completing Mossdeep Gym before Matt must not strand the hideout story. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local check, eq = T.check, T.eq +local GameVersion = require('src.core.GameVersion') +GameVersion.set('emerald') +local Profile = require('src.core.game3.profile') +Profile.reset() +local C = require('src.core.game3.constants').of('emerald') +local Flags = require('src.core.game3.scripting.flags') +local Vm = require('src.core.game3.scripting.vm') +local Adapters = require('src.core.game3.scripting.adapters') +local Space = require('src.core.game3.scripting.space') +local Objects = require('src.core.game3.objects') +local Player = require('src.core.game3.player') +local Field = require('src.core.game3.field') +local Collision = require('src.core.game3.collision') +local Trainers = require('src.core.game3.scripting.trainers') +-- Avoid an unrelated ROM party dependency; this test exercises VM continuation, +-- not battle simulation. The result comes from an explicit battle callback. +Trainers._pack = { trainers = {} } +local Schema = require('src.core.game3.save_schema_firered') +local HIDE = C.flags.byName.FLAG_HIDE_AQUA_HIDEOUT_GRUNTS +local ESCAPED = C.flags.byName.FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE +local BADGE = C.flags.byName.FLAG_BADGE07_GET +local SUB_HIDE = C.flags.byName.FLAG_HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW +local MATT = C.trainers.byName.TRAINER_MATT +local MAP = 'EM_AQUA_HIDEOUT_B2F' +local mapDef = { midLayout = { width = 40, height = 25 } } +-- Canonical template and flag operations from pokeemerald data/maps/ +-- AquaHideout_B2F/{map.json,scripts.inc} and MossdeepCity_Gym/scripts.inc. +local objects = { + { localId = 1, graphicsId = 117, x = 23, y = 19, elevation = 3, + movementType = 9, trainerType = 0, scriptKey = 'matt', flag = HIDE }, + { localId = 2, graphicsId = 117, x = 23, y = 10, movementType = 45, flag = HIDE }, + { localId = 4, graphicsId = 141, x = 19, y = 20, elevation = 1, + movementType = 9, flag = SUB_HIDE }, +} +local scripts = { + gym = { + { op = 'setflag', [1] = BADGE }, { op = 'setflag', [1] = HIDE }, { op = 'end' }, + }, + matt = { + { op = 'trainerbattle', type = 2, trainer = MATT, localId = 0, eventScript = 'escape' }, + { op = 'end' }, + }, + escape = { + { op = 'setvar', [1] = 0x8009, [2] = 4 }, + { op = 'removeobject', [1] = 0x8009 }, + { op = 'setflag', [1] = ESCAPED }, { op = 'end' }, + }, +} +local movements = {} +local root = os.getenv('POKEPORT_EMERALD_CACHE') +if root and root ~= '' then + local function loadRel(name) + return assert(loadfile(root .. '/data/generated/gba/scripts/' .. name .. '.lua'))() + end + local imported, events, labels = loadRel('scripts'), loadRel('events'), loadRel('labels') + movements = loadRel('movements') + local ev = assert(events[MAP]) + objects = ev.objects + eq(objects[1].localId, 1, 'ROM Matt local id') + eq(objects[1].flag, HIDE, 'ROM Matt uses the shared hideout-grunts hide flag') + eq(objects[1].x, 23, 'ROM Matt x') + eq(objects[1].y, 19, 'ROM Matt y') + local gymKey = assert(labels.MossdeepCity_Gym_EventScript_TateAndLizaDefeated) + local gymFlags = {} + for _, row in ipairs(assert(imported[gymKey])) do + if row.op == 'setflag' and (row[1] == BADGE or row[1] == HIDE) then + gymFlags[#gymFlags + 1] = row + end + end + eq(#gymFlags, 2, 'ROM gym defeat script sets both Mind Badge and shared hide flag') + gymFlags[#gymFlags + 1] = { op = 'end' } + -- Execute the exact imported flag writes, without unrelated gym UI/specials. + imported.gym = gymFlags + imported['std:4'] = { { op = 'return' } } -- instant msgbox adapter for headless VM + scripts = imported + print('[info] canonical Emerald B2F scripts from ' .. root) +else + print('[info] ROM-free canonical flag/continuation fixture (set POKEPORT_EMERALD_CACHE for full B2F scripts)') +end +local bundle = { events = { [MAP] = { objects = objects } }, scripts = scripts, movements = movements } +local battles, removed, moves, result = 0, {}, {}, 'win' +local function legacySave() + return { version = 'emerald', map = MAP, x = 24, y = 19, facing = 'left', + flags = { [tostring(BADGE)] = true, [tostring(HIDE)] = true }, vars = {} } +end +local function adapter() + return Adapters.stub({ + startTrainerBattle = function(foe, done, opts) + battles = battles + 1 + eq(opts.trainerId, MATT, 'interaction starts Matt battle') + eq(foe.trainerId, MATT, 'Matt foe routes through trainer lookup') + done(result) + end, + lookupMovement = function(k) return movements[k] end, + applyMovement = function(lid, actions) + moves[#moves + 1] = { lid = lid, actions = actions } + end, + pollMovement = function() return true end, + removeObject = function(lid) removed[#removed + 1] = lid; Objects.removeObject(lid) end, + onFlagChanged = function(id, hidden) Objects.syncFlagVisibility(id, hidden) end, + }) +end +local function bind(store) + Space.store, Space.bundle, Space.mapId, Space.active = store, bundle, MAP, true + Space.vm = Vm.new({ version = 'emerald', store = store, scripts = scripts, adapters = adapter() }) + Field._session = store + Field._game = { data = {} } + Field.running = true + Field.unlock() + Collision._mapDef = nil +end +local function drain(vm) + for _ = 1, 2048 do if not vm:isRunning() then break end; vm:tick() end + check(not vm:isRunning(), 'VM completed bounded script drain') +end +local function enter(store, keepTemps) + bind(store) + Flags.onMapLoad(store, keepTemps) + Objects.reset() + Objects.loadMap(nil, MAP, mapDef) + Player.reset(24, 19, 'left') +end +local function drawable(lid) + for _, eo in ipairs(Objects.forDraw()) do if eo.localId == lid then return true end end + return false +end +local function accessible(label) + local matt = Objects.find(1) + check(matt and matt.visible and not matt.hidden and not matt.invisible, label .. ': Matt visible') + check(drawable(1), label .. ': Matt in draw list') + check(Objects.at(23, 19) == matt, label .. ': Matt selectable for field interaction') +end +local fresh = Flags.newStore() +enter(fresh) +accessible('normal pre-gym visit') +-- Source-local reproducer: the canonical gym writes have no escape prerequisite. +Space.vm:start('gym'); drain(Space.vm) +check(Flags.getFlag(fresh, nil, BADGE), 'gym awarded Mind Badge') +check(Flags.getFlag(fresh, nil, HIDE), 'gym set the shared hideout flag') +check(not Flags.getFlag(fresh, nil, ESCAPED), 'gym does not complete Matt submarine escape') +-- Capture the old, hidden template snapshot before repairing its flag store. +Objects.reset() +Objects.loadMap(nil, MAP, mapDef) +local legacySnapshot = Objects.snapshot() +check(Objects.find(1).hidden, 'unrepaired gym flag is the source of Matt hiding') +-- This is where the original implementation strands the player. +enter(fresh) +accessible('out-of-order gym completion') +check(not Flags.getFlag(fresh, nil, ESCAPED), 'recovery does not invent story completion') +check(Flags.getFlag(fresh, nil, BADGE), 'recovery preserves earned badge') +check(not Flags.isTrainerDefeated(fresh, nil, MATT), 'recovery does not invent Matt victory') +local serialized = Flags.serialize(fresh) +local restored = Flags.newStore() +Flags.loadInto(restored, serialized) +enter(restored, true) +accessible('save/continue') +local snap = Objects.snapshot() +enter(restored, true) +check(Objects.restoreSnapshot(snap), 'object snapshot restored on same map') +accessible('object snapshot restore') +check(Objects.restoreSnapshot(legacySnapshot), 'pre-repair hidden-template snapshot accepted') +accessible('legacy object snapshot restore') +-- Test the actual Lua save decoder as well as the script-side flag store. +local oldSave = legacySave() +oldSave.objectEvents = legacySnapshot +local decoded = Schema.fromSaveTable(oldSave) +check(not Flags.getFlag(decoded, nil, HIDE), 'Lua save decoder repairs legacy early-gym flags') +check(Flags.getFlag(decoded, nil, BADGE), 'Lua save decoder preserves Mind Badge') +check(not Flags.getFlag(decoded, nil, ESCAPED), 'Lua save decoder does not grant escape') +local luaRoundtrip = Schema.fromSaveTable(Schema.toSaveTable(decoded)) +check(not Flags.getFlag(luaRoundtrip, nil, HIDE), 'recovery survives Lua save export/reimport') +local sidecar = Flags.newStore() +Flags.loadInto(sidecar, legacySave()) +check(not Flags.getFlag(sidecar, nil, HIDE), 'legacy script-side flag store repairs on loadInto') +-- Exercise the real field interaction entry point, not a direct escape flag edit. +check(Field.interact(Field._game), 'A-button interaction accepts Matt') +drain(Space.vm) +eq(battles, 1, 'one Matt battle started') +check(Flags.isTrainerDefeated(restored, nil, MATT), 'win records Matt defeat') +check(Flags.getFlag(restored, nil, ESCAPED), 'win continuation completes submarine escape') +eq(removed[#removed], 4, 'continuation removes submarine localId4, not Matt') +if root and root ~= '' then + eq(#moves, 5, 'full imported escape continuation applies all five movements') + local submarineMoved = false + for _, move in ipairs(moves) do if move.lid == 4 then submarineMoved = true end end + check(submarineMoved, 'VAR_0x8009 movement resolves to submarine localId4') +end +enter(restored) +check(Flags.getFlag(restored, nil, HIDE), 'completed gym plus completed escape restores late-story hiding') +check(not drawable(1) and Objects.at(23, 19) == nil, 'post-story Matt remains hidden/unselectable') +-- Cover all four ordering states and idempotence without rewriting unrelated data. +for _, badge in ipairs({ false, true }) do + for _, escaped in ipairs({ false, true }) do + local store = Flags.newStore() + Flags.setFlag(store, nil, BADGE, badge) + Flags.setFlag(store, nil, ESCAPED, escaped) + Flags.setFlag(store, nil, HIDE, badge) + Flags.setFlag(store, nil, 0x430, true) + Flags.setVar(store, nil, 0x4048, 123) + enter(store) + local expectedHidden = badge and escaped + eq(Objects.find(1).hidden, expectedHidden, + 'visibility matches completed gym/escape: ' .. tostring(badge) .. '/' .. tostring(escaped)) + check(Flags.getFlag(store, nil, 0x430), 'unrelated item flag preserved') + eq(Flags.getVar(store, nil, 0x4048), 123, 'unrelated ash variable preserved') + enter(store) + eq(Objects.find(1).hidden, expectedHidden, 'repeated map entry is idempotent') + end +end +-- Losing must not resolve the pending story or hide Matt on re-entry. +local losing = Flags.newStore() +Flags.setFlag(losing, nil, BADGE, true) +Flags.setFlag(losing, nil, HIDE, true) +enter(losing) +result = 'lose' +check(Field.interact(Field._game), 'losing encounter can still start') +drain(Space.vm) +eq(battles, 2, 'second Matt battle invokes the losing callback') +check(not Flags.getFlag(losing, nil, ESCAPED), 'loss does not advance escape') +check(not Flags.isTrainerDefeated(losing, nil, MATT), 'loss does not record Matt victory') +enter(losing) +accessible('retry after loss') +-- Without the gym marker, do not overwrite explicit/custom hiding. +local explicit = Flags.newStore() +Flags.setFlag(explicit, nil, HIDE, true) +Flags.onMapLoad(explicit) +check(Flags.getFlag(explicit, nil, HIDE), 'no Mind Badge: unrelated/custom hiding is preserved') +-- Numeric, decimal-string and hexadecimal keys all occur in imported/old saves. +for _, key in ipairs({ HIDE, tostring(HIDE), string.format('0x%X', HIDE) }) do + local store = { flags = { [BADGE] = true, [key] = true }, vars = {} } + Flags.onMapLoad(store, true) + check(not Flags.getFlag(store, nil, HIDE), 'early-gym repair clears hide flag key ' .. tostring(key)) +end +for _, version in ipairs({ 'firered', 'leafgreen', 'ruby', 'sapphire' }) do + GameVersion.set(version) + local store = Flags.newStore() + Flags.loadInto(store, legacySave()) + check(Flags.getFlag(store, nil, HIDE), version .. ': same numeric flags are not subjected to Emerald recovery') +end +GameVersion.set('emerald') +local quick = assert(io.open('tests/quick.list', 'r')) +local list = quick:read('*a'); quick:close() +check(list:find('tests/emerald_matt_2748_test.lua', 1, true) ~= nil, 'regression registered in quick suite') +T.finish() diff --git a/tests/quick.list b/tests/quick.list index cd6d2023..ecbcfe27 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -1,4 +1,5 @@ tests/emerald_tent_save_reload_2638_test.lua +tests/emerald_matt_2748_test.lua tests/engine/advanced_palette_map.lua tests/engine/amnesia_rest_share_anim_bug1881.lua tests/engine/android_asymmetric_display.lua