These files had never been tracked anywhere - they lived in a plain directory with no git at all, which is also where the whole reverse-engineering record sat. Code already committed refers to them by name (opponent_substitution.h cites "ANALYSIS.md section 6hh", DebugMenuOverlay.kt cites "DEBUG_MENU.md section 3"), so until now a fresh clone carried references to documents it did not contain. ANALYSIS.md the RE record, and the reason the rest works ARCHITECTURE.md how the mod's pieces fit together ARM64_TRANSLATION_LAYER.md the translation layer's running log PROGRESS.md chronological progress across both chats BETA_TELEMETRY_PLAN.md how crash/telemetry reporting is meant to work LOBBY_UI_DESIGN.md + .html lobby design and its clickable prototype DEBUG_MENU.md debug panel design STATIC_RECOMPILATION_FALLBACK.md the plan if translation had not panned out evidence/ font atlas capture from the glyph-corruption bug save_backups/ saves at known milestones, for reproducing state Co-Authored-By: Claude <noreply@anthropic.com>
20 KiB
Статическая перекомпиляция ARM32 → LLVM IR → ARM64
Статус: запасной путь. Не начинать, пока не исчерпан текущий подход (JIT-трансляция через Unicorn). Документ создан 2026-09-21 по запросу как запись направления, к которому осмысленно вернуться, если скорость движка упрётся в потолок.
1. Когда переключаться на этот путь
Критерий один и он числовой. Текущий подход даёт отставание примерно в 2,6 раза от реального времени. Измеренный потолок его оптимизации:
| шаг | ожидаемый результат |
|---|---|
| обход softmmu (задача #61) | ~1,4x → остаётся ~1,8x отставания |
| дальнейшие микрооптимизации | в лучшем случае 1,3–1,5x, оптимистично |
| натив | недостижим в JIT-подходе |
Переключаться, если: после обхода softmmu игра всё ещё не держит 30 кадров в гонке, и дальнейшие замеры не показывают крупных резервов.
Не переключаться, если: 30 кадров достигнуты. Цель — играбельность, а не бенчмарк.
Цена переключения, которую надо знать заранее
Этот путь обнуляет наработки по мультиплееру. Вся работа по внедрению сетевого кода делалась в расчёте на перехват функций живого ARM32-бинарника через хуки Unicorn. После перекомпиляции бинарника не будет — будет свой нативный код, и точки внедрения придётся искать заново, уже в другом виде (зато, вероятно, удобнее: в статически слинкованном коде можно просто подменить символ).
Это самая серьёзная цена, и она не техническая, а проектная. Учитывать при решении.
2. Что это за подход и чем он отличается от «отреверсить игру»
Это НЕ декомпиляция. Никто не читает код, не восстанавливает классы, не пишет C++ заново.
Машинные инструкции ARM32 механически переводятся в промежуточное представление LLVM, а затем компилируются в нативный ARM64. Инструмент не понимает, что делает код — он сохраняет его поведение команда за командой.
Известные работающие примеры этого класса: N64Recomp (использован для портов игр с Nintendo 64 на PC), аналогичные проекты для PS2 и GameCube.
Почему это даёт скорость, которой не даст JIT
Ключ не в том, что трансляция происходит заранее. Ключ в том, что после лифтинга код попадает в настоящий оптимизирующий компилятор:
| JIT (сейчас) | статическая перекомпиляция | |
|---|---|---|
| Флаги процессора ARM32 | пересчитываются после каждой операции, даже если не нужны | LLVM выбросит мёртвые вычисления |
| Регистры | 16 гостевых мапятся на 31 хостовый, лишние простаивают | распределение регистров с нуля, все 31 |
| Область оптимизации | внутри одного блока трансляции | межпроцедурная, всё приложение |
| Инлайнинг, векторизация | нет | стандартные проходы LLVM |
Именно поэтому это единственный путь к «неотличимо от натива». Для ориентира: Rosetta 2 от Apple — заранее скомпилированная трансляция плюс аппаратная поддержка в процессоре — даёт 70–80% нативной скорости.
3. Почему именно наш случай необычно удобен
Обычные блокеры статической трансляции у нас частично или полностью сняты, и это измеренные факты, а не предположения.
Границы функций известны
В бинарнике есть секция .ARM.exidx — таблица раскрутки стека для исключений C++:
.ARM.exidx 0x97e150 – 0x9aad18 (0x2cbc8 байт)
По 8 байт на запись это ≈22 900 записей, каждая указывает на начало функции. Главная проблема статической трансляции — «где начинается код» — решена самим бинарником. IDA независимо нашла 34 726 функций, что согласуется.
Самомодифицирующегося кода нет
Проверено счётчиком на уровне Unicorn: за полный прогон загрузки пролога — ноль записей гостя в
.text. Это значит, что переведённый код не нужно инвалидировать и перетранслировать.
Код и данные разделены
.text (9,5 МБ) отделён от .rodata, .data, .bss. Не надо угадывать, где инструкции, а где таблицы.
Релокации дают карту указателей
.rel.dyn (387 КБ, ~48 000 записей R_ARM_RELATIVE) перечисляет все места, где лежат адреса.
Это карта того, что является указателем, а что числом.
Чужой код, который действительно не надо переводить — 19,3%, а не треть
Исходная оценка «треть бинарника чужая» оказалась завышенной примерно вдвое. Подсчёт по адресным
диапазонам, подтверждённый тремя независимыми методами (кластеризация ссылок на строки, минимальный
разрез графа вызовов, тип записей .ARM.exidx):
| библиотека | диапазон | функций | байт | vtable внутри |
|---|---|---|---|---|
| zlib 1.2.11 | 0x660400–0x66a000 |
61 | 39 612 | 0 |
| libjpeg | 0x774000–0x790900 |
285 | 114 836 | 0 |
| libpng 1.5.10 | 0x790900–0x7ad844 |
394 | 113 808 | 0 |
| curl 7.56.0 | 0x7c72c4–0x80e9b0 |
748 | 281 300 | 0 |
| OpenSSL 1.1.0f | 0x80e9b0–0x963120 |
5 014 | 1 156 034 | 0 |
| итого | 6 502 | 1 705 590 | 0 |
Границы подтверждены independently: одна запись .ARM.exidx с признаком CANTUNWIND покрывает
1 687 132 байта одним куском — curl и OpenSSL собраны с -fno-unwind-tables, больше ничто в образе так
не собрано. Её концы совпадают с локальными минимумами разреза графа вызовов. И ни одной C++ vtable
внутри этих диапазонов — игровой код туда не затёк.
Три ошибки первой редакции этого документа
| было записано | на самом деле | как проверено |
|---|---|---|
| FMOD влинкован статически | Нет. DT_NEEDED: libfmodex.so, libfmodevent.so — отдельные библиотеки |
readelf -d |
| libc++ влинкован статически | Нет. DT_NEEDED: libc++_shared.so; в образе только заголовочные шаблоны |
readelf -d |
| 2 432 «именованные» функции | Из них 1 170 — автогенерация IDA (nullsub_*), почти всё остальное — PLT-заглушки. Восстановленных внутренних символов практически ноль |
гистограмма префиксов |
Пропущены были libjpeg (опознан по таблице сообщений jerror.c) и Bullet Physics (210 имён
классов bt*).
Почему boost, EASTL и libc++ заменить НЕЛЬЗЯ
Первая редакция утверждала, что шаблоны стандартной библиотеки «пересобираются из заголовков, а не
переводятся». В механическом лифтере это не работает. У лифтера нет исходников. Чтобы не переводить
тело std::vector<Foo>::push_back, нужно опознать инстанцирование, восстановить точную раскладку Foo и
доказать совместимость с хостовой версией — а это ручной реверс, ровно то, ради отказа от чего и выбран
этот путь.
То же касается boost (610 имён классов, 792 vtable, 1 247 виртуальных целей) и EASTL: они размазаны по
всему .text и не отделяются по адресам. Переводить как обычный код.
Что действительно облегчает задачу
Образ целиком в режиме ARM, без Thumb. 2 249 305 инструкций в 8 989 480 байтах — ровно 4,0 байта на
инструкцию. В .data.rel.ro 27 107 указателей на ARM-код против 123 с Thumb-битом. Ноль tbb/tbh,
ноль ldr pc,, ноль mov pc,. Проверено независимо: все 34 экспортируемые функции имеют чётные адреса.
Нет переключения режимов, нет IT-блоков, нет Thumb-таблиц переходов — заметно более простая цель, чем
предполагалось.
Побочно: комментарий в
guest_engine.cpp:3901называет Thumb «единственным реальным режимом этого движка». Это неверно и может ввести в заблуждение. Работе движка не мешает (режим берётся из CPSR), но как ориентир — ошибка.
.ARM.exidx покрывает .text на 100% — 22 905 записей размечают 9 526 532 из 9 527 632 байт.
Дубликаты. 4 918 функций побайтово идентичны и сводятся к 739 различным телам: 1 148 × bx lr
(пустой виртуальный метод), 678 × mov r0,#0; bx lr, 322 × переходник boost::function. Лифтер,
хеширующий тела, выдаёт 739 вместо 4 918 — 4 179 функций бесплатно.
Длинный хвост мелочи. 10 646 функций (31,6%) короче 32 байт и занимают всего 1,4% кода. При этом 1 613 функций (4,8%) длиннее килобайта и занимают 39,6%.
4. Главная нерешённая трудность: косвенные переходы
blx <reg> — вызов по адресу из регистра. Статически неизвестно, куда он ведёт.
Замерено по всем 2 249 305 инструкциям образа:
| количество | |
|---|---|
blx <reg> — косвенные вызовы |
36 232 |
bl — прямые вызовы |
136 075 |
| доля косвенных среди всех вызовов | 21% |
bx <reg> (в основном bx lr, возвраты) |
9 364 + 1 833 условных |
pop/ldm с pc (возвраты) |
27 011 |
ldr pc, / tbb / tbh / mov pc, |
0 |
Сколько целей удаётся собрать статически
| количество | |
|---|---|
| vtable, привязанных к typeinfo | 4 001 |
| слотов в них | 25 631 (из них 441 чисто виртуальных) |
| различных виртуальных целей | 10 624 |
все указатели на код в данных (.data.rel.ro, .got, .data, .init_array) |
28 003 → 12 409 различных целей |
покрытие функций .text |
36,7% |
И вот здесь главная оговорка, которой не было в первой редакции
Первая редакция утверждала, что «большинство виртуальных целей можно собрать статически». Для vtable это верно. Для колбэков — нет.
В этой сборке с позиционно-независимым кодом взятие адреса функции в регистр выглядит как
ldr rX,[pc,#N]; add rX,pc — литерал хранит смещение относительно PC и не требует релокации
(проверено на дизассемблере по адресу 0x7c758). Значит таблица релокаций такие цели не видит.
Мера того, насколько она их не видит: 8 818 функций (1 405 236 байт) не имеют ни одного входящего прямого вызова, ни одного указателя из данных. Часть — мёртвый код, оставленный компоновщиком. Остальное — колбэки, достижимые только анализом литеральных пулов.
Отсюда следует порядок работ: первым делом нужен не транслятор, а сканер литеральных пулов. Если покрытие косвенных целей не удастся поднять существенно выше 37%, то запасной путь через хеш-таблицу «адрес → функция» съест ровно тот выигрыш в скорости, ради которого всё затевается.
5. Что ещё придётся решить
| задача | сложность | комментарий |
|---|---|---|
| Исключения C++ | высокая | Есть .ARM.exidx/.ARM.extab. Раскрутка стека ARM32 не переносится на ARM64 напрямую — нужна либо своя реализация, либо отображение на нативные исключения |
| Модель памяти | средняя | ARM32 и ARM64 имеют разные гарантии упорядочивания. При многопоточности возможны тонкие гонки, которых не было на оригинале |
| Точность флагов | средняя | Где флаги реально читаются — надо сохранить. LLVM выбросит лишнее только если правильно разметить |
| JNI-граница | низкая | Уже решена в текущем движке, переносится почти как есть |
| Системные вызовы и libc | низкая | Уже есть полный набор шимов, линкуется напрямую |
6. Первый шаг, если решим начать
Не писать транслятор. Порядок такой:
- Сканер литеральных пулов (см. раздел 4). Это главный риск всего направления, и он проверяется раньше всего. Цель — поднять покрытие косвенных целей существенно выше 37%. Если не выходит — направление не окупается, и лучше узнать это на первом шаге.
- Покрытие кода. Включить блочный профилировщик (
EnableProfiling(), уже есть в движке) и записать исполненные адреса за полный сеанс: загрузка, меню, гонка, финиш. Это покажет, сколько из 23 000 тел реально работает, а сколько — мёртвый код. - Прототип на одной функции. Перевести одну чистую вычислительную функцию через LLVM IR, подставить в работающий движок вместо эмулируемой и замерить. Это даст реальный коэффициент ускорения — единственную цифру в этом документе, которая будет фактом, а не оценкой.
7. Объём работ — итоговая таблица
| категория | функций | доля | что делать |
|---|---|---|---|
| zlib, libjpeg, libpng, curl, OpenSSL | 6 502 | 19,3% | заменить линковкой — непрерывные диапазоны, ноль vtable |
| Bullet Physics | 1 828 | 5,4% | переводить (замена — отдельное исследование) |
| фреймворки EA, однозначные | 3 784 | 11,2% | переводить; обёртка GLES — единственный кандидат на замену |
| смешанные области EA и игры | 3 798 | 11,3% | переводить, по адресам не разделяются |
| игра и движок | 17 783 | 52,8% | переводить — неустранимое ядро |
Всего реальных функций в .text: 33 695 (прежние 34 726 включали 516 заглушек PLT и 515
плейсхолдеров импорта, которые кодом не являются).
Требуют механического перевода: 27 193. После дедупликации по содержимому — около 23 000 различных тел, ~1,82 млн инструкций ARM.
8. Честный вывод
Направление выполнимо в том смысле, что 23 000 тел — работа для машины, а не для человека. Условия
лучше, чем казалось: режим только ARM без переключений, .ARM.exidx покрывает код на 100%, все 2 310
имён классов RTTI сохранились целиком, 4 001 vtable дают 10 624 разрешённых виртуальных цели, а треть
функций короче 32 байт.
Но главный риск не в объёме, а в 36 232 косвенных вызовах, чьи цели собираются лишь частично. Начинать надо с проверки именно этого, а не с транслятора.
И цена из раздела 1 остаётся в силе: этот путь обнуляет наработки по мультиплееру.