ストワのメモなど書いておく

てーにし (Teinishi) が Stormworks 関連の文章を置く場所

コンポーネント Mod で何ができる? 仕組みから紐解く Stormworks Mod

この記事は Stormworks Advent Calendar 2025 第21日目の記事です。

はじめに

2025年7月、Stormworks のアップデートでコンポーネント Mod が導入されました。それまでの Mod と何が変わり、新たに何ができるようになったのか、Mod の仕組みから紐解いていきます (なお、Stormworks をリバースエンジニアリングしたわけではなく挙動から仕組みを推測しているに過ぎませんので、情報が不正確な場合があります)。本記事は Mod 作成に興味があるがよく知らないという方をメインターゲットにしていますが、Mod 作成はせず利用だけするという方にも読んでいただきたい内容になっています。

なお、コンポーネント Mod の作り方そのものには本記事ではそこまで深く切り込みません。コンポーネント Mod 導入直後に私が書いたチュートリアルもありますので、あわせてご覧ください。

stormskey.works

また、本記事ではコンポーネント以外のマップ Mod 等に関してはほとんど触れませんので、ご了承ください。

パーツ定義

前提知識として、パーツ定義ファイルの仕組みをおさらいします。Mod 作者の方はすでにご存知の内容、Mod を作る予定がない方にはあまり関係のない内容となりますので、次章 まで読み飛ばしていただいても構いません。

コンポーネント、つまりワークベンチで設置するビークルパーツは XML ファイルによって定義されています。バニラのパーツ定義ファイルは Stormworks 本体 (stormworks.exe) と同じ階層から rom/data/definitions と辿ったところにあります。stormworks.exe のフォルダは Steam から「ローカルファイルを閲覧」を押すと開きます。

Steam からローカルファイルを閲覧

例えば、rom/data/definitions/01_block.xml は通常の立方体パーツです。あまり関係のない内容を削って要約すると次のようになっています。2行目の name 属性はゲーム内での表示名、type 属性はパーツの種類 (整数値) 、mass は質量、value は価格といったように、パーツの情報が記述されています。

<?xml version="1.0" encoding="UTF-8"?>
<definition name="Block" category="0" type="0" mass="1" value="2" flags="56" tags="basic">
    <surfaces>
        <surface orientation="0" shape="1">
            <position x="0" y="0" z="0"/>
        </surface>
        ...
    </surfaces>
    <buoyancy_surfaces>
        <surface orientation="0" shape="1">
            <position x="0" y="0" z="0"/>
        </surface>
        ...
    </buoyancy_surfaces>
    <logic_nodes/>
    <voxels>
        <voxel flags="1">
            <position x="0" y="0" z="0"/>
        </voxel>
    </voxels>
    <voxel_min x="0" y="0" z="0"/>
    <voxel_max x="0" y="0" z="0"/>
    <voxel_physics_min x="0" y="0" z="0"/>
    <voxel_physics_max x="0" y="0" z="0"/>
    <tooltip_properties
        description="The block is a quarter-metre in size. Components can be attached to all 6 faces."
        short_description="Basic cube-shaped building block."
    />
    <reward_properties tier="0" number_rewarded="2000"/>
</definition>

<surfaces> は設置判定や表示に使われる面、<bupyancy_surfaces> は水密判定・浮力判定のための面です。ロジックノードを持つようなパーツは <logic_nodes> にロジックノードの名前、説明文、種類、位置などが記述されます。その他にも様々な属性・要素があります。

01_block.xml にはありませんが、3Dメッシュを持つようなパーツは .mesh とつくファイルへのパスがあります。.mesh ファイルは通常 rom/meshes 以下にあり、Stormworks 独自フォーマットのバイナリ3Dメッシュファイルです。Stormworks 独自フォーマットなのでこのファイルを直接開けるソフトウェアはありませんが、拙作の sw_block_definition_tools で間接的に見ることができます。(直接開く機能も用意したほうがいいでしょうか……)

また、音声の鳴るパーツには .ogg とつくファイルへのパスがあります。.ogg ファイルは通常 rom/audio 以下にあります。これは一般的な音声ファイル形式です。

パーツ定義 XML によって定義される内容は以上のようなものです。ここに記述されていない内容、例えばパーツの動作などは、<definition>type 属性の値に基づいて stormworks.exe の中にあるコードを呼び出すような形になっていると思われます。従来型の Mod では XML、mesh、ogg の編集で実現できない、例えばまったく新しい動作や複数の既存パーツの組み合わせのような動作は実現できないことになります。要するに見た目や音声を変更できるだけですが、それでも創意工夫次第で様々なことができます*1。ところが、2025年7月の Lua コンポーネントアップデートの追加機能でできることが広がりました。

時系列

従来型の Mod についても少々ややこしいので、一度時系列で Mod の種類を分類しておきます。なお、分類名は本記事に限ったものです。

本記事での Mod の時系列分類

非公式 Mod 期

公式に Mod がサポートされる以前から、非公式で Mod は存在していました。この時期には私は Mod にあまり関わっていませんでしたので詳しいことは言えませんが、主な内容としては次のようなことができていました。

  • ビークルパーツ
    • 定義 XML 変更
    • 3Dモデル (.mesh) 差し替え
    • 音声 (.ogg) 差し替え
  • マップタイル
    • 定義 XML 変更
    • 3Dモデル (.mesh) 差し替え
    • 地形 (.phys) 差し替え

この頃の Mod は rom フォルダの一部を直接書き換え・追加するというものでした。rom フォルダを書き換えても Steam で整合性チェックを行うとバニラに戻すことができましたが、まれに完全には戻らず副作用が残ることもありました。

この頃には Mod 利用時には非公式ツールの StormLoader などがよく使われ、Mod を作成したければやはり非公式ツールの StormworksData などでデータ形式を変換していました。

このようなタイプの Mod は以下 rom 書き換え型 Mod と呼ぶことにします。

アセット Mod 期

2024年11月のアップデートの主要な変更として、それまで非公式 Mod として行われていた内容が公式になり、rom フォルダに触らずに Mod を使うことができるようになりました。ビークルやアドオンと同じように %appdata%/Stormworks/data/mods に Mod を置くと、ワールド生成時の設定で選択でき、Steam Workshop で共有もできるようになりました。さらに、Mod を導入したマルチプレイに参加するときにはホストから Mod を配られるようになりました。Mod を持っているか否かに関わらずダウンロードするため、同一 Mod のバージョン違いなども起こらず強制的に同期されます。

まとめると、Mod 利用者の視点では次のような変化がありました。

  • ゲーム内のワールド生成時の設定で Mod をオンオフできるように
  • マルチプレイ時の Mod 同期機能
  • Steam Workshop での Mod 共有

非公式ツールを使用せずともよくなり、副作用もなくなり、オンオフも簡単になったことで Mod 利用のハードルが大きく下がったと言えます。一方で、Mod で追加されたパーツを使用したビークルは Steam Workshop にアップロードできない仕様となりました。これは Geometa によると、サブスクライブしたビークルが必要とする Mod をワールド作成時に正確に把握しなければならなくなるなど、プレイヤーの管理負担が増大するからという判断だそうです*2。この問題は後にコンポーネント Mod という形で解決が図られることになりました*3

Mod 作成者から見たアセット Mod

このアップデートにより、次のような点で Mod 作成に参入しやすくなりました。

  • Terms of use 更新、Mod 関連が明記
  • 公式アセット Mod ドキュメントが公開
  • 公式 SDK が Stormworks 本体に同梱
    • Stormworks 専用形式 (.mesh, .phys) にする前のバニラパーツ等の3Dモデル (.dae, .fbx)
    • .dae, .fbx から .mesh への公式変換ツール mesh_compiler
    • Stormworks 専用形式 (.txtr) にする前のバニラの画像ファイル (.png, .bmp)
    • .bmp, .png から .txtr への公式変換ツール texture_compiler

Mod 作成に興味がある方は、是非公式ドキュメントを一度読んでみてください。

コンポーネント Mod 期

2025年7月のアップデートでついにコンポーネント Mod が追加されました。Mod の中でもコンポーネント、つまりビークルパーツに焦点を当てたものです。ビークルデータと Mod パーツのデータを抱き合わせにすることで、その Mod が有効になっていない環境でビークルを読み込んだときにも問題なく読み込めるようになりました。同様の考え方で、先述したワークショップビークルの Mod パーツ問題を解消し*4、さらにマルチプレイにおいてはホストが持っていない Mod パーツを使用したビークルを参加者が出すことができるようになるなど、Mod パーツの扱いやすさに重点を置いた仕様となっています。*5

さらに、Lua コンポーネントが追加されました。これまでのパーツ Mod は既存パーツの見た目や属性を変更することしかできませんでしたが、このアップデートから Lua を使って既存パーツにない動作をするパーツを作れるようになりました。(ただし、Component Lua API に用意されていないものは実装できません。)

また、コンポーネント Mod と直接関係はありませんが、Game Constants Mod も可能となりました。あまりにもユーザーがねちょねちょエアー*6とかに文句を言うので、空力をはじめとしたゲーム内定数を変更する Mod を作れる道を用意することで決着にしたいようです。

まとめると、Mod 利用者の視点では次のような変化がありました。

  • コンポーネント Mod を使用したビークルは、その Mod が有効でない環境でも使えるように
    • Mod パーツを使用したビークルの取り回しが便利に
  • これまで不可能だった新機能を持つ Mod パーツが可能に
  • ゲーム内定数の一部を変更することが技術的に可能に

Mod 作成者から見たコンポーネント Mod

コンポーネント Mod の実態は .bin という拡張子のバイナリファイルです。コンポーネントを構成する要素は、定義 XML、メッシュ、音声です (Lua コンポーネントの場合には Lua スクリプトも)。このうちメッシュと音声は1つのパーツに複数含まれることもあり、1つのコンポーネントは多数のファイルで構成されうることになります。そこで、1つのコンポーネントの構成ファイルをすべてまとめて1つのファイルにしてしまおうということになっています。このアップデートで .bin ファイルにコンパイルするための component_mod_compiler がSDKに追加されました。

また、<definition>type 属性を 66 にし、lua_script 属性に Lua ファイルへのパスを指定することで Lua コンポーネントを作成できるようになりました。Component Lua API を使用し、コードを記述して任意の動作をするパーツを作成できるようになっています。

追加されたのは同時でしたが、コンポーネント Mod と Lua コンポーネントは別の概念として捉えたほうがよいでしょう。コンポーネント Mod は .bin 形式の Mod のこと、Lua コンポーネントtype="66" の Mod のことで、必ずしもこれらはセットというわけでもありません。

詳しくは公式コンポーネント Mod ドキュメントを読んでみてください。

Mod の仕組み

時系列を整理したところで、本題である Mod の仕組みに入ります。

アセット Mod の仕組み

アセット Mod の仕組みはシンプルなもので、%appdata%/Stormworks/data/mods またはワークショップサブスクリプション内のフォルダのうち、中に mod.xml を含んでいるものを Mod とみなして Mod 一覧に表示し、有効にした環境内では rom フォルダが仮想的に書き換わったような動作をします。

コンポーネント Mod の仕組み

先述したように、コンポーネント Mod は .bin ファイルのこと、.bin ファイルは XML と .mesh と .ogg と .lua をまとめたもののことです。より具体的には各ファイルのバイナリを単に結合して多少メタデータを含めたもののようです。component_mod_compiler に XML ファイルと付属する各ファイルを渡してコンパイルした .bin ファイルを rom/data/components に置くと、ゲーム内ワークベンチで設置できるようになります。従来型 rom 書き換え Mod では rom/data/definitions に .xml を置いていたのが、rom/data/components に .bin 置くようになったものです。

.bin ファイルの構造

.bin ファイルを解析し、コンポーネント Mod の逆コンパイラ、つまり .bin ファイルから元のファイルを復元するプログラムを作成することができたので、それで判明したことをまとめます。ファイルの構造は先頭から、次のようになっています。なお、整数はリトルエンディアンで、文字列は末尾ヌル文字の可変長となっていました。

  1. ファイルサイズ (ファイル全体のバイト数から4(自身の長さ)を引いた数): 4バイト整数
  2. 定義XMLのテキスト: 可変長文字列
  3. 付属ファイルの数 (定義XML以外): 2バイト整数
  4. 付属ファイル①のファイル名: 可変長文字列
  5. 付属ファイル①のサイズ (1バイト=1): 4バイト整数
  6. 付属ファイル①のバイナリ: 上で指定された長さのバイト列
  7. 付属ファイル②のファイル名: 可変長文字列
  8. 付属ファイル②のサイズ (1バイト=1): 4バイト整数
  9. 付属ファイル②のバイナリ: 上で指定された長さのバイト列
  10. (付属ファイルの数だけ繰り返し)
  11. 末尾 詳細不明 (大抵 0 だが、たまに0でない場合がある): 1バイト

アセット Mod とコンポーネント Mod の併用

上記のように rom/data/components に置いていると、rom 書き換え型 Mod と同じでアセット Mod アップデートの恩恵 (オンオフしたり、Steam Workshop にアップロードしたり) が受けられません。そのため多くの場合はアセット Mod の形を作って .bin を組み込むことになるでしょう。アセット Mod のフォルダ (mod.xml の階層) の data/components に .bin を置くと、アセット Mod にコンポーネント Mod を追加できます。このようにアセット Mod にコンポーネント Mod を含める形にすると、Steam Workshop で共有することもできます。

コンポーネント Mod を含むアセット Mod はオンオフできますが、オフのときは設置できないだけでコンポーネント Mod を使用したビークルのロードは可能です。これはどのように実現しているのでしょうか。

ビークルロードの仕組み

通常のビークルを保存すると %appdata%/Stormworks/data/vehicles に .xml ファイルが保存されます(同時にサムネイルの .png も保存されますが、今回は関係ないので割愛します)。このビークル XML の中を見ると、<c> というタグが各パーツ (コンポーネント) のことを表していて、d 属性が definitions 内のパーツ定義 XML のファイル名に対応しています (d 属性が省略されている場合は通常ブロックの 01_block とみなされていると思います)。

コンポーネント Mod のパーツを設置しでビークルを保存してみると、d 属性には「7f996f0d84e451a61694cc63fc174c52」のようなランダム文字列 (のように見えるもの) が書き込まれています。同時に、vehicles に .xml と同名のフォルダが作成されます。その中には「7f996f0d84e451a61694cc63fc174c52.bin」のような、d 属性と同じ名前の .bin ファイルがあります。このファイルは設置したコンポーネント Mod の .bin ファイルをそのまま、名前だけ変更したものです。つまり、コンポーネント Mod を使用するとビークル保存と同時にコンポーネント Mod もセットで保存されて、ビークルロード時にはセットになっているコンポーネント Mod も読み込むようになっています。

コンポーネント Mod の問題点

ビークル XML ファイルをコピー等するときに同名のフォルダがあればそれもセットにして扱うということを徹底できれば、環境を気にすることなくコンポーネント Mod を含むビークルを扱うことができます。しかし、残念なことに Steam Workshop にビークルを投稿するときにはそのフォルダがアップロード対象に含まれないというバグがあります。Mod を使用したビークルを簡単に共有できるという、コンポーネント Mod の主要な利点を潰してしまっており、しかもそのバグが半年間も放置されているのは残念な限りです。(アドオンにビークルを含める場合にも同様のバグがありましたが、まだ確認できていないものの、この記事を出す2日前のアップデートで修正されたようです。Steam Workshop 投稿のバグも近いうちに修正されることに期待しています。)

コンポーネント Mod は、マルチプレイでホストが Mod を有効にしていなくても参加者が Mod を使用したビークルを出すことができてしまうため、競技性のあるマルチプレイでは対策が問題視されることもありました。しかし、比較的最近のアップデート*7でホストがコンポーネント Mod を許可するかどうかの設定項目が追加されたため、この問題はほぼ解消したと言えるでしょう。

ランダムっぽい文字列の正体

さて、ビークルコンポーネント Mod フォルダを削除するとどうなるでしょうか。ロードができなくなるか、パーツが欠損しそうに思えますが、実際にはロード時の環境によって結果が変わり、同 Mod が有効になっていればロード可能、そうでなければ欠損という結果になります。ということは、d 属性のランダム文字列っぽいものを手がかりに対応するコンポーネント Mod を導き出していることになります。

コンポーネント Mod が欠損したときのエラーメッセージ

同じコンポーネント Mod は異なるビークルに設置して保存したときにも同じ文字列になります。これはたとえ元の .bin を component_mod_compiler で再コンパイルしても変わりません。このことから、ランダムではなさそうと推測できます。ただし、コンポーネント Mod を構成するファイルのどれか1つでも、ほんの1文字でも変更した場合は別の文字列になります。

こうした挙動から、件の文字列はビークル保存時にランダム生成したわけでもコンパイル時にランダム生成したわけでもないということがわかり、おそらく .bin ファイルのハッシュ値 *8 のようなものと推測できます。*9

コンポーネント Mod のまとめ

コンポーネント Mod は、1個のパーツを構成する複数のファイルを1個のファイルに固めた .bin のことで、おそらくハッシュ値を識別用の ID にしています。たとえ Mod が有効になっていない環境でも、ビークル XML とセットで保存されていれば読み込みが可能です。また、もしそれがなくても、同じ Mod が有効になっている環境なら読み込むことができます。ビークルと Mod をセットにして扱うことでマルチプレイや Steam Workshop 等でも Mod を使用したビークルを簡単に扱えるとされていますが、ビークルとしての Steam Workshop へのアップロードは現時点ではできません。バグとみられますが半年間放置されています。とはいえ、コンポーネント Mod を使用したビークルは、Steam Workshop へのアップロード自体ができないわけではなく、.bin が含まれずにアップロードされるだけなので、Mod は アセット Mod として別でアップロードして正しく Mod を有効にすれば Mod を使用したビークルの Steam Workshop での共有自体は可能です。

Lua コンポーネントの仕組み

コンポーネント Mod と同時に追加されましたが、Lua コンポーネントは独立した別の機能と考えたほうがよいでしょう。パーツ定義 XML<definition type="66" lua_script="{Luaファイル名}"> と指定すると、既存パーツにない挙動をするパーツを追加することができます。Stormworks にはビークルマイコンで使う Lua と、アドオンで使う Lua が存在しますが、新たにコンポーネントで使う Lua が登場した形です。

コンポーネント Lua API

ビークル Lua とアドオン LuaAPI が異なるように、コンポーネント Lua も別の API が提供されています。API公式コンポーネント Mod ドキュメントで確認できますが、本記事でもざっくり紹介します。なお、私が触ったことのないAPIはドキュメントの文章を読んだだけの理解で書いていますので、情報が不正確な場合があります。

コンポーネント Lua では次のコールバック関数を用意しておくとそれぞれのタイミングで呼ばれます。

function onAddToSimulation()
    -- ビークルスポーン時・ロード時などの処理
end

function onRemoveFromSimulation()
    -- ビークル消去時・アンロード時などの処理
end

function onParse()
    -- ビークルアンロード・ロードをまたいで保持する値の処理(?)
end

function onTick(tick_time)
    -- 毎 tick 実行される処理
    -- tick_time は通常 1 だが、ベッドで寝て早送りするときは 400 
end

function onRender()
    -- メッシュレンダリングなどの処理
end

ロジックノード

コンポーネント Lua API の重要な機能の1つとして、ロジックノードの入出力を制御することが可能です。例えば、定義 XML を次のように書いて On/Off の入力ノードを持った Lua コンポーネントがあるとします。

<defintion {略}>
    {略}
    <logic_nodes>
        <logic_node label="Input" mode="1" type="0" description="Example logic input">
            <position x="0" y="0" z="0"/>
        </logic_node>
    </logic_nodes>
    {略}
</definition>

onTick の中で次のように書くとロジックノードへの入力値を取得できます。

value, success = component.getInputLogicSlotBool(0)

定義 XML<logic_node>mode="1" と書けば入力ノード、mode="0" (または省略) で出力ノードになります。また、type 属性の値は次の表の通りです。

type 種類
0 (または省略) On/off
1 数値
2 動力 (orientation 属性でポートの面を指定)
3 流体 (orientation 属性でポートの面を指定)
4 電気
5 コンポジット
6 映像
7 音声
8 ロープ等

On/Off、数値、コンポジットノードについては、component.(getInput|setOutput)LogicSlot(Bool|Number|Float) で読み取り、書き込みができます。

ロジックノードのインデックス

component.getInputLogicSlotBool(0) の 0 の部分は、Input かつ Bool のノード、つまり <logic_node type="0" mode="1" /> のうち最初のものを示すインデックスです。type もしくは mode が異なれば別のカウントになりますし、0始まりです。公式ドキュメントでは1始まりと読めてしまう表現になっていますが、おそらく誤りかと思います。

動力

<logic_node type="2"/> の動力ポートの操作は次のAPIがあります。

  • component.slotTorqueIsConnected: 動力ポートに他のパーツが繋がっているか取得します。
  • component.slotTorqueApplyMomentum: 動力ポートを回したり止めたり、RPSを取得したりします。

動力ポートが複数あれば、それらを繋げたり切り離したりするすることができるようです。

  • component.slotTorqueCreateBridge: 2個の動力ポートを繋げます。
  • component.slotTorqueDestroyBridge: 繋げた動力ポートを切り離します。
  • component.slotTorqueSetBridgeFactor: 伝達率を設定します。
  • component.slotTorqueSetBridgeRatio: 動力ポートのギア比を設定します。

流体

<logic_node type="3"/> の流体ポートの操作は次のAPIがあります。4つまで内部タンクを持つことができます。

  • component.slotFluidResolveFlow: 流体ポートと内部タンク間で流体を流します。ポンプやバルブのように加圧したり制限したり一方通行にしたり、流体の種類をフィルターにかけることもできるようです。
  • component.slotFluidResolveFlowToSlot: 2つの流体ポート間で上と同様に流体を流します。
  • component.fluidContentsTransferVolume: 2つの内部タンク間で指定した量を移動させます。
  • component.fluidContentsTransferVolumeExceptType: 流体を1種類除いて上と同様に流体を移動させます。
  • component.fluidContentsSetFluidTypeVolume: 内部タンクの指定した種類の流体の量を指定した量にセットします。
  • component.fluidContentsGetFluidTypeVolume: 内部タンクの指定した種類の流体の量を取得します。
  • component.fluidContentsGetVolume: 内部タンクの全種類の流体の量の合計を取得します。
  • component.fluidContentsSetCapacity: 内部タンクの容量を設定します。
  • component.fluidContentsGetCapacity: 内部タンクの容量を取得します。
  • component.fluidContentsGetPressure: 内部タンクの圧力を取得します。

電力

<logic_node type="4"> の電気ノードの操作は次のAPIがあります。

  • component.slotElectricIsConnected: 電気ノードに他のパーツが繋がっているか取得します。
  • component.slotElectricAddCharge: 電気ノードに電力を与えます。
  • component.slotElectricRemoveCharge: 電気ノードから電力を奪います。
  • component.slotElectricGetChargeFactor: 電気ノードの電圧を取得します。

ヒーター

ヒーターのように周囲を温めるAPIがあります。

  • component.heaterSetSphere: 球形の範囲にヒーター効果を適用します。
  • component.heaterSetOrientedBox: 直方体の範囲にヒーター効果を適用します。
  • component.heaterSetTemperature: ヒーターの温度を設定します。
  • component.heaterSetFactor: ヒーターの効率係数(?)を設定します。

音声

音声を再生するAPIがあります。component_mod_compiler で指定した .ogg ファイルを Lua から呼び出して再生できます。同時再生枠は4つまでですが、.ogg ファイル自体の数には制限はありません(.bin 自体の2MB制限がありますが)。再生枠 (channel_index) は0~3で指定、.ogg ファイル (effect_index) はコンパイル時の .ogg の記述順 (0始まり) で指定します。ゲーム全体での再生枠も別にあるようで、それを溢れた場合優先度の低いものが無効化されるようです。

  • component.sfxPlayOnce: 音声を1回再生します。位置オフセット、可聴範囲、音量、ピッチ、優先度を指定できます。
  • component.sfxPlayLoop: 音声をループで上と同様に再生します。
  • component.sfxUpdate: すでに再生中の音声の位置オフセット、可聴範囲、音量、ピッチを変更します。
  • component.sfxStop: すでに再生中の音声を止めます。

メッシュレンダリング

定義XMLで、<definition>mesh_0_namemesh_1_namemesh_2_name で指定したメッシュを動的に表示させるAPIがあります。function onRender() の中で呼ぶ必要があります。mesh_data_nameLua スクリプトに関係なく常時表示されます。動かせるメッシュは3種類が上限となります。

  • component.renderMesh0: mesh_0_name を表示します。4x4行列を1次元で長さ16のテーブルとして指定して、位置・回転・スケール・剪断変形などを自由に操作できます。
  • component.renderMesh1: mesh_1_name を上と同様に表示します。
  • component.renderMesh2: mesh_2_name を上と同様に表示します。

ライト・レーザー

通常のライトやレーザーを出すAPIがあります。しかし、Indicator のような照明を伴わない発光や、スポットライトのような照明は出せません。function onRender() の中で呼ぶ必要があります。

  • component.renderLight: RGBライトと同様のライトを位置、色、大きさ等を指定して出します。赤外線にすることも可能です。
  • component.renderLaser: 位置と距離を指定してレーザーを出します。赤外線にすることも可能です。

物理演算

物理演算に干渉したり、情報を取得したりするAPIがあります。ただし、座標オフセットの中心がコンポーネントの中心ではなくマージ (Body) の重心になっているとみられます。また、なんと位置や回転を取得する方法がありません(Physics Sensor と同等の情報すらも得られません)。

  • component.physicsImpulse: 位置と力のベクトルを指定して、パーツに力 (Force) を加えます。しばらく力が加わっていない物体は負荷軽減のために物理演算が休止しますが、これを再開させるかどうかも指定します。
  • component.physicsRaycast: レイキャストで当たり判定を検査できます。好きな位置から好きな方向に出せる不可視なレーザー距離計のようなものです。
  • component.physicsGetLinearVelocity: 速度ベクトルを取得します。
  • component.physicsGetLinearVelocityAtPoint: 指定位置での速度ベクトルを取得します。
  • component.physicsGetAngularVelocity: 角速度を取得します。

情報取得

周囲の情報を取得するAPIがあります。

  • component.getSubmergenceFactor: 位置と半径を指定して、球が水面にどれだけ浸かっているかを取得できます。
  • component.getWindVelocity: 位置を指定して風速をベクトルで取得します。

発射物

砲弾などを発射するAPIがあります。

  • component.spawnProjectile: 種類、位置、速度等を指定して砲弾を出現させます。

その他

matrix.multiply など、簡易的な行列演算ライブラリが備わっています。また、onParse では parser.parseBoolparser.parseNumberparser.parseString などでビークルのアンロード、ロード時にデータを保存できる(?)ようですが、公式ドキュメントを読んでもよくわからず、触ってみてもよくわかりませんでした。

Lua コンポーネントのまとめ

コンポーネントの動作を Lua で記述できるようになり、Mod でできることの幅が大きく広がりました。一方で、Mod を作ってみるとすぐに仕様の限界に阻まれることになります。Indicator やスポットライトのようなものが作れないこと、マイコンでは作れるカスタムプロパティが Mod パーツで作れないことなど枚挙に暇がありません。試しに既存パーツの機能を Lua で模倣しようとしても、それができるのはごく一部のパーツのみでしょう。自信満々な割に中途半端な状態でリリースしたという印象を受けました。とはいえこのアップデートのおかげでできるようになったことも多くありますので、中途半端だとしてもプレイヤーにさらなる自由を与えてくれたことは評価しています。これ以降の半年間はパーツ追加アップデートが中心で、コンポーネント Lua に関するアップデートはほとんどありませんが、今後 API が拡充されることを期待しています。

おわりに

コンポーネント Mod と Lua コンポーネントについて、これまで調べてきたことを長々と述べてきました。本当は開発中の テツダン Mod 2 のために開発した Python 製の支援ツールについて書くつもりだったのですが、それ以前にコンポーネント Mod とは何なのかという記事を書くべきと思い、この内容に変更しました。結果として想定していたよりもずっと長い記事になってしまいましたが、読んでくださった方に新たな知見があれば嬉しく思います。

コンポーネント Mod の開発は、定義 XMLLua などのテキストファイルの編集、Blender でのモデリングと .dae 形式でのエクスポート、mesh_compiler を使用した .dae 形式から .mesh への変換、Audacity や ISSE 等を使用した音声ファイルの編集、component_mod_compiler を使用した .bin 形式へのコンパイル等、実に様々な作業があります。Mod を少しずつ更新して確認していく開発スタイルをとるには再コンパイルの手間があまりに多く、手動でやっていられませんでした。そこで、コマンド一発ですべての作業を完了させて、あとは Stormworks をリロードするだけという環境を整えることにしたのです。生の XML で書いているパーツもあれば細かいバリエーションを持つパーツ郡もあるので、パーツ郡ごとにバリエーションのXMLを自動生成する Python スクリプトを用意して、Python 製の支援ツールからさらにその Python を呼び出して XML を生成するようにしました。生の XML と生成された XML のどちらに対しても、各パーツが依存するメッシュ・音声・Lua を抽出し、メッシュを生成するために Blender をヘッドレスで起動して .blend ファイルから .dae を自動エクスポート、mesh_compiler を呼び出して .mesh に変換、component_mod_compiler を呼び出して .bin に変換、そして開発用フォルダと Stormworks 用フォルダを同期という手順をすべてコマンド一発でできるようになり、コンポーネント Mod の開発がかなり快適になりました。しかし、最近の Blender のアップデートで .dae のエクスポートができなくなってしまったので、今のところ Blender のバージョンを下げて対応ということにしています。.mesh ファイルの構造は判明しているので、いずれ Blender から直接 .mesh をエクスポートするスクリプトを用意したいという野望もありますが、今のところ着手時期は未定です。

*1:例えば、拙作の鉄道車両向けの Mod では Dial の針のモデルを変更して揺れるつり革を作ったり、モジュラーエンジンのスターター音を変更して VVVF サウンドを作ったりしていました

*2:https://store.steampowered.com/news/app/573090/view/4482864232593883181

*3:https://store.steampowered.com/news/app/573090/view/534354183830111760

*4:解消されていない。半年放置されているバグ。

*5:ホストが持っていない Mod パーツをマルチプレイで使えるようになる点はゲームバランスの観点から懸念する意見もありましたが、後のアップデートでほぼ解決となりました。

*6:やたらと大きい空気抵抗

*7:https://store.steampowered.com/news/app/573090/view/565891434097410262

*8:異なるデータを入力したら異なる値が、同じデータを入力したら同じ値が毎回出力されて、出力値から入力値の逆算がほぼ不可能など、いくつかの条件を満たすハッシュ関数を通した値

*9:ハッシュ値とみられる文字列をよく見ると g 以降のアルファベットがないことから16進数で、長さが32であることから128ビット、ということでおそらく MD5 なのではないかと推測できます。ただ、.bin のバイナリをそのまま MD5 に突っ込んでも一致しなかったためソルトみたいなものが掛かっているか、そもそも MD5 でないかもしれません。

Stormworks 自動編隊飛行制御

この記事は Stormworks Advent Calendar 2025 第3日目の記事です。

概要

Stormworks で自動操縦で編隊飛行をする飛行機を作りました。リーダー機から一定の位置関係を維持するように飛行し、リーダー機の上昇・降下・旋回などにも追従します。リーダー機は Physics Sensor のコンポジット信号をそのまま無線で送信するのみで、追従機側で様々な計算を行います。リーダー機の Physics Sensor 信号とは別にもう1つ無線を使用して、リーダー機側からでも追従機側からでも外からでも手持ちリモコンで位置関係を調整することができます。 起動時の条件は、両機とも離陸済み、目視できる程度の距離、進行方向は大まかに一致、速度と高度はずれていてもよし、としています。起動したら追従機は徐々に距離を縮めて、リーダー機に衝突しないようにリーダー機に接近します。目標相対位置は3次元ベクトルで指定して、リーダー機の位置・回転に追従します。

自動制御で編隊飛行する3機

↓動画はこちら

ベース機体

既に編隊飛行マルチで実績のある練習機をベースとします。私はビークルに名前をつけるのを面倒くさがるので名前はありませんが、ファイル名は hikouki20 になっています。この機体は角速度ベースの飛行制御を採用しており、ロールは速度によらず同じような操縦感覚、ピッチは同じような操作で同じようなGになるように速度に応じて角速度を調節します。操縦しながらトリムをとる必要はなく、手を離せば直進するようにできています。操縦しやすくなるように制御しており、編隊飛行マルチでは背面飛行で編隊を維持したり、他の機体に接近して乗り移ったりなども可能です。既に角速度制御を確立している機体を使用することで、編隊飛行マイコンからは操縦翼面そのものではなく角速度を出力すればよく、編隊飛行のための制御に集中できます。

編隊飛行用ベース機体 (hikouki20)

システム構成

無線は2種類の周波数を使用します。周波数は機体をスポーンしたままのデフォルト状態で 15219 と 4 を使うことにしています。スポーン後に後席右側のキーパッドとボタンで変更できるようにしています。無線 15219 ではリーダー機が Physics Sensor の信号をそのまま送信、無線 4 ではリモコンで相対位置を調整するようにしています。相対位置のデフォルト値はマイコンプロパティ指定で、左後方 (-10, -2, -10) にしています。

通信まわりの構成

実装

本制御は次の要素に分解して考えます。

  • リーダー機の Physics Sensor を受信
  • 目標相対位置のリモコン調整
  • 上2つと自機の Physics Sensor を組み合わせて、目標位置と目標姿勢を計算
  • リーダー機の速度に目標位置までの距離で補正をかけた目標速度を計算
  • 目標速度からエンジンの制御 (済)
  • 目標姿勢と現在姿勢の差分から、ロールピッチヨー各軸の目標角速度を計算
  • 角速度をから操縦翼面へのPID制御 (済)

今回肝となるのは、両機の Physics Sensor 信号から各軸の角速度出力とスロットル出力を計算する部分で、この計算は1つの Lua で行っています。まずは記号を定義しておきます。リーダー機のローカル座標系、自機のローカル座標系、グローバル座標系が混在するので注意してください。ローカルベクトルに左から回転行列を書けるとグローバルになります。

  • $\mathbb{p}_l$: リーダー機の位置 (グローバル)
  • $R_l$: リーダー機の回転行列
  • $\mathbb{v}_l$: リーダー機の速度 (リーダー機ローカル)
  • $\mathbb{\omega}_l$: リーダー機の角速度
  • $\mathbb{p}_s$: 自機の位置 (グローバル)
  • $R_s$: 自機の回転行列
  • $\mathbb{v}_s$: 自機の速度 (自機ローカル)
  • $\mathbb{\omega}_s$: 自機の角速度
  • $\mathbb{r}$: 目標相対位置 (リーダー機ローカル)

ピッチ・ヨー制御

$\mathbb{p}_t=\mathbb{p}_l+R_l\mathbb{r}$ で目標位置を計算することができ、$R_s^\top\left(\mathbb{p}_t-\mathbb{p}_s\right)$ とすれば機首を向けるべき方向がわかるような気がしますが、これでは目標位置に到達したときに零ベクトルとなり向きが不安定になります。そこで、機首は未来の目標位置に向ける制御を行います。$t$ 秒後の未来の目標位置を $\mathbb{p}_tf=\mathbb{p}_t+t\mathbb{v}_l$ として (実際にはリーダー機の旋回も考慮し大雑把に近似したものにしています) 、$R_s^\top\left(\mathbb{p}_tf-\mathbb{p}_s\right)\times\mathbb{v}_s$ として得られるベクトルが、機首を $\mathbb{p}_tf$ に向けるための回転軸ということになります。そのX成分をピッチ、Y成分をヨー出力に繋げば目標位置に機首を向ける制御が達成できます。

ピッチ・ヨー制御の概要

ロール制御

機首を目標位置に向ける制御をピッチとヨーで行ったので、ロール制御は別の方法で決定しなければなりません。そこで、編隊飛行したときの美しさも考慮し、リーダー機のロールに合わせることとします。自機とリーダー機の回転の差分を取得し、そのロール成分を取り出して出力に繋ぐことにします。回転行列 $R_lR_s^\top$ の回転軸ベクトルを取得し、自機のローカル系に直すため $R_s^\top$ を左から掛けてZ成分を取り出すことで、ロール出力を得ます。

速度制御

機首は未来の予測目標位置に向けましたが、速度は(未来のではなく)現在の目標位置 $\mathbb{p}_t$ に合わせます。既に編隊飛行が成立した状態なら速度はリーダー機と一致しているはずなので、リーダー機の速度に合わせるのを基本として、目標位置より後ろならやや速く、前に出ていたらやや遅く調整します。また、旋回中は旋回内側は遅く、外側は速くなるはずなので、リーダー機の角速度と相対位置ベクトルも考慮して補正します。その補正を組み込むと、編隊成立状態での目標速度は $R_s^\top\left(R_l\mathbb{v}_l+\omega_l\times\left(R_l\mathbb{r}\right)\right)$ のZ成分になり、これに目標位置までのローカルベクトルのZ成分で前後のずれ分の補正に $R_l^\top\left(\mathbb{p}_s-\mathbb{p}_t\right)$ のZ成分を適当に乗せて実際の目標速度とします。

接近時の衝突抑制制御

距離が遠い状態で編隊飛行をオンにしていきなり目標位置に向かって飛行すると、オーバーシュートしてリーダー機に衝突することが容易に想像できます。しかし、きちんと衝突回避しようとするとなかなか面倒*1ですので、編隊が成立するまでは目標位置の計算に使う相対位置ベクトルに係数を掛けて大きくしておきます。この係数は適当に実験で $1+0.25\frac{\max\left(|\mathbb{p}_t-\mathbb{p}_s|-2,0\right)}{|\mathbb{r}|}$ にすることにしました。0.25 の部分で接近スピードが決まり、0 に近いほど早く、1に近いほど遅くなるはずです。

Lua

ロール、ピッチ、ヨー、速度の制御方針が定まったので、Lua を書いていきます。入出力は次の通りです。

入力

  • B1: 起動
  • N1-N12: リーダー機から受信した Physics Sensor 信号 (位置、オイラー角、速度、角速度)
  • N13-N21: 自機の Physics Sensor 信号 (位置、オイラー角、速度)
  • N22-24: 目標相対位置

出力

  • B1: 起動
  • N1: ロール目標角速度
  • N2: ピッチ目標角速度
  • N3: ヨー目標角速度
  • N4: 目標速度偏差
gB,gN,sB,sN=input.getBool,input.getNumber,output.setBool,output.setNumber

function clamp(x,a,b) return math.min(math.max(x,a),b) end

function onTick()
    enable=gB(1) -- 編隊飛行制御の起動信号
    pos_l={gN(1),gN(2),gN(3)} -- リーダー機の位置 (グローバル)

    if enable and vec.dot(pos_l,pos_l)>1e-6 then
        rot_l=mat.rot(gN(4),gN(5),gN(6)) -- リーダー機の回転行列
        v_l={gN(7),gN(8),gN(9)} -- リーダー機の速度 (リーダー機ローカル)
        v_l_g=mat.vec(rot_l,v_l) -- リーダー機の速度 (グローバル)
        angv_l={gN(10),gN(11),gN(12)} -- リーダー機の角速度 (グローバル)

        pos_s={gN(13),gN(14),gN(15)} -- 自機の位置 (グローバル)
        rot_s=mat.rot(gN(16),gN(17),gN(18)) -- 自機の回転行列
        rot_s_t=mat.t(rot_s) -- 自機の回転行列の逆
        v_s={gN(19),gN(20),gN(21)} -- 自機の速度 (自機ローカル)
        relative_pos={gN(22),gN(23),gN(24)} -- 相対位置ベクトル (リーダー機ローカル)

        rot_diff=mat.vec(rot_s_t,mat.rot_vec(mat.mul(rot_l,rot_s_t))) -- 自機とリーダー機の姿勢差分

        function to_local_pos(a)
            return mat.vec(rot_s_t,vec.sub(a,pos_s))
        end

        true_target=vec.add(pos_l,mat.vec(rot_l,relative_pos)) -- 真目標位置 (グローバル)
        true_target_dist=vec.len(vec.sub(true_target,pos_s)) -- 真目標位置までの距離
        target_mul=1+0.25*math.max(true_target_dist-2,0)/vec.len(relative_pos) -- 接近用係数
        relative_pos_g=mat.vec(rot_l,vec.mul(relative_pos,target_mul)) -- 係数を掛けた相対位置ベクトル (グローバル)
        target=vec.add(pos_l,relative_pos_g) -- 係数を掛けた目標位置 (グローバル)
        from_target=mat.vec(mat.t(rot_l),vec.sub(pos_s,target)) -- 目標位置から見た自機の位置

        lead_t=clamp(10*(vec.len(from_target)/vec.len(v_l)),2,30) -- 未来の予測目標位置をとるための先読み時間 t
        nose_target=vec.add(target,vec.mul(vec.add(v_l_g,vec.mul(vec.cross(angv_l,v_l_g),lead_t/2)),lead_t)) -- 未来の予測目標位置 (グローバル)
        nose_target_local=to_local_pos(nose_target) -- 未来の予測目標位置 (自機ローカル)

        control_axis=vec.cross(vec.normalize(v_s),nose_target_local) -- 機首の目標回転軸

        roll_control=0.8*rot_diff[3] -- ロールはリーダー機に合わせる
        pitch_control=control_axis[1] -- ピッチは未来の予測目標位置に合わせる
        yaw_control=control_axis[2] -- ヨーは未来の予測目標位置に合わせる

        target_velocity=vec.add(v_l_g,vec.cross(angv_l,relative_pos_g)) -- 編隊成立時の速度
        throttle_control=mat.vec(mat.t(rot_s),target_velocity)[3]-v_s[3]-clamp(1*from_target[3],-20,20) -- 補正済み目標速度と現在速度の偏差

        sB(1,true)
        sN(1,roll_control)
        sN(2,pitch_control)
        sN(3,yaw_control)
        sN(4,throttle_control)
    else
        sB(1,false)
        sN(1,0)
        sN(2,0)
        sN(3,0)
        sN(4,0)
    end
end

おわりに

以上のようにして Stormworks の飛行機に自動編隊飛行制御を組み込みました。今回の機体をワークショップに公開しています。操作方法の説明等が用意できていませんが、スイッチ類にはほぼすべてラベルをつけてありますのでご容赦ください。次の手順で編隊飛行を試せます。

  1. 1機目をスポーン、後席右側の Formation Leader フリップスイッチをオンにする
  2. 前席に移動し、エンジンを始動して待つ
  3. 上矢印キーでスロットルを上げ、Sキーで機首を上げて離陸
  4. 速度を抑え(150~200kt 程度)、水平飛行にする
  5. 無操作で水平飛行していることを確認したらワークベンチに戻り、2機目をスポーン
  6. 後席右側の Formation Follower フリップスイッチをオンにする
  7. 離陸して1機目を追いかける
  8. 接近すると制御が切り替わり、自動的に1機目に接近して編隊飛行する

steamcommunity.com

解説が拙いこと、細かい部分は本記事で解説しきれていない部分があること、ベクトル・行列計算をフィーリングで適当にやっていることなどツッコミどころは多々あるかと思います。制御自体も、1人で編隊飛行が楽しめそうに思えて離陸は自動化していないため1人で試そうとするといろいろと面倒だったり、アクティブに衝突を回避する機能がなかったり等、課題がまだ多くあります。自分ならもっとうまくやれると思った方はぜひ、来年のアドベントカレンダーにもっと良い自動編隊飛行制御の記事を寄せてください。

おまけ: 行列計算 Lua

実際は上記のメイン Lua の前に最低限のベクトル・行列計算関数を用意しています。その内容も載せておきます。

-- ベクトル・行列計算
vec,mat={},{}

function vec.add(a,b) return {a[1]+b[1],a[2]+b[2],a[3]+b[3]} end
function vec.sub(a,b) return {a[1]-b[1],a[2]-b[2],a[3]-b[3]} end
function vec.mul(a,s) return {a[1]*s,a[2]*s,a[3]*s} end
function vec.dot(a,b) return a[1]*b[1]+a[2]*b[2]+a[3]*b[3] end
function vec.cross(a,b)
    return {
        a[2]*b[3]-a[3]*b[2],
        a[3]*b[1]-a[1]*b[3],
        a[1]*b[2]-a[2]*b[1]
    }
end
function vec.len(a) return math.sqrt(vec.dot(a,a)) end
function vec.normalize(a)
    l=vec.len(a)
    if l==0 then return {0,0,0} end
    return vec.mul(a,1/l)
end

-- 行列の積
function mat.mul(a,b)
    local r = {}
    for i=1,3 do
        r[i]={}
        for j=1,3 do
        r[i][j]=0
        for k=1,3 do r[i][j]=r[i][j]+a[i][k]*b[k][j] end
        end
    end
    return r
end

-- 行列とベクトルの積
function mat.vec(a,b)
    return {
        a[1][1]*b[1]+a[1][2]*b[2]+a[1][3]*b[3],
        a[2][1]*b[1]+a[2][2]*b[2]+a[2][3]*b[3],
        a[3][1]*b[1]+a[3][2]*b[2]+a[3][3]*b[3]
    }
end

-- 行列の転置
function mat.t(a)
    return {
        {a[1][1],a[2][1],a[3][1]},
        {a[1][2],a[2][2],a[3][2]},
        {a[1][3],a[2][3],a[3][3]}
    }
end

-- Physics Sensor のオイラー角から回転行列へ
function mat.rot(x,y,z)
    cx,cy,cz=math.cos(x),math.cos(y),math.cos(z)
    sx,sy,sz=math.sin(x),math.sin(y),math.sin(z)
    Rx={{1,0,0},{0,cx,-sx},{0,sx,cx}}
    Ry={{cy,0,sy},{0,1,0},{-sy,0,cy}}
    Rz={{cz,-sz,0},{sz,cz,0},{0,0,1}}
    return mat.mul(mat.mul(Rz,Ry),Rx)
end

-- 回転行列から回転軸ベクトル
function mat.rot_vec(r)
    trace=r[1][1]+r[2][2]+r[3][3]
    cos_theta=(trace-1)/2
    theta=math.acos(clamp(cos_theta,-1,1))

    if theta<1e-6 then return {0,0,0} end

    sin_theta_2=math.sin(theta)*2
    return {
        theta * (r[3][2] - r[2][3])/sin_theta_2,
        theta * (r[1][3] - r[3][1])/sin_theta_2,
        theta * (r[2][1] - r[1][2])/sin_theta_2
    }
end

*1:書いているときに思いつきましたが、リーダー機の周囲に距離n乗反比例とかで仮想的なポテンシャルを設けて、方向制御にその微分ベクトルを乗せてみるとかするといいかもしれません

Stormworks 面倒な電気配線をPythonでやっつけよう

この記事はStormworks 第1 Advent Calendar 2024第4日目の記事です。

注意: この記事では Stormworks のセーブデータや内部データを外部から読み取り、編集します。この記事に掲載したプログラムをご自身で実行する場合は、内容を十分理解してから始めてください。

動機と目標

ある程度複雑なビークルになると、電気配線をする必要のあるブロックが増えてきます。普通は電気ノードのあるブロックはすべてバッテリーかブレーカーに繋げておけばいいのですが、1つずつ手動で行わないといけません。電気ノードのあるブロックが多くなるとこの作業は大変ですし、手動で行う以上繋ぎ忘れなどミスもつきものです。そこで、この作業を自動化して楽をしつつミスを減らすことを目指します。

手動で電気配線をするのがつらいビークル

アプローチ

ビークルデータはXMLファイルなので、これを外部から編集することで電気配線を繋げたり消したりできるはずです。今回はPythonを使って、元となるビークルファイルを渡すと電気配線を済ませたビークルファイルを出力するようなプログラムを作っていきます。Pythonである必要は特にないので、XMLの読み書きができるならどの言語でやってもよいでしょう。

ビークルファイルの構造を調べる

試しに電気関連パーツをいくつかつけただけの小規模なビークルを作成し、XMLファイルを見てビークルXMLの構造を調べてみました。(今回関係ないものは省いています)

<vehicle> (ビークル全体)
├── <bodies>
│   └── <body> (いわゆるマージ)
│       └── <components>
│           └── <c d="{パーツ種類}">
│               └── <o r="{パーツの回転を示す行列}">
│                   └── <vp x="{X座標}" y="{Y座標}" z="{Z座標}"> (パーツ位置)
└── <logic_node_links>
    └── <logic_node_link type="{電気配線は4}">
        ├── <voxel_pos_0 x="{X座標}" y="{Y座標}" z="{Z座標}">
        └── <voxel_pos_1 x="{X座標}" y="{Y座標}" z="{Z座標}">

電気配線は、<logic_node_link type="4"> で表されているようです。その中の <voxel_pos_0><voxel_pos_1> で、どことどこを繋げるかを指定しています。未接続の電気ノードの座標がわかれば、新たな電気配線を追加することも可能そうです。

しかし、ビークルファイルだけでは未接続の電気ノードがどこにあるかわかりません。電気ノードのビークル内座標を調べるためには、各パーツの電気ノードの位置と、パーツそのものの座標と回転の情報が必要になります。パーツの種類、座標、回転は <c> とその中身を見ればよさそうですので、次にパーツの種類ごとの電気ノードを位置を調べます。

パーツ定義ファイルの構造を調べる

パーツの情報もXMLファイルで定義されています。Stormworksをインストールしたフォルダ (stormworks.exe のあるフォルダ) から、rom/data/definitions にパーツ定義のXMLファイルがあります。Stormworks をインストールしたフォルダは、Steam から 歯車アイコン→管理→ローカルファイルを閲覧 を押すと開くことができます。

Steamで 歯車アイコン→管理→ローカルファイルを閲覧 を押す

こちらも同様に、関係のあるものだけ抜粋すると次のような構造になっています。

<definition> (パーツ定義)
└── <logic_nodes>
    └── <logic_node type="{電気配線は4}">
        └── <position x="{X座標}" y="{Y座標}" z="{Z座標}">

パーツ定義に書かれている座標はパーツの基準位置からの相対座標のため、電気ノードのビークル内での絶対座標を得るためにはビークル内のパーツ位置とパーツの回転を考慮する必要があります。

ビークルファイルに書かれているパーツ名はパーツ定義ファイルの名前と一致しているようなので、まずはその名前と電気ノードの有無と電気ノードの位置の対応表を作ります。次に、ビークルファイルを見てビークル内の電気ノードの座標をリストアップし、電気ノード間を繋ぐ配線を追加し、最後に配線済みのビークルファイルを書き出します。

実装

ここから作業を始めていきます。適当な場所に新しいフォルダを作り、誤ってパーツ定義ファイルを変更してしまわないよう definitions フォルダをコピーして持ってきます。

パーツ-電気ノード位置 対照表

パーツ定義ファイルの名前と電気ノードの位置の対照表を作ります。今回は一旦JSON形式で保存することにします。作業フォルダ内で下記の Python を実行すれば electric_nodes.json というファイルが作成されます。

from collections import defaultdict
import json
import os
import xml.etree.ElementTree as ET

# パーツ定義ファイルのあるフォルダ
DEFINITIONS_PATH = 'definitions'

# パーツ名と電気ノード位置を記録
elec_nodes = defaultdict(lambda: [])

for filename in os.listdir(DEFINITIONS_PATH):
    name, ext = os.path.splitext(filename)
    # .xml のファイル以外を無視
    if ext != '.xml':
        continue

    with open(os.path.join(DEFINITIONS_PATH, filename), mode='r') as f:
        definition = f.read()
        # <logic_nodes> と </logic_nodes> で挟まれた部分を切り出す
        start_idx = definition.find('<logic_nodes>')
        end_idx = definition.find('</logic_nodes>')
        if start_idx != -1 and end_idx != -1:
            # XMLとして読み取り
            root = ET.fromstring(definition[start_idx:end_idx] + '</logic_nodes>')
            # type="4" のノードのみ抽出
            for elec_node in root.findall('logic_node[@type="4"]'):
                position = elec_node.find('position').attrib
                # 電気ノード座標を記録
                elec_nodes[name].append((
                    int(position.get('x', 0)),
                    int(position.get('y', 0)),
                    int(position.get('z', 0))
                ))

# JSON形式で書き出し
with open('electric_nodes.json', mode='w') as f:
    json.dump(elec_nodes, f)

次のような内容になっています。

  1. definitions 内の拡張子が .xml のファイルをすべて見つける
  2. それぞれに対して、<logic_nodes></logic_nodes> で挟まれた部分の文字列を切り出し、XMLとして読み取る
  3. <logic_node> タグで属性に type="4" をもつものをすべて見つける
  4. その中の <position> タグの xyz 属性を取得し、ファイル名と対応付けて記録する
  5. 記録した電気ノード位置をJSONとして書き出す

ここで、ファイルを直接XMLとして読み取るのではなく文字列処理で <logic_nodes> だけを読み取っているのには理由があります。ファイル全体をXMLとして読み取ろうとすると、一部のファイルでエラーとなりうまくいきません。これは Stormworks のXMLファイルがXMLとしてのルールに違反している箇所があるからです。本来XMLの属性名は数字から始めることはできませんが、今回関係ない部分で数字から始まる属性名が存在します。Stormworks 本体のXMLパーサはある意味いい加減で、これをそのまま読み取ってしまいますが、Pythonxml ライブラリはちゃんとしていて、ルール違反のXMLを読み取ろうとするとエラーを出してしまいます。これを回避するため、今回関係のある <logic_nodes> 内を無理やり文字列処理で切り出してからXMLとして読み取っています。

これで、electric_nodes.json としてパーツごとの電気ノード位置の対照表を得ることができました。大半のパーツは電気ノードを1つまでしか持ちませんが、Circuit Breaker、Charger、Relay の3つだけは電気ノードを2個持っていることがわかります。また、大半のパーツは電気ノードを (0, 0, 0) に持ちますが、中・大バッテリーなどの大きなパーツは基準位置が端にあるのに対して電気ノードはパーツ中心付近にあるため、電気ノード位置が (0, 0, 0) ではない位置にあります。

ビークルに電気配線を追加

自動で電気配線を済ませたビークル

本記事のメインです。ビークルであらかじめバッテリーやブレーカーなど、名前のつけられるパーツに hub という名前をつけておくと、そこから全ての電気ノードに電気配線を済ませた新しいビークルファイルを output フォルダに出力します。既存の電気配線は一旦すべて削除する仕様になっています。

import argparse
import json
import os
import re
import xml.etree.ElementTree as ET
import numpy as np

# コマンド引数
parser = argparse.ArgumentParser()
parser.add_argument('input', type=str)
parser.add_argument('--hub-name', type=str, default='hub')
parser.add_argument('-o', '--output', type=str, required=False)
args = parser.parse_args()

# ファイル出力先
output_path = args.output
if output_path is None:
    output_path = 'output/' + os.path.basename(args.input)

# 電気ノード位置
definitions = {}
with open('electric_nodes.json') as f:
    definitions = json.load(f)

# ファイルを読み取り
tree= ET.parse(args.input)
root= tree.getroot()
logic_links = root.find('logic_node_links')

# 放射状の配線の中心となるハブの電気ノード位置
hub_node = None
# それ以外の電気ノード位置
electric_nodes = []

for body in root.findall('bodies/body'):
    for component in body.findall('components/c'):
        name = component.attrib.get('d', '01_block')
        # 電気ノードのないパーツは無視
        if name not in definitions:
            continue

        # パーツの座標
        o = component.find('o')
        vp = o.find('vp')
        if vp is None:
            continue
        pos = np.array((vp.attrib.get('x', 0), vp.attrib.get('y', 0), vp.attrib.get('z', 0)), dtype=int)

        r = np.identity(3)
        if 'r' in o.attrib:
            # パーツの回転情報
            r = np.array(o.attrib['r'].split(','), dtype=int).reshape((3, 3))

        custom_name = o.get('custom_name')
        for node_def in definitions[name]:
            # 電気ノードの絶対位置を計算
            node_pos = pos + np.array(node_def, dtype=int) @ r

            # 電気ノード位置を記録
            if custom_name == args.hub_name:
                hub_node = node_pos
            else:
                electric_nodes.append(node_pos)

# 一旦電気配線をすべて削除
for link in logic_links.findall('logic_node_link[@type="4"]'):
    logic_links.remove(link)

# ハブが存在しなければエラー
if hub_node is None:
    raise ValueError(f'No electric block with custom name "{args.hub_name}".')

# [X, Y, Z] の形から {x: X, y: Y, z: Z} の形に変換
def pos_attrib(pos):
    attrib = {}
    for i, n in enumerate('xyz'):
        if pos[i] != 0:
            attrib[n] = str(pos[i])
    return attrib

# 各ノードとハブを繋げる配線を追加
for node in electric_nodes:
    link = ET.Element('logic_node_link', attrib={'type': '4'})
    pos0 = ET.Element('voxel_pos_0', attrib=pos_attrib(hub_node))
    pos1 = ET.Element('voxel_pos_1', attrib=pos_attrib(node))
    link.append(pos0)
    link.append(pos1)
    logic_links.append(link)

# <logic_node_links> の新しい文字列表現
logic_links_str = ET.tostring(logic_links).decode()

# ビークルファイルを文字列として読み取り
vehicle_file_str = None
with open(args.input, mode='r') as f:
    vehicle_file_str = f.read()

# ビークルファイルの <logic_node_links> を新しいものに置換
vehicle_file_str = re.sub(r'(<logic_node_links>.*</logic_node_links>|<logic_node_links/>)', logic_links_str, vehicle_file_str, flags=re.MULTILINE | re.DOTALL)

# 新しいビークルファイルを書き出し
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, mode='w', newline='\n') as f:
    f.write(vehicle_file_str)

もとのビークルファイルをなるべく保持するため、書き出すときは文字列処理で <logic_node_links> だけを置き換えるようになっています。既存の電気配線を削除せず、すでに配線済みのノードには配線しないようにするようにするなど、お好みに応じて変更してもよいでしょう。

まとめ

ビークルに限らず、Stormworks のセーブデータにはXMLがよく使われるので、単調で面倒な作業があれば自動化することができます。その際、残念ながら Stormworks のXMLは構文規則に違反している箇所があるため、しっかり作られているXMLパーサではエラーになってしまうことがあることに注意しましょう。エラーを回避するために強引ですがあらかじめ文字列処理を行う必要があるかもしれません。こうしたやり方はプログラムのバグを招きやすいので十分注意する必要がありますが、それでも面倒な作業を自動化することができるとより Stormworks が捗るでしょう。

Stormworks 数値信号に30ビット詰め込む話

この記事はStormworks 第1 Advent Calendar 2023第23日目の記事です。

コンポジット信号、チャンネル足りないと思ったことはありませんか?Lua ブロックへの入力や、マイコン間での情報のやり取り、連結した鉄道車両間での通信や、その他無線通信など、様々な場所でこのような問題に直面することがあるでしょう。この記事ではいかにしてより多くの情報を数値信号・コンポジット信号に詰め込むかという問題への対処法の一つをご紹介します。

結論から書いてしまうと、次の Lua コードを利用して数値信号に30bビットまで詰め込むことができます。なぜこのような処理が必要なのか、このコードが何をしているのか、これから見ていきましょう。

function encode(n)
    local x = ('f'):unpack(('I3B'):pack(n & 16777215, 66 + 128 * (n >> 29 & 1) + (n >> 24 & 31)))
    return x
end
function decode(x)
    local b, a = ('I3B'):unpack(('f'):pack(x))
    local v = 66 <= a and a <= 126 or 194 <= a and a <= 254
    return v, v and (a - 66 >> 2 & 32 | a - 66 & 31) << 24 | b or 0
end

Composite Binary ブロック

Composite Binary ブロック

マイコンのロジックにはこのようなブロックが用意されています。”Composite Binary To Number” 、”Number To Composite Binary” とありますが、これはコンポジット信号に含まれるBool信号32個を数値信号に詰め込み、その数値信号を再びBool信号入りのコンポジット信号に戻すブロックです。つまり、これを使うと32ビット分の情報を一旦数値信号1本にまとめることができてしまいます。しかし、このブロックには実は落とし穴があります。

そうだ!これで数値信号にしてからメモリレジスタに保存すればオンオフ信号32個まで1ブロックで保存できるぞ!

上のボタンから数値にし、大きなボタンでメモリレジスタに保存、その値を分解して下に表示

あれれ?一旦ビークルから離れて戻ってきたらなんかおかしいぞ?

右下のほうでいくつかのビットが反転している

これがこのブロックの落とし穴です。この現象について、詳しく見ていきましょう。

32ビット浮動小数点数

そもそもこのブロックで得られた数値は何なのでしょうか。コンポジットのオンオフ信号をすべてオフにしたら0になりますが、第1チャンネルだけをオンにしたとき1、第2チャンネルだけをオンにしたとき2、第3チャンネルだけをオンにしたとき4…… のようにわかりやすい2進整数値にはなりません。ダイヤル等に表示してみても一見わけのわからない数値が表示されているだけのように思えます。Stormworks の数値信号は32ビットの浮動小数点数C言語などに馴染みのある方向けに言うとfloat型で表現されていますが、件のブロックでは32個のオンオフの状態をそのまま32ビットの羅列としてそれを数値信号ということにしてしまっている、というような挙動をします。ではこの浮動小数点数とは何なのか、ググればたくさん解説が出てきますが、一応ここでも触れておくことにします。

コンピュータではあらゆる情報を0と1の羅列で表現していることはよく知られていると思います。整数を表現するときは通常の2進数で表せばよいので簡単ですが、実数を表現するときはどうすればよいでしょうか。整数なら上限と下限を定めればその間には有限個しか存在しませんが、実数というのは仮に0以上1以下に限ったとしても無限個ありますので、すべての実数を有限個のビットで表現することは不可能です。では妥協して0.1刻みで表現できればよいとしましょう。この場合、0以上1以下に限るとすれば11通りで済みます。これはいわば「表現したい実数を10倍して、整数に丸めて、2進数で保存」していると言えるでしょう。このようにして実数を表そうとする方法は固定小数点と呼ばれます。用途がはっきりしているなら固定小数点数はシンプルで扱いやすいのですが、刻み幅が固定ということは汎用性が低いということでもあります。例えば、Stormworksでは0以上1以下の数値信号を与えてパーツを制御する場面が多いので、この範囲内でできるだけ細かく表現できるような固定小数点数を数値信号に採用したとします。すると、1より大きい数や負の数は表現できなくなってしまいます。速度センサだとかGPS座標だとかはこういった数値を出力しなければいけないので困ったことになります。

では浮動小数点数とはどのようなものでしょうか。一定の桁数に収まる整数をコンピュータ上で表現するのは可能ですから、それを2つ束ねて実数を表現しようというのが浮動小数点になります。例えば、123.45 という値を表現したいとします。小数点のない値は 12345 で、10進数では5桁の整数で表せます。そして、小数点の位置は左から3桁目と4桁目の間にあり、小数点の位置も整数で表せます。こうすることで、整数を2つ束ねて実数を表現します。もちろん実際には10進数ではなく2進数ですが。

さて、123.45 は $1.2345 \times 10^{2}$ と表すこともできます。こういうのを指数表記などと呼び、 $1.2345$ の部分は仮数部、$2$ の部分は指数部と呼ばれます。仮数部は必ず同じ場所に小数点が来る (そうなるように指数部を決定する) とすると、仮数部の小数点を省略してしまっても構わないということになるので、やっていることはほぼ同じでやはり整数を2つ束ねて実数を表現しています。以上、浮動小数点について2通りの説明をしましたが、うまく解説できた自信はあまりないのでよくわからなければググってよりわかりやすい解説を見てください。

コンピュータで実際によく使われている浮動小数点と言えば、IEEE 754 の規格です。16ビットや64ビットなどいくつかの種類がありますが、Stormworksの数値信号に使われているのは32ビットのものです。C言語(など)に馴染みのある方向けに言えばfloat型です。32ビット浮動小数点数、あるいは単精度浮動小数点数と呼ばれるものでは、最上位の1ビットが符号ビット、つまりプラスマイナスを表しており、その次の8ビットが指数部、残りの23ビットが仮数部となっています。指数部はいわゆるゲタ履き表現であったり、仮数部はいわゆるケチ表現であったりしますが、詳細な説明は省きます。より細かく知りたければ 単精度浮動小数点数 - Wikipedia を参照してください。

なお、指数部が0のときはゼロあるいは非正規化数を表し、指数部が255のときは無限大あるいはNaNを表すということになっています。このうち、後ほど指数部が255の場合について触れます。

この先の話で大事なことは、0に近い値は細かく、0から遠い値は大雑把に表現されるということです。

ちなみに、Composite Binary To Number ブロック、 Number To Composite Binary ブロックと同じ挙動をするLuaを書いてみると次のようになります。

-- Composite Binary To Number
function onTick()
    local n = 0
    for i = 0, 31 do
        n = n | (input.getBool(i + 1) and 1 or 0) << i
    end
    local x = ('f'):unpack(('I4'):pack(n & 0xFFFFFFFF))
    output.setNumber(1, x)
end
-- Number To Composite Binary
function onTick()
    local x = input.getNumber(1)
    local n = ('I4'):unpack(('f'):pack(x))
    for i = 0, 31 do
        output.setBool(i + 1, n >> i & 1 ~= 0)
    end
end

前者ではまず32個のBool入力を整数に変換します。input.getBool(i + 1) and 1 or 0 では true か false で得られる入力を整数1か0に変換しています。それを後述するビット演算を用いてnの各ビットに配置していき、1個の32ビット整数にしています。('f'):unpack(('I4'):pack(n & 0xFFFFFFFF)) は、整数 n のうち下位32ビットだけを取り出して浮動小数点の実数値に変換するおまじないです。

戻り値を一旦変数 n に格納してからそれを return している部分は冗長に見えるかもしれませんが、unpack の戻り値は複数返ってきますが最初の1つしかいらないので、最初の1つだけを取って残りを捨てるためにこのようにしています。もし unpack の結果をそのまま return すると、unpack の複数の返り値をそのまま返してしまうので、encode の返り値も複数となり思わぬバグの原因になりがちです。

後者では真逆の操作を行ってもとと同じ32個のBool出力に戻します。('I4'):unpack(('f'):pack(x)) は、32ビット浮動小数点数の実数値 x を32ビットの整数値に変換するおまじないです。

ビークルステートファイル

Stormworksの話に戻ってきました。ビークルから離れてロード範囲外になると、そのときのビークルの物理・ロジック状態のスナップショットがXML形式で保存されます。これをビークルステートファイルと呼ぶことにします。メモリレジスタに入っている値もこのビークルステートファイルに保存されますが、本当に正確に保存されているでしょうか。先ほど Composite Binary ブロックを試したときのビークルステートファイルを確認してみましょう。

ビークルステートファイルを覗く

なんと、小数点以下6桁までしか保存されていません。つまりメモリレジスタに値を保存するときは、0に近い値だと精度が落ちて、0から遠い値にしたほうが精度がよくなります。浮動小数点の意味を何もわかっていない実装です。おのれゲオメタ。ひどいはなし。なぜ Composite Binary ブロックを用意しておきながらこの仕様でよしとしたのか理解に苦しみます。

大きい値にして試すと、正しく復元される

大きい値にしたときのビークルステートファイル

極端に大きい値にして試しても正しく復元される

極端に大きい値にしたときのビークルステートファイル

嘆くのはこのくらいにして、この仕様のもとで何ができるか考えましょう。先ほど Composite Binary ブロックを試したときの問題はこれが原因とみられます。ビークルステートファイルに保存するときに値を丸める、つまり一部の情報を捨ててしまっているため、ビークルを再ロードして復元するときに元とまったく同じ値に戻るとは限らないということです。逆に言えば、メモリレジスタに保存さえしないのであれば、ビークルを再ロードした際に一瞬値が乱れるかもしれませんが*1、基本的にComposite Binary ブロックで問題ないかと思われます。とはいえ、一瞬でも乱れると困る場合もあるでしょうし、マルチでどう動作するかもよくわかっていません。ビークルを一旦アンロードしてから再ロードしても値が壊れないようにするためにはどうすればよいでしょうか。

数値信号に何ビット詰め込めるか

ようやく本題です。32ビットの浮動小数点数なんですから32ビット詰め込みたいところですが、後述の理由であえて30ビットに絞ります。仮数部がすべて正しく復元されるためには、仮数部の一番下のビットが0↔1で変化したときにも、丸めた後の値が区別できる必要があります。つまり、仮数部の一番下のビットが表す幅が $0.000001=10^{-6}$ 以上であればよいということになります。指数部の8ビットをそのまま整数として見た値を $E$ とすると、仮数部の一番下のビットが表す幅は $2^{(E-127)-23}$ になります。これらから、欲しい条件は次のように計算できます。

$$ \begin{split} & 2^{(E-127)-23}\geq10^{-6}\\ \Leftrightarrow & E\geq-6\log_2 10+150\fallingdotseq-6\times3.3219+150=130.0684 \end{split} $$

$E$ は整数なので、$E\geq131$ が満たすべき条件ということになります。さらに、$E=255$ のときは、浮動小数点数の仕様として無限大やNaNを表すことになっているため、仮数部の全ビットを正しく復元できる見込みがありません。これを避けるとするなら、$131\leq E\leq254$ が満たすべき条件ということになり、$E$ がとることができる値は124通り、ビット数で表すなら $\left\lfloor\log_2 124\right\rfloor=6$ ビットとなります。符号部1ビットと仮数部23ビットを合わせて24ビット、加えて指数部で6ビットなので、30ビットまではこの条件下で表現できます。以下、この $131\leq E\leq254$ という条件が何度も登場します。

メモリレジスタに保存しても区別できる値の数だけで言えば $2^{30}$ よりも多いのですが、全ビットを互いに独立に立てたり立てなかったりするデータを表現するという条件を課すなら、少なくとも30ビットまでならうまくやる方法が存在することを示すことができました。もしかするともっといいやり方があって31ビットも可能だったりするかもしれませんが、今回は30ビットで十分だということにしておきます。

30ビットで何ができる?

30ビット詰め込めたら何ができるの? という方もいるかもしれません。用途例としてわかりやすいのは、数値信号1本に複数の数値を詰め込むといったような使い方でしょう。例えば、30ビットを10ビットずつ3つに分割したとします。10ビットで表現できる値は $2^{10}=1024$ 通りですから、符号なし整数とすると0以上1023以下の値、10進数で言えば3桁までの値が1つの数値信号に3つまで収まることになります。もし複数の値を送りたくて、値1つあたり1024通りが区別できればよいのなら、コンポジット信号で送るときのチャンネル数を約3分の1に削減することができるということになります。他にも、30ビットを6ビットずつ5個に分割するなら1つあたり64通り、チャンネル数は約5分の1とったようになります。もちろん20ビットと10ビットの2つに分割というようなことも可能です。30ビットをすべて使って1つの値を表すこともできます。浮動小数点形式をそのまま使って整数を表す場合と比較してみると、浮動小数点では $2^{24}=16777216$ までは正確にカウントすることができますが、16777217が表現できず次の値は16777218となってしまうため、10進数での桁数で言えば7桁までなら安全に扱えるがその先は危ういということになります。実際には別に符号部がありますから、浮動小数点数形式で整数を表す場合 $2^{25}-1=33554431$ 通りが限界となります*2。一方で30ビット詰め込む方法をとると、浮動小数点数形式で区別できる整数だけでなく区別できる小数も使うため表現できる値の幅が $2^{30}=1073741824$ 通りまで広がります。10進数で言えば2桁増えて9桁まで安全に扱えるということになります。

ビットをどのように配分するかは用途に合わせて決めることになります。浮動小数点数の規格そのものも、符号部、指数部、仮数部の3種類の整数をそれぞれ1ビット、8ビット、23ビット割り当てて合計32ビットに詰め込んでいると言えるでしょう。なお、送りたい数値が実数値で、なおかつ精度を落とさず送りたいという場合にはチャンネル数を減らすことはできません。精度とチャンネル数削減はトレードオフになるので、精度(桁数)があまり要求されない場面でしかこの方法は使えません。

ではどのようにすれば複数の整数値を1つの整数値に詰め込むことができるでしょうか。2進数の世界で行う場合、Luaのビット演算を使うと比較的簡単に実現できます。例として、入力の1番と2番と3番にそれぞれ8ビット、10ビット、12ビット割り当てて1個の整数にまとめる場合、次のように実装できます。

local a = math.floor(input.getNumber(1))
local b = math.floor(input.getNumber(2))
local c = math.floor(input.getNumber(3))

local n = (a & 255) | (b & 1023) << 8 | (c & 4095) << 18

なお、Luaの数値にはさらに整数と浮動小数点数があります*3input.getNumber では小数点以下がすべて0であろうとなかろうと浮動小数点数として取り込むので、ビット演算を使えるよう整数に変換するために math.floor を使っています。ビット演算は &|<< を使っています。

ビットAND演算

& はビットAND演算子で、左右の整数値を2進数で表したとき、各桁ごとに両方1となっている桁だけを1に、それ以外を0にした新たな整数値を返します。6 & 3 を例に見てみると、6=0110(2)、3=0011(2) なので、両方1となっているのは右から2桁目のみとなり、0010(2)=2 という結果になります。

a のうち下位8ビットを取り出したいときは、片方に1が8個並んでおりその上の桁はすべて0となっている数を置けばよいことになります。11111111(2)=255 ですので、a & 255 とすると a のうち8ビットだけを取り出せます。こういうのをビットマスクなどと呼びます。

b & 1023c & 4095 も同様で、2進数では表すと1023は1が10個、4095は1が12個並んだ数になっています。1が $n$ 個並んだ数は $2^{n}-1$ で求められます。

ビット左シフト演算

<< はビット左シフト演算子で、右側で指定した桁数だけ左側の値の各ビットを左にずらすという意味になります。ずらした結果右、つまり下位のビットが空きますが、この部分は0で埋められます。似た演算子として >> が後に登場しますが、こちらは右シフトです。今回はbのうち10ビットだけを取り出したあと、aのために下位8ビットを空けてその上に移動するので (b & 1023) << 8 としています。

(c & 4095) << 18 も同様で、aとbのために下位18ビットを空けてその上に移動するということです。

ビットOR演算

| はビットOR演算子で、先ほどの & のORバージョンです。左右の整数値を2進数で表したとき、各桁ごとに一方が1となっている桁を1に、両方が0の桁だけを0にした新たな整数値を返します。6 | 3 の例では、6=0110(2)、3=0011(2) なので、0111(2)=7 という結果になります。

今回は予め3つの値が被らないようにマスク、シフトしてありますので、ORで重ね合わせれば3つの値を並べたのと同じことになります。

このようにして得られた30ビット整数値 n は、このまま output.setNumber しても「浮動小数点で表した整数値」として出力されてしまうので24ビットまでしか表せず、情報が失われるおそれがあります。そこで、30ビットを損失なく浮動小数点数に変換する処理を挟むことになります。

浮動小数点数に30ビット詰め込む方法

先ほど30ビットまでは詰め込めるという計算をしたとき、仮数部の23ビットがすべて正しく復元されることを条件としていたので、この23ビットは何も考えずとも安全に使うことができます。これで残りは7ビットです。符号部は浮動小数点数が0以外の値を表すなら正しく復元されるでしょう。もし0を表すなら指数部も0になるはずですが、先ほど求めた条件では $131\leq E\leq254$ なので、指数部 $E$ が0となる場合はそもそも考えなくてよいです。よって符号部もそのまま使うことができます。残り6ビットは指数部で表す必要があります。

問題は指数部の8ビットに残りの6ビットをどう割り当てるかです。$131\leq E\leq254$ という条件を満たさなければなりません。8ビットのうち上位2ビットを1に固定した場合、つまり 11XXXXXX(2) のようにした場合、最小値は 11000000(2)=192 となり、最大値は 11111111(2)=255 となります。255のとき条件を満たさないことになるためこれではよくありません。2ビットをどのように固定しても条件を満たすようなパターンは存在しないと思われます。(証明或いは反例待ってます❤)そこで、ビット演算だけでなく算術演算も使うことにします。6ビットの入力に対して131を足して $E$ を決定するとすれば、$E$ は $131\leq E\leq194$ となり条件を満たします。この方法で送信側をLuaで実装してみると次のようになります。

function encode(n)
    local x = ('f'):unpack(('I4'):pack((n >> 29 & 1) << 31 | (n >> 23 & 63) + 131 << 23 | n & 8388607))
    return x
end

30ビットの入力をどのように1ビット、6ビット、23ビットに分割するかは、送信と受信で統一されていれば自由ですが、ここでは上位ビットから1ビット、6ビット、23ビットに分割しています。下位23ビットを取り出すために、n & 8388607 としています。8388607 は2進数で表すと1が23個並んだ数です。次に、下23ビットをすっ飛ばしてその上の6ビットだけを取り出すために、n >> 23 & 63 としています。また、最上位の1ビットを得るため同様に n >> 29 & 1 としています。

仮数部はそのままでよく、符号部も位置を変えて配置しなおせばよいですが、指数部は6ビットだったものを131を足した上で8ビットのスペースに配置することになります。指数部に131を足して、下23ビットを空けてその上に配置するために (n >> 23 & 63) + 131 << 23 としています。算術演算の + は各種ビット演算より優先度が高いので、先に行いたいビット演算にはカッコをつけています。131を足したら最後に23ビット左シフトしています。

ここで、行き先である8ビットのスペースからはみ出すことがないことを確認しなければなりません。6ビットの入力部分 n >> 23 & 63 が最大値63をとったとしても、131を足して194となり、8ビットの限界である255より小さいので問題ありません。

符号部を32ビット目に配置するために、(n >> 29 & 1) << 31 としていますが、これも同様です。最後に、3種類の値をすべて結合するために | 演算子を使います。

このencode関数を使う際には、まず送り出したい情報を30ビット整数に配置し、この関数で浮動小数点数値に変換し、その値を output.setNumber()Luaの外に出します。あとはそのコンポジット信号なり数値信号なりを通信相手に何らかの手段(マイコン内別Luaビークル内別マイコンへロジック接続、別ビークルへコネクタまたは無線などなど……)で届けて、受信側のLua関数に入れることになります。

ちなみに、この方法で出力した浮動小数点数の値は0になることはありません。指数部が131以上なので絶対値が16以上の値しか出てこないためです。このことから、受信側ではもし0を受け取った場合信号が届いていないという判定をすることもできます。

受信側では、指数部の8ビットを取り出してから131を引いて6ビットにし、符号部・仮数部と結合して30ビットの整数値に戻します。

function decode(x)
    local n = ('I4'):unpack(('f'):pack(x))
    return (n >> 31 & 1) << 29 | (((n >> 23 & 255) - 131) & 63) << 23 | n & 8388607
end

受信側と逆の操作を行うため、指数部を取り出してから131を引いているのが (n >> 23 & 255) - 131 の部分で、さらに & 63 とすることで6ビットだけを取り出します。仮数部はそのまま、符号部は位置を変えて並べています。先ほどのencode関数が本来出力しないはずの値でも例外処理などは行いませんのでご注意ください。decode関数に0.0を入れても返ってくる値は全ビット0とは限りません。

それでは、先ほどと同じコンポジット1番と2番と3番にそれぞれ8ビット、10ビット、12ビット割り当てる場合について、送信側と受信側のLuaをそれぞれ見てみましょう。

-- 送信側
function encode(n)
    local x = ('f'):unpack(('I4'):pack((n >> 29 & 1) << 31 | (n >> 23 & 63) + 131 << 23 | n & 8388607))
    return x
end
function onTick()
    local a = math.floor(input.getNumber(1))
    local b = math.floor(input.getNumber(2))
    local c = math.floor(input.getNumber(3))
    output.setNumber(1, encode((a & 255) | (b & 1023) << 8 | (c & 4095) << 18))
end
-- 受信側
function decode(x)
    local n = ('I4'):unpack(('f'):pack(x))
    return (n >> 31 & 1) << 29 | (((n >> 23 & 255) - 131) & 63) << 23 | n & 8388607
end
function onTick()
    local p = decode(input.getNumber(1))
    output.setNumber(1, p & 255)
    output.setNumber(2, p >> 8 & 1023)
    output.setNumber(3, p >> 18 & 4095)
end

送信側のLuaは先ほどの例にencode関数を付け加えて、output.setNumber で3つの値をまとめた浮動小数点数値を出力コンポジットの1番だけに出力しています。受信側では、コンポジットの1番だけを読み込んでdecode関数に通し、得られた30ビット整数値を分解して output.setNumber でコンポジット1番と2番と3番に出力しています。こうすることで、3つの数値を一旦1つの数値信号にまとめ、その後バラして再び3つの数値に戻すことができています。ただし、小数を入力しても切り捨てられてしまったり、1番に256以上や0未満の値を与えるなどするとオーバーフローして違う値が送られてしまったりします。繰り返しになりますが、実際に使うときには用途に合わせてビットを割り当てる必要があります。

30ビットより少し多く詰め込む方法

これで話は終わりではありません。これまで30ビットが限界と言ってきましたが、実は区別できる値の数は $2^{30}$ 個よりもいくらか多くあります。30ビット詰め込みの場合は $131\leq E\leq194$ の範囲しか使っていませんでした。$131\leq E\leq254$ というのが仮数部のビットがすべて保存される十分条件でしたから、195から254までの範囲はまだ活用できていないことになります。実際、$131\leq E\leq254$ の範囲をフル活用すれば区別できる値は $124\times2^{24}$ 通りあることになります。$\log_2\left(124\times2^{24}\right)\fallingdotseq30.954$ ですから、本当は約30.954ビットくらいということになります。($131\leq E\leq254$ もあくまで十分条件にすぎないため、本当に本当の限界はもう少し先だと思われます。) ギリギリ31ビットに届かないため2進数の桁数では30ビットと言わざるを得ませんが、無駄になっている分を活用する方法も一応あります。

131以上254以下124通りの整数値と、残りの24ビット分で分割し、2つの値を束ねたものとして扱うことで30ビットより少し多い分を使い切ることができます。実用上、先ほど紹介した30ビットの方式でも30ビット全部を1つの値で使いたいということはあまりなく、いくつかの部分に分割して複数の値を束ねたものとして使うことが多いでしょう。分割したうちの1つが124通りの中に収まるなら、それをそこに割り当てて、残りで24ビットを分け合うという使い方ができます。124通り+24ビットという構成でなくとも、少し工夫すれば62通り+25ビットとか、31通り+26ビットとか、15通り+27ビットというように分けることもできます。全体で30ビットとしてしまった場合、例えば26ビット使ったあとに残るのは4ビット=16通りなので、ここに31通りまで入るのなら少しお得です。コード自体は先ほど紹介したものの指数部関連の部分を少しいじるだけです。

少しシンプルに30ビット詰め込む方法

今までは上位から符号部1ビット、指数部8ビット、仮数部23ビットの3つの部分に分割して浮動小数点数値を組み立てていました。しかし、上位8ビットと下位24ビットの2分割にすることでコードを少しシンプルにすることもできます。浮動小数点の上位8ビットというのは、符号部1ビットと指数部上位7ビットに、下位24ビットは指数部下位1ビットと仮数部23ビットにあたります。指数部の最下位ビットだけを切り離して上は符号部と一緒に、下は仮数部と一緒に扱うということです。この場合、上の8ビットはどのような値にする必要があるでしょうか。下の24ビットがどんな値をとっても、指数部が $131\leq E\leq254$ という条件を満たしてほしいのでした。指数部の上位7ビットが表す整数値を $E'$ とすると、$E'=\left\lfloor \frac{E}{2}\right\rfloor$ ですので、$66\leq E'\leq126$ というのが要求する条件です。この上に符号部1ビットがくっつくので、最終的な上位8ビットの値を $A$ とすると $66\leq A\leq126,194\leq A\leq254$ というのが上の8ビットが満たすべき条件です。この条件を満たす $A$ は122通りになります。先ほど、124通り+24ビットという方式を紹介しましたが、こちらの方式でも122通り+24ビットが確保できます。この方式でのLuaコードを次に示します。

function encode(a, b)
    if a < 66 or 126 < a and a < 194 or 254 < a then
        -- aが無効なときの例外処理
        return 0
    end
    local x = ('f'):unpack(('I3B'):pack(b & 16777215, a & 255))
    return x
end
function decode(x)
    local b, a = ('I3B'):unpack(('f'):pack(x))
    return a, b
end

今まで 'I4' と書いていたところが 'I3B' となりました。I4は4バイト=32ビットの符号なし整数を意味していましたが、今回は1バイトと3バイトで分割してもよいので、I3Bとなりました。I3は3バイト符号なし整数、Bは1バイト符号なし整数を意味します。また、下位のほうが先に来ます。

例外処理のif文が増えてしまいましたが、aに変な値が紛れ込まない自信があるならこれは削除してもよいです。使用には少々注意が必要になりますが、その部分を除くとかなりスッキリしたコードになったと思います。

では、この方式をベースに入出力を30ビット整数1個にしてみましょう。122通りの部分に6ビット=64通りの値を入れればよいのですが、その条件が少しややこしいのでした。下位24ビットはそのままですが、上位8ビットについては n >> 24 & 31 で25ビット目から29ビット目までを取り出し、これに66を加えてから、n >> 29 & 1 で最後残った1ビットを取り出し、このビットが1なら128を加えます。結果として 66 + 128 * (n >> 29 & 1) + (n >> 24 & 31) というコードになりました。128を掛けるところは7ビット左シフトでも構いません。

function encode(n)
    local x = ('f'):unpack(('I3B'):pack(n & 16777215, 66 + 128 * (n >> 29 & 1) + (n >> 24 & 31)))
    return x
end
function decode(x)
    local b, a = ('I3B'):unpack(('f'):pack(x))
    local v = 66 <= a and a <= 126 or 194 <= a and a <= 254
    return v, v and (a - 66 >> 2 & 32 | a - 66 & 31) << 24 | b or 0
end

このコードは実際に私が使用しているものなので、decodeの第1戻り値では有効な値かどうかをboolで返し、第2戻り値で整数値を返しています。また、入力が有効でない場合は0になるようにしています。実際に使用するときはこのような処理を挟んだほうが致命的なバグを回避しやすく、バグがあるとき発見も容易になります。

まとめ

私が実際に使っているのは最後に紹介した方式です。開発中の電車用システムの車両間通信では、SW-TCS規格の連結器を採用した車両同士で通信するためコンポジット2本しか通信に使えませんが、これを最大限まで活用するために時分割多重化と組み合わせて使用しています。また、高原のな氏らによる「N-TRACS 宗弥急行」(近日公開予定)ではビークルとして設置されるCTC卓とアドオンLuaの間で数十個*4のフラグのやり取りが必要になりましたが、今回紹介した方法を使って数個のキーパッドとダイヤルでアドオンLuaとの送受信を行っています。浮動小数点数どうこうの部分を理解していなくても最低限整数のビット演算についてわかっていれば今回紹介したLuaの関数をコピペで使うことができますので、Stormworksで大きなデータをやり取りするときは時分割多重化とあわせてこの方法も使ってみてください。ここまで書いていて、もしかすると時分割多重化のほうの解説も需要があるかも?なんて思いました。

*1:メモリレジスタ以外にも各ロジックブロックの入出力の状態がビークルステートファイルに同様に保存されるためこのように考えられますが、未検証です

*2:+0と-0を区別しない場合

*3:いずれもLua内では64ビット確保されていますが、Stormworksの数値信号が32ビットしかないため特に気にしなくて構いません

*4:もしかすると100個以上