From 62c902993d25cfe85336c17b70ef68458adfecd2 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:22:44 +0100 Subject: [PATCH 01/60] Platinum: Cynthia rides the Distortion World lifts with the player 1F's lift (progress 2) and B6F's (progress 7) carry Cynthia down as the same map object, renumbered for the next floor (ov9 ElevatorPlatform BeginMovement/ChangeMaps). Neither next floor lists her for that moment, so B1F's and B7F's first-entry scenes moved a missing local id 128 and played to an empty platform. Take the passenger at the ride's start and place her on the arrival floor at the world tile she rode on. --- docs/platinum-parity-progress.md | 21 +++++++++++ src/world/Gen4DistPlatforms.lua | 58 ++++++++++++++++++++++++++++++- tools/gen4_dist_surface_check.lua | 42 ++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/docs/platinum-parity-progress.md b/docs/platinum-parity-progress.md index eda68b8a3..48d8a0be0 100644 --- a/docs/platinum-parity-progress.md +++ b/docs/platinum-parity-progress.md @@ -2135,3 +2135,24 @@ it is the clock exclusion); nothing animating that is not on those lists, since the union only ever adds; no prop drawn twice, since twelve models have just changed pass; and a stutter check on a waterfall route or in Canalave, for the 14 extra chunks today and 41 after the flipbooks land. + +## Story playthrough pass (November 2026) + +The automated playthrough (`tools/gen4_playthrough/platinum.lua`, 78 beats from +the bedroom to the Hall of Fame) passed every beat on a USA Rev 1 cache before +this pass. The fixes below come from what the log showed while the beats still +passed — scenes that played to missing actors, no-op commands, stalls — and +from the beats added past the credits. Each is checked against pret's +pokeplatinum. + +- **Cynthia rides the Distortion World lifts.** 1F's lift (progress 2) and + B6F's (progress 7) carry Cynthia down with the player + (`DistWorldElevatorPlatform_BeginMovement` / `_ChangeMaps`): the same map + object, renumbered for the next floor (B1F 128; B7F 128 with script 6). The + next floor's object table has no Cynthia for that moment, so B1F's and B7F's + first-entry scenes moved a local id that was not there ("no object with + localId 128") and played to an empty platform. `Gen4DistPlatforms` now + takes the passenger at the start of the ride, lifts her with the platform, + and places her on the arrival floor at the world tile she rode on (B7F: she + then walks to exactly the tile B7F's own table gives her at progress 8). + Check: `tools/gen4_dist_surface_check.lua /data/generated` (53). diff --git a/src/world/Gen4DistPlatforms.lua b/src/world/Gen4DistPlatforms.lua index 9118579af..775208be0 100644 --- a/src/world/Gen4DistPlatforms.lua +++ b/src/world/Gen4DistPlatforms.lua @@ -182,6 +182,8 @@ function P.arrive(map, w) local dy = (first.deltaY or 0) / 4096 if dy == 0 then dy = (first.changeMapsY or 0) < 0 and -4 or 4 end ow.dwRide = { + -- Cynthia riding down with the player (BeginMovement's passenger) + passenger = t.elevatorDir == 1 and P.passengerFor(ow, def, m, w.def.destHeader) or nil, phase = "vibrate", t = 0, live = live, warp = w.def, stopY = (first.changeMapsY or 0) * 16, dy = dy, y = 0, flagToSet = last.flagToSet, flagToClear = last.flagToClear, @@ -231,21 +233,30 @@ local function tickRide(ow, def) live.dy = v poseAt(ow, (r.base or standY(ow)) + v) r.base = r.base or standY(ow) + if r.passenger then P.sprite(ow, r.passenger.fromId, { lift = v }) end elseif r.phase == "move" then r.y = r.y + r.dy local done = (r.dy < 0 and r.y <= r.stopY) or (r.dy > 0 and r.y >= r.stopY) if done then r.y = r.stopY end live.dy = r.y poseAt(ow, r.base + r.y) + if r.passenger then P.sprite(ow, r.passenger.fromId, { lift = r.y }) end if done then -- DistWorldElevatorPlatform_ChangeMaps if r.flagToSet then setFlag(r.flagToSet, true) end if r.flagToClear then setFlag(r.flagToClear, false) end + -- the passenger changes maps with the platform: off this floor, onto + -- the next one as its own local id (P.spawnCast places it there) + if r.passenger then + P.castDelete(ow, r.passenger.fromId) + if ow.dwSprites then ow.dwSprites[r.passenger.fromId] = nil end + ow.dwPassenger = r.passenger + end live.dy = nil ow.dwRide = nil ow.player.inputLocked = false ow.dwArrive = { from = r.arriveFrom, wx = r.wx, wz = r.wz, header = r.destHeader, t = 0, - dy = math.abs(r.dy) } + dy = math.abs(r.dy), passengerId = r.passenger and r.passenger.localId } local okW, err = pcall(ow.takeWarp, ow, r.warp) if not okW then print("[dwplatforms] lift warp: " .. tostring(err)) end end @@ -272,6 +283,10 @@ local function tickArrive(ow, def, m) if a.y > 0 then a.y = math.max(0, a.y - step) elseif a.y < 0 then a.y = math.min(0, a.y + step) end live.dy = a.y ~= 0 and a.y or nil poseAt(ow, a.base + a.y) + if a.passengerId then + if a.y ~= 0 then P.sprite(ow, a.passengerId, { lift = a.y }) + elseif ow.dwSprites then ow.dwSprites[a.passengerId] = nil end + end if a.y == 0 then ow.dwArrive = nil ow.player.inputLocked = false @@ -1097,6 +1112,47 @@ function P.spawnCast(ow, def, m) placeCast(ow, def, m, c) end end + -- the lift's passenger arriving with the player (ElevatorPlatform_ChangeMaps) + local pas = ow.dwPassenger + if pas and tonumber(pas.destHeader) == tonumber(def.header) then + ow.dwPassenger = nil + local c = {} + for k, v in pairs(pas.cast) do c[k] = v end + c.localId, c.x, c.z, c.y = pas.localId, pas.wx, pas.wz, nil + c.flagCond, c.flagCondVal = P.COND_MANUAL, 0 + if pas.script then c.script = pas.script end + placeCast(ow, def, m, c) + end +end + +-- THE LIFT'S PASSENGER. Two lifts carry Cynthia down with the player +-- (DistWorldElevatorPlatform_BeginMovement): 1F's while the progress is +-- JUMPED_ON_1F_ELEVATOR (2), her local id 129 there, and B6F's while it is +-- FINISHED_BOULDER_PUZZLE (7), local id 134. She is not in the next floor's +-- object table for that moment -- B1F's has only Mesprit, and B7F's first +-- Cynthia wants ENTERED_B7F (8), which the first-entry script itself sets -- +-- so ChangeMaps keeps the same map object, renumbered: B1F_CYNTHIA_ELEVATOR +-- (128), or B7F_CYNTHIA (128) with script 6. Without her both floors' +-- first-entry scripts moved a local id that was not there ("no object with +-- localId 128"): the scene played to an empty platform. Her tile stays the +-- one she stood on (the floors share world coordinates). +P.HEADER_1F, P.HEADER_B1F, P.HEADER_B6F = 573, 574, 580 +function P.passengerFor(ow, def, m, destHeader) + local h = tonumber(def and def.header) + local progress = getVar(P.VAR_PROGRESS) + local fromId + if h == P.HEADER_1F and progress == 2 then fromId = 129 + elseif h == P.HEADER_B6F and progress == 7 then fromId = 134 end + if not fromId then return nil end + for _, n in ipairs(ow.npcs or {}) do + local d = n.def + if d and d.gen4DistCast and d.localId == fromId and not n.hidden then + local dest = tonumber(destHeader) + return { fromId = fromId, localId = 128, cast = d.gen4DistCast, destHeader = dest, + script = dest ~= P.HEADER_B1F and 6 or nil, + wx = (n.cellX or d.x or 0) + (m.offsetX or 0), wz = (n.cellY or d.y or 0) + (m.offsetZ or 0) } + end + end end -- THE FIELD COMING BACK FROM A BATTLE REBUILDS THE OVERLAY'S OBJECTS. The diff --git a/tools/gen4_dist_surface_check.lua b/tools/gen4_dist_surface_check.lua index 4048ac49e..dbcc8124b 100644 --- a/tools/gen4_dist_surface_check.lua +++ b/tools/gen4_dist_surface_check.lua @@ -94,6 +94,48 @@ check(b7 and b7.entries and b7.entries[2] == 'M0388/S001F', "B7F's script 2 (the local cast = dw.cast[581] check(cast and #cast == 5 and cast[1].graphicsId == 138, "B7F's cast: Cynthia and Cyrus by progress") +-- THE LIFT'S PASSENGER (BeginMovement / ChangeMaps): Cynthia rides 1F's lift +-- down at progress 2 and B6F's at progress 7, renumbered for the next floor; +-- neither next floor has her in its own table for that moment +do + local game = { data = { gen4_distortion_world = dw }, save = { gen4Vars = { [0x4055] = 2 }, flags = {} } } + package.loaded['src.core.Game'] = game + local hadOW = package.loaded['src.world.OverworldController'] + package.loaded['src.world.OverworldController'] = hadOW or {} + local function castOf(h, id, val) + for _, c in ipairs(dw.cast[h]) do + if c.localId == id and (val == nil or c.flagCondVal == val) then return c end + end + end + local function standing(h, c) + local m = dw.maps[h] + local x, y = c.x - (m.offsetX or 0), c.z - (m.offsetZ or 0) + return { def = { localId = c.localId, gen4DistCast = c, x = x, y = y }, cellX = x, cellY = y } + end + local c129 = castOf(573, 129) + local ow = { npcs = { standing(573, c129) } } + local pas = P.passengerFor(ow, { header = 573 }, dw.maps[573], 574) + check(pas and pas.fromId == 129 and pas.localId == 128 and pas.script == nil, + '1F lift at progress 2 carries Cynthia (1F 129 -> B1F 128)') + local b1 = { header = 574, objects = {} } + ow.dwPassenger = pas + P.spawnCast(ow, b1, dw.maps[574]) + local got + for _, o in ipairs(b1.objects) do if o.localId == 128 then got = o end end + check(got and got.x == c129.x - dw.maps[574].offsetX and got.y == c129.z - dw.maps[574].offsetZ + and ow.dwPassenger == nil, "...and B1F's first-entry scene finds her, on the world tile she rode on") + game.save.gen4Vars[0x4055] = 3 + check(P.passengerFor(ow, { header = 573 }, dw.maps[573], 574) == nil, 'no passenger once the progress moved on') + game.save.gen4Vars[0x4055] = 7 + local c134 = castOf(580, 134, 7) + local ow6 = { npcs = { standing(580, c134) } } + local pas6 = P.passengerFor(ow6, { header = 580 }, dw.maps[580], 581) + check(pas6 and pas6.fromId == 134 and pas6.localId == 128 and pas6.script == 6, + "B6F lift at progress 7 carries her to B7F as B7F_CYNTHIA, script 6") + package.loaded['src.world.OverworldController'] = hadOW + package.loaded['src.core.Game'] = nil +end + -- the story's events: every command's arguments decoded local gr = dw.events[582] check(gr and gr[3].params[1].scriptId == 7 and gr[3].params[3].progress == 11, From cb8d40900bf1fcbfe57ce115e9a5e53b991d2110 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:25:00 +0100 Subject: [PATCH 02/60] gen4 checks: take cache path from GEN4_DATA/APPDATA instead of hard-coded G: drive; resolve repo files independent of cwd --- tools/gen4_audio_check.lua | 2 +- tools/gen4_event_party_check.lua | 5 +++-- tools/gen4_menu_parity_check.lua | 2 +- tools/gen4_run_animation_check.lua | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tools/gen4_audio_check.lua b/tools/gen4_audio_check.lua index 09f0c1a88..891d75c1a 100644 --- a/tools/gen4_audio_check.lua +++ b/tools/gen4_audio_check.lua @@ -1,7 +1,7 @@ package.path='./?.lua;'..package.path local R=assert(require('src.import.NdsRom').open('Pokemon - Platinum Version (USA) (Rev 1).nds')) local S=require('src.import.Gen4Sdat');local raw=R:read(S.PATH);local arc=assert(S.open(raw)) -local catalogue=require('src.import.Gen4Audio').catalogue(arc,assert(loadfile('G:/Gen2Recomped/platinum/data/generated/gen4_map_headers.lua'))()) +local catalogue=require('src.import.Gen4Audio').catalogue(arc,assert(loadfile(((os.getenv('GEN4_DATA') or (os.getenv('APPDATA') and os.getenv('APPDATA')..'/LOVE/Gen2Recomp/platinum/data/generated') or 'data/generated')..'/gen4_map_headers.lua')))()) local blocks,energy=0,0 local function queue() return {setVolume=function() end,getVolume=function() return 1 end,setFilter=function() end,stop=function() end,pause=function() end,play=function() end,isPlaying=function() return true end,getFreeBufferCount=function() return 0 end,queue=function() end} end love={filesystem={read=function() return raw end},audio={newQueueableSource=queue},sound={newSoundData=function() return {setSample=function(_,i,c,v) blocks=blocks+1;energy=energy+math.abs(v) end} end}} diff --git a/tools/gen4_event_party_check.lua b/tools/gen4_event_party_check.lua index 306d8e13d..cdc367b91 100644 --- a/tools/gen4_event_party_check.lua +++ b/tools/gen4_event_party_check.lua @@ -38,9 +38,10 @@ Origin.stamp(game,mon,'met','Different location',{year=2027,month=1,day=1}) assert(mon.metDate.year==2026 and mon.metLocation=='Route 209','receiving again cannot replace provenance') local old={};Origin.stamp({data={constants={gen=3}}},old,'met','Route 1',{year=2026,month=10,day=1}) assert(next(old)==nil,'Gen3 origin behavior must remain unchanged') -for _,file in ipairs({'src/script/Commands.lua','src/battle/BattleState.lua','src/pokemon/DayCare.lua','src/world/OverworldController.lua'}) do assert(loadfile(file),file) end +local function repoFile(p) local f=io.open(p,'rb') if f then f:close() return p end local root=((arg and arg[0]) or ''):gsub(string.char(92),'/'):gsub('[^/]*/[^/]*$','') return root..p end +for _,file in ipairs({'src/script/Commands.lua','src/battle/BattleState.lua','src/pokemon/DayCare.lua','src/world/OverworldController.lua'}) do assert(loadfile(repoFile(file)),file) end print('Gen4 origin checks passed: egg receipt, hatch history, retained dates and other-generation isolation') -local dexData=assert(loadfile('G:/Gen2Recomped/platinum/data/generated/gen4_dex.lua'))() +local dexData=assert(loadfile(((os.getenv('GEN4_DATA') or (os.getenv('APPDATA') and os.getenv('APPDATA')..'/LOVE/Gen2Recomp/platinum/data/generated') or 'data/generated')..'/gen4_dex.lua')))() ctx.game={data={gen4_dex=dexData}} ctx.save.pokedex={seen={},owned={}} for _,id in ipairs(dexData.orders.sinnoh) do ctx.save.pokedex.seen[id]=true end diff --git a/tools/gen4_menu_parity_check.lua b/tools/gen4_menu_parity_check.lua index a5047bd94..6efb66c56 100644 --- a/tools/gen4_menu_parity_check.lua +++ b/tools/gen4_menu_parity_check.lua @@ -46,7 +46,7 @@ summary:touchpressed('finger',10+180*2,20+40*2) assert(summary.swapMove==1) summary:touchpressed('finger',10+180*2,20+72*2) assert(not summary.swapMove and summary.mon.moves[1]==first) -game.data.text=assert(loadfile('G:/Gen2Recomped/platinum/data/generated/text.lua'))() +game.data.text=assert(loadfile(((os.getenv('GEN4_DATA') or (os.getenv('APPDATA') and os.getenv('APPDATA')..'/LOVE/Gen2Recomp/platinum/data/generated') or 'data/generated')..'/text.lua')))() local keys={'hp','attack','defense','speed','spAttack','spDefense'} for stat,key in ipairs(keys) do for remainder=0,4 do diff --git a/tools/gen4_run_animation_check.lua b/tools/gen4_run_animation_check.lua index a018bd23d..9d348db29 100644 --- a/tools/gen4_run_animation_check.lua +++ b/tools/gen4_run_animation_check.lua @@ -8,7 +8,7 @@ local expected={up={8,9,12},down={13,14,16},left={17,18,20},right={21,23,25}} local full=assert(F.cycles(sequence,64)) local Sprite=require('src.render.SpriteRenderer') for _,sex in ipairs({'090','091'}) do - local sprites=assert(loadfile('G:/Gen2Recomped/platinum/data/generated/sprites.lua'))() + local sprites=assert(loadfile(((os.getenv('GEN4_DATA') or (os.getenv('APPDATA') and os.getenv('APPDATA')..'/LOVE/Gen2Recomp/platinum/data/generated') or 'data/generated')..'/sprites.lua')))() local walk=sprites['SPRITE_G4_'..sex] assert(walk.frames==32 and walk.facingSource:find('walk_and_run',1,true)) for facing,frames in pairs(expected) do @@ -31,6 +31,7 @@ for clock=0,15 do end runner.running=false assert(runner:pose()==runner.sprite,'walking must restore the walking sheet') -assert(loadfile('src/world/Player.lua'));assert(loadfile('src/import/RomExtractorGen4.lua')) +local function repoFile(p) local f=io.open(p,'rb') if f then f:close() return p end local root=((arg and arg[0]) or ''):gsub(string.char(92),'/'):gsub('[^/]*/[^/]*$','') return root..p end +assert(loadfile(repoFile('src/world/Player.lua')));assert(loadfile(repoFile('src/import/RomExtractorGen4.lua'))) rom:close() print('Both Platinum protagonists: ROM run cycles, four facings, both stride poses and existing-cache compatibility passed') From a5aa86a531bd8067e5acc1b98c47622152370d5b Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:25:56 +0100 Subject: [PATCH 03/60] gen4_trainer_message_check: count handlers in modules the VM loads on demand (Pal Park, BW transfer rows were already handled) --- tools/gen4_trainer_message_check.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tools/gen4_trainer_message_check.lua b/tools/gen4_trainer_message_check.lua index 76cf84cb2..2bd274be3 100644 --- a/tools/gen4_trainer_message_check.lua +++ b/tools/gen4_trainer_message_check.lua @@ -223,6 +223,18 @@ do end end end +-- ...and the modules the VM itself loads on demand when it lowers a command +-- (Pal Park's Catching Show rows, the Black/White transfer lab rows): their +-- handlers register as a side effect of that require. +do + local seen = {} + for mod in vm:gmatch('require%("src%.script%.([%w_]+)"%)') do + if mod ~= "Gen4Commands" and not seen[mod] then + seen[mod] = true + cmds = cmds .. "\n" .. (slurp("src/script/" .. mod .. ".lua") or "") + end + end +end ok(#vm > 0, "src/script/Gen4ScriptVM.lua did not open") ok(#cmds > 0, "src/script/Gen4Commands.lua did not open") -- The six `scripts_battles.s` commands this pass lowered. `getmovementtype` From 0158cee861fb5a18f9847f4b97ded63bd658c2e6 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:26:38 +0100 Subject: [PATCH 04/60] Gate the Sweet Scent and Teleport roamer calls behind GameVersion.isGen4 like the other call sites; check now pins five --- src/world/OverworldController.lua | 4 ++-- tools/gen4_roamer_check.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 2eab2e4bf..680b9c12f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -10692,7 +10692,7 @@ function OverworldState:gen4SweetScent() -- WildEncounters_TrySweetScentEncounter / FieldSystem_PerformSweetScentEncounterCheck: -- a roamer on this map first (50%, Repel not asked), then the Bug Contest's -- or the Safari's own generator; Keen Eye / Intimidate cannot turn it away - if slots and not self:gen4Escorted() then + if slots and GameVersion.isGen4() and not self:gen4Escorted() then local Roamers=require('src.world.Gen4Roamers') local slot,it=Roamers.check(Game.data,Game.save,self.map.id,love.math.random) local one=slot and Roamers.encounterFor(slot,it) @@ -15747,7 +15747,7 @@ function OverworldState:warpToHealPoint(onDone, opts) -- HGSS: Teleport's special-spawn warp is mode 2 (FieldMoveTask_Create- -- TeleportEnvironment), whose landing runs sub_02067BA4 -> -- Save_RandomizeRoamersLocation; Escape Rope (0) and Dig (1) do not - if Encounter.isHgss() and not (opts and opts.whiteout) and Game.save then + if GameVersion.isGen4() and Encounter.isHgss() and not (opts and opts.whiteout) and Game.save then pcall(function() require("src.world.Gen4Roamers").randomizeAll(Game.data, Game.save) end) end -- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN diff --git a/tools/gen4_roamer_check.lua b/tools/gen4_roamer_check.lua index 632e3f834..961b806ba 100644 --- a/tools/gen4_roamer_check.lua +++ b/tools/gen4_roamer_check.lua @@ -448,7 +448,7 @@ ok(resolved == 29, "mapOf resolves all 29 through the cache's headers", io.write("\nGold, Crystal, Prism and Emerald, which share the same three hooks\n") --- ALL THREE GEN 4 ROAMER CALL SITES ARE IN SHARED CODE: the map-change step, +-- ALL FIVE GEN 4 ROAMER CALL SITES ARE IN SHARED CODE: the map-change step, -- the encounter roll and the after-battle pass all live in -- OverworldController.lua beside Johto's and Hoenn's. The cheap way for this -- work to break the older games is for one of them to run unguarded -- Gen 2's @@ -488,7 +488,7 @@ else or "no generation guard")) end end end - ok(sites == 3, "three Gen 4 roamer call sites, as written", sites, 3) + ok(sites == 5, "five Gen 4 roamer call sites, as written", sites, 5) ok(guarded == sites and sites > 0, "...and every one of them is behind GameVersion.isGen4()", guarded .. "/" .. sites, sites .. "/" .. sites) From 5e05032b1d5ac6749658b2872d04dc179b547e41 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:27:00 +0100 Subject: [PATCH 05/60] Escorted Gen 4 steps no longer fall through to Johto's roaming-beast check; tag battle check follows the current roamer guard --- src/world/OverworldController.lua | 2 +- tools/gen4_tag_battle_check.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 680b9c12f..aa7b0315d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -14456,7 +14456,7 @@ function OverworldState:onStepComplete() enc, roamer = Roam5.encounterFor(slot, it), "gen5" self.gen5RoamerSlot = slot end - elseif enc and not onWater then + elseif enc and not onWater and not GameVersion.isGen4() then local RoamMons = require("src.world.RoamMons") local name, info = RoamMons.check(Game.save, self.map.id, onWater, love.math.random) diff --git a/tools/gen4_tag_battle_check.lua b/tools/gen4_tag_battle_check.lua index 7087d4a6e..71cb2400c 100644 --- a/tools/gen4_tag_battle_check.lua +++ b/tools/gen4_tag_battle_check.lua @@ -61,7 +61,7 @@ check(src('src/script/Commands.lua'):find('if opts and opts.ally then battle:set -- WildEncounters_TryWildEncounter's withPartner arm: BATTLE_TYPE_AI_PARTNER, -- two wild Pokemon in the grass, no roamers, and the party healed after local ow = src('src/world/OverworldController.lua') -check(ow:find('elseif enc and GameVersion.isGen4() and not self:gen4Escorted() then', 1, true), +check(ow:find('elseif (enc or self.gen45RollPassed) and GameVersion.isGen4() and not self:gen4Escorted() then', 1, true), 'no roamer joins a partner battle (TryEncounterRoamer is asked only without one)') check(ow:find('function OverworldState:gen4EscortWild(battle, onWater, land)', 1, true) and ow:find('battle:makeWildDouble(second, ally)', 1, true), 'a step with a partner: the partner and two wild Pokemon') From d4937bdeff73e82a90ce1f08e145408bfb231305 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:28:27 +0100 Subject: [PATCH 06/60] poketch checks: stub apps keep the order the steps index (engine now sorts by cartridge id); stub gaps --- tools/gen4_poketch_android_touch_check.lua | 3 +++ tools/gen4_poketch_services_check.lua | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/gen4_poketch_android_touch_check.lua b/tools/gen4_poketch_android_touch_check.lua index d5a00d0b9..1488304ff 100644 --- a/tools/gen4_poketch_android_touch_check.lua +++ b/tools/gen4_poketch_android_touch_check.lua @@ -17,6 +17,9 @@ local events={} SS._setTransport({available=function() return true end,pollTouch=function() local out=events;events={};return out end}) local Game=require('src.core.Game') local Watch=require('src.ui.Gen4Poketch') +-- The stub lists its apps in the order these steps index them; the engine's own +-- cartridge re-ordering (Gen4Poketch.cartridgeOrder) is exercised elsewhere. +Watch.cartridgeOrder=function(apps) return apps end local apps={{name='Counter'},{name='Memo Pad'},{name='Coin Toss'}} local top={} local game=setmetatable({data={isGen4Cache=true,gen4_menus={poketch={apps=apps}}}, diff --git a/tools/gen4_poketch_services_check.lua b/tools/gen4_poketch_services_check.lua index 80090f31a..20084758e 100644 --- a/tools/gen4_poketch_services_check.lua +++ b/tools/gen4_poketch_services_check.lua @@ -6,13 +6,16 @@ package.loaded['src.render.Font'] = { draw = function() end, width = function(s) package.loaded['src.core.Logger'] = { warn = function() end, info = function() end } package.loaded['src.core.Strings'] = function(s) return s end package.loaded['src.core.GameVersion'] = { isGen2 = function() return false end, - isGen3 = function() return false end, get = function() return 'platinum' end, + isGen3 = function() return false end, isGen4 = function() return true end, isGen5 = function() return false end, get = function() return 'platinum' end, info = function() return { family = 'platinum', generation = 4 } end } package.loaded['src.ui.SecondScreen'] = { toLocal = function(_, x, y) if x >= 0 and x < 256 and y >= 0 and y < 192 then return x, y end end } love = { math = { random = math.random } } local P = require('src.ui.Gen4Poketch') +-- The stub lists its apps in the order these steps index them; the engine's own +-- cartridge re-ordering (Gen4Poketch.cartridgeOrder) is exercised elsewhere. +P.cartridgeOrder = function(apps) return apps end local game = { save = { inventory = {}, money = 5000 }, data = { gen4_menus = { poketch = { apps = { {name='Counter'}, {name='Calculator'}, {name='Memo Pad'}, {name='Stopwatch'}, {name='Kitchen Timer'} } } } }, stack = { pop = function() end } } @@ -161,6 +164,7 @@ check(game.save.poketch.matchup[1]==3 and game.save.poketch.matchup[2]==2, watch:touchpressed(1,112,144); watch:touchreleased(1) check(game.save.poketch.compatibility==0, 'Matchup check uses selected party pair') package.loaded['src.script.Commands'] = {meta={}} +package.loaded['src.script.Gen4Commands'] = nil require('src.script.Gen4Commands') local commands = package.loaded['src.script.Commands'] local yielded,resumed=0,0 From 7c6debca6a6c128b05465d9e165c668ffa773b4f Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:29:09 +0100 Subject: [PATCH 07/60] gen4_mining_spots_check: the cartridge keeps the saved spots on leaving the Underground (reload next trip, re-roll after a day); pin that instead of a clear --- tools/gen4_mining_spots_check.lua | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tools/gen4_mining_spots_check.lua b/tools/gen4_mining_spots_check.lua index 801870f74..ea50c2dc7 100644 --- a/tools/gen4_mining_spots_check.lua +++ b/tools/gen4_mining_spots_check.lua @@ -258,17 +258,31 @@ ok(offWall == 0, "%d placed spots are not on a diggable wall", offWall) ok(placed >= 60 * 6, "only %d spots across 60 descents; 6 is the minimum each", placed) -section("8. leaving the cave drops the set, and other cartridges never get one") +section("8. leaving the cave keeps the set until a day passes, and other cartridges never get one") do + local World = require("src.world.Gen4UgWorld") local G, ow = fakeGame(), (fakeOw()) UG.ensureSpots(G, ow, lcg(11)) - ok(#UG.spots(G) > 0, "nothing was placed to test the clear with") - -- the same call with an overworld map: the coordinates mean a different place - -- there, so carrying them would put dig walls in Jubilife + local first = #UG.spots(G) + ok(first > 0, "nothing was placed to test the clear with") + -- THE CARTRIDGE KEEPS THEM: Mining_LoadSavedMiningSpots reloads the saved + -- set on the next trip; only Underground_HandleDailyEvents (a day has + -- passed) re-rolls it. So walking out must not drop the spots... ow.map.def.label = "Jubilife City" ow.map.id = "C01" + ok(UG.ensureSpots(G, ow, lcg(11)) == false, "an overworld map placed dig walls") + ok(#UG.spots(G) == first, "leaving the Underground changed the saved spots (%d -> %d)", + first, #UG.spots(G)) + -- ...coming back the same day reloads them rather than re-rolling... + ow.map.def.label, ow.map.id = "Mystery Zone", "UG" + local saved = UG.spots(G) + UG.ensureSpots(G, ow, lcg(99)) + ok(UG.spots(G) == saved and #UG.spots(G) == first, "same-day return re-rolled the spots") + -- ...and a day passing marks the set for a fresh spawn on the next arrival + ow.map.id = "C01"; ow.map.def.label = "Jubilife City" UG.ensureSpots(G, ow, lcg(11)) - ok(#UG.spots(G) == 0, "leaving the Underground kept %d spots", #UG.spots(G)) + World.checkDay(G.save, os.time() + 2 * 86400) + ok(World.ug(G.save).shouldSpawn == true, "a day passing did not owe a new spawn") end do GameVersion.set("crystal") From abd3985a5e710c90d3a9a17a928a68049ad3c8fe Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:30:34 +0100 Subject: [PATCH 08/60] Gen 4 screens fill string buffers through gen4Markup instead of hand-written gsubs; machine check argues the two legitimate exceptions --- src/ui/Gen4SpearPillarCutscene.lua | 7 +++++-- src/ui/HgssMailbox.lua | 2 +- src/ui/HgssPhotoAlbum.lua | 2 +- tools/gen4_machine_check.lua | 8 ++++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ui/Gen4SpearPillarCutscene.lua b/src/ui/Gen4SpearPillarCutscene.lua index f15af774f..d6eeebe82 100644 --- a/src/ui/Gen4SpearPillarCutscene.lua +++ b/src/ui/Gen4SpearPillarCutscene.lua @@ -432,10 +432,13 @@ function S:messageText(id) local key = ("TEXT_B%04d_%05d"):format(S.TEXT_BANK, id) local s = ((self.data or {}).text or {})[key] or "" local name = self.playerName or "" - s = s:gsub("{STRVAR_1 %d+ 0 %d+}", function() return name end) + -- the name goes in string buffer 0 and gen4Markup does the filling + local buffers = {} + for k, v in pairs((self.game or {}).stringBuffers or {}) do buffers[k] = v end + buffers[1] = name local okC, Commands = pcall(require, "src.script.Commands") if okC and Commands and Commands.gen4Markup then - local ok, plain = pcall(Commands.gen4Markup, s, self.game) + local ok, plain = pcall(Commands.gen4Markup, s, { stringBuffers = buffers }) if ok and type(plain) == "string" then s = plain end end return s diff --git a/src/ui/HgssMailbox.lua b/src/ui/HgssMailbox.lua index 462d67b01..c836b6770 100644 --- a/src/ui/HgssMailbox.lua +++ b/src/ui/HgssMailbox.lua @@ -41,7 +41,7 @@ local function txt(game, n, fallback, author) local T = require("src.import.Gen4Text") local s = game.data and game.data.text and game.data.text[T.label(B.BANK, n)] if type(s) ~= "string" or s == "" then s = fallback or "" end - s = s:gsub("{STRVAR_1 %d+ 0 0}", function() return author or "" end) + s = require("src.script.Commands").gen4Markup(s, { stringBuffers = { author or "" } }) return (s:gsub("\011", "\n"):gsub("{[^}]*}", "")) end diff --git a/src/ui/HgssPhotoAlbum.lua b/src/ui/HgssPhotoAlbum.lua index a8ecb22fa..08efb1724 100644 --- a/src/ui/HgssPhotoAlbum.lua +++ b/src/ui/HgssPhotoAlbum.lua @@ -38,7 +38,7 @@ local function txt(game, n, fallback, buffers) local T = require("src.import.Gen4Text") local s = game.data and game.data.text and game.data.text[T.label(A.BANK, n)] if type(s) ~= "string" or s == "" then s = fallback or "" end - s = s:gsub("{STRVAR_1 %d+ (%d+) %d+}", function(slot) return (buffers or {})[tonumber(slot) + 1] or "" end) + s = require("src.script.Commands").gen4Markup(s, { stringBuffers = buffers or {} }) return (s:gsub("\011", "\n"):gsub("{[^}]*}", "")) end A.txt = txt diff --git a/tools/gen4_machine_check.lua b/tools/gen4_machine_check.lua index 8eaf5a88d..f6587b6d5 100644 --- a/tools/gen4_machine_check.lua +++ b/tools/gen4_machine_check.lua @@ -706,6 +706,14 @@ do -- player is typing INSIDE the screen, before there is a game to buffer -- them on, and it draws its own pages rather than handing text to a box. ["src/ui/Gen4RowanIntro.lua"] = 1, + -- The mail's sentence is not a text-box line: MailMsg_GetExpandedString + -- fills only the kind-28 easy-chat blanks, from the words buffered up to + -- the first EC_WORD_NULL, and an unfilled blank reads as the caller's own + -- `blank` glyph rather than being dropped as gen4Markup would. + ["src/pokemon/Gen4Mail.lua"] = 1, + -- The Unown report prints its own pages; its tokens use the comma spelling + -- `{STRVAR_1 kind, slot, 0}` that gen4Markup does not read. + ["src/ui/HgssUnownReport.lua"] = 1, } local found, scanned = {}, 0 local function sweep(dir) From ca375b6a7742e80c6e683580ad0c3b750930d7e6 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:31:04 +0100 Subject: [PATCH 09/60] gen4_player_pic_check: Platinum's player backs are trbgra classes 0/1 under battle/trainer_back (the art is right; the check named the old DP-outfit path) --- tools/gen4_player_pic_check.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/gen4_player_pic_check.lua b/tools/gen4_player_pic_check.lua index f1f1ef9d4..fcb379ddb 100644 --- a/tools/gen4_player_pic_check.lua +++ b/tools/gen4_player_pic_check.lua @@ -71,12 +71,12 @@ if okRun and type(field) == "table" then ok(withBack >= 2, "only %d form(s) carry a back pic; Sinnoh has two", withBack) ok(forms.boy and forms.boy.back - and forms.boy.back:find("trainer_backs", 1, true) ~= nil, - "the boy's back pic is %s, which is not a Gen 4 trainer back", + and forms.boy.back == "assets/generated/gen4/battle/trainer_back/000.png", + "the boy's back pic is %s, not Platinum's own player back (trbgra class 0, Lucas's winter coat)", tostring(forms.boy and forms.boy.back)) ok(forms.girl and forms.girl.back - and forms.girl.back:find("trainer_backs", 1, true) ~= nil, - "the girl's back pic is %s", tostring(forms.girl and forms.girl.back)) + and forms.girl.back == "assets/generated/gen4/battle/trainer_back/001.png", + "the girl's back pic is %s, not trbgra class 1 (Dawn's winter coat)", tostring(forms.girl and forms.girl.back)) -- ...AND AFTER THE LIFT. local marked = Sprites.markFormsTrueColor(field) From 47d68c8c0241485a81c0a06f8101151b0d908b0f Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:33:22 +0100 Subject: [PATCH 10/60] gen4_height_rule_check: judge Pastoria gym over its three water levels (the plate moves with the buttons), not the bare BDHC --- tools/gen4_height_rule_check.lua | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tools/gen4_height_rule_check.lua b/tools/gen4_height_rule_check.lua index 855c4e75b..a17103b8a 100644 --- a/tools/gen4_height_rule_check.lua +++ b/tools/gen4_height_rule_check.lua @@ -344,6 +344,43 @@ for _, dir in ipairs(caches) do if not unionB[L.map][key] then for k in pairs((walkLifted(b, L.x, L.y, true))) do unionB[L.map][k] = true end end + -- PASTORIA'S GYM MOVES ITS FLOOR: the water plate stands at 0, 32 or 64 + -- by the button last pressed (Gen4PastoriaGym.apply), so what the + -- player can reach is the union over the three levels, not the bare + -- BDHC the walk above sees. + if L.map == 'C06GYM0101' then + local DMF = require('src.world.Gen4DynamicMapFeatures') + local def = b.def + -- The buttons are pressed from anywhere reached, so each level's walk + -- also starts from every cell an earlier level reached, until nothing + -- new turns up. + local levelSeen = { [0] = {}, [32] = {}, [64] = {} } + local grew = true + while grew do + grew = false + for _, height in ipairs({ 0, 32, 64 }) do + def.gen4DynamicPlate = { x = DMF.PASTORIA_PLATE_X, y = DMF.PASTORIA_PLATE_Z, + w = DMF.PASTORIA_PLATE_W, h = DMF.PASTORIA_PLATE_H, height = height } + def.gen4PastoriaGates = DMF.PASTORIA_GATE_OPENS_AT + local seen = levelSeen[height] + local starts = { [L.x * 4096 + L.y] = true } + for k in pairs(unionB[L.map]) do starts[k] = true end + for k in pairs(starts) do + if not seen[k] then + local sx, sy = math.floor(k / 4096), k % 4096 + if b:isWalkableCell(sx, sy) then + for c in pairs((walkLifted(b, sx, sy, true))) do + seen[c] = true + if not unionB[L.map][c] then unionB[L.map][c] = true; grew = true end + end + end + seen[k] = true + end + end + end + end + def.gen4DynamicPlate, def.gen4PastoriaGates = nil, nil + end -- the OTHER warps each walk reaches (on the cell or beside it) local function warps(seen, m) local n = 0 From b1a884c0c95a293d59ac5db56bc6658b61af4e3f Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:33:55 +0100 Subject: [PATCH 11/60] gen4_game_corner_check: the slot machine is built (pt_slot_machine), no longer a named g4_noop gap --- tools/gen4_game_corner_check.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/gen4_game_corner_check.lua b/tools/gen4_game_corner_check.lua index c80847969..fc774104b 100644 --- a/tools/gen4_game_corner_check.lua +++ b/tools/gen4_game_corner_check.lua @@ -93,7 +93,8 @@ local ok, kind, lines = pcall(IE.use, { items = items, text = text, constants = check(ok and kind == 'failed' and lines and lines[1] == 'Your Coins: 1234', 'the Coin Case says "Your Coins: 1234", got ' .. tostring(ok and lines and lines[1] or kind)) --- every Game Corner script lowers, the slot machine as its named gap +-- every Game Corner script lowers, the slot machine as its real session row +-- (PtSlotMachine.install; it was a named g4_noop gap before the app was built) local VM = require('src.script.Gen4ScriptVM') local sdata = { constants = { gen = 4 }, maps = load('maps'), map_scripts = load('map_scripts'), text = text } local bad, slots = {}, 0 @@ -101,12 +102,13 @@ for label in pairs(sdata.map_scripts.scripts) do if label:sub(1, 5) == 'M0141' or label:sub(1, 5) == 'M0150' then for _, row in ipairs(VM.compile(sdata, label) or {}) do local n = row[1] - if n == 'g4_noop' and row[2] == 'the slot machine' then slots = slots + 1 + if n == 'pt_slot_machine' and C.pt_slot_machine then slots = slots + 1 + elseif n == 'g4_noop' and row[2] == 'the slot machine' then bad[#bad + 1] = label .. ':slot machine is still the named gap' elseif type(n) == 'string' and (not C[n] or n == 'g4_unimplemented') then bad[#bad + 1] = label .. ':' .. tostring(row[2]) end end end end check(#bad == 0, 'every Game Corner script lowers: ' .. table.concat(bad, ', ')) -check(slots > 0, 'and the slot machine is a named gap, not an unknown') +check(slots > 0, 'and the slot machine lowers to its session row (pt_slot_machine)') print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) if FAIL > 0 then os.exit(1) end From bf324ed57054faa9e6719e962d54d3860276b63b Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:34:39 +0100 Subject: [PATCH 12/60] gen4_declined_census_check: dress-up photos are answered by Gen4PhotoCommands.install; classify the shadowed source row --- tools/gen4_declined_census_check.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/gen4_declined_census_check.lua b/tools/gen4_declined_census_check.lua index 8c67fa760..8db5fce5b 100644 --- a/tools/gen4_declined_census_check.lua +++ b/tools/gen4_declined_census_check.lua @@ -163,6 +163,12 @@ local KNOWN = { -- HeartGold / SoulSilver's declined rows (Gen4HgssScriptAliases), each a -- picture, an app or a link feature argued where it is lowered ["chatot's recorded cry"] = true, ["day care's pokemon in the yard"] = true, + -- Platinum's dress-up photo frames: the Gen4ScriptVM source still carries the + -- original "no photos" row for `dressupphotohasdata`, but + -- Gen4PhotoCommands.install replaces it with g4_photo_has_data at load, so + -- the 11 sites are answered; the census reads the source text and sees the + -- shadowed fallback. + ["dress-up photos"] = true, ["dress-up portrait studio"] = true, ["hall of fame machine's balls"] = true, ["hideout doors' prop animations"] = true, ["leaving the overworld for an app"] = true, ["persian statues' alarm"] = true, From b5ee663ec3c9c13fccb5a08df562cd9b63ec0544 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:34:57 +0100 Subject: [PATCH 13/60] gen4_form_mechanics_check: stub GameVersion.isNds/isGen5 (ItemEffects now asks) --- tools/gen4_form_mechanics_check.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/gen4_form_mechanics_check.lua b/tools/gen4_form_mechanics_check.lua index e4121da74..323f9abc0 100644 --- a/tools/gen4_form_mechanics_check.lua +++ b/tools/gen4_form_mechanics_check.lua @@ -1,7 +1,7 @@ package.path='./?.lua;'..package.path love={math={random=math.random}} package.loaded['src.core.GameVersion']={isGen4=function() return true end, - isGen2=function() return false end,isGen3=function() return false end,isYellow=function() return false end,get=function() return 'platinum' end} + isGen2=function() return false end,isGen3=function() return false end,isYellow=function() return false end,isNds=function() return true end,isGen5=function() return false end,get=function() return 'platinum' end} local Forms=require('src.pokemon.Gen4Forms') local E=require('src.world.Encounter') local Pokemon=require('src.pokemon.Pokemon') From de757bbfb312705b9af58a44513f4c493a8cf7d4 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:35:16 +0100 Subject: [PATCH 14/60] gen4_party_check: let Screens.resolveId reach the real MenuStyle instead of a screen stub --- tools/gen4_party_check.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/gen4_party_check.lua b/tools/gen4_party_check.lua index 1bc21ccb2..8600bd92f 100644 --- a/tools/gen4_party_check.lua +++ b/tools/gen4_party_check.lua @@ -38,6 +38,7 @@ table.insert(package.searchers, 1, function(name) if name == "src.ui.Screens" then return nil end if name == "src.import.Gen4Menus" then return nil end if name == "src.import.Gen4Trades" then return nil end + if name == "src.ui.MenuStyle" then return nil end -- resolveId calls its real isMenuScreen if name:sub(1, 7) == "src.ui." then return function() return screenStub end end if name:sub(1, 4) ~= "src." then return nil end return function() return inert end From 8a85ce9db4549aec55ebed71da1090a01442b98e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:36:38 +0100 Subject: [PATCH 15/60] Platinum playthrough: beats past the credits to Heatran Twelve post-game beats: the credits to the title and a continue at home (Mom's post-game scene), the National Dex at Rowan's lab, the S.S. Spiral to the Fight Area, Barry and Buck there, Route 227, Stark Mountain (the grunts, Mars and Jupiter, Buck as the partner through room 2 with Rock Smash and Strength, Charon's arrest), the Battleground's Buck and Heatran. Trainer-defeated flags are read from save.gen4TrainerFlags, where the port keeps that bank. --- tools/gen4_playthrough/platinum.lua | 160 ++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 0e681e437..637c39435 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -1240,4 +1240,164 @@ B[#B + 1] = { name = "champion cynthia", expect = function(r) return r:flag(0xB4) and r:flag(0x964) end, why = "flags 0xB4 (Cynthia), 0x964 (game completed)", expectBudget = 12000, budget = 220000, passBusy = true } +-- --------------------------------------------------------------------------- +-- AFTER THE CREDITS. ClearGame ends in OS_ResetSystem: the title, and a +-- CONTINUE that walks in at the special location (the house 2F, (4,6) facing +-- up; clear_game.c). Here the credits run to the title on A (they cannot be +-- skipped the first time), and the continue is Game:restoreSave on the save +-- the Hall of Fame wrote, as the title's CONTINUE does with the file. +local function onTitle(r) + local top = r:top() + return top ~= nil and top.screenId == "TitleState" +end +local function continueGame(r) + local SaveData = require("src.core.SaveData") + r.game:restoreSave(SaveData.decode(SaveData.encode(r.game.save))) +end + +-- the house 1F's frame table (VAR_PLAYER_HOUSE_POSTGAME_STATE 0x410F == 1, +-- set by the Hall of Fame's script) is Mom's post-game scene: +-- 0x410F = 2 and FLAG_TALKED_TO_MOM_ABOUT_NATIONAL_DEX_PROGRESS 0x15D +B[#B + 1] = { name = "after the credits: home", + steps = { { "until", onTitle, 60000 }, { "do", continueGame }, + { "until", function(r) return r:map() == "T01R0202" and not r:busy() end, 6000 }, + { "warpto", "T01R0201" }, + { "until", varGe(0x410F, 2), 6000 } }, + expect = function(r) return r:var(0x410F) >= 2 and r:flag(0x15D) end, + why = "0x410F >= 2, flag 0x15D (Mom's post-game scene)", budget = 80000 } + +-- A CHEAT, said as one: every Sinnoh Pokédex species marked seen (the +-- encounters are not under test here), which is what Rowan asks for +local function seeSinnohDex(r) + local save = r.game.save + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + local orders = r.game.data.gen4_dex and r.game.data.gen4_dex.orders + for _, id in ipairs(orders and orders.sinnoh or {}) do + id = tonumber(id) + if id and id > 0 then save.pokedex.seen[id] = true end + end +end +-- a FLAG_DEFEATED_TRAINER_* (TRAINER_DEFEATED_FLAGS_START 0x550 + the trainer +-- id): the port keeps that bank as save.gen4TrainerFlags[id], which +-- Check/SetTrainerFlag and the trainers' sight read -- not save.flags +local function trainerFlag(flagId) + return function(r) + local t = (r.game.save or {}).gen4TrainerFlags or {} + return t[flagId - 0x550] == true + end +end +local function nationalDex(r) return ((r.game.save or {}).pokedex or {}).national == true end + +-- Rowan's lab (T02R0101, Rowan person 0 at (7,14) since the Distortion World): +-- the local dex complete, his talk script rates it, Oak arrives and the +-- Pokédex gets its National mode (GetSetNationalDexEnabled 1), the Poké Radar +B[#B + 1] = { name = "sandgem lab: the national dex", + steps = { { "do", seeSinnohDex }, { "teleport", "T02", 8, 12, "up" }, { "warpto", "T02R0101" }, + { "talk", 0 }, { "until", nationalDex, 30000 } }, + expect = nationalDex, why = "the National Dex", expectBudget = 8000 } + +-- Snowpoint's harbour (C09): the S.S. Spiral's sailor (person 8) sails to the +-- Battle Zone once the game is completed -- Cynthia sees the player off the +-- first time (FLAG_SAILED_TO_BATTLE_ZONE 0x157) -- PlayBoatCutscene to the +-- Fight Area (C11) +B[#B + 1] = { name = "snowpoint: the s.s. spiral", + steps = { { "teleport", "C09", 13, 53, "left" }, { "talk", 8 }, + { "until", function(r) return r:map() == "C11" end, 30000 } }, + expect = function(r) return r:flag(0x157) and r:map() == "C11" end, why = "flag 0x157, on C11", + expectBudget = 8000 } + +-- the Fight Area's frame table (VAR_FIGHT_AREA_STATE 0x4081 == 0): Barry, the +-- Tower Tycoon his father, Buck; the blockade lifts with the National Dex +-- (FLAG_HIDE_FIGHT_AREA_BLOCKADE 0x294); 0x4081 = 2 +B[#B + 1] = { name = "fight area: barry and buck", + steps = { { "until", varGe(0x4081, 2), 30000 } }, + expect = function(r) return r:var(0x4081) >= 2 and r:flag(0x294) end, why = "0x4081 >= 2, flag 0x294", + expectBudget = 8000 } + +-- ROUTE 227 (R227; scripts_route_227.s). Coord 1 ((2..3,50) while +-- VAR_ROUTE_227_WAKE_RIVAL_STATE 0x408B == 0): Crasher Wake and Barry, 0x408B = 1 +B[#B + 1] = { name = "route 227: wake and barry", + steps = { { "teleport", "R227", 3, 53, "up" }, { "coord", 1 }, + { "until", varGe(0x408B, 1), 20000 } }, + expect = varGe(0x408B, 1), why = "0x408B >= 1", expectBudget = 8000 } + +-- coord 2 ((22,21) while VAR_ROUTE_227_BUCK_STATE 0x4119 == 0): Buck asks the +-- player to patrol Stark Mountain (YES), 0x4119 = 1 +B[#B + 1] = { name = "route 227: buck", + steps = { { "teleport", "R227", 22, 24, "up" }, { "answers", { 1 } }, { "coord", 2 }, + { "until", varGe(0x4119, 1), 20000 } }, + expect = varGe(0x4119, 1), why = "0x4119 >= 1", expectBudget = 8000 } + +-- STARK MOUNTAIN (D16): coord 1 ((16,11) while VAR_STARK_MOUNTAIN_OUTSIDE_STATE +-- 0x40A0 == 0): two grunts run inside, 0x40A0 = 1 +B[#B + 1] = { name = "stark mountain: the grunts", + steps = { { "teleport", "D16", 16, 14, "up" }, { "coord", 1 }, + { "until", varGe(0x40A0, 1), 20000 } }, + expect = varGe(0x40A0, 1), why = "0x40A0 >= 1", expectBudget = 8000 } + +-- the first room's frame table (VAR_STARK_MOUNTAIN_ROOM_1_STATE 0x40AD == 0): +-- Mars, then Jupiter (FLAG_DEFEATED_TRAINER_COMMANDER_JUPITER_STARK_MOUNTAIN +-- 0x8EF), Charon leaves with them; 0x40AD = 1 +B[#B + 1] = { name = "stark mountain: mars and jupiter", budget = 60000, + steps = { { "do", fullTeam(94) }, { "warpto", "D16R0101" }, + { "until", varGe(0x40AD, 1), 50000 } }, + expect = function(r) return r:var(0x40AD) >= 1 and trainerFlag(0x8EF)(r) end, + why = "0x40AD >= 1, trainer flag 0x8EF (Jupiter)", + expectBudget = 8000 } + +-- the second room (D16R0102): coord 1 ((42,69) while +-- VAR_STARK_MOUNTAIN_ROOM_2_FOLLOWER_BUCK_STATE 0x4094 == 0) is Buck joining as +-- the partner (0x4094 = 1, FLAG_TALKED_TO_STARK_MOUNTAIN_ROOM_2_BUCK 0xDD); +-- coord 3 at the far door ((47,3), 0x4094 == 1) is his "here we are", 0x4094 = 2 +-- THE WAY UP, walked with Buck trailing (found by an offline search over the +-- room's collision): east round to row 15, Rock Smash the rock at (41,15) +-- from its east side, west and up the left corridor to row 3, down column 23 +-- to row 12, the Strength boulder at (29,12) pushed east along row 12 to +-- (48,12), and up column 47 to the far door +local starkPushes = {} +for bx = 30, 48 do starkPushes[#starkPushes + 1] = { "push", "right", bx, 12 } end +local starkRoom2 = { routeSteps(), { "do", hmMoves({ [3] = 70, [4] = 249 }) }, + { "teleport", "D16R0102", 42, 72, "up" }, { "coord", 1 }, + { "until", function(r) return r:var(0x4094) >= 1 and not r:busy() end, 20000 }, + { "walkto", 42, 15 }, { "smash", "left", 41, 15 }, { "walkto", 28, 12 } } +for _, st in ipairs(starkPushes) do starkRoom2[#starkRoom2 + 1] = st end +starkRoom2[#starkRoom2 + 1] = { "coord", 3 } +starkRoom2[#starkRoom2 + 1] = { "until", varGe(0x4094, 2), 20000 } +B[#B + 1] = { name = "stark mountain: buck follows", budget = 150000, + steps = starkRoom2, + expect = function(r) return r:var(0x4094) >= 2 and r:flag(0xDD) end, why = "0x4094 >= 2, flag 0xDD", + expectBudget = 8000 } + +-- the third room's frame table (VAR_STARK_MOUNTAIN_ROOM_3_STATE 0x409E == 0): +-- Charon takes the Magma Stone, Looker and the police arrest him +-- (FLAG_ARRESTED_CHARON_STARK_MOUNTAIN 0xD6), 0x409E = 1, the warp outside; +-- outside, the frame table (VAR_STARK_MOUNTAIN_OUTSIDE_STATE 0x40A0 == 2) is +-- Looker and Buck's goodbye, 0x40A0 = 3 +B[#B + 1] = { name = "stark mountain: charon", + steps = { { "warpto", "D16R0103" }, { "until", varGe(0x40A0, 3), 40000 } }, + expect = function(r) return r:flag(0xD6) and r:var(0x409E) >= 1 and r:var(0x40A0) >= 3 end, + why = "flag 0xD6, 0x409E >= 1, 0x40A0 >= 3", expectBudget = 8000 } + +-- the Survival Area (T06): Buck (person 8, back since Charon's arrest) stands +-- at the Battleground's door and goes in when talked to (SurvivalArea_Buck); +-- inside (T06R0101) he is person 1 (FLAG_HIDE_BATTLEGROUND_BUCK cleared by the +-- arrest too), and talking to him is FLAG_TALKED_TO_BATTLEGROUND_BUCK 0x125, +-- which wakes Heatran +B[#B + 1] = { name = "battleground: buck", + steps = { { "teleport", "T06", 12, 21, "up" }, { "talk", 8 }, + { "until", function(r) return not r:busy() and r:personAt(8):find("shown") == nil end, 8000 }, + { "warpto", "T06R0101" }, { "talk", 1 }, + { "until", flag(0x125), 20000 } }, + expect = flag(0x125), why = "flag 0x125", expectBudget = 8000 } + +-- back in the third room (0x409E == 1, the game completed, the National Dex, +-- Buck talked to): Heatran (person 1, StartLegendaryBattle SPECIES_HEATRAN 50); +-- FLAG_BATTLED_STARK_MOUNTAIN_ROOM_3_HEATRAN 0xD7, 0x409E = 2 +B[#B + 1] = { name = "stark mountain: heatran", budget = 60000, + steps = { { "do", fullTeam(57) }, { "teleport", "D16R0103", 7, 15, "up" }, { "talk", 1 }, + { "until", function(r) return r:flag(0xD7) and r:var(0x409E) >= 2 and not r:busy() end, 50000 } }, + expect = function(r) return r:flag(0xD7) and r:var(0x409E) >= 2 end, why = "flag 0xD7, 0x409E >= 2", + expectBudget = 8000 } + return B \ No newline at end of file From 0dd437c870a8e7d2370a0005390c5bf158e07293 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:36:46 +0100 Subject: [PATCH 16/60] tests: title_zone_seams reads Renderer with CRLF checkouts; soft_reset_bug563 doubles follow Game:step/_step split, ensureStack and per-button overlay images --- tests/engine/soft_reset_bug563.lua | 5 ++++- tests/engine/title_zone_seams.lua | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..10b66cc7b 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -111,6 +111,8 @@ local function fakeGame() updates = 0, update = function(self) self.updates = self.updates + 1 end, } + game._step = Game._step -- Game:step is a profiling wrapper around it + game.ensureStack = Game.ensureStack function game:returnToTitle() self.returned = self.returned + 1 end return game end @@ -140,7 +142,8 @@ eq(bare.returned, 0, "an input with no chord bookkeeping never resets") Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { stub = true, a = true, b = true, start = true, select = true, + dpad = true, x = true, y = true } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/title_zone_seams.lua b/tests/engine/title_zone_seams.lua index da454142a..50f682194 100644 --- a/tests/engine/title_zone_seams.lua +++ b/tests/engine/title_zone_seams.lua @@ -21,6 +21,7 @@ do check(f ~= nil, "Renderer source is readable") local src = f and f:read("*a") or "" if f then f:close() end + src = src:gsub(string.char(13) .. string.char(10), string.char(10)) -- a Windows checkout has CRLF local body = src:match("\nlocal function scissorClamped.-\nend\n") check(body ~= nil, "scissorClamped is still a single local function") local fakeLove = { graphics = { setScissor = function(x, y, w, h) From 8f0e564d4cd6fa5b4fe1ae593d4dc7e947883fbd Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:37:00 +0100 Subject: [PATCH 17/60] Xbox controller support for Gen 4: lower-screen virtual cursor, stick 8-to-4 hysteresis, per-pad input sources, hotplug --- docs/controller.md | 61 ++++++++ main.lua | 6 + src/core/Game.lua | 30 +++- src/core/HotActions.lua | 11 +- src/core/Input.lua | 86 ++++++++++-- src/core/PadPointer.lua | 248 +++++++++++++++++++++++++++++++++ src/ui/BindingsMenu.lua | 2 + src/ui/SecondScreen.lua | 37 ++++- tools/gen4_xbox_pad_check.lua | 253 ++++++++++++++++++++++++++++++++++ 9 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 docs/controller.md create mode 100644 src/core/PadPointer.lua create mode 100644 tools/gen4_xbox_pad_check.lua diff --git a/docs/controller.md b/docs/controller.md new file mode 100644 index 000000000..2ec074b71 --- /dev/null +++ b/docs/controller.md @@ -0,0 +1,61 @@ +# Xbox controller (Gen 4 and the launcher) + +SDL game-controller mapping through `love.gamepad*` (an "Xbox One For Windows" +pad reports as a gamepad; the triggers arrive as axes and are edge-detected at +0.5 / 0.3 in `Input.AXIS_BUTTONS`). Raw `joystick*` events are used only for +sticks SDL has no mapping for. + +| Xbox | Action | Gen 4 meaning | +|---|---|---| +| A | A | confirm / talk / tap (cursor mode) | +| B | B | cancel; hold to run in the field | +| Y (north) | X | field start menu (DS X) | +| X (west) | Y | registered key item (DS Y) | +| Start | Start | start menu | +| Back/View | Select | Select (reorder moves / bag items, Pokétch clear) | +| LB | L | raises the Pokétch from the field (L or B lowers it) | +| RB | R | next Pokétch app; camera tilt in the field | +| D-pad / left stick | directions | move, menus (held = repeat) | +| RT | TOUCH | toggle the lower-screen cursor (below) | +| LT | CHAT | online chat only | +| L3 / R3 | zoom out / in | field zoom | + +All rows are rebindable in the CONTROLS screen (pad capture works for buttons, +triggers and right-stick directions; the TOUCH row is new). + +## Left stick + +Press at 0.5, release under 0.3. Eight-way input maps to four directions with +a 1.3x diagonal bias: a held direction keeps its axis until the other axis is +clearly stronger, so a 45-degree wobble does not flip between two directions. +With two controllers the one deflected furthest drives the stick; each pad has +its own button sources, so two pads holding a button are one press and the +button stays held until both release. + +## Lower-screen cursor (the DS touch screen) + +RT toggles a crosshair in the bottom screen's 256x192 space (dual-screen games +only). While it is on: left stick and D-pad move it (the stick is quadratic for +fine control), A presses and drags (hold A and move), and every other button +keeps its normal job (B still backs out). It maps through +`SecondScreen.fromLocal`, the inverse of what every screen's touch handler +already does, so it works in swap, inset, vertical/horizontal and `display` +modes and drives every pointer-only screen: Pokétch apps (drawing pad, +dowsing, calendar, calculator, roulette, move tester, party apps), the battle +bag/party touch screens, Underground, contest and photo dress-up, Poffin +cooking, mining, naming-screen touch keys. It switches itself off whenever the +top screen changes (so the stick can never stay stolen), and refuses to start +over the field when no lower screen is showing. + +Pokétch without the cursor: LB opens it, D-pad left/right or RB change app, A +presses the app's main button, B/L/Start close. + +## Hotplug + +`love.joystickadded` is forwarded (the touch overlay steps aside); removal +resets input and lifts the virtual finger. Controllers added or reconnected +mid-game send ordinary gamepad events. + +## Tests + +`python tools/run_lua_check.py tools/gen4_xbox_pad_check.lua` diff --git a/main.lua b/main.lua index 4b21efad4..d9a9491dc 100644 --- a/main.lua +++ b/main.lua @@ -884,6 +884,12 @@ function love.joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction) end +function love.joystickadded(joystick) + if editorMode or TouchEditor or Importer then return end + if not Game then return end + if Game.joystickadded then Game:joystickadded(joystick) end +end + function love.joystickremoved(joystick) if editorMode or TouchEditor then return end if Importer then return end diff --git a/src/core/Game.lua b/src/core/Game.lua index bd810ac8d..165747f68 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -624,6 +624,10 @@ function Game:update(dt) -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. self:updateCameraStick(dt) + do + local PP = package.loaded["src.core.PadPointer"] + if PP and PP.active then PP.update(self, dt) end + end local speed = self:logicSpeed() FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) do @@ -969,7 +973,12 @@ function Game:draw() -- claims it, a Gen 4 overworld still needs Platinum's always-present Poketch. self.secondScreenDrawnThisFrame = false self.secondScreenPointerOwner = nil + self.padPointerDrawn = false self:_draw() + do + local PP = package.loaded["src.core.PadPointer"] + if PP and PP.active then pcall(PP.drawFallback, self) end + end -- Single-screen generations use the physical Android lower panel as a live -- save companion without changing the game's own screen mode. @@ -1611,12 +1620,17 @@ function Game:gamepadpressed(joystick, button) top:onGamepadPressed(button) return end + -- the lower-screen cursor (TOUCH action / virtual finger) sees it first + if require("src.core.PadPointer").gamepadpressed(self, joystick, button) then + return + end Input:gamepadpressed(joystick, button) end function Game:gamepadreleased(joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) + require("src.core.PadPointer").gamepadreleased(self, joystick, button) local top = self.stack and self.stack:top() if top and top.onGamepadReleased then top:onGamepadReleased(button) end end @@ -1628,19 +1642,23 @@ function Game:gamepadaxis(joystick, axis, value) -- AXIS_BUTTONS): a press goes to BindingsMenu's capture when one is armed, -- the same routing as a real pad button local top = self.stack and self.stack:top() + local PadPointer = require("src.core.PadPointer") for _, t in ipairs(Input:axisTransitions(axis, value)) do local name, down = t[1], t[2] if down then if top and top.onGamepadPressed then top:onGamepadPressed(name) - else + elseif not PadPointer.gamepadpressed(self, joystick, name) then Input:padButton(name, true) end else Input:padButton(name, false) + PadPointer.gamepadreleased(self, joystick, name) if top and top.onGamepadReleased then top:onGamepadReleased(name) end end end + -- the cursor owns the left stick while it is up + if PadPointer.gamepadaxis(self, joystick, axis, value) then return end Input:gamepadaxis(joystick, axis, value) end @@ -1772,9 +1790,19 @@ end -- flags it owned. function Game:joystickremoved(joystick) Input:reset() + require("src.core.PadPointer").padRemoved(self) TouchControls:joystickremoved() end +-- A controller plugged in (or back in) mid-game. LOVE has already opened it +-- and will deliver its gamepad* events; all that is left is to let the touch +-- overlay step aside (Game:joystickremoved already cleared the old pad's state). SDL mappings were registered at boot, which covers +-- every pad it knows by GUID, Xbox included. +function Game:joystickadded(joystick) + if isAccelerometer(joystick) then return end + TouchControls:noteGamepad() +end + -- A POINTER GOES TO THE SCREEN FIRST, AND TO THE PAD ONLY IF NOBODY WANTED IT. -- -- Every pointer event in this engine went straight to `TouchControls` and diff --git a/src/core/HotActions.lua b/src/core/HotActions.lua index 72a65b6f7..f4b4d4c74 100644 --- a/src/core/HotActions.lua +++ b/src/core/HotActions.lua @@ -25,15 +25,20 @@ -- Both clicks together reset the zoom (HotActions.resetZoom); -- the OPTIONS ZOOM row resets the flat survey zoom too. -- +-- TOUCH pad RT (the right trigger, an axis edge like LT): the lower- +-- screen cursor of src/core/PadPointer.lua, handled at event time +-- in Game:gamepadpressed / gamepadaxis rather than per step, so +-- it works over every menu. Dual-screen games only. +-- -- Defaults are layered by Input:applyBindings UNDER the player's overlay, so -- a rebind of any other row onto LT / L3 / R3 wins that button. local HotActions = {} -HotActions.ACTIONS = { "chat", "zoomin", "zoomout" } -HotActions.IS_ACTION = { chat = true, zoomin = true, zoomout = true } +HotActions.ACTIONS = { "chat", "zoomin", "zoomout", "touch" } +HotActions.IS_ACTION = { chat = true, zoomin = true, zoomout = true, touch = true } HotActions.KEY_DEFAULTS = { t = "chat" } -HotActions.PAD_DEFAULTS = { triggerleft = "chat", rightstick = "zoomin", leftstick = "zoomout" } +HotActions.PAD_DEFAULTS = { triggerleft = "chat", rightstick = "zoomin", leftstick = "zoomout", triggerright = "touch" } -- where a chat would go right now: (client, "world"|"room") or nil function HotActions.chatTarget(game) diff --git a/src/core/Input.lua b/src/core/Input.lua index 893b2393b..ad74d7a36 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -158,6 +158,7 @@ function Input:reset() self.pressed = {} self.sources = {} self.stickAxis = { x = 0, y = 0 } + self.stickAxes = {} self.stickDir = nil self.hatDirs = {} end @@ -201,6 +202,7 @@ end function Input:keypressed(key) local btn = self.keyBindings[key] if btn then + self.lastDevice = "key" press(self, btn, "key:" .. key) end end @@ -250,17 +252,55 @@ function Input:overlayReleased(btn) release(self, btn, "touch:" .. btn) end +-- WHICH PHYSICAL PAD said it. Two controllers holding the same button used +-- to share one source name, so the first to let go released a button the +-- second still held. An identified joystick gets its own source; a stub or +-- raw event with no joystick keeps the plain name. +local function padKey(joystick) + if joystick and type(joystick) ~= "string" and joystick.getID then + local ok, id = pcall(joystick.getID, joystick) + if ok and id ~= nil then return ":" .. tostring(id) end + end + return "" +end +Input._padKey = padKey + function Input:gamepadpressed(joystick, button) local btn = self.padBindings[button] if btn then - press(self, btn, "pad:" .. button) + self.lastDevice = "pad" + press(self, btn, "pad:" .. button .. padKey(joystick)) end end function Input:gamepadreleased(joystick, button) local btn = self.padBindings[button] if btn then - release(self, btn, "pad:" .. button) + release(self, btn, "pad:" .. button .. padKey(joystick)) + end +end + +-- Drop every held direction a pad owns (D-pad and stick). Used when the +-- lower-screen cursor takes the directions over, so a walk that was in +-- progress does not keep going under the cursor. +function Input:releasePadDirections() + for _, name in ipairs({ "dpup", "dpdown", "dpleft", "dpright" }) do + local btn = self.padBindings[name] + local sources = btn and self.sources[btn] + if sources then + for src in pairs(sources) do + if src:sub(1, #name + 4) == "pad:" .. name + and (#src == #name + 4 or src:sub(#name + 5, #name + 5) == ":") then + release(self, btn, src) + end + end + end + end + self.stickAxes = {} + self.stickAxis.x, self.stickAxis.y = 0, 0 + if self.stickDir then + release(self, self.stickDir, "stick") + self.stickDir = nil end end @@ -340,22 +380,43 @@ function Input:padButton(name, down) if down then press(self, btn, "pad:" .. name) else release(self, btn, "pad:" .. name) end end --- left stick treated as a continuous held direction, same 4-way rule as --- the touch swipe d-pad: whichever axis has the larger magnitude wins. +-- left stick treated as a continuous held direction. Eight-way analog input +-- becomes the four directions the cartridge knows, with two guards against +-- diagonal jitter: a direction already held keeps its axis until the OTHER +-- axis is clearly stronger (DIAGONAL_BIAS), and between STICK_OFF and +-- STICK_ON nothing changes (hysteresis). With several pads attached the one +-- deflected furthest wins, so a resting second pad cannot cancel the first. +local DIAGONAL_BIAS = 1.3 +Input.DIAGONAL_BIAS = DIAGONAL_BIAS + function Input:gamepadaxis(joystick, axis, value) - if axis == "leftx" then - self.stickAxis.x = value - elseif axis == "lefty" then - self.stickAxis.y = value - else - return + if axis ~= "leftx" and axis ~= "lefty" then return end + self.stickAxes = self.stickAxes or {} + local key = padKey(joystick) + local st = self.stickAxes[key] + if not st then st = { x = 0, y = 0 }; self.stickAxes[key] = st end + if axis == "leftx" then st.x = value else st.y = value end + + local bx, by, bm = 0, 0, -1 + for _, p in pairs(self.stickAxes) do + local m = math.max(math.abs(p.x), math.abs(p.y)) + if m > bm then bx, by, bm = p.x, p.y, m end end + self.stickAxis.x, self.stickAxis.y = bx, by - local x, y = self.stickAxis.x, self.stickAxis.y + local x, y = bx, by local ax, ay = math.abs(x), math.abs(y) local newDir = self.stickDir if ax > STICK_ON or ay > STICK_ON then - if ax >= ay then + local horizontal + if self.stickDir == "left" or self.stickDir == "right" then + horizontal = not (ay > ax * DIAGONAL_BIAS) + elseif self.stickDir == "up" or self.stickDir == "down" then + horizontal = ax > ay * DIAGONAL_BIAS + else + horizontal = ax >= ay + end + if horizontal then newDir = x > 0 and "right" or "left" else newDir = y > 0 and "down" or "up" @@ -369,6 +430,7 @@ function Input:gamepadaxis(joystick, axis, value) release(self, self.stickDir, "stick") end if newDir then + self.lastDevice = "pad" press(self, newDir, "stick") end self.stickDir = newDir diff --git a/src/core/PadPointer.lua b/src/core/PadPointer.lua new file mode 100644 index 000000000..4c821b315 --- /dev/null +++ b/src/core/PadPointer.lua @@ -0,0 +1,248 @@ +-- THE DS LOWER TOUCH SCREEN, FOR A PLAYER WITH ONLY A CONTROLLER. +-- +-- Everything the DS asks you to touch -- the Pokétch's drawing pad and +-- dowsing machine, the battle bag, the Underground, contest dress-up, the +-- touch keyboard -- is written against pointer events (touchpressed / +-- touchmoved / touchreleased in the screen's own state). Rather than give +-- every one of those screens a second control scheme, this module is a +-- virtual finger: a cursor in the bottom screen's 256x192 space, moved by +-- the sticks and the D-pad, with A as the fingertip. It speaks to the game +-- through the same Game:touchpressed path a mouse click uses (mapped through +-- SecondScreen.fromLocal, the inverse of the mapping every screen already +-- applies), so no screen learns anything new and keyboard / touch / mouse +-- behaviour is untouched. +-- +-- TOUCH action (default: right trigger, rebindable in CONTROLS) toggles it. +-- While on: left stick / D-pad move the cursor (they no longer walk or +-- move menu cursors), A presses and drags (hold A, move), and +-- every other button -- B, X, Y, START, SELECT, L, R -- keeps +-- doing its normal job, so B still backs out of a screen. +-- It switches itself off when the top screen changes, so a forgotten +-- cursor can never leave the player unable to walk. +-- +-- Dual-screen games only (Gen 4 / HGSS / Gen 5); inert elsewhere. + +local PadPointer = {} + +PadPointer.active = false +PadPointer.x, PadPointer.y = 128, 96 +PadPointer.ID = "padcursor" +PadPointer.STICK_SPEED = 240 -- 256x192 px/s at full deflection +PadPointer.DPAD_SPEED = 110 +PadPointer.DEADZONE = 0.22 + +local W, H = 256, 192 + +local axes = { leftx = 0, lefty = 0 } +local dpad = {} -- dpup/dpdown/dpleft/dpright -> true +local touchButton = nil -- the pad button currently acting as A +local touching = false +local activeTop = nil + +local DPAD = { dpup = true, dpdown = true, dpleft = true, dpright = true } + +local function input(game) return game and game.input or require("src.core.Input") end + +local function SS() return require("src.ui.SecondScreen") end + +local function topOf(game) + local st = game and game.stack + return st and st.top and st:top() or nil +end + +function PadPointer.available(game) + local ok, yes = pcall(function() return SS().available(game) end) + return ok and yes and true or false +end + +-- Send one pointer event for the virtual finger. +local function dispatch(game, method) + local ss = SS() + local ok, took + if ss.mode(game) == "display" then + return ss.injectTouch(game, method, PadPointer.ID, PadPointer.x, PadPointer.y, true) + end + local px, py = ss.fromLocal(game, PadPointer.x, PadPointer.y) + if not px then return false end + ok, took = pcall(game[method], game, PadPointer.ID, px, py) + return ok +end + +local function liftFinger(game) + if touching then + touching = false + dispatch(game, "touchreleased") + end + touchButton = nil +end + +function PadPointer.setActive(game, on) + on = on and true or false + if on == PadPointer.active then return on end + if on then + if not PadPointer.available(game) then return false end + -- With the field on top and no lower screen showing (swap mode) there is + -- nothing to point at, and taking the stick off the player for it would + -- only strand them. + local top = topOf(game) + if top ~= nil and top == game.overworld and not game.secondScreenPointerOwner then + return false + end + PadPointer.active = true + activeTop = top + axes.leftx, axes.lefty = 0, 0 + dpad = {} + -- whatever the pad was holding when the cursor took over stops now + local inp = input(game) + if inp.releasePadDirections then inp:releasePadDirections() end + return true + end + liftFinger(game) + PadPointer.active = false + activeTop = nil + dpad = {} + -- hand a still-held stick back to the game + local inp = input(game) + local lx, ly = axes.leftx, axes.lefty + axes.leftx, axes.lefty = 0, 0 + if inp.gamepadaxis then + if lx ~= 0 then inp:gamepadaxis(nil, "leftx", lx) end + if ly ~= 0 then inp:gamepadaxis(nil, "lefty", ly) end + end + return false +end + +function PadPointer.toggle(game) + return PadPointer.setActive(game, not PadPointer.active) +end + +-- A pad button (or trigger / right-stick pseudo-button) went down. True when +-- the cursor consumed it and the game must not see it. +function PadPointer.gamepadpressed(game, joystick, button) + local inp = input(game) + local action = inp.padBindings and inp.padBindings[button] + if action == "touch" then + PadPointer.toggle(game) + return true + end + if not PadPointer.active then return false end + if DPAD[button] then + dpad[button] = true + return true + end + if action == "a" and not touching then + touchButton = button + touching = true + dispatch(game, "touchpressed") + return true + end + return false +end + +function PadPointer.gamepadreleased(game, joystick, button) + local inp = input(game) + local action = inp.padBindings and inp.padBindings[button] + if action == "touch" then return true end + if DPAD[button] then dpad[button] = nil end + if touching and button == touchButton then + touching = false + touchButton = nil + dispatch(game, "touchreleased") + return true + end + return false +end + +-- True when the cursor owns this axis (the game must not see it). +function PadPointer.gamepadaxis(game, joystick, axis, value) + if axis ~= "leftx" and axis ~= "lefty" then return false end + axes[axis] = value + return PadPointer.active +end + +local function radial(v) + local a = math.abs(v) + local dz = PadPointer.DEADZONE + if a <= dz then return 0 end + local m = (a - dz) / (1 - dz) + return (v < 0 and -1 or 1) * m * m +end + +function PadPointer.update(game, dt) + if not PadPointer.active then return end + local top = topOf(game) + if top ~= activeTop or not PadPointer.available(game) then + PadPointer.setActive(game, false) + return + end + dt = dt or 0 + local dx = radial(axes.leftx) * PadPointer.STICK_SPEED + local dy = radial(axes.lefty) * PadPointer.STICK_SPEED + if dpad.dpleft then dx = dx - PadPointer.DPAD_SPEED end + if dpad.dpright then dx = dx + PadPointer.DPAD_SPEED end + if dpad.dpup then dy = dy - PadPointer.DPAD_SPEED end + if dpad.dpdown then dy = dy + PadPointer.DPAD_SPEED end + if dx == 0 and dy == 0 then return end + local nx = math.max(0, math.min(W - 1, PadPointer.x + dx * dt)) + local ny = math.max(0, math.min(H - 1, PadPointer.y + dy * dt)) + if nx == PadPointer.x and ny == PadPointer.y then return end + PadPointer.x, PadPointer.y = nx, ny + if touching then dispatch(game, "touchmoved") end +end + +-- The crosshair, in the bottom screen's own 256x192 space. +function PadPointer.drawLocal(game) + local g = love.graphics + if not (g and g.line) then return end + game.padPointerDrawn = true + local x, y = PadPointer.x, PadPointer.y + g.push("all") + if g.setShader then g.setShader() end + if g.setScissor then g.setScissor() end + local c = touching and 1 or 0 + g.setColor(0, 0, 0, 1) + g.setLineWidth(3) + g.line(x - 7, y, x + 7, y); g.line(x, y - 7, x, y + 7) + g.setColor(1, 1 - c * 0.6, 1 - c * 0.8, 1) + g.setLineWidth(1) + g.line(x - 6, y, x + 6, y); g.line(x, y - 6, x, y + 6) + g.circle("line", x, y, touching and 4 or 2) + g.pop() +end + +-- For a screen that draws its lower half without SecondScreen.draw: put the +-- cursor on the window directly. +function PadPointer.drawFallback(game) + if not PadPointer.active or game.padPointerDrawn then return end + local g = love.graphics + if not (g and g.line) then return end + local px, py = SS().fromLocal(game, PadPointer.x, PadPointer.y) + if not px then return end + g.push("all") + g.origin() + if g.setShader then g.setShader() end + if g.setScissor then g.setScissor() end + g.setColor(0, 0, 0, 1); g.setLineWidth(4) + g.line(px - 12, py, px + 12, py); g.line(px, py - 12, px, py + 12) + g.setColor(1, 1, 1, 1); g.setLineWidth(2) + g.line(px - 10, py, px + 10, py); g.line(px, py - 10, px, py + 10) + g.pop() +end + +function PadPointer.isTouching() return touching end + +-- A controller was unplugged: nothing is held any more. +function PadPointer.padRemoved(game) + axes.leftx, axes.lefty = 0, 0 + dpad = {} + liftFinger(game) +end + +function PadPointer.reset() + PadPointer.active, touching, touchButton, activeTop = false, false, nil, nil + axes.leftx, axes.lefty = 0, 0 + dpad = {} + PadPointer.x, PadPointer.y = 128, 96 +end + +return PadPointer diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 181d09f44..7d007493e 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -43,6 +43,8 @@ local BUTTONS = { { id = "chat", label = "CHAT", key = "t", pad = "triggerleft", hot = true }, { id = "zoomin", label = "ZOOM IN", pad = "rightstick", hot = true, padOnly = true, keyLabel = "WHEEL" }, { id = "zoomout", label = "ZOOM OUT", pad = "leftstick", hot = true, padOnly = true, keyLabel = "WHEEL" }, + -- the DS lower screen's virtual finger (src/core/PadPointer.lua) + { id = "touch", label = "TOUCH", pad = "triggerright", hot = true, padOnly = true, keyLabel = "-" }, } BindingsMenu.BUTTONS = BUTTONS diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index bd2a94ca0..70f029a77 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -433,6 +433,13 @@ function SecondScreen.canvas(game) return game and game.secondScreenCanvas or nil end +-- the controller's lower-screen cursor, in this screen's own space (see +-- src/core/PadPointer.lua); a single table lookup when it is off +local function padCursor(game) + local PP = package.loaded["src.core.PadPointer"] + if PP and PP.active then PP.drawLocal(game) end +end + function SecondScreen.draw(game, body) if game then game.secondScreenDrawnThisFrame = true end local g = love.graphics @@ -466,6 +473,7 @@ function SecondScreen.draw(game, body) g.scale(sw / lw, sh / lh) end local ok, err = pcall(body) + if ok then pcall(padCursor, game) end g.pop() game.secondScreenDirty = true if not ok then error(err, 0) end @@ -480,6 +488,7 @@ function SecondScreen.draw(game, body) g.translate(x, y) g.scale(scale, scale) local ok, err = pcall(body) + if ok then pcall(padCursor, game) end g.pop() if not ok then error(err, 0) end end @@ -618,10 +627,12 @@ end -- sets the flag `toLocal` looks for, so every existing caller -- the battle, the -- mining screen, the Poketch -- keeps asking exactly the question it already -- asks and none of them learns a second coordinate space. -function SecondScreen.injectTouch(game, method, id, x, y) +function SecondScreen.injectTouch(game, method, id, x, y, logical) if not game or SecondScreen.mode(game) ~= "display" then return false end local lw, lh = logicalSize(game) - if game.secondScreenNativePanel and x and y then + -- `logical`: the point is already in 256x192 space (the pad cursor), not a + -- native panel pixel, so the panel rescale below must not touch it + if game.secondScreenNativePanel and x and y and not logical then local sw, sh = surfaceSize(game) x, y = x * lw / sw, y * lh / sh end @@ -666,6 +677,28 @@ function SecondScreen.toLocal(game, px, py) return lx, ly end +-- The inverse of toLocal: a point in the bottom screen's 256x192 space to the +-- window pixel that toLocal would map back to it. Nil in `display` mode, +-- where the bottom screen is not in the window (use injectTouch with +-- `logical` there). This is what lets a controller's cursor press exactly +-- the pixels a mouse click would. +function SecondScreen.fromLocal(game, lx, ly) + if SecondScreen.combined(game) then + local x, y, s = SecondScreen.windowRect(game) + return x + lx * s, y + ly * s + end + if SecondScreen.mode(game) == "display" then return nil end + local x, y, scale = SecondScreen.rect(game) + if not x then x, y, scale = 0, 0, 1 end + local px, py = x + lx * scale, y + ly * scale + local renderer = package.loaded['src.render.Renderer'] + local rect = renderer and renderer.uiPresentation + if type(rect) == 'table' then + px, py = rect.x + px * rect.scaleX, rect.y + py * rect.scaleY + end + return px, py +end + function SecondScreen.contains(game, px, py) return SecondScreen.toLocal(game, px, py) ~= nil end diff --git a/tools/gen4_xbox_pad_check.lua b/tools/gen4_xbox_pad_check.lua new file mode 100644 index 000000000..ab2c7daac --- /dev/null +++ b/tools/gen4_xbox_pad_check.lua @@ -0,0 +1,253 @@ +-- Xbox controller on Gen 4: buttons, stick, hotplug, and the lower-screen +-- cursor, driven by injecting love.gamepad* style events into the real Game, +-- Input, PadPointer and Gen4Poketch. Pure Lua: +-- python tools/run_lua_check.py tools/gen4_xbox_pad_check.lua +package.path = './?.lua;' .. package.path +love = { math = { random = math.random }, graphics = {} } +local version = 'platinum' +local info = { family = 'platinum', generation = 4, id = 'platinum' } +package.loaded['src.render.Assets'] = { image = function() return nil end, register = function() end } +package.loaded['src.render.Font'] = { width = function(s) return #s * 6 end, draw = function() end } +package.loaded['src.core.GameVersion'] = { + generation = function() return 4 end, get = function() return version end, + isGen2 = function() return false end, isGen3 = function() return false end, + isGen4 = function() return true end, isGen5 = function() return false end, + isDualScreen = function() return true end, info = function() return info end, + VERSIONS = {}, +} +package.loaded['src.core.FrameProfile'] = { section = function() return function() end end, frame = function() end } +package.loaded['src.ui.SaveCompanion'] = { tick = function() end } +local overlay = 0 +package.loaded['src.core.TouchControls'] = { + touchpressed = function() overlay = overlay + 1 end, touchmoved = function() end, + touchreleased = function() end, noteGamepad = function() end, reset = function() end, + joystickremoved = function() end, +} +local Renderer = { uiPresentation = { x = 100, y = 50, w = 768, h = 576, scaleX = 3, scaleY = 3 } } +package.loaded['src.render.Renderer'] = Renderer + +local passed, failed = 0, 0 +local function check(ok, label) + if ok then passed = passed + 1 else failed = failed + 1; print('FAIL ' .. label) end +end + +local Input = require('src.core.Input') +local PadPointer = require('src.core.PadPointer') +local SS = require('src.ui.SecondScreen') +local Game = require('src.core.Game') +local Watch = require('src.ui.Gen4Poketch') + +-- A display-less transport: no second panel, so the mode is `swap`. +SS._setTransport({ available = function() return false end }) + +Input:init() +local function step() Input:step() end +local function pressedThen(btn, fn) Input:gamepadpressed(nil, btn); step(); fn(); Input:gamepadreleased(nil, btn) end + +-- ---- 1. the Xbox map ------------------------------------------------------- +local MAP = { a = 'a', b = 'b', y = 'x', x = 'y', start = 'start', back = 'select', + leftshoulder = 'l', rightshoulder = 'r', + dpup = 'up', dpdown = 'down', dpleft = 'left', dpright = 'right' } +for pad, action in pairs(MAP) do + Input:reset() + Input:gamepadpressed(nil, pad); step() + check(Input:wasPressed(action), 'Xbox ' .. pad .. ' -> ' .. action) + check(Input:isDown(action), 'Xbox ' .. pad .. ' holds ' .. action) + Input:gamepadreleased(nil, pad) + check(not Input:isDown(action), 'Xbox ' .. pad .. ' releases ' .. action) +end +Input:reset(); Input:gamepadpressed(nil, 'y'); step() +check(Input:wasPressed('start'), 'Xbox Y is the Gen 4 field menu (DS X)') +Input:reset(); Input:gamepadpressed(nil, 'x'); step() +check(Input:wasPressed('select'), 'Xbox X is the registered key item (DS Y)') + +-- ---- 2. the stick ---------------------------------------------------------- +local function stick(x, y) Input:gamepadaxis(nil, 'leftx', x); Input:gamepadaxis(nil, 'lefty', y) end +Input:reset() +stick(0.15, -0.12) +check(Input.stickDir == nil, 'resting drift inside the deadzone does nothing') +stick(0.4, 0) +check(Input.stickDir == nil, 'between OFF and ON does not start a walk') +stick(0.9, 0.1); step() +check(Input.stickDir == 'right' and Input:wasPressed('right') and Input:isDown('right'), 'stick right walks right') +step(); check(Input:isDown('right') and not Input:wasPressed('right'), 'held stick is a held direction (menu repeat)') +-- diagonal jitter: hold right, wobble y around x +local flips = 0 +local last = Input.stickDir +for _, y in ipairs({ 0.6, 0.75, 0.7, 0.85, 0.7, 0.8, 0.72 }) do + stick(0.8, y) + if Input.stickDir ~= last then flips = flips + 1; last = Input.stickDir end +end +check(flips == 0, 'near-diagonal wobble does not flip between right and down') +stick(0.4, 1.0) +check(Input.stickDir == 'down', 'a clearly vertical push switches to down') +stick(0.45, 0.4) +check(Input.stickDir == 'down', 'hysteresis band keeps the direction') +stick(0.1, 0.1); check(Input.stickDir == nil and not Input:isDown('down'), 'releasing the stick releases the direction') +-- run: B held while the stick walks +Input:reset() +Input:gamepadpressed(nil, 'b'); stick(-1, 0); step() +check(Input:isDown('b') and Input:isDown('left'), 'B-hold run with the stick') +Input:gamepadreleased(nil, 'b'); stick(0, 0) +-- the D-pad and the stick share a direction +Input:reset() +Input:gamepadpressed(nil, 'dpup'); stick(0, -1); stick(0, 0) +check(Input:isDown('up'), 'releasing the stick does not drop a held D-pad direction') +Input:gamepadreleased(nil, 'dpup'); check(not Input:isDown('up'), 'D-pad release') + +-- ---- 3. two controllers ---------------------------------------------------- +local padA = { getID = function() return 1 end, isGamepad = function() return true end } +local padB = { getID = function() return 2 end, isGamepad = function() return true end } +Input:reset() +Input:gamepadpressed(padA, 'a'); Input:gamepadpressed(padB, 'a'); step() +local edges = 0 +for _, b in ipairs({ 'a' }) do if Input:wasPressed(b) then edges = edges + 1 end end +check(edges == 1 and Input:isDown('a'), 'two pads pressing A are one A edge') +Input:gamepadreleased(padA, 'a') +check(Input:isDown('a'), 'A stays held while the second pad still holds it') +Input:gamepadreleased(padB, 'a') +check(not Input:isDown('a'), 'A released when both pads let go') +Input:reset() +Input:gamepadaxis(padA, 'leftx', 1); Input:gamepadaxis(padB, 'leftx', 0) +check(Input.stickDir == 'right', 'a resting second pad does not cancel the first pad stick') +Input:gamepadaxis(padA, 'leftx', 0); check(Input.stickDir == nil, 'stick released') +-- gamepad + raw duplicate (bug620) still ignored +Input:reset(); Input:joystickpressed(padA, 1); step() +check(not Input:isDown('a'), 'mapped pad raw duplicate still ignored') + +-- ---- 4. the lower-screen cursor, on the real Pokétch ----------------------- +local apps = { { name = 'Counter' }, { name = 'Memo Pad' }, { name = 'Coin Toss' } } +local top = {} +local game = setmetatable({ + data = { isGen4Cache = true, gen4_menus = { poketch = { apps = apps } } }, + save = { options = {}, inventory = {}, poketch = { enabled = true } }, + stack = { top = function() return top end }, input = Input, +}, { __index = Game }) +local watch = Watch.new(game) +top = watch +Input:init(); Input:reset(); PadPointer.reset() + +local function use(name) + for i, a in ipairs(watch.apps) do if a.name == name then watch.index = i end end +end +local function axisEvent(axis, v) game:gamepadaxis(nil, axis, v) end +local function tapRT() axisEvent('triggerright', 1); axisEvent('triggerright', 0) end +check(not PadPointer.active, 'cursor off by default') +tapRT() +check(PadPointer.active, 'RT turns the lower-screen cursor on') +use('Counter') +local lx, ly = 114, 128 -- the Counter app's button +PadPointer.x, PadPointer.y = lx, ly +game:gamepadpressed(nil, 'a'); game:gamepadreleased(nil, 'a') +check(game.save.poketch.counter == 1, 'A taps the Counter button through the real Pokétch') +step(); check(not Input:wasPressed('a'), 'the tap did not also reach the game as an A press') +-- the stick moves the cursor and no longer walks +axisEvent('leftx', 1.0) +check(Input.stickDir == nil, 'stick does not walk while the cursor is up') +PadPointer.update(game, 0.5) +check(PadPointer.x > lx + 50, 'stick moves the cursor right') +axisEvent('leftx', 0) +PadPointer.x, PadPointer.y = 240, 120 -- the bezel's next-app button +local before = watch.index +game:gamepadpressed(nil, 'a'); game:gamepadreleased(nil, 'a') +check(watch.index ~= before, 'A on the bezel button changes Pokétch app') +-- D-pad moves too, slowly, and a drag works with A held (memo pad) +use('Memo Pad') +PadPointer.x, PadPointer.y = 30, 30 +game:gamepadpressed(nil, 'a') +game:gamepadpressed(nil, 'dpright'); PadPointer.update(game, 0.3); game:gamepadreleased(nil, 'dpright') +game:gamepadreleased(nil, 'a') +check(PadPointer.x > 30 and PadPointer.x < 90, 'D-pad moves the cursor') +check(next(game.save.poketch.memo or {}) ~= nil, 'dragging with A held draws on the memo pad') +-- other buttons keep working: B reaches the game, X is Gen 4's menu key +game:gamepadpressed(nil, 'b'); step() +check(Input:wasPressed('b'), 'B still cancels while the cursor is up') +game:gamepadreleased(nil, 'b') +-- the cursor never strands the player: a new top screen switches it off +local newTop = {} +top = newTop; PadPointer.update(game, 0.016) +check(not PadPointer.active, 'cursor switches off when the top screen changes') +axisEvent('leftx', 1.0) +check(Input.stickDir == 'right', 'stick walks again afterwards') +axisEvent('leftx', 0) +-- a stick still held when the cursor leaves goes back to the game +top = watch; Input:reset(); PadPointer.reset() +tapRT(); axisEvent('leftx', 1.0); check(Input.stickDir == nil, 'stick owned by cursor') +tapRT(); check(not PadPointer.active and Input.stickDir == 'right', 'held stick handed back on toggle-off') +axisEvent('leftx', 0) +-- refuses to take the stick in the field when no lower screen is showing +top = {}; game.overworld = top; game.secondScreenPointerOwner = nil +Input:reset(); PadPointer.reset(); tapRT() +check(not PadPointer.active, 'cursor refuses to start over a field with no lower screen') +game.secondScreenPointerOwner = watch; tapRT() +check(PadPointer.active, 'but starts over the always-present Pokétch panel') +tapRT() +game.overworld = nil + +-- disconnect mid-tap lifts the finger +top = watch; PadPointer.reset(); tapRT() +PadPointer.x, PadPointer.y = 114, 128 +game:gamepadpressed(nil, 'a') +check(PadPointer.isTouching(), 'finger down') +game:joystickremoved(padA) +check(not PadPointer.isTouching(), 'unplugging lifts the virtual finger') +game:joystickadded(padA) +tapRT() + +-- ---- 5. the other mapping modes ------------------------------------------- +local function roundTrip(mode) + game.save.options.secondScreenMode = mode + local ok = true + for _, p in ipairs({ { 10, 10 }, { 128, 96 }, { 250, 190 } }) do + local px, py = SS.fromLocal(game, p[1], p[2]) + local bx, by = SS.toLocal(game, px, py) + ok = ok and bx and math.abs(bx - p[1]) < 1e-6 and math.abs(by - p[2]) < 1e-6 + end + return ok +end +check(roundTrip('swap'), 'fromLocal inverts toLocal in swap mode') +check(roundTrip('inset'), 'fromLocal inverts toLocal in inset mode') +game.save.options.secondScreenMode = 'swap' + +-- display mode (a real second panel): events are injected in local space +SS._setTransport({ available = function() return true end, pollTouch = function() return {} end }) +game.save.options.secondScreenMode = 'display' +check(SS.mode(game) == 'display', 'display mode active') +PadPointer.reset(); use('Counter'); game.save.poketch.counter = 0 +top = watch; tapRT() +PadPointer.x, PadPointer.y = 114, 128 +game:gamepadpressed(nil, 'a'); game:gamepadreleased(nil, 'a') +check(game.save.poketch.counter == 1, 'cursor taps reach the lower panel in display mode') +game.secondScreenNativePanel = true +game.secondScreenSurfaceWidth, game.secondScreenSurfaceHeight = 768, 576 +game:gamepadpressed(nil, 'a'); game:gamepadreleased(nil, 'a') +check(game.save.poketch.counter == 2, 'native high-DPI panel does not rescale the cursor point') +game.secondScreenNativePanel = nil +tapRT() + +-- ---- 6. Pokétch app shortcuts from the pad -------------------------------- +SS._setTransport({ available = function() return false end }) +game.save.options.secondScreenMode = 'swap' +Input:reset(); use('Counter'); PadPointer.reset() +local c0 = watch.index +Input:gamepadpressed(nil, 'rightshoulder'); step(); watch:update(1 / 60); Input:gamepadreleased(nil, 'rightshoulder') +check(watch.index == c0 % #watch.apps + 1, 'RB cycles Pokétch apps forward') +Input:gamepadpressed(nil, 'dpleft'); step(); watch:update(1 / 60); Input:gamepadreleased(nil, 'dpleft') +check(watch.index == c0, 'D-pad left cycles Pokétch apps back') +Input:gamepadpressed(nil, 'a'); step(); watch:update(1 / 60); Input:gamepadreleased(nil, 'a') +check(game.save.poketch.counter == 3, 'A presses the app main button (Counter)') + +-- ---- 7. single-screen games are untouched ---------------------------------- +local oldAvail = SS.available +SS.available = function() return false end +Input:reset(); PadPointer.reset() +local g2 = setmetatable({ save = { options = {} }, stack = { top = function() return {} end }, input = Input }, { __index = Game }) +g2:gamepadaxis(nil, 'triggerright', 1) +check(not PadPointer.active, 'RT does nothing on a single-screen game') +g2:gamepadaxis(nil, 'triggerright', 0) +g2:gamepadaxis(nil, 'leftx', 1) +check(Input.stickDir == 'right', 'Gen 1-3 stick unchanged') +SS.available = oldAvail + +print(string.format('Gen4 Xbox pad: %d/%d checks passed', passed, passed + failed)) +os.exit(failed == 0 and 0 or 1) From 1253ab0201a4db5cea3feb3089b9532df05c611e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:37:07 +0100 Subject: [PATCH 18/60] wide_battle_layout: pin the pic-anchored band (centres 26 / 124) the animation offset now uses --- tests/engine/wide_battle_layout.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..7960ed7a9 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -39,10 +39,10 @@ T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") -- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) +local px, py = WideBattle.animationOffset({ { x = 30 } }) -- band centre 26: the player pic T.eq(px, 20, "a player-side frame lands on the player anchor") T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) +local ex, ey = WideBattle.animationOffset({ { x = 128 } }) -- band centre 124: the enemy pic T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") From 1d2f5568569ce111cd8ca16dedd627d14ba3ca44 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:41:15 +0100 Subject: [PATCH 19/60] Platinum playthrough: Eterna Forest with Cheryl; drop a stale lift passenger Cheryl joins as the partner at the forest's south entrance and leaves with the Soothe Bell at the east exit (VAR 0x40B1, FLAG 0xE3); the forest's trainers are fought two on two. The Distortion World lift passenger is now only ever placed on the floor the ride lands on. --- src/world/Gen4DistPlatforms.lua | 4 +++- tools/gen4_playthrough/platinum.lua | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/world/Gen4DistPlatforms.lua b/src/world/Gen4DistPlatforms.lua index 775208be0..1edea555d 100644 --- a/src/world/Gen4DistPlatforms.lua +++ b/src/world/Gen4DistPlatforms.lua @@ -1113,9 +1113,11 @@ function P.spawnCast(ow, def, m) end end -- the lift's passenger arriving with the player (ElevatorPlatform_ChangeMaps) + -- (the next floor built is the ride's own; any other means the ride never + -- landed, and she is not carried anywhere else) local pas = ow.dwPassenger + ow.dwPassenger = nil if pas and tonumber(pas.destHeader) == tonumber(def.header) then - ow.dwPassenger = nil local c = {} for k, v in pairs(pas.cast) do c[k] = v end c.localId, c.x, c.z, c.y = pas.localId, pas.wx, pas.wz, nil diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 637c39435..b1bb0195f 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -388,6 +388,20 @@ B[#B + 1] = { name = "mars at the windworks", expect = function(r) return r:var(0x4089) >= 2 and r:var(0x40CF) >= 2 end, why = "0x4089 >= 2, 0x40CF >= 2 (Mars beaten)", expectBudget = 12000 } +-- ETERNA FOREST (D03R0101; scripts_eterna_forest.s), in from Route 205's +-- north end. Coord 1 ((28..29,85) while VAR_ETERNA_FOREST_FOLLOWER_CHERYL_STATE +-- 0x40B1 == 0) is Cheryl joining as the partner (SetHasPartner, FOLLOW_PLAYER, +-- 0x40B1 = 1, FLAG_TALKED_TO_ETERNA_FOREST_CHERYL 0x8B); the forest walked with +-- her (its trainers fight two on two); coord 3 at the east exit ((82,34..39), +-- 0x40B1 == 1) is her Soothe Bell and goodbye: FLAG_TRAVELED_WITH_CHERYL 0xE3, +-- 0x40B1 = 2 +B[#B + 1] = { name = "eterna forest: cheryl", budget = 120000, + steps = { { "teleport", "R205A", 14, 7, "up" }, { "warpto", "D03R0101" }, { "coord", 1 }, + { "until", function(r) return r:var(0x40B1) >= 1 and not r:busy() end, 20000 }, + { "coord", 3 }, { "until", varGe(0x40B1, 2), 30000 } }, + expect = function(r) return r:var(0x40B1) >= 2 and r:flag(0xE3) and r:flag(0x8B) end, + why = "0x40B1 >= 2, flags 0xE3, 0x8B", expectBudget = 8000 } + -- ETERNA (scripts_eterna_city.s). Coord 1 (15,11..14 while -- VAR_ETERNA_CITY_STATE 0x407A == 0): Barry, 0x407A = 1; coords 4/7 (while -- 0x407A == 1): Cynthia gives HM01 Cut, 0x407A = 2 From 69f994ff8119c9d778a098cfe052e6ad5931cc85 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:42:19 +0100 Subject: [PATCH 20/60] Platinum playthrough: walk the League room to room The Elite Four and Champion beats no longer teleport into each room: they leave by the door the last win removed, ride each lift room's platform (TriggerPlatformLift) and are walked in by the next room's frame script, as a player is. --- tools/gen4_playthrough/platinum.lua | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index b1bb0195f..c7f54673f 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -1233,13 +1233,21 @@ B[#B + 1] = { name = "the league: barry", budget = 100000, -- at (8,5); FLAG_DEFEATED_AARON 0xB0, BERTHA 0xB1, FLINT 0xB2, LUCIAN 0xB3. -- Six Mewtwo at 100, leading with Flamethrower for Aaron's bugs, Surf for -- Bertha's ground and Flint's fire, Shadow Ball for Lucian's psychics. -local E4 = { { "aaron", "C10R0103", 0xB0, 53 }, { "bertha", "C10R0105", 0xB1, 57 }, - { "flint", "C10R0107", 0xB2, 57 }, { "lucian", "C10R0109", 0xB3, 247 } } +-- WALKED, room to room, as a player goes: out of the previous room by the +-- door its member's win removed (RemoveObject LOCALID_EXIT_DOOR), up the +-- lift room between (C10R0102/0104/0106/0108: the coord at (4,11) is +-- TriggerPlatformLift), into the next room (its frame table walks the player +-- in and shuts the door behind) +local E4 = { { "aaron", "C10R0102", "C10R0103", 0xB0, 53 }, { "bertha", "C10R0104", "C10R0105", 0xB1, 57 }, + { "flint", "C10R0106", "C10R0107", 0xB2, 57 }, { "lucian", "C10R0108", "C10R0109", 0xB3, 247 } } for _, e in ipairs(E4) do - local f = e[3] + local f = e[4] B[#B + 1] = { name = "elite four " .. e[1], - steps = { { "do", fullTeam(e[4]) }, { "teleport", e[2], 8, 9, "up" }, { "talk", 0 }, - { "until", flag(f), 60000 } }, + steps = { { "do", fullTeam(e[5]) }, { "warpto", e[2] }, { "coord", 1 }, + { "until", function(r) return not r:busy() end, 3000 }, + { "mash", 60 }, { "warpto", e[3] }, + { "until", function(r) return r:map() == e[3] and not r:busy() end, 6000 }, + { "talk", 0 }, { "until", flag(f), 60000 } }, expect = flag(f), why = ("flag 0x%X"):format(f), expectBudget = 12000 } end @@ -1248,7 +1256,9 @@ end -- the Hall of Fame (C10R0113): ClearGame sets FLAG_GAME_COMPLETED 0x964 and -- the credits roll. Ice Beam for her Garchomp. B[#B + 1] = { name = "champion cynthia", - steps = { { "do", fullTeam(58) }, { "teleport", "C10R0111", 8, 16, "up" }, + steps = { { "do", fullTeam(58) }, { "warpto", "C10R0110" }, { "coord", 1 }, + { "until", function(r) return not r:busy() end, 3000 }, { "mash", 60 }, + { "warpto", "C10R0111" }, { "until", flag(0xB4), 80000 }, { "until", flag(0x964), 120000 } }, expect = function(r) return r:flag(0xB4) and r:flag(0x964) end, why = "flags 0xB4 (Cynthia), 0x964 (game completed)", From 44763fa65b583f6fa2f1a7e125e9b86412aa9919 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:43:23 +0100 Subject: [PATCH 21/60] Add Renegade Platinum as its own version on the Platinum engine Register renegadeplatinum (Drayano's hack of Platinum USA Rev 1, trimmed patched file 381f9eef...) with its own cache, saves, manifest, launcher chip and ROM-hack sidebar group, family = platinum. Add GameVersion.isPlatinumFamily for the runtime checks that meant 'the Platinum code path' rather than the retail id. Import what the hack changes: - Fairy: slot 9 is named from Gen4Versions.TYPE_SLOTS, so species, moves and the chart speak FAIRY. Gen4TypeChart.follow reads the chart the battle overlay actually points at (Renegade repoints overlay 16 to a dense 18x18 chart appended to overlay 21). Battle and summary type icons are also saved under the slot's name; the Pokedex maps FAIRY to the hack's 19th icon sequence; Gen4AI knows TYPE.FAIRY. - constants.shinyUnder from Pokemon_IsPersonalityShiny (8 retail, 128 in Renegade), applied through Stats.setShinyUnder. - Icon palette anchors per version (the hack recolours 86 entries). - Game Corner prizes found by rows 1..3 when the first prize changed. - constants.trainerOrder covers all trainers (1066 in Renegade). A Platinum USA import is byte-identical apart from shinyUnder = 8. tools/rom_revisions_check.lua keeps GameVersion, RomRevisions and the manifests in step. --- src/battle/Gen4AI.lua | 3 + src/core/Data.lua | 3 + src/core/Game.lua | 6 +- src/core/GameVersion.lua | 50 +++++++++++- src/import/Gen4GameCorner.lua | 21 ++++++ src/import/Gen4Icons.lua | 28 ++++++- src/import/Gen4SummaryArt.lua | 4 + src/import/Gen4TypeChart.lua | 96 ++++++++++++++++++++++-- src/import/Gen4Versions.lua | 17 ++++- src/import/LauncherSidebar.lua | 2 +- src/import/RomExtractorGen4.lua | 69 ++++++++++++----- src/import/RomImporter.lua | 12 ++- src/import/RomRevisions.lua | 8 ++ src/online/CompatRules.lua | 6 +- src/pokemon/Stats.lua | 14 +++- src/script/Gen4SpearPillarScene.lua | 2 +- src/ui/Gen4Pokedex.lua | 5 +- tools/rom_manifest_renegadeplatinum.json | 17 +++++ tools/rom_revisions_check.lua | 79 +++++++++++++++++++ 19 files changed, 399 insertions(+), 43 deletions(-) create mode 100644 tools/rom_manifest_renegadeplatinum.json create mode 100644 tools/rom_revisions_check.lua diff --git a/src/battle/Gen4AI.lua b/src/battle/Gen4AI.lua index e71f4b7c1..ca0a9d1dc 100644 --- a/src/battle/Gen4AI.lua +++ b/src/battle/Gen4AI.lua @@ -67,6 +67,9 @@ local TYPE = { NORMAL = 0, FIGHTING = 1, FLYING = 2, POISON = 3, GROUND = 4, ROC ELECTRIC = 13, PSYCHIC = 14, ICE = 15, DRAGON = 16, DARK = 17 } local TYPE_NAME = {} for k, v in pairs(TYPE) do TYPE_NAME[v] = k end +-- Renegade Platinum names slot 9 FAIRY (its species, moves and chart say so); +-- an alias by name only, so TYPE_NAME[9] stays Platinum's +TYPE.FAIRY = 9 G.TYPE, G.TYPE_NAME = TYPE, TYPE_NAME local MOVE = { NATURAL_GIFT = 363, JUDGMENT = 449, HIDDEN_POWER = 237, GYRO_BALL = 360, diff --git a/src/core/Data.lua b/src/core/Data.lua index 8c0407d5d..81b6f1ed6 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -2489,6 +2489,9 @@ function Data:load() if constants.natures then require("src.pokemon.Stats").setNatures(constants.natures) end + -- the cartridge's shiny threshold (Renegade Platinum raises it to 128); + -- nil, every other cache, puts the retail 8 back + require("src.pokemon.Stats").setShinyUnder(constants.shinyUnder) -- before the mod loader runs: the deep registries fold over these self:seedDefaults() diff --git a/src/core/Game.lua b/src/core/Game.lua index bd810ac8d..d338a0acc 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -997,7 +997,7 @@ function Game:draw() -- PLATINUM'S UNDERGROUND: the other screen is the passages' map with the -- radar (top_screen.c), not the watch (src/ui/Gen4UgTopScreen.lua) local ugDrawn = false - if version and version.id == "platinum" then + if version and require("src.core.GameVersion").isPlatinumFamily(version.id) then local okU, UgTop = pcall(require, "src.ui.Gen4UgTopScreen") if okU and UgTop.active(self) then panelModule = nil @@ -2089,7 +2089,7 @@ function Game:restoreSave(loaded, recovered) -- ...which is a WALK IN there, not a continue: SystemFlag_CheckCommunicationClubAccessible -- takes FieldMapChange_UpdateGameData + CreateObjects (ON_TRANSITION and all) local specialLocation = false - if require("src.core.GameVersion").get() == "platinum" then + if require("src.core.GameVersion").isPlatinumFamily() then specialLocation = require("src.script.Gen4BattleTowerCommands").applySpecialLocation(loaded) and true or false end -- ...and HeartGold / SoulSilver's: FLAG_UNK_966 (the communication club's @@ -2116,7 +2116,7 @@ function Game:restoreSave(loaded, recovered) loaded.player.x, loaded.player.y, loaded.player.facing) -- Platinum's Journal (FieldTask_LoadSavedGameMap): opened first when two -- days or more have gone by, then today's page is taken and titled here - if require("src.core.GameVersion").get() == "platinum" then + if require("src.core.GameVersion").isPlatinumFamily() then local J = require("src.pokemon.Gen4Journal") if J.openOnContinue(loaded) then -- the pages as they were: today's is not added until after it closes diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 186e891a5..fb108735b 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1067,6 +1067,47 @@ GameVersion.VERSIONS = { hasRunning = true, dualScreen = true, }, + -- RENEGADE PLATINUM, Drayano's hack of Platinum (USA) Rev 1: a ROM hack, so + -- it sorts after the cartridges with Prism and Polished Crystal, and it runs + -- on PLATINUM'S engine -- `family = "platinum"` is what every Gen 4 check + -- that means "the Platinum code path" asks (GameVersion.family / + -- GameVersion.isPlatinumFamily), so nothing below needs a second branch. + -- + -- Measured against Platinum USA Rev 1 rather than taken from the hack's + -- notes: the filesystem keeps every path (one DP leftover, + -- /poketool/personal/personal.narc, is gone, and nothing reads it), the + -- ARM9 differs in 266 bytes, and the data NARCs carry the hack -- + -- personal (507 of 508 records), learnsets, evolutions, moves (117), + -- items (108), encounters (118 of 183), trainers (928 -> 1066, 698 parties + -- rewritten), scripts (215), events (163) and 222 text banks. + -- + -- FAIRY replaces the unused "???" type in slot 9: the type-name bank says + -- FAIRY there, 20 species carry it (Clefairy line, Gardevoir line, Mawile, + -- Togekiss, Azumarill...), pl_batt_obj's type_icons/mystery is redrawn as + -- the Fairy icon, and the battle overlay's chart pointer is redirected to a + -- dense 18x18 chart the hack appended to overlay 21 (Gen4TypeChart.follow). + -- The extractor names slot 9 from the cartridge (Gen4Versions.TYPE_SLOTS). + renegadeplatinum = { + id = "renegadeplatinum", + generation = 4, + family = "platinum", + romhack = true, + label = "Renegade Platinum", + displayName = "Pokemon Renegade Platinum", + launcherName = "Renegade Platinum", + -- the patched Rev 1 cartridge, trimmed (104,922,516 bytes); its header + -- still says CPUE revision 1 + sha1 = "381f9eef44564602a5f6469ee8c390fbb8ebac3a", + gameCode = "CPUE", + manifest = "tools/rom_manifest_renegadeplatinum.json", + cachePrefix = "renegadeplatinum/", + saveSuffix = "_renegadeplatinum", + importable = true, + experimental = true, + experimentalLabel = "PRE-ALPHA", + hasRunning = true, + dualScreen = true, + }, -- ------------------------------------------------------------------------- -- THE LEGEND OF ZELDA: the first games in this registry that are NOT Pokemon. @@ -1134,7 +1175,7 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald", "platinum", "heartgold", "soulsilver", "black", "white", "alttp", "minishcap", - "prism", "polishedcrystal" } + "prism", "polishedcrystal", "renegadeplatinum" } GameVersion.current = "red" @@ -1229,6 +1270,13 @@ function GameVersion.family(id) return info and (info.family or info.id) or nil end +-- Platinum or a ROM hack running on Platinum's engine (Renegade Platinum). +-- The question a `get() == "platinum"` check almost always means: "is this +-- the Platinum code path", not "is this exactly the retail cartridge". +function GameVersion.isPlatinumFamily(id) + return GameVersion.generation(id) == 4 and GameVersion.family(id) == "platinum" +end + -- THE MONEY SIGN THIS CARTRIDGE'S FONT DRAWS, which is a different character -- in every generation and is never a dollar. -- diff --git a/src/import/Gen4GameCorner.lua b/src/import/Gen4GameCorner.lua index 6969d02fc..a0c5da642 100644 --- a/src/import/Gen4GameCorner.lua +++ b/src/import/Gen4GameCorner.lua @@ -14,9 +14,30 @@ Gen4GameCorner.PRIZES = 19 Gen4GameCorner.SIGNATURE = string.char(0xFB, 0, 0xE8, 3, 0x09, 1, 0xE8, 3) Gen4GameCorner.MAX_COINS = 50000 -- include/coins.h +-- Rows 1..3 (Wide Lens, Zoom Lens, Metronome at 1000 coins), for a table +-- whose FIRST prize was replaced: Renegade Platinum swaps the Silk Scarf for +-- item 275 and keeps the next three, so the table starts one row earlier. +Gen4GameCorner.SIGNATURE_ROW1 = string.char(0x09, 1, 0xE8, 3, 0x14, 1, 0xE8, 3, 0x15, 1, 0xE8, 3) + function Gen4GameCorner.parse(arm9) if type(arm9) ~= "string" then return nil, "no arm9" end local at = arm9:find(Gen4GameCorner.SIGNATURE, 1, true) + if not at then + local row1 = arm9:find(Gen4GameCorner.SIGNATURE_ROW1, 5, true) + -- the row before must still read as a prize: an item id and 1000 coins + if row1 and arm9:byte(row1 - 2) == 0xE8 and arm9:byte(row1 - 1) == 3 then + -- ...and so must all nineteen: an item id and a price in tens of coins + local ok = true + for i = 0, Gen4GameCorner.PRIZES - 1 do + local o = row1 - 4 + i * 4 + local item = (arm9:byte(o) or 0) + (arm9:byte(o + 1) or 0) * 256 + local price = (arm9:byte(o + 2) or 0) + (arm9:byte(o + 3) or 0) * 256 + if item == 0 or item > 600 or price == 0 or price % 10 ~= 0 + or price > Gen4GameCorner.MAX_COINS then ok = false break end + end + if ok then at = row1 - 4 end + end + end if not at then return nil, "sGameCornerPrizeData not found in arm9" end local prizes = {} for i = 0, Gen4GameCorner.PRIZES - 1 do diff --git a/src/import/Gen4Icons.lua b/src/import/Gen4Icons.lua index c34dec41a..317e1a031 100644 --- a/src/import/Gen4Icons.lua +++ b/src/import/Gen4Icons.lua @@ -107,6 +107,26 @@ Gen4Icons.VERIFIED = { [395] = 0, -- Empoleon } +-- A HACK THAT REDRAWS ICONS REASSIGNS THEIR RAMPS. Renegade Platinum keeps +-- sPokemonIconPaletteIndex exactly where Platinum has it (11 of the 14 +-- anchors above still hold there, and the run around it is the same) but +-- re-colours 86 entries for its new icon art -- Geodude 1 -> 0, Infernape +-- 0 -> 2, Piplup 2 -> 0 among them. Its anchors are Platinum's with those +-- three read from the hack's own table at Platinum's verified offset. +Gen4Icons.VERIFIED_BY_VERSION = { + renegadeplatinum = { [74] = 0, [392] = 2, [393] = 0 }, +} + +-- every anchor of a version's table (its overrides over Platinum's) +function Gen4Icons.anchorsFor(version) + local over = Gen4Icons.VERIFIED_BY_VERSION[version] + if not over then return Gen4Icons.VERIFIED end + local out = {} + for species, ramp in pairs(Gen4Icons.VERIFIED) do out[species] = ramp end + for species, ramp in pairs(over) do out[species] = ramp end + return out +end + local function spread(bin, at, count, ramps) local seen, total = {}, 0 for i = 0, count - 1 do @@ -128,7 +148,7 @@ end -- Find sPokemonIconPaletteIndex in the ARM9 binary. -- Returns a 1-based byte offset and the entropy it won with, or nil. -function Gen4Icons.findPaletteTable(bin, icons) +function Gen4Icons.findPaletteTable(bin, icons, anchors) if type(bin) ~= "string" then return nil end icons = icons or 540 local ramps = Gen4Icons.RAMPS @@ -146,7 +166,7 @@ function Gen4Icons.findPaletteTable(bin, icons) -- the array can begin INSIDE the run (the bytes before it may be -- small too): the offset where every anchor holds wins outright for start = at, last - icons do - if Gen4Icons.verify(bin, start) then + if Gen4Icons.verify(bin, start, anchors) then local h, counts = spread(bin, start, icons, ramps) return start, h, counts end @@ -167,9 +187,9 @@ end -- against? The extractor refuses a table that does not, because a wrong one -- produces a party list that is merely MIS-COLOURED -- which looks like a -- palette bug rather than a located-the-wrong-array bug. -function Gen4Icons.verify(bin, at) +function Gen4Icons.verify(bin, at, anchors) local wrong = {} - for species, ramp in pairs(Gen4Icons.VERIFIED) do + for species, ramp in pairs(anchors or Gen4Icons.VERIFIED) do local got = bin:byte(at + species) if got ~= ramp then wrong[#wrong + 1] = ("%d got %s want %d") diff --git a/src/import/Gen4SummaryArt.lua b/src/import/Gen4SummaryArt.lua index a6b99e22b..e27e952c8 100644 --- a/src/import/Gen4SummaryArt.lua +++ b/src/import/Gen4SummaryArt.lua @@ -233,6 +233,10 @@ function Gen4SummaryArt.images(rom) end if pal and cell then for name, r in pairs(Gen4SummaryArt.TYPE_ROW) do out["type_" .. name] = icon(name, r) end + -- a renamed slot 9 (Renegade Platinum's FAIRY) is drawn by the + -- cartridge's mystery member, in mystery's palette row + local slot9 = tostring(require("src.import.Gen4TypeChart").TYPES[9] or "MYSTERY"):lower() + if slot9 ~= "mystery" then out["type_" .. slot9] = out.type_mystery end for name, r in pairs(Gen4SummaryArt.CATEGORY_ROW) do out["category_" .. name] = icon(name, r) end end end diff --git a/src/import/Gen4TypeChart.lua b/src/import/Gen4TypeChart.lua index a1189e868..7285bd83d 100644 --- a/src/import/Gen4TypeChart.lua +++ b/src/import/Gen4TypeChart.lua @@ -64,6 +64,18 @@ Gen4TypeChart.TYPES = { "BUG", "GHOST", "STEEL", "MYSTERY", "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK", } +local DEFAULT_NAMES = {} +for i = 0, #Gen4TypeChart.TYPES do DEFAULT_NAMES[i] = Gen4TypeChart.TYPES[i] end + +-- setSlotNames({ [id] = NAME }) -- a cartridge that renames a type slot +-- (Renegade Platinum's slot 9 is FAIRY; Gen4Versions.TYPE_SLOTS). Edits +-- TYPES IN PLACE, because Gen4Species.TYPES and Gen4Moves read this very +-- table; nil puts Platinum's names back, so the next extractor in the same +-- process starts clean. +function Gen4TypeChart.setSlotNames(slots) + for i = 0, #DEFAULT_NAMES do Gen4TypeChart.TYPES[i] = DEFAULT_NAMES[i] end + for i, name in pairs(slots or {}) do Gen4TypeChart.TYPES[i] = name end +end -- The physical/special split BY TYPE, which Gen 4 does not use and the engine -- still asks for. Platinum decides it per MOVE (`class` on the move record, @@ -88,12 +100,14 @@ local VALID_MULTIPLIER = { [0] = true, [5] = true, [20] = true, } -local function looksLikeRow(data, at) +-- `dense`: also accept 10 (1x). Platinum's chart lists only the exceptions; +-- Renegade Platinum's replacement lists every pair, neutral ones included. +local function looksLikeRow(data, at, dense) local a, b, m = byte(data, at, at + 2) if not m then return false end if a >= Gen4TypeChart.TYPE_COUNT then return false end if b >= Gen4TypeChart.TYPE_COUNT then return false end - return VALID_MULTIPLIER[m] == true + return VALID_MULTIPLIER[m] == true or (dense == true and m == 10) end -- find(overlay, minimumRows) -> offset (0-based) of the first row, or nil. @@ -155,22 +169,22 @@ end -- `foresightFrom` is the 1-based index of the first row past the 0xFE marker: -- those are the matchups Foresight and Scrappy remove, and a caller that wants -- the ordinary chart stops there while the damage routine does not. -function Gen4TypeChart.parse(data, at) +function Gen4TypeChart.parse(data, at, dense) if not at then at = Gen4TypeChart.find(data) end if not at then return nil, "no type chart in this binary" end - local rows, foresightFrom, sections = {}, nil, 1 + local rows, foresightFrom, sections, ended = {}, nil, 1, false local p = at + 1 while p + 2 <= #data do local a = byte(data, p) - if a == Gen4TypeChart.END then break end + if a == Gen4TypeChart.END then ended = true break end if a == Gen4TypeChart.FORESIGHT_MARKER then foresightFrom = #rows + 1 sections = sections + 1 p = p + 3 else local b, m = byte(data, p + 1, p + 2) - if not looksLikeRow(data, p) then break end + if not looksLikeRow(data, p, dense) then break end rows[#rows + 1] = { a, b, m } p = p + 3 end @@ -178,6 +192,8 @@ function Gen4TypeChart.parse(data, at) end return { + ended = ended, + dense = dense == true or nil, rows = rows, foresightFrom = foresightFrom, sections = sections, @@ -225,7 +241,8 @@ function Gen4TypeChart.chart(parsed) for i, row in ipairs(parsed.rows) do local attacker, defender, multiplier = names[row[1]], names[row[2]], row[3] - if attacker and defender then + -- a dense chart's 1x rows say nothing an absent pair does not + if attacker and defender and multiplier ~= 10 then out.matchups[#out.matchups + 1] = { attacker = attacker, defender = defender, multiplier = multiplier, } @@ -243,4 +260,69 @@ function Gen4TypeChart.chart(parsed) return out end +-- follow(rom, data, info, at) -> the chart the battle code ACTUALLY reads, +-- when that is not the one `find` located, else nil. +-- +-- The battle overlay reaches its chart through literal-pool words, and it +-- loads the three columns separately: `&chart[0]`, `&chart[1]`, `&chart[2]`, +-- three consecutive words X, X+1, X+2. Platinum's point at the table `find` +-- returns. A hack that rewrites the chart can leave the old table where it is +-- and repoint those words -- Renegade Platinum does exactly that: overlay 16 +-- keeps the vanilla 110 rows at 0x33B94, every reference to them now reads +-- 0x021E9F80, and that is a dense 18x18 chart (Fairy row and column +-- included) appended to the end of overlay 21. Read the old one and Fairy +-- does neutral damage to everything. +-- +-- So: if the overlay still holds a pointer to the table found, it is the live +-- one and this returns nil (every retail cartridge). Otherwise each X, X+1, +-- X+2 triple is resolved into whichever overlay (or the ARM9) loads at X and +-- parsed there, dense rows allowed; the first that parses to a whole chart +-- (90+ rows, closed by 0xFF) wins. +local function u32(s, i) + local a, b, c, d = byte(s, i, i + 3) + if not d then return nil end + return a + b * 256 + c * 65536 + d * 16777216 +end +function Gen4TypeChart.follow(rom, data, info, at) + if not (rom and type(data) == "string" and info and info.ram and at) then return nil end + local live = info.ram + at + local triples = {} + for p = 1, #data - 11, 4 do + local x = u32(data, p) + if x == live then return nil end + if x and x >= 0x02000000 and x < 0x02400000 + and u32(data, p + 4) == x + 1 and u32(data, p + 8) == x + 2 then + triples[#triples + 1] = x + end + end + local images = {} + local n = 0 + while true do + local ok, bytes, oi = pcall(rom.overlay, rom, n) + if not ok or not bytes then break end + images[#images + 1] = { overlay = n, bytes = bytes, ram = oi and oi.ram } + n = n + 1 + end + local okA, arm9 = pcall(rom.arm9, rom) + local okH, header = pcall(rom.header, rom) + if okA and arm9 then + images[#images + 1] = { overlay = "arm9", bytes = arm9, + ram = okH and header and header.arm9 and header.arm9.ram or 0x02000000 } + end + for _, x in ipairs(triples) do + for _, image in ipairs(images) do + local off = image.ram and (x - image.ram) + if off and off >= 0 and off + 3 <= #image.bytes then + local parsed = Gen4TypeChart.parse(image.bytes, off, true) + if parsed and parsed.ended and #parsed.rows >= 90 then + parsed.overlay = image.overlay + parsed.ram = x + return parsed + end + end + end + end + return nil +end + return Gen4TypeChart diff --git a/src/import/Gen4Versions.lua b/src/import/Gen4Versions.lua index 4a0412473..c23ee980f 100644 --- a/src/import/Gen4Versions.lua +++ b/src/import/Gen4Versions.lua @@ -39,7 +39,20 @@ local Files = require("src.import.Gen4HgssFiles") local V = {} -V.FAMILY = { platinum = "platinum", heartgold = "hgss", soulsilver = "hgss" } +V.FAMILY = { platinum = "platinum", heartgold = "hgss", soulsilver = "hgss", + -- Drayano's hack of Platinum keeps Platinum's paths and banks + renegadeplatinum = "platinum" } + +-- TYPE SLOTS a cartridge renames. Renegade Platinum turns the unused "???" +-- type (slot 9) into FAIRY: its type-name bank (624) says FAIRY there, 20 +-- species and its Fairy moves carry id 9, and its battle chart has a Fairy +-- row and column. Every Gen 4 table speaks type NAMES through +-- Gen4TypeChart.TYPES (species types, move types, the chart's matchups), and +-- the engine matches on those names -- left as MYSTERY, a Fairy move would be +-- the typeless "???" and Gen4Damage deals it no damage. +V.TYPE_SLOTS = { + renegadeplatinum = { [9] = "FAIRY" }, +} local hgssPaths = { text = Files.byName.msgdata_msg, @@ -170,6 +183,8 @@ function V.apply(version, PATH, BANK) saved = { path = {}, bank = {} } -- the script dispatcher's bands follow the cartridge too require("src.import.Gen4ScriptBands").setFamily(V.family(version)) + -- ...and the type names (Platinum's put back first, as with PATH and BANK) + require("src.import.Gen4TypeChart").setSlotNames(V.TYPE_SLOTS[version]) -- ...and the member names HGSS's numbered archives are looked up by if V.family(version) == "hgss" then local Archives = require("src.import.Gen4Archives") diff --git a/src/import/LauncherSidebar.lua b/src/import/LauncherSidebar.lua index d45fb9ad7..24322bdc7 100644 --- a/src/import/LauncherSidebar.lua +++ b/src/import/LauncherSidebar.lua @@ -55,7 +55,7 @@ Sidebar.CONSOLES = { -- The game TYPE a version belongs to. Every registered game is a Pokemon game; -- the two community hacks are kept apart so the official games read as a set. -Sidebar.HACKS = { prism = true, polishedcrystal = true } +Sidebar.HACKS = { prism = true, polishedcrystal = true, renegadeplatinum = true } -- Zelda sits right after Pokemon, so a console that has both (the GBA) shows -- the Zelda card next to the Pokemon one. A version names its own series and -- console (`info.series`, `info.console`); the generation is only the diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index daf77c8ab..85f3d6cc0 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -2344,28 +2344,38 @@ function RomExtractorGen4:extractGraphics() tiles = group.NCGR, palette = palette, cell = bankAt, tilesWide = family.tilesWide, } - local name = group.base:gsub("/", "_") + local base = group.base:gsub("/", "_") + -- A RENAMED TYPE SLOT keeps the cartridge's member name: Renegade + -- Platinum redraws type_icons/mystery as its Fairy icon, and every + -- screen asks for type_icons_. So the sheet is saved + -- under the slot's name as well (Gen4TypeChart.setSlotNames). + local savedAs = { base } + if group.base == "type_icons/mystery" and Gen4TypeChart.TYPES[9] ~= "MYSTERY" then + savedAs[2] = "type_icons_" .. tostring(Gen4TypeChart.TYPES[9]):lower() + end -- `local a, b = x and f()` keeps only f()'s FIRST return value, so -- this cannot be folded into the guard -- the palette would always -- arrive nil and every sheet would silently take the fallback path. local sheet, pal if palette then sheet, pal = self:partsFor(objArc, job) end local frames = sheet and pal and self:assembleCells(objArc, job, sheet, pal) - if frames then - for _, frame in ipairs(frames) do - local entry = self:saveImage( - "battle/" .. name .. frame.suffix, frame.image, { - family = family.label, cellBank = bankName, - cellMatch = bankHow, cell = frame.cellIndex, - }) - if entry then index.battleObjects[name .. frame.suffix] = entry end + for _, name in ipairs(savedAs) do + if frames then + for _, frame in ipairs(frames) do + local entry = self:saveImage( + "battle/" .. name .. frame.suffix, frame.image, { + family = family.label, cellBank = bankName, + cellMatch = bankHow, cell = frame.cellIndex, + }) + if entry then index.battleObjects[name .. frame.suffix] = entry end + end + else + local image = palette and self:composeJob(objArc, job) + local entry = self:saveImage("battle/" .. name, image, { + family = family.label, provisionalLayout = true, + }) + if entry then index.battleObjects[name] = entry end end - else - local image = palette and self:composeJob(objArc, job) - local entry = self:saveImage("battle/" .. name, image, { - family = family.label, provisionalLayout = true, - }) - if entry then index.battleObjects[name] = entry end end end end @@ -2647,14 +2657,15 @@ function RomExtractorGen4:extractMonIcons() -- NarcArchive keeps its member count as a plain field, so the sheet run is -- however many members follow the six cell and animation banks. local sheets = (tonumber(arc.count) or 547) - Gen4Icons.FIRST_SHEET - local at, entropy = Gen4Icons.findPaletteTable(arm9, sheets) + local anchors = Gen4Icons.anchorsFor(self.version) + local at, entropy = Gen4Icons.findPaletteTable(arm9, sheets, anchors) if not at then return nil end -- A WRONG TABLE DOES NOT FAIL, IT MIS-COLOURS -- which reads as a palette -- bug rather than as "the scan found the wrong array". So the seven values -- the render was checked against are asserted here, and the whole stage -- declines rather than writing a party list in the wrong colours. - local okTable, wrong = Gen4Icons.verify(arm9, at) + local okTable, wrong = Gen4Icons.verify(arm9, at, anchors) if not okTable then require("src.core.Logger").warn( "gen4 icons: the palette table found at 0x%X disagrees with the verified " @@ -3342,13 +3353,16 @@ function RomExtractorGen4:extractConstants() -- Normal do neutral damage to Ghost. local chart, parsed for overlay = 0, 130 do - local ok, bytes = pcall(function() return self.rom:overlay(overlay) end) + local ok, bytes, info = pcall(function() return self.rom:overlay(overlay) end) if ok and bytes and #bytes > 0 then local at = Gen4TypeChart.find(bytes) if at then parsed = Gen4TypeChart.parse(bytes, at) if parsed then parsed.overlay = overlay + -- a hack can repoint the battle code at a chart of its own and + -- leave this one behind (Renegade Platinum); read the live one + parsed = Gen4TypeChart.follow(self.rom, bytes, info, at) or parsed chart = Gen4TypeChart.chart(parsed) break end @@ -3758,6 +3772,16 @@ function RomExtractorGen4:extractConstants() self.tradeReport = { trades = trades and #trades or 0 } self:write("constants", { + -- Pokemon_IsPersonalityShiny's threshold: `(otId ^ pid halves) < N`, + -- found by its own instruction run (eors r0,r3; lsrs r2,#16; eors r0,r1; + -- eors r0,r2; cmp r0,#N; bcs; movs r0,#1). 8 on Platinum (1/8192); + -- Renegade Platinum patches the cmp to 128 (1/512). Stats.setShinyUnder. + shinyUnder = (function() + local ok, arm9 = pcall(function() return self.rom:arm9() end) + if not (ok and type(arm9) == "string") then return nil end + local at = arm9:find("X@\18\12H@P@.%(\1\210\1 ") + return at and arm9:byte(at + 8) or nil + end)(), gen4HoneyEncounters = require('src.world.Gen4HoneyTrees').tables(self.rom), experienceTables = experienceTables, tmhmMoves = tmhmMoves, @@ -3887,7 +3911,12 @@ function RomExtractorGen4:extractConstants() speciesOrder = order(508), moveOrder = order(471), itemOrder = order(446), - trainerOrder = order(928), + -- 928 on Platinum (and kept as the floor); Renegade Platinum's trdata + -- has 1066 + trainerOrder = order((function() + local arc = self:archive("trainerData") + return math.max(928, arc and tonumber(arc.count) or 0) + end)()), world = { -- A Gen 4 cell is sixteen pixels, and the map defs say so too. blockPx = 16, @@ -4465,7 +4494,7 @@ function RomExtractorGen4:extractTrainerSprites() -- (HGSS names the archive by number; Platinum's is /poketool/trgra/trbgra.narc, -- the same five members a class: 0 the player's Pt outfit, 1 hers, 2 Barry, 3-7 -- the five stat trainers, 8-10 the Diamond / Pearl outfits for a link partner) - if dpOrder or self.version == "platinum" then + if dpOrder or require("src.import.Gen4Versions").family(self.version) == "platinum" then local backs = self:archive("trainerBack") or self:archiveAt(Gen4Trgra.BACK_PATH) index.backs = {} for class = 0, Gen4Trgra.classCount(backs) - 1 do diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 330695f1a..6a921ee6e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1313,7 +1313,7 @@ local function markerFor(version) -- and time of day out of zukan_enc (Platinum zukan_enc_platinum.narc, HGSS -- /a/1/3/3) with the map's pieces and markers, drawn by Gen4DexAreaMap / -- HgssDexDetails in place of the encounter-table scan - local revision = version == "platinum" and "platinum-back-throw-v1:gen4-trainer-ai-v1:platinum-zukan-enc-v1:gen4-female-sprites-v1:platinum-ug-cursor-v1:platinum-ug-top-v1:platinum-ug-fx-v1:platinum-ug-trap-art-v1:platinum-dressup-photo-v2:gen4-light-mask-v1:platinum-capsule-app-v1:platinum-slot-app-v1:platinum-tv-script-v1:platinum-boat-cutscene-v1:platinum-gym-fx-v1:platinum-scratch-off-v1:platinum-poke-motion-v1:platinum-egg-hatch-v1:platinum-geometry-v17:platinum-field-fx-v1:platinum-library-tv-v1:platinum-spear-pillar-v1:platinum-effect-pal-v1:ds-back-throw-v1:pal-park-v1:" or "" + local revision = GameVersion.isPlatinumFamily(version) and "platinum-back-throw-v1:gen4-trainer-ai-v1:platinum-zukan-enc-v1:gen4-female-sprites-v1:platinum-ug-cursor-v1:platinum-ug-top-v1:platinum-ug-fx-v1:platinum-ug-trap-art-v1:platinum-dressup-photo-v2:gen4-light-mask-v1:platinum-capsule-app-v1:platinum-slot-app-v1:platinum-tv-script-v1:platinum-boat-cutscene-v1:platinum-gym-fx-v1:platinum-scratch-off-v1:platinum-poke-motion-v1:platinum-egg-hatch-v1:platinum-geometry-v17:platinum-field-fx-v1:platinum-library-tv-v1:platinum-spear-pillar-v1:platinum-effect-pal-v1:ds-back-throw-v1:pal-park-v1:" or "" -- gen5-trades-v5: constants.gen5Trades, gen5_trade_art and the trade -- commands' operands in map_scripts (Gen5Trades / Gen5TradeArt) -- gen5-starter-v6: gen5_starter_art, the gift box app (Gen5StarterArt) @@ -1969,6 +1969,10 @@ local PAL = { -- glance rather than by reading their labels. chipPolishedTop = { 186, 148, 252 }, -- #ba94fc Polished Crystal chipPolishedBot = { 92, 56, 168 }, -- #5c38a8 + -- Renegade Platinum: Giratina's red over Platinum's slate, so the hack + -- reads as Platinum's and still not as Platinum + chipRenegadeTop = { 214, 92, 92 }, -- #d65c5c Renegade Platinum + chipRenegadeBot = { 74, 82, 134 }, -- #4a5286 chipModTop = { 61, 74, 109 }, -- #3d4a6d chipModBot = { 32, 42, 69 }, -- #202a45 chipInkGold = { 58, 44, 0 }, -- #3a2c00 @@ -8034,6 +8038,11 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) top = PAL.chipPolishedTop, bot = PAL.chipPolishedBot, under = PAL.chipPolishedTop, label = Strings("POLISHED"), ink = PAL.chipInkSilver }, + -- Renegade Platinum, a Platinum hack, after the Gen 2 hacks ("RP") + { id = "renegadeplatinum", letter = "RP", + top = PAL.chipRenegadeTop, bot = PAL.chipRenegadeBot, + under = PAL.chipRenegadeTop, label = Strings("RENEGADE"), + ink = PAL.chipInkSilver }, { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, under = PAL.modDot, label = Strings("MODS") }, -- Browsing a community index sits beside the installed list rather than @@ -8324,6 +8333,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) -- Without a branch here the panel silently falls back to PAL.blue, which -- reads as a rendering bug rather than a missing case. elseif version == "polishedcrystal" then accent = PAL.chipPolishedTop + elseif version == "renegadeplatinum" then accent = PAL.chipRenegadeTop end local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress if locked then diff --git a/src/import/RomRevisions.lua b/src/import/RomRevisions.lua index ec9515078..64cd5d0e2 100644 --- a/src/import/RomRevisions.lua +++ b/src/import/RomRevisions.lua @@ -28,6 +28,7 @@ -- HeartGold (USA) IPKE; SoulSilver (USA, Australia) accepted -- (Europe) IPKP / IPGP NOT YET -- Black / White (USA, Europe) IRBO / IRAO accepted +-- Renegade Plat. the hack over Platinum (USA) Rev 1 (CPUE) accepted -- -- The KNOWN hashes below come from pret's own per-build `.sha1` files -- (pokefirered firered_rev1.sha1 / firered_switch.sha1 / leafgreen*.sha1, @@ -79,6 +80,12 @@ RomRevisions.ACCEPTED = { { sha1 = "6930b48af5844d373e3c9130f26d6dd1084cf4ed", code = "PKPC", release = true, label = "Polished Crystal 3.2.3" }, }, + -- Drayano's hack of Platinum (USA) Rev 1: the trimmed patched file. Its + -- header still says CPUE revision 1, so the hack is told apart by hash only + renegadeplatinum = { + { sha1 = "381f9eef44564602a5f6469ee8c390fbb8ebac3a", code = "CPUE", release = true, + label = "Renegade Platinum (over Pokemon Platinum (USA) (Rev 1))" }, + }, firered = { { sha1 = "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", code = "BPRE", revision = 0, label = "Pokemon FireRed (USA, Europe)" }, @@ -174,6 +181,7 @@ local GAME_NAMES = { green = "Pokemon Green", gold = "Pokemon Gold", silver = "Pokemon Silver", crystal = "Pokemon Crystal", prism = "Pokemon Prism", polishedcrystal = "Polished Crystal", + renegadeplatinum = "Renegade Platinum", ruby = "Pokemon Ruby", sapphire = "Pokemon Sapphire", firered = "Pokemon FireRed", leafgreen = "Pokemon LeafGreen", emerald = "Pokemon Emerald", diff --git a/src/online/CompatRules.lua b/src/online/CompatRules.lua index acf84ca57..8ccd0b410 100644 --- a/src/online/CompatRules.lua +++ b/src/online/CompatRules.lua @@ -33,11 +33,14 @@ CompatRules.FAMILY = { heartgold = "hgss", soulsilver = "hgss", black = "bw", white = "bw", prism = "prism", polishedcrystal = "polishedcrystal", + -- Renegade Platinum rewrites species, moves and the type chart (Fairy): it + -- pairs only with itself, like the Gen 2 hacks + renegadeplatinum = "renegadeplatinum", } CompatRules.GEN = { rby = 1, gsc = 2, prism = 2, polishedcrystal = 2, - rse = 3, frlg = 3, dppt = 4, hgss = 4, bw = 5, + rse = 3, frlg = 3, dppt = 4, hgss = 4, bw = 5, renegadeplatinum = 4, } -- family -> family -> link kind @@ -46,6 +49,7 @@ local LINKS = { gsc = { gsc = "same", rby = "timecapsule" }, prism = { prism = "same" }, polishedcrystal = { polishedcrystal = "same" }, + renegadeplatinum = { renegadeplatinum = "same" }, rse = { rse = "same", frlg = "family" }, frlg = { frlg = "same", rse = "family" }, dppt = { dppt = "same", hgss = "family" }, diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 25dd213b1..792e0b2b3 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -244,6 +244,10 @@ end -- same answer and had been getting three different ones: the save codec, the -- battle, and the menus. local SHINY_GEN3_UNDER = 8 +-- ...unless the cartridge says otherwise: Renegade Platinum patches +-- Pokemon_IsPersonalityShiny to compare against 128 (1/512). Data:load hands +-- in constants.shinyUnder; nil restores the retail eight. +local shinyUnder = SHINY_GEN3_UNDER local function xor16(a, b) if bit then return bit.band(bit.bxor(a, b), 0xFFFF) end @@ -267,9 +271,15 @@ function Stats.shinyValue(personality, otId) return xor16(xor16(lo(otId), hi(otId)), xor16(lo(personality), hi(personality))) end +function Stats.setShinyUnder(n) + n = tonumber(n) + shinyUnder = (n and n >= 1 and n <= 65536) and math.floor(n) or SHINY_GEN3_UNDER +end +function Stats.shinyUnder() return shinyUnder end + function Stats.isShinyGen3(personality, otId) if personality == nil or otId == nil then return false end - return Stats.shinyValue(personality, otId) < SHINY_GEN3_UNDER + return Stats.shinyValue(personality, otId) < shinyUnder end -- The personality that makes THIS trainer's Pokemon shiny while changing as @@ -299,7 +309,7 @@ function Stats.shinyPersonality(personality, otId, rng) local otFold = xor16(otId % 65536, math.floor(otId / 65536) % 65536) local wantNature = personality % 25 local candidates = {} - for want = 0, SHINY_GEN3_UNDER - 1 do + for want = 0, shinyUnder - 1 do local hi = xor16(xor16(otFold, lo), want) local pid = hi * 65536 + lo if pid % 25 == wantNature then return pid end diff --git a/src/script/Gen4SpearPillarScene.lua b/src/script/Gen4SpearPillarScene.lua index ed938457e..a5002f21c 100644 --- a/src/script/Gen4SpearPillarScene.lua +++ b/src/script/Gen4SpearPillarScene.lua @@ -39,7 +39,7 @@ M.FIELD_FADE_STEPS = 6 local function platinum() local ok, GV = pcall(require, "src.core.GameVersion") - return ok and GV.get and GV.get() == "platinum" + return ok and GV.isPlatinumFamily and GV.isPlatinumFamily() or false end function Commands.g4_spear_pillar_cutscene(ctx) diff --git a/src/ui/Gen4Pokedex.lua b/src/ui/Gen4Pokedex.lua index 5a281300b..e3d272eb4 100644 --- a/src/ui/Gen4Pokedex.lua +++ b/src/ui/Gen4Pokedex.lua @@ -1020,7 +1020,10 @@ function Gen4Pokedex:drawDetails() end local TYPE_ANIM = { NORMAL=0, FIRE=1, GRASS=2, WATER=3, ELECTRIC=4, ROCK=5, FIGHTING=6, GHOST=7, MYSTERY=7, GROUND=8, STEEL=9, POISON=10, - BUG=11, DARK=12, ICE=13, FLYING=14, PSYCHIC=15, DRAGON=16 } + BUG=11, DARK=12, ICE=13, FLYING=14, PSYCHIC=15, DRAGON=16, + -- Renegade Platinum appends a 19th sequence to zukan's type_icons and its + -- overlay 21 switch sends type 9 (FAIRY there) to it + FAIRY=18 } function Gen4Pokedex:drawType(name, x, y) local sequence = TYPE_ANIM[tostring(name):upper()] if sequence == nil then return end diff --git a/tools/rom_manifest_renegadeplatinum.json b/tools/rom_manifest_renegadeplatinum.json new file mode 100644 index 000000000..d9934a859 --- /dev/null +++ b/tools/rom_manifest_renegadeplatinum.json @@ -0,0 +1,17 @@ +{ + "format": 4, + "generation": 4, + "romSha1": "381f9eef44564602a5f6469ee8c390fbb8ebac3a", + "gameCode": "CPUE", + "title": "POKEMON PL", + "baseRomSha1": "0862ec35b24de5c7e2dcb88c9eea0873110d755c", + "notes": [ + "Renegade Platinum (Drayano), a ROM hack of Pokemon Platinum (USA) Rev 1.", + "A Nintendo DS cartridge is a filesystem: every extraction stage opens a file by", + "its path, and the hack keeps Platinum's paths, so this manifest carries only which", + "dump is this game -- RomImporter reads romSha1. The file is the trimmed patched", + "ROM (104,922,516 bytes); its header still says CPUE revision 1.", + "What differs from the base cartridge, and how the extractor handles it:", + "src/import/Gen4Versions.lua (renegadeplatinum) and docs/renegade-platinum.md." + ] +} diff --git a/tools/rom_revisions_check.lua b/tools/rom_revisions_check.lua new file mode 100644 index 000000000..cef66e13d --- /dev/null +++ b/tools/rom_revisions_check.lua @@ -0,0 +1,79 @@ +-- GameVersion (the acceptance gate) and RomRevisions (what the launcher says +-- about a dump) must name the SAME hashes: every accepted row is a hash +-- GameVersion accepts for that version, and every hash GameVersion accepts is +-- an accepted row. Also pins the Renegade Platinum registration: its own +-- version on Platinum's engine, its own cache and save names. +-- +-- python tools/run_lua_check.py tools/rom_revisions_check.lua +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") +local RomRevisions = require("src.import.RomRevisions") + +-- every RomRevisions row is accepted by GameVersion for the same version +for id, rows in pairs(RomRevisions.ACCEPTED) do + check(GameVersion.info(id) ~= nil, "RomRevisions names a registered version: " .. id) + for _, row in ipairs(rows) do + check(GameVersion.acceptsSha1(id, row.sha1), ("%s accepts %s (%s)"):format(id, row.sha1, row.label)) + eq(GameVersion.forSha1(row.sha1), id, "forSha1 -> " .. id .. " for " .. row.label) + end +end + +-- every hash GameVersion accepts has a RomRevisions row +for id, info in pairs(GameVersion.VERSIONS) do + local hashes = { info.sha1 } + for _, alt in ipairs(info.sha1Alt or {}) do hashes[#hashes + 1] = alt end + for _, sha1 in ipairs(hashes) do + local game = RomRevisions.acceptedRow(sha1) + eq(game, id, ("RomRevisions has a row for %s's %s"):format(id, sha1)) + end + check(RomRevisions.GAME_NAMES[id] ~= nil, "RomRevisions.GAME_NAMES names " .. id) + -- RomImporter's decodeManifest asserts the manifest's romSha1 is accepted + if info.manifest then + local f = io.open(info.manifest, "rb") + local raw = f and f:read("*a") + if f then f:close() end + local sha1 = raw and raw:match('"romSha1"%s*:%s*"(%x+)"') + check(sha1 and GameVersion.acceptsSha1(id, sha1), ("%s's manifest %s names an accepted dump"):format(id, info.manifest)) + end +end + +-- every ORDER entry is registered, once +local seen = {} +for _, id in ipairs(GameVersion.ORDER) do + check(GameVersion.info(id) ~= nil, "ORDER entry is registered: " .. id) + check(not seen[id], "ORDER lists " .. id .. " once") + seen[id] = true +end + +-- Renegade Platinum: its own version, Platinum's engine +local RP = "renegadeplatinum" +local rp = GameVersion.info(RP) +check(rp ~= nil, "renegadeplatinum is registered") +eq(GameVersion.forSha1("381f9eef44564602a5f6469ee8c390fbb8ebac3a"), RP, "the Renegade dump is its own version") +eq(GameVersion.forSha1("0862ec35b24de5c7e2dcb88c9eea0873110d755c"), "platinum", "Platinum Rev 1 stays Platinum") +eq(GameVersion.generation(RP), 4, "Renegade is Gen 4") +eq(GameVersion.family(RP), "platinum", "Renegade runs Platinum's family") +check(GameVersion.isPlatinumFamily(RP), "isPlatinumFamily(renegadeplatinum)") +check(GameVersion.isPlatinumFamily("platinum"), "isPlatinumFamily(platinum)") +check(not GameVersion.isPlatinumFamily("heartgold"), "HeartGold is not Platinum's family") +check(not GameVersion.isPlatinumFamily("crystal"), "Crystal is not Platinum's family") +check(rp.cachePrefix ~= GameVersion.info("platinum").cachePrefix, "Renegade has its own cache") +check(rp.saveSuffix ~= GameVersion.info("platinum").saveSuffix, "Renegade has its own saves") +check(rp.importable == true and rp.dualScreen == true, "Renegade imports and has two screens") +eq(require("src.import.Gen4Versions").family(RP), "platinum", "the extractor reads Renegade as Platinum") +check(seen[RP], "Renegade has a launcher column") + +-- an unaccepted CPUE file is still described as Platinum +local header = string.rep("\0", 0x200) +local function put(s, at, bytes) return s:sub(1, at) .. bytes .. s:sub(at + #bytes + 1) end +header = put(header, 0x00, "POKEMON PL") +header = put(header, 0x0C, "CPUE") +header = put(header, 0x1E, "\1") +header = put(header, 0x15C, "\86\207") +local d = RomRevisions.describe(header, string.rep("0", 40)) +eq(d and d.game, "platinum", "an unknown CPUE dump is described as Platinum") + +T.finish() From 7a5d6d16f7fc59325240993fdde434bc3dae0da0 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:43:23 +0100 Subject: [PATCH 22/60] Document Renegade Platinum support; battle harness SLOT docs/renegade-platinum.md: the accepted file, what the hack changes against Platinum Rev 1 and how each change is handled, and what is not verified (overlay 73's species swap, trainers 928+ dialogue, story). README supported-games table and What's New, docs/rom-revisions.md row. tools/gen4_battle_harness: SLOT=n holds the move cursor on slot n so a chosen matchup can be fought. --- README.md | 7 +- docs/renegade-platinum.md | 133 +++++++++++++++++++++++++++++ docs/rom-revisions.md | 1 + tools/gen4_battle_harness/main.lua | 4 + 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 docs/renegade-platinum.md diff --git a/README.md b/README.md index 4cd31e7c0..3e6c719e8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ items, and the Gold/Silver title sequence. It has since grown well past Generation II. The same launcher now imports and runs **FireRed** and **Emerald** (Gen 3), **Platinum**, **HeartGold** and **SoulSilver** (Gen 4), and **Black** and **White** (Gen 5), plus the -**Polished Crystal** and **Prism** romhacks — all side by side, all from your +**Polished Crystal**, **Prism** and **Renegade Platinum** romhacks — all side by side, all from your own cartridge dumps. Around the games sit the **Monster Walker** (a next-gen Pokéwalker with minigames and a Watt Shop), a **Map Editor**, a **Save Editor**, machine **translation** into 25 languages, **cloud saves**, @@ -84,6 +84,7 @@ revision) so you know why — see [docs/rom-revisions.md](docs/rom-revisions.md) | SoulSilver (USA) | DS | Pre-alpha | `f8dc38ea20c17541a43b58c5e6d18c1732c7e582` | | Black (USA/EU) | DS | Pre-alpha | `26ad0b9967aa279c4a266ee69f52b9b2332399a5` | | White (USA/EU) | DS | Pre-alpha | `bc696a0dfb448c7b3a8a206f0f8214411a039208` | +| Renegade Platinum (over Platinum USA Rev 1) | DS | Pre-alpha | `381f9eef44564602a5f6469ee8c390fbb8ebac3a` | HeartGold, SoulSilver, Black and White dumps that have had their trailing `FF` padding trimmed are also accepted, as long as every game byte is intact — see @@ -123,6 +124,10 @@ Everything below arrived since this README was last updated. including Professor Juniper's intro in BW — as **PRE-ALPHA** builds. - **Polished Crystal** (beta) and **Prism** (experimental) romhacks, with audio, overworld rendering and feature-parity fixes. +- **Renegade Platinum** (pre-alpha) imports as its own game on the Platinum + engine. It has the hack's species, moves, trainers and encounters, the + Fairy type (its chart, names and icons) and its 1/512 shiny odds. See + [docs/renegade-platinum.md](docs/renegade-platinum.md). - **Mass ROM import**: pick a folder's worth of dumps at once. ### Monster Walker diff --git a/docs/renegade-platinum.md b/docs/renegade-platinum.md new file mode 100644 index 000000000..d01967ab5 --- /dev/null +++ b/docs/renegade-platinum.md @@ -0,0 +1,133 @@ +# Renegade Platinum + +Drayano's **Renegade Platinum** is a ROM hack of Pokemon Platinum (USA) Rev 1. +It imports as its own version, `renegadeplatinum`. It has its own cache +(`renegadeplatinum/`), its own saves (`save_renegadeplatinum.lua`) and its own +launcher card ("RP", under *Pokemon ROM hacks*). It runs on the Platinum +engine: `GameVersion.family("renegadeplatinum") == "platinum"`, and +`GameVersion.isPlatinumFamily()` is what Platinum-only paths check. + +Stage: **PRE-ALPHA**, the same as Platinum. It imports, boots to the field +and fights battles, but nobody has played it through. + +## The accepted file + +| | | +|---|---| +| SHA-1 | `381f9eef44564602a5f6469ee8c390fbb8ebac3a` | +| Size | 104,922,516 bytes (trimmed) | +| Header | `POKEMON PL`, game code `CPUE`, revision 1 (the base cartridge's) | +| Base | Platinum (USA) Rev 1, `0862ec35b24de5c7e2dcb88c9eea0873110d755c` | + +The header is the retail one, so the hack can only be told apart by its hash. +A different build of the hack has a different hash. The launcher then +describes it as a modified Platinum dump, and it does not import. + +## What the hack changes, measured against Platinum Rev 1 + +Filesystem: the paths are all the same. One DP leftover, +`/poketool/personal/personal.narc`, is gone, and nothing reads it. The +archives that differ: + +| Archive | Change | Handled by | +|---|---|---| +| `pl_personal.narc` | 507 of 508 records (stats, types, abilities) | the species stage, unchanged | +| `wotbl.narc`, `evo.narc` | learnsets (503), evolutions (40) | unchanged | +| `pl_waza_tbl.narc` | 117 moves (type, power, new Fairy moves in old slots) | unchanged | +| `pl_item_data.narc` | 108 items | unchanged | +| `pl_enc_data.narc` | 118 of 183 encounter tables | unchanged | +| `trdata.narc` / `trpoke.narc` | 928 -> **1066** trainers, 698 parties rewritten | already sized by the archive; `constants.trainerOrder` now is too | +| `scr_seq.narc`, `zone_event.narc` | 215 scripts, 163 event files | unchanged (no new script commands: the ARM9 command table is untouched) | +| `pl_msg.narc` | 222 banks, trainer names 928 -> 1201 | unchanged | +| `pl_poke_icon.narc` | 539 icons redrawn | see *Icon palettes* | +| `pl_batt_obj.narc` | `type_icons/mystery` redrawn as FAIRY, `type_icons/shared.NCLR` | see *Fairy* | +| `zukan.narc` | a 19th Pokedex type icon | see *Fairy* | + +Code: the ARM9 differs in 266 bytes, and overlays 11, 16, 21 and 73 differ. +No overlay was added and none changed its load address. + +## Fairy + +Renegade turns the unused `???` type (slot 9) into **FAIRY**. Everything the +extractor reads agrees on that: + +- The type-name bank (624) says `FAIRY` at 9. `Gen4Versions.TYPE_SLOTS` + renames slot 9 in `Gen4TypeChart.TYPES` for this version. Species types, + move types and the chart's matchups are therefore written as `"FAIRY"`, not + as `"MYSTERY"` (Gen4Damage treats `MYSTERY` as typeless and deals it no + damage). +- 20 species carry the type: the Clefairy, Jigglypuff, Togepi, Marill, + Snubbull and Ralts lines, Mr. Mime, Mime Jr. and Mawile. 8 moves carry it: + Play Rough, Draining Kiss, Sweet Kiss, Charm, Moonlight, Dazzling Gleam, + Disarming Voice and Moonblast. +- **The chart moved.** Overlay 16 still holds Platinum's 110-row chart, but + every literal that pointed at it (`0x0226ECD4/5/6`) now points at + `0x021E9F80/1/2`. That address is a dense 18x18 chart, Fairy row and + column included, which the hack appended to overlay 21. The chart also has + Gen 6's Steel changes, so Ghost and Dark now hit Steel neutrally. + `Gen4TypeChart.follow` handles this generically. If the overlay no longer + references the chart `find` located, it resolves the `X, X+1, X+2` literal + triple, parses there (1x rows allowed) and drops the neutral rows. On + retail cartridges the pointer is still present, so nothing changes. +- Icons: the battle and summary extractors also save the slot-9 member under + the slot's name (`type_icons_fairy`, `type_fairy`). The Pokedex maps FAIRY + to the hack's new 19th type-icon sequence (18), as the hack's overlay 21 + switch does. +- Move buttons: overlay 11's `sMovePaletteTable` entry for slot 9 is + re-coloured. It is read by type id, so Fairy moves get the hack's colours + without any code change. +- The AI: `Gen4AI.TYPE.FAIRY = 9` resolves the chart's names to ids. + Hidden Power still skips slot 9. The hack changes nothing in overlay 16 + except the chart pointers, so its Hidden Power can never be Fairy either. + +Verified in the battle harness: Moonblast against Dragonair is "super +effective", and Dragon Pulse against Clefairy "doesn't affect" it. + +## Other ARM9 / overlay changes and how each is handled + +| Where | What | Status | +|---|---|---| +| ARM9 `0x02075E5x` | `Pokemon_IsPersonalityShiny` compares against 128, not 8 (1/512) | extracted as `constants.shinyUnder` (found by its instruction run); `Stats.setShinyUnder` applies it | +| ARM9 `sPokemonIconPaletteIndex` | 86 icon palette assignments changed for the new icon art | `Gen4Icons.VERIFIED_BY_VERSION.renegadeplatinum`: the table is where Platinum keeps it, and 11 of the 14 anchors still hold | +| ARM9 `sGameCornerPrizeData` | the first prize (Silk Scarf) replaced; others re-priced | `Gen4GameCorner.SIGNATURE_ROW1` finds the table by rows 1..3 | +| ARM9 TM/HM table | several TMs changed | already found by its HM tail | +| ARM9 mart tables | Veilstone floors restocked | already found by shape (`martSpecialties`) | +| ARM9 map headers | flags / weather bytes on some maps | read at import, as before | +| Overlay 16 | the chart pointers, nothing else | handled (above) | +| Overlay 21 | the appended chart, the Pokedex type-icon switch | handled (above) | +| Overlay 11 | the Fairy move-button palette | read by type id | +| Overlay 73 | two literals change species 427 (Buneary) to 133 (Eevee) | **not handled**: probably Amity Square's walking partners; `PtMiscCommands.AMITY_POOL_OF` is still Platinum's list | + +## Not verified yet + +- Nothing has been played past the first battles. Scripts, events and the + 1066 trainers import, and the overworld harness boots Twinleaf. The + hack's own story edits (rewritten scripts and new trainers 928..1065) + have not been walked. +- Overlay 73 (above). +- Trainer dialogue: `trtblofs` was not extended. Trainers 928..1065 have no + dialogue records, and the hack's scripts may give them lines another way. +- Data that is not read from these tables was not checked against the hack. + This covers hardcoded vanilla tables in the engine, for example the Battle + Hall's type grid, which still has no slot-9 column. + +## Checking it + +``` +POKEPORT_IDENTITY=pl_rp POKEPORT_VERSION=renegadeplatinum \ + POKEPORT_IMPORT_ROM= POKEPORT_IMPORT_ONLY=1 \ + POKEPORT_NO_BOOT_REPORT=1 lovec . +INSTALL=/renegadeplatinum VERSION=renegadeplatinum MAP=T01 \ + lovec tools/gen4_hgss_overworld_harness +POKEPORT_DATA_DIR=/renegadeplatinum/data/generated \ + POKEPORT_ASSET_ROOT=/renegadeplatinum VERSION=renegadeplatinum \ + SPECIES=148 LEVEL=30 PARTY=282 PARTYLEVEL=50 SLOT=2 TICKS=1500 PRESS=15 \ + MSGTRACE=1 lovec tools/gen4_battle_harness +python tools/run_lua_check.py tools/rom_revisions_check.lua +``` + +The battle harness's `SLOT=n` keeps the cursor on move *n*, so a chosen +matchup can be fought. + +A Platinum USA import made with these changes is byte-identical to one made +without them, apart from the new `constants.shinyUnder = 8`. diff --git a/docs/rom-revisions.md b/docs/rom-revisions.md index 843c10ba1..f946cc9c3 100644 --- a/docs/rom-revisions.md +++ b/docs/rom-revisions.md @@ -30,6 +30,7 @@ other revisions. | Crystal | Rev 0 (USA) `f4cd194b`, Rev 1 (USA, Europe) `f2f52230` | (Australia) BYTU `a0fc810f` | It is built with `_CRYSTAL_AU`, which rewrites the Celadon/Goldenrod Game Corner and Buena scripts (15 sites in 5 files). The map-script banks then no longer match `pokecrystal11.sym`, and no AU symbol table has been built. | | Prism | the one mapped release `75207669` | other Prism releases | Romhack tables were found one by one for this file | | Polished Crystal | 3.2.3 `6930b48a` | other releases | Same reason as Prism | +| Renegade Platinum | the trimmed patched Rev 1 file `381f9eef` (header CPUE rev 1) | other builds of the hack | Measured against this one file ([renegade-platinum.md](renegade-platinum.md)); a different build reads as a modified Platinum dump | | FireRed | Rev 0 (USA, Europe) `41cb23d8` | Rev 1 `dd5945db`, Switch Online build (header rev 10) `baa452d0` | See below | | Emerald | (USA, Europe) `f3ae0881` | none (one release) | n/a | | Platinum | Rev 0 `ce81046e`, Rev 1 `0862ec35` (USA) | (Europe) CPUP | No dump or symbols to map it against | diff --git a/tools/gen4_battle_harness/main.lua b/tools/gen4_battle_harness/main.lua index 0096eaf51..41c8c73d8 100644 --- a/tools/gen4_battle_harness/main.lua +++ b/tools/gen4_battle_harness/main.lua @@ -319,9 +319,13 @@ function love.load() end end local ticks = tonumber(os.getenv("TICKS") or "30") + -- SLOT=n: the move the presses pick (the cursor held on slot n), so a + -- matchup can be fought rather than whatever sits in slot 1 + local slot = tonumber(os.getenv("SLOT") or "") for i = 1, ticks do if os.getenv("PARITY_ROM") and battle.phase=="menu" then break end pressed = press > 0 and (i % press == 0) + if slot then battle.moveIndex = slot end local okU, errU = pcall(battle.update, battle, 1 / 60) if not okU then print(("update raised on tick %d: %s"):format(i, tostring(errU))) From 134566c0c845985bac06b0e5a9cdfad002515579 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:44:39 +0100 Subject: [PATCH 23/60] Playthrough runner: PT_CONTINUE saves and continues between beats With PT_CONTINUE=1 every passed beat is followed by the field's captureSave, the save through the codec and Game:restoreSave, as the title's CONTINUE does, so the next beat runs on what a reload brings back. All 91 Platinum beats pass that way too. Notes in docs/platinum-parity-progress.md. --- docs/platinum-parity-progress.md | 19 +++++++++++++++++++ tools/gen4_playthrough/runner.lua | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/platinum-parity-progress.md b/docs/platinum-parity-progress.md index 48d8a0be0..5f4c5b10f 100644 --- a/docs/platinum-parity-progress.md +++ b/docs/platinum-parity-progress.md @@ -2156,3 +2156,22 @@ pokeplatinum. and places her on the arrival floor at the world tile she rode on (B7F: she then walks to exactly the tile B7F's own table gives her at progress 8). Check: `tools/gen4_dist_surface_check.lua /data/generated` (53). +- **The playthrough now goes past the credits** (91 beats, all passing in one + unbroken run on USA Rev 1). New beats: Eterna Forest with Cheryl as the + partner (her two-on-two trainers, the Soothe Bell); the Elite Four and the + Champion walked room to room — out by the door each win removes, up each + lift room's `TriggerPlatformLift`, walked in by the next room's frame + script — instead of teleported into; the credits to the title and a + CONTINUE at the house 2F (Mom's post-game scene); the National Dex at + Rowan's lab (a cheat, said as one: the Sinnoh dex marked seen); the + S.S. Spiral (Cynthia's send-off) to the Fight Area; Barry, Palmer and Buck + there; Route 227 (Crasher Wake and Barry, Buck's patrol request); Stark + Mountain — the grunts, Mars and Jupiter, Buck as the partner through room 2 + (Rock Smash at (41,15), the Strength boulder at (29,12) pushed to (48,12), + his multi battles), Charon's arrest and Looker outside; the Battleground's + Buck; Heatran. Trainer-defeated flags are read from `save.gen4TrainerFlags` + (where the port keeps TRAINER_DEFEATED_FLAGS_START + id), not `save.flags`. +- **`PT_CONTINUE=1`** (tools/gen4_playthrough/runner.lua): save and continue + between every two beats — the field's `captureSave`, the save through the + codec, and `Game:restoreSave` as the title's CONTINUE — so each beat runs on + what a reload brings back. Off by default; shared by every game's beats. diff --git a/tools/gen4_playthrough/runner.lua b/tools/gen4_playthrough/runner.lua index c0729c3da..d7e4953bc 100644 --- a/tools/gen4_playthrough/runner.lua +++ b/tools/gen4_playthrough/runner.lua @@ -892,6 +892,19 @@ function R:snapshot() tostring(self:map()), x or 0, y or 0, tostring(p and p.facing or "down"), body)) f:close() end +function R:continueGame() + local SaveData = require("src.core.SaveData") + local game, ow = self.game, self.ow + if ow and ow.captureSave then ow:captureSave(game.save) end + local okE, body = pcall(SaveData.encode, game.save) + if not okE then self:say(" (continue: encode failed: %s)", tostring(body)) return end + local okD, loaded = pcall(SaveData.decode, body) + if not (okD and type(loaded) == "table") then self:say(" (continue: decode failed)") return end + local okR, err = pcall(game.restoreSave, game, loaded) + if not okR then self:fail("continue: " .. tostring(err)) return end + local x, y = self:pos() + self:say(" saved and continued at %s (%s,%s)", tostring(self:map()), tostring(x), tostring(y)) +end function R:resume(n) local path = self:snapshotPath(n) local okL, snap = pcall(dofile, path) @@ -963,6 +976,15 @@ function R:tick(i, game, ow, planStep) local x, y = self:pos() self:say("PASS %s (%d ticks) at %s (%s,%s)", b.name, self.beatT, tostring(self:map()), tostring(x), tostring(y)) self:snapshot() + -- PT_CONTINUE=1: SAVE AND CONTINUE between every two beats, the way a + -- player stops for the night -- the field's own capture, the save + -- through the codec, and the title's CONTINUE (Game:restoreSave) on + -- it -- so the next beat runs on what a reload brings back, not on + -- the live tables (a var, a flag or an object the save loses fails it) + if os.getenv("PT_CONTINUE") and not self:busy() then + self:continueGame() + if self.done then return nil, nil end + end self:nextBeat() return nil, nil end From 1d3940e3d1a15bd84dac8bfe6998838beab9be4d Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:48:07 +0100 Subject: [PATCH 24/60] Platinum: stamp the met location with the map's label Gen4Origin.stamp got BattleState.metHere, a Gen 3 region-map section that is nil on every Gen 4 map, so every Platinum Pokemon was met nowhere: the Hall of Fame printed "Met at ." and the summary dropped the line. Stamp the label of the map the player stands on (the cartridge's MapHeader_GetMapLabelTextID, a bank 433 index) and name numeric locations in the Hall of Fame and the summary. HGSS keeps its behaviour (another place-name bank). --- docs/platinum-parity-progress.md | 15 ++++++ src/pokemon/Gen4Origin.lua | 37 +++++++++++++ src/ui/Gen4HallOfFame.lua | 3 ++ src/ui/Gen4SummaryMenu.lua | 2 + tools/gen4_met_location_check.lua | 88 +++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 tools/gen4_met_location_check.lua diff --git a/docs/platinum-parity-progress.md b/docs/platinum-parity-progress.md index 5f4c5b10f..4eb676138 100644 --- a/docs/platinum-parity-progress.md +++ b/docs/platinum-parity-progress.md @@ -2175,3 +2175,18 @@ pokeplatinum. between every two beats — the field's `captureSave`, the save through the codec, and `Game:restoreSave` as the title's CONTINUE — so each beat runs on what a reload brings back. Off by default; shared by every game's beats. +- **Platinum Pokémon have a met location again.** The cartridge stamps a + caught, given or hatched Pokémon with the map's label + (`MapHeader_GetMapLabelTextID`, a place-name bank 433 index; egg_hatch.c, + field_battle_data_transfer.c). The port passed `BattleState.metHere`, a + Gen 3 region-map section that is nil on every Gen 4 map, so every Platinum + Pokémon was met nowhere: the Hall of Fame at the end of the story printed + "Met at ." and the summary left the line out. `Gen4Origin.stamp` now takes + the label of the map the player is on (the live field map — `save.player.map` + is only written by a save, so it named wherever the last save was: the + starter came out "met in Twinleaf Town"), and the Hall of Fame and the + summary name a numeric location from bank 433 (which also names the + locations NDS-imported Pokémon carry). HeartGold/SoulSilver, whose place + names are another bank, are left as they were. The starter now reads + "Route 201, Lv. 5". Check: `tools/gen4_met_location_check.lua + [/data/generated]` (13). diff --git a/src/pokemon/Gen4Origin.lua b/src/pokemon/Gen4Origin.lua index 070745295..0cf894e80 100644 --- a/src/pokemon/Gen4Origin.lua +++ b/src/pokemon/Gen4Origin.lua @@ -1,9 +1,46 @@ -- Keep Platinum origin fields when Pokemon are received or hatch. local Origin={} + +-- PLATINUM'S MET LOCATION IS THE MAP'S LABEL. The cartridge stamps a caught, +-- given or hatched Pokemon with MapHeader_GetMapLabelTextID of the map it +-- happens on (egg_hatch.c, field_battle_data_transfer.c's mapLabelTextID): +-- an index into the place-name bank 433, the number an NDS save carries and +-- the Hall of Fame and the summary print by name. The callers hand in +-- BattleState.metHere, which is a Gen 3 region-map section and nil on every +-- Gen 4 map -- so every Platinum Pokemon was met "nowhere": the Hall of Fame +-- printed "Met at ." and the summary left the line out. The label comes +-- from the area popup's row for the map's header (Gen4AreaPopup), which is +-- the header's own labelText. HeartGold / SoulSilver name their places out +-- of another bank, so they keep what they had. +Origin.PLACE_BANK=433 +local function platinumFamily(data) + return data and data.gen4_area_popup~=nil + and not (data.gen4_version and data.gen4_version.family=='hgss') +end +function Origin.hereLabel(game) + local data,save=game and game.data,game and game.save + if not platinumFamily(data) then return nil end + -- the map the player is ON: save.player.map is only written by a save + -- (OverworldState:captureSave), so it still names wherever the last one was + local ow=game.overworld + local here=(ow and ow.map and ow.map.id) or (save and save.player and save.player.map) + local def=here and (data.maps or {})[here] + local row=def and (data.gen4_area_popup.headers or {})[tonumber(def.header) or -1] + return row and tonumber(row.text) or nil +end +-- A numeric met location's name (bank 433), or nil +function Origin.placeName(data,location) + if type(location)~='number' or not platinumFamily(data) then return nil end + local name=(data.text or {})[('TEXT_B%04d_%05d'):format(Origin.PLACE_BANK,location)] + if type(name)~='string' then return nil end + return (name:gsub('[\n\r\f\v]',' ')) +end + function Origin.stamp(game,mon,kind,location,today) if not (game and game.data and (game.data.constants or {}).gen==4 and mon) then return end today=today or os.date('*t') local date={year=today.year,month=today.month,day=today.day} + if kind~='egg' and location==nil then location=Origin.hereLabel(game) end if kind=='egg' then mon.eggDate=mon.eggDate or date mon.eggLocation=mon.eggLocation or location diff --git a/src/ui/Gen4HallOfFame.lua b/src/ui/Gen4HallOfFame.lua index 6a307dc22..c5d5de129 100644 --- a/src/ui/Gen4HallOfFame.lua +++ b/src/ui/Gen4HallOfFame.lua @@ -116,6 +116,9 @@ function HoF:metLine(mon) -- the place name the area sign would show: the map's header, its row in -- the popup table, and that row's bank 433 entry local function place(loc) + -- the cartridge's own record: a place-name index (Gen4Origin.placeName) + local named = require("src.pokemon.Gen4Origin").placeName(self.data, loc) + if named then return named end local map = type(loc) == "string" and (self.data.maps or {})[loc] local rows = (self.data.gen4_area_popup or {}).headers or {} local row = map and rows[tonumber(map.header) or -1] diff --git a/src/ui/Gen4SummaryMenu.lua b/src/ui/Gen4SummaryMenu.lua index c75f2cd5f..ce58b68bc 100644 --- a/src/ui/Gen4SummaryMenu.lua +++ b/src/ui/Gen4SummaryMenu.lua @@ -475,6 +475,8 @@ function Gen4SummaryMenu:memoLines() :gsub('{STRVAR_1 51 0 0}',('%02d'):format(year%100)) end local function locationName(location) + -- a place-name index, the cartridge's own record (Gen4Origin.placeName) + if type(location)=='number' then return require('src.pokemon.Gen4Origin').placeName(data,location) end if type(location)~='string' then return nil end local map=(data.maps or {})[location] return map and (map.name or map.displayName) or location diff --git a/tools/gen4_met_location_check.lua b/tools/gen4_met_location_check.lua new file mode 100644 index 000000000..104685bff --- /dev/null +++ b/tools/gen4_met_location_check.lua @@ -0,0 +1,88 @@ +-- Run: python tools/run_lua_check.py tools/gen4_met_location_check.lua [/data/generated] +-- +-- PLATINUM'S MET LOCATION (src/pokemon/Gen4Origin.lua): a Pokemon caught, +-- given or hatched is stamped with the map's label (MapHeader_GetMapLabelTextID, +-- a place-name bank 433 index), which the Hall of Fame and the summary print +-- by name. Before, every Platinum Pokemon was met nowhere (BattleState.metHere +-- is a Gen 3 region-map section, nil on Gen 4 maps): "Met at ." in the Hall +-- of Fame at the end of the story. With a cache, the real labels are read too. + +package.path = './?.lua;' .. package.path +local Origin = require('src.pokemon.Gen4Origin') + +local PASS, FAIL = 0, 0 +local function check(cond, label) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. label) end +end + +local function dataset(family) + return { + constants = { gen = 4 }, + gen4_version = family and { family = family } or nil, + maps = { R201 = { header = 342 }, T01R0202 = { header = 414 } }, + gen4_area_popup = { headers = { [342] = { text = 17 }, [414] = { text = 4 } } }, + text = { TEXT_B0433_00017 = 'Route 201', TEXT_B0433_00004 = 'Twinleaf\nTown' }, + } +end +local today = { year = 2026, month = 11, day = 14 } + +local game = { data = dataset(), save = { player = { map = 'R201' } } } +local mon = {} +Origin.stamp(game, mon, 'met', nil, today) +check(mon.metLocation == 17, 'a catch on Route 201 is met at its label (17)') +check(Origin.placeName(game.data, mon.metLocation) == 'Route 201', '...which names Route 201') +check(Origin.placeName(game.data, 4) == 'Twinleaf Town', 'a two-line place name prints on one line') + +game.save.player.map = 'T01R0202' +local egg = { isEgg = true } +Origin.stamp(game, egg, 'egg', 'Day-Care Couple', today) +check(egg.eggLocation == 'Day-Care Couple' and egg.metLocation == nil, 'an egg keeps its giver and is not met yet') +Origin.stamp(game, egg, 'hatch', nil, today) +check(egg.metLocation == 4 and egg.hatched, 'it hatches where it hatches (the house, 4)') + +local given = {} +Origin.stamp(game, given, 'met', 'Pal Park', today) +check(given.metLocation == 'Pal Park', 'a location the caller names is kept') +Origin.stamp(game, given, 'met', nil, today) +check(given.metLocation == 'Pal Park', 'and a second receipt does not replace it') + +local hg = { data = dataset('hgss'), save = { player = { map = 'R201' } } } +local hgMon = {} +Origin.stamp(hg, hgMon, 'met', nil, today) +check(hgMon.metLocation == nil and Origin.placeName(hg.data, 17) == nil, + 'HeartGold / SoulSilver (another place-name bank) are left as they were') +check(Origin.placeName(game.data, 'R201') == nil, 'a map id string is not a place-name index') + +-- the map the player is on, not the one the last save names (save.player.map +-- is written by captureSave only): the starter is given on Route 201 with +-- the last save still in the bedroom +local live = { data = dataset(), save = { player = { map = 'T01R0202' } }, + overworld = { map = { id = 'R201' } } } +local starter = {} +Origin.stamp(live, starter, 'met', nil, today) +check(starter.metLocation == 17, 'met where the player stands (Route 201), not where the last save was') + +-- the real labels +local dir = arg and arg[1] +if dir and dir ~= '' then + dir = dir:gsub('[/\\]$', '') + local popup = assert(loadfile(dir .. '/gen4_area_popup.lua'))() + local maps = assert(loadfile(dir .. '/maps.lua'))() + maps = maps.maps or maps + local text = assert(loadfile(dir .. '/text.lua'))() + local real = { constants = { gen = 4 }, maps = maps, gen4_area_popup = popup, text = text } + local function at(map) + local m = {} + Origin.stamp({ data = real, save = { player = { map = map } } }, m, 'met', nil, today) + return m.metLocation, Origin.placeName(real, m.metLocation) + end + local _, r201 = at('R201') + check(r201 == 'Route 201', 'the cache: Route 201 (' .. tostring(r201) .. ')') + local _, verity = at('L01') + check(verity == 'Verity Lakefront', 'the cache: Verity Lakefront (' .. tostring(verity) .. ')') + local _, stark = at('D16R0103') + check(stark == 'Stark Mountain', 'the cache: Stark Mountain (' .. tostring(stark) .. ')') +end + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end From 01d3e381ed8d300676aad518ff56a5daeb27103f Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:51:28 +0100 Subject: [PATCH 25/60] Platinum battles: headless fuzzer, fix doubles locked actions/items, spread announce, draw, Struggle on Taunt/Torment, Encore --- src/battle/BattleState.lua | 93 ++- src/battle/TrainerAI.lua | 1 + tools/gen4_battle_fuzz/conf.lua | 18 + tools/gen4_battle_fuzz/main.lua | 1009 +++++++++++++++++++++++++++++++ tools/gen4_battle_fuzz/run.sh | 13 + 5 files changed, 1125 insertions(+), 9 deletions(-) create mode 100644 tools/gen4_battle_fuzz/conf.lua create mode 100644 tools/gen4_battle_fuzz/main.lua create mode 100644 tools/gen4_battle_fuzz/run.sh diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index cbcdbc1d1..06def0b20 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4137,6 +4137,11 @@ function BattleState:fightLockedAction(battler) if battler.encoreTurns and battler.encoreMove then for _, m in ipairs(battler.curMoves or battler.mon.moves or {}) do if m.id == battler.encoreMove and (m.pp or 0) > 0 then + -- PLATINUM / HGSS: the move itself, which is what every other + -- chosen action is. The `{ move = m }` wrapper has no `id`, so + -- performMove logged "unknown move instance nil" and an Encored + -- Pokemon lost its turn. + if self.g4fx and not self.bw then return m end return { move = m } end end @@ -4181,6 +4186,16 @@ function BattleState:menuBattler() return self:choosingBattler() or self.player end +-- PLATINUM / HGSS: CHECK_INVALID_ALL also counts the TAUNTED and TORMENTED +-- slots (BattleSystem_CheckInvalidMoves), and Struggle is forced when every +-- slot is invalid, so a Torment on a Pokemon whose only usable move was just +-- used -- or a Taunt on one that knows nothing but status moves -- is a +-- Struggle turn. Without this the move menu refused every pick for ever. +function BattleState:g4StatusBlocksMove(who, mvDef) + if not (self.g4fx and not self.bw) then return false end + return self:tauntBlocks(who, mvDef) or self:tormentBlocks(who, mvDef) +end + function BattleState:playerHasPP(who) who = who or self:menuBattler() if not (who and who.curMoves) then return false end @@ -4188,6 +4203,7 @@ function BattleState:playerHasPP(who) -- (PLATINUM / HGSS: a move Gravity or Heal Block refuses is no more a -- choice than a disabled one -- CHECK_INVALID_ALL) if mv.pp > 0 and who.disabledSlot ~= i + and not self:g4StatusBlocksMove(who, self.data.moves[mv.id]) and not (self.g4fx and require("src.battle.Gen4MoveEffects") .menuRefusal(self, who, self.data.moves[mv.id])) and not (self.g3x and require("src.battle.Gen4MoveEffectsExtra").menuRefusal(self, who, self.data.moves[mv.id])) then @@ -4611,7 +4627,12 @@ function BattleState:update(dt) -- activeOn answers the question this menu actually means: somebody on the -- player's side, left flank first. A single battle and an ordinary -- double both get exactly what they got before. - local acting = self.player or self:activeOn(1) + -- PLATINUM / HGSS DOUBLES: the slot being asked, not always the left one. + -- A locked move (Bide, a charge, recharge, Rage, a thrash) belongs to ITS + -- Pokemon; reading the left flank here handed the left's Bide to the + -- right-hand slot, which crashed continueBide (`bideTurns` nil). + local acting = (self.g4fx and self:isDouble() and self:choosingBattler()) + or self.player or self:activeOn(1) if not (acting and acting.mon) then -- nobody left on this side at all: that is the faint flow's business, -- not the menu's, and it is already running. Returning is what keeps @@ -4632,7 +4653,8 @@ function BattleState:update(dt) -- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312) local locked = self:menuLockedAction(acting) if locked then - self:resolveTurn(locked) + -- (a double files it under its own slot and goes on to ask the other) + if self.g4fx and self:isDouble() then self:chooseAction(locked) else self:resolveTurn(locked) end return end -- PLATINUM'S ACTION MENU IS NOT A 2x2, and walking it as one is what a @@ -4706,9 +4728,10 @@ function BattleState:update(dt) elseif choice == "fight" then -- After the menu: own trapping/Bide or foe Wrap skips the move -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) + local g4Double = self.g4fx and self:isDouble() + local fightLock = self:fightLockedAction(g4Double and self:menuBattler() or self.player) if fightLock then - self:resolveTurn(fightLock) + if g4Double then self:chooseAction(fightLock) else self:resolveTurn(fightLock) end return end local who = self:menuBattler() @@ -5914,7 +5937,7 @@ function BattleState:resolveTurn(playerAction) local switches, moves = {}, {} for _, e in ipairs(entries) do local sp = e.action and e.action.special - if sp == "playerSwitch" or sp == "aiSwitch" then + if sp == "playerSwitch" or sp == "aiSwitch" or sp == "playerItem" then switches[#switches + 1] = e else moves[#moves + 1] = e @@ -8003,6 +8026,7 @@ function BattleState:executeAction(user, target, action) -- resolve it to. local isSwitch = action.special == "playerSwitch" or action.special == "aiSwitch" + or action.special == "playerItem" if not isSwitch and not (target and target.mon and target.mon.hp > 0) then return end @@ -8103,6 +8127,11 @@ function BattleState:executeAction(user, target, action) -- actions rather than the whole turn. A single battle never reaches -- here: resolveSwitch does the send-out itself and gives the foe its -- free move, which is the cartridge's single-battle flow. + if action.special == "playerItem" then + for _, m in ipairs(action.messages or {}) do self:sayNext(m) end + self:drainNext() -- potions animate the bar + return + end if action.special == "playerSwitch" then if action.mon and action.mon.hp and action.mon.hp > 0 then self:switchPlayerInto(user.position or BattleState.POS.PLAYER_LEFT, @@ -8182,11 +8211,15 @@ function BattleState:executeAction(user, target, action) end end local animationPlayed = false + local announced = false for i, one in ipairs(list) do if one and one.mon and (one.mon.hp or 0) > 0 then -- only the first announces; the rest are the same move landing again + -- (Platinum / HGSS print the line once, not once per target) self:performMove(user, one, action, i > 1, - spreadAnimation and animationPlayed) + spreadAnimation and animationPlayed, + (self.g4fx and announced) or nil) + announced = true -- FireRed's Cmd_attackanimation increments animTargetsHit only when an -- animation actually survives the no-effect/failure path. If the -- first target cancels it, a later spread target is still allowed to @@ -8469,7 +8502,11 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +-- `suppressAnnounce` (PLATINUM / HGSS, a spread move's later targets): the +-- "X used Y!" line belongs to the move, not to each target -- the cartridge +-- shows it once (SYSCTL_ATTACK_MESSAGE_SHOWN) and loops the hit over the other +-- battlers (BattleControllerPlayer_MoveEnd's RANGE_ALL_ADJACENT arm). +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, suppressAnnounce) local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8548,7 +8585,9 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + if not suppressAnnounce then + self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + end -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the @@ -9184,6 +9223,18 @@ end -- before the first one has played would queue the whole thing again. function BattleState:checkSideBeaten() if self.sideBeaten then return end + -- PLATINUM / HGSS: BOTH SIDES OUT AT ONCE IS A DRAW, AND A DRAW IS PLAYED AS + -- A LOSS. CheckBattleOver ORs BATTLE_RESULT_WIN and _LOSE when every + -- battler's party is empty (Explosion taking the last Pokemon of both + -- sides), and BattleControllerPlayer_HandleResult runs subscript_battle_lost + -- for LOSE and DRAW alike. Checking the foes first made it a win -- paid + -- out, then forced to a blackout by finish() with a warning. + if self.g4fx and not self.bw and not self:sideCanContinue(false) + and not self:sideCanContinue(true) then + self.sideBeaten = "player" + self:act(function() self:playerMonFainted() end) + return + end if not self:sideCanContinue(false) then self.sideBeaten = "enemy" self:act(function() self:enemyMonFainted() end) @@ -9292,7 +9343,13 @@ function BattleState:awardExp(fallen) -- new current HP (house convention: potions drain the bar too, see -- itemUsed) so the bar grows instead. Only the active player battler -- shares its table with the HUD; other party mons (EXP.ALL) have no bar. - if mon == self.player.mon then self:drainNext() end + -- (`self.player` is the LEFT flank and is nil in a double between its + -- faint and the replacement -- the exp is paid right then) + local onField = self.player and mon == self.player.mon + for _, b in activeBattlers(self) do + if b and b.isPlayer and b.mon == mon then onField = true end + end + if onField then self:drainNext() end for _, moveId in ipairs(Experience.movesLearnedAt( self.data.pokemon[mon.species], lv)) do self:learnMove(mon, moveId) @@ -9665,6 +9722,13 @@ function BattleState:enemyMonFainted() end end self.result = "win" + -- PLATINUM / HGSS: the last foe going down in the same breath as the last + -- of the player's own Pokemon (recoil, Explosion, a sandstorm tick) is a + -- DRAW, and the cartridge plays a draw as a loss (HandleResult). playerMonFainted + -- queues the blackout; the result has to agree with it. + if self.g4fx and not self.bw and not self.ally and not Party.firstHealthy(self.game.save.party) then + self.result = "lose" + end self.afterQueue = "finish" end @@ -10308,6 +10372,17 @@ function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD -- once control returns (pokered DrawHUDsAndHPBars after item use) self:syncShownStatus() + -- PLATINUM / HGSS DOUBLES: an item is one of the four actions, not the whole + -- turn. It is filed under the slot that used it and run with the switches + -- (BattleControllerPlayer_CalcTurnOrder: items and party first, fights + -- after), while the other three slots still choose and move. Running it as + -- a turn of its own lost the partner's move and the right foe's, and left + -- the half-collected `pendingActions` behind for the NEXT turn to execute + -- (a stale Bide on a Pokemon that never used it crashed continueBide). + if self.g4fx and not self.bw and self:isDouble() then + self:chooseAction({ special = "playerItem", messages = messages or {} }) + return + end for _, m in ipairs(messages or {}) do self:say(m) end table.insert(self.queue, { drain = true }) -- potions animate the bar self:act(function() diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index bb411baff..3587e6d64 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -341,6 +341,7 @@ function TrainerAI.chooseMove(battler, rng, battle) local g4 = battle and battle.g4fx and require("src.battle.Gen4MoveEffects") for i, mv in ipairs(battler.curMoves) do if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) + and not (g4 and battle.g4StatusBlocksMove and battle:g4StatusBlocksMove(battler, battle.data.moves[mv.id])) and not (g4 and g4.menuRefusal(battle, battler, battle.data.moves[mv.id])) and not (battle and battle.g3x and require("src.battle.Gen4MoveEffectsExtra").menuRefusal(battle, battler, battle.data.moves[mv.id])) then diff --git a/tools/gen4_battle_fuzz/conf.lua b/tools/gen4_battle_fuzz/conf.lua new file mode 100644 index 000000000..ed244c20b --- /dev/null +++ b/tools/gen4_battle_fuzz/conf.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Cedric. All rights reserved. +-- Source-available under the Gen2Recomped License (see LICENSE.md): you may +-- read, build and privately modify this file; you may not redistribute it or +-- use it commercially. Cartridge-derived data is excluded and is not the +-- copyright holder's to license. + +function love.conf(t) + -- One identity for every fuzz run so the save directory is predictable. + -- POKEPORT_IDENTITY overrides it exactly as the main game's conf does. + t.identity = os.getenv("POKEPORT_IDENTITY") or "pl_battle" + t.console = true + t.window.width, t.window.height = 512, 384 + t.window.title = "gen4 battle fuzz" + t.modules.joystick = false + t.modules.physics = false + t.modules.video = false + t.modules.touch = false +end diff --git a/tools/gen4_battle_fuzz/main.lua b/tools/gen4_battle_fuzz/main.lua new file mode 100644 index 000000000..207e05186 --- /dev/null +++ b/tools/gen4_battle_fuzz/main.lua @@ -0,0 +1,1009 @@ +-- Copyright (c) 2026 Cedric. All rights reserved. +-- Source-available under the Gen2Recomped License (see LICENSE.md): you may +-- read, build and privately modify this file; you may not redistribute it or +-- use it commercially. Cartridge-derived data is excluded and is not the +-- copyright holder's to license. + +-- tools/gen4_battle_fuzz/main.lua +-- +-- A HEADLESS PLATINUM BATTLE FUZZER. +-- +-- Plays whole battles to their end through the real BattleState, the real +-- queue, the real menus' input path and the real bag / party screens, with +-- randomised but legal player choices, and reports +-- +-- error a Lua error (with traceback), from update / draw / a screen +-- hang no turn finished and no menu reached inside the frame budget +-- invalid HP < 0 / > max / NaN, PP < 0, a nil move, an empty party ... +-- stall the battle was still going after --max-turns (information only) +-- +-- POKEPORT_DATA_DIR=/data/generated POKEPORT_ASSET_ROOT= \ +-- POKEPORT_IDENTITY=pl_battle \ +-- "C:/Program Files/LOVE/lovec.exe" tools/gen4_battle_fuzz \ +-- --seed 1 --count 200 --mode all +-- +-- Options (also readable from FUZZ_ environment variables): +-- --seed N first seed (battle i uses seed N+i-1) [1] +-- --count N battles to run [100] +-- --mode M trainers | doubles | singles | wild | tag | all [all] +-- (trainers = every trainer id in the table, in order, +-- looping; `all` mixes everything by seed) +-- --trainer ID run just this trainer (with --count seeds) +-- --species ID force the wild species +-- --anims 0|1 battle animations on (1) or off (0) [0] +-- --ui U bottom | field | mixed: which bag/party screens [mixed] +-- --draw K also draw the battle every K frames (0 = never) [0] +-- --max-turns N turns before a battle is counted as a stall [300] +-- --hang-frames N frames without a turn or item use before a hang [6000] +-- --verbose one line per battle +-- --trace print every message the battle prints +-- --stop-on-fail stop at the first failure +-- +-- Exit status is 1 when any error / hang / invalid failure was found. + +local ARGS = {} +local function parseArgs(list) + local i = 1 + while i <= #list do + local a = list[i] + local key = a:match("^%-%-(.+)$") + if key then + local nxt = list[i + 1] + if nxt and not nxt:match("^%-%-") then ARGS[key] = nxt; i = i + 2 + else ARGS[key] = true; i = i + 1 end + else + i = i + 1 + end + end +end + +local function opt(name, default) + local v = ARGS[name] + if v == nil then v = os.getenv("FUZZ_" .. name:upper():gsub("%-", "_")) end + if v == nil then return default end + if type(default) == "number" then return tonumber(v) or default end + return v +end + +local function out(fmt, ...) + io.stdout:write((select("#", ...) > 0 and fmt:format(...) or fmt), "\n") + io.stdout:flush() +end + +-- --------------------------------------------------------------------------- +-- the world the battle needs +-- --------------------------------------------------------------------------- +local Data, Game, BattleState, Pokemon, SaveData, Party, StateStack +local typeNames = {} +local screensSeen +local movesSeen, abilitiesSeen = {}, {} + +local keysDown = {} +local fakeInput = setmetatable({}, { __index = function() + return function() return false end +end }) +fakeInput.wasPressed = function(_, k) return keysDown[k] == true end +fakeInput.isDown = function() return false end +fakeInput.wasReleased = function() return false end + +-- the move / species / item ids the cache has, sorted for determinism +local moveIds, speciesIds, battleItemIds, ballIds, heldItemIds = {}, {}, {}, {}, {} + +local function sortedNumericKeys(t, pred) + local keys = {} + for k, v in pairs(t or {}) do + if type(k) == "number" and (not pred or pred(k, v)) then keys[#keys + 1] = k end + end + table.sort(keys) + return keys +end + +local function installAssetRoot() + local assetRoot = os.getenv("POKEPORT_ASSET_ROOT") + if not assetRoot then return end + local Assets = require("src.render.Assets") + local realImage = Assets.image + local held = {} + Assets.image = function(path, ...) + if type(path) == "string" and not held[path] then + local f = io.open(assetRoot .. "/" .. path, "rb") + if f then + local bytes = f:read("*a"); f:close() + local okI, img = pcall(love.graphics.newImage, + love.filesystem.newFileData(bytes, path)) + if okI then img:setFilter("nearest", "nearest"); held[path] = img end + end + end + return held[path] or realImage(path, ...) + end + local realData = Assets.imageData + local heldData = {} + Assets.imageData = function(path, ...) + if type(path) == "string" and not heldData[path] then + local f = io.open(assetRoot .. "/" .. path, "rb") + if f then + local bytes = f:read("*a"); f:close() + local okD, d = pcall(love.image.newImageData, love.filesystem.newFileData(bytes, path)) + if okD then heldData[path] = d end + end + end + return heldData[path] or realData(path, ...) + end +end + +-- engine warnings / errors the Logger is told about, by format string: a +-- battle that "worked" but logged `battle finished with no healthy party; +-- forcing blackout` is still worth reading about +local warnings = {} +local currentSeed = 0 +local function hookLogger() + local Logger = require("src.core.Logger") + for _, level in ipairs({ "warn", "error" }) do + local real = Logger[level] + Logger[level] = function(fmt, ...) + local key = level .. ": " .. tostring(fmt) + local w = warnings[key] + if not w then + w = { count = 0, seed = currentSeed, sample = (pcall(string.format, fmt, ...) and string.format(fmt, ...)) or tostring(fmt) } + warnings[key] = w + end + w.count = w.count + 1 + return real(fmt, ...) + end + end +end + +local function setup() + require("src.core.GameVersion").set("platinum") + hookLogger() + installAssetRoot() + Data = require("src.core.Data") + Data:load() + assert(Data.isGen4Cache, "POKEPORT_DATA_DIR is not a Gen 4 cache") + require("src.render.Font").load(Data) + Pokemon = require("src.pokemon.Pokemon") + SaveData = require("src.core.SaveData") + BattleState = require("src.battle.BattleState") + Party = require("src.pokemon.Party") + StateStack = require("src.core.StateStack") + Game = require("src.core.Game") + Game.data = Data + Game.input = fakeInput + Game.stack = StateStack + Game.stack:init() + + moveIds = sortedNumericKeys(Data.moves, function(_, v) return type(v) == "table" and v.name end) + speciesIds = sortedNumericKeys(Data.pokemon, function(k, v) + return k >= 1 and k <= 493 and type(v) == "table" and v.baseStats + end) + for _, id in ipairs(sortedNumericKeys(Data.items)) do + local d = Data.items[id] + if type(d) == "table" and d.name and d.name ~= "" then + if (d.battleUseCategory or 0) ~= 0 and (d.battlePocket or 0) ~= 0 then + battleItemIds[#battleItemIds + 1] = id + if d.pocket == "POKE_BALLS" then ballIds[#ballIds + 1] = id end + end + if (d.holdEffect and d.holdEffect ~= 0) or d.pocket == "BERRIES" or d.pocket == "ITEMS" then + heldItemIds[#heldItemIds + 1] = id + end + end + end + if #heldItemIds == 0 then + for _, id in ipairs(sortedNumericKeys(Data.items)) do heldItemIds[#heldItemIds + 1] = id end + end +end + +-- --------------------------------------------------------------------------- +-- random parties +-- --------------------------------------------------------------------------- +local rnd -- a per-battle PRNG so a seed alone reproduces a battle + +local function newRng(seed) + -- xorshift-ish LCG on 31 bits; independent of love.math so a battle's + -- COMPOSITION is stable even if the engine's own rolls change + local s = (seed * 2654435761 + 12345) % 2147483647 + if s == 0 then s = 1 end + return function(a, b) + s = (s * 48271) % 2147483647 + local r = s / 2147483647 + if not a then return r end + if not b then a, b = 1, a end + return a + math.floor(r * (b - a + 1)) + end +end + +local function pick(list) return list[rnd(1, #list)] end + +local function randomMon(level, opts) + opts = opts or {} + local species = opts.species or pick(speciesIds) + local mon = Pokemon.new(Data, species, level) + -- random moves from the whole table, so every effect gets reached + if rnd(1, 100) <= (opts.randomMoves or 70) then + mon.moves = {} + local n = rnd(1, 4) + local used = {} + for i = 1, n do + local id + for _ = 1, 10 do id = pick(moveIds); if not used[id] then break end end + used[id] = true + local mdef = Data.moves[id] + mon.moves[i] = { id = id, pp = Pokemon.maxPP(mdef, nil) } + end + end + mon.abilitySlot = rnd(1, 2) + if rnd(1, 100) <= 40 then mon.item = pick(heldItemIds) end + -- a status on the lead now and then + if rnd(1, 100) <= 10 then mon.status = pick({ "PSN", "BRN", "PAR", "SLP", "FRZ", "TOX" }) end + if rnd(1, 100) <= 15 then + mon.hp = math.max(1, math.floor(mon.stats.hp * rnd(1, 100) / 100)) + end + mon.nickname = nil + return mon +end + +local function randomParty(minSize, around) + local n = rnd(minSize, 6) + local party = {} + for i = 1, n do + local level + local r = rnd(1, 100) + if r <= 55 and around then level = math.max(1, math.min(100, around + rnd(-6, 12))) + elseif r <= 80 then level = rnd(5, 60) + elseif r <= 92 then level = rnd(1, 100) + else level = pick({ 1, 2, 3, 50, 99, 100 }) end + party[i] = randomMon(level) + end + return party +end + +local function stockBag(save) + save.inventory = save.inventory or {} + for _, id in ipairs(ballIds) do save.inventory[id] = 5 end + for _, id in ipairs(battleItemIds) do + if rnd(1, 100) <= 45 then save.inventory[id] = rnd(1, 5) end + end + -- always a way to catch and a way to heal + if ballIds[1] then save.inventory[ballIds[1]] = 20 end + save.inventory[4] = 20 + save.inventory[17] = 5 +end + +-- --------------------------------------------------------------------------- +-- invariants +-- --------------------------------------------------------------------------- +local VALID_STATUS = { PSN = true, BRN = true, PAR = true, SLP = true, FRZ = true, TOX = true } + +local function badNumber(v) + return type(v) ~= "number" or v ~= v or v == math.huge or v == -math.huge +end + +local function checkMon(mon, where, problems) + if type(mon) ~= "table" then problems[#problems + 1] = where .. ": not a table"; return end + if mon.stats and mon.stats.hp then + if badNumber(mon.hp) then problems[#problems + 1] = ("%s: hp is %s"):format(where, tostring(mon.hp)) + elseif mon.hp < 0 then problems[#problems + 1] = ("%s: hp %s < 0"):format(where, mon.hp) + elseif mon.hp > mon.stats.hp then problems[#problems + 1] = ("%s: hp %s > max %s"):format(where, mon.hp, mon.stats.hp) + elseif mon.hp % 1 ~= 0 then problems[#problems + 1] = ("%s: fractional hp %s"):format(where, mon.hp) end + for k, v in pairs(mon.stats) do + if type(v) == "number" and (badNumber(v) or v < 0) then + problems[#problems + 1] = ("%s: stat %s = %s"):format(where, tostring(k), tostring(v)) + end + end + end + if mon.status ~= nil and not VALID_STATUS[mon.status] then + problems[#problems + 1] = ("%s: odd status %s"):format(where, tostring(mon.status)) + end + if badNumber(mon.level) or mon.level < 1 or mon.level > 100 then + problems[#problems + 1] = ("%s: level %s"):format(where, tostring(mon.level)) + end + for i, mv in ipairs(mon.moves or {}) do + if mv.id == nil or Data.moves[mv.id] == nil then + problems[#problems + 1] = ("%s: move slot %d has id %s"):format(where, i, tostring(mv.id)) + end + if badNumber(mv.pp) or mv.pp < 0 then + problems[#problems + 1] = ("%s: move slot %d pp %s"):format(where, i, tostring(mv.pp)) + end + end +end + +local function checkInvariants(battle, game) + local problems = {} + local party = game.save.party + if type(party) ~= "table" or #party == 0 then + problems[#problems + 1] = "player party is empty" + else + for i, m in ipairs(party) do checkMon(m, "party[" .. i .. "]", problems) end + end + for i, m in ipairs(battle.enemyParty or {}) do checkMon(m, "foe[" .. i .. "]", problems) end + for _, b in battle:activeBattlers() do + local label = (b.isPlayer and "player " or "foe ") .. tostring(b.name) + if b.stages then + for stat, v in pairs(b.stages) do + if badNumber(v) or v < -6 or v > 6 then + problems[#problems + 1] = ("%s: stage %s = %s"):format(label, tostring(stat), tostring(v)) + end + end + end + for i, mv in ipairs(b.curMoves or {}) do + if mv.id == nil or Data.moves[mv.id] == nil then + problems[#problems + 1] = ("%s: battle move slot %d id %s"):format(label, i, tostring(mv.id)) + end + if badNumber(mv.pp) or mv.pp < 0 then + problems[#problems + 1] = ("%s: battle move slot %d pp %s"):format(label, i, tostring(mv.pp)) + end + end + end + return problems +end + +-- --------------------------------------------------------------------------- +-- driving screens +-- --------------------------------------------------------------------------- +local function screenId(state) + return state and (state.screenId or state.name) or nil +end + +local function partyIndices(game) + local list = {} + for i = 1, #game.save.party do list[#list + 1] = i end + return list +end + +-- the Gen 4 battle bag: use a random battle item (or back out) +local function driveBag(top, battle, game, ctx) + top.fuzzVisits = (top.fuzzVisits or 0) + 1 + local choices = {} + for id, n in pairs(game.save.inventory) do + if n and n > 0 and Data.items[id] and (Data.items[id].battleUseCategory or 0) ~= 0 then + choices[#choices + 1] = id + end + end + table.sort(choices) + if #choices == 0 or ctx.itemBudget <= 0 or rnd(1, 100) <= 10 then + top:close() + ctx.bagCancels = ctx.bagCancels + 1 + return + end + local id + if ctx.wantBall and #ballIds > 0 and (game.save.inventory[ctx.wantBall] or 0) > 0 then + id = ctx.wantBall + else + id = pick(choices) + end + local def = Data.items[id] + if top.useSelected and not top.refusal then + -- the field bag in a battle (single-screen presentation) + ctx.itemBudget = ctx.itemBudget - 1 + ctx.itemUses = ctx.itemUses + 1 + ctx.usedItems[id] = (ctx.usedItems[id] or 0) + 1 + top:useSelected(id) + return + end + ctx.itemBudget = ctx.itemBudget - 1 + ctx.itemUses = ctx.itemUses + 1 + ctx.usedItems[id] = (ctx.usedItems[id] or 0) + 1 + local row = { id = id } + if def.pocket == "POKE_BALLS" and top.POCKET == nil and top.refusal then + local okP, G = pcall(require, "src.ui.Gen4BattleBag") + if okP and G.POCKET then top.pocket = G.POCKET.BALLS end + end + local why = top.refusal and top:refusal(row) + if why then top:close(); return end + local shim = { + items = {}, index = 1, + close = function() top:close() end, + rebuild = function() if top.build then top:build() end end, + } + require("src.ui.BagMenu").useItem(game, battle, id, shim) +end + +local function aliveIdx(game, avoidBattlers, battle) + local standing = {} + for _, b in battle:activeBattlers() do + if b.isPlayer then standing[b.mon] = true end + end + local list = {} + for i, m in ipairs(game.save.party) do + if m.hp > 0 and not Party.isEgg(m) and not standing[m] then list[#list + 1] = i end + end + return list +end + +local function driveParty(top, battle, game, ctx) + local mode = top.mode or (top.pickOnly and "useItem") or "switch" + if screenId(top) ~= "Gen4BattleParty" then + -- the field party list inside a battle: pick the member directly + local party = game.save.party + local good = aliveIdx(game, nil, battle) + local idx + if mode == "useItem" then + idx = pick(partyIndices(game)) + if rnd(1, 100) <= 8 then top:close(); return end + else + if not top.forceSwitch and (#good == 0 or rnd(1, 100) <= 15) then top:close(); return end + if #good > 0 and (top.forceSwitch or rnd(1, 100) <= 85) then idx = pick(good) + else idx = pick(partyIndices(game)) end + end + if os.getenv("FUZZ_DEBUG") then out(" [party] mode=%s forced=%s good=%d idx=%s hp=%s", mode, tostring(top.forceSwitch), #good, tostring(idx), tostring(party[idx] and party[idx].hp)) end + top:handOff(party[idx]) + return + end + local party = game.save.party + if mode == "learnMove" then + local slot = rnd(1, 5) + if top.pop then top:pop() end + if top.onChoose then top.onChoose(slot <= 4 and slot or nil) end + return + end + if mode == "useItem" then + local idx = pick(partyIndices(game)) + local mon = party[idx] + if rnd(1, 100) <= 8 then top:close(); return end + if top.pop then top:pop() end + if top.onMove and not Party.isEgg(mon) then + top.onMove(mon, rnd(1, math.max(1, #mon.moves))) + elseif top.onSwitch then + top.onSwitch(mon, top) + end + return + end + -- switch / forced replacement + local good = aliveIdx(game, nil, battle) + local forced = top.forceSwitch + if not forced and (#good == 0 or rnd(1, 100) <= 15) then top:close(); return end + local idx + if #good > 0 and (forced or rnd(1, 100) <= 85) then idx = pick(good) + else idx = pick(partyIndices(game)) end + local mon = party[idx] + if os.getenv("FUZZ_DEBUG") then out(" [bparty] mode=%s forced=%s good=%d idx=%s hp=%s", mode, tostring(forced), #good, tostring(idx), tostring(mon and mon.hp)) end + if top.handOff then top:handOff(mon) + elseif top.onSwitch then + if top.pop then top:pop() end + top.onSwitch(mon, top) + end +end + +-- anything else on the stack (text boxes, choice boxes, the dex page, ...) +local function driveOverlay(top, ctx) + ctx.overlayFrames = ctx.overlayFrames + 1 + local f = ctx.overlayFrames + if f % 6 == 0 then + local r = rnd(1, 100) + if r <= 70 then keysDown.a = true + elseif r <= 80 then keysDown.b = true + elseif r <= 90 then keysDown.down = true + else keysDown.up = true end + end +end + +-- --------------------------------------------------------------------------- +-- driving the battle's own menus +-- --------------------------------------------------------------------------- +local function movePower(mv) + local d = mv and Data.moves[mv.id] + return d and tonumber(d.power) or 0 +end + +local function driveBattle(battle, game, ctx) + local phase = battle.phase + if phase == "messages" then + if ctx.frame % 2 == 0 then keysDown.a = true end + return + end + if phase == "menu" then + ctx.menuFrames = ctx.menuFrames + 1 + local who = battle:choosingBattler() + if not (who and who.mon) then return end + local actions = battle:menuActions() + -- weights: fight / item / party / run (wild only) + local choice + local r = rnd(1, 100) + local wild = battle.kind == "wild" + local canRun = wild + if ctx.menuTries > 6 then r = 1 end -- fall back to fight when stuck + if r <= 62 then choice = "fight" + elseif r <= 78 then choice = "item" + elseif r <= 90 then choice = "party" + elseif canRun then choice = "run" + else choice = "fight" end + -- in a wild battle, sometimes commit to throwing balls + ctx.wantBall = nil + if choice == "item" and wild and rnd(1, 100) <= 60 and #ballIds > 0 then + ctx.wantBall = pick(ballIds) + end + local idx + for i, a in ipairs(actions) do if a == choice then idx = i end end + if not idx then + for i, a in ipairs(actions) do if a == "fight" then idx = i end end + end + ctx.menuTries = ctx.menuTries + 1 + battle.menuIndex = idx or 1 + keysDown.a = true + ctx.lastChoice = choice + return + end + if phase == "moveSelect" then + local chooser = battle:menuBattler() + local moves = chooser.curMoves or {} + -- prefer damaging moves with PP, but not always + local best, any = {}, {} + for i, mv in ipairs(moves) do + any[#any + 1] = i + if (mv.pp or 0) > 0 and movePower(mv) > 0 then best[#best + 1] = i end + end + local pickFrom = (#best > 0 and rnd(1, 100) <= 70) and best or any + if #any == 0 then keysDown.b = true; return end + battle.moveIndex = pick(pickFrom) + if ctx.moveTries > 12 then keysDown.b = true; ctx.moveTries = 0; ctx.menuTries = 0; return end + ctx.moveTries = ctx.moveTries + 1 + keysDown.a = true + return + end + if phase == "targetSelect" then + local list = battle.targetChoices or {} + battle.targetIndex = rnd(1, math.max(1, #list)) + keysDown.a = true + return + end + if phase == "mimicSelect" then + local moves = battle.mimicMoves or {} + battle.mimicIndex = rnd(1, math.max(1, #moves)) + keysDown.a = true + return + end + ctx.unknownPhases[tostring(phase)] = true + keysDown.a = true +end + +-- --------------------------------------------------------------------------- +-- one battle +-- --------------------------------------------------------------------------- +local trainerIds = {} +screensSeen = {} + +local function trainerLevelAround(rec) + local sum, n = 0, 0 + for _, m in ipairs(rec.party or (rec.parties and rec.parties[1]) or {}) do + sum = sum + (m.level or 1); n = n + 1 + end + return n > 0 and math.floor(sum / n) or 20 +end + +local function buildBattle(spec) + local game = Game + game.save = SaveData.newGame(game.bootConfig and game:bootConfig() or {}) + game.save.options = game.save.options or {} + game.save.options.animations = spec.anims + game.save.options.battleStyle = pick({ "shift", "set" }) + -- which presentation: the bottom-screen battle bag / party (stowed=false) + -- or the single-screen strip with the field bag and party + local ui = spec.ui + if ui == "bottom" or (ui == "mixed" and rnd(1, 100) <= 50) then + game.save.options.secondScreenStowed = false + else + game.save.options.secondScreenStowed = true + end + game.save.options.textSpeed = "fast" + local around = spec.around + local minParty = spec.double and 2 or 1 + game.save.party = randomParty(minParty, around) + -- the lead must be able to fight, the way a real save's lead can + if spec.leadHealthy ~= false then + game.save.party[1].hp = math.max(1, game.save.party[1].hp) + end + stockBag(game.save) + local battle + if spec.kind == "wild" then + battle = BattleState.newWild(game, spec.species, spec.level) + if spec.double then + battle:makeWildDouble(Pokemon.new(Data, spec.second, math.max(1, math.min(100, spec.level + rnd(-3, 3))))) + end + else + battle = BattleState.newTrainer(game, spec.trainer, 1) + if spec.double then battle.double = true end + if spec.tag then + local allyRec = Data.trainers[spec.ally] + local ok, party = pcall(BattleState.buildTrainerParty, game, spec.ally, 1, allyRec.parties[1]) + if ok and party then + battle:setAlly({ name = allyRec.name or "ALLY", party = party, class = allyRec.class, trainerId = spec.ally }) + battle.double = true + end + end + if battle.trainer and battle.trainer.doubleBattle then battle.double = true end + if spec.trainerB then battle:addOpponentTrainer(spec.trainerB) end + if battle.sendOutSecondPair then battle:sendOutSecondPair() end + end + return battle +end + +local function describe(spec) + if spec.kind == "wild" then + return ("wild species=%s lv=%s%s"):format(spec.species, spec.level, spec.double and " double" or "") + end + return ("trainer=%s%s%s"):format(spec.trainer, spec.double and " double" or "", + spec.tag and (" tag-ally=" .. tostring(spec.ally)) or "") +end + +local function fieldDump(battle, game) + local parts = {} + for p = 0, 3 do + local b = battle:battlerAt(p) + parts[#parts + 1] = ("pos%d=%s"):format(p, b and ("%s %d/%d"):format(tostring(b.name), b.mon.hp, b.mon.stats.hp) or "empty") + end + local mons = {} + for i, m in ipairs(game.save.party) do mons[#mons + 1] = ("%d:%s %d/%d"):format(i, tostring(m.species), m.hp, m.stats.hp) end + local foes = {} + for i, m in ipairs(battle.enemyParty or {}) do foes[#foes + 1] = ("%d:%s %d/%d"):format(i, tostring(m.species), m.hp, m.stats.hp) end + return ("field: %s | party: %s | foe party: %s | pending=%s double=%s"):format( + table.concat(parts, ", "), table.concat(mons, ", "), table.concat(foes, ", "), + tostring(battle.pendingReplacements and #battle.pendingReplacements), tostring(battle:isDouble())) +end + +local function drawBattle(battle, canvas) + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 1) + local ok, err = xpcall(battle.draw, debug.traceback, battle) + love.graphics.setCanvas() + return ok, err +end + +local function runOne(spec, seed, cfg) + local result = { seed = seed, desc = describe(spec) } + rnd = newRng(seed) + currentSeed = seed + love.math.setRandomSeed(seed) + math.randomseed(seed) + -- the harness's stack starts empty for every battle + while Game.stack:top() do Game.stack:pop() end + + local okB, battle = xpcall(buildBattle, debug.traceback, spec) + if not okB then + result.status = "error"; result.where = "build"; result.err = battle + return result + end + if battle.dead then + result.status = "skip"; return result + end + local finishedWith + local realPerform = battle.performMove + battle.performMove = function(self, user, target, moveInst, ...) + if type(moveInst) == "table" and moveInst.id ~= nil then + movesSeen[moveInst.id] = (movesSeen[moveInst.id] or 0) + 1 + elseif os.getenv("FUZZ_DEBUG") then + local keys = {} + for k, v in pairs(moveInst or {}) do keys[#keys + 1] = tostring(k) .. "=" .. tostring(v) end + out(" [dbg] performMove without an id: %s user=%s\n%s", table.concat(keys, ","), tostring(user and user.name), debug.traceback("", 2)) + end + return realPerform(self, user, target, moveInst, ...) + end + if os.getenv("FUZZ_DEBUG") then + local realChoose, realResolve = battle.chooseAction, battle.resolveTurn + battle.chooseAction = function(self, a) + local who = self:choosingBattler() + out(" [dbg] chooseAction by %s pos=%s: id=%s special=%s", tostring(who and who.name), tostring(who and who.position), + tostring(a and a.id), tostring(a and a.special)) + return realChoose(self, a) + end + battle.resolveTurn = function(self, a) + out(" [dbg] resolveTurn first: id=%s special=%s pending: %s", tostring(a and a.id), tostring(a and a.special), + tostring(self.pendingActions and (self.pendingActions[0] and "L" or "-") .. (self.pendingActions[2] and "R" or "-"))) + return realResolve(self, a) + end + local realBide = battle.continueBide + battle.continueBide = function(self, user, target) + out(" [dbg] continueBide user=%s pos=%s bideTurns=%s", tostring(user.name), tostring(user.position), tostring(user.bideTurns)) + for p = 0, 3 do + local b = self:battlerAt(p) + out(" [dbg] pos%d %s bide=%s", p, b and b.name or "-", b and tostring(b.bideTurns) or "-") + end + return realBide(self, user, target) + end + end + local realFinish = battle.finish + battle.finish = function(self, ...) + finishedWith = self.result or "run" + return realFinish(self, ...) + end + battle.onFinish = function(r) finishedWith = finishedWith or r end + local ctx = { + frame = 0, menuFrames = 0, menuTries = 0, moveTries = 0, overlayFrames = 0, + itemBudget = 12, itemUses = 0, bagCancels = 0, usedItems = {}, + unknownPhases = {}, lastChoice = "-", + } + local trace = cfg.trace and {} or nil + if cfg.trace then + local orig = battle.startMessage + battle.startMessage = function(self, item) + out(" [msg] %s", (tostring(item and item.text):gsub("[\n\v]", " / "))) + return orig(self, item) + end + end + local okS, errS = xpcall(function() Game.stack:push(battle) end, debug.traceback) + if not okS then + result.status = "error"; result.where = "enter"; result.err = errS + return result + end + local lastTurn, lastProgress, lastItems = -1, 0, 0 + local frame = 0 + local lastPhase + while not finishedWith do + frame = frame + 1 + ctx.frame = frame + keysDown = {} + local top = Game.stack:top() + local okF, errF = xpcall(function() + if top == battle then + driveBattle(battle, Game, ctx) + if battle.phase ~= "menu" then ctx.menuTries = 0 end + if battle.phase ~= "moveSelect" then ctx.moveTries = 0 end + elseif top ~= nil then + local sid = screenId(top) + local dname = tostring(sid or StateStack.describe(top)) + screensSeen[dname] = (screensSeen[dname] or 0) + 1 + local isBag = sid and (sid:find("BagMenu$") or sid:find("BattleBag$")) + if isBag then + if (top.fuzzVisits or 0) == 0 or frame % 5 == 0 then driveBag(top, battle, Game, ctx) end + elseif sid and (sid:find("PartyMenu$") or sid:find("BattleParty$")) then + driveParty(top, battle, Game, ctx) + elseif sid and sid:find("NamingScreen") then + if top.close then top:close(rnd(1, 2) == 1 and "FUZZ" or nil) + else Game.stack:pop() end + else + driveOverlay(top, ctx) + end + else + error("the state stack is empty with the battle unfinished") + end + top = Game.stack:top() + if top and top.update then top:update(1 / 60) end + if cfg.draw > 0 and frame % cfg.draw == 0 and Game.stack:top() == battle then + local okD, errD = drawBattle(battle, cfg.canvas) + if not okD then error("draw: " .. tostring(errD), 0) end + end + end, debug.traceback) + if not okF then + result.status = "error"; result.where = "frame " .. frame; result.err = errF + result.phase = battle.phase + break + end + -- progress tracking: a turn counted, or a fresh menu, resets the hang clock + local progress = (battle.turnCount or 0) * 4 + (battle.phase == "menu" and 1 or 0) + local sig = tostring(battle.turnCount) .. tostring(battle.phase == "menu" and ctx.lastChoice or "") + if (battle.turnCount or 0) ~= lastTurn then lastTurn = battle.turnCount or 0; lastProgress = frame end + if ctx.itemUses ~= lastItems then lastItems = ctx.itemUses; lastProgress = frame end + lastPhase = battle.phase + if frame - lastProgress > cfg.hangFrames then + result.status = "hang" + result.dump = fieldDump(battle, Game) + result.where = ("frame %d phase=%s top=%s queue=%d current=%s turn=%s"):format( + frame, tostring(battle.phase), StateStack.describe(Game.stack:top()), + #(battle.queue or {}), tostring(battle.current and (battle.current.text or battle.current.kind or "item")), + tostring(battle.turnCount)) + break + end + if frame % 20 == 0 or finishedWith then + local problems = checkInvariants(battle, Game) + if #problems > 0 then + result.status = "invalid"; result.err = table.concat(problems, "\n ") + break + end + end + if (battle.turnCount or 0) > cfg.maxTurns then + result.status = "stall"; break + end + if frame > cfg.maxTurns * 3000 then result.status = "hang"; result.where = "absolute frame budget"; break end + end + result.frames = frame + result.turns = battle.turnCount or 0 + result.itemUses = ctx.itemUses + if not result.status then + local problems = checkInvariants(battle, Game) + if #problems > 0 then result.status = "invalid"; result.err = table.concat(problems, "\n ") + else result.status = "ok"; result.outcome = finishedWith end + end + result.unknownPhases = next(ctx.unknownPhases) and ctx.unknownPhases or nil + result.usedItems = ctx.usedItems + return result +end + +-- --------------------------------------------------------------------------- +-- choosing what to fight +-- --------------------------------------------------------------------------- +local function trainerRecord(id) + local rec = Data.trainers[id] + if rec and type(rec.parties) == "table" and type(rec.parties[1]) == "table" and rec.parties[1][1] then + return rec + end +end + +local function isDoubleTrainer(rec) + return (rec.doubleBattle or (tonumber(rec.battleType) or 0) ~= 0) and true or false +end + +local function makeSpec(i, seed, mode, cfg) + rnd = newRng(seed * 7919 + 17) + local spec = { anims = cfg.anims, ui = cfg.ui } + local m = mode + if m == "all" then + local r = rnd(1, 100) + m = (r <= 40) and "wild" or (r <= 85) and "trainers" or (r <= 93) and "tag" or "doubles" + end + if cfg.trainer then m = "trainers" end + if m == "wild" then + spec.kind = "wild" + spec.species = cfg.species or pick(speciesIds) + local r = rnd(1, 100) + spec.level = (r <= 70) and rnd(2, 40) or (r <= 90) and rnd(1, 100) or pick({ 1, 2, 3, 100 }) + spec.around = spec.level + if cfg.wildDouble and rnd(1, 100) <= 8 then spec.double = true; spec.second = pick(speciesIds) end + return spec + end + spec.kind = "trainer" + if cfg.trainer then + spec.trainer = cfg.trainer + elseif mode == "trainers" then + -- walk the table so every trainer is visited, in order + spec.trainer = trainerIds[((i - 1) % #trainerIds) + 1] + else + local pool = {} + for _, id in ipairs(trainerIds) do + local rec = Data.trainers[id] + if m == "doubles" and isDoubleTrainer(rec) then pool[#pool + 1] = id + elseif m == "singles" and not isDoubleTrainer(rec) then pool[#pool + 1] = id + elseif m == "tag" then pool[#pool + 1] = id end + end + if #pool == 0 then pool = trainerIds end + spec.trainer = pick(pool) + end + local rec = Data.trainers[spec.trainer] + spec.double = isDoubleTrainer(rec) + spec.around = trainerLevelAround(rec) + if m == "tag" then + spec.tag = true + spec.double = true + spec.ally = pick(trainerIds) + local rec2 = Data.trainers[spec.ally] + if rec2 and #(rec2.parties[1] or {}) < 1 then spec.tag = nil end + end + return spec +end + +-- --------------------------------------------------------------------------- +-- main +-- --------------------------------------------------------------------------- +local function summarise(err) + -- the first line of the message, with addresses / numbers normalised, plus + -- the first src/ frame of the traceback: what groups failures by root cause + local first = (tostring(err):match("^[^\n]*") or ""):gsub("0x%x+", "0x?") + local frame = tostring(err):match("(src/[%w_/%.]+:%d+)") or "" + return first .. " @" .. frame +end + +function love.load(argv) + parseArgs(argv or {}) + local ok, err = xpcall(function() + setup() + local cfg = { + anims = opt("anims", 0) ~= 0, + ui = opt("ui", "mixed"), + draw = opt("draw", 0), + maxTurns = opt("max-turns", 300), + hangFrames = opt("hang-frames", 6000), + verbose = ARGS.verbose ~= nil, + trace = ARGS.trace ~= nil, + trainer = ARGS.trainer and tonumber(ARGS.trainer) or nil, + species = ARGS.species and tonumber(ARGS.species) or nil, + wildDouble = ARGS["wild-double"] ~= nil, + } + if cfg.draw > 0 then cfg.canvas = love.graphics.newCanvas(256, 192) end + local seed0 = opt("seed", 1) + local count = opt("count", 100) + local mode = opt("mode", "all") + local stopOnFail = ARGS["stop-on-fail"] ~= nil + + for _, id in ipairs(sortedNumericKeys(Data.trainers)) do + if trainerRecord(id) then trainerIds[#trainerIds + 1] = id end + end + out("fuzz: %d moves, %d species, %d trainers, %d battle items (%d balls)", + #moveIds, #speciesIds, #trainerIds, #battleItemIds, #ballIds) + out("fuzz: seed=%d count=%d mode=%s anims=%s draw=%d", seed0, count, mode, tostring(cfg.anims), cfg.draw) + + local tally = { ok = 0, error = 0, hang = 0, invalid = 0, stall = 0, skip = 0 } + local groups, groupOrder = {}, {} + local outcomes, usedItems = {}, {} + local totalFrames, totalTurns = 0, 0 + local started = love.timer.getTime() + for i = 1, count do + local seed = seed0 + i - 1 + local spec = makeSpec(i, seed, mode, cfg) + local before = {} + for k, w in pairs(warnings) do before[k] = w.count end + local result = runOne(spec, seed, cfg) + for k, w in pairs(warnings) do + if w.count > (before[k] or 0) and (k:find("forcing blackout") or k:find("unknown move") or k:find("battle")) then + out(" note: seed=%d %s -> %s", seed, result.desc, w.sample:gsub("%s+", " "):sub(1, 120)) + end + end + tally[result.status] = (tally[result.status] or 0) + 1 + totalFrames = totalFrames + (result.frames or 0) + totalTurns = totalTurns + (result.turns or 0) + if result.outcome then outcomes[result.outcome] = (outcomes[result.outcome] or 0) + 1 end + for id, n in pairs(result.usedItems or {}) do usedItems[id] = (usedItems[id] or 0) + n end + if cfg.verbose or (result.status ~= "ok" and result.status ~= "skip" and result.status ~= "stall") then + out("[%4d] seed=%d %s -> %s%s (%d turns, %d frames)", i, seed, result.desc, result.status, + result.outcome and (" " .. result.outcome) or "", result.turns or 0, result.frames or 0) + end + if result.status == "error" or result.status == "invalid" or result.status == "hang" then + local key = result.status .. ": " .. summarise(result.err or result.where or "") + local g = groups[key] + if not g then + g = { count = 0, first = result, key = key } + groups[key] = g; groupOrder[#groupOrder + 1] = key + end + g.count = g.count + 1 + g.seeds = g.seeds or {} + if #g.seeds < 8 then g.seeds[#g.seeds + 1] = ("%d(%s)"):format(seed, result.desc) end + if result.err then out("%s", tostring(result.err)) end + if result.where then out(" where: %s", result.where) end + if result.dump then out(" %s", result.dump) end + if stopOnFail then break end + end + if result.unknownPhases then + for p in pairs(result.unknownPhases) do out(" note: unknown phase %s (seed %d)", p, seed) end + end + end + local elapsed = love.timer.getTime() - started + out("") + out("==== summary ====") + out("battles: %d ok=%d error=%d hang=%d invalid=%d stall=%d skip=%d", tally.ok + tally.error + tally.hang + tally.invalid + tally.stall + tally.skip, + tally.ok, tally.error, tally.hang, tally.invalid, tally.stall, tally.skip) + out("turns: %d frames: %d time: %.1fs", totalTurns, totalFrames, elapsed) + local oc = {} + for k, v in pairs(outcomes) do oc[#oc + 1] = k .. "=" .. v end + table.sort(oc) + out("outcomes: %s", table.concat(oc, " ")) + local nItems = 0 + for _ in pairs(usedItems) do nItems = nItems + 1 end + out("distinct battle items used: %d", nItems) + local nMoves, missing = 0, {} + for _, id in ipairs(moveIds) do + if movesSeen[id] then nMoves = nMoves + 1 + else missing[#missing + 1] = tostring(Data.moves[id].name or id) end + end + out("moves executed: %d of %d%s", nMoves, #moveIds, + (#missing > 0 and #missing <= 40) and (" never: " .. table.concat(missing, ", ")) or "") + local sn = {} + for k, v in pairs(screensSeen) do sn[#sn + 1] = k .. "=" .. v end + table.sort(sn) + out("overlay frames by screen: %s", table.concat(sn, " ")) + for _, key in ipairs(groupOrder) do + local g = groups[key] + out("FAILURE x%d %s", g.count, key) + out(" seeds: %s", table.concat(g.seeds, ", ")) + end + local wkeys = {} + for k in pairs(warnings) do wkeys[#wkeys + 1] = k end + table.sort(wkeys) + local noise = { "bad cry def", "in%-world 3D", "SecondScreen", "no glyph" } + local shown = 0 + for _, k in ipairs(wkeys) do + local w = warnings[k] + local skip = false + for _, n in ipairs(noise) do if w.sample:find(n) or k:find(n) then skip = true end end + if not skip then + if shown == 0 then out("engine warnings during the run:") end + shown = shown + 1 + out(" x%d (first at seed %d) %s", w.count, w.seed, (w.sample:gsub("%s+", " "):sub(1, 220))) + end + end + local failed = tally.error + tally.hang + tally.invalid + love.event.quit(failed > 0 and 1 or 0) + end, debug.traceback) + if not ok then + out("FUZZER FAILED:\n%s", tostring(err)) + love.event.quit(2) + end +end + +function love.draw() end diff --git a/tools/gen4_battle_fuzz/run.sh b/tools/gen4_battle_fuzz/run.sh new file mode 100644 index 000000000..8ef99b93d --- /dev/null +++ b/tools/gen4_battle_fuzz/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Run the Platinum battle fuzzer (see main.lua for the options). +# bash tools/gen4_battle_fuzz/run.sh --seed 1 --count 200 --mode all +# CACHE = the imported Platinum cache folder (the one holding assets/ and data/) +# LOVE = lovec.exe +CACHE="${CACHE:-$APPDATA/LOVE/Gen2Recomp/platinum}" +LOVE="${LOVE:-C:/Program Files/LOVE/lovec.exe}" +export POKEPORT_DATA_DIR="$CACHE/data/generated" +export POKEPORT_ASSET_ROOT="$CACHE" +export POKEPORT_IDENTITY="${POKEPORT_IDENTITY:-pl_battle}" +cd "$(dirname "$0")/../.." || exit 2 +"$LOVE" tools/gen4_battle_fuzz "$@" 2>&1 | grep --line-buffered -v -E "^\[(info|warn|debug)\] " +exit "${PIPESTATUS[0]}" From 021bf7a60a2d326e12d22022d3929f925f649c93 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:52:35 +0100 Subject: [PATCH 26/60] Add Gen 4 field sweep harness; fix party-pick cancel hang and trainer walk-up crash tools/gen4_field_sweep_harness + tools/gen4_field_sweep.py walk, warp, talk and menu-fuzz every imported map and report Lua errors and softlocks. - Gen4Commands g4_open_party_for_trade: B on the picker never resumed the script (move tutor / NPC trade froze the field behind a fade). - OverworldController trainer approach: queue the fight script when a map script is still running instead of asserting out of update. - Gen3FactoryScreen: an empty or short rental list could not be left. --- src/script/Gen4Commands.lua | 7 + src/ui/Gen3FactoryScreen.lua | 11 +- src/world/OverworldController.lua | 15 +- tools/gen4_field_sweep.py | 140 +++ tools/gen4_field_sweep_harness/conf.lua | 7 + tools/gen4_field_sweep_harness/main.lua | 1332 +++++++++++++++++++++++ tools/gen4_party_pick_cancel_check.lua | 51 + 7 files changed, 1559 insertions(+), 4 deletions(-) create mode 100644 tools/gen4_field_sweep.py create mode 100644 tools/gen4_field_sweep_harness/conf.lua create mode 100644 tools/gen4_field_sweep_harness/main.lua create mode 100644 tools/gen4_party_pick_cancel_check.lua diff --git a/src/script/Gen4Commands.lua b/src/script/Gen4Commands.lua index 466a74ba1..ea537263c 100644 --- a/src/script/Gen4Commands.lua +++ b/src/script/Gen4Commands.lua @@ -5756,6 +5756,13 @@ function Commands.g4_open_party_for_trade(ctx) end if runner then runner:resume() end end, + -- B / CANCEL on the picker: the slot stays PARTY_SLOT_NONE (set above) and + -- the script carries on. Without this the runner parked on the yield for + -- good -- the tutor / trade NPC's script never ended and the player was + -- left locked in the field behind a fade. + onCancel = function() + if runner then runner:resume() end + end, }) if runner then runner:yield() end end diff --git a/src/ui/Gen3FactoryScreen.lua b/src/ui/Gen3FactoryScreen.lua index 7dbf07f00..f7674dcbe 100644 --- a/src/ui/Gen3FactoryScreen.lua +++ b/src/ui/Gen3FactoryScreen.lua @@ -89,6 +89,13 @@ function S:update() if not input then return end local list = self:list() local n = #list + -- A LIST TOO SHORT TO FINISH FROM must not hold the player in the screen: + -- SELECT only ends with three picks and SWAP with a pair, so an empty (or + -- short) offer -- a Factory challenge resumed with no rentals on the save -- + -- had no way out at all. Hand back "nothing chosen" and the caller falls + -- back to its defaults (fr_bf_app reads a missing result as slots 1..n). + if n == 0 then return self:finish(nil) end + local needed = (self.mode == "select") and math.min(3, n) or 1 if input:wasPressed("up") then self.index = (self.index - 2) % math.max(1, n) + 1 Sound.play(self.data, "Press_AB") @@ -100,9 +107,9 @@ function S:update() if self.mode == "select" then local at = self:pickedAt(self.index) if at then table.remove(self.picks, at) - elseif list[self.index] and #self.picks < 3 then + elseif list[self.index] and #self.picks < needed then self.picks[#self.picks + 1] = self.index - if #self.picks == 3 then self:confirmNow() end + if #self.picks == needed then self:confirmNow() end end elseif self.phase == "give" then if list[self.index] then self.give = self.index; self.phase = "take"; self.index = 1 end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 2eab2e4bf..ec3c16956 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -12804,7 +12804,7 @@ function OverworldState:startTrainerApproach(npc, dist, partner, vs2) and partner.def.gen3TrainerId or nil -- VS2: the eye script, the pair's slots, approach type 2 if vs2 and vs2.rows then rows = vs2.rows end - self.runner:run(rows, { npc = npc, mapId = self.map.id, + local extra = { npc = npc, mapId = self.map.id, gen3PartnerTrainer = partnerId, gen4ApproachingTrainers = { type(npc.def.trainer) == "table" and npc.def.trainer.id or 0, @@ -12833,7 +12833,18 @@ function OverworldState:startTrainerApproach(npc, dist, partner, vs2) if other then hush[other] = true end self.spotHush = hush end - end }) + end } + -- A MAP SCRIPT CAN BE RUNNING WHEN THE WALK-UP ENDS (a coordinate + -- event the player was standing on as the trainer spotted them: Route + -- 207's slope scene). `ScriptRunner:run` asserts on a second script, + -- which raised out of the field's update -- the game was over. Queue + -- the fight behind it instead; the queue drains once the world is idle + -- (drainPendingScripts), and the trainer stays frozen meanwhile. + if self.runner:isRunning() then + self:queueScript(rows, extra) + else + self.runner:run(rows, extra) + end return end npc.frozen = false diff --git a/tools/gen4_field_sweep.py b/tools/gen4_field_sweep.py new file mode 100644 index 000000000..7de5e75dc --- /dev/null +++ b/tools/gen4_field_sweep.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Batch driver for tools/gen4_field_sweep_harness (the Gen 4 overworld/menu sweep). + +Runs the harness over every map of an imported cache in batches, restarts the +process after a hard crash or a hang (marking the map it died on as a failure), +and merges the SWEEP_* lines into one report. + + python tools/gen4_field_sweep.py --install "/platinum" --out sweep.txt + python tools/gen4_field_sweep.py --install ... --maps T01,R201 --profile rich + python tools/gen4_field_sweep.py --install ... --prefix D --menus map + +Everything the harness reads from the environment can be given as `--env K=V` +(repeatable): LOAD_FRAMES, WALK_FRAMES, TALK_BUDGET, MAX_NPCS, SEED, PHASES ... +See the header of tools/gen4_field_sweep_harness/main.lua. + +The report ends with a per-kind count and the failing maps; exit status is 1 +when anything failed, so it can gate a check. +""" +import argparse +import collections +import os +import subprocess +import sys +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_LOVE = r"C:\Program Files\LOVE\lovec.exe" + + +def run_harness(love, env, timeout): + """Run the wrapper once; return (stdout lines, timed_out).""" + cmd = [love, os.path.join("tools", "gen4_hgss_overworld_harness")] + try: + proc = subprocess.run(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=timeout) + out = proc.stdout + timed_out = False + except subprocess.TimeoutExpired as exc: + out = exc.stdout or b"" + timed_out = True + text = out.decode("utf-8", errors="replace") + return [ln for ln in text.splitlines() if ln.startswith("SWEEP_")], timed_out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--install", required=True, help="the imported version folder (e.g. .../Gen2Recomp/platinum)") + ap.add_argument("--version", default="platinum") + ap.add_argument("--identity", default="pl_field", help="POKEPORT_IDENTITY for the run") + ap.add_argument("--love", default=DEFAULT_LOVE) + ap.add_argument("--maps", help="comma list of map ids (default: every map)") + ap.add_argument("--prefix", help="only maps whose id starts with this") + ap.add_argument("--batch", type=int, default=100, help="maps per harness process") + ap.add_argument("--timeout", type=int, default=900, help="seconds per batch before it is killed") + ap.add_argument("--profile", default="new", choices=["new", "rich"]) + ap.add_argument("--state", default="none", choices=["none", "flags", "chaos"]) + ap.add_argument("--menus", default="once", choices=["once", "map", "off"]) + ap.add_argument("--seed", default="1") + ap.add_argument("--env", action="append", default=[], help="extra K=V for the harness") + ap.add_argument("--out", default="field_sweep_report.txt") + args = ap.parse_args() + + env = dict(os.environ) + env.update({ + "POKEPORT_IDENTITY": args.identity, + "INSTALL": args.install, + "VERSION": args.version, + "HARNESS": "gen4_field_sweep_harness", + "PROFILE": args.profile, + "STATE": args.state, + "SEED": args.seed, + "MENUS": args.menus, + }) + for kv in args.env: + k, _, v = kv.partition("=") + env[k] = v + + # the map list, from the harness itself so it always matches the engine's view + list_env = dict(env, LIST="1") + if args.maps: + list_env["MAPS"] = args.maps + if args.prefix: + list_env["MAP_PREFIX"] = args.prefix + lines, _ = run_harness(args.love, list_env, 300) + ids = [ln.split("|", 1)[1] for ln in lines if ln.startswith("SWEEP_LIST|")] + if not ids: + print("no maps listed; is --install a Platinum cache?", file=sys.stderr) + return 2 + print(f"{len(ids)} maps", flush=True) + + all_lines = [] + t0 = time.time() + pending = list(ids) + crashes = [] + menus_pending = args.menus == "once" + while pending: + batch = pending[: args.batch] + benv = dict(env, MAPS=",".join(batch)) + if args.menus == "once": + benv["MENUS"] = "once" if menus_pending else "off" + lines, timed_out = run_harness(args.love, benv, args.timeout) + done = {ln.split("|")[1] for ln in lines if ln.startswith("SWEEP_MAP|")} + if any(ln.startswith("SWEEP_MAP|") for ln in lines): + menus_pending = False + all_lines.extend(ln for ln in lines if not ln.startswith(("SWEEP_START", "SWEEP_DONE"))) + missing = [m for m in batch if m not in done] + if missing: + culprit = missing[0] + why = "hung past the batch timeout" if timed_out else "the process died (hard crash)" + crashes.append(culprit) + all_lines.append(f"SWEEP_FAIL|{culprit}|-|process|{why}") + all_lines.append(f"SWEEP_MAP|{culprit}|fail|crashed") + # carry on after the culprit + pending = missing[1:] + pending[len(batch):] + else: + pending = pending[len(batch):] + print(f" {len(ids) - len(pending)}/{len(ids)} maps, {time.time() - t0:.0f}s", flush=True) + + fails = [ln for ln in all_lines if ln.startswith("SWEEP_FAIL|")] + loops = [ln for ln in all_lines if ln.startswith("SWEEP_LOOP|")] + maps = [ln for ln in all_lines if ln.startswith("SWEEP_MAP|")] + bad_maps = sorted({ln.split("|")[1] for ln in fails}) + kinds = collections.Counter(ln.split("|")[3] for ln in fails) + with open(args.out, "w", encoding="utf-8") as fh: + for ln in all_lines: + fh.write(ln + "\n") + fh.write(f"SWEEP_SUMMARY|maps={len(maps)} failed_maps={len(bad_maps)} fails={len(fails)} " + f"loops={len(loops)} crashes={len(crashes)} secs={time.time() - t0:.0f}\n") + for k, n in kinds.most_common(): + fh.write(f"SWEEP_KIND|{k}|{n}\n") + print(f"maps={len(maps)} failed_maps={len(bad_maps)} fails={len(fails)} loops={len(loops)} " + f"crashes={len(crashes)}") + for k, n in kinds.most_common(): + print(f" {k}: {n}") + print(f"report: {args.out}") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gen4_field_sweep_harness/conf.lua b/tools/gen4_field_sweep_harness/conf.lua new file mode 100644 index 000000000..b4d103449 --- /dev/null +++ b/tools/gen4_field_sweep_harness/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "field_sweep" + t.window.width, t.window.height = 512, 384 + t.window.visible = false + t.modules.audio = false + t.modules.sound = false +end diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua new file mode 100644 index 000000000..820e7152a --- /dev/null +++ b/tools/gen4_field_sweep_harness/main.lua @@ -0,0 +1,1332 @@ +-- tools/gen4_field_sweep_harness/main.lua +-- +-- A deterministic, headless SWEEP of the Gen 4 overworld: for every map in the +-- imported cache it enters the map the way a warp does, then walks it, takes +-- its warps, talks to its people and reads its signs -- and, once per run, opens +-- every start-menu screen and the Pokétch's apps -- catching Lua errors with a +-- traceback, logged errors, and softlocks (input that never comes back, a +-- script parked on one row, a screen that B does not close). +-- +-- Run it through the version wrapper so the cache is mounted (see +-- tools/gen4_field_sweep.py for the batching driver, which restarts after a hard +-- crash and merges the per-batch reports): +-- +-- POKEPORT_IDENTITY=pl_field INSTALL=/platinum VERSION=platinum \ +-- HARNESS=gen4_field_sweep_harness MAPS=T01,R201 \ +-- lovec tools/gen4_hgss_overworld_harness +-- +-- Environment (all optional): +-- MAPS=T01,R201|all which maps (default all, in sorted order) +-- MAP_PREFIX=D only maps whose id starts with this +-- SHARD=i/n every n-th map starting at i (1-based) of the list +-- SKIP=n LIMIT=n skip the first n / stop after n maps of the list +-- SEED=n the random seed (walks, menu fuzz); default 1 +-- PHASES=load,walk,warp,npc,sign,menu which phases run per map (default all) +-- MENUS=once|map|off the full menu pass: once in the run (default), on +-- every map, or never +-- PROFILE=new|rich the save every map starts from (rich: six Pokémon, +-- a stocked bag, badges, the Pokédex and the Pokétch) +-- STATE=none|flags|chaos the story state laid over it: none (a new game's), +-- flags (every event flag and trainer flag set: the +-- post-game world), chaos (each flag, trainer flag and +-- the low story vars random, seeded per map -- reaches +-- script branches a straight playthrough meets late +-- or never; some states it makes cannot occur in play) +-- LOAD_FRAMES=n WALK_FRAMES=n WARP_FRAMES=n TALK_BUDGET=n frame budgets +-- MAX_NPCS=n MAX_SIGNS=n MAX_WARPS=n per-map caps on what is exercised +-- DRAW_EVERY=n draw the frame every n ticks in load/walk (0 = never) +-- ENTRIES=n how many arrival cells per map (default 1) +-- MAP_SECS=n wall-clock allowance per map before it is failed as a +-- hang (default 120) +-- TRACE=1 print each phase as it starts +-- OUT= also append the SWEEP_* lines to this file +-- +-- Output: one line per finding, `SWEEP_FAIL||||`, and +-- one per map, `SWEEP_MAP|||...`, and a final `SWEEP_DONE|...`. + +io.stdout:setvbuf("line") +local getenv = os.getenv + +local function envn(k, d) return tonumber(getenv(k) or "") or d end +local SEED = envn("SEED", 1) +local LOAD_FRAMES = envn("LOAD_FRAMES", 90) +local WALK_FRAMES = envn("WALK_FRAMES", 240) +local WARP_FRAMES = envn("WARP_FRAMES", 240) +local TALK_BUDGET = envn("TALK_BUDGET", 5000) +local MAX_NPCS = envn("MAX_NPCS", 14) +local MAX_SIGNS = envn("MAX_SIGNS", 10) +local MAX_WARPS = envn("MAX_WARPS", 8) +local DRAW_EVERY = envn("DRAW_EVERY", 45) +local ENTRIES = envn("ENTRIES", 1) +local MENUS = getenv("MENUS") or "once" +local PROFILE = getenv("PROFILE") or "new" +local STATE = getenv("STATE") or "none" +local TRACE = getenv("TRACE") +local OUT = getenv("OUT") + +local PHASES = {} +for p in (getenv("PHASES") or "load,walk,warp,npc,sign,menu"):gmatch("[%w_]+") do PHASES[p] = true end + +local outFile = OUT and io.open(OUT, "a") +local function emit(line) + print(line) + if outFile then outFile:write(line, "\n"); outFile:flush() end +end + +local function oneLine(s, n) + s = tostring(s):gsub("[\r\n]+", " | "):gsub("|%s*|", "|"):gsub("%s+", " ") + if #s > (n or 900) then s = s:sub(1, n or 900) .. "..." end + return (s:gsub("%|", "/")) +end + +-- --------------------------------------------------------------- the cursor -- + +local cur = { map = "-", phase = "-" } +local mapFails, mapFailKinds = {}, {} +local totals = { fails = 0 } +local warnShapes = {} + +local function fail(kind, detail) + local line = oneLine(detail) + local key = kind .. "|" .. line:sub(1, 120) + if mapFails[key] then return end + mapFailKinds[kind] = (mapFailKinds[kind] or 0) + 1 + if mapFailKinds[kind] > 6 then return end + mapFails[key] = true + totals.fails = totals.fails + 1 + emit(("SWEEP_FAIL|%s|%s|%s|%s"):format(cur.map, cur.phase, kind, line)) +end + +-- ---------------------------------------------------------------- the logger -- + +local WARN_FLAGGED = { "parked", "putting it down", "raised", "failed", "stranded", + "stack overflow", "attempt to", "traceback", "could not", "did not decode" } + +local function hookLogger() + local Logger = require("src.core.Logger") + for _, level in ipairs({ "error", "warn" }) do + local orig = Logger[level] + Logger[level] = function(fmt, ...) + local ok, msg = pcall(string.format, tostring(fmt), ...) + if not ok then msg = tostring(fmt) end + if level == "error" then + fail("log_error", msg) + else + local low = msg:lower() + local hit + for _, w in ipairs(WARN_FLAGGED) do if low:find(w, 1, true) then hit = true break end end + if hit then + fail("log_warn", msg) + else + local shape = msg:gsub("%d+", "#"):sub(1, 110) + if not shape:find("emptied the stack", 1, true) then warnShapes[shape] = (warnShapes[shape] or 0) + 1 end + end + end + return orig(fmt, ...) + end + end +end + +-- ------------------------------------------------------------------ the rng -- + +local rngState = SEED +local function rnd(n) -- 1..n, a small LCG so a run is the same everywhere + rngState = (rngState * 1103515245 + 12345) % 2147483648 + return math.floor(rngState / 65536) % n + 1 +end + +-- ----------------------------------------------------------------- the game -- + +local Game, OW, Data, StateStack, Collision, Music +local keysDown, keysPressed = {}, {} +local frames = 0 +local canvas + +local function describeTop() + local top = Game.stack and Game.stack.states and Game.stack.states[#Game.stack.states] + if not top then return "empty" end + if top == OW then return "overworld" end + local ok, d = pcall(StateStack.describe, top) + return ok and tostring(d) or tostring(top.screenId or top.name or top.kind or "?") +end + +local function stackDepth() return #Game.stack.states end +local function top() return Game.stack.states[#Game.stack.states] end + +local function drawFrame() + local w, h = love.graphics.getDimensions() + canvas = canvas or love.graphics.newCanvas(w, h) + love.graphics.push("all") + local setCanvas = love.graphics.setCanvas + local target = { canvas, depth = true } + love.graphics.setCanvas = function(c, ...) + if c == nil then return setCanvas(target) end + return setCanvas(c, ...) + end + setCanvas(target) + love.graphics.clear(0, 0, 0, 1) + local ok, err = xpcall(Game.draw, debug.traceback, Game) + love.graphics.setCanvas = setCanvas + setCanvas() + love.graphics.pop() + if not ok then fail("draw_error", err) end + return ok +end + +-- One logic tick. Returns false when the update raised (the state is then +-- suspect, and the caller recovers). +local deadline = math.huge +local function tick() + frames = frames + 1 + -- a map that has run past its wall-clock allowance is a hang of some kind + -- (a frame loop that never ends); the error unwinds to sweepMap's xpcall + if os.clock() > deadline then error("MAP_TIMEOUT", 0) end + local ok, err = xpcall(Game.stack.update, debug.traceback, Game.stack, 1 / 60) + keysPressed = {} + -- Game:update advances the music clock on its own accumulator; scripts wait + -- on its fades (fadeoutbgm), so it has to run here or they never finish + if ok then pcall(Music.update, Game.data) end + if not ok then + fail("update_error", err) + return false + end + return true +end + +local function press(k) keysPressed[k] = true end + +-- run n ticks with `each(i)` called before each (it may press/hold keys); +-- stops early when `stopWhen()` is true. Returns ok, ticksRun. +local function run(n, each, stopWhen, drawEvery) + for i = 1, n do + if each then each(i) end + if not tick() then return false, i end + if drawEvery and drawEvery > 0 and i % drawEvery == 0 then drawFrame() end + if stopWhen then + local stop = stopWhen(i) + if stop then return true, i end + end + end + return true, n +end + +local function fieldHolds() + -- what keeps the overworld from reading the pad (the conditions in + -- OverworldState:update's `scripted`), by name + local why = {} + if OW.transitioning then why[#why + 1] = "transitioning" end + if OW.runner and OW.runner:isRunning() then why[#why + 1] = "script" end + if OW.pendingScripts and OW.pendingScripts[1] then why[#why + 1] = "pendingScripts" end + if #(OW.scriptMoves or {}) > 0 then why[#why + 1] = "scriptMoves" end + if OW.engaging then why[#why + 1] = "engaging" end + if OW.emote then why[#why + 1] = "emote" end + if OW.teleportOut then why[#why + 1] = "teleportOut" end + if OW.whirlSpin then why[#why + 1] = "whirlSpin" end + if OW.gen4Waterfall then why[#why + 1] = "waterfall" end + if OW.player and OW.player.stairExit then why[#why + 1] = "stairExit" end + if OW.player and OW.player.moving then why[#why + 1] = "moving" end + return why +end + +local function idle() + return top() == OW and #fieldHolds() == 0 +end + +-- --------------------------------------------------------------- the stubs --- + +local battles = 0 +local pushCount = 0 -- every state push: a script that re-asks its question is cycling, not hung + +local function installStubs() + -- the LINK row is the port's online "Play Together" screen: a network client, + -- not the field -- the start menu is built without it + pcall(function() require("src.ui.Gen4StartMenu").linkRow = function() return nil end end) + -- TEXT_TRACE=1: every text box and menu the field opens, as it opens + if getenv("TEXT_TRACE") then + local TextBox = require("src.render.TextBox") + local tbNew = TextBox.new + TextBox.new = function(game, text, ...) + print("[text] " .. (tostring(text):sub(1, 100):gsub("%c", " / "))) + return tbNew(game, text, ...) + end + local Menu = require("src.ui.Menu") + local mNew = Menu.new + Menu.new = function(game, rows, ...) + local labels = {} + for i, r in ipairs(rows or {}) do labels[i] = tostring(r.label) end + print("[menu] " .. table.concat(labels, " | ")) + return mNew(game, rows, ...) + end + end + -- nothing the sweep does may write a save + local SaveData = require("src.core.SaveData") + SaveData.save = function() return true end + SaveData.writeSlot = function() return true end + SaveData.saveOptions = function() return true end + -- a script's battle is cut short: counted, and answered as a win + local Commands = require("src.script.Commands") + Commands.start_battle = function(ctx, kind, a) + battles = battles + 1 + ctx.lastBattleResult = "win" + ctx.lastBattleCanLose = false + ctx.lastCheck = true + pcall(function() + require("src.script.Gen4Commands").setVar(ctx.save, 0x4013, 1) + end) + end +end + +local function resetKeys() + keysDown, keysPressed = {}, {} +end + +-- ------------------------------------------------------------ the save ------ + +local function richSave(save) + local Pokemon = require("src.pokemon.Pokemon") + local party = {} + for i, sp in ipairs({ 387, 390, 393, 25, 448, 445 }) do + local ok, mon = pcall(Pokemon.new, Data, sp, 30 + i) + if ok and mon then party[#party + 1] = mon end + end + -- the field moves, spread over the party so the party menu offers them all + local hm = { { 15, 19, 57, 70 }, { 249, 127, 431, 432 }, { 148, 100, 91, 230 }, { 208, 156, 29, 15 } } + for i, ids in ipairs(hm) do + if party[i] then + party[i].moves = {} + for _, id in ipairs(ids) do + local d = Data.moves and Data.moves[id] + party[i].moves[#party[i].moves + 1] = { id = id, pp = d and d.pp or 10, maxPP = d and d.pp or 10 } + end + end + end + if #party > 0 then save.party = party end + save.money = 99999 + save.coins = 500 + save.badges = save.badges or {} + for i, entry in ipairs(require("src.inventory.Badges").list(Data) or {}) do + if i <= 8 then save.badges[entry.id] = true end + end + save.poketch = save.poketch or {} + save.poketch.enabled = true + save.pokedex = save.pokedex or {} + save.pokedex.seen, save.pokedex.owned = save.pokedex.seen or {}, save.pokedex.owned or {} + for id = 1, 493 do save.pokedex.seen[id] = true; save.pokedex.owned[id] = (id % 3 ~= 0) end + local Bag = require("src.inventory.Bag") + local n = 0 + for id, it in pairs(Data.items or {}) do + if type(id) == "string" and type(it) == "table" and n < 160 then + local ok = pcall(Bag.add, save, id, 3, Data) + if ok then n = n + 1 end + end + end +end + +local function storyState(save) + if STATE == "none" then return end + save.flags = save.flags or {} + save.gen4Vars = save.gen4Vars or {} + save.gen4TrainerFlags = save.gen4TrainerFlags or {} + if STATE == "flags" then + for id = 0, 0x7FF do save.flags[("FLAG_G4_%04X"):format(id)] = true end + for id = 0, 1200 do save.gen4TrainerFlags[id] = true end + return + end + -- chaos: the same state every time this map is entered, whatever happened + -- in between, so it is its own random stream + local keep = rngState + rngState = (cur.hash or SEED) + 17 + for id = 0, 0x7FF do + if rnd(2) == 1 then save.flags[("FLAG_G4_%04X"):format(id)] = true end + end + for id = 0, 1200 do + if rnd(5) <= 2 then save.gen4TrainerFlags[id] = true end + end + for id = 0x4000, 0x40FF do + local r = rnd(10) + save.gen4Vars[id] = r <= 4 and 0 or r <= 6 and 1 or r <= 8 and 2 or r - 6 + end + rngState = keep +end + +local function newSave() + local SaveData = require("src.core.SaveData") + local save = SaveData.newGame(Game:bootConfig()) + Game.save = save + if Game.adoptSave then pcall(Game.adoptSave, Game, save, true) end + save.repelSteps = 99999 -- wild battles are the battle agent's, not this sweep's + local okS, errS = pcall(storyState, save) + if not okS then fail("profile_error", errS) end + if PROFILE == "rich" then + local ok, err = pcall(richSave, save) + if not ok then fail("profile_error", err) end + end + return save +end + +-- ------------------------------------------------------------- map entry ----- + +local function popAll() + local guard = 0 + while Game.stack:top() and guard < 64 do Game.stack:pop(); guard = guard + 1 end + -- the overworld outlives the stack (Game:returnToTitle clears this too): a + -- fade overlay it still names would be reused by the next script's fade + OW.fadeOverlay = nil +end + +-- Enter `id` the way a continue does (a fresh stack and a fresh save), so each +-- map starts from the same state whatever the one before it did. +local function enterMap(id, x, y, facing) + popAll() + resetKeys() + newSave() + Game.stack:push(OW, id, x, y, facing or "down") +end + +local function settle(budget, mashA) + local ok, n = run(budget or 600, function(i) + if mashA and i % 7 == 0 then press("a") end + end, idle) + return ok, n +end + +-- every walkable cell of the current map, found by flooding out from (x, y) +-- with the engine's own collision question +local DIRS = { { "up", 0, -1 }, { "down", 0, 1 }, { "left", -1, 0 }, { "right", 1, 0 } } + +local function reachable(map, sx, sy, limit) + local ents = OW.cast or OW.entities or OW.npcs or {} + local p = OW.player + local seen, order, head = { [sy * 4096 + sx] = true }, { { sx, sy } }, 1 + while head <= #order and #order < (limit or 4000) do + local c = order[head]; head = head + 1 + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[2], c[2] + d[3] + local k = ny * 4096 + nx + if not seen[k] then + local mover = { cellX = c[1], cellY = c[2], facing = d[1], elevation = p and p.elevation, + surfing = p and p.surfing } + local ok, allowed = pcall(Collision.canMove, map, ents, mover, d[1]) + if ok and allowed then seen[k] = true; order[#order + 1] = { nx, ny } end + end + end + end + return order, seen +end + +local function placePlayer(x, y, facing) + local p = OW.player + p.cellX, p.cellY = x, y + p.px, p.py = x * 16, y * 16 + p.targetX, p.targetY = nil, nil + p.moving = false + p.facing = facing or p.facing + OW.player.gen4Height = nil +end + +-- ------------------------------------------------------------- the phases ---- + +local function shallow(t) + local parts = {} + if type(t) ~= "table" then return tostring(t) end + local keys = {} + for k, v in pairs(t) do + local tv = type(v) + if tv == "number" or tv == "boolean" or (tv == "string" and #v < 40) then keys[#keys + 1] = tostring(k) end + end + table.sort(keys) + for _, k in ipairs(keys) do + if #parts < 24 then parts[#parts + 1] = k .. "=" .. tostring(t[k]) end + end + -- ...and the NAMES of the table fields that are set: a screen's sub-state + -- (menu, trash, moving, message, dial) is usually one of those + local sets = {} + for k, v in pairs(t) do + if type(v) == "table" and type(k) == "string" and not k:find("^_") and k ~= "game" and k ~= "data" + and k ~= "opts" and k ~= "images" and next(v) ~= nil then sets[#sets + 1] = k end + end + table.sort(sets) + if #sets > 0 then parts[#parts + 1] = "{" .. table.concat(sets, ",", 1, math.min(#sets, 30)) .. "}" end + return table.concat(parts, ",") +end + +local function stuckDetail(extra) + local runner = OW.runner + if getenv("DEBUG_STUCK") then + local t = Game.stack.states[#Game.stack.states] + print("[stuck] top fields: " .. shallow(t)) + print(("[stuck] runner waitingFrames=%s waitingCheck=%s moves=%d pending=%s stuckFrames=%s"):format( + tostring(runner.waitingFrames), tostring(runner.waitingCheck ~= nil), #(OW.scriptMoves or {}), + tostring(OW.pendingScripts and #OW.pendingScripts), tostring(OW.stuckFrames))) + end + local row = runner and runner.lastRow + local src = runner and runner.ctx and runner.ctx.source + return ("%s | top=%s depth=%d runner=%s row=%s src=%s holds=%s"):format( + extra or "", describeTop(), stackDepth(), tostring(runner and runner:isRunning()), + tostring(row), src and (tostring(src.mapId) .. "." .. tostring(src.hook)) or "?", + table.concat(fieldHolds(), "+")) +end + +-- After a failure the world may be half-torn-down; put the player back on a +-- clean overworld of the same map without touching the save. +local function recover(mapId, x, y) + local guard = 0 + while Game.stack:top() and Game.stack:top() ~= OW and guard < 64 do + Game.stack:pop(); guard = guard + 1 + end + if OW.runner then OW.runner.co = nil; OW.runner.waitingFrames = nil; OW.runner.waitingCheck = nil end + OW.transitioning = false + OW.fadeOverlay = nil + OW.scriptMoves = {} + OW.pendingScripts = {} + resetKeys() + if Game.stack:top() ~= OW then + Game.stack:push(OW, mapId, x or 1, y or 1, "down") + end +end + +-- Clear a state the sweep caused: a text box or choice left up, a battle that +-- came onto the stack, ... by mashing A/B. Reports a softlock when it does not clear. +local function fingerprint() + local t = top() + local p = OW.player + local parts = { tostring(pushCount), tostring(stackDepth()), describeTop(), tostring(OW.runner and OW.runner.lastRow), + tostring(OW.map and OW.map.id), p and (p.cellX .. "," .. p.cellY) or "-", + tostring(OW.transitioning), tostring(OW.runner and OW.runner.waitingFrames ~= nil) } + if t and t ~= OW then + -- a timed screen (the credits, a cutscene scene) shows its progress in a + -- frame counter; a text box's own counters only blink + if not t.pages then parts[#parts + 1] = tostring(t.frame) .. "/" .. tostring(t.tick) end + -- a scene with a script of its own (the Frontier's) + if type(t.runner) == "table" and t.runner ~= OW.runner then + parts[#parts + 1] = tostring(t.runner.lastRow) .. tostring(t.runner.waitingFrames) + end + parts[#parts + 1] = table.concat({ tostring(t.pageIndex), tostring(t.lineIndex), tostring(t.charIndex), + tostring(t.index), tostring(t.row), tostring(t.cursor), tostring(t.page), tostring(t.phase), + tostring(t.state), tostring(t.step), tostring(t.alpha), tostring(t.mode) }, ":") + end + return table.concat(parts, "|") +end + +-- Clear a state the sweep caused: a text box or choice left up, a battle that +-- came onto the stack, ... by mashing A/B. +-- softlock nothing about the game changed for STALL frames of mashing +-- loop it kept changing but never came back (an NPC that asks again +-- while A keeps answering yes, ...) -- reported separately, it +-- is what the mashing does, not a hang +local STALL = envn("STALL", 1500) +local STALL_WAIT = envn("STALL_WAIT", 2400) +local loops = 0 +local function drain(kind, budget) + budget = budget or TALK_BUDGET + local lastFp, sameFor = nil, 0 + local rows = {} + local ok, n = run(budget, function(i) + -- A alone first (text, menus and quantity pickers advance), then B alone + -- (every menu backs out), then a mix; a plain A/B alternation can cycle + -- a shop between BUY and its list forever + if i <= 360 then + if i % 7 == 0 then press("a") end + elseif i <= 1000 then + if i % 7 == 0 then press("b") end + else + local m = i % 11 + if m == 0 then press("a") elseif m == 5 then press("b") end + if i % 97 == 0 then press("start") end + if i % 13 == 0 then press(({ "down", "right", "up", "left", "down", "right" })[rnd(6)]) end + end + end, function(i) + local t = top() + -- a battle that came onto the stack is the battle agent's: pop it + if t and t ~= OW and t.enemy and t.player and t.battle ~= nil then + battles = battles + 1 + Game.stack:pop() + pcall(OW.afterBattle, OW, "win", t) + end + if i % 6 == 0 then + local fp = fingerprint() + if fp == lastFp then sameFor = sameFor + 6 else sameFor = 0; lastFp = fp end + if OW.runner and OW.runner.lastRow then rows[OW.runner.lastRow] = true end + -- a script waiting on a frame count or a condition may be a long cutscene + -- (the boat's sail is ten seconds); one with nothing waiting is parked + local r = OW.runner + local waiting = (r and r:isRunning() and (r.waitingFrames or r.waitingCheck)) + or (t and t ~= OW and type(t.co) == "thread" and coroutine.status(t.co) ~= "dead") + if sameFor >= (waiting and STALL_WAIT or STALL) then return "stalled" end + end + return idle() + end) + if not ok then return false end + if idle() then return true end + local r, t = OW.runner, top() + local waiting = (r and r:isRunning() and (r.waitingFrames or r.waitingCheck)) + or (t and t ~= OW and type(t.co) == "thread" and coroutine.status(t.co) ~= "dead") + if sameFor >= (waiting and STALL_WAIT or STALL) then + fail(waiting and "softlock_waiting" or "softlock", + stuckDetail(("%s: nothing changed for %d frames of A/B"):format(kind, sameFor))) + return false + end + local n, names = 0, {} + for row in pairs(rows) do n = n + 1; names[#names + 1] = row end + table.sort(names) + loops = loops + 1 + emit(("SWEEP_LOOP|%s|%s|%s|%d distinct rows in %d frames | %s | rows: %s"):format(cur.map, cur.phase, kind, n, budget, + oneLine(stuckDetail(""), 300), oneLine(table.concat(names, "; "), 400))) + return "loop" +end + +local function phaseLoad(mapDef) + cur.phase = "load" + local ok = run(LOAD_FRAMES, nil, nil, DRAW_EVERY) + if not ok then return false end + local r = settle(300) + return r ~= false +end + +local function randomWalk(mapId, ox, oy) + cur.phase = "walk" + local dir, hold = nil, 0 + local lastCell, still = nil, 0 + local moved = {} + local ok = run(WALK_FRAMES, function(i) + local p = OW.player + if not idle() and top() == OW and not OW.player.moving and OW.runner:isRunning() then + -- a script took over: mash through it + if i % 9 == 0 then press("a") end + keysDown = {} + return + end + local cell = p.cellX .. "," .. p.cellY + if cell == lastCell and not p.moving then still = still + 1 else still = 0 end + lastCell = cell + moved[cell] = true + if hold <= 0 or still > 8 then + dir = DIRS[rnd(4)][1] + hold = 6 + rnd(30) + still = 0 + end + hold = hold - 1 + keysDown = { [dir] = true } + if top() ~= OW then keysDown = {}; if i % 9 == 0 then press("b") end end + end, nil, DRAW_EVERY) + keysDown = {} + local n = 0 + for _ in pairs(moved) do n = n + 1 end + return ok, n +end + +-- take each warp of the map: stand next to it, face it, walk in +local function phaseWarps(mapId, def, landing) + cur.phase = "warp" + local taken, tried = 0, 0 + local warps = def.warps or {} + for wi, w in ipairs(warps) do + if tried >= MAX_WARPS then break end + if w.destMap and w.x and w.y then + tried = tried + 1 + enterMap(mapId, landing[1], landing[2], "down") + local okL = run(30) + if not okL then recover(mapId, landing[1], landing[2]); goto continue end + settle(200) + if not idle() or OW.map.id ~= mapId then goto continue end + do + local p, map = OW.player, OW.map + local ents = OW.cast or OW.entities or OW.npcs or {} + local placed + for _, d in ipairs(DIRS) do + -- a stand cell next to the warp from which stepping `toward` enters it + local toward = ({ up = "down", down = "up", left = "right", right = "left" })[d[1]] + local sx, sy = w.x + d[2], w.y + d[3] + if map:inBounds(sx, sy) then + local mover = { cellX = sx, cellY = sy, facing = toward, elevation = p.elevation } + local ok, allowed = pcall(Collision.canMove, map, ents, mover, toward) + if ok and allowed and map:isWalkableCell(sx, sy) then placed = { sx, sy, toward } break end + end + end + if not placed then + -- edge/mat warps: step onto the warp cell from a side the map allows is + -- not decidable here; count it as untested rather than failing + goto continue + end + placePlayer(placed[1], placed[2], placed[3]) + local before = OW.map.id + local changed = false + local ok = run(WARP_FRAMES, function(i) + if top() == OW and not OW.transitioning and not OW.player.moving then + keysDown = { [placed[3]] = true } + else + keysDown = {} + end + end, function() + if OW.map.id ~= before and idle() then changed = true return true end + end, 0) + keysDown = {} + if not ok then recover(mapId, landing[1], landing[2]); goto continue end + if changed then + taken = taken + 1 + -- the far side is alive: a few frames, a draw, and out of any script + local okA = run(60, nil, nil, 30) + if okA then drain("warp arrival") end + else + -- the walk-in did not take (a locked door, a script-held tile or + -- an arrival that needs a flag) is not a failure by itself; only a + -- stuck state is + if not idle() then drain("warp walk-in") end + end + end + ::continue:: + end + end + return taken, tried +end + +-- neighbours of (cx, cy) the player can stand on and step toward it from +local function standCells(map, cx, cy, skipEnt) + local ents = {} + for _, e in ipairs(OW.cast or OW.entities or OW.npcs or {}) do + if e ~= skipEnt then ents[#ents + 1] = e end + end + local out = {} + local p = OW.player + for _, d in ipairs(DIRS) do + local toward = ({ up = "down", down = "up", left = "right", right = "left" })[d[1]] + local sx, sy = cx + d[2], cy + d[3] + if map:inBounds(sx, sy) and not map:warpAtCell(sx, sy) then + local mover = { cellX = sx, cellY = sy, facing = toward, elevation = p.elevation } + local ok, allowed = pcall(Collision.canMove, map, ents, mover, toward) + if ok and allowed then out[#out + 1] = { sx, sy, toward } end + end + end + return out +end + +local function talkAt(mapId, landing, label, getTarget) + -- the world is idle and on the right map; put the player on a stand cell + -- next to the target, face it, press A, and mash through whatever answers + local map = OW.map + local cx, cy, skip = getTarget() + if not cx then return "skipped" end + local cells = standCells(map, cx, cy, skip) + if #cells == 0 then return "unreachable" end + local c = cells[rnd(#cells)] + placePlayer(c[1], c[2], c[3]) + local ok = run(3) + if not ok then return "error" end + press("a") + local ok2 = run(2) + if not ok2 then return "error" end + local busy = not idle() + if busy then + local r = drain(label) + if r == "loop" then return "loop" end + if not r then return "stuck" end + end + return busy and "talked" or "silent" +end + +local function phaseTalk(mapId, def, landing, signsToo) + local talked, silent, stuck, errs = 0, 0, 0, 0 + cur.phase = "npc" + -- NPCs: indexes by their def so a script that moves them cannot reorder the walk + local list = {} + enterMap(mapId, landing[1], landing[2], "down") + run(30) + settle(300) + for _, npc in ipairs(OW.npcs or {}) do + if npc.def and not npc.hidden and not npc.pikachuFollower then list[#list + 1] = npc.def.index end + end + local count = 0 + local only = tonumber(getenv("ONLY_NPC") or "") + for _, index in ipairs(list) do + if count >= MAX_NPCS then break end + if only and index ~= only then goto skipnpc end + count = count + 1 + if OW.map.id ~= mapId or top() ~= OW then + enterMap(mapId, landing[1], landing[2], "down") + run(20); settle(300) + end + local label = "npc#" .. tostring(index) + local res = talkAt(mapId, landing, label, function() + for _, npc in ipairs(OW.npcs or {}) do + if npc.def and npc.def.index == index and not npc.hidden then return npc.cellX, npc.cellY, npc end + end + end) + if res == "talked" then talked = talked + 1 elseif res == "silent" then silent = silent + 1 + elseif res == "stuck" then stuck = stuck + 1 elseif res == "error" then errs = errs + 1 end + if res == "loop" then recover(mapId, landing[1], landing[2]) end + if TRACE then print(("[sweep] %s %s -> %s"):format(mapId, label, res)) end + if res == "stuck" or res == "error" or res == "loop" or OW.map.id ~= mapId then + recover(mapId, landing[1], landing[2]) + enterMap(mapId, landing[1], landing[2], "down") + run(20); settle(300) + end + ::skipnpc:: + end + if signsToo then + cur.phase = "sign" + local n = 0 + for _, s in ipairs(def.signs or {}) do + if n >= MAX_SIGNS then break end + n = n + 1 + if OW.map.id ~= mapId or top() ~= OW then + enterMap(mapId, landing[1], landing[2], "down") + run(20); settle(300) + end + local res = talkAt(mapId, landing, "sign@" .. s.x .. "," .. s.y, function() return s.x, s.y end) + if res == "talked" then talked = talked + 1 elseif res == "silent" then silent = silent + 1 + elseif res == "stuck" then stuck = stuck + 1 end + if res == "stuck" or res == "error" or res == "loop" or OW.map.id ~= mapId then + recover(mapId, landing[1], landing[2]) + enterMap(mapId, landing[1], landing[2], "down") + run(20); settle(300) + end + end + end + return talked, silent, stuck +end + +-- ----------------------------------------------------------- the menu pass --- + +-- Every key the pad has; the fuzz leans on A and the d-pad so screens go +-- DEEP (a submenu, a pocket, a page) before B backs them out +local KEYS = { "a", "a", "down", "down", "up", "left", "right", "a", "b", "down", "right", "l", "r", + "start", "select", "left", "a" } + +local visited = {} +local tally = { party = 0, bag = 0 } +local function noteScreen() + visited[describeTop()] = true +end + +-- Close what is above `floorDepth` with B (and START, which closes the pause +-- menu) -- a screen the pad cannot leave is a softlock. +local function closeScreens(label, floorDepth) + -- B alone (every screen should back out), then A alone (a "really?" that + -- defaults to NO has to be answered), then a random mix with the d-pad (a + -- cursor parked on the wrong row of a yes/no) + local ok = run(1000, function(i) + if i <= 150 then + if i % 4 == 0 then press("b") end + if i % 40 == 0 then press("start") end + elseif i <= 300 then + if i % 5 == 0 then press("a") end + else + local m = i % 6 + if m == 0 then press("b") elseif m == 2 then press("a") + elseif m == 4 then press(({ "up", "down", "left", "right" })[rnd(4)]) end + end + end, function() return stackDepth() <= floorDepth end) + if not ok then return false end + if stackDepth() > floorDepth then + local names = {} + for _, st in ipairs(Game.stack.states) do + local okD, d = pcall(StateStack.describe, st) + names[#names + 1] = okD and tostring(d) or "?" + end + fail("softlock", ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s"):format(label, + describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()))) + while stackDepth() > floorDepth do Game.stack:pop() end + return false + end + return true +end + +-- Press random keys at whatever is on top for `n` ticks, recording each screen +-- seen; stops when everything above `floor` has closed or the map changed. +local function fuzzStack(label, n, floor, keys) + keys = keys or KEYS + local mapNow = OW.map and OW.map.id + local ok = run(n, function(i) + if i % 4 == 0 then press(keys[rnd(#keys)]) end + end, function(i) + if i % 8 == 0 then noteScreen() end + return stackDepth() <= floor or (OW.map and OW.map.id) ~= mapNow + end, 30) + return ok +end + +local function openMenu(label) + local floor = stackDepth() + press("start") + local ok = run(20) + if not ok then return nil end + if stackDepth() <= floor then + fail("menu_unopened", label .. ": START did not open a menu | " .. stuckDetail( + ("map=%s moving=%s locked=%s cell=%s,%s surf=%s emote=%s engaging=%s"):format(tostring(OW.map and OW.map.id), + tostring(OW.player.moving), tostring(OW.lockedFrames), tostring(OW.player.cellX), tostring(OW.player.cellY), + tostring(OW.player.surfing), tostring(OW.emote ~= nil), tostring(OW.engaging)))) + return nil + end + return floor +end + +local MENU_SKIP = {} +for id in (getenv("MENU_SKIP") or "link,mods"):gmatch("[^,%s]+") do MENU_SKIP[id] = true end + +local MENU_ROUNDS = envn("MENU_ROUNDS", 4) +local MENU_FUZZ = envn("MENU_FUZZ", 700) + +local function menuPass() + cur.phase = "menu" + -- a map that opens on a scene: read it out (A / B) before the menu is asked for + if not idle() then + local r = drain("menu arrival scene") + if r ~= true then recover(OW.map.id, OW.player.cellX, OW.player.cellY) end + end + local floor = openMenu("start menu") + if not floor then return end + local menu = top() + local rows = (menu and (menu.rows or menu.items)) or {} + local ids = {} + for i, r in ipairs(rows) do ids[i] = tostring(r.id or r.label or r.key or i) end + if #ids == 0 then ids = { "?", "?", "?", "?", "?", "?", "?" } end + if not closeScreens("start menu close", floor) then return end + local mapId = OW.map.id + local lx, ly = OW.player.cellX, OW.player.cellY + for i, rowId in ipairs(ids) do + if MENU_SKIP[rowId] then goto skiprow end + for round = 1, MENU_ROUNDS do + local label = ("start row %s #%d"):format(rowId, round) + visited = {} + if not idle() then + if not drain(label .. " settle") then recover(mapId, lx, ly) end + end + local f = openMenu(label) + if not f then recover(mapId, lx, ly); goto next end + do + local m = top() + if m and m.index then m.index = i end + press("a") + local ok = run(25, nil, nil, 10) + if not ok then recover(mapId, lx, ly); goto next end + noteScreen() + if stackDepth() > f then + -- a sub-screen (or the confirmation of one) is up: poke at it + drawFrame() + fuzzStack(label, MENU_FUZZ, f) + end + local okC = closeScreens(label, f) + if not okC then recover(mapId, lx, ly) end + -- the field may have a script/prompt of its own after (save, ...) + if OW.map.id ~= mapId then + enterMap(mapId, lx, ly, "down"); run(20); settle(300) + elseif not idle() then drain(label) end + end + if round == 1 then + local names = {} + for name in pairs(visited) do names[#names + 1] = name end + table.sort(names) + emit(("SWEEP_INFO|%s|menu_row|%s -> %s"):format(cur.map, rowId, oneLine(table.concat(names, " ; "), 300))) + end + ::next:: + end + ::skiprow:: + end +end + +local function poketchPass() + cur.phase = "poketch" + local Screens = require("src.ui.Screens") + Game.save.poketch = Game.save.poketch or {} + Game.save.poketch.enabled = true + local okN, pk = pcall(Screens.push, Game, "Gen4Poketch", { onCancel = function() end }) + if not okN then fail("poketch_open", pk) return end + local menu = top() + if not (menu and menu.apps) then + fail("poketch_open", "no apps list on " .. describeTop()) + closeScreens("poketch", 1) + return + end + -- every cartridge app registered + local st = menu:store() + st.registered = {} + for i = 0, 40 do st.registered[i] = true end + local count = #menu.apps + for idx = 1, count do + menu.index = idx + menu.appState = nil + local label = "poketch app " .. idx + local ok = run(40, nil, nil, 20) + if not ok then break end + -- taps over the touch area, then keys + for _, pt in ipairs({ { 128, 96 }, { 40, 40 }, { 200, 150 }, { 128, 170 } }) do + if menu.touchpressed then + local okT, errT = xpcall(menu.touchpressed, debug.traceback, menu, 1, pt[1], pt[2]) + if not okT then fail("poketch_touch", label .. ": " .. tostring(errT)) end + if menu.touchreleased then pcall(menu.touchreleased, menu, 1, pt[1], pt[2]) end + end + if not run(8) then break end + end + fuzzStack(label, 80, stackDepth() - 1, { "a", "down", "up", "left", "right" }) + if top() ~= menu then + fail("poketch_closed", label .. ": the watch closed under input") + return + end + end + -- cycle with the bezel + for _ = 1, count do + press("right") + if not run(12, nil, nil, 6) then break end + end + closeScreens("poketch", 1) +end + +-- bring the world back to an idle field on `mapId` with only the overworld on +-- the stack: B out of whatever is open, re-enter the map when a field move or +-- an item took the player elsewhere, put down anything the world is still doing +local function ensureField(mapId, lx, ly, label) + if stackDepth() > 1 then closeScreens(label, 1) end + if OW.map.id ~= mapId or top() ~= OW then + enterMap(mapId, lx, ly, "down"); run(20); settle(300) + elseif not idle() then + if not drain(label) then recover(mapId, lx, ly) end + end + if OW.map.id ~= mapId or top() ~= OW then + enterMap(mapId, lx, ly, "down"); run(20); settle(300) + end +end + +-- Open the party menu and take EVERY action it offers on every member (the +-- summary, switch, give / take, and each field move the HM-loaded rich party +-- knows), drawing and fuzzing whatever comes up. Each action starts from a +-- freshly opened menu on a fresh field, so one that ends the visit (Fly, +-- Teleport, Dig) costs nothing. +local function partyPass() + cur.phase = "party" + local mapId = OW.map.id + local lx, ly = OW.player.cellX, OW.player.cellY + local Screens = require("src.ui.Screens") + local slot, k, guard = 1, 1, 0 + while guard < 90 do + guard = guard + 1 + ensureField(mapId, lx, ly, "party") + if slot > #(Game.save.party or {}) then break end + local base = stackDepth() + local okP, errP = pcall(Screens.push, Game, "PartyMenu", { onCancel = function() end }) + if not okP then fail("party_open", errP) return end + run(8, nil, nil, 8) + local menu = top() + if not (menu and menu.actions and menu.runAction) then closeScreens("party", base) return end + menu.index = slot + menu.submenu, menu.itemMenu, menu.mailMenu, menu.switchFrom = 1, nil, nil, nil + local acts = menu:actions() + local action = acts[k] + if not action or action == "cancel" then + slot, k = slot + 1, 1 + else + local label = ("party slot %d action %s"):format(slot, tostring(action)) + tally.party = tally.party + 1 + if TRACE then print("[sweep] " .. label) end + visited = {} + local okA, errA = xpcall(menu.runAction, debug.traceback, menu, action) + if not okA then fail("party_action", label .. ": " .. tostring(errA)) end + if not run(40, nil, nil, 20) then recover(mapId, lx, ly) return end + noteScreen() + if stackDepth() > base + 1 or (top() ~= menu and stackDepth() > base) then + fuzzStack(label, 220, base + 1) + end + k = k + 1 + end + end + ensureField(mapId, lx, ly, "party") +end + +-- Open the bag, walk every pocket, and use / give / toss / register every item +-- in it (A on the item, then each action in the item's menu), backing out of +-- whatever each one opens. Same shape as the party pass: one fresh bag per action. +local MAX_BAG_ITEMS = envn("MAX_BAG_ITEMS", 14) +local function bagPass() + cur.phase = "bag" + local mapId = OW.map.id + local lx, ly = OW.player.cellX, OW.player.cellY + local Screens = require("src.ui.Screens") + local pocket, item, k, guard = 1, 1, 1, 0 + local pockets + while guard < 600 do + guard = guard + 1 + ensureField(mapId, lx, ly, "bag") + local base = stackDepth() + local okP, errP = pcall(Screens.push, Game, "BagMenu", { onCancel = function() end }) + if not okP then fail("bag_open", errP) return end + run(8, nil, nil, 8) + local bag = top() + if not (bag and bag.rows and bag.runAction) then closeScreens("bag", base) return end + pockets = pockets or (bag.pockets and #bag.pockets or 8) + if pocket > pockets then break end + for _ = 2, pocket do press("right"); run(3) end + run(4) + local count = math.min(#(bag.rows or {}), MAX_BAG_ITEMS) + if item > count then + pocket, item, k = pocket + 1, 1, 1 + else + for _ = 2, item do press("down"); run(2) end + press("a"); run(6) + local m = bag.menu + local action = m and m.rows and m.rows[k] + if not action or action == "cancel" then + item, k = item + 1, 1 + else + local label = ("bag pocket %d item %d action %s"):format(pocket, item, tostring(action)) + tally.bag = tally.bag + 1 + if TRACE then print("[sweep] " .. label) end + visited = {} + bag.menu.index = k + press("a") + if not run(30, nil, nil, 15) then recover(mapId, lx, ly) return end + noteScreen() + if stackDepth() > base + 1 or (top() ~= bag and stackDepth() > base) then + fuzzStack(label, 180, base + 1) + end + k = k + 1 + end + end + end + ensureField(mapId, lx, ly, "bag") +end + +local function menusFull() + tally = { party = 0, bag = 0 } + menuPass() + if not idle() then drain("menus") end + if idle() then partyPass() end + if idle() then bagPass() end + if idle() then poketchPass() end + emit(("SWEEP_INFO|%s|menus|party actions %d, bag actions %d"):format(cur.map, tally.party, tally.bag)) +end + +-- -------------------------------------------------------------- the driver --- + +local function pickMaps() + local ids = {} + for k, v in pairs(Data.maps) do + if type(k) == "string" and type(v) == "table" and v.id == k then ids[#ids + 1] = k end + end + table.sort(ids) + local want = getenv("MAPS") + if want and want ~= "all" then + local out = {} + for id in want:gmatch("[^,%s]+") do out[#out + 1] = id end + ids = out + end + local prefix = getenv("MAP_PREFIX") + if prefix then + local out = {} + for _, id in ipairs(ids) do if id:sub(1, #prefix) == prefix then out[#out + 1] = id end end + ids = out + end + local shard, of = (getenv("SHARD") or ""):match("^(%d+)/(%d+)$") + if shard then + local out = {} + for i, id in ipairs(ids) do if (i - 1) % tonumber(of) == tonumber(shard) - 1 then out[#out + 1] = id end end + ids = out + end + local skip, limit = envn("SKIP", 0), envn("LIMIT", math.huge) + local out = {} + for i, id in ipairs(ids) do + if i > skip and #out < limit then out[#out + 1] = id end + end + return out +end + +-- an arrival cell for the map: one of its own warps' cells (what a warp in +-- lands on), else any reachable cell +local function landings(id, def) + local out = {} + for _, w in ipairs(def.warps or {}) do + if w.x and w.y then out[#out + 1] = { w.x, w.y } end + if #out >= ENTRIES then break end + end + if #out == 0 then + -- no warps at all: the middle of the map, nudged until the engine agrees it is floor + local cx, cy = math.floor((def.width or 2) / 2), math.floor((def.height or 2) / 2) + out[1] = { cx, cy, probe = true } + end + return out +end + +local function sweepMap(id, menusHere) + cur.map, cur.phase = id, "load" + mapFails, mapFailKinds = {}, {} + -- each map's randomness is its own, so a restart or a shard replays it exactly + local h = SEED * 7919 + for i = 1, #id do h = (h * 31 + id:byte(i)) % 2147483647 end + rngState = h + cur.hash = h + local def = Data.maps[id] + local t0 = os.clock() + deadline = t0 + envn("MAP_SECS", 120) + local f0 = frames + local failsBefore = totals.fails + local info = { taken = 0, tried = 0, walked = 0, talked = 0, silent = 0, stuck = 0 } + local lands = landings(id, def) + local landing = lands[1] + local ok, err = xpcall(function() + if TRACE then print("[sweep] " .. id .. " load") end + enterMap(id, landing[1], landing[2], "down") + if OW.map == nil or OW.map.id ~= id then + fail("load_mismatch", ("asked for %s, got %s"):format(id, tostring(OW.map and OW.map.id))) + end + if landing.probe and OW.map then + local order = reachable(OW.map, landing[1], landing[2]) + if #order <= 1 then + -- a seed that is a wall: scan for any reachable cell + for y = 0, (def.height or 0) - 1 do + for x = 0, (def.width or 0) - 1 do + local o = reachable(OW.map, x, y, 12) + if #o > 6 then landing[1], landing[2] = x, y; goto found end + end + end + ::found:: + enterMap(id, landing[1], landing[2], "down") + end + end + if PHASES.load and not phaseLoad(def) then return end + if PHASES.walk and OW.map and OW.map.id == id then + if TRACE then print("[sweep] " .. id .. " walk") end + local okW, n = randomWalk(id, landing[1], landing[2]) + info.walked = n or 0 + if okW and not idle() then drain("walk") end + if not okW then recover(id, landing[1], landing[2]) end + end + if PHASES.warp then + if TRACE then print("[sweep] " .. id .. " warp") end + info.taken, info.tried = phaseWarps(id, def, landing) + end + if PHASES.npc or PHASES.sign then + if TRACE then print("[sweep] " .. id .. " talk") end + info.talked, info.silent, info.stuck = phaseTalk(id, def, landing, PHASES.sign) + end + if PHASES.menu and menusHere then + if TRACE then print("[sweep] " .. id .. " menus") end + if OW.map.id ~= id or top() ~= OW or not idle() then + enterMap(id, landing[1], landing[2], "down") + run(20); settle(300) + end + menusFull() + end + end, debug.traceback) + if not ok then + if tostring(err):find("MAP_TIMEOUT", 1, true) then + fail("map_timeout", ("the map ran past %ds of wall clock | top=%s depth=%d"):format(envn("MAP_SECS", 120), + describeTop(), stackDepth())) + else + fail("harness_error", err) + end + end + deadline = math.huge + -- an arrival the player cannot walk away from (a cutscene room, or a map + -- whose spawn is boxed in): informational, the scripted ones are legitimate + if PHASES.walk and info.walked <= 2 and #(def.warps or {}) > 0 then + emit(("SWEEP_INFO|%s|boxed_arrival|walked %d cell(s) from %d,%d"):format(id, info.walked, landing[1], landing[2])) + end + local failed = totals.fails - failsBefore + emit(("SWEEP_MAP|%s|%s|frames=%d secs=%.1f npcs=%d signs=%d warps=%d/%d talked=%d silent=%d stuck=%d walked=%d"):format( + id, failed == 0 and "ok" or "fail", frames - f0, os.clock() - t0, #(def.objects or {}), #(def.signs or {}), + info.taken, info.tried, info.talked, info.silent, info.stuck, info.walked)) + cur.phase = "-" +end + +function love.load() + local ok, err = xpcall(function() + require("src.core.GameVersion").set(getenv("VERSION") or "platinum") + local assetRoot = getenv("POKEPORT_ASSET_ROOT") + if assetRoot then + local Assets = require("src.render.Assets") + local realImage, held = Assets.image, {} + Assets.image = function(path, ...) + if type(path) == "string" and not held[path] then + local f = io.open(assetRoot .. "/" .. path, "rb") + if f then + local bytes = f:read("*a"); f:close() + local okI, img = pcall(love.graphics.newImage, love.filesystem.newFileData(bytes, path)) + if okI then img:setFilter("nearest", "nearest"); held[path] = img end + end + end + return held[path] or realImage(path, ...) + end + end + -- the services Game:boot boots, in its order + Data = require("src.core.Data") + Data:load() + Game = require("src.core.Game") + Game.data = Data + local ModLoader = require("src.mods.Loader") + Game.mods = ModLoader.new() + Game.mods:load(Data) + Game.modStatus = Game.mods:status() + require("src.render.Pipelines").install(Data) + local Renderer = require("src.render.Renderer") + Game.renderer = Renderer + Renderer:init() + require("src.render.Font").load(Data) + require("src.ui.Theme").load(Data) + require("src.core.Strings").load(Data) + StateStack = require("src.core.StateStack") + StateStack:init() + Game.stack = StateStack + Collision = require("src.world.Collision") + Music = require("src.core.Music") + OW = require("src.world.OverworldController") + + Game.input = setmetatable({ + state = keysDown, pressed = keysPressed, + wasPressed = function(_, k) return keysPressed[k] == true end, + isDown = function(_, k) return keysDown[k] == true or keysPressed[k] == true end, + wasReleased = function() return false end, + isTouchDown = function() return false end, + step = function() end, + softResetStep = function() return false end, + }, { __index = function() return function() return false end end }) + + hookLogger() + installStubs() + do + local push = StateStack.push + StateStack.push = function(self, ...) + pushCount = pushCount + 1 + return push(self, ...) + end + end + newSave() + + _G.__sweep = { Game = Game, OW = OW, Data = Data, enterMap = enterMap, run = run, press = press } + -- PROBE=: run a scratch script against the booted game and quit + if getenv("PROBE") then + local okP, errP = xpcall(dofile, debug.traceback, getenv("PROBE")) + if not okP then print("PROBE FAILED: " .. tostring(errP)) end + love.event.quit(0) + return + end + local ids = pickMaps() + if getenv("LIST") then + for _, id in ipairs(ids) do print("SWEEP_LIST|" .. id) end + love.event.quit(0) + return + end + emit(("SWEEP_START|maps=%d profile=%s state=%s seed=%d phases=%s menus=%s"):format(#ids, PROFILE, STATE, SEED, + getenv("PHASES") or "all", MENUS)) + local menusDone = false + local t0 = os.clock() + local failedMaps = 0 + for i, id in ipairs(ids) do + local menusHere = (MENUS == "map") or (MENUS == "once" and not menusDone) + local fb = totals.fails + sweepMap(id, menusHere) + if menusHere then menusDone = true end + if totals.fails > fb then failedMaps = failedMaps + 1 end + collectgarbage("collect") + end + -- the warnings that were not flagged, by shape, most frequent first + local shapes = {} + for s, n in pairs(warnShapes) do shapes[#shapes + 1] = { s, n } end + table.sort(shapes, function(a, b) return a[2] > b[2] or (a[2] == b[2] and a[1] < b[1]) end) + for i = 1, math.min(25, #shapes) do emit(("SWEEP_WARN|%d|%s"):format(shapes[i][2], oneLine(shapes[i][1], 200))) end + emit(("SWEEP_DONE|maps=%d failed_maps=%d fails=%d battles_cut=%d frames=%d secs=%.0f"):format( + #ids, failedMaps, totals.fails, battles, frames, os.clock() - t0)) + end, debug.traceback) + if not ok then + emit("SWEEP_CRASH|" .. oneLine(err, 2000)) + end + if outFile then outFile:close() end + love.event.quit(0) +end diff --git a/tools/gen4_party_pick_cancel_check.lua b/tools/gen4_party_pick_cancel_check.lua new file mode 100644 index 000000000..eed2439ec --- /dev/null +++ b/tools/gen4_party_pick_cancel_check.lua @@ -0,0 +1,51 @@ +-- Run: python tools/run_lua_check.py tools/gen4_party_pick_cancel_check.lua +-- +-- A SCRIPT THAT OPENS THE PARTY TO PICK ONE MUST END WHEN THE PLAYER BACKS OUT. +-- +-- `selectmovetutorpokemon` and the NPC-trade pickers (g4_open_party_for_trade) +-- push the party menu with `pickOnly` and yield; only `onSwitch` resumed the +-- runner. B / CANCEL goes through Gen4PartyMenu:close(), which calls +-- `onCancel` -- and the script passed none, so the coroutine stayed parked for +-- good, the field stayed locked behind the script's fade, and the Platinum +-- move-tutor and trade NPCs froze the game for anybody who changed their mind. +-- Found by tools/gen4_field_sweep (Route 205's / Solaceon's tutors). + +package.path = './?.lua;./?/init.lua;' .. package.path +love = require('tests.love_stub') +local T = require('tests.harness').suite('Gen 4 party pick cancel') +require('src.core.GameVersion').set('platinum') +local Commands = require('src.script.Commands'); require('src.script.Gen4Commands') +local Runner = require('src.script.ScriptRunner') +local Screens = require('src.ui.Screens') +local push = Screens.push + +local function scenario(answer) + local game = { data = { pokemon = {}, constants = {} }, + save = { party = { { species = 387 }, { species = 390 } }, flags = {}, player = {} } } + local runner = Runner.new(game, {}) + local pushed + Screens.push = function(_, id, opts) pushed = { id = id, opts = opts } end + local reached = false + Commands.pick_check_reached = function(ctx) reached = ctx.g4PartySlot end + runner:run({ { 'g4_select_tutor_mon' }, { 'pick_check_reached' } }) + T.check(pushed and pushed.id == 'PartyMenu' and pushed.opts.pickOnly, 'the party opens as a picker') + T.eq(runner:isRunning(), true, 'the script waits on the picker') + if answer == 'cancel' then + T.check(type(pushed.opts.onCancel) == 'function', 'B on the picker has a handler') + pushed.opts.onCancel() + else + pushed.opts.onSwitch(game.save.party[2]) + end + return runner, reached +end + +local runner, reached = scenario('cancel') +T.eq(runner:isRunning(), false, 'cancelling the picker lets the script finish') +T.eq(reached, 0xFF, 'and the slot reads "none"') +runner, reached = scenario('pick') +T.eq(runner:isRunning(), false, 'picking a Pokemon still finishes the script') +T.eq(reached, 1, 'and the slot is the zero-based index') + +Screens.push = push +Commands.pick_check_reached = nil +T.finish() From f614837fb376050ae23385e3e2f2edede49f258e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:52:43 +0100 Subject: [PATCH 27/60] metHere: name the live map, not the last saved one; regression check --- src/battle/BattleState.lua | 6 +++++- tools/battle_met_here_check.lua | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tools/battle_met_here_check.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 06def0b20..3dda7379a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1192,7 +1192,11 @@ BattleState.stampOT = stampOT -- internal id of whichever screen the ball happened to land on. function BattleState.metHere(game) local save = game and game.save - local mapId = save and save.player and save.player.map + -- THE MAP THE PLAYER IS ON, not the one last saved: save.player.map is only + -- written by OverworldState:captureSave, so it named wherever the last save + -- happened. Falls back to it when no overworld is live (tools, tests). + local ow = game and game.overworld + local mapId = (ow and ow.map and ow.map.id) or (save and save.player and save.player.map) local def = mapId and game.data and game.data.maps and game.data.maps[mapId] return def and def.regionMapSection or nil end diff --git a/tools/battle_met_here_check.lua b/tools/battle_met_here_check.lua new file mode 100644 index 000000000..6e29a05c4 --- /dev/null +++ b/tools/battle_met_here_check.lua @@ -0,0 +1,17 @@ +-- Run: python tools/run_lua_check.py tools/battle_met_here_check.lua +-- +-- BattleState.metHere names the map the player is ON (game.overworld.map), +-- not save.player.map, which only a save writes (captureSave). +package.path = './?.lua;' .. package.path +love = love or { math = { random = math.random }, filesystem = {}, graphics = {} } +local ok, BattleState = pcall(require, 'src.battle.BattleState') +if not ok then print('SKIP: BattleState needs LOVE (' .. tostring(BattleState):sub(1, 80) .. ')'); return end +local fails = 0 +local function check(c, l) if not c then fails = fails + 1; print('FAIL: ' .. l) end end +local data = { maps = { A = { regionMapSection = 'SEC_A' }, B = { regionMapSection = 'SEC_B' } } } +local game = { data = data, save = { player = { map = 'A' } } } +check(BattleState.metHere(game) == 'SEC_A', 'no overworld: falls back to the saved map') +game.overworld = { map = { id = 'B' } } +check(BattleState.metHere(game) == 'SEC_B', 'a live overworld wins over the saved map') +if fails > 0 then os.exit(1) end +print('battle_met_here_check: ok') From e899887a871c65ddfd7188c8709d8d18b72bbcc6 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 10:54:58 +0100 Subject: [PATCH 28/60] Renegade Platinum: trainer lines past the offset table, intro Pokemon from overlay 73, data-driven slot-9 type names, version-aware Game Corner check --- src/battle/Gen4MoveEffects.lua | 18 ++++++++++- src/import/Gen4IntroScene.lua | 41 ++++++++++++++++++++++++ src/import/Gen4TrainerMessages.lua | 51 +++++++++++++++++++++++++++++- src/import/RomExtractorGen4.lua | 9 ++++++ src/script/Gen4BattleFactory.lua | 12 ++++++- src/ui/Gen4HallTypeScreen.lua | 6 ++-- src/ui/Gen4RowanIntro.lua | 5 ++- tools/gen4_game_corner_check.lua | 42 +++++++++++++++++++++--- 8 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/battle/Gen4MoveEffects.lua b/src/battle/Gen4MoveEffects.lua index e96ff6816..24b1dbf24 100644 --- a/src/battle/Gen4MoveEffects.lua +++ b/src/battle/Gen4MoveEffects.lua @@ -269,6 +269,22 @@ G4.TYPES = { [0] = "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", " "GHOST", "STEEL", "MYSTERY", "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK" } +-- Slot 9 is the cartridge's own to name: "???" (MYSTERY) on Platinum, FAIRY +-- on Renegade Platinum. The import writes the type-name bank as +-- `constants.types`, so a number is named from the cache rather than from the +-- retail list above; every other slot keeps the list's spelling. +function G4.typeName(data, id) + id = tonumber(id) or 0 + if id == 9 then + local list = data and data.constants and data.constants.types + local text = list and list[9] + if type(text) == "string" and text ~= "" and text ~= "???" then + return text:upper() + end + end + return G4.TYPES[id] +end + -- the bench a TryReplaceFaintedMon / U-turn draws from local function bench(battle, b) local G5 = require("src.battle.Gen5MoveEffects") @@ -469,7 +485,7 @@ F.NATURAL_GIFT = { return false, Strings(FAILED) end ctx.g4Gift = { power = tonumber(def.naturalGiftPower), - type = G4.TYPES[tonumber(def.naturalGiftType) or 0] or "NORMAL" } + type = G4.typeName(ctx.battle.data, def.naturalGiftType) or "NORMAL" } return true end, beforeAccuracy = function(ctx) diff --git a/src/import/Gen4IntroScene.lua b/src/import/Gen4IntroScene.lua index 3f6bb1bf4..95562ea77 100644 --- a/src/import/Gen4IntroScene.lua +++ b/src/import/Gen4IntroScene.lua @@ -240,4 +240,45 @@ Gen4IntroScene.SCRIPT = { { text = "ending", figure = "rowan", backdrop = "speech" }, } +-- THE POKEMON ROWAN RELEASES, read from overlay 73 rather than assumed. +-- +-- Overlay 73 is the opening's own code (`RowanIntro_LoadBunearySprite` and the +-- cry played as the ball opens). Retail Platinum names Buneary (427) in both +-- places as an instruction literal; Renegade Platinum edits exactly those two +-- literals to Eevee (133) and touches nothing else in the overlay, which is +-- how this was found: it is the intro's Pokemon, not Amity Square's walkers. +-- +-- The sprite loader is a fixed Thumb sequence -- +-- ldr r1, [pc, #n] ; the species literal +-- str r2, [sp, #8] ; 0x9202 +-- add r0, sp, #0x10 ; 0xA804 +-- movs r3, #2 ; 0x2302 (FACE_FRONT) +-- bl +-- -- so the species is the literal that `ldr r1` loads there. Returns nil when +-- the sequence is absent (another build of the overlay), and the screen keeps +-- Buneary. +Gen4IntroScene.OVERLAY = 73 +function Gen4IntroScene.monSpecies(overlay, maxSpecies) + if type(overlay) ~= "string" then return nil end + local function h16(at) + local a, b = overlay:byte(at + 1, at + 2) + if not b then return nil end + return a + b * 256 + end + for at = 0, #overlay - 12, 2 do + local w = h16(at) + if w and w >= 0x4900 and w < 0x4A00 + and h16(at + 2) == 0x9202 and h16(at + 4) == 0xA804 + and h16(at + 6) == 0x2302 then + local target = math.floor((at + 4) / 4) * 4 + (w % 256) * 4 + local a, b, c, d = overlay:byte(target + 1, target + 4) + if d then + local value = a + b * 256 + c * 65536 + d * 16777216 + if value >= 1 and value <= (maxSpecies or 493) then return value end + end + end + end + return nil +end + return Gen4IntroScene diff --git a/src/import/Gen4TrainerMessages.lua b/src/import/Gen4TrainerMessages.lua index 28a1c0e89..5afbbf347 100644 --- a/src/import/Gen4TrainerMessages.lua +++ b/src/import/Gen4TrainerMessages.lua @@ -251,9 +251,44 @@ function Gen4TrainerMessages.forTrainer(tableBytes, offsetBytes, trainerId) return out, n end +-- TRAINERS THE OFFSET TABLE DOES NOT COVER (ROM hacks that add trainers). +-- +-- Renegade Platinum grows trdata to 1,066 trainers and rewrites 136 records +-- of trtbl IN PLACE -- blank placeholder lines of dummy trainers become the +-- new trainers' real lines, retagged with ids 928..1065 -- but leaves +-- trtblofs at its 928 entries. The cartridge's walk reads the offset of +-- trainer 928 from past the end of that file (zero padding), starts at record +-- 0 (trainer 247's), and breaks at once: by the letter of the walk those +-- trainers are silent and their authored lines unreachable. +-- +-- The records of one trainer are always contiguous (that is what makes the +-- walk work), so for ids PAST the offset table the run can be found by +-- scanning the table. It is applied only there: a trainer the offset table +-- covers keeps the cartridge's answer, so a retail cartridge (offset table +-- length == trainer count) never reaches this path and extracts the same +-- bytes as before. +function Gen4TrainerMessages.scanUncovered(tableBytes, firstId) + local out, n = {}, 0 + local size = type(tableBytes) == "string" and #tableBytes or 0 + local stride = Gen4TrainerMessages.RECORD_BYTES + local offset = 0 + while offset + stride <= size do + local t, mt = u16(tableBytes, offset), u16(tableBytes, offset + 2) + if t >= firstId then + local row = out[t] + if not row then row = {}; out[t] = row end + if row[mt] == nil then row[mt] = floor(offset / stride); n = n + 1 end + end + offset = offset + stride + end + return out, n +end + -- Every trainer, plus a report whose numbers are the controls described in the -- header: `records` and `reached` must be equal, and both must equal the entry --- count of bank 617. +-- count of bank 617. On a hack with trainers past the offset table the walk +-- alone reaches fewer (`unreachable`), and `scanned` counts the records +-- recovered by `scanUncovered` for them. function Gen4TrainerMessages.all(tableBytes, offsetBytes) local out = {} local trainers = Gen4TrainerMessages.trainerCount(offsetBytes) @@ -273,7 +308,21 @@ function Gen4TrainerMessages.all(tableBytes, offsetBytes) end end end + local scanned, scannedTrainers = 0, 0 + local extra = Gen4TrainerMessages.scanUncovered(tableBytes, trainers) + for id, row in pairs(extra) do + if out[id] == nil then + out[id] = row + scannedTrainers = scannedTrainers + 1 + for _, index in pairs(row) do + scanned = scanned + 1 + if not seen[index] then seen[index] = true; reached = reached + 1 end + end + end + end return out, { + scanned = scanned, + scannedTrainers = scannedTrainers, trainers = trainers, withMessages = withMessages, withoutMessages = trainers - withMessages, diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index 85f3d6cc0..e87b8b05e 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -5552,6 +5552,15 @@ function RomExtractorGen4:extractIntro() out.script = Gen4IntroScene.SCRIPT out.role = Gen4IntroScene.ROLE + -- The Pokemon Rowan releases (Buneary on retail, Eevee on Renegade). + do + local ok, ov = pcall(function() return self.rom:overlay(Gen4IntroScene.OVERLAY) end) + local species = ok and Gen4IntroScene.monSpecies(ov) or nil + -- Written only when it differs from the screen's built-in Buneary, so a + -- retail cache stays byte-identical to the one made before this existed. + if species and species ~= 427 then out.monSpecies = species end + end + -- ------------------------------------------------------------ television -- local tvArc = self:archive("introTv") if tvArc then diff --git a/src/script/Gen4BattleFactory.lua b/src/script/Gen4BattleFactory.lua index 81a1512dc..b2e53899f 100644 --- a/src/script/Gen4BattleFactory.lua +++ b/src/script/Gen4BattleFactory.lua @@ -306,7 +306,10 @@ function B.commonType(game, f) if t2 and t2 ~= t1 then counts[t2] = (counts[t2] or 0) + 1 end end local best = 0 - for t = 0, 17 do + -- every slot the cartridge names (18 on retail Platinum and on Renegade, + -- where slot 9 is FAIRY), not a literal 17 + local slots = (game.data.constants and tonumber(game.data.constants.typeCount)) or 18 + for t = 0, slots - 1 do if (counts[best] or 0) < (counts[t] or 0) then best = t end end return ((counts[best] or 0) <= 1) and 0xFF or best @@ -315,6 +318,13 @@ function B.typeNumber(data, t) if type(t) == "number" then return t end local order = data.constants and data.constants.typeOrder for i, name in ipairs(order or {}) do if name == t then return i - 1 end end + -- the cache's own type names (slot 9: MYSTERY on retail, FAIRY on Renegade) + local named = data.constants and data.constants.types + if type(named) == "table" and type(t) == "string" then + for i = 0, 63 do + if type(named[i]) == "string" and named[i]:upper() == t:upper() then return i end + end + end local T = { NORMAL = 0, FIGHTING = 1, FLYING = 2, POISON = 3, GROUND = 4, ROCK = 5, BUG = 6, GHOST = 7, STEEL = 8, MYSTERY = 9, FIRE = 10, WATER = 11, GRASS = 12, ELECTRIC = 13, PSYCHIC = 14, ICE = 15, DRAGON = 16, DARK = 17 } diff --git a/src/ui/Gen4HallTypeScreen.lua b/src/ui/Gen4HallTypeScreen.lua index bdec47df4..f0bbe52c5 100644 --- a/src/ui/Gen4HallTypeScreen.lua +++ b/src/ui/Gen4HallTypeScreen.lua @@ -59,7 +59,8 @@ local function fill(game, n, fallback, ...) return ok and plain or fallback end function S:typeName(t) - return Gen4Text.resolve(self.data, S.TYPE_NAMES, t, self.game) or tostring(t) + return Gen4Text.resolve(self.data, S.TYPE_NAMES, t, self.game) + or (t == 9 and "???" or tostring(t)) end -- GetCursorPos: cells past 16 read the ??? rank slot function S:rankAt(cell) @@ -181,7 +182,8 @@ function S:draw() Font.draw(tostring(math.min(self:rankAt(cell) + 1, 10)), x + 18, y + 4) Font.draw(self:typeName(t), x + 1, y + 16) else - Font.draw("???", x + 18, y + 12) + -- slot 9's own name: "???" on retail, FAIRY on Renegade (bank 624) + Font.draw(self:typeName(9), x + 18, y + 12) end end end diff --git a/src/ui/Gen4RowanIntro.lua b/src/ui/Gen4RowanIntro.lua index 96c17212a..b01458155 100644 --- a/src/ui/Gen4RowanIntro.lua +++ b/src/ui/Gen4RowanIntro.lua @@ -111,6 +111,8 @@ local FLASH_MAX = 16 -- PokemonSpriteTemplate for SPECIES_BUNEARY, FACE_FRONT, so the picture is the -- species' own front sprite out of pl_pokegra rather than anything stored in -- the intro archive -- which is why this needs no new art. +-- (the extractor reads the real one from overlay 73 into `gen4_intro.monSpecies`; +-- 427 is the fallback for a cache imported before it did) local MON_SPECIES = 427 -- Where it sits: `Bg_LoadToTilemapRect(..., 11, 9, 10, 10)`, a 10x10-tile @@ -533,7 +535,8 @@ end -- has no sprite for it, which costs the picture and not the sequence. function Gen4RowanIntro:monImage() if self.monImageCache ~= nil then return self.monImageCache or nil end - local path = Sprites.path(self.game.data, MON_SPECIES, "front", + local species = (self.rec and tonumber(self.rec.monSpecies)) or MON_SPECIES + local path = Sprites.path(self.game.data, species, "front", { kind = "intro" }) self.monImageCache = path and self:img(path) or false return self.monImageCache or nil diff --git a/tools/gen4_game_corner_check.lua b/tools/gen4_game_corner_check.lua index fc774104b..9e7f645f3 100644 --- a/tools/gen4_game_corner_check.lua +++ b/tools/gen4_game_corner_check.lua @@ -16,11 +16,41 @@ local rec = load('gen4_game_corner') check(rec, 'the cache must carry gen4_game_corner (run tools/gen4_game_corner_extract.lua)') if not rec then print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) os.exit(1) end +-- Which cartridge made this cache: the SHA-1 that ends `rom-cache.complete`, +-- two directories above `data/generated`. Renegade Platinum re-prices and +-- replaces prizes (its first row is item 275, not the Silk Scarf), so the +-- retail asserts below hold for every cache EXCEPT a Renegade one, and the +-- Renegade branch asserts what the hack's ARM9 actually holds. +local RENEGADE_SHA1 = '381f9eef44564602a5f6469ee8c390fbb8ebac3a' +local function cacheSha1() + local f = io.open(dir .. '/../../rom-cache.complete', 'rb') + local raw = f and f:read('*a') + if f then f:close() end + return raw and raw:match(':(%x+)%s*$') +end +local renegade = cacheSha1() == RENEGADE_SHA1 + -- the table, against scrcmd_game_corner_prize.c check(#rec.prizes == 19, 'nineteen prizes') -check(rec.prizes[1].item == 251 and rec.prizes[1].price == 1000, 'Silk Scarf for 1000 first') -check(rec.prizes[5].item == 417 and rec.prizes[5].price == 2000, 'TM90 (Substitute) for 2000') -check(rec.prizes[19].item == 395 and rec.prizes[19].price == 20000, 'TM68 (Giga Impact) for 20000 last') +local lastPrize = rec.prizes[19] +if not renegade then + check(rec.prizes[1].item == 251 and rec.prizes[1].price == 1000, 'Silk Scarf for 1000 first') + check(rec.prizes[5].item == 417 and rec.prizes[5].price == 2000, 'TM90 (Substitute) for 2000') + check(lastPrize.item == 395 and lastPrize.price == 20000, 'TM68 (Giga Impact) for 20000 last') +else + -- Renegade's table, as its ARM9 holds it (sGameCornerPrizeData, first row + -- replaced and the TM rows re-priced): shape plus the rows the hack changed + local itemsData = load('items') + for i, row in ipairs(rec.prizes) do + check(row.item > 0 and row.price > 0 and row.price % 10 == 0 and row.price <= 50000, + 'prize ' .. i .. ' is an item and a price in coins') + check(itemsData and itemsData[row.item], 'prize ' .. i .. ' names an item the cache knows') + end + check(rec.prizes[1].item == 275 and rec.prizes[1].price == 1000, + 'Renegade: item 275 for 1000 replaces the Silk Scarf first') + check(rec.prizes[2].item == 265 and rec.prizes[3].item == 276, + 'Renegade: rows 2 and 3 follow the replaced first row') +end -- coins.c local GC = require('src.import.Gen4GameCorner') @@ -57,7 +87,11 @@ C.g4_get_coins(ctx, 0x800C) check(var(0x800C) == 50, 'getcoinsamount') save.gen4Vars[0x8008] = 18 C.g4_prize_data(ctx, 0x8008, 0x8000, 0x8001) -check(var(0x8000) == 395 and var(0x8001) == 20000, 'getgamecornerprizedata 18: TM68, 20000') +check(var(0x8000) == lastPrize.item and var(0x8001) == lastPrize.price, + 'getgamecornerprizedata 18: the last row of the table') +if not renegade then + check(var(0x8000) == 395 and var(0x8001) == 20000, 'getgamecornerprizedata 18: TM68, 20000') +end -- the window: " 50 Coins", right-aligned, and buffer 0 left alone game.stringBuffers[1] = 'KEEP' From 38049c336bfaab0a111f8684048ef7e2ea670486 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:02:34 +0100 Subject: [PATCH 29/60] Trainer message check: Renegade expectations --- tools/gen4_trainer_message_check.lua | 34 ++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tools/gen4_trainer_message_check.lua b/tools/gen4_trainer_message_check.lua index 2bd274be3..18fb30d97 100644 --- a/tools/gen4_trainer_message_check.lua +++ b/tools/gen4_trainer_message_check.lua @@ -401,11 +401,31 @@ else :format(trainers, pairsCount, maxType)) -- EXACT, not floors: these are structural counts of one cartridge, and a -- re-extract that changes any of them has changed the walk, not the data. - ok(trainers == 836, - "%d trainers carry messages, expected exactly 836 of 928", trainers) - ok(pairsCount == 2497, - "%d (trainer, type) pairs, expected exactly 2497 -- the record count of " - .. "trtbl and the entry count of bank 617 are the same number", pairsCount) + -- Renegade Platinum rewrites 136 trtbl records in place for trainers + -- 928..1065 but leaves trtblofs at 928 entries, so its rows are the + -- cartridge's walk (all but the retagged records) plus the scan the + -- extractor adds past the offset table (Gen4TrainerMessages.scanUncovered). + local cf = io.open(CACHE .. "/../../rom-cache.complete", "rb") + local marker = cf and cf:read("*a") or "" + if cf then cf:close() end + local renegade = marker:match(":(%x+)%s*$") == "381f9eef44564602a5f6469ee8c390fbb8ebac3a" + if renegade then + local high = 0 + for id in pairs(rows) do if id >= 928 then high = high + 1 end end + ok(trainers == 930, + "%d trainers carry messages, expected exactly 930 on Renegade", trainers) + ok(pairsCount == 2493, + "%d (trainer, type) pairs, expected exactly 2493 on Renegade", pairsCount) + ok(high == 135 and rows[928] and rows[928][1] and rows[1065] and rows[1065][1], + "trainers past the offset table (928..1065) must carry their authored " + .. "lines, found %d", high) + else + ok(trainers == 836, + "%d trainers carry messages, expected exactly 836 of 928", trainers) + ok(pairsCount == 2497, + "%d (trainer, type) pairs, expected exactly 2497 -- the record count of " + .. "trtbl and the entry count of bank 617 are the same number", pairsCount) + end ok(dupes == 0, "%d entry index(es) are claimed by more than one (trainer, type); each " .. "record is reachable from exactly one trainer", dupes) @@ -447,8 +467,8 @@ else ok(#missing == 0, "%d index(es) name a line this cache does not hold: %s", #missing, table.concat(missing, "; ")) - ok(resolved == 2497, - "only %d of 2497 indices resolved; the index and the text bank have " + ok(resolved == (renegade and 2493 or 2497), + "only %d of the expected indices resolved; the index and the text bank have " .. "drifted apart", resolved) ok(empty == 0, "%d resolved line(s) are the empty string", empty) end From 49a7ff82880ba704bebe0ef5b944a223612df54e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:05:30 +0100 Subject: [PATCH 30/60] Field sweep: warp-in landings, camera/bike/run walking, party and bag passes, hang guard --- tools/gen4_field_sweep_harness/main.lua | 158 +++++++++++++++++++++--- 1 file changed, 140 insertions(+), 18 deletions(-) diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index 820e7152a..a85e5936c 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -18,6 +18,7 @@ -- Environment (all optional): -- MAPS=T01,R201|all which maps (default all, in sorted order) -- MAP_PREFIX=D only maps whose id starts with this +-- DEDUPE=0 keep the second id the cache gives every header (C_01 for C01) -- SHARD=i/n every n-th map starting at i (1-based) of the list -- SKIP=n LIMIT=n skip the first n / stop after n maps of the list -- SEED=n the random seed (walks, menu fuzz); default 1 @@ -38,6 +39,9 @@ -- ENTRIES=n how many arrival cells per map (default 1) -- MAP_SECS=n wall-clock allowance per map before it is failed as a -- hang (default 120) +-- ENCOUNTERS=1 leave wild encounters on (every grass step may roll one) +-- CAM=off|n camera rung for the walk phase (default: one per map, +-- by hash, over the whole ladder; off keeps the player's) -- TRACE=1 print each phase as it starts -- OUT= also append the SWEEP_* lines to this file -- @@ -175,17 +179,60 @@ end -- One logic tick. Returns false when the update raised (the state is then -- suspect, and the caller recovers). +-- the last stack changes and the keys pressed with them: what a failure needs +-- to be replayed by hand +local ring, ringAt, lastStack = {}, 0, "" +local function ringNote(line) + ringAt = ringAt % 40 + 1 + ring[ringAt] = line +end +local function ringDump(n) + local out = {} + for i = 1, math.min(n or 14, #ring) do + local at = (ringAt - i) % 40 + 1 + if ring[at] then table.insert(out, 1, ring[at]) end + end + return table.concat(out, " ;; ") +end + local deadline = math.huge local function tick() frames = frames + 1 -- a map that has run past its wall-clock allowance is a hang of some kind -- (a frame loop that never ends); the error unwinds to sweepMap's xpcall if os.clock() > deadline then error("MAP_TIMEOUT", 0) end + local keys = next(keysPressed) and (function() + local k = {} + for name in pairs(keysPressed) do k[#k + 1] = name end + table.sort(k) + return table.concat(k, "+") + end)() or nil local ok, err = xpcall(Game.stack.update, debug.traceback, Game.stack, 1 / 60) keysPressed = {} + do + local okS, now = pcall(Game.stack.describeAll, Game.stack) + now = okS and now or "?" + if now ~= lastStack then + lastStack = now + ringNote(("f%d%s %s"):format(frames, keys and (" [" .. keys .. "]") or "", now)) + elseif keys then + ringNote(("f%d [%s]"):format(frames, keys)) + end + end -- Game:update advances the music clock on its own accumulator; scripts wait -- on its fades (fadeoutbgm), so it has to run here or they never finish if ok then pcall(Music.update, Game.data) end + -- a battle that came onto the stack (a trainer's sight, a wild encounter, a + -- script's) is the battle agent's: take it off and let the field finish the + -- way a win would, which is the field's half of every battle + local t = ok and top() + if t and t ~= OW and BattleStateClass and getmetatable(t) == BattleStateClass then + battles = battles + 1 + Game.stack:pop() + local okB, errB = xpcall(OW.afterBattle, debug.traceback, OW, "win", t) + if not okB then fail("after_battle_error", errB) end + OW.engaging = false + end if not ok then fail("update_error", err) return false @@ -235,9 +282,17 @@ end -- --------------------------------------------------------------- the stubs --- local battles = 0 +local BattleStateClass local pushCount = 0 -- every state push: a script that re-asks its question is cycling, not hung local function installStubs() + BattleStateClass = require("src.battle.BattleState") + -- ENCOUNTERS=1: wild encounters stay on and every grass step may roll one, so + -- the field's side of a battle (the wipe, the battle on the stack, the way + -- back) is walked; the sweep pops the battle itself and calls afterBattle + if getenv("ENCOUNTERS") then + require("src.world.Encounter").gen4StepAllowed = function() return true end + end -- the LINK row is the port's online "Play Together" screen: a network client, -- not the field -- the start menu is built without it pcall(function() require("src.ui.Gen4StartMenu").linkRow = function() return nil end end) @@ -290,7 +345,9 @@ local function richSave(save) if ok and mon then party[#party + 1] = mon end end -- the field moves, spread over the party so the party menu offers them all - local hm = { { 15, 19, 57, 70 }, { 249, 127, 431, 432 }, { 148, 100, 91, 230 }, { 208, 156, 29, 15 } } + -- (three each and a plain Tackle, so a move-learn prompt has a move to forget) + local hm = { { 15, 19, 57, 33 }, { 70, 249, 127, 33 }, { 431, 432, 148, 33 }, { 100, 91, 230, 33 }, + { 208, 156, 29, 33 } } for i, ids in ipairs(hm) do if party[i] then party[i].moves = {} @@ -354,7 +411,9 @@ local function newSave() local save = SaveData.newGame(Game:bootConfig()) Game.save = save if Game.adoptSave then pcall(Game.adoptSave, Game, save, true) end - save.repelSteps = 99999 -- wild battles are the battle agent's, not this sweep's + if not getenv("ENCOUNTERS") then + save.repelSteps = 99999 -- wild battles are the battle agent's, not this sweep's + end local okS, errS = pcall(storyState, save) if not okS then fail("profile_error", errS) end if PROFILE == "rich" then @@ -537,12 +596,6 @@ local function drain(kind, budget) end end, function(i) local t = top() - -- a battle that came onto the stack is the battle agent's: pop it - if t and t ~= OW and t.enemy and t.player and t.battle ~= nil then - battles = battles + 1 - Game.stack:pop() - pcall(OW.afterBattle, OW, "win", t) - end if i % 6 == 0 then local fp = fingerprint() if fp == lastFp then sameFor = sameFor + 6 else sameFor = 0; lastFp = fp end @@ -585,6 +638,19 @@ end local function randomWalk(mapId, ox, oy) cur.phase = "walk" + if (cur.hash or 0) % 3 == 0 and Game.save then Game.save.onBike = true end + -- every map's walk uses one rung of the camera ladder (cartridge, fixed + -- angles, third and first person -- the last two walk OFF the grid) + if getenv("CAM") ~= "off" then + pcall(function() + local Gen4Camera = require("src.render.Gen4Camera") + local want = tonumber(getenv("CAM") or "") or ((cur.hash or 0) % #Gen4Camera.TILTS + 1) + local _, index = Gen4Camera.setTilt(want) + Game.save.options = Game.save.options or {} + Game.save.options.gen4CameraTilt = index + cur.camera = index + end) + end local dir, hold = nil, 0 local lastCell, still = nil, 0 local moved = {} @@ -606,7 +672,8 @@ local function randomWalk(mapId, ox, oy) still = 0 end hold = hold - 1 - keysDown = { [dir] = true } + -- hold B (run) a third of the time, and ride the bicycle on a third of maps + keysDown = { [dir] = true, b = (math.floor(i / 40) % 3 == 0) or nil } if top() ~= OW then keysDown = {}; if i % 9 == 0 then press("b") end end end, nil, DRAW_EVERY) keysDown = {} @@ -823,8 +890,8 @@ local function closeScreens(label, floorDepth) local okD, d = pcall(StateStack.describe, st) names[#names + 1] = okD and tostring(d) or "?" end - fail("softlock", ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s"):format(label, - describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()))) + fail("softlock", ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s | recent: %s"):format(label, + describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(16))) while stackDepth() > floorDepth do Game.stack:pop() end return false end @@ -1108,6 +1175,17 @@ local function pickMaps() for id in want:gmatch("[^,%s]+") do out[#out + 1] = id end ids = out end + -- the cache carries every Sinnoh map under two ids (C01 and C_01: the same + -- header twice); sweeping both doubles the run and finds nothing new. + -- DEDUPE=0 keeps the aliases. + if getenv("DEDUPE") ~= "0" and not (want and want ~= "all") then + local seen, out = {}, {} + for _, id in ipairs(ids) do + local h = Data.maps[id] and Data.maps[id].header + if h == nil or not seen[h] then out[#out + 1] = id; if h ~= nil then seen[h] = true end end + end + ids = out + end local prefix = getenv("MAP_PREFIX") if prefix then local out = {} @@ -1128,14 +1206,52 @@ local function pickMaps() return out end --- an arrival cell for the map: one of its own warps' cells (what a warp in --- lands on), else any reachable cell +-- where a warp INTO each map lands (the destination warp's own cell, which is +-- what Warp.destination hands the player), found by reading every map's warps +local incoming +local function buildIncoming() + incoming = {} + for fromId, def in pairs(Data.maps) do + if type(fromId) == "string" and type(def) == "table" and def.id == fromId then + for _, w in ipairs(def.warps or {}) do + local dest = w.destMap and Data.maps[w.destMap] + local dw = dest and dest.warps and dest.warps[w.destWarp] + if dw and dw.x and dw.y then + local list = incoming[w.destMap] + if not list then list = {}; incoming[w.destMap] = list end + list[#list + 1] = { dw.x, dw.y, from = fromId } + end + end + end + end + for _, list in pairs(incoming) do + table.sort(list, function(a, b) + if a.from ~= b.from then return a.from < b.from end + if a[1] ~= b[1] then return a[1] < b[1] end + return a[2] < b[2] + end) + end +end + +-- an arrival cell for the map: where a real warp lands (a map nothing warps to +-- gets one of its own warps' cells), else the middle of it local function landings(id, def) - local out = {} - for _, w in ipairs(def.warps or {}) do - if w.x and w.y then out[#out + 1] = { w.x, w.y } end + if not incoming then buildIncoming() end + local out, seen = {}, {} + for _, l in ipairs(incoming[id] or {}) do + local key = l[1] * 4096 + l[2] + if not seen[key] then + seen[key] = true + out[#out + 1] = { l[1], l[2], via = l.from } + end if #out >= ENTRIES then break end end + if #out == 0 then + for _, w in ipairs(def.warps or {}) do + if w.x and w.y then out[#out + 1] = { w.x, w.y } end + if #out >= ENTRIES then break end + end + end if #out == 0 then -- no warps at all: the middle of the map, nudged until the engine agrees it is floor local cx, cy = math.floor((def.width or 2) / 2), math.floor((def.height or 2) / 2) @@ -1153,6 +1269,11 @@ local function sweepMap(id, menusHere) rngState = h cur.hash = h local def = Data.maps[id] + if not def then + fail("unknown_map", "no such map in the cache") + emit(("SWEEP_MAP|%s|fail|unknown"):format(id)) + return + end local t0 = os.clock() deadline = t0 + envn("MAP_SECS", 120) local f0 = frames @@ -1217,7 +1338,8 @@ local function sweepMap(id, menusHere) -- an arrival the player cannot walk away from (a cutscene room, or a map -- whose spawn is boxed in): informational, the scripted ones are legitimate if PHASES.walk and info.walked <= 2 and #(def.warps or {}) > 0 then - emit(("SWEEP_INFO|%s|boxed_arrival|walked %d cell(s) from %d,%d"):format(id, info.walked, landing[1], landing[2])) + emit(("SWEEP_INFO|%s|boxed_arrival|walked %d cell(s) from %d,%d (arrival of a warp from %s)"):format(id, info.walked, + landing[1], landing[2], tostring(landing.via or "none -- own warp cell"))) end local failed = totals.fails - failsBefore emit(("SWEEP_MAP|%s|%s|frames=%d secs=%.1f npcs=%d signs=%d warps=%d/%d talked=%d silent=%d stuck=%d walked=%d"):format( @@ -1309,7 +1431,7 @@ function love.load() local t0 = os.clock() local failedMaps = 0 for i, id in ipairs(ids) do - local menusHere = (MENUS == "map") or (MENUS == "once" and not menusDone) + local menusHere = (MENUS == "map") or (MENUS == "once" and (not menusDone or id == "UG")) local fb = totals.fails sweepMap(id, menusHere) if menusHere then menusDone = true end From 15a849659be71cc13a6b52c7f3c5a777887a8be8 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:11:07 +0100 Subject: [PATCH 31/60] Field sweep: regression probes (--regress) for the three fixed bugs --- tools/gen4_field_sweep.py | 34 ++++++++++ tools/gen4_field_sweep_harness/main.lua | 5 +- .../regress/factory_short_list.lua | 42 ++++++++++++ .../regress/party_pick_cancel.lua | 64 +++++++++++++++++++ .../regress/trainer_queue.lua | 43 +++++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tools/gen4_field_sweep_harness/regress/factory_short_list.lua create mode 100644 tools/gen4_field_sweep_harness/regress/party_pick_cancel.lua create mode 100644 tools/gen4_field_sweep_harness/regress/trainer_queue.lua diff --git a/tools/gen4_field_sweep.py b/tools/gen4_field_sweep.py index 7de5e75dc..9b7037609 100644 --- a/tools/gen4_field_sweep.py +++ b/tools/gen4_field_sweep.py @@ -42,6 +42,34 @@ def run_harness(love, env, timeout): return [ln for ln in text.splitlines() if ln.startswith("SWEEP_")], timed_out +def run_regressions(args, env): + """Each regress/*.lua is a PROBE: it runs inside the booted harness and prints + REGRESS_OK or REGRESS_FAIL : .""" + folder = os.path.join(ROOT, "tools", "gen4_field_sweep_harness", "regress") + failed = 0 + for name in sorted(os.listdir(folder)): + if not name.endswith(".lua"): + continue + penv = dict(env, PROBE=os.path.join("tools", "gen4_field_sweep_harness", "regress", name)) + cmd = [args.love, os.path.join("tools", "gen4_hgss_overworld_harness")] + try: + proc = subprocess.run(cmd, cwd=ROOT, env=penv, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=args.timeout) + text = proc.stdout.decode("utf-8", errors="replace") + except subprocess.TimeoutExpired: + text = "" + lines = [ln for ln in text.splitlines() if ln.startswith(("REGRESS_", "PROBE FAILED"))] + ok = any(ln.startswith("REGRESS_OK") for ln in lines) and not any( + ln.startswith(("REGRESS_FAIL", "PROBE FAILED")) for ln in lines) + print(("ok " if ok else "FAIL ") + name) + if not ok: + failed += 1 + for ln in lines or ["(no result: the probe hung or died)"]: + print(" " + ln) + print(f"{failed} regression(s) failed") + return 1 if failed else 0 + + def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--install", required=True, help="the imported version folder (e.g. .../Gen2Recomp/platinum)") @@ -58,6 +86,9 @@ def main(): ap.add_argument("--seed", default="1") ap.add_argument("--env", action="append", default=[], help="extra K=V for the harness") ap.add_argument("--out", default="field_sweep_report.txt") + ap.add_argument("--regress", action="store_true", + help="run the probes in tools/gen4_field_sweep_harness/regress/ (one fixed bug each) " + "instead of a sweep; exit status 1 if any fails") args = ap.parse_args() env = dict(os.environ) @@ -75,6 +106,9 @@ def main(): k, _, v = kv.partition("=") env[k] = v + if args.regress: + return run_regressions(args, env) + # the map list, from the harness itself so it always matches the engine's view list_env = dict(env, LIST="1") if args.maps: diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index a85e5936c..5e27f2473 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -1411,7 +1411,10 @@ function love.load() end newSave() - _G.__sweep = { Game = Game, OW = OW, Data = Data, enterMap = enterMap, run = run, press = press } + _G.__sweep = { Game = Game, OW = OW, Data = Data, enterMap = enterMap, run = run, press = press, + idle = idle, top = top, drain = drain, settle = settle, placePlayer = placePlayer, + standCells = standCells, stackDepth = stackDepth, describeTop = describeTop, + fails = function() return totals.fails end } -- PROBE=: run a scratch script against the booted game and quit if getenv("PROBE") then local okP, errP = xpcall(dofile, debug.traceback, getenv("PROBE")) diff --git a/tools/gen4_field_sweep_harness/regress/factory_short_list.lua b/tools/gen4_field_sweep_harness/regress/factory_short_list.lua new file mode 100644 index 000000000..d4e74e55c --- /dev/null +++ b/tools/gen4_field_sweep_harness/regress/factory_short_list.lua @@ -0,0 +1,42 @@ +-- The Battle Factory's rental screen must always be leavable. +-- +-- SELECT only finished with three picks, so an empty or short rental list (a +-- challenge resumed with nothing on the save) held the player in the screen +-- for good: B only ever un-picks. Found by tools/gen4_field_sweep under a +-- chaos story state (D32R0301). +-- +-- Run: python tools/gen4_field_sweep.py --install --regress + +local S = _G.__sweep +local Game = S.Game +local failed = false +local function check(cond, what) + if not cond then failed = true; print("REGRESS_FAIL factory_short_list: " .. what) end +end + +local Screen = require("src.ui.Gen3FactoryScreen") +local Pokemon = require("src.pokemon.Pokemon") +S.enterMap("T01", 15, 25, "down") +S.run(60) +local base = S.stackDepth() + +-- empty: it hands back "nothing chosen" at once +local called, result = false, "unset" +Game.stack:push(Screen.new(Game, { mode = "select", mons = {}, onDone = function(r) called, result = true, r end })) +S.run(5) +check(called and result == nil, "an empty offer closes itself with no result") +check(S.stackDepth() == base, "and leaves the stack as it was") + +-- two on offer: both can be picked and confirmed +local mons = { Pokemon.new(S.Data, 1, 30), Pokemon.new(S.Data, 4, 30) } +called, result = false, nil +Game.stack:push(Screen.new(Game, { mode = "select", mons = mons, onDone = function(r) called, result = true, r end })) +S.run(3) +S.press("a"); S.run(3) +S.press("down"); S.run(3) +S.press("a"); S.run(3) +for _ = 1, 60 do S.press("a"); S.run(8); if called then break end end +check(called and type(result) == "table" and result[1] == 1 and result[2] == 2, + "two picks on a two-Pokemon offer finish the screen") + +print(failed and "REGRESS_FAIL factory_short_list" or "REGRESS_OK factory_short_list") diff --git a/tools/gen4_field_sweep_harness/regress/party_pick_cancel.lua b/tools/gen4_field_sweep_harness/regress/party_pick_cancel.lua new file mode 100644 index 000000000..36f5b4011 --- /dev/null +++ b/tools/gen4_field_sweep_harness/regress/party_pick_cancel.lua @@ -0,0 +1,64 @@ +-- Backing out of a "pick a Pokemon" screen a script opened must end the script. +-- +-- Platinum's move tutors, NPC traders and the daycare ask for a party member +-- through g4_open_party_for_trade; B on the picker closed the screen and left +-- the script parked on its yield, with the field locked behind a fade. Found by +-- tools/gen4_field_sweep (seven tutor / trade maps; the engine's own watchdog +-- logged "parked for 360 frames ... g4_select_tutor_mon"). +-- +-- Run: python tools/gen4_field_sweep.py --install --regress + +local S = _G.__sweep +local OW, Game = S.OW, S.Game +local failed = false +local function check(cond, what) + if not cond then failed = true; print("REGRESS_FAIL party_pick_cancel: " .. what) end +end + +S.enterMap("C01R0205", 4, 6, "down") +S.run(90) +S.settle(300) + +-- talk to the tutor (the one object on the map whose script asks for a Pokemon) +local asked = false +for _, npc in ipairs(OW.npcs or {}) do + if npc.def and not npc.hidden and not asked then + local cells = S.standCells(OW.map, npc.cellX, npc.cellY, npc) + if #cells > 0 then + S.placePlayer(cells[1][1], cells[1][2], cells[1][3]) + S.run(3) + S.press("a") + -- A through the text until the party picker is up (or the script ends) + for i = 1, 1500 do + S.run(1) + if i % 6 == 0 then S.press("a") end + local t = S.top() + if t and t.screenId == "Gen4PartyMenu" or (t and t.pickOnly) then asked = true break end + if S.idle() then break end + end + if asked then + -- B on the picker... + for _ = 1, 12 do S.press("b"); S.run(8); if S.top() == OW then break end end + -- ...and the script must run to its end on its own, with A only to + -- read the lines that follow + local ended = false + local failsBefore = S.fails() + for i = 1, 300 do + S.run(1) + if i % 7 == 0 then S.press("a") end + if S.idle() then ended = true break end + end + -- (inside 300 frames: the engine's own watchdog puts a parked script + -- down after 720, which would hide the bug) + check(ended, "the script ends after B on the party picker (stack: " .. tostring(S.describeTop()) .. ")") + check(S.fails() == failsBefore, "no parked-script warning") + else + -- this NPC's script never asked; try the next one from a fresh map + S.enterMap("C01R0205", 4, 6, "down"); S.run(90); S.settle(300) + end + end + end +end +check(asked, "some object on C01R0205 opens the party picker") + +print(failed and "REGRESS_FAIL party_pick_cancel" or "REGRESS_OK party_pick_cancel") diff --git a/tools/gen4_field_sweep_harness/regress/trainer_queue.lua b/tools/gen4_field_sweep_harness/regress/trainer_queue.lua new file mode 100644 index 000000000..ba3fc9285 --- /dev/null +++ b/tools/gen4_field_sweep_harness/regress/trainer_queue.lua @@ -0,0 +1,43 @@ +-- A trainer's walk-up that ends while a map script is still running must queue +-- the fight behind it, not assert out of the field's update. +-- +-- Route 207: the player stood on a coordinate event as a trainer spotted them; +-- `fight()` called ScriptRunner:run with the coordinate script still going and +-- `assert(not self:isRunning(), "script already running")` raised from +-- OverworldState:update -- the game was over. Found by tools/gen4_field_sweep. +-- +-- Run: python tools/gen4_field_sweep.py --install --regress +-- (a probe: it runs inside the sweep harness with PROBE=) + +local S = _G.__sweep +local OW, Game = S.OW, S.Game +local failed = false +local function check(cond, what) + if not cond then failed = true; print("REGRESS_FAIL trainer_queue: " .. what) end +end + +S.enterMap("R207", 31, 40, "down") +S.run(90) +for _ = 1, 20 do if not (OW.runner:isRunning() or OW.transitioning) then break end S.run(30) end + +local trainer +for _, npc in ipairs(OW.npcs or {}) do + if npc.def and type(npc.def.trainer) == "table" and npc.def.trainer.id and not npc.hidden then + trainer = npc + break + end +end +check(trainer ~= nil, "Route 207 has a visible trainer") +if trainer then + -- a map script is running (a long wait, as a coordinate event's walk is) ... + OW.runner:run({ { "wait", 300 } }, { mapId = OW.map.id }) + check(OW.runner:isRunning(), "the stand-in map script is running") + -- ...when the trainer's walk-up ends + trainer.facing = "down" + OW:startTrainerApproach(trainer, 1) + local ok = S.run(600) + check(ok, "updating through the walk-up must not raise") + check(#(OW.pendingScripts or {}) <= 1, "at most the one queued fight is pending") +end + +print(failed and "REGRESS_FAIL trainer_queue" or "REGRESS_OK trainer_queue") From 3a744c2e662538d4c014826af2186f2c6657381b Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:13:39 +0100 Subject: [PATCH 32/60] Platinum: accept the European and German cartridges Platinum (Europe) (CPUE rev 10, 8d15cd44) and Platin Edition (Germany) (CPUD, 5d009a17) import as alternates of platinum, into the same cache and save. The European cart is the US build with five files changed (the European Game Corner, one collision fix). The German cart is its own ARM9/overlay build and moves 32 language archives under /resource/ger/; NdsRom now lets the US path name the moved file, from a table generated by tools/gen4_region_map_gen.py (src/import/Gen4RegionMap.lua, paths only). tools/gen4_region_check.lua diffs two imports' generated tables and fails on any difference that is not text, a listed cartridge difference, or a located address. --- README.md | 7 + docs/gen4-platinum.md | 6 + docs/rom-revisions.md | 121 +++++++++++++++- src/core/GameVersion.lua | 8 +- src/import/Gen4RegionMap.lua | 46 ++++++ src/import/Gen4Regions.lua | 75 ++++++++++ src/import/NdsRom.lua | 10 ++ src/import/RomRevisions.lua | 9 +- tools/gen4_region_check.lua | 242 +++++++++++++++++++++++++++++++ tools/gen4_region_map_gen.py | 144 ++++++++++++++++++ tools/rom_manifest_platinum.json | 4 +- 11 files changed, 665 insertions(+), 7 deletions(-) create mode 100644 src/import/Gen4RegionMap.lua create mode 100644 src/import/Gen4Regions.lua create mode 100644 tools/gen4_region_check.lua create mode 100644 tools/gen4_region_map_gen.py diff --git a/README.md b/README.md index 4cd31e7c0..3022a6fe9 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,18 @@ revision) so you know why — see [docs/rom-revisions.md](docs/rom-revisions.md) | Emerald | GBA | Alpha | `f3ae088181bf583e55daf962a92bb46f4f1d07b7` | | Platinum (Rev 1) | DS | Pre-alpha | `0862ec35b24de5c7e2dcb88c9eea0873110d755c` | | Platinum (Rev 0) | DS | Pre-alpha | `ce81046eda7d232513069519cb2085349896dec7` | +| Platinum (Europe, English) | DS | Pre-alpha | `8d15cd443d4e83e20493c79d3f9dc408c4f51ae0` | +| Platin Edition (Germany) | DS | Pre-alpha | `5d009a175c77d4387cde26d34efb71aee825a938` | | HeartGold (USA) | DS | Pre-alpha | `4fcded0e2713dc03929845de631d0932ea2b5a37` | | SoulSilver (USA) | DS | Pre-alpha | `f8dc38ea20c17541a43b58c5e6d18c1732c7e582` | | Black (USA/EU) | DS | Pre-alpha | `26ad0b9967aa279c4a266ee69f52b9b2332399a5` | | White (USA/EU) | DS | Pre-alpha | `bc696a0dfb448c7b3a8a206f0f8214411a039208` | +All four Platinum dumps import as the same game and share one save. The +German cartridge plays in German (its own text, names and localized art); the +European one is the US game with the European Game Corner. See +[docs/rom-revisions.md](docs/rom-revisions.md#platinum-europe-and-germany). + HeartGold, SoulSilver, Black and White dumps that have had their trailing `FF` padding trimmed are also accepted, as long as every game byte is intact — see [docs/hgss-bw-import.md](docs/hgss-bw-import.md). The launcher shows each diff --git a/docs/gen4-platinum.md b/docs/gen4-platinum.md index 82af339fb..0169eacbe 100644 --- a/docs/gen4-platinum.md +++ b/docs/gen4-platinum.md @@ -106,6 +106,12 @@ Rev 0 (`ce81046eda7d232513069519cb2085349896dec7`) is registered as an alternate hash. pokeplatinum builds both and the filesystem layout is the same in each, which is what the extractor reads. +The English European cartridge (`8d15cd44…`, CPUE rev 10) and the German +Platin Edition (`5d009a17…`, CPUD) are alternates too. The German build moves +its 32 language archives under `/resource/ger/`, which +`src/import/Gen4Regions.lua` answers at the filesystem. See +[rom-revisions.md](rom-revisions.md#platinum-europe-and-germany). + ### Files that will matter | Path | Bytes | What | diff --git a/docs/rom-revisions.md b/docs/rom-revisions.md index 843c10ba1..7ebbab37c 100644 --- a/docs/rom-revisions.md +++ b/docs/rom-revisions.md @@ -32,7 +32,7 @@ other revisions. | Polished Crystal | 3.2.3 `6930b48a` | other releases | Same reason as Prism | | FireRed | Rev 0 (USA, Europe) `41cb23d8` | Rev 1 `dd5945db`, Switch Online build (header rev 10) `baa452d0` | See below | | Emerald | (USA, Europe) `f3ae0881` | none (one release) | n/a | -| Platinum | Rev 0 `ce81046e`, Rev 1 `0862ec35` (USA) | (Europe) CPUP | No dump or symbols to map it against | +| Platinum | Rev 0 `ce81046e`, Rev 1 `0862ec35` (USA); (Europe) CPUE rev 10 `8d15cd44`; (Germany) CPUD `5d009a17` | French, Italian, Spanish (CPUF/CPUI/CPUS) | No dump to map against; see below for how the German one was mapped | | HeartGold | (USA) IPKE `4fcded0e` | (Europe) IPKP | pokeheartgold builds the US version only | | SoulSilver | (USA, Australia) IPGE `f8dc38ea` | (Europe) IPGP | Same as HeartGold | | Black | (USA, Europe) IRBO `26ad0b99` | none known | n/a | @@ -41,9 +41,122 @@ other revisions. LeafGreen (`574fa542`, Rev 1 `7862c67b`, Switch `62b9fc77`) is recognised by hash and named, but this build does not import LeafGreen at all. -No revision moved from "refused" to "accepted" in this pass. Each candidate -either has no extractor mapping to back it (FireRed Rev 1, Crystal AU) or has -neither a dump nor a symbol table to check against (the European DS carts). +Platinum (Europe) and (Germany) moved from "refused" to "accepted" once the +user's own dumps were there to map against (next section). The rest still +either have no extractor mapping to back them (FireRed Rev 1, Crystal AU) or +have neither a dump nor a symbol table to check against (the other European +DS carts). + +## Platinum (Europe) and (Germany) + +Both import as `platinum`, into the same cache folder and with the same save +(`_platinum`). A save started on one cartridge's cache keeps working on +another's: the only language-bearing value a Gen 4 mon stores that the engine +computes with is its nature NAME, and `SaveData` re-derives an unknown one from +the personality (`personality % 25`) against the loaded cache. + +**(Europe), English, `8d15cd44`.** The header says CPUE, revision 10. The +ARM9 and all 122 overlays are byte-identical to US Rev 1, and so is every file +but five. Member by member: + +| File | Members | What | +|---|---|---| +| `/data/slot.narc` | 0, 8, 11, 21 | the Game Corner machines' art | +| `/demo/title/op_demo.narc` | 113 | the opening's legal overlay | +| `/fielddata/land_data/land_data.narc` | 164 | Route 225, four collision bytes | +| `/fielddata/script/scr_seq.narc` | 141 | the Veilstone Game Corner's script | +| `/msgdata/pl_msg.narc` | 143, 147, 366, 417, 544, 623 | 28 strings, mostly the Game Corner's | + +That is the European Game Corner: its machines are not slots. Its staff talk +about Coins and a TM campaign instead, and the script is the European one. The +importer needs nothing for this cartridge beyond its hash. + +**(Germany), Platin Edition, `5d009a17`.** This is a separate build: + +- The ARM9 is 416 bytes longer, and every overlay loads 0x1A0 or 0x1C0 higher. +- The 724 text banks keep the US bank numbers and hold German text. +- The 32 language-specific archives moved from paths like `/graphic/box.narc` + to `/resource/ger/box/box.narc`. The file name never changed. `op_demo.narc` + is three members shorter because it has no US legal overlay. + +What the extractor needed: + +- **The archive paths.** `tools/gen4_region_map_gen.py ` + pairs each US path with the one German file of the same name and writes + `src/import/Gen4RegionMap.lua`. A name with zero or two candidates stops the + tool; it does not guess. `NdsRom:index()` then lets the US path name the + German file, so no stage changed its paths. +- **The code tables.** No address map was needed. Every ARM9 and overlay table + the extractor reads is found by content (a signature, a structural search + or a known first row). Two were not, and both were fixed: + - The area-name popup read the map headers at the fixed US offset + `0xE601C` and got plausible garbage. It now takes the headers the maps + stage located. + - The staff roll's signature included a y coordinate and a row count of + 237. The German roll is spaced differently and has 224 rows. +- **English used as a key.** These were rekeyed to ids: + - Poketch app pictures and behaviour + - the starter cross-check + - TM/HM detection (German HMs are `VM01`) + - the Plate range + - the orb flag + - Repels and Flutes + - the Journal, Poke Radar, Town Map, Coin, Poffin and Seal Cases, Point Card + and Fashion Case + - the Underground's Mystery Zone test, which now uses the label text id + - Underground treasures into the bag + - the berry-soil bag pocket + - the first PC Potion + - GTS mail +- **File names.** Battle sprites are `001.png` rather than `001_bisasam.png` on + a non-English cartridge. + +`tools/gen4_region_check.lua ` proves the result. It +walks every generated table of both imports and sorts each difference into one +of four kinds: + +- **Text:** the cartridge's language. +- **Known:** listed in the tool with the reason. +- **Address:** where a table was found. +- **Data:** anything else. The check fails on any data difference. + +Both cartridges pass with zero data differences. The known ones are: + +- **Both cartridges:** + - the Route 225 collision fix + - the rewritten scripts. German Platinum also rewrites script files 31 + (Trainers' School), 148 (Veilstone Store), 367 and 376..379 (Battle Tower + and the Frontier facilities) and 500. + - the slot-machine art +- **German cartridge only:** + - the Pokedex A-Z order and its initial-letter groups + - the wallpaper-password word list + - the in-game trades' language field + - the title logo + - the missing legal overlay + - the staff roll + - overlay words the trainer-AI reader keeps past its script. They are RAM + pointers, so they move. + +The art with baked-in text is German as well. That covers the summary, Pokedex, +party, box, bag, trainer card, naming, contest and touch screens, the type +icons, the "KP" healthbox, and one map texture set (21, its `m_depanel` signs). + +**Still English on a German cartridge:** + +- the engine's own strings ("Enemy …", menus the port draws itself) +- the English default player and rival name presets +- the scurry line on a whiteout (the nurse/Mom text is located by its English + content) +- Pal Park and cross-generation transfers. They map Gen 3 items and walker + moves by English name, so held items are dropped from Gen 3 migrants. +- the randomizer's protected-item and protected-class lists, which are name + based. + +None of these blocks play. Some existing checks assert English strings +(`gen4_item_use_check`, `gen4_area_popup_check` "Twinleaf Town", +`gen4_underground_check`, `gen4_machine_check` "HM01", +`gen4_mining_screen_check`), so they fail on a German cache by design. ## Evidence for the revisions already accepted as alternates diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 186e891a5..1a68a0d9f 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -969,7 +969,13 @@ GameVersion.VERSIONS = { -- than needing a second entry -- the filesystem layout is what the -- extractor reads and both revisions carry the same one. sha1 = "0862ec35b24de5c7e2dcb88c9eea0873110d755c", - sha1Alt = { "ce81046eda7d232513069519cb2085349896dec7" }, + sha1Alt = { "ce81046eda7d232513069519cb2085349896dec7", + -- (Europe), English: header CPUE rev 10 -- the US code and + -- overlays byte for byte, five files differ (docs/rom-revisions.md) + "8d15cd443d4e83e20493c79d3f9dc408c4f51ae0", + -- (Germany) "Platin Edition", CPUD: its own ARM9/overlay build + -- and German resources, read through src/import/Gen4Regions.lua + "5d009a175c77d4387cde26d34efb71aee825a938" }, manifest = "tools/rom_manifest_platinum.json", cachePrefix = "platinum/", saveSuffix = "_platinum", diff --git a/src/import/Gen4RegionMap.lua b/src/import/Gen4RegionMap.lua new file mode 100644 index 000000000..09ad2c5f4 --- /dev/null +++ b/src/import/Gen4RegionMap.lua @@ -0,0 +1,46 @@ +-- GENERATED by tools/gen4_region_map_gen.py from the US cartridge and each +-- localized one; do not edit, regenerate. Paths, counts and numbers only. +-- See src/import/Gen4Regions.lua for what reads it. +return { + CPUD = { + arm9Size = { usa = 1057784, here = 1058200 }, + overlayRamShift = { 0x0, 0x1A0, 0x1C0 }, + paths = { + ["/battle/graphic/b_plist_gra.narc"] = "/resource/ger/b_plist/b_plist_gra.narc", + ["/battle/graphic/batt_obj.narc"] = "/resource/ger/battle_graphic/batt_obj.narc", + ["/battle/graphic/pl_b_plist_gra.narc"] = "/resource/ger/b_plist/pl_b_plist_gra.narc", + ["/battle/graphic/pl_batt_obj.narc"] = "/resource/ger/battle_graphic/pl_batt_obj.narc", + ["/battle/graphic/vs_demo_gra.narc"] = "/resource/ger/vs_demo/vs_demo_gra.narc", + ["/contest/graphic/contest_bg.narc"] = "/resource/ger/contest_graphic/contest_bg.narc", + ["/contest/graphic/contest_obj.narc"] = "/resource/ger/contest_graphic/contest_obj.narc", + ["/data/namein.narc"] = "/resource/ger/nameinput/namein.narc", + ["/data/slot.narc"] = "/resource/ger/slot/slot.narc", + ["/demo/title/op_demo.narc"] = "/resource/ger/opening_demo/op_demo.narc", + ["/demo/title/titledemo.narc"] = "/resource/ger/title/titledemo.narc", + ["/fielddata/pokemon_trade/fld_trade.narc"] = "/resource/ger/pokemon_trade/fld_trade.narc", + ["/graphic/bag_gra.narc"] = "/resource/ger/bag/bag_gra.narc", + ["/graphic/box.narc"] = "/resource/ger/box/box.narc", + ["/graphic/imageclip.narc"] = "/resource/ger/dress_up_graphic/imageclip.narc", + ["/graphic/mystery.narc"] = "/resource/ger/mysterycard/mystery.narc", + ["/graphic/nutmixer.narc"] = "/resource/ger/nutmixer/nutmixer.narc", + ["/graphic/pl_bag_gra.narc"] = "/resource/ger/bag/pl_bag_gra.narc", + ["/graphic/pl_plist_gra.narc"] = "/resource/ger/pokelist/pl_plist_gra.narc", + ["/graphic/pl_pst_gra.narc"] = "/resource/ger/p_status/pl_pst_gra.narc", + ["/graphic/plist_gra.narc"] = "/resource/ger/pokelist/plist_gra.narc", + ["/graphic/poketch.narc"] = "/resource/ger/poketch/poketch.narc", + ["/graphic/pst_gra.narc"] = "/resource/ger/p_status/pst_gra.narc", + ["/graphic/touch_subwindow.narc"] = "/resource/ger/touch_subwindow/touch_subwindow.narc", + ["/graphic/trainer_case.narc"] = "/resource/ger/trainer_case/trainer_case.narc", + ["/resource/eng/batt_rec/batt_rec_gra.narc"] = "/resource/ger/batt_rec/batt_rec_gra.narc", + ["/resource/eng/frontier_graphic/frontier_bg.narc"] = "/resource/ger/frontier_graphic/frontier_bg.narc", + ["/resource/eng/frontier_graphic/frontier_obj.narc"] = "/resource/ger/frontier_graphic/frontier_obj.narc", + ["/resource/eng/pms_aikotoba/pms_aikotoba.narc"] = "/resource/ger/pms_aikotoba/pms_aikotoba.narc", + ["/resource/eng/scratch/scratch.narc"] = "/resource/ger/scratch/scratch.narc", + ["/resource/eng/wifi_lobby_minigame/wlmngm_tool.narc"] = "/resource/ger/wifi_lobby_minigame/wlmngm_tool.narc", + ["/resource/eng/zukan/zukan.narc"] = "/resource/ger/zukan/zukan.narc", + }, + shortNarcs = { + ["/demo/title/op_demo.narc"] = { 116, 113 }, + }, + }, +} diff --git a/src/import/Gen4Regions.lua b/src/import/Gen4Regions.lua new file mode 100644 index 000000000..7a9eb3efa --- /dev/null +++ b/src/import/Gen4Regions.lua @@ -0,0 +1,75 @@ +-- THE PLATINUM CARTRIDGES THAT ARE NOT THE US ONE, and what the extractor has +-- to know to read them as if they were. +-- +-- Three retail dumps import as `platinum` (GameVersion sha1/sha1Alt): +-- +-- CPUE rev 1 (USA) Rev 1, what the extractor was written against +-- CPUE rev 10 (Europe), English. The SAME game code, the same ARM9 and all +-- 122 overlays byte for byte; five files differ (the slot +-- machine reels, the opening's legal overlay, one map's +-- collision, one map's scripts, six text banks). Nothing here +-- applies to it -- the US layout IS its layout. +-- CPUD rev 0 (Germany) "Platin Edition". Its own ARM9 (+416 bytes) and +-- overlay build (loaded 0x1A0/0x1C0 higher), German text in the +-- same 724-bank message archive under the same bank numbers, +-- and its 32 language-specific archives moved under +-- /resource/ger// with their file names unchanged. +-- +-- THE ARCHIVES are the only layout difference the extractor sees: every ARM9 +-- and overlay table it reads is FOUND BY CONTENT (a signature, a structural +-- search, a known-first-row), never by address, so a shifted build costs +-- nothing there -- and tools/gen4_region_check.lua proves that by diffing a +-- German import's tables against a US one. The moved archives are answered +-- at the filesystem: NdsRom:index() asks `Gen4Regions.pathAliases` for the +-- cartridge's game code and lets the US path name the German file, so the +-- two hundred stage modules that open "/graphic/box.narc" keep doing so. +-- +-- The table itself is generated (src/import/Gen4RegionMap.lua, by +-- tools/gen4_region_map_gen.py from the US dump and the localized one), so it +-- is regenerated rather than edited. +-- +-- Zero requires at load time, like NdsRom: the import worker thread loads it. + +local Regions = {} + +-- by game code: the language of the cartridge's own text. `language` is an +-- ISO 639-1 code; the engine's text is whatever the cartridge says, so this +-- is for reports and for the few places that must know (see `isEnglish`). +Regions.BY_CODE = { + CPUE = { language = "en", label = "English" }, + CPUD = { language = "de", label = "German" }, +} + +local map +local function regionMap() + if map == nil then + local ok, m = pcall(require, "src.import.Gen4RegionMap") + map = ok and type(m) == "table" and m or false + end + return map or nil +end + +-- US path -> this cartridge's path, or nil when the cartridge uses US paths. +function Regions.pathAliases(gameCode) + local m = regionMap() + local entry = m and gameCode and m[gameCode] + return entry and entry.paths or nil +end + +-- Moved archives whose member count is not the US one ({ us, here }). +function Regions.shortNarcs(gameCode) + local m = regionMap() + local entry = m and gameCode and m[gameCode] + return entry and entry.shortNarcs or {} +end + +function Regions.language(gameCode) + local r = gameCode and Regions.BY_CODE[gameCode] + return r and r.language or "en" +end + +function Regions.isEnglish(gameCode) + return Regions.language(gameCode) == "en" +end + +return Regions diff --git a/src/import/NdsRom.lua b/src/import/NdsRom.lua index 71df0a964..8f57c91b5 100644 --- a/src/import/NdsRom.lua +++ b/src/import/NdsRom.lua @@ -235,6 +235,16 @@ function NdsRom:index() end walk(ROOT, "") + -- A LOCALIZED BUILD THAT MOVED ITS LANGUAGE ARCHIVES: German Platinum keeps + -- /graphic/box.narc as /resource/ger/box/box.narc, and so on for 32 files + -- (src/import/Gen4Regions.lua). The US path is made to name the moved file + -- so every reader keeps asking for the path it was written against. Only + -- paths the cartridge does NOT have are aliased; `files` stays the real list. + local okR, Regions = pcall(require, "src.import.Gen4Regions") + local aliases = okR and Regions.pathAliases(readAt(self, H.GAME_CODE, 4)) + for usPath, here in pairs(aliases or {}) do + if not byPath[usPath] and byPath[here] then byPath[usPath] = byPath[here] end + end self._index = { files = files, byPath = byPath, dirCount = dirs } return self._index end diff --git a/src/import/RomRevisions.lua b/src/import/RomRevisions.lua index ec9515078..e3acf8787 100644 --- a/src/import/RomRevisions.lua +++ b/src/import/RomRevisions.lua @@ -24,7 +24,8 @@ -- Rev 1, Switch Online build NOT YET -- Emerald one release, (USA, Europe) accepted -- Platinum Rev 0, Rev 1 (USA) both accepted --- (Europe) CPUP NOT YET +-- (Europe) CPUE rev 10, (Germany) CPUD accepted +-- other European languages (CPUF/CPUI/CPUS) NOT YET -- HeartGold (USA) IPKE; SoulSilver (USA, Australia) accepted -- (Europe) IPKP / IPGP NOT YET -- Black / White (USA, Europe) IRBO / IRAO accepted @@ -92,6 +93,12 @@ RomRevisions.ACCEPTED = { label = "Pokemon Platinum (USA)" }, { sha1 = "0862ec35b24de5c7e2dcb88c9eea0873110d755c", code = "CPUE", revision = 1, label = "Pokemon Platinum (USA) (Rev 1)" }, + -- the English European cartridge keeps the US game code; its header + -- revision byte is 10 (docs/rom-revisions.md) + { sha1 = "8d15cd443d4e83e20493c79d3f9dc408c4f51ae0", code = "CPUE", revision = 10, + label = "Pokemon Platinum (Europe)" }, + { sha1 = "5d009a175c77d4387cde26d34efb71aee825a938", code = "CPUD", revision = 0, + label = "Pokemon Platin Edition (Germany)" }, }, heartgold = { { sha1 = "4fcded0e2713dc03929845de631d0932ea2b5a37", code = "IPKE", revision = 0, diff --git a/tools/gen4_region_check.lua b/tools/gen4_region_check.lua new file mode 100644 index 000000000..539b76335 --- /dev/null +++ b/tools/gen4_region_check.lua @@ -0,0 +1,242 @@ +-- tools/gen4_region_check.lua -- does a localized Platinum import hold the +-- SAME GAME as the US one? +-- +-- python tools/run_lua_check.py tools/gen4_region_check.lua [verbose] +-- +-- Each cache is a version folder (…/platinum, holding data/generated). Every +-- generated table is loaded from both and walked side by side. A difference +-- is sorted into one of three bins: +-- +-- TEXT both values are strings and the key is one the cartridge's +-- language decides (a name, a description, a label, a line of +-- dialogue -- TEXT_KEYS below), or the whole table is text +-- (TEXT_FILES). Expected: a German cartridge says it in German. +-- KNOWN a difference the cartridges really have, listed in KNOWN below +-- with the reason (the European bug fixes, the German build's +-- localized art). Reported, not failed. +-- DATA anything else. A number, a flag, a table shape or a non-text +-- string that is not the same is the extractor reading something +-- else on the other cartridge -- which is exactly the silent +-- wrong-address failure this check exists to catch. Any DATA +-- difference fails the run. +-- +-- Asset PATHS are compared after folding the name part out of a +-- "NNN_name" file stem, so "battle/front/001_bulbasaur" on the US cartridge +-- and "battle/front/001" on a German one compare equal: the file is chosen +-- by id, the name was only ever a label for a human. + +local usDir, otherDir, verbose = arg[1], arg[2], arg[3] +assert(usDir and otherDir, "usage: gen4_region_check.lua [verbose]") + +-- tables that are nothing but the cartridge's text +local TEXT_FILES = { ["text.lua"] = true, ["gen4_text.lua"] = true, ["text_pointers.lua"] = true } + +-- keys whose string value is the cartridge's language +local TEXT_KEYS = {} +for k in ([[name names label labels description descriptions text texts title + dexEntry entry category categoryName height weight heightText weightText line lines + message messages className classArticle article nickname otName trainerName + words word caption captions prompt prompts question answer answers option options + string strings menu menus hint hints rank ranks greeting farewell win lose + firstLine noun plural singular pocket pockets displayName shortName longName + mapName areaName location locationName placeName place town route sign + speciesName moveName itemName abilityName natureName typeName]]):gmatch("%S+") do + TEXT_KEYS[k] = true +end + +-- keys whose value is WHERE something was found (an ARM9/overlay offset, a +-- RAM address, a "ROM:overlay5+0x2B414" provenance line). The German build +-- is a different link of the same code, so these move while what they point +-- at stays the same -- which the rest of the table proves. +local ADDRESS_KEYS = { at = true, source = true, paletteTable = true, overlayRam = true } + +-- keys holding prose the cartridge's language decides, beyond TEXT_KEYS +for k in ("controlInfo adventureInfo offer intro choose natures types characteristics newGameWarning order"):gmatch("%S+") do + TEXT_KEYS[k] = true +end + +-- Differences the cartridges themselves carry, by file and path prefix +-- (docs/rom-revisions.md, "Platinum (Europe) and (Germany)"). +local KNOWN = { + -- (Europe) and (Germany) both: land_data member 164 (Route 225) differs in + -- four collision bytes + { file = "gen4_map_permissions.lua", path = "[164]", why = "Route 225 collision (EU/DE cartridges)" }, + { file = "map_layouts.lua", path = "[0].blocks", why = "Route 225 collision (EU/DE cartridges)" }, + { file = "maps.lua", path = ".R225.blocks", why = "Route 225 collision (EU/DE cartridges)" }, + -- script files differ: 141 (Veilstone Game Corner) on both, and on the + -- German cartridge also 31, 148, 367, 376..379 and 500 + { file = "map_scripts.lua", why = "script files the EU/DE cartridges rewrote" }, + -- the slot machine's art differs on European cartridges, and the German + -- one draws a wider "games" label + { file = "gen4_pt_slot_art.lua", why = "slot machine art (EU/DE cartridges)" }, + -- German build: the Pokedex's A-Z order and its by-initial search groups + -- (ABC, DEF, ...) follow the German names (zukan_data.narc 13, 18..26) + { file = "gen4_dex.lua", why = "German alphabetical order and initial groups", + test = function(path) + local key = path:match("^%.orders%.(%a+)%[") + return key == "alphabetical" or key == "ABC" or key == "DEF" or key == "GHI" + or key == "JKL" or key == "MNO" or key == "PQR" or key == "STU" + or key == "VWX" or key == "YZ" + end }, + -- German build: the in-game trades' Pokemon come from other languages + { file = "constants.lua", why = "in-game trade language (a German cartridge's trades are foreign)", + test = function(path) return path:match("^%.gen4Trades%[%d+%]%.language$") end }, + -- German build: the wallpaper-password alphabet is German easy-chat words + { file = "constants.lua", path = ".passwordWords", why = "German password word list" }, + -- German build: its own title logo palette, and no US legal overlay in + -- the opening (op_demo.narc members 113..115 do not exist) + { file = "gen4_graphics.lua", path = ".palettes[title/logo]", why = "German title logo" }, + { file = "gen4_menus.lua", path = ".title.logo.content", why = "German title logo's drawn box" }, + { file = "gen4_graphics.lua", path = ".screens[opening/first_overlay]", why = "no US legal overlay in the German opening" }, + -- German build: the staff roll is laid out for German credits + { file = "gen4_ending.lua", path = ".roll", why = "German staff roll spacing" }, + -- the trainer AI reader keeps a margin past Terminate (word 10607); the + -- words after it are the overlay's next table, which holds RAM pointers + { file = "gen4_trainer_ai.lua", why = "overlay words past the AI script", + test = function(path) + local i = tonumber(path:match("^%.words%[(%d+)%]$")) + return i and i > 10607 + end }, +} + +-- Tables keyed by a NAME the cartridge spells in its own language: re-keyed +-- by something language-free before comparing. +local REKEY = { + -- audio.cries is keyed by species name (Sound.playCry's Gen 1-3 shape); + -- the cry file is "cries/NNN.wav" by species id + ["audio.lua"] = function(t) + if type(t) == "table" and type(t.cries) == "table" then + local byFile = {} + for _, def in pairs(t.cries) do + if type(def) == "table" and def.file then byFile[def.file] = def end + end + t.cries = byFile + end + return t + end, +} + +local function listDir(dir) + local out = {} + local p = io.popen(('dir /b "%s"'):format((dir:gsub("/", "\\")))) + if p then + for line in p:lines() do out[#out + 1] = line end + p:close() + end + table.sort(out) + return out +end + +local function load(path) + local chunk = loadfile(path) + if not chunk then return nil end + local ok, v = pcall(chunk) + return ok and v or nil +end + +-- fold "…/001_bulbasaur.png" and "…/001.png" to the same thing +local function foldPath(s) + return (s:gsub("/(%d%d%d)_[^/]*(%.%w+)$", "/%1%2"):gsub("/(%d%d%d)_[^/.]*$", "/%1")) +end + +local function isTextKey(k) + if type(k) ~= "string" then return false end + if TEXT_KEYS[k] then return true end + local lower = k:lower() + return lower:find("name") or lower:find("text") or lower:find("label") + or lower:find("desc") or lower:find("message") or lower:find("string") +end + +local report = { text = {}, known = {}, address = {}, data = {} } +local function note(bin, file, path, a, b) + local t = report[bin] + t[file] = t[file] or { n = 0, first = {} } + t[file].n = t[file].n + 1 + if #t[file].first < (verbose and 40 or 6) then + local function show(v) + if type(v) == "string" then + local s = v:sub(1, 60):gsub("[%c\128-\255]", "?") + return ("%q"):format(s) + end + return tostring(v) + end + t[file].first[#t[file].first + 1] = ("%s: %s ~= %s"):format(path, show(a), show(b)) + end +end + +local function knownFor(file, path) + for _, k in ipairs(KNOWN) do + if k.file == file and (not k.path or path:sub(1, #k.path) == k.path) + and (not k.test or k.test(path)) then return k end + end +end + +local function keyText(k) + if type(k) == "string" and k:match("^[%a_][%w_]*$") then return "." .. k end + return "[" .. tostring(k) .. "]" +end + +local function walk(file, path, a, b, lastKey) + if a == b then return end + local ta, tb = type(a), type(b) + if ta == "table" and tb == "table" then + local seen = {} + for k, v in pairs(a) do seen[k] = true; walk(file, path .. keyText(k), v, b[k], k) end + for k, v in pairs(b) do if not seen[k] then walk(file, path .. keyText(k), nil, v, k) end end + return + end + if ta == "string" and tb == "string" and foldPath(a) == foldPath(b) then return end + if knownFor(file, path) then return note("known", file, path, a, b) end + if ADDRESS_KEYS[lastKey] and ta == tb then return note("address", file, path, a, b) end + if (ta == "string" or ta == "nil") and (tb == "string" or tb == "nil") and isTextKey(lastKey) then + return note("text", file, path, a, b) + end + -- a string anywhere under a text key (names = { … }, text = { yes = … }) + if ta == "string" and tb == "string" then + for segment in path:gmatch("%.([%a_][%w_]*)") do + if TEXT_KEYS[segment] then return note("text", file, path, a, b) end + end + end + note("data", file, path, a, b) +end + +local usData, otherData = usDir .. "/data/generated", otherDir .. "/data/generated" +local files = listDir(usData) +assert(#files > 0, "no generated tables under " .. usData) +local missing = {} +for _, f in ipairs(files) do + if f:match("%.lua$") and not TEXT_FILES[f] then + local a, b = load(usData .. "/" .. f), load(otherData .. "/" .. f) + if a == nil and b == nil then + -- unloadable on both sides: not this check's business + elseif b == nil then missing[#missing + 1] = f + else + if REKEY[f] then a, b = REKEY[f](a), REKEY[f](b) end + walk(f, "", a, b, nil) + end + end +end + +local function dump(bin, title) + local names = {} + for f in pairs(report[bin]) do names[#names + 1] = f end + table.sort(names) + local total = 0 + for _, f in ipairs(names) do total = total + report[bin][f].n end + print(("%s: %d differences in %d tables"):format(title, total, #names)) + for _, f in ipairs(names) do + print((" %-36s %d"):format(f, report[bin][f].n)) + if bin ~= "text" or verbose then + for _, line in ipairs(report[bin][f].first) do print(" " .. line) end + end + end + return total +end + +dump("text", "TEXT (expected: the cartridge's language)") +dump("known", "KNOWN (the cartridges differ here)") +dump("address", "ADDRESS (where a table was found; expected to move)") +local bad = dump("data", "DATA (must be zero)") +if #missing > 0 then print("MISSING on the other cartridge: " .. table.concat(missing, ", ")) end +assert(bad == 0 and #missing == 0, ("%d data differences, %d missing tables"):format(bad, #missing)) +print("OK: no data differences outside text and the known cartridge differences") diff --git a/tools/gen4_region_map_gen.py b/tools/gen4_region_map_gen.py new file mode 100644 index 000000000..7239eaf7f --- /dev/null +++ b/tools/gen4_region_map_gen.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Generate src/import/Gen4RegionMap.lua: how a localized Platinum cartridge +differs in LAYOUT from the US one the Gen 4 extractor was written against. + + python tools/gen4_region_map_gen.py [ ...] + +For every cartridge after the first it records, under the cartridge's game +code: + + paths every US file path the localized cartridge does not have, mapped to + the one file it has instead. The German build moved its + language-specific archives under /resource/ger// (and + /resource/eng/... became /resource/ger/...); the archive's own + file name never changed, so the pair is found by matching the file + name among the paths each side has and the other lacks. A name + that matches zero or two candidates is an error, not a guess. + shortNarcs each moved archive whose member count differs from the US one, + as { us, here }. A member-name table keyed by the US path is only + right for a moved archive whose count agrees. (German op_demo.narc + lacks the US-only members 113..115, the legal overlay the opening + fades in over its first scene; no other count differs.) + ramShift the ARM9 load-address shift of each overlay (its RAM address on + this cartridge minus the US one), and the ARM9 size, for the + report only: the extractor finds its ARM9 and overlay tables by + content, never by address. + +Nothing from the cartridges is written but paths, counts and numbers. +""" +import os +import struct +import sys + + +class Nds: + def __init__(self, path): + self.d = open(path, 'rb').read() + d = self.d + self.code = d[0x0C:0x10].decode('ascii') + self.revision = d[0x1E] + self.fnt, _, self.fat, self.fatsz, self.ovt9, self.ovt9sz = struct.unpack_from('<6I', d, 0x40) + self.arm9size = struct.unpack_from('= 0 else '-0x%X' % -s for s in shifts)) + out.append(' paths = {') + for a, b in pairs: + out.append(' ["%s"] = "%s",' % (a, b)) + out.append(' },') + out.append(' shortNarcs = {') + for a, n, m in counts: + out.append(' ["%s"] = { %s, %s },' % (a, n, m)) + out.append(' },') + out.append(' },') + print('%s rev %d: %d moved files, overlay RAM shift %s' % (other.code, other.revision, len(pairs), shifts)) + out.append('}') + here = os.path.dirname(os.path.abspath(__file__)) + dst = os.path.join(here, '..', 'src', 'import', 'Gen4RegionMap.lua') + with open(dst, 'w', encoding='utf-8', newline='\n') as f: + f.write('\n'.join(out) + '\n') + print('wrote', os.path.normpath(dst)) + + +if __name__ == '__main__': + main() diff --git a/tools/rom_manifest_platinum.json b/tools/rom_manifest_platinum.json index 26063d2e3..9040806ea 100644 --- a/tools/rom_manifest_platinum.json +++ b/tools/rom_manifest_platinum.json @@ -3,7 +3,9 @@ "generation": 4, "romSha1": "0862ec35b24de5c7e2dcb88c9eea0873110d755c", "romSha1Alt": [ - "ce81046eda7d232513069519cb2085349896dec7" + "ce81046eda7d232513069519cb2085349896dec7", + "8d15cd443d4e83e20493c79d3f9dc408c4f51ae0", + "5d009a175c77d4387cde26d34efb71aee825a938" ], "notes": [ "WHY THIS FILE IS A THOUSAND TIMES SMALLER THAN CRYSTAL'S.", From fea4dba9e92496d8fe48520f4a72fa0ac04b8b5f Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:13:40 +0100 Subject: [PATCH 33/60] Renegade Platinum: playthrough beats for the hack's rival battle and Poketch, intro species and scan checks, docs --- docs/renegade-platinum.md | 75 ++++++++++++++++++++++++---- src/import/Gen4IntroScene.lua | 2 +- tools/gen4_intro_species_check.lua | 44 ++++++++++++++++ tools/gen4_playthrough/platinum.lua | 24 +++++++-- tools/gen4_trainer_message_check.lua | 17 +++++++ 5 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 tools/gen4_intro_species_check.lua diff --git a/docs/renegade-platinum.md b/docs/renegade-platinum.md index d01967ab5..ea12b402d 100644 --- a/docs/renegade-platinum.md +++ b/docs/renegade-platinum.md @@ -96,20 +96,73 @@ effective", and Dragon Pulse against Clefairy "doesn't affect" it. | Overlay 16 | the chart pointers, nothing else | handled (above) | | Overlay 21 | the appended chart, the Pokedex type-icon switch | handled (above) | | Overlay 11 | the Fairy move-button palette | read by type id | -| Overlay 73 | two literals change species 427 (Buneary) to 133 (Eevee) | **not handled**: probably Amity Square's walking partners; `PtMiscCommands.AMITY_POOL_OF` is still Platinum's list | +| Overlay 73 | two literals change species 427 (Buneary) to 133 (Eevee) | the opening's own Pokemon (Rowan's Poke Ball), NOT Amity Square: `Gen4IntroScene.monSpecies` reads it into `gen4_intro.monSpecies` (written only when it is not Buneary) | + +## Trainers past the offset table + +The hack grows trdata to 1,066 trainers and rewrites 136 `trtbl` records IN +PLACE (blank placeholder lines of dummy trainers become real lines, retagged +with ids 928..1065), but `trtblofs` keeps its 928 entries (the file is +byte-identical to Platinum's). The cartridge's walk reads the offset of +trainer 928 from past the end of that file (zero padding), starts at record 0 +and stops at once, so by the letter of the walk those trainers are silent. +`Gen4TrainerMessages.all` therefore also scans the table for the runs of ids +PAST the offset table (`scanUncovered`; records of one trainer are +contiguous). A retail cartridge has no such ids and is unchanged. 135 +trainers get lines (e.g. the counterpart's Route 202 battle, trainers +928..933: "Wow! You're that strong already?"). A trainer with no line has no +row: `g4_print_trainer_dialogue` returns without printing, so neither a script +nor a battle end waits on it. It is not known whether the real hack shows +these lines (it reads the same padding); the cache carries what the hack +authored. + +## Other differences the survey found + +- Route 202: the catching tutorial (`startcatchingtutorial`) is gone. The + coordinate trigger runs a real trainer battle against the counterpart + (trainers 928..933, level 9 against a level-5 starter), so a lone starter + loses and blacks out. The playthrough beat trains the lead up first. +- Jubilife: the Pokétch clowns' quizzes are removed (persons 24 and 25 are + parked at 0,0 without a script); the president gives the Pokétch himself. +- Evolutions: 40 records change (trade evolutions gain a level, Eevee uses + Sun/Moon/Ice/Leaf stones, and 12 trade-with-item species can also evolve by + using the item, whose records the hack flags as party items); the cache + matches the cartridge's `evo.narc` record for record, and + `Gen45Evolution.itemTarget` evolves Poliwhirl with King's Rock, Rhydon with + the Protector, Eevee with the Sun/Moon/Ice/Leaf stone. +- Amity Square's accessory pools are ARM9 data (six 10-entry u16 pools at + `0x020EBF84` on Platinum) and are byte-identical in the hack, so the + hard-coded `PtMiscCommands.AMITY_POOLS` is right for both. +- Type logic that lives in code the hack leaves alone behaves as retail: the + Battle Hall's grid and opponent-type table (overlay 104 is identical), + Hidden Power (never Fairy), Judgment / Natural Gift (no item carries + Natural Gift type 9; there is no Pixie Plate), Camouflage, Weather Ball, + the Pokedex search type list and the Pokétch move tester (their overlays + are identical apart from the icon switch). Natural Gift's type name, + the Battle Factory's type counts and the Battle Hall's slot-9 cell + now read slot 9's name from the cache instead of assuming "???". + +## ARM9 and overlay differences, all accounted for + +The ARM9 differs in 136 runs; every one is inside a table the extractor finds +by content. Map headers `0x020E6BA0..0x020E9468` (weather / flags), the mart +specialty lists `0x020EBA19`, `0x020EBA66` (Veilstone floors), the Game +Corner prize table `0x020EC018` (first prize replaced, TM prizes replaced and +re-priced), the icon palette table `0x020F079D..0x020F096D`, the TM/HM list +`0x020F0C6C..0x020F0CAA`, and the shiny comparison `0x02075E50`. Overlays 11 +(Fairy move-button palette), 16 (chart pointers), 21 (appended chart, Pokedex +type-icon switch) and 73 (the intro Pokemon); no other overlay differs, so +overlay-resident tables (move tutors, starters, the Frontier, Pickup, Amity, +trainer prize money) read as in Platinum. Starters are Turtwig, Chimchar and +Piplup in both. ## Not verified yet -- Nothing has been played past the first battles. Scripts, events and the - 1066 trainers import, and the overworld harness boots Twinleaf. The - hack's own story edits (rewritten scripts and new trainers 928..1065) - have not been walked. -- Overlay 73 (above). -- Trainer dialogue: `trtblofs` was not extended. Trainers 928..1065 have no - dialogue records, and the hack's scripts may give them lines another way. -- Data that is not read from these tables was not checked against the hack. - This covers hardcoded vanilla tables in the engine, for example the Battle - Hall's type grid, which still has no slot-9 column. +- The playthrough runs 19 beats (the first battle, Jubilife, Roark's gym) on + this cache and stops there by tick budget; later story edits are not walked. +- Whether the real hack prints the lines of trainers 928..1065 (see above). +- Pickup, Pokemart common stock and the Frontier were not diffed at the data + level, only shown unchanged by the ARM9/overlay survey. ## Checking it diff --git a/src/import/Gen4IntroScene.lua b/src/import/Gen4IntroScene.lua index 95562ea77..8a88b2c62 100644 --- a/src/import/Gen4IntroScene.lua +++ b/src/import/Gen4IntroScene.lua @@ -274,7 +274,7 @@ function Gen4IntroScene.monSpecies(overlay, maxSpecies) local a, b, c, d = overlay:byte(target + 1, target + 4) if d then local value = a + b * 256 + c * 65536 + d * 16777216 - if value >= 1 and value <= (maxSpecies or 493) then return value end + if value >= 1 and value <= (tonumber(maxSpecies) or 493) then return value end end end end diff --git a/tools/gen4_intro_species_check.lua b/tools/gen4_intro_species_check.lua new file mode 100644 index 000000000..6c0831392 --- /dev/null +++ b/tools/gen4_intro_species_check.lua @@ -0,0 +1,44 @@ +-- The Pokemon Rowan releases in the opening comes out of overlay 73 (Buneary on +-- retail Platinum, Eevee on Renegade Platinum), not out of a constant. +-- +-- python tools/run_lua_check.py tools/gen4_intro_species_check.lua +-- python tools/run_lua_check.py tools/gen4_intro_species_check.lua +-- +-- The first form runs on a synthetic Thumb sequence; with a cartridge it also +-- reads the real overlay and compares. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Intro = require("src.import.Gen4IntroScene") +local fails = 0 +local function check(cond, label) + if not cond then fails = fails + 1; print("FAIL: " .. label) end +end + +local function u16(n) return string.char(n % 256, math.floor(n / 256) % 256) end +local function u32(n) return u16(n % 65536) .. u16(math.floor(n / 65536)) end + +-- ldr r1, [pc, #8] ; str r2, [sp, #8] ; add r0, sp, #0x10 ; movs r3, #2 ; pad +-- from offset 0 the literal is at floor(4 / 4) * 4 + 8 = 12 +local function overlay(species) + return u16(0x4902) .. u16(0x9202) .. u16(0xA804) .. u16(0x2302) + .. u16(0) .. u16(0) .. u32(species) +end +check(Intro.monSpecies(overlay(427)) == 427, "reads Buneary's literal") +check(Intro.monSpecies(overlay(133)) == 133, "reads Eevee's literal") +check(Intro.monSpecies(overlay(0)) == nil, "species 0 is not a species") +check(Intro.monSpecies(overlay(9999)) == nil, "a value past the species count is refused") +check(Intro.monSpecies(u16(0x4902) .. u16(0x9203) .. u16(0xA804) .. u16(0x2302) + .. u16(0) .. u16(0) .. u32(133)) == nil, "a different sequence is not matched") +check(Intro.monSpecies(nil) == nil and Intro.monSpecies("") == nil, "no overlay, no species") + +local rom, expected = arg and arg[1], tonumber(arg and arg[2]) +if rom and expected then + local NdsRom = require("src.import.NdsRom") + local cart = assert(NdsRom.open(rom)) + local got = Intro.monSpecies((cart:overlay(Intro.OVERLAY))) + check(got == expected, ("overlay %d names species %s, expected %d"):format( + Intro.OVERLAY, tostring(got), expected)) +end + +print(("gen4 intro species: %s"):format(fails == 0 and "ok" or (fails .. " failed"))) +if fails > 0 then os.exit(1) end diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 0e681e437..34cc49ea6 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -255,9 +255,18 @@ B[#B + 1] = { name = "the parcel", -- Route 202 (coord 1, x 20 y 25..29 while VAR_ROUTE_202_STATE 0x4087 == 0, -- FLAG_RECEIVED_PARCEL set): the catching tutorial, the Poké Balls (0x4087 = 1) +-- +-- RENEGADE PLATINUM replaces the demo (`startcatchingtutorial`) with a REAL +-- trainer battle against the counterpart (trainers 928..933, level 9 with a +-- level-5 starter to meet it), so a lone starter loses and blacks out. The +-- lead is trained up first there, as every later beat does; retail keeps the +-- demo and no cheat. +local renegade = os.getenv("VERSION") == "renegadeplatinum" +local tutorialSteps = { { "teleport", "R202", 21, 27, "left" }, { "activecoord" }, + { "until", varGe(0x4087, 1), 30000 } } +if renegade then table.insert(tutorialSteps, 1, { "do", trainUpWith(57) }) end B[#B + 1] = { name = "route 202 catching tutorial", - steps = { { "teleport", "R202", 21, 27, "left" }, { "activecoord" }, - { "until", varGe(0x4087, 1), 30000 } }, + steps = tutorialSteps, expect = varGe(0x4087, 1), why = "0x4087 >= 1 (the tutorial)", expectBudget = 8000 } -- JUBILIFE (scripts_jubilife_city.s). Coord 1 (45..48,60 while @@ -299,9 +308,14 @@ local poketch = { { "warpto", "C01" }, { "walkto", 44, 42 }, -- the list, which is Looker's strip (coord 3, 0x4077 == 1) -- -- his "have you not obtained a Pokétch?" pushes back forever { "coord", 5 }, { "until", varGe(0x40E7, 2), 20000 } } -talkUntil(poketch, 24, flag(0xED)) -talkUntil(poketch, 25, flag(0xEE)) -talkUntil(poketch, 27, flag(0xEF)) +-- RENEGADE PLATINUM removes the clowns' quizzes (persons 24, 25 are parked at +-- 0,0 with no script, 27 is another sprite): the president hands the Pokétch +-- over on his own after the pitch, so only he is talked to there +if not renegade then + talkUntil(poketch, 24, flag(0xED)) + talkUntil(poketch, 25, flag(0xEE)) + talkUntil(poketch, 27, flag(0xEF)) +end talkUntil(poketch, 23, flag(0xF3)) B[#B + 1] = { name = "jubilife: the poketch", steps = poketch, diff --git a/tools/gen4_trainer_message_check.lua b/tools/gen4_trainer_message_check.lua index 18fb30d97..3f980159f 100644 --- a/tools/gen4_trainer_message_check.lua +++ b/tools/gen4_trainer_message_check.lua @@ -166,6 +166,23 @@ ok(srep.duplicates == 0, "synthetic: %d duplicate entry indices", srep.duplicate ok(srep.pairs == 6, "synthetic: %d pairs, expected 6", srep.pairs) ok(sall[7] ~= nil and sall[3] ~= nil and sall[9] ~= nil and sall[0] == nil, "synthetic: the wrong set of trainers came back") +-- A hack that adds trainers past the offset table (Renegade Platinum's 928..): +-- the walk cannot reach them (offsetFor is nil), so `all` scans for their runs, +-- and ONLY for ids past the table: trainer 7's run is untouched. +local XTBL = TBL .. rec(12, 1) .. rec(12, 2) .. rec(11, 0) +local xall, xrep = M.all(XTBL, OFS) +ok(xall[12] ~= nil and xall[12][1] == 6 and xall[12][2] == 7, + "scan: trainer 12 (past the offset table) did not get its run at records 6, 7") +ok(xall[11] ~= nil and xall[11][0] == 8, "scan: trainer 11 did not get record 8") +ok(xall[7] ~= nil and xall[7][0] == 0 and xall[0] == nil, + "scan: a covered trainer's answer changed, or trainer 0 grew a row") +ok(xrep.scanned == 3 and xrep.scannedTrainers == 2 and xrep.reached == xrep.records, + "scan: report says %s scanned / %s trainers, reached %s of %s records", + tostring(xrep.scanned), tostring(xrep.scannedTrainers), tostring(xrep.reached), + tostring(xrep.records)) +ok(srep.scanned == 0, "scan: a table with no uncovered trainers scanned %s records", + tostring(srep.scanned)) + -- Out of range reads nothing rather than reading off the end. ok(M.offsetFor(OFS, 10) == nil, "trainer 10 is past the offset table and resolved") ok(M.find(TBL, OFS, 10, 0) == nil, "trainer 10 found a message") From baedad5e1cb8291b0ea5c2efdb35598c2ceae3eb Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:13:48 +0100 Subject: [PATCH 34/60] Renegade doc wording --- docs/renegade-platinum.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/renegade-platinum.md b/docs/renegade-platinum.md index ea12b402d..df2ffc1e7 100644 --- a/docs/renegade-platinum.md +++ b/docs/renegade-platinum.md @@ -125,7 +125,7 @@ authored. - Jubilife: the Pokétch clowns' quizzes are removed (persons 24 and 25 are parked at 0,0 without a script); the president gives the Pokétch himself. - Evolutions: 40 records change (trade evolutions gain a level, Eevee uses - Sun/Moon/Ice/Leaf stones, and 12 trade-with-item species can also evolve by + Sun/Moon/Ice/Leaf stones, and about a dozen trade-with-item species can also evolve by using the item, whose records the hack flags as party items); the cache matches the cartridge's `evo.narc` record for record, and `Gen45Evolution.itemTarget` evolves Poliwhirl with King's Rock, Rhydon with From 12ba3d60c93c6083568d563fba44349623aa1369 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:13:49 +0100 Subject: [PATCH 35/60] Gen 4 extractor: read the German Platinum the way it reads the US one - area popup: use the map headers the maps stage located; the fixed US offset read plausible garbage on the German ARM9 - staff roll: identify the table by its first rows and message ids and run it to its own length (224 rows on the German cart, 237 on the US) - Poketch: pair app names by bank position and key art/behaviour by app id instead of the English app name - starter cross-check only holds an English cartridge to US spellings - non-English cartridges name battle/trainer sprites by id alone - Gen4Mining: item ids for the Underground treasure constants The US import is byte-identical before and after. --- src/import/Gen4AreaPopup.lua | 43 ++++++++++++---- src/import/Gen4EndingArt.lua | 20 ++++++-- src/import/Gen4Menus.lua | 91 +++++++++++++++++---------------- src/import/Gen4Mining.lua | 20 ++++++++ src/import/RomExtractorGen4.lua | 59 ++++++++++++++------- 5 files changed, 159 insertions(+), 74 deletions(-) diff --git a/src/import/Gen4AreaPopup.lua b/src/import/Gen4AreaPopup.lua index 1b6b41fc5..5bcc9701c 100644 --- a/src/import/Gen4AreaPopup.lua +++ b/src/import/Gen4AreaPopup.lua @@ -47,10 +47,9 @@ local function hexTiles(G, sheet, palette) return { w = w, h = h, idx = table.concat(out) } end -function Gen4AreaPopup.extract(rom) +function Gen4AreaPopup.extract(rom, headers) local Narc = require("src.import.NarcArchive") local G = require("src.import.Gen4Graphics") - local H = require("src.import.Gen4MapHeaders") local raw = rom:read(Gen4AreaPopup.PATH) local arc = raw and Narc.parse(raw) if not arc then return nil, "no " .. Gen4AreaPopup.PATH end @@ -71,16 +70,40 @@ function Gen4AreaPopup.extract(rom) out.windows[i - 1] = win end end - local arm9 = rom:arm9() - local at = H.KNOWN_OFFSET - for id = 0, 592 do - local o = at + id * H.RECORD_BYTES - local h = arm9 and H.parse(arm9:sub(o + 1, o + H.RECORD_BYTES)) - if h then - out.headers[id] = { text = h.labelText, window = h.labelWindow, mapType = h.mapType } - end + -- THE HEADERS THE MAPS STAGE FOUND, not a fixed address: the table sits at + -- arm9+0xE601C on the US cartridge and 0x58 bytes later on the German one, + -- and 24-byte records read from the wrong place still parse -- every map + -- came out "no sign" until this took the located table. + if type(headers) ~= "table" then headers = Gen4AreaPopup.locate(rom) end + for id, h in pairs(headers or {}) do + out.headers[id] = { text = h.labelText, window = h.labelWindow, mapType = h.mapType } end return out end +-- Standalone (no maps stage before it): the known offset when it holds a +-- full table on this cartridge, else the structural search with the bounds +-- the Platinum archives give. +function Gen4AreaPopup.locate(rom) + local H = require("src.import.Gen4MapHeaders") + local Narc = require("src.import.NarcArchive") + local arm9 = rom and rom:arm9() + if not arm9 then return nil end + local function count(path) + local raw = rom:read(path) + local arc = raw and Narc.parse(raw) + return arc and arc.count or 0 + end + local limits = { + matrix = count("/fielddata/mapmatrix/map_matrix.narc"), + scripts = count("/fielddata/script/scr_seq.narc"), + messages = count("/msgdata/pl_msg.narc"), + encounters = count("/fielddata/encountdata/pl_enc_data.narc"), + events = count("/fielddata/eventdata/zone_event.narc"), + names = 593, + } + local at, n = H.find(arm9, limits) + return at and H.all(arm9, at, n) or nil +end + return Gen4AreaPopup diff --git a/src/import/Gen4EndingArt.lua b/src/import/Gen4EndingArt.lua index 76497f279..c5e248bb6 100644 --- a/src/import/Gen4EndingArt.lua +++ b/src/import/Gen4EndingArt.lua @@ -150,16 +150,30 @@ end -- reaches its y and erased sixteen pixels after the top passes it. Also the -- credits' text palette (ending.narc text.NCLR, member 85) for {COLOR n}. Gen4EndingArt.ROLL_OVERLAY = 99 -Gen4EndingArt.ROLL_SIGNATURE = string.char(0,0, 0,0, 1,0, 1,0, 16,0, 1,0, 2,0, 146,0, 0,0) -Gen4EndingArt.ROLL_COUNT = 237 +-- Only the first two rows and the third's message id: the German cartridge +-- lays its roll out for its own credits (row 2 at y 171 where the US one has +-- 146, and 224 rows where the US has 237), so neither the third row's y nor +-- the count identifies the table. Every row's message id is its index, and +-- the table runs for as long as that holds. +Gen4EndingArt.ROLL_SIGNATURE = string.char(0,0, 0,0, 1,0, 1,0, 16,0, 1,0, 2,0) +Gen4EndingArt.ROLL_COUNT = 237 -- the US cartridge's; German Platinum has 224 +Gen4EndingArt.ROLL_MIN, Gen4EndingArt.ROLL_MAX = 100, 400 function Gen4EndingArt.data(rom) local out = {} local ok, ov = pcall(rom.overlay, rom, Gen4EndingArt.ROLL_OVERLAY) local at = ok and ov and ov:find(Gen4EndingArt.ROLL_SIGNATURE, 1, true) + local count = 0 if at then + while count < Gen4EndingArt.ROLL_MAX and at + count * 6 + 5 <= #ov do + local o = at + count * 6 + if ov:byte(o) + ov:byte(o + 1) * 256 ~= count then break end + count = count + 1 + end + end + if at and count >= Gen4EndingArt.ROLL_MIN then local roll = {} - for i = 0, Gen4EndingArt.ROLL_COUNT - 1 do + for i = 0, count - 1 do local o = at + i * 6 roll[i + 1] = { message = ov:byte(o) + ov:byte(o + 1) * 256, y = ov:byte(o + 2) + ov:byte(o + 3) * 256, centred = (ov:byte(o + 4) + ov:byte(o + 5) * 256) ~= 0 } diff --git a/src/import/Gen4Menus.lua b/src/import/Gen4Menus.lua index 233eec91f..8c6cc9b96 100644 --- a/src/import/Gen4Menus.lua +++ b/src/import/Gen4Menus.lua @@ -532,55 +532,60 @@ function Gen4Menus.poketchName(text) return text:match("{COLOR %d+}(.-){COLOR %d+}") end --- Which picture each app wears. Twenty-two of the twenty-five have a face of --- their own in `/graphic/poketch.narc`; the Friendship Checker, the Pokémon --- History and the Dot Artist do not, and they fall back to the cartridge's own --- `unavailable` screen rather than to a blank one this port drew. +-- BANK 213'S ORDER, BY POSITION, as app ids. It was found by the text +-- pairing the comment above describes -- every coloured name opening exactly +-- one bank-29 description -- and it is a property of the bank, not of the +-- language: the German cartridge pairs 23 of its 25 names to the same app ids +-- at the same positions, and the other two ("Kalender" inside +-- "Monatskalender", "Pokémon-Historie" against "Historie der Pokémon") are +-- the ones prose cannot pair. So the pairing is this table, and the text +-- match is only the cross-check that reports a cartridge where it disagrees. +Gen4Menus.POKETCH_NAME_ORDER = { [0] = 0, 6, 13, 19, 7, 8, 20, 12, 9, 11, 21, 1, 16, 23, 18, 24, + 5, 14, 17, 22, 4, 15, 10, 3, 2 } + +-- Which picture each app wears, by APP ID (the bank-29 order; pokeplatinum +-- generated/poketch_apps.txt). Twenty-two of the twenty-five have a face of +-- their own in `/graphic/poketch.narc`; the Friendship Checker (8), the +-- Pokémon History (11) and the Dot Artist (14) do not, and they fall back to +-- the cartridge's own `unavailable` screen rather than to a blank one this +-- port drew. Keyed by id rather than by the app's English name so a German +-- cartridge's "Digitaluhr" wears the same face as "Digital Watch". Gen4Menus.POKETCH_ART = { - ["Digital Watch"] = "poketch/digital_watch", - ["Analog Watch"] = "poketch/analog_watch", - ["Alarm Clock"] = "poketch/alarm_clock", - ["Stopwatch"] = "poketch/stopwatch", - ["Kitchen Timer"] = "poketch/kitchen_timer", - ["Calendar"] = "poketch/calendar", - ["Calculator"] = "poketch/calculator", - ["Pokémon List"] = "poketch/party_status", - ["Day-Care Checker"] = "poketch/daycare_checker", - ["Matchup Checker"] = "poketch/matchup_checker", - ["Berry Searcher"] = "poketch/berry_searcher", - ["Memo Pad"] = "poketch/memo_pad", - ["Color Changer"] = "poketch/color_changer", - ["Marking Map"] = "poketch/marking_map", - ["Roulette"] = "poketch/roulette", - ["Coin Toss"] = "poketch/coin_toss", - ["Pedometer"] = "poketch/pedometer", - ["Dowsing Machine"] = "poketch/dowsing_machine", - ["Counter"] = "poketch/counter", - ["Trainer Counter"] = "poketch/trainer_counter", - ["Link Searcher"] = "poketch/link_searcher", - ["Move Tester"] = "poketch/move_tester", + [0] = "poketch/digital_watch", + [1] = "poketch/analog_watch", + [2] = "poketch/alarm_clock", + [3] = "poketch/stopwatch", + [4] = "poketch/kitchen_timer", + [5] = "poketch/calendar", + [6] = "poketch/calculator", + [7] = "poketch/party_status", + [9] = "poketch/daycare_checker", + [10] = "poketch/matchup_checker", + [12] = "poketch/berry_searcher", + [13] = "poketch/memo_pad", + [15] = "poketch/color_changer", + [16] = "poketch/marking_map", + [17] = "poketch/roulette", + [18] = "poketch/coin_toss", + [19] = "poketch/pedometer", + [20] = "poketch/dowsing_machine", + [21] = "poketch/counter", + [22] = "poketch/trainer_counter", + [23] = "poketch/link_searcher", + [24] = "poketch/move_tester", } Gen4Menus.POKETCH_BORDER = "poketch/poketch_border" Gen4Menus.POKETCH_UNAVAILABLE = "poketch/unavailable" Gen4Menus.POKETCH_DIGITS = "poketch/digital_watch_digits" --- Which apps this port DOES something with, as opposed to drawing. An app --- that is not here still appears and still shows its own face; it simply has --- no behaviour yet, which is visible on screen rather than hidden. -Gen4Menus.POKETCH_LIVE = { - ["Digital Watch"] = true, ["Analog Watch"] = true, ["Calendar"] = true, - ["Counter"] = true, ["Coin Toss"] = true, ["Pedometer"] = true, - ["Pokémon List"] = true, - ["Calculator"] = true, ["Stopwatch"] = true, ["Kitchen Timer"] = true, - ["Memo Pad"] = true, ["Dot Artist"] = true, ["Marking Map"] = true, - ["Roulette"] = true, ["Friendship Checker"] = true, - ["Day-Care Checker"] = true, - ["Alarm Clock"] = true, ["Dowsing Machine"] = true, - ["Move Tester"] = true, - ["Color Changer"] = true, - ["Matchup Checker"] = true, ["Pokémon History"] = true, - ["Berry Searcher"] = true, -} +-- Which apps this port DOES something with, as opposed to drawing, by app id. +-- An app that is not here still appears and still shows its own face; it +-- simply has no behaviour yet, which is visible on screen rather than hidden. +-- Everything but the Trainer Counter (22) and the Link Searcher (23). +Gen4Menus.POKETCH_LIVE = {} +for id = 0, 24 do + if id ~= 22 and id ~= 23 then Gen4Menus.POKETCH_LIVE[id] = true end +end return Gen4Menus diff --git a/src/import/Gen4Mining.lua b/src/import/Gen4Mining.lua index 812b1c239..d3a4a2d6a 100644 --- a/src/import/Gen4Mining.lua +++ b/src/import/Gen4Mining.lua @@ -488,6 +488,26 @@ Gen4Mining.TREASURE_ITEMS = { [48] = "ITEM_IRON_PLATE", } +-- ...and each constant's item id (pokeplatinum include/constants/items.h, +-- checked against a US cache by name), because the bag has to find the item +-- by id: the names are the cartridge's language, and a German cache has no +-- "Oval Stone" to match ITEM_OVAL_STONE against. +Gen4Mining.TREASURE_ITEM_IDS = { + ITEM_OVAL_STONE = 110, ITEM_ODD_KEYSTONE = 111, ITEM_SUN_STONE = 80, ITEM_STAR_PIECE = 91, + ITEM_MOON_STONE = 81, ITEM_HARD_STONE = 238, ITEM_THUNDERSTONE = 83, ITEM_EVERSTONE = 229, + ITEM_FIRE_STONE = 82, ITEM_WATER_STONE = 84, ITEM_LEAF_STONE = 85, ITEM_NUGGET = 92, + ITEM_HELIX_FOSSIL = 101, ITEM_DOME_FOSSIL = 102, ITEM_CLAW_FOSSIL = 100, ITEM_ROOT_FOSSIL = 99, + ITEM_OLD_AMBER = 103, ITEM_RARE_BONE = 106, ITEM_REVIVE = 28, ITEM_MAX_REVIVE = 29, + ITEM_RED_SHARD = 72, ITEM_BLUE_SHARD = 73, ITEM_YELLOW_SHARD = 74, ITEM_GREEN_SHARD = 75, + ITEM_HEART_SCALE = 93, ITEM_ARMOR_FOSSIL = 104, ITEM_SKULL_FOSSIL = 105, ITEM_LIGHT_CLAY = 269, + ITEM_IRON_BALL = 278, ITEM_ICY_ROCK = 282, ITEM_SMOOTH_ROCK = 283, ITEM_HEAT_ROCK = 284, + ITEM_DAMP_ROCK = 285, ITEM_FLAME_PLATE = 298, ITEM_SPLASH_PLATE = 299, ITEM_ZAP_PLATE = 300, + ITEM_MEADOW_PLATE = 301, ITEM_ICICLE_PLATE = 302, ITEM_FIST_PLATE = 303, ITEM_TOXIC_PLATE = 304, + ITEM_EARTH_PLATE = 305, ITEM_SKY_PLATE = 306, ITEM_MIND_PLATE = 307, ITEM_INSECT_PLATE = 308, + ITEM_STONE_PLATE = 309, ITEM_SPOOKY_PLATE = 310, ITEM_DRACO_PLATE = 311, ITEM_DREAD_PLATE = 312, + ITEM_IRON_PLATE = 313, +} + -- Is this cell of an object solid? `Mining_AreCoordinatesWithinObjectShape`, -- with the flat read already baked into `occ` above, so this is a lookup. -- x and y are 0-based CELL offsets within the object. diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index daf77c8ab..64fb0557c 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -264,6 +264,17 @@ function RomExtractorGen4:close() self.rom = nil end +-- The cartridge's text language (src/import/Gen4Regions.lua): German +-- Platinum is CPUD. Only checks that compare against a US spelling and the +-- human-readable part of a file name ask; every table is keyed by id. +function RomExtractorGen4:isEnglish() + if self._english == nil then + local code = self.rom and self.rom:header().gameCode + self._english = require("src.import.Gen4Regions").isEnglish(code) + end + return self._english +end + function RomExtractorGen4:beginStage(name) self.stage = self.stage + 1 if self.progress then self.progress(self.stage - 1, #STAGES, name, 0, 1) end @@ -4253,6 +4264,10 @@ end -- halves are needed. Everything outside [a-z0-9] folds to an underscore and -- runs collapse, so the two Nidoran, Mr. Mime, Farfetch'd, Ho-Oh and the -- Porygon numerals all produce a path a filesystem will take. +-- +-- A NON-ENGLISH CARTRIDGE GETS THE ID ALONE ("001"): its names would fold to +-- something else ("003_g_re" for Göre), so a German cache would neither match +-- a US one path for path nor read any better. The caller passes no `def`. function RomExtractorGen4.speciesSlug(species, def) local name = def and def.name if type(name) ~= "string" or name == "" then @@ -4433,7 +4448,7 @@ function RomExtractorGen4:extractTrainerSprites() if frames > 1 then animated = animated + 1 end local image = Gen4Trgra.frame(sheet, palette, 1) local entry = image and self:saveImage( - ("battle/trainer/%s"):format(Gen4Trgra.slug(class, name)), image, + ("battle/trainer/%s"):format(Gen4Trgra.slug(class, self:isEnglish() and name or nil)), image, { class = class, name = name, frames = frames }) if entry then written = written + 1 @@ -4523,7 +4538,7 @@ function RomExtractorGen4:extractSpeciesSprites() local shiny = Gen4Graphics.palette(arc:get(species * 6 + Gen4Pokegra.SLOT.shinyPalette)) local def = mons and mons[species] local record = { id = species } - local slug = RomExtractorGen4.speciesSlug(species, def) + local slug = RomExtractorGen4.speciesSlug(species, self:isEnglish() and def or nil) for _, face in ipairs({ "front", "back" }) do local sheet, slot = Gen4Pokegra.sheet(arc, Gen4Graphics, species, face) @@ -4954,14 +4969,21 @@ function RomExtractorGen4:extractMenus() -- own wording. out.newGameWarning = self:string(BANK.mainMenuAlert, Gen4Menus.ALERT_NEW_GAME) - -- THE POKETCH. The order is one bank's and the names are another's, and - -- the pairing is checked rather than assumed: every coloured name must open - -- exactly one description in the ordered bank. - local names = {} + -- THE POKETCH. The order is one bank's and the names are another's. The + -- names are taken by POSITION (Gen4Menus.POKETCH_NAME_ORDER, bank 213 -> + -- app id), which holds on every language; the text pairing below is the + -- cross-check, and `unpaired` lists the apps where it found a DIFFERENT + -- name than the position says (or no name exists) -- empty on the US, + -- European and German cartridges. + local names, nameById = {}, {} for i = 0, Gen4Menus.POKETCH_COUNT - 1 do local text = self:string(BANK.poketchName, Gen4Menus.POKETCH_NAME_FIRST + i) local name = Gen4Menus.poketchName(text) - if name then names[#names + 1] = name end + if name then + names[#names + 1] = name + local id = Gen4Menus.POKETCH_NAME_ORDER[i] + if id then nameById[id] = name end + end end out.poketch = { descBank = BANK.poketchDesc, nameBank = BANK.poketchName, @@ -4976,10 +4998,9 @@ function RomExtractorGen4:extractMenus() -- twenty-five and left the Calendar out, because its description is the one -- that does not begin that way: "Use the monthly Calendar to make a note of -- important dates." Matching anywhere instead needs LONGEST, because - -- "Counter" occurs inside "Trainer Counter" -- and then the twenty-five - -- assignments have to be a permutation, which is the check that says the - -- looser match did not fold two apps onto one name. - local used = {} + -- "Counter" occurs inside "Trainer Counter". German prose leaves two apps + -- with no match at all ("Monatskalender", "Historie der Pokemon"), which is + -- why the position decides and the match only checks. for i = 0, Gen4Menus.POKETCH_COUNT - 1 do local description = self:string(BANK.poketchDesc, Gen4Menus.POKETCH_DESC_FIRST + i) local found = nil @@ -4988,16 +5009,16 @@ function RomExtractorGen4:extractMenus() if not found or #name > #found then found = name end end end - if not found or used[found] then + local name = nameById[i] + if not name or (found and found ~= name) then out.poketch.unpaired[#out.poketch.unpaired + 1] = i end - if found then used[found] = true end out.poketch.apps[i + 1] = { id = i, - name = found, + name = name, description = description, - art = found and Gen4Menus.POKETCH_ART[found] or nil, - live = found and Gen4Menus.POKETCH_LIVE[found] or nil, + art = Gen4Menus.POKETCH_ART[i], + live = Gen4Menus.POKETCH_LIVE[i], } end @@ -5097,7 +5118,9 @@ function RomExtractorGen4:extractMenus() -- cartridge's own source says this slot holds. Nothing should ever read -- it; it exists so that if the three lists ever drift, the drift is in -- the data rather than in somebody's memory. - mismatch = (name ~= row.expect) and + -- `expect` is the US spelling, so only an English cartridge is held to + -- it; the species id is what picks the Pokemon either way. + mismatch = self:isEnglish() and (name ~= row.expect) and ("expected %s, species %d is %s"):format(row.expect, row.species, tostring(name)) or nil, } @@ -6571,7 +6594,7 @@ function RomExtractorGen4:moveTutor() end function RomExtractorGen4:areaPopup() - local out = self.rom and require("src.import.Gen4AreaPopup").extract(self.rom) + local out = self.rom and require("src.import.Gen4AreaPopup").extract(self.rom, self._mapHeaders) if out then self:write("gen4_area_popup", out) end end From 732e061b045ab114d24885e962c26848c2184546 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:13:50 +0100 Subject: [PATCH 36/60] Gen 4 runtime: key on ids where German text broke English lookups A German cache names everything in German, so lookups by English item, app or place names stopped matching: - TM/HM machines: the TM and HM prefixes are read from the items (German HMs are VM01..VM08); TM/HM range also accepts the TM_HM pocket - Plate range, orb flag, Repels/Flutes, Journal, Poke Radar, Town Map, Coin/Poffin/Seal Case, Point Card, Fashion Case: cartridge item ids - Underground: Mystery Zone is the label text id 0, not the string - Underground treasures and the mining screen resolve items by id - bag pockets can be named by engine key (BERRIES, POKE_BALLS) - first PC Potion is ITEM_017 on a Gen 4 cache; GTS mail by pocket - Poketch UI dispatches on a key from the app id, not app.name - saves: an unknown nature name is re-derived from the personality, so one platinum save works across the US, European and German caches --- src/battle/BattleState.lua | 2 +- src/core/Game.lua | 3 ++ src/core/SaveData.lua | 17 +++++++++++ src/inventory/Gen4ItemUse.lua | 10 +++++-- src/inventory/ItemEffects.lua | 55 +++++++++++++++++++++++++++++------ src/online/gts/GtsRules.lua | 6 +++- src/script/Gen4Commands.lua | 15 ++++++++-- src/ui/Gen4BagMenu.lua | 18 +++++++++++- src/ui/Gen4MiningScreen.lua | 4 +++ src/ui/Gen4Poketch.lua | 41 +++++++++++++++++--------- src/ui/Gen4UgPockets.lua | 8 +++++ src/world/Gen4Underground.lua | 25 ++++++++++++++-- 12 files changed, 169 insertions(+), 35 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index cbcdbc1d1..5ec38b6c0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5006,7 +5006,7 @@ function BattleState:gen3TutorialBag() if require("src.core.GameVersion").isGen4() then self.phase, self.afterQueue = "messages", "menu" self:ui(function() - local bag = require("src.ui.Gen4BagMenu").new(self.game, { pocket = "POKé BALLS" }) + local bag = require("src.ui.Gen4BagMenu").new(self.game, { pocket = "POKE_BALLS" }) bag.rows = { { id = 4, label = "POKé BALL", qty = 20 } } bag.update = function(b) b.scriptTimer = (b.scriptTimer or 0) + 1 diff --git a/src/core/Game.lua b/src/core/Game.lua index bd810ac8d..5f9866ce2 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -223,6 +223,9 @@ function Game:bootConfig() local items = self.data and self.data.items if type(items) == "table" then local id = items.POTION and "POTION" or nil + -- A GEN 4 CACHE NAMES IT BY ID: item 17 is the Potion on Platinum and + -- HGSS whatever the cartridge's language calls it ("Trank" in German) + if not id and self.data.isGen4Cache and items.ITEM_017 then id = "ITEM_017" end if not id then -- STRING KEYS ONLY: Gen 4's table carries every item under ITEM_nnn -- AND its bare number, and a bag is keyed by the string. Taking the diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index e59d4c1b2..a1f3c3194 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1989,6 +1989,23 @@ local function scrubKnownMon(mon, data) for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end end mon.level = clamp(mon.level, 1, 100, 1) + -- THE NATURE IS STORED BY NAME, in the language of the cache that made the + -- mon. One `platinum` save serves the US, European and German cartridges + -- (src/import/Gen4Regions.lua), so a mon caught under one and loaded under + -- another carries a name the loaded cache does not know -- and Stats would + -- then apply no modifiers at all. Where the nature rides on the + -- personality (Gen 3 and 4), the loaded cache's own name for it is + -- personality % 25, so an unknown name is put back that way. + if type(mon.nature) == "string" and tonumber(mon.personality) + and not Stats.natureFor(mon.nature) then + local order = data.constants and data.constants.natureOrder + local name = type(order) == "table" and order[(tonumber(mon.personality) % 25) + 1] + local Pokemon = package.loaded["src.pokemon.Pokemon"] + local ownField = Pokemon and Pokemon.natureIsOwnField and Pokemon.natureIsOwnField() + if type(name) == "string" and Stats.natureFor(name) and not ownField then + mon.nature = name + end + end -- Box mons imported from a real .sav carry NO stat block: box_struct stops -- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon -- only fills `stats` for party slots. Every HP-bar draw then nil-indexes diff --git a/src/inventory/Gen4ItemUse.lua b/src/inventory/Gen4ItemUse.lua index c35e3c0b0..d671c3212 100644 --- a/src/inventory/Gen4ItemUse.lua +++ b/src/inventory/Gen4ItemUse.lua @@ -47,6 +47,8 @@ local function isPlatinum(data) return data and data.isGen4Cache and not (v and v.family == "hgss") end +G.isPlatinum = isPlatinum + local function game() return package.loaded["src.core.Game"] end -- the berry patch the player faces, and its state (BERRY_PATCH_FLAG_*) @@ -205,14 +207,16 @@ function G.use(data, save, id, itemDef, target, battle) elseif f == 19 then -- BagContext_FormatUsageMessage's cases (the Repels and Flutes are -- ItemEffects' own, from the name) - if name == "Point Card" then + -- by the cartridge's ids (Point Card 432, Fashion Case 435, Coin Case 444), + -- not the names: a German cache spells all three its own way + if n == 432 then return "failed", { bagLine(data, 97, Strings("Saved Battle Points\n%d BP", save.battlePoints or 0), tostring(save.battlePoints or 0)) } - elseif name == "Fashion Case" then + elseif n == 435 then local acc, bd = 0, 0 for _, c in pairs((save.gen4Accessories or {})) do acc = acc + (tonumber(c) or 0) end for _ in pairs((save.gen4Backdrops or {})) do bd = bd + 1 end return "failed", { bagLine(data, 93, Strings("Accessories: %d\nBackdrops: %d", acc, bd), tostring(acc), tostring(bd)) } - elseif name == "Coin Case" then + elseif n == 444 then return "failed", { bagLine(data, 57, ("Your Coins: %d"):format(save.coins or 0), tostring(save.coins or 0)) } end return nil diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 738ddbe11..fc99d87d5 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -514,13 +514,35 @@ function ItemEffects.markGen4Machines(data) .. "unbroken run, so an id is not an index into tmhmMoves", n, lo, hi) end + -- THE TWO PREFIXES ARE THE CARTRIDGE'S OWN WORDS: "TM"/"HM" in English, + -- "TM"/"VM" on a German Platinum (CT/CS in French, MT/MN in Italian). So + -- the TM prefix is the one the FIRST machine (the lowest id, TM01) carries, + -- the HM prefix is the other one, and the kind below is which of the two a + -- name wears -- never the English letters. + local tmPrefix = tostring(byId[lo].name or ""):match("^(%a+)%d+$") + local function kindOf(name) + local prefix, number = tostring(name or ""):match("^(%a+)(%d+)$") + if not prefix then return nil end + return (prefix == tmPrefix) and "TM" or "HM", number, prefix + end + -- The TM run's length, read off the names rather than assumed to be 92. local tmCount = 0 for id in pairs(byId) do - if tostring(byId[id].name or ""):match("^TM%d+$") then + if kindOf(byId[id].name) == "TM" then tmCount = tmCount + 1 end end + -- ...and only two prefixes may exist, or "the other one" means nothing + local prefixes, nPrefixes = {}, 0 + for id in pairs(byId) do + local _, _, prefix = kindOf(byId[id].name) + if prefix and not prefixes[prefix] then prefixes[prefix] = true; nPrefixes = nPrefixes + 1 end + end + if not tmPrefix or nPrefixes > 2 then + return refuse("the machine items carry %d name prefixes (first %q), not a " + .. "TM and an HM one", nPrefixes, tostring(byId[lo].name)) + end -- IN ID ORDER, so a refusal names the FIRST item that disagrees. `pairs` -- made the message depend on hash order: the same planted fault reported a @@ -533,7 +555,7 @@ function ItemEffects.markGen4Machines(data) for _, id in ipairs(ids) do local def = byId[id] local index = id - lo + 1 - local kind, number = tostring(def.name or ""):match("^([TH]M)(%d+)$") + local kind, number = kindOf(def.name) if not kind then return refuse("item %d carries the TM/HM use function but is named %q", id, tostring(def.name)) @@ -944,6 +966,14 @@ end ItemEffects.gen3Use = gen3Use +-- A PLATINUM KEY ITEM BY ITS CARTRIDGE ID, for the branches below that also +-- test the English name: on a Platinum cache (any language -- a German one +-- says "Tagebuch" for the Journal) the id alone decides. +local function ptItem(data, rawItemId, id) + local G = require("src.inventory.Gen4ItemUse") + return G.numeric(rawItemId) == id and G.isPlatinum(data) and true or false +end + function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local itemDef = data.items[itemId] local name = itemDef and itemDef.name or itemId @@ -973,8 +1003,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if f == 4 then itemId = "BICYCLE" elseif f == 21 then itemId = "ESCAPE_ROPE" elseif f == 19 then + -- by name on an English cache, else by the cartridge's ids (Repel 79, + -- Super Repel 76, Max Repel 77, Black Flute 68, White Flute 69 -- the + -- same on Platinum, HGSS and Black / White): German names are German itemId = ({ ["Repel"] = "REPEL", ["Super Repel"] = "SUPER_REPEL", ["Max Repel"] = "MAX_REPEL", - ["Black Flute"] = "BLACK_FLUTE", ["White Flute"] = "WHITE_FLUTE" })[nm] or itemId + ["Black Flute"] = "BLACK_FLUTE", ["White Flute"] = "WHITE_FLUTE" })[nm] + or ({ [79] = "REPEL", [76] = "SUPER_REPEL", [77] = "MAX_REPEL", + [68] = "BLACK_FLUTE", [69] = "WHITE_FLUTE" })[numericRaw] or itemId end end local gen2Friendship = require("src.pokemon.Gen2Friendship") @@ -1493,14 +1528,16 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end -- PLATINUM'S JOURNAL (fieldUseFunc 5, ITEM_USE_FUNC_JOURNAL: UseJournalFromMenu -- and UseJournalInField both open the journal app, sub_0203D30C) - if itemDef and itemDef.fieldUseFunc == 5 and (itemDef.translationSourceName or itemDef.name) == "Journal" then + if itemDef and itemDef.fieldUseFunc == 5 and ((itemDef.translationSourceName or itemDef.name) == "Journal" + or ptItem(data, rawItemId, 433)) then if battle then return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "journal" end -- THE POKE RADAR (fieldUseFunc 11, ITEM_USE_FUNC_POKE_RADAR) - if itemDef and itemDef.fieldUseFunc == 11 and (itemDef.translationSourceName or itemDef.name) == "Poké Radar" then + if itemDef and itemDef.fieldUseFunc == 11 and ((itemDef.translationSourceName or itemDef.name) == "Poké Radar" + or ptItem(data, rawItemId, 431)) then if battle then return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end @@ -1510,7 +1547,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- function row (`fieldUseFunc` 2, UseTownMapFromBag) says what it is -- (Black / White's item records call the same row `fieldUse`) local isGen4TownMap = itemDef and (itemDef.fieldUseFunc == 2 or itemDef.fieldUse == 2) - and (itemDef.translationSourceName or itemDef.name) == "Town Map" + and ((itemDef.translationSourceName or itemDef.name) == "Town Map" or ptItem(data, rawItemId, 442)) if itemId == "TOWN_MAP" or isGen4TownMap then if battle then return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } @@ -1540,7 +1577,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end -- PLATINUM'S COIN CASE (item 444): BagContext_FormatUsageMessage prints bank -- 7 (TEXT_BANK_BAG) #57, "Your Coins: {STRVAR_1 54 0 0}", unpadded. - if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 444 and itemDef and (itemDef.translationSourceName or itemDef.name) == "Coin Case" then + if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 444 and itemDef and ((itemDef.translationSourceName or itemDef.name) == "Coin Case" or ptItem(data, rawItemId, 444)) then local key = require("src.import.Gen4Text").label(7, 57) local line = data and data.text and data.text[key] local coins = tostring(require("src.import.Gen4GameCorner").coins(save)) @@ -1548,14 +1585,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "failed", { line } end -- THE POFFIN CASE (item 449): its own screen (src/ui/Gen4PoffinCase.lua). - if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 449 and itemDef and (itemDef.translationSourceName or itemDef.name) == "Poffin Case" then + if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 449 and itemDef and ((itemDef.translationSourceName or itemDef.name) == "Poffin Case" or ptItem(data, rawItemId, 449)) then if battle then return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "poffin_case" end -- ...and the SEAL CASE (item 434): bank 7 #92, "Seals: n" -- CalcTotalBallSeals. - if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 434 and itemDef and (itemDef.translationSourceName or itemDef.name) == "Seal Case" then + if require("src.inventory.Gen4ItemUse").numeric(rawItemId) == 434 and itemDef and ((itemDef.translationSourceName or itemDef.name) == "Seal Case" or ptItem(data, rawItemId, 434)) then local key = require("src.import.Gen4Text").label(7, 92) local line = data and data.text and data.text[key] local n = tostring(require("src.import.Gen4Seals").total(save)) diff --git a/src/online/gts/GtsRules.lua b/src/online/gts/GtsRules.lua index c13156a47..f20b0cae9 100644 --- a/src/online/gts/GtsRules.lua +++ b/src/online/gts/GtsRules.lua @@ -205,7 +205,11 @@ function GtsRules.depositBlocker(save, data, mon, index) local runBlock = require("src.runmodes.RunModes").tradeBlock(mon, save) if runBlock then return require("src.runmodes.RunModes").BLOCK_TEXT[runBlock] end local held = mon.item or mon.heldItem - if held ~= nil and itemName(data, held):upper():find("MAIL") then + -- the item's own pocket first (Gen 4: "MAIL"), the name only where a cache + -- has no pocket -- a German cache's mail is "...brief" + local heldDef = data and data.items and data.items[held] + if held ~= nil and ((type(heldDef) == "table" and heldDef.pocket == "MAIL") + or itemName(data, held):upper():find("MAIL")) then return "Remove the MAIL first." end local CompatRules = require("src.online.CompatRules") diff --git a/src/script/Gen4Commands.lua b/src/script/Gen4Commands.lua index 466a74ba1..4b47e9cdd 100644 --- a/src/script/Gen4Commands.lua +++ b/src/script/Gen4Commands.lua @@ -5948,6 +5948,10 @@ function Gen4Commands.plateRange(data) if not high or n > high then high = n end end end + -- A cartridge that does not spell them "... Plate" (German Platinum's are + -- "...tafel") falls back to the cartridge's own pair, ITEM_FLAME_PLATE (298) + -- .. ITEM_IRON_PLATE (313), the same ids on Platinum, HGSS and the US scan. + if not low then low, high = 298, 313 end Gen4Commands._plateLow = low or 0 Gen4Commands._plateHigh = high or -1 return Gen4Commands._plateLow, Gen4Commands._plateHigh @@ -6001,9 +6005,12 @@ function Gen4Commands.tmhmRange(data) local n = tonumber(id) or tonumber(def and def.id) local name = def and def.name -- `^TM%d` and `^HM%d` rather than the pocket, so this stays the id-range - -- test the cartridge performs even if an item is ever re-pocketed. + -- test the cartridge performs even if an item is ever re-pocketed -- OR the + -- TM_HM pocket, because the names are the cartridge's language: German + -- Platinum's HMs are "VM01".."VM08", and the names alone would end the + -- run at TM92. On the US cache both select the same 100 rows. if n and type(name) == "string" - and (name:match("^TM%d") or name:match("^HM%d")) then + and (name:match("^TM%d") or name:match("^HM%d") or def.pocket == "TM_HM") then if not low or n < low then low = n end if not high or n > high then high = n end end @@ -6039,7 +6046,9 @@ function Commands.g4_try_set_collected_orb_flag(ctx, item) local def = data and data.items and data.items[itemKey(data, id)] local name = def and def.name if type(name) ~= "string" then return end - if name == "Adamant Orb" or name == "Lustrous Orb" then + -- by name on an English cache, else by the cartridge's ids (ITEM_ADAMANT_ORB + -- 135, ITEM_LUSTROUS_ORB 136): a German cache spells them its own way + if name == "Adamant Orb" or name == "Lustrous Orb" or id == 135 or id == 136 then local ug = ctx.save and ctx.save.underground if not ug and ctx.save then ug = {}; ctx.save.underground = ug end if ug then ug.collectedOrb = true end diff --git a/src/ui/Gen4BagMenu.lua b/src/ui/Gen4BagMenu.lua index 7b94186e3..aebf0af8b 100644 --- a/src/ui/Gen4BagMenu.lua +++ b/src/ui/Gen4BagMenu.lua @@ -70,6 +70,14 @@ local Theme = require("src.ui.Theme") local Gen4BagMenu = {} Gen4BagMenu.__index = Gen4BagMenu + +-- An engine pocket key -> its position in the bag (fieldPocket + 1), so a +-- caller names a pocket without spelling it in the cartridge's language. +Gen4BagMenu.POCKET_INDEX = { + ITEMS = 1, MEDICINE = 2, POKE_BALLS = 3, ["POKé_BALLS"] = 3, BALLS = 3, + TM_HM = 4, TMS_HMS = 4, BERRIES = 5, MAIL = 6, + BATTLE_ITEMS = 7, KEY_ITEMS = 8, +} Gen4BagMenu.isOpaque = true local W, H = 256, 192 @@ -261,9 +269,17 @@ function Gen4BagMenu.new(game, opts) -- cartridge's berry-planting and Poffin bags do: one icon, one button, no -- arrows. AN EXACT NAME BEATS A CONTAINING ONE, because "ITEMS" is inside -- "BATTLE ITEMS" and "KEY ITEMS" too. + -- + -- THE NAMES ARE THE CARTRIDGE'S, so a caller asking for "BERRIES" on a + -- German cache ("BEEREN") would get the ITEMS pocket. The pockets are in + -- `fieldPocket` order (Gen4Items: ITEMS, MEDICINE, POKE_BALLS, TM_HM, + -- BERRIES, MAIL, BATTLE_ITEMS, KEY_ITEMS), so an engine pocket key, or a + -- number, picks by position first and only an unknown word is matched. self.pocket = 1 if opts.pocket then local wanted = tostring(opts.pocket):upper() + local byKey = Gen4BagMenu.POCKET_INDEX[wanted:gsub("[%s&]+", "_")] + local index = tonumber(opts.pocket) or (#self.pockets == 8 and byKey) or nil local exact, loose for i, name in ipairs(self.pockets) do local upper = tostring(name):upper() @@ -273,7 +289,7 @@ function Gen4BagMenu.new(game, opts) loose = loose or i end end - self.pocket = exact or loose or 1 + self.pocket = (index and self.pockets[index] and index) or exact or loose or 1 self.lockPocket = true end diff --git a/src/ui/Gen4MiningScreen.lua b/src/ui/Gen4MiningScreen.lua index c6c3dbe9a..421dca726 100644 --- a/src/ui/Gen4MiningScreen.lua +++ b/src/ui/Gen4MiningScreen.lua @@ -363,6 +363,10 @@ end -- on the display name -- checked to resolve for all forty-nine. function Gen4MiningScreen:itemIdFor(constant) if not constant then return nil end + -- by id first: the names below are the cartridge's language + local known = require("src.import.Gen4Mining").TREASURE_ITEM_IDS[constant] + local items = (self.game.data or {}).items or {} + if known and items[known] then return known end if not self.itemsByName then local out = {} for id, def in pairs((self.game.data or {}).items or {}) do diff --git a/src/ui/Gen4Poketch.lua b/src/ui/Gen4Poketch.lua index 3a7817c6c..929ec190a 100644 --- a/src/ui/Gen4Poketch.lua +++ b/src/ui/Gen4Poketch.lua @@ -102,12 +102,25 @@ Gen4Poketch.APP_IDS = { ["Trainer Counter"] = 19, ["Kitchen Timer"] = 20, ["Color Changer"] = 21, ["Matchup Checker"] = 22, ["Stopwatch"] = 23, ["Alarm Clock"] = 24, } +-- THE KEY THIS SCREEN DISPATCHES ON, by the cache's `apps[].id` (the +-- description bank's order, the same on every language): `app.name` is the +-- cartridge's own spelling -- "Digitaluhr" on a German Platinum -- so it is +-- shown, never compared. These strings are this file's identifiers for the +-- apps (APP_IDS, DRAW, UNDER, NO_FACE), not text read from a cartridge. +Gen4Poketch.KEY_BY_DESC_ID = { + [0] = "Digital Watch", "Analog Watch", "Alarm Clock", "Stopwatch", "Kitchen Timer", + "Calendar", "Calculator", "Pokémon List", "Friendship Checker", "Day-Care Checker", + "Matchup Checker", "Pokémon History", "Berry Searcher", "Memo Pad", "Dot Artist", + "Color Changer", "Marking Map", "Roulette", "Coin Toss", "Pedometer", + "Dowsing Machine", "Counter", "Trainer Counter", "Link Searcher", "Move Tester", +} function Gen4Poketch.cartridgeOrder(apps) local out = {} for i, app in ipairs(apps or {}) do local copy = {} for k, v in pairs(app) do copy[k] = v end - copy.cartId = Gen4Poketch.APP_IDS[app.name] + copy.key = (app.id ~= nil and Gen4Poketch.KEY_BY_DESC_ID[tonumber(app.id)]) or app.name + copy.cartId = Gen4Poketch.APP_IDS[copy.key] copy.order = i out[#out + 1] = copy end @@ -375,7 +388,7 @@ function Gen4Poketch:update(dt) end local app = self:app() - local name = app and app.name + local name = app and app.key if not app then return end if input:wasPressed("a") then @@ -392,7 +405,7 @@ end -- The keyboard's stand-in for the app's main touch target. function Gen4Poketch:activate() local app, s = self:app(), self:store() - local name = app and app.name + local name = app and app.key if name == "Counter" then s.counter = ((s.counter or 0) + 1) % 10000 appSe(self, "SEQ_SE_DP_POKETCH_010") @@ -466,7 +479,7 @@ function Gen4Poketch:touchpressed(id, px, py) if x < FACE.x or x >= FACE.x + FACE.w or y < FACE.y or y >= FACE.y + FACE.h then return true end -- POKETCH_SYSTEM_MAIN_BUTTON_SCREEN: a touch while the app is changing beeps if self.shutter then se(self, "SEQ_SE_DP_BEEP") end - local name = (self:app() or {}).name + local name = (self:app() or {}).key local s = self:store() if name == 'Calculator' then for i, r in ipairs(CALC_RECTS) do @@ -574,7 +587,7 @@ end function Gen4Poketch:doodle(x, y) if x < FACE.x or x >= FACE.x + FACE.w or y < FACE.y or y >= FACE.y + FACE.h then return end - local s = self:store(); local name = (self:app() or {}).name + local s = self:store(); local name = (self:app() or {}).key local dot = name == 'Dot Artist' local key = dot and 'dots' or 'memo' s[key] = s[key] or {} @@ -605,7 +618,7 @@ end function Gen4Poketch:touchmoved(id, px, py) if self.pointer ~= id then return false end local x, y = SecondScreen.toLocal(self.game, px, py) - local name = (self:app() or {}).name + local name = (self:app() or {}).key if x and self.held then if math.abs(x - self.held.x) > 2 or math.abs(y - self.held.y) > 2 then self.held.moved = true end self.held.x, self.held.y = x, y @@ -636,7 +649,7 @@ end function Gen4Poketch:touchreleased(id) if self.pointer ~= id then return false end - local name = (self:app() or {}).name + local name = (self:app() or {}).key if self.held then if name == 'Stopwatch' then self:stopwatchRelease() end if name == 'Kitchen Timer' then self:timerRelease() end @@ -1213,7 +1226,7 @@ function Gen4Poketch:drawDotArtist() end function Gen4Poketch:drawDoodle() - if (self:app() or {}).name == 'Dot Artist' then return self:drawDotArtist() end + if (self:app() or {}).key == 'Dot Artist' then return self:drawDotArtist() end return self:drawMemoPad() end @@ -2110,7 +2123,7 @@ end -- ------------------------------------------------------- per-frame logic -- function Gen4Poketch:updateApp() - local name = (self:app() or {}).name + local name = (self:app() or {}).key local tick30 = (self.ticks or 0) % 2 == 0 if name == 'Stopwatch' then self:updateStopwatch(tick30) elseif name == 'Friendship Checker' then if tick30 then self:updateFriendship() end @@ -2220,20 +2233,20 @@ function Gen4Poketch:drawLCD(app) lcd(4) g.rectangle('fill', FACE.x, FACE.y, FACE.w, FACE.h) g.setColor(1, 1, 1, 1) - local under = app and UNDER[app.name] + local under = app and UNDER[app.key] if under then under(self) end - local face = app and not NO_FACE[app.name] and app.art and self:img(app.art) + local face = app and not NO_FACE[app.key] and app.art and self:img(app.art) if face then g.setColor(1, 1, 1, 1) g.draw(face, 0, 0) - elseif not (app and DRAW[app.name]) then + elseif not (app and DRAW[app.key]) then -- Only apps which genuinely have no composed face use the cartridge's -- unavailable screen. local blank = self:img(self.unavailable) if blank then g.setColor(1, 1, 1, 1); g.draw(blank, 0, 0) end end if app then - local body = app.name and DRAW[app.name] + local body = app.key and DRAW[app.key] if body then body(self) elseif not face then @@ -2258,7 +2271,7 @@ function Gen4Poketch:drawWatch() -- in theme 0 (their icons and text through shaders of their own), and the -- colour changer's theme -- and the watches' backlight -- is applied to the -- finished LCD, the way the hardware loads one palette under everything. - local backlight = self.backlight and app and (app.name == 'Digital Watch' or app.name == 'Analog Watch') + local backlight = self.backlight and app and (app.key == 'Digital Watch' or app.key == 'Analog Watch') local shown = app local phase = self:shutterCover() if self.shutter and phase == 'close' then shown = self.apps[self.shutter.from] or app end diff --git a/src/ui/Gen4UgPockets.lua b/src/ui/Gen4UgPockets.lua index fb602f27f..c22291fa5 100644 --- a/src/ui/Gen4UgPockets.lua +++ b/src/ui/Gen4UgPockets.lua @@ -185,6 +185,14 @@ function P:doOption(opt, slot) end function P:bagItemId(constant) + local inv = self.game.save.inventory or {} + -- by id first (Gen4Mining.TREASURE_ITEM_IDS): the names below are English + local known = require("src.import.Gen4Mining").TREASURE_ITEM_IDS[constant] + local items = self.game.data.items or {} + if known and (items[known] or items[("ITEM_%03d"):format(known)]) then + if inv[("ITEM_%03d"):format(known)] ~= nil or not items[known] then return ("ITEM_%03d"):format(known) end + return known + end local want = (constant:gsub("^ITEM_", ""):gsub("_", " ")):upper() for id, def in pairs(self.game.data.items or {}) do if type(def) == "table" and ((def.translationSourceName or def.name) or ""):upper() == want then diff --git a/src/world/Gen4Underground.lua b/src/world/Gen4Underground.lua index 5e57453c8..3b9d9914a 100644 --- a/src/world/Gen4Underground.lua +++ b/src/world/Gen4Underground.lua @@ -123,7 +123,25 @@ end -- LocationNames_Text_MysteryZone`. Two headers carry it -- `NOTHING` (1) and -- the Underground itself (2) -- and both should refuse, which is why this is -- the label test and not `id ~= "UG"`. +-- +-- THE TEXT ID, NOT THE STRING: the label is the cartridge's language ("Mystery +-- Zone" is not what a German Platinum calls it), so the test is the header's +-- label text id -- LocationNames_Text_MysteryZone is entry 0 of the location +-- bank -- read from `gen4_area_popup.headers` (Gen4AreaPopup). A cache +-- without that table compares against the Underground map's own label, and +-- only a cache with neither falls back to the US spelling. local MYSTERY_ZONE = "Mystery Zone" +local MYSTERY_ZONE_TEXT = 0 +local function isMysteryZone(def, data) + if not def then return false end + local popup = data and data.gen4_area_popup + local h = popup and popup.headers and def.header and popup.headers[def.header] + if h and h.text ~= nil then return h.text == MYSTERY_ZONE_TEXT end + local ug = data and data.maps and data.maps[Gen4Underground.MAP_ID] + if def.label == MYSTERY_ZONE then return true end + return ug ~= nil and ug.label ~= nil and def.label == ug.label +end +Gen4Underground.isMysteryZone = isMysteryZone -- CAN THE KIT BE USED HERE? CanUseExplorerKit (item_use_functions.c), in its -- own order, returning the first refusal. @@ -139,7 +157,7 @@ function Gen4Underground.canUse(Game, ow) if not GameVersion.isGen4() then return false, "not Gen 4" end if not (Game and ow and ow.map) then return false, "no overworld" end local def = ow.map.def - if def and def.label == MYSTERY_ZONE then + if def and isMysteryZone(def, Game.data) then return false, "already underground" end if not onMainMatrix(def) then return false, "not on the main matrix" end @@ -218,7 +236,7 @@ end -- behind it. function Gen4Underground.leave(Game, ow) if not (Game and ow and ow.map) then return false, "no overworld" end - if ow.map.def and ow.map.def.label ~= MYSTERY_ZONE then + if ow.map.def and not isMysteryZone(ow.map.def, Game.data) then return false, "not underground" end local spot = Game.save and Game.save.gen4SpecialLocation @@ -232,7 +250,8 @@ end -- Is the player in the Underground right now? function Gen4Underground.isUnderground(ow) local def = ow and ow.map and ow.map.def - return def ~= nil and def.label == MYSTERY_ZONE + local game = ow and (ow.game or ow.Game) + return def ~= nil and isMysteryZone(def, game and game.data) and ow.map.id == Gen4Underground.MAP_ID end -- --------------------------------------------------------------------------- From ddc7d20aadf9e3b45327988a2306e3a250ad212a Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:14:40 +0100 Subject: [PATCH 37/60] Field sweep: reset the field between maps; Battle Factory takeRentals tolerates a save with no rental list --- src/script/Gen4BattleFactory.lua | 8 ++++++-- tools/gen4_field_sweep_harness/main.lua | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/script/Gen4BattleFactory.lua b/src/script/Gen4BattleFactory.lua index 81a1512dc..3790a987b 100644 --- a/src/script/Gen4BattleFactory.lua +++ b/src/script/Gen4BattleFactory.lua @@ -234,11 +234,15 @@ end -- BattleFactory_AddSelectedRentalsToParty function B.takeRentals(f) + -- a challenge resumed from a save that carries no rental list (a save the + -- factory never wrote, or one older than it) has nothing to take from: the + -- team already in `playerEntries` stays, rather than raising out of the script + local pool, sets = f.rentals or f.playerEntries or {}, f.rentalSets or f.playerSets or {} f.playerEntries, f.playerSets = {}, {} for i = 1, B.partySize(f.challengeType) do local slot = (f.selected[i - 1] or (i - 1)) + 1 - f.playerEntries[i] = f.rentals[slot] - f.playerSets[i] = f.rentalSets[slot] + f.playerEntries[i] = pool[slot] + f.playerSets[i] = sets[slot] end f.opponentParty = f.opponentEntries end diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index 5e27f2473..1cf9da595 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -431,6 +431,12 @@ local function popAll() -- the overworld outlives the stack (Game:returnToTitle clears this too): a -- fade overlay it still names would be reused by the next script's fade OW.fadeOverlay = nil + -- ...and so does whatever a popped screen was in the middle of (a warp's + -- transition leaves `transitioning` up; a scene its queue) + OW.transitioning = false + OW.scriptMoves = {} + OW.pendingScripts = {} + if OW.runner then OW.runner.co = nil; OW.runner.waitingFrames = nil; OW.runner.waitingCheck = nil end end -- Enter `id` the way a continue does (a fresh stack and a fresh save), so each From 25671e28530c098bdeab7acb3676b30101f04146 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:18:26 +0100 Subject: [PATCH 38/60] gen4_met_location_check: per-language place-name expectations --- tools/gen4_met_location_check.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/gen4_met_location_check.lua b/tools/gen4_met_location_check.lua index 104685bff..3e49e6a39 100644 --- a/tools/gen4_met_location_check.lua +++ b/tools/gen4_met_location_check.lua @@ -78,10 +78,20 @@ if dir and dir ~= '' then end local _, r201 = at('R201') check(r201 == 'Route 201', 'the cache: Route 201 (' .. tostring(r201) .. ')') + -- the cartridge's language: item 1's name (Master Ball / Meisterball). + -- The expected place names are per language; English stays exact. + local items = assert(loadfile(dir .. '/items.lua'))() + local ball = items[1] and items[1].name + local lang = (ball == 'Master Ball') and 'en' or 'de' + local expectBy = { + en = { verity = 'Verity Lakefront', stark = 'Stark Mountain' }, + de = { verity = 'Wahrheitsufer', stark = 'Kahlberg' }, + } + local expect = expectBy[lang] local _, verity = at('L01') - check(verity == 'Verity Lakefront', 'the cache: Verity Lakefront (' .. tostring(verity) .. ')') + check(verity == expect.verity, 'the cache (' .. lang .. '): ' .. expect.verity .. ' (' .. tostring(verity) .. ')') local _, stark = at('D16R0103') - check(stark == 'Stark Mountain', 'the cache: Stark Mountain (' .. tostring(stark) .. ')') + check(stark == expect.stark, 'the cache (' .. lang .. '): ' .. expect.stark .. ' (' .. tostring(stark) .. ')') end print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) From ebb5bdf5b5a5c03dc653c366d839e83a2452e24e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:21:47 +0100 Subject: [PATCH 39/60] Gen 4 checks: assert per language (item ids, HM prefix, place names), English asserts kept --- tools/gen4_area_popup_check.lua | 8 +++++++- tools/gen4_item_use_check.lua | 23 ++++++++++++++++++++++- tools/gen4_machine_check.lua | 18 ++++++++++++++++-- tools/gen4_mining_screen_check.lua | 9 ++++++++- tools/gen4_underground_check.lua | 18 +++++++++++------- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/tools/gen4_area_popup_check.lua b/tools/gen4_area_popup_check.lua index 84ef2033b..e53e618e0 100644 --- a/tools/gen4_area_popup_check.lua +++ b/tools/gen4_area_popup_check.lua @@ -26,11 +26,17 @@ check(n == 9, 'nine signs') check(rec.headers[411].window == 2 and rec.headers[3].window == 1, 'Twinleaf wears the town sign, Jubilife the city one') local game = { data = { gen4_area_popup = rec, text = load('text') } } +-- Twinleaf Town's label (place-name bank 433, index 1) in the cartridge's language; +-- English stays exact, German (Zweiblattdorf) is its own expectation. +local itemsT = load('items') +local english = itemsT and itemsT[1] and itemsT[1].name == 'Master Ball' +local twinText = game.data.text['TEXT_B0433_00001'] +check(twinText == (english and 'Twinleaf Town' or 'Zweiblattdorf'), 'Twinleaf label is the cartridge language name (' .. tostring(twinText) .. ')') local P = require('src.world.Gen4AreaPopup') -- triggers local s = P.onArrive(game, nil, 411) -check(s and s.name == 'Twinleaf Town' and s.window == 1, 'arriving in Twinleaf shows its name on the town sign') +check(s and s.name == twinText and s.window == 1, 'arriving in Twinleaf shows its name on the town sign') local building for id, h in pairs(rec.headers) do if h.mapType == 4 and h.window ~= 0 then building = id break end end check(building == nil or P.onArrive(game, nil, building) == nil, 'a building announces nothing on arrival') diff --git a/tools/gen4_item_use_check.lua b/tools/gen4_item_use_check.lua index 10175aea3..6f0e7a8f8 100644 --- a/tools/gen4_item_use_check.lua +++ b/tools/gen4_item_use_check.lua @@ -87,6 +87,27 @@ for id, def in pairs(plItems) do byName[def.name] = def end end +-- THE CARTRIDGE'S ITEM IDS (language-free) for the English names this file +-- speaks in. An English cache is checked against them (so the table itself +-- stays honest); a cache in another language is read through them. +local EN_IDS = { + ["Potion"] = 17, ["Super Potion"] = 26, ["Hyper Potion"] = 25, ["Max Potion"] = 24, + ["Fresh Water"] = 30, ["Revive"] = 28, ["Max Revive"] = 29, ["Antidote"] = 18, + ["Burn Heal"] = 19, ["Awakening"] = 21, ["Full Heal"] = 27, ["Full Restore"] = 23, + ["Ether"] = 38, ["Max Ether"] = 39, ["Elixir"] = 40, ["Max Elixir"] = 41, + ["PP Up"] = 51, ["PP Max"] = 53, ["Rare Candy"] = 50, ["HP Up"] = 45, + ["Protein"] = 46, ["Iron"] = 47, ["Carbos"] = 48, ["Calcium"] = 49, ["Zinc"] = 52, + ["Energy Root"] = 35, ["Poké Ball"] = 4, +} +local englishCache = plItems[1] and plItems[1].name == "Master Ball" +for name, id in pairs(EN_IDS) do + if englishCache then + ok(byName[name] ~= nil and plItems[id] == byName[name], + "EN_IDS says %s is item %d, the cache disagrees", name, id) + elseif plItems[id] then + byName[name] = plItems[id] + end +end io.write((" %s: %d items, %d of them keyed by number\n") :format(plRoot, total, numeric)) @@ -308,7 +329,7 @@ if plPokemon then end local function keyOf(name) for id, def in pairs(plItems) do - if type(def) == "table" and def.name == name and type(id) == "number" then + if type(def) == "table" and def == byName[name] and type(id) == "number" then return ("ITEM_%03d"):format(id) end end diff --git a/tools/gen4_machine_check.lua b/tools/gen4_machine_check.lua index f6587b6d5..b79f11988 100644 --- a/tools/gen4_machine_check.lua +++ b/tools/gen4_machine_check.lua @@ -211,7 +211,21 @@ else return { items = t, constants = { tmhmMoves = list }, moves = moves, text = texts } end + -- THE HM PREFIX IS THE CARTRIDGE'S OWN ("HM" in English, "VM" in German): + -- read off the first HM of the machine run (the 8th from its end), so the + -- English asserts stay "HM01" and a German cache asserts "VM01". + local HMP = "HM" + do + local lo + for k, def in pairs(items) do + if tonumber(k) and type(def) == "table" and def.fieldUseFunc == ItemEffects.ITEM_USE_FUNC_TM_HM + and (not lo or tonumber(k) < lo) then lo = tonumber(k) end + end + local first = lo and items[lo + #tmhm - 8] + HMP = first and tostring(first.name):match("^(%a+)%d+$") or "HM" + end local function byName(data, name) + name = name:gsub("^HM(%d)", HMP .. "%1") for k, def in pairs(data.items) do if tonumber(k) and type(def) == "table" and def.name == name then return def, tonumber(k) @@ -301,11 +315,11 @@ else local hm01b = byName(before, "HM01") ok(hm01b ~= nil, "HM01 is not in this cache") if hm01b then - ok(not ItemEffects.needsTarget("HM01", hm01b), + ok(not ItemEffects.needsTarget(hm01b.name, hm01b), "HM01 asks for a target BEFORE the record is stamped, so this check " .. "cannot tell whether the record is what opens the party picker") ItemEffects.markGen4Machines(before) - ok(ItemEffects.needsTarget("HM01", hm01b) and true or false, + ok(ItemEffects.needsTarget(hm01b.name, hm01b) and true or false, "HM01 still answers no target needed after the record is stamped -- " .. "which is the bag saying \"This isn't the time to use that!\"") end diff --git a/tools/gen4_mining_screen_check.lua b/tools/gen4_mining_screen_check.lua index 691861776..0ea36a137 100644 --- a/tools/gen4_mining_screen_check.lua +++ b/tools/gen4_mining_screen_check.lua @@ -226,7 +226,14 @@ local resolved, unresolved = 0, {} for key = 0, 48 do local constant = Mining.TREASURE_ITEMS[key] if constant then - local id = byName[(constant:gsub("^ITEM_", ""):gsub("_", " "))] + -- by cartridge id (language-free); an English cache's display name must + -- agree with that id, so the id table itself stays checked + local id = Mining.TREASURE_ITEM_IDS[constant] + if id and not items[id] then id = nil end + local byEnglish = byName[(constant:gsub("^ITEM_", ""):gsub("_", " "))] + if id and byEnglish then + ok(byEnglish == id, "%s: the id table says %s, the English name says %s", constant, tostring(id), tostring(byEnglish)) + end if id then resolved = resolved + 1 else unresolved[#unresolved + 1] = constant end end end diff --git a/tools/gen4_underground_check.lua b/tools/gen4_underground_check.lua index 7470eca9d..bbad43736 100644 --- a/tools/gen4_underground_check.lua +++ b/tools/gen4_underground_check.lua @@ -69,10 +69,14 @@ if ug then "the Underground should be 480x480 (15x15 chunks), dataset says %sx%s", tostring(ug.width), tostring(ug.height)) -- The label is what CanUseExplorerKit compares, so the refusal depends on it. - ok(ug.label == "Mystery Zone", - "the Underground's label is %q, and Gen4Underground refuses on " - .. "\"Mystery Zone\" -- the kit would not climb back out", - tostring(ug.label)) + -- (the label is the cartridge's language; Gen4Underground compares the + -- text id, so the name is only asserted per language) + local itemsT = load_("items.lua") + local english = itemsT and itemsT[1] and itemsT[1].name == "Master Ball" + local wantLabel = english and "Mystery Zone" or "Mysteriöser Ort" + ok(ug.label == wantLabel, + "the Underground's label is %q, expected %q -- the kit would not " + .. "climb back out", tostring(ug.label), wantLabel) end -- --------------------------------------------------------------------------- @@ -89,9 +93,9 @@ end io.write((" items with fieldUseFunc == 3: %d (id %s, %s)\n"):format( kits, tostring(kitId), kitId and tostring(items[kitId].name) or "-")) ok(kits == 1, "expected exactly one item on dispatch row 3, found %d", kits) -ok(kitId ~= nil and items[kitId].name == "Explorer Kit", - "dispatch row 3 is %q, not the Explorer Kit", - kitId and tostring(items[kitId].name) or "nothing") +ok(kitId == 428 and (items[kitId].name == "Explorer Kit" or items[1].name ~= "Master Ball"), + "dispatch row 3 is %q (id %s), not the Explorer Kit (428)", + kitId and tostring(items[kitId].name) or "nothing", tostring(kitId)) -- The control: the row number has to be doing work. If every item answered, or -- none did, the test above would pass for the wrong reason. local rows = {} From de1fe88d64e85ce4d0e38176e6b9c9b9769ed7e0 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:24:30 +0100 Subject: [PATCH 40/60] Pal Park / CrossGen / Walker: reach Gen 4 items and moves by id through an English reference, not the destination's names --- src/pokemon/CrossGen.lua | 28 +- src/pokemon/Gen4EnglishNames.lua | 922 +++++++++++++++++++++++++++++++ src/pokemon/Gen4EnglishRef.lua | 69 +++ src/pokewalker/WalkerEvents.lua | 7 +- src/pokewalker/WalkerInbox.lua | 13 + src/world/Gen4PalPark.lua | 4 +- tools/gen4_english_ref_check.lua | 54 ++ tools/gen4_english_ref_gen.lua | 40 ++ 8 files changed, 1130 insertions(+), 7 deletions(-) create mode 100644 src/pokemon/Gen4EnglishNames.lua create mode 100644 src/pokemon/Gen4EnglishRef.lua create mode 100644 tools/gen4_english_ref_check.lua create mode 100644 tools/gen4_english_ref_gen.lua diff --git a/src/pokemon/CrossGen.lua b/src/pokemon/CrossGen.lua index 869c5b8b8..a7d1ba3da 100644 --- a/src/pokemon/CrossGen.lua +++ b/src/pokemon/CrossGen.lua @@ -16,11 +16,26 @@ local CG = {} local function norm(s) return (tostring(s or ""):upper():gsub("[^%w]", "")) end CG.norm = norm +-- A Gen IV destination may be in any language: its ids are the cartridge's, +-- so an ENGLISH name (a Walker prize, a Gen III item) is carried to its id +-- through src/pokemon/Gen4EnglishRef.lua instead of the destination's own +-- (German) names. Other destinations still match by name. +local function Ref() return require("src.pokemon.Gen4EnglishRef") end +local function itemByName(data, name) + return Ref().itemKey(data, name) or CG.byName(data.items)[norm(name)] +end +local function moveByName(data, name) + return Ref().moveKey(data, name) or CG.byName(data.moves)[norm(name)] +end +CG.itemByName, CG.moveByName = itemByName, moveByName + -- a source game's records, off its own cache (WalkerDelivery's way) function CG.sourceData(version) local Art = require("src.pokewalker.WalkerArt") local prefix = require("src.core.GameVersion").cachePrefix(version) local out = {} + -- a Gen IV cartridge's item ids are language-free (Gen4EnglishRef) + out.gen4 = require("src.import.Gen4Versions").FAMILY[version] ~= nil or nil for _, name in ipairs({ "pokemon", "items", "moves" }) do local src = Art._read(prefix, "data/generated/" .. name .. ".lua") local chunk = src and (loadstring or load)(src, "=" .. version .. "/" .. name) @@ -62,6 +77,9 @@ end function CG.moveId(data, src, id) local n, name = CG.moveRef(src, id) local moves = data.moves or {} + -- Gen IV keeps Gen III's move index, so on a Gen IV destination the index + -- is the answer in any language + if n and moves[n] and Ref().isGen4(data) then return n end if name then local byName = CG.byName(moves) local hit = byName[norm(name)] @@ -76,8 +94,10 @@ function CG.itemId(data, src, key) if key == nil or key == 0 then return nil end local d = src and src.items and src.items[key] local name = (d and d.name) or (type(key) == "string" and key:gsub("^ITEM_", ""):gsub("_", " ")) or nil + -- a Gen IV source names its items in its own language: the id is the key + if src and src.gen4 then name = Ref().englishName("items", key) or name end if name then - local hit = CG.byName(data.items)[norm(name)] + local hit = itemByName(data, name) if hit then return hit end end if type(key) == "number" and src == nil and data.items and data.items[key] then return key end @@ -155,7 +175,7 @@ function CG.convert(game, version, src, raw) mon.origin = raw.origin or version mon.metLevel = level mon.item = CG.itemId(data, src, raw.item) - mon.ball = CG.itemId(data, src, raw.ball) or CG.byName(data.items)[norm("POKE BALL")] + mon.ball = CG.itemId(data, src, raw.ball) or itemByName(data, "POKE BALL") local moves = {} for _, m in ipairs(raw.moves or {}) do local id = CG.moveId(data, src, m.id) @@ -179,13 +199,13 @@ function CG.fromWalker(game, entry) local mon = Pokemon.new(data, dex, math.max(1, math.min(100, tonumber(entry.level) or 5))) local moves = {} for _, name in ipairs(entry.moves or {}) do - local id = CG.byName(data.moves)[norm(type(name) == "table" and name.name or name)] + local id = moveByName(data, type(name) == "table" and name.name or name) if id then moves[#moves + 1] = { id = id, pp = data.moves[id].pp or 0 } end end if #moves > 0 then mon.moves = moves end if entry.shiny then Pokemon.forceShiny(data, mon) end mon.origin = "walker" - mon.ball = CG.byName(data.items)[norm("POKE BALL")] + mon.ball = itemByName(data, "POKE BALL") return mon end diff --git a/src/pokemon/Gen4EnglishNames.lua b/src/pokemon/Gen4EnglishNames.lua new file mode 100644 index 000000000..7cd949d29 --- /dev/null +++ b/src/pokemon/Gen4EnglishNames.lua @@ -0,0 +1,922 @@ +-- GENERATED by tools/gen4_english_ref_gen.lua -- do not edit. +-- English move / item names (normalised: upper case, letters and digits +-- only) -> the Gen IV cartridge id. See src/pokemon/Gen4EnglishRef.lua +-- (the reader) for what this is for. +return { + moves = { + POUND = 1, + KARATECHOP = 2, + DOUBLESLAP = 3, + COMETPUNCH = 4, + MEGAPUNCH = 5, + PAYDAY = 6, + FIREPUNCH = 7, + ICEPUNCH = 8, + THUNDERPUNCH = 9, + SCRATCH = 10, + VICEGRIP = 11, + GUILLOTINE = 12, + RAZORWIND = 13, + SWORDSDANCE = 14, + CUT = 15, + GUST = 16, + WINGATTACK = 17, + WHIRLWIND = 18, + FLY = 19, + BIND = 20, + SLAM = 21, + VINEWHIP = 22, + STOMP = 23, + DOUBLEKICK = 24, + MEGAKICK = 25, + JUMPKICK = 26, + ROLLINGKICK = 27, + SANDATTACK = 28, + HEADBUTT = 29, + HORNATTACK = 30, + FURYATTACK = 31, + HORNDRILL = 32, + TACKLE = 33, + BODYSLAM = 34, + WRAP = 35, + TAKEDOWN = 36, + THRASH = 37, + DOUBLEEDGE = 38, + TAILWHIP = 39, + POISONSTING = 40, + TWINEEDLE = 41, + PINMISSILE = 42, + LEER = 43, + BITE = 44, + GROWL = 45, + ROAR = 46, + SING = 47, + SUPERSONIC = 48, + SONICBOOM = 49, + DISABLE = 50, + ACID = 51, + EMBER = 52, + FLAMETHROWER = 53, + MIST = 54, + WATERGUN = 55, + HYDROPUMP = 56, + SURF = 57, + ICEBEAM = 58, + BLIZZARD = 59, + PSYBEAM = 60, + BUBBLEBEAM = 61, + AURORABEAM = 62, + HYPERBEAM = 63, + PECK = 64, + DRILLPECK = 65, + SUBMISSION = 66, + LOWKICK = 67, + COUNTER = 68, + SEISMICTOSS = 69, + STRENGTH = 70, + ABSORB = 71, + MEGADRAIN = 72, + LEECHSEED = 73, + GROWTH = 74, + RAZORLEAF = 75, + SOLARBEAM = 76, + POISONPOWDER = 77, + STUNSPORE = 78, + SLEEPPOWDER = 79, + PETALDANCE = 80, + STRINGSHOT = 81, + DRAGONRAGE = 82, + FIRESPIN = 83, + THUNDERSHOCK = 84, + THUNDERBOLT = 85, + THUNDERWAVE = 86, + THUNDER = 87, + ROCKTHROW = 88, + EARTHQUAKE = 89, + FISSURE = 90, + DIG = 91, + TOXIC = 92, + CONFUSION = 93, + PSYCHIC = 94, + HYPNOSIS = 95, + MEDITATE = 96, + AGILITY = 97, + QUICKATTACK = 98, + RAGE = 99, + TELEPORT = 100, + NIGHTSHADE = 101, + MIMIC = 102, + SCREECH = 103, + DOUBLETEAM = 104, + RECOVER = 105, + HARDEN = 106, + MINIMIZE = 107, + SMOKESCREEN = 108, + CONFUSERAY = 109, + WITHDRAW = 110, + DEFENSECURL = 111, + BARRIER = 112, + LIGHTSCREEN = 113, + HAZE = 114, + REFLECT = 115, + FOCUSENERGY = 116, + BIDE = 117, + METRONOME = 118, + MIRRORMOVE = 119, + SELFDESTRUCT = 120, + EGGBOMB = 121, + LICK = 122, + SMOG = 123, + SLUDGE = 124, + BONECLUB = 125, + FIREBLAST = 126, + WATERFALL = 127, + CLAMP = 128, + SWIFT = 129, + SKULLBASH = 130, + SPIKECANNON = 131, + CONSTRICT = 132, + AMNESIA = 133, + KINESIS = 134, + SOFTBOILED = 135, + HIJUMPKICK = 136, + GLARE = 137, + DREAMEATER = 138, + POISONGAS = 139, + BARRAGE = 140, + LEECHLIFE = 141, + LOVELYKISS = 142, + SKYATTACK = 143, + TRANSFORM = 144, + BUBBLE = 145, + DIZZYPUNCH = 146, + SPORE = 147, + FLASH = 148, + PSYWAVE = 149, + SPLASH = 150, + ACIDARMOR = 151, + CRABHAMMER = 152, + EXPLOSION = 153, + FURYSWIPES = 154, + BONEMERANG = 155, + REST = 156, + ROCKSLIDE = 157, + HYPERFANG = 158, + SHARPEN = 159, + CONVERSION = 160, + TRIATTACK = 161, + SUPERFANG = 162, + SLASH = 163, + SUBSTITUTE = 164, + STRUGGLE = 165, + SKETCH = 166, + TRIPLEKICK = 167, + THIEF = 168, + SPIDERWEB = 169, + MINDREADER = 170, + NIGHTMARE = 171, + FLAMEWHEEL = 172, + SNORE = 173, + CURSE = 174, + FLAIL = 175, + CONVERSION2 = 176, + AEROBLAST = 177, + COTTONSPORE = 178, + REVERSAL = 179, + SPITE = 180, + POWDERSNOW = 181, + PROTECT = 182, + MACHPUNCH = 183, + SCARYFACE = 184, + FAINTATTACK = 185, + SWEETKISS = 186, + BELLYDRUM = 187, + SLUDGEBOMB = 188, + MUDSLAP = 189, + OCTAZOOKA = 190, + SPIKES = 191, + ZAPCANNON = 192, + FORESIGHT = 193, + DESTINYBOND = 194, + PERISHSONG = 195, + ICYWIND = 196, + DETECT = 197, + BONERUSH = 198, + LOCKON = 199, + OUTRAGE = 200, + SANDSTORM = 201, + GIGADRAIN = 202, + ENDURE = 203, + CHARM = 204, + ROLLOUT = 205, + FALSESWIPE = 206, + SWAGGER = 207, + MILKDRINK = 208, + SPARK = 209, + FURYCUTTER = 210, + STEELWING = 211, + MEANLOOK = 212, + ATTRACT = 213, + SLEEPTALK = 214, + HEALBELL = 215, + RETURN = 216, + PRESENT = 217, + FRUSTRATION = 218, + SAFEGUARD = 219, + PAINSPLIT = 220, + SACREDFIRE = 221, + MAGNITUDE = 222, + DYNAMICPUNCH = 223, + MEGAHORN = 224, + DRAGONBREATH = 225, + BATONPASS = 226, + ENCORE = 227, + PURSUIT = 228, + RAPIDSPIN = 229, + SWEETSCENT = 230, + IRONTAIL = 231, + METALCLAW = 232, + VITALTHROW = 233, + MORNINGSUN = 234, + SYNTHESIS = 235, + MOONLIGHT = 236, + HIDDENPOWER = 237, + CROSSCHOP = 238, + TWISTER = 239, + RAINDANCE = 240, + SUNNYDAY = 241, + CRUNCH = 242, + MIRRORCOAT = 243, + PSYCHUP = 244, + EXTREMESPEED = 245, + ANCIENTPOWER = 246, + SHADOWBALL = 247, + FUTURESIGHT = 248, + ROCKSMASH = 249, + WHIRLPOOL = 250, + BEATUP = 251, + FAKEOUT = 252, + UPROAR = 253, + STOCKPILE = 254, + SPITUP = 255, + SWALLOW = 256, + HEATWAVE = 257, + HAIL = 258, + TORMENT = 259, + FLATTER = 260, + WILLOWISP = 261, + MEMENTO = 262, + FACADE = 263, + FOCUSPUNCH = 264, + SMELLINGSALT = 265, + FOLLOWME = 266, + NATUREPOWER = 267, + CHARGE = 268, + TAUNT = 269, + HELPINGHAND = 270, + TRICK = 271, + ROLEPLAY = 272, + WISH = 273, + ASSIST = 274, + INGRAIN = 275, + SUPERPOWER = 276, + MAGICCOAT = 277, + RECYCLE = 278, + REVENGE = 279, + BRICKBREAK = 280, + YAWN = 281, + KNOCKOFF = 282, + ENDEAVOR = 283, + ERUPTION = 284, + SKILLSWAP = 285, + IMPRISON = 286, + REFRESH = 287, + GRUDGE = 288, + SNATCH = 289, + SECRETPOWER = 290, + DIVE = 291, + ARMTHRUST = 292, + CAMOUFLAGE = 293, + TAILGLOW = 294, + LUSTERPURGE = 295, + MISTBALL = 296, + FEATHERDANCE = 297, + TEETERDANCE = 298, + BLAZEKICK = 299, + MUDSPORT = 300, + ICEBALL = 301, + NEEDLEARM = 302, + SLACKOFF = 303, + HYPERVOICE = 304, + POISONFANG = 305, + CRUSHCLAW = 306, + BLASTBURN = 307, + HYDROCANNON = 308, + METEORMASH = 309, + ASTONISH = 310, + WEATHERBALL = 311, + AROMATHERAPY = 312, + FAKETEARS = 313, + AIRCUTTER = 314, + OVERHEAT = 315, + ODORSLEUTH = 316, + ROCKTOMB = 317, + SILVERWIND = 318, + METALSOUND = 319, + GRASSWHISTLE = 320, + TICKLE = 321, + COSMICPOWER = 322, + WATERSPOUT = 323, + SIGNALBEAM = 324, + SHADOWPUNCH = 325, + EXTRASENSORY = 326, + SKYUPPERCUT = 327, + SANDTOMB = 328, + SHEERCOLD = 329, + MUDDYWATER = 330, + BULLETSEED = 331, + AERIALACE = 332, + ICICLESPEAR = 333, + IRONDEFENSE = 334, + BLOCK = 335, + HOWL = 336, + DRAGONCLAW = 337, + FRENZYPLANT = 338, + BULKUP = 339, + BOUNCE = 340, + MUDSHOT = 341, + POISONTAIL = 342, + COVET = 343, + VOLTTACKLE = 344, + MAGICALLEAF = 345, + WATERSPORT = 346, + CALMMIND = 347, + LEAFBLADE = 348, + DRAGONDANCE = 349, + ROCKBLAST = 350, + SHOCKWAVE = 351, + WATERPULSE = 352, + DOOMDESIRE = 353, + PSYCHOBOOST = 354, + ROOST = 355, + GRAVITY = 356, + MIRACLEEYE = 357, + WAKEUPSLAP = 358, + HAMMERARM = 359, + GYROBALL = 360, + HEALINGWISH = 361, + BRINE = 362, + NATURALGIFT = 363, + FEINT = 364, + PLUCK = 365, + TAILWIND = 366, + ACUPRESSURE = 367, + METALBURST = 368, + UTURN = 369, + CLOSECOMBAT = 370, + PAYBACK = 371, + ASSURANCE = 372, + EMBARGO = 373, + FLING = 374, + PSYCHOSHIFT = 375, + TRUMPCARD = 376, + HEALBLOCK = 377, + WRINGOUT = 378, + POWERTRICK = 379, + GASTROACID = 380, + LUCKYCHANT = 381, + MEFIRST = 382, + COPYCAT = 383, + POWERSWAP = 384, + GUARDSWAP = 385, + PUNISHMENT = 386, + LASTRESORT = 387, + WORRYSEED = 388, + SUCKERPUNCH = 389, + TOXICSPIKES = 390, + HEARTSWAP = 391, + AQUARING = 392, + MAGNETRISE = 393, + FLAREBLITZ = 394, + FORCEPALM = 395, + AURASPHERE = 396, + ROCKPOLISH = 397, + POISONJAB = 398, + DARKPULSE = 399, + NIGHTSLASH = 400, + AQUATAIL = 401, + SEEDBOMB = 402, + AIRSLASH = 403, + XSCISSOR = 404, + BUGBUZZ = 405, + DRAGONPULSE = 406, + DRAGONRUSH = 407, + POWERGEM = 408, + DRAINPUNCH = 409, + VACUUMWAVE = 410, + FOCUSBLAST = 411, + ENERGYBALL = 412, + BRAVEBIRD = 413, + EARTHPOWER = 414, + SWITCHEROO = 415, + GIGAIMPACT = 416, + NASTYPLOT = 417, + BULLETPUNCH = 418, + AVALANCHE = 419, + ICESHARD = 420, + SHADOWCLAW = 421, + THUNDERFANG = 422, + ICEFANG = 423, + FIREFANG = 424, + SHADOWSNEAK = 425, + MUDBOMB = 426, + PSYCHOCUT = 427, + ZENHEADBUTT = 428, + MIRRORSHOT = 429, + FLASHCANNON = 430, + ROCKCLIMB = 431, + DEFOG = 432, + TRICKROOM = 433, + DRACOMETEOR = 434, + DISCHARGE = 435, + LAVAPLUME = 436, + LEAFSTORM = 437, + POWERWHIP = 438, + ROCKWRECKER = 439, + CROSSPOISON = 440, + GUNKSHOT = 441, + IRONHEAD = 442, + MAGNETBOMB = 443, + STONEEDGE = 444, + CAPTIVATE = 445, + STEALTHROCK = 446, + GRASSKNOT = 447, + CHATTER = 448, + JUDGMENT = 449, + BUGBITE = 450, + CHARGEBEAM = 451, + WOODHAMMER = 452, + AQUAJET = 453, + ATTACKORDER = 454, + DEFENDORDER = 455, + HEALORDER = 456, + HEADSMASH = 457, + DOUBLEHIT = 458, + ROAROFTIME = 459, + SPACIALREND = 460, + LUNARDANCE = 461, + CRUSHGRIP = 462, + MAGMASTORM = 463, + DARKVOID = 464, + SEEDFLARE = 465, + OMINOUSWIND = 466, + SHADOWFORCE = 467, + }, + items = { + MASTERBALL = 1, + ULTRABALL = 2, + GREATBALL = 3, + POKEBALL = 4, + SAFARIBALL = 5, + NETBALL = 6, + DIVEBALL = 7, + NESTBALL = 8, + REPEATBALL = 9, + TIMERBALL = 10, + LUXURYBALL = 11, + PREMIERBALL = 12, + DUSKBALL = 13, + HEALBALL = 14, + QUICKBALL = 15, + CHERISHBALL = 16, + POTION = 17, + ANTIDOTE = 18, + BURNHEAL = 19, + ICEHEAL = 20, + AWAKENING = 21, + PARLYZHEAL = 22, + FULLRESTORE = 23, + MAXPOTION = 24, + HYPERPOTION = 25, + SUPERPOTION = 26, + FULLHEAL = 27, + REVIVE = 28, + MAXREVIVE = 29, + FRESHWATER = 30, + SODAPOP = 31, + LEMONADE = 32, + MOOMOOMILK = 33, + ENERGYPOWDER = 34, + ENERGYROOT = 35, + HEALPOWDER = 36, + REVIVALHERB = 37, + ETHER = 38, + MAXETHER = 39, + ELIXIR = 40, + MAXELIXIR = 41, + LAVACOOKIE = 42, + BERRYJUICE = 43, + SACREDASH = 44, + HPUP = 45, + PROTEIN = 46, + IRON = 47, + CARBOS = 48, + CALCIUM = 49, + RARECANDY = 50, + PPUP = 51, + ZINC = 52, + PPMAX = 53, + OLDGATEAU = 54, + GUARDSPEC = 55, + DIREHIT = 56, + XATTACK = 57, + XDEFEND = 58, + XSPEED = 59, + XACCURACY = 60, + XSPECIAL = 61, + XSPDEF = 62, + POKEDOLL = 63, + FLUFFYTAIL = 64, + BLUEFLUTE = 65, + YELLOWFLUTE = 66, + REDFLUTE = 67, + BLACKFLUTE = 68, + WHITEFLUTE = 69, + SHOALSALT = 70, + SHOALSHELL = 71, + REDSHARD = 72, + BLUESHARD = 73, + YELLOWSHARD = 74, + GREENSHARD = 75, + SUPERREPEL = 76, + MAXREPEL = 77, + ESCAPEROPE = 78, + REPEL = 79, + SUNSTONE = 80, + MOONSTONE = 81, + FIRESTONE = 82, + THUNDERSTONE = 83, + WATERSTONE = 84, + LEAFSTONE = 85, + TINYMUSHROOM = 86, + BIGMUSHROOM = 87, + PEARL = 88, + BIGPEARL = 89, + STARDUST = 90, + STARPIECE = 91, + NUGGET = 92, + HEARTSCALE = 93, + HONEY = 94, + GROWTHMULCH = 95, + DAMPMULCH = 96, + STABLEMULCH = 97, + GOOEYMULCH = 98, + ROOTFOSSIL = 99, + CLAWFOSSIL = 100, + HELIXFOSSIL = 101, + DOMEFOSSIL = 102, + OLDAMBER = 103, + ARMORFOSSIL = 104, + SKULLFOSSIL = 105, + RAREBONE = 106, + SHINYSTONE = 107, + DUSKSTONE = 108, + DAWNSTONE = 109, + OVALSTONE = 110, + ODDKEYSTONE = 111, + GRISEOUSORB = 112, + ADAMANTORB = 135, + LUSTROUSORB = 136, + GRASSMAIL = 137, + FLAMEMAIL = 138, + BUBBLEMAIL = 139, + BLOOMMAIL = 140, + TUNNELMAIL = 141, + STEELMAIL = 142, + HEARTMAIL = 143, + SNOWMAIL = 144, + SPACEMAIL = 145, + AIRMAIL = 146, + MOSAICMAIL = 147, + BRICKMAIL = 148, + CHERIBERRY = 149, + CHESTOBERRY = 150, + PECHABERRY = 151, + RAWSTBERRY = 152, + ASPEARBERRY = 153, + LEPPABERRY = 154, + ORANBERRY = 155, + PERSIMBERRY = 156, + LUMBERRY = 157, + SITRUSBERRY = 158, + FIGYBERRY = 159, + WIKIBERRY = 160, + MAGOBERRY = 161, + AGUAVBERRY = 162, + IAPAPABERRY = 163, + RAZZBERRY = 164, + BLUKBERRY = 165, + NANABBERRY = 166, + WEPEARBERRY = 167, + PINAPBERRY = 168, + POMEGBERRY = 169, + KELPSYBERRY = 170, + QUALOTBERRY = 171, + HONDEWBERRY = 172, + GREPABERRY = 173, + TAMATOBERRY = 174, + CORNNBERRY = 175, + MAGOSTBERRY = 176, + RABUTABERRY = 177, + NOMELBERRY = 178, + SPELONBERRY = 179, + PAMTREBERRY = 180, + WATMELBERRY = 181, + DURINBERRY = 182, + BELUEBERRY = 183, + OCCABERRY = 184, + PASSHOBERRY = 185, + WACANBERRY = 186, + RINDOBERRY = 187, + YACHEBERRY = 188, + CHOPLEBERRY = 189, + KEBIABERRY = 190, + SHUCABERRY = 191, + COBABERRY = 192, + PAYAPABERRY = 193, + TANGABERRY = 194, + CHARTIBERRY = 195, + KASIBBERRY = 196, + HABANBERRY = 197, + COLBURBERRY = 198, + BABIRIBERRY = 199, + CHILANBERRY = 200, + LIECHIBERRY = 201, + GANLONBERRY = 202, + SALACBERRY = 203, + PETAYABERRY = 204, + APICOTBERRY = 205, + LANSATBERRY = 206, + STARFBERRY = 207, + ENIGMABERRY = 208, + MICLEBERRY = 209, + CUSTAPBERRY = 210, + JABOCABERRY = 211, + ROWAPBERRY = 212, + BRIGHTPOWDER = 213, + WHITEHERB = 214, + MACHOBRACE = 215, + EXPSHARE = 216, + QUICKCLAW = 217, + SOOTHEBELL = 218, + MENTALHERB = 219, + CHOICEBAND = 220, + KINGSROCK = 221, + SILVERPOWDER = 222, + AMULETCOIN = 223, + CLEANSETAG = 224, + SOULDEW = 225, + DEEPSEATOOTH = 226, + DEEPSEASCALE = 227, + SMOKEBALL = 228, + EVERSTONE = 229, + FOCUSBAND = 230, + LUCKYEGG = 231, + SCOPELENS = 232, + METALCOAT = 233, + LEFTOVERS = 234, + DRAGONSCALE = 235, + LIGHTBALL = 236, + SOFTSAND = 237, + HARDSTONE = 238, + MIRACLESEED = 239, + BLACKGLASSES = 240, + BLACKBELT = 241, + MAGNET = 242, + MYSTICWATER = 243, + SHARPBEAK = 244, + POISONBARB = 245, + NEVERMELTICE = 246, + SPELLTAG = 247, + TWISTEDSPOON = 248, + CHARCOAL = 249, + DRAGONFANG = 250, + SILKSCARF = 251, + UPGRADE = 252, + SHELLBELL = 253, + SEAINCENSE = 254, + LAXINCENSE = 255, + LUCKYPUNCH = 256, + METALPOWDER = 257, + THICKCLUB = 258, + STICK = 259, + REDSCARF = 260, + BLUESCARF = 261, + PINKSCARF = 262, + GREENSCARF = 263, + YELLOWSCARF = 264, + WIDELENS = 265, + MUSCLEBAND = 266, + WISEGLASSES = 267, + EXPERTBELT = 268, + LIGHTCLAY = 269, + LIFEORB = 270, + POWERHERB = 271, + TOXICORB = 272, + FLAMEORB = 273, + QUICKPOWDER = 274, + FOCUSSASH = 275, + ZOOMLENS = 276, + METRONOME = 277, + IRONBALL = 278, + LAGGINGTAIL = 279, + DESTINYKNOT = 280, + BLACKSLUDGE = 281, + ICYROCK = 282, + SMOOTHROCK = 283, + HEATROCK = 284, + DAMPROCK = 285, + GRIPCLAW = 286, + CHOICESCARF = 287, + STICKYBARB = 288, + POWERBRACER = 289, + POWERBELT = 290, + POWERLENS = 291, + POWERBAND = 292, + POWERANKLET = 293, + POWERWEIGHT = 294, + SHEDSHELL = 295, + BIGROOT = 296, + CHOICESPECS = 297, + FLAMEPLATE = 298, + SPLASHPLATE = 299, + ZAPPLATE = 300, + MEADOWPLATE = 301, + ICICLEPLATE = 302, + FISTPLATE = 303, + TOXICPLATE = 304, + EARTHPLATE = 305, + SKYPLATE = 306, + MINDPLATE = 307, + INSECTPLATE = 308, + STONEPLATE = 309, + SPOOKYPLATE = 310, + DRACOPLATE = 311, + DREADPLATE = 312, + IRONPLATE = 313, + ODDINCENSE = 314, + ROCKINCENSE = 315, + FULLINCENSE = 316, + WAVEINCENSE = 317, + ROSEINCENSE = 318, + LUCKINCENSE = 319, + PUREINCENSE = 320, + PROTECTOR = 321, + ELECTIRIZER = 322, + MAGMARIZER = 323, + DUBIOUSDISC = 324, + REAPERCLOTH = 325, + RAZORCLAW = 326, + RAZORFANG = 327, + TM01 = 328, + TM02 = 329, + TM03 = 330, + TM04 = 331, + TM05 = 332, + TM06 = 333, + TM07 = 334, + TM08 = 335, + TM09 = 336, + TM10 = 337, + TM11 = 338, + TM12 = 339, + TM13 = 340, + TM14 = 341, + TM15 = 342, + TM16 = 343, + TM17 = 344, + TM18 = 345, + TM19 = 346, + TM20 = 347, + TM21 = 348, + TM22 = 349, + TM23 = 350, + TM24 = 351, + TM25 = 352, + TM26 = 353, + TM27 = 354, + TM28 = 355, + TM29 = 356, + TM30 = 357, + TM31 = 358, + TM32 = 359, + TM33 = 360, + TM34 = 361, + TM35 = 362, + TM36 = 363, + TM37 = 364, + TM38 = 365, + TM39 = 366, + TM40 = 367, + TM41 = 368, + TM42 = 369, + TM43 = 370, + TM44 = 371, + TM45 = 372, + TM46 = 373, + TM47 = 374, + TM48 = 375, + TM49 = 376, + TM50 = 377, + TM51 = 378, + TM52 = 379, + TM53 = 380, + TM54 = 381, + TM55 = 382, + TM56 = 383, + TM57 = 384, + TM58 = 385, + TM59 = 386, + TM60 = 387, + TM61 = 388, + TM62 = 389, + TM63 = 390, + TM64 = 391, + TM65 = 392, + TM66 = 393, + TM67 = 394, + TM68 = 395, + TM69 = 396, + TM70 = 397, + TM71 = 398, + TM72 = 399, + TM73 = 400, + TM74 = 401, + TM75 = 402, + TM76 = 403, + TM77 = 404, + TM78 = 405, + TM79 = 406, + TM80 = 407, + TM81 = 408, + TM82 = 409, + TM83 = 410, + TM84 = 411, + TM85 = 412, + TM86 = 413, + TM87 = 414, + TM88 = 415, + TM89 = 416, + TM90 = 417, + TM91 = 418, + TM92 = 419, + HM01 = 420, + HM02 = 421, + HM03 = 422, + HM04 = 423, + HM05 = 424, + HM06 = 425, + HM07 = 426, + HM08 = 427, + EXPLORERKIT = 428, + LOOTSACK = 429, + RULEBOOK = 430, + POKERADAR = 431, + POINTCARD = 432, + JOURNAL = 433, + SEALCASE = 434, + FASHIONCASE = 435, + SEALBAG = 436, + PALPAD = 437, + WORKSKEY = 438, + OLDCHARM = 439, + GALACTICKEY = 440, + REDCHAIN = 441, + TOWNMAP = 442, + VSSEEKER = 443, + COINCASE = 444, + OLDROD = 445, + GOODROD = 446, + SUPERROD = 447, + SPRAYDUCK = 448, + POFFINCASE = 449, + BICYCLE = 450, + SUITEKEY = 451, + OAKSLETTER = 452, + LUNARWING = 453, + MEMBERCARD = 454, + AZUREFLUTE = 455, + SSTICKET = 456, + CONTESTPASS = 457, + MAGMASTONE = 458, + PARCEL = 459, + COUPON1 = 460, + COUPON2 = 461, + COUPON3 = 462, + STORAGEKEY = 463, + SECRETPOTION = 464, + VSRECORDER = 465, + GRACIDEA = 466, + SECRETKEY = 467, + }, +} diff --git a/src/pokemon/Gen4EnglishRef.lua b/src/pokemon/Gen4EnglishRef.lua new file mode 100644 index 000000000..289a80b03 --- /dev/null +++ b/src/pokemon/Gen4EnglishRef.lua @@ -0,0 +1,69 @@ +-- ENGLISH NAME -> GEN IV ID, FOR A CACHE IN ANOTHER LANGUAGE. +-- +-- Several systems are handed an English item or move name: a Monster Walker +-- prize or catch (src/pokewalker), a Gen III cartridge's held item (Pal Park, +-- src/pokemon/CrossGen.lua), a Platinum Pokemon moving on to Black / White. +-- They used to find the destination's entry by comparing that name with the +-- destination's own, which only works while the destination is English: on a +-- German Platinum the held item was dropped and the Walker's prizes became +-- Potions. +-- +-- Move ids are the National move index of every generation, and a Gen IV +-- item id is the same on every language, so the id is the language-free +-- answer. Gen4EnglishNames.lua is the table (tools/gen4_english_ref_gen.lua +-- writes it from an English import); this is its reader. + +local Ref = {} + +local function names() return require("src.pokemon.Gen4EnglishNames") end + +local function norm(s) + return (tostring(s or ""):gsub("é", "e"):gsub("É", "E"):upper():gsub("[^%w]", "")) +end +Ref.norm = norm + +-- the data table's entry for a Gen IV id (numeric key, or the `ITEM_nnn` alias) +local function entry(tbl, id) + if type(tbl) ~= "table" then return nil, nil end + if tbl[id] ~= nil then return id, tbl[id] end + local alias = ("ITEM_%03d"):format(id) + if tbl[alias] ~= nil then return alias, tbl[alias] end + return nil, nil +end + +-- Is `data` a Gen IV dataset (ids are the cartridge's)? +function Ref.isGen4(data) + return type(data) == "table" and type(data.constants) == "table" and data.constants.gen == 4 +end + +-- move / item key in `data` for an English name; nil when `data` is not Gen IV +-- or the name is not one of Gen IV's. Returns the key the data table uses. +function Ref.moveKey(data, name) + if not Ref.isGen4(data) then return nil end + local id = names().moves[norm(name)] + if not id then return nil end + return (entry(data.moves, id)) +end + +function Ref.itemKey(data, name) + if not Ref.isGen4(data) then return nil end + local id = names().items[norm(name)] + if not id then return nil end + return (entry(data.items, id)) +end + +-- the other way: a Gen IV id (or ITEM_nnn alias) -> its English name +local reverse +function Ref.englishName(kind, key) + local id = tonumber(key) or tonumber(tostring(key):match("^ITEM_(%d+)$")) + if not id then return nil end + reverse = reverse or {} + if not reverse[kind] then + local out = {} + for n, i in pairs(names()[kind]) do if out[i] == nil then out[i] = n end end + reverse[kind] = out + end + return reverse[kind][id] +end + +return Ref diff --git a/src/pokewalker/WalkerEvents.lua b/src/pokewalker/WalkerEvents.lua index c97f927c0..86b90ff7a 100644 --- a/src/pokewalker/WalkerEvents.lua +++ b/src/pokewalker/WalkerEvents.lua @@ -465,7 +465,9 @@ end Ev.speciesKey = speciesKey local function itemKey(data, name, gen) - return findKey(data.items, name, gen >= 4 or nil) + -- a Gen IV cache in another language: the English name's cartridge id + return require("src.pokemon.Gen4EnglishRef").itemKey(data, name) + or findKey(data.items, name, gen >= 4 or nil) end -- names a dataset spells the older way (Gen 3-5 cartridges say "Faint @@ -476,7 +478,8 @@ local MOVE_ALIAS = { ["Feint Attack"] = "Faint Attack", ["Hi Jump Kick"] = "High local function moveList(data, names, gen) local out = {} for _, n in ipairs(names or {}) do - local k = findKey(data.moves, n, gen >= 4 or nil) + local k = require("src.pokemon.Gen4EnglishRef").moveKey(data, n) + or findKey(data.moves, n, gen >= 4 or nil) or (MOVE_ALIAS[n] and findKey(data.moves, MOVE_ALIAS[n], gen >= 4 or nil)) local def = k and data.moves[k] if def then out[#out + 1] = { id = k, pp = def.pp or 0 } end diff --git a/src/pokewalker/WalkerInbox.lua b/src/pokewalker/WalkerInbox.lua index e13de991a..6f357f3a2 100644 --- a/src/pokewalker/WalkerInbox.lua +++ b/src/pokewalker/WalkerInbox.lua @@ -43,6 +43,9 @@ local function bagKey(data,id) end local function itemId(data, name) + -- a Gen IV cache in another language: the English name's cartridge id + local refKey = require("src.pokemon.Gen4EnglishRef").itemKey(data, name) + if refKey then return bagKey(data, refKey) end local want = norm(name) local fallback for id, def in pairs(data.items or {}) do @@ -58,6 +61,16 @@ local function itemId(data, name) if type(def) == "table" and norm(def.name) == "POKEBALL" then return bagKey(data,id) end end end + -- (a Gen IV cache in another language has no English names to find them by) + local R = require("src.pokemon.Gen4EnglishRef") + if want:find("BALL") then + local k = R.itemKey(data, "POKE BALL") + if k then return bagKey(data, k) end + end + if not fallback then + local k = R.itemKey(data, "POTION") + if k then fallback = bagKey(data, k) end + end return fallback end diff --git a/src/world/Gen4PalPark.lua b/src/world/Gen4PalPark.lua index aa76693d9..1ac00441f 100644 --- a/src/world/Gen4PalPark.lua +++ b/src/world/Gen4PalPark.lua @@ -370,7 +370,9 @@ function P.fromGen3(game, version, src, raw) if key == nil then return nil end local d = srcItems[key] local name = (d and d.name) or tostring(key):gsub("^ITEM_", ""):gsub("_", " ") - return items[norm(name)] + -- Gen IV ids by the English name (Gen4EnglishRef), so a German Platinum + -- keeps the held item; an English cache matches by its own names as before + return require("src.pokemon.CrossGen").itemByName(data, name) or items[norm(name)] end mon.item = itemId(raw.item) mon.ball = itemId(raw.ball) or itemId("POKE_BALL") diff --git a/tools/gen4_english_ref_check.lua b/tools/gen4_english_ref_check.lua new file mode 100644 index 000000000..09652bb7e --- /dev/null +++ b/tools/gen4_english_ref_check.lua @@ -0,0 +1,54 @@ +-- Run: python tools/run_lua_check.py tools/gen4_english_ref_check.lua /data/generated +-- +-- ENGLISH NAMES ONTO A GEN IV CACHE OF ANY LANGUAGE (src/pokemon/Gen4EnglishRef.lua): +-- Pal Park's held items, the Walker's prizes and moves, and Poke Transfer's +-- items reach the right id on the German cache as on the English one. +-- On an English cache the table is also checked against the cache's own names. + +package.path = './?.lua;' .. package.path +local dir = (arg[1] or ''):gsub('[/\\]*$', '') .. '/' +local PASS, FAIL = 0, 0 +local function check(cond, label) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. label) end +end +local function load_(n) return assert(loadfile(dir .. n))() end + +local items, moves = load_('items.lua'), load_('moves.lua') +local data = { constants = { gen = 4 }, items = items, moves = moves } +local english = items[1] and items[1].name == 'Master Ball' +local Ref = require('src.pokemon.Gen4EnglishRef') +local CG = require('src.pokemon.CrossGen') +local N = require('src.pokemon.Gen4EnglishNames') + +if english then + local bad = 0 + for kind, tbl in pairs({ items = items, moves = moves }) do + for name, id in pairs(N[kind]) do + if Ref.norm(tbl[id] and tbl[id].name) ~= name then bad = bad + 1 end + end + end + check(bad == 0, 'every English name in the table is that id\'s name in the English cache (' .. bad .. ' differ)') +end + +-- held items of a Gen III cartridge (keyed by slug, named in English) +local src = { items = { ORANBERRY = { name = 'ORAN BERRY' }, KINGSROCK = { name = "KING'S ROCK" }, + MASTERBALL = { name = 'MASTER BALL' } }, + moves = { THUNDERBOLT = { name = 'THUNDERBOLT', index = 85 }, TACKLE = { name = 'TACKLE', index = 33 } } } +check(CG.itemId(data, src, 'KINGSROCK') == 221, 'King\'s Rock holds on as item 221') +check(CG.itemId(data, src, 'ORANBERRY') == 155, 'Oran Berry holds on as item 155') +check(CG.itemId(data, src, 'MASTERBALL') == 1, 'a Master Ball stays a Master Ball') +check(CG.moveId(data, src, 'THUNDERBOLT') == 85 and CG.moveId(data, src, 'TACKLE') == 33, 'moves come across by index') +check(CG.itemByName(data, 'POKE BALL') == 4, 'the default ball is a Poke Ball (id 4)') + +-- the Walker: a prize name and a catch's moves +check(CG.itemByName(data, 'Potion') == 17, 'the Walker prize "Potion" is item 17') +check(CG.moveByName(data, 'Sand-attack') == 28, 'Sand-attack is move 28') +check(CG.moveByName(data, 'Poisonpowder') == 77, 'Poisonpowder is move 77') + +-- a Gen IV cartridge as the source (Poke Transfer): a German name does not matter +local srcPl = { gen4 = true, items = { [155] = { name = 'Nicht-Englisch' } } } +local bw = { constants = { gen = 5 }, items = { [900] = { name = 'Oran Berry' } }, moves = {} } +check(CG.itemId(bw, srcPl, 155) == 900, 'a German Platinum item reaches Black/White by its id') + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/gen4_english_ref_gen.lua b/tools/gen4_english_ref_gen.lua new file mode 100644 index 000000000..d913435ef --- /dev/null +++ b/tools/gen4_english_ref_gen.lua @@ -0,0 +1,40 @@ +-- Run: python tools/run_lua_check.py tools/gen4_english_ref_gen.lua /data/generated +-- +-- Writes src/pokemon/Gen4EnglishNames.lua: the ENGLISH name of every Gen 4 move +-- and item next to the cartridge's id, so a lookup that is handed an English +-- name (a Monster Walker prize, a Gen III held item, a Poke Transfer from a +-- cartridge of another language) can reach the right id on a cache in ANY +-- language. Only names and ids; the ids are the cartridge's own (move ids +-- are the National move index of every generation, item ids are Gen IV's). +local dir = (arg[1] or ""):gsub("[/\\]*$", "") .. "/" +local out = assert(arg[2], "output path") + +local function norm(s) return (tostring(s or ""):gsub("é", "e"):gsub("É", "E"):upper():gsub("[^%w]", "")) end +local function load_(n) return assert(loadfile(dir .. n))() end + +local moves, items = load_("moves.lua"), load_("items.lua") +assert(items[1] and items[1].name == "Master Ball", "this needs the ENGLISH cache") + +local function section(tbl, last) + local rows = {} + for id = 1, last do + local d = tbl[id] + if type(d) == "table" and d.name and d.name ~= "" and d.name ~= "-" and not tostring(d.name):find("^%?") then + rows[#rows + 1] = { id = id, n = norm(d.name) } + end + end + return rows +end + +local f = assert(io.open(out, "wb")) +f:write("-- GENERATED by tools/gen4_english_ref_gen.lua -- do not edit.\n") +f:write("-- English move / item names (normalised: upper case, letters and digits\n") +f:write("-- only) -> the Gen IV cartridge id. See src/pokemon/Gen4EnglishRef.lua\n") +f:write("-- (the reader) for what this is for.\n") +f:write("return {\n moves = {\n") +for _, r in ipairs(section(moves, 467)) do f:write((' %s = %d,\n'):format(r.n:find("^%d") and ('["' .. r.n .. '"]') or r.n, r.id)) end +f:write(" },\n items = {\n") +for _, r in ipairs(section(items, 467)) do f:write((' %s = %d,\n'):format(r.n:find("^%d") and ('["' .. r.n .. '"]') or r.n, r.id)) end +f:write(" },\n}\n") +f:close() +print("wrote " .. out) From 76dedef21a28f7f8aa978b7d362007b8d5e255f8 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:26:12 +0100 Subject: [PATCH 41/60] Whiteout: find the nurse/Mom scripts, loss bank and scurry line by shape on a non-English cache --- src/world/Gen4Blackout.lua | 36 ++++++++++++++++++++++++++++++++++- tools/gen4_blackout_check.lua | 14 ++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/world/Gen4Blackout.lua b/src/world/Gen4Blackout.lua index 228497b33..7332ff6be 100644 --- a/src/world/Gen4Blackout.lua +++ b/src/world/Gen4Blackout.lua @@ -86,6 +86,16 @@ function M.lossBank(data) dots = gen4Key(bank, 38), blackedOut = gen4Key(bank, 37) } end end + -- ANOTHER LANGUAGE: the English words are not there, so the bank is told + -- by its shape -- entry 37 (the blackout line) is followed by the three + -- dots at 38, which is the same in every language + for _, bank in ipairs({ 368, 197 }) do + local last, dots = textOf(data, gen4Key(bank, 37)), textOf(data, gen4Key(bank, 38)) + if last and last:find("{STRVAR", 1, true) and dots and dots:find("^%.%.%. %.%.%.") then + return { out = gen4Key(bank, 36), panic = gen4Key(bank, 34), paid = gen4Key(bank, 35), + dots = gen4Key(bank, 38), blackedOut = gen4Key(bank, 37) } + end + end local s = textOf(data, gen5SysKey(15, 57)) if s and s:find("blacked out") then return { out = gen5SysKey(15, 54), panic = gen5SysKey(15, 55), paid = gen5SysKey(15, 56), @@ -118,6 +128,13 @@ function M.scurryLine(data, name, home) if not (s and s:find("scurried")) then s = textOf(data, gen4Key(373, home and 4 or 3)) end -- Platinum if not (s and s:find("scurried")) then s = textOf(data, gen5SysKey(37, home and 3 or 2)) end -- BW if not (s and s:find("scurried")) then + -- another language: the version's own bank, by number + local fam = (not GameVersion.isGen5()) and require("src.import.Gen4Versions").family(GameVersion.get()) or nil + local bank = (fam == "hgss" and 203) or (fam == "platinum" and 373) or nil + local l = bank and textOf(data, gen4Key(bank, home and 4 or 3)) + if l and l:find("{STRVAR", 1, true) then s = l end + end + if not (s and (s:find("scurried") or s:find("{STRVAR", 1, true))) then s = home and "{PLAYER} scurried back home, protecting the exhausted and fainted Pokémon from further harm..." or "{PLAYER} scurried to a Pokémon Center, protecting the exhausted and fainted Pokémon from further harm..." end @@ -206,6 +223,22 @@ function M.whiteoutLabel(data, home) local s = textOf(data, gen4Key(bank, i)) if s and s:find("^First, let.-s restore your Pok") then nurseIndex = i break end end + local englishNurse = nurseIndex ~= nil + -- ANOTHER LANGUAGE: the nurse's line cannot be found by its English words, + -- so find it by shape. The house's script is the common-band entry that + -- heals the party after a message (Mom's), and the Center's first line is + -- the one three after Mom's -- the same relation the English search uses. + if not nurseIndex then + for _, label in ipairs(band.entries) do + local s = scripts[label] + local first, healed + for _, ins in ipairs((s and s.instructions) or {}) do + if ins.name == "message" and ins.args and first == nil then first = ins.args[1] end + if ins.name == "healparty" then healed = true end + end + if healed and tonumber(first) then nurseIndex = tonumber(first) + 3 break end + end + end if not nurseIndex then return nil end local function has(label, index, needHeal) local s = scripts[label] @@ -219,7 +252,8 @@ function M.whiteoutLabel(data, home) for _, label in ipairs(band.entries) do if home then local mom = textOf(data, gen4Key(bank, nurseIndex - 3)) - if mom and mom:find("^Mom") and has(label, nurseIndex - 3, true) then return label end + -- (English: the line starts "Mom"; another language: the heal in it says so) + if mom and (mom:find("^Mom") or not englishNurse) and has(label, nurseIndex - 3, true) then return label end elseif has(label, nurseIndex) then return label end diff --git a/tools/gen4_blackout_check.lua b/tools/gen4_blackout_check.lua index 693adede4..dd3eb3466 100644 --- a/tools/gen4_blackout_check.lua +++ b/tools/gen4_blackout_check.lua @@ -74,8 +74,18 @@ if not pt then SKIP = SKIP + 1; print("skip: platinum") else check(B.whiteoutLabel(pt, false) == "M0211/S103B", "Pt: the Center's whiteout script") check(B.whiteoutLabel(pt, true) == "M0211/S0FCB", "Pt: the house's whiteout script") local lines = B.lossLines(pt, "LUCAS", 40, false) - check(lines and #lines == 4 and lines[2]:find("dropped %$40"), "Pt: the loss lines are bank 368's") - check(B.scurryLine(pt, "LUCAS", false):find("^LUCAS scurried to"), "Pt: the Center line") + -- the cartridge's language: the English words are asserted on an English + -- cache; any other language asserts the structure (four lines, the money + -- and the name in them) + local english = (pt.text.TEXT_B0213_00043 or ""):find("^First") ~= nil + if english then + check(lines and #lines == 4 and lines[2]:find("dropped %$40"), "Pt: the loss lines are bank 368's") + check(B.scurryLine(pt, "LUCAS", false):find("^LUCAS scurried to"), "Pt: the Center line") + else + check(lines and #lines == 4 and lines[2]:find("40") and lines[4]:find("LUCAS"), "Pt (non-English): the loss lines are bank 368's, with the money and the name") + local sc = B.scurryLine(pt, "LUCAS", false) + check(sc and sc:find("LUCAS", 1, true), "Pt (non-English): the Center line names the player") + end local n = 0 for _, row in ipairs(pt.field.healLocations or {}) do if row.blackOut and B.spawnFor(pt, row.blackOut.map) then n = n + 1 end From d4ea1d5a4cd649cd3dee0d332540e59e948b8d6e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:28:03 +0100 Subject: [PATCH 42/60] Gen 4 playthrough: walk Renegade Platinum's story edits through Spear Pillar Renegade-only beat changes (retail's path is untouched): - the training cheat leads with a level-100 Mewtwo and keeps a second able Pokemon (Floaroma Meadow's grunts are one double battle that refuses a party of one; Maylene's gym trainers outlast a level-75 starter) - Pastoria: Crasher Wake is met on Route 212 first (the gym guide stands on Barry's coord cell until he returns) - Canalave gate: the policeman wants Pal Park's Tea (Oak, then Darach's battle); the Pokedex-upgrade scientist is gone - Iron Island: HM04 comes after Riley's tag battle inside the island - Mt. Coronet 2F: the Strength boulder is gone runner.lua: PT_BATTLELOG=1 prints every battle line. --- tools/gen4_playthrough/platinum.lua | 83 +++++++++++++++++++++++++++-- tools/gen4_playthrough/runner.lua | 14 +++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 886928ac5..47541e342 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -58,10 +58,23 @@ local function badges(r) return n end +-- RENEGADE PLATINUM (VERSION=renegadeplatinum) edits the story in places; +-- each beat that differs says so where it does. Retail's path is untouched. +local renegade = os.getenv("VERSION") == "renegadeplatinum" + -- A CHEAT, said as one (heartgold.lua's): the lead is rebuilt at level 75, -- healed, with `move` first, so the runner's A-mashing (the first move, every -- turn) wins the story battles. The story is under test here, not the battles. +-- +-- RENEGADE turns several story fights into DOUBLE battles that refuse a +-- party of one (Floaroma Meadow's grunts: `getpartycount`, "you only have +-- one Pokémon?", pushed back), so there the cheat also keeps a second able +-- Pokémon behind the lead -- a copy of it -- when the party has only one. +-- And its trainers are far stronger (Maylene's gym: full level-35 teams with +-- coverage, no heal between the three fights), so there the lead is the +-- Mewtwo at 100 that retail only needs from Canalave on. local function trainUpWith(move, level, species) + if renegade then species, level = species or 150, math.max(level or 75, 100) end return function(r) local party = r:party() local lead = party[1] @@ -73,6 +86,14 @@ local function trainUpWith(move, level, species) table.insert(mon.moves, 1, { id = move or 94, pp = 64 }) while #mon.moves > 4 do table.remove(mon.moves) end party[1] = mon + if renegade and #party < 2 then + local second = Pokemon.new(r.game.data, species or lead.species, level or 75) + second.otName, second.otId = lead.otName, lead.otId + second.moves = second.moves or {} + table.insert(second.moves, 1, { id = move or 94, pp = 64 }) + while #second.moves > 4 do table.remove(second.moves) end + party[2] = second + end end end -- Surf by default: it hits both foes of a double battle (Floaroma Meadow's @@ -261,7 +282,6 @@ B[#B + 1] = { name = "the parcel", -- level-5 starter to meet it), so a lone starter loses and blacks out. The -- lead is trained up first there, as every later beat does; retail keeps the -- demo and no cheat. -local renegade = os.getenv("VERSION") == "renegadeplatinum" local tutorialSteps = { { "teleport", "R202", 21, 27, "left" }, { "activecoord" }, { "until", varGe(0x4087, 1), 30000 } } if renegade then table.insert(tutorialSteps, 1, { "do", trainUpWith(57) }) end @@ -594,9 +614,22 @@ B[#B + 1] = { name = "hm02 fly", -- PASTORIA (scripts_pastoria_city.s). Coord 2 (13,28 while -- VAR_PASTORIA_CITY_STATE 0x407C == 1), in front of the gym door: Barry's -- battle, 0x407C = 2 +-- +-- RENEGADE PLATINUM puts the Gym Guide ON that cell (C06 person 29, shown +-- while flag 0xA08 is clear: "Wake ... was heading out west to Route 212") +-- and Crasher Wake on Route 212 south (R212B person 44): its coord trigger +-- ((10..13,5) while 0x406E == 0) is his song, he heads back, flag 0xA08 set +-- (the guide steps aside), 0x406E = 1. So he is met there first. +local pastoriaBarry = { { "do", trainUp }, { "teleport", "C06", 13, 30, "up" }, { "activecoord" }, + { "until", varGe(0x407C, 2), 60000 } } +if renegade then + for i, s in ipairs({ { "teleport", "R212B", 11, 7, "up" }, { "coord", 1 }, + { "until", flag(0xA08), 20000 } }) do + table.insert(pastoriaBarry, 1 + i, s) + end +end B[#B + 1] = { name = "pastoria: barry", - steps = { { "do", trainUp }, { "teleport", "C06", 13, 30, "up" }, { "activecoord" }, - { "until", varGe(0x407C, 2), 60000 } }, + steps = pastoriaBarry, expect = varGe(0x407C, 2), why = "0x407C >= 2 (Barry beaten)", expectBudget = 8000 } -- PASTORIA GYM, WALKED (src/world/Gen4PastoriaGym.lua): the water floor @@ -700,10 +733,31 @@ B[#B + 1] = { name = "celestic: cynthia", -- CANALAVE. The gate (R218R0201) coord 1 (7,5..9 while -- VAR_ROUTE_218_GATE_TO_CANALAVE_CITY_STATE 0x4105 == 0): the Pokédex -- upgrade, 0x4105 = 1 +-- +-- RENEGADE PLATINUM drops the scientist and closes the gate: a thirsty +-- policeman (person 0) and a new coord trigger ((2,5..9) while 0x406C == 0) +-- that pushes the player back unless the bag holds the TEA (item 456, the +-- S.S. Ticket's slot); with it he drinks it and 0x406C = 1. The Tea is +-- Pal Park's (R221R0101, as his "take a look at that fancy Pal Park" says): +-- Prof. Oak (person 6) first (flag 0xA0B, he leaves), then Darach (person 5) +-- asks for a battle (trainer 969, six level-48s: Thunderbolt hits them all +-- at least neutrally) and Lady Caitlin hands over the Tea (flag 0xA0C). +if renegade then + B[#B + 1] = { name = "pal park: the tea", budget = 90000, + steps = { { "do", trainUpWith(85) }, { "teleport", "R221R0101", 7, 17, "up" }, + { "talk", 6 }, { "until", flag(0xA0B), 20000 }, + { "talk", 5 }, { "until", flag(0xA0C), 60000 } }, + expect = flag(0xA0C), why = "flag 0xA0C (Darach beaten, the Tea)", expectBudget = 8000 } + B[#B + 1] = { name = "canalave gate: the policeman's tea", + steps = { { "teleport", "R218R0201", 9, 7, "left" }, { "activecoord" }, + { "until", varGe(0x406C, 1), 30000 } }, + expect = varGe(0x406C, 1), why = "0x406C >= 1 (the Tea drunk, the gate open)", expectBudget = 8000 } +else B[#B + 1] = { name = "canalave gate: the scientist", steps = { { "teleport", "R218R0201", 9, 7, "left" }, { "activecoord" }, { "until", varGe(0x4105, 1), 30000 } }, expect = varGe(0x4105, 1), why = "0x4105 >= 1", expectBudget = 8000 } +end -- Canalave coord 1 (15,19..22 while VAR_CANALAVE_CITY_STATE 0x4078 == 0) on -- the bridge: Barry's battle, 0x4078 = 1 @@ -748,6 +802,22 @@ B[#B + 1] = { name = "iron island: riley", { "until", flag(0x214), 30000 } }, expect = flag(0x214), why = "flag 0x214 (Riley gave HM04)", expectBudget = 8000 } +-- RENEGADE PLATINUM: Riley at the entrance BATTLES the player instead +-- (trainer 970) and keeps the HM. HM04 is his thanks after the tag battle +-- deep in the island (D24R0105, as retail's egg scene): coord 1 ((35,2..3) +-- while 0x4092 == 0) has him join as the partner (0x4092 = 1), coord 4 +-- ((21,40..41) while 0x4092 == 1) is the two grunts (trainers 835, 836: +-- Golbat, Houndoom, Muk, Purugly, Toxicroak, Skuntank -- Thunderbolt), then +-- HM04 and 0x4092 = 2. (Coord 2, east of him at x 36, turns a partnered +-- player back, so the walk starts west of it.) +if renegade then + B[#B + 1] = { name = "iron island: riley's tag battle", budget = 90000, + steps = { { "do", trainUpWith(85) }, { "teleport", "D24R0105", 36, 3, "left" }, { "coord", 1 }, + { "until", function(r) return r:var(0x4092) >= 1 and not r:busy() end, 20000 }, + { "coord", 4 }, { "until", varGe(0x4092, 2), 60000 } }, + expect = varGe(0x4092, 2), why = "0x4092 >= 2 (the grunts beaten, HM04)", expectBudget = 8000 } +end + -- Barry at the library door (C02 person 18) lets the player by once HM04 is -- in the bag and goes in (FLAG_HIDE_CANALAVE_CITY_RIVAL_LIBRARY 0x2CC); 3F's -- frame table (0x40B2 == 1) is Rowan's story and the Lake Valor explosion: @@ -924,6 +994,13 @@ local coronet = { { "do", fullTeam(58) }, { "do", hmMoves({ [2] = 70, [3] = 249, { "hold", "down", 900, ["until"] = function(r) local _, y = r:pos() return y >= 49 end }, { "activecoord" }, { "until", varGe(0x40A9, 2), 20000 }, { "panel", 7, 23 }, { "warpto", "D05R0103", 11, 4 }, { "warpto", "D05R0105" }, { "walkto", 31, 23 } } +-- RENEGADE PLATINUM takes 2F's boulder out (person 8 is gone): the column +-- is walked, nothing pushed +if renegade then + -- steps 5..13: the walk up to it, the push and the three holds + for _ = 5, 13 do table.remove(coronet, 5) end + assert(coronet[5][1] == "activecoord") +end for _, s in ipairs({ climbTo("up", function(r) local _, y = r:pos() return y <= 19 end) }) do coronet[#coronet + 1] = s end for _, s in ipairs({ { "warpto", "D05R0106" }, { "walkto", 7, 17 } }) do coronet[#coronet + 1] = s end for _, s in ipairs({ climbTo("up", function(r) local _, y = r:pos() return y <= 12 end) }) do coronet[#coronet + 1] = s end diff --git a/tools/gen4_playthrough/runner.lua b/tools/gen4_playthrough/runner.lua index d7e4953bc..672f49e00 100644 --- a/tools/gen4_playthrough/runner.lua +++ b/tools/gen4_playthrough/runner.lua @@ -934,6 +934,20 @@ function R:tick(i, game, ow, planStep) end local b = self:currentBeat() self.beatT = self.beatT + 1 + -- PT_BATTLELOG=1: every line a battle's box types, as the player reads it + -- (a story battle that is lost: what each side did) + if os.getenv("PT_BATTLELOG") then + local top = self:top() + if top and top.battle and top.startMessage and not top.__ptLogged then + top.__ptLogged = true + local orig = top.startMessage + top.startMessage = function(selfB, item) + local t = tostring(item and item.text) + print(("[pt %6d] [bmsg] %s"):format(self.t, (t:gsub("\n", " / "):gsub("\v", " ")))) + return orig(selfB, item) + end + end + end if os.getenv("PT_DEBUG") and i % 500 == 0 then local x, y = self:pos() local busy, why = self:busy() From 6fc585185af3bb9529d7d7e71a528835d9c29645 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:28:08 +0100 Subject: [PATCH 43/60] Randomizer: protected/banned move, item and trainer-class lists read through cartridge ids on a non-English Gen 4 cache --- src/runmodes/RandomData.lua | 59 +++++++++++++++++++-- src/runmodes/RunHooks.lua | 2 +- tools/gen4_randomizer_language_check.lua | 67 ++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tools/gen4_randomizer_language_check.lua diff --git a/src/runmodes/RandomData.lua b/src/runmodes/RandomData.lua index ea964f70e..849a8738e 100644 --- a/src/runmodes/RandomData.lua +++ b/src/runmodes/RandomData.lua @@ -44,6 +44,32 @@ local function norm(s) end RandomData._norm = norm +-- THE ENGLISH NAME OF A GEN IV MOVE / ITEM, BY ITS ID. +-- The protected / banned lists below are written as English names. A Gen IV +-- cache in another language (German Platinum) names everything in that +-- language, so before a pass reads a name it `prime`s the dataset: every +-- Gen IV move and item record is mapped to its English name through the +-- cartridge id (src/pokemon/Gen4EnglishRef.lua). Other datasets keep the +-- names they have. +local ENGLISH = setmetatable({}, { __mode = "k" }) +local primed = setmetatable({}, { __mode = "k" }) +local function prime(data) + if type(data) ~= "table" or primed[data] then return end + primed[data] = true + local Ref = require("src.pokemon.Gen4EnglishRef") + if not Ref.isGen4(data) then return end + for _, kind in ipairs({ "moves", "items" }) do + for key, def in pairs(data[kind] or {}) do + local n = type(def) == "table" and (tonumber(key) or tonumber(tostring(key):match("^ITEM_(%d+)$"))) + local english = n and Ref.englishName(kind, n) + if english then ENGLISH[def] = english end + end + end +end +RandomData._prime = prime +local function englishOf(def, id) return ENGLISH[def] or norm(type(def) == "table" and def.name or id) end +RandomData._english = englishOf + local function sortedKeys(t) local ks = {} for k in pairs(t) do ks[#ks + 1] = k end @@ -134,7 +160,7 @@ local function validMove(id, m) if type(m) ~= "table" then return false end if id == 0 or id == "0" or id == "NO_MOVE" or id == "NONE" then return false end if (tonumber(m.pp) or 0) <= 0 then return false end - local n = norm(m.name or id) + local n = englishOf(m, id) if n == "" or n == "STRUGGLE" then return false end return true end @@ -153,6 +179,7 @@ RandomData.isDamaging = isDamaging -- never move a move between the damaging and status sets) local moveCache = setmetatable({}, { __mode = "k" }) function RandomData.moveInfo(data) + prime(data) local moves = data and data.moves if type(moves) ~= "table" then return { all = {}, attacks = {}, good = {}, damaging = {} } end local hit = moveCache[moves] @@ -166,7 +193,7 @@ function RandomData.moveInfo(data) info.damaging[id] = true info.attacks[#info.attacks + 1] = id local acc = tonumber(m.accuracy) or 100 - if not BAD_ATTACK[norm(m.name or id)] and (acc == 0 or acc >= 50) then + if not BAD_ATTACK[englishOf(m, id)] and (acc == 0 or acc >= 50) then info.good[#info.good + 1] = id end end @@ -210,7 +237,7 @@ local PROTECT = { "FRESHWATER", "SODAPOP", "LEMONADE", "FOSSIL", "AMBER", "PARCE "MASTERBALL", "RARECANDY" } local PROTECT_EXACT = { MOONSTONE = false } -local function itemName(it, id) return norm(type(it) == "table" and it.name or id) end +local function itemName(it, id) return englishOf(it, id) end local function isMachine(it, kind) local m = type(it) == "table" and it.machine @@ -232,6 +259,7 @@ end -- an item the randomizer never replaces (and never hands out) function RandomData.protectedItem(data, id) + prime(data) local it = data and data.items and data.items[id] if not it then return true end if isKey(it) or isMachine(it, "HM") then return true end @@ -276,6 +304,7 @@ end local itemCache = setmetatable({}, { __mode = "k" }) -- { general = {ids}, tms = {ids}, held = {ids}, balls = {[id]=true} } function RandomData.itemInfo(data) + prime(data) local items = data and data.items if type(items) ~= "table" then return { general = {}, tms = {}, held = {}, balls = {} } end local hit = itemCache[items] @@ -382,7 +411,7 @@ local function movesPass(data, rm, opt, report) local m = moves[id] if validMove(id, m) and not done[m] then done[m] = true - local name = norm(m.name or id) + local name = englishOf(m, id) local rng = Rng(rm.seed, "move", tostring(id)) local dmg = isDamaging(m) local power = tonumber(m.power) or 0 @@ -848,6 +877,25 @@ local function keepClass(className) return false end +-- PLATINUM'S TRAINER CLASSES THE RANDOMIZER KEEPS, BY CLASS ID (the Leaders, +-- Elite Four, Champion, Galactic admins and boss, Tower Tycoon, Factory Head, +-- the Ace Trainers and the Scientist -- what KEEP_CLASS's words pick out of +-- the English class names). A class id is the same on every cartridge +-- language; the name is not ("Arenaleiter"). +local KEEP_CLASS_ID_PLATINUM = {} +for _, id in ipairs({ 0, 24, 25, 41, 53, 54, 62, 63, 64, 65, 66, 67, 68, 69, 72, 73, 74, 75, + 76, 77, 78, 79, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 100 }) do + KEEP_CLASS_ID_PLATINUM[id] = true +end + +local function keepTrainer(data, t) + local c = data.constants + if type(c) == "table" and c.gen == 4 and data.gen4_version == nil and tonumber(t.class) then + return KEEP_CLASS_ID_PLATINUM[tonumber(t.class)] == true + end + return keepClass(t.className) +end + local function trainerNamesPass(data, rm, report) local trainers = data.trainers if type(trainers) ~= "table" then return end @@ -856,7 +904,7 @@ local function trainerNamesPass(data, rm, report) local groups = {} for _, id in ipairs(sortedKeys(trainers)) do local t = trainers[id] - if type(t) == "table" and type(t.name) == "string" and t.className ~= nil and not keepClass(t.className) then + if type(t) == "table" and type(t.name) == "string" and t.className ~= nil and not keepTrainer(data, t) then local nm = norm(t.name) if nm ~= "" and #t.name > 1 then local key = tostring(t.className) @@ -953,6 +1001,7 @@ end -- apply every data-side option of runModes `rm` to `data` (from the -- original values: restore first) function RandomData.apply(data, rm) + prime(data) local report = {} if type(data) ~= "table" or type(rm) ~= "table" or rm.seed == nil then return report end local opt = {} diff --git a/src/runmodes/RunHooks.lua b/src/runmodes/RunHooks.lua index 025dfdf6f..776d1182f 100644 --- a/src/runmodes/RunHooks.lua +++ b/src/runmodes/RunHooks.lua @@ -474,7 +474,7 @@ function RunHooks.martStock(game, stock) for i, id in ipairs(stock) do local it = data.items and data.items[id] local keep = type(id) == "table" or not it or RandomData.protectedItem(data, id) - or ESSENTIAL[RandomData._norm(it.name or id)] + or ESSENTIAL[RandomData._english(it, id)] local new = id if not keep then for try = 1, 8 do diff --git a/tools/gen4_randomizer_language_check.lua b/tools/gen4_randomizer_language_check.lua new file mode 100644 index 000000000..844911511 --- /dev/null +++ b/tools/gen4_randomizer_language_check.lua @@ -0,0 +1,67 @@ +-- Run: python tools/run_lua_check.py tools/gen4_randomizer_language_check.lua /data/generated +-- +-- THE RANDOMIZER'S PROTECTED LISTS ON A CACHE IN ANY LANGUAGE +-- (src/runmodes/RandomData.lua). The lists are written as English names; +-- a German cache names its moves and items in German, so they are read through +-- the cartridge ids (Gen4EnglishRef). The same assertions hold on both caches. + +package.path = './?.lua;' .. package.path +if not _G.love then _G.love = require('tests.love_stub') end +local dir = (arg[1] or ''):gsub('[/\\]*$', '') .. '/' +local PASS, FAIL = 0, 0 +local function check(cond, label) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. label) end +end +local function load_(n) return assert(loadfile(dir .. n))() end + +local RD = require('src.runmodes.RandomData') +local data = { constants = { gen = 4 }, items = load_('items.lua'), moves = load_('moves.lua'), + trainers = load_('trainers.lua') } + +-- items: Master Ball 1, Fresh Water 30, Soda Pop 31, Lemonade 32, Rare Candy 50 +-- are story / protected; a Potion (17) and an Oran Berry are not +for _, id in ipairs({ 1, 30, 31, 32, 50 }) do + check(RD.protectedItem(data, id), 'item ' .. id .. ' is protected') +end +check(not RD.protectedItem(data, 17), 'a Potion is not protected') +check(not RD.protectedItem(data, 155), 'an Oran Berry is not protected') + +-- moves: Selfdestruct 120, Explosion 153, Counter 68, Struggle 165 never come out of the "good" pool +local info = RD.moveInfo(data) +local good = {} +for _, id in ipairs(info.good) do good[id] = true end +for _, id in ipairs({ 120, 153, 68, 243, 90 }) do check(not good[id], 'move ' .. id .. ' is not a good random attack') end +check(good[33] and good[85], 'Tackle and Thunderbolt are good attacks') + +-- the item pools +local items = RD.itemInfo(data) +local general = {} +for _, id in ipairs(items.general) do general[id] = true end +check(general[17] and not general[50] and not general[30], 'Potion is in the pool, Rare Candy and Fresh Water are not') + +-- trainer names: the Gym Leaders (class ids) keep theirs, a Youngster-type does not need to +local leaderName, leaderId +for id, t in pairs(data.trainers) do + if type(t) == 'table' and t.class == 62 then leaderId, leaderName = id, t.name break end +end +local before = {} +for id, t in pairs(data.trainers) do if type(t) == 'table' then before[id] = t.name end end +local RandomOptions = require('src.runmodes.RandomOptions') +local rm = { seed = 12345, random = { trainerNames = true } } +local ok, why = pcall(RD.apply, data, rm) +check(ok, 'apply runs (' .. tostring(why) .. ')') +local changed, keptLeaders = 0, true +for id, t in pairs(data.trainers) do + if type(t) == 'table' and before[id] ~= t.name then + changed = changed + 1 + if t.class == 62 or t.class == 64 or (t.class and t.class >= 74 and t.class <= 79) or (t.class and t.class >= 65 and t.class <= 69) then + keptLeaders = false + end + end +end +check(keptLeaders, 'Leaders, the Elite Four and the Champion keep their names') +check(changed > 0, 'other trainers were renamed (' .. changed .. ')') +pcall(RD.restore) + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end From 8683023567d27ac1a1dafb0d16396aaf09fc327e Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:30:26 +0100 Subject: [PATCH 44/60] German: hero default names from the cartridge's name bank; ROM language recorded in the cache for the translation layer --- src/import/RomExtractorGen4.lua | 8 ++++++++ src/import/RomImporter.lua | 2 +- src/script/Gen4Commands.lua | 6 ++++-- src/script/Gen4DefaultNames.lua | 23 +++++++++++++++++++++++ src/translation/Languages.lua | 19 ++++++++++++++++++- src/ui/Gen4RowanIntro.lua | 2 +- 6 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 src/script/Gen4DefaultNames.lua diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index 7b628c09f..e711e9d7e 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -7616,6 +7616,14 @@ function RomExtractorGen4:runStages(list) if not listed then skipped[#skipped + 1] = name end end self.notPorted = skipped + -- THE CARTRIDGE'S TEXT LANGUAGE, for what has to know it (the translation + -- layer must not treat German ROM text as English). Only a non-English + -- cartridge writes it, so an English cache is unchanged. + if not self:isEnglish() then + local Regions = require("src.import.Gen4Regions") + local code = self.rom and self.rom:header().gameCode + self:write("gen4_language", { language = Regions.language(code), gameCode = code }) + end self:write("gen4_version", { version = self.version, family = require("src.import.Gen4Versions").family(self.version), stages = list, notPorted = skipped }) return self.wrote diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 6a921ee6e..5f736cb45 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -8873,7 +8873,7 @@ function RomImporter:_settingsRows() -- No ROM TEXT LANGUAGE row: the ROM's language is known (Languages.romLanguage), -- and a row offering it let French -> French jobs leave the text in English. add({kind="section",label="THIS GAME: TEXT TRANSLATION", - note="Applies to "..tostring(translationVersion)..". The ROM's text is English. Play after translation finishes; ORIGINAL ROM TEXT restores the imported wording."}) + note="Applies to "..tostring(translationVersion)..". The ROM's text is "..(function() local c=languages.romLanguage(translationVersion);for _,v in ipairs(languages)do if v[1]==c then return v[2] end end;return "English" end)()..". Play after translation finishes; ORIGINAL ROM TEXT restores the imported wording."}) add({kind="option",scope="translation",version=translationVersion,label="LANGUAGE", row={id="target",values=values,labels=labels}}) add({kind="action",label="TRANSLATE THIS GAME",value="START",action="translateGame",version=translationVersion}) diff --git a/src/script/Gen4Commands.lua b/src/script/Gen4Commands.lua index 4b47e9cdd..2c2e876b6 100644 --- a/src/script/Gen4Commands.lua +++ b/src/script/Gen4Commands.lua @@ -476,7 +476,9 @@ local function defaultName(data, kind) local presets = boot and boot.namePresets and boot.namePresets.player local first = presets and presets[1] if type(first) == "table" then first = first.label end - return (type(first) == "string" and first ~= "") and first or nil + if type(first) == "string" and first ~= "" then return first end + -- the cartridge's own default (boy's entry; the player's gender is not known here) + return require("src.script.Gen4DefaultNames").hero(data, false) end -- FORWARD-DECLARED, and the reason is a crash this file shipped with. @@ -3327,7 +3329,7 @@ function Commands.g4_catching_tutorial(ctx) local save = {} for k, v in pairs(game.save) do save[k] = v end local female = game.save.player and game.save.player.gender == "girl" - save.player = { name = female and "Lucas" or "Dawn", gender = female and "boy" or "girl" } + save.player = { name = require("src.script.Gen4DefaultNames").hero(game.data, not female), gender = female and "boy" or "girl" } save.party = { require("src.pokemon.Pokemon").new(game.data, counterpartStarter(playerStarter(game.save)), 5) } save.pokedex = { seen = {}, owned = {} } diff --git a/src/script/Gen4DefaultNames.lua b/src/script/Gen4DefaultNames.lua new file mode 100644 index 000000000..47abe779e --- /dev/null +++ b/src/script/Gen4DefaultNames.lua @@ -0,0 +1,23 @@ +-- THE CARTRIDGE'S OWN DEFAULT NAMES for the hero. +-- +-- Platinum's name-entry bank (text bank 427) holds the preset names: +-- entry 42 is the boy's default and 43 the girl's ("Lucas" / "Dawn" on the +-- US cartridge, "Lucius" / "Lucia" on the German one). Before this the engine +-- spelled the English pair itself, so a German game greeted its hero as +-- "Lucas". A cache without the bank (HeartGold / SoulSilver, which number +-- their banks differently, or an old cache) keeps the English pair. + +local M = {} + +M.BANK = 427 +M.BOY, M.GIRL = 42, 43 + +function M.hero(data, girl) + local text = data and data.text + local isPlatinum = data and data.gen4_version == nil + local s = isPlatinum and text and text[("TEXT_B%04d_%05d"):format(M.BANK, girl and M.GIRL or M.BOY)] + if type(s) == "string" and s ~= "" and #s <= 8 then return s end + return girl and "Dawn" or "Lucas" +end + +return M diff --git a/src/translation/Languages.lua b/src/translation/Languages.lua index 10dd4891a..3df27df07 100644 --- a/src/translation/Languages.lua +++ b/src/translation/Languages.lua @@ -31,11 +31,28 @@ function L.resolve(options,version) if target==source then target="original" end return target,source end +L._romLang={} function L.romLanguage(version) local ok,GameVersion=pcall(require,"src.core.GameVersion") local info=ok and version and GameVersion.info and GameVersion.info(version) local lang=type(info)=="table" and info.textLanguage - return type(lang)=="string" and lang or "en" + if type(lang)=="string" then return lang end + -- a non-English Gen 4 cartridge (German Platinum) records its text language + -- in its cache (gen4_language.lua); without it German ROM text would be + -- sent for translation as if it were English + if type(info)=="table" and info.cachePrefix then + if L._romLang[version] then return L._romLang[version] end + local okA,Art=pcall(require,"src.pokewalker.WalkerArt") + local src=okA and Art._read and Art._read(info.cachePrefix,"data/generated/gen4_language.lua") + local chunk=src and (loadstring or load)(src,"=gen4_language") + local okC,rec=false,nil + if chunk then okC,rec=pcall(chunk) end + if okC and type(rec)=="table" and type(rec.language)=="string" then + L._romLang[version]=rec.language + return rec.language + end + end + return "en" end function L.font(target) if target=="ja" or target=="zh" then diff --git a/src/ui/Gen4RowanIntro.lua b/src/ui/Gen4RowanIntro.lua index b01458155..7bb80b191 100644 --- a/src/ui/Gen4RowanIntro.lua +++ b/src/ui/Gen4RowanIntro.lua @@ -344,7 +344,7 @@ function Gen4RowanIntro:askName() local boot = self.game.data.field and self.game.data.field.boot local presets = (boot and boot.namePresets and boot.namePresets.player) or nil if not presets or #presets == 0 then - presets = { self.answers.gender == "girl" and "Dawn" or "Lucas" } + presets = { require("src.script.Gen4DefaultNames").hero(self.game.data, self.answers.gender == "girl") } end local maxLen = (self.game.data.constants or {}).playerNameLength or 7 Screens.push(self.game, "NamingScreen", { From 0d2fe75e3ccb8bd49f4ab150102c38e581b878a2 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:31:15 +0100 Subject: [PATCH 45/60] Field sweep: driver batch fix, seeded engine RNG, failure diagnostics, skip menus behind long scenes --- tools/gen4_field_sweep.py | 3 ++ tools/gen4_field_sweep_harness/main.lua | 37 +++++++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tools/gen4_field_sweep.py b/tools/gen4_field_sweep.py index 9b7037609..150a7d26a 100644 --- a/tools/gen4_field_sweep.py +++ b/tools/gen4_field_sweep.py @@ -130,6 +130,9 @@ def main(): while pending: batch = pending[: args.batch] benv = dict(env, MAPS=",".join(batch)) + # the list was cut once, by the listing run; a batch is an explicit list + for key in ("SHARD", "SKIP", "LIMIT", "MAP_PREFIX"): + benv.pop(key, None) if args.menus == "once": benv["MENUS"] = "once" if menus_pending else "off" lines, timed_out = run_harness(args.love, benv, args.timeout) diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index 1cf9da595..81f3014c3 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -181,15 +181,16 @@ end -- suspect, and the caller recovers). -- the last stack changes and the keys pressed with them: what a failure needs -- to be replayed by hand +local RING = 240 local ring, ringAt, lastStack = {}, 0, "" local function ringNote(line) - ringAt = ringAt % 40 + 1 + ringAt = ringAt % RING + 1 ring[ringAt] = line end local function ringDump(n) local out = {} for i = 1, math.min(n or 14, #ring) do - local at = (ringAt - i) % 40 + 1 + local at = (ringAt - i) % RING + 1 if ring[at] then table.insert(out, 1, ring[at]) end end return table.concat(out, " ;; ") @@ -226,7 +227,8 @@ local function tick() -- script's) is the battle agent's: take it off and let the field finish the -- way a win would, which is the field's half of every battle local t = ok and top() - if t and t ~= OW and BattleStateClass and getmetatable(t) == BattleStateClass then + if t and t ~= OW and BattleStateClass + and (getmetatable(t) == BattleStateClass or tostring(describeTop()):find("BattleState", 1, true)) then battles = battles + 1 Game.stack:pop() local okB, errB = xpcall(OW.afterBattle, debug.traceback, OW, "win", t) @@ -512,6 +514,12 @@ local function shallow(t) end table.sort(sets) if #sets > 0 then parts[#parts + 1] = "{" .. table.concat(sets, ",", 1, math.min(#sets, 30)) .. "}" end + -- a text box: the words it is on, and whether it is asking something + if type(t.pages) == "table" and t.pageIndex and t.pages[t.pageIndex] then + local page = t.pages[t.pageIndex] + local words = type(page) == "table" and table.concat(page, " / ") or tostring(page) + parts[#parts + 1] = ("text=%q choice=%s auto=%s"):format(words:sub(1, 60), tostring(t.choice ~= nil), tostring(t.auto ~= nil)) + end return table.concat(parts, ",") end @@ -884,9 +892,11 @@ local function closeScreens(label, floorDepth) elseif i <= 300 then if i % 5 == 0 then press("a") end else - local m = i % 6 - if m == 0 then press("b") elseif m == 2 then press("a") - elseif m == 4 then press(({ "up", "down", "left", "right" })[rnd(4)]) end + -- not a fixed cycle: B-then-A in lockstep can answer NO to a yes/no + -- every time (the move-learn prompt re-asks on NO) + if i % 3 == 0 then + press(({ "a", "a", "b", "up", "down", "left", "right", "a", "b" })[rnd(9)]) + end end end, function() return stackDepth() <= floorDepth end) if not ok then return false end @@ -897,7 +907,7 @@ local function closeScreens(label, floorDepth) names[#names + 1] = okD and tostring(d) or "?" end fail("softlock", ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s | recent: %s"):format(label, - describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(16))) + describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(tonumber(getenv('RING_DUMP') or '') or 16))) while stackDepth() > floorDepth do Game.stack:pop() end return false end @@ -944,7 +954,13 @@ local function menuPass() -- a map that opens on a scene: read it out (A / B) before the menu is asked for if not idle() then local r = drain("menu arrival scene") - if r ~= true then recover(OW.map.id, OW.player.cellX, OW.player.cellY) end + if r ~= true then + -- a cutscene longer than the budget: the menus are not reachable until it + -- ends, which is the game working -- say so and move on + emit(("SWEEP_INFO|%s|menu_skipped|the arrival scene outlasted the budget (%s)"):format(cur.map, tostring(r))) + recover(OW.map.id, OW.player.cellX, OW.player.cellY) + return false + end end local floor = openMenu("start menu") if not floor then return end @@ -1159,7 +1175,7 @@ end local function menusFull() tally = { party = 0, bag = 0 } - menuPass() + if menuPass() == false then return end if not idle() then drain("menus") end if idle() then partyPass() end if idle() then bagPass() end @@ -1274,6 +1290,9 @@ local function sweepMap(id, menusHere) for i = 1, #id do h = (h * 31 + id:byte(i)) % 2147483647 end rngState = h cur.hash = h + -- the game's own randomness (encounters, wild natures, NPC wandering) too + math.randomseed(h) + if love.math and love.math.setRandomSeed then pcall(love.math.setRandomSeed, h) end local def = Data.maps[id] if not def then fail("unknown_map", "no such map in the cache") From b4034a9b53b61adce838fbecf8fe68b6547da00d Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:31:23 +0100 Subject: [PATCH 46/60] Gen 4 abilities: berries by pocket --- src/battle/Gen45Abilities.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen45Abilities.lua b/src/battle/Gen45Abilities.lua index ea9fdd05c..47dd27f9f 100644 --- a/src/battle/Gen45Abilities.lua +++ b/src/battle/Gen45Abilities.lua @@ -610,7 +610,10 @@ function M.unovaAfterHit(battle, attacker, target, move, contact) end end -local function isBerry(def) return def and type(def.name) == "string" and def.name:find("Berry", 1, true) ~= nil end +local function isBerry(def) + -- the Berries pocket is the cartridge's own answer in any language; the name is the older test + return def and (def.pocket == "BERRIES" or (type(def.name) == "string" and def.name:find("Berry", 1, true) ~= nil)) or false +end -- ZEN MODE: Darmanitan below half HP is Fire / Psychic with its Zen stats, -- and in its Zen picture (form 1, gen5-form-sprites-v1) -- shown through a From 97142feae820fb678308aa42e2e7262bc077fb37 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:41:01 +0100 Subject: [PATCH 47/60] German battle: the cartridge's own battle lines (bank 368/0) in place of the engine's English; bag close row; check --- src/battle/BattleState.lua | 5 + src/battle/Gen4BattleText.lua | 159 +++++++++++++++++++++++++++++++ src/core/Data.lua | 7 ++ src/import/RomExtractorGen4.lua | 14 +-- src/ui/Gen4BagMenu.lua | 7 +- tools/gen4_battle_text_check.lua | 63 ++++++++++++ tools/gen4_game_corner_check.lua | 2 +- 7 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 src/battle/Gen4BattleText.lua create mode 100644 tools/gen4_battle_text_check.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8612ff5ec..02d8ffb07 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2672,6 +2672,11 @@ function BattleState:startMessage(item) if self.bw and text ~= "" then text = require("src.battle.Gen5BattleText").translate(self, text) end + -- A NON-ENGLISH GEN 4 CARTRIDGE (German Platinum) prints its own battle + -- lines (text bank 368 / 0) in place of the engine's English + if self.data and self.data.gen4_language and not self.bw and text ~= "" then + text = require("src.battle.Gen4BattleText").translate(self, text) + end local pos, cont = 1, false while true do local npos = text:find("[\n\v]", pos) diff --git a/src/battle/Gen4BattleText.lua b/src/battle/Gen4BattleText.lua new file mode 100644 index 000000000..789c1d305 --- /dev/null +++ b/src/battle/Gen4BattleText.lua @@ -0,0 +1,159 @@ +-- PLATINUM'S BATTLE LINES IN THE CARTRIDGE'S OWN LANGUAGE. +-- +-- The Gen 4 battle prints the engine's English ("Wild X\nappeared!", "Enemy X +-- used Y!"). On an English cartridge that is the game's own wording; on a +-- German one it is English in the middle of a German game. The cartridge's +-- lines are in text bank 368 (the battle) and bank 0 (a line per move: +-- 3 * move + side), three variants per line like Black / White's: the +-- player's Pokemon (+0), the wild one (+1) and the foe's (+2). +-- +-- Like src/battle/Gen5BattleText.lua this is applied where a battle message +-- starts (BattleState:startMessage), and only on a non-English cartridge +-- (`data.gen4_language`), so an English cache prints exactly what it did. A +-- line no pattern knows is left as the engine wrote it. +-- +-- The indices were read off an imported US cache by the line they start with +-- (grep TEXT_B0368_); the placeholders ({STRVAR_1 0}) are +-- filled from the arguments by type: 1 a Pokemon name, 3 a trainer, 6 a move, +-- 13 a stat, 50/52/54 a number. + +local T = {} + +T.BANK = 368 +T.BANK_MOVE_USED = 0 + +-- the engine's stat labels -> the summary's stat-name entries (bank 201) +T.STAT_TEXT = { ["ATTACK"] = 54, ["DEFENSE"] = 56, ["SP. ATK"] = 58, ["SP. DEF"] = 60, ["SPEED"] = 62 } + +local function key(bank, index) return ("TEXT_B%04d_%05d"):format(bank, index) end + +local function line(data, bank, index) + local s = data and data.text and data.text[key(bank, index)] + return type(s) == "string" and s ~= "" and s or nil +end + +-- fill a cartridge line: vals[type] = { slot0, slot1, ... } +local function fill(raw, vals) + local out = raw:gsub("{WAIT %d+}", ""):gsub("{STRVAR_1 (%d+) (%d+) %d+}", function(kind, slot) + local list = vals[tonumber(kind)] + local v = list and list[tonumber(slot) + 1] + return v ~= nil and tostring(v) or "" + end) + -- the ROM's trailing wait marker is the box's own concern + out = out:gsub("[\011]+$", "") + return out +end + +-- which of the three variants a printed name belongs to +local function side(battle, enemyPrefix) + if not enemyPrefix or enemyPrefix == "" then return 0 end + return battle.trainer and 2 or 1 +end + +local function moveIdByName(battle, name) + local map = battle._gen4MoveIdByName + if not map then + map = {} + for id, def in pairs((battle.data and battle.data.moves) or {}) do + if type(id) == "number" and type(def) == "table" and def.name and map[def.name] == nil then map[def.name] = id end + end + battle._gen4MoveIdByName = map + end + return map[name] +end + +-- the three-variant line (index = base + side) with the Pokemon name in slot 0 +local function three(base, extra) + return function(battle, prefix, name, ...) + local raw = line(battle.data, T.BANK, base + side(battle, prefix)) + if not raw then return nil end + local vals = { [1] = { name } } + if extra then extra(vals, ...) end + return fill(raw, vals) + end +end + +-- patterns: { lua pattern over the engine's text, handler(battle, captures...) } +T.PATTERNS = { + { "^(Enemy )(.-)\nused (.-)!$", function(battle, prefix, name, move) + local id = moveIdByName(battle, move) + local raw = id and line(battle.data, T.BANK_MOVE_USED, 3 * id + side(battle, prefix)) + return raw and fill(raw, { [1] = { name } }) end }, + { "^(.-)\nused (.-)!$", function(battle, name, move) + local id = moveIdByName(battle, move) + local raw = id and line(battle.data, T.BANK_MOVE_USED, 3 * id) + return raw and fill(raw, { [1] = { name } }) end }, + { "^Wild (.-)\nappeared!$", function(battle, name) + local raw = line(battle.data, T.BANK, 1246) + return raw and fill(raw, { [1] = { name } }) end }, + { "^Go! (.-)!$", function(battle, name) + local raw = line(battle.data, T.BANK, 979) + return raw and fill(raw, { [1] = { name } }) end }, + { "^(Enemy )(.-)\nfainted!$", three(30) }, + { "^(.-)\nfainted!$", function(battle, name) return three(30)(battle, "", name) end }, + { "^It's super\neffective!$", function(battle) return line(battle.data, T.BANK, 780) and fill(line(battle.data, T.BANK, 780), {}) end }, + { "^It's not very\neffective%.%.%.$", function(battle) return line(battle.data, T.BANK, 779) and fill(line(battle.data, T.BANK, 779), {}) end }, + { "^Critical hit!$", function(battle) return line(battle.data, T.BANK, 774) and fill(line(battle.data, T.BANK, 774), {}) end }, + { "^One%-hit KO!$", function(battle) return line(battle.data, T.BANK, 775) and fill(line(battle.data, T.BANK, 775), {}) end }, + { "^(Enemy )(.-)'s\nattack missed!$", three(12) }, + { "^(.-)'s\nattack missed!$", function(battle, name) return three(12)(battle, "", name) end }, + { "^It doesn't affect\n(Enemy )(.-)%.%.%.$", three(27) }, + { "^It doesn't affect\n(.-)%.%.%.$", function(battle, name) return three(27)(battle, "", name) end }, + { "^(.-) gained\n(%d+) EXP%. Points!$", function(battle, name, n) + local raw = line(battle.data, T.BANK, 1) + return raw and fill(raw, { [1] = { name }, [54] = { nil, n } }) end }, + { "^(.-) grew\nto level (%d+)!$", function(battle, name, n) + local raw = line(battle.data, T.BANK, 3) + return raw and fill(raw, { [1] = { name }, [52] = { nil, n } }) end }, + { "^Gotcha!\n(.-) was caught!$", function(battle, name) + local raw = line(battle.data, T.BANK, 867) + return raw and fill(raw, { [1] = { name } }) end }, + { "^Got away safely!$", function(battle) return line(battle.data, T.BANK, 781) and fill(line(battle.data, T.BANK, 781), {}) end }, + { "^Can't escape!$", function(battle) return line(battle.data, T.BANK, 42) and fill(line(battle.data, T.BANK, 42), {}) end }, + { "^(.-) sent\nout (.-)!$", function(battle, who, mon) + local raw = line(battle.data, T.BANK, 974) + return raw and fill(raw, { [3] = { who }, [1] = { nil, mon } }) end }, + -- "Bug Catcher Jack wants to fight!": the class and the name are the + -- trainer record's, which the cartridge prints from two slots + { "^(.-) wants\nto fight!$", function(battle) + local t = battle.trainer + local raw = t and line(battle.data, T.BANK, 969) + return raw and fill(raw, { [14] = { t.className }, [3] = { nil, t.name } }) end }, + -- "(Enemy )NAME's\nSTAT rose!" / "fell!" + { "^(Enemy )(.-)'s\n(.-) (rose)!$", nil }, +} + +-- a stat change names the stat in the cartridge's language +local function statLine(base) + return function(battle, prefix, name, stat) + local at = T.STAT_TEXT[stat] + local label = at and line(battle.data, 201, at) + if not label then return nil end + return three(base, function(vals) vals[13] = { label } end)(battle, prefix, name) + end +end +T.PATTERNS[#T.PATTERNS] = nil +T.PATTERNS[#T.PATTERNS + 1] = { "^(Enemy )(.-)'s\n(.-) rose!$", statLine(750) } +T.PATTERNS[#T.PATTERNS + 1] = { "^(.-)'s\n(.-) rose!$", function(battle, name, stat) return statLine(750)(battle, "", name, stat) end } +T.PATTERNS[#T.PATTERNS + 1] = { "^(Enemy )(.-)'s\n(.-) fell!$", statLine(762) } +T.PATTERNS[#T.PATTERNS + 1] = { "^(.-)'s\n(.-) fell!$", function(battle, name, stat) return statLine(762)(battle, "", name, stat) end } + +-- the engine's line in, the cartridge's out (or the engine's, unchanged) +function T.translate(battle, text) + if type(text) ~= "string" or text == "" then return text end + local data = battle and battle.data + if not (data and data.gen4_language) then return text end + for _, row in ipairs(T.PATTERNS) do + local fn = row[2] + if fn then + local caps = { text:match(row[1]) } + if caps[1] ~= nil then + local ok, out = pcall(fn, battle, caps[1], caps[2], caps[3]) + if ok and type(out) == "string" and out ~= "" then return out end + end + end + end + return text +end + +return T diff --git a/src/core/Data.lua b/src/core/Data.lua index 81b6f1ed6..c97e9b8fd 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -2274,6 +2274,13 @@ function Data:load() -- own bands and its menus read HGSS's bank (Gen4ScriptBands.setFamily). -- Platinum's cache has no such file and keeps Platinum's tables. local cacheFamily + -- THE CARTRIDGE'S TEXT LANGUAGE, written only by a non-English import + -- (German Platinum): { language = "de", gameCode = "CPUD" }. nil means + -- English, which the engine's own English battle lines are written for. + do + local okL, lang = loadModule(dir, "gen4_language") + self.gen4_language = okL and type(lang) == "table" and lang or nil + end do local okV, ver = loadModule(dir, "gen4_version") self.gen4_version = okV and ver or nil diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index e711e9d7e..3de82b457 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -7616,6 +7616,13 @@ function RomExtractorGen4:runStages(list) if not listed then skipped[#skipped + 1] = name end end self.notPorted = skipped + self:write("gen4_version", { version = self.version, family = require("src.import.Gen4Versions").family(self.version), + stages = list, notPorted = skipped }) + return self.wrote +end + +function RomExtractorGen4:run() + local V = require("src.import.Gen4Versions") -- THE CARTRIDGE'S TEXT LANGUAGE, for what has to know it (the translation -- layer must not treat German ROM text as English). Only a non-English -- cartridge writes it, so an English cache is unchanged. @@ -7624,13 +7631,6 @@ function RomExtractorGen4:runStages(list) local code = self.rom and self.rom:header().gameCode self:write("gen4_language", { language = Regions.language(code), gameCode = code }) end - self:write("gen4_version", { version = self.version, family = require("src.import.Gen4Versions").family(self.version), - stages = list, notPorted = skipped }) - return self.wrote -end - -function RomExtractorGen4:run() - local V = require("src.import.Gen4Versions") local only = V.STAGES[V.family(self.version)] if only then return self:runStages(only) end self:extractText() diff --git a/src/ui/Gen4BagMenu.lua b/src/ui/Gen4BagMenu.lua index aebf0af8b..6b48ac050 100644 --- a/src/ui/Gen4BagMenu.lua +++ b/src/ui/Gen4BagMenu.lua @@ -380,7 +380,12 @@ function Gen4BagMenu:rebuild() rows[#rows + 1] = row end end - rows[#rows + 1] = { close = true, label = Strings("CLOSE BAG") } + -- the cartridge's own row on Platinum (text bank 7 #41: "CLOSE BAG" / + -- "BEENDEN"), else the engine's + local gdata = self.game and self.game.data + local closeText = gdata and gdata.gen4_version == nil and gdata.text and gdata.text.TEXT_B0007_00041 + rows[#rows + 1] = { close = true, + label = (type(closeText) == "string" and closeText:find("%S") and closeText) or Strings("CLOSE BAG") } rows[#rows + 1] = { header = true } self.rows = rows diff --git a/tools/gen4_battle_text_check.lua b/tools/gen4_battle_text_check.lua new file mode 100644 index 000000000..557423f71 --- /dev/null +++ b/tools/gen4_battle_text_check.lua @@ -0,0 +1,63 @@ +-- Run: python tools/run_lua_check.py tools/gen4_battle_text_check.lua /data/generated +-- +-- PLATINUM'S BATTLE LINES FROM THE CARTRIDGE (src/battle/Gen4BattleText.lua). +-- On a non-English cache (`gen4_language` present) the engine's English lines +-- come out as the cartridge's bank 368 / bank 0 lines with the names filled +-- in; on an English cache nothing changes at all. + +package.path = './?.lua;' .. package.path +local dir = (arg[1] or ''):gsub('[/\\]*$', '') .. '/' +local PASS, FAIL = 0, 0 +local function check(cond, label) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. label) end +end +local function load_(n) local f = loadfile(dir .. n) return f and f() end + +local T = require('src.battle.Gen4BattleText') +local data = { text = load_('text.lua'), moves = load_('moves.lua'), constants = { gen = 4 } } +local lang = load_('gen4_language.lua') +local battle = { data = data, trainer = nil } + +local tackleName +for id, def in pairs(data.moves) do if id == 33 then tackleName = def.name end end + +local function lines(s) return s and s:gsub('\n', ' / ') end + +if not lang then + -- an English cache: the engine's own text, untouched + data.gen4_language = nil + for _, s in ipairs({ 'Wild STARLY\nappeared!', 'Go! TURTWIG!', 'Enemy STARLY\nfainted!', "It's super\neffective!" }) do + check(T.translate(battle, s) == s, 'an English cache keeps "' .. lines(s) .. '"') + end +else + data.gen4_language = lang + local function differs(s, mustHave) + local out = T.translate(battle, s) + check(out ~= s and out:find(mustHave, 1, true), ('"%s" -> "%s" (needs %s)'):format(lines(s), lines(out), mustHave)) + check(not out:find('{STRVAR', 1, true) and not out:find('{WAIT', 1, true), 'no raw placeholder left in "' .. lines(out) .. '"') + return out + end + differs('Wild STARLY\nappeared!', 'STARLY') + differs('Go! TURTWIG!', 'TURTWIG') + differs('Enemy STARLY\nfainted!', 'STARLY') + differs('TURTWIG\nfainted!', 'TURTWIG') + differs("It's super\neffective!", '') + differs("It's not very\neffective...", '') + differs('Critical hit!', '') + differs("Enemy STARLY's\nattack missed!", 'STARLY') + differs('TURTWIG gained\n64 EXP. Points!', '64') + differs('TURTWIG grew\nto level 11!', '11') + differs("TURTWIG's\nATTACK fell!", 'TURTWIG') + differs("Enemy STARLY's\nSPEED rose!", 'STARLY') + differs('TURTWIG\nused ' .. tackleName .. '!', 'TURTWIG') + differs('Enemy STARLY\nused ' .. tackleName .. '!', 'STARLY') + battle.trainer = { className = 'KLASSE', name = 'NAME' } + differs('Enemy STARLY\nused ' .. tackleName .. '!', 'STARLY') + differs('KLASSE NAME wants\nto fight!', 'KLASSE') + differs('KLASSE NAME sent\nout STARLY!', 'STARLY') + -- an unknown line is left alone + check(T.translate(battle, 'Something nobody wrote.') == 'Something nobody wrote.', 'an unknown line is unchanged') +end + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/gen4_game_corner_check.lua b/tools/gen4_game_corner_check.lua index 9e7f645f3..4966bc8fc 100644 --- a/tools/gen4_game_corner_check.lua +++ b/tools/gen4_game_corner_check.lua @@ -123,7 +123,7 @@ check(game.stringBuffers[1] == 'DARK', 'buffertypename reads bank 624') local items = load('items') local IE = require('src.inventory.ItemEffects') save.coins = 1234 -local ok, kind, lines = pcall(IE.use, { items = items, text = text, constants = { gen = 4 } }, save, 444) +local ok, kind, lines = pcall(IE.use, { items = items, text = text, constants = { gen = 4 }, isGen4Cache = true }, save, 444) check(ok and kind == 'failed' and lines and lines[1] == 'Your Coins: 1234', 'the Coin Case says "Your Coins: 1234", got ' .. tostring(ok and lines and lines[1] or kind)) From 429099e3610677603323e565e6e2b53094ad6a2b Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:41:32 +0100 Subject: [PATCH 48/60] docs: German runtime coverage --- docs/rom-revisions.md | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/rom-revisions.md b/docs/rom-revisions.md index 06e0a43c9..d696333f5 100644 --- a/docs/rom-revisions.md +++ b/docs/rom-revisions.md @@ -143,21 +143,35 @@ The art with baked-in text is German as well. That covers the summary, Pokedex, party, box, bag, trainer card, naming, contest and touch screens, the type icons, the "KP" healthbox, and one map texture set (21, its `m_depanel` signs). +**Fixed since (runtime on a German cache):** + +- Pal Park, Poke Transfer and the Monster Walker reach Gen IV items and moves + by cartridge id through `src/pokemon/Gen4EnglishRef.lua` (the table is + `Gen4EnglishNames.lua`, written by `tools/gen4_english_ref_gen.lua` from an + English import). Held items survive a Gen 3 migration. +- The whiteout's nurse/Mom scripts, the loss bank and the scurry line are found + by shape when the English words are absent. +- The randomizer's protected move/item lists read English names through the + same reference, and the kept trainer classes are class ids. +- The hero's default names come from the cartridge's name bank (text bank 427). +- A non-English Gen 4 import writes `gen4_language.lua`; the translation layer + reads it, so German ROM text is not sent for translation as English. +- The battle prints the cartridge's own lines (`src/battle/Gen4BattleText.lua`, + bank 368 and bank 0) for the common ones: wild/trainer intro, "Go!", move + use, fainted, effectiveness, critical hit, misses, EXP and level-up, stat + changes, catch, escape. + **Still English on a German cartridge:** -- the engine's own strings ("Enemy …", menus the port draws itself) -- the English default player and rival name presets -- the scurry line on a whiteout (the nurse/Mom text is located by its English - content) -- Pal Park and cross-generation transfers. They map Gen 3 items and walker - moves by English name, so held items are dropped from Gen 3 migrants. -- the randomizer's protected-item and protected-class lists, which are name - based. - -None of these blocks play. Some existing checks assert English strings -(`gen4_item_use_check`, `gen4_area_popup_check` "Twinleaf Town", -`gen4_underground_check`, `gen4_machine_check` "HM01", -`gen4_mining_screen_check`), so they fail on a German cache by design. +- the engine's own strings that are not mapped above (the less common battle + lines: statuses, weather, abilities, items; some menu words the port draws) +- the translation layer's engine-string catalog is English-keyed + +Checks that assert English text now take the language from the cache +(`gen4_item_use_check`, `gen4_area_popup_check`, `gen4_underground_check`, +`gen4_machine_check`, `gen4_mining_screen_check`, `gen4_met_location_check`, +`gen4_blackout_check`). Others still assert English (contest, safari, seals, +journal, tablet, tag battle, town map, field queries, fly, daily slots). ## Evidence for the revisions already accepted as alternates From 89ada6747142f13b83edaca759ac3dbbd3924f15 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:43:28 +0100 Subject: [PATCH 49/60] Field sweep: finish reaped battles through their own onFinish --- tools/gen4_field_sweep_harness/main.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index 81f3014c3..d66b35dc2 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -181,6 +181,8 @@ end -- suspect, and the caller recovers). -- the last stack changes and the keys pressed with them: what a failure needs -- to be replayed by hand +local battles = 0 -- battles taken off the stack (and answered as wins) +local BattleStateClass -- set in installStubs; tick() reads it local RING = 240 local ring, ringAt, lastStack = {}, 0, "" local function ringNote(line) @@ -231,9 +233,15 @@ local function tick() and (getmetatable(t) == BattleStateClass or tostring(describeTop()):find("BattleState", 1, true)) then battles = battles + 1 Game.stack:pop() - local okB, errB = xpcall(OW.afterBattle, debug.traceback, OW, "win", t) + -- onFinish is whoever started the fight: a script resumes its runner, a wild + -- encounter or a trainer's sight hands back to the field + local okB, errB + if type(t.onFinish) == "function" then + okB, errB = xpcall(t.onFinish, debug.traceback, "win") + else + okB, errB = xpcall(OW.afterBattle, debug.traceback, OW, "win", t) + end if not okB then fail("after_battle_error", errB) end - OW.engaging = false end if not ok then fail("update_error", err) @@ -283,8 +291,6 @@ end -- --------------------------------------------------------------- the stubs --- -local battles = 0 -local BattleStateClass local pushCount = 0 -- every state push: a script that re-asks its question is cycling, not hung local function installStubs() From 540041b30fe43e0a863c0e360c234481bd36e691 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:44:35 +0100 Subject: [PATCH 50/60] Gen 4 playthrough: walk Renegade Platinum's story to Heatran Renegade-only beats and moves (retail's 91 beats are unchanged): - Victory Road needs the counterpart beaten on Route 224 first; VR 2F has no boulders; the six Mewtwo are healed between its legs (the hack's own nurses stand inside) - Buck on Route 227 wants the six Battle Zone leaders' badges, then battles - Stark Mountain room 2 has no rock or boulder; the Battleground sets 0x125 on entry; Mom's post-game scene takes the National Dex branch (the hack enables it in Rowan's first scene) - moves chosen for the hack's teams: Psychic for Volkner's Rotom, Barry at the League and Floaroma's Croagunk; Flamethrower for Stark Mountain's Sableye; the double-battle partner is rebuilt with the beat's move docs/renegade-platinum.md lists the hack's story edits. --- docs/renegade-platinum.md | 44 ++++++++- tools/gen4_playthrough/platinum.lua | 134 ++++++++++++++++++++++++---- 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/docs/renegade-platinum.md b/docs/renegade-platinum.md index df2ffc1e7..646cf701e 100644 --- a/docs/renegade-platinum.md +++ b/docs/renegade-platinum.md @@ -124,6 +124,43 @@ authored. loses and blacks out. The playthrough beat trains the lead up first. - Jubilife: the Pokétch clowns' quizzes are removed (persons 24 and 25 are parked at 0,0 without a script); the president gives the Pokétch himself. + +## The story, as the hack changes it + +All of these are script and event-data edits; the port runs them with no +engine change. `tools/gen4_playthrough/platinum.lua` walks each one when +`VERSION=renegadeplatinum` (retail's beats are untouched). + +- Sandgem: Rowan's first lab scene enables the **National Dex** at once + (`getsetnationaldexenabled 1`), so Mom's post-game scene takes its National + Dex branch (flag 0x15C, not 0x15D). +- Floaroma Meadow: the two grunts are one double battle (296 + 297) that + checks `getpartycount` and pushes a party of one back. +- Pastoria: the Gym Guide (person 29, flag 0xA08) stands on Barry's coord + cell in front of the gym; Crasher Wake sings on Route 212 south (R212B + coord (10..13,5), 0x406E): meeting him sets 0xA08 and clears the way. +- Route 218 gate: the Pokédex-upgrade scientist is gone; a thirsty + policeman's coord trigger ((2,5..9), 0x406C) turns the player back unless + the bag holds the **Tea** (item 456, the old S.S. Ticket slot). The Tea is + Pal Park's: talk to Prof. Oak (flag 0xA0B), then Darach battles the player + (trainer 969) and Caitlin hands it over (flag 0xA0C). +- Iron Island: Riley at the entrance battles the player (trainer 970); + HM04 Strength comes after his tag battle inside (D24R0105, 0x4092 = 2). +- Mt. Coronet 2F, Victory Road 2F and Stark Mountain's second room: the + Strength boulders and Rock Smash rocks are parked at (999,999). +- Victory Road 1F: a collector and a coord trigger ((37..39,18), 0x403E) + hold the way north until the counterpart is beaten on **Route 224**, which + is open before the League (talk twice, battle 975..977 by starter; flag + 0xA12). The hack also puts two nurses inside Victory Road. +- Route 227: Buck moved to (22,1); his trigger ((22,2..4), 0x403D) wants the + six **Battle Zone leaders**' unofficial badges first (Heather R225 0x8C9, + Wyver W226 0x8CA, Slythe R227 0x8CB, Shade R228 0x908, Gene R229 0x909, + Luna W230 0x8CE), then he battles (trainer 1023) and leaves (flag 0xA18). + Retail's patrol question (0x4119) is unreferenced. +- Battleground: the room holds the gym leaders; its frame table sets + FLAG_TALKED_TO_BATTLEGROUND_BUCK (0x125) as the player walks in. +- Trainers are much stronger throughout (full level-appropriate teams with + coverage, e.g. Volkner's two Rotom, Jupiter's Sableye). - Evolutions: 40 records change (trade evolutions gain a level, Eevee uses Sun/Moon/Ice/Leaf stones, and about a dozen trade-with-item species can also evolve by using the item, whose records the hack flags as party items); the cache @@ -158,8 +195,11 @@ Piplup in both. ## Not verified yet -- The playthrough runs 19 beats (the first battle, Jubilife, Roark's gym) on - this cache and stops there by tick budget; later story edits are not walked. +- The playthrough walks the whole story to Heatran (100 beats, retail's 91 + plus the hack's nine) with the runner's training cheat (a level-100 + Mewtwo team, healed between Victory Road's legs). Optional content the + hack adds (gift Pokémon, the Battleground's leaders, new item balls) is + not walked. - Whether the real hack prints the lines of trainers 928..1065 (see above). - Pickup, Pokemart common stock and the Frontier were not diffed at the data level, only shown unchanged by the ARM9/overlay survey. diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 47541e342..8af74798f 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -86,7 +86,9 @@ local function trainUpWith(move, level, species) table.insert(mon.moves, 1, { id = move or 94, pp = 64 }) while #mon.moves > 4 do table.remove(mon.moves) end party[1] = mon - if renegade and #party < 2 then + -- (rebuilt each time with the same first move, so a double battle's + -- partner does not keep the last beat's -- Surf into Dry Skin) + if renegade and (#party < 2 or party[2].species == mon.species) then local second = Pokemon.new(r.game.data, species or lead.species, level or 75) second.otName, second.otId = lead.otName, lead.otId second.moves = second.moves or {} @@ -398,8 +400,10 @@ B[#B + 1] = { name = "route 205: the little girl", -- Floaroma Meadow (D13R0101) coord 1 (12..13,48 while -- VAR_FLOAROMA_MEADOW_STATE 0x40E9 == 0): the two grunts' double battle, -- the Works Key (FLAG_OBTAINED_FLOAROMA_MEADOW_WORKS_KEY 0x9F) +-- (RENEGADE's pair brings a Croagunk, whose Dry Skin drinks Surf, and Surf +-- also hits the partner in a double: Psychic there) B[#B + 1] = { name = "floaroma meadow grunts", - steps = { { "do", trainUp }, { "teleport", "D13R0101", 12, 52, "up" }, { "activecoord" }, + steps = { { "do", renegade and trainUpWith(94) or trainUp }, { "teleport", "D13R0101", 12, 52, "up" }, { "activecoord" }, { "until", flag(0x9F), 60000 } }, expect = function(r) return r:flag(0x9F) and r:var(0x40E9) >= 1 end, why = "flag 0x9F (Works Key), 0x40E9 >= 1", expectBudget = 8000 } @@ -1228,8 +1232,11 @@ B[#B + 1] = { name = "sunyshore: volkner at the lighthouse", -- and west hubs, room 2 its bottom hub three times and both top (reverse) -- hubs, room 3 a bottom (double) hub twice -- in by the door, out by the far -- one, to Volkner. +-- RENEGADE's Volkner leads two Rotom (Electric/Ghost, Levitate): Earthquake +-- never touches them, so there the team leads with Psychic (STAB, neutral +-- on his whole team and the gym's) B[#B + 1] = { name = "volkner", budget = 60000, - steps = { { "do", fullTeam(89) }, battleStyle("set"), routeSteps(), { "teleport", "C08GYM0101", 8, 14, "up" }, + steps = { { "do", fullTeam(renegade and 94 or 89) }, battleStyle("set"), routeSteps(), { "teleport", "C08GYM0101", 8, 14, "up" }, { "route", { "U6R5D3L5U3L5R5U5" } }, { "warpto", "C08GYM0102" }, { "route", { "U5L3U3LRUDR5D7L8U4LU12R3DRD3R5D5R3DRU11L6" } }, { "warpto", "C08GYM0103" }, { "route", { "U7R5D3L5U3L5D6L3U3LU17R9" } }, { "face", "up" }, { "press", "a" }, @@ -1245,6 +1252,23 @@ B[#B + 1] = { name = "sunyshore: jasmine's waterfall", expect = function(r) return r:flag(0x9A) and r:var(0x407E) >= 3 end, why = "flag 0x9A (HM07), 0x407E >= 3", expectBudget = 8000 } +-- RENEGADE PLATINUM: Victory Road 1F's north part is held by a collector +-- (D09R0101 person 12 at (38,18), shown while flag 0xA12 is clear) and a +-- coord trigger across the column ((37..39,18) while 0x403E == 0) -- "you're +-- not quite ready yet. Have you been to Route 224?" -- and Route 224 is +-- open before the League: the counterpart (R224 person 23, by the stone +-- tablet) talks once (flag 0x8B5), then offers a battle (YES; trainers +-- 975..977 by the starter, six level-69s, Psychic hits them all), and on a +-- win leaves: flag 0xA12, 0x403E = 1. +if renegade then + B[#B + 1] = { name = "route 224: the counterpart", budget = 90000, + steps = { { "do", fullTeam(94) }, { "teleport", "R224", 44, 14, "up" }, + { "talk", 23 }, { "until", function(r) return r:flag(0x8B5) and not r:busy() end, 20000 }, + { "talk", 23 }, { "until", flag(0xA12), 60000 } }, + expect = function(r) return r:flag(0xA12) and r:var(0x403E) >= 1 end, + why = "flag 0xA12, 0x403E >= 1 (the counterpart beaten on Route 224)", expectBudget = 8000 } +end + -- ROUTE 223, SURFED: from Sunyshore's north shore (21,1), three seas joined -- by SHALLOW_WATER shoals -- ashore at (17,49), down the shoal to (10,49) and -- Surf again, ashore on the west shoal (4,49), Surf from (5,40), the strip @@ -1289,13 +1313,17 @@ add({ { "do", fullTeam(58) }, { "do", function(r) r.game.save.repelSteps = 250 end }, battleStyle("set"), routeSteps(), { "walkto", 15, 77 }, { "route", { "U15R16UR2U13R3" } }, { "act", "down", 36, 53 }, { "route", { "DL11D2L12U13L4U6L4" } }, - { "warpto", "D09R0102", 4, 37 }, - { "route", { "DL3DL" } }, { "smash", "down", 16, 19 }, { "route", { "D3RD6" } }, { "push", "down", 17, 29 }, + { "warpto", "D09R0102", 4, 37 } }) +-- (RENEGADE PLATINUM clears 2F: every rock and boulder is parked at +-- (999,999), so the stairs are simply walked to) +if not renegade then + add({ { "route", { "DL3DL" } }, { "smash", "down", 16, 19 }, { "route", { "D3RD6" } }, { "push", "down", 17, 29 }, { "route", { "D" } }, { "push", "left", 15, 28 }, { "route", { "L" } }, { "push", "down", 16, 30 }, { "route", { "DLD2L10" } }, { "push", "down", 5, 33 }, { "route", { "DL2D2R" } }, { "smash", "right", 5, 34 }, { "route", { "R" } }, { "push", "up", 5, 32 }, { "route", { "UR4D2" } }, { "smash", "down", 9, 36 }, - { "route", { "D2R2UR10DR" } }, { "push", "right", 24, 37 }, { "route", { "RU7LU4" } }, - { "warpto", "D09R0101", 23, 26 }, + { "route", { "D2R2UR10DR" } }, { "push", "right", 24, 37 }, { "route", { "RU7LU4" } } }) +end +add({ { "warpto", "D09R0101", 23, 26 }, { "route", { "DR15" } }, { "act", "down", 22, 53 }, { "route", { "L5U11R14UR10" } }, { "warpto", "D09R0103", 42, 41 }, { "route", { "UR11U3" } }, { "act", "up", 15, 34 }, { "route", { "U4R3U" } }, { "act", "up", 18, 28 }, @@ -1311,11 +1339,29 @@ add({ { "do", fullTeam(58) }, { "do", function(r) { "swimto", 15, 26 }, { "face", "up" }, { "press", "a" }, { "mash", 200 }, { "until", function(r) local _, y = r:pos() return y <= 24 and not r:busy() end, 4000 }, { "swimto", 15, 20 }, { "step", "up", 1 }, battleStyle(nil) }) -add({ { "do", fullTeam(57) }, { "warpto", "C10R0101" }, { "walkto", 11, 7 }, { "face", "up" }, { "activecoord" }, +-- (RENEGADE's Barry brings Azumarill, Breloom and Empoleon, who all resist +-- Surf: Psychic there) +add({ { "do", fullTeam(renegade and 94 or 57) }, { "warpto", "C10R0101" }, { "walkto", 11, 7 }, { "face", "up" }, { "activecoord" }, { "until", varGe(0x40EF, 1), 60000 }, { "talk", 0 }, { "until", flag(0xAF), 20000 } }) -B[#B + 1] = { name = "the league: barry", budget = 100000, +-- RENEGADE's Route 223 and Victory Road trainers (level 60-70 full teams, +-- one after another) wear the six Mewtwo down before Barry. The hack puts +-- nurses inside Victory Road for that; here the party is healed, a CHEAT +-- said as one, before every leg of the walk (each route / warp / field move) +if renegade then + local heal = { "do", function(r) + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(r:party()) do Pokemon.heal(mon) end + end } + local out = {} + for _, s in ipairs(league) do + if s[1] == "route" or s[1] == "warpto" or s[1] == "act" then out[#out + 1] = heal end + out[#out + 1] = s + end + league = out +end +B[#B + 1] = { name = "the league: barry", budget = renegade and 200000 or 100000, steps = league, expect = function(r) return r:var(0x40EF) >= 1 and r:flag(0xAF) end, why = "0x40EF >= 1, flag 0xAF", expectBudget = 12000 } @@ -1378,8 +1424,12 @@ B[#B + 1] = { name = "after the credits: home", { "until", function(r) return r:map() == "T01R0202" and not r:busy() end, 6000 }, { "warpto", "T01R0201" }, { "until", varGe(0x410F, 2), 6000 } }, - expect = function(r) return r:var(0x410F) >= 2 and r:flag(0x15D) end, - why = "0x410F >= 2, flag 0x15D (Mom's post-game scene)", budget = 80000 } + -- RENEGADE PLATINUM enables the National Dex in Rowan's first lab scene + -- (GetSetNationalDexEnabled 1 there), so Mom takes the scene's other + -- branch: FLAG_TALKED_TO_MOM_ABOUT_NATIONAL_DEX 0x15C, not 0x15D + expect = function(r) return r:var(0x410F) >= 2 and r:flag(renegade and 0x15C or 0x15D) end, + why = renegade and "0x410F >= 2, flag 0x15C (Mom's post-game scene, National Dex)" + or "0x410F >= 2, flag 0x15D (Mom's post-game scene)", budget = 80000 } -- A CHEAT, said as one: every Sinnoh Pokédex species marked seen (the -- encounters are not under test here), which is what Rowan asks for @@ -1439,10 +1489,50 @@ B[#B + 1] = { name = "route 227: wake and barry", -- coord 2 ((22,21) while VAR_ROUTE_227_BUCK_STATE 0x4119 == 0): Buck asks the -- player to patrol Stark Mountain (YES), 0x4119 = 1 +-- +-- RENEGADE PLATINUM moves Buck (person 11) up to (22,1) and the trigger to +-- (22,2..4) while 0x403D == 0: he BATTLES the player (trainer 1023: Torkoal, +-- Shuckle, Umbreon, Dusknoir, Cloyster, Claydol -- Surf), heals the party +-- and leaves (flag 0xA18, 0x403D = 1). Retail's patrol question is left +-- unreferenced, so 0x4119 stays 0 there (nothing in the hack reads it). +-- His trigger also checks SIX FLAGS first and, short of any, sends the +-- player back south ("did you manage to beat all six of the Battle Zone +-- leaders? ... Nope"): the hack's Battle Zone leaders, one per route, each +-- talked to, YES, beaten for an unofficial badge. They come first, each +-- with the move that its team cannot shrug off. +if renegade then + local LEADERS = { + -- route, leader (person), stand at, flag, move: Heather's flyers + -- (Thunderbolt), Wyver's dragons (Ice Beam), Slythe's poisons + -- (Psychic), Shade's darks (Thunderbolt: nothing immune, unlike + -- Psychic and Aura Sphere), Gene's normals (Aura Sphere), Luna's + -- fairies (Flash Cannon; the hack's chart has Steel 2x on Fairy) + { "R225", 31, 9, 81, 0x8C9, 85, "Heather (Wind)" }, + { "W226", 16, 44, 26, 0x8CA, 58, "Wyver (Fang)" }, + { "R227", 13, 12, 25, 0x8CB, 94, "Slythe (Acid)" }, + -- (Shade's only open side is the east one, (23,84): faced from there) + { "R228", 27, 23, 84, 0x908, 85, "Shade (Shadow)", face = "left" }, + { "R229", 19, 59, 23, 0x909, 396, "Gene (General)" }, + { "W230", 19, 56, 16, 0x8CE, 430, "Luna (Moon)" }, + } + for _, l in ipairs(LEADERS) do + B[#B + 1] = { name = "battle zone: " .. l[7], budget = 60000, + steps = { { "do", fullTeam(l[6]) }, { "teleport", l[1], l[3], l[4], l.face or "up" }, + l.face and { "press", "a" } or { "talk", l[2], retarget = true }, + { "until", flag(l[5]), 50000 } }, + expect = flag(l[5]), why = ("flag 0x%X (%s's badge)"):format(l[5], l[7]), expectBudget = 8000 } + end + B[#B + 1] = { name = "route 227: buck", + steps = { { "do", fullTeam(57) }, { "teleport", "R227", 19, 7, "up" }, { "coord", 2 }, + { "until", varGe(0x403D, 1), 60000 } }, + expect = function(r) return r:var(0x403D) >= 1 and r:flag(0xA18) end, why = "0x403D >= 1, flag 0xA18 (Buck beaten)", + expectBudget = 8000 } +else B[#B + 1] = { name = "route 227: buck", steps = { { "teleport", "R227", 22, 24, "up" }, { "answers", { 1 } }, { "coord", 2 }, { "until", varGe(0x4119, 1), 20000 } }, expect = varGe(0x4119, 1), why = "0x4119 >= 1", expectBudget = 8000 } +end -- STARK MOUNTAIN (D16): coord 1 ((16,11) while VAR_STARK_MOUNTAIN_OUTSIDE_STATE -- 0x40A0 == 0): two grunts run inside, 0x40A0 = 1 @@ -1454,8 +1544,10 @@ B[#B + 1] = { name = "stark mountain: the grunts", -- the first room's frame table (VAR_STARK_MOUNTAIN_ROOM_1_STATE 0x40AD == 0): -- Mars, then Jupiter (FLAG_DEFEATED_TRAINER_COMMANDER_JUPITER_STARK_MOUNTAIN -- 0x8EF), Charon leaves with them; 0x40AD = 1 +-- (RENEGADE's Jupiter brings Sableye, immune to Psychic, and Skuntank: +-- Flamethrower there, which nothing on either team is immune to) B[#B + 1] = { name = "stark mountain: mars and jupiter", budget = 60000, - steps = { { "do", fullTeam(94) }, { "warpto", "D16R0101" }, + steps = { { "do", fullTeam(renegade and 53 or 94) }, { "warpto", "D16R0101" }, { "until", varGe(0x40AD, 1), 50000 } }, expect = function(r) return r:var(0x40AD) >= 1 and trainerFlag(0x8EF)(r) end, why = "0x40AD >= 1, trainer flag 0x8EF (Jupiter)", @@ -1477,6 +1569,11 @@ local starkRoom2 = { routeSteps(), { "do", hmMoves({ [3] = 70, [4] = 249 }) }, { "until", function(r) return r:var(0x4094) >= 1 and not r:busy() end, 20000 }, { "walkto", 42, 15 }, { "smash", "left", 41, 15 }, { "walkto", 28, 12 } } for _, st in ipairs(starkPushes) do starkRoom2[#starkRoom2 + 1] = st end +-- RENEGADE PLATINUM parks the room's rocks and boulders at (999,999): the +-- far door is simply walked to +if renegade then + for _ = 1, 3 + #starkPushes do table.remove(starkRoom2, 6) end +end starkRoom2[#starkRoom2 + 1] = { "coord", 3 } starkRoom2[#starkRoom2 + 1] = { "until", varGe(0x4094, 2), 20000 } B[#B + 1] = { name = "stark mountain: buck follows", budget = 150000, @@ -1499,11 +1596,18 @@ B[#B + 1] = { name = "stark mountain: charon", -- inside (T06R0101) he is person 1 (FLAG_HIDE_BATTLEGROUND_BUCK cleared by the -- arrest too), and talking to him is FLAG_TALKED_TO_BATTLEGROUND_BUCK 0x125, -- which wakes Heatran -B[#B + 1] = { name = "battleground: buck", - steps = { { "teleport", "T06", 12, 21, "up" }, { "talk", 8 }, +-- +-- RENEGADE PLATINUM fills the Battleground with the gym leaders (person 1 +-- is Marley, Buck is person 5) and moves the flag to the door: the room's +-- frame table (0x40C1 == 0) has the receptionist (person 0) meet the player +-- and sets 0x125 itself, so nobody is talked to inside +local battleground = { { "teleport", "T06", 12, 21, "up" }, { "talk", 8 }, { "until", function(r) return not r:busy() and r:personAt(8):find("shown") == nil end, 8000 }, { "warpto", "T06R0101" }, { "talk", 1 }, - { "until", flag(0x125), 20000 } }, + { "until", flag(0x125), 20000 } } +if renegade then table.remove(battleground, 5) end +B[#B + 1] = { name = "battleground: buck", + steps = battleground, expect = flag(0x125), why = "flag 0x125", expectBudget = 8000 } -- back in the third room (0x409E == 1, the game completed, the National Dex, From 44fa2ba97bc13c816345ebd3148ab2e9d171fa84 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:48:26 +0100 Subject: [PATCH 51/60] Gen 4 playthrough: Psychic for Renegade's Saturn (Dry Skin Toxicroak) The hack lists Toxicroak's Dry Skin first, so the default Surf can be drunk for the whole battle. docs: note that trainer Pokemon still get a random personality where the cartridge seeds one (ability slot 0). --- docs/renegade-platinum.md | 8 ++++++++ tools/gen4_playthrough/platinum.lua | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/renegade-platinum.md b/docs/renegade-platinum.md index 646cf701e..8ebf0a5a9 100644 --- a/docs/renegade-platinum.md +++ b/docs/renegade-platinum.md @@ -200,6 +200,14 @@ Piplup in both. Mewtwo team, healed between Victory Road's legs). Optional content the hack adds (gift Pokémon, the Battleground's leaders, new item balls) is not walked. +- Trainer Pokémon abilities. The hack reorders some ability pairs (Toxicroak + is DRY_SKIN first). The cartridge builds a trainer mon's personality from + a seed (`TrainerData_BuildParty`: dv + level + species + trainer id, the + LCRNG run `class` times, `<< 8` plus 0x88 / 0x78), so its low bit, and + with it the ability slot, is always 0: every trainer Toxicroak has Dry + Skin. The port's `buildTrainerParty` (BattleState) still rolls a random + personality (nature, ability, gender) for both games, so half the time it + gets Anticipation. Not fixed here; it changes retail's battles too. - Whether the real hack prints the lines of trainers 928..1065 (see above). - Pickup, Pokemart common stock and the Frontier were not diffed at the data level, only shown unchanged by the ARM9/overlay survey. diff --git a/tools/gen4_playthrough/platinum.lua b/tools/gen4_playthrough/platinum.lua index 8af74798f..4c40e9957 100644 --- a/tools/gen4_playthrough/platinum.lua +++ b/tools/gen4_playthrough/platinum.lua @@ -842,8 +842,10 @@ B[#B + 1] = { name = "canalave: rowan's orders", -- THE LAKES. Lake Valor, drained (D28R0101) -> Valor Cavern (D28R0103): -- Saturn (person 1) -- FLAG_DEFEATED_COMMANDER_SATURN_VALOR_CAVERN 0x13E. -- Golbat/Bronzor/Toxicroak: Psychic. +-- (RENEGADE's Toxicroak may roll DRY SKIN, which drinks the default Surf +-- for the whole battle: Psychic there, as the line above says) B[#B + 1] = { name = "saturn at lake valor", - steps = { { "do", strong() }, { "teleport", "D28R0103", 14, 23, "up" }, { "talk", 1 }, + steps = { { "do", strong(renegade and 94 or nil) }, { "teleport", "D28R0103", 14, 23, "up" }, { "talk", 1 }, { "until", flag(0x13E), 60000 } }, expect = flag(0x13E), why = "flag 0x13E (Saturn beaten)", expectBudget = 12000 } From 845b37e9aac90d049dfe66fae608975457488429 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:55:10 +0100 Subject: [PATCH 52/60] Field sweep: retry START after a scene, report cycling prompts as loops, watch/ring diagnostics --- tools/gen4_field_sweep_harness/main.lua | 52 ++++++++++++++++++------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index d66b35dc2..c040cae07 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -243,6 +243,13 @@ local function tick() end if not okB then fail("after_battle_error", errB) end end + if getenv("WATCH") and frames % tonumber(getenv("WATCH")) == 0 and OW.player then + local mv = OW.scriptMoves and OW.scriptMoves[1] + print(("[watch] f%d %s cell=%s,%s moving=%s target=%s,%s moves=%d rem=%s row=%s top=%s keys=%s"):format(frames, + tostring(OW.map and OW.map.id), tostring(OW.player.cellX), tostring(OW.player.cellY), tostring(OW.player.moving), + tostring(OW.player.targetX), tostring(OW.player.targetY), #(OW.scriptMoves or {}), tostring(mv and mv.remaining), + tostring(OW.runner and OW.runner.lastRow), describeTop(), tostring(keys))) + end if not ok then fail("update_error", err) return false @@ -888,6 +895,7 @@ end -- Close what is above `floorDepth` with B (and START, which closes the pause -- menu) -- a screen the pad cannot leave is a softlock. local function closeScreens(label, floorDepth) + local pushesAtStart = pushCount -- B alone (every screen should back out), then A alone (a "really?" that -- defaults to NO has to be answered), then a random mix with the d-pad (a -- cursor parked on the wrong row of a yes/no) @@ -912,8 +920,15 @@ local function closeScreens(label, floorDepth) local okD, d = pcall(StateStack.describe, st) names[#names + 1] = okD and tostring(d) or "?" end - fail("softlock", ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s | recent: %s"):format(label, - describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(tonumber(getenv('RING_DUMP') or '') or 16))) + local detail = ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s | recent: %s"):format(label, + describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(tonumber(getenv('RING_DUMP') or '') or 16)) + if pushCount - pushesAtStart >= 20 then + -- states kept being pushed: a prompt that re-asks (the move-learn "abandon?" + -- loop answers NO to itself under B) -- cycling, not hung + emit(("SWEEP_LOOP|%s|%s|close|%d pushes while closing | %s"):format(cur.map, cur.phase, pushCount - pushesAtStart, oneLine(detail, 700))) + else + fail("softlock", detail) + end while stackDepth() > floorDepth do Game.stack:pop() end return false end @@ -936,17 +951,26 @@ end local function openMenu(label) local floor = stackDepth() - press("start") - local ok = run(20) - if not ok then return nil end - if stackDepth() <= floor then - fail("menu_unopened", label .. ": START did not open a menu | " .. stuckDetail( - ("map=%s moving=%s locked=%s cell=%s,%s surf=%s emote=%s engaging=%s"):format(tostring(OW.map and OW.map.id), - tostring(OW.player.moving), tostring(OW.lockedFrames), tostring(OW.player.cellX), tostring(OW.player.cellY), - tostring(OW.player.surfing), tostring(OW.emote ~= nil), tostring(OW.engaging)))) - return nil - end - return floor + -- START is only read on an idle field: a scene can start a frame after the + -- last one ended (an arrival script queued behind the map's), so ask again + -- after reading it out rather than calling the first miss a bug + for attempt = 1, 4 do + if not idle() then + local r = drain(label .. " before the menu") + if r ~= true then + emit(("SWEEP_INFO|%s|menu_skipped|%s: the field did not come back from a scene (%s)"):format(cur.map, label, tostring(r))) + return nil + end + end + press("start") + if not run(20) then return nil end + if stackDepth() > floor then return floor end + end + fail("menu_unopened", label .. ": START did not open a menu | " .. stuckDetail( + ("map=%s moving=%s locked=%s cell=%s,%s surf=%s emote=%s engaging=%s"):format(tostring(OW.map and OW.map.id), + tostring(OW.player.moving), tostring(OW.lockedFrames), tostring(OW.player.cellX), tostring(OW.player.cellY), + tostring(OW.player.surfing), tostring(OW.emote ~= nil), tostring(OW.engaging)))) + return nil end local MENU_SKIP = {} @@ -1445,7 +1469,7 @@ function love.load() _G.__sweep = { Game = Game, OW = OW, Data = Data, enterMap = enterMap, run = run, press = press, idle = idle, top = top, drain = drain, settle = settle, placePlayer = placePlayer, standCells = standCells, stackDepth = stackDepth, describeTop = describeTop, - fails = function() return totals.fails end } + drawFrame = drawFrame, fails = function() return totals.fails end } -- PROBE=: run a scratch script against the booted game and quit if getenv("PROBE") then local okP, errP = xpcall(dofile, debug.traceback, getenv("PROBE")) From 4c91995d8af42d9b04961f95b4985921849fc5cd Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 11:56:23 +0100 Subject: [PATCH 53/60] Gen 4 (Platinum family): trainer Pokemon use TrainerData_BuildParty's deterministic personality Seed the LCRNG from ivScale+level+species+trainerId, advance once per trainer class, (rnd<<8)+0x78/0x88 by class gender; nature, ability slot, gender, IVs and stats follow. Never shiny. HGSS and Battle Frontier sets untouched. Adds a pure-Lua and a lovec end-to-end check. --- src/battle/BattleState.lua | 6 +- src/battle/Gen4TrainerPersonality.lua | 110 ++++++++++++++++++ tools/gen4_trainer_personality_check.lua | 67 +++++++++++ tools/gen4_trainer_personality_check/conf.lua | 18 +++ tools/gen4_trainer_personality_check/main.lua | 69 +++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/battle/Gen4TrainerPersonality.lua create mode 100644 tools/gen4_trainer_personality_check.lua create mode 100644 tools/gen4_trainer_personality_check/conf.lua create mode 100644 tools/gen4_trainer_personality_check/main.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 02d8ffb07..2e52a2ca0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1745,6 +1745,10 @@ local function buildTrainerParty(game, oppClass, partyIndex, partyDef) -- other trainer in the game is unchanged. local dvs = slot.dvs or trainerDvs mon.dvs = dvs + -- GEN 4 (Platinum family): TrainerData_BuildParty's own personality, so + -- nature / ability slot / gender are the cartridge's, not rolled + local cartridgePid = require("src.battle.Gen4TrainerPersonality").apply( + game, game.data.trainers and game.data.trainers[oppClass], slot, mon) if slot.statExp then mon.statExp = slot.statExp end if slot.happiness then mon.happiness = slot.happiness end if slot.stats then @@ -1757,7 +1761,7 @@ local function buildTrainerParty(game, oppClass, partyIndex, partyDef) else local def=require('src.pokemon.Gen4Forms').definition(game.data,mon) if (game.data.constants or {}).gen==4 then - mon.ivs=slot.dvs or mon.ivs + if not cartridgePid then mon.ivs=slot.dvs or mon.ivs end mon.stats=require('src.pokemon.Stats').calc( def,slot.level,mon.ivs,nil,mon.evs,mon.nature) else diff --git a/src/battle/Gen4TrainerPersonality.lua b/src/battle/Gen4TrainerPersonality.lua new file mode 100644 index 000000000..dcc3bad75 --- /dev/null +++ b/src/battle/Gen4TrainerPersonality.lua @@ -0,0 +1,110 @@ +-- A GEN 4 TRAINER'S POKEMON HAVE ONE PERSONALITY, AND IT IS THE SAME EVERY FIGHT. +-- +-- pret/pokeplatinum src/trainer_data.c, TrainerData_BuildParty (all four +-- TRDATATYPE_* arms are the same arithmetic): +-- +-- genderMod = TrainerClass_Gender(trainerType) == GENDER_FEMALE ? 120 : 136; +-- for each party member: +-- rnd = ivScale + level + (species & 0x3FF) + trainerID; // u32 +-- LCRNG_SetSeed(rnd); +-- for (j = 0; j < trainerType; j++) rnd = LCRNG_Next(); // u16 +-- rnd = (rnd << 8) + genderMod; // 24 bits +-- ivs = ivScale * 31 / 255; // all six +-- Pokemon_InitWith(mon, species, level, ivs, TRUE, rnd, +-- OTID_NOT_SHINY, 0); +-- ... and the global LCRNG seed is saved on entry, restored on exit. +-- +-- `trainerType` there is the trainer CLASS (header.trainerType; the cache's +-- `class`). LCRNG_Next (math_util.c) is +-- state = state * 0x41C64E6D + 0x6073; return state >> 16; (u16) +-- Consequences, all flowing from the one number: +-- nature personality % 25 +-- ability slot personality bit 0 == bit 0 of genderMod (120 / 136, both +-- even) -> ALWAYS the first ability +-- gender low byte (120 / 136) against the species' ratio: a 50/50 +-- species takes its trainer class's gender, otherwise the +-- commoner one; ratio 0 / 254 / 255 are fixed +-- shininess OTID_NOT_SHINY re-rolls the OT id until the mon is not +-- shiny, so a trainer's mon is never shiny +-- +-- TrainerClass_Gender indexes `sTrainerClassGender` +-- (include/data/trainer_class_genders.h). The table was located in the +-- arm9 of BOTH the USA Platinum ROM and the Renegade Platinum ROM at the +-- same offset (0xF0894) and is byte-identical to the decomp's, so the +-- female list below holds for both. HeartGold / SoulSilver are NOT this +-- code (pokeheartgold CreateNPCTrainerParty adds TrMon_OverridePidGender +-- and a different class table) and are left alone. +local P = {} + +-- class ids whose sTrainerClassGender entry is GENDER_FEMALE +local FEMALE = {} +for _, id in ipairs({ 1, 3, 5, 7, 8, 10, 13, 17, 18, 21, 22, 25, 26, 30, 33, + 35, 36, 40, 43, 45, 50, 54, 56, 61, 66, 69, 72, 74, 76, 77, 78, 80, 84, 85, + 87, 88, 89, 90, 92, 94, 96, 98, 99, 101, 104 }) do + FEMALE[id] = true +end + +local MOD32 = 4294967296 + +-- state * 0x41C64E6D + 0x6073 mod 2^32, without leaving double precision +local function step(state) + local sh, sl = math.floor(state / 65536), state % 65536 + local ah, al = 0x41C6, 0x4E6D + local lo = sl * al + 0x6073 + local hi = (sh * al + sl * ah) % 65536 + return (lo + hi * 65536) % MOD32 +end + +P.step = step + +function P.genderMod(class) + return FEMALE[tonumber(class) or -1] and 120 or 136 +end + +-- the u16 `difficulty` the cartridge reads: the importer splits it into the +-- low byte (`ivScale`) and the high byte (`genderAbilityOverride`) +function P.ivScale16(slot) + return (tonumber(slot.ivScale) or 0) + 256 * (tonumber(slot.genderAbilityOverride) or 0) +end + +function P.personality(trainerId, class, slot) + local species = (tonumber(slot.species) or 0) % 1024 + local state = (P.ivScale16(slot) + (tonumber(slot.level) or 0) + species + + trainerId) % MOD32 + local rnd = state + for _ = 1, class do + state = step(state) + rnd = math.floor(state / 65536) + end + return (rnd * 256 + P.genderMod(class)) % MOD32 +end + +-- ivScale * 31 / 255, into the 5-bit IV field +function P.iv(slot) + return math.floor(P.ivScale16(slot) * 31 / 255) % 32 +end + +-- Applies the cartridge's personality, IVs and (by Pokemon.applySeed) nature, +-- ability slot and stats to `mon`. Returns true when it did; false leaves the +-- mon untouched (not Gen 4 Platinum family, a stored mon, a synthetic trainer). +function P.apply(game, trainerRec, slot, mon) + local GV = require("src.core.GameVersion") + if not GV.isPlatinumFamily() then return false end + if type(trainerRec) ~= "table" or type(slot) ~= "table" then return false end + local id, class = tonumber(trainerRec.id), tonumber(trainerRec.class) + if not (id and class and slot.ivScale ~= nil and slot.prebuilt == nil) then + return false + end + local iv = P.iv(slot) + local ivs = { hp = iv, attack = iv, defense = iv, speed = iv, spatk = iv, + spdef = iv, special = iv } + require("src.pokemon.Pokemon").applySeed(game.data, mon, { + personality = P.personality(id, class, slot), ivs = ivs }) + mon.dvs = ivs + -- OTID_NOT_SHINY: no trainer's Pokemon is shiny, whatever the shiny dial + mon.shiny = nil + mon.gender = nil + return true +end + +return P diff --git a/tools/gen4_trainer_personality_check.lua b/tools/gen4_trainer_personality_check.lua new file mode 100644 index 000000000..8d7a0305a --- /dev/null +++ b/tools/gen4_trainer_personality_check.lua @@ -0,0 +1,67 @@ +-- Pure-Lua check of src/battle/Gen4TrainerPersonality.lua against +-- pret/pokeplatinum TrainerData_BuildParty (src/trainer_data.c). +-- python tools/run_lua_check.py tools/gen4_trainer_personality_check.lua \ +-- +-- (caches: ...LOVE/v_usa/platinum and ...LOVE/v_rp/renegadeplatinum) +-- +-- The expected personalities below come from an INDEPENDENT integer +-- implementation of the decomp's algorithm (python, masked 32-bit math): +-- seed = ivScale + level + (species & 0x3FF) + trainerId +-- run the LCRNG (x*0x41C64E6D+0x6073) `class` times, keep x >> 16 +-- pid = (that << 8) + (female class ? 120 : 136) +-- The end-to-end half (buildTrainerParty through the real Data) is +-- tools/gen4_trainer_personality_check/ (lovec). +package.path = "./?.lua;" .. package.path +local P = require("src.battle.Gen4TrainerPersonality") +local n = 0 +local function check(v, why) n = n + 1; if not v then error("FAIL: " .. why, 2) end end + +-- { cache, trainer id, class, ivScale, level, species, personality, nature } +local CASES = { + { "usa", 1, 2, 0, 5, 396, 9229448, 23 }, -- Youngster Tristan's Starly + { "usa", 3, 3, 0, 5, 399, 10115192, 17 }, -- Lass Natalie's Bidoof (female class: low byte 0x78) + { "usa", 408, 88, 200, 40, 454, 11684728, 3 }, -- Commander Saturn's Toxicroak (class 88 is female in the ROM table) + { "usa", 445, 14, 30, 38, 454, 1153416, 16 }, -- Black Belt Kendal's Toxicroak + { "rp", 408, 88, 255, 53, 454, 4054136, 11 }, + { "rp", 959, 88, 255, 34, 454, 5544312, 12 }, + { "rp", 317, 76, 248, 38, 454, 5523576, 1 }, + { "rp", 148, 14, 250, 51, 454, 13765256, 6 }, +} +for _, c in ipairs(CASES) do + local slot = { ivScale = c[4], level = c[5], species = c[6] } + local pid = P.personality(c[2], c[3], slot) + check(pid == c[7], ("%s trainer %d: personality %d (0x%x), want %d"):format(c[1], c[2], pid, pid, c[7])) + check(pid % 25 == c[8], "nature of " .. c[7]) + check(P.personality(c[2], c[3], slot) == pid, "determinism") + check(pid % 2 == 0 and (pid % 256 == 120 or pid % 256 == 136) and pid < 2 ^ 24, "shape") + check((pid % 256 == 120) == (c[3] == 3 or c[3] == 88 or c[3] == 76), "gender byte follows the class") +end +check(P.iv({ ivScale = 255 }) == 31 and P.iv({ ivScale = 0 }) == 0 + and P.iv({ ivScale = 200 }) == 24 and P.iv({ ivScale = 100 }) == 12, "ivScale*31/255") +-- the LCRNG itself: seed 0 -> 0x6073; seed 1 -> 0x41C64E6D + 0x6073 +check(P.step(0) == 0x6073 and P.step(1) == 0x41C64E6D + 0x6073, "LCRNG step") +check(P.step(0xFFFFFFFF) == (0x100000000 - 0x41C64E6D + 0x6073) % 0x100000000, "LCRNG wraps like u32") + +-- sweep every party slot of both caches +for _, root in ipairs({ arg[1], arg[2] }) do + local tr = assert(loadfile(root .. "/data/generated/trainers.lua"))() + local pk = assert(loadfile(root .. "/data/generated/pokemon.lua"))() + local slots, tox = 0, 0 + for id = 0, 4000 do + local rec = tr[id] + if rec and rec.party and rec.class then + for _, s in ipairs(rec.party) do + local pid = P.personality(id, rec.class, s) + check(pid % 2 == 0 and pid < 2 ^ 24 and (pid % 256 == 120 or pid % 256 == 136), "slot shape") + slots = slots + 1 + local def = pk[s.species] + if root == arg[2] and s.species == 454 and def then + tox = tox + 1 + check(def.abilities[(pid % 2) + 1] == "DRY_SKIN", "Renegade Toxicroak slot -> Dry Skin") + end + end + end + end + print(root, "slots", slots, "renegade toxicroak", tox) +end +print("gen4_trainer_personality_check: " .. n .. " checks ok") diff --git a/tools/gen4_trainer_personality_check/conf.lua b/tools/gen4_trainer_personality_check/conf.lua new file mode 100644 index 000000000..f1f11fc0e --- /dev/null +++ b/tools/gen4_trainer_personality_check/conf.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Cedric. All rights reserved. +-- Source-available under the Gen2Recomped License (see LICENSE.md): you may +-- read, build and privately modify this file; you may not redistribute it or +-- use it commercially. Cartridge-derived data is excluded and is not the +-- copyright holder's to license. + +function love.conf(t) + -- One identity for every fuzz run so the save directory is predictable. + -- POKEPORT_IDENTITY overrides it exactly as the main game's conf does. + t.identity = os.getenv("POKEPORT_IDENTITY") or "pl_battle" + t.console = true + t.window.width, t.window.height = 512, 384 + t.window.title = "gen4 trainer personality check" + t.modules.joystick = false + t.modules.physics = false + t.modules.video = false + t.modules.touch = false +end diff --git a/tools/gen4_trainer_personality_check/main.lua b/tools/gen4_trainer_personality_check/main.lua new file mode 100644 index 000000000..862392f59 --- /dev/null +++ b/tools/gen4_trainer_personality_check/main.lua @@ -0,0 +1,69 @@ +-- End-to-end check: BattleState.buildTrainerParty builds every Gen 4 trainer +-- party with the cartridge's personality (TrainerData_BuildParty). +-- CACHE= POKEPORT_VERSION=renegadeplatinum \ +-- POKEPORT_DATA_DIR=/data/generated POKEPORT_ASSET_ROOT= \ +-- "C:/Program Files/LOVE/lovec.exe" tools/gen4_trainer_personality_check +-- (version defaults to platinum). Verifies for every party slot of every +-- trainer: personality == Gen4TrainerPersonality, nature = pid % 25, ability +-- slot first, gender from the low byte, never shiny, IVs = ivScale*31/255, +-- stats recomputed from those IVs and that nature, and two builds agree. +local function out(fmt, ...) io.stdout:write(fmt:format(...), "\n"); io.stdout:flush() end + +function love.load() + local ok, err = xpcall(function() + local version = os.getenv("POKEPORT_VERSION") or "platinum" + require("src.core.GameVersion").set(version) + local Data = require("src.core.Data") + Data:load() + assert(Data.isGen4Cache, "not a Gen 4 cache") + local Game = require("src.core.Game") + Game.data = Data + local BattleState = require("src.battle.BattleState") + local P = require("src.battle.Gen4TrainerPersonality") + local Pokemon = require("src.pokemon.Pokemon") + local Stats = require("src.pokemon.Stats") + local Forms = require("src.pokemon.Gen4Forms") + local order = Data.constants.natureOrder + local fails, mons, tox = {}, 0, 0 + local function fail(m) if #fails < 20 then fails[#fails + 1] = m end end + for id = 1, 4000 do + local rec = Data.trainers[id] + if rec and rec.parties and rec.parties[1] and rec.class then + local a = BattleState.buildTrainerParty(Game, id, 1, rec.parties[1]) + local b = BattleState.buildTrainerParty(Game, id, 1, rec.parties[1]) + for i, slot in ipairs(rec.parties[1]) do + local m, m2 = a[i], b[i] + mons = mons + 1 + local tag = ("trainer %d slot %d"):format(id, i) + local pid = P.personality(id, rec.class, slot) + if m.personality ~= pid then fail(tag .. " personality") end + if m2.personality ~= pid then fail(tag .. " not deterministic") end + if m.nature ~= order[(pid % 25) + 1] then fail(tag .. " nature") end + if m.abilitySlot ~= 1 then fail(tag .. " ability slot " .. tostring(m.abilitySlot)) end + if Pokemon.isShiny(m) or m.shiny then fail(tag .. " shiny") end + local def = Forms.definition(Data, m) + local iv = P.iv(slot) + if m.ivs.hp ~= iv or m.ivs.spdef ~= iv then fail(tag .. " ivs") end + local want = Stats.calc(def, m.level, m.ivs, nil, m.evs, m.nature) + for k, v in pairs(want) do if m.stats[k] ~= v then fail(tag .. " stat " .. k) end end + if m.hp ~= m.stats.hp then fail(tag .. " hp") end + local g = Pokemon.genderOf(Data, m) + local ratio = def.genderRatio + local exp = (ratio == 255 and "none") or (ratio == 0 and "male") or (ratio == 254 and "female") + or ((ratio > pid % 256) and "female" or "male") + if g ~= exp then fail(tag .. " gender " .. tostring(g) .. " want " .. exp) end + if version == "renegadeplatinum" and slot.species == 454 then + tox = tox + 1 + local AbilityOf = require("src.pokemon.AbilityOf") + if AbilityOf.pick(m, def.abilities) ~= "DRY_SKIN" then fail(tag .. " Toxicroak not Dry Skin") end + end + end + end + end + out("%s: %d trainer mons, %d renegade toxicroak, %d failures", version, mons, tox, #fails) + for _, m in ipairs(fails) do out(" FAIL %s", m) end + love.event.quit(#fails > 0 and 1 or 0) + end, debug.traceback) + if not ok then out("CHECK FAILED:\n%s", tostring(err)); love.event.quit(2) end +end +function love.draw() end From e3042bce4728f841b7244c8ef8a8e2d7c4a4c4ee Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 12:03:16 +0100 Subject: [PATCH 54/60] Field sweep: cycling-prompt threshold --- tools/gen4_field_sweep_harness/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/gen4_field_sweep_harness/main.lua b/tools/gen4_field_sweep_harness/main.lua index c040cae07..c629c101e 100644 --- a/tools/gen4_field_sweep_harness/main.lua +++ b/tools/gen4_field_sweep_harness/main.lua @@ -922,7 +922,7 @@ local function closeScreens(label, floorDepth) end local detail = ("%s: B did not close the screens | top=%s depth=%d | stack=%s | top fields: %s | recent: %s"):format(label, describeTop(), stackDepth(), table.concat(names, " > "), shallow(top()), ringDump(tonumber(getenv('RING_DUMP') or '') or 16)) - if pushCount - pushesAtStart >= 20 then + if pushCount - pushesAtStart >= 6 then -- states kept being pushed: a prompt that re-asks (the move-learn "abandon?" -- loop answers NO to itself under B) -- cycling, not hung emit(("SWEEP_LOOP|%s|%s|close|%d pushes while closing | %s"):format(cur.map, cur.phase, pushCount - pushesAtStart, oneLine(detail, 700))) From 8a86923aff545958d88b838d6ecfa6104e716fe0 Mon Sep 17 00:00:00 2001 From: Darkatek7 Date: Sat, 14 Nov 2026 12:09:46 +0100 Subject: [PATCH 55/60] German battle: status, weather, items, abilities, hazards and stat lines from the cartridge's bank 368 Gen4BattleText now also fills the generated table src/battle/Gen4BattleTextMap.lua (tools/gen4_battle_text_map.py aligns the engine's English templates with the USA bank 368 and writes only entry numbers; a hand table covers the Red/Blue and Black/White phrasings), stat changes from the battle's stat bank (551), the charge turn of two-turn moves and the vortex trap lines. The fuzzer's --trace de prints the translated line, the harness has SAY= to put lines in the box. --- src/battle/BattleState.lua | 8 + src/battle/Gen4BattleText.lua | 302 ++++++++++++++++++--- src/battle/Gen4BattleTextMap.lua | 309 ++++++++++++++++++++++ tools/gen4_battle_fuzz/main.lua | 10 +- tools/gen4_battle_harness/main.lua | 19 ++ tools/gen4_battle_text_check.lua | 47 +++- tools/gen4_battle_text_map.py | 406 +++++++++++++++++++++++++++++ tools/gen4_battle_text_map_cand.py | 71 +++++ 8 files changed, 1138 insertions(+), 34 deletions(-) create mode 100644 src/battle/Gen4BattleTextMap.lua create mode 100644 tools/gen4_battle_text_map.py create mode 100644 tools/gen4_battle_text_map_cand.py diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 02d8ffb07..dacfa121e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8694,6 +8694,14 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima self:animNext(chargeAnim, user.isPlayer) end end + -- a non-English Gen 4 cartridge has the charge line of each move itself + if self.data and self.data.gen4_language and not self.bw then + local own = require("src.battle.Gen4BattleText").chargeLine(self, move, user) + if own then + self:sayNext(own) + return + end + end local chargeText = move.chargeText or CHARGE_TEXT[move.id] or Strings.source("%s\nis charging up!") -- the template is a source string (a move record may supply its own), diff --git a/src/battle/Gen4BattleText.lua b/src/battle/Gen4BattleText.lua index 789c1d305..68edcff08 100644 --- a/src/battle/Gen4BattleText.lua +++ b/src/battle/Gen4BattleText.lua @@ -12,19 +12,27 @@ -- (`data.gen4_language`), so an English cache prints exactly what it did. A -- line no pattern knows is left as the engine wrote it. -- --- The indices were read off an imported US cache by the line they start with --- (grep TEXT_B0368_); the placeholders ({STRVAR_1 0}) are --- filled from the arguments by type: 1 a Pokemon name, 3 a trainer, 6 a move, --- 13 a stat, 50/52/54 a number. +-- THREE WAYS IN, tried in this order: +-- 1. T.PATTERNS: the lines worked out first (move used, appearing, fainted, +-- the trainer's challenge ...), read off an imported US cache by the line +-- they start with (grep TEXT_B0368_). +-- 2. stat changes ("X's ATTACK rose!"): the stat's name is the battle's own +-- (bank 551), the verb picks the entry. +-- 3. src/battle/Gen4BattleTextMap.lua, GENERATED by tools/gen4_battle_text_map.py: +-- every engine template that is also a line of bank 368 (matched by its +-- wording against the USA cartridge's English) plus a hand table of the +-- lines the engine words differently. Only entry NUMBERS are in it; the +-- text is read from the running cache. +-- +-- The placeholders ({STRVAR_1 0}) are filled from the printed +-- names by type: 1 a Pokemon (nickname), 3 a trainer, 5 an ability, 6 a move, +-- 8 an item, 13 a stat, 14 a trainer class, 50/52/54 a number. local T = {} T.BANK = 368 T.BANK_MOVE_USED = 0 --- the engine's stat labels -> the summary's stat-name entries (bank 201) -T.STAT_TEXT = { ["ATTACK"] = 54, ["DEFENSE"] = 56, ["SP. ATK"] = 58, ["SP. DEF"] = 60, ["SPEED"] = 62 } - local function key(bank, index) return ("TEXT_B%04d_%05d"):format(bank, index) end local function line(data, bank, index) @@ -32,6 +40,14 @@ local function line(data, bank, index) return type(s) == "string" and s ~= "" and s or nil end +-- the cartridge's page breaks and controls as this engine's line breaks +local function tidy(out) + out = out:gsub("{[^}]*}", "") + out = out:gsub("\012", "\n") -- a page break: the box scrolls + out = out:gsub("[\011]+$", "") -- the ROM's trailing wait marker is the box's own concern + return out +end + -- fill a cartridge line: vals[type] = { slot0, slot1, ... } local function fill(raw, vals) local out = raw:gsub("{WAIT %d+}", ""):gsub("{STRVAR_1 (%d+) (%d+) %d+}", function(kind, slot) @@ -39,9 +55,7 @@ local function fill(raw, vals) local v = list and list[tonumber(slot) + 1] return v ~= nil and tostring(v) or "" end) - -- the ROM's trailing wait marker is the box's own concern - out = out:gsub("[\011]+$", "") - return out + return tidy(out) end -- which of the three variants a printed name belongs to @@ -73,6 +87,13 @@ local function three(base, extra) end end +local function plain(index) + return function(battle) local raw = line(battle.data, T.BANK, index) return raw and fill(raw, {}) end +end + +-- move -> the "was trapped" entry +T.TRAP = { [250] = 242, [83] = 242, [328] = 245, [463] = 1247 } + -- patterns: { lua pattern over the engine's text, handler(battle, captures...) } T.PATTERNS = { { "^(Enemy )(.-)\nused (.-)!$", function(battle, prefix, name, move) @@ -91,10 +112,10 @@ T.PATTERNS = { return raw and fill(raw, { [1] = { name } }) end }, { "^(Enemy )(.-)\nfainted!$", three(30) }, { "^(.-)\nfainted!$", function(battle, name) return three(30)(battle, "", name) end }, - { "^It's super\neffective!$", function(battle) return line(battle.data, T.BANK, 780) and fill(line(battle.data, T.BANK, 780), {}) end }, - { "^It's not very\neffective%.%.%.$", function(battle) return line(battle.data, T.BANK, 779) and fill(line(battle.data, T.BANK, 779), {}) end }, - { "^Critical hit!$", function(battle) return line(battle.data, T.BANK, 774) and fill(line(battle.data, T.BANK, 774), {}) end }, - { "^One%-hit KO!$", function(battle) return line(battle.data, T.BANK, 775) and fill(line(battle.data, T.BANK, 775), {}) end }, + { "^It's super\neffective!$", plain(780) }, + { "^It's not very\neffective%.%.%.$", plain(779) }, + { "^Critical hit!$", plain(774) }, + { "^One%-hit KO!$", plain(775) }, { "^(Enemy )(.-)'s\nattack missed!$", three(12) }, { "^(.-)'s\nattack missed!$", function(battle, name) return three(12)(battle, "", name) end }, { "^It doesn't affect\n(Enemy )(.-)%.%.%.$", three(27) }, @@ -108,35 +129,250 @@ T.PATTERNS = { { "^Gotcha!\n(.-) was caught!$", function(battle, name) local raw = line(battle.data, T.BANK, 867) return raw and fill(raw, { [1] = { name } }) end }, - { "^Got away safely!$", function(battle) return line(battle.data, T.BANK, 781) and fill(line(battle.data, T.BANK, 781), {}) end }, - { "^Can't escape!$", function(battle) return line(battle.data, T.BANK, 42) and fill(line(battle.data, T.BANK, 42), {}) end }, + { "^Got away safely!$", plain(781) }, + { "^Can't escape!$", plain(42) }, { "^(.-) sent\nout (.-)!$", function(battle, who, mon) local raw = line(battle.data, T.BANK, 974) return raw and fill(raw, { [3] = { who }, [1] = { nil, mon } }) end }, -- "Bug Catcher Jack wants to fight!": the class and the name are the -- trainer record's, which the cartridge prints from two slots + -- "X was trapped by !": the vortex lines (Whirlpool, Fire Spin 242, Sand + -- Tomb 245, Magma Storm 1247), each three variants; the others name the + -- attacker, which the engine's line does not + { "^(Enemy )(.-) was trapped\nby (.-)!$", function(battle, prefix, name, move) + local at = T.TRAP[moveIdByName(battle, move) or 0] + return at and three(at)(battle, prefix, name) end }, + { "^(.-) was trapped\nby (.-)!$", function(battle, name, move) + local at = T.TRAP[moveIdByName(battle, move) or 0] + return at and three(at)(battle, "", name) end }, { "^(.-) wants\nto fight!$", function(battle) local t = battle.trainer local raw = t and line(battle.data, T.BANK, 969) return raw and fill(raw, { [14] = { t.className }, [3] = { nil, t.name } }) end }, - -- "(Enemy )NAME's\nSTAT rose!" / "fell!" - { "^(Enemy )(.-)'s\n(.-) (rose)!$", nil }, } --- a stat change names the stat in the cartridge's language -local function statLine(base) - return function(battle, prefix, name, stat) - local at = T.STAT_TEXT[stat] - local label = at and line(battle.data, 201, at) - if not label then return nil end - return three(base, function(vals) vals[13] = { label } end)(battle, prefix, name) +-- --------------------------------------------------------------------------- +-- A STAT CHANGE names the stat in the cartridge's language: the battle's own +-- stat-name bank (551: HP, attack, defense, speed, sp. atk, sp. def, accuracy, +-- evasiveness). Entries: 750 rose, 753 sharply rose, 762 fell, 765 harshly +-- fell, 142 won't go higher, 145 won't go lower; each three variants (ally, +-- wild, foe). +-- --------------------------------------------------------------------------- +T.STAT_BANK = 551 +T.STAT_INDEX = { ["HP"] = 0, ["ATTACK"] = 1, ["DEFENSE"] = 2, ["SPEED"] = 3, ["SP. ATK"] = 4, ["SPCL.ATK"] = 4, + ["SP. DEF"] = 5, ["SPCL.DEF"] = 5, ["ACCURACY"] = 6, ["accuracy"] = 6, ["EVASION"] = 7, ["evasiveness"] = 7, + ["EVASIVENESS"] = 7 } +T.STAT_VERB = { ["rose"] = 750, ["greatly rose"] = 753, ["fell"] = 762, ["greatly fell"] = 765, + ["won't go higher"] = 142, ["won't go lower"] = 145 } + +local function statName(data, label) + local at = T.STAT_INDEX[label] + return at and line(data, T.STAT_BANK, at) or nil +end + +local function statChange(battle, text) + local flat = text:gsub("[\n\v]+", " ") + local best + for verb, base in pairs(T.STAT_VERB) do + local tail = " " .. verb .. "!" + if flat:sub(-#tail) == tail and (not best or #verb > #best[1]) then + local head = flat:sub(1, #flat - #tail) + local name, stat = head:match("^(.-)'s (.+)$") + if name and statName(battle.data, stat) then best = { verb, base, name, stat } end + end end + if not best then return nil end + local prefix = best[3]:match("^Enemy ") and "Enemy " or "" + local label = statName(battle.data, best[4]) + return three(best[2], function(vals) vals[13] = { label, label, label, label } end)(battle, prefix, (best[3]:gsub("^Enemy ", ""))) +end + +-- --------------------------------------------------------------------------- +-- THE GENERATED TABLE (Gen4BattleTextMap.lua) +-- +-- Per row: the line as the engine prints it (%s for each filled-in name), what +-- each %s is ("type.slot" of the bank entry; "side" only picks the variant; "-" +-- is dropped), the entry of each variant by the sides of the pokemon names +-- (a ally, w wild, f foe), and options: fixed (constants the engine prints as +-- English: "move:", "ability: