Packs in Yu-Gi-Oh! Nightmare Troubadour
Yu-Gi-Oh! Nightmare Troubadour is the first Yu-Gi-Oh! game for the Nintendo DS. I don’t consider it to be especially good, and unless you’re dying for a Yu-Gi-Oh! game with the original cast, I’d suggest giving it a miss. However, it will forever hold a special place in my heart as it, along with Sonic Rush, were the two games I got with my DS one Christmas day.
I had occasion to replay it recently for RetroAchievements. And it annoyed me that there’s still a lot of things not well understood about the game. So naturally, now that my playthrough is complete, it’s the perfect time to solve some of those mysteries. After that knowledge could have helped me.
This will be the first of a few planned posts wherein I dig into some of those mysteries. My first post will be on how packs work.
My research will be done with the PAL version of the game, because that’s the version I played for RetroAchievements so I already have a complete save.
Overview
Yu-Gi-Oh! Nightmare Troubadour is based off the Yu-Gi-Oh! card game. You accrue cards by purchasing packs from the shop (there are a few other means, but they aren’t relevant for our purposes). Each pack contains five cards. The first four will always be common cards. The last will be a common, a rare, a super rare, or an ultra rare.
Every in-game day the player can purchase up to 10 packs of each type of booster. Every box of 10 contains one pack with an ultra rare, two packs with a super rare, three packs with a rare, and four packs with all commons. A long-known interesting fact about the distribution of rares is that you can save the game after buying a single pack, and the distribution of the packs with rares are fixed until the next in-game day.
The RNG, and distribution of rares
So hey, the issue with the rares sounds interesting. Let’s see how the game determines them. To generate random numbers for pack pulls, the game uses a linear congruential generator, or LCG for short. The game keeps a 32-bit number called a seed. Every time the game requires a new random number, the game first changes the seed according to a mathematical transformation, then extracts the desired number from the seed. In Python, it would look like this:
1
2
3
4
5
6
7
class Prng:
def __init__(self, seed):
self.__seed = seed
def rand(self):
self.__seed = (0x343FD * self.__seed + 0x269EC3) & 0xFFFFFFFF
return (self.__seed >> 16) & 0x7FFF
To use an LCG, you must first supply an initial seed value. If we can learn (or better still, control) said initial seed value, and control when the game requests a random number, we will be able to predict all output of the random number generator, thereby controlling what cards we get from packs. Therefore, the next important issue is how the game chooses the initial seed value and when the game requests a random number.
The game takes the number of frames that have elapsed since power-on, as a 16-bit number, and uses that as the initial seed value. This happens when the player enters the menu where they select the booster they wish to buy packs of.
The RNG is initialised in the black screen right after this
The first time in an in-game day the player enters this menu, right after initialising the LCG the game will use it to determine which packs contain rares, super rares, and ultra rares. The only other time the game will request random numbers is when opening a pack. The procedure the game uses to allocate the distribution of rares is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def generate_rare_slots(rng):
# 4 for Ultra Rare, 3 for Super Rare, 1 for Rare, 0 for Common
rares = [4, 3, 3, 1, 1, 1, 0, 0, 0, 0]
rare_slots = []
# The game has 20 types of booster
for _ in range(20):
pack_rare_slots = [-1] * 10
for rare in rares:
while True:
slot = rng.rand() % 10
if pack_rare_slots[slot] == -1:
pack_rare_slots[slot] = rare
break
rare_slots.append(pack_rare_slots)
return rare_slots
This is as close to a best-case scenario for manipulating the packs as we could reasonably hope for. If we do one frame-perfect input, we have perfect knowledge of the RNG’s output. Additionally, if you don’t even want to do that, there are only 65536 starting seeds so after buying one pack we will almost definitely be able to work out what the initial seed was, and what it will be going forward. That means we just need to know how the game uses the RNG to generate the contents of pack, but unfortunately that is somewhat more complicated.
Pack contents
The main complication comes from the fact that the developers decided to add a feature where the player does not spend an eternity getting the last rares they need. Say a pack has three potential ultra rare cards it can give you, and you already own one of them. If a pack is determined to contain an ultra rare, under normal circumstances you will have a one in three chance of getting any of them. However, if the player has opened two or more packs from that booster containing an ultra rare, and both of them gave ultra rares that the player owned before opening said packs, then the game will ensure the player gets an ultra rare they have not already obtained. In our hypothetical scenario, this would mean the player has a one in two chance of getting either unowned ultra rare, and no chance of getting the one they already own. This works in the same way for rares and super rares.
With that explanation out of the way, here is how it works:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def generate_pulls(pack_data, rng, rarity, guarantee_new, unowned_rares):
"""Calculate the pulls from a pack.
pack_data: an array of pairs of the form (card, rarity) for the pack's cards
rng: the RNG
rarity: 0 for common, 1 for rare, 3 for super rare, 4 for ultra rare
guarantee_new: if the game tries to guarantee a new rare due to 2+ old ones
unowned_rares: the rares from pack_data that are unowned
"""
if rarity == 0:
common_count = 5
else:
common_count = 4
pulls = []
while len(pulls) < common_count:
slot = rng.rand() % len(pack_data)
new_card, nc_rarity = pack_data[slot]
if new_card in pulls:
continue
if nc_rarity != 0:
continue
pulls.append(new_card)
if rarity == 0:
return pulls
rare_pool = [c for c, r in unowned_rares if r == rarity]
if rare_pool and guarantee_new:
slot = rng.rand() % len(rare_pool)
pulls.append(rare_pool[slot])
return pulls
while True:
slot = rng.rand() % len(pack_data)
new_card, nc_rarity = pack_data[slot]
if nc_rarity == rarity:
pulls.append(new_card)
return pulls
I have placed the contents of the packs into an XML here. Order is important, so listings from GameFAQs and the like are of no use here.
To generate a card from an array, the game requests a random number from the LCG and then does modulo the length of the array, and takes the corresponding item. With this procedure, the game will generate a card and keep doing so until it gets a common. This common is then placed as the first card. The game will use this process to get another common, adding it if the game has not already decided to give the player that card. This is repeated until all the commons are done. If the pack is five commons, the generation is over.
If not, the procedure for the rare card depends on whether the new rare guarantee if active. If it is active, the game will generate the array of cards in the pack that are unowned and of the right rarity, in the same order the pack’s contents are in. If this array is non-empty, the game will pick a random element from this array for the rare. If this array is empty, the game will fall back on the procedure used when the new rare is not guarantee. Said procedure is to keep generating random cards from the full array, like with commons, until one of the right rarity is found.