TECH NOTE / 002
Stormworks HTTPデータロガーの仕組み
この仕組みの役割はグラフを作ることではなく、Stormworks内にある数値をゲームの外へ取り出し、一般的なCSVとして残すことです。
/ Hræsa
01 / OVERVIEW
ゲーム内の数値を、普通のファイルへ運ぶ
Stormworksのマイコンは、機体の速度、RPS、姿勢、操作入力などを数値として扱えます。しかし、そのままでは長時間の変化をゲーム外の道具で調べにくい。そこでLuaからPC上の小さなHTTPサーバーへ数値を送り、Pythonが1回の通信をCSVの1行へ変換します。
- 01Stormworks
8つの数値入力と記録スイッチをLuaが読む。
- 02HTTP GET
Tickと各チャンネルをURLのクエリへ並べて送る。
- 03Python
localhostの8080番ポートでリクエストを受け取る。
- 04CSV
PC時刻、Tick、8チャンネルを1行として追記する。
CSVになった後は、Excelやスプレッドシートなどで自由にグラフ化できます。データの採取と分析を分けられることが、この構成の利点です。
02 / LUA
Lua側:値をHTTPリクエストへ詰める
次は、note記事と同じ通信形式を保ちながら、処理のまとまりが見えるように整理した解説用コードです。Bool入力1が記録スイッチ、Number入力1〜8が記録対象です。
local PORT = 8080
local SAMPLE_INTERVAL = 1
local CHANNEL_COUNT = 8
local tick = 0
function onTick()
tick = tick + 1
if not input.getBool(1) then
return
end
if tick % SAMPLE_INTERVAL ~= 0 then
return
end
local query = {"/log?tick=", tostring(tick)}
for channel = 1, CHANNEL_COUNT do
local value = input.getNumber(channel)
query[#query + 1] = string.format(
"&c%d=%.6f",
channel,
value
)
end
async.httpGet(PORT, table.concat(query))
end
function httpReply(port, request, reply)
-- 最小構成では応答本文を使わない
endTickは常に数える
tickはonTick()が呼ばれるたびに増えます。記録スイッチがOFFの間も増えるため、設定した送信間隔より大きく飛んでいれば、その区間は送信されていないと判断できます。
記録する瞬間だけ先へ進む
Bool入力1がOFFなら、そのTickの処理をそこで終えます。さらにSAMPLE_INTERVALで送信間隔を間引きます。記事の設定では、1で毎Tick、6で約10Hz、60で約1Hzです。
8つの値をクエリへ変換する
送信先は/logです。Tickはtick、数値入力はc1〜c8という名前で並べます。小数は6桁へそろえ、毎回同じ形式にします。
非同期で送る
async.httpGet()は、Lua側のTick処理を待たせずHTTPリクエストを開始します。最小構成ではhttpReply()を空にしているため、受信や保存の成功確認までは行いません。
03 / HTTP
HTTP側:URLが1サンプルを表す
Luaが組み立てるリクエストは、概念的には次の形です。
GET /log?tick=720&c1=12.500000&c2=3.250000&...&c8=0.000000/log- Python側がデータ記録用として受け付けるパス。
tick=720- Stormworks側で数えた時間順序。PC時刻とは別の基準です。
c1〜c8- 8つのNumber入力。チャンネルの意味は機体側の配線で決まります。
8080- Luaの送信先とPythonの待受先を一致させるポート番号。
値はHTTPの本文ではなくURLのクエリとして送られます。8チャンネル程度なら構造が単純で、受信側も標準ライブラリだけで処理できます。
04 / PYTHON
Python側:リクエストをCSVの1行へ変える
次の解説用コードは、note記事の基本構成に、出力先の明示、空ファイルの判定、同時書込みを避けるロック、終了処理を加えたものです。外部ライブラリは使いません。
from csv import writer
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Lock
from urllib.parse import parse_qs, urlparse
PORT = 8080
CHANNELS = range(1, 9)
OUTPUT = Path(__file__).with_name("stormworks_log.csv")
WRITE_LOCK = Lock()
class LogHandler(BaseHTTPRequestHandler):
def do_GET(self):
request = urlparse(self.path)
if request.path != "/log":
self.send_error(404)
return
query = parse_qs(request.query)
row = [
datetime.now().isoformat(timespec="milliseconds"),
query.get("tick", [""])[0],
*[
query.get(f"c{channel}", [""])[0]
for channel in CHANNELS
],
]
with WRITE_LOCK:
needs_header = (
not OUTPUT.exists()
or OUTPUT.stat().st_size == 0
)
with OUTPUT.open(
"a",
newline="",
encoding="utf-8",
) as stream:
csv_writer = writer(stream)
if needs_header:
csv_writer.writerow([
"PC_Time",
"Tick",
*[
f"Channel{channel}"
for channel in CHANNELS
],
])
csv_writer.writerow(row)
self.send_response(204)
self.end_headers()
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = ThreadingHTTPServer(
("localhost", PORT),
LogHandler,
)
print(f"Stormworks logger: http://localhost:{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()localhostだけで待ち受ける
ThreadingHTTPServerをlocalhost:8080へ結びます。同じPCからの通信だけを受ける構成で、LANやインターネットへ公開する必要はありません。
URLをパスと値に分解する
urlparse()で/logとクエリを分け、parse_qs()でtickやc1を辞書として取り出します。別のパスは404で拒否します。
到着時刻とゲーム内時刻を両方残す
PC_TimeはPythonが受信したPC側の時刻、TickはLuaが付けたゲーム側の順序です。通信の遅れを含む時刻と、シミュレーション内の並びを分けて確認できます。
ヘッダーは最初の1回だけ書く
CSVが存在しないか空なら列名を書き、その後にデータ行を追記します。サーバーが複数のリクエストを並行処理しても書込みが重ならないよう、ファイル操作をロックで囲んでいます。
204で受信完了を返す
保存後は本文なしのHTTP 204を返します。Lua側は返信内容を使いませんが、HTTP通信を完了させるために応答自体は必要です。
05 / CSV
保存される列
| 列 | 作る側 | 意味 |
|---|---|---|
PC_Time | Python | リクエストを受け取ったPC側の時刻 |
Tick | Lua | Stormworks側で数えたTick番号 |
Channel1〜Channel8 | Lua | Number入力1〜8の値 |
チャンネル名は汎用のまま保存されます。どの列が速度、RPS、操作入力などに対応するかは、機体ごとに別途メモするか、分析用ファイル側で見出しを付け替えると扱いやすくなります。
06 / LIMITS
記録するときに意識すること
- 送信間隔
変化の速い制御値は細かく、燃料量など緩やかな値は粗く記録できます。必要以上に毎Tick送るとCSVが急速に大きくなります。
- Tickの欠け
この最小構成には再送処理がありません。記録スイッチを切った区間や通信できなかったサンプルは、設定した送信間隔を超えるTickの飛びとして現れます。
- セッションの区切り
Pythonは既存CSVへ追記します。試験ごとにファイル名を変えるか、実行前に以前のCSVを別の場所へ移すと混ざりません。
- 公開範囲
サーバーは
localhostへだけ結びます。外部PCから受ける必要がない限り、待受先を0.0.0.0へ広げません。