Post

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.

Rare, Super Rare, and Ultra Rare The three genders

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.

Ten packs A new day, a new 10 packs

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.

Going to buy packs 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.

This post is licensed under CC BY 4.0 by the author.