Skip to content

Fight a copy of your own party in the Trainer House

Grate Oracle Lewot edited this page Oct 24, 2024 · 4 revisions

Contents

  1. Preamble: Understanding how the Trainer House works
  2. Optional: Adjust the code that reads the Mystery Gift trainer data
  3. Optional: Have the Trainer House always load CAL1
  4. Optional: Remove unused trainer data
  5. Implement the function to copy player party data
  6. 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.

3. Optional: Have the Trainer House always load CAL1

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 that ReadTrainerParty knows that Mystery Gift trainer data exists
  • Copy the player's name from wPlayerName into sMysteryGiftPartnerName
  • Copy the player's party from wPokemonData (starting at wPartySpecies) into sMysteryGiftTrainer

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.

6. Zero out sMysteryGiftTrainerHouseFlag upon initialization

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.

scrnsht_trainerhouse0.png scrnsht_trainerhouse1.png scrnsht_trainerhouse2.png


TODO: copy held items, nicknames, DVs, stat experience, trainer gender

Clone this wiki locally