mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-03-21 22:42:19 +01:00
Add apk patcher. Update readme and added disclaimer.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -398,5 +398,10 @@ FodyWeavers.xsd
|
||||
*.sln.iml
|
||||
|
||||
# Squad
|
||||
.gitattributes
|
||||
.github
|
||||
.copilot
|
||||
.squad
|
||||
|
||||
# User Data
|
||||
src/Data/UserData
|
||||
|
||||
33
DISCLAIMER.md
Normal file
33
DISCLAIMER.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Disclaimer
|
||||
|
||||
## No Affiliation
|
||||
|
||||
This project is an unofficial, fan-made private server reimplementation for NieR: Reincarnation. It is not affiliated with, endorsed by, or associated with Square Enix Co., Ltd., Applibot, Inc., or any of their subsidiaries or affiliates in any way.
|
||||
|
||||
NieR: Reincarnation, NieR, and all related names, characters, logos, and content are trademarks and intellectual property of Square Enix Co., Ltd. and/or Applibot, Inc. All rights reserved by their respective owners.
|
||||
|
||||
## Purpose
|
||||
|
||||
This project exists solely for **preservation and educational purposes**. NieR: Reincarnation's official servers were shut down on April 30, 2024, making the game otherwise unplayable. This project aims to allow fans to continue experiencing a game they love, with no intent to harm the interests of the original creators.
|
||||
|
||||
## Non-Commercial
|
||||
|
||||
This project is and will remain entirely **non-profit**. No fees are charged for access. No donations are solicited in connection with this project. No attempt is made to monetise the NieR: Reincarnation name, brand, or any associated intellectual property.
|
||||
|
||||
## License Scope
|
||||
|
||||
The [MIT License](./LICENSE) applying to this repository covers **only the original server implementation code** written by the contributors of this project. It does not and cannot grant any rights over NieR: Reincarnation's game assets, artwork, audio, story, characters, data, or any other intellectual property owned by Square Enix or Applibot.
|
||||
|
||||
**No game assets are included in this repository.** This includes but is not limited to: APK files, images, audio files, video files, and game data files. Users are responsible for obtaining any necessary game files through legitimate means.
|
||||
|
||||
## Reverse Engineering
|
||||
|
||||
This implementation was developed through independent research into observed network behaviour. No proprietary source code belonging to Square Enix or Applibot was used in the creation of this project.
|
||||
|
||||
## No Warranty
|
||||
|
||||
This software is provided as-is, with no warranty of any kind. See the [MIT License](./LICENSE) for full terms.
|
||||
|
||||
## Takedown Requests
|
||||
|
||||
If you are a representative of Square Enix Co., Ltd. or Applibot, Inc. and have concerns about this project, please open an issue or contact the repository maintainers directly. We will respond promptly and in good faith.
|
||||
117
README.md
117
README.md
@@ -1,35 +1,106 @@
|
||||
# Marie's Wonderland
|
||||
|
||||
An attempt at a private server implementation for mobile game NieR Reincarnation.
|
||||
An attempt at a private server implementation for the mobile game NieR Reincarnation.
|
||||
|
||||
## Game information
|
||||
- Built in Unity 2019.4.29 with C# and IL2CPP
|
||||
- Has a GRPC-based server
|
||||
- Has a web-based API
|
||||
- Has a gRPC-based API server
|
||||
- Has a supplementary web-based HTTP API
|
||||
|
||||
**Current status:** We can get past the initial loading screens and reach the in-game state, but the game is not fully playable, yet.
|
||||
|
||||
## Requirements
|
||||
|
||||
#### PC
|
||||
- [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools)
|
||||
- [Visual Studio 2025](https://visualstudio.microsoft.com/downloads)
|
||||
- [Visual Studio Code](https://code.visualstudio.com/download) or similar text editor
|
||||
- [ngrok](https://ngrok.com/download) or similar
|
||||
- HTTP/2 support is required
|
||||
- [Frida tools 17.x](https://frida.re/docs/installation)
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
||||
- [Visual Studio 2026](https://visualstudio.microsoft.com/downloads) or [Rider](https://www.jetbrains.com/rider/)
|
||||
- [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools) (`adb`)
|
||||
|
||||
#### Phone
|
||||
- Latest version of the game installed (v3.7.1)
|
||||
- Rooted Android device
|
||||
- USB debugging enabled
|
||||
- [Frida server](https://frida.re/docs/android) installed and running
|
||||
- An Android device or an [Android Studio](https://developer.android.com/studio) emulator (physical device not required)
|
||||
|
||||
## How to run
|
||||
- Connect your Android device to your PC and ensure it shows up when executing the following command: `adb devices`
|
||||
- Run the GRPC server
|
||||
- Open the `MariesWonderland.sln` solution and run the project. The server runs on ports 7777 (HTTP) and 7776 (HTTPS)
|
||||
- Run ngrok
|
||||
- Execute the following command: `ngrok http --app-protocol=http2 7777`
|
||||
- Take note of your public ngrok domain
|
||||
- Hook and run the game
|
||||
- Open `frida/hooks.js` in VSCode and change the variable `SERVER_ADDRESS` to your ngrok domain
|
||||
- Execute the following command from the VSCode terminal (adjust the file path): `frida -Uf com.square_enix.android_googleplay.nierspww -l "path\to\hooks.js"`
|
||||
#### Patching (Google Colab)
|
||||
- A Google account to run the patcher notebook
|
||||
|
||||
## Setup overview
|
||||
|
||||
### 1. Run the server
|
||||
Open `MariesWonderland.sln` and run the project, or from the `src/` directory
|
||||
|
||||
The server listens on the standard HTTP and HTTPS ports on localhost:
|
||||
- `http://localhost` (port 80) - used for HTTP asset serving
|
||||
- `https://localhost` (port 443) - used for gRPC (HTTP/2)
|
||||
|
||||
### 2. Expose the server
|
||||
The game communicates over gRPC (HTTP/2). You do not need ngrok or an external tunnel if your emulator or device can reach your machine directly.
|
||||
|
||||
- If running on an emulator: configure the emulator to reach the host (Android Studio emulators usually can reach the host).
|
||||
- If running from a remote device or across a network: open ports 80 and 443 on your firewall/NAT and ensure those ports are forwarded to the machine running the server so the game can reach `http(s)://<your-host>`.
|
||||
|
||||
Ensure any network path supports HTTP/2 for gRPC traffic on port 443.
|
||||
|
||||
### 3. Patch the APK
|
||||
The `scripts/patcher.ipynb` notebook runs entirely in Google Colab - no local toolchain needed.
|
||||
|
||||
1. Open [Google Colab](https://colab.research.google.com) and upload `scripts/patcher.ipynb`
|
||||
2. Fill in the configuration at the top of the code cell:
|
||||
- `protocol` - `http` for plain tunnels, `https` for TLS-terminated endpoints
|
||||
- `server_host` - your hostname (without protocol), e.g. `192.168.1.1`
|
||||
- `server_port` - leave empty unless using a non-standard port
|
||||
3. Run the single code cell
|
||||
4. Wait for it to complete and the patched APK will be automatically downloaded
|
||||
|
||||
**What the patcher does:**
|
||||
- Rewrites server URLs and hostnames in `global-metadata.dat` (IL2CPP string literals)
|
||||
- Applies ARM64 binary patches to `libil2cpp.so`: SSL bypass, encryption passthrough, plain Octo asset list
|
||||
- When using `http`: patches `AndroidManifest.xml` and `network_security_config.xml` to allow cleartext traffic
|
||||
|
||||
> **Note:** Replacement strings must not be longer than the originals. Use short hostnames and omit the port if you run into length issues.
|
||||
|
||||
### 4. Install and run
|
||||
Install `NieRReincarnation-patched.apk` in your phone or emulator. Launch the game. It will connect to your local server.
|
||||
|
||||
## Configuration
|
||||
Server settings live in `src/appsettings.development.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Server": {
|
||||
"Paths": {
|
||||
"AssetDatabase": "<path to extracted asset revisions>",
|
||||
"MasterDatabase": "<path to extracted master data>",
|
||||
"ResourcesBaseUrl": "http://<your-host>/aaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"Data": {
|
||||
"LatestMasterDataVersion": "20240404193219",
|
||||
"UserDataPath": "Data/UserData",
|
||||
"MasterDataPath": "Data/MasterData"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The `ResourcesBaseUrl` value must be exactly 43 characters long.
|
||||
- If you change the length of that segment, you may also need to update the server-side minimal API that serves the short path (the `/aaaaaaaa...` handler) so its expected length matches your new value.
|
||||
|
||||
## Project structure
|
||||
```
|
||||
src/ .NET 10 gRPC + HTTP server
|
||||
proto/ protobuf service definitions
|
||||
Services/ gRPC service implementations
|
||||
Data/ in-memory data stores (master + user)
|
||||
Models/ entity and type definitions
|
||||
Extensions/ DI, HTTP, and gRPC helpers
|
||||
Configuration/ strongly-typed options
|
||||
Http/ HTTP API handlers (asset serving, etc.)
|
||||
scripts/
|
||||
patcher.ipynb Google Colab APK patcher notebook
|
||||
hooks.js legacy Frida hooks (for reference only)
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
See [DISCLAIMER.md](DISCLAIMER.md).
|
||||
|
||||
## Special Thanks
|
||||
- [onepiecefreak3](https://github.com/onepiecefreak3)
|
||||
- [Walter-Sparrow](https://github.com/Walter-Sparrow)
|
||||
|
||||
481
scripts/patcher.ipynb
Normal file
481
scripts/patcher.ipynb
Normal file
@@ -0,0 +1,481 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "400a2588",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Black Bird APK patcher for Google Colab\n",
|
||||
"\n",
|
||||
"Installs tools, configure the target server, upload an APK, patch it for a private server, rebuild, sign, and download the patched APK.\n",
|
||||
"\n",
|
||||
"**Patches**\n",
|
||||
"- `global-metadata.dat` — rewrite IL2CPP string literals (URLs and hostnames)\n",
|
||||
"- `libil2cpp.so` — ARM64 binary patches (SSL bypass, encryption passthrough, plain Octo list)\n",
|
||||
"- `AndroidManifest.xml` — add `networkSecurityConfig` for cleartext HTTP (when using HTTP)\n",
|
||||
"- `res/xml/network_security_config.xml` — allow cleartext traffic\n",
|
||||
"\n",
|
||||
"**Quick Instructions**\n",
|
||||
"- Configure `protocol`, `server_host`, and optional `server_port` below.\n",
|
||||
"- Run the single code cell. It installs tools, patches, and downloads the result.\n",
|
||||
"\n",
|
||||
"**Notes**\n",
|
||||
"- Choose `http` to enable cleartext network patches; choose `https` to skip manifest/network changes.\n",
|
||||
"- Replacements must not be longer than the original strings; use short hostnames or omit the port if needed."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "combined-cell",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Configuration ────────────────────────────────────────────────────────────\n",
|
||||
"protocol = 'http' #@param ['http', 'https']\n",
|
||||
"server_host = '192.168.1.1' #@param {type:'string'}\n",
|
||||
"server_port = '' #@param {type:'string'}\n",
|
||||
"\n",
|
||||
"# ── Imports & constants ───────────────────────────────────────────────────────\n",
|
||||
"import os\n",
|
||||
"import re\n",
|
||||
"import shutil\n",
|
||||
"import stat\n",
|
||||
"import struct\n",
|
||||
"import subprocess\n",
|
||||
"import urllib.parse\n",
|
||||
"from dataclasses import dataclass\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"WORK_ROOT = Path('/content/nier_patch_work')\n",
|
||||
"TOOLS_DIR = WORK_ROOT / 'tools'\n",
|
||||
"ANDROID_SDK_ROOT = TOOLS_DIR / 'android-sdk'\n",
|
||||
"APKTOOL_VERSION = '3.0.1'\n",
|
||||
"ANDROID_BUILD_TOOLS = '36.1.0'\n",
|
||||
"ANDROID_CMDLINE_TOOLS_URL = 'https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip'\n",
|
||||
"\n",
|
||||
"METADATA_MAGIC = 0xFAB11BAF\n",
|
||||
"HDR_STRING_LITERAL_OFF = 8\n",
|
||||
"HDR_STRING_LITERAL_DATA_OFF = 16\n",
|
||||
"\n",
|
||||
"MOV_X0_0 = struct.pack('<I', 0xD2800000)\n",
|
||||
"MOV_X0_X1 = struct.pack('<I', 0xAA0103E0)\n",
|
||||
"RET = struct.pack('<I', 0xD65F03C0)\n",
|
||||
"YEAR_9999 = struct.pack('<I', 0x5284E1E1)\n",
|
||||
"\n",
|
||||
"IL2CPP_PATCHES = [\n",
|
||||
" {\n",
|
||||
" 'name': 'ToNativeCredentials',\n",
|
||||
" 'desc': 'SSL bypass — return NULL to force insecure gRPC channel',\n",
|
||||
" 'rva': 0x35C8670,\n",
|
||||
" 'bytes': MOV_X0_0 + RET,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" 'name': 'HandleNet.Encrypt',\n",
|
||||
" 'desc': 'encryption passthrough — return payload as-is',\n",
|
||||
" 'rva': 0x279410C,\n",
|
||||
" 'bytes': MOV_X0_X1 + RET,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" 'name': 'HandleNet.Decrypt',\n",
|
||||
" 'desc': 'decryption passthrough — return receivedMessage as-is',\n",
|
||||
" 'rva': 0x279420C,\n",
|
||||
" 'bytes': MOV_X0_X1 + RET,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" 'name': 'OctoManager.Internal.GetListAes',\n",
|
||||
" 'desc': 'Octo list: force plain list (return false = no AES); server serves raw list.bin',\n",
|
||||
" 'rva': 0x4C27038,\n",
|
||||
" 'bytes': MOV_X0_0 + RET,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" 'name': 'TitleScreen.ForceYear9999',\n",
|
||||
" 'desc': 'Replace EoS year 2024 with 9999',\n",
|
||||
" 'rva': 0x2F11E7C,\n",
|
||||
" 'bytes': YEAR_9999\n",
|
||||
" },\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"# ── Helper functions ──────────────────────────────────────────────────────────\n",
|
||||
"def run(cmd, check=True, shell=False, env=None, cwd=None):\n",
|
||||
" printable = cmd if isinstance(cmd, str) else ' '.join(str(part) for part in cmd)\n",
|
||||
" print(f'$ {printable}')\n",
|
||||
" return subprocess.run(cmd, check=check, text=True, shell=shell, env=env, cwd=cwd)\n",
|
||||
"\n",
|
||||
"def ensure_dir(path: Path) -> Path:\n",
|
||||
" path.mkdir(parents=True, exist_ok=True)\n",
|
||||
" return path\n",
|
||||
"\n",
|
||||
"# ── Tool installation ─────────────────────────────────────────────────────────\n",
|
||||
"def install_apktool():\n",
|
||||
" ensure_dir(TOOLS_DIR)\n",
|
||||
" jar_path = TOOLS_DIR / f'apktool_{APKTOOL_VERSION}.jar'\n",
|
||||
" wrapper_path = TOOLS_DIR / 'apktool'\n",
|
||||
"\n",
|
||||
" if not jar_path.exists():\n",
|
||||
" run([\n",
|
||||
" 'wget', '-q', '-O', str(jar_path),\n",
|
||||
" f'https://github.com/iBotPeaches/Apktool/releases/download/v{APKTOOL_VERSION}/apktool_{APKTOOL_VERSION}.jar',\n",
|
||||
" ])\n",
|
||||
"\n",
|
||||
" wrapper_path.write_text(\n",
|
||||
" '#!/usr/bin/env bash\\n'\n",
|
||||
" f'exec java -jar \"{jar_path}\" \"$@\"\\n',\n",
|
||||
" encoding='utf-8',\n",
|
||||
" )\n",
|
||||
" wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC)\n",
|
||||
" os.environ['PATH'] = f'{TOOLS_DIR}:{os.environ[\"PATH\"]}'\n",
|
||||
"\n",
|
||||
"def install_android_commandline_tools() -> Path:\n",
|
||||
" ensure_dir(TOOLS_DIR)\n",
|
||||
" ensure_dir(ANDROID_SDK_ROOT)\n",
|
||||
"\n",
|
||||
" zip_path = TOOLS_DIR / 'commandlinetools.zip'\n",
|
||||
" temp_dir = TOOLS_DIR / 'cmdline-tools-temp'\n",
|
||||
" cmdline_root = ANDROID_SDK_ROOT / 'cmdline-tools'\n",
|
||||
" latest_dir = cmdline_root / 'latest'\n",
|
||||
"\n",
|
||||
" if not latest_dir.exists():\n",
|
||||
" if temp_dir.exists():\n",
|
||||
" shutil.rmtree(temp_dir)\n",
|
||||
" run(['wget', '-q', '-O', str(zip_path), ANDROID_CMDLINE_TOOLS_URL])\n",
|
||||
" ensure_dir(temp_dir)\n",
|
||||
" run(['unzip', '-q', str(zip_path), '-d', str(temp_dir)])\n",
|
||||
" ensure_dir(cmdline_root)\n",
|
||||
" extracted_dir = temp_dir / 'cmdline-tools'\n",
|
||||
" if latest_dir.exists():\n",
|
||||
" shutil.rmtree(latest_dir)\n",
|
||||
" shutil.move(str(extracted_dir), str(latest_dir))\n",
|
||||
"\n",
|
||||
" return latest_dir\n",
|
||||
"\n",
|
||||
"def install_android_build_tools():\n",
|
||||
" latest_dir = install_android_commandline_tools()\n",
|
||||
" sdkmanager = latest_dir / 'bin' / 'sdkmanager'\n",
|
||||
"\n",
|
||||
" env = os.environ.copy()\n",
|
||||
" env['ANDROID_SDK_ROOT'] = str(ANDROID_SDK_ROOT)\n",
|
||||
" env['JAVA_HOME'] = '/usr/lib/jvm/java-17-openjdk-amd64'\n",
|
||||
" env['PATH'] = f'{latest_dir / \"bin\"}:{env[\"PATH\"]}'\n",
|
||||
"\n",
|
||||
" run(f'yes | \"{sdkmanager}\" --sdk_root=\"{ANDROID_SDK_ROOT}\" --licenses >/dev/null', check=False, shell=True, env=env)\n",
|
||||
" run([\n",
|
||||
" str(sdkmanager),\n",
|
||||
" f'--sdk_root={ANDROID_SDK_ROOT}',\n",
|
||||
" f'build-tools;{ANDROID_BUILD_TOOLS}',\n",
|
||||
" 'platform-tools',\n",
|
||||
" ], env=env)\n",
|
||||
"\n",
|
||||
" build_tools_dir = ANDROID_SDK_ROOT / 'build-tools' / ANDROID_BUILD_TOOLS\n",
|
||||
" os.environ['ANDROID_SDK_ROOT'] = str(ANDROID_SDK_ROOT)\n",
|
||||
" os.environ['PATH'] = f'{build_tools_dir}:{latest_dir / \"bin\"}:{os.environ[\"PATH\"]}'\n",
|
||||
"\n",
|
||||
"def install_dependencies():\n",
|
||||
" ensure_dir(WORK_ROOT)\n",
|
||||
" ensure_dir(TOOLS_DIR)\n",
|
||||
"\n",
|
||||
" run(['apt-get', 'update'])\n",
|
||||
" run([\n",
|
||||
" 'apt-get', 'install', '-y',\n",
|
||||
" 'openjdk-17-jdk-headless',\n",
|
||||
" 'wget',\n",
|
||||
" 'unzip',\n",
|
||||
" 'ca-certificates',\n",
|
||||
" ])\n",
|
||||
"\n",
|
||||
" install_apktool()\n",
|
||||
" install_android_build_tools()\n",
|
||||
"\n",
|
||||
" missing = [tool for tool in ('apktool', 'apksigner', 'zipalign', 'keytool') if shutil.which(tool) is None]\n",
|
||||
" if missing:\n",
|
||||
" raise RuntimeError(f'Missing required tools after installation: {missing}')\n",
|
||||
"\n",
|
||||
" print('\\nInstalled tool paths:')\n",
|
||||
" for tool in ('apktool', 'apksigner', 'zipalign', 'keytool'):\n",
|
||||
" print(f' {tool}: {shutil.which(tool)}')\n",
|
||||
"\n",
|
||||
" print('\\nRequested versions:')\n",
|
||||
" print(f' apktool: {APKTOOL_VERSION}')\n",
|
||||
" print(f' Android build-tools: {ANDROID_BUILD_TOOLS}')\n",
|
||||
"\n",
|
||||
"# ── Patch helpers ─────────────────────────────────────────────────────────────\n",
|
||||
"@dataclass\n",
|
||||
"class RuntimeConfig:\n",
|
||||
" protocol: str\n",
|
||||
" host: str\n",
|
||||
" port: str = ''\n",
|
||||
"\n",
|
||||
" @property\n",
|
||||
" def web_base_url(self) -> str:\n",
|
||||
" base = f'{self.protocol}://{self.host}'\n",
|
||||
" if self.port:\n",
|
||||
" base += f':{self.port}'\n",
|
||||
" return base\n",
|
||||
"\n",
|
||||
" @property\n",
|
||||
" def grpc_host(self) -> str:\n",
|
||||
" return self.host\n",
|
||||
"\n",
|
||||
"def require_file(path: Path):\n",
|
||||
" if not path.is_file():\n",
|
||||
" raise FileNotFoundError(f'Not found: {path}')\n",
|
||||
"\n",
|
||||
"def build_replacements(cfg: RuntimeConfig):\n",
|
||||
" replacements = [\n",
|
||||
" ('api.app.nierreincarnation.com', cfg.grpc_host),\n",
|
||||
" (\n",
|
||||
" 'https://web.app.nierreincarnation.com/assets/release/{0}/database.bin',\n",
|
||||
" f'{cfg.web_base_url}/assets/release/{{0}}/database.bin',\n",
|
||||
" ),\n",
|
||||
" ('https://web.app.nierreincarnation.com', cfg.web_base_url),\n",
|
||||
" ('https://resources-api.app.nierreincarnation.com/', f'{cfg.web_base_url}/'),\n",
|
||||
" ]\n",
|
||||
"\n",
|
||||
" for old_value, new_value in replacements:\n",
|
||||
" if len(new_value.encode('utf-8')) > len(old_value.encode('utf-8')):\n",
|
||||
" raise ValueError(\n",
|
||||
" 'Replacement too long: '\n",
|
||||
" f'{old_value!r} -> {new_value!r}. '\n",
|
||||
" 'Use a shorter host name or omit the port.'\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" return replacements\n",
|
||||
"\n",
|
||||
"def patch_metadata_strings(meta_path: Path, replacements) -> int:\n",
|
||||
" data = bytearray(meta_path.read_bytes())\n",
|
||||
"\n",
|
||||
" magic = struct.unpack_from('<I', data, 0)[0]\n",
|
||||
" if magic != METADATA_MAGIC:\n",
|
||||
" raise RuntimeError(f'Bad metadata magic: 0x{magic:08X}')\n",
|
||||
"\n",
|
||||
" version = struct.unpack_from('<i', data, 4)[0]\n",
|
||||
" print(f' metadata v{version}, {len(data)} bytes')\n",
|
||||
"\n",
|
||||
" sl_off, sl_size = struct.unpack_from('<II', data, HDR_STRING_LITERAL_OFF)\n",
|
||||
" sld_off, sld_size = struct.unpack_from('<II', data, HDR_STRING_LITERAL_DATA_OFF)\n",
|
||||
" n_entries = sl_size // 8\n",
|
||||
" print(f' stringLiteral: {n_entries} entries @ 0x{sl_off:X}')\n",
|
||||
" print(f' stringLiteralData: {sld_size} bytes @ 0x{sld_off:X}')\n",
|
||||
"\n",
|
||||
" patched = 0\n",
|
||||
" for old_str, new_str in replacements:\n",
|
||||
" old_bytes = old_str.encode('utf-8')\n",
|
||||
" new_bytes = new_str.encode('utf-8')\n",
|
||||
"\n",
|
||||
" blob_pos = data.find(old_bytes, sld_off, sld_off + sld_size)\n",
|
||||
" if blob_pos < 0:\n",
|
||||
" print(f' [!] NOT FOUND in blob: {old_str!r}')\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" data_index = blob_pos - sld_off\n",
|
||||
" entry_found = False\n",
|
||||
" for i in range(n_entries):\n",
|
||||
" entry_offset = sl_off + i * 8\n",
|
||||
" entry_len, entry_index = struct.unpack_from('<II', data, entry_offset)\n",
|
||||
" if entry_index == data_index and entry_len == len(old_bytes):\n",
|
||||
" struct.pack_into('<I', data, entry_offset, len(new_bytes))\n",
|
||||
" entry_found = True\n",
|
||||
" print(f' entry #{i}: length {entry_len} -> {len(new_bytes)}')\n",
|
||||
" break\n",
|
||||
"\n",
|
||||
" if not entry_found:\n",
|
||||
" print(f' [!] No table entry found for {old_str!r} (dataIndex=0x{data_index:X})')\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" data[blob_pos:blob_pos + len(old_bytes)] = new_bytes + (b'\\x00' * (len(old_bytes) - len(new_bytes)))\n",
|
||||
" print(f' PATCHED: {old_str!r} -> {new_str!r}')\n",
|
||||
" patched += 1\n",
|
||||
"\n",
|
||||
" meta_path.write_bytes(data)\n",
|
||||
" return patched\n",
|
||||
"\n",
|
||||
"def patch_manifest(manifest_path: Path) -> bool:\n",
|
||||
" text = manifest_path.read_text(encoding='utf-8')\n",
|
||||
"\n",
|
||||
" if 'networkSecurityConfig' in text:\n",
|
||||
" print(' networkSecurityConfig already present')\n",
|
||||
" return True\n",
|
||||
"\n",
|
||||
" new_attr = 'android:networkSecurityConfig=\"@xml/network_security_config\"'\n",
|
||||
" patched_text, count = re.subn(r'<application\\b', f'<application {new_attr}', text, count=1)\n",
|
||||
" if count != 1:\n",
|
||||
" raise RuntimeError('Could not find <application> tag in AndroidManifest.xml')\n",
|
||||
"\n",
|
||||
" manifest_path.write_text(patched_text, encoding='utf-8')\n",
|
||||
" print(f' added {new_attr}')\n",
|
||||
" return True\n",
|
||||
"\n",
|
||||
"NETWORK_SECURITY_CONFIG = '''<?xml version=\"1.0\" encoding=\"utf-8\"?>\n",
|
||||
"<network-security-config>\n",
|
||||
" <base-config cleartextTrafficPermitted=\"true\" />\n",
|
||||
"</network-security-config>\n",
|
||||
"'''\n",
|
||||
"\n",
|
||||
"def create_network_security_config(res_xml_dir: Path) -> bool:\n",
|
||||
" res_xml_dir.mkdir(parents=True, exist_ok=True)\n",
|
||||
" output_path = res_xml_dir / 'network_security_config.xml'\n",
|
||||
" output_path.write_text(NETWORK_SECURITY_CONFIG, encoding='utf-8')\n",
|
||||
" print(f' wrote {output_path}')\n",
|
||||
" return True\n",
|
||||
"\n",
|
||||
"def patch_libil2cpp(so_path: Path) -> int:\n",
|
||||
" patched = 0\n",
|
||||
" with so_path.open('r+b') as handle:\n",
|
||||
" file_size = handle.seek(0, 2)\n",
|
||||
" for patch in IL2CPP_PATCHES:\n",
|
||||
" rva = patch['rva']\n",
|
||||
" patch_bytes = patch['bytes']\n",
|
||||
" if rva + len(patch_bytes) > file_size:\n",
|
||||
" print(f\" [!] SKIP {patch['name']}: RVA 0x{rva:X} beyond file size\")\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" handle.seek(rva)\n",
|
||||
" original = handle.read(len(patch_bytes))\n",
|
||||
" handle.seek(rva)\n",
|
||||
" handle.write(patch_bytes)\n",
|
||||
"\n",
|
||||
" patched += 1\n",
|
||||
" print(f\" {patch['name']} @ 0x{rva:X}: {original.hex()} -> {patch_bytes.hex()}\")\n",
|
||||
" print(f\" {patch['desc']}\")\n",
|
||||
"\n",
|
||||
" return patched\n",
|
||||
"\n",
|
||||
"def ensure_debug_keystore() -> Path:\n",
|
||||
" keystore_path = TOOLS_DIR / 'debug.keystore'\n",
|
||||
" if keystore_path.exists():\n",
|
||||
" return keystore_path\n",
|
||||
"\n",
|
||||
" run([\n",
|
||||
" 'keytool',\n",
|
||||
" '-genkeypair',\n",
|
||||
" '-v',\n",
|
||||
" '-keystore', str(keystore_path),\n",
|
||||
" '-storepass', 'android',\n",
|
||||
" '-alias', 'androiddebugkey',\n",
|
||||
" '-keypass', 'android',\n",
|
||||
" '-keyalg', 'RSA',\n",
|
||||
" '-keysize', '2048',\n",
|
||||
" '-validity', '10000',\n",
|
||||
" '-dname', 'CN=Android Debug,O=Android,C=US',\n",
|
||||
" '-noprompt',\n",
|
||||
" ])\n",
|
||||
" return keystore_path\n",
|
||||
"\n",
|
||||
"def patch_uploaded_apk(uploaded_apk: Path, cfg: RuntimeConfig) -> Path:\n",
|
||||
" job_dir = WORK_ROOT / 'job'\n",
|
||||
" decoded_dir = job_dir / 'decoded'\n",
|
||||
" unsigned_apk = job_dir / f'{uploaded_apk.stem}-unsigned.apk'\n",
|
||||
" aligned_apk = job_dir / f'{uploaded_apk.stem}-aligned.apk'\n",
|
||||
" signed_apk = job_dir / f'{uploaded_apk.stem}-patched.apk'\n",
|
||||
"\n",
|
||||
" if job_dir.exists():\n",
|
||||
" shutil.rmtree(job_dir)\n",
|
||||
" job_dir.mkdir(parents=True, exist_ok=True)\n",
|
||||
"\n",
|
||||
" print(f'[*] Decompiling {uploaded_apk.name} ...')\n",
|
||||
" run(['apktool', 'd', '-f', str(uploaded_apk), '-o', str(decoded_dir)])\n",
|
||||
"\n",
|
||||
" metadata_path = decoded_dir / 'assets/bin/Data/Managed/Metadata/global-metadata.dat'\n",
|
||||
" so_path = decoded_dir / 'lib/arm64-v8a/libil2cpp.so'\n",
|
||||
" manifest_path = decoded_dir / 'AndroidManifest.xml'\n",
|
||||
" res_xml_dir = decoded_dir / 'res/xml'\n",
|
||||
"\n",
|
||||
" for required_path in (metadata_path, so_path, manifest_path):\n",
|
||||
" require_file(required_path)\n",
|
||||
"\n",
|
||||
" replacements = build_replacements(cfg)\n",
|
||||
"\n",
|
||||
" print(f'\\n[*] Patching for server {cfg.host} (web={cfg.web_base_url}, gRPC host={cfg.grpc_host})')\n",
|
||||
"\n",
|
||||
" print('\\n[1] Patching global-metadata.dat string literals ...')\n",
|
||||
" patched_strings = patch_metadata_strings(metadata_path, replacements)\n",
|
||||
" print(f' {patched_strings}/{len(replacements)} strings patched')\n",
|
||||
"\n",
|
||||
" print('\\n[2] Patching libil2cpp.so ...')\n",
|
||||
" patched_methods = patch_libil2cpp(so_path)\n",
|
||||
" print(f' {patched_methods}/{len(IL2CPP_PATCHES)} methods patched')\n",
|
||||
"\n",
|
||||
" if cfg.protocol == 'http':\n",
|
||||
" print('\\n[3] Enabling cleartext traffic in AndroidManifest.xml ...')\n",
|
||||
" patch_manifest(manifest_path)\n",
|
||||
"\n",
|
||||
" print('\\n[4] Writing network_security_config.xml ...')\n",
|
||||
" create_network_security_config(res_xml_dir)\n",
|
||||
" else:\n",
|
||||
" print('\\n[3] HTTPS selected; skipping cleartext network security changes')\n",
|
||||
"\n",
|
||||
" print('\\n[5] Rebuilding APK ...')\n",
|
||||
" run(['apktool', 'b', str(decoded_dir), '-o', str(unsigned_apk)])\n",
|
||||
"\n",
|
||||
" print('\\n[6] Aligning APK ...')\n",
|
||||
" run(['zipalign', '-f', '4', str(unsigned_apk), str(aligned_apk)])\n",
|
||||
"\n",
|
||||
" print('\\n[7] Signing APK ...')\n",
|
||||
" keystore_path = ensure_debug_keystore()\n",
|
||||
" if signed_apk.exists():\n",
|
||||
" signed_apk.unlink()\n",
|
||||
" shutil.copy2(aligned_apk, signed_apk)\n",
|
||||
" run([\n",
|
||||
" 'apksigner', 'sign',\n",
|
||||
" '--ks', str(keystore_path),\n",
|
||||
" '--ks-pass', 'pass:android',\n",
|
||||
" '--key-pass', 'pass:android',\n",
|
||||
" '--ks-key-alias', 'androiddebugkey',\n",
|
||||
" str(signed_apk),\n",
|
||||
" ])\n",
|
||||
"\n",
|
||||
" print('\\n[8] Verifying signature ...')\n",
|
||||
" run(['apksigner', 'verify', '--verbose', str(signed_apk)])\n",
|
||||
"\n",
|
||||
" print(f'\\n[+] Finished: {signed_apk}')\n",
|
||||
" return signed_apk\n",
|
||||
"\n",
|
||||
"# ── Validate config ───────────────────────────────────────────────────────────\n",
|
||||
"server_host = server_host.strip()\n",
|
||||
"server_port = server_port.strip()\n",
|
||||
"\n",
|
||||
"if not server_host:\n",
|
||||
" raise ValueError('server_host cannot be empty')\n",
|
||||
"\n",
|
||||
"if server_port and not server_port.isdigit():\n",
|
||||
" raise ValueError('server_port must be blank or numeric')\n",
|
||||
"\n",
|
||||
"display_port = f':{server_port}' if server_port else ''\n",
|
||||
"print(f'Configured web base URL: {protocol}://{server_host}{display_port}')\n",
|
||||
"print(f'Configured gRPC host patch: {server_host}')\n",
|
||||
"\n",
|
||||
"# ── Install dependencies ──────────────────────────────────────────────────────\n",
|
||||
"install_dependencies()\n",
|
||||
"\n",
|
||||
"# ── Download, patch, and export APK ──────────────────────────────────────────\n",
|
||||
"apk_url = 'https://archive.org/download/nierreincarnation/Global/apk/NieR%20Re%5Bin%5Dcarnation%203.7.1.apk'\n",
|
||||
"output_name = Path(urllib.parse.unquote(apk_url.split('/')[-1]))\n",
|
||||
"input_apk = WORK_ROOT / output_name.name\n",
|
||||
"ensure_dir(WORK_ROOT)\n",
|
||||
"\n",
|
||||
"print(f'Downloading APK from {apk_url} ...')\n",
|
||||
"run(['wget', '-q', '-O', str(input_apk), apk_url])\n",
|
||||
"if not input_apk.exists():\n",
|
||||
" raise RuntimeError('Download failed')\n",
|
||||
"print(f'Downloaded {input_apk.name} ({input_apk.stat().st_size:,} bytes)')\n",
|
||||
"\n",
|
||||
"cfg = RuntimeConfig(protocol=protocol, host=server_host, port=server_port)\n",
|
||||
"output_apk = patch_uploaded_apk(input_apk, cfg)\n",
|
||||
"print(f'Patched APK: {output_apk}')\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" from google.colab import files\n",
|
||||
" files.download(str(output_apk))\n",
|
||||
"except Exception:\n",
|
||||
" print('Browser download failed; patched APK is at:', output_apk)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
Reference in New Issue
Block a user