Пять способов подмены¶
Режим Rockstar держит файлы игры чистыми, пока игрок не играет. Модифицированные файлы набора (update\update.rpf, dlc.rpf ганпаков и звуковые паки из x64\audio\sfx, у которых рядом лежит .bak с оригиналом) уезжают в «образ» вне папки игры, а внутрь игры кладётся чистая копия - лаунчер Rockstar при проверке целостности видит ровно то, что ожидает. На время сессии моды подставляются обратно.
Способов подмены пять, и это не пять отдельных реализаций. Это три независимые оси, перемноженные не полностью: кто решает, что игра началась (агент или игрок кнопкой), где физически лежит образ (том игры или выбранная папка) и чем именно переставляется файл (File.Replace или копирование через временный файл). Пятый способ - клон поведения ReplaceX: агент, копирование и гашение процессов Rockstar перед возвратом чистых файлов.
Нумерация зафиксирована контрактом с интерфейсом (мост шлёт голое число), поэтому значения в enum проставлены явно. Переставить их - молча сломать конфиги уже установленных клиентов.
Три оси вместо пяти веток¶
public enum HotSwapTrigger { Agent = 0, Manual = 1 }
public enum HotSwapStoreKind { GameVolumeDefault = 0, CustomFolder = 1 }
public enum HotSwapPrimitive { AtomicReplace = 0, SafeCopy = 1 }
Свойства способа собраны в HotSwapPlan - одну таблицу, из которой их читают агент, своппер и восстановление. Остальной код спрашивает у плана свойство, а не сравнивает номер способа: иначе каждая новая ось расползлась бы if'ами по трём подсистемам сразу.
Из примитива выводится и требование к тому: RequireSameVolume => Primitive == AtomicReplace. File.Replace - функция NTFS и работает только внутри одного тома; для копирования такого ограничения нет.
Таблица способов¶
| № | Триггер | Хранилище образа | Примитив | Опрос | Дебаунс разоружения | Гасим процессы игры | Список процессов |
|---|---|---|---|---|---|---|---|
| 1 | агент | том игры | File.Replace |
250 мс | 3000 мс | нет | боевой |
| 2 | агент | выбранная папка | File.Replace |
250 мс | 3000 мс | нет | боевой |
| 3 | кнопка | том игры | File.Replace |
1000 мс | 0 мс | нет | не используется |
| 4 | кнопка | выбранная папка | копирование | 1000 мс | 0 мс | нет | не используется |
| 5 | агент | том игры | копирование | 500 мс | 0 мс | да | ReplaceX |
Шаг опроса у способов 3 и 4 стоит не «ноль»: цикл агента всё равно крутится (он чинит незавершённые операции и пишет heartbeat), просто за процессами не следит. Дебаунс 0 мс у способа 5 - это не «мгновенно»: вместо ожидания тишины он сам гасит хвосты Rockstar и ждёт фиксированную секунду.
private static readonly Dictionary<HotSwapMethod, HotSwapPlan> Table = new()
{
[HotSwapMethod.AgentSameFolder] = new HotSwapPlan(
HotSwapMethod.AgentSameFolder, HotSwapTrigger.Agent, HotSwapStoreKind.GameVolumeDefault,
HotSwapPrimitive.AtomicReplace, false, 250, 3000, false, "..."),
// ...
[HotSwapMethod.ReplaceXCopy] = new HotSwapPlan(
HotSwapMethod.ReplaceXCopy, HotSwapTrigger.Agent, HotSwapStoreKind.GameVolumeDefault,
HotSwapPrimitive.SafeCopy, true, 500, 0, true, "..."),
};
public static HotSwapMethod Normalize(int method) =>
Table.ContainsKey((HotSwapMethod)method) ? (HotSwapMethod)method : HotSwapMethod.AgentSameFolder;
Normalize читает старый конфиг и любой мусор из интерфейса как способ 1: историческое поведение обязано быть дефолтом. Способ 1 при этом обязан остаться байт-в-байт прежним - он стоит у всех уже установленных клиентов.
Ось триггера: агент или кнопка¶
Агент - это тот же exe лаунчера, запущенный с ключом --hotswap-agent. Отдельный exe тянул бы за собой либо .NET на машине игрока, либо ещё один рантайм на 70 МБ.
Способ агент берёт не из галочки в настройках, а из привязки образа: пока моды разложены, работать надо тем способом, которым их раскладывали.
var plan = HotSwapPlan.For(HotSwapStore.ActiveMethod(gta));
using var procs = GameProcessWatcher.ProcSnapshot.Take();
sleepMs = !starts.Active && GameProcessWatcher.RockstarLauncherRunning(procs)
? Math.Min(plan.PollMs, 30)
: plan.PollMs;
Три числа из этого фрагмента объясняются отдельно.
Один снимок процессов на итерацию. Раньше их было четыре: три GetProcessesByName на имена лаунчера плюс GetProcesses на поиск игры. Замер на машине с 435 процессами: один обход - 7,6 мс, итерация агента до правок - 25 мс. При шаге 250 мс это шестнадцать полных снимков таблицы процессов в секунду в покое и полторы сотни при открытом лаунчере Rockstar, круглые сутки, в фоновом процессе. Отсюда жалобы «компьютер шумит в 2-4 раза громче». Нативный CreateToolhelp32Snapshot пробовали - вышло хуже: 13,2 мс против 7,6 мс на тех же 435 процессах.
Шаг 30 мс рядом с лаунчером Rockstar. Игра стартует именно из него, и цена опоздания - не подставившийся мод. Этот частый шаг остаётся только как запасной путь: обычно поднята подписка на Win32_ProcessStartTrace, и о старте процесса Windows сообщает сама за миллисекунды.
_watcher = new ManagementEventWatcher(new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"));
_watcher.EventArrived += OnStart;
_watcher.Start();
Active = true;
Подписка ничего не гарантирует и никого не заменяет: опрос остаётся, просто с редким шагом, а событие лишь будит цикл раньше таймаута. Не поднялась (сломанный репозиторий WMI, нет прав) - Active остаётся false, и агент возвращается к прежней частоте.
Остальные периоды цикла: heartbeat и HotSwapRecovery.EnsureConsistent - раз в 10 с; проверка «наш ли ещё файл в игре» во время сессии - раз в 5 с (пара FileInfo, но четыре раза в секунду по гигабайтному файлу подряд с игрой - лишний ввод-вывод); пауза после неудачного arm при живой игре - 10 с. Последнее число не про производительность, а про лог: отказ подмены повторяется каждый шаг опроса, а причина у него обычно вечная на всю сессию, и без паузы пятимегабайтный hotswap.log проворачивался за полчаса игры, унося ровно те строки, ради которых он заведён.
У ручных способов цикл всё равно проходит эту ветку и выходит из неё:
if (plan.Trigger == HotSwapTrigger.Manual)
{
wasGame = false;
gameGoneAt = DateTime.MaxValue;
Sleep(starts, sleepMs);
continue;
}
Проверка живёт внутри цикла, а не сводится к «просто не запускаем агента»: агент мог остаться зарегистрированным в Планировщике от прошлого способа, и подменять файлы за спиной у ручного триггера он не должен. Сброс wasGame тут же - страховка от того, чтобы после смены способа агент однажды решил, что игра «закрылась», и снял моды посреди сессии.
Дебаунс разоружения: 3000 мс против 0¶
Игра пропала из процессов - это ещё не выход из игры. RageMP и EAC перезапускают процессы по цепочке, и между звеньями таблица процессов пуста.
if ((DateTime.UtcNow - gameGoneAt).TotalMilliseconds >= plan.DisarmDebounceMs)
{
if (plan.KillGameBeforeReturn)
{
int killed = GameProcessWatcher.KillReturnBlockers(gta);
Thread.Sleep(1000);
}
GameFileSwapper.Disarm(gta, plan.Method, out var err);
}
Способы 1 и 2 ждут 3 с тишины. Способ 5 не ждёт вовсе, потому что решает ту же задачу иначе: он гасит процессы, которые держат update.rpf открытым, и даёт секунду на закрытие хендлов. Без этого возврат упирается в «файл занят» - RockstarService и SocialClubHelper живут рядом с игрой.
Побочный эффект у гашения есть, и он же - главное преимущество способа 5. Rockstar Games Launcher, живущий рядом всю сессию, видит подменённый update.rpf и заказывает ремонт: перекачку двух гигабайт. Способ 5 гасит его перед возвратом и этой беды не знает, остальные знают - поэтому Arm при живом лаунчере и не гасящем плане пишет в лог прямое предупреждение.
Ось хранилища: том игры или выбранная папка¶
public static string RootFor(string gtaRoot, HotSwapMethod method, string? storeRoot)
{
var plan = HotSwapPlan.For(method);
if (plan.Store != HotSwapStoreKind.CustomFolder) return DefaultRoot(gtaRoot);
if (!string.IsNullOrWhiteSpace(storeRoot))
return Path.Combine(Path.GetFullPath(storeRoot!), "MiamiGraphics", "hotswap");
return FallbackCustomRoot(gtaRoot, method);
}
Дефолт - <том игры>:\MiamiGraphics\hotswap. Внутрь выбранной игроком папки всегда дописывается тот же хвост MiamiGraphics\hotswap, чтобы разморозка сносила ровно своё и не трогала чужие файлы.
FallbackCustomRoot появился не для красоты. Раньше способы 2 и 4 отбивались проверкой тома с просьбой выбрать папку, а выбрать её в интерфейсе негде - то есть включить их было нельзя вообще. Значения подобраны по смыслу оси: способу 2 нужен тот же том игры (атомарная замена работает только внутри тома), но другая папка, чем у способа 1, - <том игры>:\MiamiGraphicsSwap\hotswap; способу 4 том безразличен, и образ уезжает в %LocalAppData%\MiamiGraphics\hotswap_store\hotswap.
Пока образ существует, источник правды - не конфиг, а файл store.json рядом с игрой. Конфиг живёт в %LocalAppData% и переживает что угодно: переустановку лаунчера, сброс настроек, ручную правку способа в интерфейсе. Два гигабайта модов - нет. Потеряв конфиг, мы обязаны найти образ по одному пути игры, поэтому привязка всегда пишется по дефолтному пути (он выводится из пути игры), даже когда сам образ уехал в чужую папку или на другой том.
Что проверяется перед включением¶
HotSwapPaths.VolumeSupported задаёт три вопроса, по одному на ось.
| Ось | Проверка | Отказ |
|---|---|---|
| Примитив | RequireSameVolume и образ на другом томе |
«мгновенная подмена работает только внутри одного диска» |
| Примитив | RequireSameVolume и файловая система не NTFS |
называет фактическую ФС |
| Хранилище | выбранная папка лежит внутри папки игры | лаунчер Rockstar сканирует свою папку и найдёт моды |
| Место | не хватает на диске образа | называет диск и требуемые ГБ |
| Место | не хватает на диске игры под временный файл | называет диск и требуемые ГБ |
Арифметика места разная у примитивов: rename-способам нужен один размер набора, копирующим - два (modded и clean живут одновременно) плюс запас под временный файл рядом с игрой, размером с самый крупный файл набора. Резерв сверху: 512 МБ на диске образа, 256 МБ на диске игры. Если оба корня на одном томе, требования складываются.
Ось примитива: File.Replace против копирования¶
Общее правило у обоих движков одно: файл игры никогда не пишется на месте. Он либо участвует в rename-транзакции, либо заменяется готовым соседним файлом одним rename'ом. Причина - размер: прерванная запись «поверх» оставила бы игроку обрезанный двухгигабайтный архив, а это чинится только полной перекачкой игры.
ReplaceSwapEngine (способы 1, 2, 3)¶
Три копии ходят по кругу одним вызовом File.Replace - rename-транзакцией NTFS, за миллисекунды на любом размере.
ARM Replace(modded → game, clean-в-store): игра = МОДЫ
DISARM Replace(clean → game, modded-в-image): игра = ЧИСТЫЙ
Отсюда и способ учёта состояния: в каждый момент занят ровно один слот образа, и наличие clean-копии само по себе означает «моды в игре». Временных файлов движок не создаёт - File.Replace сам себе транзакция. Занятый файл ретраится 12 раз с паузой 250 мс.
CopySwapEngine (способы 4 и 5)¶
Обе копии лежат в образе постоянно, а в игру кладётся третья - копия одной из них. Так делает и ReplaceX, и это не блажь: гонять два гигабайта «по кругу» означало бы на каждый вход в игру писать вдвое больше - сначала спрятать текущий файл, потом положить новый.
Раз копии не двигаются, состояние по их наличию не прочитать. Источник правды - отпечаток файла игры: длина плюс LastWriteTimeUtc.Ticks. File.Copy и File.Move сохраняют время записи, поэтому файл игры совпадает по отпечатку ровно с тем слотом, из которого он скопирован. Отдельный файл состояния был бы четвёртой сущностью, способной разъехаться с реальностью.
private static Slot Probe(string gtaRoot, string rel)
{
var game = HotSwapPaths.GamePath(gtaRoot, rel);
if (!File.Exists(game)) return Slot.Missing;
if (HotSwapFileOps.StampEq(game, HotSwapPaths.ModdedPath(gtaRoot, rel))) return Slot.Armed;
if (HotSwapFileOps.StampEq(game, HotSwapPaths.CleanPath(gtaRoot, rel))) return Slot.Clean;
return Slot.Unknown;
}
public bool CanDisarm(string gtaRoot, string rel) =>
File.Exists(HotSwapPaths.CleanPath(gtaRoot, rel)) && Probe(gtaRoot, rel) == Slot.Armed;
CanDisarm требует именно Armed, и раньше сюда попадало ещё и Unknown - по логике «ошибиться в сторону ванили безопасно». На деле небезопасно: если Rockstar Launcher обновил игру, пока журнал висел в Armed, в update.rpf лежит свежий файл новой версии, который под наше описание не подходит и читается как Unknown. Копировать поверх него старую чистую копию - откатить игру к прошлой версии и получить «files corrupted».
Целостность копирования держится на записи через соседа:
public const string TempSuffix = ".mgswap.tmp";
public static void CopyThroughTemp(string source, string dest, int attempts = 12)
{
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
var tmp = TempFor(dest);
DeleteQuiet(tmp);
try { CopyWithRetry(source, tmp, attempts); }
catch { DeleteQuiet(tmp); throw; }
MoveOverwriteWithRetry(tmp, dest, attempts);
}
Временный файл пишется в тот же каталог, значит гарантированно на тот же том, и только полностью записанный встаёт на место одним rename'ом. Падение или выключение света посреди двухгигабайтной копии оставляет мусорный .tmp, а не обрезанный update.rpf. Хвосты убирает SanitizeTemp, и только те, которым больше 2 минут: рядом может работать агент, и удалить его недописанный .tmp значило бы уронить идущую подмену.
Ручной триггер и его предпроверки¶
Способы 3 и 4 управляются двумя кнопками в настройках - «Захожу в игру» и «Вышел из игры». Мост зовёт HotSwapManual.ArmNow / DisarmNow. Здесь же живут все проверки, которые в агентном сценарии делает цикл: без них игрок жал бы кнопку в произвольный момент и получал «файл занят» после двенадцати ретраев вместо внятного ответа.
private static bool Precheck(string gtaRoot, out HotSwapMethod method, out string? error)
{
if (string.IsNullOrWhiteSpace(gtaRoot) || !Directory.Exists(gtaRoot)) { /* путь игры не найден */ }
var mode = HotSwapModeStore.Read();
if (!mode.Enabled) { /* режим Rockstar выключен */ }
method = HotSwapStore.ActiveMethod(gtaRoot);
if (!HotSwapPlan.IsManual(method)) { /* способ подставляет моды сам */ }
if (GameFileSwapper.ReadSet(gtaRoot).Count == 0) { /* образ не собран */ }
return true;
}
Порядок проверок:
- Путь игры из конфига существует.
- Режим Rockstar включён.
- Активный способ - ручной. Способ берётся по привязке образа, а не по галочке: сообщение об отказе называет тот способ, которым образ реально собран.
- Набор файлов образа не пуст (
swapset.jsonесть и не пустой). - Игру не держит запущенный процесс.
Пятая проверка - самая тонкая, потому что списков процессов два и путать их нельзя.
if (GameProcessWatcher.FindRpfHolderProcess() is not null)
{
error = Loc.T("error.gtaAlreadyRunningArm");
return false;
}
FindRpfHolderProcess спрашивает про держателя файла, а не «пора ли армить». Разница в PlayGTAV: это стаб Rockstar, который порождает GTA5.exe, он живёт сам по себе, update.rpf не открывает и висит в процессах, когда игры нет и в помине. Пока список был один, человек, нажавший Play в лаунчере Rockstar и следом «Захожу в игру», получал отказ «GTA уже запущена» вместо подмены.
На возврате та же ошибка стоила дороже. Зависший PlayGTAV не давал вернуть чистые файлы вообще: игрок уходил к лаунчеру Rockstar с модами в игре - ровно в ту ситуацию, от которой режим и защищает.
После проверок ArmNow зовёт HotSwapRecovery.EnsureConsistent (дочистить незавершённые операции прошлого раза) и только потом GameFileSwapper.Arm. DisarmNow перед возвратом смотрит на plan.KillGameBeforeReturn - ветка написана от плана, а не от номера способа; у нынешних пяти способов гашение стоит только у пятого, а он агентный, так что на ручном пути она не срабатывает.
Списки процессов¶
| Список | Кто входит | Где используется |
|---|---|---|
ArmProcesses |
PlayGTAV, GTA5, GTA5_Enhanced, GTA5_Enhanced_BE, EACLauncher, RageMP, ragemp_v, altv, altv-client |
агент способов 1 и 2: «пора армить» |
RpfHolderProcesses |
то же без PlayGTAV |
гейты «сначала закрой игру» |
ReplaceXArmProcesses |
то же без PlayGTAV, плюс EasyAntiCheat_Launcher и Launcher |
агент способа 5 |
ReturnBlockers |
GTA5, GTA5_Enhanced, GTA5_Enhanced_BE, SocialClubHelper, Launcher, RockstarService, PlayGTAV, LauncherPatcher |
что гасит способ 5 перед возвратом |
PlayGTAV стоит в боевом списке первым не по алфавиту. GTA открывает update.rpf в первые миллисекунды после старта, а об GTA5.exe мы узнавали только со следующего опроса и подменяли файл, который игра уже держала открытым: переименование проходило без ошибки, но игра продолжала читать чистую копию через старый хендл. Отсюда «агент работает через раз». Реакция на стаб даёт запас в сотни миллисекунд.
Общий EasyAntiCheat_launcher из боевого списка, наоборот, убран: его запускают десятки чужих игр, и моды подставлялись бы под чужую сессию. В списке способа 5 неоднозначные имена (Launcher, EasyAntiCheat_Launcher) остались, потому что ReplaceX подменяет файлы уже при запуске лаунчера Rockstar, - но для них включается проверка пути процесса: путь обязан вести в папку игры или содержать Rockstar, Grand Theft Auto, GTAV, RAGEMP, altv. Путь не прочитался - ответ «нет»: лучше не подставить моды, чем подставить их под чужую игру.
Список неоднозначных имён намеренно узкий - только те, которых нет в историческом ArmProcesses. Добавить туда altv или EACLauncher значило бы поменять поведение способа 1.
Что показывает интерфейс¶
В настройках сейчас предлагаются два способа: 1 («автоматически, при запуске игры») и 3 («вручную, по кнопке»). Способы 2, 4 и 5 остаются в ядре и продолжают работать у тех, у кого уже записаны в привязке образа, - для них в интерфейсе есть только текстовые названия. Выбор из интерфейса нормализуется: 4 читается как 3, всё остальное неизвестное - как 1.
Кнопки ручного триггера показываются только там, где что-то делают: HotSwapManual.IsManualMode спрашивает у плана активного способа, ручной ли он.
Чистый источник для заморозки берётся из бэкапов - см. Pipeline backup.