Реверс-инжиниринг Hololive Dreams — архив заметок

Preface

Cutoff: 26/08/23 Revision: 26/08/23

Записи о реверс-инжиниринге системы ресурсов мобильной игры на Unity 6. Текущее состояние: ассеты, анимация, лицо, волосы, камера, Live2D — всё готово к использованию; физика пружинных костей (spring bone) — единственное, что осталось нерешённым.

Инструменты и полный постраничный журнал — на https://git.siao.ai/siao/hohohololive (без дешифрования, причины см. в разделе (8)).

Фрагменты кода в этой статье встраиваются напрямую из SiaoHub во время сборки, а не вставлены вручную, поэтому они не расходятся с исходным кодом; страницы файлов на SiaoHub, в свою очередь, помечают, что на них ссылается эта статья.

Название — дань уважения sssekai: этот проект и его архив заметок стали самым полезным ориентиром на этом пути реверс-инжиниринга.

Объект анализа: game.qualiarts.hololive.dreams.com 1.0.0 (iOS, расшифрованный IPA), Unity 6000.3.0b1, IL2CPP metadata v39.


(1) Подключение устройства и IL2CPP metadata v39

1. Передача данных: сначала оценим потолок

В контейнере приложения Library/octo/ — кэш загруженных ресурсов, 3,4 ГБ. В подкаталоге v1/ лежат 178 файлов .awb, 1240 .acb, 176 .usm — все в форматах CRIWARE для звука и видео.

По WiFi через SSH scp тянул 12 МБ 11 минут. Первая реакция — сменить шифр (штатный набор не имеет аппаратного ускорения); после смены скорость как будто мгновенно выросла — но это была иллюзия локального дискового буфера, на больших файлах всё вернулось на круги своя.

Но важнее другое: даже если бы шифр реально помог, это не имело бы значения — 3,4 ГБ по WiFi при любых настройках это десятки минут. Перешёл на USB:

iproxy 2222:22 &         # SSH
iproxy 27042:27042 &     # Frida
Канал Измерено Оценка для 3,4 ГБ
WiFi SSH (штатный шифр) ~18 КБ/с около 2 дней
WiFi SSH (gcm) на маленьких файлах выглядит быстрым, на больших всё так же медленно
USB (iproxy) 40–70 МБ/с около 1 минуты

Прежде чем что-либо трогать, стянул весь tar-архив на локальную машину. Позже несколько раз требовалось очищать кэш на устройстве, чтобы отследить момент загрузки, — без резервной копии каждая такая очистка была бы необратимой.

2. Metadata зашифрована, бинарник — нет

$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ...       # ожидалось AF 1B B1 FA

Magic не совпадает. Но UnityFramework на диске вообще не зашифрован — это два разных факта, которые нужно различать, а я сначала их не различил (см. промах в Приложении).

Во время выполнения metadata неизбежно оказывается расшифрованной в памяти — сканируем память в поисках magic:

import frida
 
MAGIC = b"\xaf\x1b\xb1\xfa"
 
def on_message(msg, data):
    if msg["payload"].get("event") == "metadata":
        open("global-metadata-decrypted.dat", "wb").write(data)
 
dev = frida.get_usb_device()
pid = dev.spawn(["game.qualiarts.hololive.dreams.com"])
ses = dev.attach(pid)
scr = ses.create_script(open("dump.js").read())
scr.on("message", on_message)
scr.load()
dev.resume(pid)

Process.enumerateRanges("r--") сканирует по сегментам, находит единственное совпадение по адресу:

magic   = AF 1B B1 FA
version = 39

version = 39 — валидный номер версии IL2CPP, а значит это настоящая расшифрованная metadata, а не случайное совпадение.

3. Ответ дала не подделка версии, а то, как именно она проваливалась

Последняя версия Il2CppDumper поддерживает только до 31. Стандартный приём — подменить номер версии, потому что формат часто не меняется, просто скачет нумерация.

Подделка под Результат
27 Провал — конфликт key
29 Провал — тот же конфликт key
31 Провал — тот же конфликт key

Все три раза застревало в одном и том же месте. Если бы дело было просто в скачке номеров, разные старые версии должны были бы ломаться в разных местах; три идентичных провала означают, что структура, которую читает парсер, расходится с ожидаемой именно в этой точке — формат действительно был обновлён.

Ценность этого вывода в том, что он одним махом закрывает целую ветку поисков. «Попробовать ещё один номер версии» стоит каждый раз секунд тридцать, поэтому так легко застрять в бесконечном переборе.

Перешёл на Il2CppInspectorRedux (форк LukeFZ), получил полную карту из 560 тысяч соответствий «имя метода → виртуальный адрес».

4. Среда декомпиляции: в обход JVM

Ghidra требует JVM, а на этой машине ядерный модуль AppleSystemPolicy блокирует неподписанную java — это не ограничение песочницы инструмента, а сама система: даже в собственном терминале запуск заканчивается тем же Kill: 9.

Не стал бодаться с системой. rz-ghidra вытаскивает C++-ядро движка декомпиляции Ghidra (SLEIGH + decompiler) и собирает его как плагин rizin, которому JVM во время работы не нужна:

git clone --recurse-submodules https://github.com/rizinorg/rz-ghidra.git
cd rz-ghidra && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(sysctl -n hw.ncpu)
cp *.dylib /opt/homebrew/lib/rizin/plugins/

Ловушка первая: одного копирования .dylib недостаточно — собранные .sla (скомпилированные спецификации языка sleigh) нужно положить обратно в дерево исходников, рядом с .slaspec, и выставить SLEIGHHOME. Сообщение об ошибке ни словом не намекает на отсутствующие файлы данных.

Ловушка вторая: если af в rizin -q -c "s <addr>; af; pdg" попадает по адресу внутрь границ другой, более ранней и более крупной функции, он унаследует её границы и декомпилирует содержимое совершенно другой функции. Из-за этого я одно время принимал определённый адрес за общую логику поиска по Dictionary в IL2CPP и был уверен, что некое ключевое значение бралось из таблицы, а не вычислялось.

Статус: завершено.

References


(2) Локализация уровня защиты: официальное шифрование вообще не включено

Игра использует аудио-мидлварь CRIWARE, а у CRIWARE есть штатное шифрование. В карте методов оно действительно есть:

Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)

Похоже, что это оно. Ставим хук и смотрим реальные аргументы вызова:

[+] CriWareDecrypter.Initialize
    key         = "..." (не пустой)
    enableAtom  = false
    enableMana  = false

Оба флага — false, официальное шифрование вообще не активировано: key передаётся, но никто им не пользуется. Ресурсы на самом деле защищает другой, самописный слой игры (Vision.Octo.ResourceDecrypter).

До этого я несколько часов изучал документацию CRIWARE. Поставить хук и посмотреть реальные значения заняло меньше десяти минут.

Уточнение (сделанное в момент анализа): сначала по поверхностным признакам (у bundle нет открытого префикса, характерного для аудио, энтропия высока с самого начала) я решил, что аудио и bundle — это два разных механизма, и сделал большой крюк (пробовал LZ4, AES, хуки GPU, Metal capture). Настоящий прорыв случился, когда я вернулся к азам и провёл атаку по известному открытому тексту — на деле оба используют один и тот же механизм, различаются только префикс и начальная точка. Урок: различие на поверхности — не то же самое, что различие в механизме, а «выглядит иначе» слишком легко превращается в повод прекратить проверку.

Статус: завершено.


(3) Каталог ресурсов и офлайн-цепочка без джейлбрейка

1. Аргумент масштаба: почему не пассивный хук

Для расшифровки нужно исходное имя файла (address) для каждого файла. Имени нет в самом зашифрованном файле — оно в каталоге ресурсов игры.

Пассивный подход: повесить хук на функцию расшифровки, играть в игру и записывать всё, что загружается. Гарантированно сработает, технически без всякого риска.

Проблема — в масштабе:

Всего зашифрованных файлов     1467
Покрытие пассивным хуком       зависит от того, докуда доиграл
Оценка времени                  сотни часов
Гарантия покрытия               нет (ресурсы лимитированных ивентов могут не сработать никогда)

Когда цена пути — «время × удача», а гарантии покрытия нет, это не путь, а наклонная плоскость.

2. Сначала измерить, потом решать, стоит ли того

octo/pdb/5/100001/octocacheevai, 4,4 МБ. Сначала считаем энтропию:

from collections import Counter
import math
 
d = open(path, "rb").read()
c = Counter(d)
H = -sum((n / len(d)) * math.log2(n / len(d)) for n in c.values())
# -> 8.0 bits/byte

Максимум — 8,0, что подтверждает настоящее шифрование, а не просто сжатие или формат сериализации — усилия того стоят, и в то же время это значит, что статический разбор невозможен.

3. Локализация расшифрованной формы

Индекс во время выполнения обязательно расшифровывается для использования, поэтому его расшифрованная форма неизбежно оказывается в памяти процесса. Не нужно взламывать шифр — достаточно найти, как он выглядит после расшифровки.

Первая версия пыталась поймать расшифровку «на месте»: хук на read(), запоминаем адрес буфера, через несколько секунд возвращаемся и читаем тот же блок. Полностью ошибочно — native-буфер очень быстро переиспользуется после возврата из функции (см. Приложение).

Вместо этого: ждём 60 секунд, пока индекс полностью загрузится, а затем сканируем всю память процесса на предмет строки-имени файла, которая заведомо должна там быть.

Найденный блок    16 МБ
Число совпадений  3563

Этот блок и есть полностью расшифрованный каталог ресурсов в формате protobuf.

4. Структура записи

1a <len>              # запись (length-delimited submessage)
  08 <varint>         # 1  id          -> имя каталога кэша = ("A"|"R") + id, затем hex-кодирование
  12 <len> <bytes>    # 2  name        -> address (исходное имя файла)
  18 <varint>         # 3  size        -> размер открытых данных в байтах
  2a 20 <32 bytes>    # 5  md5         -> имя файла в кэше
  3a <len> <bytes>    # 7  objectName  -> ключ объекта на CDN, случайная строка из 6 символов

Пример (VisionProject.acf, полная длина записи 0x42 = 66 байт, сумма по полям сходится точно):

1a 42  08 01  12 11 "VisionProject.acf"  18 89 77  2a 20 "bd19...8afb"  3a 06 "VQHQAP"
       id=1        name(17)              size=15241   md5(32)            objectName(6)

5. Верификация: сверка, а не «выглядит похоже на правду»

Имена каталогов в кэше octo — это hex-кодированный ASCII: 413138363439 раскодируется в A18649. Значит, ("A"|"R") + id можно сверить один к одному с 4942 локальными файлами кэша:

id совпал          4929
id не совпал         13
нет в catalog         0
                     -> 99.74%

Все 13 несовпадений — это обрезание на границе блока памяти: в начало address подмешались мусорные символы вроде *, # (пример: *vo_live_cmn_chr_0). Это проблема границы дампа, а не логики разбора — достаточно фильтра «первый символ address должен быть буквой или цифрой».

Этот шаг — самый важный во всём разделе. Когда парсер выдаёт «строку, похожую на имя файла», он вполне может просто читать шум. Нужен независимый источник для сверки — здесь это имена каталогов в локальном кэше.

В итоге — полная таблица соответствий hash → address из 16823 записей, покрытие пакетной загрузки 99,93%, 1595 файлов, 1,5 ГБ.

6. objectName → CDN

Ранее, при реверсе OctoAPI.DecryptAes, уже был разобран шаблон CDN:

https://asset.game-hololive-dreams.com/{o}

Тогда было непонятно, что такое {o}, и это списали на отвлекающий манёвр. Только после разбора структуры записи стало ясно: {o} — это objectName, поле 7.

GET https://asset.game-hololive-dreams.com/UFfHjj
    User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a

md5 в catalog считается по шифротексту, поэтому целостность можно проверить сразу после загрузки, не расшифровывая заранее.

Это значит, что джейлбрейк нужен теперь ровно для одной вещи — один раз получить catalog.

DEFAULT_URL_FORMAT = "https://asset.game-hololive-dreams.com/{o}"
USER_AGENT = "UnityPlayer/6000.3.0b1"
 
 
def url_for(entry: dict, url_format: str = DEFAULT_URL_FORMAT) -> str:
    return url_format.replace("{o}", entry["object_name"])

在 SiaoHub 檢視 siao/hohohololive/hohohololive/cdn.py L21-26

7. Пошаговый разбор полей: одно предположение стоило 94% пропущенных записей

Первая версия парсера предполагала, что objectName (поле 7) идёт сразу за md5 (поле 5). В итоге из 608 записей mdl_chr удалось поймать только 2.

Между ними затесалось повторяющееся поле 6:

2a 20 <md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 <address>
             \____ 6 = id зависимых ассетов (repeated varint) ____/  \_ 7 = objectName

Каждая 3D-модель зависит от текстур и материалов, поэтому поле 6 есть у всех, и все они пропускались. Переписал на нормальный пошаговый разбор полей — по wire type, последовательно продвигаясь tag → длина → значение:

        while p < n:
            tag = blob[p]
            field, wire = tag >> 3, tag & 7
            p += 1
            if wire == 0:                       # varint
                v = shift = 0
                while p < n:
                    b = blob[p]
                    v |= (b & 0x7F) << shift
                    p += 1
                    if not (b & 0x80):
                        break
                    shift += 7
                if field == 6:
                    deps.append(v)
                else:
                    break
            elif wire == 2:                     # length-delimited
                if p >= n:
                    break
                ln2 = blob[p]
                p += 1
                if ln2 & 0x80:                  # 兩位元組長度, 已超出本筆範圍
                    break
                val = blob[p:p + ln2]
                p += ln2
                if field == 7 and len(val) == ln2 and all(0x20 <= c < 0x7F for c in val):
                    obj = val.decode("ascii")
                break
            else:
                break
        out.append({"id": oid, "address": address, "size": size,
                    "md5": md5.decode(), "object_name": obj,
                    "dependencies": deps})

在 SiaoHub 檢視 siao/hohohololive/hohohololive/catalog.py L112-145

В ветке field == 7 дополнительно требуется «длина совпадает и все байты — печатаемый ASCII», потому что обрезание на границе дампа памяти оставляет у последней записи наполовину прочитанное поле. После исправления:

До исправления    33799 / 36119 записей с objectName
После исправления  36119 / 36119

Заодно разобрал и список зависимостей (поле 6) — потом это очень пригодилось для «захвата вместе с зависимостями».

Порядок полей в protobuf ничем не гарантирован, а поля repeated вообще произвольной длины. Искать поле «по смещению» — это ставка на детали реализации сериализатора, а проигрыш этой ставки не выдаёт ошибку, а просто недобирает данные: соотношение вроде 2/608 бросается в глаза сразу, а вот 33799/36119 могло бы пройти незамеченным.

8. Проверка на практике

К загрузке   944 записи (3D + недостающий Live2D + mot_define), 1,01 ГБ
Успешно      944
Пропущено      0
Ошибок         0

Полная офлайн-цепочка: загрузка с CDN → проверка → расшифровка → извлечение.

Статус: завершено.

References


(4) 3D-модели: glTF со скелетной привязкой

1. Раскладка потоков вершин

stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))

Реальные данные для body персонажа:

stream0 stride 40  ch0 Position(3f)  ch1 Normal(3f)  ch2 Tangent(4f)
stream1 stride 16  ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32  ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)

Способ проверки: для каждого меша сравниваем «расчётную суммарную длину» с реальным m_DataSize — везде совпадение до байта.

2. Два реальных бага

(1) Число влияющих костей не всегда равно 4. Я изначально предполагал, что ch12/ch13 всегда dim4, и просто брал [:, :4]:

Меш ch12 ch13 На деле
Geo_Body_LOD0 dim4 dim4 смешивание 4 костей
Geo_Eye_LOD0 dim2 dim2 смешивание 2 костей
Geo_Brow_LOD0 / Geo_Iris_LOD0 нет dim1 жёсткая привязка к одной кости, вес всегда 1

Для мешей с dim2 [:, :4] брал массив шириной 2, но объявлял его как VEC4 → сумма весов уходила в диапазон −2.37 ~ 3.02, индекс joint — 63471. Брови и радужка из-за «отсутствия ch12» вообще пропускались как непривязанные. Исправление: всегда дополнять нулями до ширины 4; при отсутствии ch12 считать привязку жёсткой и ставить вес 1.0 в слот 0.

(2) Данные вершин хранятся двумя способами. У моделей персонажей они встроены в m_VertexData.m_DataSize, но части окружения по большей части лежат во внешнем потоковом файле (.resS), на который указывают path/offset/size из mesh.m_StreamData. Без учёта этого fbx_mdl_env_* целиком выдавали 0 мешей. Исправил через UnityPy.helpers.ResourceReader.get_resource_data().

3. Преобразование системы координат

Unity — левая система координат, glTF — правая; отражение по оси X, M = diag(-1,1,1):

Объект Преобразование
Позиция / нормаль / тангенс (x,y,z) → (-x,y,z)
Кватернион вращения (x,y,z,w) → (x,-y,-z,w)
Обратная bind-матрица M' = S·M·S, S = diag(-1,1,1,1)
UV v → 1-v (в Unity начало координат внизу слева, в glTF — вверху слева)
Обход треугольника инвертируется (меняется знак определителя)

Вывод для кватерниона: R' = M R M, где M — несобственное преобразование (det = −1), превращающее «поворот на θ вокруг оси a» в «поворот на θ вокруг (aₓ, -a_y, -a_z)»; подставив в q = (sin(θ/2)·a, cos(θ/2)), получаем нужную формулу.

Эти правила записаны прямо в docstring реализации, а не только в журнале, потому что это предпосылки, которые нужно перепроверять при каждом изменении этого файла:

## 座標系轉換
 
Unity 是左手系 (Y-up, Z-forward),glTF 是右手系 (Y-up, Z-back)。
以鏡射 X 軸 M = diag(-1,1,1) 轉換:
 
    位置/法線/切線   (x,y,z) -> (-x, y, z)
    旋轉四元數       (x,y,z,w) -> (x, -y, -z, w)
        推導: R' = M R M, M 為非正常變換 (det=-1),
        使繞軸 a 轉 θ 變成繞 (ax,-ay,-az) 轉 θ
    逆綁定矩陣       M' = S · M · S     (S = diag(-1,1,1,1))
    三角形環繞順序   必須反轉 (行列式變號)

在 SiaoHub 檢視 siao/hohohololive/hohohololive/gltf.py L27-37

4. Место, где вывод оказался ошибочным дважды: оболочка обводки

У Geo_Body_LOD0 три подмеша, число граней 13932 / 240 / 13932 — нулевой и второй полностью совпадают. Индексный буфер 41796 + 720 + 41796 = 84312 в точности заполняет 168624 байт (uint16), так что это не ошибка разбора, а реальные данные.

Первый вывод (ошибочный): у этих дублирующихся подмешей в renderer.m_Materials не было соответствующего материала, и я решил, что «поэтому Unity его не рендерит», и стал использовать «нет материала» как условие отсечения.

Правда: материал был на месте всё это время, просто это общий материал, лежащий в зависимом bundle, и без загрузки зависимостей PPtr просто не резолвится. После добавления load_bundle_with_deps() имя всплывает сразу же:

Материалы: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
  Geo_Body_LOD0 sub0 граней=13932 материал=m_bdy
  Geo_Body_LOD0 sub1 граней=  240 материал=m_bdyco
  Geo_Body_LOD0 sub2 граней=13932 материал=SubMeshOutlineMaterial   <- оболочка обводки

Это классическая техника обводки inverted-hull из тулшейдинга: та же геометрия, но нормали вытолкнуты наружу, а грани развёрнуты так, что рисуется только задняя сторона.

«У этого объекта нет X, поэтому движок его не использует» — когда X разрешается через границу bundle, предпосылка этого утверждения может быть просто в том, что ты не загрузил зависимости.

5. BlendShape → glTF morph target

Мимика лица не завязана на скелет, а идёт через blendshape, причём только на мешах лица:

Меш Число каналов
Geo_Eye_LOD0 16
Geo_Brow_LOD0 14
Geo_Iris_LOD0 2
Geo_Body_LOD0/1 0 (деформация тела целиком на скелете)

В сумме 32 — ровно столько же кривых с typeID 137 в клипах анимации, что служит взаимным подтверждением.

Unity хранит это разреженно: channels[] указывает на отрезок shapes[], shapes[i] в свою очередь указывает на отрезок vertices[], и у каждой вершины свой индекс в исходном меше. Morph target в glTF требует плотный массив смещений той же длины, что и меш, поэтому пришлось разворачивать всё обратно по одной записи (к смещениям тоже применяется отражение по X).

channel.frameCount > 1 означает постепенную деформацию, но один target в glTF может представлять только одну форму, поэтому берётся последний кадр. На практике в этой игре везде frameCount = 1.

Статус: завершено.


(5) 3D-анимация: обратный поиск по CRC32 и mot_define

1. Прорыв дал MonoBehaviour, а не AnimationClip

В genericBindings у AnimationClip хранятся только хэши:

{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}

Сначала пробовал считать CRC32 от путей костей из skeleton.json персонажа и сравнивать — 608 скелетов × все варианты формы пути, 0 совпадений.

Настоящий ответ нашёлся в MonoBehaviour того же bundle (VisionActorMotionDefine): его baseAnimation.bindings перечисляет каждую привязку открытым текстом:

{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
 "type": "UnityEngine.SkinnedMeshRenderer",
 "properties": ["blendShape.b_eye.eye_001", "..."]}

Корневой узел Animator называется Root_Body, а не как bundle — вот в чём была причина несовпадения.

Хэш-функция подтверждена как CRC32(открытый текст):

Строка CRC32 Встречается в clip
b_eye.eye_001 1661978518 первая запись blendshape (без префикса blendShape.)
m_FadeFactor 682354173 6 раз = 6 DecalProjector
bakeAnimationWeight 3202011236 44 раза = 44 кости swing bone

Собрал bindings из всех 637 bundle в единую глобальную таблицу обратного поиска (словаря одного bundle хватает только на его собственные записи) — получил 520 путей / 198 имён свойств.

Это самая переиспользуемая мысль в этом разделе: когда хэш не совпадает, сначала подозревай форму входной строки, а не саму хэш-функцию. Я потратил гораздо больше времени на «а вдруг это другой хэш», чем на «а вдруг просто другой префикс пути» — а ответом оказалось именно второе.

Статус: завершено.


(6) Live2D: атака на нативный слой, а не на верхний

1. Три тупиковых пути

По очереди попробовал и провалил все три: предположение, что это сжатие LZ4; поиск Octo.dll/Octo/Loader/OctoAPI.DecryptAes (обманчивый успех — он расшифровывает пакеты API, а не ресурсы); хук API Cubism SDK на уровне IL2CPP.

Способ, которым провалился третий путь, указал на первопричину: Cubism Core — это нативная библиотека на C, статически слинкованная прямо в UnityFramework, без отдельного .framework, поэтому на уровне IL2CPP просто нечего хукать.

2. Переход на атаку нативных экспортируемых символов

Module.enumerateExports() выдаёт все нативные экспорты, начинающиеся с csm (всего 44):

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← ключевая функция
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* и другие

csmReviveMocInPlace(void* address, unsigned int mocSize) принимает два аргумента: адрес памяти, где лежат уже расшифрованные, готовые к разбору данные moc3, и их размер. Не нужно понимать, что там наверху — C# или IL2CPP, и не нужно трогать сам алгоритм шифрования — достаточно того, что эта нативная функция вызывается: данные в памяти в этот момент на 100% корректный и пригодный к использованию открытый moc3.

const target = Module.findExportByName("UnityFramework", "csmReviveMocInPlace");
Interceptor.attach(target, {
  onEnter(args) {
    const addr = args[0], size = args[1].toInt32();
    send({event: "csmReviveMocInPlace", size}, addr.readByteArray(size));
  }
});

Использование Module.findExportByName вместо жёстко прописанного смещения адреса даёт естественный иммунитет к ASLR и сдвигу адресов при перезапуске.

3. Обобщённая форма приёма

Переиспользуемую форму этого приёма стоит выписать отдельно: найди ту нативную функцию, которая принимает открытый текст в качестве аргумента, и жди на ней. Сколько бы слоёв шифрования, обфускации и managed-исполнения ни было наверху, в конце концов данные должны быть переданы движку в форме, которую движок понимает. Эта точка передачи — позиция с наименьшей стоимостью, и она по своей природе не зависит от того, как поменяется схема шифрования.

Полученные .moc3 / .model3 / .physics3 можно сразу открыть в Cubism Editor; материалы нужно переименовать по информации из BuildModelData.

Статус: завершено (атлас текстур отдельно, см. ниже).

4. Незавершённое: атлас текстур

Текстурный атлас, привязанный к moc3, до сих пор не получен. По очереди перепробовал семь путей, и все провалились (везде это были хуки Frida + ручной триггер игроком); настоящий источник данных нашёлся только после перехода на чисто статическую локализацию пути загрузки. По ходу дела одно время принял set_MainTexture за верную точку зацепа, но дальнейшая декомпиляция это опровергла.

Уточнение: предположение о set_MainTexture оказалось ошибочным. В тот момент оно «выглядело как то самое», и хук действительно что-то ловил — просто не то, что было нужно. Поймать хуком что-то — не то же самое, что поймать хуком нужное место.

Статус: не завершено. Из оставшихся вариантов — Xcode Metal Frame Capture.

References


(7) Физика пружинных костей (не завершено)

Подол юбки, ленты и волосы не идут запечёнными в анимацию — запекание охватывает только 51 кость humanoid-скелета, а у модели их на деле 126; недостающие 75 (31 в подоле, 10 в щеках, 8 в лентах…) вычисляются игровым Swing/Quartz во время выполнения.

Но параметры поставляются в комплекте, встроенные в MonoBehaviour каждого bundle модели:

ActorSwingDynamicBone   висит на кости _sim   симулируемая кость
ActorSwingStaticBone    висит на кости тела    коллайдер
ActorSwingChain         висит на hips          структура цепи
QuartzDriverSkirtBone   висит на кости _ast    процедурная вспомогательная кость

(Их не найти поиском по address в catalog, потому что address не содержит имени класса MonoBehaviour — та же ошибка, что была допущена при первоначальном поиске Avatar.)

Интегратор для офлайн-воспроизведения:

step    = min(dt, 1/60) × 40
inertia = (pos - prevPos) × (1 - damping)²
force   = CalcStiffnessPendulum(...) + childSpeed × spring
newPos  = pos + inertia + force × step
newPos.y -= mass × 0.01            гравитация
                                   далее: жёсткое ограничение длины кости, затем перевод в поворот родительской кости

CalcStiffnessPendulum (dynamicType == 0, покрывает 6900 из 6940 костей):

delta = rotate(parent.worldRot, boneAxis)      направление кости в покое
cos   = |dot(cur, rest)| / (|cur|·|rest|)      угол между двумя векторами позиции
p     = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01

Координаты заданы в animator root space, а не в мировых, поэтому можно считать прямо по иерархии скелета из GLB.

Основной цикл интегрирования в реализации:

 
            for _ in range(n_sub + warm):
                cur, pv = pos[ni], prev[ni]
                inertia = (cur - pv) * (1.0 - damping) ** 2
                # cos 是 prevPos 與 pos 的夾角 (呼叫端 childTx=-0xf0=prevPos,
                # childDefaultTx=(s13,s11,s12)=pos)。0x02793a04 把 -0xf0
                # 寫回 child.selfTx.translation, 證實它就是子骨的位置。
                if pen <= 1e-5 or rng <= 1e-5:
                    p_term = 0.0
                else:
                    na, nb = np.linalg.norm(pv), np.linalg.norm(cur)
                    cosv = abs(float(np.dot(pv, cur))) / max(na * nb, 1e-9)
                    p_term = max(0.0, cosv - (1.0 - rng)) / rng * pen
                # delta = rotate(**當前**的 selfTx.rotation, boneAxis) —— §35 釘死:
                # selfTx 是迴圈攜帶狀態, 每個子步讀到的是上一子步的模擬結果,
                # 不是動畫給的靜止姿勢。骨的當前方向就是 (pos - 骨位置) 正規化。
                # 用動畫的 rest_dir 等於憑空多給一個遊戲裡沒有的角度回復力。
                if args.delta_current:
                    cd = cur - anchor      # §39: 原本用 WT[ni], 與骨長約束的基準不一致
                    cn = float(np.linalg.norm(cd))
                    dvec = cd / cn if cn > 1e-9 else rest_dir
                else:
                    dvec = rest_dir
                force = dvec * (stiff - p_term) * 0.01 + csp
                if args.vel == "off":
                    new = cur + inertia + force * step
                else:
                    # §36: 0x02793408/0x02793410 讀寫 [x20+0x54] 這個累加器 ——
                    #   fmul s1, s0, s5             力 × step
                    #   fmul v13.2s, v0.2s, v2.s[0] × swingPowerWeight (實測 1.0)
                    #   fadd v4.2s, v13.2s, v0.2s   累加進狀態
                    # 力不是加到位置, 是加進狀態; 狀態才改位置。
                    vel[ni] = vel[ni] * (1.0 - args.vel_damp) + force * step
                    if args.vel == "add":
                        new = cur + inertia + vel[ni] * step
                    else:
                        new = cur + vel[ni] * step
                new[1] -= mass * 0.01 * args.gravity_scale  # 重力 (每個子步)

在 SiaoHub 檢視 siao/hohohololive/sim_swing.py L705-742

Текущее состояние

Способ проверки — покадровое сравнение с эталонными данными из игры: bake_swing_truth.py записывает из игры local rotation костей _sim для того же клипа, и для каждого кадра считается угол:

угол = 2 · arccos(|dot(q_sim, q_truth)|)

Знак кватерниона не влияет на позу, поэтому берётся модуль.

ошибка / амплитуда эталонного движения   110%      (100% = пружинные кости вообще не двигаются)

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

--quartz          QuartzDriverSkirtBone управляет корнем цепи _ast   142%
--delta-current   delta берёт текущее направление вместо направления покоя   153%
--collision       сфера vs конусовидная капсула                            202%
--vel             сила накапливается в состояние скорости                   147%

Всё ещё не реализовано: ветер (CalcWindPower, на практике windPower = 0.7 включён), четвёртый проход сглаживания цепи костей, привод _ast для множественных вариантов.

Уточнение: изначально решение «ветер только увеличивает раскачку, направление не то» и было отложено — но это решение принято на модели, в которой ещё сидело четыре бага, и его нужно перепроверить. Исключение, сделанное на неверном базовом уровне, не считается исключением.

Статус: не завершено. Это единственное, что во всём проекте всё ещё открыто.


(8) Границы публикации

Алгоритм расшифровки разобран полностью и оформлен в виде полной математической спецификации и рабочего инструмента. Эта часть не публикуется — ни здесь, ни в открытом репозитории.

Причина — юридическая. §80-2 Закона об авторском праве Тайваня (Taiwan Copyright Act) запрещает предоставление «устройств, оборудования, компонентов, технологий или информации, основным назначением которых является обход мер защиты от копирования». Слово «информация» охватывает не только код, но и достаточно ясно написанную спецификацию, по которой читатель сможет сам всё реализовать. В третьем пункте той же статьи есть исключение для «реверс-инжиниринга, проводимого ради достижения совместимости между информационными системами», но общепринятое понимание таково, что оно покрывает проведение реверс-инжиниринга, а не публичное распространение метода обхода защиты. Серая зона, а я не юрист.

(Поэтому эта статья в этом отношении отличается от архива заметок sssekai — там публикуется полная key table и AES key/iv, здесь — нет. Это моя консервативная оценка применительно к моей собственной юрисдикции, а не оценка чужого подхода.)

Инструменты разделены при публикации: половина, отвечающая за разбор форматов (извлечение AssetBundle, mesh/скелет, конвертация в glTF, декодирование Live2D и 3D-анимации), — это работа по совместимости, она открыта на https://git.siao.ai/siao/hohohololive; половина с расшифровкой — нет.

Сам способ разделения содержит поворот, который стоит зафиксировать. Изначально планировался обычный ход — «убрать ключ, оставить алгоритм», но это не сработало: ключ выводится из address, и сама процедура вывода и есть ключ, отдельного секрета, который можно было бы убрать, просто нет. А единственная магическая константа в реализации — это один-единственный байт, известный открытый текст прямо в заголовке файла, и перебор 256 вариантов занимает микросекунды. Скрыть только эту константу — значит не снизить трудозатраты ни на йоту, а получить нечто, что «выглядит скрытым, но на деле нет». Поэтому убрал весь слой целиком.

Материалы игры не публикуются вообще — авторские права на игровые ресурсы принадлежат издателю, ни один байт из них не попадает ни в одну из публикуемых мной вещей.


Приложение: подводные камни

Мерил скорость передачи на маленьком файле. После смены шифра протестировал на маленьком файле — он «завершил передачу» в тот же миг, когда попал в дисковый кэш. Улучшение, замеченное после какого-то изменения, не обязательно этим изменением и вызвано.

Сдампил 234 МБ ненужной памяти. UnityFramework на диске и так не зашифрован. Хуже того, версия из памяти оказалась вообще непригодной:

Диск     __TEXT упакован плотно, file offset == vaddr - base
Память   __TEXT выровнен по страницам, разница — padding выравнивания каждого сегмента

Адреса не сходятся, инструмент разбора просто ломается. Зашифрована metadata, а не бинарник, а я это не различил и применил к обоим приёмы для борьбы с шифрованием.

Читал native-буфер с задержкой. Буфер нативного read() очень быстро переиспользуется после возврата из функции, и то, что читается с задержкой, — это не относящиеся к делу остатки: одно время я принимал их за логику поиска по таблице, строки UTF16, bplist. Дамп нужно делать синхронно, прямо в onLeave:

Interceptor.attach(Module.findExportByName(null, "read"), {
  onEnter(args) { this.buf = args[1]; },
  onLeave(ret) {
    const n = ret.toInt32();
    if (n > 0) send({tag: "read", n}, this.buf.readByteArray(n));  // синхронно, откладывать нельзя
  }
});

Переиспользование fd загрязняет таблицу отслеживания. Хук на open() запоминал интересующие fd, но не убирал их в close(). Когда система переиспользует тот же номер fd для другого файла, посторонний контент (заголовок UnityFS, bplist00) принимается за содержимое целевого файла:

Interceptor.attach(Module.findExportByName(null, "close"), {
  onEnter(args) { tracked.delete(args[0].toInt32()); }
});

Последние два стоит запомнить особо, потому что дело не в том, что «я сделал неверный вывод», а в том, что сам метод наблюдения производил поддельные данные. Поддельные данные выглядели совершенно неотличимо от настоящих, и я успел построить под них не одно объяснение.


Инструменты

Назначение Инструмент
Проброс USB-порта libimobiledevice / iproxy
Динамическая инструментация Frida (Python API, не CLI)
Разбор IL2CPP Il2CppInspectorRedux (форк LukeFZ)
Декомпиляция rizin + rz-ghidra
Ассеты Unity UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1")
  • Используй Python API Frida, а не CLI. У CLI бывают проблемы с таймаутом attach, а device.spawn() → attach() → resume() гораздо стабильнее.
  • CLI Il2CppInspectorRedux может зависать мнимо. Встроенный веб-сервис SignalR иногда подвешивает весь процесс целиком — все потоки простаивают в __psynch_cvwait. sample <pid> сразу показывает, что процесс не занят работой, а ждёт. Обходится переходом на более лёгкие опции вывода.

References