NBT (Named Binary Tag)の読み書き仕様。このレイヤはMinecraftのバージョンに一切依存しない。 バージョン検査は30 チャンク形式以降でのみ行う。
前提: 00 共通規約(ビッグエンディアン、エラー分類、安全上限)
| 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。
TAG_Compoundの中身は「名前付きタグ」の並びで、各要素はタグID・名前・ペイロードの3つからなる。
名前はバイト長とMUTF-8のバイト列で表すので、並びは次の4項目になる。
u8 タグID
u16 名前のバイト長 -- タグIDが 0 (TAG_End) の場合は名前ごと存在しない
... MUTF-8 の名前
... ペイロード
TAG_Endは1バイトの0x00のみで、名前もペイロードも持たない。
ByteArray / IntArray / LongArray / Listの長さはi32(符号付き)である。
- 負値 →
MALFORMED_DATA - 長さ × 要素バイト数が残り入力バイト数を超える → 確保する前に
MALFORMED_DATA
この先行検証により、0x7FFFFFFFを宣言しただけの数バイトの入力でメモリを枯渇させられない。
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。
- 孤立サロゲート(対にならない
U+D800〜U+DFFF)はそのまま保持する。 Minecraftの実データには通常現れないが、破損データが知らないうちに変換されるとラウンドトリップでバイトが変わってしまうため、失われないようにする。 - 冗長な符号化(例:
U+0041を2バイトで表現)はMALFORMED_DATAとする。 ただしU+0000のC0 80はMUTF-8として正当なので受理する。 - 継続バイトが
10xxxxxxでない、途中で入力が尽きた →MALFORMED_DATA
TAG_CompoundのキーもMUTF-8の文字列だが、値と違い孤立サロゲートを許さない。
UTF-8へ写せないキーを読んだ場合はMALFORMED_DATAとする。
APIで孤立サロゲートを含むキーを設定しようとした場合はINVALID_ARGUMENTとする(書き出すと読み戻せなくなるため)。
Minecraftが書き出すキーは実際にはASCIIの識別子のみであり、そこに孤立サロゲートが現れるのはデータ破損を意味する。 また、キーをすべての言語で「ただの文字列」として扱えると、マップ型をそのまま使えるという実装上の大きな利点がある。
| 言語 | 内部表現 | 孤立サロゲートの扱い |
|---|---|---|
| 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になる。
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とする。
読み違えを見逃さず、エラーとして知らせるためである。
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回かかった圧縮であれば展開してから読む。
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バイトで判定する。
1F 8B→Gzip- 先頭バイトの下位4bitが
8(=deflate圧縮法)、かつ先頭2バイトをu16ビッグエンディアンとして読んだ値が31の倍数 →Zlib - 先頭バイトが
0x0A→None - いずれでもない →
MALFORMED_DATA
書き込み時にAutoを指定した場合はINVALID_ARGUMENT。
level.datの既定はGzip、リージョン内チャンクの既定はZlib(→ 20)。
ラウンドトリップ検証を成立させるため、書き出しは一意でなければならない。
NbtCompoundは挿入順で書き出す。ソートしないNbtListの要素型IDは、リストが持つ要素型をそのまま書く。要素が1つ以上あればその型、要素型が決まっていない空リストならEnd(0)になるf32/f64はNaNも含めビットパターンをそのまま書く(正規化しない)- 圧縮は結果バイト列が実装依存になるため、ラウンドトリップ検証は展開後のバイト列で行う
空のNbtListの要素型については、読み込んだ値がEnd以外(例: 実装によってはByte)だった場合も読んだ値をそのまま保持して書き戻す。Minecraft側が生成した値を損なわないため。
NbtReadOptions {
format: NbtFormat = Java
compression: Compression = Auto
max_depth: i32 = 512
max_decompressed_size: i64 = -1 // -1は無制限
}
NbtWriteOptions {
format: NbtFormat = Java
compression: Compression = Gzip
}
- 挿入順を保持するマップ(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として書く - 対象は
byteshortintlongfloatdoubleboolstringbyte_arrayint_arraylong_arrayの11種
- 要素型は1つに強制される。異なる型を追加しようとしたら
UNEXPECTED_TAG_TYPE - 空リストの要素型は
End - 空リストに最初の要素を追加すると、要素型がその型に確定する
- 全要素を削除しても、確定済みの要素型は維持する(読み書きの往復で型が消えないように)
どのタグ型も、値としての等値比較と深い複製を提供する。 言語ごとの綴りはAPI対応表を参照。
等値比較の規則は全言語で同じでなければならない。
| 対象 | 規則 |
|---|---|
| タグ型が違うもの同士 | 値が同じでも等しくない(NbtInt(1) ≠ NbtShort(1)) |
Float / Double |
値ではなくビットパターンで比べる。NaN同士は等しく、+0.0と-0.0は等しくない |
| 配列 | 長さと、同じ位置の要素がすべて一致すること |
NbtList |
要素型が一致し、同じ位置の要素がすべて一致すること |
NbtCompound |
キーと値が挿入順も含めて一致すること |
Float / Doubleをビットパターンで比べるのは、正準書き出し(5章)がビットパターンをそのまま書くためである。
値で比べると、「等しいのに書き出すと違うバイト列になる」組み合わせが生じる。
NbtCompoundの並び順を等価性に含めるのも同じ理由で、順序が違えば書き出したバイト列も違う。
複製は深いコピーとする。複製したタグをいくら書き換えても元に影響しない。
どの型も、デバッグ用の文字列表現を持つ(C# ToString / Java toString / TypeScript toString / Python __repr__ / Rust Debug)。
Rustでは、NbtString / NbtList / NbtCompoundがDisplayも実装する。
中身の形式は決めない。 言語ごとの作法に合わせてよい。 決めるのは「どの型にも用意する」ことだけである。
機械が読む形式が要るならSNBTを使う。 こちらは形式を定めており、全言語で同じ文字列になる。