Hololive Dreams Reverse Engineering — Archived Notes
Preface
Cutoff: 26/08/23 Revision: 26/08/23
A record of reverse-engineering a Unity 6 mobile game's asset system. Current status: assets, animation, face, hair, camera, and Live2D are all deliverable — spring-bone physics is the only piece still unsolved.
Tools and the full chapter-by-chapter journal live at https://git.siao.ai/siao/hohohololive (decryption not included — reasoning in (8)).
The code snippets in this post are embedded live from SiaoHub at build time, not pasted by hand, so they never drift from the source; SiaoHub's file pages also show a back-reference marking them as "cited by this article."
The name is a nod to sssekai — that project and its journal archive were the most useful reference on this whole reverse-engineering trip.
Analysis target: game.qualiarts.hololive.dreams.com 1.0.0 (iOS, decrypted IPA), Unity 6000.3.0b1, IL2CPP metadata v39.
(1) Device Connection and IL2CPP metadata v39
1. Transfer: check the ceiling first
Library/octo/ inside the app container is the asset download cache, 3.4GB. The v1/ subdirectory has 178 .awb, 1240 .acb, and 176 .usm files — all CRIWARE audio/video formats.
scp over WiFi SSH took 11 minutes to move 12MB. First instinct: switch cipher (the default has no hardware acceleration). After switching it looked instantly faster — that was a local disk-buffering illusion; a bigger file exposed the real number.
But more to the point: even if the cipher swap had genuinely helped, it wouldn't have mattered — 3.4GB over WiFi is tens-of-minutes territory no matter how you tune it. Switched to USB:
iproxy 2222:22 & # SSH
iproxy 27042:27042 & # Frida| Path | Measured | Estimated time for 3.4GB |
|---|---|---|
| WiFi SSH (default cipher) | ~18 KB/s | ~2 days |
| WiFi SSH (gcm) | small files look instant, large files still slow | — |
USB (iproxy) |
40–70 MB/s | ~1 minute |
Pulled the whole thing back to the local machine as a tar before touching anything. Later on there were several occasions where I needed to clear the device-side cache to observe download timing — without a backup, every clear would have been irreversible.
2. The metadata is encrypted, the binary is not
$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ... # expected AF 1B B1 FA
The magic doesn't match. But the UnityFramework on disk isn't encrypted — the two need to be kept separate, and I didn't at first (see the Appendix, pitfalls hit).
The metadata has to be decrypted at runtime to be used, so scan memory for the 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--") scans region by region, a single address hit:
magic = AF 1B B1 FA
version = 39
version = 39 is a valid IL2CPP version number, which means this is the real decrypted thing and not a coincidental hit.
3. The failure mode of version spoofing was the real answer
The latest Il2CppDumper only supports up to version 31. The standard trick is to spoof the version number, since the format often doesn't change — the number just gets bumped.
| Spoofed as | Result |
|---|---|
| 27 | fails — key collision |
| 29 | fails — same key collision |
| 31 | fails — same key collision |
All three hung at the exact same spot. If this were just a bumped number, spoofing different old versions should break in different places; three identical failures mean the parser's read of the structure diverges from expectation at that exact point — this is a genuinely updated format.
What makes this judgment worth something is that it closes off an entire branch in one shot. "Try one more version number" only costs thirty seconds each time, which makes it very easy to keep trying forever.
Switched to Il2CppInspectorRedux (the LukeFZ fork), which produced a complete map of 560,000 method names to virtual addresses.
4. Decompiler environment: routing around the JVM
Ghidra needs a JVM, and this machine's AppleSystemPolicy kernel module blocks unsigned java outright — not a tool-sandbox restriction, the system itself; running it directly in my own terminal gets the same Kill: 9.
No point fighting the system. rz-ghidra pulls Ghidra's decompiler engine's C++ core (SLEIGH + decompiler) out and builds it as a rizin plugin, which needs no JVM at runtime:
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/Pitfall one: copying the .dylib alone isn't enough — you also need to put the built .sla (compiled sleigh language spec) back into the source tree alongside its .slaspec, and set SLEIGHHOME. The error message never mentions a missing data file.
Pitfall two: if the af in rizin -q -c "s <addr>; af; pdg" lands inside the range of an earlier, larger function at some address, it reuses that function's existing boundary and decompiles unrelated function content. I once misread this as IL2CPP's shared generic Dictionary lookup logic at a given address, and briefly believed some key value was looked up rather than computed.
Status: done.
References
(2) Locating the protection layer: the official encryption was never turned on
This game uses CRIWARE audio middleware, and CRIWARE has its own official encryption. The method map does have it:
Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)
Looked like the obvious candidate. Hooked it to see the actual call arguments:
[+] CriWareDecrypter.Initialize
key = "..." (non-empty)
enableAtom = false
enableMana = false
Both switches are false — the official encryption was never enabled at all. A key gets passed in, but nobody uses it. The layer actually protecting the assets is the game's own custom one (Vision.Octo.ResourceDecrypter).
Before this I'd spent a few hours poring over CRIWARE's documentation. Hooking it and looking at the actual values took under ten minutes.
Correction (at time of analysis): I initially judged from surface appearance (bundles lack the audio side's plaintext prefix, and open with high entropy) that audio and bundles were two separate mechanisms, and went a long way around because of it (tried LZ4, AES, GPU hooking, Metal capture). The real breakthrough was going back to basics and running a known-plaintext attack — the two actually share the same scheme, differing only in prefix and starting offset. Lesson: a surface difference is not a mechanism difference, and "looks different" is an easy excuse to stop verifying.
Status: done.
(3) The asset catalog and a jailbreak-free offline pipeline
1. The scale argument: why not passive hooking
Decryption needs each file's original filename (address). The filename isn't inside the encrypted file — it lives in the game's asset catalog.
The passive approach: hook the decrypt function, play the game, log whatever gets loaded. Guaranteed to work, technically zero risk.
The problem is scale:
Total encrypted files 1467
Passive hook coverage depends how far you play
Estimated hours hundreds
Coverage guarantee none (limited-time event assets may never trigger)
When a path's cost is "time × luck" with no coverage guarantee, it isn't a path — it's a slope.
2. Measure first, then decide if it's worth it
octo/pdb/5/100001/octocacheevai, 4.4MB. Computed entropy first:
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/byteA perfect 8.0 — confirms real encryption rather than just compression or a serialization format. Worth the effort, and it also means static parsing is off the table.
3. Locating the decrypted form
The index has to be decrypted to be used at runtime, so its decrypted form must exist somewhere in process memory. No need to break the encryption — just find what it looks like once decrypted.
First attempt tried to catch "in-place decryption": hook read(), record the buffer address, come back a few seconds later and read the same block. Completely wrong — the native buffer gets reclaimed and reused very shortly after the function returns (see the Appendix).
Changed approach: wait 60 seconds for the index to fully load, then scan the entire process memory for a filename string known to be in there.
Hit region 16 MB
Hit count 3563
That block is the fully decrypted asset catalog, in protobuf format.
4. Entry structure
1a <len> # entry (length-delimited submessage)
08 <varint> # 1 id -> cache directory name = ("A"|"R") + id, then hex-encoded
12 <len> <bytes> # 2 name -> address (original filename)
18 <varint> # 3 size -> plaintext byte count
2a 20 <32 bytes> # 5 md5 -> cache filename
3a <len> <bytes> # 7 objectName -> CDN object key, 6-character random string
Example (VisionProject.acf, total entry length 0x42 = 66 bytes, sums exactly field by field):
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. Verification: reconciliation, not "looks right"
The octo cache's directory names are hex-encoded ASCII — 413138363439 decodes to A18649. So ("A"|"R") + id can be reconciled one-by-one against the 4942 local cache files:
id match 4929
id mismatch 13
not in catalog 0
-> 99.74%
All 13 mismatches were memory block boundary truncation: noise characters like * and # got mixed into the start of the address (e.g. *vo_live_cmn_chr_0). That's a dump boundary issue, not a parsing logic issue — filtering with "address's first character must be alphanumeric" clears it.
This step is the most important one in the whole section. When a parser outputs "a string that looks like a filename," it might just be reading noise. You need an independent source to reconcile against — here, the local cache's directory names.
Final result: a complete table of 16,823 hash-to-address mappings, 99.93% batch coverage, 1595 files, 1.5GB.
6. objectName → CDN
While previously reverse-engineering OctoAPI.DecryptAes, I'd already decoded a CDN template:
https://asset.game-hololive-dreams.com/{o}
Didn't know what {o} was at the time — chalked it up to a red herring. Only after decoding the entry structure did it become clear: {o} is field 7's objectName.
GET https://asset.game-hololive-dreams.com/UFfHjj
User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
The md5 stored in the catalog is computed over the ciphertext, so integrity can be checked right after download, without decrypting first.
Which means jailbreaking is only needed for exactly one thing: pulling the catalog once.
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. Field-advance parsing: one assumption caused 94% to be missed
The first parser version assumed objectName (field 7) immediately follows md5 (field 5). The result: only 2 out of 608 mdl_chr entries were captured.
There's a repeated field 6 sitting in between:
2a 20 <md5> 30 ca8402 30 e19402 30 809502 ... 3a 06 "SswxO0" 42 23 <address>
\____ 6 = dependent asset id (repeated varint) ____/ \_ 7 = objectName
Every 3D model depends on textures and materials, so all of them have field 6 — and all got skipped. Switched to proper field-advance parsing — walking tag → length → value field by field according to wire type:
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
The field == 7 branch also additionally requires "length matches and is entirely printable ASCII," because boundary truncation in the memory dump can leave the last entry with a half-read field. After the fix:
Before fix 33799 / 36119 entries have objectName
After fix 36119 / 36119
Decoded the dependency list (field 6) along the way too, which made "fetch dependencies together" easy later on.
protobuf's field order isn't guaranteed, and repeated fields can be any length. Locating a field by offset is a bet on the serializer's implementation details, and when you lose that bet, it doesn't error — it just under-captures. A ratio like 2/608 is obvious; 33799/36119 might not have been noticed at all.
8. Live test
To download 944 entries (3D + missing Live2D + mot_define), 1.01 GB
Succeeded 944
Skipped 0
Failed 0
Full offline pipeline: CDN download → checksum → decrypt → extract.
Status: done.
References
(4) 3D models: glTF with skeleton binding
1. Vertex stream layout
stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
Measured on a character 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)
Verification: for every mesh, compare "computed total length" against the actual m_DataSize — matched byte-for-byte across the board.
2. Two real bugs
(1) Bone-influence count isn't a fixed 4. I originally assumed ch12/ch13 were always dim4 and just did [:, :4]:
| Mesh | ch12 | ch13 | Actual |
|---|---|---|---|
Geo_Body_LOD0 |
dim4 | dim4 | 4-bone blend |
Geo_Eye_LOD0 |
dim2 | dim2 | 2-bone blend |
Geo_Brow_LOD0 / Geo_Iris_LOD0 |
none | dim1 | rigid single-bone, weight always 1 |
For dim2 meshes, [:, :4] only grabbed a width-2 array but declared it as VEC4 → weight sums came out as −2.37 to 3.02, joint index 63471. Eyebrows and iris got skipped entirely as unbound simply because they "had no ch12." Fix: always zero-pad to width 4; when ch12 is missing, treat it as rigid binding with slot 0's weight set to 1.0.
(2) Vertex data is stored two different ways. Character models embed it in m_VertexData.m_DataSize, but scene props mostly live in an external streaming file (.resS), pointed to by mesh.m_StreamData's path/offset/size. Without handling this, the entire fbx_mdl_env_* batch decoded to 0 meshes. Fixed by fetching via UnityPy.helpers.ResourceReader.get_resource_data().
3. Coordinate system conversion
Unity's left-handed system → glTF's right-handed system, via mirroring the X axis with M = diag(-1,1,1):
| Target | Transformation |
|---|---|
| Position / normal / tangent | (x,y,z) → (-x,y,z) |
| Rotation quaternion | (x,y,z,w) → (x,-y,-z,w) |
| Inverse bind matrix | M' = S·M·S, S = diag(-1,1,1,1) |
| UV | v → 1-v (Unity origin bottom-left, glTF top-left) |
| Triangle winding | reversed (determinant sign flips) |
Derivation for the quaternion row: R' = M R M, where M is an improper transform (det = −1), turns "rotate θ about axis a" into "rotate θ about (aₓ, -a_y, -a_z)"; substituting into q = (sin(θ/2)·a, cos(θ/2)) gives the result.
These live in the implementation's docstring, not just in the journal, because they're assumptions that have to be reconfirmed every time this file changes:
## 座標系轉換
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. A place where the inference was wrong twice: the outline shell
Geo_Body_LOD0 has 3 submeshes, with face counts 13932 / 240 / 13932 — submesh 0 and submesh 2 are identical. The index buffer's 41796 + 720 + 41796 = 84312 fills exactly 168624 bytes (uint16), so this isn't a parsing error — it's real data.
First inference (wrong): these duplicated submeshes had no corresponding material in renderer.m_Materials, so I concluded "Unity therefore doesn't render it" and used "no material" as a culling condition.
The truth: the material was there all along — it was just a shared material living in a dependency bundle, and without loading the dependency, the PPtr simply couldn't resolve. Adding load_bundle_with_deps() made the names surface immediately:
Materials: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
Geo_Body_LOD0 sub0 faces=13932 material=m_bdy
Geo_Body_LOD0 sub1 faces= 240 material=m_bdyco
Geo_Body_LOD0 sub2 faces=13932 material=SubMeshOutlineMaterial <- outline shell
The classic toon-rendering inverted-hull outline: the same geometry, normals extruded outward, faces flipped, only the back faces drawn.
"This thing doesn't have X, so the engine doesn't use it" — when X is something resolved across bundles, that sentence's premise might just be that you never loaded the dependency.
5. BlendShape → glTF morph target
Facial expressions don't go through the skeleton — they go through blendshapes, and only on face meshes:
| Mesh | Channel count |
|---|---|
Geo_Eye_LOD0 |
16 |
Geo_Brow_LOD0 |
14 |
Geo_Iris_LOD0 |
2 |
Geo_Body_LOD0/1 |
0 (body deformation entirely via skeleton) |
Total 32 — exactly matching the curve count for typeID 137 inside the animation clip, corroborating each other.
Unity stores this sparsely: channels[] points into a range of shapes[], and each shapes[i] points into a range of vertices[], with each vertex carrying its own original mesh index. glTF's morph target needs a dense displacement array the same length as the mesh, so it has to be expanded back out one by one (the displacements also need the X mirror applied).
channel.frameCount > 1 indicates progressive deformation; since a glTF target can only represent one shape, the last frame is taken. In this game, every one measured turned out to have frameCount = 1.
Status: done.
(5) 3D animation: CRC32 reverse lookup and mot_define
1. The breakthrough was MonoBehaviour, not AnimationClip
AnimationClip's genericBindings only stores hashes:
{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}First attempt: compute CRC32 over the character's skeleton.json bone paths and compare — 608 skeletons × every path form tried, 0 hits.
The real answer was in the same bundle's MonoBehaviour (VisionActorMotionDefine): its baseAnimation.bindings lists every binding in plaintext:
{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
"type": "UnityEngine.SkinnedMeshRenderer",
"properties": ["blendShape.b_eye.eye_001", "..."]}The Animator's root node is called Root_Body, not the bundle name — that's why nothing was matching.
Confirmed the hash function is CRC32(plaintext):
| String | CRC32 | Occurrence in clip |
|---|---|---|
b_eye.eye_001 |
1661978518 | blendshape's first entry (without the blendShape. prefix) |
m_FadeFactor |
682354173 | appears 6 times = 6 DecalProjectors |
bakeAnimationWeight |
3202011236 | appears 44 times = 44 swing bones |
Collected the bindings from all 637 bundles into a global reverse-lookup table (a single bundle's dictionary is only enough to resolve its own entries), yielding 520 paths / 198 property names.
This is the most reusable lesson from this section: when a hash doesn't match, suspect the input string's form before you suspect the hash function. I spent far more time on "maybe it's actually a different hash" than on "maybe the path prefix is different" — and the answer was the latter.
Status: done.
(6) Live2D: attacking the native layer, not the layer above it
1. Three dead ends
Tried in order, all failed: guessed it was LZ4 compression; went after Octo.dll/Octo/Loader/OctoAPI.DecryptAes (a misleading success — it decrypts API packets, not assets); hooked the Cubism SDK API at the IL2CPP layer.
The way the third one failed pointed at the root cause: Cubism Core is a native C library, statically linked straight into UnityFramework with no separate .framework — so there's simply nothing at the IL2CPP layer to hook.
2. Switching to native exported symbols
Module.enumerateExports() lists every native export starting with csm (44 total):
csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace ← the key function
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* family
csmReviveMocInPlace(void* address, unsigned int mocSize) takes two arguments: the memory address of already-decrypted, parseable moc3 data, plus its size. No need to understand whether the layer above is C# or IL2CPP, and no need to touch the encryption algorithm itself — the moment this native function is called, whatever's in memory at that address is guaranteed to be 100% correct, usable plaintext 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));
}
});Using Module.findExportByName instead of a hardcoded address offset makes this naturally immune to ASLR and restart address shifts.
3. The general shape
The reusable shape here is worth writing out on its own: find the native function that "takes plaintext as an argument," and wait on it. No matter how many layers of encryption, obfuscation, or managed runtime wrap the layer above, the data eventually has to be handed to the engine in a form the engine can understand. That handoff point is the lowest-cost place to sit, and by nature it doesn't move when the encryption scheme changes version.
Once .moc3 / .model3 / .physics3 are obtained, they open directly in Cubism Editor; materials need renaming based on BuildModelData's info.
Status: done (texture atlas handled separately, see below).
4. Not yet solved: the texture atlas
Still haven't gotten the moc3's dedicated texture atlas. Tried seven approaches in sequence, all failed (all Frida hooks + manually triggering as a player), and only found the real data source by switching to purely static locating of the load path. Along the way set_MainTexture was treated as the right hook point, then overturned after further decompilation.
Correction: the
set_MainTextureassumption turned out wrong. At the time it "looked like the one," and hooking it genuinely returned something — that something just wasn't what I needed. Catching something on a hook doesn't mean you hooked the right place.
Status: unfinished. The remaining option is Xcode Metal Frame Capture.
References
- https://github.com/OpenL2D/moc3ingbird (an ImHex pattern for moc3)
(7) Spring-bone physics (unfinished)
Skirt hems, ribbons, and hair don't ship baked with the animation — the bake only covers 51 humanoid bones, while the model actually has 126; the missing 75 (31 skirt, 10 cheek, 8 ribbon...) are computed at runtime by the game's Swing/Quartz system.
But the parameters do ship, embedded in each model bundle's MonoBehaviour:
ActorSwingDynamicBone attached to _sim bones the simulated bones
ActorSwingStaticBone attached to body bones collider
ActorSwingChain attached to hips chain structure
QuartzDriverSkirtBone attached to _ast bones procedural helper bones
(These don't turn up searching the catalog's address field, because address doesn't contain the MonoBehaviour class name — the same mistake as searching for Avatar early on.)
The offline-reproduced integrator:
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 gravity
after: hard bone-length constraint, then convert to parent-bone rotation
CalcStiffnessPendulum (dynamicType == 0, covering 6900/6940 bones):
delta = rotate(parent.worldRot, boneAxis) bone's rest direction
cos = |dot(cur, rest)| / (|cur|·|rest|) angle between the two position vectors
p = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01
The coordinates are in animator root space, not world space, so it works out correctly by computing directly against the GLB's skeleton hierarchy.
The main integration loop in the implementation:
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
Current state
Verification works by comparing frame-by-frame against ground truth from the game — bake_swing_truth.py records the _sim bones' local rotation for the same clip from the actual game, then computes the angle per frame:
angle = 2 · arccos(|dot(q_sim, q_truth)|)
A quaternion's sign doesn't affect the pose, hence the absolute value.
error / ground-truth motion amplitude 110% (100% = spring bones not moving at all)
Still slightly worse than doing nothing at all. The symptom converges to a single term: overshoot at 1.50×. Four items are implemented but off by default (each penalized because something downstream is still missing damping):
--quartz QuartzDriverSkirtBone drives _ast chain root 142%
--delta-current delta uses current direction instead of rest 153%
--collision sphere vs. capped-cone capsule 202%
--vel force accumulates into velocity state 147%
Still unimplemented: wind (CalcWindPower — measured windPower = 0.7 is on), the fourth pass of chain-bone smoothing, and multi-variant _ast driving.
Correction: I originally shelved wind on the judgment that "it increases swing, in the wrong direction" — that judgment was made on a model that still had four bugs in it, and needs retesting. Ruling something out on a broken baseline doesn't count as ruling it out.
Status: unfinished. This is the only thing still open across the whole project.
(8) Scope of disclosure
The decryption algorithm has been fully worked out, and written up as a complete mathematical spec plus a working tool. That part is not here, and not in the public repo.
The reason is legal. Taiwan's Copyright Act §80-2 prohibits providing "devices, equipment, parts, technology, or information primarily used to circumvent anti-piracy protection measures" — the word "information" covers not just code, but also a spec document written clearly enough that reading it lets you implement it yourself. The same article's third clause has an exception for "reverse engineering conducted to achieve interoperability between pieces of information," but the common understanding is that this covers doing the reverse engineering, not publicly publishing the circumvention method. It's a gray area, and I'm not a lawyer.
(This is where this post diverges from sssekai's journal archive — that one publishes the full key table and AES key/iv, this one doesn't. That's a conservative call about my own jurisdiction, not a judgment on anyone else's approach.)
The tooling is split for release: the format-parsing half (AssetBundle extraction, mesh/skeleton, glTF conversion, Live2D and 3D animation decoding) is interoperability work and is public at https://git.siao.ai/siao/hohohololive; the decryption half is not.
The split itself had a twist worth noting. I originally planned the usual move: "strip the key, keep the algorithm" — but that doesn't hold up here. The key is derived from address, and the derivation procedure itself is the key; there's no separate secret to strip out. And the only magic constant in the implementation is a single byte, with known-plaintext sitting right in the file header — 256 possibilities is a microsecond-scale brute force. Redacting just that constant would reduce the effort required by zero, while producing something that looks redacted but isn't. So the entire layer comes out together.
No game assets are published at all — the copyright on the game's resources belongs to the publisher, and not a single byte of it sits anywhere I've released.
Appendix: pitfalls hit
Testing transfer speed with a small file. After switching cipher I tested a small file, and it "finished transferring" the instant it hit the disk write cache. Seeing improvement after a change doesn't mean that change caused it.
Dumped 234MB of memory that wasn't needed. The on-disk UnityFramework was never encrypted to begin with. Worse, the in-memory version turned out to be unusable:
disk __TEXT is tightly packed, file offset == vaddr - base
memory __TEXT is page-aligned, the two differ by each segment's alignment padding
Addresses didn't line up, and the parsing tool broke outright. The encrypted thing was the metadata, not the binary — I hadn't drawn that distinction, and applied anti-encryption tactics to both.
Reading the native buffer too late. native read()'s buffer gets reclaimed and reused shortly after it returns; reading it late picks up unrelated leftovers — at one point misread as lookup logic, UTF16 strings, and a bplist. Has to be dumped synchronously right inside 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)); // right now, cannot be deferred
}
});fd reuse poisoning the tracking table. Hooked open() to record fds of interest but never cleared them in close(). Once the system reassigned the same fd number to a different file, unrelated content (UnityFS headers, bplist00) got treated as the target file's content:
Interceptor.attach(Module.findExportByName(null, "close"), {
onEnter(args) { tracked.delete(args[0].toInt32()); }
});The last two are the most worth remembering, because they weren't cases of "I inferred wrong" — the observation method itself was manufacturing fake data. The fake data looked identical to real data, and I built several explanations around it before catching on.
Toolchain
| Purpose | Tool |
|---|---|
| USB port forwarding | libimobiledevice / iproxy |
| Dynamic instrumentation | Frida (Python API, not the CLI) |
| IL2CPP parsing | Il2CppInspectorRedux (LukeFZ fork) |
| Decompilation | rizin + rz-ghidra |
| Unity assets | UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1") |
- Use Frida's Python API, not the CLI. The CLI has attach-timeout issues;
device.spawn() → attach() → resume()is far more stable. Il2CppInspectorRedux's CLI can appear to hang. Its built-in SignalR web service can stall the whole process, with every thread idling in__psynch_cvwait. Runningsample <pid>shows it's not busy, it's waiting. Switching to a lighter-weight output option works around it.