見出し画像

ポケスロン(スマッシュゴール) TAS制作日記

はじめに

これは Pokémon Past Generation Advent Calendar 2023 12日目の記事です。

こちらの動画を制作した際の手順を順を追って書いていくだけです。
所々に雑な手順が含まれるので参考にする際は注意してください。

「TASって何?」という方はこちらを見るのが早いと思います。
TAS - ニコ百
WelcomeToTASVideos

制作動機

以前こちらのTAS動画を作った際に、スマッシュゴールのボールがランダムに生成されてることは知っていました。

そして最近になって「スマッシュゴールで乱数調整して金のボールを大量に出したら面白いだろうなあ…」と唐突に思いついたので、作りました。
要するにただの衝動です。

TAS制作環境

今回はDeSmuME 0.9.13 (64bit)というエミュレータを使用してTASを制作しました。
64bit版のDeSmuMEを使う際の注意ですが、Lua Scriptを使う際はLua51.dllではなく、Lua5.1.dllを"Lua51.dll"にリネームしたものを同じ階層に置きます。

エミュレータ以外にはGoogle SpreadSheetや、C言語で適当に書いたコードを使用しました。

乱数のメモリアドレス、生成式の特定

何をするにもまず乱数のアドレスや式を特定しないことには始まりません。まずアドレスを特定します。

RAM Search

RAM Searchの画面を開き、"4 bytes", "Hexadecimal"にチェック。
(この世代のゲームの乱数は大体32bitか64bitをフルに使うので。)

ゲーム内でランダムに決定されてそうな事象を探します。
例えばポケモンHGSSだとそこらへんを歩いてるNPCの方向決定などに乱数が使われてそうですね。

RAM Search と ゲーム画面

という訳で、NPCの隣に立って[Reset]し、"Not Equal To" "Previous Value"でNPCが動く度に[Search]をかけました。
ある程度アドレスの数を絞れたら目視でそれっぽい数値を探します。
今回でいう"それっぽい"は、乱数なので32bit(または64bit)全部が一見規則性もなく動いてる数値です。

この画像では0x021D0AE8のアドレスがそれっぽかったので、本当に乱数なのかどうか確かめます。

Memory viewer

Memory viewerで0x021D0AE8を表示させ、ブレークポイントを仕掛けます。
この状態で0x021D0AE8のアドレスを適当な数値に書き換えて乱数を変動させ、変化を見ます。

0x00000000に書き換えると0x00006073に変化しました。
0x00000001に書き換えると0x41C6AEE0に変化しました。
つまり乱数値をrとして、
r[n+1] = (r[n] * 0x41C64E6D + 0x6073) mod 0x100000000 ですね。

…流石に雑すぎるので補足を加えると、この世代のゲームには線形合同法という疑似乱数の生成式がよく使われていて、
r[n+1] = (r[n] * A + B) mod 0x100000000
という式の定数A, Bだけが変化することが多いので、このように特定でき(ることもあり)ます。(違ったらアセンブリを読む等してちゃんと確認します)

式が間違えていることもあるので、検証します。
Google SpreadSheetで上の式から乱数列を生成します。

=dec2hex(bitand(bitand(hex2dec(A1),65535)*1103515245+bitlshift(bitand(bitrshift(hex2dec(A1),16)*1103515245,65535),16)+24691,4294967295),8)

Google SpreadSheetは数値を倍精度浮動小数点数で扱っており、仮数部が52bit、つまり整数は2^52までしか精度を保証できません。
最大で32bit^2付近の数値が出てくる32bitの線形合同法では桁落ちが発生してしまうので、上位bitと下位bitを分けて計算する必要があります。

乱数をいくらか動かして計算した値と一致していること、0x021D0AE8を書き換えるとNPCの動き等が変わることも確認できたので、こちらのアドレスと式で間違い無さそうです。(ゲームによっては乱数のアドレスが複数存在することもあるので注意)

スマッシュゴールのボール生成処理

乱数のアドレスと式も特定できたので、スマッシュゴールのボールが生成される際の処理を解析します。

乱数を適当に書き換えて、ボールが出現した座標を並べて眺めてたら処理が分かったりしないかと思いましたが、分からなかったので素直にアセンブリを読むことにします。

乱数アドレスをWrite Breakpointに挿入して、ボールが生成される直前で止めます。その後Dissassemblerを開き、PCレジスタが指すアドレスを開きます。

ARM9 Disassembler

この状態から1Stepずつ実行することで、ボールが生成される際の処理(アセンブリ)とレジスタの内容を目で追うことができます。
ボールの座標が生成されるまでの処理を目で追った結果をGoogle SpreadSheetに書き出してみました。
https://docs.google.com/spreadsheets/d/1sp3xLeyK4RmtHiTMvWQazR5QUNMrUO3OtHroOTB3fow/edit?usp=sharing

乱数の値を16bit右シフトしたものを上位4bitと下位12bitに分けて、下位12bitを2倍して繰り上がったら上位4bitを2倍したものに1を足して、上位4bitから13を引いて負になったら13を足して…のような処理を12回繰り返したりしていますが、要するに乱数をの上位16bitを13で割った余り( (r>>16)%13 )でボールの生成位置を決定しているようです。
ボールの出現箇所は横5*縦3の15箇所あり、中央と前回出現した位置を除いた13箇所なようです。(最初から2個目のボールだけは14箇所)

続けてボールの種類を決定する処理ですが、1Stepずつポチポチ実行してR1に0xFFFFFF9Cが入った時点で (r>>16)%100 だと決め打ちしていくつかの乱数値で結果を生成してみたら合っていました。
金のボールが出た際に金のボールの出現率が0にリセットされ、白いボールが出る度に出現率が+1され、 (r>>16)%100 が出現率未満だった場合に金のボールになるようです。
また、後半になると金のボールの出現率が上昇します。

初期乱数 総当り

必要なデータも取り終わったのでTASを作り始めたいところですが、まず1試合内に金ボールが大量に出現しうる開始時の乱数を探します。
スマッシュゴールでの乱数の消費にNPCの行動等が関わってるようで面倒くさくて労力が大きいので、とりあえず都合のいい乱数値が多く出現する初期乱数を探して、そこからは場当たり的にTASを作ろうと思います。今回は完璧は求めません。

以前作ったTASで乱数値が1試合内でそこまで大幅に変動しないことと、1試合で770の乱数を消費していたことを覚えているので、とりあえず大雑把に試合開始から800消費の範囲で (r>>16)%100 の値が小さいものが多く出現する初期乱数を総当りして探してみます。

#include <stdio.h>

int lcg(unsigned long seed){
    return seed * 0x41C64E6D + 0x6073 & 0xFFFFFFFF;
}

int main(){
    unsigned long r = 0x00000000;
    int arr[800][2] = {0};
    int mc[5] = {0};
    int mcp[5] = {10, 8, 6, 0, 0};
    unsigned int m;
    int point, max[2];
    unsigned int i;

    for (i = 0; i < 800; i++){
        m = (r >> 16) % 100;
        if (m < 5){ mc[m] += 1; }
        arr[i][0] = r;
        arr[i][1] = m;
        r = lcg(r);
    }

    max[0] = arr[0][0];
    max[1] = mc[0] * mcp[1] + mc[1] * mcp[0] + mc[2] * mcp[2];

    for (i = 0; i <= (unsigned int)0xFFFFFFFF; i++){
        m = arr[i % 800][1];
        if (m < 5){ mc[m] -= 1; }
        m = (r >> 16) % 100;
        if (m < 5){ mc[m] += 1; }
        arr[i % 800][0] = r;
        arr[i % 800][1] = m;
        r = lcg(r);
        point = mc[0] * mcp[1] + mc[1] * mcp[0] + mc[2] * mcp[2];
        if (point > max[1]){
            max[0] = arr[(i + 1) % 800][0];
            max[1] = point;
        }
        if(point > 440){
            printf("%08X %d [%d %d %d %d %d]\n",
                arr[(i + 1) % 800][0], point, mc[0], mc[1], mc[2], mc[3], mc[4]);
        }
        if(i % 100000000 == 0){
            printf("--- %u ---\n", i);
        }
    }

    return 0;
}

適当にコードを書きました。(あまり凝視はしないで…)
(r>>16)%100 の結果の0,1,2に対して10,8,6の評価を与えて、足したものが440を超える初期乱数を表示するコードです。色々なパラメータで試してみた結果、このような評価になりました。

今回選んだ初期乱数の値は0x7D2133EAでした。(最速で行動すれば開幕に金のボールが出てくる)
なので、スマッシュゴール開始時に乱数の値が0x7D2133EAになるように調整します。(単純に乱数を特定の値に調整するのは他でいくらでも書かれてそうなことなので割愛します)

TAS製作開始

諸々の準備が終わり、ようやくTAS製作開始です。
まずTAS制作に必要な情報をLua Scriptで表示します。

local RNG = 0x021D0AE8
local PStat = 0x027E3328

function lcg(seed, a, b, m)
	l = bit.band(seed, 0xFFFF) * a
	h = bit.lshift(bit.rshift(seed, 16) * a, 16)
	return bit.band(l + h + b, m)
end

function main()
	r = memory.readdword(RNG)
	
	P = memory.readdword(PStat)
	BallX = memory.readdword(P + 0x760) / 0x1000
	BallY = memory.readdword(P + 0x764) / 0x1000
	Ball2X = memory.readdword(P + 0x7AC) / 0x1000
	Ball2Y = memory.readdword(P + 0x7B0) / 0x1000
	Score = memory.readdword(P + 0x820)
	Timer = memory.readdword(P + 0x864)
	
	gui.text(90, -189, string.format("X: %f\nY: %f\n\nX: %f\nY: %f\n\nScore: %d\n\nTimer:\n%.2f (%d)", BallX, BallY, Ball2X, Ball2Y, Score, Timer / 30, Timer))
	
	for i = 0, 18 do
		m = bit.rshift(r, 16) % 100
		c = 0xFFFFFFFF
		if m < 5 then c = 0xFF0000FF end
		gui.text(180, -189 + i * 10, string.format("%08X %02d", r, m), c, 0x000000FF)
		r = lcg(r, 0x41C64E6D, 0x6073, 0xFFFFFFFF)
	end
	
end

gui.register(main)

ボールの座標、スコア、残タイム、しばらく先までの乱数値と (r>>16)%100 を表示しました。

NPCの動き等により乱数の値が激しく変化するのでフレームアドバンス(ゲームを1Fずつ進める機能)、ステートセーブ/ロード(ゲームの状態を保存し、その状態からやり直す機能)等を駆使してゴールのタイミングをずらしたり、ポールにぶつけて乱数の消費数を変動させ、ボールが生成されるタイミングに都合のいい乱数が来るように調整します。ひたすら調整します。
(肝心なTAS制作の根幹部分なのに説明が雑だけど、ここで必要なのは気合だけです、多分。前準備に手を込めるほど楽になります。)

制作中の画面 (実際はコマ送り)

上手くいかなかったら上手くいくまでやり直し、最後まで上手く行ったらTAS動画の完成です。
(今回はこの1試合の90秒間で6613回やり直したようです)

おわりに

以上でこの記事は終わりです。
制作中の手順・思考等を最低限人に見せられるよう書き出しただけの記事ですが、何かの参考になれば幸いです。

13日目の記事ははHopeさんの「XDバトル山ジョウト御三家の乱数調整について」です。お楽しみに。

この記事が気に入ったらサポートをしてみませんか?