docs: bring living project documentation under version control

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>
This commit is contained in:
2026-09-22 23:48:32 +03:00
co-authored by Claude
parent 239a9a6346
commit 944aa68b96
9 changed files with 8070 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
# Статическая перекомпиляция 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 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.**