Puyo Puyo!! 20th Anniversary/Upcoming Pair Randomizer

From Puyo Nexus Wiki
Jump to navigation Jump to search

The pair randomization algorithm for Puyo Puyo!! 20th Anniversary Tsuu mode has been carried forward in nearly every Puyo game since release, including Puyo Puyo Champions, the competitive standard currently used by SEGA's professional championship circuit.

Methodology

Reverse engineering was performed on an NDS rom of Puyo Puyo!! 20th Anniversary using a combination of Ghidra and DeSmuME's debugging capabilities. Hints for important memory addresses were taken from this tweeter, who appears to have previously reverse engineered the algorithm in 2017 but did not publish the full process.

Summary

The basic algorithm is:

  1. Map the random colors used this game
  2. Fill and shuffle the 3-color puyo pool
  3. Fill and shuffle the 4-color puyo pool
  4. Fill and shuffle the 5-color puyo pool
  5. Copy the first 4 puyos from the 3-color pool to the 4- and 5-color pools

Memory offsets are all stored relative to the address stored in the pointer located at 0x211a6dc. Most of the time, this contains the address 0x0216fad8, but may sometimes lead elsewhere. All memory offsets in the writeup assume 0x0216fad8 as the base pointer.

Random Number Generator

The RNG is a linear congruential generator of the following form:

seed = seed * 0x5d588b65 + 0x00269ec3

The actual function that performs the shift is located at 0x02049b9c, and also performs a modulo operation on the new seed by way of a multiply-and-shift calculation. The seed is stored as a uint32 at the address 0x0216fb00. However, the initial seed sent at the start of the pair generation operation is a uint16, and the result of the modulo operation is a uint16. Therefore, the total number of possible games is 0xffff+1=65536.

Color Mapping

The puyos in the pair pools are stored as integers from 0-4. These indicies are translated to colors by way of the map generated by the function at 0x02041c74. For example, a colormap of YBGRP means that a raw int sequence of 01201333 corresponds to a piece sequence of YB,GY,BR,RR.

The map is generated by randomly popping an element from the sequence RGBYP until no elements remain. The RNG is called 5 times in total. The results are stored at 0x0216fc9c with each element taking up 32 bits and shifted up by 1 (so instead of 0-4, they range from 1-5).

Fill and Shuffle

The fill and shuffle function is found at 0x02041968. Each puyo is stored as one byte, with pair pools for each color stored at the following locations:

0x0216fcb0 - 3 colors
0x0216fdb0 - 4 colors
0x0216feb0 - 5 colors

First, the pool is filled in sequential order with its relative offset modulo the number of colors, up to 256 puyos. For example, the 4 color pool is filled with 0,1,2,3,0,1,2,3,0,1,2,3,etc...

Then, a check is performed *(0x0216FB74) >= 0x8000. This controls whether an alternate shuffle algorithm is used (appears to be unused for Tsuu).

Shuffling begins at 0x02041aa4, and is done in 3 passes. Each pass consists of swapping 2 random puyos in adjacent "rows", where each row 0x10 bytes long in the first pass, 0x20 bytes long in the second pass, and 0x40 bytes long in the final pass. Choose 1 random puyo in the upper row and 1 random puyo in the lower row. Swap the 2 values. Repeat this N times, where N is half the length of the row. Then shift down by 1 row, then perform the series of swaps again. Continue the down shifts until the end of the pool has been reached.

In total, the number of swaps performed is 15*8 in the first pass, 7*16 in the second pass, and 3*32 in the third pass. Given 2 calls to the RNG per swap, 3 separate pools, and 5 calls to the RNG during the color mapping process, a total of 1973 RNG calls are made over the course of the pair generation process.

Pseudocode

The following Python code can be used to exactly create and search through all possible games generated by the algorithm. It closely matches the disassembled NDS code, with some liberties and Pythonic shortcuts taken for brevity.

import ctypes
import random
import argparse
import pickle

def rand_mod(seed_init, modulo=0):
    uint32_seed = ctypes.c_uint32(seed_init).value
    seed = (uint32_seed * 0x5D588B65 + 0x00269EC3) & 0xFFFFFFFF
    result = seed >> 0x10
    if modulo != 0:
        result = (result * modulo) >> 0x10
    return seed, result

def shuffle(seed_init, n_colors):
    seed = seed_init
    puyos = [i % n_colors for i in range(0x100)]

    # swap 2 random elements from adjacent rows of varying length
    for length in [0x10*2**exp for exp in range(3)]: # 0x10, 0x20, 0x40
        n_rows = 0x100 // length
        for row in range(n_rows - 1):
            for n_swaps in range(length >> 1):
                seed, i1 = rand_mod(seed, length)
                i1 = row * length + i1
                seed, i2 = rand_mod(seed, length)
                i2 = (row + 1) * length + i2
                temp = puyos[i1]
                puyos[i1] = puyos[i2]
                puyos[i2] = temp
    return seed, puyos

def create_table(seed_init):
    seed = seed_init
    puyos = {}
    for n_colors in range(3,6):
        seed, puyos[n_colors] = shuffle(seed, n_colors)

    # ensure first two pairs are 3-color by copying from 3-color table
    puyos[4][:4] = puyos[3][:4]
    puyos[5][:4] = puyos[3][:4]
    return seed, puyos

def create_colormap(seed_init):
    seed = seed_init
    colors = '_RGBYP' # this starts at 1, for Reasons
    cmap = ''
    c_left = 5
    for _ in range(5):
        seed, i = rand_mod(seed, c_left)
        i = i + 1
        cmap += colors[i]
        colors = colors[:i] + colors[i+1:]
        c_left = c_left - 1
    return seed, cmap

def create_rainbow(num_seeds):
    table = {}
    for seed_init in range(num_seeds):
        seed, cmap = create_colormap(seed_init)
        seed, puyos = create_table(seed)
        table[seed_init] = (cmap, puyos)
    return table

if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        prog='PP20th RNG Tool',
        description='Displays and searches RNG seeds for Puyo Puyo!! 20th Anniversary Tsuu'
    )
    parser.add_argument('-s', '--seed', type=lambda x: int(x,0), default=None,
                        help='display the puyos for this (uint16 hex) seed')
    parser.add_argument('--rand', action='store_true',
                        help='use a random seed instead')
    parser.add_argument('-f', '--find', type=str, default=None,
                        help='list of puyos to search for in the rainbow table')
    parser.add_argument('-n', '--ncolors', type=int, default=4,
                        help='number of colors to get')
    parser.add_argument('--raw', action="store_true",
                        help='display results as raw numbers instead of mapped colors')
    parser.add_argument('--filename', type=str, default="puyo20_seeds.p",
                        help='filename to load the seeds from')

    args = parser.parse_args()
    disp = lambda p: str(p[0])+str(p[1]) if args.raw else cmap[p[0]]+cmap[p[1]]

    # print the table for a given seed
    if args.seed is not None or args.rand:
        seed_init = random.randint(0x10000) if args.rand else args.seed
        seed, cmap = create_colormap(seed_init)
        seed, puyos = create_table(seed)
        print(f'seed: 0x{args.seed:04x} -> 0x{seed:08x}')
        print(f"colormap: {cmap[:args.ncolors]}({cmap[args.ncolors:]})")
        print(','.join(disp(p) for p in zip(*[iter(puyos[args.ncolors])]*2)))

    # search for the corresponding starting puyos
    if args.find:
        try:
            table = pickle.load(open(args.filename, "rb"))
        # generate rainbow table if it doesnt already exist
        except FileNotFoundError:
            print(f"Saving rainbow table to {args.filename}, this may take a while...")
            table = create_rainbow(0x10000)
            pickle.dump(table, open(args.filename, "wb"))

        found = 0
        searchterm = args.find
        for seed, (cmap,puyos) in table.items():
            as_color = ''.join(cmap[p] for p in puyos[args.ncolors])
            if as_color[:len(searchterm)] == searchterm:
                as_fmt = ','.join(disp(p) for p in zip(*[iter(puyos[args.ncolors])]*2))
                print(f'{seed:04x},{as_fmt}')
                found = found + 1
        if found == 0:
            print("No matches found.")