Fight a copy of your own party in the Trainer House - pret/pokecrystal GitHub Wiki
Contents
- Preamble: Understanding how the Trainer House works
- Optional: Adjust the code that reads the Mystery Gift trainer data
- Optional: Have the Trainer House always load
CAL1
- Optional: Remove unused trainer data
- Implement the function to copy player party data
- Zero out
sMysteryGiftTrainerHouseFlag
upon initialization
1. Preamble: Understanding how the Trainer House works
The Trainer House in Viridian City is meant to allow the player to battle against a computer-controlled copy of a different player's party of Pokemon. This is a supplemental aspect of the Mystery Gift feature; when sending gifts, the games also exchange party data. Problem is, this exchange happens through the GBC's infra red port, which is difficult to emulate. But with a little bit of logical deduction, we can see that all the building blocks are there for one possible solution: have the player battle a copy of their own party.
Let's take a look at how this works in the actual code. In maps/TrainerHouseB1F.asm, we find this:
...
TrainerHouseReceptionistScript:
turnobject PLAYER, UP
opentext
checkflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY
iftrue .FoughtTooManyTimes
writetext TrainerHouseB1FIntroText
promptbutton
special TrainerHouse
iffalse .GetCal3Name
gettrainername STRING_BUFFER_3, CAL, CAL2
sjump .GotName
.GetCal3Name:
gettrainername STRING_BUFFER_3, CAL, CAL3
.GotName:
writetext TrainerHouseB1FYourOpponentIsText
promptbutton
writetext TrainerHouseB1FAskWantToBattleText
yesorno
iffalse .Declined
setflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY
writetext TrainerHouseB1FGoRightInText
waitbutton
closetext
applymovement PLAYER, Movement_EnterTrainerHouseBattleRoom
opentext
writetext TrainerHouseB1FCalBeforeText
waitbutton
closetext
special TrainerHouse
iffalse .NoSpecialBattle
winlosstext TrainerHouseB1FCalBeatenText, 0
setlasttalked TRAINERHOUSEB1F_CHRIS
loadtrainer CAL, CAL2
startbattle
reloadmapafterbattle
iffalse .End
.NoSpecialBattle:
winlosstext TrainerHouseB1FCalBeatenText, 0
setlasttalked TRAINERHOUSEB1F_CHRIS
loadtrainer CAL, CAL3
startbattle
reloadmapafterbattle
.End:
applymovement PLAYER, Movement_ExitTrainerHouseBattleRoom
end
.Declined:
writetext TrainerHouseB1FPleaseComeAgainText
waitbutton
closetext
applymovement PLAYER, Movement_TrainerHouseTurnBack
end
.FoughtTooManyTimes:
writetext TrainerHouseB1FSecondChallengeDeniedText
waitbutton
closetext
applymovement PLAYER, Movement_TrainerHouseTurnBack
end
...
So the game loads either CAL2
or CAL3
as the opponent, and CAL3
is marked as "no special battle." (CAL1
is never actually used.) There's also the map command special TrainerHouse
. If we look up the definition of that special in engine/events/specials.asm...
...
TrainerHouse:
ld a, BANK(sMysteryGiftTrainerHouseFlag)
call OpenSRAM
ld a, [sMysteryGiftTrainerHouseFlag]
ld [wScriptVar], a
jp CloseSRAM
...we see that it's just checking a flag that's stored in SRAM. So none of this actually loads your Mystery Gift partner's party into the battle. After some digging, we find that the true culprit lies in engine/battle/read_trainer_party.asm:
ReadTrainerParty:
...
ld a, [wOtherTrainerClass]
cp CAL
jr nz, .not_cal2
ld a, [wOtherTrainerID]
cp CAL2
jr z, .cal2
ld a, [wOtherTrainerClass]
.not_cal2
...
.cal2
ld a, BANK(sMysteryGiftTrainer)
call OpenSRAM
ld de, sMysteryGiftTrainer
call TrainerType2
call CloseSRAM
jr .done
...
What this means is, if the opponent is CAL2
, then instead of reading CAL2
's party from data/trainers/parties.asm, the data stored at sMysteryGiftTrainer
is interpreted as trainer data and used in its place. Note that this will happen any time that the opponent is CAL2
, not just inside the Trainer House—so the map itself is fairly irrelevant to this whole process.
Anyway, if we look for sMysteryGiftTrainer
in ram/sram.asm...
...
sMysteryGiftData::
sMysteryGiftItem:: db
sMysteryGiftUnlocked:: db
sBackupMysteryGiftItem:: db
sNumDailyMysteryGiftPartnerIDs:: db
sDailyMysteryGiftPartnerIDs:: ds MAX_MYSTERY_GIFT_PARTNERS * 2
sMysteryGiftDecorationsReceived:: flag_array NUM_NON_TROPHY_DECOS
ds 4
sMysteryGiftTimer:: dw
ds 1
sMysteryGiftTrainerHouseFlag:: db
sMysteryGiftPartnerName:: ds NAME_LENGTH
sMysteryGiftUnusedFlag:: db
sMysteryGiftTrainer:: ds wMysteryGiftTrainerEnd - wMysteryGiftTrainer
sBackupMysteryGiftItemEnd::
ds $30
...
...we see that SRAM reserves a number of bytes for sMysteryGiftTrainer
equal to wMysteryGiftTrainerEnd - wMysteryGiftTrainer
, or in other words, however many bytes span wMysteryGiftTrainer
to wMysteryGiftTrainerEnd
. Those addresses are over in ram/wram.asm:
...
wMysteryGiftTrainer:: ds 1 + (1 + 1 + NUM_MOVES) * PARTY_LENGTH + 1
wMysteryGiftTrainerEnd::
...
You can think of WRAM as the data that's being tossed around as the game is in the middle of running, and anything important gets copied from WRAM into SRAM whenever the game is saved. Then when the save file is loaded back up again, data is copied from SRAM back into WRAM so that it can be safely altered without messing up SRAM.
Okay, so we know how the Mystery Gift trainer data is read from SRAM, but how is it loaded into SRAM in the first place? That's where the actual Mystery Gift functions come in. These can be found in engine/link/mystery_gift.asm and engine/link/mystery_gift_2.asm. The first one contains what we're after:
...
StagePartyDataForMysteryGift:
; You will be sending this data to your mystery gift partner.
; Structure is the same as a trainer with species and moves
; defined.
ld a, BANK(sPokemonData)
call OpenSRAM
ld de, wMysteryGiftStaging
ld bc, sPokemonData + wPartyMons - wPokemonData
ld hl, sPokemonData + wPartySpecies - wPokemonData
.loop
ld a, [hli]
cp -1
jr z, .party_end
cp EGG
jr z, .next
push hl
; copy level
ld hl, MON_LEVEL
add hl, bc
ld a, [hl]
ld [de], a
inc de
; copy species
ld hl, MON_SPECIES
add hl, bc
ld a, [hl]
ld [de], a
inc de
; copy moves
ld hl, MON_MOVES
add hl, bc
push bc
ld bc, NUM_MOVES
call CopyBytes
pop bc
pop hl
.next
push hl
ld hl, PARTYMON_STRUCT_LENGTH
add hl, bc
ld b, h
ld c, l
pop hl
jr .loop
.party_end
ld a, -1
ld [de], a
ld a, wMysteryGiftTrainerEnd - wMysteryGiftTrainer
ld [wUnusedMysteryGiftStagedDataLength], a
jp CloseSRAM
...
This function copies data from sPokemonData
(which contains the player's party from the last time that they saved the game) into wMysteryGiftStaging
, and the comment at the top explains how it's stored. If we again open ram/wram.asm and find wMysteryGiftStaging
...
...
; mystery gift data
wMysteryGiftStaging:: ds 80
...
...we see that 80 bytes are reserved for this data staging process. Obviously, Mystery Gift was designed to account for a full party of six Pokemon, but do note that only level, species, and moves are copied. If you want to copy more data, you may have to free up more space in WRAM and SRAM.
There's one more thing to acknowledge here. The default Trainer House opponent is named CAL, but this name will change after using Mystery Gift, copying the other player's name. That happens at a different point in engine/battle/read_trainer_party.asm:
...
Battle_GetTrainerName::
...
ld a, [wOtherTrainerID]
ld b, a
ld a, [wOtherTrainerClass]
ld c, a
GetTrainerName::
ld a, c
cp CAL
jr nz, .not_cal2
ld a, BANK(sMysteryGiftTrainerHouseFlag)
call OpenSRAM
ld a, [sMysteryGiftTrainerHouseFlag]
and a
call CloseSRAM
jr z, .not_cal2
ld a, BANK(sMysteryGiftPartnerName)
call OpenSRAM
ld hl, sMysteryGiftPartnerName
call CopyTrainerName
jp CloseSRAM
.not_cal2
...
So the new name is stored in sMysteryGiftPartnerName
, similarly to how the party works. But the condition checks are structured a little differently: instead of specifically looking for CAL2
, it just checks for any CAL
, and then checks if the sMysteryGiftTrainerHouseFlag
is set. This means that any trainer in the CAL
class will display the Mystery Gift trainer's name if the flag is set, but will use their predefined name otherwise. That works fine for vanilla, but for our purposes it would be preferable if the party-loading and name-loading functions worked the same way, so that we can keep them synchronized.
2. Optional: Adjust the code that reads the Mystery Gift trainer data
If you don't care about minor optimization and organization issues, you can safely skip to Step 5. But if you start with Step 2, make sure to complete Steps 3 and 4 as well.
Edit engine/battle/read_trainer_party.asm:
ReadTrainerParty:
...
ld a, [wOtherTrainerClass]
cp CAL
- jr nz, .not_cal2
+ jr nz, .not_cal1
ld a, [wOtherTrainerID]
- cp CAL2
- jr z, .cal2
+ cp CAL1
+ jr z, .cal1
+.no_mystery_gift_trainer
ld a, [wOtherTrainerClass]
-.not_cal2
+.not_cal1
...
-.cal2
+.cal1
+ ld a, BANK(sMysteryGiftTrainerHouseFlag)
+ call OpenSRAM
+ ld a, [sMysteryGiftTrainerHouseFlag]
+ and a
+ call CloseSRAM
+ jr z, .no_mystery_gift_trainer
ld a, BANK(sMysteryGiftTrainer)
call OpenSRAM
ld de, sMysteryGiftTrainer
call TrainerType2
call CloseSRAM
jr .done
...
Battle_GetTrainerName::
...
ld a, [wOtherTrainerID]
ld b, a
ld a, [wOtherTrainerClass]
ld c, a
GetTrainerName::
ld a, c
cp CAL
- jr nz, .not_cal2
+ jr nz, .not_cal1
+ ld a, b
+ cp CAL1
+ jr nz, .not_cal1
ld a, BANK(sMysteryGiftTrainerHouseFlag)
call OpenSRAM
ld a, [sMysteryGiftTrainerHouseFlag]
and a
call CloseSRAM
- jr z, .not_cal2
+ jr z, .not_cal1
ld a, BANK(sMysteryGiftPartnerName)
call OpenSRAM
ld hl, sMysteryGiftPartnerName
call CopyTrainerName
jp CloseSRAM
-.not_cal2
+.not_cal1
...
Since we're changing this anyway, we're going to take the opportunity to remove Cal's two unused parties, leaving him with only one defined. Therefore, we've changed both the party-reading and name-reading functions to look for CAL1
instead of CAL2
, and to both check if sMysteryGiftTrainerHouseFlag
is set. If it isn't, Cal's default party and name are used like a normal trainer; if it is, the Mystery Gift trainer data is read.
CAL1
3. Optional: Have the Trainer House always load We've changed the logic of how Cal's party is loaded to no longer rely on different trainer constants, so now we only ever need to load CAL1
in the Trainer House. Edit maps/TrainerHouseB1F.asm:
...
TrainerHouseReceptionistScript:
turnobject PLAYER, UP
opentext
checkflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY
iftrue .FoughtTooManyTimes
writetext TrainerHouseB1FIntroText
promptbutton
- special TrainerHouse
- iffalse .GetCal3Name
- gettrainername STRING_BUFFER_3, CAL, CAL2
- sjump .GotName
-
-.GetCal3Name:
- gettrainername STRING_BUFFER_3, CAL, CAL3
-.GotName:
+ gettrainername STRING_BUFFER_3, CAL, CAL1
writetext TrainerHouseB1FYourOpponentIsText
promptbutton
writetext TrainerHouseB1FAskWantToBattleText
yesorno
iffalse .Declined
setflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY
writetext TrainerHouseB1FGoRightInText
waitbutton
closetext
applymovement PLAYER, Movement_EnterTrainerHouseBattleRoom
opentext
writetext TrainerHouseB1FCalBeforeText
waitbutton
closetext
- special TrainerHouse
- iffalse .NoSpecialBattle
- winlosstext TrainerHouseB1FCalBeatenText, 0
- setlasttalked TRAINERHOUSEB1F_CHRIS
- loadtrainer CAL, CAL2
- startbattle
- reloadmapafterbattle
- iffalse .End
-.NoSpecialBattle:
winlosstext TrainerHouseB1FCalBeatenText, 0
setlasttalked TRAINERHOUSEB1F_CHRIS
- loadtrainer CAL, CAL3
+ loadtrainer CAL, CAL1
startbattle
reloadmapafterbattle
-.End:
applymovement PLAYER, Movement_ExitTrainerHouseBattleRoom
end
...
4. Optional: Remove unused trainer data
Now that everything's set up to only need CAL1
, we can get rid of CAL2
and CAL3
if we want.
In constants/trainer_constants.asm:
...
trainerclass CAL ; c
- const CAL1 ; unused
+ const CAL1
- const CAL2
- const CAL3
...
And in data/trainers/parties.asm:
...
PKMNTrainerGroup:
; CAL (1)
db "CAL@", TRAINERTYPE_NORMAL
- db 10, CHIKORITA
- db 10, CYNDAQUIL
- db 10, TOTODILE
+ db 50, MEGANIUM
+ db 50, TYPHLOSION
+ db 50, FERALIGATR
db -1 ; end
-
- ; CAL (2)
- db "CAL@", TRAINERTYPE_NORMAL
- db 30, BAYLEEF
- db 30, QUILAVA
- db 30, CROCONAW
- db -1 ; end
-
- ; CAL (3)
- db "CAL@", TRAINERTYPE_NORMAL
- db 50, MEGANIUM
- db 50, TYPHLOSION
- db 50, FERALIGATR
- db -1 ; end
...
CAL1
's party can be anything, since it will be used normally when there's no Mystery Gift trainer data. Other trainers in the CAL
class can also be used as normal trainers, since only CAL1
gets his name and party replaced. Removing CAL2
and CAL3
doesn't free up a whole lot of space, but it's something.
5. Implement the function to copy player party data
We're going to copy the player's party data every time that the game is saved. This will keep the Trainer House's party relatively up-to-date, while still allowing a window for the player to swap out party members after saving, in case they want to fight one party with a different one.
Edit engine/menus/save.asm:
...
SavePokemonData:
ld a, BANK(sPokemonData)
call OpenSRAM
ld hl, wPokemonData
ld de, sPokemonData
ld bc, wPokemonDataEnd - wPokemonData
call CopyBytes
call CloseSRAM
- ret
+ ; fallthrough
+
+CopyPlayerPartyToMysteryGiftTrainer:
+ ld a, BANK(sMysteryGiftData)
+ call OpenSRAM
+
+ ld a, TRUE
+ ld [sMysteryGiftTrainerHouseFlag], a
+
+ ld hl, wPlayerName
+ ld de, sMysteryGiftPartnerName
+ ld bc, NAME_LENGTH
+ call CopyBytes
+
+ ld hl, wPartySpecies
+ ld de, sMysteryGiftTrainer
+ ld bc, wPartyMons
+.loop
+ ld a, [hli]
+ cp -1
+ jr z, .party_end
+ cp EGG
+ jr z, .next
+ push hl
+ ; copy level
+ ld hl, MON_LEVEL
+ add hl, bc
+ ld a, [hl]
+ ld [de], a
+ inc de
+ ; copy species
+ ld hl, MON_SPECIES
+ add hl, bc
+ ld a, [hl]
+ ld [de], a
+ inc de
+ ; copy moves
+ ld hl, MON_MOVES
+ add hl, bc
+ push bc
+ ld bc, NUM_MOVES
+ call CopyBytes
+ pop bc
+ pop hl
+.next
+ push hl
+ ld hl, PARTYMON_STRUCT_LENGTH
+ add hl, bc
+ ld b, h
+ ld c, l
+ pop hl
+ jr .loop
+.party_end
+ ld a, -1
+ ld [de], a
+
+ jp CloseSRAM
...
We've set up our new function so that it will automatically happen every time that SavePokemonData
is called. This isn't strictly necessary—you can leave the ret
at the end of SavePokemonData
and just directly call CopyPlayerPartyToMysteryGiftTrainer
whenever you want the Trainer House's party to be updated. But if you want that to happen every time that Pokemon data is saved, this will allow that without requiring multiple new function calls.
CopyPlayerPartyToMysteryGiftTrainer
does three things:
- Set the flag
sMysteryGiftTrainerHouseFlag
so thatReadTrainerParty
knows that Mystery Gift trainer data exists - Copy the player's name from
wPlayerName
intosMysteryGiftPartnerName
- Copy the player's party from
wPokemonData
(starting atwPartySpecies
) intosMysteryGiftTrainer
Technically, it's only necessary to set the flag and copy the player's name once, but it would probably be more trouble than it's worth to install extra checks just to get them to only happen the first time. Plus, should you implement a feature to allow the player to change their name, this will keep the Trainer House opponent's name updated to match the player's.
The meat of this function is copied directly from StagePartyDataForMysteryGift
, only instead of copying data from SRAM into WRAM, we're going the other way around. The Trainer House reads from sMysteryGiftTrainer
, and the wPokemonData
section of WRAM contains our current Pokemon party (with wPartySpecies
and wPartyMons
being sub-labels for specific spots within wPokemonData
). But we're not copying the data over exactly—we're filtering it into the format of an enemy trainer, which (in this case) only wants the levels of each Pokemon, their species, and their moves. Luckily, the filtration process itself didn't need to be changed; we just had to feed it the right variables.
sMysteryGiftTrainerHouseFlag
upon initialization
6. Zero out There's one fatal flaw in our master plan. When you start a new game, WRAM bytes are all set to zero, but SRAM remains intact until you overwrite your old file with your new one. In our setup, we're expecting sMysteryGiftTrainerHouseFlag
to be set to FALSE
(0) until the first time the game is saved, and then TRUE
(1) ever after. But since SRAM bytes don't start at zero, we don't actually know for sure that it will start out as FALSE
. To fix this, open engine/events/std_scripts.asm and find the function InitializeEventsScript
.
...
InitializeEventsScript:
setevent EVENT_EARLS_ACADEMY_EARL
...
setevent EVENT_INITIALIZED_EVENTS
+ callasm .mystery_gift_flag
endcallback
+
+.mystery_gift_flag
+ ld a, BANK(sMysteryGiftTrainerHouseFlag)
+ call OpenSRAM
+ xor a
+ ld [sMysteryGiftTrainerHouseFlag], a
+ jp CloseSRAM
...
InitializeEventsScript
is called once at the beginning of the game. By using xor a
to set sMysteryGiftTrainerHouseFlag
to 0 here, we now know that our function will work correctly even before a new game has ever been saved. There is one quirk to doing this: if you have an existing save file, start a new game but don't save it, and then return to your old save file, sMysteryGiftTrainerHouseFlag
will have been set back to 0, and you'll face Cal again instead of yourself. This will be fixed the next time that you save, so it isn't a big deal—at least, it's better than the alternative, which is to let the flag be read as TRUE
when we don't know what data exists in sMysteryGiftTrainer
. That will most likely result in the game crashing when attempting to load invalid party data.
If you've done the tutorial to improve the event initialization system, then instead of doing the above, you can edit the new InitializeEvents
function like this:
InitializeEvents:
+; zero out sMysteryGiftTrainerHouseFlag to prevent glitchy Cal
+ ld a, BANK(sMysteryGiftTrainerHouseFlag)
+ call OpenSRAM
+ xor a
+ ld [sMysteryGiftTrainerHouseFlag], a
+ call CloseSRAM
+
; initialize events
...
Keep in mind that in other hacks, sMysteryGiftTrainerHouseFlag
may lie at a different location in SRAM, may have a different name, or may not even exist. Because of such things, messing with SRAM can result in the wrong saved data bytes being altered if you don't know what you're doing, which can corrupt save files, so do be careful.
And there we have it! The Trainer House is no longer virtually pointless in a ROM.
TODO: copy held items, nicknames, DVs, stat experience, trainer gender