Skip to content

ワールド・プロトコル: Nocturne(草案)

com.hoshiboshi.world.nocturne は、Hoshiboshi が定義するライブ・ワールド・セッション用のプロトコル識別子です。ワールド・マニフェストおよびインスタンス情報の worldProtocols[] に、name として記載します。

ステータス

本稿は草案です。暗号スイート、チャネルごとの詳細なパケット形式、エラーコードの詳細は、今後の稿で固定します。

前提:マニフェストと権限の検証済み

Nocturne 接続を開始する前に、クライアントは以下を完了している必要があります。詳細は VirMesh の World Authority および World Instances を参照してください。

  1. world address(例: example.com/world)、catalog entry、または既知の worldIdme.virmesh.world.resolveWorld で解決する。
  2. 返却されたワールド・マニフェストを、ワールド・アイデンティティ鍵(medi:world:ed25519:<public-key>)でトップレベルの署名を検証する。
  3. hostingDelegation の署名を検証し、capabilitieshostLiveSession が含まれていることを確認する。
  4. expiresAt が存在する場合は期限切れでないことを確認する。
  5. 参加対象のインスタンスを me.virmesh.worldInstance.listInstance または getInstance で解決し、インスタンス情報内の worldProtocols を読み取る。

Nocturne はこの検証済みの「信頼されたエンドポイント」への接続手段です。マニフェスト検証をスキップして接続先の UDP ポートや証明書を信頼してはいけません。

Nocturne 接続前の信頼チェーン

図: クライアントは world address / catalog entry からマニフェストと hostingDelegation を検証し、インスタンス情報に含まれる Nocturne の接続先だけを QUIC エンドポイントとして扱います。

トランスポート層

QUIC over UDP

  • QUIC を用い、UDP 上でクライアントとワールドサーバー(または委任されたエッジ)の間を接続します。
  • QUIC 実装は RFC 9000 に準拠し、TLS 1.3 を内包します。サーバー証明書の検証は TLS 標準に従います。
  • UDP ポートは既定で 443 を推奨します。代替ポートを使う場合は information.port に明示します。
  • IPv4 / IPv6 の両方をサポートします。Dual-stack 環境では Happy Eyeballs に似た並行接続を推奨します。

データ形式: CBOR

Nocturne の認証フレーム、制御フレーム、イベントフレームは CBOR(RFC 8949)でシリアライズします。

CBOR は JSON と同等のデータモデルを持つバイナリシリアライゼーションフォーマットです。テキストよりコンパクトで、バイト列をそのまま埋め込めるため、nonce や署名の base64url 変換が不要になります。

QUIC ストリームは byte stream であり、メッセージ境界を保持しません。Nocturne のストリーム上の CBOR 制御フレームは、次の長さ付き形式で送信します。

text
frame = length || cbor-payload

length は QUIC variable-length integer とし、直後の cbor-payload のバイト長を表します。cbor-payload は単一の CBOR item でなければなりません。認証完了前に受け付けるフレームは 8 KiB 以下、認証後の制御フレームは 64 KiB 以下を既定の上限とします。

生バイナリは、Asset Channel、一部の realtime payload、voice frame など、各チャネル定義で明示された場所でだけ使用します。生バイナリをストリームで送る場合も、ストリーム先頭には CBOR header を置き、後続の raw bytes の意味、長さ、対応するセッションを宣言します。DATAGRAM 拡張(RFC 9221)を使う場合は、1 datagram に 1 CBOR item または用途固有の生バイナリ 1 unit を載せます。

CBOR map の key 設計: string-first + hot-path compact

Nocturne の CBOR map は、通常フレームでは camelCase の string key を使います。認証、制御、イベント、アセット、プロフィール、avatar fetchGrant relay は可読性と拡張性を優先し、"type""playerId""assetHash" のような意味名を wire key にします。

例外として、Realtime 系 Channel の高頻度 datagram だけは、帯域とパース負荷を抑えるためにフレーム内ローカルの small integer key を使えます。v1 で対象になるのは Player Connection ChannelUnreliable Script RPC Channel です。この integer key は global registry ではなく、該当 channel の realtime frame の中でだけ意味を持ちます。

領域key 形式理由
認証・制御・イベントstring keyデバッグしやすく、拡張時に衝突しにくい
アセット・プロフィール・avatar relaystring key領域固有フィールドを意味名で扱える
Realtime datagramlocal small integer key高頻度の state / signal 配信で帯域を抑える

CBOR の byte string はそのまま使います。noncesignatureprofileEnvelopeprofileFingerprintchannelBindingToken などは、CBOR 上では base64url 文字列に変換せず生バイト列として送ります。

署名検証に使う正規化文字列(署名対象)は、CBOR に依存せず VirMesh 標準の Canonical JSON で生成します。詳細は後述の「プレイヤー認証ハンドシェイク」を参照してください。

ALPN とバージョン交渉

  • TLS ハンドシェイク時に ALPN を使用し、サーバーが Nocturne v1 セッションを受け付けることを確認します。Nocturne v1 の ALPN 識別子は com.hoshiboshi.world.nocturne/1 です。
  • worldProtocols[].version は Nocturne アプリケーションレイヤ仕様の semver です。クライアントは対応する同一 major のうち、提示された最も新しい version を選びます。
  • サーバーは server_hello"protocolVersion" で選択結果を返します。クライアントは、選択した worldProtocols[].version と一致しない場合は接続を閉じます。
  • ALPN 交渉に失敗した場合、クライアントは他の worldProtocols(例: WebSocket)へのフォールバックを試みてもよく、Nocturne 接続を継続してはいけません。

0-RTT

  • 0-RTT Early Data は アプリケーション・ペイロードには使用しません。プレイヤー認証ハンドシェイク(後述)が完了する前に、プレイヤーに紐づく状態変更を送信できると、リプレイ攻撃のリスクが高まるためです。
  • Nocturne v1 サーバーは 0-RTT Early Data を受理してはいけません。TLS/QUIC 実装上 Early Data が到着した場合も、アプリケーション層へ渡さず破棄する必要があります。

接続移行(Connection Migration)

  • QUIC の接続移行を利用し、Wi-Fi からモバイル回線への切り替えなどを drop なしで継続できるようにします。
  • サーバーはアドレス検証(PATH_CHALLENGE / PATH_RESPONSE)を実装し、接続移行中のスプーフィングを防ぎます。
  • 接続移行後も、プレイヤーの認証状態は同一 QUIC コネクション内で継続されます。再認証は必要ありません。

識別子とバージョン

項目
プロトコル名com.hoshiboshi.world.nocturne
意味Hoshiboshi ワールドのリアルタイム参加層(星夜・夜想曲のイメージでのコードネーム)。

worldProtocols[].version には、この識別子に紐づくアプリケーションレイヤ仕様のセマンティックバージョン(例: 1.0.0)を載せます。QUIC 自体のバージョンとは別概念です。

セッション情報(information)

接続に必要な値は、インスタンス情報の worldProtocols[].information に載せます。Nocturne では、resolveWorld で返る world manifest 側の worldProtocols[] には information を含めません。以下は instance 側 information の必須および推奨キーです。

キー必須説明
hostはいQUIC エンドポイントのホスト名(IP アドレスも可だが推奨しない)。
portはいUDP ポート番号。通常 443
serverNameはいTLS SNI に使うサーバー名。証明書の Subject Alternative Name と一致させる。
alpn推奨想定 ALPN 識別子。省略時は com.hoshiboshi.world.nocturne/1 とみなす。サーバーが複数プロトコルを同一ポートで運用する場合に使用。
assetKinds推奨接続先が配信できるワールドアセット形式の一覧。例: ["com.hoshiboshi.asset.hoscene"]。詳細は「ワールドアセット交換」を参照。
componentKinds推奨接続先のワールドが利用するコンポーネントの一覧。例: ["component+com.hoshiboshi.transform", "component+com.hoshiboshi.rigidBody"]。末尾 .* による名前空間 wildcard も指定できる。クライアントは非対応のコンポーネントが含まれる場合、接続前にワーニングまたは拒否できる。
json
{
  "name": "com.hoshiboshi.world.nocturne",
  "version": "1.0.0",
  "information": {
    "host": "worlds.example.com",
    "port": 443,
    "serverName": "worlds.example.com",
    "alpn": "com.hoshiboshi.world.nocturne/1",
    "assetKinds": ["com.hoshiboshi.asset.hoscene"],
    "componentKinds": [
      "component+com.hoshiboshi.transform",
      "component+com.hoshiboshi.staticMesh",
      "component+com.hoshiboshi.collider",
      "component+com.hoshiboshi.spawnPoint",
      "component+com.hoshiboshi.camera",
      "component+dev.yoking.specialComponents.*"
    ]
  }
}

componentKinds の wildcard は、文字列末尾の .* のみを特別扱いします。component+dev.yoking.specialComponents.* は、component+dev.yoking.specialComponents. で始まるすべての具体コンポーネント ID を表します。* を文字列の途中に置く glob や正規表現は v1 ではサポートしません。

wildcard は対応範囲の宣言だけに使います。hoscenecomponents[].type など、実際に送られるコンポーネント ID は常に具体名にします。

インスタンス固有の information は、インスタンス解決時(me.virmesh.worldInstance.getInstance など)に返される worldProtocols に含まれます。クライアントは manifest 側の protocol 宣言を接続先として扱わず、インスタンス情報に含まれる information を接続に使います。

プレイヤー認証ハンドシェイク

Nocturne は「誰が接続してきたか」を、VirMesh の player identity(medi:player:ed25519:<public-key>)と紐づけて検証します。TLS のみではプレイヤー個人を識別できないため、QUIC 接続確立後にアプリケーションレイヤで認証ハンドシェイクを行います。

ハンドシェイクの流れ

text
[Client]                           [Server]
   | ---- QUIC + TLS 1.3 接続 -----> |
   | <---- 1. server_hello + nonce  |
   |                                 |
   | ---- 2. auth frame ------------>|
   |                                 |
   | <---- 3. auth_result ----------|
   |                                 |
   | ---- 4. 以降、ゲームストリーム ->|

このハンドシェイクで使うフレームはすべて CBOR でシリアライズされます。

Nocturne プレイヤー認証ハンドシェイク

図: QUIC + TLS 1.3 の接続確立後、server_hello の nonce と Canonical JSON 署名を使って player identity を検証し、成功後にライブ通信用ストリームへ進みます。

CBOR map の通常フィールド

認証ハンドシェイク、制御フレーム、イベント、アセット、プロフィール、avatar fetchGrant relay では、CBOR map の key はすべて string key です。フィールド名は Canonical JSON で使う名前と同じ camelCase を基本にします。

代表的な共通フィールドは以下です。

keyCBOR 型説明
typetext stringフレーム種別
kindtext stringevent / state の種別
requestIdtext stringPlayer Event と Server Event の RPC 相関 ID(任意)
statustext string応答の状態("ok" または "error"
errorCodetext stringstatus="error" のときのエラーコード
errorMessagetext stringエラーの人間向け説明(任意)
playerIdtext stringmedi:player:ed25519:...
sessionIdtext stringセッション識別子

byte string を持つフィールド(noncesignatureprofileEnvelopeprofileFingerprintavatarGrantSignaturechannelBindingToken など)は、CBOR 上では生バイト列です。署名対象の Canonical JSON に埋め込む場合だけ、対応する値を base64url 文字列へ変換します。

サーバーから nonce の発行

QUIC 接続確立後、クライアントは最初のクライアント初期化双方向ストリーム(ストリーム ID: 0)を開きます。サーバーはこのストリーム上で server_hello フレームを CBOR で送信します。

{
  "type": "server_hello",
  "nonce": h'<32-bytes-random>',
  "serverTime": 1770000000,
  "protocolVersion": "1.0.0"
}
  • "type" = "server_hello"
  • "nonce" = 32 バイトのランダム値(生バイト列
  • "serverTime" = サーバー側 Unix 秒。クライアントはクロックズレの参考とする
  • "protocolVersion" = 選択された Nocturne アプリケーションレイヤのバージョン文字列。不一致の場合クライアントは接続を閉じる

nonce は接続ごとに一意であり、再利用されないようにします。サーバーは発行した nonce を短時間(例: 60 秒)保持し、同じ nonce に対する二度目の認証リクエストを拒否します。

クライアントからの認証フレーム

クライアントは受け取った nonce、参加対象の worldIdinstanceId、選択したプロトコル、接続先の information、自分の playerId、自身の PlayerServer URI、現在時刻 issuedAt を組み合わせ、VirMesh の Canonical JSON 規則で正規化し、自分の Ed25519 秘密鍵で署名します。

署名対象の JSON 構造:

json
{
  "type": "com.hoshiboshi.world.nocturne.auth.v1",
  "worldId": "medi:world:ed25519:base64url-world-public-key",
  "instanceId": "inst_01HZY8K7N2Q4Y6Z8A0B1C2D3E4",
  "protocol": "com.hoshiboshi.world.nocturne",
  "protocolVersion": "1.0.0",
  "playerId": "medi:player:ed25519:base64url-public-key",
  "playerServer": "https://ps.example.com/",
  "nonce": "base64url-32bytes-random",
  "host": "worlds.example.com",
  "serverName": "worlds.example.com",
  "port": 443,
  "issuedAt": 1770000000
}

worldIdinstanceIdhostserverNameport を含めることで、この署名を特定のワールド・インスタンスと接続先に紐付けます(チャネルバインディング)。たとえ署名が漏洩しても、別のワールド、別のインスタンス、別の接続先に対して再利用できません。

署名対象の文字列は key-sort された空白なし Canonical JSON です:

text
{"host":"worlds.example.com","instanceId":"inst_01HZY8K7N2Q4Y6Z8A0B1C2D3E4","issuedAt":1770000000,"nonce":"base64url-32bytes-random","playerId":"medi:player:ed25519:base64url-public-key","playerServer":"https://ps.example.com/","port":443,"protocol":"com.hoshiboshi.world.nocturne","protocolVersion":"1.0.0","serverName":"worlds.example.com","type":"com.hoshiboshi.world.nocturne.auth.v1","worldId":"medi:world:ed25519:base64url-world-public-key"}

重要: 署名対象(署名の入力)は CBOR ではなく Canonical JSON です。CBOR はワイヤー形式(データの輸送手段)としてのみ使います。Canonical JSON 内の nonce は base64url エンコードした文字列にします。CBOR 上では nonce は生バイト列ですが、署名対象を作るときだけ base64url 文字列に変換して Canonical JSON に埋め込みます。

クライアントは以下の auth フレームを CBOR で同じストリームに送信します。

{
  "type": "auth",
  "worldId": "medi:world:ed25519:base64url-world-public-key",
  "instanceId": "inst_01HZY8K7N2Q4Y6Z8A0B1C2D3E4",
  "protocol": "com.hoshiboshi.world.nocturne",
  "protocolVersion": "1.0.0",
  "playerId": "medi:player:ed25519:base64url-public-key",
  "nonce": h'<32-bytes-nonce>',
  "host": "worlds.example.com",
  "serverName": "worlds.example.com",
  "port": 443,
  "playerServer": "https://ps.example.com/",
  "issuedAt": 1770000000,
  "signature": h'<64-bytes-ed25519-signature>'
}
  • "worldId" = 参加対象の world ID
  • "instanceId" = 参加対象の instance ID
  • "protocol" = "com.hoshiboshi.world.nocturne"
  • "protocolVersion" = 選択した Nocturne アプリケーションバージョン
  • "playerId" = medi:player:ed25519:... 形式の player ID
  • "nonce" = サーバーから受け取った nonce をそのまま(生バイト列)
  • "host" = 接続先ワールドサーバーのホスト名(information.host の値)
  • "serverName" = TLS SNI と証明書検証に使った名前(information.serverName の値)
  • "port" = 接続先 UDP ポート(information.port の値)
  • "playerServer" = プレイヤー自身の PlayerServer の正規 URL(https://ps.example.com/)。ワールドサーバーはこの URI を使ってプレイヤーのプロフィールやハンドル情報を VirMesh の action で解決できます
  • "issuedAt" = クライアントの現在 Unix 秒
  • "signature" = Ed25519 署名(生 64 バイト

サーバー側の検証

サーバーは以下を順に検証します。いずれかが失敗した場合、ストリームをエラーコード auth_failed で閉じ、接続を継続しないか、または接続ごと閉じます。

  1. nonce の一致: "nonce" が、この接続で発行した nonce とバイト列として一致すること。
  2. nonce の再利用チェック: その nonce が既に消費されていないこと。
  3. 接続先の一致: "host""serverName""port" が、この QUIC 接続で使われた information.hostinformation.serverNameinformation.port と一致すること。これにより、別サーバー向けの署名を拒否できます。
  4. 参加対象の一致: "worldId""instanceId" が、この接続で参加させようとしている world と instance に一致すること。
  5. プロトコルの一致: "protocol"com.hoshiboshi.world.nocturne であり、"protocolVersion" が negotiated version と一致すること。
  6. issuedAt の鮮度: "issuedAt" がサーバー時刻から ±60 秒以内であること。極端に未来・過去の値はリプレイの疑いで拒否します。
  7. playerId の形式: "playerId"medi:player:ed25519:<public-key> の形式であること。
  8. 署名の検証: "nonce" を base64url エンコードし、Canonical JSON { type, worldId, instanceId, protocol, protocolVersion, playerId, playerServer, nonce, host, serverName, port, issuedAt } を組み立て、"signature"(生 64 バイト)を Ed25519 署名として検証する。
  9. アカウントの有効性: 該当 playerId が無効化(tombstone)されていないこと。
  10. playerServer の形式: "playerServer" が有効な HTTPS URL であり、該当 playerId がその PlayerServer に所属していること。サーバーは必要に応じて "playerServer" を使って player の公開プロフィールを解決します。

認証結果

検証に成功した場合、サーバーは auth_result フレームを CBOR で返します。

{
  "type": "auth_result",
  "ok": true,
  "sessionId": "sess_01HZY8K7N2Q4Y6Z8A0B1C2D3E4",
  "playerId": "medi:player:ed25519:base64url-public-key"
}
  • "type" = "auth_result"
  • "ok" = true
  • "sessionId" = この QUIC コネクション内での player セッション識別子
  • "playerId" = 認証された player ID

sessionId は接続移行後も維持されます。

ワールド・サーバー証明書の検証

プレイヤー認証とは別に、TLS レイヤでは従来通りサーバー証明書を検証します。

  • information.serverName と証明書の Subject Alternative Name が一致することを確認します。
  • 証明書の有効期限を確認します。
  • 証明書の名前検証は Subject Alternative Name の dNSName または iPAddress を基準にします。Common Name へのフォールバックは行いません。
  • Nocturne は、インスタンス解決結果の worldProtocols[].information.host によって別 host のライブセッションエンドポイントへ接続できます。クライアントは、検証済み worldId に対して正当な WorldServer から返された instance 情報だけを接続先として使い、署名対象にはその information.hostinformation.serverNameinformation.port を含めます。

チャネルモデル

認証完了後、Nocturne は用途別の Channel で通信を分離します。Channel は公開仕様上の抽象であり、実装は QUIC stream、QUIC DATAGRAM、WebTransport Datagram、WebRTC、Native UDP、または platform voice SDK を選べます。

QUIC stream は byte stream なので順序保証と再送が必要なデータに向いています。DATAGRAM はメッセージ境界を持つ小さな独立パケットで、到達保証、順序保証、再送を前提にしません。移動情報、3D tracking、音声フレームのように、古い値より最新値が重要なデータに使います。

Nocturne v1 の規範的なセッションは、プレイヤー認証が完了した QUIC connection です。WebRTC、Native UDP、platform voice SDK など QUIC connection の外に出る transport は、後述の channel binding を完了した場合だけ、その Nocturne セッションに属します。

Nocturne ライブセッションのチャネルモデル

図: 認証後の 1 本の QUIC connection 上で、制御、イベント、latest-wins のリアルタイム状態、低優先度のアセット転送を別チャネルとして分離します。

Script RPC Channels

Reliable Script RPC Channel は、WASM Script 用の信頼配送 RPC です。状態変更、オブジェクト操作、決済的なワールドイベントなど、欠落すると結果が変わる呼び出しに使います。ペイロードは string key の CBOR map とし、順序保証と再送を持つ transport で送ります。v1 では Realtime compact payload schema の対象外です。将来の v1.x / v2 で Reliable RPC 用の compact payload profile を追加できるよう、type / kind / requestId などの envelope は string key のまま固定します。

Unreliable Script RPC Channel は、WASM Script 用の低遅延 RPC です。一時的なエフェクト、短命な通知、最新値だけが意味を持つ script signal に使います。QUIC DATAGRAM または WebTransport Datagram で実装でき、再送は行いません。WASM Script が高頻度 payload を送る場合は、後述の Realtime compact payload schema を使って payload field 名だけを connection-local な integer key に圧縮できます。RPC / signal の意味論は script 側が定義し、Nocturne は repeated payload field name の圧縮だけを標準化します。

Event Channels

Server Event Channel は server から player/client へ送るイベントです。入室、退出、kick、切断理由、サーバー停止、権限変更など、server 起点のライフサイクル通知を扱います。基本は Reliable とし、各メッセージは CBOR map の "type" にイベント種別を持ちます。

Player Event Channel は player/client から server へ送るイベントです。切断リクエスト、インタラクト操作、UI 起点のコマンド、明示的な意思表示を扱います。結果を伴うイベントは Reliable とし、頻繁な入力サンプルは Player Connection Channel に分離します。

Realtime Channels

Player Connection Channel は、移動、姿勢、3D tracking、avatar state などのリアルタイム状態を扱います。原則として Unreliable latest-wins とし、受信側は古い sequence の値を破棄して最新値を優先します。ペイロードは用途に応じて CBOR または小さな生バイナリ unit とします。

Voice Channel は VC 用の抽象チャネルです。Nocturne は voice session、player identity、mute、deaf、speaking state、spatial settings を統一管理し、実際の転送は WebRTC、WebTransport Datagram、Native UDP、または platform voice SDK に委譲できます。

WebRTC は jitter buffer、echo cancellation、noise suppression、congestion control、packet loss concealment、NAT traversal を備えているため、ブラウザや一般的な VC 品質では有力な transport です。一方で SDP/ICE/TURN/STUN などのシグナリングと、Nocturne 本体とは別の接続ライフサイクルを持つ可能性があります。Nocturne の公開 API では WebRTC を直接露出せず、Voice Channel の transport 実装として扱います。

チャネル割当: channel_open / channel_open_result

Server Event ChannelPlayer Event ChannelPlayer Connection Channel は、いずれも認証済み制御ストリーム(stream 0)上で channel_open / channel_open_result必ず交換してから利用を開始します。channel_open_result"ok": true を受領するまで、クライアントは新規ストリームの open も datagram の送信も行ってはいけません。サーバーは許可前に到着した datagram や stream を破棄してかまいません。

channel_open フレーム共通形:

{
  "type": "channel_open",
  "channelKind": "<channelKind>",
  "streamRole": "<streamRole>"
}

channel_open_result 共通形:

{
  "type": "channel_open_result",
  "ok": true,
  "channelKind": "<channelKind>",
  "streamRole": "<streamRole>",
  "sessionId": "sess_..."
}

検証に失敗した場合、サーバーは "ok": false"errorCode" を返し、必要に応じて "errorMessage" を添えます。

Player Event Channel(client → server unidirectional stream)

クライアントが stream 0 で送信:

{
  "type": "channel_open",
  "channelKind": "Player Event Channel",
  "streamRole": "player_event"
}

サーバーが stream 0 で返信:

{
  "type": "channel_open_result",
  "ok": true,
  "channelKind": "Player Event Channel",
  "streamRole": "player_event",
  "sessionId": "sess_01HZY8K7N2Q4Y6Z8A0B1C2D3E4"
}

許可後、クライアントは client-initiated 単方向ストリーム を 1 本 open し、ストリームの先頭にチャネル種別を宣言する channel_open_ack ヘッダを CBOR で書いてから、Player Event を逆多重化して流します。

{
  "type": "channel_open_ack",
  "channelKind": "Player Event Channel",
  "streamRole": "player_event"
}

channel_open_ack の後は、length || cbor(player_event) を順次 append します。

Server Event Channel(server → client unidirectional stream)

クライアントが stream 0 で送信:

{
  "type": "channel_open",
  "channelKind": "Server Event Channel",
  "streamRole": "server_event"
}

サーバーが stream 0 で返信:

{
  "type": "channel_open_result",
  "ok": true,
  "channelKind": "Server Event Channel",
  "streamRole": "server_event",
  "sessionId": "sess_01HZY8K7N2Q4Y6Z8A0B1C2D3E4"
}

許可後、サーバーは server-initiated 単方向ストリーム を 1 本 open し、ストリームの先頭に同様の channel_open_ack ヘッダ("channelKind": "Server Event Channel""streamRole": "server_event")を書いてから、Server Event を逆多重化して流します。

Player Connection Channel(QUIC DATAGRAM)

クライアントが stream 0 で送信:

{
  "type": "channel_open",
  "channelKind": "Player Connection Channel",
  "streamRole": "player_connection"
}

サーバーが stream 0 で返信:

{
  "type": "channel_open_result",
  "ok": true,
  "channelKind": "Player Connection Channel",
  "streamRole": "player_connection",
  "sessionId": "sess_01HZY8K7N2Q4Y6Z8A0B1C2D3E4"
}

このチャネルは新規ストリームを開かず、channel_open_result で許可された後、双方向で QUIC DATAGRAM を送受信できるようになります。hot path の datagram は local key 1(frameFormat)で verbose / compact / typed binary を識別します(後述「Player Connection Channel の datagram スキーマ」を参照)。

Server Event Channel の event スキーマ

長生き unidirectional stream 上に length || cbor(map) を逆多重化します。各 event の最小形:

{
  "type": "server_event",
  "kind": "<kind>",
  "sentAt": 1770000123456,
  // kind 固有のフィールドが続く
  // 必要に応じて "requestId" を反射する
}

主要 kind(v1 コア):

kind説明主要フィールド
presence.joinedプレイヤー入室playerId、拡張で profile snapshot
presence.leftプレイヤー退出playerIdstate に reason map
session.kickkick 通知state{reason, code}
session.disconnect切断理由errorCodeerrorMessage
session.shutdownサーバー停止予告expiresAt(停止予定時刻)
auth.permissionChanged権限変更state に新しい capability 集合

応答系(Player Event の結果通知)は kindrpc.result とし、requestIdstatus を必ず含めます。

{
  "type": "server_event",
  "kind": "rpc.result",
  "requestId": "<requestId>",
  "status": "ok",
  "state": { /* result payload */ }
}

エラー応答:

{
  "type": "server_event",
  "kind": "rpc.result",
  "requestId": "<requestId>",
  "status": "error",
  "errorCode": "<errorCode>",
  "errorMessage": "<errorMessage>",
  "state": { /* optional details */ }
}

未知の kind を受け取ったクライアントは、その event を無視するか、debug ログに残してかまいません。critical な意味を持つ拡張 kind は、別仕様で明示的な negotiation を定義します(CBOR 上の string extension key と同じ扱い)。

Player Event Channel の event スキーマ

クライアント → サーバーの長生き unidirectional stream 上に length || cbor(map) を逆多重化します。

{
  "type": "player_event",
  "kind": "<kind>",
  "requestId": "<requestId>",
  "sentAt": 1770000123456,
  "state": { /* kind-specific payload */ }
}

"requestId" は任意です。値を含む player_event はサーバーが server_eventrpc.result を同じ requestId で返すことを期待し、含まない player_event は fire-and-forget として扱われます。

主要 kind(v1 コア):

kind説明主要フィールド
session.disconnect自発的切断要求state{reason}
interact.invokeobject へのインタラクト操作state{objectId, action, args}
ui.commandUI 起点のコマンドstate{command, args}
intent.signal明示的な意思表示(emote 等)state{signal}

サーバーは未知の kind を受け取った場合、rpc.resultstatus="error"errorCode="unknown_kind" で返すか、requestId がなければ無視します。

Realtime compact payload schema

Realtime compact payload schema は、string key の verbose payload を意味定義として持ち、wire では schema-scoped integer key で送るための仕組みです。v1 では Player Connection ChannelUnreliable Script RPC Channel にだけ適用します。Server Event ChannelPlayer Event ChannelReliable Script RPC ChannelVoice Channel の media frame、Asset Channel では使いません。

schema は、対象 channel が許可された後、認証済み control stream(stream 0)上の realtime_schema_define で定義します。schemaKey は 1 つの QUIC connection と 1 つの channelKind の中だけで有効な unsigned int です。schemaId や hash 文字列を realtime datagram に毎回載せてはいけません。schema cache が将来必要になった場合も、識別子は realtime_schema_define 側の任意フィールドとして追加します。

realtime_schema_define は、複数 schema を schemas[] にまとめて送れます。接続直後はよく使う schema を batch で送り、後から script が必要にした schema は小さな追加 batch で送るのを推奨します。単一 schema だけを定義したい場合も、schemas[] に 1 要素を入れます。

{
  "type": "realtime_schema_define",
  "channelKind": "Player Connection Channel",
  "schemas": [
    {
      "schemaKey": 1,
      "kind": "transform",
      "encoding": "cbor-int-map",
      "fields": [
        { "key": 1, "path": ["position", "x"], "type": "float32", "required": true },
        { "key": 2, "path": ["position", "y"], "type": "float32", "required": true },
        { "key": 3, "path": ["position", "z"], "type": "float32", "required": true },
        { "key": 20, "path": ["dev.yoking.surfaceKind"], "type": "text", "required": false }
      ]
    },
    {
      "schemaKey": 2,
      "kind": "tracking.body",
      "encoding": "typed-binary",
      "binaryLayout": "com.hoshiboshi.tracking.body.v1"
    }
  ]
}

fields[].path は verbose payload 内の field path です。["position", "x"]{"position":{"x":...}} を表し、["dev.yoking.surfaceKind"] のように namespaced extension field を 1 つの path segment として表してもかまいません。fields[].type は CBOR 値の型を表し、v1 では booluintintfloat32float64textbytesarraymap を使います。encoding="typed-binary" の schema は fields の代わりに binaryLayout を持ち、datagram 側では同じ schemaKey で binary layout と kind を解決します。

verbose payload では、receiver が理解しない optional field は無視できます。compact payload では、schema に定義されていない integer key を送ってはいけません。critical な extension field が必要な場合は、その field を含む schema を negotiation し、ack 後に compact frame を使います。

receiver は schema を受理した場合、同じ control stream 上で realtime_schema_ack を返します。ack は batch 単位で返し、受理した schema を accepted、拒否した schema を rejected に分けます。一部だけ rejected されても、accepted に入った schema は使用できます。

{
  "type": "realtime_schema_ack",
  "channelKind": "Player Connection Channel",
  "accepted": [1, 2],
  "rejected": [
    { "schemaKey": 3, "errorCode": "unsupported_encoding" }
  ]
}

v1 の schema は WorldServer が割り当てる server-authoritative な定義です。client-originated realtime data も、サーバーがその connection に対して schema を定義し、receiver が ack した後でだけ compact payload を送れます。WorldServer が client 間で realtime data を relay する場合、送信元 connection の schemaKey をそのまま転送せず、受信先 connection の schemaKey に変換します。

Unreliable Script RPC Channel で使う場合は、channelKind"Unreliable Script RPC Channel" とし、kind には script が定義する signal / RPC kind を入れます。Nocturne は compact payload の field 名圧縮だけを扱い、script の実行順序、重複処理、欠落時の意味、RPC result の有無は script runtime 側の profile が定義します。Reliable Script RPC で同じ仕組みが必要になった場合は、v1.x / v2 で別の compact payload profile として追加します。

realtime_schema_define は認証後の制御フレーム上限(既定 64 KiB)に収めます。収まらない場合は複数の batch に分割します。schema 間に依存関係がある場合は同じ batch に入れるか、先に必要な schema の ack を受けてから後続 batch を送ります。

Player Connection Channel の datagram スキーマ

QUIC DATAGRAM(双方向、unreliable、再送なし)。1 つの datagram に 1 unit を詰めます。受信側は同一の (playerId, kind) ペアで seq が古い値を破棄し、最新値を優先します(latest-wins)。

verbose state(意味定義)

verbose state は、すべてを string key で表す意味上の基準形です。schema なしで読めるため、debug、fallback、仕様説明に使います。

{
  "type": "player_connection",
  "kind": "transform",
  "seq": 4711,
  "state": {
    "position": { "x": 1.2, "y": 3.4, "z": 5.6 },
    "dev.yoking.surfaceKind": "ice"
  },
  "sentAt": 1770000123456
}

compact realtime frame(hot path)

高頻度の datagram では、Realtime compact payload schema と組み合わせた local key 形式を使えます。

local keyフィールドCBOR 型説明
1frameFormatunsigned int0 = verbose、1 = compact、2 = typed binary
2kind / schemaKeytext string または unsigned intverbose では kind、compact / typed binary では schemaKey
3sequnsigned intlatest-wins 比較用 sequence
4state / compactPayload / binarySizemap または unsigned intverbose では string-key state、compact では int-key payload、typed binary では後続 raw binary のバイト長
5sentAtunsigned int送信時刻の Unix milli(任意、観測用)
6playerIdtext stringserver → client の場合のみ必須

verbose payload を local frame で送る場合:

{
  1: 0,
  2: "transform",
  3: 4711,
  4: {
    "position": { "x": 1.2, "y": 3.4, "z": 5.6 },
    "dev.yoking.surfaceKind": "ice"
  },
  5: 1770000123456,
  6: "medi:player:ed25519:<public-key>"
}

compact payload を送る場合:

{
  1: 1,
  2: 1,
  3: 4711,
  4: { 1: 1.2, 2: 3.4, 3: 5.6, 20: "ice" },
  5: 1770000123456,
  6: "medi:player:ed25519:<public-key>"
}
  • local key 6(playerId): server → client の場合のみ必須。client → server では送信元が QUIC connection の認証済みプレイヤーに固定されるため省略します。
  • local key 3(seq): 同一の (playerId, kind) について 単調増加 する unsigned int。compact frame では schemaKey から kind を解決して比較します。サイクルを跨ぐ実装では、ラップアラウンドの扱いを別仕様で定義します。
  • local key 4: verbose では kind 固有の string-key state、compact では schema で定義された integer-key payload です。

主要 kind(v1 コア):

kind内容
transform位置・回転・スケール
velocity線速度・角速度
pose.head頭の姿勢
pose.hand.left / pose.hand.right手の姿勢
avatar.stateavatar 状態 flag 群

typed binary モード(特定 kind のみ v1 で許可)

3D tracking のように帯域を支配する kind では、CBOR header と raw binary unit を 1 datagram に並べた形を使えます。

[length-prefixed CBOR header][raw binary unit]

CBOR ヘッダ:

{
  1: 2,
  2: 2,
  3: 4712,
  4: 96,
  6: "medi:player:ed25519:<public-key>"
}
  • local key 2(schemaKey)から kind と binaryLayout を解決します。typed binary の hot path datagram に "tracking.body" のような kind 文字列を毎回載せてはいけません。
  • local key 4(binarySize)は、後続 raw binary のバイト長を表します。受信側はヘッダ直後から正確に local key 4 バイト分を読み、それ以降のバイトがあれば破棄します。
  • 後続 raw binary のレイアウトは schema の binaryLayout で固定します。v1 コアでは tracking.body のみ将来の付録で固定する予定で、それ以外の kind は verbose state または compact payload を使用してください。
  • ヘッダ部のサイズは 256 バイト以下を目安とし、ヘッダ + raw binary の合計が QUIC max_datagram_frame_size を超えないようにします。

Player Connection Channel の購読モデル

Player Connection Channelchannel_open した直後の既定状態は、同一 instance 内の全プレイヤー × 全 kind の datagram を購読です。クライアントは subscribe.update または subscribe.replacePlayer Event Channel 上で送って、購読範囲を絞り込めます。

サーバーは購読が確定した直後、以下 2 段階で初期同期します。

  1. initial_state.players[].connectionStates[] に、各プレイヤーの verbose state 最新値を埋め込んで送ります。クライアントはレンダリング前にここから他プレイヤーの初期表示を構築できます。
  2. Player Connection Channelchannel_open_result.ok=true を返した直後、サーバーは購読範囲に含まれる各 (playerId, kind) について、保持している最新値を 1 回ずつ verbose または compact datagram で送ります(初回 push)。typed binary モード(tracking.body 等)の最新値は通常の datagram 配信に任せ、初回 push には含めません。

subscribe.update / subscribe.replace(Player Event)

帯域や CPU を抑えたいクライアントは、Player Event Channel 上で subscribe.update(差分更新)または subscribe.replace(全置換)を送って、購読範囲を変更します。

{
  "type": "player_event",
  "kind": "subscribe.update",
  "requestId": "<requestId>",
  "unsubscribePlayerIds": ["medi:player:ed25519:player_xxx"],
  "unsubscribeKinds": ["avatar.state"]
}
{
  "type": "player_event",
  "kind": "subscribe.replace",
  "requestId": "<requestId>",
  "subscribePlayerIds": [],
  "subscribeKinds": ["transform", "pose.*"]
}
kind動作
subscribe.updatesubscribePlayerIds / subscribeKinds で追加購読、unsubscribePlayerIds / unsubscribeKinds で購読解除。同一フレームで両方指定可能。
subscribe.replacesubscribePlayerIds / subscribeKinds の組で表される範囲に全置換。指定外の購読はすべて停止。
  • subscribePlayerIds / unsubscribePlayerIds の要素は medi:player:ed25519:... 形式の playerId。
  • subscribeKinds / unsubscribeKinds の要素は "transform" のような具体 kind、"pose.*" のような末尾 wildcard、特別形 "*" のいずれか。* を文字列の途中に置く glob は v1 では受け付けません。
  • subscribePlayerIds / subscribeKinds / unsubscribePlayerIds / unsubscribeKinds はいずれも省略時は「指定なし」として扱います。subscribe.replace の場合、省略軸は「該当軸はワイルドカード」を意味し、subscribe.update の場合は「該当軸の更新なし」を意味します。

サーバーは受信後、Server Event Channelrpc.resultrequestId 付きで返します。subscribe.replace で範囲が広がった結果、新たに購読対象になった (playerId, kind) については、初回 push と同じ規則で最新値を 1 回ずつ送ります。狭まった範囲については、追加 datagram の送信を停止するだけで明示通知は送りません。

既定値と上限

  • 既定の購読範囲: 同一 instance の全 playerId × 全 kind。
  • subscribe.update / subscribe.replace を一度も送らないクライアントは、上記の既定で動作します。
  • 1 接続あたりの購読対象 playerId 数 / kind 数の上限は実装定義です。サーバーは制限を超える subscribe を errorCode="subscribe_limit_exceeded" で拒否してかまいません。

ワールドオブジェクト同期の扱い

ワールドオブジェクト(hoscene シーン上の object)の動的状態を実時間で同期する仕組みは、Nocturne v1 の Realtime / Event Channel では標準化しません。そうした同期が必要な場合は、Reliable Script RPC Channel または Unreliable Script RPC Channel 上で WASM Script 側が個別に定義します。高頻度の script signal では、Unreliable Script RPC Channel 上で Realtime compact payload schema を使えます。

信頼性とサイズの上限

  • Server / Player Event は length || cbor(payload) 形式で、CBOR payload は 64 KiB 以下(既存の制御フレーム上限と同一)。
  • Player Connection の 1 datagram は QUIC max_datagram_frame_size の範囲内で、実用上 1200 バイト以下を目安とします。typed binary モードの CBOR ヘッダ部は 256 バイト以下。
  • Unreliable Script RPC の 1 datagram も QUIC max_datagram_frame_size の範囲内とし、Realtime compact payload schema を使う場合も schema 定義自体は control stream で送ります。
  • 長生き unidirectional stream の event 同士の順序は QUIC が担保します。順序が必要な event は同一ストリームに順次書きます。

チャネルの終了と再オープン

  • クライアントが Player Event Channel を閉じる場合、自身の uni stream に length || cbor({"type":"channel_close","channelKind":"Player Event Channel"}) を書いてから stream を finish します。
  • サーバーが Server Event Channel を閉じる場合も対称に、自身の uni stream に channel_close を書いて finish します。
  • Player Connection Channel には終端マーカーが乗る stream がないため、終了は stream 0 上で length || cbor({"type":"channel_close","channelKind":"Player Connection Channel"}) を送って宣言します。channel_close 受領後に到着した datagram は破棄してかまいません。
  • 親 QUIC connection が終了した場合は、すべての channel と外部 binding が無効化されます(後述「外部 transport の channel binding」と同じ扱い)。
  • 一度閉じたチャネルを同じ QUIC connection で再開する場合は、再度 channel_open から開始します。

外部 transport の channel binding

QUIC connection 外の transport を使う Channel は、認証済み control stream 上で channel_bind_request / channel_bind_result を交換してから開始します。

{
  "type": "channel_bind_request",
  "channelKind": "Voice Channel",
  "transport": "webrtc"
}
{
  "type": "channel_bind_result",
  "ok": true,
  "sessionId": "sess_01HZY8K7N2Q4Y6Z8A0B1C2D3E4",
  "channelKind": "Voice Channel",
  "transport": "webrtc",
  "channelBindingToken": h'<opaque-channel-binding-token>',
  "expiresAt": 1770000060
}
  • "channelKind" = 紐付ける Channel 種別。例: "Voice Channel""Player Connection Channel"
  • "transport" = 実 transport 種別。例: "webrtc""native_udp""platform_voice_sdk"
  • "channelBindingToken" = サーバーが発行する短命 opaque bearer token。
  • "expiresAt" = token の有効期限。

外部 transport の最初の認証メッセージ、または platform SDK 側の session metadata には、channelBindingToken を含めます。CBOR 以外の signaling に載せる場合は、token を base64url 文字列に変換します。サーバーは token が未使用または有効期限内であり、sessionIdplayerIdchannelKindtransport と一致することを確認してから、その transport を Nocturne セッションに紐付けます。

親 Nocturne QUIC session が終了した場合、kick された場合、または token が失効した場合、紐付いた外部 transport も無効化します。channelBindingToken は TLS、DTLS、SRTP など暗号化済みの経路でだけ送信します。Native UDP を使う場合は、token 検証後の packet も暗号化するか、transport profile が定義する署名付き envelope に入れる必要があります。

プロフィール解決と配信

Nocturne セッションでは、各プレイヤーの 公開プロフィール(handle、displayName、profile card など)を WorldServer がプロキシキャッシュして他プレイヤーに配信します。プレイヤー自身が自分の PlayerServer に me.virmesh.player.resolveProfile を打って取得する代わりに、WorldServer が一括して取得・キャッシュ・配信することで、参加プレイヤー数 N に対する PlayerServer リクエストを一回に圧縮します。

WorldServer のプロフィール解決

WorldServer は、auth ハンドシェイクが成功したプレイヤーごとに me.virmesh.player.resolveProfile を呼び、返り値の Canonical JSON 全体を内部キャッシュに保持します。

  • 解決先は auth フレームの "playerServer" です。SSRF 防止のため、auth 検証時と同じ制限(https のみ、private address 拒否、redirect 制限)を適用します。
  • 解決はアセットダウンロードと並行に実行されます。クライアントは初期同期フェーズの一部として、アセット転送が進む間にプロフィール解決の完了を待ちます。
  • いずれかのプロフィール解決に最終的に失敗した場合、WorldServer はその接続を session.disconnect(後述)で閉じます。errorCodeprofile_unavailable を使います。一時的なエラーへの retry を実装するかは WorldServer 側の自由です(推奨は短いバックオフでの再試行)。

キャッシュの保持と TTL

  • WorldServer は、解決済み envelope を playerId 単位でキャッシュします。
  • キャッシュ TTL は WorldServer の運用ポリシーに従います。推奨は 5 分から 1 時間程度ですが、参加規模や PlayerServer の負荷耐性に応じてサーバーが自由に決定してかまいません。resolveProfile を毎回呼ぶ実装も許容されます。
  • 同一 instance 内で同一 playerId のキャッシュは共有してかまいません。
  • TTL 経過、または明示の profile.refresh(後述)を受けたとき、WorldServer は resolveProfile を再実行し、内容に変化があれば購読者全員に presence.profileUpdated を送ります。
  • 内容の変化検出には profileFingerprint(SHA-256)を使うのが推奨です。fingerprint が一致した場合は再配信を省略してかまいません。

Envelope の中継形式

WorldServer は resolveProfile の返り値を、Canonical JSON のバイト列のまま "profileEnvelope" に格納して中継します。CBOR 経由で再シリアライズすると、内部の handle.record.signaturemodules.*.signature の検証対象バイト列が壊れるため、これは厳守してください。

受信側クライアントは、"profileEnvelope" のバイト列を Canonical JSON としてパースし、payload.handle.recordpayload.modules.*.payload のそれぞれの個別署名を、対応する player 鍵で検証します。検証に失敗したプロフィール部分はそのまま使わず、playerId だけで仮表示してください。

配信タイミング

プロフィールは以下の経路で他プレイヤーに届きます。

  1. initial_state.players[]: 新規参加クライアントは、入室時点で WorldServer がキャッシュしている全プレイヤーのプロフィールを players[] の各要素として一括受信します。
  2. presence.joined server_event: 既参加クライアントは、新しいプレイヤーが入室するたびに、その人のプロフィールつき presence event を受信します。
  3. presence.profileUpdated server_event: WorldServer が後からプロフィールを取得し直し、内容に変化があったときに発火します。

presence.joined のフレーム例:

{
  "type": "server_event",
  "kind": "presence.joined",
  "sentAt": 1770000123456,
  "playerId": "medi:player:ed25519:base64url-public-key",
  "playerServer": "https://ps.example.com/",
  "issuedAt": 1770000010,
  "profileEnvelope": h'<canonical-json-envelope-bytes>',
  "profileFetchedAt": 1770000020,
  "profileFingerprint": h'<32-byte-fingerprint>'
}

presence.profileUpdated のフレーム例:

{
  "type": "server_event",
  "kind": "presence.profileUpdated",
  "sentAt": 1770000223456,
  "playerId": "medi:player:ed25519:base64url-public-key",
  "profileEnvelope": h'<canonical-json-envelope-bytes>',
  "profileFetchedAt": 1770000220,
  "profileFingerprint": h'<new-32-byte-fingerprint>'
}

profile.refresh(Player Event)

クライアントは自分自身のプロフィールを WorldServer に再取得させたいとき、profile.refreshPlayer Event Channel に送ります。WorldServer は対応する resolveProfile を即時呼び出し、結果に変化があれば購読者全員に presence.profileUpdated を配信します。

{
  "type": "player_event",
  "kind": "profile.refresh",
  "requestId": "<requestId>"
}

サーバーは rpc.resultrequestId 付きで返します(status="ok" または status="error" + errorCode)。他プレイヤーの強制 refresh は v1 では認めません。

詳細プロフィールへの拡張

WorldServer 経由で配信するのは me.virmesh.player.resolveProfile の返り値そのものに限ります。それ以上の詳細(friends、follows、collections、長文 bio など)が必要な場合、各クライアントが直接該当プレイヤーの PlayerServer に対応する VirMesh Action を呼んで取得します。WorldServer は中継しません。

アバターの fetchGrant 中継

private avatar の取得には、wearer が発行する短命 avatarFetchGrantobj+me.virmesh.avatar.fetchGrant)が必要です。Nocturne では、viewer ↔ wearer 間の grant 交換を WorldServer がrelayします。WorldServer は payload 内容を改変せず、同一 instance 内の参加確認とレート制御だけを担います。

4 つの relay フレーム

mermaid
sequenceDiagram
    participant V as Viewer Client
    participant WS as WorldServer
    participant W as Wearer Client
    participant AS as AvatarServer

    V->>WS: player_event<br/>kind: "avatar.fetchGrant.request"
    WS->>WS: V が同 instance 内か検証
    WS->>WS: cache / rate limit 確認
    WS->>W: server_event<br/>kind: "avatar.fetchGrant.requested"
    W->>W: 自動署名(auto-issue)
    W->>WS: player_event<br/>kind: "avatar.fetchGrant.issue"
    WS->>WS: 短期キャッシュに保存
    WS->>V: server_event<br/>kind: "avatar.fetchGrant.issued"
    V->>AS: resolveAvatar(avatarId, fetchGrant)
    AS-->>V: signed manifest

avatar.fetchGrant.request(Player Event、viewer → server)

{
  "type": "player_event",
  "kind": "avatar.fetchGrant.request",
  "requestId": "<requestId>",
  "wearerId": "medi:player:ed25519:wearer-public-key",
  "avatarId": "medi:avatar:ed25519:avatar-public-key",
  "versionId": "2026-04-11T00:00:00Z",
  "hash": "sha256:base64url-hash"
}
  • "wearerId" 必須。viewer は同一 instance 内の wearer のみを指定できます。
  • "avatarId" は推奨。省略時は wearer が現在着用中の avatarReference.avatarId を使います。
  • "versionId""hash" は同時指定不可。両方省略時は wearer が現在着用中の avatarReferenceversionId または hash を使います。

WorldServer の責務:

  • viewer が当該 instance に参加中であることを確認する。
  • 同一 (viewer, wearer, avatarId, versionId/hash) 組み合わせの avatar.fetchGrant.issued がキャッシュ内に存在し、expiresAt 未満なら、wearer に中継せず即座にキャッシュ済み grant を viewer に返す。
  • avatar.md 規定どおり、grant は発行から 60 秒程度の短い許容時間しか持たないため、キャッシュも同等の TTL を超えてはいけません。
  • 同一 (viewer, wearer) ペアごとのレート制限を実装します。短時間に過剰な request を出した viewer に対しては errorCode="avatar_grant_rate_limited" を返します。

avatar.fetchGrant.requested(Server Event、server → wearer)

WorldServer が wearer に対して中継するフレーム。

{
  "type": "server_event",
  "kind": "avatar.fetchGrant.requested",
  "requestId": "<requestId>",
  "viewerId": "medi:player:ed25519:viewer-public-key",
  "avatarId": "medi:avatar:ed25519:avatar-public-key",
  "versionId": "2026-04-11T00:00:00Z",
  "hash": "sha256:base64url-hash"
}

"viewerId" 必須。avatarIdversionIdhash は viewer 要求から WorldServer が補完済みの値を載せます。WorldServer 側で値が解決できなければ、wearer に中継せず viewer に errorCode="avatar_grant_invalid_target" を返します。

avatar.fetchGrant.issue(Player Event、wearer → server)

wearer client は受信後、自動的に obj+me.virmesh.avatar.fetchGrant 形式の payload を構築し、本人 Ed25519 鍵で Canonical JSON 署名を付けて返します。

{
  "type": "player_event",
  "kind": "avatar.fetchGrant.issue",
  "requestId": "<requestId>",
  "avatarGrantPayload": {
    "avatarId": "medi:avatar:ed25519:avatar-public-key",
    "versionId": "2026-04-11T00:00:00Z",
    "wearerId": "medi:player:ed25519:wearer-public-key",
    "viewerId": "medi:player:ed25519:viewer-public-key",
    "issuedAt": 1770000000
  },
  "avatarGrantSignature": h'<64-bytes-ed25519-signature>'
}
  • "avatarGrantPayload" は CBOR map で表しますが、署名対象は Canonical JSON です。wearer client は payload を Canonical JSON 化し、それに対して Ed25519 署名を行ってから、"avatarGrantPayload" に CBOR map として、"avatarGrantSignature" に生 64 バイトとして格納します(auth フレームと同じ思想)。
  • wearer client は同一 instance 内の viewer 要求であれば自動的に issue します。ignore / block / friends-only などのフィルタは wearer client 内部のポリシーで処理し、Nocturne 仕様には現れません。

wearer が avatar.fetchGrant.issue を返さなかった、または接続が落ちた場合、WorldServer は viewer に対して errorCode="avatar_grant_unavailable" を返します。WorldServer は avatar.fetchGrant.requested 送信から 10 秒以内に wearer の応答が来なければ timeout 扱いとし、errorCode="avatar_grant_timeout" を返してかまいません。

avatar.fetchGrant.issued(Server Event、server → viewer)

WorldServer が viewer に grant を中継するフレーム。

{
  "type": "server_event",
  "kind": "avatar.fetchGrant.issued",
  "requestId": "<requestId>",
  "status": "ok",
  "wearerId": "medi:player:ed25519:wearer-public-key",
  "avatarGrantPayload": { /* same payload as issue */ },
  "avatarGrantSignature": h'<64-bytes-ed25519-signature>'
}

viewer は payloadsignature を組み合わせて me.virmesh.avatar.resolveAvatarquery.fetchGrant に添え、AvatarServer から manifest を解決します。

エラーコード一覧

errorCode意味
avatar_grant_invalid_targetviewer が要求した wearer が同 instance 内に居ない、avatarId などの値が解決できない
avatar_grant_rate_limited同 viewer / wearer ペアのレート制限超過
avatar_grant_timeoutwearer が制限時間内に issue を返さなかった
avatar_grant_unavailablewearer 接続が切れた、または明示的に応答できなかった

これらはいずれも Server Event Channelrpc.result(kind = "rpc.result")として requestId 付きで viewer に返します。grant が成功した場合は、rpc.result ではなく avatar.fetchGrant.issued として grant を直接配信します。

信頼性と優先度の分離

Channel主な用途Transport 候補信頼性ペイロード形式
Reliable Script RPC ChannelWASM Script の状態変更 RPCQUIC bidirectional streamReliable orderedstring-key CBOR
Unreliable Script RPC ChannelWASM Script の短命な signalQUIC DATAGRAM / WebTransport DatagramUnreliable latest-winsstring-key CBOR または Realtime compact payload
Server Event Channel入室、退出、kick、切断理由、サーバー停止QUIC server-initiated unidirectional stream(要 channel_openReliable orderedCBOR
Player Event Channel切断リクエスト、操作コマンド、意思表示QUIC client-initiated unidirectional stream(要 channel_openReliable orderedCBOR
Player Connection Channel移動、姿勢、3D tracking、avatar stateQUIC DATAGRAM(要 channel_open、verbose / compact / typed binary 混在) / WebTransport Datagram / Native UDP(binding 後)Unreliable latest-winsstring-key CBOR、Realtime compact payload、または生バイナリ
Voice ChannelVC、空間音声、speaking stateWebRTC / WebTransport Datagram / Native UDP / platform voice SDK(binding 後)Media realtimeOpus 等の音声フレーム
Asset Channelアセット・バイナリ転送独立した QUIC stream または CDN URLReliable生バイナリ

ワールドアセット交換

ワールドのテクスチャ、モデル、音声、WASM などの静的データ(以下「アセット」)は、クライアントがワールドに参加する前後に取得し、ローカルキャッシュに保持します。Nocturne では、アセットのやり取りを QUIC コネクション上で完結させることも、CDN URL に委譲することもできます。

アセット発見と参加プレイヤー snapshot(Push 型)

認証完了後、サーバーが送信する initial_state には、現在のワールドで必要な全アセットの一覧と、現在参加中のプレイヤー snapshot が含まれます。クライアントは明示的に問い合わせることなく、これらを最初に受け取ります。

{
  "type": "initial_state",
  "assets": [
    {
      "assetKind": "com.hoshiboshi.asset.hoscene",
      "assetHash": "sha256:4b2c...",
      "assetSize": 18432012,
      "contentType": "application/hoscene",
      "assetUrl": "https://cdn.example.com/worlds/world_123/v1/world.hoscene"
    },
    {
      "assetKind": "com.hoshiboshi.asset.hoscene",
      "assetHash": "sha256:8fb1...",
      "assetSize": 2480000,
      "contentType": "application/hoscene"
    }
  ],
  "players": [
    {
      "playerId": "medi:player:ed25519:base64url-public-key",
      "playerServer": "https://ps.example.com/",
      "issuedAt": 1770000010,
      "profileEnvelope": h'<canonical-json-envelope-bytes>',
      "profileFetchedAt": 1770000020,
      "profileFingerprint": h'<32-byte-fingerprint>',
      "connectionStates": [
        {
          "type": "player_connection",
          "kind": "transform",
          "seq": 4711,
          "state": {
            "position": { "x": 1.2, "y": 3.4, "z": 5.6 }
          },
          "sentAt": 1770000123456
        },
        {
          "type": "player_connection",
          "kind": "avatar.state",
          "seq": 88,
          "state": { /* latest avatar.state payload */ }
        }
      ]
    }
  ]
}

"assets""players"initial_state のコアフィールドです。各要素は string key で拡張できます。initial_state 全体も string extension key を受け付けます。connectionStates[] の各要素は、Player Connection の verbose state と同じ string-key schema を使います。

com.hoshiboshi.asset.hoscene.hoscene パッケージ全体の assetKind です。配布時の MIME 型は application/hoscene です。GLB は hoscene パッケージ内部の resource として model/gltf-binary の MIME 型を持つことがありますが、Nocturne のトップレベル asset entry では assetKindcontentType をパッケージ全体に対して記載します。

各アセットエントリのフィールド:

key必須説明
assetKindはいアセット形式識別子
assetHashはいSHA-256 ハッシュ("sha256:base64url"
assetSizeはいバイトサイズ
contentTypeはいMIME 型
assetUrlいいえCDN URL。省略時は QUIC 経由で取得
string keyいいえアセット形式ごとの拡張フィールド

各プレイヤー snapshot エントリ(players[] 要素)のフィールド:

key必須説明
playerIdはい参加中プレイヤーの ID
playerServerいいえ該当プレイヤーが auth で申告した PlayerServer URI
issuedAtいいえこのプレイヤーが認証成功した Unix 秒
profileEnvelopeいいえWorldServer がキャッシュしている me.virmesh.player.resolveProfile の Canonical JSON 返り値の生バイト
profileFetchedAtいいえWorldServer がプロフィールを取得した Unix 秒
profileFingerprintいいえprofileEnvelope の SHA-256(差分検出用)
connectionStatesいいえPlayer Connection Channel で保持されている最新 verbose state の集合

connectionStates[] の各要素は、type="player_connection"kindseqstate、任意の sentAt を持つ verbose state です。compact payload と typed binary の最新値は initial_state には含めません。tracking.body のような大きな状態は接続後の datagram 配信に任せ、軽量な定常状態(transformavatar.state など)だけを snapshot に載せます。

サーバーは未購読 kind や、まだ受信していない kind について connectionStates[] を省略してかまいません。profileEnvelope も、WorldServer が当該プレイヤーのプロフィールを取得していない場合(取得遅延中、後続の presence.profileUpdated で配信予定)は省略できます。受信側クライアントは欠落フィールドを「未取得」として扱い、playerId だけで仮表示してかまいません。

取得フロー

クライアントは initial_state のアセット一覧を受け取った後、各アセットのハッシュをローカルキャッシュと照合します。キャッシュヒットしたアセットは再取得しません。

キャッシュミス時の取得:

Client                              Server
   |                                  |
   | -- control stream (stream 0):   |
   |    asset_request {                |
   |      "type": "asset_request",     |
   |      "assetHash": "sha256:abc",   |
   |      "cachedHashes": ["sha256:def", "sha256:ghi"]
   |    }                             |  ← def, ghi は既にキャッシュ済み
   |                                  |
   | <-- control stream (stream 0):  |
   |     asset_response {             |
   |      "type": "asset_response",    |
   |      "assetTransferId": "xfer_01HZY8K7...",
   |      "assetHash": "sha256:abc",   |
   |      "assetSize": 5000000,        |
   |      "contentType": "application/hoscene"
   |     }                            |
   |                                  |
   | <-- 新規 unidirectional stream:  |
   |     asset_stream {               |
   |      "type": "asset_stream",      |
   |      "assetTransferId": "xfer_01HZY8K7...",
   |      "assetHash": "sha256:abc",   |
   |      "assetSize": 5000000         |
   |     }                            |
   |     [raw bytes 5000000 bytes]    |  ← 生バイナリをそのまま流す
   |                                  |
   | ハッシュ検証 (SHA-256)            |
  • asset_request: 制御ストリーム(stream 0)に CBOR で送信。"assetHash" で欲しいアセットを指定し、"cachedHashes" でクライアントが既に持っているハッシュ群を伝える。
  • asset_response: サーバーが制御ストリームに CBOR で返す。"assetTransferId" でこの転送を識別し、"assetSize""contentType" でバイナリの仕様を伝える。
  • バイナリ転送: サーバーはアセットごとに新規の単方向ストリームを開く。ストリーム先頭に length || cbor(asset_stream) の header を置き、"assetTransferId""assetHash"asset_response と対応付ける。その後に "assetSize" のバイト数だけ raw bytes を流す。対応付けは stream の到着順ではなく assetTransferId で行う。
  • ハッシュ検証: クライアントは受信完了後、全バイトの SHA-256 を計算し、asset_response"assetHash" と一致することを確認する。不一致の場合は破棄し、再要求してもよい。

キャッシュ制御

クライアント側:

  • 取得したアセットは sha256: ハッシュをキーにローカルキャッシュに保存する。
  • キャッシュの有効期限は実装依存。容量上限超過時は LRU で破棄する。
  • initial_state 受信時にキャッシュと照合し、不足分だけ asset_request する。

サーバー側:

  • asset_request"cachedHashes" に要求対象の "assetHash" が含まれる場合、サーバーは asset_not_modified を返して転送を省略できる。
  • 要求対象が cachedHashes に含まれない場合、サーバーは asset_response を返し、アセットごとの単方向ストリームで転送する。
  • クライアントが全アセットをキャッシュ済みの場合、asset_request すら不要。initial_state の一覧ですべてヒットした時点で同期完了。

asset_not_modified: キャッシュが最新であることを明示する応答(ETag 304 相当)。

// サーバー: 「abc はお前のキャッシュで最新」
{
  "type": "asset_not_modified",
  "assetHash": "sha256:abc"
}

CDN URL の併用

initial_state のアセットエントリに "assetUrl" がある場合、クライアントは QUIC ストリームではなくその URL から HTTP/3 または HTTPS でダウンロードすることを選べます。ハッシュ検証はどちらの経路でも必須です。

// CDN 経由を案内(QUIC 直送なし)
{
  "assetKind": "com.hoshiboshi.asset.hoscene",
  "assetHash": "sha256:4b2c...",
  "assetSize": 18432012,
  "contentType": "application/hoscene",
  "assetUrl": "https://cdn.example.com/worlds/world_123/v1/world.hoscene"
}

// QUIC 直送のみ(CDN URL なし)
{
  "assetKind": "com.hoshiboshi.asset.hoscene",
  "assetHash": "sha256:4b2c...",
  "assetSize": 18432012,
  "contentType": "application/hoscene"
}

サーバーは両方を同時に提供してもよい。クライアントは CDN 到達性や帯域状況に応じて経路を選択できる。

アセットストリームの優先度

アセット転送用のストリームは低優先度とし、ゲームプレイの制御や移動データを妨げないようにします。QUIC のストリーム優先度機能を用いるか、実装側で帯域制御します。

インスタンス参加フロー

VirMesh のインスタンス解決から Nocturne セッション確立までの一連の流れです。

  1. マニフェスト検証: world address(例: example.com/world)、catalog entry、または既知の worldIdme.virmesh.world.resolveWorld で解決し、core manifest と各 module の署名を検証する。
  2. インスタンス解決: me.virmesh.worldInstance.listInstance でインスタンス一覧を取得し、参加対象を選ぶ。または createInstance で新規作成する。
  3. プロトコル選択: インスタンスの worldProtocols から com.hoshiboshi.world.nocturne を選ぶ。複数バージョンが提示されている場合は、クライアントが対応する最新版を選ぶ。
  4. QUIC 接続: information.hostportserverName を使い、QUIC over UDP で接続する。TLS 証明書を検証する。
  5. プレイヤー認証: 上述の nonce + 署名ハンドシェイク(CBOR フレーム)で player identity を証明する。自身の PlayerServer URI、参加対象 world / instance、接続先 information を含める。
  6. プロフィール解決とアセット取得(並行): WorldServer は新規プレイヤーの PlayerServer に me.virmesh.player.resolveProfile を呼び出してプロフィールを解決し、結果を内部キャッシュに保持する。同時に、クライアントは initial_state の事前準備として、サーバーが保持する全プレイヤー snapshot(プロフィール + connectionStates)を構築する。クライアント側はアセット一覧の準備が整い次第 asset_request で不足アセットを取得し、SHA-256 ハッシュを検証する。プロフィール解決とアセット取得の両方が完了して初めて initial_state を確定送信し、入室を許可する。プロフィール解決に最終的に失敗した接続は errorCode="profile_unavailable" で閉じる。
  7. 初期同期: サーバーから initial_state(参加プレイヤー snapshot、connectionStates、アセット一覧)を CBOR で受信する。アセット取得は必須の同期フェーズであり、完了するまでワールドレンダリングは開始しない。
  8. チャネル割当と購読開始: クライアントは Server Event ChannelPlayer Event ChannelPlayer Connection Channel を順次 channel_open し、最後の channel が許可された時点で初回 push を含む datagram を受信開始する。
  9. ライブセッション開始: 以降、制御ストリームとリアルタイム・データストリームを使ってワールドに参加する。プロフィール変更や avatar 取得は presence.profileUpdated および avatar.fetchGrant.* を通じて随時処理される。

CBOR 設計上の注意

string-first key の実装指針

通常フレームは string key の CBOR map として実装します。未知の string key は、非 critical extension として無視するか、将来の拡張用に保持できます。通常フレームに integer key が含まれている場合は、誤った schema または downgrade の疑いとして拒否します。

通常フレームの例:

{
  "type": "auth",
  "nonce": h'...',
  "playerId": "medi:player:ed25519:...",
  "playerServer": "https://ps.example.com/",
  "customField": "some value"
}

Realtime 系 Channel の hot path datagram だけは、前述の Realtime compact payload schema に従って small integer key を使えます。この key は channel / frame 内ローカルであり、他のフレーム種別では意味を持ちません。

CBOR の strict decode

認証フレーム、制御フレーム、アセットストリーム先頭 header を読む実装は、次の入力を拒否します。

  • map 内の重複 key
  • 通常フレーム内の integer key(Realtime compact payload frame を除く)
  • indefinite-length の text string、byte string、array、map
  • auth フレーム内の未知の string key
  • 認証完了前に許可されていない string extension key
  • 上限を超える map 要素数、text string 長、byte string 長
  • 32 バイト以外の nonce、64 バイト以外の signature

チャネル系フレーム・event・datagram についても、次の入力を拒否します。

  • channel_open / channel_open_result / channel_open_ack / channel_closechannelKind または streamRole が欠落、または値が定義済み列挙外
  • player_event / server_eventkind が欠落
  • server_eventkind="rpc.result"requestId または status が欠落
  • status の値が "ok" でも "error" でもない
  • status="error"errorCode が欠落
  • verbose player_connectionkindseqstate が欠落
  • Realtime compact frame の local key 1(frameFormat)が 0 / 1 / 2 以外
  • Realtime compact frame で local key 3(seq)または local key 4(state / compactPayload / binarySize)が欠落
  • frameFormat=0 で local key 2(kind)が text string ではない、または local key 4 が string-key map ではない
  • frameFormat=1 で local key 2(schemaKey)が未知または ack 前、または local key 4 が integer-key map ではない
  • frameFormat=2 で local key 2(schemaKey)が未知または ack 前、schema の encoding"typed-binary" ではない、local key 4(binarySize)が欠落、または後続の raw binary が宣言バイト長と不一致
  • Realtime compact frame に定義済み local key 以外の integer key または string key が含まれている
  • channel_open_result"ok": false のときに errorCode が欠落

Realtime schema 定義では、追加で次の入力を拒否します。

  • realtime_schema_definechannelKind または schemas が欠落、または schemas が空 array
  • schemas[] の要素で schemaKeykindencoding が欠落
  • schemaKey が同一 batch 内、または同一 QUIC connection / channelKind scope 内で重複している
  • encoding"cbor-int-map" でも "typed-binary" でもない
  • encoding="cbor-int-map"fields が欠落、または fields[].key / fields[].path が schema 内で重複している
  • encoding="cbor-int-map"fields[].type が v1 で定義された型名以外
  • encoding="typed-binary"binaryLayout が欠落
  • realtime_schema_ackaccepted[] または rejected[].schemaKey が未定義の schemaKey を参照している
  • compact payload に schema の fields[].key で定義されていない integer key が含まれている
  • compact payload で required=true の field が欠落、または field 型が schema と一致しない

snapshot・subscribe・profile・avatar fetchGrant フレームでは、追加で次の入力を拒否します。

  • initial_state.players[] の要素で playerId が欠落
  • players[] 要素の connectionStates[] 要素で kindseqstate が欠落
  • subscribe.update / subscribe.replacesubscribePlayerIdssubscribeKindsunsubscribePlayerIdsunsubscribeKinds の値が array 以外
  • subscribeKinds / unsubscribeKinds の要素が "*" 以外で、文字列途中に * を含む
  • presence.joined / presence.profileUpdatedplayerId が欠落
  • profileEnvelope が空 byte string、または UTF-8 として不正
  • profileFingerprint が 32 バイト以外
  • avatar.fetchGrant.requestwearerId が欠落
  • avatar.fetchGrant.requestedviewerId が欠落
  • avatar.fetchGrant.issue / avatar.fetchGrant.issuedavatarGrantPayload または avatarGrantSignature が欠落
  • avatarGrantSignature が 64 バイト以外
  • avatarGrantPayload 内に versionIdhash が同時指定されている
  • avatar.fetchGrant.requestversionIdhash が同時指定されている

認証完了後の制御フレームでは string extension key を使えますが、実装が理解しない critical な意味を持つ拡張は無視してはいけません。critical extension が必要な場合は、拡張仕様側で明示的な negotiation を定義します。

署名対象と Canonical JSON の関係

Nocturne のワイヤー形式が CBOR でも、署名検証は VirMesh の Canonical JSON で行います。理由は以下の通りです。

  1. CBOR には複数の等価なエンコードが存在します(map のキー順序が不定、整数のエンコード幅が可変など)。決定的シリアライゼーションを独自定義すると実装負担が大きい。
  2. VirMesh のエコシステム(HTTP action の envelope 署名や world manifest の署名)は Canonical JSON で統一されています。
  3. CBOR 上のバイト列(nonce、signature)は、署名対象を作るときだけ base64url 文字列に変換して JSON に埋め込みます。この変換は決定的であり、CBOR ↔ JSON 間の往復で情報が失われません。

実装上のポイント:

  • nonce の生バイト列 → base64url 文字列 → Canonical JSON に埋め込み
  • worldIdinstanceIdprotocolprotocolVersionhostserverNameportplayerServer を、CBOR フレームと同じ値で Canonical JSON に埋め込み
  • 署名の生 64 バイトを auth"signature" にセット
  • サーバー側で署名検証用の Canonical JSON を組み立てるときに、nonce を base64url に変換する
  • 検証後は base64url 文字列を破棄し、生バイト列の nonce を内部で保持する

nonce と signature は生バイト列

CBOR の利点として、nonce(32 バイト)と signature(64 バイト)を文字列変換なしでそのまま格納できます。

  • JSON でやり取りする場合、これらは base64url 文字列(nonce: 約 43 文字、signature: 約 86 文字)になり、デコードコストがかかります。
  • CBOR では 32 バイトの nonce がそのまま 34 バイト程度(CBOR header + payload)で表現され、signature も同様です。
  • パース時に base64url デコードのバグやエラー処理を実装する必要がなくなります。

セキュリティ考慮事項

リプレイ攻撃の防止

  • nonce は接続ごとに一意で、60 秒程度の短い有効期限を持ちます。
  • issuedAt を含むことで、nonce の再利用がなくても署名文字列自体の長期再利用を防ぎます。
  • サーバーは消費済み nonce のセットを保持し、2 度目の検証を拒否します。
  • worldIdinstanceIdhostserverNameport を署名対象に含めることで、署名を特定の参加先と接続先に紐付けます(チャネルバインディング)。別ワールド、別インスタンス、別サーバーへのリプレイを防止します。

PlayerServer 解決時の SSRF 防止

playerServer はクライアントから送られる URI です。サーバーがこの URI を使ってプロフィールや所属情報を解決する場合は、通常の HTTP クライアントとしてではなく、外部入力として制限付きに扱います。

  • scheme は https のみ許可します。
  • localhost、loopback、private address、link-local address、metadata service address への接続を拒否します。
  • DNS 解決後の IP アドレスを検査し、redirect 後も同じ制限を適用します。
  • redirect 回数、レスポンスサイズ、接続 timeout、読み取り timeout を小さく制限します。
  • 所属確認は VirMesh Identity の規則に従い、playerServer が自己申告した値だけで playerId の所有を認めてはいけません。

前方秘匿性(Forward Secrecy)

  • QUIC / TLS 1.3 は既定で前方秘匿性を提供します。
  • player identity の Ed25519 署名鍵が後に漏洩しても、過去の QUIC セッションの内容は復号されません。

鍵の分離

Nocturne では最低限 3 つの異なる鍵が関与します。これらを混同しないでください。

鍵の種類用途寿命
ワールド・アイデンティティ鍵マニフェストと hostingDelegation の署名。ワールドの不変な権威。長期(年単位)
サーバー TLS 鍵QUIC 接続時のサーバー証明書。ホスト名に紐づく。中期(月単位)
プレイヤー Identity 鍵medi:player:ed25519:... の署名。nonce 検証に使用。ユーザー管理下の長期鍵

サーバー運用者は、ワールド・アイデンティティ鍵を TLS 終端サーバー上に置かず、オフラインまたは別の鍵管理基盤で保護すべきです。

クロックスキューと issuedAt

  • issuedAt の許容窓(±60 秒)は、極端に大きくしないでください。大きい窓はリプレイの有効期間を延ばします。
  • モバイル端末などで NTP が同期されていない場合、クライアントは接続前に時刻同期を推奨します。サーバーは server_hello"serverTime" を参考情報として返しますが、これを署名対象には含めません(サーバーが未来時刻を送って issuedAt バリデーションを迂回する攻撃を防ぐため)。

匿名接続の扱い

  • Nocturne は「誰が接続してきたか」を player identity で検証することを前提としています。
  • 匿名ゲスト参加を許可する場合、サーバーは guest_ プレフィクスを持つ一時 ID を発行し、別途ポリシーで制限を設けることができます。ただし、これは v1 標準外の動作です。

VirMesh との関係

  • ワールドの身元・マニフェスト検証・インスタンス解決は引き続き VirMeshme.virmesh.world.* など)の action / object で表現します。
  • com.hoshiboshi.world.nocturne は、その上に載る参加用トランスポートのラベルです。
  • VirMesh の Transport で定義された Canonical JSON および Ed25519 署名ルールを、プレイヤー認証の署名検証にそのまま適用します。CBOR はワイヤー形式であり、署名対象はあくまで Canonical JSON です。
  • player identifier の形式(medi:player:ed25519:<public-key>)、無効化(tombstone)の扱い、public profile の取得方法はすべて VirMesh の Identity に従います。

参考