Files
nfsmw-online/docs/STATIC_RECOMPILATION_FALLBACK.md
T
megboyzzandClaude e7c76fc2dd docs: bring 1.8 MB of project documentation under version control
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>
2026-09-22 23:29:12 +03:00

20 KiB
Raw Blame History

Статическая перекомпиляция 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 0x6604000x66a000 61 39 612 0
libjpeg 0x7740000x790900 285 114 836 0
libpng 1.5.10 0x7909000x7ad844 394 113 808 0
curl 7.56.0 0x7c72c40x80e9b0 748 281 300 0
OpenSSL 1.1.0f 0x80e9b00x963120 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. Первый шаг, если решим начать

Не писать транслятор. Порядок такой:

  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 остаётся в силе: этот путь обнуляет наработки по мультиплееру.