hrxdev
Українська

№2 Світ, збереження, повтори та баг, що сховався у форматі збережень

· Nick

Після першого запису в ядра з’явився світ, у який можна щось поміщати, файли, у яких його можна зберігати, і спосіб довести, що два прогони збігаються. Графіки та персонажів, як і раніше, немає. Більша частина запису – інфраструктура, а одна її частина – баг, який виявився вартим окремої історії.

Світ із клітинок

Світ – це сітка клітинок 1×1 м на дискретних рівнях, пронумерованих від нуля. Стіна чи двері – це клітинка; сходи будуть особливим зв’язком між двома рівнями, але сходів поки немає. Усередині світ є набором пласких шарів (підлога, конструкція, прапорці конструкції) на всю карту плюс похідний шар прохідності. Карта відстежується чанками 16×16 із лічильниками версій, щоб рендерер чи пошук шляху могли спитати «чи змінилася ця ділянка?», не порівнюючи клітинки.

Розмір світу обирається під час створення. Будівництво виконується командами, що приймають прямокутник: покласти підлогу, стіну чи двері на ділянку або прибрати їх. Якщо частина прямокутника зайнята, решта все одно будується, а команда повідомляє, що пропустила. Поки є три вбудовані типи клітинок: підлога, стіна, двері. Відтепер карта входить у хеш світу й у збереження. Вона звіряється з повільною, свідомо правильною еталонною моделлю на тисячах випадкових команд.

Збереження та повтори

Збереження й повтор використовують один файловий контейнер. Він розбитий на іменовані секції, у кожної є тип і версія, тож нові частини світу (персонажі, потреби, стосунки) згодом зможуть стати новими секціями, не ламаючи старі файли. Є контрольні суми заголовка й тіла, тіло стиснуте, а кожен розмір перевіряється на відповідність ліміту до виділення пам’яті, тож пошкоджений чи зловмисний файл не змусить гру з’їсти всю вашу пам’ять. Типи клітинок зберігаються за іменем, а не за номером, тому збереження працюватиме далі, якщо список типів зміниться.

Повтор – це те, чого я хотів із самого початку: сид плюс журнал команд. Під час запису ядро ще й пише контрольний хеш кожні 100 тіків. Під час відтворення воно порівнює результат кожної команди та кожен контрольний хеш і зупиняється на першому тіку, де вони розходяться. Повтор також запам’ятовує, яка збірка ядра його записала, тому повтор з іншої збірки відхиляється, а не тихо розходиться.

Саме ядро ніколи не торкається файлів: воно читає й пише потоки, а де лежать збереження, вирішує гра. Ця межа забезпечується тим самим механізмом заборонених API, що й раніше.

Поки не зроблено: утиліта командного рядка, що відтворює файл повтору, і ігровий бік збереження (тека збережень, автозбереження, безпечний запис).

Доводимо, що два прогони збігаються

Тест детермінізму тепер запускає один сценарій у двох окремих процесах: сид, скрипт команд будівництва (частина з них навмисно некоректна) і тестова система, що бере числа з кожного випадкового потоку. Кожен процес проходить його трьома способами – напряму, через збереження й відновлення та через повтор записаних байтів – і порівнює хеші на кожному тіку. Потім два процеси звіряють свої хеші один з одним кожен 10-й тік.

Я навмисно його ламав, щоб переконатися, що він уміє падати. Незасіяний Random, протягнутий у тік, зробив тест червоним. Рандомізований хеш рядка, який .NET засіває по-різному в кожному процесі, всередині одного процесу лишився непоміченим і був спійманий лише під час порівняння двох. Саме заради цього й потрібні два процеси.

Правило, яке я записав: одна збірка ядра має давати однакові хеші на процесорах x64 й arm64, тож повтор, записаний на Mac із чипом Apple, має відтворюватися на Windows-ПК. Закріплені еталонні хеші та приклади файлів збережень і повторів існують саме для цієї перевірки. Тригонометрія та експоненти з рухомою комою на різних платформах дають різні результати, тому в тому, що впливає на світ, вони під забороною; коли якійсь системі вони знадобляться вперше, мені знадобиться детермінована заміна. Ці еталонні файли змінюються лише навмисно: якщо хеш попливе, зміну потрібно пояснити, перш ніж її прийняти.

Бенчмарк, який уміє сказати «ні»

Тепер є безголовий запускач, який бере сценарій, сид і кількість тіків та друкує звіт у JSON: медіанний і 99-й процентиль часу на тік, кількість агентів, хеш світу та скільки байтів виділено за тік. Перший сценарій будує карту 512×512 на 16 рівнях приблизно з 52 000 команд будівництва.

Базова лінія зберігається для кожної машини окремо, бо виміри з ноутбука нічого не значать на настільному комп’ютері. Регресією вважається медіана більш ніж на 10% вища за базову лінію або 99-й процентиль більш ніж на 35% вищий, і лише якщо зростання перевищує 0,05 мс, оскільки майже порожній тік лежить на межі роздільності таймера. На кожній машині діє один поріг: нуль виділених байтів за тік. Варто чесно сказати, що це вимірює сьогодні. Без систем і без персонажів тік займає близько 35 наносекунд, тож поріг за часом здебільшого спить, доки на етапі руху не з’являться перші справжні системи. Зараз працює поріг за виділенням пам’яті.

Нестабільний тест, який не був нестабільним

Поки я робив бенчмарк, тест формату збережень почав падати на деяких збірках, хоча ядро не змінювалося. Тест по черзі перевертає кожен байт стисненого повтору й вимагає, щоб завантажувач відхиляв кожен варіант. В одній збірці байт ближче до кінця файлу було прийнято. У попередній збірці той самий тест проходив.

Чому це то з’являлося, то зникало: повтор несе ідентифікатор збірки, що його записала, тож стиснені байти відрізняються від збірки до збірки, а разом із ними й байт, який перевертається. Діра була завжди; тест помічав її, лише коли кості лягали саме так.

Сама діра: контрольна сума покривала дані після розпакування, а розпаковувач .NET виявився поблажливим. Він приймає потік, який так і не повідомляє про свій кінець, і ігнорує все після кінця. Переворот біта-заповнювача в останньому байті дає інший, усе ще допустимий потік, який розпаковується в ті самі дані, тож контрольна сума збігалася, а пошкоджений файл завантажувався як справний.

Виправлення – нова версія контейнера. Контрольна сума тепер покриває байти в тому вигляді, як вони зберігаються у файлі, і перевіряється до розпакування, а розпакування має закінчуватися рівно там, де сказано в заголовку. Порожнє тіло зберігається без стиснення, оскільки компресор для порожнього входу нічого не пише. Перевірки тепер використовують фіксовані стиснені потоки, що не залежать від збірки, плюс фаззинг пошкоджених файлів. Приклади файлів перестворено для нової версії; хеші світу не змінилися.

Принагідно

На сайті, який ви читаєте, на головній сторінці тепер два лічильники відвідувачів: унікальні за весь час і за останні 24 години. Вони рахуються за журналом сторінок вебсервера, без JavaScript, cookie та сторонніх сервісів. Адреса зберігається лише у вигляді хеша із сіллю, боти відфільтровуються за user agent, а числа відстають на кілька хвилин. Кілька людей за однією мережею рахуються як одна.

Що далі

Перший етап, каркас ядра, завершено. Далі M1, режим будівництва на Godot: стіни, двері та кімнати за моделлю світу, зріз поверхів, камера з поворотом кроками по 90° і прості куби замість графіки. Робота над ним ще не почалася. Див. дорожню карту.

← Усі записи девлогу · Далі: №3 →