S式サブセットとそのリーダを考える会
ここ3ヶ月くらい殆どnmosh関連の開発ができていないが、そろそろゲーム(teslawire)作りの方がコーディングに戻ってくるので。。
teslawireでは、細かいコンフィグやシナリオ上のスクリプトデータをS式で表現する - メモリの都合でリリース時には専用のバイナリフォーマットに変換され適宜ロードされる。別に適当な文法を作っても良いかもしれないが、S式にはpareditのような良いエディタが有る。JSONやXMLへの置き換えも一瞬考えたが、oXygenのような良いエディタが簡単に入手できるわけでもないので。
現状はS式の処理にはnmoshを丸ごと使っているが、これだとメモリが厳しい環境ではどうやっても収まらない(し、古い環境にどうやって対応すんのかという問題が残る)。というわけで、Pure Cでリーダーと簡単なオブジェクトシステム(文字列処理やMark&Sweep GC等)を実装することにした。
...これをやるとScheme処理系を丸ごと作るのとほぼ変らない労力が掛かるというのはよく知られたところではあるが、背に腹は代えられない。。本来は逆、つまり、移植性の高いScheme処理系を新たに作ってその上にゲームを載せる方が美しいし、実際それを目指していた時期もあったが、今回は20世紀の遺物みたいな環境でも動かさないといけないので必要最低限のものにしないとダメな可能性が高いと考えた。
重要な背景は、(nmoshのようなデカいインタプリタが使える)モダンPCでゲームのバグを取ったあとモバイルや専用機のような厳しい環境でネイティブコードを書けば良いと思っていたのを諦め、全ての環境でちゃんとしたREPLとダイナミックなSDK API呼び出しを実現するという路線変更にある。結局のところ、シナリオデータ/ステージデータ自体のバグはそれほど多くなく、バグの70 %以上は環境固有のランタイムまたは環境そのものに存在するためそちらのデバッグを徹底する必要があった。
パーサAPIのデザイン
今回はjsmnのデザインをもってくることにする。jsmnは最低限(300行程度)のJSONパーサであり、標準Cライブラリにすら依存しない。
パースAPIはオブジェクトではなく単にトークンタイプとストリーム中のrangeを返すことになる。
パーサコードにオブジェクトの生成を埋め込まないのは、リーダーマクロというかシナリオテキストには専用のリーダを用意するため。jsmnは
typedef enum { JSMN_UNDEFINED = 0, JSMN_OBJECT = 1, JSMN_ARRAY = 2, JSMN_STRING = 3, JSMN_PRIMITIVE = 4 } jsmntype_t; typedef struct { jsmntype_t type; int start; int end; int size; #ifdef JSMN_PARENT_LINKS int parent; #endif } jsmntok_t;
という定義のjsmntok_tを埋めるだけというAPIになっている(クライアントは予めjsmntok_tの配列を用意してリーダに渡す)。このため同じAPIのパーサを別に用意すれば、実際に読んでいるのがS式でなくても、クライアントコードからはS式と同等に扱える。オブジェクト生成APIをポータブルに設計するのはかなり骨が折れる作業になるので、APIをリーダAPI側で切ってしまう方が望ましい。
S式のサブセットを考える
パーサを簡略化するため、S式をギリギリまでサブセットにする。要求は、
- R6RS / R7RS処理系の標準リーダで同様に読める
- ゲームリソースを記述するのに十分なプリミティブを備える
- コメントが書ける
こと。
今回は、既存のゲームリソースで使われているものを中心に以下のような仕様とした:
- オブジェクトの区切りはカッコ、スペース(0x20)またはダブルクオートでなければならない
- コメント
- セミコロンの行コメント
- ブロックコメント(SRFI-30 http://srfi.schemers.org/srfi-30/srfi-30.html )
- #; によるdatumコメント
- # 記法
- 文字列の\エスケープ
- \" のみ。リーダは実際のエスケープ内容には関知しない。
- 数値
- その他Scheme固有の短縮記法
- '() - いわゆる空リスト - を特別扱いで読む
- quote、quasiquote、unquote、syntax、quasisyntax、unsyntax - を特別扱いで読む
意外とdatumコメントを使っている。。3Dステージデータを格納する都合上、残念ながらほぼ完全なnumeric towerも実装する必要がある。
今となってはかなり後悔しているが、R6RSのsyntaxやquasisyntaxを特別な意味で使っているのでこれらもサポートする必要がある。(スクリプト中のシンボルに対してブレークポイントを設定していることを #'sym のように表記している)
空リストの特別扱いとは、'()を(quote ())のように読まずに、単一のNILオブジェクトとして読み取ること。これは、通常のScheme処理系の内部表現でもNILは特別なオブジェクトとして処理しているため。
重要なomissionはUnicode空白等の拡張文字コードを一切処理しない点。平たく言えば全てbytevector上で動作する。(ただ、R7RS compliantは目指しても良いかもしれない - 今のnmosh readerではサポートできないため)
また、 #t#t のような連続表記もサポートしない。(実はnmoshやGaucheは #t#t で #t 2つをreadしてくる)
次の一手
次の一手はイベントストリームの設計。
数値と文字列を除くと、lexerは高々 N 文字の先読みで1つのイベントをpushできることになる。数値と文字列、コメントは無限長になり得るため特別扱いが必要になる。
リソース節約のため、他の常識的なリーダと異なり、quoteのようなSchemeの特殊構文を抽象化しないことにする。(この段階はリーダというよりはlexerと言えるが、諸般の事情でこの区別は実際のオブジェクト構築の際にも継承される)
'hoge ;; => [QUOTE_NEXT] [SYMBOL "hoge"] の2イベントを生成する (quote hoge) ;; => [LIST_BEGIN_PAREN] [SYMBOL "quote"] ;; [SYMBOL "hoge"] [LIST_END_PAREN] の4イベントを生成する
このQUOTE_NEXTとかLIST_BEGINのようなイベントを列挙していく必要がある。