diff --git a/.gitignore b/.gitignore index a716a9f..0d6fdb3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 873cf6e..0030490 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index e114c0e..b0aa9f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 diff --git a/docs/tool-verification/EXTENSION-game-reserve-2026-11.md b/docs/tool-verification/EXTENSION-game-reserve-2026-11.md new file mode 100644 index 0000000..47ed5a3 --- /dev/null +++ b/docs/tool-verification/EXTENSION-game-reserve-2026-11.md @@ -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. diff --git a/docs/tool-verification/README.md b/docs/tool-verification/README.md index 6427728..9de1652 100644 --- a/docs/tool-verification/README.md +++ b/docs/tool-verification/README.md @@ -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 diff --git a/skills/apk-reverse/SKILL.md b/skills/apk-reverse/SKILL.md index ee4b3d4..fcf27e4 100644 --- a/skills/apk-reverse/SKILL.md +++ b/skills/apk-reverse/SKILL.md @@ -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: `** | `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 diff --git a/skills/apk-reverse/evidence/capability-matrix.json b/skills/apk-reverse/evidence/capability-matrix.json index 77f26c2..7ea54a3 100644 --- a/skills/apk-reverse/evidence/capability-matrix.json +++ b/skills/apk-reverse/evidence/capability-matrix.json @@ -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." } ] } diff --git a/skills/apk-reverse/evidence/known-limitations.md b/skills/apk-reverse/evidence/known-limitations.md index 4f70f0f..a173053 100644 --- a/skills/apk-reverse/evidence/known-limitations.md +++ b/skills/apk-reverse/evidence/known-limitations.md @@ -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 diff --git a/skills/apk-reverse/references/coverage-and-limits.md b/skills/apk-reverse/references/coverage-and-limits.md index 4c333c5..138931d 100644 --- a/skills/apk-reverse/references/coverage-and-limits.md +++ b/skills/apk-reverse/references/coverage-and-limits.md @@ -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 diff --git a/skills/apk-reverse/references/coverage/route-inventory.md b/skills/apk-reverse/references/coverage/route-inventory.md index 0f56a25..2ad8975 100644 --- a/skills/apk-reverse/references/coverage/route-inventory.md +++ b/skills/apk-reverse/references/coverage/route-inventory.md @@ -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` | diff --git a/skills/apk-reverse/references/dex-patching.md b/skills/apk-reverse/references/dex-patching.md index bff7900..32e9b5a 100644 --- a/skills/apk-reverse/references/dex-patching.md +++ b/skills/apk-reverse/references/dex-patching.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: diff --git a/skills/apk-reverse/references/evidence-summary.md b/skills/apk-reverse/references/evidence-summary.md index a0f444d..bf004b6 100644 --- a/skills/apk-reverse/references/evidence-summary.md +++ b/skills/apk-reverse/references/evidence-summary.md @@ -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 diff --git a/skills/apk-reverse/references/framework-runtimes.md b/skills/apk-reverse/references/framework-runtimes.md index acb8479..c314475 100644 --- a/skills/apk-reverse/references/framework-runtimes.md +++ b/skills/apk-reverse/references/framework-runtimes.md @@ -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//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.___/`. 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 diff --git a/skills/apk-reverse/references/packers.md b/skills/apk-reverse/references/packers.md index dcf558b..0097e20 100644 --- a/skills/apk-reverse/references/packers.md +++ b/skills/apk-reverse/references/packers.md @@ -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 diff --git a/skills/apk-reverse/references/pitfalls.md b/skills/apk-reverse/references/pitfalls.md index f783eec..312f110 100644 --- a/skills/apk-reverse/references/pitfalls.md +++ b/skills/apk-reverse/references/pitfalls.md @@ -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. diff --git a/skills/apk-reverse/references/recon.md b/skills/apk-reverse/references/recon.md index 4bb3e91..cbce23b 100644 --- a/skills/apk-reverse/references/recon.md +++ b/skills/apk-reverse/references/recon.md @@ -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 | diff --git a/skills/apk-reverse/references/repack-and-sign.md b/skills/apk-reverse/references/repack-and-sign.md index bf742ba..570eab2 100644 --- a/skills/apk-reverse/references/repack-and-sign.md +++ b/skills/apk-reverse/references/repack-and-sign.md @@ -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//`, 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 diff --git a/skills/apk-reverse/references/routing.md b/skills/apk-reverse/references/routing.md index 1d965e5..735d0c8 100644 --- a/skills/apk-reverse/references/routing.md +++ b/skills/apk-reverse/references/routing.md @@ -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: `** | `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 ` 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 | diff --git a/skills/apk-reverse/references/split-apk.md b/skills/apk-reverse/references/split-apk.md index 9e1fc53..3e0d621 100644 --- a/skills/apk-reverse/references/split-apk.md +++ b/skills/apk-reverse/references/split-apk.md @@ -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 -t `. +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//` 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 | diff --git a/skills/apk-reverse/references/unity-lua-and-assetbundles.md b/skills/apk-reverse/references/unity-lua-and-assetbundles.md new file mode 100644 index 0000000..c5359c5 --- /dev/null +++ b/skills/apk-reverse/references/unity-lua-and-assetbundles.md @@ -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//` 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: + +- `.manifest` next to the bundle, and +- `assets/AssetBundles//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//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`.