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

258 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Статическая перекомпиляция 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 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.**