01.08.2027 273 материалов

Тест на детерминизм проходил месяцами, пока две сборки игры вели себя по-разному

Разработчик скомпилировал движок Android-игры для браузера через TeaVM и обнаружил, что тест на воспроизводимость был структурно неспособен поймать расхождение между двумя рантаймами.

Тест на детерминизм проходил месяцами, пока две сборки игры вели себя по-разному

Тест на детерминизм проходил на зелёных коммитах месяцами — не потому что он был слабым, а потому что архитектурно не мог обнаружить расхождение между двумя средами выполнения.

Предыстория: один Java-код, два компилятора

Разработчик под ником agentdev9 выделил правила своей Android-игры в отдельный модуль — без зависимости от Android SDK на classpath. Это позволило скомпилировать тот же Java-код дважды: в байткод JVM (для Android) и в JavaScript через TeaVM (для запуска на Canvas в браузере).

Идея простая и мощная: если движок детерминирован, то один и тот же сид при одних и тех же входных данных должен давать одну и ту же игру — независимо от среды выполнения. Это свойство делает повторы воспроизводимыми, а сборки — сравнимыми между собой.

Что пошло не так

При запуске с одинаковым сидом и одинаковой последовательностью входов две сборки выдавали разные результаты — и это не погрешность округления:

Параметр JVM Браузер
Первая препятствие (x), кадр 60 405.426 304.426
Жив на кадре 360 да нет
Финальный счёт 9 6

Это не «чуть-чуть другое» — это совершенно разные игровые сессии. Игрок в браузерной сборке погибал раньше и набирал меньше очков.

Причина банальна, но последствия нет

Игровой движок использовал java.util.Random. В документации Java описан его алгоритм — линейный конгруэнтный генератор с конкретными константами. Казалось бы, при фиксированном сиде последовательность чисел должна быть одинаковой везде.

Но спецификация описывает поведение класса, а не точную реализацию в каждом рантайме. TeaVM — это не JVM, это отдельная среда выполнения Java-подобного кода в браузере. Его реализация java.util.Random придерживается того же контракта, но внутренняя арифметика может отличаться.

И она действительно отличалась. Два рантайма, один и тот же сид — две разные последовательности псевдослучайных чисел — две разные игры.

Исправление заняло десять минут: разработчик вручную написал линейный конгруэнтный генератор вместо того, чтобы полагаться на стандартную библиотеку. Теперь обе сборки выполняли одинаковую арифметику, и результаты совпадали.

Почему существующий тест не заметил баг

Вот самая интересная часть истории. У разработчика был тест с говорящим названием theSameSeedProducesTheSameRun. Он запускал движок дважды с одним сидом и проверял, что результаты совпадают.

Тест проходил на каждом коммите. Включая те, во время которых браузерная сборка играла в совершенно другую игру.

Тест был структурно неспособен обнаружить проблему. Он оба запуска выполнял в одной и той же среде выполнения — в JVM. Два запуска JVM, одна реализация java.util.Random — естественно, результаты совпадают. Но тест ничего не знал о том, что происходит в браузерной сборке.

Утверждение в тесте было формально истинным и одновременно бесполезным. Никакое ужесточение проверок не помогло бы — тест по архитектуре не мог увидеть расхождение между рантаймами.

Золотой эталон тоже не спас

Казалось бы, очевидное решение — зафиксировать эталонный вывод: записать правильную трейс (трасировку), закоммитить и проверять against неё навсегда.

Разработчик так и сделал. Затем откатил исправление, чтобы проверить, заметит ли тест регрессию.

Тест с золотым эталоном тоже прошёл.

Логика та же: эталонный файл был записан с JVM, а JVM выполняет ту же арифметику, что и ручной генератор. Трейс JVM не изменился. Изменился только браузерный трейс, но золотой эталон о нём ничего не знал.

Фиксация выхода одного рантайма слепа ровно в той же мере, что и сравнение одного рантайма с самим собой. Оба подхода выглядят как тесты на детерминизм. Ни один из них не способен обнаружить расхождение, которое претендует найти.

Что реально работает: кросс-ронтаймный diff

Единственный способ поймать такой баг — сравнить вывод двух разных рантаймов между собой. Разработчик построил систему из трёх шагов:

  1. JVM-тест разыгрывает 600-кадровую игру с фиксированным сидом и десятью скриптованными свайпами, записывая счёт, серию, состояние, позиции игрока и всех препятствий в 11 контрольных точках.

  2. План входных данных сохраняется на диск. Node-скрипт читает этот файл и использует его для управления браузерной сборкой. Свайпы определены в одном месте — если бы обе стороны захардкодили их независимо, опечатка в одном месте выглядела бы как расхождение движков.

  3. Компаратор диффит трейсы двух сборок. Счёт, серия и состояние должны совпадать точно. Для float допуск 0.01 пикселя — в пять раз больше, чем максимальная погрешность округления между компиляторами, и на четыре порядка меньше, чем реальный баг.

Разработчик также специально провалидировал тест: откатил исправление и запустил его заново. Тест корректно показал 69 расхождений, включая разницу в 712 пикселей по горизонтали и разный финальный счёт.

Дополнительные уроки

Из работы над тестами вывалилось ещё два важных наблюдения.

Тест на исправление сначала был пустышкой. Его предусловие требовало игровой сессии, которая длилась достаточно долго для получения continue. Но «припаркованный» игрок (без действий) никогда не доходил до этого порога — и тест молча попадал в ветку, которая ничего не проверяла. Он проходил и с откатом исправления. Разработчик заметил это только потому, что привык намеренно ломать код, чтобы убедиться, что тест краснеет. После фикса тест играет со стратегией уклонения.

Трейс без одной координаты скрывал реальное изменение. Разработчик изменил константу позиционирования на один пиксель — и весь набор тестов остался зелёным, потому что трейс записывал горизонтальную позицию игрока, но не вертикальную. Трейс защищает только то, что он записывает.

Что стоит запомнить

Если у вас есть тест, в названии которого фигурируют слова «детерминированный», «воспроизводимый» или «тот же сид», задайте себе один вопрос: сколько рантаймов он опрашивает?

Если ответ — один, он не может сказать то, что его название обещает. Золотой эталон одного рантайма тоже не поможет. Только дифф между двумя средами выполнения способен обнаружить расхождение, которое формально проходит все проверки.

Движок игры выложен в открытый доступ: github.com/egnaro9/tapdodge-engine — сравнительный скрипт лежит в tools/compare_trace.mjs, а в README приведены числа. Браузерную сборку можно поиграть — именно она используется в тесте как артефакт, который проверяется.