# Статическая перекомпиляция 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::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 ` — вызов по адресу из регистра. Статически неизвестно, куда он ведёт. **Замерено по всем 2 249 305 инструкциям образа:** | | количество | |---|---| | `blx ` — косвенные вызовы | **36 232** | | `bl` — прямые вызовы | 136 075 | | доля косвенных среди всех вызовов | **21%** | | `bx ` (в основном `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. Первый шаг, если решим начать **Не писать транслятор.** Порядок такой: 1. **Сканер литеральных пулов** (см. раздел 4). Это главный риск всего направления, и он проверяется раньше всего. Цель — поднять покрытие косвенных целей существенно выше 37%. Если не выходит — направление не окупается, и лучше узнать это на первом шаге. 2. **Покрытие кода.** Включить блочный профилировщик (`EnableProfiling()`, уже есть в движке) и записать исполненные адреса за полный сеанс: загрузка, меню, гонка, финиш. Это покажет, сколько из 23 000 тел реально работает, а сколько — мёртвый код. 3. **Прототип на одной функции.** Перевести одну чистую вычислительную функцию через 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 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.**