@ledsun blog

無味の味は佳境に入らざればすなわち知れず

ruby.wasmでオートロードする

kakikataというペン習字練習用紙を印刷するruby.wasmアプリケーションがあります。

ledsun.github.io

複数ページ印刷する機能を追加するついでに、requrie_relativeを使ってファイル分割していました。 やっている内に、単純作業が面倒臭くなってオートローダーが欲しくなりました。 const_missingを使えばできるはずと思って、ひとまず作ってみました。

簡単オートローダー

https://github.com/ledsun/kakikata/blob/9fefffc518025fb559e4b5d7d5c07592b3f72a12/main.rb#L12-L31

# 定数名からモジュールをオートーロードします。
def Object.const_missing(id)
  module_name = id.to_snake_case
  JS::RequireRemote.instance.load(module_name)
  p "#{module_name} loaded!"

  mod = const_get(id)
  # 読み込んだモジュールに、サブモジュールのオートーロードを定義します。
  mod.define_singleton_method(:const_missing) do |sub_id|
    path = self.name.to_s.split('::')
                         .map(&:to_snake_case)
                         .join('/')
    module_name = "#{path}/#{sub_id.to_snake_case}"
    JS::RequireRemote.instance.load(module_name)
    p "#{module_name} loaded!"

    const_get(sub_id)
  end

  mod
end

初めて使う定数の読み出し時にconst_missingが呼ばれます。 定数名からRubyスクリプトのファイルパスを解決して読み込みます。 名前空間入れ子になっている場合は、親モジュールのconst_missingが呼ばれます。 モジュールを読み込んだときに、同様のRubyスクリプト読込処理を定義しています。

今回は比較的シンプルなアプリケーションなので、ルートディレクトリからのロードしか考慮していません。 ディレクトリ内のRubyスクリプトからロードしようとすると、ミスるはずです。 絶対パス指定にすれば良いだけのような気もしますが、試していません。

組み込みのも外部のもgemの読込は考慮していません。 erbをつかっていますが、手動でrequire 'erb'しています。 アプリケーションのコードをネーミングルールに従って読み込みます。

これだけの行数でオートローダーがかけるRubyって、不思議なプログラミング言語ですね。

以前はオートローダーは考えてなかった

僕が、もともと想定していた、ruby.wasmアプリケーションの作り方は、

  1. CRubyで大まかな動きを実装する
  2. ruby.wasmに移植する

でした。 Wordle SearchGitHub - ledsun/tetris: Rubyでテトリスを実装するは、このパターンです。 このパターンでは、オートローダーは要らないと考えていました。

Rails以外のCRubyアプリケーションを作る時にzeitwerkのようなオートローダーを使うのはあまり流行っていなさそうです。 Railsruby.wasmで動かしても、そんなに嬉しくなさそうです。 また、zeitwerkそのものをruby.wasmに持ってくるのは難しいです。 というわけで、オートローダーはオマケぐらいに考えていました。

kakikataは、ブラウザで印刷するためのアプリケーションなので、最初からruby.wasm向けに作っています。 CRuby版は存在しません。 CRubyでの開発体験を気にせずに、ruby.wasmではオートローダーが使えてもいいのかもしれません。

追記

よくると、このオートローダーは、動きがちょっと変です。

https://github.com/ledsun/kakikata/blob/9fefffc518025fb559e4b5d7d5c07592b3f72a12/app/document.rb

Documentはトップレベルのモジュールです。 Appモジュール内で呼び出されたので、App::Documentとして解釈しています。 ディレクトリがappなので解決できています。

リモート呼び出しだと、スカって次の候補を探すのはあまりやりたくありません。 もうちょっとシンプルなモジュール名-パス解決ルールを決めないと、汎用的なオートローダーとするのは難しそうです。

万年筆

万年筆を買いました。 ペン習字をはじめた - @ledsun blog のの一環です。 練習するなら良い道具を使った方が気持ちが盛り上がります。

以前、パイロットの細字を使っていました。 今回は万年筆のヌルヌル感を求めて太字にしました。 色はアイボリーです。 なんとなく、女性向けラインのような気はします。 形がスタンダードなので、色までスタンダードだとつまらないので、アイボリーです。

実際に書いてみると、漢字はかなり大きく書かないと潰れます。 書き慣れていないので、バランス調整が難しいです。

万年筆を使って書いた文字の写真

内容は辰年にちなんだ書き初めです。 サビは慣れちゃっているのでそうでもないのですが、すごいフレーズです。

愛遠き世界にひるがえす 純情にも似た欲望よ
銀色の月に照らされたオマエのEYEが怖いほど

文の意味はよくわかりませんが、あふれ出る中2感が素晴らしいです。 何をどうやったらこんな詞が書けるのでしょうか?

rustのWASI用のThreadのsleep関数を読む

https://github.com/rust-lang/rust/blob/e9271846294c4ee5bd7706df68180320c0b5ff20/library/std/src/sys/wasi/thread.rs#L137 *1

   pub fn sleep(dur: Duration) {
        let nanos = dur.as_nanos();
        assert!(nanos <= u64::MAX as u128);

        const USERDATA: wasi::Userdata = 0x0123_45678;

        let clock = wasi::SubscriptionClock {
            id: wasi::CLOCKID_MONOTONIC,
            timeout: nanos as u64,
            precision: 0,
            flags: 0,
        };

        let in_ = wasi::Subscription {
            userdata: USERDATA,
            u: wasi::SubscriptionU { tag: 0, u: wasi::SubscriptionUU { clock } },
        };
        unsafe {
            let mut event: wasi::Event = mem::zeroed();
            let res = wasi::poll_oneoff(&in_, &mut event, 1);
            match (res, event) {
                (
                    Ok(1),
                    wasi::Event {
                        userdata: USERDATA,
                        error: wasi::ERRNO_SUCCESS,
                        type_: wasi::EVENTTYPE_CLOCK,
                        ..
                    },
                ) => {}
                _ => panic!("thread::sleep(): unexpected result of poll_oneoff"),
            }
        }
    }

このファイルが何かはあまりよくわかっていません。 src/sysディレクトリにunixwindowsがあります。 rustでクロスコンパイルするときに、ターゲットOSに合わせて埋め込まれる実装だろうと思っています。

なんとなく読み方がわかったのでメモしておきます。

poll_oneoff in wasi - Rustpoll_oneoff関数のリファレンスがあります。

pub unsafe fn poll_oneoff(
    in_: *const Subscription, 
    out: *mut Event, 
    nsubscriptions: Size
) -> Result<Size, Errno>

実際の呼び出しが

wasi::poll_oneoff(&in_, &mut event, 1);

です。引数を3つ受け取っています。

  1. _in
  2. event
  3. 1

_inは少し上で定義している構造体です。

let in_ = wasi::Subscription {
  userdata: USERDATA,
  u: wasi::SubscriptionU { tag: 0, u: wasi::SubscriptionUU { clock } },
};

APIリファレンスとSubscriptionという型名が一致しています。 SubscriptionにはSubscriptionUと言う構造体が入っていて、さらにその中にSubscriptionUU構造体があります。 その中にclockが入ります。 clock

let clock = wasi::SubscriptionClock {
  id: wasi::CLOCKID_MONOTONIC,
  timeout: nanos as u64,
  precision: 0,
  flags: 0,
};

です。 要するにこの関数はpoll_oneoffを呼ぶのに必要なデータを作って、poll_oneoffを呼び出しています。 CLOCKID_MONOTONICというのは

CLOCK_REALTIMEとCLOCK_MONOTONIC #Linux - Qiita

CLOCK_MONOTONIC 時刻はかならず単調増加する システムの時刻変更の影響を受けるが、大きく変化することはないし、時間が戻ったりもしない

経過時間をみる指定のようです。 timeout: nanos as u64タイムアウトまでのナノ秒を指定しているようです。

poll_oneoff関数の戻り値の処理は

match (res, event) {
    (
        Ok(1),
        wasi::Event {
            userdata: USERDATA,
            error: wasi::ERRNO_SUCCESS,
            type_: wasi::EVENTTYPE_CLOCK,
            ..
        },
    ) => {}
    _ => panic!("thread::sleep(): unexpected result of poll_oneoff"),
}

パターンマッチで成功か失敗を判定して、失敗したら例外をあげているようです。

おそらくこれと似たような関数を実装して、Kernel#sleepを置き換えるgemを作れば、ruby.wasmでKernel#sleepが使えるようになるはずです。 そうそうruby.wasmはごく最近、gemをwasmバイナリにpackできるようになりました*2。 さて、RubyのRust拡張ってどうやって作ればいいのでしょうか?

*1:前回の記事の後により新しいバージョンを発見しました。

*2:https://github.com/ruby/ruby.wasm/pull/358

ruby.wasmのKernel#sleepをどう実装したものか?

ruby.wasmを使ってブラウザ上でKernel#sleepを呼ぶとエラーが起きます。

<html>
<body>
  <script
    src="https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@2.4.1-2024-01-05-a/dist/browser.script.iife.js"></script>
  <script type="text/ruby">
    sleep 1
  </script>
</body>
</html>

エラーのスクリーンショット

とりあえずこんなパッチを当てれば動くことはわかっています。

module Kernel
  def sleep(time)
    JS.eval("return new Promise((resolve) => setTimeout(resolve, #{time * 1000}))").await
  end
end

現状Promise#newにコールバックをブロックで渡せないので、JS.evalを使っています。 これは直せると思います。

もう一つ悩みがあります。 JavaScriptsetTimeoutを使っています。 WASIのインターフェースを使ったほうがポータブルになりそうな気がします。

Add a sleep function to the Core · Issue #77 · WebAssembly/WASI · GitHub によると

I'm the current API, the way to implement sleep is to use poll_oneoff, polling for a single __WASI_EVENTTYPE_CLOCK event.

poll_oneoffという関数があるようです。

https://github.com/newpavlov/rust/blob/1e2b711d308be714e6211c125b1a33ac1247f866/src/libstd/sys/wasi/thread.rs#L30-L63をみると、rustではWASI用のThread.sleeppoll_oneoff関数を使って実装しているようです。

これらの情報をみても、ruyb.wasmにどうやって実装したらいいのか皆目見当もつきません。

Rubyでテトリスを実装する その8

Rubyでテトリスを実装する その7 - @ledsun blog の続きです。

今回は

  • 行を消すロジックの修正
  • T字形テトリミノの追加
  • デバッグ表示の追加

です。

行を消すロジックの修正

より宣言的にならないか工夫してみました。

https://github.com/ledsun/tetoris/blob/e5bb87a3c6a24049e8b3ea973a5965c25b5d643b/lib/field.rb#L34-L44

    filled_rows = in_field_rows.select { |row| row.filled? }
    return if filled_rows.empty?

    # 埋まった行を消す
    filled_rows.each { |row| row.clear! }

    # 消した行より上の、インフィールド内のすべてのブロックを消した行分下にずらす
    # 下から順番にずらす
    # 上からずらすと、ずらしたブロックがずらす前のブロックと衝突してしまう
    y = filled_rows.last.y
    in_field_blocks_over(y).reverse.each { |block| block.down filled_rows.size, self }

T字型テトリミノの追加

他のテトリミノも実装予定です。

デバッグ表示の追加

やはりデバッグが難しいです。 例外時に盤面の情報を出せるようにしてみました。

例外時に盤面の情報を出力しているスクリーンショット

ペン習字をはじめた

私は字が汚いです。 理由はわかっています。 脳内に収納している字形の解像度が低すぎるのです。 空で字を書くと、脳内の間違ったモデルをお手本に書くので、必ず汚い字が書けます。

克服するためには、脳に正しい字形をインプットする必要があります。 そこでペン習字です。 正しい字形をなぞって、正しい字形を覚えます。 絵を描くときのデッサンに似ています。 手を動かす訓練ではなく、字がどういう形なのかを見る解像度を上げる訓練です。

ところで、kakikataという便利なアプリケーションがあります。 ledsun.github.io 入力した任意の日本語をペン習字フォーマットで印刷できます。 これを使えば、ペン習字の教材を無限に生成できます。

しかも、自分が気に入った文章で練習できるので、テンションアゲアゲです。

気に入った文章を印刷したもの

そういえばこのアプリケーションはruby.wasmでできているらしいです。 ruby.wasm便利ですね。

Rubyでテトリスを実装する その7

Rubyでテトリスを実装する その6 - @ledsun blog の続きです。

今回は、ライン消しです。 https://github.com/ledsun/tetoris/commit/fb4fd5d11f58d7353d4501124290574206a98dbf がコミットです。

行単位の処理が増えたので、FieldクラスからRowクラスを分離しました。

1行消すのは簡単です。 その後上のブロックを1段ずつ落としてくるところが難しかったです。 cursesを使っているので、デバッグプリントが出来ません。 上手く動かないときに原因を見つけるのが難しいです。 たぶん、デバッガを上手く使うともう少し効率的に進められるのだと思います。

Rubyでテトリスを実装する その6

Rubyでテトリスを実装する その5 - @ledsun blog の続きです。 左右キー下キーでテトリミノを移動出来るようにしました。

https://github.com/ledsun/tetoris/commit/bda62edefc2d13cdb6c93c242fc97b2729526313 が、コミットです。 実装は簡単でした。 落下と同様です。 移動し、衝突していたら戻します。

ここまでくると、とてもゲームっぽくなりました。 残念ながら、一行揃えても消えません。

Rubyでテトリスを実装する その4

Rubyでテトリスを実装する その3 - @ledsun blog の続きです。 今回はテトリミノの種類を増やします。 同時に、テトリミノが着地したときに新しいテトリミノを追加します。

着地しとテトリミノと新しいテトリミノが衝突する判定も必要なので、それもいれます。 実態としては、テトリミノに含まれているブロックを、フィールド(盤面)に移管します。 衝突判定ロジックは既存のままです。

https://github.com/ledsun/tetoris/commit/5e6308ecf6460991b9085c98ba7fd00c5dd9cddd がコミットです。

ゲームオーバーの判定が入っていません。 テトリミノが上まで行っても新しいテトリミノを追加しようとします。

Rubyでテトリスを実装する その3

Rubyでテトリスを動かす その2 - @ledsun blog の続きです。 前回、I字型テトリミノが落ちてくるようになりました。 今回は、上キーの入力でテトリミノを回転します。

変更は https://github.com/ledsun/tetoris/commit/f93310b1254d2b53f8ca27ac0a15a373b95d8d01 です。 Rubyで行列を回転するのは@shape_map.transpose.map(&:reverse)で終わりました。 簡単でびっくりします。

Curses.stdscr.keypad(true)を呼ばないと、上キーの入力を拾えないところで躓きました。

Rubyでテトリスを実装する その2

Rubyでテトリスを動かす その1 - @ledsun blog の続きです。 前回、枠を表示しました。 今回は、I字型テトリミノを表示して落下させます。

https://github.com/ledsun/tetoris/commit/9000667616f82486fe55c20e2c42b9c29e41bf34 の1コミットにまとめてあります。 Tetoriminoの実装と更新処理を追加しました。 Blockクラスがあると描画を共通化できることに気がついたので、追加しました。

wez.termで実行すると、カラーテーマの影響を受けるようです。 赤色を指定していますが、緑色で表示されます。 VisualStudio Codeのターミナルで実行すると、赤で表示されます。

Rubyでテトリスを実装する その1

WSL2上にRuby開発環境を構築してテトリスを作ってみた #Ruby - Qiita を見ながらテトリスを動かそうとしています。 読む分には簡単にできそうと思いました。 実際に、写経して動かそうとしてみたら結構大変でした。 特に、Cursesアプリケーションのデバッグがとても大変です。

一気に全部動かすのは諦めます。 マイルストーンを切ることにしました。 まずは、ゲームの盤面を表示します。

ゲームの盤面を表示したスクリーンショット

ここまでのソースコードです。 GitHub - ledsun/tetoris at draw_wall

次は、一種類のテトリミノが落ちてくるのを目指します。

wez.termのテーマを変えた

変更後のテーマのスクリーンショット

matrixという中2っぽいテーマにしました。

ついでに .wezterm.lua に次の設定を追加してフォントを変更しました。

config.font = wezterm.font '源ノ角ゴシック Code JP'
config.font_size = 14.0

もともとは補完途中の文字が暗い赤で表示されて見にくかったのでテーマを変えようとしました。 途中で、wez.termのテーマではなく、fish-shellのcolor-schemeの設定だと気がつきました。 fish-shellの設定をいじっているうちに直ってしまいました。 しかし、どの設定がきいたのかわかりません・・・。

明確にわかっているのは、以前、GitHub - microsoft/inshellisense: IDE style command line auto completeを試したときの設定が .config/fish/config.fishに残っているのを消しました。 でも、inshellisenseを試す前から、色が暗い赤にだったので影響するとは思えないんですよね・・・。