К содержимому
Формат Blob
На этой странице

Формат Blob

Байтовая раскладка файла .blob: заголовок в 24 байта, порядок его проверки и конверты внутри

.blob хранит те же данные, что .json, только в бинарном виде. Это равноправный формат уровня, а не кэш: уровень может быть сохранён только в .blob

Уровень volcano, 19 341 объектРазмер
level.json15,7 МБ
level.blob5,1 МБ, читается за 203 мс

Читаемости .blob не обещает. Его нельзя прочитать глазами или сравнить diff-ом, поэтому он нигде не выбран по умолчанию

Кодек для каждой модели создаёт генератор Roslyn

Заголовок

СмещениеРазмерПолеЗначение
04magicuint 0x4F424842, байты 42 48 42 4F (BHBO)
42поколение кодекаushort, BlobFormat.Generation = 1
62флагиushort, бит 0 FlagHashed = хеш есть. Остальные биты зарезервированы и должны быть 0
88длина данныхlong
168хешulong, xxHash64 данных с поколением кодека в качестве seed

Длина заголовка BlobFormat.HeaderLength = 24 байта, за ним идут данные

Порядок проверок

BlobFormat.ReadHeader проверяет заголовок в жёстком порядке. Пока заголовок не пройден, ничего не выделяется:

  1. magic
  2. поколение кодека равно 1
  3. нет неизвестных флагов
  4. заявленная длина равна настоящей
  5. хеш совпадает, если выставлен FlagHashed

Каждый провал - BlobFormatException со своим сообщением. "Файл повреждён" и "файл из более новой сборки" требуют от игрока разного, поэтому в одну ошибку они не сливаются

Хеш намеренно не криптографический. Он ловит повреждение, а от подделки защищает слой OpenPGP. Подробнее - Архивы и защита

Кодирование

  • little-endian, числа фиксированной ширины, без varint
  • строка - это число байтов int и затем UTF-8
  • null - это длина -1. Поэтому пустой и отсутствующий список остаются разными после прогона туда и обратно
  • полиморфное значение начинается с однобайтового тега, 0xFF зарезервирован под null. Тег - это GetModelType() модели, тот же признак, что JSON пишет в [тег, данные]
  • количество, прочитанное из файла, проверяется в BlobReader.ReadCount до того, как под него что-то выделено

Конверты и два поколения

Каждый корень с [ModelGeneration] пишет свой конверт: домен строкой, поколение модели как int, длину содержимого, затем само содержимое. Так инструмент читает поколение файла:

var bytes = File.ReadAllBytes(path);
var reader = new BlobReader(bytes, BlobFormat.HeaderLength, bytes.Length - BlobFormat.HeaderLength);
var domain = reader.ReadString();
var generation = reader.ReadInt();

Здесь два разных поколения:

  • поколение кодека в заголовке описывает раскладку байтов. Когда оно меняется, каждый старый .blob становится нечитаемым целиком: откатиться не к чему
  • поколение модели в каждом конверте описывает форму одного домена. Старое мигрирует, новое отвергается через NewerGenerationException. Подробнее - Версии и миграции

Слить их в одно число нельзя. Поколение модели лежит внутри конверта, а найти конверт может только тот, кто уже знает раскладку байтов. Изменение модели поколение кодека не сдвигает никогда

Расширение заголовка

В заголовке нет запасных байтов и нет поля с его собственной длиной. Он растёт двумя способами:

  • флаг. Свободно 15 битов. Старый читатель отвергает файл с незнакомым флагом, а не читает его неправильно
  • новое поколение кодека. Новая раскладка может поменять что угодно, включая длину заголовка. Более новый читатель может читать старое поколение рядом с новым

Дописать что-то после данных нельзя. Заявленная длина должна совпадать с настоящей, поэтому любой лишний хвост отвергается. Новые данные уровня идут внутрь данных, через поколение модели

Интересно

Содержимое, которое кончается не ровно на заявленной длине, считается повреждением в обе стороны. Бывает, что корень прошёл все проверки заголовка и всё равно не разбирается. Тогда он пропускается по своей длине и остаётся со значениями по умолчанию. Пропуск записывается в SerializationReport, никогда не молча