ELVM Scratch 3.0 backend
この記事は 言語実装 Advent Calendar 2020 の12日目の記事です。 昨日はfukkunさんの「Lazy Code Motion」でした。 明日はelpinalさんの「モジュールのパターンマッチ・Focused Logic」です。
はじめに
こんにちは。algonです。
今年の4月頃にScratchで遊んでいて、そのときにELVMのScratch 3.0のバックエンドを実装しました。 この記事では
- Scratchについて
- ELVMについて
- どのようにScratchバックエンドを実装したか
などについて紹介したいと思います。
Scratchとは
ScratchはMIT Media Lab Lifelong Kindergarten Groupによって開発されているビジュアルプログラミング言語とその開発環境です。 ブロック状のパーツをグラフィカルに組み合わせていくことで直感的にプログラミングすることができます。
簡単な例を示すと、「1秒毎に変数をインクリメントして表示する」プログラムは以下のように実装できます。 (こちらから実行することができます。)
このプログラムでは、緑色の旗ボタンをクリックすると実行が始まり、 1秒毎に「へんすう」がインクリメントされながら吹き出しの中にその値が表示されます。
Scratchでは、このような簡単なプログラムはもちろん、 ブロック崩しのようなゲームからLISP処理系まで、 基本的には何でもつくることができます。
開発環境
Scratchの開発環境は公式サイトから利用でき、Webブラウザさえあれば簡単に遊ぶことができます。 作ったプログラムを共有しないのであればログインすら不要です。
言語について
Scratchでは「緑色の旗ボタンがクリックされたタイミング」や「キーボードの〜キーが押されたタイミング」 などのイベントに応じて動作するスクリプトを記述することによってプログラムを行います。
データ型
Scratchのデータには数値、文字列、真偽値の3種類があります。 数値と文字列はどちらも同じ形のブロックで表され、必要に応じてキャストが行われます1。
ブロック
Scratchでは以下に挙げるようなブロックを組み合わせることでプログラミングを行います。 ブロックの機能によって形や色が分けられています。 ブロックの形が合わないような繋ぎ方はできなくなっており、これによってシンタックスエラーを回避しています。
イベントブロック
上部が丸くなっているブロックは「ハットブロック」と呼ばれ、
対応するイベントが発生するとこれらのブロックから実行が開始されます。
コントロールブロック
条件分岐やループを行うためのブロックです。
数値演算
数値演算を行うブロックです。
文字列演算
文字列に関する演算を行うブロックです。
真偽値演算
真偽値に関する演算を行うブロックです。
変数
Scratchでは変数を扱うことができます。
変数には数値か文字列のどちらかを格納することができます。
リスト
リストを扱うこともできます。
その他
その他にも様々な機能を持つブロックが用意されています。
カスタムブロック
一連の手続きをまとめた新しいブロックを定義して利用することができます。
他のプログラミング言語における関数のように使うことができ、再帰的に呼び出すこともできます。
ここに挙げたブロック以外にも、見た目を変えたり音を鳴らしたりするブロックも用意されています。 ブロックの完全な一覧と説明はwikiを参照してください。
Scratchのコード生成
さて、Scratchについての説明はこれくらいにしておいて、本題に移りたいと思います。
一通り遊んでみたあと、何か面白いことはできないかと考えていました。
そんなときに思い出したのがScratcher’s AtCoderです。
Scratch → C++ (Scratcher’s AtCoder)
Scratcher’s AtCoder は、yos1upさん作の、 ScratchでAtCoder(競技プログラミングのコンテスト)に参加するためのChrome拡張機能です。
Scratcher’s AtCoderでは、 ScratchのプログラムからC++のコードを出力することによってAtCoderに参加できるようにしています。
「〜ときいてまつ」ブロックと「〜という」ブロックを標準入出力として解釈してC++のソースコードへ変換を行っているようです。
C言語 → Scratch (???)
ScratchからC++を生成することができるのであれば、 逆に別の言語からScratchプログラムを生成することもできるはずです。 少し調べたところ誰もやっていなさそうなので挑戦してみることにしました。
そこで、ELVMを用いて C言語からScratchのコードを生成することを考えました。
ELVMはLLVMのような仕組みを用いて、C言語から様々な難解言語に対してコンパイルを行うことができるコンパイラ基盤です。 ELVMでは、フロントエンド(改造された8cc)によってC言語からELVM IRへの変換が行われ、 各言語のバックエンドによってIRからそれぞれの言語への変換が行われます。
今回はScratchのコードを生成するので、 ELVM IRを入力としてScratchのコードを出力するバックエンドを作成することになります。
Scratchのソースコード(?)を扱う
Scratchエディタで作成したScratchプログラムはメニューの「ファイル → コンピュータにほぞんする」 からローカルに保存し、「ファイル → コンピュータからよみこむ」から保存したプロジェクトを 読み込むことができるようになっています。
ここで保存・読み込みしているファイルがプログラムの本体なので、このファイルを機械的に生成すればよいことになります。
このファイルのフォーマットについてはScratch File Format - Scratch Wikiに詳しい説明がありました。
The Scratch 3.0 file format is the format used to store exported Scratch 3.0 projects and sprites. These are ZIP archives which contain information encoded in a text-based format called JSON and project media in separate files. Projects have the extension .sb3, and sprites .sprite3.
JSON形式のプロジェクトファイルとその他のアセット(画像や音声)がzipアーカイブされたファイルになっているようです。
wikiのページにはJSONファイルについてのフォーマットが書かれています。 大まかには次のような雰囲気になっています。
クリックで開く
{
"targets":[
{
"isStage":true,
"name":"Stage",
"variables":{
"#r:a":["a", "0"],
"#r:b":["b", "0"],
...(略)...
},
"broadcasts":{},
"blocks":{
...(略)...
"b250":{
"opcode":"data_setvariableto",
"next":"b251",
"parent":"b247",
"shadow":false, "topLevel":false,
"inputs":{ "VALUE":[2, "b249"]},
"fields":{ "VARIABLE":["b", "#r:b"]}
},
"b249":{
"opcode":"operator_mod",
"next":null,
"parent":"b250",
"shadow":false, "topLevel":false,
"inputs":{
"NUM1":[2, "b248"],
"NUM2":[1, [10, "16777216"]]
},
"fields":{}
},
...(略)...
},
"comments":{},
"currentCostume":0,
"costumes":[{
"name":"backdrop",
"assetId":"fcb8546c50e11d422b78fd55e34d7e7e",
"md5ext":"fcb8546c50e11d422b78fd55e34d7e7e.svg",
"dataFormat":"svg"
}],
"sounds":[],
"volume":100,
"layerOrder":0,
"tempo":60,
"videoTransparency":50,
"videoState":"on",
"textToSpeechLanguage":null
}
],
"monitors":[],
"extensions":[],
"meta":{"semver":"3.0.0"}
}
重要なのは"variables"
と"blocks"
です。
"variables"
にはプログラム内で使用している変数についての情報が入っています。
"blocks"
にはブロックの種類やブロック同士がどのように繋がっているかなどといった情報が入っています。
ELVM
ELVMはとてもシンプルに設計されたVMです。 簡単にまとめると、以下のような仕様になっています。
- ワード: 符号なし整数 (多くのバックエンドで24bit)
- レジスタ: A, B, C, D, SP, BP の6種類 (それぞれ1ワード)
- メモリのアドレス空間: 1ワード
- 命令列はメモリとは別に存在
- 各ベーシックブロックに対してアドレス(PC)が振られる
- あるPCへジャンプすると対応するベーシックブロックの先頭の命令から順に実行される
- 命令: 以下の22種類
- MOV dst, src
- 代入
- ADD dst, src
- 加算
- SUB dst, src
- 減算
- LOAD dst, src
- メモリ読み込み
- STORE src, dst
- メモリ書き込み
- PUTC src
- レジスタの値をASCIIコードとして標準出力に1文字書く
- GETC dst
- 標準入力から1文字読み、ASCIIコードをレジスタに格納
- EXIT
- プログラムの終了
- JEQ/JNE/JLT/JGT/JLE/JGE jmp, dst, src
- 条件付きジャンプ
- JMP jmp
- ジャンプ
- EQ/NE/LT/GT/LE/GE dst, src
- 比較結果(0か1)をdstに格納
- DUMP
- 何もしない
- MOV dst, src
バックエンドの実装
ここからはELVMの構成要素をScratch上でどのように扱うかについて見ていきます。
レジスタ
ELVMの6つのレジスタはScratchの変数を用いて管理します。 符号なし24bit整数の範囲(0〜16777215)はそのままScratch上での数値として扱うことができます2。
標準入出力
標準入力は「〜ときいてまつ」ブロックを用いて受け取り、 標準出力はリストの1要素を1行にみたてて出力します。
以下のスクリーンショットは test/echo.eir から生成した例です。 NULL文字が入力されるまで標準入力の文字を標準出力にエコーし続けるプログラムです。
特殊文字を入力するためにエスケープ文字として全角バックスラッシュ(\
)を使うことにしました3。
改行文字は \n
、null文字は \0
、それ以外の特殊文字は \dXXX
(XXX
は10進3桁)で入力します。
入力を処理する部分は次のように実装しました。
_w
はGETC命令が呼ばれたことを示すフラグ変数です。
標準出力のstdout
の他に、標準入力のバッファとしてstdin
というリストを用いています。
入力された文字列を文字毎に区切ってstdin
に追加しています。
GETC命令の実装は次のようになっています。
標準入力から1文字取ってきてascii_encode
を呼び出しています。
結果はgetchar
とascii_encode
どちらも変数_r
に格納するということにしています。(ここではascii_encode
の結果がそのままgetchar
の結果になります。)
PUTC命令の実装は次のようになっています。
今度は_r
が入力となり、入力の数値をASCIIコードとして解釈し、対応する文字をstdout
に追記しています。
改行文字のとき(_r == 10
)は新しい行を追加します。
ASCIIエンコーダ・デコーダ
ascii_encode
とascii_decode
の実装は以下のようになっています。
特殊文字(\dXXX
)の場合は3文字目から3文字をそのままコードとして返します。
それ以外の文字の場合は後述する背景を用いたテクニックによってASCIIコードに変換します。
当初はASCIIテーブルをリストとして埋め込んでおいて、 リストのi番目を取得する操作と線形探索によってASCIIエンコーダ・デコーダを実装しようとしていたのですが、 Scratchの等価演算子(=ブロック)は大文字小文字を区別しない という仕様のせいでうまく動きませんでした。
大文字小文字を区別して等価判定を行うハックとして「コスチューム」を用いる方法がScratch Wikiで紹介されていたので最終的にこれを採用しました4。
背景
背景は複数用意することができ、それぞれに名前をつけることができます。
Blue Sky
という名前、2番の背景にはDesert
という名前がつけらています。
ステージの背景を切り替えるための「はいけいを〜にする」というブロックがあります。
入力には背景の番号もしくは背景の名前を用いることができ、
指定した背景に切り替えることができます。
ここで、背景の名前を入力したときの挙動がポイントで、実はこのときの背景の名前は大文字と小文字が区別されるという仕様になっています。 そして現在の背景の名前と番号はそれぞれ「はいけいのなまえ」「はいけいのばんごう」ブロックから得ることができます。 これらを使って背景の名前としてASCIIテーブルを埋め込むのが次のテクニックです。
ASCIIテーブルを背景に埋め込む
背景を128個用意し、それぞれに
- 1番目の背景は「
\0
」 - 2番目の背景は「
\1
」 - …
- 34番目の背景は「
!
」 - 35番目の背景は「
"
」 - …
- 66番目の背景は「
A
」 - …
- 98番目の背景は「
a
」
のように名前をつけておくと、背景をASCIIテーブルとして用いることができるようになります。
例えば「はいけいを33+1
にする」を実行したあとで「はいけいのなまえ」を参照することで!
を得ることができ、ASCIIコードの33から!
という文字を引くことができます。
エンコードはこの逆を行います。
「はいけいを〜にする」ブロックでは大文字小文字が区別されるため、「はいけいを"A"
にする」と「はいけいを"a"
にする」はそれぞれ66番目の背景と98番目の背景に切り替わり、正しくASCIIエンコードを行うことができます。
メモリの管理
24bitのアドレスで表現されるメモリをどう扱うかが一番の悩みどころでした。
まず愚直な方針として、巨大なリストを用意し、メモリの各ワードをリストの1要素として表現しようと考えました。 しかし残念ながら、Scratchのリストは最大で20万要素に制限されているため、そのままでは24bitのアドレス空間を扱うことができません。
また、2^24個の値を10進表記の文字列として連結して1つの巨大な文字列として扱うのはどうだろうと思い試してみたところ、プロジェクトが重くなりすぎてエラーで落ちてしまいました。
実はScratchのプロジェクトファイルには最大で5MBというサイズ制限があり、メモリの情報を直に持たせるとそれだけで5MBを超えてしまうのが原因でした。
そこで値を文字列としてそのまま連結するのではなく圧縮した状態で持つことにしました。 圧縮といっても複雑なロジックを書くのは大変なのでBase64を用いて文字列化します。 初めは16進表記で文字列化しようとしていましたが、圧縮率の観点からBase64でエンコードすることにしました5。
それでもメモリ全体の値を持つのはファイルサイズの制限からまずそうなので、適当なグループ毎(4069要素毎)にリストの1要素に格納することにして、未使用の部分は空にしておいて必要になったタイミングで生成するようにしました。
この例では、
- 0番地に
8388608
- 1番地に
4194304
- …
- 4096番地に
0
- 4097番地に
0
- …
という感じで値が格納されています。
あとはASCIIコードと同じようにBase64エンコーダ・デコーダを実装すれば完了、といきたいところですが、ここに来てまたまた問題に直面します。
実は、Scratchには「文字列のi文字目を変更する」ブロックが存在しないのでした! 「文字列のi文字目を取得する」ブロックがあるので変更するブロックもあるだろうと思い込んでいました……
しばらく考えた後、 特定のグループ(4069要素)だけ別のリストに要素毎にデコードしておいて、 必要になったタイミングでエンコードして書き戻す方針を思いつきました。
ちょうどOSのページイン・ページアウトのようなイメージです。 (以降、Base64デコードして要素ごとにばらす操作をページイン、その逆をページアウトと呼ぶことにします。)
ページアウトの処理には「i文字目を変更する」という操作は必要なく、 「文字列の末尾に文字を追加する」操作によって実現できます。
展開中のページ以外の範囲を書き換える必要が出てきたら、そのタイミングでページアウト・ページインを行います。
「こういうときはダーティビットを用意すると不要なページアウト処理をなくせる」
というOSの授業で習った知識を思い出しましたが、今回は不要でした。
メモリのロードだけなら「文字列のi文字目を取得する」ブロックを使うことでページインなしで読み込めるからです。
つまり、ページインが必要なのはメモリを書き換えるときのみなので、そもそもダーティビットは不要なのでした。
そんなこんなでようやくメモリ管理の方針が決まりました。
Base64エンコーダ・デコーダ
base64_encode
、base64_decode
の実装は以下のようになっています。ASCIIエンコーダ・デコーダと基本的に同じです。
背景の番号をASCIIの空間分(128要素分)ずらしている点と、
衝突を避けるために先頭に !
を前置している点が異なります。
MOV命令
変数の値を初期化するブロックがそのまま使えます。
ADD命令、SUB命令
符号なし整数のラップアラウンドのために0x1000000でmodを取っています。
LOAD命令、STORE命令
_r
変数を経由して値を読み書きしています。
load
とstore
の実装は省略します。)
EXIT命令
処理を中断するブロックがそのまま使えます。
JMP系命令
pcレジスタを変更します。-1しているのは外側のループでpcのインクリメントを行っている分を打ち消すためです。 (あるベーシックブロック内の命令をすべて実行し終えたら次のベーシックブロックに遷移する必要があるため、ループの末尾でPCのインクリメントを入れています。)
JGE, JLE, JNE命令では、比較を行う際にブロック数を減らすために逆の判定を行いif-elseブロックのelse側を利用するような工夫を行っています。
Scratchには「X ≤ Y」のようなブロックが存在しないため、愚直にやろうとすると「X<(Y+1)」のようになってしまい、加算の1ブロックが余計に必要になってしまいます。
比較系命令
if-elseブロックを用いて愚直に実装しています。GE命令やNE命令では、JGE命令やJNE命令と同様のブロック数を減らす工夫を行っています。
制御フロー
PCレジスタの値に応じて対応するベーシックブロックを実行するようにします。
switch文が欲しくなりますが、Scratchにはswitch文に相当するブロックは存在しないので、if-elseを用いて実装します。
素直に実装すると次のようになると思います。
これでいいのですが、目的のベーシックブロックにたどり着くまでにPCの大きさに比例した回数の比較が発生することになり、後ろの方を実行するのが遅くなってしまいます。
そこで、以下のように二分探索的にif-elseをネストするようにしました。
最終的には、ここからさらに512個毎のチャンクに分けることでネストが深くなりすぎないようにしました。 (ただ、この工夫にどれくらい効果があるかは不明です。)
C言語でJSONを生成
ELVMのバックエンドとして完成させるために、ここまでに紹介したScratchのプログラムをJSONとして出力するものをC言語で実装します。 wikiのJSONの仕様が不完全だったため、コードを読みに行ったりScratchエディタで組み立てたものをリバースエンジニアリングしながら頑張りました。
実装したものが target/scratch3.c になります。
方針としては、まずはブロックの木を構築し、これをトラバースしながらJSONを出力しています。
バックエンドのエントリポイントは void target_scratch3(Module *module)
です。module->data
に静的に配置するデータの情報が、module->text
に命令列が格納されています。
以下の部分で各コンポーネントを構築しています。
1635 scr3_impl_read_stdin();
1636 scr3_impl_ascii_encode();
1637 scr3_impl_ascii_decode();
1638 scr3_impl_base64_decode();
1639 scr3_impl_base64_encode();
1640 scr3_impl_writeback_page();
1641 scr3_impl_expand_page();
1642 scr3_impl_store();
1643 scr3_impl_load();
1644 scr3_impl_initialize();
1645 scr3_impl_getchar();
1646 scr3_impl_putchar();
1647 scr3_impl_init_memory(module->data);
1648 scr3_impl_main_loop(module->text);
その後、JSONを出力します。 Scratchプログラムの変数(レジスタやリスト)、ブロック(プログラム本体)、背景(ASCIIテーブルとBase64テーブル)などを出力していきます。
1652 printf("{\"targets\":[{\"isStage\":true,\"name\":\"Stage\",");
1653 printf("\"variables\":{");
1654 for (scr3_reg_t reg = 0; reg < scr3_reg_count; reg++) {
1655 if (reg) putchar(',');
1656 const char *name = scr3_register_name[reg];
1657 printf("\"#r:%s\":[\"%s\", \"0\"]", name, name);
1658 }
1659 printf("},\"lists\":{");
1660 for (int list = 0; list < SCR3_NUM_OF_LIST; list++) {
1661 if (list) putchar(',');
1662 const char *name = SCR3_LIST_NAME[list];
1663 printf("\"#l:%s\":[\"%s\", []]", name, name);
1664 }
1665 printf("},\"broadcasts\":{},\"blocks\":{");
1666
1667 scr3_emit_all();
1668
1669 printf("},\"comments\":{},\"currentCostume\":192,\"costumes\":[");
1670
1671 // embed ASCII table in backdrops
1672 for (int i = 0; i < 128; i++) {
1673 char escaped_char_str[10];
1674 if (32 <= i && i < 127) {
1675 sprintf(escaped_char_str, (i == '"' || i == '\\') ? "\\%c" : "%c", i);
1676 } else if (i == 10) {
1677 sprintf(escaped_char_str, "\n");
1678 } else {
1679 sprintf(escaped_char_str, "\%d", i);
1680 }
1681 printf(
1682 "{\"name\":\"%s\",\"assetId\":\"%s\",\"md5ext\":\"%s.svg\","
1683 "\"dataFormat\":\"svg\"},",
1684 escaped_char_str, SCR3_SVG_MD5, SCR3_SVG_MD5);
1685 }
1686 // embed Base64 table in backdrops (with prefix '!')
1687 for (int i = 0; i < 64; i++) {
1688 printf(
1689 "{\"name\":\"!%s\",\"assetId\":\"%s\",\"md5ext\":\"%s.svg\","
1690 "\"dataFormat\":\"svg\"},",
1691 SCR3_BASE64_TABLE[i], SCR3_SVG_MD5, SCR3_SVG_MD5);
1692 }
1693
1694 // main backdrop
1695 printf(
1696 "{\"name\":\"backdrop\",\"assetId\":\"%s\",\"md5ext\":\"%s.svg\","
1697 "\"dataFormat\":\"svg\"}",
1698 SCR3_SVG_MD5, SCR3_SVG_MD5);
1699
1700 printf(
1701 "],\"sounds\":[],\"volume\":100,\"layerOrder\":0,\"tempo\":60,"
1702 "\"videoTransparency\":50,\"videoState\":\"on\",\"textToSpeechLanguage\":"
1703 "null}],\"monitors\":[{\"id\":\"#l:stdout\",\"mode\":\"list\","
1704 "\"opcode\":");
1705 printf(
1706 "\"data_listcontents\",\"params\":{\"LIST\":\"stdout\"},\"spriteName\":"
1707 "null,\"value\":[],\"width\":480,\"height\":280,\"x\":0,\"y\":0,"
1708 "\"visible\":true}],\"extensions\":[],\"meta\":{\"semver\":\"3.0.0\"}}");
1709 return;
C言語にはraw stringリテラルがないため、頑張ってダブルクオートをエスケープしています。
ちなみに、#define RAW(...) (#__VA_ARGS__)
というマクロによってraw stringリテラルを模倣することもできますが、連続する空白文字が半角スペース1個に置換されてしまいます。
1#include <stdio.h>
2#define RAW(...) (#__VA_ARGS__)
3int main(void) {
4 printf("%s\n",RAW(
5 {
6 "foo": [
7 "bar",
8 "baz"
9 ]
10 }
11 ));
12 // => { "foo": [ "bar", "baz" ] }
13}
初めはこれを使って実装を進めていたのですが、 エディタのシンタックスハイライトやフォーマッタがうまく動かなかったり、 あまりきれいな方法とは言えないので結局使いませんでした。
実行
ELVMのコマンド
elvmのリポジトリのルートディレクトリでmake
を実行すると、out/
以下に8cc
、elc
、eli
などが生成されます。
out/8cc
はELVM IRを出力する機能が加わった8ccです。
C言語のソースファイル(*.c
)をコンパイルしてEIRファイル(*.eir
)を出力することができます。
$ ./out/8cc -S -Ilibc ./test/fizzbuzz.c -o ./fizzbuzz.eir
$ ls ./fizzbuzz.eir
./fizzbuzz.eir
out/elc
はEIRファイルを入力に取って、各バックエンドの出力を得ることができます。
コマンドラインオプションでバックエンドの名前を指定します。
バックエンドの名前はtarget/elc.c
のget_target_func
から確認できます。
$ ./out/elc -rb ./test/basic.eir > basic.rb
$ ruby basic.rb
!!@X
out/eli
はEIRをそのまま解釈して実行するインタプリタです。
$ ./out/eli ./test/basic.eir
!!@X
Scratchプロジェクトの生成
Scratchバックエンドが出力したJSONからsb3ファイルを生成するための補助スクリプト(tools/gen_scratch_sb3.sh)を作成しました。 以下のように使うことができます。
$ ./out/elc -scratch3 ./test/echo.eir > ./echo.eir.scratch3
$ ./tools/gen_scratch_sb3.sh ./echo.eir.scratch3
$ ls echo.eir.scratch3.sb3
echo.eir.scratch3.sb3
elcが出力したJSONファイル(./echo.eir.scratch3
)を引数として渡して実行すると、同じディレクトリに*.sb3
ファイルが生成されます。生成された*.sb3
ファイルはScratchエディタから読み込んで実行できます。
コマンドラインからの実行
生成されたScratchプログラムをコマンドラインから実行するための補助スクリプト(tools/runscratch3.sh, tools/run_scratch.js)を作成しました。
コマンドラインから実行するにはtoolsディレクトリ以下にnpmパッケージscratch-vmをインストールしておく必要があります。
run_scratch.js
側でエスケープ処理を行っているため、
コマンドラインから実行する場合は特殊文字もそのまま入力できます。
$ cd tools
$ npm install scratch-vm
$ echo -e "hello\nworld" | node ./run_scratch.js ../echo.eir.scratch3.sb3
hello
world
ちなみに実行はかなり遅く、私の環境では結果が出力されるまでに20秒くらいかかりました6。
C言語からの変換
以下のようにしてC言語で書かれたプログラム(your_program.c
)をScratchプロジェクト(your_program.scratch3.sb3
)へ変換できます。
$ ./out/8cc -Ilibc -S your_program.c -o your_program.eir
$ ./out/elc -scratch3 your_program.eir > your_program.scratch3
$ ./tools/gen_scratch_sb3.sh your_program.scratch3
$ ls your_program.scratch3.sb3
your_program.scratch3.sb3
8cc in Scratch
$ ./out/elc -scratch3 ./out/8cc.c.eir > ./out/8cc.c.eir.scratch3 # 8ccをScratchに変換
$ cat ./test/8cc.in
int putchar(int x);
int main() {
putchar('X');
return 0;
}
$ time nodejs --stack-size=65500 ./tools/run_scratch.js \
> ./out/8cc.c.eir.scratch3 < ./test/8cc.in > ./8cc.in.eir # Scratch版8ccでコンパイルしてEIRを出力
real 92m52.362s
user 90m44.119s
sys 0m10.411s
$ ./out/eli ./8cc.in.eir # eliで実行
X$
1.5時間くらいかかりましたが、Scratch上で8ccが動きました!!
生成された ./out/8cc.c.eir.scratch3
のサイズは46MBくらいありました。
Scratchの仕様上は5MBの制限があるはずですが、scratch-vmではそれを超えるものでも一応実行できるようです。
ちなみに、Scratchエディタからは読み込むことができませんでした。
elc in Scratch
8ccが動いたならelcも! と思いながら走らせてみましたが、48時間経っても終わらなかったので諦めました…… 残念!
おわりに
この記事では今年の4月頃に取り組んだELVM Scratchバックエンドの実装について紹介しました。 やはりいろいろな制約がある環境でのプログラミングは楽しいですね。
みなさんも是非ScratchやELVMで遊んでみてください。
1#include <stdio.h>
2int main(void) {
3 printf("Thank you for reading!\n");
4 return 0;
5}
試行錯誤ツイートまとめ
ELVMのScratchバックエンドを実装しようと思っていろいろと調べていたんだけど、project JSONのファイルサイズに5MBの制約があるのが厳しそうだなぁ
— あるごん (@algon_320) April 7, 2020
メモリはBase64でエンコードした文字列をvariableに入れておくのがいい気がする(1ワード24bitだから1ワード4文字で表現できる)のだけど、
— あるごん (@algon_320) April 7, 2020
これを2**24個持とうとするとそれだけで64MB使うことになってしまう
projectのJSONの外側でプログラムを表現する手段として、プログラムやデータを画像にエンコードしておいて「touching color () ?」ブロックを使ってデコードして動作させるというのを考えた(もはやScratchではなくpiet)
— あるごん (@algon_320) April 7, 2020
メモリは収まる範囲内で使えるということにして、とりあえずコード生成部分を書いてみるか
— あるごん (@algon_320) April 8, 2020
あと、テストのために標準入出力に繋ぎたいけどこれはどうしようかなぁ
"手書き"のScratchコードが動いた(ちょっと感動)
— あるごん (@algon_320) April 8, 2020
Scratchコード生成の進捗
— あるごん (@algon_320) April 10, 2020
レジスタの代入と足し算はとりあえず実装できた pic.twitter.com/wykV4sKg8Y
文字列のn番目を変更する操作できないのか……
— あるごん (@algon_320) April 11, 2020
メモリを文字列で管理するつもりだったからちょっと考えなきゃだな
ASCIIコード、Base64をエンコード・デコードをする手続きが生成できるようになった
— あるごん (@algon_320) April 13, 2020
(wikiに載ってたハックでbackdropにテーブルを埋め込んでいる) pic.twitter.com/mzpY3uuE9S
作ってるものはしょうもないけど、作業量は結構あるから達成感がある
— あるごん (@algon_320) April 13, 2020
ついにELVMのScratchバックエンド完成した(っぽい)けど、激重でFizzBuzzすら動かない😭
— あるごん (@algon_320) April 15, 2020
こういう小さいやつは動いてるぞ pic.twitter.com/vz1gNWjtgZ
— あるごん (@algon_320) April 15, 2020
1分くらい放置したらout/fizzbuzz_fast.c.eirが動いた!(感動) pic.twitter.com/JYXqZNigIx
— あるごん (@algon_320) April 15, 2020
fizzbuzzが激重なだけじゃなくて、どこかがバグってるっぽい
— あるごん (@algon_320) April 16, 2020
putsを呼んでるだけのコードでもプログラム自体は終了はするけど正しく出力されない https://t.co/a0p6782W6B
LISPインタプリタ(out/lisp.c.eir)も動いた!(めっちゃ遅いけど)
— あるごん (@algon_320) April 16, 2020
62953ブロックあるらしくて、このレベルなら間違いなくELVMの恩恵を受けていると言えるでしょ! pic.twitter.com/xuXE3bKQV7
いろいろ改善した結果、FizzBuzzの実行は結構速くなった
— あるごん (@algon_320) April 21, 2020
(終了時なぜか重くなるけど)
生成元のCコードはこれ→https://t.co/Sx6CchqLCk pic.twitter.com/ZT06Hq5bm6
ELVM Scratch backend! https://t.co/1UBscIEHdl https://t.co/ZBTLmkIfWw https://t.co/MUGDOpZ30S pic.twitter.com/vAFwVbak99
— shinichiro hamaji (@shinh) May 1, 2020
ELVM Scratchバックエンドマージされました!🙌
— あるごん (@algon_320) May 1, 2020
結局1ヶ月近くかかっちゃったけど、これにて一段落
-
Scratchにおける数値の仕様は見つけられませんでしたが、おそらくJavaScriptのNumber型として実装されています。 ↩︎
-
本当は半角バックスラッシュを使いたかったのですが、 (おそらく)Scratchのバグによってプロジェクトが保存できなくなってしまうため、 全角バックスラッシュを使いました。 ↩︎
-
コスチュームとはキャラクターの見た目を変える機能のことで、 ステージの背景を用いても同じことができます。 ↩︎
-
24bitの値を16進で表記すると6文字必要ですが、Base64なら4文字で表現できます。 ↩︎
-
Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz、メモリ32GiB、Ubuntu 18.04、node v8.10.0。 ↩︎