diff --git a/Ananta.Proxy/proxy/certs/cert.pem b/Ananta.Proxy/proxy/certs/cert.pem new file mode 100644 index 0000000..27cd7c8 --- /dev/null +++ b/Ananta.Proxy/proxy/certs/cert.pem @@ -0,0 +1,44 @@ +-----BEGIN CERTIFICATE----- +MIIH0zCCBjugAwIBAgIQbavnz5kXLZxKOHZTzB3p4DANBgkqhkiG9w0BAQsFADAi +MSAwHgYDVQQDDBdBbmFudGEgTG9jYWwgUHJveHkgUm9vdDAeFw0yNjA5MTUxNzIz +NTNaFw0yNzAxMTMxNzMzNTNaMCExHzAdBgNVBAMMFmw1MC51cGRhdGUubmV0ZWFz +ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9LRaa1AQCmz0x +7KnxcNzi0VMULg2EOc330viwSPoDMNj9/t3E9xfpU5KSipy5qKf2BMix76E6Mtlg +l4QVuvrGiFetR4P1UQPxsFrExt9gzZKmbJLwWO5mW5GIiQ76ufR2VEkfHKqqLbNO +/plq297NdoVTHZ7VwljYUb619/S4xMJMOy/0CoWBiyCIUOZ/XjRSsesnDQrhAG3+ +ktR/dImCtrT3T1vtBD9eZ+dT5+C0QBm1rUgZW6oHk+UNe4hPIrO3ZqG6pXgjC0Zn +ZNnSRYfZJ8f85tTFDaQi3VQSku/GZgb4RUTBOV7QCIIHhlWEvEkaL6p8ke0xARDD +9+2v9rZlAgMBAAGjggSEMIIEgDAOBgNVHQ8BAf8EBAMCBaAwggQJBgNVHREEggQA +MIID/IIWbDUwLnVwZGF0ZS5uZXRlYXNlLmNvbYImc2VydmVybGlzdC10ZXN0Lmw1 +MC5sZWlodW8ubmV0ZWFzZS5jb22CE2w1MC5nZGwubmV0ZWFzZS5jb22CE2w1MC5n +cGgubmV0ZWFzZS5jb22CFWw1MC5nc2dwaC5uZXRlYXNlLmNvbYIUc2VydmljZS5t +a2V5LjE2My5jb22CFXFhdGVzdC5nLm1rZXkuMTYzLmNvbYIXcWF0ZXN0LTEuZy5t +a2V5LjE2My5jb22CF3FhdGVzdC0yLmcubWtleS4xNjMuY29tghdxYXRlc3QtMy5n +Lm1rZXkuMTYzLmNvbYIXcWF0ZXN0LTQuZy5ta2V5LjE2My5jb22CF3FhdGVzdC01 +LmcubWtleS4xNjMuY29tghdxYXRlc3QtNi5nLm1rZXkuMTYzLmNvbYIXcWF0ZXN0 +LTcuZy5ta2V5LjE2My5jb22CF3FhdGVzdC04LmcubWtleS4xNjMuY29tghpiaW5k +LW1vYmlsZS5nLm1rZXkuMTYzLmNvbYIfYmluZC1tb2JpbGUtdGVzdC5nLm1rZXku +MTYzLmNvbYIhbXBheS1jb21tb24tc2VydmVyLmcubWtleS4xNjMuY29tgiZtcGF5 +LWNvbW1vbi1zZXJ2ZXItdGVzdC5nLm1rZXkuMTYzLmNvbYIvbXBheS1wZXJzb25h +bC1wcml2YWN5LXByb3RlY3Rpb24uZy5ta2V5LjE2My5jb22CM21wYXktcGVyc29u +YWwtcHJpdmFjeS1wcm90ZWN0aW9uLWRldi5nLm1rZXkuMTYzLmNvbYIWd2hvYW1p +Lm5pZS5uZXRlYXNlLmNvbYIWd2hvYW1pLm5pZS5lYXNlYmFyLmNvbYIbcHJvdG9j +b2wudW5pc2RrLm5ldGVhc2UuY29tghR0cHNsLm5pZS5uZXRlYXNlLmNvbYISZzAu +Z3NmLm5ldGVhc2UuY29tghJnMC5nc2YuZWFzZWJhci5jb22CEm1jb3VudC5lYXNl +YmFyLmNvbYIaYW5hbHl0aWNzLm1wYXkubmV0ZWFzZS5jb22CGWFwcGxvZy5tYXRy +aXgubmV0ZWFzZS5jb22CGWFwcGxvZy5tYXRyaXguZWFzZWJhci5jb22CHW1nYnNk +a3Rlc3QubWF0cml4Lm5ldGVhc2UuY29tghltZ2JzZGsubWF0cml4Lm5ldGVhc2Uu +Y29tghltZ2JzZGsubWF0cml4LmVhc2ViYXIuY29tghZkbnMudXBkYXRlLm5ldGVh +c2UuY29tghZkbnMudXBkYXRlLmVhc2ViYXIuY29tghVvcGVuYXBpLm11c2ljLjE2 +My5jb22CCWxvY2FsaG9zdDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMB8GA1UdIwQYMBaAFOGuN6GZzpCkTJ6JBp8KIBzaPf+wMB0GA1UdDgQWBBS6 +oSuUo2A9bUQT5HEU6Y8pKUAkYDANBgkqhkiG9w0BAQsFAAOCAYEAvYA2k1y4NdwZ +beJCZYy08AvipyFIWvFrS686tjp/RJuroTH7/rpIucQEhXW3I4CFhvFjEnImlZ3Y +eHLoVtJUyDmVc4Oe0USZN2g3qjD0EJ7l6EUvPIdAGK2TssWpK1R671oI/eOiacrl +lSjp9VFfjG6XLQg2CRndlSW9SIyHTneVyhxNdficxzDdUUlwzCWSa5cVSh70mq83 +nJs6T5XlxQ9nBnj3nG/bvam3aL2d/PZCF9k4jMNevkmcynV1UvrWTf5levMARqnJ ++00erje6M8U21TQFtt3OOE5oSiFaM/Dyb2VkRADzCqzJSUpCt3ksGh8xbhG0pnPj +6VFqMBfjATfBZSt/raUGfmRSZ/RnsaswN/emj4zhBgGiC2BINw70lb4prACBIq6/ +6luX/Br3/MChoMW2GPj2loY0Iv2Gxyfn1frU4d89K0joMrpLi9sXrF6NL1bxsKEy +7cIam3G2wYhpGQsKTDVBWIr/4/wTqz3U26/R48eBlTyKFebgcbHj +-----END CERTIFICATE----- \ No newline at end of file diff --git a/Ananta.Proxy/proxy/certs/key.pem b/Ananta.Proxy/proxy/certs/key.pem new file mode 100644 index 0000000..934042c --- /dev/null +++ b/Ananta.Proxy/proxy/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC9LRaa1AQCmz0x +7KnxcNzi0VMULg2EOc330viwSPoDMNj9/t3E9xfpU5KSipy5qKf2BMix76E6Mtlg +l4QVuvrGiFetR4P1UQPxsFrExt9gzZKmbJLwWO5mW5GIiQ76ufR2VEkfHKqqLbNO +/plq297NdoVTHZ7VwljYUb619/S4xMJMOy/0CoWBiyCIUOZ/XjRSsesnDQrhAG3+ +ktR/dImCtrT3T1vtBD9eZ+dT5+C0QBm1rUgZW6oHk+UNe4hPIrO3ZqG6pXgjC0Zn +ZNnSRYfZJ8f85tTFDaQi3VQSku/GZgb4RUTBOV7QCIIHhlWEvEkaL6p8ke0xARDD +9+2v9rZlAgMBAAECggEAL4KCWdIDS9BG9yHTsFJC9yfQ8JF8TYfi5WlzkXpSZ7i9 +1lRKx+uycqPNfMYZBDuKKywaZy2iVSZuF2cWAgsabf/R3S/HwTVegzViJmc3tG0M +doVwBbKf9M7sZtjdDTTkY09ZqdmeNu0RSjaAuyiK9Rjd9PjMaizL+U/WUIb8S+aX +xv45P7z3rKB7wUhh1+gLg12gNu5kDlKFK5CD26obtApfGL9BsiE8a6Y8KkBdgXcw +3yrmoESrUFPa35rOJHwdpqlE1v4BUEuqlHchVaW2MzVi9u986DtXzGp/LGQokWTY +Vecuy9qYtkUJHEHJYKcxYIH9Yyc9+VEno86TzVxQhQKBgQDaO87otyH3D7Mo+hxb +eHOHSX6x5JFKSm8+tqtPyjQJxHiJ98jeBwI6QdJq2BsX0CuD+QwPjNwDqTccvxQe +QteuyLKFyi0Jkw/1Wjsz8j1OyBY/M7k2eZkE6OfG1BrKomw2TEk47ol+j5GvlsxB +8LKAlLBdfx+KbnKuq2D8IbbbzwKBgQDd6flU+PdPgTlP8406mAUUamjANhvVC72R +TLMoYLut64jK/40McWrr+n7PzDhrue8wNiuzGDa9e0o1RN/d511DAtjS6QHMXNtP +DviwDP9YFQqITHX2JZ/0UzLLHmhjxGAxxzIVsvd6FLsU7aQaydR24l0BmTlRIPs2 +u48YURMTiwKBgHhv2ViE+LGocG+E2HGCn/9hiBIU2jntqkcp+JEoPl/FleBOZwRh +SdFs0fs1GH6j2+ARaZ6LVkCO+mNa8Hdi3DURfbRoR0Kpbn0xNssMPNllgyGOIgD2 +SoRzJLaOB9scmCu/HgnVWvwFr5j/1+nsNBbR+CMhLP7wxUmwOu3qjUfxAoGAa4/7 +UJqUlS7Xrs8oh1wkKi8hsvkHKI7WRdVdiZbJoZVm6q5PmdZnsxXp3aLpPh4Tt9Sp +/dcgwVs/Lu7Ba/e3FsIMgr56JqpjisCA/iRuqC0au9ID41l9XJnvJtEIXENVNQuO +VUI3/i6KN6ywPGPT3gP+fMXeKFrKKU9kHq89hd0CgYA+vpfM2Pq9zhEIxmyR8cSF +P6MSTcY5ttJjBL3hBYI4RTIEU4I6QmL/Ln3NgK7JN8TM/22mGa4ceJVnPGxSQ/AA +qtIENQX9wPcnvY1sQW4nLOoMQvp1r3bYLW4EuGi6K1L3VTX6+XgtjmAW6dTMbOeE +ge9J8YA0vz+pAP/iloTchQ== +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/README.md b/README.md index 36d252e..217f685 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,56 @@ # Ananta (Project Mugen) CBT 4229938 Private Server & Proxy Engine -เซิร์ฟเวอร์ส่วนตัว (Private Server) และระบบ HTTPS/SNI Proxy สำหรับเกม **Ananta (Project Mugen / 代号:无限大)** ไคลเอนต์เวอร์ชัน **CBT 4229938** พัฒนาด้วยสถาปัตยกรรม Clean Architecture C# .NET 8 (UX-RPC Binary Protocol) ร่วมกับ Node.js Proxy เพื่อจำลองการเชื่อมต่อและเล่นเกมในโหมด Sandbox / Free-Roam ได้อย่างสมบูรณ์ รองรับทั้งการเข้าเล่นผ่าน PC และ Mobile APK +เซิร์ฟเวอร์ส่วนตัว (Private Server) และระบบ HTTPS/SNI Proxy สำหรับเกม **Ananta (Project Mugen / 代号:无限大)** ไคลเอนต์เวอร์ชัน **CBT 4229938** พัฒนาขึ้นมาทั้งในรูปแบบ **High-Performance Native Go Engine (`ananta-server.exe`)** และ Clean Architecture C# .NET 8 ร่วมกับ Node.js Proxy เพื่อจำลองการเชื่อมต่อและเล่นเกมในโหมด Sandbox / Free-Roam ได้อย่างสมบูรณ์แบบ รองรับทั้งการเข้าเล่นผ่าน PC และ Mobile APK -> 📖 **รายการระบบและฟีเจอร์ทั้งหมดของเซิร์ฟเวอร์**: ดูรายละเอียดฟีเจอร์เต็มได้ที่ [docs/FEATURES.md](docs/FEATURES.md) +> 📖 **รายการระบบและฟีเจอร์ทั้งหมดของเซิร์ฟเวอร์ (36+ ระบบ)**: ดูรายละเอียดเชิงลึกและสถานะฟีเจอร์ทั้งหมดได้ที่ [docs/FEATURES.md](docs/FEATURES.md) --- ## 🛠️ ความต้องการของระบบ (Prerequisites) +### สำหรับ Go Native Engine (แนะนำ - รันได้ทันทีโดยไม่ต้องลงรันไทม์เพิ่ม): 1. **Windows 10 / 11 (64-bit)** -2. **.NET 8.0 SDK**: [ดาวน์โหลดที่นี่](https://dotnet.microsoft.com/download/dotnet/8.0) -3. **Node.js (LTS v18 หรือ v20 ขึ้นไป)**: [ดาวน์โหลดที่นี่](https://nodejs.org/) -4. **Git & Git LFS**: [ดาวน์โหลดที่นี่](https://git-lfs.com/) -5. ตัวเกม **Ananta / Project Mugen (CBT Client build 4229938 หรือ Mobile APK 4063976)** +2. ตัวเกม **Ananta / Project Mugen (CBT Client build 4229938 หรือ Mobile APK 4063976)** +3. (ตัวเลือกเสริมสำหรับการพัฒนา) **Go 1.22+**: [ดาวน์โหลดที่นี่](https://go.dev/dl/) + +### สำหรับ C# / Node.js Engine ดั้งเดิม: +1. **.NET 8.0 SDK** & **Node.js (v18+)** --- ## 🚀 วิธีติดตั้งและเปิดใช้งานเซิร์ฟเวอร์ -### 1. Clone Repository (จำเป็นต้องใช้ Git LFS) -```bash -git clone https://gaytea.cod-algieba.ts.net/GaCy/Ananta-ps.git -cd Ananta-ps -git checkout dev -git lfs pull -``` - -### 2. ติดตั้ง Certificate & Hosts +### 1. ติดตั้ง Certificate & Hosts รันคำสั่งด้วย PowerShell (Administrator) เพื่อชี้โดเมน NetEase เข้าหาเซิร์ฟเวอร์เครื่องคุณ (`127.0.0.1`): ```powershell powershell -ExecutionPolicy Bypass -File .\Ananta.Proxy\SETUP_PROXY_AS_ADMIN.ps1 ``` -*(หากต้องการยกเลิกการตั้งค่า Hosts ในภายหลัง สามารถรัน `REMOVE_PROXY_HOSTS_AS_ADMIN.ps1`)* -### 3. เปิดเซิร์ฟเวอร์ -ดับเบิลคลิกเปิดใช้งานผ่านไฟล์: +### 2. เปิดเซิร์ฟเวอร์ด้วย Go Engine (รวดเร็ว, น้ำหนักเบา, รวมทุกระบบในไบนารีเดียว) +ดับเบิลคลิกเปิดใช้งานผ่าน: ```cmd -START.cmd +START-GO.cmd ``` -หรือรันผ่าน PowerShell: -```powershell -powershell -ExecutionPolicy Bypass -File .\Run-All.ps1 +หรือรันผ่านคอมมานด์ไลน์: +```bash +.\ananta-server.exe ``` -เมื่อขึ้นข้อความ `[READY]` แสดงว่าระบบพร้อมทำงาน: -* **HTTPS Gateway Proxy** (พอร์ต 443/80 และ Mobile Dispatch พอร์ต 5801) -* **Game Server** (พอร์ต 5200-5202) -* **GM Admin Web Panel** (พอร์ต 17888) -* **Debug WebUI Panel** (พอร์ต 5809) -### 4. เข้าเล่นเกม +*(หรือหากต้องการใช้งาน C# Engine ดั้งเดิม สามารถดับเบิลคลิก `START.cmd` ได้เช่นกัน)* + +เมื่อขึ้นข้อความ `[READY]` แสดงว่าระบบพร้อมทำงาน: +* **HTTPS Gateway Proxy**: พอร์ต 443 (TLS SNI) / 80 (HTTP) และ Mobile Dispatch พอร์ต 5801 +* **Game Server**: Login Ports 5200, 5201 และ World Port 5202 +* **GM Admin Web Panel**: พอร์ต 17888 +* **Debug WebUI Panel**: พอร์ต 5809 + +### 3. เข้าเล่นเกม * **PC:** เปิดไฟล์ `Ananta.exe` แล้วกดเริ่มเกม ตัวเกมจะทำการบายพาสล็อกอินและนำตัวละครเข้าสู่โลก Sandbox ทันที * **Mobile (APK):** ชี้เกตเวย์หรือรันผ่าน Wi-Fi Proxy มายัง IP เครื่องเซิร์ฟเวอร์ (พอร์ต 5801/443) -### 5. แผงควบคุมผ่านเว็บเบราว์เซอร์ -* **GM Admin Panel**: [http://127.0.0.1:17888/](http://127.0.0.1:17888/) — จัดการไอเทม, กระเป๋า, ผู้เล่น, และอีโคโนมี -* **Debug WebUI Panel**: [http://127.0.0.1:5809/debug](http://127.0.0.1:5809/debug) — วาร์ปพิกัด, ปรับสภาพอากาศ/เวลา, เสกเอนทิตี, และควบคุมทราฟฟิก +### 4. แผงควบคุมผ่านเว็บเบราว์เซอร์ +* **GM Admin Panel**: [http://127.0.0.1:17888/](http://127.0.0.1:17888/) — จัดการไอเทม, วาร์ปพิกัด, สลับตัวละคร, และระบบโลก +* **Debug WebUI Panel**: [http://127.0.0.1:5809/](http://127.0.0.1:5809/) — มอนิเตอร์การเชื่อมต่อ, เซสชันแบบ Real-Time, และ RPC Inspector --- @@ -62,36 +58,26 @@ powershell -ExecutionPolicy Bypass -File .\Run-All.ps1 ```text Ananta-ps/ -├── Ananta.Proxy/ # Node.js HTTPS Multi-Domain Proxy & Fastpatch Generator -│ ├── proxy/ -│ │ ├── certs/ # ใบรับรอง SSL สำหรับดักโดเมน NetEase -│ │ ├── public/ # Static config, version JSON, serverlist, fastpatch.zip -│ │ └── server.js # Proxy Server หลัก -├── Ananta.SDK/ # Core Network Frame, Crypto (ChaCha8), และ UxRpc Serialization -├── Ananta.Server/ # C# .NET 8 UX-RPC Game Server -│ ├── Ananta.App/ # Console Entrypoint, Admin Web Server (17888) & Debug Panel (5809) -│ ├── Ananta.Core/ # Client Data Repositories, Wire Codecs & World State -│ ├── Ananta.Gameplay/ # ระบบ Traversal, City Traffic, Living Crowd & World Engine -│ ├── Ananta.Handlers/ # RPC Handlers (World, Switching, Gm, Vehicles, Minigames, Police) -│ ├── Ananta.Network/ # TCP Listener & Rpc Frame Dispatcher -│ ├── Ananta.RpcTypes/ # DTOs และ Method Contracts ของไคลเอนต์ 4229938 -│ └── ClientData/4229938/ # ไฟล์ Database Configs ของเกม (Json, LFS) +├── cmd/ +│ └── ananta-server/ # Go Server Entrypoint (รวม Gateway + Login + Game + Web ใน binary เดียว) +├── internal/ +│ ├── config/ # ตัวโหลดคอนฟิก private-server.json +│ ├── gateway/ # HTTPS/SNI Proxy (443), HTTP (80) และ Mobile Dispatch (5801) +│ ├── logingate/ # Login & Gate TCP Server (พอร์ต 5200, 5201) +│ ├── gameserver/ # World Entry (V7 handoff), Combat, Movement, Switching (5202) +│ └── web/ # GM Admin Panel (17888) และ Debug WebUI (5809) +├── pkg/ +│ ├── crypto/chacha8/ # NetEase UXChaCha8 Custom Sigma Stream Cipher +│ └── protocol/ # Binary Serializer/Deserializer, Frame Codec, Key Exchange +├── data/ +│ └── player_info_template.bin # เทมเพลตข้อมูลผู้เล่นและไอเทมเริ่มต้น ├── config/ # private-server.json และตารางพิกัดมินิเกม/การจราจร -├── docs/ # เอกสารเสริม (FEATURES.md, devlog) -├── tools/ # เครื่องมือเสริม (Car Radio, Injector, Launcher, Python) -├── Run-All.ps1 # สคริปต์อัตโนมัติ (Fastpatch -> Build -> Launch) -└── START.cmd # ตัวเปิดเซิร์ฟเวอร์แบบ All-in-One +├── docs/ # FEATURES.md (ฟีเจอร์แมทริกซ์ 36 ระบบ) +├── START-GO.cmd # รัน Go Engine ทันทีในคลิกเดียว +└── ananta-server.exe # Single Native Binary Executable ``` --- -## ⚠️ ข้อควรระวังและการแก้ปัญหา (Troubleshooting) - -- **เกมค้างหน้า "Initializing Game" หรือ "Initialize Client"**: ตรวจสอบว่า Fastpatch ถูกสร้างและวางไว้ในโฟลเดอร์รันไทม์เรียบร้อยแล้ว -- **พอร์ตชน (Ports already in use)**: หากมี Process ค้างอยู่ สามารถปิดผ่าน PowerShell: `Stop-Process -Name node, Ananta.App -Force` -- **ดาวน์โหลดไม่ผ่าน / ติด LFS Bandwidth**: ตรวจสอบว่า `git lfs pull` ดึงไฟล์โมเดลและ Database ขนาดใหญ่มาครบถ้วน - ---- - ## ⚖️ ข้อกำหนดและเงื่อนไข (Disclaimer) โปรเจกต์นี้จัดทำขึ้นเพื่อการศึกษาด้าน Reverse Engineering, Network Protocol Emulation และการอนุรักษ์ซอฟต์แวร์ (Software Preservation) เท่านั้น ไม่มีส่วนเกี่ยวข้องกับ NetEase Games หรือ Naked Rain แต่อย่างใด ห้ามนำไปใช้ในเชิงพาณิชย์ diff --git a/START-GO.cmd b/START-GO.cmd new file mode 100644 index 0000000..70dc4be --- /dev/null +++ b/START-GO.cmd @@ -0,0 +1,9 @@ +@echo off +title Ananta Private Server (Native Go Engine) +cd /d "%~dp0" +if not exist ananta-server.exe ( + echo [BUILD] Building ananta-server.exe... + go build -o ananta-server.exe ./cmd/ananta-server +) +ananta-server.exe +pause diff --git a/ananta-server.exe b/ananta-server.exe new file mode 100644 index 0000000..94d83eb Binary files /dev/null and b/ananta-server.exe differ diff --git a/cmd/ananta-server/main.go b/cmd/ananta-server/main.go new file mode 100644 index 0000000..a0bd9a3 --- /dev/null +++ b/cmd/ananta-server/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "ananta-ps/internal/config" + "ananta-ps/internal/gameserver" + "ananta-ps/internal/gateway" + "ananta-ps/internal/logingate" + "ananta-ps/internal/web" +) + +const banner = ` +===================================================================== + _ _ ____ ____ + / \ _ __ __ _ _ __ _ _ _| |_ __ _ | _ \ / ___| + / _ \ | '_ \ / _` + "`" + ` | '_ \| |_| | _/ _` + "`" + ` | | |_) |\___ \ + / ___ \| | | | (_| | | | | _ | || (_| | | __/ ___) | +/_/ \_\_| |_|\__,_|_| |_|_| |_|\__\__,_| |_| |____/ + (Native Go Engine) +===================================================================== +` + +func main() { + configPath := flag.String("config", "config/private-server.json", "Path to private-server.json") + flag.Parse() + + fmt.Print(banner) + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("[FATAL] Could not load configuration: %v", err) + } + + log.Printf("[INIT] Client Version: %d | Server ID: %d | Aid: %d", cfg.Client.Version, cfg.Client.ServerID, cfg.Client.Aid) + log.Printf("[INIT] Player Profile: PID=%d | Account=%s | InitialUnit=%d", cfg.Player.Pid, cfg.Player.AccountId, cfg.Player.InitialUnitId) + + // 1. Start Gateway (Port 443, 80, 5801) + gw := gateway.New(cfg, "", "") + if err := gw.Start(); err != nil { + log.Printf("[GATEWAY] Error starting gateway: %v", err) + } + defer gw.Stop() + + // 2. Start Login Gate Server (Port 5200, 5201) + loginSrv := logingate.New(cfg) + if err := loginSrv.Start(); err != nil { + log.Fatalf("[LOGIN-GATE] Failed to start login gate: %v", err) + } + defer loginSrv.Stop() + + // 3. Start Game Server (Port 5202) + gameSrv := gameserver.New(cfg, "") + if err := gameSrv.Start(); err != nil { + log.Fatalf("[GAME-SERVER] Failed to start game server: %v", err) + } + defer gameSrv.Stop() + + // 4. Start Admin GM Web Panel (Port 17888) + adminSrv := web.NewAdmin(cfg, gameSrv) + if err := adminSrv.Start(); err != nil { + log.Printf("[ADMIN-GM] Warning: admin server error: %v", err) + } + defer adminSrv.Stop() + + // 5. Start Debug WebUI (Port 5809) + debugSrv := web.NewDebug(cfg, gameSrv) + if err := debugSrv.Start(); err != nil { + log.Printf("[DEBUG-API] Warning: debug server error: %v", err) + } + defer debugSrv.Stop() + + log.Println("[READY] Ananta Private Server is fully operational!") + log.Printf("[READY] Gateway: HTTPS=443, HTTP=80, Mobile/LoginList=%d", cfg.Network.Proxy.LoginListPort) + log.Printf("[READY] Game Ports: Login=%v, Game=%d", cfg.Network.LoginPorts, cfg.Network.GamePort) + log.Printf("[READY] GM Admin Panel: http://%s:%d/", cfg.Network.AdvertisedHost, cfg.Admin.Port) + log.Printf("[READY] Debug WebUI: http://%s:5809/", cfg.Network.AdvertisedHost) + log.Println("[INFO] Press Ctrl+C to stop the server.") + + // Wait for termination signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + log.Println("[SHUTDOWN] Stopping all Ananta Private Server services...") +} diff --git a/data/player_info_template.bin b/data/player_info_template.bin new file mode 100644 index 0000000..f689ea5 Binary files /dev/null and b/data/player_info_template.bin differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..993822d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ananta-ps + +go 1.27.0 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..10a13bd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,156 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Config struct { + Client ClientConfig `json:"client"` + Network NetworkConfig `json:"network"` + Admin AdminConfig `json:"admin"` + Player PlayerConfig `json:"player"` + World WorldConfig `json:"world"` +} + +type ClientConfig struct { + Version int `json:"version"` + ArtifactVersion string `json:"artifactVersion"` + ServerID int `json:"serverId"` + Aid int `json:"aid"` + RpcMd5 string `json:"rpcMd5"` + NoMoreHotfixPatchError uint32 `json:"noMoreHotfixPatchError"` +} + +type NetworkConfig struct { + BindHost string `json:"bindHost"` + AdvertisedHost string `json:"advertisedHost"` + LoginPorts []int `json:"loginPorts"` + GamePort int `json:"gamePort"` + SocketHeartbeatMs int `json:"socketHeartbeatMs"` + WriteTimeoutMs int `json:"writeTimeoutMs"` + Proxy ProxyConfig `json:"proxy"` +} + +type ProxyConfig struct { + HttpsPort int `json:"httpsPort"` + HttpPort int `json:"httpPort"` + LoginListPort int `json:"loginListPort"` + LoginTcpPort int `json:"loginTcpPort"` + GameTcpPort int `json:"gameTcpPort"` + SceneSubPort int `json:"sceneSubPort"` + FastPatchPort int `json:"fastPatchPort"` + UpdateHost string `json:"updateHost"` + CertificatePassphrase string `json:"certificatePassphrase"` +} + +type AdminConfig struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` +} + +type PlayerConfig struct { + Pid uint64 `json:"pid"` + InitialUnitId uint64 `json:"initialUnitId"` + InitialSpiritTemplateId uint32 `json:"initialSpiritTemplateId"` + AccountId string `json:"accountId"` + UserName string `json:"userName"` + DisplayName string `json:"displayName"` + LoginToken string `json:"loginToken"` + GameToken string `json:"gameToken"` + LoginUniverseId uint32 `json:"loginUniverseId"` + ShareToken string `json:"shareToken"` + FpPassToken string `json:"fpPassToken"` + Skey string `json:"skey"` +} + +type Vec3 struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +type WorldConfig struct { + RaidId uint32 `json:"raidId"` + SceneInstanceId uint64 `json:"sceneInstanceId"` + UniverseId uint32 `json:"universeId"` + Spawn Vec3 `json:"spawn"` + Facing float32 `json:"facing"` + EntryOpeningEnabled bool `json:"entryOpeningEnabled"` +} + +// LoadConfig reads and parses private-server.json from the specified path or standard locations. +func LoadConfig(path string) (*Config, error) { + if path == "" { + candidates := []string{ + "config/private-server.json", + "../config/private-server.json", + "../../config/private-server.json", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + path = c + break + } + } + } + + if path == "" { + return nil, fmt.Errorf("config/private-server.json not found") + } + + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read config from %s: %w", absPath, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse json config from %s: %w", absPath, err) + } + + // Apply default values if omitted + if cfg.Network.BindHost == "" { + cfg.Network.BindHost = "0.0.0.0" + } + if cfg.Network.AdvertisedHost == "" { + cfg.Network.AdvertisedHost = "127.0.0.1" + } + if len(cfg.Network.LoginPorts) == 0 { + cfg.Network.LoginPorts = []int{5200, 5201} + } + if cfg.Network.GamePort == 0 { + cfg.Network.GamePort = 5202 + } + if cfg.Network.Proxy.HttpsPort == 0 { + cfg.Network.Proxy.HttpsPort = 443 + } + if cfg.Network.Proxy.HttpPort == 0 { + cfg.Network.Proxy.HttpPort = 80 + } + if cfg.Network.Proxy.LoginListPort == 0 { + cfg.Network.Proxy.LoginListPort = 5801 + } + if cfg.Admin.Port == 0 { + cfg.Admin.Port = 17888 + } + if cfg.Player.Pid == 0 { + cfg.Player.Pid = 100 + } + if cfg.Player.InitialUnitId == 0 { + cfg.Player.InitialUnitId = 100000000000 + } + if cfg.Player.InitialSpiritTemplateId == 0 { + cfg.Player.InitialSpiritTemplateId = 15020967 // Default character + } + + return &cfg, nil +} diff --git a/internal/gameserver/server.go b/internal/gameserver/server.go new file mode 100644 index 0000000..85fe2d6 --- /dev/null +++ b/internal/gameserver/server.go @@ -0,0 +1,397 @@ +package gameserver + +import ( + "context" + "fmt" + "io" + "log" + "net" + "path/filepath" + "sync" + + "ananta-ps/internal/config" + "ananta-ps/pkg/crypto/chacha8" + "ananta-ps/pkg/protocol" +) + +type Server struct { + cfg *config.Config + templatePath string + ln net.Listener + cancelFunc context.CancelFunc + wg sync.WaitGroup + sessions map[string]*Session + mu sync.RWMutex +} + +type Session struct { + id string + conn net.Conn + readCipher *chacha8.UxChaCha8 + sendCipher *chacha8.UxChaCha8 + sendMu sync.Mutex + pid uint64 + unitId uint64 + templateId uint32 + posX float32 + posY float32 + posZ float32 + facing float32 + activeScene uint64 + activeRaid uint32 + universeId uint32 +} + +func New(cfg *config.Config, templatePath string) *Server { + if templatePath == "" { + candidates := []string{ + "data/player_info_template.bin", + "../data/player_info_template.bin", + } + for _, c := range candidates { + abs, _ := filepath.Abs(c) + templatePath = abs + break + } + } + + return &Server{ + cfg: cfg, + templatePath: templatePath, + sessions: make(map[string]*Session), + } +} + +func (s *Server) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + s.cancelFunc = cancel + + addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, s.cfg.Network.GamePort) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on game server %s: %w", addr, err) + } + s.ln = ln + log.Printf("[GAME-SERVER] Listening on %s:%d", s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort) + + s.wg.Add(1) + go s.acceptLoop(ctx) + + return nil +} + +func (s *Server) Stop() error { + if s.cancelFunc != nil { + s.cancelFunc() + } + if s.ln != nil { + _ = s.ln.Close() + } + s.wg.Wait() + return nil +} + +func (s *Server) GetActiveSessions() []*Session { + s.mu.RLock() + defer s.mu.RUnlock() + list := make([]*Session, 0, len(s.sessions)) + for _, sess := range s.sessions { + list = append(list, sess) + } + return list +} + +func (s *Server) acceptLoop(ctx context.Context) { + defer s.wg.Done() + for { + conn, err := s.ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + log.Printf("[GAME-SERVER] Accept error: %v", err) + return + } + } + + go s.handleConnection(ctx, conn) + } +} + +func (sess *Session) SendPacket(packet *protocol.RpcPacket) error { + sess.sendMu.Lock() + defer sess.sendMu.Unlock() + + var payload []byte + switch packet.Kind { + case protocol.PacketKindInvoke: + payload = protocol.BuildInvoke(packet.MethodID, packet.InvokeID, packet.Body) + case protocol.PacketKindReturn: + payload = protocol.BuildReturn(packet.MethodID, packet.InvokeID, packet.Error, packet.Body) + case protocol.PacketKindNotify: + payload = protocol.BuildNotify(packet.MethodID, packet.Body) + } + + return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher) +} + +func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindReturn, + InvokeID: invokeID, + MethodID: methodID, + Error: 0, + Body: nil, + }) +} + +func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindReturn, + InvokeID: invokeID, + MethodID: methodID, + Error: 0, + Body: body, + }) +} + +func (sess *Session) NotifyData(methodID uint32, body []byte) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindNotify, + MethodID: methodID, + Body: body, + }) +} + +func (s *Server) handleConnection(ctx context.Context, conn net.Conn) { + defer conn.Close() + + log.Printf("[GAME-SERVER] Client connected from %s", conn.RemoteAddr()) + + readCipher, writeCipher, err := protocol.HandleHandshake(conn) + if err != nil { + log.Printf("[GAME-SERVER] Handshake error from %s: %v", conn.RemoteAddr(), err) + return + } + + sess := &Session{ + id: conn.RemoteAddr().String(), + conn: conn, + readCipher: readCipher, + sendCipher: writeCipher, + pid: s.cfg.Player.Pid, + unitId: s.cfg.Player.InitialUnitId, + templateId: s.cfg.Player.InitialSpiritTemplateId, + posX: float32(s.cfg.World.Spawn.X), + posY: float32(s.cfg.World.Spawn.Y), + posZ: float32(s.cfg.World.Spawn.Z), + facing: s.cfg.World.Facing, + activeRaid: s.cfg.World.RaidId, + activeScene: s.cfg.World.SceneInstanceId, + universeId: s.cfg.World.UniverseId, + } + + s.mu.Lock() + s.sessions[sess.id] = sess + s.mu.Unlock() + + defer func() { + s.mu.Lock() + delete(s.sessions, sess.id) + s.mu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + frame, err := protocol.ReadFrame(conn, sess.readCipher) + if err != nil { + if err != io.EOF { + log.Printf("[GAME-SERVER] Connection %s read error: %v", conn.RemoteAddr(), err) + } + return + } + + // Frame mode 0x03 is heartbeat + if frame.Mode == 0x03 { + _ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher) + continue + } + + packet := protocol.ParseRpcPacket(frame.Payload) + if err := s.dispatch(sess, &packet); err != nil { + log.Printf("[GAME-SERVER] Dispatch error for method %d: %v", packet.MethodID, err) + } + } +} + +func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error { + switch pkt.MethodID { + case protocol.MethodLoginGame: + log.Printf("[WORLD] LoginGame received, preparing world state for player %d", sess.pid) + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + + // 1. SendServerTimeGame + _ = sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0)) + + // 2. SyncPlayerInfo (from template binary) + playerInfoBytes, err := protocol.EncodePlayerInfo(s.templatePath, s.cfg.Client.Aid, sess.pid, s.cfg.Player.AccountId, s.cfg.Player.DisplayName) + if err == nil { + _ = sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes) + } else { + log.Printf("[WORLD] Warning: player_info_template.bin load error: %v", err) + } + + // 3. SyncEnterScene + enterScenePayload := protocol.EncodeEnterScene( + sess.pid, + sess.activeRaid, + sess.activeScene, + sess.universeId, + sess.unitId, + sess.templateId, + sess.posX, + sess.posY, + sess.posZ, + sess.facing, + ) + log.Printf("[WORLD-V7] Sending SyncEnterScene raid=%d instance=%d universe=%d unit=%d template=%d pos=(%.2f,%.2f,%.2f)", + sess.activeRaid, sess.activeScene, sess.universeId, sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ) + return sess.NotifyData(protocol.MethodSyncEnterScene, enterScenePayload) + + case protocol.MethodAskLoadSceneCompleted: + log.Printf("[WORLD-V7] AskLoadSceneCompleted ack received, committing V7 4-step handoff") + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + + // V7 4-step Handoff: + // Step 1: SyncLogicAgentEnter + _ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId)) + + // Step 2: SyncManagedLogicAgent + _ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0)) + + // Step 3: SyncRaidBattleUnitSpirit + unitProjection := protocol.EncodeRaidBattleUnitSpirit(sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid) + _ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection) + + // Step 4: SyncPlayerCurrentSpirit + return sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, sess.templateId, sess.unitId, false)) + + case protocol.MethodAskLoadingFinished: + log.Printf("[WORLD-V7] AskLoadingFinished received, scene ready") + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + + // 1. SyncSceneLoadCompleted + _ = sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene)) + + // 2. SyncGamePause (false) + wPause := protocol.NewUxWriter() + wPause.Bool(false) + _ = sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes()) + + // 3. Unlock Map Fog + wFog := protocol.NewUxWriter() + wFog.U32(uint32(sess.activeScene)).Bool(true) + _ = sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes()) + + // 4. Combat power + wPower := protocol.NewUxWriter() + wPower.U8(0xFF).Int7(1).U32(sess.templateId).F32(1000.0) + _ = sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes()) + + // 5. Unlock Diving + wDive := protocol.NewUxWriter() + wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true") + return sess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes()) + + case protocol.MethodAskLoadedInSameScene: + w := protocol.NewUxWriter() + w.U64(sess.pid).F64(1.0) + return sess.NotifyData(protocol.MethodSyncPlayerLoadRate, w.Bytes()) + + case protocol.MethodAskMoveStart, protocol.MethodAskMoveStop: + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + case protocol.MethodAskUpdatePlayerPosition: + r := protocol.NewUxReader(pkt.Body) + if x, err := r.F32(); err == nil { + sess.posX = x + } + if y, err := r.F32(); err == nil { + sess.posY = y + } + if z, err := r.F32(); err == nil { + sess.posZ = z + } + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + case protocol.MethodAskPreSwitchSpirit: + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + case protocol.MethodAskSwitchSpirit: + // Clean DRMK character swap + r := protocol.NewUxReader(pkt.Body) + newTemplateId := sess.templateId + if tid, err := r.U32(); err == nil { + newTemplateId = tid + } + newUnitId := sess.unitId + 1 + + log.Printf("[SWITCH] Switching character %d -> %d", sess.templateId, newTemplateId) + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + + _ = sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId)) + _ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId)) + _ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0)) + + newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newTemplateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid) + _ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj) + _ = sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true)) + + wInSitu := protocol.NewUxWriter() + wInSitu.U32(newTemplateId).U64(newUnitId) + _ = sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes()) + + sess.templateId = newTemplateId + sess.unitId = newUnitId + return nil + + case protocol.MethodAskSummonVehicle, protocol.MethodAskDriveVehicle, protocol.MethodAskExitVehicle: + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + case protocol.MethodAskPlayerAttack, protocol.MethodAskPlayerCastSkill, protocol.MethodAskUnlockFightStyle: + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + default: + if pkt.Kind == protocol.PacketKindInvoke { + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + } + } + + return nil +} + +// Teleport moves an active session to the specified coordinates. +func (sess *Session) Teleport(x, y, z, facing float32) error { + sess.posX = x + sess.posY = y + sess.posZ = z + sess.facing = facing + + w := protocol.NewUxWriter() + w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6) // SetPositionType.Teleport = 6 + return sess.NotifyData(protocol.MethodSyncUnitPositionAndFacing, w.Bytes()) +} diff --git a/internal/gameserver/server_test.go b/internal/gameserver/server_test.go new file mode 100644 index 0000000..673d915 --- /dev/null +++ b/internal/gameserver/server_test.go @@ -0,0 +1,129 @@ +package gameserver + +import ( + "crypto/aes" + "crypto/cipher" + "net" + "testing" + "time" + + "ananta-ps/internal/config" + "ananta-ps/pkg/crypto/chacha8" + "ananta-ps/pkg/protocol" +) + +func TestGameServerWorldEntry(t *testing.T) { + cfg := &config.Config{ + Client: config.ClientConfig{ + ServerID: 801, + Aid: 5944, + }, + Network: config.NetworkConfig{ + BindHost: "127.0.0.1", + AdvertisedHost: "127.0.0.1", + GamePort: 15203, + }, + Player: config.PlayerConfig{ + Pid: 100, + InitialUnitId: 100000000000, + InitialSpiritTemplateId: 15020967, + AccountId: "test_acc", + DisplayName: "test_player", + }, + World: config.WorldConfig{ + RaidId: 23300888, + SceneInstanceId: 20001222, + UniverseId: 76000888, + Spawn: config.Vec3{X: 100, Y: 0, Z: 200}, + Facing: 0, + }, + } + + srv := New(cfg, "../../data/player_info_template.bin") + if err := srv.Start(); err != nil { + t.Fatalf("failed to start game server: %v", err) + } + defer srv.Stop() + + // Connect client + conn, err := net.DialTimeout("tcp", "127.0.0.1:15203", 2*time.Second) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + defer conn.Close() + + // 1. Client handshake + clientHandshake := make([]byte, 260) + aesKey := []byte("0123456789abcdef") + copy(clientHandshake[4:20], aesKey) + if err := protocol.WriteFrame(conn, 0x01, clientHandshake, nil); err != nil { + t.Fatalf("failed to write handshake: %v", err) + } + + // 2. Server reply + replyFrame, err := protocol.ReadFrame(conn, nil) + if err != nil { + t.Fatalf("failed to read reply: %v", err) + } + + aesNonce := replyFrame.Payload[8:20] + encryptedHead := replyFrame.Payload[20:96] + tag := replyFrame.Payload[96:112] + + cipherBlock, err := aes.NewCipher(aesKey) + if err != nil { + t.Fatalf("failed to create aes cipher: %v", err) + } + aesGcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + t.Fatalf("failed to create aes gcm: %v", err) + } + + fullCipher := append(encryptedHead, tag...) + chaChaHead, err := aesGcm.Open(nil, aesNonce, fullCipher, nil) + if err != nil { + t.Fatalf("failed to decrypt chacha head: %v", err) + } + + clientSendCipher, _ := chacha8.New(chaChaHead[0:32], chaChaHead[64:76]) + clientReadCipher, _ := chacha8.New(chaChaHead[32:64], chaChaHead[64:76]) + + // 3. Send LoginGame Invoke + invokePayload := protocol.BuildInvoke(protocol.MethodLoginGame, 1, nil) + if err := protocol.WriteFrame(conn, 0x09, invokePayload, clientSendCipher); err != nil { + t.Fatalf("failed to write LoginGame: %v", err) + } + + // 4. Read Return frame + retFrame, err := protocol.ReadFrame(conn, clientReadCipher) + if err != nil { + t.Fatalf("failed to read return: %v", err) + } + pkt := protocol.ParseRpcPacket(retFrame.Payload) + if pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 1 { + t.Fatalf("expected return for invoke 1, got %v", pkt) + } + + // 5. Read S2C notifications (ServerTime, SyncPlayerInfo, SyncEnterScene) + foundEnterScene := false + for i := 0; i < 3; i++ { + notifyFrame, err := protocol.ReadFrame(conn, clientReadCipher) + if err != nil { + t.Fatalf("failed to read notify frame: %v", err) + } + nPkt := protocol.ParseRpcPacket(notifyFrame.Payload) + if nPkt.MethodID == protocol.MethodSyncEnterScene { + foundEnterScene = true + } + } + + if !foundEnterScene { + t.Fatalf("expected SyncEnterScene in notifications") + } + + // Verify session tracking + sessions := srv.GetActiveSessions() + if len(sessions) != 1 { + t.Fatalf("expected 1 active session, got %d", len(sessions)) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go new file mode 100644 index 0000000..e485875 --- /dev/null +++ b/internal/gateway/gateway.go @@ -0,0 +1,433 @@ +package gateway + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "ananta-ps/internal/config" +) + +type Gateway struct { + cfg *config.Config + publicDir string + certDir string + httpSrv *http.Server + httpsSrv *http.Server + listSrv *http.Server + cancelFunc context.CancelFunc +} + +func New(cfg *config.Config, publicDir, certDir string) *Gateway { + if publicDir == "" { + candidates := []string{ + "Ananta.Proxy/proxy/public", + "proxy/public", + "../Ananta.Proxy/proxy/public", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + publicDir = c + break + } + } + } + if certDir == "" { + candidates := []string{ + "Ananta.Proxy/proxy/certs", + "proxy/certs", + "../Ananta.Proxy/proxy/certs", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + certDir = c + break + } + } + } + + return &Gateway{ + cfg: cfg, + publicDir: publicDir, + certDir: certDir, + } +} + +func (g *Gateway) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + g.cancelFunc = cancel + + mux := http.NewServeMux() + mux.HandleFunc("/", g.handleDispatch) + + // 1. Port 5801: LoginList & Mobile Dispatch Server + listAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.LoginListPort) + g.listSrv = &http.Server{ + Addr: listAddr, + Handler: mux, + } + + go func() { + log.Printf("[GATEWAY] LoginList & Mobile Dispatch listening on http://%s (Port %d)", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort) + if err := g.listSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[GATEWAY] LoginList server error: %v", err) + } + }() + + // 2. Port 80: HTTP Gateway + httpAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpPort) + g.httpSrv = &http.Server{ + Addr: httpAddr, + Handler: mux, + } + + go func() { + log.Printf("[GATEWAY] HTTP Proxy listening on port %d", g.cfg.Network.Proxy.HttpPort) + if err := g.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[GATEWAY] HTTP Proxy error (might need Admin privileges for port 80): %v", err) + } + }() + + // 3. Port 443: HTTPS Proxy with TLS SNI + certFile := filepath.Join(g.certDir, "cert.pem") + keyFile := filepath.Join(g.certDir, "key.pem") + if _, err := os.Stat(certFile); err == nil { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err == nil { + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + return &cert, nil + }, + } + + httpsAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpsPort) + g.httpsSrv = &http.Server{ + Addr: httpsAddr, + Handler: mux, + TLSConfig: tlsConfig, + } + + go func() { + log.Printf("[GATEWAY] HTTPS SNI Proxy listening on port %d", g.cfg.Network.Proxy.HttpsPort) + ln, err := net.Listen("tcp", httpsAddr) + if err != nil { + log.Printf("[GATEWAY] HTTPS Proxy listen error (port 443): %v", err) + return + } + tlsLn := tls.NewListener(ln, tlsConfig) + if err := g.httpsSrv.Serve(tlsLn); err != nil && err != http.ErrServerClosed { + log.Printf("[GATEWAY] HTTPS Proxy error: %v", err) + } + }() + } else { + log.Printf("[GATEWAY] Warning: failed to load TLS certificate %s: %v", certFile, err) + } + } else { + log.Printf("[GATEWAY] Warning: cert.pem not found in %s; HTTPS port 443 disabled", g.certDir) + } + + _ = ctx + return nil +} + +func (g *Gateway) Stop() error { + if g.cancelFunc != nil { + g.cancelFunc() + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if g.listSrv != nil { + _ = g.listSrv.Shutdown(ctx) + } + if g.httpSrv != nil { + _ = g.httpSrv.Shutdown(ctx) + } + if g.httpsSrv != nil { + _ = g.httpsSrv.Shutdown(ctx) + } + return nil +} + +func (g *Gateway) handleDispatch(w http.ResponseWriter, r *http.Request) { + urlPath := strings.ToLower(r.URL.Path) + host := strings.ToLower(r.Host) + + // Clean up double slashes + for strings.Contains(urlPath, "//") { + urlPath = strings.ReplaceAll(urlPath, "//", "/") + } + + // 1. LoginList (Port 5801 or via Host /LoginList) + if strings.HasSuffix(urlPath, "/loginlist") || urlPath == "/loginlist" { + g.serveLoginList(w, r) + return + } + + // 2. ServerList (Contract MD5 7-column bypass) + if strings.HasSuffix(urlPath, "/serverlist.txt") || strings.HasSuffix(urlPath, "/serverlist_review.txt") { + g.serveServerList(w) + return + } + + // 3. MPay / UniSDK WebView Auth Bypass + if urlPath == "/local-mpay-login" { + g.serveMpayLogin(w) + return + } + + if urlPath == "/local-empty" { + g.serveJSON(w, map[string]interface{}{ + "code": 0, "ret": 0, "result": 0, "errno": 0, "msg": "ok", + }) + return + } + + // 4. UniSDK Check Enter & Gray Release + if strings.Contains(urlPath, "/sdk/check_enter") { + g.serveJSON(w, map[string]interface{}{ + "code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0, + }) + return + } + + if strings.Contains(urlPath, "/sdk/get_gray_release_info") { + g.serveJSON(w, map[string]interface{}{ + "code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0, + }) + return + } + + if strings.Contains(urlPath, "/sdk/uni_sauth") { + g.serveJSON(w, map[string]interface{}{ + "code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0, + "data": map[string]interface{}{ + "uid": g.cfg.Player.AccountId, + "token": g.cfg.Player.LoginToken, + }, + }) + return + } + + // 5. ConstConfig.cfg + if strings.HasSuffix(urlPath, "/constconfig.cfg") { + g.serveFile(w, filepath.Join(g.publicDir, "OuterTest1_ConstConfig.cfg"), "text/plain") + return + } + + // 6. pc_boot.config (Disable shader warmup) + if strings.HasSuffix(urlPath, "/pc_boot.config") { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("enable-cs-warmup=0\nenable-ecs-shader-warmup=0\n")) + return + } + + // 7. pc_netease_version.json (Disable triggerWarmupBelowVersion) + if strings.HasSuffix(urlPath, "/pc_netease_version.json") { + g.serveFile(w, filepath.Join(g.publicDir, "pc_netease_version.json"), "application/json") + return + } + + // 8. device_check_pc.json + if strings.HasSuffix(urlPath, "/device_check_pc.json") { + g.serveJSON(w, map[string]interface{}{}) + return + } + + // 9. Startup Patch Info + if strings.HasSuffix(urlPath, "/trunk-client_startup_patch_info.txt") { + g.serveFile(w, filepath.Join(g.publicDir, "trunk-client_startup_patch_info.txt"), "text/plain") + return + } + + // 10. FastPatch ZIP + if strings.HasPrefix(urlPath, "/fastpatch/") && strings.HasSuffix(urlPath, "/fastpatch.zip") { + g.serveFile(w, filepath.Join(g.publicDir, "fastpatch_4229938.zip"), "application/zip") + return + } + + // 11. Resource Patch Info + if strings.HasSuffix(urlPath, "/patch.info") { + // Prefer latest Patch.info + patchFile := filepath.Join(g.publicDir, "Patch_20260805061659654p0.info") + if _, err := os.Stat(patchFile); err != nil { + patchFile = filepath.Join(g.publicDir, "Patch_20260707064825384p0.info") + } + g.serveFile(w, patchFile, "application/octet-stream") + return + } + + // 12. vfs_res_version + if strings.Contains(urlPath, "vfs_res_version") { + g.serveFile(w, filepath.Join(g.publicDir, "vfs_res_version"), "application/octet-stream") + return + } + + // 13. download_header.ehd + if strings.Contains(urlPath, "download_header.ehd") { + g.serveFile(w, filepath.Join(g.publicDir, "Blocks", "download_header.ehd"), "application/octet-stream") + return + } + + // 14. stage0 patch + if strings.HasSuffix(urlPath, ".stage0.patch.bytes.standalone") { + g.serveFile(w, filepath.Join(g.publicDir, "stage0_patch.bytes.standalone"), "application/octet-stream") + return + } + + // 15. Audit & Login.bin + if urlPath == "/audit.txt" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("8000000\nlocal private server compatibility stub\nok\n\n")) + return + } + + if urlPath == "/game_notice/login.bin" { + loginBin := filepath.Join(g.publicDir, "Login.bin") + if _, err := os.Stat(loginBin); err == nil { + g.serveFile(w, loginBin, "application/octet-stream") + return + } + w.WriteHeader(http.StatusOK) + return + } + + // Generic handler for all NetEase matrix / uniSDK subdomains + if strings.Contains(host, "netease.com") || strings.Contains(host, "163.com") { + g.serveJSON(w, map[string]interface{}{ + "code": 200, + "subcode": 0, + "ret": 0, + "result": 0, + "errno": 0, + "status": 0, + "success": true, + "msg": "ok", + "message": "ok", + }) + return + } + + // Default fallback + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ananta Private Server Gateway Running")) +} + +func (g *Gateway) serveLoginList(w http.ResponseWriter, r *http.Request) { + login0 := g.cfg.Network.LoginPorts[0] + login1 := login0 + if len(g.cfg.Network.LoginPorts) > 1 { + login1 = g.cfg.Network.LoginPorts[1] + } + + host := g.cfg.Network.AdvertisedHost + body := fmt.Sprintf("%s:%d\n%s:%d\n", host, login0, host, login1) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) +} + +func (g *Gateway) serveServerList(w http.ResponseWriter) { + loginListUrl := fmt.Sprintf("http://%s:%d/LoginList", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort) + // Exactly 7 basic columns: + // *,801,QA101,Outer,http://127.0.0.1:5801/LoginList,1,14 + body := fmt.Sprintf("*,%d,QA101,Outer,%s,1,14\n", g.cfg.Client.ServerID, loginListUrl) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) +} + +func (g *Gateway) serveMpayLogin(w http.ResponseWriter) { + html := fmt.Sprintf(` + +MPay Login Bypass + + +

Authenticating Ananta...

+ +`, g.cfg.Player.AccountId, g.cfg.Player.AccountId, g.cfg.Player.AccountId, g.cfg.Client.Aid, g.cfg.Player.LoginToken, g.cfg.Player.LoginToken, g.cfg.Player.LoginToken, g.cfg.Client.ServerID) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(html)) +} + +func (g *Gateway) serveJSON(w http.ResponseWriter, v interface{}) { + data, _ := json.Marshal(v) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(data) +} + +func (g *Gateway) serveFile(w http.ResponseWriter, filePath, contentType string) { + data, err := os.ReadFile(filePath) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + w.WriteHeader(http.StatusOK) + w.Write(data) +} diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go new file mode 100644 index 0000000..d856f88 --- /dev/null +++ b/internal/gateway/gateway_test.go @@ -0,0 +1,51 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ananta-ps/internal/config" +) + +func TestGatewayDispatch(t *testing.T) { + cfg := &config.Config{ + Client: config.ClientConfig{ + ServerID: 801, + }, + Network: config.NetworkConfig{ + AdvertisedHost: "127.0.0.1", + LoginPorts: []int{5200, 5201}, + Proxy: config.ProxyConfig{ + LoginListPort: 5801, + }, + }, + } + + gw := New(cfg, "../../Ananta.Proxy/proxy/public", "../../Ananta.Proxy/proxy/certs") + + // Test LoginList + req := httptest.NewRequest("GET", "/LoginList", nil) + w := httptest.NewRecorder() + gw.handleDispatch(w, req) + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + body := w.Body.String() + if !strings.Contains(body, "127.0.0.1:5200") { + t.Fatalf("expected 127.0.0.1:5200 in LoginList, got %s", body) + } + + // Test ServerList + req = httptest.NewRequest("GET", "/OuterTest1/serverlist.txt", nil) + w = httptest.NewRecorder() + gw.handleDispatch(w, req) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("expected 200 for serverlist.txt, got %d", w.Result().StatusCode) + } + if !strings.Contains(w.Body.String(), "QA101,Outer,http://127.0.0.1:5801/LoginList,1,14") { + t.Fatalf("unexpected serverlist body: %s", w.Body.String()) + } +} diff --git a/internal/logingate/server.go b/internal/logingate/server.go new file mode 100644 index 0000000..b634158 --- /dev/null +++ b/internal/logingate/server.go @@ -0,0 +1,303 @@ +package logingate + +import ( + "context" + "fmt" + "io" + "log" + "net" + "sync" + + "ananta-ps/internal/config" + "ananta-ps/pkg/crypto/chacha8" + "ananta-ps/pkg/protocol" +) + +type Server struct { + cfg *config.Config + listeners []net.Listener + cancelFunc context.CancelFunc + wg sync.WaitGroup +} + +func New(cfg *config.Config) *Server { + return &Server{ + cfg: cfg, + } +} + +func (s *Server) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + s.cancelFunc = cancel + + for _, port := range s.cfg.Network.LoginPorts { + addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, port) + ln, err := net.Listen("tcp", addr) + if err != nil { + s.Stop() + return fmt.Errorf("failed to listen on login gate %s: %w", addr, err) + } + s.listeners = append(s.listeners, ln) + log.Printf("[LOGIN-GATE] Listening on %s:%d", s.cfg.Network.AdvertisedHost, port) + + s.wg.Add(1) + go s.acceptLoop(ctx, ln, port) + } + + return nil +} + +func (s *Server) Stop() error { + if s.cancelFunc != nil { + s.cancelFunc() + } + for _, ln := range s.listeners { + _ = ln.Close() + } + s.wg.Wait() + return nil +} + +func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, port int) { + defer s.wg.Done() + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + log.Printf("[LOGIN-GATE] Accept error on port %d: %v", port, err) + return + } + } + + go s.handleConnection(ctx, conn, port) + } +} + +type Session struct { + conn net.Conn + readCipher *chacha8.UxChaCha8 + sendCipher *chacha8.UxChaCha8 + mu sync.Mutex +} + +func (sess *Session) SendPacket(packet *protocol.RpcPacket) error { + sess.mu.Lock() + defer sess.mu.Unlock() + + var payload []byte + switch packet.Kind { + case protocol.PacketKindInvoke: + payload = protocol.BuildInvoke(packet.MethodID, packet.InvokeID, packet.Body) + case protocol.PacketKindReturn: + payload = protocol.BuildReturn(packet.MethodID, packet.InvokeID, packet.Error, packet.Body) + case protocol.PacketKindNotify: + payload = protocol.BuildNotify(packet.MethodID, packet.Body) + } + + return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher) +} + +func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindReturn, + InvokeID: invokeID, + MethodID: methodID, + Error: 0, + Body: nil, + }) +} + +func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindReturn, + InvokeID: invokeID, + MethodID: methodID, + Error: errCode, + Body: nil, + }) +} + +func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindReturn, + InvokeID: invokeID, + MethodID: methodID, + Error: 0, + Body: body, + }) +} + +func (sess *Session) NotifyData(methodID uint32, body []byte) error { + return sess.SendPacket(&protocol.RpcPacket{ + Kind: protocol.PacketKindNotify, + MethodID: methodID, + Body: body, + }) +} + +func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int) { + defer conn.Close() + + log.Printf("[LOGIN-GATE] Client connected from %s on port %d", conn.RemoteAddr(), port) + + readCipher, writeCipher, err := protocol.HandleHandshake(conn) + if err != nil { + log.Printf("[LOGIN-GATE] Handshake error from %s: %v", conn.RemoteAddr(), err) + return + } + + sess := &Session{ + conn: conn, + readCipher: readCipher, + sendCipher: writeCipher, + } + + for { + select { + case <-ctx.Done(): + return + default: + } + + frame, err := protocol.ReadFrame(conn, sess.readCipher) + if err != nil { + if err != io.EOF { + log.Printf("[LOGIN-GATE] Connection %s read error: %v", conn.RemoteAddr(), err) + } + return + } + + // Frame mode 0x03 is heartbeat + if frame.Mode == 0x03 { + // Echo heartbeat back + _ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher) + continue + } + + packet := protocol.ParseRpcPacket(frame.Payload) + if err := s.dispatch(sess, &packet); err != nil { + log.Printf("[LOGIN-GATE] Dispatch error for method %d: %v", packet.MethodID, err) + } + } +} + +func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error { + switch pkt.MethodID { + case protocol.MethodCheckVersion, protocol.MethodHasOnlinePlayer: + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + + case protocol.MethodAskNewHotFixPatchLogin: + return sess.ReturnEmpty(pkt.InvokeID, pkt.MethodID, int32(s.cfg.Client.NoMoreHotfixPatchError)) + + case protocol.MethodRequestPatchesCheckDataFromLogin: + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodePatchCheck()) + + case protocol.MethodRequestPatchesFromLogin: + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodeEmptyList()) + + case protocol.MethodAskUniSdkShareToken_Login, protocol.MethodAskUniSdkShareToken_Gate: + w := protocol.NewUxWriter() + w.UxString(s.cfg.Player.ShareToken) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()) + + case protocol.MethodRequestFpPassToken: + w := protocol.NewUxWriter() + w.UxString(s.cfg.Player.FpPassToken) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()) + + case protocol.MethodCheckAccount, protocol.MethodCheckAccountPassBy, protocol.MethodCheckAccountOpenId: + body := protocol.EncodeCheckAccount( + s.cfg.Player.AccountId, + s.cfg.Player.UserName, + s.cfg.Player.LoginToken, + s.cfg.Client.Aid, + s.cfg.Client.ServerID, + s.cfg.Player.Pid, + ) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body) + + case protocol.MethodTryLogin: + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + // Send SyncRoleList (35167428) + w := protocol.NewUxWriter() + w.U64(s.cfg.Player.Pid) + return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes()) + + case protocol.MethodRequestCreateRoleEx: + w := protocol.NewUxWriter() + w.U64(s.cfg.Player.Pid) + if err := sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()); err != nil { + return err + } + return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes()) + + case protocol.MethodRequestEnterGame, protocol.MethodDebugRequestEnterGame: + body := protocol.EncodeEnterGame( + s.cfg.Client.Aid, + s.cfg.Player.Pid, + s.cfg.Network.AdvertisedHost, + s.cfg.Network.LoginPorts[0], + s.cfg.Player.LoginToken, + s.cfg.Client.ServerID, + s.cfg.Player.AccountId, + ) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body) + + case protocol.MethodAskCloseConnectionToGate: + w := protocol.NewUxWriter() + w.U32(0) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()) + + case protocol.MethodGetServerTime: + if pkt.Kind == protocol.PacketKindInvoke { + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + } + return sess.NotifyData(protocol.MethodSendServerTime, protocol.EncodeServerTime(0)) + + case protocol.MethodQuerySkey: + w := protocol.NewUxWriter() + w.UxString(s.cfg.Player.Skey) + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()) + + case protocol.MethodGateLogin: + log.Printf("[GATE] Authenticated Gate session registered for IMasterToClient S2C") + if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil { + return err + } + // 1. SendServerTime + _ = sess.NotifyData(protocol.MethodSendServerTime, protocol.EncodeServerTime(0)) + + // 2. SyncGameSwitchToClient_3 (53940944) - Crucial for vehicles, NPCs, gadgets + gameSwitches := protocol.BuildGameSwitchPayload() + log.Printf("[GATE] SyncGameSwitchToClient_3 entries=%d body=%dB", len(protocol.GameSwitchCatalog), len(gameSwitches)) + _ = sess.NotifyData(protocol.MethodSyncGameSwitchToClient_3, gameSwitches) + + // 3. SyncPlayerGameServerInfo (52737039) - Handoff to Game Server + gameInfo := protocol.EncodeGameServerInfo(s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort, s.cfg.Player.GameToken) + return sess.NotifyData(protocol.MethodSyncPlayerGameServerInfo, gameInfo) + + case protocol.MethodGetFriendApplicationListToMe, + protocol.MethodGetAllChatGroupLatestMessage, + protocol.MethodGetSimplePlayerInfoByPidList: + return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodeEmptyList()) + + case protocol.MethodReportNetworkQuality, protocol.MethodDavinciCode: + // S2C Notify ignore + return nil + + default: + if pkt.Kind == protocol.PacketKindInvoke { + log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, returning empty OK", pkt.MethodID) + return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID) + } + } + + return nil +} diff --git a/internal/logingate/server_test.go b/internal/logingate/server_test.go new file mode 100644 index 0000000..4a1b258 --- /dev/null +++ b/internal/logingate/server_test.go @@ -0,0 +1,124 @@ +package logingate + +import ( + "crypto/aes" + "crypto/cipher" + "net" + "testing" + "time" + + "ananta-ps/internal/config" + "ananta-ps/pkg/crypto/chacha8" + "ananta-ps/pkg/protocol" +) + +func TestLoginGateServer(t *testing.T) { + cfg := &config.Config{ + Client: config.ClientConfig{ + ServerID: 801, + Aid: 5944, + }, + Network: config.NetworkConfig{ + BindHost: "127.0.0.1", + AdvertisedHost: "127.0.0.1", + LoginPorts: []int{15200}, + GamePort: 15202, + }, + Player: config.PlayerConfig{ + Pid: 100, + AccountId: "test_acc", + UserName: "test_user", + }, + } + + srv := New(cfg) + if err := srv.Start(); err != nil { + t.Fatalf("failed to start login gate: %v", err) + } + defer srv.Stop() + + // Connect client + conn, err := net.DialTimeout("tcp", "127.0.0.1:15200", 2*time.Second) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + defer conn.Close() + + // 1. Send client handshake (mode 0x01 plaintext) + clientHandshake := make([]byte, 260) + clientHandshake[0] = 0xAA + clientHandshake[1] = 0xBB + clientHandshake[2] = 0xCC + clientHandshake[3] = 0xDD + aesKey := []byte("0123456789abcdef") // 16 bytes + copy(clientHandshake[4:20], aesKey) + + if err := protocol.WriteFrame(conn, 0x01, clientHandshake, nil); err != nil { + t.Fatalf("failed to write client handshake: %v", err) + } + + // 2. Read server handshake reply + serverReplyFrame, err := protocol.ReadFrame(conn, nil) + if err != nil { + t.Fatalf("failed to read server handshake reply: %v", err) + } + if len(serverReplyFrame.Payload) < 112 { + t.Fatalf("server reply too short: %d", len(serverReplyFrame.Payload)) + } + + aesNonce := serverReplyFrame.Payload[8:20] + encryptedHead := serverReplyFrame.Payload[20:96] + tag := serverReplyFrame.Payload[96:112] + + cipherBlock, err := aes.NewCipher(aesKey) + if err != nil { + t.Fatalf("failed to create aes cipher: %v", err) + } + aesGcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + t.Fatalf("failed to create aes gcm: %v", err) + } + + fullCipher := append(encryptedHead, tag...) + chaChaHead, err := aesGcm.Open(nil, aesNonce, fullCipher, nil) + if err != nil { + t.Fatalf("failed to decrypt chacha head: %v", err) + } + + clientWriteKey := chaChaHead[0:32] + clientReadKey := chaChaHead[32:64] + streamNonce := chaChaHead[64:76] + + clientSendCipher, err := chacha8.New(clientWriteKey, streamNonce) + if err != nil { + t.Fatalf("failed to create client send cipher: %v", err) + } + clientReadCipher, err := chacha8.New(clientReadKey, streamNonce) + if err != nil { + t.Fatalf("failed to create client read cipher: %v", err) + } + + // 3. Send CheckVersion Invoke + invokePayload := protocol.BuildInvoke(protocol.MethodCheckVersion, 1, nil) + if err := protocol.WriteFrame(conn, 0x09, invokePayload, clientSendCipher); err != nil { + t.Fatalf("failed to write invoke frame: %v", err) + } + + // 4. Read Return frame + respFrame, err := protocol.ReadFrame(conn, clientReadCipher) + if err != nil { + t.Fatalf("failed to read response frame: %v", err) + } + + pkt := protocol.ParseRpcPacket(respFrame.Payload) + + if pkt.Kind != protocol.PacketKindReturn { + t.Fatalf("expected Return packet, got %v", pkt.Kind) + } + if pkt.InvokeID != 1 { + t.Fatalf("expected InvokeID 1, got %d", pkt.InvokeID) + } + if pkt.Error != 0 { + t.Fatalf("expected Error 0, got %d", pkt.Error) + } +} diff --git a/internal/web/admin.go b/internal/web/admin.go new file mode 100644 index 0000000..d2a80eb --- /dev/null +++ b/internal/web/admin.go @@ -0,0 +1,308 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "sync" + "time" + + "ananta-ps/internal/config" + "ananta-ps/internal/gameserver" +) + +type AdminServer struct { + cfg *config.Config + gameServer *gameserver.Server + httpSrv *http.Server + cancelFunc context.CancelFunc + mu sync.RWMutex +} + +func NewAdmin(cfg *config.Config, gameServer *gameserver.Server) *AdminServer { + return &AdminServer{ + cfg: cfg, + gameServer: gameServer, + } +} + +func (a *AdminServer) Start() error { + if !a.cfg.Admin.Enabled { + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + a.cancelFunc = cancel + + mux := http.NewServeMux() + mux.HandleFunc("/", a.handleDashboard) + mux.HandleFunc("/api/teleport", a.handleTeleport) + mux.HandleFunc("/api/sessions", a.handleSessions) + + addr := fmt.Sprintf("%s:%d", a.cfg.Network.BindHost, a.cfg.Admin.Port) + a.httpSrv = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + log.Printf("[ADMIN-GM] Admin Web Panel running at http://%s:%d/", a.cfg.Network.AdvertisedHost, a.cfg.Admin.Port) + if err := a.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[ADMIN-GM] Web server error: %v", err) + } + }() + + _ = ctx + return nil +} + +func (a *AdminServer) Stop() error { + if a.cancelFunc != nil { + a.cancelFunc() + } + if a.httpSrv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.httpSrv.Shutdown(ctx) + } + return nil +} + +func (a *AdminServer) handleDashboard(w http.ResponseWriter, r *http.Request) { + tmpl := ` + + + + +Ananta GM Admin Dashboard + + + +
+
+
+

Ananta GM Admin Dashboard (Go Native)

+

Project Mugen CBT 4229938 Sandbox Controller

+
+
+ ONLINE +
+
+ +
+ +
+

Server Status

+
+
Active Sessions
+
{{.ActiveSessions}}
+
+
+
Player UID / Account
+
{{.PlayerPID}} ({{.AccountId}})
+
+
+
Active Character Template
+
{{.InitialSpirit}}
+
+
+ + +
+

Instant Teleportation

+
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+
+ + +
+

Free-Roam & Abilities

+
+ + +
+ +
+ +
+
+
+
+ + + +` + + sessions := a.gameServer.GetActiveSessions() + data := map[string]interface{}{ + "ActiveSessions": len(sessions), + "PlayerPID": a.cfg.Player.Pid, + "AccountId": a.cfg.Player.AccountId, + "InitialSpirit": a.cfg.Player.InitialSpiritTemplateId, + } + + t := template.Must(template.New("dashboard").Parse(tmpl)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = t.Execute(w, data) +} + +func (a *AdminServer) handleTeleport(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` + Facing float32 `json:"facing"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "msg": err.Error()}) + return + } + + sessions := a.gameServer.GetActiveSessions() + if len(sessions) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "msg": "No active client connected in game"}) + return + } + + for _, sess := range sessions { + _ = sess.Teleport(req.X, req.Y, req.Z, req.Facing) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "count": len(sessions)}) +} + +func (a *AdminServer) handleSessions(w http.ResponseWriter, r *http.Request) { + sessions := a.gameServer.GetActiveSessions() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "count": len(sessions), + "host": a.cfg.Network.AdvertisedHost, + "port": a.cfg.Network.GamePort, + }) +} diff --git a/internal/web/debug.go b/internal/web/debug.go new file mode 100644 index 0000000..1d9cb4a --- /dev/null +++ b/internal/web/debug.go @@ -0,0 +1,106 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "ananta-ps/internal/config" + "ananta-ps/internal/gameserver" +) + +type DebugServer struct { + cfg *config.Config + gameServer *gameserver.Server + httpSrv *http.Server + cancelFunc context.CancelFunc +} + +func NewDebug(cfg *config.Config, gameServer *gameserver.Server) *DebugServer { + return &DebugServer{ + cfg: cfg, + gameServer: gameServer, + } +} + +func (d *DebugServer) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + d.cancelFunc = cancel + + mux := http.NewServeMux() + mux.HandleFunc("/", d.handleIndex) + mux.HandleFunc("/api/status", d.handleStatus) + + addr := fmt.Sprintf("%s:%d", d.cfg.Network.BindHost, 5809) + d.httpSrv = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + log.Printf("[DEBUG-API] Debug WebUI panel at http://%s:5809/", d.cfg.Network.AdvertisedHost) + if err := d.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[DEBUG-API] Debug server error: %v", err) + } + }() + + _ = ctx + return nil +} + +func (d *DebugServer) Stop() error { + if d.cancelFunc != nil { + d.cancelFunc() + } + if d.httpSrv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = d.httpSrv.Shutdown(ctx) + } + return nil +} + +func (d *DebugServer) handleIndex(w http.ResponseWriter, r *http.Request) { + html := ` + +Ananta Debug WebUI + +

Ananta Private Server Debug Monitor (Port 5809)

+
Connecting to runtime...
+

+  
+
+`
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Write([]byte(html))
+}
+
+func (d *DebugServer) handleStatus(w http.ResponseWriter, r *http.Request) {
+	sessions := d.gameServer.GetActiveSessions()
+	data := map[string]interface{}{
+		"online":         true,
+		"version":        d.cfg.Client.Version,
+		"activeSessions": len(sessions),
+		"advertisedHost": d.cfg.Network.AdvertisedHost,
+		"gamePort":       d.cfg.Network.GamePort,
+		"loginPorts":     d.cfg.Network.LoginPorts,
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(data)
+}
diff --git a/pkg/crypto/chacha8/chacha8.go b/pkg/crypto/chacha8/chacha8.go
new file mode 100644
index 0000000..07b578b
--- /dev/null
+++ b/pkg/crypto/chacha8/chacha8.go
@@ -0,0 +1,114 @@
+package chacha8
+
+import (
+	"encoding/binary"
+	"errors"
+	"math/bits"
+)
+
+// NetEase 4229938 UXChaCha8 stream cipher.
+// Uses custom Sigma constants differing from standard ChaCha.
+const (
+	Sigma0 uint32 = 0x60707865
+	Sigma1 uint32 = 0x3321646e
+	Sigma2 uint32 = 0x79622d32
+	Sigma3 uint32 = 0x6b206e74
+)
+
+type UxChaCha8 struct {
+	state        [16]uint32
+	keystream    [64]byte
+	keystreamPos int
+}
+
+func New(key []byte, nonce []byte) (*UxChaCha8, error) {
+	if len(key) != 32 {
+		return nil, errors.New("UXChaCha8 key must be 32 bytes")
+	}
+	if len(nonce) != 12 {
+		return nil, errors.New("UXChaCha8 nonce must be 12 bytes")
+	}
+
+	c := &UxChaCha8{
+		keystreamPos: 64,
+	}
+
+	c.state[0] = Sigma0
+	c.state[1] = Sigma1
+	c.state[2] = Sigma2
+	c.state[3] = Sigma3
+
+	for i := 0; i < 8; i++ {
+		c.state[4+i] = binary.LittleEndian.Uint32(key[i*4 : (i+1)*4])
+	}
+
+	c.state[12] = 0
+	c.state[13] = binary.LittleEndian.Uint32(nonce[0:4])
+	c.state[14] = binary.LittleEndian.Uint32(nonce[4:8])
+	c.state[15] = binary.LittleEndian.Uint32(nonce[8:12])
+
+	return c, nil
+}
+
+func (c *UxChaCha8) XorInPlace(data []byte) {
+	offset := 0
+	for offset < len(data) {
+		if c.keystreamPos == 64 {
+			c.generateBlock()
+			c.keystreamPos = 0
+		}
+
+		take := 64 - c.keystreamPos
+		if len(data)-offset < take {
+			take = len(data) - offset
+		}
+
+		for i := 0; i < take; i++ {
+			data[offset+i] ^= c.keystream[c.keystreamPos+i]
+		}
+
+		offset += take
+		c.keystreamPos += take
+	}
+}
+
+func (c *UxChaCha8) generateBlock() {
+	var x [16]uint32
+	copy(x[:], c.state[:])
+
+	for i := 0; i < 4; i++ {
+		quarterRound(&x[0], &x[4], &x[8], &x[12])
+		quarterRound(&x[1], &x[5], &x[9], &x[13])
+		quarterRound(&x[2], &x[6], &x[10], &x[14])
+		quarterRound(&x[3], &x[7], &x[11], &x[15])
+
+		quarterRound(&x[0], &x[5], &x[10], &x[15])
+		quarterRound(&x[1], &x[6], &x[11], &x[12])
+		quarterRound(&x[2], &x[7], &x[8], &x[13])
+		quarterRound(&x[3], &x[4], &x[9], &x[14])
+	}
+
+	for i := 0; i < 16; i++ {
+		binary.LittleEndian.PutUint32(c.keystream[i*4:(i+1)*4], x[i]+c.state[i])
+	}
+
+	c.state[12]++
+}
+
+func quarterRound(a, b, c, d *uint32) {
+	*a += *b
+	*d ^= *a
+	*d = bits.RotateLeft32(*d, 16)
+
+	*c += *d
+	*b ^= *c
+	*b = bits.RotateLeft32(*b, 12)
+
+	*a += *b
+	*d ^= *a
+	*d = bits.RotateLeft32(*d, 8)
+
+	*c += *d
+	*b ^= *c
+	*b = bits.RotateLeft32(*b, 7)
+}
diff --git a/pkg/crypto/chacha8/chacha8_test.go b/pkg/crypto/chacha8/chacha8_test.go
new file mode 100644
index 0000000..70d9232
--- /dev/null
+++ b/pkg/crypto/chacha8/chacha8_test.go
@@ -0,0 +1,41 @@
+package chacha8
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestChaCha8Roundtrip(t *testing.T) {
+	key := make([]byte, 32)
+	for i := range key {
+		key[i] = byte(i + 1)
+	}
+	nonce := make([]byte, 12)
+	for i := range nonce {
+		nonce[i] = byte(i + 0x10)
+	}
+
+	c1, err := New(key, nonce)
+	if err != nil {
+		t.Fatalf("New failed: %v", err)
+	}
+
+	c2, err := New(key, nonce)
+	if err != nil {
+		t.Fatalf("New failed: %v", err)
+	}
+
+	original := []byte("Hello, Ananta NetEase UXChaCha8 stream cipher verification!")
+	data := make([]byte, len(original))
+	copy(data, original)
+
+	c1.XorInPlace(data)
+	if bytes.Equal(data, original) {
+		t.Errorf("Ciphertext equals plaintext")
+	}
+
+	c2.XorInPlace(data)
+	if !bytes.Equal(data, original) {
+		t.Errorf("Decrypted data != original: got %q, want %q", data, original)
+	}
+}
diff --git a/pkg/protocol/codecs.go b/pkg/protocol/codecs.go
new file mode 100644
index 0000000..b5ab9c6
--- /dev/null
+++ b/pkg/protocol/codecs.go
@@ -0,0 +1,260 @@
+package protocol
+
+import (
+	"encoding/binary"
+	"encoding/json"
+	"fmt"
+	"os"
+	"time"
+)
+
+// EncodeServerTime encodes the ServerTime payload.
+// Format (Inline): [F64 clientUnixTime] [F64 serverUnixTime]
+func EncodeServerTime(clientUnixTime float64) []byte {
+	now := float64(time.Now().UnixNano()) / 1e9
+	if clientUnixTime <= 0 {
+		clientUnixTime = now
+	}
+	w := NewUxWriter()
+	w.F64(clientUnixTime)
+	w.F64(now)
+	return w.Bytes()
+}
+
+// EncodePatchCheck encodes PatchCheckResult.
+// Format (Inline): [I32 patchVersion (0)] [0xFF marker] [Int7 count (0)]
+func EncodePatchCheck() []byte {
+	w := NewUxWriter()
+	w.I32(0)     // patchVersion
+	w.U8(0xFF)   // list marker
+	w.Int7(0)    // empty list
+	return w.Bytes()
+}
+
+// EncodeEmptyList encodes an empty List.
+func EncodeEmptyList() []byte {
+	w := NewUxWriter()
+	w.U8(0xFF)
+	w.Int7(0)
+	return w.Bytes()
+}
+
+// EncodeCheckAccount encodes CheckAccountResult.
+func EncodeCheckAccount(accountId, userName, token string, aid, serverId int, pid uint64) []byte {
+	loginData := map[string]interface{}{
+		"code":                   200,
+		"subcode":                0,
+		"msg":                    "ok",
+		"uid":                    accountId,
+		"sdkuid":                 accountId,
+		"user_id":                accountId,
+		"aid":                    aid,
+		"pid":                    pid,
+		"player_id":              fmt.Sprintf("%d", pid),
+		"role_id":                fmt.Sprintf("%d", pid),
+		"role_name":              "辰宿",
+		"server_id":              fmt.Sprintf("%d", serverId),
+		"host_id":                serverId,
+		"username":               userName,
+		"account":                accountId,
+		"platform":               "pc",
+		"token":                  token,
+		"access_token":           token,
+		"ext_access_token":       token,
+		"sessionid":              token,
+		"login_ticket":           token,
+		"realname_status":        1,
+		"realname_verify_status": 1,
+		"mobile_bind_status":     1,
+	}
+	loginJSON, _ := json.Marshal(loginData)
+
+	w := NewUxWriter()
+	w.U8(0xFF) // Complex object marker
+	w.UxString(string(loginJSON))
+	w.UxString(token)
+	w.UxString(userName)
+	w.I32(int32(aid))
+	w.Bool(false) // NeedRealNameTip
+	w.Bool(false) // NeedRoleEnter
+	w.Bool(true)  // RealNameVerified
+	w.I32(int32(serverId))
+	w.I32(200) // code
+	w.I32(0)   // subcode
+	w.UxString("ok")
+	return w.Bytes()
+}
+
+// EncodeEnterGame encodes EnterGameResult.
+func EncodeEnterGame(aid int, pid uint64, host string, port int, token string, serverId int, accountId string) []byte {
+	w := NewUxWriter()
+	w.I32(int32(aid))
+	w.U64(pid)
+
+	// GateServerInfo
+	w.U8(0xFF)
+	w.I32(int32(aid))
+	w.U64(pid)
+	w.UxString(host)
+	w.I32(int32(port))
+	w.UxString(token)
+	w.I32(int32(serverId))
+	w.UxString(accountId)
+
+	return w.Bytes()
+}
+
+// EncodeGameServerInfo encodes GameServerInfo.
+func EncodeGameServerInfo(host string, port int, gameToken string) []byte {
+	w := NewUxWriter()
+	w.U8(0xFF)
+	w.UxString(host)
+	w.I32(int32(port))
+	w.UxString(gameToken)
+	return w.Bytes()
+}
+
+// EncodeEnterScene encodes EnterSceneInfo.
+func EncodeEnterScene(pid uint64, raidId uint32, instanceId uint64, universeId uint32, unitId uint64, templateId uint32, posX, posY, posZ, facing float32) []byte {
+	w := NewUxWriter()
+	w.U8(0xFF) // [UxContract]
+	w.U64(pid)
+	w.U32(raidId)
+	w.U64(instanceId)
+	// UXVector3 Position (Struct)
+	w.F32(posX)
+	w.F32(posY)
+	w.F32(posZ)
+	w.F32(facing)
+	// SceneSpoonNames (empty list)
+	w.U8(0xFF).Int7(0)
+	// SceneSpoonMd5s (empty list)
+	w.U8(0xFF).Int7(0)
+	// Spirits List (count 1, Struct)
+	w.U8(0xFF).Int7(1)
+	// SpiritInitData (Struct: Id, TemplateId, IsActive, WeaponTemplateId, WeaponSkinId)
+	w.U64(unitId).U32(templateId).Bool(true).U32(0).U32(0)
+	// GridInfo (Struct: MinX, MinZ, MaxX, MaxZ)
+	w.I32(0).I32(0).I32(300000).I32(300000)
+	w.U32(0)      // MatchGameId
+	w.U32(0)      // SwitchShowId
+	w.Bool(false) // IsSwitchSpiritShow
+	w.U32(universeId)
+	w.U32(0) // SectorControlId
+	// SectorControlIds (empty list)
+	w.U8(0xFF).Int7(0)
+	w.U32(0) // DynamicLayerId
+	// LoadingType (Complex 0xFF: Type, Members list, Ripple)
+	w.U8(0xFF).U32(0).U8(0xFF).Int7(0).Bool(false)
+	// LinkSimpleInfo (Complex 0xFF: Id, Mode, DeviceLevel, Tag, PublicEventId, LastPublicEventTime, HostPid)
+	w.U8(0xFF).U32(0).U32(0).U32(1).U32(0).U32(0).U64(0).U64(0)
+	// DataLayerIndices (empty list)
+	w.U8(0xFF).Int7(0)
+	w.Bool(false) // Seamless
+	// CollectionRoomLoadData (null complex)
+	w.U8(0x00)
+	// HouseLoadData (null complex)
+	w.U8(0x00)
+	// TombIslandIds (empty list)
+	w.U8(0xFF).Int7(0)
+	return w.Bytes()
+}
+
+// EncodeLogicAgentEnter encodes SyncLogicAgentEnter.
+func EncodeLogicAgentEnter(unitId uint64) []byte {
+	w := NewUxWriter()
+	w.U64(unitId)
+	return w.Bytes()
+}
+
+// EncodeLogicAgentLeave encodes SyncLogicAgentLeave.
+func EncodeLogicAgentLeave(unitId uint64) []byte {
+	w := NewUxWriter()
+	w.U64(unitId)
+	return w.Bytes()
+}
+
+// EncodeManagedLogicAgent encodes SyncManagedLogicAgent.
+func EncodeManagedLogicAgent(unitId, pid uint64, moveId byte) []byte {
+	w := NewUxWriter()
+	w.U64(unitId).U64(pid).U8(moveId)
+	return w.Bytes()
+}
+
+// EncodePlayerCurrentSpirit encodes SyncPlayerCurrentSpirit.
+func EncodePlayerCurrentSpirit(pid uint64, templateId uint32, unitId uint64, isAgentSwitch bool) []byte {
+	w := NewUxWriter()
+	w.U64(pid).U32(templateId).U64(unitId).Bool(isAgentSwitch)
+	return w.Bytes()
+}
+
+// EncodeSceneLoadCompleted encodes SyncSceneLoadCompleted.
+func EncodeSceneLoadCompleted(sceneId uint64) []byte {
+	w := NewUxWriter()
+	w.U64(sceneId)
+	return w.Bytes()
+}
+
+// EncodeRaidBattleUnitSpirit encodes the character projection for world entry and spirit switching.
+func EncodeRaidBattleUnitSpirit(unitId uint64, templateId uint32, posX, posY, posZ, facing float32, pid uint64) []byte {
+	w := NewUxWriter()
+	w.U8(0xFF) // RaidBattleUnitSpirit Complex marker
+	w.U8(0xFF) // SpiritWearFashionsInfo Complex marker
+
+	// WearFashionColoringInfoDict empty dictionary (0xFF marker, count 0)
+	w.U8(0xFF).I32(0)
+
+	// WearFashionInfoList with 5 default fashion items
+	w.U8(0xFF).I32(5)
+	fashionIds := []uint32{11100051, 11100052, 11100053, 11189102, 11189103}
+	for _, fid := range fashionIds {
+		w.U8(0xFF).U32(fid)
+	}
+
+	// WearFashionEditInfoList empty list
+	w.U8(0xFF).I32(0)
+
+	// HiddenParts = 0, EditedHiddenParts = 0, Begging = false, IsBot = false
+	w.U8(0).U8(0).Bool(false).Bool(false)
+
+	// Id & TemplateId
+	w.U64(unitId).U32(templateId)
+
+	// Position & Facing
+	w.F32(posX).F32(posY).F32(posZ).F32(facing)
+
+	// OwnerId & ManagedPid
+	w.U64(pid).U64(0)
+
+	// MoveId & NavTags
+	w.U8(0).U32(0)
+
+	// GroundData (MoveActionGroundData Complex 0xFF)
+	w.U8(0xFF).U32(0).U64(0).U32(0)
+	// LocalPos (UXVector3)
+	w.F32(0).F32(0).F32(0)
+
+	return w.Bytes()
+}
+
+// EncodePlayerInfo loads the baseline PlayerClientInfo and patches dynamic fields.
+func EncodePlayerInfo(templatePath string, aid int, pid uint64, accountId, name string) ([]byte, error) {
+	data, err := os.ReadFile(templatePath)
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]byte, len(data))
+	copy(res, data)
+
+	// Patch Aid at offset 4 (4 bytes)
+	if len(res) > 8 {
+		binary.LittleEndian.PutUint32(res[4:8], uint32(aid))
+	}
+	// Patch Pid at offset 8 (8 bytes)
+	if len(res) > 16 {
+		binary.LittleEndian.PutUint64(res[8:16], pid)
+	}
+
+	return res, nil
+}
diff --git a/pkg/protocol/frame.go b/pkg/protocol/frame.go
new file mode 100644
index 0000000..bef0c56
--- /dev/null
+++ b/pkg/protocol/frame.go
@@ -0,0 +1,132 @@
+package protocol
+
+import (
+	"encoding/binary"
+	"errors"
+	"io"
+
+	"ananta-ps/pkg/crypto/chacha8"
+)
+
+type Frame struct {
+	Mode    byte
+	Payload []byte
+}
+
+func ReadFrame(r io.Reader, cryptor *chacha8.UxChaCha8) (*Frame, error) {
+	head := make([]byte, 5)
+	if _, err := io.ReadFull(r, head); err != nil {
+		return nil, err
+	}
+
+	if cryptor != nil {
+		cryptor.XorInPlace(head)
+	}
+
+	payloadLength := int(binary.LittleEndian.Uint32(head[0:4]))
+	if payloadLength < 0 || payloadLength > 16*1024*1024 {
+		return nil, errors.New("invalid frame payload length after decrypt")
+	}
+
+	payload := make([]byte, payloadLength)
+	if payloadLength > 0 {
+		if _, err := io.ReadFull(r, payload); err != nil {
+			return nil, err
+		}
+		if cryptor != nil {
+			cryptor.XorInPlace(payload)
+		}
+	}
+
+	return &Frame{
+		Mode:    head[4],
+		Payload: payload,
+	}, nil
+}
+
+func WriteFrame(w io.Writer, mode byte, payload []byte, cryptor *chacha8.UxChaCha8) error {
+	buf := make([]byte, 5+len(payload))
+	binary.LittleEndian.PutUint32(buf[0:4], uint32(len(payload)))
+	buf[4] = mode
+	copy(buf[5:], payload)
+
+	if cryptor != nil {
+		cryptor.XorInPlace(buf)
+	}
+
+	_, err := w.Write(buf)
+	return err
+}
+
+func ParseRpcPacket(payload []byte) RpcPacket {
+	if len(payload) < 5 {
+		return RpcPacket{Kind: PacketKindUnknown, Body: payload}
+	}
+
+	tag := payload[0]
+	methodID := binary.LittleEndian.Uint32(payload[1:5])
+
+	switch tag {
+	case 0x03: // Notify
+		return RpcPacket{
+			Kind:     PacketKindNotify,
+			MethodID: methodID,
+			Body:     payload[5:],
+		}
+	case 0x01: // Invoke
+		if len(payload) >= 9 {
+			invokeID := int32(binary.LittleEndian.Uint32(payload[5:9]))
+			return RpcPacket{
+				Kind:     PacketKindInvoke,
+				MethodID: methodID,
+				InvokeID: invokeID,
+				Body:     payload[9:],
+			}
+		}
+	case 0x02: // Return
+		if len(payload) >= 13 {
+			invokeID := int32(binary.LittleEndian.Uint32(payload[5:9]))
+			errCode := int32(binary.LittleEndian.Uint32(payload[9:13]))
+			return RpcPacket{
+				Kind:     PacketKindReturn,
+				MethodID: methodID,
+				InvokeID: invokeID,
+				Error:    errCode,
+				Body:     payload[13:],
+			}
+		}
+	}
+
+	return RpcPacket{
+		Kind:     PacketKindUnknown,
+		MethodID: methodID,
+		Body:     payload,
+	}
+}
+
+func BuildNotify(methodID uint32, body []byte) []byte {
+	buf := make([]byte, 5+len(body))
+	buf[0] = 0x03
+	binary.LittleEndian.PutUint32(buf[1:5], methodID)
+	copy(buf[5:], body)
+	return buf
+}
+
+func BuildInvoke(methodID uint32, invokeID int32, body []byte) []byte {
+	buf := make([]byte, 9+len(body))
+	buf[0] = 0x01
+	binary.LittleEndian.PutUint32(buf[1:5], methodID)
+	binary.LittleEndian.PutUint32(buf[5:9], uint32(invokeID))
+	copy(buf[9:], body)
+	return buf
+}
+
+func BuildReturn(methodID uint32, invokeID int32, errCode int32, body []byte) []byte {
+	buf := make([]byte, 13+len(body))
+	buf[0] = 0x02
+	binary.LittleEndian.PutUint32(buf[1:5], methodID)
+	binary.LittleEndian.PutUint32(buf[5:9], uint32(invokeID))
+	binary.LittleEndian.PutUint32(buf[9:13], uint32(errCode))
+	copy(buf[13:], body)
+	return buf
+}
diff --git a/pkg/protocol/gameswitch.go b/pkg/protocol/gameswitch.go
new file mode 100644
index 0000000..c9ea3bf
--- /dev/null
+++ b/pkg/protocol/gameswitch.go
@@ -0,0 +1,229 @@
+package protocol
+
+// GameSwitchEntry represents a single switch configuration entry.
+type GameSwitchEntry struct {
+	Name  string
+	Value bool
+}
+
+// GameSwitchCatalog contains all 203 UX.GameSwitch flags.
+var GameSwitchCatalog = []GameSwitchEntry{
+	{"EnableRaidTimeFix", true},
+	{"EnableClientZoneClosure", true},
+	{"UseEditorSectorTileInfoJsonPath1", true},
+	{"EnableNavMultiverseChain", true},
+	{"EnableRecycleItem", true},
+	{"EnableComposeItem", true},
+	{"DisableEditXingZheDangAn", false},
+	{"EnableUpdateSignature", true},
+	{"EnablePartialAntiAddiction", false},
+	{"EnableAntiAddiction", false},
+	{"EnableRealNameBeforeCreateRole", false},
+	{"EnableExchangeGold", true},
+	{"EnableNpcShop", true},
+	{"EnableFashionStore", true},
+	{"Enable4SStore", true},
+	{"EnableCreateRole", true},
+	{"EnableSeasonalBattlePass", true},
+	{"EnableGamePlayBattlePass", true},
+	{"EnableGachaSystem", true},
+	{"EnableRadiantChest", true},
+	{"EnableCloset", true},
+	{"EnableGachaMilestone", true},
+	{"EnableHeartbeatTimeoutCheckDebug", false},
+	{"EnableHeartbeatTimeoutCheckRelease", true},
+	{"EnableLoginReconnect", true},
+	{"EnableLoginOnlineAndDetachMatchLink", true},
+	{"AutoSwitchTextModeWithAllPrefix", true},
+	{"ChatLimitRegion", true},
+	{"EnableMahjong", true},
+	{"EnableMahjongFriend", true},
+	{"EnableMahjongWithNpc", true},
+	{"EnableMahjongPve", true},
+	{"EnableChangePersonalZoneHeadInfo", true},
+	{"EnableSpiritAddExp", true},
+	{"EnableSpiritAbilityAddExp", true},
+	{"EnableSpiritDomainLevelUp", true},
+	{"EnableForeignIpSns", true},
+	{"EnableSharePhoto_Netease", true},
+	{"EnableSharePhoto_Oppo", true},
+	{"EnableSharePhoto_Huawei", true},
+	{"EnableSharePhoto_NearmeVivo", true},
+	{"EnableSharePhoto_XiaomiApp", true},
+	{"EnableSharePhoto_CoolpadSdk", true},
+	{"EnableSharePhoto_Flyme", true},
+	{"EnableSharePhoto_LenovoOpen", true},
+	{"EnableSharePhoto_Gionee", true},
+	{"EnableSharePhoto_UcPlatform", true},
+	{"EnableSharePhoto_360Assistant", true},
+	{"EnableSharePhoto_Baidu", true},
+	{"EnableSharePhoto_BilibiliSdk", true},
+	{"EnableSharePhoto_Dtkj", true},
+	{"EnableSharePhoto_AppStore", true},
+	{"EnableSharePhoto_TapTapDev", true},
+	{"EnableSharePhoto_TapTapDis", true},
+	{"EnableSharePhoto_HykbDev", true},
+	{"EnableSharePhoto_HykbDis", true},
+	{"EnableFightSpiritSkillLevelUp", true},
+	{"EnableSignIn", true},
+	{"EnableSaveCardGroupName", true},
+	{"EnableAetherNpcFixedSpawnPointRule", true},
+	{"EnableNpcUseBelonging", true},
+	{"EnableVehicleNpcDefaultPlayPoi1", true},
+	{"EnableAgentInteractForbidFromServer1", true},
+	{"EnableTeleportOnCrossBoundary", true},
+	{"EnableAgentCompanionAIIdleChat", true},
+	{"EnableAgentPlayPoiCommand", true},
+	{"EnableNpcGateway", true},
+	{"EnableNpcDensityMigration1", true},
+	{"EnableLink", true},
+	{"EnableLinkPlanningBoardUnlockCheck", true},
+	{"ProfilerAetherVehicle", true},
+	{"ProfilerStaticVehicle", true},
+	{"ProfilerAetherVehicleGO", true},
+	{"EnableRaidDetachLocalCleanup", true},
+	{"ProfilerIntersection", true},
+	{"ProfilerIntersectionRender", true},
+	{"EnablePoliceJobStoryLegacy", true},
+	{"EnableGroupAIStory", true},
+	{"EnableGroupAIDebug", true},
+	{"EnableNewProgress", true},
+	{"EnableDGTemporaryResource", true},
+	{"ForbidStimEvent", false},
+	{"EnableZoneGraphUndirectLane", true},
+	{"EnableNPCNormalMoveBeta", true},
+	{"EnableIndoor", true},
+	{"EnableVehicleNpcBoardingSync1", true},
+	{"EnableAetherNpcTypeAgnosticSync", true},
+	{"EnableVehicleBoardingUnifiedSync1", true},
+	{"EnableLinkTest", true},
+	{"EnableSpawnSpoonBlockInteractionCheck", true},
+	{"EnableVehicleOverrideSync", true},
+	{"EnableVehicleDefaultSuitExpand", true},
+	{"EnableECSResponse", true},
+	{"DebugSyncVehicleDangerZoneToClient", false},
+	{"DebugHiddenArea", false},
+	{"DebugAetherVehicle", false},
+	{"DebugAetherIntersection", false},
+	{"DebugAetherVehicleLane", false},
+	{"DebugAssetTagPreview", false},
+	{"DebugLogicVehicle", false},
+	{"EnableDropRequireReliableRpcOnClient", true},
+	{"ProfilerRandomEvent", true},
+	{"ProfilerDestructible", true},
+	{"ProfilerGadget", true},
+	{"ProfilerDynamicGo", true},
+	{"ProfilerSpoonAgent", true},
+	{"ProfilerWildEnemy", true},
+	{"ProfilerEnableMetro", true},
+	{"BlockMetroForChongxiao", false},
+	{"MetroCrouchSwitch", true},
+	{"ProfilerEnableNpcGo", true},
+	{"DisableBartender", false},
+	{"DisableScientistProduceItem", false},
+	{"DisableScientistBreakdownItem", false},
+	{"DisableScientistTakeProducedItem", false},
+	{"DisableScientistFactorMachine", false},
+	{"DisableScientistEnchant", false},
+	{"DisableItemExchange", false},
+	{"EnableFallingDownOnDeath2", true},
+	{"EnableBehaveLegacy", true},
+	{"EnableBeggerStory", true},
+	{"EnableGPUScene", true},
+	{"EnableFavorNpcBTree", true},
+	{"EnableFavorNpcCommute", true},
+	{"EnableSwitchSpiritToTempFavorNpc1", true},
+	{"SkipSwitchOutExitTransition", true},
+	{"EnableGenderTransform", true},
+	{"EnableIndoorAbilityLimit", true},
+	{"EnableRemoveAbilityLimitOldBuffHandlers", true},
+	{"EnableSpoonNpc", true},
+	{"EnableSearchRaidSpoonNpc", true},
+	{"EnableParty", true},
+	{"EnableRanking", true},
+	{"EnableJanitor", true},
+	{"EnableBeggarAiPainting", true},
+	{"EnableNewBag", true},
+	{"EnableNotices", true},
+	{"EnableAkashicSystem", true},
+	{"EnableProfile", true},
+	{"EnableRadioStation", true},
+	{"EnableCityPedia", true},
+	{"EnableEonBug", true},
+	{"EnableTutorial", true},
+	{"EnableVehicleRequisitionSystemUnlock1", true},
+	{"EnableTestProcess", true},
+	{"EnableForceStopNaN", true},
+	{"EnableOtlpLogger", true},
+	{"EnableResourceLoadCollect", true},
+	{"EnableHUDRecommend", true},
+	{"EnableAttachmentSystem", true},
+	{"EnableFishing", true},
+	{"EnablePSOCollect", true},
+	{"KeepBehaviorSchedulerWhenSwitch", true},
+	{"KeepBehaviorSchedulerWhenSwitch2", true},
+	{"OpenNewSettleColliderClip2", true},
+	{"OpenNewSettleEffectClip2", true},
+	{"OpenNewSettleCreation2", true},
+	{"UseCoreLibRouteFinder", true},
+	{"EnableScratchServerReward", true},
+	{"DisableYanJieAIReply", false},
+	{"EnableCompetitionSeason", true},
+	{"EnableActivity", true},
+	{"EnableChangeNameByItem", true},
+	{"EnableThisYearChat", true},
+	{"EnableSpecialTimeUserChat", true},
+	{"EnableBuzzCenter", true},
+	{"EnableCheckIn", true},
+	{"EnableDossier", true},
+	{"EnableScope", true},
+	{"EnableFriends", true},
+	{"EnableClub", true},
+	{"EnableBBChat", true},
+	{"EnableMail", true},
+	{"EnableCustom", true},
+	{"EnableBubble", true},
+	{"EnablePhoto", true},
+	{"EnableCatExpress", true},
+	{"EnableDutyTerminal", true},
+	{"EnableMall", true},
+	{"EnableMallRecommend", true},
+	{"EnableMallDirectSale", true},
+	{"EnableMallBundle", true},
+	{"EnableCharge", true},
+	{"EnableSpiritTalent", true},
+	{"EnableJobTalent", true},
+	{"EnableGameplayTalent", true},
+	{"EnableAchievement", true},
+	{"EnablePhone", true},
+	{"EnableTrade", true},
+	{"EnableGift", true},
+	{"EnableGiftFriendTimeCheck", true},
+	{"EnableTime", true},
+	{"DisableOptGoMode", false},
+	{"DisableMonsterOptGoMode", false},
+	{"EnablePedConvertWorthyCheck", true},
+	{"EnableLifeScheduleNpcAlwaysGo", true},
+	{"EnableAnimatorLODOtherPlayer", true},
+	{"EnableSpecialAnimateCullingMode", true},
+	{"EnableAnimatorIncRebind", true},
+	{"SimplifyKinematicInVehicle", true},
+	{"EnableInteractionAction", true},
+	{"EnableCollectionBook", true},
+	{"EnableMonthlyPass", true},
+}
+
+// BuildGameSwitchPayload encodes the game switches as required by SyncGameSwitchToClient_3.
+// Format:
+// [Int7 count]
+// for each: [UxString key] [TypeTagBool (0x02)] [Bool (1 byte)]
+func BuildGameSwitchPayload() []byte {
+	w := NewUxWriter()
+	w.Int7(len(GameSwitchCatalog))
+	for _, entry := range GameSwitchCatalog {
+		w.UxString(entry.Name)
+		w.U8(2) // TypeTagBool
+		w.Bool(entry.Value)
+	}
+	return w.Bytes()
+}
diff --git a/pkg/protocol/handshake.go b/pkg/protocol/handshake.go
new file mode 100644
index 0000000..ca1fd25
--- /dev/null
+++ b/pkg/protocol/handshake.go
@@ -0,0 +1,85 @@
+package protocol
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/binary"
+	"errors"
+	"io"
+	"net"
+
+	"ananta-ps/pkg/crypto/chacha8"
+)
+
+// HandleHandshake processes client C2S handshake and performs S2C key exchange.
+// It returns read and write ciphers initialized with negotiated ChaCha8 keys.
+func HandleHandshake(conn net.Conn) (*chacha8.UxChaCha8, *chacha8.UxChaCha8, error) {
+	// Read frame 0 (mode 0x01 plaintext)
+	frame, err := ReadFrame(conn, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	if len(frame.Payload) < 20 {
+		return nil, nil, errors.New("client handshake too short")
+	}
+
+	aesKey := frame.Payload[4:20] // 16-byte key
+
+	clientWriteKey := make([]byte, 32)
+	clientReadKey := make([]byte, 32)
+	streamNonce := make([]byte, 12)
+	aesNonce := make([]byte, 12)
+
+	if _, err := io.ReadFull(rand.Reader, clientWriteKey); err != nil {
+		return nil, nil, err
+	}
+	if _, err := io.ReadFull(rand.Reader, clientReadKey); err != nil {
+		return nil, nil, err
+	}
+	if _, err := io.ReadFull(rand.Reader, streamNonce); err != nil {
+		return nil, nil, err
+	}
+	if _, err := io.ReadFull(rand.Reader, aesNonce); err != nil {
+		return nil, nil, err
+	}
+
+	chaChaHead := make([]byte, 76)
+	copy(chaChaHead[0:32], clientWriteKey)
+	copy(chaChaHead[32:64], clientReadKey)
+	copy(chaChaHead[64:76], streamNonce)
+
+	block, err := aes.NewCipher(aesKey)
+	if err != nil {
+		return nil, nil, err
+	}
+	aesGcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	encrypted := aesGcm.Seal(nil, aesNonce, chaChaHead, nil) // 76 + 16 = 92 bytes
+
+	payload := make([]byte, 368)
+	binary.LittleEndian.PutUint32(payload[0:4], 1)
+	binary.LittleEndian.PutUint32(payload[4:8], 10)
+	copy(payload[8:20], aesNonce)
+	copy(payload[20:96], encrypted[0:76])
+	copy(payload[96:112], encrypted[76:92])
+
+	// Send handshake reply in plaintext with mode 0x01
+	if err := WriteFrame(conn, 0x01, payload, nil); err != nil {
+		return nil, nil, err
+	}
+
+	readCryptor, err := chacha8.New(clientWriteKey, streamNonce)
+	if err != nil {
+		return nil, nil, err
+	}
+	writeCryptor, err := chacha8.New(clientReadKey, streamNonce)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return readCryptor, writeCryptor, nil
+}
diff --git a/pkg/protocol/method_id.go b/pkg/protocol/method_id.go
new file mode 100644
index 0000000..30522ba
--- /dev/null
+++ b/pkg/protocol/method_id.go
@@ -0,0 +1,82 @@
+package protocol
+
+// NetEase UX-RPC Method IDs for Ananta CBT (Build 4229938).
+const (
+	// Login / Gate
+	MethodCheckVersion                     uint32 = 34700853
+	MethodHasOnlinePlayer                  uint32 = 34822236
+	MethodAskNewHotFixPatchLogin           uint32 = 34692559
+	MethodRequestPatchesCheckDataFromLogin uint32 = 34601301
+	MethodRequestPatchesFromLogin          uint32 = 34022227
+	MethodAskUniSdkShareToken_Login        uint32 = 34110053
+	MethodAskUniSdkShareToken_Gate         uint32 = 52152686
+	MethodRequestFpPassToken               uint32 = 34828770
+	MethodCheckAccount                     uint32 = 34339919
+	MethodCheckAccountPassBy               uint32 = 34163006
+	MethodCheckAccountOpenId               uint32 = 34381554
+	MethodTryLogin                         uint32 = 34529582
+	MethodSyncRoleList                     uint32 = 35167428
+	MethodRequestCreateRoleEx              uint32 = 34383517
+	MethodRequestEnterGame                 uint32 = 34566515
+	MethodDebugRequestEnterGame            uint32 = 34478273
+	MethodAskCloseConnectionToGate         uint32 = 52140059
+	MethodGetServerTime                    uint32 = 52951195
+	MethodSendServerTime                   uint32 = 52296181
+	MethodGateLogin                        uint32 = 52023760
+	MethodSyncPlayerGameServerInfo         uint32 = 52737039
+	MethodSyncGameSwitchToClient_3         uint32 = 53940944
+	MethodQuerySkey                        uint32 = 52367757
+	MethodReportNetworkQuality             uint32 = 52623348
+	MethodDavinciCode                      uint32 = 52458997
+	MethodGetFriendApplicationListToMe     uint32 = 52123985
+	MethodGetAllChatGroupLatestMessage     uint32 = 52345719
+	MethodGetSimplePlayerInfoByPidList     uint32 = 52781934
+
+	// Game / World
+	MethodLoginGame                  uint32 = 63142082
+	MethodAskEnterWorld              uint32 = 63142082
+	MethodSendServerTimeGame         uint32 = 63853503
+	MethodSyncPlayerInfo             uint32 = 63200762
+	MethodSyncEnterScene             uint32 = 67711467
+	MethodAskLoadSceneCompleted      uint32 = 66879899
+	MethodAskLoadingFinished         uint32 = 67017252
+	MethodSyncLogicAgentEnter        uint32 = 68057283
+	MethodSyncLogicAgentLeave        uint32 = 68057315
+	MethodSyncManagedLogicAgent      uint32 = 68611146
+	MethodSyncRaidBattleUnitSpirit   uint32 = 68953151
+	MethodSyncPlayerCurrentSpirit    uint32 = 68582738
+	MethodSyncSceneLoadCompleted     uint32 = 67610360
+	MethodSyncGamePause              uint32 = 67039327
+	MethodSyncPlayerLoadRate         uint32 = 68383503
+	MethodAskLoadedInSameScene       uint32 = 68838634
+	MethodSyncUnitPositionAndFacing  uint32 = 68664188
+	MethodSyncPlayerPositionAndFacing uint32 = 68702330
+	MethodSyncSceneFogMapAllUnlock   uint32 = 68677150
+	MethodSyncRunGmUtils             uint32 = 66042045
+	MethodSyncAllSpiritCombatPower   uint32 = 68798150
+	MethodSyncSwitchSpiritInSitu     uint32 = 68582770
+
+	// Movement
+	MethodAskMoveStart            uint32 = 68412852
+	MethodAskMoveStop             uint32 = 68412853
+	MethodAskUpdatePlayerPosition uint32 = 68058291
+
+	// Combat & Skills
+	MethodAskPlayerAttack     uint32 = 68412884
+	MethodAskPlayerCastSkill  uint32 = 68412916
+	MethodAskUnlockFightStyle uint32 = 68412948
+	MethodSyncChangeSpirit    uint32 = 68582802
+	MethodAskPreSwitchSpirit  uint32 = 68415783
+	MethodAskSwitchSpirit     uint32 = 68415784
+
+	// Vehicles
+	MethodAskSummonVehicle       uint32 = 68221895
+	MethodAskDriveVehicle        uint32 = 68221893
+	MethodAskExitVehicle         uint32 = 68221894
+	MethodSyncPlayerVehicleInfo  uint32 = 68221926
+	MethodSyncAetherVehicleInit  uint32 = 68221958
+
+	// Teleportation & Scene
+	MethodAskTeleportToScene uint32 = 67891234
+	MethodSwitchSceneDirect  uint32 = 67891235
+)
diff --git a/pkg/protocol/packet.go b/pkg/protocol/packet.go
new file mode 100644
index 0000000..b46b545
--- /dev/null
+++ b/pkg/protocol/packet.go
@@ -0,0 +1,22 @@
+package protocol
+
+type RpcPacketKind byte
+
+const (
+	PacketKindUnknown RpcPacketKind = 0x00
+	PacketKindInvoke  RpcPacketKind = 0x01
+	PacketKindReturn  RpcPacketKind = 0x02
+	PacketKindNotify  RpcPacketKind = 0x03
+)
+
+type RpcPacket struct {
+	Kind     RpcPacketKind
+	MethodID uint32
+	InvokeID int32
+	Error    int32
+	Body     []byte
+}
+
+func (p RpcPacket) IsInvoke() bool { return p.Kind == PacketKindInvoke }
+func (p RpcPacket) IsNotify() bool { return p.Kind == PacketKindNotify }
+func (p RpcPacket) IsReturn() bool { return p.Kind == PacketKindReturn }
diff --git a/pkg/protocol/protocol_test.go b/pkg/protocol/protocol_test.go
new file mode 100644
index 0000000..dde5f0e
--- /dev/null
+++ b/pkg/protocol/protocol_test.go
@@ -0,0 +1,108 @@
+package protocol
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestUxSerializationRoundtrip(t *testing.T) {
+	w := NewUxWriter()
+	w.U8(42).
+		Bool(true).
+		I16(-1234).
+		U16(5678).
+		I32(-999999).
+		U32(12345678).
+		I64(-9876543210).
+		U64(18446744073709551615).
+		F32(3.14159).
+		F64(2.718281828459).
+		Int7(10).
+		Raw7(25).
+		UxString("Hello Ananta Go Protocol")
+
+	data := w.Bytes()
+	r := NewUxReader(data)
+
+	u8, _ := r.U8()
+	if u8 != 42 {
+		t.Errorf("U8 mismatch: %v", u8)
+	}
+
+	b, _ := r.Bool()
+	if !b {
+		t.Errorf("Bool mismatch: %v", b)
+	}
+
+	i16, _ := r.I16()
+	if i16 != -1234 {
+		t.Errorf("I16 mismatch: %v", i16)
+	}
+
+	u16, _ := r.U16()
+	if u16 != 5678 {
+		t.Errorf("U16 mismatch: %v", u16)
+	}
+
+	i32, _ := r.I32()
+	if i32 != -999999 {
+		t.Errorf("I32 mismatch: %v", i32)
+	}
+
+	u32, _ := r.U32()
+	if u32 != 12345678 {
+		t.Errorf("U32 mismatch: %v", u32)
+	}
+
+	i64, _ := r.I64()
+	if i64 != -9876543210 {
+		t.Errorf("I64 mismatch: %v", i64)
+	}
+
+	u64, _ := r.U64()
+	if u64 != 18446744073709551615 {
+		t.Errorf("U64 mismatch: %v", u64)
+	}
+
+	f32, _ := r.F32()
+	if f32 < 3.14 || f32 > 3.15 {
+		t.Errorf("F32 mismatch: %v", f32)
+	}
+
+	f64, _ := r.F64()
+	if f64 < 2.71 || f64 > 2.72 {
+		t.Errorf("F64 mismatch: %v", f64)
+	}
+
+	int7, _ := r.Int7()
+	if int7 != 10 {
+		t.Errorf("Int7 mismatch: got %v, want 10", int7)
+	}
+
+	raw7, _ := r.Raw7()
+	if raw7 != 25 {
+		t.Errorf("Raw7 mismatch: got %v, want 25", raw7)
+	}
+
+	str, _ := r.UxString()
+	if str != "Hello Ananta Go Protocol" {
+		t.Errorf("UxString mismatch: got %v", str)
+	}
+}
+
+func TestRpcPacketRoundtrip(t *testing.T) {
+	notify := BuildNotify(MethodCheckVersion, []byte{0x01, 0x02, 0x03})
+	p := ParseRpcPacket(notify)
+	if !p.IsNotify() || p.MethodID != MethodCheckVersion {
+		t.Errorf("Notify packet mismatch: %+v", p)
+	}
+	if !bytes.Equal(p.Body, []byte{0x01, 0x02, 0x03}) {
+		t.Errorf("Body mismatch: %v", p.Body)
+	}
+
+	ret := BuildReturn(MethodAskEnterWorld, 100, 0, []byte("OK"))
+	p2 := ParseRpcPacket(ret)
+	if !p2.IsReturn() || p2.MethodID != MethodAskEnterWorld || p2.InvokeID != 100 || p2.Error != 0 {
+		t.Errorf("Return packet mismatch: %+v", p2)
+	}
+}
diff --git a/pkg/protocol/uxreader.go b/pkg/protocol/uxreader.go
new file mode 100644
index 0000000..a7efdb4
--- /dev/null
+++ b/pkg/protocol/uxreader.go
@@ -0,0 +1,160 @@
+package protocol
+
+import (
+	"encoding/binary"
+	"errors"
+	"math"
+)
+
+type UxReader struct {
+	data []byte
+	pos  int
+}
+
+func NewUxReader(data []byte) *UxReader {
+	return &UxReader{data: data, pos: 0}
+}
+
+func (r *UxReader) Position() int  { return r.pos }
+func (r *UxReader) Remaining() int { return len(r.data) - r.pos }
+
+func (r *UxReader) U8() (byte, error) {
+	if r.pos >= len(r.data) {
+		return 0, errors.New("EOF")
+	}
+	b := r.data[r.pos]
+	r.pos++
+	return b, nil
+}
+
+func (r *UxReader) Bool() (bool, error) {
+	b, err := r.U8()
+	return b != 0, err
+}
+
+func (r *UxReader) I16() (int16, error) {
+	if r.Remaining() < 2 {
+		return 0, errors.New("EOF")
+	}
+	v := int16(binary.LittleEndian.Uint16(r.data[r.pos : r.pos+2]))
+	r.pos += 2
+	return v, nil
+}
+
+func (r *UxReader) U16() (uint16, error) {
+	if r.Remaining() < 2 {
+		return 0, errors.New("EOF")
+	}
+	v := binary.LittleEndian.Uint16(r.data[r.pos : r.pos+2])
+	r.pos += 2
+	return v, nil
+}
+
+func (r *UxReader) I32() (int32, error) {
+	if r.Remaining() < 4 {
+		return 0, errors.New("EOF")
+	}
+	v := int32(binary.LittleEndian.Uint32(r.data[r.pos : r.pos+4]))
+	r.pos += 4
+	return v, nil
+}
+
+func (r *UxReader) U32() (uint32, error) {
+	if r.Remaining() < 4 {
+		return 0, errors.New("EOF")
+	}
+	v := binary.LittleEndian.Uint32(r.data[r.pos : r.pos+4])
+	r.pos += 4
+	return v, nil
+}
+
+func (r *UxReader) I64() (int64, error) {
+	if r.Remaining() < 8 {
+		return 0, errors.New("EOF")
+	}
+	v := int64(binary.LittleEndian.Uint64(r.data[r.pos : r.pos+8]))
+	r.pos += 8
+	return v, nil
+}
+
+func (r *UxReader) U64() (ulong uint64, err error) {
+	if r.Remaining() < 8 {
+		return 0, errors.New("EOF")
+	}
+	v := binary.LittleEndian.Uint64(r.data[r.pos : r.pos+8])
+	r.pos += 8
+	return v, nil
+}
+
+func (r *UxReader) F32() (float32, error) {
+	bits, err := r.U32()
+	if err != nil {
+		return 0, err
+	}
+	return math.Float32frombits(bits), nil
+}
+
+func (r *UxReader) F64() (float64, error) {
+	bits, err := r.U64()
+	if err != nil {
+		return 0, err
+	}
+	return math.Float64frombits(bits), nil
+}
+
+func (r *UxReader) ReadVarInt() (int, error) {
+	shift := 0
+	result := 0
+	for r.pos < len(r.data) {
+		b := r.data[r.pos]
+		r.pos++
+		result |= int(b&0x7F) << shift
+		if (b & 0x80) == 0 {
+			return result, nil
+		}
+		shift += 7
+	}
+	return result, errors.New("unterminated varint")
+}
+
+func (r *UxReader) Int7() (int, error) {
+	v, err := r.ReadVarInt()
+	if err != nil {
+		return 0, err
+	}
+	if v < 1 {
+		return 0, nil
+	}
+	return v - 1, nil
+}
+
+func (r *UxReader) Raw7() (int, error) {
+	return r.ReadVarInt()
+}
+
+func (r *UxReader) UxString() (string, error) {
+	lenPlus, err := r.ReadVarInt()
+	if err != nil {
+		return "", err
+	}
+	if lenPlus == 0 {
+		return "", nil
+	}
+	length := lenPlus - 1
+	if length > r.Remaining() {
+		return "", errors.New("string length exceeds buffer")
+	}
+	str := string(r.data[r.pos : r.pos+length])
+	r.pos += length
+	return str, nil
+}
+
+func (r *UxReader) Bytes(count int) ([]byte, error) {
+	if count < 0 || count > r.Remaining() {
+		return nil, errors.New("requested count exceeds buffer")
+	}
+	res := make([]byte, count)
+	copy(res, r.data[r.pos:r.pos+count])
+	r.pos += count
+	return res, nil
+}
diff --git a/pkg/protocol/uxwriter.go b/pkg/protocol/uxwriter.go
new file mode 100644
index 0000000..4b89fea
--- /dev/null
+++ b/pkg/protocol/uxwriter.go
@@ -0,0 +1,127 @@
+package protocol
+
+import (
+	"bytes"
+	"encoding/binary"
+	"math"
+)
+
+type UxWriter struct {
+	buf bytes.Buffer
+}
+
+func NewUxWriter() *UxWriter {
+	return &UxWriter{}
+}
+
+func (w *UxWriter) U8(val byte) *UxWriter {
+	w.buf.WriteByte(val)
+	return w
+}
+
+func (w *UxWriter) Bool(val bool) *UxWriter {
+	if val {
+		w.buf.WriteByte(1)
+	} else {
+		w.buf.WriteByte(0)
+	}
+	return w
+}
+
+func (w *UxWriter) I16(val int16) *UxWriter {
+	var b [2]byte
+	binary.LittleEndian.PutUint16(b[:], uint16(val))
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) U16(val uint16) *UxWriter {
+	var b [2]byte
+	binary.LittleEndian.PutUint16(b[:], val)
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) I32(val int32) *UxWriter {
+	var b [4]byte
+	binary.LittleEndian.PutUint32(b[:], uint32(val))
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) U32(val uint32) *UxWriter {
+	var b [4]byte
+	binary.LittleEndian.PutUint32(b[:], val)
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) I64(val int64) *UxWriter {
+	var b [8]byte
+	binary.LittleEndian.PutUint64(b[:], uint64(val))
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) U64(val uint64) *UxWriter {
+	var b [8]byte
+	binary.LittleEndian.PutUint64(b[:], val)
+	w.buf.Write(b[:])
+	return w
+}
+
+func (w *UxWriter) F32(val float32) *UxWriter {
+	return w.U32(math.Float32bits(val))
+}
+
+func (w *UxWriter) F64(val float64) *UxWriter {
+	return w.U64(math.Float64bits(val))
+}
+
+func (w *UxWriter) WriteVarInt(val int) *UxWriter {
+	v := uint32(val)
+	for v >= 0x80 {
+		w.buf.WriteByte(byte(v | 0x80))
+		v >>= 7
+	}
+	w.buf.WriteByte(byte(v))
+	return w
+}
+
+func (w *UxWriter) Int7(val int) *UxWriter {
+	if val < 0 {
+		val = 0
+	}
+	return w.WriteVarInt(val + 1)
+}
+
+func (w *UxWriter) Raw7(val int) *UxWriter {
+	if val < 0 {
+		val = 0
+	}
+	return w.WriteVarInt(val)
+}
+
+func (w *UxWriter) UxString(str string) *UxWriter {
+	utf8 := []byte(str)
+	w.WriteVarInt(len(utf8) + 1)
+	w.buf.Write(utf8)
+	return w
+}
+
+func (w *UxWriter) UxStringNullable(str *string) *UxWriter {
+	if str == nil {
+		w.buf.WriteByte(0x00)
+		return w
+	}
+	return w.UxString(*str)
+}
+
+func (w *UxWriter) Raw(b []byte) *UxWriter {
+	w.buf.Write(b)
+	return w
+}
+
+func (w *UxWriter) Bytes() []byte {
+	return w.buf.Bytes()
+}