WASM-4でのセーブデータの扱い
ゲーム開発していて思ったんですが、ゲーム全体の状態を直列化してあとで復元できたりすると、開発がかなり楽になります。たとえばゲームのレベル(ステージ)をちょっと修正したくなって、コードをちょっといじってホットリロードしたとき、普通だとプレイヤーキャラクターの位置が初期化されてスタート位置に戻ってしまい、問題の箇所までまた歩いて行かなけれななりません。でも自動でプレイヤーキャラクターの位置を復元できるようにすれば、修正後のリトライがすごく早くなります。なので早い段階でセーブとロードの仕組みも押さえておこうと思います。
WASM-4でのセーブデータの扱いは非常にシンプルで、diskw
関数で生バイト列を書き込み、diskr
で生バイト列を読み込む、そのふたつしかありません。なお、セーブデータに使える領域は最大で 1024 バイト しかないので注意です。
Serdeって何語なんや
それで、Rustでデータの直列化について少し調べたら、RustではSerdeというクレートがよく使われているようなので、これを使っていきたいと思います。
ところで、Serdeってなんだっけ?そんな英単語あったっけ?と思って少し考えたのですが、これもしかして Serialize と Deserialize の両方の頭のほうの文字をとって繋いだだけだったりします?
Serialize
とDeserialize
のトレイトを導出する
Serdeのビルドの設定には若干コツがあるようで、今回も Serde のドキュメントをよく読まずに突っ込んだらあっさりコンパイルエラーで失敗しました。公式ドキュメントにちゃんとチェックポイント付きで説明されていました。
- Cargo.tomlに
serde = { version = "1.0", features = ["derive"] }
を追加 -
rmp-serde = "1.1"
も追加 - 対象のstructを定義するクレートで、
serde::Serialize
を読み込み、#[derive(Serialize)]
でトレイトを導出する - 同様に、
serde::Deserialize
を読み込み、#[derive(Deserialize)]
でトレイトを導出する
まあセルフチェック式のチェックボックス まで付けて公式ドキュメントに書いてあるということは、私と同じようにドキュメントを読まずに使おうとしてつまずく人が多いんでしょうね。
今回はプレイヤーキャラクターの位置だけ保存しようと思うので、x: f32
とy: f32
を持つstructを定義します。また、一応今後のバージョンアップに備えて version: u8
というフィールドも持っておくことにしました。derive
も付けてこんな感じです。
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct GameData {
pub version: u8,
pub x: f32,
pub y: f32,
}
rmp_serdeを使ってみる
SerdeはJSONやYAMLなどの様々な形式に対応しているようですが、今回は MessagePack を試してみたいと思います。なにしろ1キロバイトまでしかデータを保存できないので、JSONで雑に直列化しようとしたらあっさりサイズをオーバーしてしまうような気がします。その点、MessagePackはコンパクトな形式なので、今回の目的には適しているかなと思います。
Serde本体は直列化のインターフェイスだけを用意するだけのようで、それぞれの形式での直列化の実装は別クレートを使います。MessagePackの場合は rmp_serde を使えばいいようです。
構造体をMessagePackのバイナリ列に直列化するには、rmp_serde::to_vec
を使えばいいようです。また、to_vec
の結果はVec<u8>
で返ってきますが、 diskw
は生のポインタ *const u8
を取るので(いや生々しすぎないか?)、vec.as_slice().try_into()
のようにするといいらしいです。
pub fn save(game_data: &GameData) {
let vec: Vec<u8> = rmp_serde::to_vec(game_data).unwrap();
let bytes: &[u8] = vec.as_slice().try_into().unwrap();
unsafe {
diskw(bytes.as_ptr(), core::mem::size_of_val(bytes) as u32);
}
}
なんか unwrap
とか雑に使いまくって大丈夫なのかと思いますが、筆者はまだ Rust 初心者なのでこれでいいのかよくわかりません。もしダメだったら教えてください。
読み込みのほうは、適当にスタック上に領域をとって diskr
にポインタを渡して読み込みます。それから rmp_serde::from_slice
でMessagePack形式のバイナリ列をstructに変換します。
pub fn load() -> GameData {
let mut buffer: [u8; GAME_DATA_SIZE] = [0; GAME_DATA_SIZE];
unsafe {
diskr(buffer.as_mut_ptr(), buffer.len() as u32);
}
rmp_serde::from_slice(&buffer).unwrap()
}
ここでちょっとした問題があって、diskr
にはバッファのサイズを引数で指定しなければなりません。しかし、セーブデータの形式はMessagePackに詰めているだけなので、実際に直列化してみないとわからないし、データの内容によってサイズが変わってしまうことがわかりました。じゃあ適当に最大の1キロバイトを指定しておけばいいかというと、何しろWASM-4のメモリ領域は限られているので雑に1キロバイト確保するのはちょっともったいないです。
仕方ないので、なんか十分足りそうな感じのサイズ GAME_DATA_SIZE
を定義して、もし書き込むときにそれを超えたらエラーにすることでチェックすることにしました。もしかしたら、データの先頭にセーブデータ全体の長さを入れておくとかにしたほうがスマートかもしれません。
あとぜんぜん関係ないのですが、Rustを書いていてもついクセで変数名や関数名をキャメルケースにしてしまいます。Rustの標準だとスネークケースが推奨とのことなのですが、このへん未だに慣れません。
bincodeについて
Rustで直列化に使われるライブラリとしては、bincodeというものもあるようです。
これもコンパクトで効率のいいバイナリ形式のようで、このライブラリ単独で使えて依存関係もシンプルにできるのですが、Rustのこのライブラリでしか使われていない独自の形式なので今回は採用を見送りました。bincodeではなくMessagePackを採用したのは他にも思惑があるのですが、それは他の記事で触れようと思います。
何やこれ!でかすぎるやろ!
それでしばらく開発を続けていて気付いたのですが、なんか最近カートリッジのビルドサイズが大きいような……。なんか嫌な予感がしてWASMの中を覗いてみると、なんか serde
とついたシンボルがやけに多い気がする……。しかもなんか serde 関係の関数がとてつもない数のローカル変数を宣言してる……なんだこれ……。
- Serdeなし 29799バイト
- Serdeあり 55659バイト
おい! 25キロバイトも増えてるやんけ! 64キロバイトしかなくてただでさえカツカツなのに、これはあかんやろということで、ここまで記事も書いてやっておいてアレですが Serde はボツにしました。
bytes
で生バイト列を書く
代替として何を使うかというと、bytes
で固定レイアウトの生バイト列を書きます。こうじゃい!
pub fn save(game_data: &GameData) {
let mut buf = Vec::new();
buf.put_u8(0); // version
buf.put_f32_le(game_data.x); // x
buf.put_f32_le(game_data.y); // y
let bytes: &[u8] = buf.as_slice().try_into().unwrap();
unsafe {
diskw(bytes.as_ptr(), core::mem::size_of_val(bytes) as u32);
}
}
身も蓋もないですが、これでセーブデータを保存できました。
念のためSerdeを取り除いたあとのWASMの中を点検したら、大半は core
とか buildin
、あと buddy_alloc
というようなものがついたシンボルばかりだったので、これ以上大きく削減することはできなさそうです。でももし Serde を使っていてもバイナリサイズを減らせる解決方法を知っている人がいたら教えてください。
次回予告
WASM-4は生のメモリを参照する生々しいフレームワークです。ちょっと低レベルな部分を覗いてみます。