SunScriptとメモリ

作成日時:2022/02/23(水) 08:28:04
更新日時:2022/02/23(水) 10:02:00

SunScriptの概要

SunScriptはJavaScriptをモチーフとして作られた言語なので、文法はほぼJavaScriptと同じです。ここでは一般的に必要な文法に絞ってJavaScriptと比較しながら注意点を挙げます。文法の詳しい説明はこちらを参照してください。

SunScript vs. JavaScript

SunScript特有の表現

効果使用タイミング
yield実行が一旦中止され、
次のQF/フレームに再開
QF/フレーム
処理が終わった
exit実行を終了させる全ての処理が終わった時

注意点

ssc(SunScriptのコンパイラ) v1.2.1にはいくつかのバグがあるため、一部合法の書き方でも正常に動作しないことがあります。今後修正される可能性が高いですが、修正されるまでの解決策をまとめました。

ssc v1.2.1のbug

内蔵関数(builtin)

実際にゲームを制御するためには、内蔵関数(builtin)が必要です。SunScriptで使える内蔵関数は下記のリンクを参照してください。

実行タイミング

実行タイミング

各QFに実行するSunScriptは、spフォルダの直下に置けば実行されます。別途のGeckoコードが不要であり、ファイル名も問いません。
※ ただし、経験上ファイル名は小文字と数字以外の文字を使わないことをおすすめします。

一方、描画処理時(1フレームごとに1回)に実行するSunScriptは個別のGeckoコードが必要であり、ファイル名も指定通りにする必要があります。

スクリプトの構成

SunScriptのファイルは.sunという拡張子を使い、内容は一般的には次の3つの部分からなります。

テンプレ

メモリとは

SunScriptでゲーム内の情報を取得する方法を紹介する前に、まずゲーム内の情報を格納するメモリについて解説します。

データを保存する媒介として、レジスタメモリディスクが挙げられます。

レジスタ-メモリ-ディスク
(画像はいらすとやのイラストを使いました)

SunScriptで扱えるデータはメモリに保持されるものであり、以下の実例が挙げられます。

これらの情報が格納されている位置(すなわちアドレス)が分かれば、SunScriptでreadRAMやwriteRAMといった関数を使ってこれらの情報を読み込んで改変することができます。

GC/Wiiのメモリ

Dolphinを使う場合、Dolphin-memory-engineを用いてメモリに格納されるデータを見ることができます。

メモリのアドレスは基本的に十六進数で表されます。GC/Wiiのメモリアドレスは80000000から817FFFFFまでの整数であり、1つの整数は1 byte(=8 bit)のデータに対応します。すなわち、メモリを読み込む・書き込む最小単位は1 byteです。

1 bitは二進数の1桁に相当するため、1 byteのデータは8桁の二進数で表せます。しかし、二進数で表すと極端に長くなるので、一般的には十六進数で表すことが多いです。16=24より、十六進数の1桁は二進数の4桁に相当し、1 byteは2桁十六進数で表されます。

メモリ

※ メモリアドレスを表す時は何も付けずに十六進数で表すことが多いですが、その他の数値を表す場合は十進数と区別するために数字の前に0xを付けます。例えば、数字の391207は十進表記であり、0x270x4B7は十六進表記です。

バイナリの解釈と型

1 byteは8桁の二進数で表せるし、0~255の整数で表せますが、各byteは必ずしもある整数を表すとは限りません。小数、文字、音声、画像、動画といったコンピュータが扱うものであれば、必ずビット列に変換されてバイナリで表されます。

コンピュータがよく扱う情報は次のように挙げられます。

encode

注1:Big Endianを使うと仮定する
注2:整数は2 byteの符号付き整数で2の補数を使うと仮定する
注3:実数はIEEE 754の標準に従って4 byteで表すと仮定する
注4:文字はShift-JISエンコーディングを使うと仮定する
注5:得られるバイナリは符号化の方式に依存するため、注1~4のように符号化方式を細かく決める必要があります

Endianとは

2 byte以上のバイナリをメモリに格納する時、格納するbyteの順番を決める必要があります。一般的には次の2種類の方法が使われています。

endian

どちらがいいというわけではありませんが、Endianを間違えると解釈違いが発生してしまうので、注意する必要があります。GC/WiiのゲームではBig Endianが使われます。

保存したい情報をこのようにバイナリに符号化(encode)すれば、メモリに格納することができますね。では、格納されたバイナリはどういう情報を表すのでしょうか。例えば、BC AC B2 DDという4 byteのバイナリはどんな情報を表すのでしょうか。

decode

答えは…そうですね、分からないです。コンピュータにとってどれも同じバイナリですが、人間の解釈によって異なる情報を表すことになります。

メモリに格納されているバイナリを正しく解釈するために、が必要となります。例えば、CやJavaといった静的型付けのプログラミング言語では、開発者が開発時に各変数にint(整数)、float(実数)、char(文字)といった型を付ける必要があります。変数に型を付けておけば、誤った解釈をしてしまう恐れはないですね。

一方、JavaScriptやPythonの開発者はあまり型に気にしていないかもしれません。変数や関数に型を付ける必要がないから、型が存在しないのではないかと思われるかもしれませんが、実はそうではありません。証拠として、JavaScriptにはtypeofがあり、Pythonにはtype()があって各変数の型を確認することができます。開発者が明示的に型を付ける必要がないのは、各変数の型は実行時に決められ、変数は値のほかに型の情報も保存されているからです。これを動的型付けといいます。要するに、型が存在しないのではなく、コンピュータが型が管理してくれるからあまり気にしなくてもいいということです。

SunScriptでの型

SunScriptはJavaScriptと同じように動的型付けの言語なので、明示的に型を付ける必要がありませんが、もちろん型は存在します。SunScriptでは3つの型が存在します。

intとfloat

JavaScriptでは整数も実数もnumber(64bitの浮動小数点数)という型として扱われるため、int(整数)とfloat(実数)を区別する必要がありませんが、SunScriptでは2つの異なる型として扱われます。動的型付け(後述)なので基本的には気にしなくても大丈夫ですが、writeRAMといった一部の関数は引数の型によって動作が異なるので、注意する必要があります。

バイナリの解釈違い

ホバーバグとACE

ホバーバグ

バイナリの解釈違いの実例としてホバーバグが挙げられます。ホバーバグについてはNoki Dokiさん大福さんが詳しく解説されたので、ぜひそちらを参照してください。

隙あらば自分語りで大変申し訳ありませんが、実はサポミクがただのサンシャイン視聴者からサンシャイン勢の一員になろうとしたきっかけはNoki Dokiさんのこのツイートなのですね。仕組みと検証方法が詳しく解説されていてとても面白くて、今後はきっともっと盛り上がるだろうなと思って自分もその一員になりたいと思ってサンシャインを購入しました。RTAとまた違うサンシャインの面白さを知らせてくださったNoki Dokiさん本当にありがとうございます!

注意してほしいのは、ホバーバグが発生する理由はバイナリの解釈違いではありません。単純に開発者のミスで変数を初期化せずに値を使ったせいで、古いデータ(前のステージで残った情報)がホバーの描画行列として使われてしまい、予期せぬ初速ができてしまうだけです。

ただ、よく考えると、古い情報だとしても、何億・何兆・1038程度の数字がゲーム内で現れるはずがないですね。では、このような極端に大きい数字はどこから出てきたのでしょうか。

はい、これはまさにバイナリの解釈違いですね。例えば、古いデータが2904349264の2つの2 byteの整数(角度や何かのフラグなど)だとすると、メモリに格納されるバイナリは71 73 C0 70の4 byteになります。このバイナリをホバーの描画行列の一つの数字として無理やり解釈すると1.207×1030という非常に大きい数字ができてしまいます。

ACE

ホバーバグの他に、ACE(Arbitrary Code Execution、任意コード実行)もバイナリの解釈違いの一種だと思われます。例えば、Noki Dokiさんが作成されたSMS ACE Any%では、Cutscene Underflow [1] [2] [3]というバグを利用し、「シャインキャッチの後にクッパ戦にワープさせる」や「クッパ戦で破壊する足場の必要な数を1に変更する」といった命令をゲームに実行させました詳細はSMS ACE Any%の概要欄を参照してください。

簡単に言うと、今回のACEはホバーバグと同じようにStale memory(古いデータが残った初期化されないメモリ)を利用します。ホバーバグは古い情報を描画行列として解釈させるのに対して、今回のACEはシャインへのポインタ(シャインの情報が格納されるアドレス)として解釈させ、特定のアドレスに格納されるデータを命令として解釈させます。

今回のACEでは、命令(へのポインタ)として解釈されたデータは特定のスプラッシュエフェクトの座標(float)となります。その座標をマリオのスピンバッファとして解釈されるようにうまく設定し、実行したい命令として解釈されるようにスピンバッファに格納されるデータ(角度)をうまく設定したことで、ACEが実現されました。
非常に難しい内容なので、うまく説明できていないと思いますが、もし需要があれば別途詳しく解説します。

SunScriptでのメモリアクセス

SunScriptでメモリをアクセスするために、次の3つの関数が使われます。

readRAM - 読み込み

readRAM(addr: int): int
readRAM(addr: int, type: TYPE_INT|TYPE_FLOAT|8|-8|16|-16): int|float
readRAM の使用例

intの読み込み

メモリアドレス8040A378から8040A37Bまでに格納される4 byteを80 E8 74 C4とすると、

readRAM($8040A378)

の結果は0x80E874C4(int)となり、

readRAM($8040A378, TYPE_INT)

の結果も同じく0x80E874C4(int)となる。

また、

readRAM($8040A378, 16)

の結果は0x80E8(符号なし)=33000となり、

readRAM($8040A378, -16)

の結果は0x80E8(符号付き)=-32536となる。

floatの読み込み

メモリアドレス80E874D4から80E874D7までに格納される4 byteを44 6D 80 00とすると、

readRAM($80E874D4, TYPE_FLOAT)

の結果は950(float)となる。

writeRAM - 書き込み

writeRAM(addr: int, value: int|float)
writeRAM(addr: int, value: int, type=8|16)
writeRAM の使用例

32 bitの書き込み(型に注意!)

writeRAM($817D0000, 15153117) // int

を実行すると、メモリアドレス817D0000から817D0003までに格納される4 byteは00 E7 37 DDとなるが、

writeRAM($817D0000, 15153117.0) // float

を実行する場合、格納される4 byteは4B 67 37 DDとなる。

16 bitの書き込み

writeRAM($817D0000, -428, 16)

を実行すると、メモリアドレス817D0000から817D0001までに格納される2 byteはFE 54となる。

memcpy - コピー

memcpy(dst: int, src: int, count: int)
memcpy の使用例

メモリアドレス817D0010から817D001Bまでに格納される12 byteを3F 99 99 9A 3F 33 33 33 40 79 99 9Aとする。

memcpy($80E874D4, $817D0010, 12)

を実行すると、80E874D4から80E874DFまでに格納される12 byteも3F 99 99 9A 3F 33 33 33 40 79 99 9Aとなる。

実例:アイテムの状態保存

実例として、十字キー左を押した時にアイテムの状態をセーブし、十字キー右を押した時にアイテムの状態をロードする機能を作ります。扱うアイテムはコインやフルーツといったアイテムマネージャー(ItemManager)に管理されているものとし、扱う状態はアイテムのstate(this+0xF0。消滅したかなどを表す)とします。また、特例として赤コインの状態も保存します。

実装例

ソースコードはこちらにてダウンロードできます。

1/**** [1] インポート ****/
2import "ssc/common.sun";
3import "ssc-sup39/common.sun";
4
5/**** [2] 変数宣言 ****/
6// ポインタの準備
7var itemManager = readRAM(gpItemManager);
8var const slotBase = $817d1000;
9// 赤コイン数のフラグID
10var const SYSF_REDCOINNUM = 0x60000;
11// セーブした状態があるかどうか(初期化を確保)
12var ready = 0;
13// 前のQFに押されたボタン
14var btn0 = 0;
15// 局所変数
16var slot, var itemCount, var ptrItem;
17
18/**** [3] メイン関数 ****/
19while (1) {
20 // 押されたボタン(16 bitの整数)
21 var btn = readRAM(addrButton, 16);
22
23 if (btn == PRESS_DL && !(btn0 & PRESS_DL)) {
24 /** セーブ **/
25 /*
26 押されたボタンは十字キー左であり、
27 前に押されたボタンに十字キー左が含まれない
28 (押された瞬間だけ考える。押しっぱなしは無視)
29 */
30 // 準備
31 ready = 1; // これでセーブした状態がある
32 slot = slotBase; // セーブする状態へのポインタをリセット
33 // 赤コイン数
34 writeRAM(slot, getSystemFlag(SYSF_REDCOINNUM));
35 slot += 4;
36 // アイテムの状態
37 itemCount = readRAM(itemManager+0x14);
38 ptrItem = readRAM(itemManager+0x18);
39 while (itemCount--) { // 各アイテムに対して
40 // セーブ
41 writeRAM(slot, readRAM(readRAM(ptrItem)+0xf0));
42 // 次へ
43 ptrItem += 4;
44 slot += 4;
45 }
46 } else if (btn == PRESS_DR && !(btn0 & PRESS_DR) && ready) {
47 /* ロード */
48 /*
49 押されたボタンは十字キー右であり、
50 前に押されたボタンに十字キー右が含まれない
51 かつ既にセーブした状態がある
52 */
53 slot = slotBase; // セーブした状態へのポインタをリセット
54 // 赤コイン数
55 setSystemFlag(SYSF_REDCOINNUM, readRAM(slot));
56 slot += 4;
57 // アイテムの状態
58 itemCount = readRAM(itemManager+0x14);
59 ptrItem = readRAM(itemManager+0x18);
60 while (itemCount--) { // 各アイテムに対して
61 // ロード
62 writeRAM(readRAM(ptrItem)+0xf0, readRAM(slot));
63 // 次へ
64 ptrItem += 4;
65 slot += 4;
66 }
67 }
68 // 前のQFに押されたボタンはこのQFに押されたボタンとなる
69 btn0 = btn;
70
71 // このQFの実行はここまで
72 yield;
73}

概要

状態の保存先

ゲーム内では、メモリは基本的に小さいアドレスから使うので、大きいアドレスの方は基本的に使われません。逆に言うと、ゲームに使われないので、自由に使ってもいいということになります。

基本的にはアドレスが大きいほど(817FFFFFに近いほど)競合せずに安全に使えますが、817FXXXXPractice codeが使うことが多く、817EXXXXは今後特殊用途で使われる可能性があるので、817DFFFFまでを使うことをおすすめします。今回の例では817D1000から使うことにしました。
※ もちろん競合が発生しなければどこでも使えます

ボタン入力

ボタン入力の値はaddrButton(=80400A50)に格納される16 bitの整数です。ビット演算による押されたボタンの判断方法はこちらを参照してください。

赤コイン数

赤コイン数はgetSystemFlagsetSystemFlag関数でセーブ・ロードできます。

getSystemFlag(flagID: int): int
setSystemFlag(flagID: int, value: int)

他に使える内蔵関数はSSC Builtin and standard utilityを参照してください。

また、赤コイン数のflagIDSYSF_REDCOINNUM(=0x60000)ですが、黄色コイン数、残機数、ノズル(ホバー、ロケットなど)といったその他のフラグのIDはSMS RAM Mapを参照してください。

アイテムの状態

各アイテムはItemManagerで管理されているため、アイテムの一覧を取得するために、gpItemManagerからItemManagerへのポインタitemManager(ItemManagerが格納されるアドレス)を取得します(7行目)。それから、アイテムの数itemCount(37・58行目)とアイテム一覧の配列の開始アドレスptrItem(38・59行目)を取得すれば、各アイテムへのポインタが分かります。

図で表すと次のようになります。

item-ptr

まとめると、次のようになります。

item-ptr

また、この例で扱う状態はアイテムのstate(this+0xF0。消滅したかなどを表す)でしたが、アイテムの位置や速度といった他の値を扱う場合は0xF0をその値が格納される位置(offset)に置き換えばよいです。

各値のoffsetはSMS RAM Mapを参照してください。例えば、位置のoffsetは0x10から0x1Bまでの12 byte(3つのfloat)、速度のoffsetは0xACから0xB7までの12 byte(3つのfloat)です。

セーブ

各アイテムへのポインタが分かれば、各アイテムに対して状態をセーブすることができます(41行目)。item-save

また、41行目で一つのアイテムの状態をセーブした後、次のアイテムの状態をセーブするために、43行目でptrItemを次のアイテムに指すように4進め、44行目でslotを次の保存先アドレスに指すように4進めます。

item-next

ロード

ロードはセーブと同様です。62行目で各アイテムに対して状態をロードし、64行目でptrItemを次のアイテムに指すように4だけ進め、65行目でslotを次のロード先アドレスに指すように4だけ進めます。item-load

補足

39と60行目の

while (itemCount--) {

for (i=0; i<itemCount; i++) {

と同値です。itemCountを具体的な値(2, 4, 8など)にして検証してみましょう。

追記

思ったより遥かに難しかったので、色々上手く解説できなかったと思いますが、もし分かりにくいところがありましたら、Peingやツイッターの方でお気軽にお声がけください。より深く学びたい方はC/C++を学ぶことをおすすめします。

次からはサポミクが実装した関数のドキュメントを整理し、様々な実装例を公開する予定です。現時点では解説する予定はありませんが、もし需要がありましたらまた解説記事を作成します。よろしくお願いいたします。