← About

KSC VM を C++ へ、37 opcode 完走 — 課金は抽象境界の裏に閉じる

2026年4月21日

開発日誌から起こした設計の振り返り(2026年4月21日) KSC の実行系を C++ に移植する Phase 1。VM を底から積み、課金をどう閉じ込めたか


KSC(Kaede Script Code)の実行系を C++ に移植する。これが Phase 1 の主題だった。web 側は TS の VM がすでに動いている。同じ IR をスマホでも一文字違わず実行したい——その「同一性」を、二つの実装を持ったまま成立させるのがゴール。

設計書を先に書いてから着手した。VM の opcode を底から積むには順番が要る。Value がいちばん下、その上に算術、制御フロー、オブジェクト、最後に外との接続。依存の浅い順に並べておけば、各工程は前の工程の上にしか乗らない。だから後戻りが出ない。

VM を底から積む

C++ VM はレイヤーを下から積んだ。

  • まず Value / IR / IRLoader——VM が食う中間表現の読み込みまで
  • 次に VM 本体と、算術・比較・論理・スタックの 20 opcode
  • 制御フロー(JMP / JMP_IF_FALSE / CALL / RET / SWITCH)
  • オブジェクトと配列の 6 opcode
  • 最後に CALL_METHOD / HOST_CALL / AWAIT を入れて、全 37 opcode を完走

ここまでが Week 1 の前半。続けて VMState のシリアライズ——save / load 対応——まで通した。VM が途中状態を丸ごと書き出して読み戻せる、ということ。

積み方が Value→算術→制御フロー→オブジェクト→HOST_CALL という浅い順だったから、各工程が前の工程にしか依存しない。純粋な積み上げで、削るものがほとんど出なかった。

bit-exact という規律

移植で怖いのは「動いた」と「同じ動きをする」の差だ。ここを埋めるために parity テストハーネスを組んだ。同じ IR を TS VM と C++ VM の両方に食わせ、結果をビット単位で突き合わせる仕組み。

これが効いた。fixtures を拡張している途中で、「RET on empty stack」——空スタックでの RET——の挙動が TS と C++ で分岐しているのを見つけた。ハーネスが無ければ気づかず本番まで持ち越していた類いのズレで、その場で潰した。あとは fixtures を足して件数を増やし、HOST_CALL 経路もハーネスに通して、bit-exact を維持した。

同じスクリプトが web でもスマホでも一文字違わず同じ結果を出す。この同一性が量産の土台で、divergence を一個ずつ潰すこと自体がそのまま規律になる。テストで同一性を縛るのが、二つの実装を持つコストを正当化する唯一の方法だと思う。

課金を抽象境界の裏に閉じる

もう一本の柱が課金だった。ここで決めたのは、プラットフォーム固有の課金を全部 IBilling という一枚の抽象の裏に収めること。

  • IBilling 抽象と、stub / Switch / Web の各実装
  • Android 側の Play Billing 連携の足場
  • iOS 側の StoreKit 連携
  • レシート検証と Player 状態、Entitlement / 取引のスキーマ
  • それらを束ねる API クライアントと統合テスト
  • 最後に、KSC スクリプトから課金を叩けるようホスト連携を配線し、作者向けの billing API ガイドと、コンパイラへの billing builtins 登録まで

ポイントは、上から見ると全部同じ抽象に見えること。Play Billing も StoreKit も Switch も、KSC の作者が触るのは IBilling という一枚の API だけ。プラットフォーム固有のコードが足場でどれだけ膨らんでも、表には一切出てこない。

これは Switch を抽象境界の裏に閉じる、という前からの方針と同じ形だった。課金がきれいにその型に収まったのが気持ちいい。

合間の地ならし

主題の周りで小さい整地もした。崩れていた表組みの修正、ブラウザ向けバンドルから余計なファイル依存を外す整理、about ページへのリポジトリ統計の注入、KSC 仕様書の深掘り。Phase 1 の本筋ではないが、放っておくと後で効いてくる類いのもの。

振り返り

設計書を先に置いておいたおかげで、VM の opcode を底から積む順番に迷いがなかった。Value から HOST_CALL までを依存の浅い順に並べる——それだけで各工程が前の工程の上にしか乗らず、後戻りが消える。積み上げが純粋なら、削除はほとんど出ない。

一番効いたのは parity ハーネスだ。同じ IR を両 VM に食わせてビット一致を見る。これがあると「移植した」が「同じ動きをする」に格上げされる。RET on empty stack の分岐は、その格上げが無ければ拾えなかったズレだった。

課金を IBilling の裏に閉じたのも、同じ思想の現れだ。固有のコードがどれだけ膨らんでも、上から見れば一枚の抽象。Switch も、Android も、iOS も、Web も、表に差を出さない。VM の同一性と課金の抽象化は、別々の作業に見えて「プラットフォーム差を境界の裏に閉じる」という一本の規律でつながっている。

次は parity を広げて、C++ 側のホストに繋ぐ番だ。

Ad: inContent (336x280)
Ad: stickyBottom (728x90)
kaedevn - ノベルゲームを作れるプラットフォーム