Режим Rockstar (hot-swap)¶
Rockstar Games Launcher сверяет файлы установки со своим манифестом и возвращает изменённые к оригиналу. Модифицированный update.rpf в папке игры живёт ровно до ближайшей такой проверки: лаунчер выкачивает поверх наши 2 ГБ обратно на свои, и сборка исчезает без единого сообщения. Спросить нас никто не спрашивает - достаточно, чтобы окно лаунчера было открыто рядом с игрой.
Режим Rockstar (в коде - hot-swap) решает это тем, что в покое в папке игры нет ни одного модифицированного файла. Моды уезжают в «образ» вне папки игры, игре отдаются чистые копии. Подмена происходит в момент старта игры и откатывается после её закрытия - проверять лаунчеру нечего.
Весь код в MiamiGraphics.Core/HotSwap/. Разделение там жёсткое: GameFileSwapper - сценарий (что и в каком порядке делать со всем набором файлов, что писать в журнал), а как физически переставляется один файл, знает движок за интерфейсом ISwapEngine. Движков два: rename-транзакция File.Replace и копирование через временный файл.
Четыре операции¶
| Операция | Когда | Что делает |
|---|---|---|
| FREEZE | включение режима, один раз | моды из игры уезжают в образ, игре встаёт чистый файл |
| ARM | старт игры | игра = моды |
| DISARM | выход из игры | игра = чистый |
| UNFREEZE | выключение режима | моды возвращаются в игру насовсем, образ сносится |
FREEZE и UNFREEZE - действия человека в интерфейсе. ARM и DISARM крутит фоновый агент (способы 1, 2, 5) либо инициирует сам игрок кнопкой (способы 3, 4).
Почему образ лежит вне папки игры¶
Лаунчер сканирует свою папку. Любой наш файл внутри неё - кандидат на ремонт, и держать там же спрятанные моды бессмысленно. Отсюда правило: корень образа никогда не находится внутри gtaRoot, и папка, выбранная игроком, проверяется на вложенность явно.
if (plan.Store == HotSwapStoreKind.CustomFolder && !string.IsNullOrWhiteSpace(storeRoot))
{
var full = Path.GetFullPath(storeRoot!);
if (IsInside(full, Path.GetFullPath(gtaRoot)))
{
reason = Loc.T("error.hotSwapStoreInsideGame");
return false;
}
}
Второе ограничение - том. File.Replace это функция NTFS и работает только внутри одного тома; именно она даёт подмену двухгигабайтного файла за миллисекунды вместо копирования. Способы с rename поэтому требуют, чтобы образ и игра были на одном томе, копирующим способам том безразличен.
Что именно замораживается¶
Набор фиксированный и короткий:
public static readonly string[] RelPaths =
{
@"update\update.rpf",
@"update\x64\dlcpacks\patchday18ng\dlc.rpf",
};
| Файл | Что там наше | Чистый источник |
|---|---|---|
update\update.rpf |
редукс, минимапа, прицелы, оверлеи (что внутри) | clean-бэкап лаунчера |
update\x64\dlcpacks\patchday18ng\dlc.rpf |
ганпаки, броня | шаблон backup\dlc.rpf (нетронутый patchday18ng) |
x64\audio\sfx\*.rpf |
звуковые паки | .bak рядом с самим файлом |
Ганпаки вынесены отдельной строкой не для красоты: dlc.rpf лежит отдельным файлом на диске, а не внутри update.rpf, и без него в режиме Rockstar пропадали бы пушки.
Звуковые паки в статическом списке отсутствуют - имена файлов зависят от пака, поэтому список собирается с диска на каждый вызов. Берём только те, рядом с которыми лежит .bak, то есть оригинал игры реально у нас на руках:
foreach (var f in Directory.EnumerateFiles(dir, "*.rpf"))
{
if (!File.Exists(f + ".bak")) continue;
res.Add(Path.Combine(SfxDirRel, Path.GetFileName(f)));
}
Это же правило объясняет, чего в наборе нет. mpapartment не включён: лаунчер туда не пишет, чистого источника для него у нас нет, а подсунуть игре вместо него чужой шаблон - гарантированно испортить файл. Нет чистого источника - файл не трогаем вовсе.
Звуки добавлены не сразу. Пока режим знал только update.rpf и dlc.rpf, звуковые паки оставались открытыми, лаунчер видел изменённые файлы и чинил их обратно - человек получал «режим работает, а звуков нет».
Как выглядит образ¶
Всё, что нужно режиму, живёт в одном корне:
<корень образа>\
├── modded\<rel> ← файлы с модами
├── clean\<rel> ← чистые копии
├── swapset.json ← rel-пути, реально попавшие в образ
├── baseline.json ← отпечатки чистых файлов, отданных игре при заморозке
├── armed.json ← отпечатки того, что мы положили в игру при ARM
├── repair.json ← маркер «нас переписал Rockstar» (если случилось)
├── journal.json ← фаза свапа
├── agent.json ← heartbeat агента для интерфейса
└── store.json ← привязка: чем и куда заморожено
swapset.json - главный файл: пока он существует и непустой, режим считается включённым. ReadSet пуст - образа нет, Arm отказывает с «образ не собран».
Оба слота, modded и clean, одновременно заняты только у копирующего движка. У rename-движка в каждый момент занят ровно один из них, и это само по себе состояние: есть clean-копия - значит моды сейчас в игре.
public bool IsArmed(string gtaRoot, string rel) =>
File.Exists(HotSwapPaths.CleanPath(gtaRoot, rel));
public void ArmOne(string gtaRoot, string rel) =>
HotSwapFileOps.ReplaceWithRetry(
HotSwapPaths.ModdedPath(gtaRoot, rel),
HotSwapPaths.GamePath(gtaRoot, rel),
HotSwapPaths.CleanPath(gtaRoot, rel));
Копирующий движок держит обе копии на месте постоянно, а в игру кладёт третью - копию одной из них. Гонять два гигабайта по кругу означало бы на каждый вход в игру писать вдвое больше. Раз копии не двигаются, состояние по их наличию не прочитать, поэтому источник правды там - отпечаток файла игры (длина + время записи): File.Copy и File.Move сохраняют время записи, значит файл игры совпадает по отпечатку ровно с тем слотом, откуда он скопирован.
Где лежит корень образа¶
| Способ | Примитив | Корень образа |
|---|---|---|
| 1, 3, 5 | 1, 3 - rename; 5 - копирование | <том игры>:\MiamiGraphics\hotswap |
| 2 (папка не выбрана) | rename | <том игры>:\MiamiGraphicsSwap\hotswap |
| 4 (папка не выбрана) | копирование | %LocalAppData%\MiamiGraphics\hotswap_store\hotswap |
| 2, 4 (папка выбрана) | 2 - rename; 4 - копирование | <выбранная папка>\MiamiGraphics\hotswap |
Внутрь выбранной игроком папки всегда добавляется MiamiGraphics\hotswap - ровно так же, как к корню тома в дефолте. Без этого разморозка сносила бы содержимое чужой папки целиком вместо своего.
Запасные корни для способов 2 и 4 - не заглушка. Раньше проверка тома отбивала оба способа с просьбой выбрать папку, а выбрать её в интерфейсе было негде, и включить эти способы было нельзя в принципе.
Привязка важнее конфига¶
Конфиг режима лежит в %LocalAppData%:
public static string ConfigPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MiamiGraphics", "config", "hotswap_mode.json");
Но пока образ заморожен, источник правды - не он, а store.json по дефолтному пути рядом с игрой. Причина прямая: конфиг переживает что угодно (переустановку лаунчера, сброс настроек, ручную правку способа в интерфейсе), а два гигабайта модов - нет. Потеряв конфиг, мы обязаны найти образ по одному пути игры, иначе игрок остаётся с чистой игрой и «потерянными» модами. Привязка всегда пишется по дефолтному пути, даже когда сам образ уехал в чужую папку или на другой том.
Оттуда же берётся способ, которым надо работать сейчас:
public static HotSwapMethod ActiveMethod(string gtaRoot)
{
var b = ReadBinding(gtaRoot);
if (b is not null) return HotSwapPlan.Normalize(b.Method);
return ConfiguredMethod();
}
Переключение галочки в настройках не должно менять примитив под уже разложенным образом: собранный копированием образ и разбирать надо копированием. Чтение привязки кэшируется на 1000 мс - Resolve дёргается на каждый ModdedPath/CleanPath/JournalPath, читать json на каждый Path.Combine незачем. Любая запись привязки или конфига сбрасывает кэш явно.
Заморозка¶
Freeze идёт по существующим файлам набора и для каждого делает три вещи: мод уезжает в modded, чистый источник встаёт в игру, swapset.json дописывается.
var imageRoot = HotSwapStore.Bind(gtaRoot, method, storeRoot);
...
HotSwapJournal.Write(gtaRoot, HotSwapPhase.Freezing);
foreach (var rel in HotSwapPaths.ExistingRelPaths(gtaRoot))
{
if (!cleanSources.TryGetValue(rel, out var cleanSrc)
|| string.IsNullOrWhiteSpace(cleanSrc) || !File.Exists(cleanSrc))
continue;
var game = HotSwapPaths.GamePath(gtaRoot, rel);
var gameLen = new FileInfo(game).Length;
if (gameLen == new FileInfo(cleanSrc).Length)
continue;
eng.FreezeOne(gtaRoot, rel, cleanSrc);
done.Add(rel);
WriteSet(gtaRoot, done);
}
Порядок здесь - не стилистика, каждый шаг закрывает конкретный сценарий краха:
- Привязка пишется до первого байта. Крах между первым
moveи записью привязки оставил бы образ в папке, которую потом никто не найдёт. - Журнал переводится в
Freezingдо первогоmove. Крах во время минутной копии 2 ГБ иначе оставил бы игру безupdate.rpf, а восстановление не поняло бы, что чинить. - Файл, совпавший по размеру с чистым, в набор не берётся - он не модифицирован, замораживать нечего.
swapset.jsonдописывается после каждого файла, а не в конце: частичная заморозка остаётся восстановимой.baseline.jsonпишется последним - отпечатки чистых файлов, которые мы отдали игре.
Если модифицированным не оказался ни один файл, привязка снимается обратно, а человек получает текст про то, что замораживать нечего. Раньше здесь было сухое «моды в игре не найдены», и человек после того, как лаунчер снёс ему сборку, читал это как поломку нашего лаунчера и жал кнопку по кругу.
Свой откат внутри Freeze не пишется намеренно: при исключении журнал остаётся в фазе Freezing, и разгребает это HotSwapRecovery.EnsureConsistent - тот же код, что чинит крах, а не вторая реализация того же сценария.
Повторная заморозка поверх живого образа запрещена: непустой swapset.json - отказ. Перезаписать привязку другим способом поверх собранного образа значит потерять его, пути уехали бы в пустую папку.
Разморозка¶
Unfreeze возвращает моды в игру насовсем и сносит образ целиком: копии, swapset.json, baseline.json, armed.json, маркер ремонта. Привязка снимается последней - пока она есть, пути образа резолвятся туда, куда замораживали.
Единственная сложная часть - проверка GameFileIsForeign. Если лаунчер успел перекачать update.rpf, в игре лежит файл новой версии, и класть поверх него наш старый мод нельзя: игра откатится на прошлую версию, лаунчер начнёт ремонт заново, и так по кругу. Такой файл мы оставляем как есть, копии образа выбрасываем, а rel уходит наружу в списке dropped - человек обязан узнать, что моды для этого файла придётся ставить заново.
Правило «наш или чужой» намеренно несимметрично: «наш» - это совпадение хоть с одной опорой (modded, clean, baseline, отпечаток armed), «чужой» - несовпадение со всеми при том, что хоть одна опора вообще есть. Ошибка в сторону «наш» стоит лишнего ремонта от Rockstar, ошибка в сторону «чужой» - выброшенных двух гигабайт модов игрока.
Отдельная ветка - когда игры по замороженному корню больше нет (перенесли на другой диск, снесли). Возвращать моды некуда, а держать человека в вечно включённом режиме - тупик, поэтому DropImage сносит образ, не трогая файлы игры.
Что происходит между заморозкой и разморозкой¶
Фазы журнала пишутся до каждой файловой операции, поэтому после краха или ребута по нему видно, что где лежит:
| Фаза | Значение |
|---|---|
Idle = 0 |
файл игры чистый, моды в образе - штатное состояние |
Arming = 1 |
идёт подмена |
Armed = 2 |
моды подставлены, игра запущена |
Disarming = 3 |
идёт возврат |
Freezing = 4 |
идёт первичная заморозка |
Пока моды подставлены, armed.json хранит отпечатки того, что мы положили в игру. Агент сверяет их раз в несколько секунд - два FileInfo на набор, дешевле любого хеша:
public static bool DetectRepairWhileArmed(string gtaRoot, out string? rel)
{
rel = null;
var armed = ReadArmedStamps(gtaRoot);
if (armed.Count == 0) return false;
foreach (var kv in armed)
{
var now = HotSwapFileOps.Stamp(HotSwapPaths.GamePath(gtaRoot, kv.Key));
if (string.IsNullOrEmpty(now) || string.IsNullOrEmpty(kv.Value)) continue;
if (string.Equals(now, kv.Value, StringComparison.Ordinal)) continue;
rel = kv.Key;
RockstarRepairWatch.Mark(gtaRoot, kv.Key, kv.Value, now,
RockstarRepairWatch.Probe(gtaRoot).Describe());
return true;
}
return false;
}
Разъехавшийся отпечаток - это и есть ремонт. Факт записывается в repair.json, чтобы пережить перезагрузку: после ремонта образ собран под старую версию игры, и подставлять его больше нельзя до пересборки. Disarm в этом состоянии не трогает файлы игры вовсе - они новее наших копий.
Ремонт можно и предупредить. Перед подменой снимается картина окружения: живые процессы лаунчера (Launcher, RockstarService, SocialClubHelper, LauncherPatcher, RockstarErrorHandler, PlayGTAV) и свежие временные файлы рядом с файлами набора - хвосты .part, .partial, .download, .rgl_tmp не старше 10 минут. Пока такие файлы есть, Arm и Disarm отказывают: подменять файл под руку тому, кто его прямо сейчас переписывает, значит получить половину нашего файла и половину чужого.
Голого .tmp в этом списке нет намеренно - расширение пишет кто угодно, от антивируса до наших же инжекторов, и один такой файл на 10 минут запрещал бы подмену с текстом про ремонт игры там, где ремонта нет.
Тайминги агента задаются планом способа:
| Способ | Опрос процессов | Пауза перед возвратом | Гасит процессы Rockstar |
|---|---|---|---|
| 1, 2 | 250 мс | 3000 мс | нет |
| 3, 4 | 1000 мс | 0 | нет |
| 5 | 500 мс | 0 | да, затем ждёт 1000 мс |
Пока открыт лаунчер Rockstar и нет живой подписки на старт процессов, шаг опроса ужимается до 30 мс: игра стартует именно оттуда, и цена опоздания - не поставившийся мод. Неудачный Arm повторяется не чаще, чем раз в 10 с.
Проверка диска перед включением¶
VolumeSupported отвечает на три вопроса по трём осям способа:
| Ось | Требование | Когда проверяется |
|---|---|---|
| примитив | том образа = том игры, файловая система NTFS | только rename-способы |
| хранилище | папка лежит вне папки игры | только способы 2 и 4 с выбранной папкой |
| место | размер набора (копирующим - удвоенный) + 512 МБ запаса на томе образа | всегда |
Копирующие способы держат обе копии одновременно, поэтому требуют двойного размера набора, плюс место под временный файл рядом с игрой - самый крупный файл набора и ещё 256 МБ запаса на томе игры (если тома совпадают, оба требования складываются в одно).
Файл игры не пишется на месте никогда¶
Общее правило всех примитивов: update.rpf весит два гигабайта, и прерванная запись «поверх» оставила бы игроку обрезанный архив, а это чинится только полной перекачкой игры. Поэтому файл либо участвует в rename-транзакции File.Replace, либо заменяется готовым соседним файлом одним File.Move(overwrite: true).
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 атомарен. Падение посреди двухгигабайтной копии оставляет мусорный .mgswap.tmp, но не обрезанный update.rpf. Хвост убирается отдельно и только если ему больше 2 минут: рядом может работать агент, и удалить его недописанный временный файл значило бы уронить идущую подмену.
Файл может быть занят игрой или лаунчером, поэтому обе операции ретраятся: 12 попыток с паузой 250 мс между ними.
Два отпечатка вместо одного¶
Дешёвый отпечаток - длина плюс время записи. Его хватает на 99% вопросов, но не на вердикт «игра обновилась»: лаунчер при проверке целостности переписывает файл на месте, и mtime тикает, хотя байты те же. На этом режим однажды объявлял образ протухшим и выбрасывал моды игрока.
Поэтому при расхождении отпечатков спрашивается содержимое - и только в этот момент, а не на каждом опросе:
const int Chunk = 1024 * 1024;
long[] offsets = { 0, Math.Max(0, fi.Length / 2 - Chunk / 2), Math.Max(0, fi.Length - Chunk) };
...
return fi.Length + "-" + Convert.ToHexString(sha.Hash!, 0, 8);
Три куска по 1 МБ (начало, середина, конец), SHA-256, в подпись уходят первые 8 байт хеша. Совпало - файл тот же, обновляем отпечаток в baseline.json, чтобы не перечитывать 3 МБ каждые 5 секунд. Не прочиталось (лаунчер держит файл ровно когда его переписывает), но длина прежняя - молчим и считаем образ годным до следующей проверки. Ошибиться здесь дороже, чем пропустить настоящее обновление: его поймает следующий опрос.