Absorb game-reserve's Unity/Lua and AssetBundle reverse engineering

Adds references/unity-lua-and-assetbundles.md: Unity + Lua containers, the Lua 5.1
header-byte tamper, the loader's bytecode-only check, the CRC manifest the game
actually reads, UnityFS partial-encryption windows, rebuild traps (layout loss,
dependency null, compressed == raw), TextAsset read-back, and half-encrypted Mono
assemblies behind a publisher shell.

Extends framework-runtimes with LayaAir Conch (DCC checksum and loader patch),
recon with resource completeness and the encryption map, repack-and-sign with
package rename, split-apk with the narrow apktool merge, dex-patching with
call-deletion instead of method rewrite, and packers with PairIP as a
target-selection signal. Adds pitfalls P51-P53 and two symptom rows.

Registration: one reference row, two capability rows (both partial/unverified),
two evidence-summary rows, two route-inventory rows, two coverage bullets, one
known-limitation bullet, and EXTENSION-game-reserve-2026-11.md. Gates and pytest
green.
This commit is contained in:
root
2026-11-09 20:35:44 +08:00
parent 90d9bd3e57
commit 93ec493d02
20 changed files with 658 additions and 4 deletions
+4
View File
@@ -38,6 +38,10 @@ tools/
# resting on "nobody has added them yet".
.ref/
# A public skill clone read by the 2026-11 game-reserve absorption pass. Same rule: the method
# and the anonymised record ship, the upstream tree does not.
game-reserve/
# sandbox installs made while testing `npx skills add .` against this repo
.claude/
.agents/
+7
View File
@@ -200,6 +200,13 @@ references/ loaded on demand, one topic each
dnSpy-Unity-mono patch that makes a retail Mono player accept a
debugger, a run-time class lookup over frida-il2cpp-bridge, and
the audit probes when the deliverable is a report
unity-lua-and-assetbundles.md
the Unity that is neither plain Mono nor IL2CPP: Lua logic in a
bundle (xLua/tolua/SLua), the Lua 5.1 header-byte tamper, bundle
CRC in the manifest the game actually reads, partial-encryption
windows, UnityFS rebuild traps (layout loss, stripped dependency
null, compressed == raw), TextAsset read-back, and half-encrypted
Mono assemblies behind a publisher shell
il2cpp-protected-metadata.md
when that pair is not readable: absent, 0-byte, encrypted or
embedded metadata, the consumer-side dump, version-independent
+6
View File
@@ -120,6 +120,12 @@ references/ 按需加载,每个文件一个主题
用 Il2CppDumper 导出这一对文件、按能成功的顺序把表导入 IDA、
让零售 Mono 播放器接受调试器的 dnSpy-Unity-mono 补丁、
基于 frida-il2cpp-bridge 的运行时类查找,以及交付物是报告时的审计探针
unity-lua-and-assetbundles.md
既不是纯 Mono 也不是 IL2CPP 的 Unity:逻辑在 Lua bundle 里
(xLua/tolua/SLua)、Lua 5.1 头字节篡改、游戏真正读取的 manifest
CRC、局部加密窗口、UnityFS 重建陷阱(布局丢失、依赖被剥后返回
null、压缩大小等于原始大小)、TextAsset 读回,以及发行方壳后面的
半加密 Mono 程序集
il2cpp-protected-metadata.md
当这一对不可读时:缺失、0 字节、加密或内嵌的 metadata、
从消费端 dump、不信任版本号的 header 定长、XOR/VM/XXTEA
@@ -0,0 +1,113 @@
# EXTENSION — absorbing the reverse-engineering half of `game-reserve` (2026-11-09)
A public game-preservation skill was cloned at the repository root (`game-reserve/`). It is
mostly a workflow for turning a dead server into an offline build. That workflow is **not** what this
pass took. This pass read the case reports and the pitfalls for the reverse-engineering mechanisms
they contain and landed only those: how the Unity/Lua script layer is found and repaired, how
AssetBundles are checked and rebuilt, how a half-encrypted Mono assembly is read, how LayaAir's Conch
cache is recomputed, and what recon should measure before a route is chosen.
## Sources
- `game-reserve/` (the clone at the repository root) — its recon reference (logic-layer table,
encryption map, CRC log, resource-completeness check), four anonymised case reports (Unity + Lua,
LayaAir + JS, Unity Mono + SLua, native Kotlin), and its patch/pitfalls notes (UnityFS rebuild
traps, TextAsset read-back, hot-update merge, R8 minSdk branches, `os.date` race).
All are public secondary sources. Their case reports are `observed` **by their authors**; nothing in
this repository executed them. Identifiers, package names, hosts, keys, offsets and the source's own
scripts were deliberately not copied.
## What was added
One new reference file, because the topic had no owner:
| File | Covers | Why no existing file owned it |
|---|---|---|
| `references/unity-lua-and-assetbundles.md` | Unity + Lua (xLua/tolua/SLua) container discovery, script decryption and Lua 5.1 header repair, loader bytecode-only check, AssetBundle CRC source, UnityFS partial-encryption windows, bundle rebuild traps (layout loss, stripped dependency null, compressed == raw), added/replaced TextAsset read-back, half-encrypted Mono assemblies and the publisher shell | `unity-runtime.md` owns Mono/IL2CPP and `il2cpp-protected-metadata.md` owns protected metadata; neither owns a Lua layer or UnityFS container editing. `packers.md` owns generic shell identification, not the Mono-assembly decryptor shape |
Existing topic files were extended, not duplicated:
- `references/framework-runtimes.md` — a **LayaAir native (Conch)** section: identification, the
path-addressed cache, the DCC filetable checksum, metadata regeneration from shipped content, the
loader patch and the startup-delay race, plus the Laya render-loop freeze and poisoned-XHR shapes.
- `references/recon.md` — Unity + Lua and LayaAir rows in the code-location table, and a
**§1c** for resource completeness and the encryption map (manifest diff, player backup, entropy is
not encryption, fixed prefixes, config-table field check, separate "unreadable" from "not in hand").
- `references/repack-and-sign.md` — **renaming the package** across binary AXML, the `resources.arsc`
package block and the launcher label, with the validation that both builds coexist.
- `references/split-apk.md` — the narrow manual resource merge through `apktool` that the source's
native case used, with the condition that makes it safe and the collision that stops it.
- `references/dex-patching.md` — deleting specific calls from an original smali method instead of
hand-rewriting a lifecycle method, removing the argument-preparation instructions, and putting new
logic in an extra dex behind one `invoke-static`.
- `references/packers.md` — PairIP as a target-selection signal (Play licensing plus partial code
virtualization): prefer an older/regional build without it rather than fighting a re-signed suicide.
- `references/pitfalls.md` — **P51** (R8 deleted the low-API branch when `minSdk` was lowered),
**P52** (a decompiled Lua file does not match its name because the writer's `read()` returns the
template/original bytes) and **P53** (a retained AssetBundle returns null after its dependency was
stripped).
- `SKILL.md` and `references/routing.md` — two symptom rows: a Lua decompiler rejecting every
script with `non-standard lua format`, and a re-signed Unity/Mono build exiting cleanly with code
4 two seconds in.
## Deliberately not absorbed
- **The offline-revival workflow itself**: feasibility verdicts, fake local servers, virtual-clock
testing, player-order playtest, save-preserving release, media/OCR recovery, distribution and the
unattended-run playbook. It is a legitimate body of work, but it is a product workflow rather than
an APK-reverse mechanism, and the first pass that imported it was corrected for exactly that.
- **Target-specific constants**: package names, hosts, keys, offsets, counts, paths and the source's
scripts.
- **The source's local-server implementation**: `RPCWaitSend` handling, entity-property bags and the
rest belong to a game-revival project, not to this skill's protocol-reverse files.
- **The media/OCR and night-shift stacks**: no script surface is imported that this pass cannot test.
## Registration surface updated
- `references/routing.md`: one reference-index row; two symptom rows mirrored from `SKILL.md`.
- `README.md` and `README.zh-CN.md`: one entry in the structure block.
- `evidence/capability-matrix.json`: `unity-lua-and-assetbundle-recovery` and
`layaair-conch-dcc-recovery`, both `partial` / `route_measured: false` / `unverified`.
- `references/evidence-summary.md`: the same two rows.
- `references/coverage/route-inventory.md` and `coverage-and-limits.md`: two documented-route rows
and two bullets.
- `evidence/known-limitations.md`: one "never exercised" bullet.
- `.gitignore`: the upstream clone is ignored, so a later `git add -A` cannot commit it.
## Verification
None of the absorbed reverse-engineering mechanisms is `observed` in this repository. No Unity or
LayaAir package was decrypted, no bundle was rebuilt, no Mono assembly was reconstructed, and no
device build was installed. The checks performed here are structural: the new file and every changed
section resolve, the symptom mirror agrees, and the registration surface names the new capability.
## What would change a label
- **Unity + Lua**: decrypt one anonymous Unity+Lua package, decompile a script, patch one anchor,
rebuild the bundle, install it and show the changed behaviour after a cold start, with a control
build. That moves the Lua half toward `observed`.
- **UnityFS**: rebuild one bundle with the same layout and show it loads; then reproduce the
compressed-size-equals-raw crash in a control and fix it in one field. The single-field experiment is
the cheapest.
- **Mono half-encryption**: reconstruct one PE header from the documented landmarks and have a
decompiler open it; the shell-recovery half additionally needs the original process.
- **LayaAir DCC**: recompute the filetable for a package and show the engine accepts the regenerated
metadata offline. The checksum algorithm is arithmetic and can be host-tested on synthetic files;
the full route still needs a package.
## Gate status
Run after the change:
```text
python check_repo.py && python check_refs.py && python check_routing.py \
&& python check_commands.py && python check_budget.py && python -m pytest -q
```
Result on 2026-11-09: `check_repo` 0 problems; `check_refs` 489 reachable references, 0 dangling,
0 warnings; `check_routing` coherent (72 symptom rows, 63 reference rows, 64 script rows);
`check_commands` 196 checked, 0 drift; `check_budget` 448 lines / ~12,987 tokens, 0 failures,
0 warnings; `pytest` 796 passed, 209 skipped, 25 xfailed. A green run proves the inventory resolves
and the mirrors agree; it says nothing about whether a decrypted script or a rebuilt bundle works,
which is what the labels above are for.
+3 -2
View File
@@ -76,8 +76,8 @@ those are called out in the matrix rather than buried here.
## Absorption-pass record
A later pass absorbed mechanics from external skill repositories (`reverse-skill`,
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`), keeping each
project's own discipline: only the phase, the checklist and the command pattern cross over, with the
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`, `game-reserve`),
keeping each project's own discipline: only the phase, the checklist and the command pattern cross over, with the
source URL and the access date recorded, and nothing vendored as a runtime dependency. These files
live here rather than in the skill because two of them corrected a claim the repository had already
written down.
@@ -89,6 +89,7 @@ written down.
| `EXTENSION-detection-pipeline.md` | Naming the check that fires: three arms on a public MASTG target (control alive, probe arm dead in ≈300 ms via `strstr("frida")`, with the `TracerPid=0` versus 4-frida-mappings asymmetry), `svc_scan.py`'s two decoders agreeing on an identical 214-site set, the layered Java-to-svc descent, and the ptrace-free dump boundary on targets whose dex is deflated inside the APK |
| `EXTENSION-dexsql.md` | The SQL query layer adopted as a **third indexer** beside rasc/droidasc/ddc: identical class-definition sets on four archives (zero differences either way), the string xref agreement and the `=` versus `LIKE` match-semantics trap, `decode` against `ddc disasm` line for line, and the persistent cache measured as **not** a speed switch |
| `EXTENSION-ref-absorption-2026-10.md` | Sixteen public write-ups in four groups (Unity/IL2CPP metadata protection, linker-level SO packing, VM protector idioms, Pyarmor bytecode), the four reference files they became, the registration surface that changed, and the one cheap experiment that would move each row off `unverified` |
| `EXTENSION-game-reserve-2026-11.md` | The reverse-engineering half of a public game-preservation skill: Unity + Lua script extraction and the Lua 5.1 header tamper, AssetBundle CRC and UnityFS rebuild traps, half-encrypted Mono assemblies, LayaAir Conch DCC checksums, plus recon/repack/split/dex/packer additions — all absorbed as `unverified`, since no package was rebuilt here |
## Hardware-layer pass record
+2
View File
@@ -252,6 +252,8 @@ unread in this repository.
| A `dlopen`-able `.so` will not load after a custom loader, or a repaired `soinfo` resolves symbols to garbage | `linker-and-soinfo-repair.md` — bias versus base, `seg_page_offset`, the defining library's bias, `APS2` packed relocs, the minimal `soinfo` field set, and the dump window after `LoadSegments` |
| A native trace has no `cmp`/`sub`/`jcc` — only `and/or/not/shr/neg`, a flags slot, and a `ret` that lands at a VM entry | `vm-protector-analysis.md` — virtual eflags is data, NAND lowering, branchless select, data-anchored breakpoints, and trace-then-search-by-constant; dispatcher slicing stays in `native-vm-recovery.md` |
| The logic you need is Python bytecode — a `.pyc`/PyInstaller/Pyarmor payload rather than Java, Dart or native | `python-bytecode-recovery.md` — key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing and the extra-section TLV; static and offline, then call the recovered module |
| A Lua decompiler rejects every script with **`non-standard lua format: <n>`** | `unity-lua-and-assetbundles.md` §2 — a changed Lua 5.1 header byte, not encryption |
| A re-signed Unity/Mono build exits cleanly with code 4 about two seconds in, with no Java exception | `unity-lua-and-assetbundles.md` §5 — a publisher shell owns the process |
## Gates — clear these before you patch, in order
@@ -1078,6 +1078,39 @@
"docs/tool-verification/fixtures/verify_ref_claims_10c.py and EXTENSION-ref-absorption-2026-10c.md section Verification pass (repository root, not shipped)"
],
"limits": "The measured half is host-side and synthetic: an early-exit byte compare costs k+1 comparisons for a k-byte correct prefix over k=0..8 while an MD5-then-compare costs a constant 16, so the closed and open cases are separable without a target. Nothing was run against a real serial, licence or activation gate. Reading the comparator's counter requires a hook on the comparison itself; wall-clock timing on a device is not the measured form and is expected to be dominated by scheduler and JIT noise. A remote check is a rate-limited network experiment and a value the server also holds is not releaseable client-side, so the route stops at local gates."
},
{
"capability": "unity-lua-and-assetbundle-recovery",
"group": "unpacking",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": [
"references/unity-lua-and-assetbundles.md",
"references/unity-runtime.md",
"references/repack-and-sign.md"
],
"evidence_not_shipped": [
"game-reserve/ (repository root, untracked, not shipped): anonymised Unity + Lua and Unity Mono + SLua case reports; absorbed 2026-11-09"
],
"limits": "Absorbed only: locating a Lua container on top of Unity, header-byte repair, loader bytecode-only check, bundle CRC source, UnityFS partial-encryption windowing, UnityFS rebuild traps (layout loss, dependency null, compressed==raw), added/replaced TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell. No Unity package was decrypted, no bundle rebuilt and no assembly reconstructed here; every route is unverified. It assumes the Lua bytecode opcodes are not shuffled and that the bundle's container format is one UnityPy can parse."
},
{
"capability": "layaair-conch-dcc-recovery",
"group": "runtime",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": [
"references/framework-runtimes.md",
"references/recon.md"
],
"evidence_not_shipped": [
"game-reserve/ (repository root, untracked, not shipped): anonymised LayaAir native + JS case report and DCC notes; absorbed 2026-11-09"
],
"limits": "Absorbed only: Conch identification, the path-addressed cache layout, the DCC filetable checksum (8-byte little-endian word XOR plus tail), regenerating the four metadata files from shipped content, and the loader patch plus its startup-delay race. No LayaAir package was reversed here, and the checksum was reverse-engineered by the source against its own package, not measured in this repository."
}
]
}
@@ -115,7 +115,12 @@ built on them has nothing to check itself against.
- **No real OLLVM trace and no trace pipeline verified on a device.** Exclusion keeps a target alive
but does not restore event delivery, and a zero-event trace is a boundary to identify rather than a
recipe to follow.
- **The Unity + Lua / AssetBundle and LayaAir Conch routes were never exercised here.** The Lua
header repair, bundle CRC selection, UnityFS partial-encryption windowing, bundle rebuild traps,
TextAsset read-back, half-encrypted Mono assemblies and the LayaAir DCC checksum are absorbed from
two public case reports; no Unity or LayaAir package was decrypted, rebuilt or installed in this
repository. Treat every step in `references/unity-lua-and-assetbundles.md` and
`references/framework-runtimes.md` §LayaAir native (Conch) specifics as `unverified`.
## Conclusions that changed — an older copy may still state the old one
Each of these was a claim this skill used to make and no longer does. If you are reading a copy or a
@@ -220,7 +220,17 @@ its own strength note at the top.
(`kernel-event-injection.md`, `scripts/kernel_instrument_scaffold.py`).
- **Working from the phone itself** — MT Manager, its APK MCP surface, LSPosed Manager, on-device
data inspection (`on-device-tooling.md`, `scripts/mt_mcp_probe.py`).
- **Unity + Lua and AssetBundle recovery** — finding a Lua container on top of Unity, repairing the
Lua 5.1 header byte, checking the loader's bytecode-only flag, reading the CRC manifest the game
actually reads, bounding a partial-encryption window, rebuilding a UnityFS bundle without losing
its layout or dependency edges, verifying added/replaced TextAsset read-back, and reading
half-encrypted Mono assemblies behind a publisher shell. Absorbed from two anonymised case reports;
no package was decrypted or bundle rebuilt here, so every step is `unverified`
(`unity-lua-and-assetbundles.md`).
- **LayaAir native (Conch) DCC recovery** — Conch identification, the path-addressed cache, the
filetable checksum, regenerating metadata from shipped content, and the loader patch with its
startup-delay race. Absorbed from one case report; no LayaAir package was reversed here
(`framework-runtimes.md` §LayaAir native (Conch) specifics).
## Dependencies this skill does not ship — name them before the workflow starts
- **JS AST rewriting needs node + babel** (`@babel/parser`/`traverse`/`generator`) — the detector
@@ -61,6 +61,8 @@ route was not measured on this repository's evidence**. Treat them as leads with
| Attaching a real debugger to a `.so` | IDA `dbgsrv` with the ABI match, wait-for-debugger plus the JDWP handshake, and the `extractNativeLibs="false"` failure where no breakpoint binds — `ida-so-debugging.md` |
| Kernel-side event injection | watchpoints and register rewrites from a driver, as gated templates — `kernel-event-injection.md` |
| Working from the phone itself | MT Manager edit/repack/sign and its APK MCP, LSPosed Manager, Termux — `on-device-tooling.md` |
| Unity + Lua scripts and AssetBundle rebuilds | find/decrypt the Lua container, repair a Lua 5.1 header, locate the live CRC manifest, bound a partial-encryption window, rebuild a UnityFS bundle without losing layout/dependencies, verify TextAsset read-back, and recover half-encrypted Mono assemblies behind a publisher shell — absorbed from two case reports; nothing was run here — `unity-lua-and-assetbundles.md` |
| LayaAir native (Conch) DCC recovery | Conch identification, path-addressed cache, DCC filetable checksum and metadata regeneration, and the loader patch with its startup-delay race — absorbed; no LayaAir package was reversed here — `framework-runtimes.md` §LayaAir native (Conch) specifics |
| Publishing without publishing the target | the leak scanner, the do-not-anonymize list, and the graded precedent library — `desensitization-and-leak-scans.md`, `scripts/scan_leaks.py`, `precedents/` |
| Native dump window and syscall reader | dump at `android_dlopen_ext` entry when self-decryption and watchdog startup share `JNI_OnLoad`; read via raw syscalls; zero-fill unreadable pages so offsets survive repair — absorbed, nothing run here — `linker-and-soinfo-repair.md` |
| White-box crypto devirtualization | fold a deterministic VM transform into a straight-line `(op, args)` program plus constant table, output map and template, pinned to one build — absorbed, no extract produced — `native-vm-recovery.md` |
@@ -180,6 +180,32 @@ Patch spec format for `patch_smali.py`:
]
```
### Deleting calls instead of rewriting the method
For a large lifecycle method (`onCreate`, an Application initialiser, a main-screen controller), do
not hand-write the whole body in smali. The source's native case tried that and produced two failure
classes: `VerifyError` from a `invoke-static/range` whose argument registers were placed at the
wrong end of the range, and a missing repository initialiser that surfaced later as
`lateinit property ... has not been initialized`.
The safer shape:
1. **Keep the original method body.** Remove `.line` directives only for the purpose of matching.
2. **Delete the specific calls** (network, analytics, push, billing, account) with multi-line anchors,
and assert each anchor matches exactly once.
3. **Delete the instructions that existed only to prepare that call** — the `move-object` and
`check-cast` pairs that feed its arguments. Leaving them is harmless only if their registers stay
live; removing the call and leaving a half-built argument list is how verification fails.
4. **Put new logic in a Java class** compiled with `javac` + `d8` into an extra `classesN.dex`;
the smali edit becomes one `invoke-static` line. The new class is testable on the host and does not
disturb register allocation in the original method.
5. **Find the real input point, not every consumer.** In the source's case the dead profile supplied
"current character" and "language"; changing those two getters to read local settings made the
whole default-model and greeting chain work without touching the model loader.
Verify with `dex_classdiff`, install, cold-start, and exercise the exact entry that used the deleted
call. A build that verifies can still have removed an initialiser that only one screen needs.
## Finding the call site
Reverse-lookup from a smali tree or a set of disassembled dex:
@@ -86,6 +86,8 @@ next** — and points at the one section that says "the full record is elsewhere
| Native dump window and syscall reader | Absorbed operating rules: dump at `android_dlopen_ext` entry because the window closes inside `JNI_OnLoad`, a raw-syscall reader outside a self-hooked libc, and zero-filled holes so repair keeps offsets. Zero-fill offset arithmetic host-checked on a synthetic image; the window itself never exercised here | partial | unverified | `references/linker-and-soinfo-repair.md` §6.2 |
| White-box crypto devirtualization to a program | Absorbed route: symbolic input, constant folding, a straight-line `(op, args)` program plus its constant table, output map and template; version-bound, deterministic functions only. No extract was produced here | partial | unverified | `references/native-vm-recovery.md` §11 |
| Cipher classification by differential probe | Method: header length, determinism, one-byte diffusion width and the length law separate CTR, CBC and ECB (the earlier prefix-XOR-only claim was falsified and is corrected); involution wrappers cancel; Java/Python encoding and compression drift decide the last mile. Probes and algebra host-checked on AES-128 and synthetic inputs; no target body classified | partial | inferred | `references/protocol-reverse.md` §7 |
| Unity + Lua scripts and AssetBundle rebuilds | Absorbed: Locating a Lua container on top of Unity, the Lua 5.1 header-byte tamper, loader bytecode-only check, the CRC manifest the game actually reads, UnityFS partial-encryption windows, rebuild traps (layout loss, dependency null, compressed == raw), TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell. No package was decrypted or bundle rebuilt here | partial | unverified | `references/unity-lua-and-assetbundles.md` |
| LayaAir native (Conch) DCC recovery | Absorbed: Conch identification, the path-addressed cache, the DCC filetable checksum, regenerating metadata from shipped content, the loader patch, and the startup-delay race. No LayaAir package was reversed here | partial | unverified | `references/framework-runtimes.md` §LayaAir native (Conch) specifics |
## Where the full record lives — repository root, not shipped
@@ -93,6 +93,49 @@ workflow, and how to patch this layer safely.
- **The metadata can be encrypted while the `.so` is not, or the reverse.** Check each file's magic
before concluding the dump failed; the two failures have different routes.
## LayaAir native (Conch) specifics
LayaAir's native shell is a recognisable shape: `lib/<abi>/liblayaair.so` plus `libv8*.so`, a
`assets/scripts/` directory (`index.js`, `apploader.js`, `config.js`), an `assets/config.ini`,
and a cache tree `assets/cache/https.__<host>_<path>/`. If those are present, the game logic is
JavaScript and the engine is Conch, not a WebView.
- **Cache file names are content-addressed by path, not by content:** each relative path (leading `/`)
is named `'%08x' % crc32(path)`. The same directory holds `allfiles.txt`, `filetable.txt`,
`filetable1.txt`, `filetable.bin` and `assetsid.txt`.
- **The game's logic is usually one plaintext compressed file:** `js/bundle.js`. Before reversing it,
fetch `js/bundle.js.map` from the same CDN path if it exists — in the source's project it contained
470 TypeScript source files, which is the source, not a reconstruction.
- **DCC checksums are recomputed by the engine, not trusted from the table.** `filetable.txt` is a
`ffeeffee 1` header, the assets id split over two lines, then one `fileid chksum` line per file.
The checksum is XOR over 8-byte blocks of little-endian `uint32` words, with the remaining 0–7
bytes XORed individually. The source matched 8,940 of 8,943 files. The engine recomputes it from the
file's content; a mismatch makes it re-download, so offline it fails with
`JsAppCache::loadCachedURL Error, no cache data` or `Load start url error`.
- **Do not copy the CDN's file table.** The source found its values inconsistent with the file contents
(same content, different checksum). Recompute every checksum from the bytes you are shipping and
regenerate all four metadata files, then `bundle.js` can be edited freely.
- **The loader is plain JS and lives outside the cache** (`index.js` under `assets/scripts/`). It calls
`updateDcc()`; offline that fails and, if a cached id exists, the app exits with
`onLayaInitError`. Patch the loader to skip the update but **re-create the side effect it had**:
`cache.setResourceID('appurl', urlpath)`. Then keep a delay of roughly three seconds before
starting: in the source, the network update had been hiding a startup race, and an instant start
crashed in `JCScriptRuntime::dispatchLayaGLBuffer` on the GL thread (x86 and arm), with or without
any offline JS module. Prove it with a control build that contains only the loader patch.
- **`loadLib()` is asynchronous.** A hook installed before `loadLib('js/bundle.js')` returns sees no
`Laya` object; poll until the engine exists, then install the hook.
- **A local module is loaded by `require("offline_server")`**, which Conch resolves from
`assets/scripts/`, the same way the game's own modules load.
**Failure shape to remember:** one uncaught exception in a Laya render callback freezes the game,
because the main loop is `requestAnimationFrame(loop)` and an exception stops the next frame. The
symptom is a frozen or black screen while the debug channel still answers; the check is
`Laya.timer.currFrame` not increasing. Wrapping `Laya.stage._loop` in try/catch is a diagnostic
guard, not a fix for the underlying null asset. Mocked XHR responses can also poison a pooled
`HttpRequest` instance — clear the own properties you defined in `open()` or every later request
on that instance fails instantly. These are engine-side behaviours from the source's case, not
measured here.
## Locating logic without symbols
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
+5
View File
@@ -88,6 +88,11 @@ rounds) re-deriving what a cleaner experiment would have shown in one run.
changes nothing about the shell.
- **Equal-length renames** keep binary XML chunk structure valid, so no manifest re-encoding is needed:
replace the name bytes in `AndroidManifest.xml` in place, size unchanged.
- **A build with Google PairIP is usually the one to leave alone.** Look for `com.pairip` classes and
a `LicenseClient` before committing: it combines Play licensing with partial code virtualization and
terminates a re-signed process. If the app ships several variants, an older or regional build without
PairIP is a better target than an unwinnable one — that is target selection, not defeat. The source's
native case chose the older regional split build and accepted fewer cosmetics.
## Native libraries as a place to live
+75
View File
@@ -1521,3 +1521,78 @@ the wrapper in the report, because it is the reason the algorithm looked unfamil
the differential probes that identify the primitive underneath, is `protocol-reverse.md` §7; the
mask identity itself is host-checked there (`observed`: zero counterexamples over 65,536 eight-bit
pairs and 200,000 random 16-bit pairs).
## P51. The low-API branch you need was removed by R8
**Symptom**
A build with `minSdkVersion 29` is changed to 28 and installed on Android 9. It starts, then dies on
`No virtual method rebase()`, `registerActivityLifecycleCallbacks`, or `WindowInsets$Builder`; or it
runs but lifecycle events never arrive, so coroutines inside `lifecycleScope` never execute.
**Root cause**
R8 evaluated `if (SDK_INT >= 29)` as true for the declared `minSdk` and deleted the else branch. The
`$ApiNNImpl` fallbacks below 29, the old `WindowInsetsCompat` implementation, and the pre-29 path in
`ReportFragment.dispatch` (which became an empty function) are no longer in the dex.
**Why it is hard to see**
The APK installs and starts; only the path that needs the deleted branch fails. The stack names a
framework or AndroidX class, so the first hypothesis is an SDK/framework mismatch, not "our own build
deleted this code". The empty lifecycle method is silent: nothing throws, the observable is "the app
does nothing".
**The rule**
**Lowering `minSdk` is a code change, not a manifest edit.** Audit every `$ApiNNImpl` with N at or
above the original minSdk, the lifecycle dispatch, and `WindowInsetsCompat`; add a version guard that
returns the default on old devices and restore the old implementation. Test on the oldest API you
claim to support. Prefer a newer emulator over lowering the floor; if you must lower it, treat every
`SDK_INT` comparison as a branch you may have to re-create.
## P52. A decompiled Lua file does not match its filename
**Symptom**
A batch decompile produces valid Lua, but many files in different directories contain the same
unrelated script (the source found 112). The running game is fine, so the "source" is the only thing
that is wrong. A later variant: a script you replaced is not the script the build patched; constants
from the hot update disappear, and a runtime stack contains a function name that does not exist
anywhere in the decompiled source.
**Root cause**
The bundle writer cloned a template `TextAsset` and wrote new bytes with `save_typetree`; the
library's `read()` kept parsing the template object's original bytes. The same cache applies after a
hot-update merge: `set_raw`/replacement writes the new script, but `read()` still returns the packaged
old one, so a read-modify-write patch operates on the old file and silently overwrites the new one.
**Why it is hard to see**
Every file is syntactically valid Lua. Nothing in the pipeline reports an error; the only signal is
semantic — a function that should exist does not, or two entries share identical content. If the
analysis copy is wrong, the patch is written against a file the device never runs.
**The rule**
**Compare the entry name with the chunk name before analysing a batch.** Lua 5.1 bytecode stores
`@UI/...` immediately after the header; zero mismatches is the gate for trusting the export. In your
own bundle writer, keep a sidecar map of every written payload and make reads return it first, then
compare the bytes read back with the hot-update source. The quick runtime signal is a stack frame
naming a function absent from the "source" — that means you are reading the old script.
## P53. A retained AssetBundle returns null after stripping unused bundles
**Symptom**
You remove bundles that the package does not seem to use. The build installs and starts, but a bundle
you kept now loads as null or its assets are missing. There is no CRC error and the removed files are
not mentioned in the log.
**Root cause**
`AssetBundleManifest` still lists the removed bundle as a dependency of a retained one. Unity resolves
the dependency graph before loading; a missing edge makes the parent unavailable even though its own
bytes are intact.
**Why it is hard to see**
The failure is attributed to the bundle that is still present, or to the patch you just made. The
removed bundle is absent from the evidence by definition, and "the file exists" looks like proof that a
bundle should load.
**The rule**
**When you remove an AssetBundle, rebuild the dependency list in the manifest in the same step.**
Never treat file presence as loadability. Load each retained bundle in a control build and check for
null before signing; keep the removed-bundle list in the build record so a later change can rebuild
the graph.
+47
View File
@@ -54,6 +54,51 @@ launch screen, popups and tab set are **client decisions** or **server data**,
which is the difference between a five-minute patch and a dead end. See
`server-config-and-updates.md`.
## 1c. Resource completeness and the encryption map
Two questions are cheap on day one and expensive after a week of patching: **how much of the game is
in this APK**, and **what exactly is encrypted**. Answer both before choosing a route.
**Completeness.** A 1.2 GB package can be a quarter of the game. Find the game's own catalogue
(`manifest.*`, `AssetDatabase.manifest`, a version file, a native manifest) and diff it against the
files actually present, summed by directory. Then diff the content tables (costumes, characters,
levels) against entries that have a model or texture. In the source's micro-client case, the manifest
listed 42,700 bundles / 4.62 GB while the APK held 12,120 / 1.05 GB, and about 91% of the clothing
models were absent. The rest was downloaded on first launch and died with the server.
A player's app backup is often the only complete source; treat it as untrusted: identify the container
(headers, compression, encryption), verify every file against the backup's own manifest and MD5, and
confirm the APK inside is the **same build** — the source found a backup APK with the same version
name and versionCode but 1,518 differing files. Merge the hot-update layer (scripts, manifests, updated
bundles) into the package and make it survive the client's first-launch cleanup, or the files are
deleted before they are read. If the missing resources were never in any player's copy, stop and say so
rather than patching around a game that cannot be complete.
**Encryption map.** Do not answer "is anything else encrypted?" from memory. Spend half an hour:
1. **Classify every entry** — installed files plus the hot-update directory: size, first 16 bytes,
magic type, and entropy over the first 64 KB. The goal is zero "unknown" entries.
2. **High entropy is not encryption.** Compressed bundles, audio and textures all sit above 7.5.
Test whether the standard loader opens the file (a resource bundle that UnityPy loads is not
encrypted), whether a public header is present and plausible (e.g. `FSB5`), and whether a sample
decodes to a real image or sound.
3. **Judge text resources one by one** — readable text, a public binary format (skeleton, sound bank,
protobuf), or high entropy and a length that is a multiple of the block size (a cipher candidate).
4. **A fixed prefix is not encryption.** Several zero bytes at the start of every bundle are a common
anti-unpacker trick; count them in the original, skip them on read, preserve them on write.
5. **Check config tables by field**, not by vibe: decode with the protocol description, then scan for
field numbers the description does not contain. Zero unknown fields, and a re-serialization of the
same byte length, are two independent confirmations.
6. **Find the second copy.** Packages often contain an old config database while the phone's
hot-updated copy is newer. Compare row counts per table and build from the newer one.
7. **List runtime crypto points separately** — script load, login token, resource check, payment,
network packet — and record for each whether the offline route needs it.
8. **Separate "cannot decrypt" from "not in hand".** A server database or an unreadable private
directory is missing data, not an encryption problem; mixing the two produces a false "cracked".
The map is a deliverable: it lets a later reader see which conclusions were measured, which were
inferred, and which files nobody could open.
## 2. Is it packed?
Read `AndroidManifest.xml` → `application android:name`.
@@ -129,6 +174,8 @@ Heuristic: the business dex mixes the app's package prefix (hundreds of hits) wi
| `libflutter.so` + `libapp.so` | **Flutter (Dart AOT)** — dex contains only a thin shell |
| `libil2cpp.so` + `assets/bin/Data/` | **Unity (IL2CPP)** — native |
| `libmono*.so` + `assets/bin/Data/Managed/` | **Unity (Mono)** — managed DLLs under `assets/` |
| `libunity.so` + a `luascript` bundle or thousands of Lua `TextAsset` entries | **Unity + Lua** (xLua / tolua / SLua) — the logic is in a bundle, not the assembly — `unity-lua-and-assetbundles.md` |
| `liblayaair.so` + `libv8*.so` + `assets/scripts/*.js` | **LayaAir native (Conch)** — JavaScript logic and a DCC cache — `framework-runtimes.md` §LayaAir native (Conch) specifics |
| `libreactnativejni.so` + `assets/index.android.bundle` | **React Native** — JS bundle under `assets/` |
| `libhermes.so` | **Hermes bytecode** |
| Almost everything in `.so` | **Native** — different toolchain entirely |
@@ -206,6 +206,33 @@ Always pass the explicit range (Route B above) and read the scheme list from tha
Useful detail: keeping the same keystore across builds lets you iterate with `-r` and **preserve a logged-in session**, which matters a lot when the feature you are testing needs auth.
### Renaming the package so two builds coexist
Same package name plus a different signature is an install refusal, and uninstalling the official app
destroys its data. To install alongside it, rename the package in three places — a manifest-only edit
produces an APK that installs and then fails to find its own resources:
1. **Binary `AndroidManifest.xml`.** Rebuild the UTF-16 string pool and replace the package name and
**every string that carries its prefix** (permissions, provider authorities, the file provider, the
push `C2D_MESSAGE` permission, and similar). Enumerate the suffixes from a table rather than
guessing; update the chunk sizes and the file size; assert that no occurrence of the old name
remains. Do not rename Java class names.
2. **The `resources.arsc` package block.** Its name is a fixed 128-UTF-16-character field. Miss it
and the build installs but resource resolution fails at start — the same symptom as a missing
resource, far from the cause.
3. **The launcher label**, in the arsc UTF-8 string pool. This can be replaced in place only when the
new label is not longer than the old one.
Then grep Java/smali for quoted package-name literals and change build scripts to read the name from
one variable instead of hardcoding it. The app's data directory becomes
`Android/data/<new-package>/`, so old saves are not carried over — say so in the install notes.
Verify with `aapt dump badging` (package and label), then install both builds on one device: the new
one must reach its main screen and read/write a save while the official one remains installed. The
source's LayaAir/Conch case also had to change the arsc package name (Android 9+ compares it when
resolving resources); hardcoded private-directory paths used a build-time placeholder, and the local
licence value derived from `ANDROID_ID` did not change with the package name, while saves did.
### Vendor install interception (OEM shells)
Some ROMs route `adb install` through their own "security centre", which can return
+3
View File
@@ -90,6 +90,8 @@ reference file is not named here, or when a script is not named here.
| A `dlopen`-able `.so` will not load after a custom loader, or a repaired `soinfo` resolves symbols to garbage | `linker-and-soinfo-repair.md` — bias versus base, `seg_page_offset`, the defining library's bias, `APS2` packed relocs, the minimal `soinfo` field set, and the dump window after `LoadSegments` |
| A native trace has no `cmp`/`sub`/`jcc` — only `and/or/not/shr/neg`, a flags slot, and a `ret` that lands at a VM entry | `vm-protector-analysis.md` — virtual eflags is data, NAND lowering, branchless select, data-anchored breakpoints, and trace-then-search-by-constant; dispatcher slicing stays in `native-vm-recovery.md` |
| The logic you need is Python bytecode — a `.pyc`/PyInstaller/Pyarmor payload rather than Java, Dart or native | `python-bytecode-recovery.md` — key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing and the extra-section TLV; static and offline, then call the recovered module |
| A Lua decompiler rejects every script with **`non-standard lua format: <n>`** | `unity-lua-and-assetbundles.md` §2 — a changed Lua 5.1 header byte, not encryption |
| A re-signed Unity/Mono build exits cleanly with code 4 about two seconds in, with no Java exception | `unity-lua-and-assetbundles.md` §5 — a publisher shell owns the process |
_70 row(s) below the header._
@@ -155,6 +157,7 @@ _70 row(s) below the header._
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path <PKG>` returned several files: reading a set, merge versus unified re-signing, and the install refusal each mistake produces |
| `references/unity-runtime.md` | The package is **Unity**: telling Mono from IL2CPP, dumping `libil2cpp.so` + `global-metadata.dat` with Il2CppDumper and importing it into IDA in the order that works, a run-time lookup over frida-il2cpp-bridge, the dnSpy-Unity-mono patch for a Mono player, and the audit probes when the deliverable is a report |
| `references/unity-lua-and-assetbundles.md` | A Unity game keeps its logic in **Lua** (xLua/tolua/SLua), or the edit is in an AssetBundle: finding and decrypting the scripts, the Lua 5.1 header tamper, bundle CRC in the manifest the game actually reads, partial-encryption windows, UnityFS rebuild traps (layout loss, stripped dependencies, compressed==raw), added/replaced TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell |
| `references/il2cpp-protected-metadata.md` | A Unity IL2CPP pair is **not readable**: `global-metadata.dat` is absent, 0 bytes, encrypted, embedded, or its version lies. Consumer-side dump, version-independent header sizing, the XOR/VM/XXTEA container shapes, the live-image dump, and the ELF repair that makes a dump usable |
| `references/python-bytecode-recovery.md` | The logic is **Python bytecode** behind Pyarmor or PyInstaller: key derivation, AES-CTR nonce rules, the 12-byte VM, a version-gated `pyc` parser, the extra-section TLV, and per-code-object / per-string decryption |
| `references/linker-and-soinfo-repair.md` | A protected `.so` must be loaded or dumped **by hand**: the `dlopen` pipeline as a loader spec, load_bias and segment arithmetic, `DT_*` parsing, relocation rules and the two biases, the minimal `soinfo` repair set, anti-dump header XOR, and where a dump is valid |
@@ -221,6 +221,28 @@ the attribute chunks around it, which is what `apktool` is for — and note that
`apktool b` rebuild rewrites the whole archive
(`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app).
### A narrow manual merge through apktool (when bundletool is unavailable)
The rule above is the safe one: do not fold resource tables by hand. There is one exception narrow
enough to name — a set whose resource splits are **additive** (density or language variants that do not
redefine an existing name with a different id). The source's native case did this and installed the
result as a single APK:
1. Install the base as an apktool framework: `apktool if base.apk -p <dir> -t <tag>`.
2. Decode each split with the same `-p/-t` so its resources keep their **original ids** instead of
being renumbered by the decoder.
3. Copy the split's `res/` files into the base and add their ids to the base's `public.xml`. Skip a
name that already exists with the same id; replace `APKTOOL_DUMMY_0x...` names by looking the real
name up in the base's id table.
4. Copy `lib/<abi>/` into the base. In the manifest, remove `requiredSplitTypes`, `splitTypes`,
`vending.splits` and `stamp.*`, and set `extractNativeLibs="true"`.
5. Verify every resource reference resolves and the app renders the split-only content. If two members
define the same resource name with different ids, **stop** — that is the folding problem the first
rule warns about, and bundletool is the route.
This is a per-set workaround, not a resource merger. Say in the delivery which splits were folded and
which resource families they carried.
## 6. Install refusals, and what each one means
| Message | What it actually says | Attribution |
@@ -0,0 +1,221 @@
# Unity + Lua, AssetBundles, and half-encrypted Mono assemblies
Load this when a Unity APK keeps its game logic in Lua (xLua / tolua / SLua), when the thing you need to
edit is an AssetBundle rather than an assembly, or when a rebuilt Unity package fails in a way that
looks like the patch: a decompiler rejects every script, a bundle loads as null, a repacked bundle
crashes only on device, or a re-signed Mono build exits cleanly a second or two after the splash. It
is the script-and-asset half of Unity; `unity-runtime.md` owns Mono/IL2CPP identification and the
managed-code route, and `il2cpp-protected-metadata.md` owns protected IL2CPP metadata.
**Claim strength.** Absorbed from a public game-preservation skill's anonymised case reports: one
Unity + Lua target (1,443 encrypted TextAssets, a Lua bundle, CRC in an AssetBundle manifest) and one
Unity Mono + SLua target (8,420 Lua 5.1 TextAssets, a publisher shell that decrypts the first 4 KB of
each assembly, a 1.2 GB micro-client). The source reports these as work performed on real devices;
**this repository has run none of it** — no Unity package was decrypted, no bundle rebuilt, and no
Mono assembly reconstructed here. Every mechanism below is therefore `unverified`, and the counts
and offsets are the source's, not this repository's measurements. What generalises is the failure
shape, not the constants.
## 1. Recognise the shape before choosing a tool
| What is in the package | Where the logic is | Route |
|---|---|---|
| `libunity.so` + `libmono*.so` + `assets/bin/Data/Managed/Assembly-CSharp.dll` | C# IL | `unity-runtime.md` §Mono |
| `libunity.so` + `libil2cpp.so` + `global-metadata.dat` | native + metadata | `unity-runtime.md` §IL2CPP |
| `libunity.so` + a `luascript` bundle or thousands of Lua `TextAsset` entries | Lua source or Lua 5.1 bytecode | this file §2 |
| `assets/AssetBundles/<platform>/` with per-bundle `.manifest` files | assets, prefabs, textures, sometimes scripts | this file §3–§4 |
| `Application` is a vendor shell class + `libtprt.so` / `__tpinfo.tsa` | shell decrypts managed assemblies at run time | this file §5 |
The first question is not "how do I decrypt Lua" but **which layer owns the behaviour**. A Lua game
can have almost no interesting C#: the C# boots the engine, loads a bundle and calls into Lua. In that
shape, editing IL is the wrong layer even when the IL is readable.
## 2. Getting Lua out of a Unity package
### 2.1 Find the container and the key
Scripts usually live in one of three places: a dedicated bundle such as `luascript`, individual
`TextAsset` entries inside many bundles, or a loose file list such as `all_lua_files.lua` (which is
often the one plaintext index that names the rest). Locate the loader first: a C# method with names
like `LuaScriptKey`, `ScriptKey`, `Key()` or `LuaRuntimeConfig` is where the key is chosen.
The source's case A: 1,443 `TextAsset` entries, DES-CBC, and **`key == IV ==` an 8-byte constant**
taken from `LuaRuntimeConfig.LuaScriptKey()`. Do not assume every script is encrypted: in that case
`all_lua_files.lua` was plaintext. Decrypt a sample, then prove the output is a script (it parses,
and its first line is plausible Lua) before batch-processing 1,443 files.
Patterns worth recognising without reversing the decryptor:
- **Header-only tamper.** A Lua 5.1 chunk starts with signature `1B 4C 75 61` and version `51`;
the next byte (offset 5) is the format byte, normally `00`. A build that changes it to `01` makes
`unluac` say *"non-standard lua format: 1"* while the rest of the file is untouched. Compare the
first 12 bytes with the standard header before concluding "encrypted".
- **A single-byte XOR tail** with a strongly encrypted head (see §5 for the assembly version). The
tell is the plaintext zero: deep in the file, the same byte repeats in long runs. One byte of XOR
turns every plaintext zero into that byte.
- **A "decrypt" that is only a wrapper.** Before implementing a cipher, check whether the transform
cancels (a repeating XOR with a fixed key, or a stream cipher applied twice). `protocol-reverse.md`
§7 has the differential probe for classifying a primitive; use it when the shape is not obvious.
### 2.2 Repair the header, then decide what the loader accepts
For the byte-5 tamper, copy the standard 5.1 header over the first 12 bytes, then decompile one file.
If the opcodes were not shuffled, the output is clean source; if the decompiler produces nonsense,
the build may also have shuffled opcodes, which is a different problem.
The other half is the loader: the C# call that loads a chunk often takes a mode/`bytecodeOnly` flag.
If the bundle path loads with `bytecodeOnly == false`, your patch can be **plaintext Lua** — no need
to find a matching 32-bit `luac`. If it is true, recompile the module to the exact Lua version and
ABI, or patch the loader to accept source. Read the call site, do not guess from the file extension.
Batch decompilation is CPU-bound per file (a JVM start per script in the source's toolchain); run it
in parallel and keep the output indexed by entry name, because the next step is a lookup, not a read.
### 2.3 Verify content against entry names
Lua 5.1 bytecode stores its source name immediately after the header (for example
`@UI/reputationshop/shop.lua`). After a batch decompile, compare **the entry name with the chunk
name** before analysing anything. In the source's case, 112 files decompiled to the same unrelated
file because their bundle writer had cloned a template object and `read()` kept returning the
template's bytes. The game itself was fine; only the analysis copy was wrong.
The same warning applies to a hot-update merge: if your bundle wrapper replaces `main.lua` but
`read()` still returns the packaged original, the build patches the **old** script, and the
hot-update version's constants silently disappear. Keep a sidecar map of every byte you write
(replacements and additions) and make your reader return it first; then compare the bytes read back
with the hot-update file before trusting a patch.
## 3. AssetBundle integrity: the CRC is not where you guess
Unity verifies a bundle's CRC when it loads it. The value can live in:
- `<bundle>.manifest` next to the bundle, and
- `assets/AssetBundles/<platform>/AssetDatabase.manifest` (the catalogue).
Guessing which one is live costs a full round. **Run the unmodified build once and read the log** —
Unity prints both values:
```text
E/Unity: CRC Mismatch. Provided d906612, calculated ab764880 from data.
Will not load AssetBundle 'luascript'
```
Change the manifest the game actually reads (the catalogue in the source's case, not the same-named
bundle manifest) to `0` (skip) or to the computed value. Then verify the bundle loads, not only that
the error disappeared.
**An 8-byte zero prefix is not corruption.** In the source's case A, 2,354 of 2,355 bundles began with
eight `0x00` bytes in the original package; the only exception was the catalogue. All of them loaded.
An earlier pass "repaired" 13 of them and carried the damage into the next baseline. Count the prefix
across the original package before treating it as a fault; skip it on read and preserve it on write.
### 3.1 A bundle whose first block will not decompress
The shape is: a valid `UnityFS` header, the first compressed block fails to decompress, later blocks
are fine. That is a **partial-encryption window**, not a corrupt download. To bound it:
1. Parse the UnityFS header and block table yourself; decompress each block independently and record
which blocks fail.
2. Entropy-scan the failing block's compressed stream. A sharp drop in entropy marks the end of the
encrypted window.
3. Run the block's compressor (often LZ4) directly over the ciphertext; the byte offset where it
crashes is the start of the window.
4. Compare the start/end across several bundles. The source found 1,691 bundles with the same
`[56:4096]` window, which proves a build-wide scheme rather than per-file damage.
5. XOR two ciphertexts from different bundles: if the transform is a fixed stream cipher, the
plaintext-derived difference is stable. That tells you the shape, not the key.
Only after this do you know what you are missing: the key or the algorithm. Do not rebuild the
bundles before the window is pinned.
## 4. Rebuilding a bundle without changing it
A bundle is a serialized container, not a zip. Two independent traps:
**The rewriter changes the header/layout.** UnityPy's `save_typetree` can produce a much smaller
serialized file than the original (the source saw 6,700 → 3,552 bytes) because it drops the original
header layout and alignment padding. Compare the object metadata and header of old and new files; if
they differ, pad/restore the original layout before writing the bundle back. For a texture, render the
new text with the game's own font and encode it back to the original format (ETC2 in that case); do
not assume a PNG round-trip is equivalent.
**A stripped resource is still in the dependency graph.** If you remove an AssetBundle but leave the
`AssetBundleManifest` listing it as a dependency of a retained bundle, Unity returns **null** for the
retained bundle. Remove the dependency edges when you remove the assets, not only the files.
**The compressed-size edge case.** A rebuilt bundle can crash where a block's compressed size equals
its raw size — the loader takes a branch that assumes compression. Do not debug this inside a full
build. Replace one hand-made bundle into an existing signed APK, re-align and re-sign, install and
cold-start; each experiment is about a minute. Write a minimal UnityFS reader that lets you change one
block size, one compression flag or one field at a time, and keep a control bundle that differs in
nothing. That is how the source found the exact equality that broke the load.
### 4.1 Adding or replacing a TextAsset
When a Lua script is added to an existing bundle, the writer has to create a new serialized object and
update the container index. Two checks make this safe:
- **Keep the bytes you wrote.** Do not rely on a library's `read()` after `set_raw`/`save_typetree`;
some versions return the template object or the original bytes. Store the written payload in a
sidecar map and serve it on read.
- **Verify entry name and chunk name agree**, for every added and every replaced script. Then verify
the bytes read back equal the source file you intended (especially after merging a hot-update
layer). A build that patches the old script is indistinguishable from a patch that did nothing.
## 5. Mono assemblies: half-encryption, a rebuilt PE header, and the publisher shell
This is a different shape from a packed dex. The assembly file is present and looks like a PE, but its
first `0x1000` bytes are strongly encrypted and the rest is XORed with one byte. The tell is the same
as §2: deep in the file, one value repeats in long runs, because plaintext zeros became that value.
Autocorrelation does not show a period; the repeated value does.
To read it without reversing the runtime's decryptor:
1. XOR the tail with the single byte.
2. Rebuild the PE header from structural landmarks rather than guessing offsets: the
`_CorDllMain`/`mscoree.dll` import, the `FF 25 00 20 40 00` entry trampoline, the `.reloc`
block that points at it, `VS_VERSION_INFO` in `.rsrc`, and the `BSJB` metadata root.
3. Watch for an extra `.sdata` section (static-field initial values): it appears as a hole between
`.text` and `.rsrc` and as a file offset `0x400` instead of `0x200` for `.text`. Missing it
makes ILSpy report *"Field data (rva=...) could not be found in any section"*.
4. Accept the boundary: methods whose bodies lived in the first 4 KB are lost (the source counted 87
small constructors and 123 getters across two assemblies). Static analysis of the rest still works.
**The publisher shell changes the plan.** If `AndroidManifest`'s `application` is a vendor class
(for example a `TPShellApplication`) and `lib/` contains its runtime, the shell checks the APK
(signature/CRC/package) and kills the process about two seconds after launch. The symptom is
`Process ... exited cleanly (4)` with **no Java exception** and no tombstone. Do not read that as a
normal crash. The same shell often decrypts the assembly heads, so removing it also removes the thing
that made the assemblies readable.
The source's route: switch the shell off through its own dex string constants (same-length value
change, then recompute the dex header's SHA-1 and adler32), and recover the plaintext assemblies from
the **original game's process memory** — root, read `/proc/<pid>/maps`, stream anonymous regions,
locate the known XORed body, and restore the DOS header the shell zeroed. That route is specific to
that shell; the general rule is: identify the shell's own kill signal and its own string gate before
patching, and do not ship a rebuild while the shell still owns the process. Shell identification and
the keep/drop decision are in `packers.md` and `code-virtualization-and-custom-linkers.md`;
`advanced-unpacking.md` owns the memory-dump boundary.
## 6. Verification
A Unity asset patch is done when:
- the bundle **loads** (the CRC error is gone, and no null bundle appears in the log);
- the script's **entry name, chunk name and decompiled content** agree;
- a replaced script's bytes read back byte-identical to the source you wrote;
- an added entry exists in the container index and is returned by the loader;
- the on-device screen shows the change, after a cold start, on the same build you installed;
- a control build with **no** patch shows the old behaviour on the same device.
A hash of the APK is not evidence that the bundle inside it changed — check the bundle entry, not the
APK size. Signing and alignment stay in `repack-and-sign.md`.
## 7. Boundary
- **IL2CPP metadata and native code** — `unity-runtime.md` and `il2cpp-protected-metadata.md`.
- **A packer or a self-terminating native check** — `packers.md`,
`code-virtualization-and-custom-linkers.md`, `native-tamper-and-suicide.md`.
- **Crypto classification** — `protocol-reverse.md` §7; do not implement a cipher from a guess.
- **The game's network layer** — `protocol-reverse.md` and `server-api.md`.
- **Splitting, signing and package rename** — `split-apk.md` and `repack-and-sign.md`.