Я получил данные на каждого...| Парсер файла данных Shadows of doubt
Приветствую, молодые успешные. Недавно решил снова вернуться к детективной деятельности в Shadows of Doubt. Создал самый большой город и бросился на борьбу с преступностью. Однако очень скоро я встретился с проблемой, из-за которой забросил игру в прошлый раз.
Да, это часть детективного геймплея, да, нужно проявить смекалку, но не когда тебе нужно прошерстить гигантский город без какой-либо конкретики.
И тут я задумался: можно ли как-то вытянуть информацию о каждом жителе в более удобный формат? Да, в игре уже присутствует база всех жителей, но она работает только если ты знаешь имя конкретного человека, выборки множества записей нет.
В чём идея. Я хочу вытянуть инфу о ВСЕХ жителях в Excel-документ с возможностью сортировки по столбцам.
Реализовывать буду с помощью языка C#, а конкретно WPF, потому что очень люблю десктопные решения.
Начало раскопок
А начал я, как это обычно бывает, с банального поиска в Гугле. И сразу же я набрел на сестру по аутизму Snoozeds, которая уже реализовала похожее решение, но с использованием JS, то есть для локального использования нам нужен Node.js.
Но давайте для начала разберемся, с чем придется работать.
Игра опирается на файлы формата .cit/.citb, они создаются при генерации города, и в них содержится вся информация о городе. И когда я говорю «вся», я имею в виду ВСЯ: расположение зданий, интерьер внутри апартаментов, освещение на улице и вся информация о жителях города, то, что нам нужно.
Но в чем разница между форматами файлов?
.cit — это несжатый файл данных города. Он полностью открыт для чтения и редактирования (например, можно поменять надпись на вывеске забегаловки).
.citb — это сжатый (в некоторых источниках ошибочно называемый зашифрованным) файл города. Буква «B» здесь означает Binary (бинарный).
И тут у нас возникает первое требование — учитывать сжатие файла конфигурации. Да, есть возможность отключить сжатие файлов города, но мы как ответственные ребята и девчата не будем исключать это требование.
Сжатие происходит с помощью алгоритма Google Brotli. Когда мы приводим файл к исходному (читаемому) виду, мы видим, что он имеет JSON-структуру... Что прекрасно для нас (с ней проще работать... чаще всего...), но ужасно, потому что на C# неудобно работать с JSON (ну, по моему мнению). Поэтому мы добавляем второе требование — конвертировать в «чистый» JSON-файл конфигурации.
Не украл, а вдохновился
Итак, у нас две задачи:
- Иметь возможность парсить оба вида файлов (.cit и .citb).
- Приводить файл конфигурации к «чистому» JSON для дальнейшей десериализации в объекты.
Ну и, конечно, мы заглянем к нашей пионерке сегодняшней темы и посмотрим, как она решила первую задачу.
Когда мы заглядываем в проект SoDTools, мы видим привычную структуру проекта JS. И в первую очередь нас интересует файл App.jsx — обычно в нём концентрируется вся основная логика.
Я начал поиски по ключевым словам (CTRL+F, поехали), меня интересовали ключевые слова ".cit" и ".citb".
И по первой задаче есть попадание: в функции «onDrop» мы видим конструкцию if, которая проверяет файл на расширение «.citb». Если условие истинно, начинается процесс «развертывания» из алгоритма Brotli, и только после этого приводим к JSON, иначе просто начинаем приводить к JSON. И тут в комментарии описывается очень важный момент:
// Replace invalid NaN, I had "Casablanca Quarter.1.3903.wK08ZyOM1xmZWTL1" fail to load for me because it had NaN as one of the values strangely enough.
Требуется заменить значение NaN на null, иначе возникает ошибка. Почему этого не требуется при развертывании? Не знаю, возможно, при развертывании значение NaN автоматически приводится к null.
В результате у нас два метода: «ParseGameFileAsync» и «DecompressBrotli».
«ParseGameFileAsync» — для первичной обработки файла и для выяснения, требуется ли для него процесс развертывания.
«DecompressBrotli» — сам процесс развертывания.
Пробно закинем файл города и на точке останова внутри объекта видим наш JSON. В итоге мы закрыли две первые задачи: мы можем читать оба варианта файлов города и приводим их к формату JSON.
Теперь нам нужно хорошенько покопаться в получившемся JSON-е, чтобы получить нужный массив, а также подумать, как вывести нужные данные в Excel.
Заглянул внутрь массива, а там массивы в нарды играют
На предыдущем этапе мы получили самое главное — данные, с которыми можно работать. В этой части статьи мы разберемся, что с этими данными делать. Для начала по классике определимся с задачами:
- Определить основные поля сущностей.
- Вывести функционал формирования Excel-документа.
Для начала обратимся к карточке персонажа в игре.
Здесь мы видим ряд полей, которые могут нас заинтересовать.
Теперь нужно найти соответствие данных полей в файле конфигурации города. Если мы снова обратимся к коду SoDTools, то увидим, что идет обращение к некоему массиву «citizens».
Для читаемости возьмем наш файл конфигурации города (любой), скопируем его в отдельную папку и прогоним через «jq» (Она позволяет фильтровать, преобразовывать и извлекать информацию из структурированных JSON-документов.). Теперь по старой схеме CTRL+F и ищем по ключевому слову «citizens». В результате находим конских размеров массив жителей города (с указанием параметров внешности, характера, скорости передвижения и т.д.). Из записи жителя нужно выцыганить поля, которые соответствуют карточке жителя в игре.
Здесь нужно учитывать, что есть поля, которые запрятаны во вложенные массивы. Для таких полей нужно создавать отдельные сущности.
В поля первого уровня (относительно массива «citizens») попадают следующие поля:
- citizenName — полное имя
- birthday — день рождения
- gender — пол
- blood — группа крови
- job — id работы
- home — id дома
Также есть подчиненный массив «descriptors», в котором хранятся параметры тушки гражданина. Нам интересны поля:
- build — тип телосложения
- heightCM — рост
- shoeSize — размер обуви
- hairType — прическа
- eyeColour — цвет глаз
Также у нас есть ID-шники для работы и дома, и нам остается только вбить их в поиск и получить место, откуда брать данные, ведь так?
Поискав эти ID в файле конфигурации, мы понимаем, что не всё так просто...
Давайте начнем с поля «home». Допустим, в этом поле указано «1113». Вбив это значение в поиск, получаем 2296 совпадений. Ладно, уже ознакомившись с общей стилистикой файла, вбиваю «"id": 1113», получаю 5 совпадений. У первого совпадения в соседних полях номер квартиры и название здания — всё в одном поле. КЛАСС (не сарказм).
Нам всё еще нужен корневой массив, идем дальше. Вбиваем название здания «Taylor View», видим, что идет разбитие на этажи -> комнаты (квартиры) -> комнаты (внутри квартир) внутри массива «floors». Так пробегаем по всем этажам (их может быть 20+), пока не добираемся до корневого массива «building» и не видим, что у него есть поле «buildingID» со значением 11...
То есть это еще не корневой массив, а лишь массив для полей, которые находятся в еще одном массиве... Вбиваем в поиск «building» и добираемся наверх до записи с «buildingID», равной 1, и видим, что они все находятся в массиве «cityTiles». Вот теперь с этим можно работать.
Теперь давайте разберемся с полем «job». Не теряя веру в человечество, вбиваем в поиск ID работы одного из граждан, допустим «436», и получаем «9235» совпадений... Меняем стратегию и в строку поиска вводим «"id": 436,» (запятая в конце обязательна!!!!), получаем 8 совпадений.
Уже лучше. В 7-м совпадении — ссылки на этот ID (соединения вентиляций, данные о внутренней фурнитуре), и только в одном совпадении видно поле «name» со значением «Продавец в уличном киоске». Именно здесь нам и нужно копать дальше. Выше видим начало массива «companyRoster», этот массив хранится в массиве «company», а он уже хранится в массиве «addresses», который хранится в массиве «floors».
В итоге мы получаем вот такую последовательность: cityTiles -> building -> floors -> addresses -> company -> companyRoster : name.В итоге, объединив это в единый класс для записей Excel, мы получаем удобную табличку с общей характеристикой человека, его адресом и адресом, где он работает. Всё хорошо, но нет типа отпечатков пальцев...
Последний рывок
Итак, нам осталось найти один недостающий компонент полного контроля над игрой. Внутри нашего файла «.cit» поиск по «fingers», «finger», «fingerprint» и т.д. ничего не дал. Какие варианты есть еще? В соседней папке у нас имеются файлы сохранений. Они так же разделяются на файлы «.sodb» и «.sod», где «.sodb» файл сжат, а «.sod» — без сжатия.
Беглый поиск по тем же ключевым словам также не дает ничего конкретного. Однако я наткнулся на отметку о создании нового дела (на момент просмотра файла сохранения было совершено убийство). К нему прилагается объект анкеты из мэрии, где нужно указать убийцу, орудие убийства и т.д. В этой анкете указаны также ID верных вариантов, в том числе и отпечатки убийцы (которые я успел отсканировать, запомните на будущее, это важно!).
Дальше мучить файлы сохранения нет смысла, так как это всего лишь отпечатки того, что происходит в игре. На данный момент у нас есть два варианта:
- Массив сформированных отпечатков пальцев хранится где-то в игре, и нужно понять, куда игра обращается, чтобы получить эти данные.
- Игра выдает отпечатки пальцев на лету в момент сканирования (узнавания) отпечатка пальцев.
Я решил отработать первую теорию. Вначале я попытался залезть под капот игры. Для этого мне понадобилось скачать «Il2CppDumper», чтобы получить исходные файлы игры, чтобы открыть их в «dnSpy». В результате поиск по ключевым словам («fingers», «finger», «fingerprint»), если и находит, то не очень информативно.
Поэтому я бросил ковырять исходники «на холодную» и решил обратиться непосредственно к процессу игры. Для этого я скачал старый добрый CheatEngine и решил попробовать отсканировать память.
Вначале, по привычке, я начал нащупывать значения, избивая окружающих, но быстро понял, что получаю не те участки памяти. После этого я пошел в мэрию и решил отлавливать значения в момент получения досье человека (там как раз были и типы отпечатков пальцев). В итоге это каждый раз приводило меня к структуре JSON жителя из файла конфигурации города.
Совсем отчаявшись, я решил перейти ко второй теории, что игра выдает отпечатки пальцев на лету. Это привело меня к обсуждению в Steam-сообществе по игре. В нём обсуждалась особенность, что разная последовательность действий при расследовании приводила к разным результатам.
А именно: после того как убийца был найден по отпечаткам пальцев, игрок загрузил сейв до начала расследования, побегал, сделал еще несколько заданий, требующих получения отпечатков пальцев. В результате, когда он вернулся к ранее закрытому делу (до перезагрузки сейва), он получил совершенно другие отпечатки пальцев и другого виновного.
Как объяснили участники обсуждения, тип отпечатка пальцев действительно выдается в момент сканирования, и выдается тип по алфавиту (первый отсканированный отпечаток всегда будет «A» и т.д.). Когда алфавит заканчивается, идет повышение разряда, и в ход идут комбинации по типу «AA», «AB» и т.д. В результате, когда игрок пошел сканировать новые отпечатки до начала расследования убийства, он уже сместил очередь получения типа отпечатка пальцев. Я немедленно пошел проверять этот факт. Я сделал сейв, получил досье из базы мэрии — в нём тип отпечатка был «E». После этого я перезагрузился, выбежал на улицу и по стелсу отсканировал руку прохожего — тип был «E»...
То есть искать массив отпечатков пальцев не имеет смысла, так как назначение типа отпечатка пальцев — это внутриигровая механика, а не статическое значение. И всё, что я описал в этой части, было бессмысленно, так как я мог просто написать правильный вариант и не тратить впустую ваше время. Извините, мне не стыдно, это повторится.
Сборка
Все данные добыты, аккуратно сложены и готовы к сборке. Как я говорил ранее, решение будет основано на WPF. Структура проекта будет разбита на две части: «Сущности» и «Логика».
Структура проекта
В «Entity» будут храниться классы, отвечающие за «маппинг» данных из нашего JSON, это нужно для передачи значений в Excel-документ.
«Логика» будет сосредоточена за основной формой, проект не сильно сложный, чтобы растаскивать её по разным классам. Максимум для удобства выделил папку для «экселько-собиралки» (ExcelCreater.cs).
Как использовать
Идея в следующем. У нас будет одно окно, на всю область которого будет растянуто drag-and-drop поле. Мы будем просто перетягивать туда нужный файл, и если с ним всё хорошо, на выходе мы получаем диалоговое окно сохранения файла Excel.
Сущности
Как ранее было сказано, «Сущности» отвечают за корректную передачу данных из JSON в Excel. Для этого мы обращаемся к двум структурам внутри файла конфигурации: «Citizen» и «CityTiles».
«Citizen» — структура, где хранится основная информация о тушке гражданина (имя, вес и т.д.), также эта информация находится в дочерней структуре DescriptorsEntity (не знаю зачем, не спрашивайте). Но где он живет и где работает, указано в виде ID без названия конкретного адреса, и как раз чтобы это исправить, нам понадобится структура «CityTiles».
«CityTiles» — структура, в которой хранится информация о городе (здания, улицы, интерьер внутри зданий и т.д.). Имея на руках ID из прошлой структуры, мы можем добыть название для полей места работы и адреса проживания. Это самая богатая на дочерние элементы структура.
Еще для удобства я описал словари (CitizenMappingDictionaries), где указал обозначение для ID (подглядел у Snoozeds).
Ну и промежуточным итогом между чтением JSON и передачей в «экселько-собиралку» будет «плоская» сущность «CitizenExcel».
Логика
Здесь я бы хотел сосредоточиться на движении файла .cit/.citb до конечной Excel-реализации. Я не буду описывать логику крутилок-вертелок, которые сопровождают этот процесс, во-первых, мне это не интересно (я заставил всё это сделать DeepSeek), во-вторых, скорее всего, не интересно вам.
Основная логика разбита на две части: класс ExcelCreater и несколько методов за главной формой (ParseGameFileAsync, FindElementArray, DecompressBrotli, CityFileCather_Drop).
Основная форма
Место, откуда наш «сырой» файл начинает свой путь, а именно с события «CityFileCather_Drop».
CityFileCather_Drop — занимается обработкой приходящих файлов через событие drop. Первым делом проверяется, соответствует ли расширение файла требованиям (.cit или .citb). После этого начинается парсинг файла через вызов «ParseGameFileAsync».
ParseGameFileAsync - здесь метод принимает путь до файла, после чего в зависимости от расширения (.cit или .citb) либо приводит данные в формат JSON, либо сначала развертывает данные (метод «DecompressBrotli») и только потом возвращает JSON.
FindElementArray - далее через этот метод идет инициализация двух массивов, предназначенных для дальнейшего маппинга. Метод «FindElementArray» вызывается два раза: в первом вызове мы ищем коллекцию «citizens», во втором — «cityTiles». Так как исходный JSON, «мягко» говоря, «очень» многоуровневый, прохожусь на всякий случай рекурсивно по внутренним элементам.
После того как мы выделили массивы «citizens» и «cityTiles» из общего документа, мы возвращаемся в событие «CityFileCather_Drop», где «десериализуем» полученные данные в массивы объектов «Citizens» и «Buildings». Теперь нам нужно сформировать массив «плоских» объектов для формирования Excel-документа.
Для этого мы берем ранее сформированный объект «Citizens» и проходимся в цикле по каждому элементу. Внутри каждой итерации создается объект «CitizenExcel», данные гражданина передаются из объекта текущей итерации, а сопоставление с адресом проживания и местом работы идет через переборку переданного массива «Buildings»; внутри метода маппинга идет выборка из массива по ID из записи массива «Citizens».
Полученный объект добавляется в массив «CitizensToExcel», который передается в нашу «экселько-делалку».
Экселько-делка (ExcelCreater.CreateExcelFile)
Финальный этап «парсинга» данных из файла конфигурации. Реализован этот этап в классе «ExcelCreater», а конкретно в методе «CreateExcelFile».
В начале мы интересуемся, сохранять файл или нет; получив положительный ответ, двигаемся дальше.
Создаем лист с названием и создаем текстовый массив, куда указываем названия для наших заголовков. Это нужно для обозначения полей на русском. В цикле проходим по массиву заголовков и вписываем их в первую строчку, указав параметры (стили, заливка).
Теперь наконец-то переходим к самим значениям. Нам нужно пройтись по переданному массиву с гражданами циклом for, а не foreach, так как тут нужен контроль над индексами: первая строка — заголовок, а первая строка в листе Excel — «1», поэтому к текущему индексу мы прибавляем «2», это будет указанием строки в Excel, а нетронутый индекс будет указывать на объект в массиве граждан для передачи значения.
После этого идет обработка трёх из возможных событий:
- Успешное сохранение: «Excel файл успешно создан!»
- Отмена сохранения: «Сохранение Excel файла отменено.»
- Закрытие окна сохранения: «Диалог сохранения был закрыт.»
Итог
Итак, сыщики, в результате сегодняшнего расследования у нас на руках появился инструмент получения данных о каждом жителе города. Мы смогли упаковать это в удобную форму для дальнейшего обращения к ним. Спасибо, что проявили интерес, на этом считаю дело закрытым.
А если вам интересно оставляю ссылку на репозиторий: