SunScriptとメモリ
作成日時:2022/02/23(水) 08:28:04
更新日時:2022/02/23(水) 10:02:00
SunScriptの概要
SunScriptはJavaScriptをモチーフとして作られた言語なので、文法はほぼJavaScriptと同じです。ここでは一般的に必要な文法に絞ってJavaScriptと比較しながら注意点を挙げます。文法の詳しい説明はこちらを参照してください。
SunScript特有の表現
効果 | 使用タイミング | |
---|---|---|
yield | 実行が一旦中止され、次のQF/フレームに再開 | QF/フレームの処理が終わった時 |
exit | 実行を終了させる | 全ての処理が終わった時 |
注意点
ssc(SunScriptのコンパイラ) v1.2.1にはいくつかのバグがあるため、一部合法の書き方でも正常に動作しないことがあります。今後修正される可能性が高いですが、修正されるまでの解決策をまとめました。
内蔵関数(builtin)
実際にゲームを制御するためには、内蔵関数(builtin)が必要です。SunScriptで使える内蔵関数は下記のリンクを参照してください。
- sscが提供する内蔵関数
- サポミクが実装した関数 ※ 別途Geckoコードが必要
実行タイミング
各QFに実行するSunScriptは、spフォルダの直下に置けば実行されます。別途のGeckoコードが不要であり、ファイル名も問いません。
※ ただし、経験上ファイル名は小文字と数字以外の文字を使わないことをおすすめします。
一方、描画処理時(1フレームごとに1回)に実行するSunScriptは個別のGeckoコードが必要であり、ファイル名も指定通りにする必要があります。
ss/draw2d.sb
:HUD(コイン数、HP、タイマーなど)の描画時に実行されます。レベル選択画面やステージに入った時のカットシーン再生中といった、HUDが描画されない時は実行されないので注意してください。ss/zmenu.sb
:Zメニューが描画される時に実行されます。ss/top2d.sb
:描画処理の最後に常に実行されます。コントローラ入力といった2Dの描画を想定しています。3Dの描画はできません。ss/top3d.sb
:top2d.sb
と同じく描画処理の最後に常に実行されますが、3Dの描画を想定しています。
スクリプトの構成
SunScriptのファイルは.sun
という拡張子を使い、内容は一般的には次の3つの部分からなります。
- インポート:必要なライブラリ(ヘッダファイル)をインポートします。基本的には
ssc/common.sun
とssc-sup39/common.sun
の二つで大丈夫ですが、他に自作のライブラリがあればここでインポートします。 - 変数宣言:必要な変数を宣言して初期化します。ssc v1.2.1を使う場合、局所変数もここで宣言しておきます。
- メイン関数:毎QF/フレーム実行されるプログラム本体です。基本的には
while (1) {
から始まってyield; }
で終わるようにすれば思い通りに実行してくれます。特に最後のyield;
を忘れないように注意してください。
メモリとは
SunScriptでゲーム内の情報を取得する方法を紹介する前に、まずゲーム内の情報を格納するメモリについて解説します。
データを保存する媒介として、レジスタ、メモリ、ディスクが挙げられます。
(画像はいらすとやのイラストを使いました)
SunScriptで扱えるデータはメモリに保持されるものであり、以下の実例が挙げられます。
- 現在のステージの番号
- (QFタイマーで使われる)QFカウンタ
- マリオの状態(位置、速度、HPなど)
- ポンプの状態(種類、水量、向きなど)
- コインやフルーツといったアイテムの状態(位置、速度、消滅までの時間など)
- 各敵の状態
これらの情報が格納されている位置(すなわちアドレス)が分かれば、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
を付けます。例えば、数字の39
、1207
は十進表記であり、0x27
、0x4B7
は十六進表記です。
バイナリの解釈と型
1 byteは8桁の二進数で表せるし、0~255の整数で表せますが、各byteは必ずしもある整数を表すとは限りません。小数、文字、音声、画像、動画といったコンピュータが扱うものであれば、必ずビット列に変換されてバイナリで表されます。
コンピュータがよく扱う情報は次のように挙げられます。
- 整数
- 実数(有理数で近似)
- 文字
- ポインタ(他のデータの所在アドレス)
- 命令(プロセッサが実行するもの)
- これらの組み合わせ(構造体、struct)
注1:Big Endianを使うと仮定する
注2:整数は2 byteの符号付き整数で2の補数を使うと仮定する
注3:実数はIEEE 754の標準に従って4 byteで表すと仮定する
注4:文字はShift-JISエンコーディングを使うと仮定する
注5:得られるバイナリは符号化の方式に依存するため、注1~4のように符号化方式を細かく決める必要があります
Endianとは
2 byte以上のバイナリをメモリに格納する時、格納するbyteの順番を決める必要があります。一般的には次の2種類の方法が使われています。
どちらがいいというわけではありませんが、Endianを間違えると解釈違いが発生してしまうので、注意する必要があります。GC/WiiのゲームではBig Endianが使われます。
保存したい情報をこのようにバイナリに符号化(encode)すれば、メモリに格納することができますね。では、格納されたバイナリはどういう情報を表すのでしょうか。例えば、BC AC B2 DD
という4 byteのバイナリはどんな情報を表すのでしょうか。
答えは…そうですね、分からないです。コンピュータにとってどれも同じバイナリですが、人間の解釈によって異なる情報を表すことになります。
メモリに格納されているバイナリを正しく解釈するために、型が必要となります。例えば、CやJavaといった静的型付けのプログラミング言語では、開発者が開発時に各変数にint
(整数)、float
(実数)、char
(文字)といった型を付ける必要があります。変数に型を付けておけば、誤った解釈をしてしまう恐れはないですね。
一方、JavaScriptやPythonの開発者はあまり型に気にしていないかもしれません。変数や関数に型を付ける必要がないから、型が存在しないのではないかと思われるかもしれませんが、実はそうではありません。証拠として、JavaScriptにはtypeof
があり、Pythonにはtype()
があって各変数の型を確認することができます。開発者が明示的に型を付ける必要がないのは、各変数の型は実行時に決められ、変数は値のほかに型の情報も保存されているからです。これを動的型付けといいます。要するに、型が存在しないのではなく、コンピュータが型が管理してくれるからあまり気にしなくてもいいということです。
SunScriptでの型
SunScriptはJavaScriptと同じように動的型付けの言語なので、明示的に型を付ける必要がありませんが、もちろん型は存在します。SunScriptでは3つの型が存在します。
int
:整数(4 byte)float
:実数(4 byte)string
:文字列
intとfloat
JavaScriptでは整数も実数もnumber
(64bitの浮動小数点数)という型として扱われるため、int
(整数)とfloat
(実数)を区別する必要がありませんが、SunScriptでは2つの異なる型として扱われます。動的型付け(後述)なので基本的には気にしなくても大丈夫ですが、writeRAM
といった一部の関数は引数の型によって動作が異なるので、注意する必要があります。
バイナリの解釈違い
ホバーバグとACE
ホバーバグ
バイナリの解釈違いの実例としてホバーバグが挙げられます。ホバーバグについてはNoki Dokiさんと大福さんが詳しく解説されたので、ぜひそちらを参照してください。
注意してほしいのは、ホバーバグが発生する理由はバイナリの解釈違いではありません。単純に開発者のミスで変数を初期化せずに値を使ったせいで、古いデータ(前のステージで残った情報)がホバーの描画行列として使われてしまい、予期せぬ初速ができてしまうだけです。
ただ、よく考えると、古い情報だとしても、何億・何兆・1038程度の数字がゲーム内で現れるはずがないですね。では、このような極端に大きい数字はどこから出てきたのでしょうか。
はい、これはまさにバイナリの解釈違いですね。例えば、古いデータが29043
と49264
の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
- addr:読み込むメモリのアドレス
- type:読み込むメモリの型(解釈)。省略する場合デフォルトではint(整数)とする。指定する場合は次の値が使える:
TYPE_INT
:int
(32 bitの整数)TYPE_FLOAT
:float
8
:uint8
(8 bitの符号なし整数)-8
:int8
(8 bitの符号付き整数)16
:uint16
(16 bitの符号なし整数)-16
:int16
(16 bitの符号付き整数)
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)
- addr:書き込むメモリのアドレス
- value:書き込む値。引数の型を使うため、必要な場合は
int(x)
やfloat(x)
を用いて明示的に型キャストを行い、引数の型が正しいようにすること - type:省略する場合は32 bitのintかfloatを書き込む。8 bitか16 bitの整数を書き込む場合は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)
- dst:コピー先のアドレス
- src:コピー元のアドレス
- count:コピーするbyte数
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] インポート ****/ 2 import "ssc/common.sun"; 3 import "ssc-sup39/common.sun"; 4 5 /**** [2] 変数宣言 ****/ 6 // ポインタの準備 7 var itemManager = readRAM(gpItemManager); 8 var const slotBase = $817d1000; 9 // 赤コイン数のフラグID 10 var const SYSF_REDCOINNUM = 0x60000; 11 // セーブした状態があるかどうか(初期化を確保) 12 var ready = 0; 13 // 前のQFに押されたボタン 14 var btn0 = 0; 15 // 局所変数 16 var slot, var itemCount, var ptrItem; 17 18 /**** [3] メイン関数 ****/ 19 while (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 }
概要
- セーブする状態は
slotBase
(=817D1000)(8行目)から格納する - QF毎にボタン入力を読み込み(21行目)、セーブすべきか(21行目)ロードすべきか(46行目)判断する
- セーブ・ロードする際に、最初に赤コイン数(4 byte)をセーブ(34行目)・ロード(55行目)し、その後に各アイテムの状態をセーブ(37~45行目)・ロード(58~66行目)する
状態の保存先
ゲーム内では、メモリは基本的に小さいアドレスから使うので、大きいアドレスの方は基本的に使われません。逆に言うと、ゲームに使われないので、自由に使ってもいいということになります。
基本的にはアドレスが大きいほど(817FFFFF
に近いほど)競合せずに安全に使えますが、817FXXXXはPractice codeが使うことが多く、817EXXXXは今後特殊用途で使われる可能性があるので、817DFFFFまでを使うことをおすすめします。今回の例では817D1000から使うことにしました。
※ もちろん競合が発生しなければどこでも使えます
ボタン入力
ボタン入力の値はaddrButton
(=80400A50)に格納される16 bitの整数です。ビット演算による押されたボタンの判断方法はこちらを参照してください。
赤コイン数
赤コイン数はgetSystemFlag
とsetSystemFlag
関数でセーブ・ロードできます。
getSystemFlag(flagID: int): int
setSystemFlag(flagID: int, value: int)
他に使える内蔵関数はSSC Builtin and standard utilityを参照してください。
また、赤コイン数のflagID
はSYSF_REDCOINNUM
(=0x60000)ですが、黄色コイン数、残機数、ノズル(ホバー、ロケットなど)といったその他のフラグのIDはSMS RAM Mapを参照してください。
アイテムの状態
各アイテムはItemManagerで管理されているため、アイテムの一覧を取得するために、gpItemManager
からItemManagerへのポインタitemManager
(ItemManagerが格納されるアドレス)を取得します(7行目)。それから、アイテムの数itemCount
(37・58行目)とアイテム一覧の配列の開始アドレスptrItem
(38・59行目)を取得すれば、各アイテムへのポインタが分かります。
図で表すと次のようになります。
まとめると、次のようになります。
また、この例で扱う状態はアイテムのstate(this+0xF0。消滅したかなどを表す)でしたが、アイテムの位置や速度といった他の値を扱う場合は0xF0
をその値が格納される位置(offset)に置き換えばよいです。
各値のoffsetはSMS RAM Mapを参照してください。例えば、位置のoffsetは0x10から0x1Bまでの12 byte(3つのfloat)、速度のoffsetは0xACから0xB7までの12 byte(3つのfloat)です。
セーブ
各アイテムへのポインタが分かれば、各アイテムに対して状態をセーブすることができます(41行目)。
また、41行目で一つのアイテムの状態をセーブした後、次のアイテムの状態をセーブするために、43行目でptrItemを次のアイテムに指すように4進め、44行目でslotを次の保存先アドレスに指すように4進めます。
ロード
ロードはセーブと同様です。62行目で各アイテムに対して状態をロードし、64行目でptrItemを次のアイテムに指すように4だけ進め、65行目でslotを次のロード先アドレスに指すように4だけ進めます。
補足
39と60行目の
while (itemCount--) {
は
for (i=0; i<itemCount; i++) {
と同値です。itemCountを具体的な値(2, 4, 8など)にして検証してみましょう。
追記
思ったより遥かに難しかったので、色々上手く解説できなかったと思いますが、もし分かりにくいところがありましたら、Peingやツイッターの方でお気軽にお声がけください。より深く学びたい方はC/C++を学ぶことをおすすめします。
次からはサポミクが実装した関数のドキュメントを整理し、様々な実装例を公開する予定です。現時点では解説する予定はありませんが、もし需要がありましたらまた解説記事を作成します。よろしくお願いいたします。