fix(runtime): preserve native mission outcome guards
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-10-20 03:20:20 +04:00
1 parent 65f1298d93
commit dc8be18458
6 files changed
+524 -68

No files matched your search

+63 -18
View File
@@ -343,21 +343,20 @@ remaining script event semantics.
текстовых значений по умолчанию. `resolve_loaded_handler30` возвращает точную
команду обратного вызова `(mode=0, first, second)`, не приписывая ей
дополнительный смысл. Отдельная функция `apply_loaded_script_host_callback`
отображает в Rust состояние исхода только `(0, 0, payload=0|1)`; она не
выбирает событие `.scr` и не исполняет ветвь оригинальной игры.
моделирует в Rust состояние исхода для `(0, 0, payload=0|1)`; она не выбирает
событие `.scr` и не вызывает машинный код оригинала.
Снимок работающей GOG AutoDemo определяет обратный вызов: прочитанный без записи
указатель по `ai.dll + 0x555e4` равен `0x100611d0`, то есть `iron3d.dll +
0x611d0` при наблюдавшейся базе загрузки. Восстановленное ABI вызываемой
функции — `__cdecl(mode, command, payload)`; `Handler(30)` передаёт те же поля как
`(0, first, second)`. В режиме `mode == 0` команда `command == 0` выполняется,
только если контрольный байт по `IGame + 0xe5` равен нулю. Ветвь отказа при
значении аргумента `payload`, равном `0`, срабатывает лишь когда слово по
`IGame + 8` не равно `1`: игра запускает
`VOICE_MISSION_FAIL`, записывает `1` в это слово и обнуляет байт по
`*(IGame + 4) + 0x157`. Ветвь завершения при `payload`, равном `1`, срабатывает
лишь когда слово не равно `0`: игра запускает `VOICE_MISSION_COMPLETE`, записывает
`0` в слово и устанавливает байт в `1`. Команды 3, 4 и 5 ведут к другим
`(0, first, second)`. Обратный вызов принимает интересующую нас команду только
при `mode == 0`, `command == 0` и нулевом байте `IGame + 0xe5`. Ветвь отказа
(`payload == 0`) запускает `VOICE_MISSION_FAIL`, записывает `1` в слово `IGame + 8`
и обнуляет байт `*(IGame + 4) + 0x157`, только если прежнее слово не равно `1`.
Ветвь завершения (`payload == 1`) запускает `VOICE_MISSION_COMPLETE`, записывает
`0` в слово и устанавливает байт в `1`, только если прежнее слово не равно `0`.
Оба звуковых вызова предшествуют этим записям. Команды 3, 4 и 5 ведут к другим
ветвям игры; режим `2` выполняет отдельный вызов IGame, а ветви для режима `1`
в этой функции нет.
@@ -376,14 +375,60 @@ remaining script event semantics.
повторных вызовов, но не назначение сообщения интерфейса или смысл этих
идентификаторов; в Rust команда `1` поэтому остаётся `Unhandled`.
Сейчас `apply_loaded_script_host_callback` отображает `(0, 0, 0)` в локальное
состояние `Failed`, `(0, 0, 1)` — в `Completed`, а повтор уже записанного
состояния возвращает `NoChange`. Эта дедупликация относится только к модели
Rust и не заменяет проверки Iron3D: в Rust нет ни байта блокировки
`IGame + 0xe5`, ни слова состояния `IGame + 8`. Значение `Active` назначается
при загрузке миссии, но его соответствие исходному состоянию игры пока не
подтверждено. Команды 1, 3, 4, 5 и режим 2 остаются `Unhandled` в этом
обработчике исхода.
### Состояние обратного вызова исхода миссии
Слово `IGame + 8` создаётся не загрузчиком миссии. В исследованной GOG-сборке
`iron3d.dll` (SHA-256
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`, image base
`0x10000000`) конструктор `FUN_1005c820` записывает туда `5` (RVA `0x5c84f`), а
байт `IGame + 0xe5` обнуляет (RVA `0x5c9f6`). При настройке игры функция
`FUN_1005cbb0` задаёт `+0xe5` выражением `(*AppState == 3)` (RVA `0x5cc4a`);
обработчик таймера главного меню выставляет режим `3` для AutoDemo (RVA `0x70f3`).
Отдельный обработчик выбора миссии записывает режим `1` (RVA `0x29142`) перед
переходом к пути `missions/single.*`. После успешной стандартной инициализации
игровой цикл записывает в `IGame + 8` значение `4` (`FUN_1005eb70`, RVA `0x5eb70`).
Этот переход запуска не выполняется при статическом просмотре миссии и здесь не
моделируется.
`MissionRequest` в Rust несёт явный `MissionMode::{SingleMission, AutoDemo}`;
режим задаёт вызывающая сторона и его нельзя выводить из имени пути. Во время
загрузки Rust инициализирует своё слово состояния значением `5`, как у нового
IGame, и сохраняет запрошенный режим; сама загрузка не вызывает нативный
конструктор.
Публичный `apply_loaded_script_host_callback` применяет те же интересующие нас
проверки: режим `AutoDemo` закрывает ветвь, а в `SingleMission` обратный вызов
меняет только слово состояния и исход Rust, если выполняется исходное условие
по этому слову. Поэтому при состоянии `4` ветвь завершения разрешена, как и
ветвь отказа, пока слово не равно `1`. Повторный отказ при состоянии `1` либо
завершение при состоянии `0` ничего не меняют; если тест намеренно
рассинхронизирует исход Rust и слово состояния, повтор оставляет это
расхождение. Так работает исходная проверка слова, а не выравнивание всех полей.
Изолированное сравнение с оригинальной DLL выполнено небольшой тестовой
x86-программой `crates/fparkan-runtime/tests/original_host_callback_oracle.cs`:
программа проверяет SHA-256, загружает `iron3d.dll` и вызывает RVA `0x611d0` с
синтетическими IGame и AppState. Для изоляции проверки она временно подставляет
синтетический IGame через глобальную переменную `DAT_1010a5f8` (RVA `0x10a5f8`)
и заменяет слот таблицы импортов звукового интерфейса (RVA `0xe42cc`)
регистратором; машинный код обратного вызова остаётся исходным. Проверка сверяет
записи полей, повторные/обратные переходы, блокирующий `+0xe5` и порядок вызова
звука. Это
подтверждает локальную семантику обратного вызова, но не запуск исходной миссии,
диспетчеризацию Handler(30) или весь игровой цикл. Rust пока не запускает
диспетчеризацию сценария автоматически. Команды 1, 3, 4, 5 и режим 2 остаются
`Unhandled` в этом обработчике исхода.
Повторить из корня репозитория в PowerShell на Windows с установленными GOG
ресурсами можно так:
```powershell
$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
New-Item -ItemType Directory -Force -Path target\original-host-callback-probe | Out-Null
& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /out:target\original-host-callback-probe\HostCallbackOracle.exe crates\fparkan-runtime\tests\original_host_callback_oracle.cs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& .\target\original-host-callback-probe\HostCallbackOracle.exe
exit $LASTEXITCODE
```
### Handler(8): problem-record state write