Перейти к содержанию

Пять способов подмены

Режим Rockstar держит файлы игры чистыми, пока игрок не играет. Модифицированные файлы набора (update\update.rpf, dlc.rpf ганпаков и звуковые паки из x64\audio\sfx, у которых рядом лежит .bak с оригиналом) уезжают в «образ» вне папки игры, а внутрь игры кладётся чистая копия - лаунчер Rockstar при проверке целостности видит ровно то, что ожидает. На время сессии моды подставляются обратно.

Способов подмены пять, и это не пять отдельных реализаций. Это три независимые оси, перемноженные не полностью: кто решает, что игра началась (агент или игрок кнопкой), где физически лежит образ (том игры или выбранная папка) и чем именно переставляется файл (File.Replace или копирование через временный файл). Пятый способ - клон поведения ReplaceX: агент, копирование и гашение процессов Rockstar перед возвратом чистых файлов.

Нумерация зафиксирована контрактом с интерфейсом (мост шлёт голое число), поэтому значения в enum проставлены явно. Переставить их - молча сломать конфиги уже установленных клиентов.

Три оси вместо пяти веток

MiamiGraphics.Core/HotSwap/HotSwapMethod.cs
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 и ждёт фиксированную секунду.

MiamiGraphics.Core/HotSwap/HotSwapMethod.cs
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 МБ.

Способ агент берёт не из галочки в настройках, а из привязки образа: пока моды разложены, работать надо тем способом, которым их раскладывали.

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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 сообщает сама за миллисекунды.

MiamiGraphics.Core/HotSwap/ProcessStartNotifier.cs
_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 проворачивался за полчаса игры, унося ровно те строки, ради которых он заведён.

У ручных способов цикл всё равно проходит эту ветку и выходит из неё:

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
if (plan.Trigger == HotSwapTrigger.Manual)
{
    wasGame = false;
    gameGoneAt = DateTime.MaxValue;
    Sleep(starts, sleepMs);
    continue;
}

Проверка живёт внутри цикла, а не сводится к «просто не запускаем агента»: агент мог остаться зарегистрированным в Планировщике от прошлого способа, и подменять файлы за спиной у ручного триггера он не должен. Сброс wasGame тут же - страховка от того, чтобы после смены способа агент однажды решил, что игра «закрылась», и снял моды посреди сессии.

Дебаунс разоружения: 3000 мс против 0

Игра пропала из процессов - это ещё не выход из игры. RageMP и EAC перезапускают процессы по цепочке, и между звеньями таблица процессов пуста.

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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 при живом лаунчере и не гасящем плане пишет в лог прямое предупреждение.

Ось хранилища: том игры или выбранная папка

MiamiGraphics.Core/HotSwap/HotSwapStore.cs
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 сохраняют время записи, поэтому файл игры совпадает по отпечатку ровно с тем слотом, из которого он скопирован. Отдельный файл состояния был бы четвёртой сущностью, способной разъехаться с реальностью.

MiamiGraphics.Core/HotSwap/CopySwapEngine.cs
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».

Целостность копирования держится на записи через соседа:

MiamiGraphics.Core/HotSwap/HotSwapFileOps.cs
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. Здесь же живут все проверки, которые в агентном сценарии делает цикл: без них игрок жал бы кнопку в произвольный момент и получал «файл занят» после двенадцати ретраев вместо внятного ответа.

MiamiGraphics.Core/HotSwap/HotSwapManual.cs
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;
}

Порядок проверок:

  1. Путь игры из конфига существует.
  2. Режим Rockstar включён.
  3. Активный способ - ручной. Способ берётся по привязке образа, а не по галочке: сообщение об отказе называет тот способ, которым образ реально собран.
  4. Набор файлов образа не пуст (swapset.json есть и не пустой).
  5. Игру не держит запущенный процесс.

Пятая проверка - самая тонкая, потому что списков процессов два и путать их нельзя.

MiamiGraphics.Core/HotSwap/HotSwapManual.cs
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.