From 1c2a7d64dec62836ec97e034b731bdd08aecd739 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:20:31 +0200 Subject: [PATCH 1/3] Give the FireRed rival and champion the rival's name by class id Trainers.info gave a trainer the player's chosen rival name only when its class was called "RIVAL". A mod that translates the class (RIVALE in German and Italian) made the rival battle as the ROM's placeholder TERRY, and the champion, whose class is CHAMPION, was always TERRY. pret's B_TXT_TRAINER1_NAME names TRAINER_CLASS_RIVAL_EARLY, _RIVAL_LATE and _CHAMPION by the rival's name (pokefirered/src/battle_message.c:2078); Trainers.info now does the same, by class id. --- src/core/game3/scripting/trainers.lua | 14 ++++++++-- tests/engine/game3_trainer_class_ids_test.lua | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/engine/game3_trainer_class_ids_test.lua diff --git a/src/core/game3/scripting/trainers.lua b/src/core/game3/scripting/trainers.lua index 602971e3..35ce494f 100644 --- a/src/core/game3/scripting/trainers.lua +++ b/src/core/game3/scripting/trainers.lua @@ -8,6 +8,11 @@ local SPECIES_BULBASAUR = 1 local SPECIES_CHARMANDER = 4 local SPECIES_SQUIRTLE = 7 +-- pokefirered/include/constants/trainers.h:264, :272, :273 +local TRAINER_CLASS_RIVAL_EARLY = 81 +local TRAINER_CLASS_RIVAL_LATE = 89 +local TRAINER_CLASS_CHAMPION = 90 + local TRAINER_RIVAL_OAKS_LAB_SQUIRTLE = 326 local TRAINER_RIVAL_OAKS_LAB_BULBASAUR = 327 local TRAINER_RIVAL_OAKS_LAB_CHARMANDER = 328 @@ -263,7 +268,8 @@ function Trainers.foeFromId(trainerId) end --- ROM-derived trainer presentation info (class / name / pic / partySize / dialogs). --- opts.rivalName replaces placeholder "TERRY" for class RIVAL when provided. +-- opts.rivalName replaces the placeholder "TERRY" of the rival and champion +-- classes when provided. function Trainers.info(trainerId, opts) opts = opts or {} trainerId = tonumber(trainerId) @@ -289,7 +295,11 @@ function Trainers.info(trainerId, opts) dialogs = t.dialogs, } - if info.className == "RIVAL" and opts.rivalName and opts.rivalName ~= "" then + -- pokefirered/src/battle_message.c:2078 names these classes by the player's + -- rival, recognised by class id: a mod may rename the class itself. + local class = tonumber(info.class) + if (class == TRAINER_CLASS_RIVAL_EARLY or class == TRAINER_CLASS_RIVAL_LATE + or class == TRAINER_CLASS_CHAMPION) and opts.rivalName and opts.rivalName ~= "" then info.name = opts.rivalName end return info diff --git a/tests/engine/game3_trainer_class_ids_test.lua b/tests/engine/game3_trainer_class_ids_test.lua new file mode 100644 index 00000000..6f76377b --- /dev/null +++ b/tests/engine/game3_trainer_class_ids_test.lua @@ -0,0 +1,28 @@ +#!/usr/bin/env luajit +-- The rival and the champion take the player's chosen rival name by class id, +-- as pret's B_TXT_TRAINER1_NAME does (pokefirered/src/battle_message.c:2078), +-- so a mod that translates the class ("RIVALE", "MAÎTRE") keeps the name. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") + +local T = require("tests.harness") +local check = T.check + +local Trainers = require("src.core.game3.scripting.trainers") +local rows = { + [326] = { id = 326, class = 81, className = "RIVALE", name = "TERRY", party = {} }, + [438] = { id = 438, class = 90, className = "MAÎTRE", name = "TERRY", party = {} }, + [500] = { id = 500, class = 89, className = "RIVAL", name = "TERRY", party = {} }, + [347] = { id = 347, class = 82, className = "RIVAL", name = "IVAN", party = {} }, +} +Trainers.get = function(id) return rows[tonumber(id)] end + +local function nameOf(id) return Trainers.info(id, { rivalName = "GARY" }).name end +check(nameOf(326) == "GARY", "the early rival takes the rival name, whatever its class is called") +check(nameOf(500) == "GARY", "so does the late rival") +check(nameOf(438) == "GARY", "and the champion, as on the cart") +check(nameOf(347) == "IVAN", "another class keeps its own trainer's name, even one called RIVAL") +check(Trainers.info(326, {}).name == "TERRY", "with no rival name, the ROM's placeholder stays") + +T.finish("game3_trainer_class_ids_test") From fa0ab3621fba9c2bb02f0d37abdbd2986d9c4e81 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:20:31 +0200 Subject: [PATCH 2/3] Record FireRed gym, Elite Four and champion wins by class id The quest log told a gym leader, Elite Four or champion win from the trainer's class name ("LEADER", "ELITE FOUR", "CHAMPION"). With a translated class every such win fell back to the plain trainer event, and in French, where the gym LEADER class reads CHAMPION, every gym win would be logged as a champion battle. pret switches on the class id (pokefirered/src/quest_log_battle.c:25); the battle state now keeps the trainer's class id next to its name, and the recorder reads it. --- src/core/game3/battle/init.lua | 2 ++ src/core/game3/quest_log_recorder.lua | 10 ++++++---- tests/game3_quest_log_integration_test.lua | 13 ++++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index a962f468..83e3534d 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -529,10 +529,12 @@ function Battle.start(opts) })) st.trainerId = trainerId + st.trainerClass = trainerInfo and tonumber(trainerInfo.class) st.trainerClassName = trainerInfo and trainerInfo.className st.trainerName = (trainerInfo and trainerInfo.name) or opts.trainerName -- pokefirered/src/battle_message.c:394 the link opponent is named, never classed if st.link and not st.unionRoom and st.peerName then + st.trainerClass = nil st.trainerClassName = "" st.trainerName = st.peerName end diff --git a/src/core/game3/quest_log_recorder.lua b/src/core/game3/quest_log_recorder.lua index 48568013..f27576da 100644 --- a/src/core/game3/quest_log_recorder.lua +++ b/src/core/game3/quest_log_recorder.lua @@ -91,12 +91,14 @@ function R.battle(session,st) local outcome=hp>=math.floor(max/3)*2 and 'Handily' or (hp>=math.floor(max/3) and 'Tenaciously' or 'Somehow') local args={D0=loc,D1=st.trainerName or 'TRAINER',D2=enemy,D3=player,D4={text=outcome}} local key='TookOnTrainersMonWithMonAndWon' - local class=st.trainerClassName or '' - if class=='LEADER' then key='TookOnGymLeadersMonWithMonAndWon' - elseif class=='ELITE FOUR' then + -- pokefirered/src/quest_log_battle.c:25 switches on the class id, which a + -- mod renaming the class leaves alone (include/constants/trainers.h:267-273) + local class=tonumber(st.trainerClass) + if class==84 then key='TookOnGymLeadersMonWithMonAndWon' + elseif class==87 then key='TookOnEliteFoursMonWithMonAndWon' args={D0=st.trainerName,D1=enemy,D2=player,D3={text=outcome}} - elseif class=='CHAMPION' then + elseif class==90 then key='PlayerBattledChampionRival';args={D0=session.name,D1=st.trainerName} end R.event(session,key,args) diff --git a/tests/game3_quest_log_integration_test.lua b/tests/game3_quest_log_integration_test.lua index a83b5c73..2be33983 100644 --- a/tests/game3_quest_log_integration_test.lua +++ b/tests/game3_quest_log_integration_test.lua @@ -42,9 +42,20 @@ local a={'TEST'};Recorder.event(session,'ArrivedInLocation',a);a[1]='WRONG' assert(session.questLog.scenes[#session.questLog.scenes].events[2].args[1]=='TEST') local other={map='FR_OTHER'};Recorder.event(other,'ArrivedInLocation',a);assert(other.questLog==nil) local Pokemon=require('src.core.game3.pokemon');Pokemon.displayMonName=function(m)return m.name end -local st={wild=false,result='win',trainerName='BROCK',trainerClassName='LEADER', +-- The French cart calls a gym LEADER "CHAMPION": the class id decides, not its name. +local st={wild=false,result='win',trainerName='BROCK',trainerClass=84,trainerClassName='CHAMPION', player={mon={name='BULBASAUR',hp=20,maxHp=30}},enemy={mon={name='ONIX'}}} Recorder.battle(session,st) local e=session.questLog.scenes[#session.questLog.scenes].events[3] assert(e.key=='TookOnGymLeadersMonWithMonAndWon' and e.args.D4.text=='Handily') +local function keyFor(class,className) + local s={wild=false,result='win',trainerName='X',trainerClass=class,trainerClassName=className, + player={mon={name='BULBASAUR',hp=20,maxHp=30}},enemy={mon={name='ONIX'}}} + Recorder.battle(session,s) + local evs=session.questLog.scenes[#session.questLog.scenes].events + return evs[#evs].key +end +assert(keyFor(87,'CONSEIL 4')=='TookOnEliteFoursMonWithMonAndWon') +assert(keyFor(90,'MAÎTRE')=='PlayerBattledChampionRival') +assert(keyFor(82,'LEADER')=='TookOnTrainersMonWithMonAndWon') print('PASS Quest Log Continue, legacy save, quit, RNG isolation, event scoping and battle summary') From 5ad701022b09d33c600159fad3f1d27d6f453ba8 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:20:31 +0200 Subject: [PATCH 3/3] Pick the FireRed Elite Four and champion transitions by class id BattleTransition.pickTrainer told the Elite Four and the champion by class names ("ELITE_FOUR", "CHAMPION", "RIVAL") or by class ids 57 and 58, and the Elite Four members by trainer ids 412-419. None of these is FireRed's: TRAINER_CLASS_ELITE_FOUR is 87, TRAINER_CLASS_CHAMPION 90, and Lorelei, Bruno, Agatha and Lance are trainers 410-413 (735-738 for the rematch). The bridge also never passed the class, so these battles always got the terrain transition. The bridge now hands over the foe's class id and pickTrainer follows GetTrainerBattleTransition (pokefirered/src/battle_setup.c:624): the Elite Four member's own transition, BLUE for the champion, and none for the rival's class (a caller's isRival flag still asks for BLUE). A Trainer Tower or e-Reader foe carries a facility class instead, whose numbers overlap the trainer classes (FACILITY_CLASS_LASS is 90, the champion's class), and pret never picks their transition by class (battle_setup.c:660), so pickTrainer ignores the class for them. --- src/core/game3/battle_bridge.lua | 3 +++ src/core/game3/battle_transition.lua | 33 +++++++++++++++++++------- tests/game3_battle_transition_test.lua | 23 +++++++++++++----- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 9001ca7c..9d6171ac 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -480,6 +480,9 @@ function BattleBridge.start(mod, game, foe, opts) playerLevel = playerLv, enemyLevel = foeLv, trainerId = startOpts.trainerId, + trainerClass = (not opts.wild) and foe and foe.trainerClass or nil, + trainerTower = startOpts.trainerTower, + eReader = startOpts.eReader, playerGender = startOpts.playerGender, transitionId = opts.transitionId, } diff --git a/src/core/game3/battle_transition.lua b/src/core/game3/battle_transition.lua index 8b3af5be..4c7748a4 100644 --- a/src/core/game3/battle_transition.lua +++ b/src/core/game3/battle_transition.lua @@ -121,19 +121,36 @@ function BattleTransition.pickWild(opts) end end +-- pokefirered/include/constants/trainers.h:270, :273 +local TRAINER_CLASS_ELITE_FOUR = 87 +local TRAINER_CLASS_CHAMPION = 90 +-- pokefirered/include/constants/opponents.h:416-419, :741-744 (first run, rematch) +local ELITE_FOUR_TRANSITION = { + [410] = ID.LORELEI, [735] = ID.LORELEI, + [411] = ID.BRUNO, [736] = ID.BRUNO, + [412] = ID.AGATHA, [737] = ID.AGATHA, + [413] = ID.LANCE, [738] = ID.LANCE, +} + +-- pokefirered/src/battle_setup.c:624 GetTrainerBattleTransition: the Elite Four +-- and the champion are recognised by class id, never by the class's name. function BattleTransition.pickTrainer(opts) opts = opts or {} local tid = tonumber(opts.trainerId) or 0 - local tClass = opts.trainerClass + -- A Trainer Tower or e-Reader foe carries a facility class, whose numbers + -- overlap the trainer classes (FACILITY_CLASS_LASS is 90, the champion's, + -- pokefirered/include/constants/trainers.h:381); pret never picks their + -- transition by class (battle_setup.c:660). + local tClass = not (opts.trainerTower or opts.eReader) and tonumber(opts.trainerClass) or nil - if tClass == "ELITE_FOUR" or tClass == 57 then - if tid == 412 or tid == 413 or opts.isLorelei then return ID.LORELEI end - if tid == 414 or tid == 415 or opts.isBruno then return ID.BRUNO end - if tid == 416 or tid == 417 or opts.isAgatha then return ID.AGATHA end - if tid == 418 or tid == 419 or opts.isLance then return ID.LANCE end - return ID.BLUE + if tClass == TRAINER_CLASS_ELITE_FOUR then + if opts.isLorelei then return ID.LORELEI end + if opts.isBruno then return ID.BRUNO end + if opts.isAgatha then return ID.AGATHA end + if opts.isLance then return ID.LANCE end + return ELITE_FOUR_TRANSITION[tid] or ID.BLUE end - if tClass == "CHAMPION" or tClass == "RIVAL" or tClass == 58 or opts.isRival or opts.isChampion then + if tClass == TRAINER_CLASS_CHAMPION or opts.isRival or opts.isChampion then return ID.BLUE end if opts.isLorelei then return ID.LORELEI end diff --git a/tests/game3_battle_transition_test.lua b/tests/game3_battle_transition_test.lua index 6f780c6f..03ece328 100644 --- a/tests/game3_battle_transition_test.lua +++ b/tests/game3_battle_transition_test.lua @@ -89,12 +89,23 @@ check(BattleTransition.pickTrainer({ terrain = TERRAIN.WATER, playerLevel = 5, e "trainer water high-level foe -> RIPPLE") print("[test] 5. Elite Four & Rival Mugshots Routing") -check(BattleTransition.pickTrainer({ trainerClass = "ELITE_FOUR", trainerId = 412 }) == ID.LORELEI, "Lorelei -> LORELEI") -check(BattleTransition.pickTrainer({ trainerClass = "ELITE_FOUR", trainerId = 414 }) == ID.BRUNO, "Bruno -> BRUNO") -check(BattleTransition.pickTrainer({ trainerClass = "ELITE_FOUR", trainerId = 416 }) == ID.AGATHA, "Agatha -> AGATHA") -check(BattleTransition.pickTrainer({ trainerClass = "ELITE_FOUR", trainerId = 418 }) == ID.LANCE, "Lance -> LANCE") -check(BattleTransition.pickTrainer({ trainerClass = "ELITE_FOUR", trainerId = 420 }) == ID.BLUE, "E4 Champion -> BLUE") -check(BattleTransition.pickTrainer({ trainerClass = "CHAMPION" }) == ID.BLUE, "Champion -> BLUE") +-- pokefirered/src/battle_setup.c:633, opponents.h:416-419 and :741-744 +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 410 }) == ID.LORELEI, "Lorelei -> LORELEI") +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 411 }) == ID.BRUNO, "Bruno -> BRUNO") +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 412 }) == ID.AGATHA, "Agatha -> AGATHA") +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 413 }) == ID.LANCE, "Lance -> LANCE") +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 737 }) == ID.AGATHA, "Agatha's rematch -> AGATHA") +check(BattleTransition.pickTrainer({ trainerClass = 87, trainerId = 999 }) == ID.BLUE, "other Elite Four -> BLUE") +check(BattleTransition.pickTrainer({ trainerClass = 90, trainerId = 438 }) == ID.BLUE, "Champion -> BLUE") +check(BattleTransition.pickTrainer({ trainerClass = 81, trainerId = 326, terrain = TERRAIN.NORMAL, + playerLevel = 5, enemyLevel = 5 }) ~= ID.BLUE, "an early rival battle keeps the terrain transition") +check(BattleTransition.pickTrainer({ trainerClass = "CHAMPION", playerLevel = 5, enemyLevel = 5 }) ~= ID.BLUE, + "a class is recognised by its id, not by its (translatable) name") +-- pokefirered/src/trainer_tower_sets.c:8663 MIKAELA, FACILITY_CLASS_LASS (90) +check(BattleTransition.pickTrainer({ trainerTower = true, trainerClass = 90, trainerId = 0, + playerLevel = 5, enemyLevel = 5 }) ~= ID.BLUE, "a Trainer Tower LASS is not taken for the champion") +check(BattleTransition.pickTrainer({ eReader = true, trainerClass = 87, trainerId = 0, + playerLevel = 5, enemyLevel = 5 }) ~= ID.BLUE, "nor an e-Reader trainer for the Elite Four") check(BattleTransition.pickTrainer({ isRival = true }) == ID.BLUE, "Rival -> BLUE") print("[test] 6. Cache Contract & Extract Pipeline")