3
0

More than 1 year has passed since last update.

ラストのまほう 第11話『Serdeでセーブとロード』

Last updated at Posted at 2022-12-10

WASM-4でのセーブデータの扱い

ゲーム開発していて思ったんですが、ゲーム全体の状態を直列化してあとで復元できたりすると、開発がかなり楽になります。たとえばゲームのレベル(ステージ)をちょっと修正したくなって、コードをちょっといじってホットリロードしたとき、普通だとプレイヤーキャラクターの位置が初期化されてスタート位置に戻ってしまい、問題の箇所までまた歩いて行かなけれななりません。でも自動でプレイヤーキャラクターの位置を復元できるようにすれば、修正後のリトライがすごく早くなります。なので早い段階でセーブとロードの仕組みも押さえておこうと思います。

WASM-4でのセーブデータの扱いは非常にシンプルで、diskw関数で生バイト列を書き込み、diskrで生バイト列を読み込む、そのふたつしかありません。なお、セーブデータに使える領域は最大で 1024 バイト しかないので注意です。

Serdeって何語なんや

それで、Rustでデータの直列化について少し調べたら、RustではSerdeというクレートがよく使われているようなので、これを使っていきたいと思います。

ところで、Serdeってなんだっけ?そんな英単語あったっけ?と思って少し考えたのですが、これもしかして Serialize と Deserialize の両方の頭のほうの文字をとって繋いだだけだったりします? 

SerializeDeserializeのトレイトを導出する

Serdeのビルドの設定には若干コツがあるようで、今回も Serde のドキュメントをよく読まずに突っ込んだらあっさりコンパイルエラーで失敗しました。公式ドキュメントにちゃんとチェックポイント付きで説明されていました。

  • Cargo.tomlにserde = { version = "1.0", features = ["derive"] }を追加
  • rmp-serde = "1.1"も追加
  • 対象のstructを定義するクレートで、serde::Serializeを読み込み、#[derive(Serialize)]でトレイトを導出する
  • 同様に、serde::Deserializeを読み込み、#[derive(Deserialize)]でトレイトを導出する

まあセルフチェック式のチェックボックス まで付けて公式ドキュメントに書いてあるということは、私と同じようにドキュメントを読まずに使おうとしてつまずく人が多いんでしょうね。

今回はプレイヤーキャラクターの位置だけ保存しようと思うので、x: f32y: 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は生のメモリを参照する生々しいフレームワークです。ちょっと低レベルな部分を覗いてみます。

3
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0