Формат Blob
Байтовая раскладка файла .blob: заголовок в 24 байта, порядок его проверки и конверты внутри
.blob хранит те же данные, что .json, только в бинарном виде. Это равноправный формат уровня, а не кэш: уровень может быть сохранён только в .blob
Уровень volcano, 19 341 объект | Размер |
|---|---|
level.json | 15,7 МБ |
level.blob | 5,1 МБ, читается за 203 мс |
Читаемости .blob не обещает. Его нельзя прочитать глазами или сравнить diff-ом, поэтому он нигде не выбран по умолчанию
Кодек для каждой модели создаёт генератор Roslyn
Заголовок
| Смещение | Размер | Поле | Значение |
|---|---|---|---|
| 0 | 4 | magic | uint 0x4F424842, байты 42 48 42 4F (BHBO) |
| 4 | 2 | поколение кодека | ushort, BlobFormat.Generation = 1 |
| 6 | 2 | флаги | ushort, бит 0 FlagHashed = хеш есть. Остальные биты зарезервированы и должны быть 0 |
| 8 | 8 | длина данных | long |
| 16 | 8 | хеш | ulong, xxHash64 данных с поколением кодека в качестве seed |
Длина заголовка BlobFormat.HeaderLength = 24 байта, за ним идут данные
Порядок проверок
BlobFormat.ReadHeader проверяет заголовок в жёстком порядке. Пока заголовок не пройден, ничего не выделяется:
- magic
- поколение кодека равно 1
- нет неизвестных флагов
- заявленная длина равна настоящей
- хеш совпадает, если выставлен
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, никогда не молча