Skip to content

Latest commit

 

History

History
300 lines (214 loc) · 13.6 KB

10-nbt-binary.md

File metadata and controls

300 lines (214 loc) · 13.6 KB

10. NBTバイナリ形式

NBT (Named Binary Tag)の読み書き仕様。このレイヤはMinecraftのバージョンに一切依存しない。 バージョン検査は30 チャンク形式以降でのみ行う。

前提: 00 共通規約(ビッグエンディアン、エラー分類、安全上限)


1. タグ型

ID 論理型名 型名 (C#/Java/TypeScript/Python) ペイロード
0 End — なし
1 Byte NbtByte i8 1バイト
2 Short NbtShort i16 2バイト
3 Int NbtInt i32 4バイト
4 Long NbtLong i64 8バイト
5 Float NbtFloat f32 4バイト (IEEE 754 binary32)
6 Double NbtDouble f64 8バイト (IEEE 754 binary64)
7 ByteArray NbtByteArray i32長さ + i8 × 長さ
8 String NbtString u16バイト長 + MUTF-8バイト列
9 List NbtList u8要素型ID + i32個数 + ペイロード × 個数
10 Compound NbtCompound 名前付きタグの並び + TAG_End (0x00)
11 IntArray NbtIntArray i32長さ + i32 × 長さ
12 LongArray NbtLongArray i32長さ + i64 × 長さ

Endは値として持てないため、対応する型は無い。 Rustは型を分けず、NbtTag列挙のNbtTag::Byte(i8)のようなバリアントで表す。 独立した型を持つのはNbtString / NbtList / NbtCompoundだけである。

未知のタグID(13以上、または負値)を読んだ場合はMALFORMED_DATA。

1.1 名前付きタグ

TAG_Compoundの中身は「名前付きタグ」の並びで、各要素はタグID・名前・ペイロードの3つからなる。 名前はバイト長とMUTF-8のバイト列で表すので、並びは次の4項目になる。

u8   タグID
u16  名前のバイト長      -- タグIDが 0 (TAG_End) の場合は名前ごと存在しない
...  MUTF-8 の名前
...  ペイロード

TAG_Endは1バイトの0x00のみで、名前もペイロードも持たない。

1.2 長さフィールドの検証

ByteArray / IntArray / LongArray / Listの長さはi32(符号付き)である。

  • 負値 → MALFORMED_DATA
  • 長さ × 要素バイト数が残り入力バイト数を超える → 確保する前にMALFORMED_DATA

この先行検証により、0x7FFFFFFFを宣言しただけの数バイトの入力でメモリを枯渇させられない。


2. 文字列: MUTF-8

NBTの文字列はUTF-8ではなくModified UTF-8 (MUTF-8) である。 JavaのDataInput.readUTF / DataOutput.writeUTFと同じ符号化で、標準UTF-8と次の2点が異なる。

対象 標準UTF-8 MUTF-8
U+0000 00(1バイト) C0 80(2バイト)
U+10000〜U+10FFFF 4バイト1シーケンス サロゲートペアに分解し、3バイト × 2(CESU-8)

符号化規則は次のとおり。

U+0001 .. U+007F   ->  0xxxxxxx                                  (1 バイト)
U+0000, U+0080 ..
       U+07FF      ->  110xxxxx 10xxxxxx                         (2 バイト)
U+0800 .. U+FFFF   ->  1110xxxx 10xxxxxx 10xxxxxx                (3 バイト)
U+10000 ..         ->  上位/下位サロゲートへ分解し、各々を 3 バイトで符号化

長さフィールドは符号化後のバイト数を表すu16であり、最大65535バイト。 文字数ではないことに注意する。書き込み時に65535を超えたらINVALID_ARGUMENT。

2.1 復号時の扱い

  • 孤立サロゲート(対にならないU+D800〜U+DFFF)はそのまま保持する。 Minecraftの実データには通常現れないが、破損データが知らないうちに変換されるとラウンドトリップでバイトが変わってしまうため、失われないようにする。
  • 冗長な符号化(例: U+0041を2バイトで表現)はMALFORMED_DATAとする。 ただしU+0000のC0 80はMUTF-8として正当なので受理する。
  • 継続バイトが10xxxxxxでない、途中で入力が尽きた → MALFORMED_DATA

2.2 Compoundのキー

TAG_CompoundのキーもMUTF-8の文字列だが、値と違い孤立サロゲートを許さない。 UTF-8へ写せないキーを読んだ場合はMALFORMED_DATAとする。 APIで孤立サロゲートを含むキーを設定しようとした場合はINVALID_ARGUMENTとする(書き出すと読み戻せなくなるため)。

Minecraftが書き出すキーは実際にはASCIIの識別子のみであり、そこに孤立サロゲートが現れるのはデータ破損を意味する。 また、キーをすべての言語で「ただの文字列」として扱えると、マップ型をそのまま使えるという実装上の大きな利点がある。

2.3 各言語での保持方法

言語 内部表現 孤立サロゲートの扱い
C# string(UTF-16) そのまま保持できる
Java String(UTF-16) そのまま保持できる
TypeScript string(UTF-16) そのまま保持できる
Python str(コードポイント列) サロゲートを単独の文字として保持できる
Rust String(UTF-8) UTF-8に写せないため、専用の表現へ退避する

RustだけはUTF-8の不変条件があるため、NbtStringを次の2形態の型として定義する。

enum NbtString {
    Text(String),         // UTF-8として表せる通常の文字列。実データはほぼすべてこちら
    Surrogates(Vec<u16>), // UTF-8に写せないUTF-16コード単位の列
}

通常の入力では常にTextになる。


3. ファイル形式とルートタグ

NbtFormatは2種類。

値 用途 ルートの構造
Java level.dat、チャンク、.nbtファイル全般 u8タグID(=10) + u16名前長 + 名前 + Compoundペイロード
Network 1.20.2以降のネットワークプロトコル u8タグID(=10) + Compoundペイロード(名前なし)

Java形式のルート名は通常空文字列だが、値は保持し、書き込み時にそのまま出力する。 ルートタグがTAG_Compound以外だった場合はMALFORMED_DATA。

読み書きに使うAPIは、次の論理APIに統一する。

NbtIo.read_file(path, options)        -> NamedTag
NbtIo.read_bytes(bytes, options)      -> NamedTag
NbtIo.write_file(path, named_tag, options)
NbtIo.write_bytes(named_tag, options) -> bytes

NamedTag { name: String, tag: NbtCompound }

read_bytesは入力を1つのNBTとして読む。 ルートタグの後にバイトが残っていたらMALFORMED_DATAとする。 読み違えを見逃さず、エラーとして知らせるためである。

3.1 連なったNBTを読む

1つのバイト列にNBTが複数並んでいることがある。 この形はread_bytesでは読めないので、専用のAPIを用意する。

NbtIo.read_bytes_at(bytes, offset, options)  -> NbtReadResult
NbtIo.read_bytes_all(bytes, options)         -> NamedTag の一覧

NbtReadResult { tag: NamedTag, end: 整数 }

read_bytes_atはoffsetからNBTを1つ読み、読み終わった直後の位置をendに入れて返す。 endを次のoffsetとして渡せば、順に読み進められる。

offset = 0
while offset < len(bytes):
    result = read_bytes_at(bytes, offset)
    使う(result.tag)
    offset = result.end

read_bytes_allは入力を使い切るまで読み、読んだ順に並べて返す。 空のバイト列は「0個」であってエラーではない。

条件 結果
offsetが負、または入力長を超える INVALID_ARGUMENT
read_bytes_atに圧縮を指定した INVALID_ARGUMENT
途中で入力が尽きた MALFORMED_DATA

read_bytes_atの位置は、渡したバイト列そのものを指す。 展開すると位置が変わってしまうので、圧縮されたデータは扱えない。 read_bytes_allは位置を返さないので、入力全体に1回かかった圧縮であれば展開してから読む。


4. 圧縮

Compressionは4値。

値 内容 判定に使う先頭バイト
None 無圧縮 0x0A(TAG_CompoundのID)
Gzip RFC 1952 1F 8B
Zlib RFC 1950 (zlibラッパ付きdeflate) 78(78 01 / 78 9C / 78 DA など)
Auto 読み込み時のみ指定可。上記から自動判定 —

Autoは先頭2バイトで判定する。

  1. 1F 8B → Gzip
  2. 先頭バイトの下位4bitが8(=deflate圧縮法)、かつ先頭2バイトをu16ビッグエンディアンとして読んだ値が31の倍数 → Zlib
  3. 先頭バイトが0x0A → None
  4. いずれでもない → MALFORMED_DATA

書き込み時にAutoを指定した場合はINVALID_ARGUMENT。 level.datの既定はGzip、リージョン内チャンクの既定はZlib(→ 20)。


5. 正準書き出し (Canonical Writer)

ラウンドトリップ検証を成立させるため、書き出しは一意でなければならない。

  • NbtCompoundは挿入順で書き出す。ソートしない
  • NbtListの要素型IDは、リストが持つ要素型をそのまま書く。要素が1つ以上あればその型、要素型が決まっていない空リストならEnd(0)になる
  • f32 / f64はNaNも含めビットパターンをそのまま書く(正規化しない)
  • 圧縮は結果バイト列が実装依存になるため、ラウンドトリップ検証は展開後のバイト列で行う

空のNbtListの要素型については、読み込んだ値がEnd以外(例: 実装によってはByte)だった場合も読んだ値をそのまま保持して書き戻す。Minecraft側が生成した値を損なわないため。


6. 読み書きオプション

NbtReadOptions {
    format: NbtFormat = Java
    compression: Compression = Auto
    max_depth: i32 = 512
    max_decompressed_size: i64 = -1   // -1は無制限
}

NbtWriteOptions {
    format: NbtFormat = Java
    compression: Compression = Gzip
}

7. コンテナ型の振る舞い

7.1 NbtCompound

  • 挿入順を保持するマップ(Rust: IndexMap相当の自前実装 / C#: OrderedDictionary相当 / Java: LinkedHashMap / TypeScript: Map / Python: dict)
  • 同名キーの再設定は位置を維持したまま値を置き換える
  • 型付き取得子get_int(key)等は、キーが無い場合と型が違う場合を区別する
    • キーが無い → None / nullを返す取得子(opt_int)と、エラーにする取得子(get_int)の両方を用意する
    • 型が違う → 常にUNEXPECTED_TAG_TYPE
  • 型付き設定子set_int(key, value)等を、取得子と対で用意する
    • set(key, NbtInt(42))と書かずに済ませるための糖衣であり、動きはsetと同じ
    • 真偽値は専用型が無いので、set_boolはTAG_Byteの0 / 1として書く
    • 対象はbyte short int long float double bool string byte_array int_array long_arrayの11種

7.2 NbtList

  • 要素型は1つに強制される。異なる型を追加しようとしたらUNEXPECTED_TAG_TYPE
  • 空リストの要素型はEnd
  • 空リストに最初の要素を追加すると、要素型がその型に確定する
  • 全要素を削除しても、確定済みの要素型は維持する(読み書きの往復で型が消えないように)

7.3 等値比較と複製

どのタグ型も、値としての等値比較と深い複製を提供する。 言語ごとの綴りはAPI対応表を参照。

等値比較の規則は全言語で同じでなければならない。

対象 規則
タグ型が違うもの同士 値が同じでも等しくない(NbtInt(1) ≠ NbtShort(1))
Float / Double 値ではなくビットパターンで比べる。NaN同士は等しく、+0.0と-0.0は等しくない
配列 長さと、同じ位置の要素がすべて一致すること
NbtList 要素型が一致し、同じ位置の要素がすべて一致すること
NbtCompound キーと値が挿入順も含めて一致すること

Float / Doubleをビットパターンで比べるのは、正準書き出し(5章)がビットパターンをそのまま書くためである。 値で比べると、「等しいのに書き出すと違うバイト列になる」組み合わせが生じる。

NbtCompoundの並び順を等価性に含めるのも同じ理由で、順序が違えば書き出したバイト列も違う。

複製は深いコピーとする。複製したタグをいくら書き換えても元に影響しない。

7.4 人が読むための表現

どの型も、デバッグ用の文字列表現を持つ(C# ToString / Java toString / TypeScript toString / Python __repr__ / Rust Debug)。 Rustでは、NbtString / NbtList / NbtCompoundがDisplayも実装する。

中身の形式は決めない。 言語ごとの作法に合わせてよい。 決めるのは「どの型にも用意する」ことだけである。

機械が読む形式が要るならSNBTを使う。 こちらは形式を定めており、全言語で同じ文字列になる。


8. 関連