ANALYSIS.md/ARCHITECTURE.md/PROGRESS.md and the rest of this project's living docs have always lived one directory above this repo's root (NFSMW_Online_Claude_workdir/*.md), so they were never actually part of this git history despite being the authoritative record of every hook, offset, and RE finding this branch's code is built on. Mirrors the docs/ layout already used on native-arm32-trace-harness so both branches reference the same file set by name, pending the actual code merge (see PROGRESS.md's own "Repo merge pending" note). Co-Authored-By: Claude <noreply@anthropic.com>
258 lines
20 KiB
Markdown
258 lines
20 KiB
Markdown
# Статическая перекомпиляция 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. Первый шаг, если решим начать
|
||
|
||
**Не писать транслятор.** Порядок такой:
|
||
|
||
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 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.**
|