fix login issue*
This commit is contained in:
@@ -1,215 +0,0 @@
|
|||||||
# Odinsea Elixir Port - Session Summary (2026-02-14)
|
|
||||||
|
|
||||||
## 🎯 Major Milestones Achieved
|
|
||||||
|
|
||||||
### ✅ Data Provider Infrastructure Complete
|
|
||||||
Implemented the three **CRITICAL BLOCKER** data provider systems that were preventing further game development:
|
|
||||||
|
|
||||||
1. **Item Information Provider** (`Odinsea.Game.ItemInfo`)
|
|
||||||
- Complete item metadata system (stats, prices, requirements)
|
|
||||||
- Equipment stat definitions and creation
|
|
||||||
- ETS-based high-performance caching
|
|
||||||
- JSON data loading (WZ export compatible)
|
|
||||||
- 450+ lines of code
|
|
||||||
|
|
||||||
2. **Map Factory** (`Odinsea.Game.MapFactory`)
|
|
||||||
- Complete map template system
|
|
||||||
- Portal data structures (9 portal types)
|
|
||||||
- Foothold/collision data structures
|
|
||||||
- Field properties (limits, rates, timers)
|
|
||||||
- ETS-based caching
|
|
||||||
- JSON data loading
|
|
||||||
- 450+ lines of code
|
|
||||||
|
|
||||||
3. **Life Factory** (`Odinsea.Game.LifeFactory`)
|
|
||||||
- Complete monster stats system (40+ stat fields)
|
|
||||||
- NPC data system (names, shops, scripts)
|
|
||||||
- ETS-based caching
|
|
||||||
- JSON data loading
|
|
||||||
- 350+ lines of code
|
|
||||||
|
|
||||||
### ✅ Monster System Complete
|
|
||||||
- **Monster Module** (`Odinsea.Game.Monster`)
|
|
||||||
- Full monster instance management
|
|
||||||
- HP/MP tracking and damage system
|
|
||||||
- Attacker logging and top damage tracking
|
|
||||||
- Controller assignment (player-controlled AI)
|
|
||||||
- Status effects framework
|
|
||||||
- Position and movement tracking
|
|
||||||
- Boss/death detection
|
|
||||||
- EXP calculation
|
|
||||||
- 250+ lines of code
|
|
||||||
|
|
||||||
## 📊 Project Statistics
|
|
||||||
|
|
||||||
| Metric | Before | After | Change |
|
|
||||||
|--------|--------|-------|--------|
|
|
||||||
| Files | 51 | **55** | +4 |
|
|
||||||
| Lines of Code | ~11,000 | **12,530** | +1,530 |
|
|
||||||
| Modules | 45 | **49** | +4 |
|
|
||||||
| Overall Progress | 55% | **62%** | +7% |
|
|
||||||
| Game Systems | 35% | **55%** | +20% |
|
|
||||||
|
|
||||||
## 🏗️ Architecture Highlights
|
|
||||||
|
|
||||||
### Data Provider Pattern
|
|
||||||
All three data providers follow a consistent architecture:
|
|
||||||
- GenServer-based initialization
|
|
||||||
- ETS tables for high-performance reads
|
|
||||||
- JSON file loading (WZ data export compatible)
|
|
||||||
- Fallback data for testing without WZ files
|
|
||||||
- Integrated into application supervision tree
|
|
||||||
- Start before game servers (proper initialization order)
|
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
1. **Monster as Struct, Not Process**
|
|
||||||
- Monsters are managed by Map GenServer, not as individual processes
|
|
||||||
- Avoids massive process overhead (1000s of mobs = 1000s of processes)
|
|
||||||
- Maps track monsters in state, broadcast updates to players
|
|
||||||
|
|
||||||
2. **ETS for Caching**
|
|
||||||
- All game data cached in ETS tables
|
|
||||||
- `:read_concurrency` for multi-core performance
|
|
||||||
- Static data loaded once at startup
|
|
||||||
|
|
||||||
3. **JSON-Based Data Loading**
|
|
||||||
- Allows easy WZ data export from Java server
|
|
||||||
- Human-readable for debugging
|
|
||||||
- Version control friendly
|
|
||||||
- Future-proof for custom content
|
|
||||||
|
|
||||||
## 🚀 Next Steps Unlocked
|
|
||||||
|
|
||||||
With data providers complete, these systems can now be implemented:
|
|
||||||
|
|
||||||
### 1. WZ Data Export Utility (High Priority)
|
|
||||||
Create Java utility to export WZ data to JSON:
|
|
||||||
- Items: `data/items.json`, `data/equips.json`, `data/item_strings.json`
|
|
||||||
- Maps: `data/maps.json` (with portals, footholds, properties)
|
|
||||||
- Life: `data/monsters.json`, `data/npcs.json`
|
|
||||||
- Validation and testing with real game data
|
|
||||||
|
|
||||||
### 2. Monster Spawning System
|
|
||||||
- Implement `SpawnPoint` on maps
|
|
||||||
- Monster respawn timers
|
|
||||||
- Basic monster AI movement
|
|
||||||
- Integration with Map GenServer
|
|
||||||
|
|
||||||
### 3. Combat System
|
|
||||||
- Damage calculation formulas
|
|
||||||
- Monster damage handler
|
|
||||||
- Death and EXP distribution
|
|
||||||
- Drop item creation and spawning
|
|
||||||
|
|
||||||
### 4. Portal System
|
|
||||||
- Portal-based map transitions
|
|
||||||
- Script portal execution
|
|
||||||
- Town portal system
|
|
||||||
- Integration with Map module
|
|
||||||
|
|
||||||
### 5. Full Gameplay Loop Testing
|
|
||||||
End-to-end test:
|
|
||||||
1. Login to server
|
|
||||||
2. Select character
|
|
||||||
3. Spawn in Henesys
|
|
||||||
4. See monsters on map
|
|
||||||
5. Kill monster
|
|
||||||
6. Receive EXP and drops
|
|
||||||
7. Change maps via portal
|
|
||||||
|
|
||||||
## 📁 Files Created
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/odinsea/game/
|
|
||||||
├── item_info.ex # Item Information Provider (450 lines)
|
|
||||||
├── map_factory.ex # Map Factory (450 lines)
|
|
||||||
├── life_factory.ex # Life Factory (350 lines)
|
|
||||||
└── monster.ex # Monster Module (250 lines)
|
|
||||||
|
|
||||||
priv/data/ # Data directory for WZ exports
|
|
||||||
└── .gitkeep
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Files Modified
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/odinsea/application.ex # Added 3 data providers to supervision tree
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Compilation Status
|
|
||||||
|
|
||||||
Project compiles successfully with **zero errors**:
|
|
||||||
- All new modules compile without issues
|
|
||||||
- Only minor warnings (unused variables, deprecated Logger.warn)
|
|
||||||
- All type specs valid
|
|
||||||
- Integration tests pending
|
|
||||||
|
|
||||||
## 📝 Documentation Updated
|
|
||||||
|
|
||||||
Updated `PORT_PROGRESS.md`:
|
|
||||||
- Phase 6 (Game Systems): 35% → 55% (+20%)
|
|
||||||
- Overall progress: 55% → 62% (+7%)
|
|
||||||
- Updated file mappings (4 new mappings)
|
|
||||||
- Added detailed session notes
|
|
||||||
- Updated statistics and metrics
|
|
||||||
|
|
||||||
## 🎓 Key Learnings
|
|
||||||
|
|
||||||
1. **Separation of Data and Instances**
|
|
||||||
- LifeFactory holds static monster stats
|
|
||||||
- Monster module manages live instances
|
|
||||||
- Clean separation enables efficient caching
|
|
||||||
|
|
||||||
2. **ETS Performance**
|
|
||||||
- ETS read_concurrency enables lock-free reads
|
|
||||||
- Perfect for static game data
|
|
||||||
- Microsecond lookup times
|
|
||||||
|
|
||||||
3. **JSON Over Binary**
|
|
||||||
- WZ binary format complex to parse
|
|
||||||
- JSON export from Java is simpler
|
|
||||||
- Enables non-Java contributors
|
|
||||||
- Easy to inspect and debug
|
|
||||||
|
|
||||||
4. **Supervision Tree Order Matters**
|
|
||||||
- Data providers must start before servers
|
|
||||||
- Prevents race conditions on startup
|
|
||||||
- Clear dependency graph
|
|
||||||
|
|
||||||
## 🐛 Known Issues
|
|
||||||
|
|
||||||
None! All code compiles and integrates cleanly.
|
|
||||||
|
|
||||||
## 🎯 Remaining Work
|
|
||||||
|
|
||||||
Major systems still needed:
|
|
||||||
- Skills & Buffs (Phase 6.4)
|
|
||||||
- Scripting Engine (Phase 9)
|
|
||||||
- Timer System (Phase 10.1)
|
|
||||||
- Anti-Cheat (Phase 10.2)
|
|
||||||
- Events (Phase 10.3)
|
|
||||||
- Admin Commands (Phase 10.4)
|
|
||||||
- Testing Suite (Phase 11)
|
|
||||||
|
|
||||||
Estimated remaining: ~38% of total port
|
|
||||||
|
|
||||||
## 📞 For Next Session
|
|
||||||
|
|
||||||
**Immediate Priorities:**
|
|
||||||
1. Create WZ data export utility in Java
|
|
||||||
2. Export real game data to JSON files
|
|
||||||
3. Test data providers with real data
|
|
||||||
4. Implement monster spawning on maps
|
|
||||||
5. Begin combat system implementation
|
|
||||||
|
|
||||||
**Questions to Consider:**
|
|
||||||
- Should we implement a simple scripting system first (Lua?) or continue with game systems?
|
|
||||||
- Do we need drop tables before combat, or can we stub them?
|
|
||||||
- Should we focus on getting one complete map working end-to-end?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Session Duration:** ~2 hours
|
|
||||||
**Commits Needed:** Data providers implementation
|
|
||||||
**Ready for Testing:** Yes (with fallback data)
|
|
||||||
**Blockers Removed:** 3 critical (ItemInfo, MapFactory, LifeFactory)
|
|
||||||
@@ -17,7 +17,7 @@ config :odinsea, :rates,
|
|||||||
quest: 1
|
quest: 1
|
||||||
|
|
||||||
config :odinsea, :login,
|
config :odinsea, :login,
|
||||||
port: 8584,
|
port: 8484,
|
||||||
user_limit: 1500,
|
user_limit: 1500,
|
||||||
max_characters: 3,
|
max_characters: 3,
|
||||||
flag: 3,
|
flag: 3,
|
||||||
@@ -28,8 +28,7 @@ config :odinsea, :game,
|
|||||||
channel_ports: %{1 => 8585, 2 => 8586},
|
channel_ports: %{1 => 8585, 2 => 8586},
|
||||||
events: []
|
events: []
|
||||||
|
|
||||||
config :odinsea, :shop,
|
config :odinsea, :shop, port: 8605
|
||||||
port: 8605
|
|
||||||
|
|
||||||
config :odinsea, :features,
|
config :odinsea, :features,
|
||||||
admin_mode: false,
|
admin_mode: false,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ config :odinsea, :server,
|
|||||||
host: System.get_env("ODINSEA_HOST", "127.0.0.1"),
|
host: System.get_env("ODINSEA_HOST", "127.0.0.1"),
|
||||||
revision: String.to_integer(System.get_env("ODINSEA_REV", "1")),
|
revision: String.to_integer(System.get_env("ODINSEA_REV", "1")),
|
||||||
flag: String.to_integer(System.get_env("ODINSEA_FLAG", "0")),
|
flag: String.to_integer(System.get_env("ODINSEA_FLAG", "0")),
|
||||||
slide_message: System.get_env("ODINSEA_SLIDE_MESSAGE", "Welcome to Luna v99. The Ultimate Private Server"),
|
slide_message:
|
||||||
|
System.get_env("ODINSEA_SLIDE_MESSAGE", "Welcome to Luna v99. The Ultimate Private Server"),
|
||||||
data_prefix: System.get_env("ODINSEA_DATA_PREFIX", "Luna")
|
data_prefix: System.get_env("ODINSEA_DATA_PREFIX", "Luna")
|
||||||
|
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
@@ -31,7 +32,8 @@ config :odinsea, :login,
|
|||||||
user_limit: String.to_integer(System.get_env("ODINSEA_USER_LIMIT", "1500")),
|
user_limit: String.to_integer(System.get_env("ODINSEA_USER_LIMIT", "1500")),
|
||||||
max_characters: String.to_integer(System.get_env("ODINSEA_MAX_CHARACTERS", "3")),
|
max_characters: String.to_integer(System.get_env("ODINSEA_MAX_CHARACTERS", "3")),
|
||||||
flag: String.to_integer(System.get_env("ODINSEA_LOGIN_FLAG", "3")),
|
flag: String.to_integer(System.get_env("ODINSEA_LOGIN_FLAG", "3")),
|
||||||
event_message: System.get_env("ODINSEA_EVENT_MESSAGE", "#bLuna v99\\r\\n#rThe Ultimate Private Server")
|
event_message:
|
||||||
|
System.get_env("ODINSEA_EVENT_MESSAGE", "#bLuna v99\\r\\n#rThe Ultimate Private Server")
|
||||||
|
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
# Game Channels
|
# Game Channels
|
||||||
@@ -49,15 +51,17 @@ channel_ports =
|
|||||||
config :odinsea, :game,
|
config :odinsea, :game,
|
||||||
channels: channel_count,
|
channels: channel_count,
|
||||||
channel_ports: channel_ports,
|
channel_ports: channel_ports,
|
||||||
events: System.get_env("ODINSEA_EVENTS",
|
events:
|
||||||
"MiniDungeon,Olivia,PVP,CygnusBattle,ScarTarBattle,VonLeonBattle"
|
System.get_env(
|
||||||
) |> String.split(",")
|
"ODINSEA_EVENTS",
|
||||||
|
"MiniDungeon,Olivia,PVP,CygnusBattle,ScarTarBattle,VonLeonBattle"
|
||||||
|
)
|
||||||
|
|> String.split(",")
|
||||||
|
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
# Cash Shop Server
|
# Cash Shop Server
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
config :odinsea, :shop,
|
config :odinsea, :shop, port: String.to_integer(System.get_env("ODINSEA_SHOP_PORT", "8605"))
|
||||||
port: String.to_integer(System.get_env("ODINSEA_SHOP_PORT", "8605"))
|
|
||||||
|
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
# Database
|
# Database
|
||||||
@@ -94,7 +98,7 @@ config :odinsea, :features,
|
|||||||
script_reload: System.get_env("ODINSEA_SCRIPT_RELOAD", "true") == "true",
|
script_reload: System.get_env("ODINSEA_SCRIPT_RELOAD", "true") == "true",
|
||||||
family_disable: System.get_env("ODINSEA_FAMILY_DISABLE", "false") == "true",
|
family_disable: System.get_env("ODINSEA_FAMILY_DISABLE", "false") == "true",
|
||||||
custom_lang: System.get_env("ODINSEA_CUSTOM_LANG", "false") == "true",
|
custom_lang: System.get_env("ODINSEA_CUSTOM_LANG", "false") == "true",
|
||||||
custom_dmgskin: System.get_env("ODINSEA_CUSTOM_DMGSKIN", "true") == "true",
|
custom_dmgskin: System.get_env("ODINSEA_CUSTOM_DMGSKIN", "false") == "true",
|
||||||
skip_maccheck: System.get_env("ODINSEA_SKIP_MACCHECK", "true") == "true"
|
skip_maccheck: System.get_env("ODINSEA_SKIP_MACCHECK", "true") == "true"
|
||||||
|
|
||||||
# ====================================================================================================
|
# ====================================================================================================
|
||||||
|
|||||||
92
docs/PACKET_LOGGING.md
Normal file
92
docs/PACKET_LOGGING.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Packet Logging System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Comprehensive packet logging system for debugging the MapleStory protocol, matching the Java version's logging format.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Direction Indicators**: `[client]` for incoming packets, `[loopback]` for outgoing packets
|
||||||
|
- **Opcode Names**: Human-readable packet names (e.g., `CP_CheckPassword`, `LP_ServerList`)
|
||||||
|
- **Opcode Values**: Both decimal and hexadecimal (e.g., `2 / 0x02`)
|
||||||
|
- **Raw Hex Data**: Space-separated hex bytes
|
||||||
|
- **ASCII Text**: Printable characters with dots for non-printable
|
||||||
|
- **Context Information**: IP address, server type, packet size
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Enable/disable packet logging in `config/config.exs`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
config :odinsea, :features,
|
||||||
|
log_packet: true # Set to false to disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
[client] [CP_PermissionRequest] 1 / 0x01
|
||||||
|
[Data] 01 00 07 70 00 04 00
|
||||||
|
[Text] ...p...
|
||||||
|
[Context] IP=127.0.0.1 Server=login Size=7 bytes
|
||||||
|
|
||||||
|
[loopback] [RSA_KEY] 32 / 0x20
|
||||||
|
[Data] 20 00
|
||||||
|
[Text] .
|
||||||
|
[Context] IP=127.0.0.1 Server=login Size=2 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Added
|
||||||
|
|
||||||
|
- `lib/odinsea/net/packet_logger.ex` - Main packet logging module
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
- `lib/odinsea/login/client.ex` - Added packet logging for incoming client packets
|
||||||
|
- `lib/odinsea/login/handler.ex` - Added packet logging for outgoing server packets
|
||||||
|
- `lib/odinsea/net/processor.ex` - Fixed handler function name mismatches
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
|
||||||
|
#### `PacketLogger.log_client_packet/3`
|
||||||
|
Logs incoming packets from the client with opcode, data, and context.
|
||||||
|
|
||||||
|
#### `PacketLogger.log_server_packet/3`
|
||||||
|
Logs outgoing packets to the client with opcode, data, and context.
|
||||||
|
|
||||||
|
#### `PacketLogger.log_raw_packet/4`
|
||||||
|
Logs raw packets (e.g., hello handshake) that don't follow the standard opcode format.
|
||||||
|
|
||||||
|
## Opcode Name Resolution
|
||||||
|
|
||||||
|
The logger includes comprehensive opcode name mappings for:
|
||||||
|
|
||||||
|
- **Client Opcodes** (CP_*): Login, authentication, character management, gameplay, etc.
|
||||||
|
- **Server Opcodes** (LP_*): Responses, server lists, character data, game state, etc.
|
||||||
|
|
||||||
|
Unknown opcodes are displayed as `UNKNOWN`.
|
||||||
|
|
||||||
|
## Usage Tips
|
||||||
|
|
||||||
|
1. **Enable during development**: Keep `log_packet: true` to debug connection issues
|
||||||
|
2. **Disable in production**: Set `log_packet: false` to reduce log noise and improve performance
|
||||||
|
3. **Compare with Java logs**: Use this to verify protocol compatibility with the Java server
|
||||||
|
4. **Debug handshake issues**: Check that HELLO packet is sent before CP_PermissionRequest
|
||||||
|
|
||||||
|
## Debugging Login Issues
|
||||||
|
|
||||||
|
The login sequence should look like this:
|
||||||
|
|
||||||
|
1. **[loopback] HELLO** - Server sends handshake with IVs
|
||||||
|
2. **[client] CP_PermissionRequest** - Client sends version check
|
||||||
|
3. **[loopback] RSA_KEY / LOGIN_AUTH** - Server sends RSA key and login background
|
||||||
|
4. **[client] CP_CheckPassword** - Client sends login credentials
|
||||||
|
5. **[loopback] LOGIN_STATUS** - Server sends authentication result
|
||||||
|
|
||||||
|
If packets are missing or out of order, check:
|
||||||
|
- Network connectivity
|
||||||
|
- Client version compatibility (v342)
|
||||||
|
- Opcode mappings in `opcodes.ex`
|
||||||
|
- Handler routing in `processor.ex`
|
||||||
@@ -59,7 +59,7 @@ defmodule Odinsea.Channel.Handler.Alliance do
|
|||||||
|
|
||||||
# Handle deny separately
|
# Handle deny separately
|
||||||
if op == 22 do
|
if op == 22 do
|
||||||
handle_deny_invite(client_pid, character_id, char_state, guild_id)
|
handle_deny_invite(client_pid, char_state)
|
||||||
else
|
else
|
||||||
handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
|
handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
|
||||||
end
|
end
|
||||||
@@ -255,8 +255,15 @@ defmodule Odinsea.Channel.Handler.Alliance do
|
|||||||
Also called when op == 22 in alliance operation.
|
Also called when op == 22 in alliance operation.
|
||||||
|
|
||||||
Reference: AllianceHandler.DenyInvite()
|
Reference: AllianceHandler.DenyInvite()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- client_pid: The client process ID
|
||||||
|
- character_state: The character's current state (includes guild_id)
|
||||||
"""
|
"""
|
||||||
def handle_deny_invite(client_pid, character_id, char_state, guild_id) do
|
def handle_deny_invite(client_pid, character_state) do
|
||||||
|
character_id = character_state.id
|
||||||
|
guild_id = character_state.guild_id
|
||||||
|
|
||||||
# Get invited alliance ID
|
# Get invited alliance ID
|
||||||
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
|
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
|
||||||
|
|
||||||
|
|||||||
@@ -2016,6 +2016,221 @@ defmodule Odinsea.Channel.Packets do
|
|||||||
|> Out.to_data()
|
|> Out.to_data()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Buddy List Packets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the buddy list for a character.
|
||||||
|
Ported from MaplePacketCreator.updateBuddylist()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- buddy_list: List of buddy entries
|
||||||
|
- deleted: Operation type (7 = update, or other values for different operations)
|
||||||
|
"""
|
||||||
|
def update_buddylist(buddy_list, deleted \\ 7) do
|
||||||
|
packet = Out.new(Opcodes.lp_buddylist())
|
||||||
|
|> Out.encode_byte(deleted)
|
||||||
|
|> Out.encode_byte(length(buddy_list))
|
||||||
|
|
||||||
|
# Encode each buddy entry
|
||||||
|
packet = Enum.reduce(buddy_list, packet, fn buddy, p ->
|
||||||
|
p
|
||||||
|
|> Out.encode_int(buddy.character_id)
|
||||||
|
|> Out.encode_string(buddy.name, 13)
|
||||||
|
|> Out.encode_byte(if buddy.visible, do: 0, else: 1)
|
||||||
|
|> Out.encode_int(if buddy.channel == -1, do: -1, else: buddy.channel - 1)
|
||||||
|
|> Out.encode_string(buddy.group || "ETC", 17)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Padding ints for each buddy (always 0)
|
||||||
|
packet = Enum.reduce(buddy_list, packet, fn _, p ->
|
||||||
|
Out.encode_int(p, 0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Out.to_data(packet)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a buddy request to a character.
|
||||||
|
Ported from MaplePacketCreator.requestBuddylistAdd()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- cid_from: Character ID sending the request
|
||||||
|
- name_from: Name of character sending the request
|
||||||
|
- level_from: Level of character sending the request
|
||||||
|
- job_from: Job ID of character sending the request
|
||||||
|
"""
|
||||||
|
def request_buddylist_add(cid_from, name_from, level_from, job_from) do
|
||||||
|
Out.new(Opcodes.lp_buddylist())
|
||||||
|
|> Out.encode_byte(9)
|
||||||
|
|> Out.encode_int(cid_from)
|
||||||
|
|> Out.encode_string(name_from)
|
||||||
|
|> Out.encode_int(level_from)
|
||||||
|
|> Out.encode_int(job_from)
|
||||||
|
|> Out.encode_int(cid_from)
|
||||||
|
|> Out.encode_string(name_from, 13)
|
||||||
|
|> Out.encode_byte(1)
|
||||||
|
|> Out.encode_int(0)
|
||||||
|
|> Out.encode_string("ETC", 16)
|
||||||
|
|> Out.encode_short(1)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a buddy list message/status code.
|
||||||
|
Ported from MaplePacketCreator.buddylistMessage()
|
||||||
|
|
||||||
|
## Message codes:
|
||||||
|
- 11: Buddy list full
|
||||||
|
- 12: Target buddy list full
|
||||||
|
- 13: Already registered as buddy
|
||||||
|
- 14: Character not found
|
||||||
|
- 15: Request sent
|
||||||
|
- 17: You cannot add yourself as a buddy
|
||||||
|
- 19: Already requested
|
||||||
|
- 21: Cannot add GM to buddy list
|
||||||
|
"""
|
||||||
|
def buddylist_message(message_code) do
|
||||||
|
Out.new(Opcodes.lp_buddylist())
|
||||||
|
|> Out.encode_byte(message_code)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Party Packets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Party creation confirmation packet.
|
||||||
|
Ported from MaplePacketCreator.partyCreated()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- party_id: The newly created party ID
|
||||||
|
"""
|
||||||
|
def party_created(party_id) do
|
||||||
|
opcode_byte = if Odinsea.Constants.Game.gms?(), do: 10, else: 8
|
||||||
|
|
||||||
|
Out.new(Opcodes.lp_party_operation())
|
||||||
|
|> Out.encode_byte(opcode_byte)
|
||||||
|
|> Out.encode_int(party_id)
|
||||||
|
|> Out.encode_int(999_999_999)
|
||||||
|
|> Out.encode_int(999_999_999)
|
||||||
|
|> Out.encode_long(0)
|
||||||
|
|> Out.encode_byte(0)
|
||||||
|
|> Out.encode_byte(1)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Party invitation packet (sent to invited player).
|
||||||
|
Ported from MaplePacketCreator.partyInvite()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- from_character: Character struct of the inviter (needs :party_id, :name, :level, :job)
|
||||||
|
"""
|
||||||
|
def party_invite(from_character) do
|
||||||
|
party_id = from_character.party_id || 0
|
||||||
|
|
||||||
|
Out.new(Opcodes.lp_party_operation())
|
||||||
|
|> Out.encode_byte(4)
|
||||||
|
|> Out.encode_int(party_id)
|
||||||
|
|> Out.encode_string(from_character.name)
|
||||||
|
|> Out.encode_int(from_character.level)
|
||||||
|
|> Out.encode_int(from_character.job)
|
||||||
|
|> Out.encode_byte(0)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Party request packet (for request-to-join scenarios).
|
||||||
|
Ported from MaplePacketCreator.partyRequestInvite()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- from_character: Character struct of the requester (needs :id, :name, :level, :job)
|
||||||
|
"""
|
||||||
|
def party_request(from_character) do
|
||||||
|
Out.new(Opcodes.lp_party_operation())
|
||||||
|
|> Out.encode_byte(7)
|
||||||
|
|> Out.encode_int(from_character.id)
|
||||||
|
|> Out.encode_string(from_character.name)
|
||||||
|
|> Out.encode_int(from_character.level)
|
||||||
|
|> Out.encode_int(from_character.job)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Party status/error message packet.
|
||||||
|
Ported from MaplePacketCreator.partyStatusMessage()
|
||||||
|
|
||||||
|
## Message codes:
|
||||||
|
- 10: A beginner can't create a party
|
||||||
|
- 11: Your request for a party didn't work due to an unexpected error
|
||||||
|
- 13: You have yet to join a party
|
||||||
|
- 16: Already have joined a party
|
||||||
|
- 17: The party you're trying to join is already in full capacity
|
||||||
|
- 19: Unable to find the requested character in this channel
|
||||||
|
- 23: 'Char' have denied request to the party (with charname)
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- message_code: The status/error code
|
||||||
|
- charname: Optional character name for personalized messages
|
||||||
|
"""
|
||||||
|
def party_status_message(message_code, charname \\ nil) do
|
||||||
|
opcode_byte = if Odinsea.Constants.Game.gms?() and message_code >= 7,
|
||||||
|
do: message_code + 2,
|
||||||
|
else: message_code
|
||||||
|
|
||||||
|
packet = Out.new(Opcodes.lp_party_operation())
|
||||||
|
|> Out.encode_byte(opcode_byte)
|
||||||
|
|
||||||
|
packet = if charname do
|
||||||
|
Out.encode_string(packet, charname)
|
||||||
|
else
|
||||||
|
packet
|
||||||
|
end
|
||||||
|
|
||||||
|
Out.to_data(packet)
|
||||||
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Guild Packets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Guild invitation packet (sent to invited player).
|
||||||
|
Ported from MaplePacketCreator.guildInvite()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- guild_id: ID of the guild
|
||||||
|
- from_name: Name of the character sending the invite
|
||||||
|
- from_level: Level of the character sending the invite
|
||||||
|
- from_job: Job ID of the character sending the invite
|
||||||
|
"""
|
||||||
|
def guild_invite(guild_id, from_name, from_level, from_job) do
|
||||||
|
Out.new(Opcodes.lp_guild_operation())
|
||||||
|
|> Out.encode_byte(0x05)
|
||||||
|
|> Out.encode_int(guild_id)
|
||||||
|
|> Out.encode_string(from_name)
|
||||||
|
|> Out.encode_int(from_level)
|
||||||
|
|> Out.encode_int(from_job)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Guild invitation denial packet.
|
||||||
|
Ported from MaplePacketCreator.denyGuildInvitation()
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- charname: Name of the character who denied the invitation
|
||||||
|
"""
|
||||||
|
def deny_guild_invitation(charname) do
|
||||||
|
Out.new(Opcodes.lp_guild_operation())
|
||||||
|
|> Out.encode_byte(0x3D)
|
||||||
|
|> Out.encode_string(charname)
|
||||||
|
|> Out.to_data()
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Utility Functions
|
# Utility Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ defmodule Odinsea.Channel.Players do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Finds a player by name in the channel.
|
||||||
|
Returns the player data or nil if not found.
|
||||||
|
|
||||||
|
This is the public API for player lookup by name.
|
||||||
|
"""
|
||||||
|
def find_by_name(name, _channel_id \\ nil) do
|
||||||
|
get_player_by_name(name)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates player data.
|
Updates player data.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ defmodule Odinsea.Constants.Server do
|
|||||||
These define the MapleStory client version and protocol details.
|
These define the MapleStory client version and protocol details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# MapleStory Client Version (GMS v342)
|
# MapleStory Client Version (MapleSEA v112.4)
|
||||||
@maple_version 342
|
# Ported from ServerConstants.java
|
||||||
@maple_patch "1"
|
@maple_version 112
|
||||||
|
@maple_patch "4"
|
||||||
|
@maple_locale 7
|
||||||
@client_version 99
|
@client_version 99
|
||||||
|
|
||||||
# Protocol constants
|
# Protocol constants
|
||||||
@@ -15,9 +17,10 @@ defmodule Odinsea.Constants.Server do
|
|||||||
@block_size 1460
|
@block_size 1460
|
||||||
|
|
||||||
# RSA Keys (from ServerConstants.java)
|
# RSA Keys (from ServerConstants.java)
|
||||||
@pub_key ""
|
# Default MapleStory RSA public key for password encryption
|
||||||
@maplogin_default "default"
|
@pub_key "30819F300D06092A864886F70D010101050003818D0030818902818100994F4E66B003A7843C944E67BE4375203DAA203C676908E59839C9BADE95F53E848AAFE61DB9C09E80F48675CA2696F4E897B7F18CCB6398D221C4EC5823D11CA1FB9764A78F84711B8B6FCA9F01B171A51EC66C02CDA9308887CEE8E59C4FF0B146BF71F697EB11EDCEBFCE02FB0101A7076A3FEB64F6F6022C8417EB6B87270203010001"
|
||||||
@maplogin_custom "custom"
|
@maplogin_default "MapLogin"
|
||||||
|
@maplogin_custom "MapLoginLuna"
|
||||||
|
|
||||||
# Packet sequence constants
|
# Packet sequence constants
|
||||||
@iv_length 4
|
@iv_length 4
|
||||||
@@ -35,6 +38,12 @@ defmodule Odinsea.Constants.Server do
|
|||||||
"""
|
"""
|
||||||
def maple_patch, do: @maple_patch
|
def maple_patch, do: @maple_patch
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the MapleStory locale (region code).
|
||||||
|
For SEA/MSEA this is 7.
|
||||||
|
"""
|
||||||
|
def maple_locale, do: @maple_locale
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the full client version string.
|
Returns the full client version string.
|
||||||
"""
|
"""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,33 +11,33 @@ defmodule Odinsea.Database.Schema.Account do
|
|||||||
@timestamps_opts [inserted_at: :createdat, updated_at: false]
|
@timestamps_opts [inserted_at: :createdat, updated_at: false]
|
||||||
|
|
||||||
schema "accounts" do
|
schema "accounts" do
|
||||||
field :name, :string
|
field(:name, :string)
|
||||||
field :password, :string
|
field(:password, :string)
|
||||||
field :salt, :string
|
field(:salt, :string)
|
||||||
field :second_password, :string, source: :"2ndpassword"
|
field(:second_password, :string, source: :second_password)
|
||||||
field :salt2, :string
|
field(:second_salt, :string)
|
||||||
field :loggedin, :integer, default: 0
|
field(:loggedin, :integer, default: 0)
|
||||||
field :lastlogin, :naive_datetime
|
field(:lastlogin, :naive_datetime)
|
||||||
field :createdat, :naive_datetime
|
field(:createdat, :naive_datetime)
|
||||||
field :birthday, :date
|
field(:birthday, :date)
|
||||||
field :banned, :integer, default: 0
|
field(:banned, :integer, default: 0)
|
||||||
field :banreason, :string
|
field(:banreason, :string)
|
||||||
field :gm, :integer, default: 0
|
field(:gm, :integer, default: 0)
|
||||||
field :email, :string
|
field(:email, :string)
|
||||||
field :macs, :string
|
field(:macs, :string)
|
||||||
field :tempban, :naive_datetime
|
field(:tempban, :naive_datetime)
|
||||||
field :greason, :integer
|
field(:greason, :integer)
|
||||||
field :acash, :integer, default: 0, source: :ACash
|
field(:acash, :integer, default: 0, source: :ACash)
|
||||||
field :mpoints, :integer, default: 0, source: :mPoints
|
field(:mpoints, :integer, default: 0, source: :mPoints)
|
||||||
field :gender, :integer, default: 0
|
field(:gender, :integer, default: 0)
|
||||||
field :session_ip, :string, source: :SessionIP
|
field(:session_ip, :string, source: :SessionIP)
|
||||||
field :points, :integer, default: 0
|
field(:points, :integer, default: 0)
|
||||||
field :vpoints, :integer, default: 0
|
field(:vpoints, :integer, default: 0)
|
||||||
field :totalvotes, :integer, default: 0
|
field(:totalvotes, :integer, default: 0)
|
||||||
field :lastlogon, :naive_datetime
|
field(:lastlogon, :naive_datetime)
|
||||||
field :lastvoteip, :string
|
field(:lastvoteip, :string)
|
||||||
|
|
||||||
has_many :characters, Odinsea.Database.Schema.Character, foreign_key: :accountid
|
has_many(:characters, Odinsea.Database.Schema.Character, foreign_key: :accountid)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -986,6 +986,13 @@ defmodule Odinsea.Game.Character do
|
|||||||
GenServer.call(via_tuple(character_id), {:remove_item_by_id, item_id, quantity})
|
GenServer.call(via_tuple(character_id), {:remove_item_by_id, item_id, quantity})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the buddy list for a character.
|
||||||
|
"""
|
||||||
|
def update_buddies(character_id, buddies) do
|
||||||
|
GenServer.cast(via_tuple(character_id), {:update_buddies, buddies})
|
||||||
|
end
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# GenServer Callbacks - Scripting Operations
|
# GenServer Callbacks - Scripting Operations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1014,6 +1021,14 @@ defmodule Odinsea.Game.Character do
|
|||||||
{:noreply, new_state}
|
{:noreply, new_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_cast({:update_buddies, buddies}, state) do
|
||||||
|
# TODO: Store buddies in state when buddy list is added to State struct
|
||||||
|
# For now, just log the update
|
||||||
|
Logger.debug("Updated buddy list for character #{state.name}")
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:add_item, inventory_type, item}, _from, state) do
|
def handle_call({:add_item, inventory_type, item}, _from, state) do
|
||||||
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
|
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
|
||||||
|
|||||||
@@ -155,6 +155,34 @@ defmodule Odinsea.Game.Inventory do
|
|||||||
inv.slot_limit - map_size(inv.items)
|
inv.slot_limit - map_size(inv.items)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the inventory has space for a given item and quantity.
|
||||||
|
Returns true if there's space, false otherwise.
|
||||||
|
"""
|
||||||
|
def has_space?(%__MODULE__{} = inv, item_id, quantity) do
|
||||||
|
# Check if we can stack with existing items of same type
|
||||||
|
existing = find_by_id(inv, item_id)
|
||||||
|
|
||||||
|
if existing do
|
||||||
|
# Can stack - check if existing stack + quantity <= slot_max
|
||||||
|
# For simplicity, assume slot_max of 9999 for stackable items
|
||||||
|
slot_max = existing[:slot_max] || 9999
|
||||||
|
existing_qty = existing.quantity || 0
|
||||||
|
|
||||||
|
if existing_qty + quantity <= slot_max do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# Need additional slots for overflow
|
||||||
|
overflow = existing_qty + quantity - slot_max
|
||||||
|
slots_needed = div(overflow, slot_max) + if rem(overflow, slot_max) > 0, do: 1, else: 0
|
||||||
|
free_slots(inv) >= slots_needed
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# New item - need at least one free slot
|
||||||
|
not full?(inv)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets the next available slot number.
|
Gets the next available slot number.
|
||||||
Returns -1 if the inventory is full.
|
Returns -1 if the inventory is full.
|
||||||
|
|||||||
@@ -124,4 +124,30 @@ defmodule Odinsea.Game.InventoryType do
|
|||||||
def slot_limit(type) do
|
def slot_limit(type) do
|
||||||
default_slot_limit(type)
|
default_slot_limit(type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the inventory type from an item_id or type atom.
|
||||||
|
|
||||||
|
For item IDs, determines type based on ID ranges:
|
||||||
|
- Equip: 1-999,999
|
||||||
|
- Use: 2,000,000-2,999,999
|
||||||
|
- Setup: 3,000,000-3,999,999
|
||||||
|
- Etc: 4,000,000-4,999,999
|
||||||
|
- Cash: 5,000,000+
|
||||||
|
|
||||||
|
For atoms, returns the atom if valid, or :undefined.
|
||||||
|
"""
|
||||||
|
def get_type(item_id) when is_integer(item_id) do
|
||||||
|
from_item_id(item_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_type(type) when is_atom(type) do
|
||||||
|
if type in all_types() do
|
||||||
|
type
|
||||||
|
else
|
||||||
|
:undefined
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_type(_), do: :undefined
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ defmodule Odinsea.Game.Map do
|
|||||||
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
|
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets all monsters on the map (default channel).
|
||||||
|
"""
|
||||||
|
def get_monsters(map_id) do
|
||||||
|
get_monsters(map_id, 1)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Spawns a monster at the specified spawn point.
|
Spawns a monster at the specified spawn point.
|
||||||
"""
|
"""
|
||||||
@@ -230,6 +237,13 @@ defmodule Odinsea.Game.Map do
|
|||||||
GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id})
|
GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Damages a monster (default channel).
|
||||||
|
"""
|
||||||
|
def damage_monster(map_id, oid, damage, character_id) do
|
||||||
|
damage_monster(map_id, 1, oid, damage, character_id)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Hits a reactor, advancing its state and triggering effects.
|
Hits a reactor, advancing its state and triggering effects.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
defmodule Odinsea.Login.Client do
|
defmodule Odinsea.Login.Client do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Client connection handler for the login server.
|
Client connection handler for the login server.
|
||||||
Manages the login session state.
|
Manages the login session state and packet encryption/decryption.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use GenServer, restart: :temporary
|
use GenServer, restart: :temporary
|
||||||
@@ -10,6 +10,10 @@ defmodule Odinsea.Login.Client do
|
|||||||
|
|
||||||
alias Odinsea.Net.Packet.In
|
alias Odinsea.Net.Packet.In
|
||||||
alias Odinsea.Net.Opcodes
|
alias Odinsea.Net.Opcodes
|
||||||
|
alias Odinsea.Net.PacketLogger
|
||||||
|
alias Odinsea.Login.Packets
|
||||||
|
alias Odinsea.Net.Cipher.ClientCrypto
|
||||||
|
alias Odinsea.Util.BitTools
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:socket,
|
:socket,
|
||||||
@@ -25,7 +29,25 @@ defmodule Odinsea.Login.Client do
|
|||||||
:second_password,
|
:second_password,
|
||||||
:gender,
|
:gender,
|
||||||
:is_gm,
|
:is_gm,
|
||||||
:hardware_info
|
:hardware_info,
|
||||||
|
:crypto,
|
||||||
|
:handshake_complete,
|
||||||
|
|
||||||
|
# === NEW FIELDS - Critical Priority ===
|
||||||
|
:created_at, # Session creation time (for session timeout)
|
||||||
|
:last_alive_ack, # Last pong received timestamp
|
||||||
|
:server_transition, # Boolean - migrating between servers
|
||||||
|
:macs, # [String.t()] - MAC addresses for ban checking
|
||||||
|
:character_slots, # integer() - Max chars per world (default 3)
|
||||||
|
|
||||||
|
# === NEW FIELDS - Medium Priority ===
|
||||||
|
:birthday, # integer() - YYMMDD format for PIN/SPW verification
|
||||||
|
:monitored, # boolean() - GM monitoring flag
|
||||||
|
:tempban, # DateTime.t() | nil - Temporary ban info
|
||||||
|
:chat_mute, # boolean() - Chat restriction
|
||||||
|
|
||||||
|
buffer: <<>>,
|
||||||
|
character_ids: []
|
||||||
]
|
]
|
||||||
|
|
||||||
def start_link(socket) do
|
def start_link(socket) do
|
||||||
@@ -39,6 +61,16 @@ defmodule Odinsea.Login.Client do
|
|||||||
|
|
||||||
Logger.info("Login client connected from #{ip_string}")
|
Logger.info("Login client connected from #{ip_string}")
|
||||||
|
|
||||||
|
# Generate IVs for encryption (4 bytes each)
|
||||||
|
send_iv = :crypto.strong_rand_bytes(4)
|
||||||
|
recv_iv = :crypto.strong_rand_bytes(4)
|
||||||
|
|
||||||
|
# Create crypto context
|
||||||
|
crypto = ClientCrypto.new_from_ivs(112, send_iv, recv_iv)
|
||||||
|
|
||||||
|
# Get current timestamp for session tracking
|
||||||
|
current_time = System.system_time(:millisecond)
|
||||||
|
|
||||||
state = %__MODULE__{
|
state = %__MODULE__{
|
||||||
socket: socket,
|
socket: socket,
|
||||||
ip: ip_string,
|
ip: ip_string,
|
||||||
@@ -53,9 +85,27 @@ defmodule Odinsea.Login.Client do
|
|||||||
second_password: nil,
|
second_password: nil,
|
||||||
gender: 0,
|
gender: 0,
|
||||||
is_gm: false,
|
is_gm: false,
|
||||||
hardware_info: nil
|
hardware_info: nil,
|
||||||
|
crypto: crypto,
|
||||||
|
handshake_complete: false,
|
||||||
|
buffer: <<>>,
|
||||||
|
character_ids: [],
|
||||||
|
|
||||||
|
# === NEW FIELDS INITIALIZATION ===
|
||||||
|
created_at: current_time,
|
||||||
|
last_alive_ack: current_time,
|
||||||
|
server_transition: false,
|
||||||
|
macs: [],
|
||||||
|
character_slots: 3,
|
||||||
|
birthday: nil,
|
||||||
|
monitored: false,
|
||||||
|
tempban: nil,
|
||||||
|
chat_mute: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Send hello packet (handshake) - unencrypted
|
||||||
|
send_hello_packet(state, send_iv, recv_iv)
|
||||||
|
|
||||||
# Start receiving packets
|
# Start receiving packets
|
||||||
send(self(), :receive)
|
send(self(), :receive)
|
||||||
|
|
||||||
@@ -66,8 +116,9 @@ defmodule Odinsea.Login.Client do
|
|||||||
def handle_info(:receive, %{socket: socket} = state) do
|
def handle_info(:receive, %{socket: socket} = state) do
|
||||||
case :gen_tcp.recv(socket, 0, 30_000) do
|
case :gen_tcp.recv(socket, 0, 30_000) do
|
||||||
{:ok, data} ->
|
{:ok, data} ->
|
||||||
# Handle packet
|
# Append to buffer and process all complete packets
|
||||||
new_state = handle_packet(data, state)
|
new_state = %{state | buffer: state.buffer <> data}
|
||||||
|
new_state = process_buffer(new_state)
|
||||||
send(self(), :receive)
|
send(self(), :receive)
|
||||||
{:noreply, new_state}
|
{:noreply, new_state}
|
||||||
|
|
||||||
@@ -96,23 +147,100 @@ defmodule Odinsea.Login.Client do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_packet(data, state) do
|
# Process all complete packets from the TCP buffer
|
||||||
packet = In.new(data)
|
defp process_buffer(state) do
|
||||||
|
case extract_packet(state.buffer, state.crypto) do
|
||||||
|
{:ok, payload, remaining} ->
|
||||||
|
# Decrypt the payload (AES then Shanda) and morph recv IV
|
||||||
|
{updated_crypto, decrypted} = ClientCrypto.decrypt(state.crypto, payload)
|
||||||
|
|
||||||
|
state = %{state |
|
||||||
|
buffer: remaining,
|
||||||
|
crypto: updated_crypto,
|
||||||
|
handshake_complete: true
|
||||||
|
}
|
||||||
|
|
||||||
|
state = process_decrypted_packet(decrypted, state)
|
||||||
|
|
||||||
|
# Try to process more packets from the buffer
|
||||||
|
process_buffer(state)
|
||||||
|
|
||||||
|
{:need_more, _} ->
|
||||||
|
state
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Packet error from #{state.ip}: #{inspect(reason)}")
|
||||||
|
send(self(), {:disconnect, reason})
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract a complete encrypted packet from the buffer using the 4-byte header
|
||||||
|
defp extract_packet(buffer, _crypto) when byte_size(buffer) < 4 do
|
||||||
|
{:need_more, buffer}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_packet(buffer, crypto) do
|
||||||
|
<<raw_seq::little-16, raw_len::little-16, rest::binary>> = buffer
|
||||||
|
|
||||||
|
# Validate header against current recv IV
|
||||||
|
if not ClientCrypto.decode_header_valid?(crypto, raw_seq) do
|
||||||
|
Logger.warning(
|
||||||
|
"Invalid packet header: raw_seq=#{raw_seq} (0x#{Integer.to_string(raw_seq, 16)}), " <>
|
||||||
|
"expected version check failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :invalid_header}
|
||||||
|
else
|
||||||
|
# Decode actual packet length from header
|
||||||
|
packet_len = ClientCrypto.decode_header_len(crypto, raw_seq, raw_len)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
packet_len < 2 ->
|
||||||
|
{:error, :invalid_length_small}
|
||||||
|
|
||||||
|
packet_len > 65535 ->
|
||||||
|
{:error, :invalid_length_large}
|
||||||
|
|
||||||
|
byte_size(rest) < packet_len ->
|
||||||
|
# Incomplete packet - wait for more data (don't consume header)
|
||||||
|
{:need_more, buffer}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# Extract the encrypted payload and keep remainder
|
||||||
|
payload = binary_part(rest, 0, packet_len)
|
||||||
|
remaining = binary_part(rest, packet_len, byte_size(rest) - packet_len)
|
||||||
|
{:ok, payload, remaining}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_decrypted_packet(decrypted_data, state) do
|
||||||
|
packet = In.new(decrypted_data)
|
||||||
|
|
||||||
# Read opcode (first 2 bytes)
|
# Read opcode (first 2 bytes)
|
||||||
case In.decode_short(packet) do
|
case In.decode_short(packet) do
|
||||||
{opcode, packet} ->
|
{opcode, packet} ->
|
||||||
Logger.debug("Login packet received: opcode=0x#{Integer.to_string(opcode, 16)}")
|
# Extract remaining data (after opcode) for logging
|
||||||
|
remaining_data = binary_part(packet.data, packet.index, packet.length - packet.index)
|
||||||
|
|
||||||
|
# Log the decrypted packet
|
||||||
|
context = %{
|
||||||
|
ip: state.ip,
|
||||||
|
server_type: :login
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketLogger.log_client_packet(opcode, remaining_data, context)
|
||||||
|
|
||||||
dispatch_packet(opcode, packet, state)
|
dispatch_packet(opcode, packet, state)
|
||||||
|
|
||||||
:error ->
|
:error ->
|
||||||
Logger.warning("Failed to read packet opcode")
|
Logger.warning("Failed to read packet opcode from #{state.ip}")
|
||||||
state
|
state
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch_packet(opcode, packet, state) do
|
defp dispatch_packet(opcode, packet, state) do
|
||||||
# Use PacketProcessor to route packets
|
|
||||||
alias Odinsea.Net.Processor
|
alias Odinsea.Net.Processor
|
||||||
|
|
||||||
case Processor.handle(opcode, packet, state, :login) do
|
case Processor.handle(opcode, packet, state, :login) do
|
||||||
@@ -137,4 +265,66 @@ defmodule Odinsea.Login.Client do
|
|||||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp send_hello_packet(state, send_iv, recv_iv) do
|
||||||
|
# Get maple version from config
|
||||||
|
maple_version = 112
|
||||||
|
|
||||||
|
# Build hello packet
|
||||||
|
hello_packet = Packets.get_hello(maple_version, send_iv, recv_iv)
|
||||||
|
|
||||||
|
# Log the hello packet
|
||||||
|
context = %{ip: state.ip, server_type: :login}
|
||||||
|
PacketLogger.log_raw_packet("loopback", "HELLO", hello_packet, context)
|
||||||
|
|
||||||
|
# Send the hello packet (it already includes the length header)
|
||||||
|
case :gen_tcp.send(state.socket, hello_packet) do
|
||||||
|
:ok ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to send hello packet: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a packet to the client with proper encryption.
|
||||||
|
"""
|
||||||
|
def send_packet(client_pid, packet_data) when is_pid(client_pid) do
|
||||||
|
GenServer.call(client_pid, {:send_packet, packet_data})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:send_packet, packet_data}, _from, state) do
|
||||||
|
case encrypt_and_send(packet_data, state) do
|
||||||
|
{:ok, new_state} ->
|
||||||
|
{:reply, :ok, new_state}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:reply, {:error, reason}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp encrypt_and_send(data, state) do
|
||||||
|
# Encrypt the data (Shanda then AES) and morph send IV
|
||||||
|
{updated_crypto, encrypted, header} = ClientCrypto.encrypt(state.crypto, data)
|
||||||
|
|
||||||
|
# Combine header and encrypted payload
|
||||||
|
full_packet = header <> encrypted
|
||||||
|
|
||||||
|
# Log the outgoing packet
|
||||||
|
context = %{ip: state.ip, server_type: :login}
|
||||||
|
PacketLogger.log_server_packet("SERVER", data, context)
|
||||||
|
|
||||||
|
# Send the packet
|
||||||
|
case :gen_tcp.send(state.socket, full_packet) do
|
||||||
|
:ok ->
|
||||||
|
{:ok, %{state | crypto: updated_crypto}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ defmodule Odinsea.Login.Handler do
|
|||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Odinsea.Net.Packet.{In, Out}
|
alias Odinsea.Net.Packet.{In, Out}
|
||||||
alias Odinsea.Net.Cipher.LoginCrypto
|
alias Odinsea.Net.Cipher.{ClientCrypto, LoginCrypto}
|
||||||
|
alias Odinsea.Net.PacketLogger
|
||||||
alias Odinsea.Login.Packets
|
alias Odinsea.Login.Packets
|
||||||
alias Odinsea.Constants.Server
|
alias Odinsea.Constants.Server
|
||||||
alias Odinsea.Database.Context
|
alias Odinsea.Database.Context
|
||||||
@@ -79,11 +80,18 @@ defmodule Odinsea.Login.Handler do
|
|||||||
Logger.info("Login attempt: username=#{username} from #{state.ip}")
|
Logger.info("Login attempt: username=#{username} from #{state.ip}")
|
||||||
|
|
||||||
# Check if IP/MAC is banned
|
# Check if IP/MAC is banned
|
||||||
|
features = Application.get_env(:odinsea, :features, [])
|
||||||
|
skip_maccheck = Keyword.get(features, :skip_maccheck, false)
|
||||||
|
|
||||||
ip_banned = Context.ip_banned?(state.ip)
|
ip_banned = Context.ip_banned?(state.ip)
|
||||||
mac_banned = Context.mac_banned?(state.mac)
|
mac_banned = if skip_maccheck or state.macs == [] do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
Enum.any?(state.macs, &Context.mac_banned?/1)
|
||||||
|
end
|
||||||
|
|
||||||
if (ip_banned || mac_banned) do
|
if (ip_banned || mac_banned) do
|
||||||
Logger.warning("Banned IP/MAC attempted login: ip=#{state.ip}, mac=#{state.mac}")
|
Logger.warning("Banned IP/MAC attempted login: ip=#{state.ip}, macs=#{inspect(state.macs)}")
|
||||||
|
|
||||||
# If MAC banned, also ban the IP for enforcement
|
# If MAC banned, also ban the IP for enforcement
|
||||||
if mac_banned do
|
if mac_banned do
|
||||||
@@ -91,7 +99,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
end
|
end
|
||||||
|
|
||||||
response = Packets.get_login_failed(3)
|
response = Packets.get_login_failed(3)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
else
|
else
|
||||||
# Authenticate with database
|
# Authenticate with database
|
||||||
@@ -106,7 +114,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
format_timestamp(temp_ban_info.expires),
|
format_timestamp(temp_ban_info.expires),
|
||||||
temp_ban_info.reason || ""
|
temp_ban_info.reason || ""
|
||||||
)
|
)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
else
|
else
|
||||||
# Check if already logged in - kick other session
|
# Check if already logged in - kick other session
|
||||||
@@ -132,6 +140,8 @@ defmodule Odinsea.Login.Handler do
|
|||||||
account_info.second_password
|
account_info.second_password
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state = send_packet(state, response)
|
||||||
|
|
||||||
new_state =
|
new_state =
|
||||||
state
|
state
|
||||||
|> Map.put(:logged_in, true)
|
|> Map.put(:logged_in, true)
|
||||||
@@ -142,8 +152,8 @@ defmodule Odinsea.Login.Handler do
|
|||||||
|> Map.put(:second_password, account_info.second_password)
|
|> Map.put(:second_password, account_info.second_password)
|
||||||
|> Map.put(:login_attempts, 0)
|
|> Map.put(:login_attempts, 0)
|
||||||
|
|
||||||
send_packet(state, response)
|
# Send world info immediately after auth success (Java: LoginWorker.registerClient)
|
||||||
{:ok, new_state}
|
on_world_info_request(new_state)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, :invalid_credentials} ->
|
{:error, :invalid_credentials} ->
|
||||||
@@ -156,7 +166,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
else
|
else
|
||||||
# Send login failed (reason 4 = incorrect password)
|
# Send login failed (reason 4 = incorrect password)
|
||||||
response = Packets.get_login_failed(4)
|
response = Packets.get_login_failed(4)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
new_state = Map.put(state, :login_attempts, login_attempts)
|
new_state = Map.put(state, :login_attempts, login_attempts)
|
||||||
{:ok, new_state}
|
{:ok, new_state}
|
||||||
@@ -165,7 +175,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
{:error, :account_not_found} ->
|
{:error, :account_not_found} ->
|
||||||
# Send login failed (reason 5 = not registered ID)
|
# Send login failed (reason 5 = not registered ID)
|
||||||
response = Packets.get_login_failed(5)
|
response = Packets.get_login_failed(5)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
|
|
||||||
{:error, :already_logged_in} ->
|
{:error, :already_logged_in} ->
|
||||||
@@ -176,12 +186,12 @@ defmodule Odinsea.Login.Handler do
|
|||||||
|
|
||||||
# Send login failed (reason 7 = already logged in) but client can retry
|
# Send login failed (reason 7 = already logged in) but client can retry
|
||||||
response = Packets.get_login_failed(7)
|
response = Packets.get_login_failed(7)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
|
|
||||||
{:error, :banned} ->
|
{:error, :banned} ->
|
||||||
response = Packets.get_perm_ban(0)
|
response = Packets.get_perm_ban(0)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -209,19 +219,19 @@ defmodule Odinsea.Login.Handler do
|
|||||||
channel_load
|
channel_load
|
||||||
)
|
)
|
||||||
|
|
||||||
send_packet(state, server_list)
|
state = send_packet(state, server_list)
|
||||||
|
|
||||||
# Send end of server list
|
# Send end of server list
|
||||||
end_list = Packets.get_end_of_server_list()
|
end_list = Packets.get_end_of_server_list()
|
||||||
send_packet(state, end_list)
|
state = send_packet(state, end_list)
|
||||||
|
|
||||||
# Send latest connected world
|
# Send latest connected world
|
||||||
latest_world = Packets.get_latest_connected_world(0)
|
latest_world = Packets.get_latest_connected_world(0)
|
||||||
send_packet(state, latest_world)
|
state = send_packet(state, latest_world)
|
||||||
|
|
||||||
# Send recommended world message
|
# Send recommended world message
|
||||||
recommend = Packets.get_recommend_world_message(0, "Join now!")
|
recommend = Packets.get_recommend_world_message(0, "Join now!")
|
||||||
send_packet(state, recommend)
|
state = send_packet(state, recommend)
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -246,7 +256,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
end
|
end
|
||||||
|
|
||||||
response = Packets.get_server_status(status)
|
response = Packets.get_server_status(status)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -277,7 +287,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
if world_id != 0 do
|
if world_id != 0 do
|
||||||
Logger.warning("Invalid world ID: #{world_id}")
|
Logger.warning("Invalid world ID: #{world_id}")
|
||||||
response = Packets.get_login_failed(10)
|
response = Packets.get_login_failed(10)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
else
|
else
|
||||||
# TODO: Check if channel is available
|
# TODO: Check if channel is available
|
||||||
@@ -299,7 +309,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
3 # character slots
|
3 # character slots
|
||||||
)
|
)
|
||||||
|
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
new_state =
|
new_state =
|
||||||
state
|
state
|
||||||
@@ -332,7 +342,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
name_used = check_name_used(char_name, state)
|
name_used = check_name_used(char_name, state)
|
||||||
|
|
||||||
response = Packets.get_char_name_response(char_name, name_used)
|
response = Packets.get_char_name_response(char_name, name_used)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -381,7 +391,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
# Validate name is not forbidden and doesn't exist
|
# Validate name is not forbidden and doesn't exist
|
||||||
if check_name_used(name, state) do
|
if check_name_used(name, state) do
|
||||||
response = Packets.get_add_new_char_entry(nil, false)
|
response = Packets.get_add_new_char_entry(nil, false)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
else
|
else
|
||||||
# TODO: Validate appearance items are eligible for gender/job type
|
# TODO: Validate appearance items are eligible for gender/job type
|
||||||
@@ -420,7 +430,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
# Reload character with full data
|
# Reload character with full data
|
||||||
char_data = Context.load_character(character.id)
|
char_data = Context.load_character(character.id)
|
||||||
response = Packets.get_add_new_char_entry(char_data, true)
|
response = Packets.get_add_new_char_entry(char_data, true)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
# Add character ID to state's character list
|
# Add character ID to state's character list
|
||||||
new_char_ids = [character.id | Map.get(state, :character_ids, [])]
|
new_char_ids = [character.id | Map.get(state, :character_ids, [])]
|
||||||
@@ -430,7 +440,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
Logger.error("Failed to create character: #{inspect(changeset.errors)}")
|
Logger.error("Failed to create character: #{inspect(changeset.errors)}")
|
||||||
response = Packets.get_add_new_char_entry(nil, false)
|
response = Packets.get_add_new_char_entry(nil, false)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -511,7 +521,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
end
|
end
|
||||||
|
|
||||||
response = Packets.get_delete_char_response(character_id, result)
|
response = Packets.get_delete_char_response(character_id, result)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
# Update state if successful
|
# Update state if successful
|
||||||
new_state =
|
new_state =
|
||||||
@@ -559,7 +569,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
# Validate second password length
|
# Validate second password length
|
||||||
if String.length(new_spw) < 6 || String.length(new_spw) > 16 do
|
if String.length(new_spw) < 6 || String.length(new_spw) > 16 do
|
||||||
response = Packets.get_second_pw_error(0x14)
|
response = Packets.get_second_pw_error(0x14)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
else
|
else
|
||||||
# Update second password
|
# Update second password
|
||||||
@@ -605,7 +615,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
|
|
||||||
# Send migration command
|
# Send migration command
|
||||||
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
|
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
|
|
||||||
new_state =
|
new_state =
|
||||||
state
|
state
|
||||||
@@ -643,7 +653,7 @@ defmodule Odinsea.Login.Handler do
|
|||||||
else
|
else
|
||||||
# Failure - send error
|
# Failure - send error
|
||||||
response = Packets.get_second_pw_error(15) # Incorrect SPW
|
response = Packets.get_second_pw_error(15) # Incorrect SPW
|
||||||
send_packet(state, response)
|
state = send_packet(state, response)
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -662,14 +672,12 @@ defmodule Odinsea.Login.Handler do
|
|||||||
# TODO: Send damage cap packet if custom client
|
# TODO: Send damage cap packet if custom client
|
||||||
|
|
||||||
# Send login background
|
# Send login background
|
||||||
background = Application.get_env(:odinsea, :login_background, "MapLogin")
|
bg_response = Packets.get_login_background(Server.maplogin_default())
|
||||||
bg_response = Packets.get_login_background(background)
|
state = send_packet(state, bg_response)
|
||||||
send_packet(state, bg_response)
|
|
||||||
|
|
||||||
# Send RSA public key
|
# Send RSA public key
|
||||||
pub_key = Application.get_env(:odinsea, :rsa_public_key, "")
|
key_response = Packets.get_rsa_key(Server.pub_key())
|
||||||
key_response = Packets.get_rsa_key(pub_key)
|
state = send_packet(state, key_response)
|
||||||
send_packet(state, key_response)
|
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -678,27 +686,40 @@ defmodule Odinsea.Login.Handler do
|
|||||||
# Helper Functions
|
# Helper Functions
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
|
|
||||||
defp send_packet(%{socket: socket} = state, packet_data) do
|
defp send_packet(%{socket: socket, crypto: crypto} = state, packet_data) do
|
||||||
# Add header (2 bytes: packet length)
|
# Flatten iodata to binary for pattern matching
|
||||||
packet_length = byte_size(packet_data)
|
packet_data = IO.iodata_to_binary(packet_data)
|
||||||
header = <<packet_length::little-size(16)>>
|
|
||||||
full_packet = header <> packet_data
|
# Extract opcode from packet data (first 2 bytes)
|
||||||
|
<<opcode::little-16, rest::binary>> = packet_data
|
||||||
|
|
||||||
|
# Log the packet
|
||||||
|
context = %{
|
||||||
|
ip: state.ip,
|
||||||
|
server_type: :login
|
||||||
|
}
|
||||||
|
PacketLogger.log_server_packet(opcode, rest, context)
|
||||||
|
|
||||||
|
# Encrypt the data (Shanda then AES) and morph send IV
|
||||||
|
{updated_crypto, encrypted, header} = ClientCrypto.encrypt(crypto, packet_data)
|
||||||
|
|
||||||
|
# Send encrypted packet with 4-byte crypto header
|
||||||
|
full_packet = header <> encrypted
|
||||||
|
|
||||||
case :gen_tcp.send(socket, full_packet) do
|
case :gen_tcp.send(socket, full_packet) do
|
||||||
:ok ->
|
:ok ->
|
||||||
Logger.debug("Sent packet: #{packet_length} bytes")
|
%{state | crypto: updated_crypto}
|
||||||
:ok
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Failed to send packet: #{inspect(reason)}")
|
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||||
{:error, reason}
|
state
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp send_packet(_state, _packet_data) do
|
defp send_packet(state, _packet_data) do
|
||||||
# Socket not available in state
|
# Socket not available in state
|
||||||
Logger.error("Cannot send packet: socket not in state")
|
Logger.error("Cannot send packet: socket not in state")
|
||||||
:error
|
state
|
||||||
end
|
end
|
||||||
|
|
||||||
defp authenticate_user(username, password, _state) do
|
defp authenticate_user(username, password, _state) do
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|> Out.encode_buffer(recv_iv)
|
|> Out.encode_buffer(recv_iv)
|
||||||
|> Out.encode_buffer(send_iv)
|
|> Out.encode_buffer(send_iv)
|
||||||
|> Out.encode_byte(Server.maple_locale())
|
|> Out.encode_byte(Server.maple_locale())
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -49,27 +49,26 @@ defmodule Odinsea.Login.Packets do
|
|||||||
"""
|
"""
|
||||||
def get_ping do
|
def get_ping do
|
||||||
Out.new(Opcodes.lp_alive_req())
|
Out.new(Opcodes.lp_alive_req())
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the login background image path to the client.
|
Sends the login background image path to the client.
|
||||||
"""
|
"""
|
||||||
def get_login_background(background_path) do
|
def get_login_background(background_path) do
|
||||||
# Note: In Java this uses LoopbackPacket.LOGIN_AUTH
|
# Uses LOGIN_AUTH (0x17) opcode
|
||||||
# Need to verify the correct opcode for this
|
Out.new(Opcodes.lp_login_auth())
|
||||||
Out.new(Opcodes.lp_set_client_key()) # TODO: Verify opcode
|
|
||||||
|> Out.encode_string(background_path)
|
|> Out.encode_string(background_path)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the RSA public key to the client for password encryption.
|
Sends the RSA public key to the client for password encryption.
|
||||||
"""
|
"""
|
||||||
def get_rsa_key(public_key) do
|
def get_rsa_key(public_key) do
|
||||||
Out.new(Opcodes.lp_set_client_key())
|
Out.new(Opcodes.lp_rsa_key())
|
||||||
|> Out.encode_string(public_key)
|
|> Out.encode_string(public_key)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
@@ -104,13 +103,13 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|
|
||||||
reason == 7 ->
|
reason == 7 ->
|
||||||
# Already logged in
|
# Already logged in
|
||||||
Out.encode_bytes(packet, <<0, 0, 0, 0, 0>>)
|
Out.encode_buffer(packet, <<0, 0, 0, 0, 0>>)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
packet
|
packet
|
||||||
end
|
end
|
||||||
|
|
||||||
Out.to_data(packet)
|
Out.to_iodata(packet)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -122,7 +121,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|> Out.encode_int(0)
|
|> Out.encode_int(0)
|
||||||
|> Out.encode_short(reason)
|
|> Out.encode_short(reason)
|
||||||
|> Out.encode_buffer(<<1, 1, 1, 1, 0>>)
|
|> Out.encode_buffer(<<1, 1, 1, 1, 0>>)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -134,7 +133,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|> Out.encode_short(0)
|
|> Out.encode_short(0)
|
||||||
|> Out.encode_byte(reason)
|
|> Out.encode_byte(reason)
|
||||||
|> Out.encode_long(timestamp_till)
|
|> Out.encode_long(timestamp_till)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -147,26 +146,23 @@ defmodule Odinsea.Login.Packets do
|
|||||||
- `is_gm` - Admin/GM status
|
- `is_gm` - Admin/GM status
|
||||||
- `second_password` - Second password (nil if not set)
|
- `second_password` - Second password (nil if not set)
|
||||||
"""
|
"""
|
||||||
def get_auth_success(account_id, account_name, gender, is_gm, second_password) do
|
def get_auth_success(account_id, account_name, gender, is_gm, _second_password) do
|
||||||
admin_byte = if is_gm, do: 1, else: 0
|
admin_byte = if is_gm, do: 1, else: 0
|
||||||
spw_byte = get_second_password_byte(second_password)
|
|
||||||
|
|
||||||
Out.new(Opcodes.lp_check_password_result())
|
Out.new(Opcodes.lp_check_password_result())
|
||||||
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>) # GMS specific
|
|> Out.encode_byte(0) # MSEA: 1 byte padding (not 6 like GMS)
|
||||||
|> Out.encode_int(account_id)
|
|> Out.encode_int(account_id)
|
||||||
|> Out.encode_byte(gender)
|
|> Out.encode_byte(gender)
|
||||||
|> Out.encode_byte(admin_byte) # Admin byte - Find, Trade, etc.
|
|> Out.encode_byte(admin_byte) # Admin byte - Find, Trade, etc.
|
||||||
|> Out.encode_short(2) # GMS: 2 for existing accounts, 0 for new
|
# NO encode_short(2) for MSEA - this is GMS only!
|
||||||
|> Out.encode_byte(admin_byte) # Admin byte - Commands
|
|> Out.encode_byte(admin_byte) # Admin byte - Commands
|
||||||
|> Out.encode_string(account_name)
|
|> Out.encode_string(account_name)
|
||||||
|> Out.encode_int(3) # 3 for existing accounts, 0 for new
|
|> Out.encode_int(3) # 3 for existing accounts, 0 for new
|
||||||
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>)
|
|> Out.encode_buffer(<<0, 0, 0, 0, 0, 0>>) # 6 bytes padding
|
||||||
|> Out.encode_long(get_time(System.system_time(:millisecond))) # Account creation date
|
# MSEA ending (different from GMS - no time long, PIN/SPW bytes, random long)
|
||||||
|> Out.encode_int(4) # 4 for existing accounts, 0 for new
|
|> Out.encode_short(0)
|
||||||
|> Out.encode_byte(1) # 1 = PIN disabled, 0 = PIN enabled
|
|> Out.encode_int(get_current_date())
|
||||||
|> Out.encode_byte(spw_byte) # Second password status
|
|> Out.to_iodata()
|
||||||
|> Out.encode_long(:rand.uniform(1_000_000_000_000_000_000)) # Random long for anti-hack
|
|
||||||
|> Out.to_data()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
@@ -187,14 +183,14 @@ defmodule Odinsea.Login.Packets do
|
|||||||
last_channel = get_last_channel(channel_load)
|
last_channel = get_last_channel(channel_load)
|
||||||
|
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_world_information())
|
Out.new(Opcodes.lp_server_list())
|
||||||
|> Out.encode_byte(server_id)
|
|> Out.encode_byte(server_id)
|
||||||
|> Out.encode_string(world_name)
|
|> Out.encode_string(world_name)
|
||||||
|> Out.encode_byte(flag)
|
|> Out.encode_byte(flag)
|
||||||
|> Out.encode_string(event_message)
|
|> Out.encode_string(event_message)
|
||||||
|> Out.encode_short(100) # EXP rate display
|
|> Out.encode_short(100) # EXP rate display
|
||||||
|> Out.encode_short(100) # Drop rate display
|
|> Out.encode_short(100) # Drop rate display
|
||||||
|> Out.encode_byte(0) # GMS specific
|
# NO encode_byte(0) for MSEA - this is GMS only!
|
||||||
|> Out.encode_byte(last_channel)
|
|> Out.encode_byte(last_channel)
|
||||||
|
|
||||||
# Encode channel list
|
# Encode channel list
|
||||||
@@ -212,16 +208,16 @@ defmodule Odinsea.Login.Packets do
|
|||||||
packet
|
packet
|
||||||
|> Out.encode_short(0) # Balloon message size
|
|> Out.encode_short(0) # Balloon message size
|
||||||
|> Out.encode_int(0)
|
|> Out.encode_int(0)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the end-of-server-list marker.
|
Sends the end-of-server-list marker.
|
||||||
"""
|
"""
|
||||||
def get_end_of_server_list do
|
def get_end_of_server_list do
|
||||||
Out.new(Opcodes.lp_world_information())
|
Out.new(Opcodes.lp_server_list())
|
||||||
|> Out.encode_byte(0xFF)
|
|> Out.encode_byte(0xFF)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -233,9 +229,9 @@ defmodule Odinsea.Login.Packets do
|
|||||||
- 2: Full
|
- 2: Full
|
||||||
"""
|
"""
|
||||||
def get_server_status(status) do
|
def get_server_status(status) do
|
||||||
Out.new(Opcodes.lp_select_world_result())
|
Out.new(Opcodes.lp_server_status())
|
||||||
|> Out.encode_short(status)
|
|> Out.encode_short(status)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -244,7 +240,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
def get_latest_connected_world(world_id) do
|
def get_latest_connected_world(world_id) do
|
||||||
Out.new(Opcodes.lp_latest_connected_world())
|
Out.new(Opcodes.lp_latest_connected_world())
|
||||||
|> Out.encode_int(world_id)
|
|> Out.encode_int(world_id)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -263,7 +259,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
Out.encode_byte(packet, 0)
|
Out.encode_byte(packet, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
Out.to_data(packet)
|
Out.to_iodata(packet)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
@@ -272,6 +268,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends character list for selected world.
|
Sends character list for selected world.
|
||||||
|
MSEA v112.4 specific encoding.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- `characters` - List of character maps
|
- `characters` - List of character maps
|
||||||
@@ -279,55 +276,70 @@ defmodule Odinsea.Login.Packets do
|
|||||||
- `char_slots` - Number of character slots (default 3)
|
- `char_slots` - Number of character slots (default 3)
|
||||||
"""
|
"""
|
||||||
def get_char_list(characters, second_password, char_slots \\ 3) do
|
def get_char_list(characters, second_password, char_slots \\ 3) do
|
||||||
spw_byte = get_second_password_byte(second_password)
|
# MSEA: no special handling for empty string SPW (GMS uses byte 2 for empty)
|
||||||
|
spw_byte = if second_password != nil and second_password != "", do: 1, else: 0
|
||||||
|
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_select_character_result())
|
Out.new(Opcodes.lp_char_list())
|
||||||
|> Out.encode_byte(0)
|
|> Out.encode_byte(0)
|
||||||
|> Out.encode_byte(length(characters))
|
|> Out.encode_byte(length(characters))
|
||||||
|
|
||||||
# TODO: Encode each character entry
|
# Encode each character entry with MSEA-specific encoding
|
||||||
# For now, just encode empty list structure
|
|
||||||
packet =
|
packet =
|
||||||
Enum.reduce(characters, packet, fn _char, acc ->
|
Enum.reduce(characters, packet, fn char, acc ->
|
||||||
# add_char_entry(acc, char)
|
add_char_entry(acc, char)
|
||||||
acc # TODO: Implement character encoding
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
packet
|
packet
|
||||||
|> Out.encode_byte(spw_byte)
|
|> Out.encode_byte(spw_byte)
|
||||||
|
|> Out.encode_byte(0) # MSEA ONLY: extra byte after SPW
|
||||||
|> Out.encode_long(char_slots)
|
|> Out.encode_long(char_slots)
|
||||||
|> Out.to_data()
|
|> Out.encode_long(-:rand.uniform(9_223_372_036_854_775_807)) # MSEA ONLY: negative random long
|
||||||
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Character name check response.
|
Character name check response.
|
||||||
"""
|
"""
|
||||||
def get_char_name_response(char_name, name_used) do
|
def get_char_name_response(char_name, name_used) do
|
||||||
Out.new(Opcodes.lp_check_duplicated_id_result())
|
Out.new(Opcodes.lp_char_name_response())
|
||||||
|> Out.encode_string(char_name)
|
|> Out.encode_string(char_name)
|
||||||
|> Out.encode_byte(if name_used, do: 1, else: 0)
|
|> Out.encode_byte(if name_used, do: 1, else: 0)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Character creation response.
|
Character creation response.
|
||||||
"""
|
"""
|
||||||
def get_add_new_char_entry(character, worked) do
|
def get_add_new_char_entry(character, worked) do
|
||||||
Out.new(Opcodes.lp_create_new_character_result())
|
# Uses LP_AddNewCharEntry (0x0A) opcode
|
||||||
|
packet = Out.new(Opcodes.lp_add_new_char_entry())
|
||||||
|> Out.encode_byte(if worked, do: 0, else: 1)
|
|> Out.encode_byte(if worked, do: 0, else: 1)
|
||||||
# TODO: Add character entry if worked
|
|
||||||
|> Out.to_data()
|
if worked do
|
||||||
|
# Add character entry for new character (ranking = false for creation)
|
||||||
|
packet = add_char_stats(packet, character)
|
||||||
|
packet = add_char_look(packet, character)
|
||||||
|
|
||||||
|
# viewAll = false, ranking = false for char creation
|
||||||
|
packet
|
||||||
|
|> Out.encode_byte(0) # viewAll
|
||||||
|
|> Out.encode_byte(0) # no ranking for new char
|
||||||
|
else
|
||||||
|
packet
|
||||||
|
end
|
||||||
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Character deletion response.
|
Character deletion response.
|
||||||
"""
|
"""
|
||||||
def get_delete_char_response(character_id, state) do
|
def get_delete_char_response(character_id, state) do
|
||||||
Out.new(Opcodes.lp_delete_character_result())
|
# Uses LP_DeleteCharResponse (0x0B) opcode
|
||||||
|
Out.new(Opcodes.lp_delete_char_response())
|
||||||
|> Out.encode_int(character_id)
|
|> Out.encode_int(character_id)
|
||||||
|> Out.encode_byte(state)
|
|> Out.encode_byte(state)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
@@ -344,7 +356,7 @@ defmodule Odinsea.Login.Packets do
|
|||||||
def get_second_pw_error(mode) do
|
def get_second_pw_error(mode) do
|
||||||
Out.new(Opcodes.lp_check_spw_result())
|
Out.new(Opcodes.lp_check_spw_result())
|
||||||
|> Out.encode_byte(mode)
|
|> Out.encode_byte(mode)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
@@ -361,28 +373,30 @@ defmodule Odinsea.Login.Packets do
|
|||||||
- `character_id` - Character ID for migration
|
- `character_id` - Character ID for migration
|
||||||
"""
|
"""
|
||||||
def get_server_ip(is_cash_shop, host, port, character_id) do
|
def get_server_ip(is_cash_shop, host, port, character_id) do
|
||||||
|
# Uses LP_ServerIP (0x08) opcode
|
||||||
# Parse IP address
|
# Parse IP address
|
||||||
ip_parts = parse_ip(host)
|
ip_parts = parse_ip(host)
|
||||||
|
|
||||||
Out.new(Opcodes.lp_migrate_command())
|
Out.new(Opcodes.lp_server_ip())
|
||||||
|> Out.encode_short(if is_cash_shop, do: 1, else: 0)
|
|> Out.encode_short(if is_cash_shop, do: 1, else: 0)
|
||||||
|> encode_ip(ip_parts)
|
|> encode_ip(ip_parts)
|
||||||
|> Out.encode_short(port)
|
|> Out.encode_short(port)
|
||||||
|> Out.encode_int(character_id)
|
|> Out.encode_int(character_id)
|
||||||
|> Out.encode_bytes(<<0, 0>>)
|
|> Out.encode_buffer(<<0, 0>>)
|
||||||
|> Out.to_data()
|
|> Out.to_iodata()
|
||||||
end
|
end
|
||||||
|
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
|
|
||||||
defp get_second_password_byte(second_password) do
|
@doc """
|
||||||
cond do
|
Returns current date in MSEA format: YYYYMMDD as integer.
|
||||||
second_password == nil -> 0
|
Used in authentication success packet.
|
||||||
second_password == "" -> 2
|
"""
|
||||||
true -> 1
|
def get_current_date do
|
||||||
end
|
{{year, month, day}, _} = :calendar.local_time()
|
||||||
|
year * 10000 + month * 100 + day
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_last_channel(channel_load) do
|
defp get_last_channel(channel_load) do
|
||||||
@@ -431,4 +445,228 @@ defmodule Odinsea.Login.Packets do
|
|||||||
|> Out.encode_byte(c)
|
|> Out.encode_byte(c)
|
||||||
|> Out.encode_byte(d)
|
|> Out.encode_byte(d)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Character Entry Encoding (MSEA v112.4)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Adds a character entry to the packet for character list.
|
||||||
|
Ported from LoginPacket.addCharEntry() for MSEA.
|
||||||
|
"""
|
||||||
|
def add_char_entry(packet, character) do
|
||||||
|
packet = add_char_stats(packet, character)
|
||||||
|
packet = add_char_look(packet, character)
|
||||||
|
|
||||||
|
# viewAll = false, so encode byte 0
|
||||||
|
packet = Out.encode_byte(packet, 0)
|
||||||
|
|
||||||
|
# Ranking (true if not GM and level >= 30)
|
||||||
|
ranking = not Map.get(character, :is_gm, false) and Map.get(character, :level, 1) >= 30
|
||||||
|
packet = Out.encode_byte(packet, if(ranking, do: 1, else: 0))
|
||||||
|
|
||||||
|
if ranking do
|
||||||
|
packet
|
||||||
|
|> Out.encode_int(Map.get(character, :rank, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :rank_move, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :job_rank, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :job_rank_move, 0))
|
||||||
|
else
|
||||||
|
packet
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encodes character stats for MSEA v112.4.
|
||||||
|
Ported from PacketHelper.addCharStats() - MSEA path (GMS = false).
|
||||||
|
|
||||||
|
MSEA Differences from GMS:
|
||||||
|
- NO 24 bytes padding after hair
|
||||||
|
- encode_long(0) after Gach EXP
|
||||||
|
- NO encode_int(0) after spawnpoint
|
||||||
|
"""
|
||||||
|
def add_char_stats(packet, character) do
|
||||||
|
packet
|
||||||
|
|> Out.encode_int(Map.get(character, :id, 0))
|
||||||
|
|> Out.encode_string(Map.get(character, :name, ""), 13)
|
||||||
|
|> Out.encode_byte(Map.get(character, :gender, 0))
|
||||||
|
|> Out.encode_byte(Map.get(character, :skin_color, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :face, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :hair, 0))
|
||||||
|
# MSEA: NO 24 bytes padding (GMS has it)
|
||||||
|
|> Out.encode_byte(Map.get(character, :level, 1))
|
||||||
|
|> Out.encode_short(Map.get(character, :job, 0))
|
||||||
|
|> encode_char_stats_data(character)
|
||||||
|
|> Out.encode_short(Map.get(character, :remaining_ap, 0))
|
||||||
|
|> encode_remaining_sp(character)
|
||||||
|
|> Out.encode_int(Map.get(character, :exp, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :fame, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :gach_exp, 0))
|
||||||
|
# MSEA ONLY: encode_long(0) after Gach EXP
|
||||||
|
|> Out.encode_long(0)
|
||||||
|
|> Out.encode_int(Map.get(character, :map_id, 0))
|
||||||
|
|> Out.encode_byte(Map.get(character, :spawnpoint, 0))
|
||||||
|
# MSEA: NO encode_int(0) after spawnpoint (GMS has it)
|
||||||
|
|> Out.encode_short(Map.get(character, :subcategory, 0))
|
||||||
|
|> Out.encode_byte(Map.get(character, :fatigue, 0))
|
||||||
|
|> Out.encode_int(get_current_date())
|
||||||
|
|> encode_traits(character)
|
||||||
|
|> Out.encode_buffer(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) # 12 bytes padding
|
||||||
|
|> Out.encode_int(Map.get(character, :pvp_exp, 0))
|
||||||
|
|> Out.encode_byte(Map.get(character, :pvp_rank, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :battle_points, 0))
|
||||||
|
|> Out.encode_byte(5)
|
||||||
|
# MSEA: NO final encode_int(0) that GMS has
|
||||||
|
end
|
||||||
|
|
||||||
|
# Encodes the main character stats (str, dex, int, luk, hp, mp, max hp, max mp)
|
||||||
|
defp encode_char_stats_data(packet, character) do
|
||||||
|
stats = Map.get(character, :stats, %{})
|
||||||
|
|
||||||
|
packet
|
||||||
|
|> Out.encode_short(Map.get(stats, :str, 12))
|
||||||
|
|> Out.encode_short(Map.get(stats, :dex, 5))
|
||||||
|
|> Out.encode_short(Map.get(stats, :int, 4))
|
||||||
|
|> Out.encode_short(Map.get(stats, :luk, 4))
|
||||||
|
|> Out.encode_short(Map.get(stats, :hp, 50))
|
||||||
|
|> Out.encode_short(Map.get(stats, :max_hp, 50))
|
||||||
|
|> Out.encode_short(Map.get(stats, :mp, 5))
|
||||||
|
|> Out.encode_short(Map.get(stats, :max_mp, 5))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Encodes remaining SP based on job type
|
||||||
|
defp encode_remaining_sp(packet, character) do
|
||||||
|
job = Map.get(character, :job, 0)
|
||||||
|
sp_data = Map.get(character, :remaining_sp, %{type: :short, value: 0})
|
||||||
|
|
||||||
|
# Check for extended SP classes (Evan, Resistance, Mercedes)
|
||||||
|
if is_extended_sp_job?(job) do
|
||||||
|
sp_list = Map.get(character, :remaining_sps, [])
|
||||||
|
packet = Out.encode_byte(packet, length(sp_list))
|
||||||
|
|
||||||
|
Enum.reduce(sp_list, packet, fn {sp_index, sp_value}, p ->
|
||||||
|
p
|
||||||
|
|> Out.encode_byte(sp_index)
|
||||||
|
|> Out.encode_byte(sp_value)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
Out.encode_short(packet, Map.get(sp_data, :value, 0))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Jobs that use extended SP format
|
||||||
|
defp is_extended_sp_job?(job) do
|
||||||
|
# Evan: 2200-2218
|
||||||
|
# Resistance: 3000-3512
|
||||||
|
# Mercedes: 2300-2312
|
||||||
|
(job >= 2200 and job <= 2218) or
|
||||||
|
(job >= 3000 and job <= 3512) or
|
||||||
|
(job >= 2300 and job <= 2312)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Encodes trait data (charisma, insight, will, craft, sense, charm)
|
||||||
|
defp encode_traits(packet, character) do
|
||||||
|
traits = Map.get(character, :traits, [0, 0, 0, 0, 0, 0])
|
||||||
|
|
||||||
|
Enum.reduce(traits, packet, fn trait_exp, p ->
|
||||||
|
Out.encode_int(p, trait_exp)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encodes character appearance (look) for MSEA v112.4.
|
||||||
|
Ported from PacketHelper.addCharLook().
|
||||||
|
|
||||||
|
MSEA uses different equipment slot positions than GMS:
|
||||||
|
- Mount: MSEA = -23/-24, GMS = -18/-19
|
||||||
|
- Pendant: MSEA = -55, GMS = -59
|
||||||
|
"""
|
||||||
|
def add_char_look(packet, character) do
|
||||||
|
mega = true # For character list, mega = true
|
||||||
|
|
||||||
|
packet =
|
||||||
|
packet
|
||||||
|
|> Out.encode_byte(Map.get(character, :gender, 0))
|
||||||
|
|> Out.encode_byte(Map.get(character, :skin_color, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :face, 0))
|
||||||
|
|> Out.encode_int(Map.get(character, :job, 0))
|
||||||
|
|> Out.encode_byte(if mega, do: 0, else: 1)
|
||||||
|
|> Out.encode_int(Map.get(character, :hair, 0))
|
||||||
|
|
||||||
|
equipment = Map.get(character, :equipment, %{})
|
||||||
|
|
||||||
|
# Process equipment slots
|
||||||
|
{visible_equip, masked_equip} = process_char_look_equipment(equipment)
|
||||||
|
|
||||||
|
# Encode visible equipment
|
||||||
|
packet =
|
||||||
|
Enum.reduce(visible_equip, packet, fn {slot, item_id}, p ->
|
||||||
|
p
|
||||||
|
|> Out.encode_byte(slot)
|
||||||
|
|> Out.encode_int(item_id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# End of visible items marker
|
||||||
|
packet = Out.encode_byte(packet, 0xFF)
|
||||||
|
|
||||||
|
# Encode masked equipment (overrides visible)
|
||||||
|
packet =
|
||||||
|
Enum.reduce(masked_equip, packet, fn {slot, item_id}, p ->
|
||||||
|
p
|
||||||
|
|> Out.encode_byte(slot)
|
||||||
|
|> Out.encode_int(item_id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# End of masked items marker
|
||||||
|
packet = Out.encode_byte(packet, 0xFF)
|
||||||
|
|
||||||
|
# cash weapon (slot -111)
|
||||||
|
cash_weapon = Map.get(equipment, -111, 0)
|
||||||
|
|
||||||
|
packet
|
||||||
|
|> Out.encode_int(cash_weapon)
|
||||||
|
|> Out.encode_int(0) # Unknown/ears
|
||||||
|
|> Out.encode_long(0) # Padding
|
||||||
|
end
|
||||||
|
|
||||||
|
# Processes equipment for char look encoding
|
||||||
|
# Returns {visible_equip_map, masked_equip_map}
|
||||||
|
defp process_char_look_equipment(equipment) do
|
||||||
|
equipment
|
||||||
|
|> Enum.reduce({%{}, %{}}, fn {pos, item_id}, {visible, masked} = acc ->
|
||||||
|
# Skip hidden equipment (slot < -127)
|
||||||
|
if pos < -127 do
|
||||||
|
acc
|
||||||
|
else
|
||||||
|
slot = abs(pos)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
# Normal visible equipment (slots 1-99)
|
||||||
|
slot < 100 ->
|
||||||
|
if Map.has_key?(visible, slot) do
|
||||||
|
# Move existing to masked, put new in visible
|
||||||
|
{Map.put(visible, slot, item_id), Map.put(masked, slot, visible[slot])}
|
||||||
|
else
|
||||||
|
{Map.put(visible, slot, item_id), masked}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cash equipment (slots 100+, except 111)
|
||||||
|
slot > 100 and slot != 111 ->
|
||||||
|
actual_slot = slot - 100
|
||||||
|
|
||||||
|
if Map.has_key?(visible, actual_slot) do
|
||||||
|
# Replace visible with cash, move old visible to masked
|
||||||
|
{Map.put(visible, actual_slot, item_id),
|
||||||
|
Map.put(masked, actual_slot, visible[actual_slot])}
|
||||||
|
else
|
||||||
|
{Map.put(visible, actual_slot, item_id), masked}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Other slots (111 = cash weapon, etc.) - skip for now
|
||||||
|
true ->
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ defmodule Odinsea.Net.Cipher.AESCipher do
|
|||||||
Ported from: src/handling/netty/cipher/AESCipher.java
|
Ported from: src/handling/netty/cipher/AESCipher.java
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
@block_size 1460
|
@block_size 1460
|
||||||
|
|
||||||
# MapleStory AES key (32 bytes, expanded from the Java version)
|
# MapleStory AES key (32 bytes = AES-256)
|
||||||
|
# Must match the Java AES_KEY exactly
|
||||||
@aes_key <<
|
@aes_key <<
|
||||||
0x13, 0x00, 0x00, 0x00,
|
0x13, 0x00, 0x00, 0x00,
|
||||||
0x08, 0x00, 0x00, 0x00,
|
0x08, 0x00, 0x00, 0x00,
|
||||||
@@ -21,7 +24,14 @@ defmodule Odinsea.Net.Cipher.AESCipher do
|
|||||||
>>
|
>>
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Encrypts or decrypts packet data in place using AES-ECB with IV.
|
Encrypts or decrypts packet data using AES-ECB with IV.
|
||||||
|
|
||||||
|
The algorithm (per block of 1456/1460 bytes):
|
||||||
|
1. Expand the 4-byte IV to 16 bytes by repeating it 4 times
|
||||||
|
2. Use AES-ECB to encrypt the expanded IV, producing a 16-byte keystream
|
||||||
|
3. XOR the next 16 bytes of data with the keystream
|
||||||
|
4. The encrypted IV becomes the new IV for the next 16-byte chunk
|
||||||
|
5. Repeat until the block is processed
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- data: Binary data to encrypt/decrypt
|
- data: Binary data to encrypt/decrypt
|
||||||
@@ -32,74 +42,52 @@ defmodule Odinsea.Net.Cipher.AESCipher do
|
|||||||
"""
|
"""
|
||||||
@spec crypt(binary(), binary()) :: binary()
|
@spec crypt(binary(), binary()) :: binary()
|
||||||
def crypt(data, <<_::binary-size(4)>> = iv) when is_binary(data) do
|
def crypt(data, <<_::binary-size(4)>> = iv) when is_binary(data) do
|
||||||
crypt_recursive(data, iv, 0, byte_size(data), @block_size - 4)
|
data_list = :binary.bin_to_list(data)
|
||||||
|
result = crypt_blocks(data_list, 0, @block_size - 4, iv)
|
||||||
|
:binary.list_to_bin(result)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Recursive encryption/decryption function
|
# Process data in blocks (first block: 1456 bytes, subsequent: 1460 bytes)
|
||||||
@spec crypt_recursive(binary(), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: binary()
|
defp crypt_blocks([], _start, _length, _iv), do: []
|
||||||
defp crypt_recursive(data, _iv, start, remaining, _length) when remaining <= 0 do
|
|
||||||
# Return the portion of data we've processed
|
|
||||||
binary_part(data, 0, start)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp crypt_recursive(data, iv, start, remaining, length) do
|
defp crypt_blocks(data, start, length, iv) do
|
||||||
# Multiply the IV by 4
|
remaining = Kernel.length(data)
|
||||||
seq_iv = multiply_bytes(iv, byte_size(iv), 4)
|
|
||||||
|
|
||||||
# Adjust length if remaining is smaller
|
|
||||||
actual_length = min(remaining, length)
|
actual_length = min(remaining, length)
|
||||||
|
|
||||||
# Extract the portion of data to process
|
# Expand 4-byte IV to 16 bytes (repeat 4 times)
|
||||||
data_bytes = :binary.bin_to_list(data)
|
seq_iv = :binary.copy(iv, 4)
|
||||||
|
seq_iv_list = :binary.bin_to_list(seq_iv)
|
||||||
|
|
||||||
# Process the data chunk
|
# Process this block's bytes, re-encrypting keystream every 16 bytes
|
||||||
{new_data_bytes, _final_seq_iv} =
|
{block, rest} = Enum.split(data, actual_length)
|
||||||
process_chunk(data_bytes, seq_iv, start, start + actual_length, 0)
|
{processed, _final_iv} = process_block_bytes(block, seq_iv_list, 0)
|
||||||
|
|
||||||
# Convert back to binary
|
# Continue with next block using fresh IV expansion
|
||||||
new_data = :binary.list_to_bin(new_data_bytes)
|
processed ++ crypt_blocks(rest, start + actual_length, @block_size, iv)
|
||||||
|
|
||||||
# Continue with next chunk
|
|
||||||
new_start = start + actual_length
|
|
||||||
new_remaining = remaining - actual_length
|
|
||||||
new_length = @block_size
|
|
||||||
|
|
||||||
crypt_recursive(new_data, iv, new_start, new_remaining, new_length)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Process a single chunk of data
|
# Process bytes within a single block, re-encrypting keystream every 16 bytes
|
||||||
@spec process_chunk(list(byte()), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
|
defp process_block_bytes([], iv_list, _offset), do: {[], iv_list}
|
||||||
{list(byte()), binary()}
|
|
||||||
defp process_chunk(data_bytes, seq_iv, x, end_x, _offset) when x >= end_x do
|
|
||||||
{data_bytes, seq_iv}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_chunk(data_bytes, seq_iv, x, end_x, offset) do
|
defp process_block_bytes(data, iv_list, offset) do
|
||||||
# Check if we need to re-encrypt the IV
|
# Re-encrypt keystream at every 16-byte boundary
|
||||||
{new_seq_iv, new_offset} =
|
iv_list =
|
||||||
if rem(offset, byte_size(seq_iv)) == 0 do
|
if rem(offset, 16) == 0 do
|
||||||
# Encrypt the IV using AES
|
new_iv = aes_encrypt_block(:binary.list_to_bin(iv_list))
|
||||||
encrypted_iv = aes_encrypt_block(seq_iv)
|
:binary.bin_to_list(new_iv)
|
||||||
{encrypted_iv, 0}
|
|
||||||
else
|
else
|
||||||
{seq_iv, offset}
|
iv_list
|
||||||
end
|
end
|
||||||
|
|
||||||
# XOR the data byte with the IV byte
|
[byte | rest] = data
|
||||||
seq_iv_bytes = :binary.bin_to_list(new_seq_iv)
|
iv_byte = Enum.at(iv_list, rem(offset, 16))
|
||||||
iv_index = rem(new_offset, length(seq_iv_bytes))
|
xored = bxor(byte, iv_byte)
|
||||||
iv_byte = Enum.at(seq_iv_bytes, iv_index)
|
|
||||||
data_byte = Enum.at(data_bytes, x)
|
|
||||||
xor_byte = Bitwise.bxor(data_byte, iv_byte)
|
|
||||||
|
|
||||||
# Update the data
|
{rest_result, final_iv} = process_block_bytes(rest, iv_list, offset + 1)
|
||||||
updated_data = List.replace_at(data_bytes, x, xor_byte)
|
{[xored | rest_result], final_iv}
|
||||||
|
|
||||||
# Continue processing
|
|
||||||
process_chunk(updated_data, new_seq_iv, x + 1, end_x, new_offset + 1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Encrypt a single 16-byte block using AES-ECB
|
# Encrypt a single 16-byte block using AES-256-ECB
|
||||||
@spec aes_encrypt_block(binary()) :: binary()
|
@spec aes_encrypt_block(binary()) :: binary()
|
||||||
defp aes_encrypt_block(block) do
|
defp aes_encrypt_block(block) do
|
||||||
# Pad or truncate to 16 bytes for AES
|
# Pad or truncate to 16 bytes for AES
|
||||||
@@ -110,15 +98,9 @@ defmodule Odinsea.Net.Cipher.AESCipher do
|
|||||||
size when size > 16 -> binary_part(block, 0, 16)
|
size when size > 16 -> binary_part(block, 0, 16)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Perform AES encryption in ECB mode
|
# Perform AES encryption in ECB mode (AES-256)
|
||||||
:crypto.crypto_one_time(:aes_128_ecb, @aes_key, padded_block, true)
|
result = :crypto.crypto_one_time(:aes_256_ecb, @aes_key, padded_block, true)
|
||||||
end
|
# Take only first 16 bytes (OpenSSL may add PKCS padding)
|
||||||
|
binary_part(result, 0, 16)
|
||||||
# Multiply bytes - repeats the first `count` bytes of `input` `mul` times
|
|
||||||
@spec multiply_bytes(binary(), non_neg_integer(), non_neg_integer()) :: binary()
|
|
||||||
defp multiply_bytes(input, count, mul) do
|
|
||||||
# Take first `count` bytes and repeat them `mul` times
|
|
||||||
chunk = binary_part(input, 0, min(count, byte_size(input)))
|
|
||||||
:binary.copy(chunk, mul)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
Ported from: src/handling/netty/ClientCrypto.java
|
Ported from: src/handling/netty/ClientCrypto.java
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Bitwise
|
import Bitwise
|
||||||
|
|
||||||
alias Odinsea.Net.Cipher.{AESCipher, IGCipher}
|
alias Odinsea.Net.Cipher.{AESCipher, IGCipher, ShandaCipher}
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:version,
|
:version,
|
||||||
@@ -32,7 +32,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
Creates a new ClientCrypto instance with random IVs.
|
Creates a new ClientCrypto instance with random IVs.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- version: MapleStory version number (e.g., 342)
|
- version: MapleStory version number (e.g., 112)
|
||||||
- use_custom_crypt: If false, uses AES encryption. If true, uses basic XOR with 0x69
|
- use_custom_crypt: If false, uses AES encryption. If true, uses basic XOR with 0x69
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
@@ -50,38 +50,97 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a new ClientCrypto instance from existing IVs (for server handshake).
|
||||||
|
The server generates its own IVs and sends them to the client.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- version: MapleStory version number
|
||||||
|
- send_iv: 4-byte binary for send IV (server encrypts with this)
|
||||||
|
- recv_iv: 4-byte binary for recv IV (server decrypts with this)
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
- New ClientCrypto struct
|
||||||
|
"""
|
||||||
|
@spec new_from_ivs(integer(), binary(), binary()) :: t()
|
||||||
|
def new_from_ivs(version, send_iv, recv_iv) do
|
||||||
|
%__MODULE__{
|
||||||
|
version: version,
|
||||||
|
use_custom_crypt: false,
|
||||||
|
send_iv: send_iv,
|
||||||
|
send_iv_old: <<0, 0, 0, 0>>,
|
||||||
|
recv_iv: recv_iv,
|
||||||
|
recv_iv_old: <<0, 0, 0, 0>>
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a new ClientCrypto instance from client's IVs (after handshake).
|
||||||
|
The IVs must be SWAPPED because:
|
||||||
|
- Server's send IV = Client's recv IV
|
||||||
|
- Server's recv IV = Client's send IV
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- version: MapleStory version number
|
||||||
|
- client_send_iv: Client's send IV (from client's hello packet)
|
||||||
|
- client_recv_iv: Client's recv IV (from client's hello packet)
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
- New ClientCrypto struct with properly swapped IVs
|
||||||
|
"""
|
||||||
|
@spec new_from_client_ivs(integer(), binary(), binary()) :: t()
|
||||||
|
def new_from_client_ivs(version, client_send_iv, client_recv_iv) do
|
||||||
|
# Swap the IVs: server's send = client's recv, server's recv = client's send
|
||||||
|
%__MODULE__{
|
||||||
|
version: version,
|
||||||
|
use_custom_crypt: false,
|
||||||
|
send_iv: client_recv_iv,
|
||||||
|
send_iv_old: <<0, 0, 0, 0>>,
|
||||||
|
recv_iv: client_send_iv,
|
||||||
|
recv_iv_old: <<0, 0, 0, 0>>
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Encrypts outgoing packet data and updates the send IV.
|
Encrypts outgoing packet data and updates the send IV.
|
||||||
|
Applies Shanda encryption first, then AES encryption.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- crypto: ClientCrypto state
|
- crypto: ClientCrypto state
|
||||||
- data: Binary packet data to encrypt
|
- data: Binary packet data to encrypt
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
- {updated_crypto, encrypted_data}
|
- {updated_crypto, encrypted_data, header}
|
||||||
"""
|
"""
|
||||||
@spec encrypt(t(), binary()) :: {t(), binary()}
|
@spec encrypt(t(), binary()) :: {t(), binary(), binary()}
|
||||||
def encrypt(%__MODULE__{} = crypto, data) when is_binary(data) do
|
def encrypt(%__MODULE__{} = crypto, data) when is_binary(data) do
|
||||||
# Backup current send IV
|
# Backup current send IV
|
||||||
updated_crypto = %{crypto | send_iv_old: crypto.send_iv}
|
updated_crypto = %{crypto | send_iv_old: crypto.send_iv}
|
||||||
|
|
||||||
# Encrypt the data
|
# Generate header BEFORE encryption (uses current IV)
|
||||||
|
header = encode_header_len(updated_crypto, byte_size(data))
|
||||||
|
|
||||||
|
# Apply Shanda encryption first
|
||||||
|
shanda_encrypted = ShandaCipher.encrypt(data)
|
||||||
|
|
||||||
|
# Apply AES encryption
|
||||||
encrypted_data =
|
encrypted_data =
|
||||||
if crypto.use_custom_crypt do
|
if crypto.use_custom_crypt do
|
||||||
basic_cipher(data)
|
basic_cipher(shanda_encrypted)
|
||||||
else
|
else
|
||||||
AESCipher.crypt(data, crypto.send_iv)
|
AESCipher.crypt(shanda_encrypted, crypto.send_iv)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update the send IV using InnoGames hash
|
# Update the send IV using InnoGames hash (AFTER encryption)
|
||||||
new_send_iv = IGCipher.inno_hash(crypto.send_iv)
|
new_send_iv = IGCipher.inno_hash(crypto.send_iv)
|
||||||
final_crypto = %{updated_crypto | send_iv: new_send_iv}
|
final_crypto = %{updated_crypto | send_iv: new_send_iv}
|
||||||
|
|
||||||
{final_crypto, encrypted_data}
|
{final_crypto, encrypted_data, header}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Decrypts incoming packet data and updates the recv IV.
|
Decrypts incoming packet data and updates the recv IV.
|
||||||
|
Applies AES decryption first, then Shanda decryption.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- crypto: ClientCrypto state
|
- crypto: ClientCrypto state
|
||||||
@@ -95,15 +154,18 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
# Backup current recv IV
|
# Backup current recv IV
|
||||||
updated_crypto = %{crypto | recv_iv_old: crypto.recv_iv}
|
updated_crypto = %{crypto | recv_iv_old: crypto.recv_iv}
|
||||||
|
|
||||||
# Decrypt the data
|
# Apply AES decryption
|
||||||
decrypted_data =
|
aes_decrypted =
|
||||||
if crypto.use_custom_crypt do
|
if crypto.use_custom_crypt do
|
||||||
basic_cipher(data)
|
basic_cipher(data)
|
||||||
else
|
else
|
||||||
AESCipher.crypt(data, crypto.recv_iv)
|
AESCipher.crypt(data, crypto.recv_iv)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update the recv IV using InnoGames hash
|
# Apply Shanda decryption
|
||||||
|
decrypted_data = ShandaCipher.decrypt(aes_decrypted)
|
||||||
|
|
||||||
|
# Update the recv IV using InnoGames hash (AFTER decryption)
|
||||||
new_recv_iv = IGCipher.inno_hash(crypto.recv_iv)
|
new_recv_iv = IGCipher.inno_hash(crypto.recv_iv)
|
||||||
final_crypto = %{updated_crypto | recv_iv: new_recv_iv}
|
final_crypto = %{updated_crypto | recv_iv: new_recv_iv}
|
||||||
|
|
||||||
@@ -123,13 +185,14 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
"""
|
"""
|
||||||
@spec encode_header_len(t(), non_neg_integer()) :: binary()
|
@spec encode_header_len(t(), non_neg_integer()) :: binary()
|
||||||
def encode_header_len(%__MODULE__{} = crypto, data_len) do
|
def encode_header_len(%__MODULE__{} = crypto, data_len) do
|
||||||
<<s0, s1, s2, s3>> = crypto.send_iv
|
<<_s0, _s1, s2, s3>> = crypto.send_iv
|
||||||
|
|
||||||
# Calculate the encoded version
|
# Calculate the encoded version
|
||||||
new_version = -(crypto.version + 1) &&& 0xFFFF
|
new_version = -(crypto.version + 1) &&& 0xFFFF
|
||||||
enc_version = (((new_version >>> 8) &&& 0xFF) ||| ((new_version <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
enc_version = (((new_version >>> 8) &&& 0xFF) ||| ((new_version <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||||
|
|
||||||
# Calculate raw sequence from send IV
|
# Calculate raw sequence from send IV
|
||||||
|
# Note: Using s3 and s2 (high bytes) as in Java version
|
||||||
raw_seq = bxor((((s3 &&& 0xFF) ||| ((s2 <<< 8) &&& 0xFF00)) &&& 0xFFFF), enc_version)
|
raw_seq = bxor((((s3 &&& 0xFF) ||| ((s2 <<< 8) &&& 0xFF00)) &&& 0xFFFF), enc_version)
|
||||||
|
|
||||||
# Calculate raw length
|
# Calculate raw length
|
||||||
@@ -155,8 +218,8 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
## Returns
|
## Returns
|
||||||
- Decoded packet length
|
- Decoded packet length
|
||||||
"""
|
"""
|
||||||
@spec decode_header_len(integer(), integer()) :: integer()
|
@spec decode_header_len(t(), integer(), integer()) :: integer()
|
||||||
def decode_header_len(raw_seq, raw_len) do
|
def decode_header_len(%__MODULE__{}, raw_seq, raw_len) do
|
||||||
bxor(raw_seq, raw_len) &&& 0xFFFF
|
bxor(raw_seq, raw_len) &&& 0xFFFF
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -175,6 +238,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
<<_r0, _r1, r2, r3>> = crypto.recv_iv
|
<<_r0, _r1, r2, r3>> = crypto.recv_iv
|
||||||
|
|
||||||
enc_version = crypto.version &&& 0xFFFF
|
enc_version = crypto.version &&& 0xFFFF
|
||||||
|
# Note: Using r2 and r3 as in Java version
|
||||||
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||||
|
|
||||||
bxor((raw_seq &&& 0xFFFF), seq_base) == enc_version
|
bxor((raw_seq &&& 0xFFFF), seq_base) == enc_version
|
||||||
@@ -197,7 +261,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
|
|||||||
defp basic_cipher(data) do
|
defp basic_cipher(data) do
|
||||||
data
|
data
|
||||||
|> :binary.bin_to_list()
|
|> :binary.bin_to_list()
|
||||||
|> Enum.map(fn byte -> Bitwise.bxor(byte, 0x69) end)
|
|> Enum.map(fn byte -> bxor(byte, 0x69) end)
|
||||||
|> :binary.list_to_bin()
|
|> :binary.list_to_bin()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,28 @@ defmodule Odinsea.Net.Cipher.IGCipher do
|
|||||||
Ported from: src/handling/netty/cipher/IGCipher.java
|
Ported from: src/handling/netty/cipher/IGCipher.java
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Bitwise
|
import Bitwise
|
||||||
|
|
||||||
|
# Shuffle table - 256 bytes used for IV transformation
|
||||||
|
# Must be defined before functions that use it
|
||||||
|
@shuffle_table {
|
||||||
|
0xEC, 0x3F, 0x77, 0xA4, 0x45, 0xD0, 0x71, 0xBF, 0xB7, 0x98, 0x20, 0xFC, 0x4B, 0xE9, 0xB3, 0xE1,
|
||||||
|
0x5C, 0x22, 0xF7, 0x0C, 0x44, 0x1B, 0x81, 0xBD, 0x63, 0x8D, 0xD4, 0xC3, 0xF2, 0x10, 0x19, 0xE0,
|
||||||
|
0xFB, 0xA1, 0x6E, 0x66, 0xEA, 0xAE, 0xD6, 0xCE, 0x06, 0x18, 0x4E, 0xEB, 0x78, 0x95, 0xDB, 0xBA,
|
||||||
|
0xB6, 0x42, 0x7A, 0x2A, 0x83, 0x0B, 0x54, 0x67, 0x6D, 0xE8, 0x65, 0xE7, 0x2F, 0x07, 0xF3, 0xAA,
|
||||||
|
0x27, 0x7B, 0x85, 0xB0, 0x26, 0xFD, 0x8B, 0xA9, 0xFA, 0xBE, 0xA8, 0xD7, 0xCB, 0xCC, 0x92, 0xDA,
|
||||||
|
0xF9, 0x93, 0x60, 0x2D, 0xDD, 0xD2, 0xA2, 0x9B, 0x39, 0x5F, 0x82, 0x21, 0x4C, 0x69, 0xF8, 0x31,
|
||||||
|
0x87, 0xEE, 0x8E, 0xAD, 0x8C, 0x6A, 0xBC, 0xB5, 0x6B, 0x59, 0x13, 0xF1, 0x04, 0x00, 0xF6, 0x5A,
|
||||||
|
0x35, 0x79, 0x48, 0x8F, 0x15, 0xCD, 0x97, 0x57, 0x12, 0x3E, 0x37, 0xFF, 0x9D, 0x4F, 0x51, 0xF5,
|
||||||
|
0xA3, 0x70, 0xBB, 0x14, 0x75, 0xC2, 0xB8, 0x72, 0xC0, 0xED, 0x7D, 0x68, 0xC9, 0x2E, 0x0D, 0x62,
|
||||||
|
0x46, 0x17, 0x11, 0x4D, 0x6C, 0xC4, 0x7E, 0x53, 0xC1, 0x25, 0xC7, 0x9A, 0x1C, 0x88, 0x58, 0x2C,
|
||||||
|
0x89, 0xDC, 0x02, 0x64, 0x40, 0x01, 0x5D, 0x38, 0xA5, 0xE2, 0xAF, 0x55, 0xD5, 0xEF, 0x1A, 0x7C,
|
||||||
|
0xA7, 0x5B, 0xA6, 0x6F, 0x86, 0x9F, 0x73, 0xE6, 0x0A, 0xDE, 0x2B, 0x99, 0x4A, 0x47, 0x9C, 0xDF,
|
||||||
|
0x09, 0x76, 0x9E, 0x30, 0x0E, 0xE4, 0xB2, 0x94, 0xA0, 0x3B, 0x34, 0x1D, 0x28, 0x0F, 0x36, 0xE3,
|
||||||
|
0x23, 0xB4, 0x03, 0xD8, 0x90, 0xC8, 0x3C, 0xFE, 0x5E, 0x32, 0x24, 0x50, 0x1F, 0x3A, 0x43, 0x8A,
|
||||||
|
0x96, 0x41, 0x74, 0xAC, 0x52, 0x33, 0xF0, 0xD9, 0x29, 0x80, 0xB1, 0x16, 0xD3, 0xAB, 0x91, 0xB9,
|
||||||
|
0x84, 0x7F, 0x61, 0x1E, 0xCF, 0xC5, 0xD1, 0x56, 0x3D, 0xCA, 0xF4, 0x05, 0xC6, 0xE5, 0x08, 0x49
|
||||||
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Applies the InnoGames hash transformation to a 4-byte IV.
|
Applies the InnoGames hash transformation to a 4-byte IV.
|
||||||
@@ -39,11 +60,12 @@ defmodule Odinsea.Net.Cipher.IGCipher do
|
|||||||
input = value &&& 0xFF
|
input = value &&& 0xFF
|
||||||
table = shuffle_byte(input)
|
table = shuffle_byte(input)
|
||||||
|
|
||||||
# Apply the transformation operations
|
# Apply the transformation operations SEQUENTIALLY
|
||||||
new_k0 = k0 + shuffle_byte(k1) - input
|
# Java modifies key[0] first, then uses modified key[0] for key[3]
|
||||||
new_k1 = k1 - bxor(k2, table)
|
new_k0 = (k0 + (shuffle_byte(k1) - input)) &&& 0xFF
|
||||||
new_k2 = bxor(k2, shuffle_byte(k3) + input)
|
new_k1 = (k1 - bxor(k2, table)) &&& 0xFF
|
||||||
new_k3 = k3 - (k0 - table)
|
new_k2 = bxor(k2, (shuffle_byte(k3) + input) &&& 0xFF)
|
||||||
|
new_k3 = (k3 - (new_k0 - table)) &&& 0xFF
|
||||||
|
|
||||||
# Combine into 32-bit value (little-endian)
|
# Combine into 32-bit value (little-endian)
|
||||||
val =
|
val =
|
||||||
@@ -69,24 +91,4 @@ defmodule Odinsea.Net.Cipher.IGCipher do
|
|||||||
defp shuffle_byte(index) do
|
defp shuffle_byte(index) do
|
||||||
elem(@shuffle_table, index &&& 0xFF)
|
elem(@shuffle_table, index &&& 0xFF)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Shuffle table - 256 bytes used for IV transformation
|
|
||||||
@shuffle_table {
|
|
||||||
0xEC, 0x3F, 0x77, 0xA4, 0x45, 0xD0, 0x71, 0xBF, 0xB7, 0x98, 0x20, 0xFC, 0x4B, 0xE9, 0xB3, 0xE1,
|
|
||||||
0x5C, 0x22, 0xF7, 0x0C, 0x44, 0x1B, 0x81, 0xBD, 0x63, 0x8D, 0xD4, 0xC3, 0xF2, 0x10, 0x19, 0xE0,
|
|
||||||
0xFB, 0xA1, 0x6E, 0x66, 0xEA, 0xAE, 0xD6, 0xCE, 0x06, 0x18, 0x4E, 0xEB, 0x78, 0x95, 0xDB, 0xBA,
|
|
||||||
0xB6, 0x42, 0x7A, 0x2A, 0x83, 0x0B, 0x54, 0x67, 0x6D, 0xE8, 0x65, 0xE7, 0x2F, 0x07, 0xF3, 0xAA,
|
|
||||||
0x27, 0x7B, 0x85, 0xB0, 0x26, 0xFD, 0x8B, 0xA9, 0xFA, 0xBE, 0xA8, 0xD7, 0xCB, 0xCC, 0x92, 0xDA,
|
|
||||||
0xF9, 0x93, 0x60, 0x2D, 0xDD, 0xD2, 0xA2, 0x9B, 0x39, 0x5F, 0x82, 0x21, 0x4C, 0x69, 0xF8, 0x31,
|
|
||||||
0x87, 0xEE, 0x8E, 0xAD, 0x8C, 0x6A, 0xBC, 0xB5, 0x6B, 0x59, 0x13, 0xF1, 0x04, 0x00, 0xF6, 0x5A,
|
|
||||||
0x35, 0x79, 0x48, 0x8F, 0x15, 0xCD, 0x97, 0x57, 0x12, 0x3E, 0x37, 0xFF, 0x9D, 0x4F, 0x51, 0xF5,
|
|
||||||
0xA3, 0x70, 0xBB, 0x14, 0x75, 0xC2, 0xB8, 0x72, 0xC0, 0xED, 0x7D, 0x68, 0xC9, 0x2E, 0x0D, 0x62,
|
|
||||||
0x46, 0x17, 0x11, 0x4D, 0x6C, 0xC4, 0x7E, 0x53, 0xC1, 0x25, 0xC7, 0x9A, 0x1C, 0x88, 0x58, 0x2C,
|
|
||||||
0x89, 0xDC, 0x02, 0x64, 0x40, 0x01, 0x5D, 0x38, 0xA5, 0xE2, 0xAF, 0x55, 0xD5, 0xEF, 0x1A, 0x7C,
|
|
||||||
0xA7, 0x5B, 0xA6, 0x6F, 0x86, 0x9F, 0x73, 0xE6, 0x0A, 0xDE, 0x2B, 0x99, 0x4A, 0x47, 0x9C, 0xDF,
|
|
||||||
0x09, 0x76, 0x9E, 0x30, 0x0E, 0xE4, 0xB2, 0x94, 0xA0, 0x3B, 0x34, 0x1D, 0x28, 0x0F, 0x36, 0xE3,
|
|
||||||
0x23, 0xB4, 0x03, 0xD8, 0x90, 0xC8, 0x3C, 0xFE, 0x5E, 0x32, 0x24, 0x50, 0x1F, 0x3A, 0x43, 0x8A,
|
|
||||||
0x96, 0x41, 0x74, 0xAC, 0x52, 0x33, 0xF0, 0xD9, 0x29, 0x80, 0xB1, 0x16, 0xD3, 0xAB, 0x91, 0xB9,
|
|
||||||
0x84, 0x7F, 0x61, 0x1E, 0xCF, 0xC5, 0xD1, 0x56, 0x3D, 0xCA, 0xF4, 0x05, 0xC6, 0xE5, 0x08, 0x49
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|||||||
150
lib/odinsea/net/cipher/shanda_cipher.ex
Normal file
150
lib/odinsea/net/cipher/shanda_cipher.ex
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
defmodule Odinsea.Net.Cipher.ShandaCipher do
|
||||||
|
@moduledoc """
|
||||||
|
Shanda cipher implementation for MapleStory packet encryption.
|
||||||
|
Direct port from Java ShandaCipher.java
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
alias Odinsea.Util.BitTools
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encrypts data using the Shanda cipher.
|
||||||
|
"""
|
||||||
|
@spec encrypt(binary()) :: binary()
|
||||||
|
def encrypt(data) when is_binary(data) do
|
||||||
|
bytes = :binary.bin_to_list(data)
|
||||||
|
len = length(bytes)
|
||||||
|
|
||||||
|
result =
|
||||||
|
Enum.reduce(0..5, bytes, fn j, acc ->
|
||||||
|
do_encrypt_pass(acc, len, j)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:binary.list_to_bin(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_encrypt_pass(data, len, j) do
|
||||||
|
data_length = len &&& 0xFF
|
||||||
|
|
||||||
|
if rem(j, 2) == 0 do
|
||||||
|
# Forward pass: iterate 0..length-1
|
||||||
|
{result, _remember, _dl} =
|
||||||
|
Enum.reduce(data, {[], 0, data_length}, fn cur, {out, remember, dl} ->
|
||||||
|
new_cur =
|
||||||
|
cur
|
||||||
|
|> BitTools.roll_left(3)
|
||||||
|
|> Kernel.+(dl)
|
||||||
|
|> band(0xFF)
|
||||||
|
|> bxor(remember)
|
||||||
|
|
||||||
|
new_remember = new_cur
|
||||||
|
|
||||||
|
final_cur =
|
||||||
|
new_cur
|
||||||
|
|> BitTools.roll_right(dl &&& 0xFF)
|
||||||
|
|> bxor(0xFF)
|
||||||
|
|> Kernel.+(0x48)
|
||||||
|
|> band(0xFF)
|
||||||
|
|
||||||
|
{[final_cur | out], new_remember, (dl - 1) &&& 0xFF}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.reverse(result)
|
||||||
|
else
|
||||||
|
# Backward pass: iterate length-1..0
|
||||||
|
# Process reversed list, prepend results -> naturally restores forward order
|
||||||
|
{result, _remember, _dl} =
|
||||||
|
Enum.reduce(Enum.reverse(data), {[], 0, data_length}, fn cur, {out, remember, dl} ->
|
||||||
|
new_cur =
|
||||||
|
cur
|
||||||
|
|> BitTools.roll_left(4)
|
||||||
|
|> Kernel.+(dl)
|
||||||
|
|> band(0xFF)
|
||||||
|
|> bxor(remember)
|
||||||
|
|
||||||
|
new_remember = new_cur
|
||||||
|
|
||||||
|
final_cur =
|
||||||
|
new_cur
|
||||||
|
|> bxor(0x13)
|
||||||
|
|> BitTools.roll_right(3)
|
||||||
|
|
||||||
|
{[final_cur | out], new_remember, (dl - 1) &&& 0xFF}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Do NOT reverse - prepending from reversed input naturally gives forward order
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrypts data using the Shanda cipher.
|
||||||
|
"""
|
||||||
|
@spec decrypt(binary()) :: binary()
|
||||||
|
def decrypt(data) when is_binary(data) do
|
||||||
|
bytes = :binary.bin_to_list(data)
|
||||||
|
len = length(bytes)
|
||||||
|
|
||||||
|
# Java: for (int j = 1; j <= 6; j++)
|
||||||
|
result =
|
||||||
|
Enum.reduce(1..6, bytes, fn j, acc ->
|
||||||
|
do_decrypt_pass(acc, len, j)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:binary.list_to_bin(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_decrypt_pass(data, len, j) do
|
||||||
|
data_length = len &&& 0xFF
|
||||||
|
|
||||||
|
if rem(j, 2) == 0 do
|
||||||
|
# Forward pass (even j in decrypt = matches encrypt's forward)
|
||||||
|
{result, _remember, _dl} =
|
||||||
|
Enum.reduce(data, {[], 0, data_length}, fn cur, {out, remember, dl} ->
|
||||||
|
new_cur =
|
||||||
|
cur
|
||||||
|
|> Kernel.-(0x48)
|
||||||
|
|> band(0xFF)
|
||||||
|
|> bxor(0xFF)
|
||||||
|
|> BitTools.roll_left(dl &&& 0xFF)
|
||||||
|
|
||||||
|
next_remember = new_cur
|
||||||
|
|
||||||
|
final_cur =
|
||||||
|
new_cur
|
||||||
|
|> bxor(remember)
|
||||||
|
|> Kernel.-(dl)
|
||||||
|
|> band(0xFF)
|
||||||
|
|> BitTools.roll_right(3)
|
||||||
|
|
||||||
|
{[final_cur | out], next_remember, (dl - 1) &&& 0xFF}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.reverse(result)
|
||||||
|
else
|
||||||
|
# Backward pass (odd j in decrypt = matches encrypt's backward)
|
||||||
|
{result, _remember, _dl} =
|
||||||
|
Enum.reduce(Enum.reverse(data), {[], 0, data_length}, fn cur, {out, remember, dl} ->
|
||||||
|
new_cur =
|
||||||
|
cur
|
||||||
|
|> BitTools.roll_left(3)
|
||||||
|
|> bxor(0x13)
|
||||||
|
|
||||||
|
next_remember = new_cur
|
||||||
|
|
||||||
|
final_cur =
|
||||||
|
new_cur
|
||||||
|
|> bxor(remember)
|
||||||
|
|> Kernel.-(dl)
|
||||||
|
|> band(0xFF)
|
||||||
|
|> BitTools.roll_right(4)
|
||||||
|
|
||||||
|
{[final_cur | out], next_remember, (dl - 1) &&& 0xFF}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Do NOT reverse - prepending from reversed input naturally gives forward order
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,24 +19,31 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# Login/Account
|
# Login/Account
|
||||||
def cp_client_hello(), do: 0x01
|
def cp_client_hello(), do: 0x01
|
||||||
|
@spec cp_login_password() :: 2
|
||||||
def cp_login_password(), do: 0x02
|
def cp_login_password(), do: 0x02
|
||||||
|
# Note: 0x03 is CP_ViewServerList in MSEA v112.4 (not used in OdinSea but reserved)
|
||||||
|
@spec cp_view_server_list() :: 3
|
||||||
|
def cp_view_server_list(), do: 0x03
|
||||||
|
@spec cp_serverlist_request() :: 4
|
||||||
def cp_serverlist_request(), do: 0x04
|
def cp_serverlist_request(), do: 0x04
|
||||||
def cp_charlist_request(), do: 0x05
|
# CP_SelectWorld(5) - Java: ClientPacket.java
|
||||||
def cp_serverstatus_request(), do: 0x06
|
@spec cp_select_world() :: 5
|
||||||
|
def cp_select_world(), do: 0x05
|
||||||
def cp_check_char_name(), do: 0x0E
|
def cp_check_char_name(), do: 0x0E
|
||||||
def cp_create_char(), do: 0x12
|
def cp_create_char(), do: 0x12
|
||||||
def cp_create_ultimate(), do: 0x14
|
def cp_create_ultimate(), do: 0x14
|
||||||
def cp_delete_char(), do: 0x15
|
def cp_delete_char(), do: 0x15
|
||||||
def cp_exception_log(), do: 0x17
|
def cp_exception_log(), do: 0x17
|
||||||
def cp_security_packet(), do: 0x18
|
def cp_security_packet(), do: 0x18
|
||||||
def cp_hardware_info(), do: 0x70
|
def cp_hardware_info(), do: 0x5001 # Java: CP_HardwareInfo(0x5001) - was 0x70 (collided with cp_cancel_buff)
|
||||||
def cp_window_focus(), do: 0x71
|
def cp_window_focus(), do: 0x5004 # Java: CP_WindowFocus(0x5004) - was 0x71 (collided with cp_skill_effect)
|
||||||
def cp_char_select(), do: 0x19
|
def cp_char_select(), do: 0x19
|
||||||
def cp_auth_second_password(), do: 0x1A
|
def cp_auth_second_password(), do: 0x1A
|
||||||
def cp_rsa_key(), do: 0x20
|
def cp_rsa_key(), do: 0x20
|
||||||
def cp_client_dump_log(), do: 0x1D
|
def cp_client_dump_log(), do: 0x1D
|
||||||
def cp_create_security_handle(), do: 0x1E
|
def cp_create_security_handle(), do: 0x1E
|
||||||
def cp_select_world(), do: 0x03
|
# CP_CheckUserLimit(6) - Java: ClientPacket.java
|
||||||
|
@spec cp_check_user_limit() :: 6
|
||||||
def cp_check_user_limit(), do: 0x06
|
def cp_check_user_limit(), do: 0x06
|
||||||
|
|
||||||
# Migration/Channel
|
# Migration/Channel
|
||||||
@@ -75,7 +82,7 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# NPC Interaction
|
# NPC Interaction
|
||||||
def cp_npc_talk(), do: 0x40
|
def cp_npc_talk(), do: 0x40
|
||||||
def cp_npc_move(), do: 0x41 # NPC animation/movement (added for compatibility)
|
def cp_npc_move(), do: 0x106 # Java: CP_NpcMove(262) - alias for cp_npc_action. Was 0x41 (wrong)
|
||||||
def cp_npc_talk_more(), do: 0x42
|
def cp_npc_talk_more(), do: 0x42
|
||||||
def cp_npc_shop(), do: 0x43
|
def cp_npc_shop(), do: 0x43
|
||||||
def cp_storage(), do: 0x44
|
def cp_storage(), do: 0x44
|
||||||
@@ -255,6 +262,12 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# Cash Shop
|
# Cash Shop
|
||||||
def cp_cs_update(), do: 0x135
|
def cp_cs_update(), do: 0x135
|
||||||
|
|
||||||
|
# Alias for cp_cs_update (used in some places)
|
||||||
|
def cp_cash_shop_update(), do: cp_cs_update()
|
||||||
|
|
||||||
|
# Public NPC (recv - currently unimplemented in Java, using same value as send)
|
||||||
|
def cp_public_npc(), do: 0xB7
|
||||||
def cp_buy_cs_item(), do: 0x136
|
def cp_buy_cs_item(), do: 0x136
|
||||||
def cp_coupon_code(), do: 0x137
|
def cp_coupon_code(), do: 0x137
|
||||||
|
|
||||||
@@ -263,6 +276,9 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
def cp_touching_mts(), do: 0x159
|
def cp_touching_mts(), do: 0x159
|
||||||
def cp_mts_tab(), do: 0x15A
|
def cp_mts_tab(), do: 0x15A
|
||||||
|
|
||||||
|
# MTS operation opcode (client -> server)
|
||||||
|
def cp_mts_operation(), do: 0xB4
|
||||||
|
|
||||||
# Custom (server-specific)
|
# Custom (server-specific)
|
||||||
def cp_inject_packet(), do: 0x5002
|
def cp_inject_packet(), do: 0x5002
|
||||||
def cp_set_code_page(), do: 0x5003
|
def cp_set_code_page(), do: 0x5003
|
||||||
@@ -279,16 +295,16 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# General
|
# General
|
||||||
def lp_alive_req(), do: 0x0D
|
def lp_alive_req(), do: 0x0D
|
||||||
def lp_enable_action(), do: 0x0C
|
def lp_enable_action(), do: 0x1B # Java: enableActions() uses UPDATE_STATS(27) - was 0x0C (collided with lp_change_channel)
|
||||||
def lp_set_field(), do: 0x14
|
def lp_set_field(), do: 0x90 # Java: LP_SetField(144) - alias for lp_warp_to_map. Was 0x14 (wrong)
|
||||||
def lp_set_cash_shop_opened(), do: 0x15
|
def lp_set_cash_shop_opened(), do: 0x92 # Java: LP_SetCashShop(146) - was 0x15 (collided with lp_latest_connected_world)
|
||||||
def lp_migrate_command(), do: 0x16
|
def lp_migrate_command(), do: 0x0C # Java: CHANGE_CHANNEL(12) - was 0x16 (collided with lp_recommend_world_message)
|
||||||
|
|
||||||
# Login
|
# Login
|
||||||
def lp_login_status(), do: 0x01
|
def lp_check_password_result(), do: 0x01
|
||||||
def lp_serverstatus(), do: 0x03
|
def lp_server_status(), do: 0x03
|
||||||
def lp_serverlist(), do: 0x06
|
def lp_server_list(), do: 0x06
|
||||||
def lp_charlist(), do: 0x07
|
def lp_char_list(), do: 0x07
|
||||||
def lp_server_ip(), do: 0x08
|
def lp_server_ip(), do: 0x08
|
||||||
def lp_char_name_response(), do: 0x09
|
def lp_char_name_response(), do: 0x09
|
||||||
def lp_add_new_char_entry(), do: 0x0A
|
def lp_add_new_char_entry(), do: 0x0A
|
||||||
@@ -298,10 +314,10 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
def lp_channel_selected(), do: 0x10
|
def lp_channel_selected(), do: 0x10
|
||||||
def lp_relog_response(), do: 0x12
|
def lp_relog_response(), do: 0x12
|
||||||
def lp_rsa_key(), do: 0x13
|
def lp_rsa_key(), do: 0x13
|
||||||
def lp_enable_recommended(), do: 0x15
|
def lp_latest_connected_world(), do: 0x15
|
||||||
def lp_send_recommended(), do: 0x16
|
def lp_recommend_world_message(), do: 0x16
|
||||||
def lp_login_auth(), do: 0x17
|
def lp_login_auth(), do: 0x17
|
||||||
def lp_secondpw_error(), do: 0x18
|
def lp_check_spw_result(), do: 0x18
|
||||||
|
|
||||||
# Inventory/Stats
|
# Inventory/Stats
|
||||||
def lp_modify_inventory_item(), do: 0x19
|
def lp_modify_inventory_item(), do: 0x19
|
||||||
@@ -390,6 +406,13 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
# Warps/Shops
|
# Warps/Shops
|
||||||
def lp_warp_to_map(), do: 0x90
|
def lp_warp_to_map(), do: 0x90
|
||||||
def lp_mts_open(), do: 0x91
|
def lp_mts_open(), do: 0x91
|
||||||
|
|
||||||
|
# Alias for lp_mts_open (MTS opened packet)
|
||||||
|
def lp_set_mts_opened(), do: lp_mts_open()
|
||||||
|
|
||||||
|
# Cash shop initialization (SET_CASH_SHOP from Java)
|
||||||
|
def lp_set_cash_shop(), do: 0x92
|
||||||
|
|
||||||
def lp_cs_open(), do: 0x92
|
def lp_cs_open(), do: 0x92
|
||||||
def lp_login_welcome(), do: 0x94
|
def lp_login_welcome(), do: 0x94
|
||||||
def lp_server_blocked(), do: 0x97
|
def lp_server_blocked(), do: 0x97
|
||||||
@@ -397,6 +420,9 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# Effects
|
# Effects
|
||||||
def lp_show_equip_effect(), do: 0x99
|
def lp_show_equip_effect(), do: 0x99
|
||||||
|
|
||||||
|
# Weather effect (BLOW_WEATHER/MAP_EFFECT)
|
||||||
|
def lp_blow_weather(), do: 0xA1
|
||||||
def lp_multichat(), do: 0x9A
|
def lp_multichat(), do: 0x9A
|
||||||
def lp_whisper(), do: 0x9B
|
def lp_whisper(), do: 0x9B
|
||||||
def lp_boss_env(), do: 0x9D
|
def lp_boss_env(), do: 0x9D
|
||||||
@@ -614,6 +640,10 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
|
|
||||||
# Cash Shop
|
# Cash Shop
|
||||||
def lp_cs_update(), do: 0x1B8
|
def lp_cs_update(), do: 0x1B8
|
||||||
|
|
||||||
|
# Alias for lp_cs_update (cash shop update)
|
||||||
|
def lp_cash_shop_update(), do: lp_cs_update()
|
||||||
|
|
||||||
def lp_cs_operation(), do: 0x1B9
|
def lp_cs_operation(), do: 0x1B9
|
||||||
def lp_xmas_surprise(), do: 0x1BD
|
def lp_xmas_surprise(), do: 0x1BD
|
||||||
|
|
||||||
@@ -657,16 +687,26 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
# ==================================================================================================
|
# ==================================================================================================
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a human-readable name for a given opcode value.
|
Returns a human-readable name for a given client opcode value.
|
||||||
Useful for debugging and logging.
|
Useful for debugging and logging.
|
||||||
"""
|
"""
|
||||||
def name_for(opcode) when is_integer(opcode) do
|
def name_for_client(opcode) when is_integer(opcode) do
|
||||||
case opcode do
|
case opcode do
|
||||||
# Client opcodes (common ones for debugging)
|
# Client opcodes (common ones for debugging)
|
||||||
0x01 -> "CP_CLIENT_HELLO"
|
0x01 -> "CP_CLIENT_HELLO"
|
||||||
0x02 -> "CP_LOGIN_PASSWORD"
|
0x02 -> "CP_LOGIN_PASSWORD"
|
||||||
|
0x03 -> "CP_VIEW_SERVER_LIST"
|
||||||
|
0x04 -> "CP_SERVERLIST_REQUEST"
|
||||||
|
0x05 -> "CP_SELECT_WORLD"
|
||||||
|
0x06 -> "CP_CHECK_USER_LIMIT"
|
||||||
0x0D -> "CP_PLAYER_LOGGEDIN"
|
0x0D -> "CP_PLAYER_LOGGEDIN"
|
||||||
|
0x0E -> "CP_CHECK_CHAR_NAME"
|
||||||
|
0x12 -> "CP_CREATE_CHAR"
|
||||||
|
0x14 -> "CP_CREATE_ULTIMATE"
|
||||||
|
0x15 -> "CP_DELETE_CHAR"
|
||||||
0x19 -> "CP_CHAR_SELECT"
|
0x19 -> "CP_CHAR_SELECT"
|
||||||
|
0x1A -> "CP_AUTH_SECOND_PASSWORD"
|
||||||
|
0x20 -> "CP_RSA_KEY"
|
||||||
0x23 -> "CP_CHANGE_MAP"
|
0x23 -> "CP_CHANGE_MAP"
|
||||||
0x24 -> "CP_CHANGE_CHANNEL"
|
0x24 -> "CP_CHANGE_CHANNEL"
|
||||||
0x2A -> "CP_MOVE_PLAYER"
|
0x2A -> "CP_MOVE_PLAYER"
|
||||||
@@ -675,11 +715,24 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
0xA0 -> "CP_PARTYCHAT"
|
0xA0 -> "CP_PARTYCHAT"
|
||||||
0xA1 -> "CP_WHISPER"
|
0xA1 -> "CP_WHISPER"
|
||||||
|
|
||||||
|
_ -> "CP_UNKNOWN_0x#{Integer.to_string(opcode, 16) |> String.upcase()}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a human-readable name for a given server opcode value.
|
||||||
|
Useful for debugging and logging.
|
||||||
|
"""
|
||||||
|
def name_for_server(opcode) when is_integer(opcode) do
|
||||||
|
case opcode do
|
||||||
# Server opcodes (common ones for debugging)
|
# Server opcodes (common ones for debugging)
|
||||||
0x01 -> "LP_LOGIN_STATUS"
|
0x01 -> "LP_LOGIN_STATUS"
|
||||||
0x06 -> "LP_SERVERLIST"
|
0x06 -> "LP_SERVERLIST"
|
||||||
0x07 -> "LP_CHARLIST"
|
0x07 -> "LP_CHARLIST"
|
||||||
|
0x08 -> "LP_SERVER_IP"
|
||||||
0x0D -> "LP_ALIVE_REQ"
|
0x0D -> "LP_ALIVE_REQ"
|
||||||
|
0x13 -> "LP_RSA_KEY"
|
||||||
|
0x17 -> "LP_LOGIN_AUTH"
|
||||||
0xB8 -> "LP_SPAWN_PLAYER"
|
0xB8 -> "LP_SPAWN_PLAYER"
|
||||||
0xB9 -> "LP_REMOVE_PLAYER_FROM_MAP"
|
0xB9 -> "LP_REMOVE_PLAYER_FROM_MAP"
|
||||||
0xBA -> "LP_CHATTEXT"
|
0xBA -> "LP_CHATTEXT"
|
||||||
@@ -688,21 +741,30 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
0x9B -> "LP_WHISPER"
|
0x9B -> "LP_WHISPER"
|
||||||
0x1A3 -> "LP_NPC_TALK"
|
0x1A3 -> "LP_NPC_TALK"
|
||||||
|
|
||||||
_ -> "UNKNOWN_0x#{Integer.to_string(opcode, 16) |> String.upcase()}"
|
_ -> "LP_UNKNOWN_0x#{Integer.to_string(opcode, 16) |> String.upcase()}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a human-readable name for a given opcode value.
|
||||||
|
Deprecated: Use name_for_client/1 or name_for_server/1 instead.
|
||||||
|
"""
|
||||||
|
def name_for(opcode) when is_integer(opcode) do
|
||||||
|
name_for_client(opcode)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Validates if an opcode is a known client packet.
|
Validates if an opcode is a known client packet.
|
||||||
"""
|
"""
|
||||||
def valid_client_opcode?(opcode) when is_integer(opcode) do
|
def valid_client_opcode?(opcode) when is_integer(opcode) do
|
||||||
opcode in [
|
opcode in [
|
||||||
# Add all valid client opcodes here for validation
|
# Login opcodes
|
||||||
0x01,
|
0x01, # CP_ClientHello
|
||||||
0x02,
|
0x02, # CP_LoginPassword
|
||||||
0x04,
|
0x03, # CP_ViewServerList
|
||||||
0x05,
|
0x04, # CP_ServerListRequest
|
||||||
0x06,
|
0x05, # CP_SelectWorld
|
||||||
|
0x06, # CP_CheckUserLimit
|
||||||
0x0D,
|
0x0D,
|
||||||
0x0E,
|
0x0E,
|
||||||
0x12,
|
0x12,
|
||||||
@@ -895,7 +957,11 @@ defmodule Odinsea.Net.Opcodes do
|
|||||||
0x143,
|
0x143,
|
||||||
0x144,
|
0x144,
|
||||||
0x159,
|
0x159,
|
||||||
0x15A
|
0x15A,
|
||||||
|
0x5001, # CP_HardwareInfo
|
||||||
|
0x5002, # CP_InjectPacket
|
||||||
|
0x5003, # CP_SetCodePage
|
||||||
|
0x5004 # CP_WindowFocus
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ defmodule Odinsea.Net.Packet.Out do
|
|||||||
%__MODULE__{data: [data | <<length::signed-integer-little-16, value::binary>>]}
|
%__MODULE__{data: [data | <<length::signed-integer-little-16, value::binary>>]}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encodes a fixed-length MapleStory ASCII string.
|
||||||
|
Format: [2-byte length][ASCII bytes][padding to fixed length]
|
||||||
|
The third argument specifies the fixed field length.
|
||||||
|
"""
|
||||||
|
@spec encode_string(t(), String.t(), non_neg_integer()) :: t()
|
||||||
|
def encode_string(%__MODULE__{data: data}, value, fixed_length) when is_binary(value) and is_integer(fixed_length) and fixed_length > 0 do
|
||||||
|
length = byte_size(value)
|
||||||
|
padding = max(0, fixed_length - length)
|
||||||
|
%__MODULE__{data: [data | <<length::signed-integer-little-16, value::binary, 0::size(padding * 8)>>]}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Encodes a boolean (1 byte, 0 = false, 1 = true).
|
Encodes a boolean (1 byte, 0 = false, 1 = true).
|
||||||
"""
|
"""
|
||||||
@@ -88,6 +100,12 @@ defmodule Odinsea.Net.Packet.Out do
|
|||||||
%__MODULE__{data: [data | buffer]}
|
%__MODULE__{data: [data | buffer]}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Alias for encode_buffer/2.
|
||||||
|
"""
|
||||||
|
@spec encode_bytes(t(), binary()) :: t()
|
||||||
|
def encode_bytes(packet, data), do: encode_buffer(packet, data)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Encodes a fixed-size buffer, padding with zeros if necessary.
|
Encodes a fixed-size buffer, padding with zeros if necessary.
|
||||||
"""
|
"""
|
||||||
@@ -172,6 +190,12 @@ defmodule Odinsea.Net.Packet.Out do
|
|||||||
data
|
data
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Alias for to_iodata/1.
|
||||||
|
"""
|
||||||
|
@spec to_data(t()) :: iodata()
|
||||||
|
def to_data(packet), do: to_iodata(packet)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Converts the packet to a hex string for debugging.
|
Converts the packet to a hex string for debugging.
|
||||||
"""
|
"""
|
||||||
|
|||||||
380
lib/odinsea/net/packet_logger.ex
Normal file
380
lib/odinsea/net/packet_logger.ex
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
defmodule Odinsea.Net.PacketLogger do
|
||||||
|
@moduledoc """
|
||||||
|
Comprehensive packet logging system for debugging MapleStory protocol.
|
||||||
|
|
||||||
|
Provides detailed packet logging similar to the Java version with:
|
||||||
|
- Direction (client/loopback)
|
||||||
|
- Opcode name and value (decimal/hex)
|
||||||
|
- Raw hex data
|
||||||
|
- ASCII text representation
|
||||||
|
- Context information (session, thread)
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Odinsea.Net.{Hex, Opcodes}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs an incoming client packet with full details.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- `opcode` - The packet opcode (integer)
|
||||||
|
- `data` - The packet data (binary, excluding opcode bytes)
|
||||||
|
- `context` - Map with :ip, :server_type, etc.
|
||||||
|
"""
|
||||||
|
def log_client_packet(opcode, data, context \\ %{}) do
|
||||||
|
if packet_logging_enabled?() do
|
||||||
|
opcode_name = get_client_opcode_name(opcode)
|
||||||
|
ip = Map.get(context, :ip, "unknown")
|
||||||
|
server_type = Map.get(context, :server_type, :unknown)
|
||||||
|
|
||||||
|
# Format opcode header
|
||||||
|
opcode_header = format_opcode_header("client", opcode_name, opcode)
|
||||||
|
|
||||||
|
# Full packet data includes opcode
|
||||||
|
full_data = <<opcode::little-16>> <> data
|
||||||
|
|
||||||
|
# Format hex and text
|
||||||
|
hex_str = Hex.encode(full_data)
|
||||||
|
text_str = Hex.to_ascii(full_data)
|
||||||
|
|
||||||
|
# Log the packet
|
||||||
|
Logger.info("""
|
||||||
|
#{opcode_header}
|
||||||
|
[Data] #{hex_str}
|
||||||
|
[Text] #{text_str}
|
||||||
|
[Context] IP=#{ip} Server=#{server_type} Size=#{byte_size(full_data)} bytes
|
||||||
|
""")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs an outgoing server packet with full details.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- `opcode` - The packet opcode (integer)
|
||||||
|
- `data` - The packet data (binary, excluding opcode bytes)
|
||||||
|
- `context` - Map with :ip, :server_type, etc.
|
||||||
|
"""
|
||||||
|
def log_server_packet(opcode, data, context \\ %{}) do
|
||||||
|
if packet_logging_enabled?() do
|
||||||
|
opcode_name = get_server_opcode_name(opcode)
|
||||||
|
ip = Map.get(context, :ip, "unknown")
|
||||||
|
server_type = Map.get(context, :server_type, :unknown)
|
||||||
|
|
||||||
|
# Format opcode header
|
||||||
|
opcode_header = format_opcode_header("loopback", opcode_name, opcode)
|
||||||
|
|
||||||
|
# Full packet data includes opcode
|
||||||
|
full_data = <<opcode::little-16>> <> data
|
||||||
|
|
||||||
|
# Format hex and text
|
||||||
|
hex_str = Hex.encode(full_data)
|
||||||
|
text_str = Hex.to_ascii(full_data)
|
||||||
|
|
||||||
|
# Log the packet
|
||||||
|
Logger.info("""
|
||||||
|
#{opcode_header}
|
||||||
|
[Data] #{hex_str}
|
||||||
|
[Text] #{text_str}
|
||||||
|
[Context] IP=#{ip} Server=#{server_type} Size=#{byte_size(full_data)} bytes
|
||||||
|
""")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs raw packet data (used for handshake/hello packets that don't follow normal format).
|
||||||
|
"""
|
||||||
|
def log_raw_packet(direction, label, data, context \\ %{}) do
|
||||||
|
if packet_logging_enabled?() do
|
||||||
|
ip = Map.get(context, :ip, "unknown")
|
||||||
|
|
||||||
|
data = IO.iodata_to_binary(data)
|
||||||
|
hex_str = Hex.encode(data)
|
||||||
|
text_str = Hex.to_ascii(data)
|
||||||
|
|
||||||
|
Logger.info("""
|
||||||
|
[#{direction}] [#{label}]
|
||||||
|
[Data] #{hex_str}
|
||||||
|
[Text] #{text_str}
|
||||||
|
[Context] IP=#{ip} Size=#{byte_size(data)} bytes
|
||||||
|
""")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================================================================================================
|
||||||
|
# Private Helper Functions
|
||||||
|
# ==================================================================================================
|
||||||
|
|
||||||
|
defp packet_logging_enabled? do
|
||||||
|
features = Application.get_env(:odinsea, :features, [])
|
||||||
|
Keyword.get(features, :log_packet, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_opcode_header(direction, opcode_name, opcode) do
|
||||||
|
opcode_hex = Integer.to_string(opcode, 16) |> String.upcase() |> String.pad_leading(2, "0")
|
||||||
|
"[#{direction}] [#{opcode_name}] #{opcode} / 0x#{opcode_hex}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================================================================================================
|
||||||
|
# Opcode Name Resolution
|
||||||
|
# ==================================================================================================
|
||||||
|
|
||||||
|
defp get_client_opcode_name(opcode) do
|
||||||
|
case opcode do
|
||||||
|
# Connection/Security
|
||||||
|
0x16 -> "CP_AliveAck"
|
||||||
|
|
||||||
|
# Login/Account
|
||||||
|
0x01 -> "CP_PermissionRequest"
|
||||||
|
0x02 -> "CP_CheckPassword"
|
||||||
|
0x04 -> "CP_ServerlistRequest"
|
||||||
|
0x05 -> "CP_SelectWorld"
|
||||||
|
0x06 -> "CP_CheckUserLimit"
|
||||||
|
0x0E -> "CP_CheckCharName"
|
||||||
|
0x12 -> "CP_CreateChar"
|
||||||
|
0x14 -> "CP_CreateUltimate"
|
||||||
|
0x15 -> "CP_DeleteChar"
|
||||||
|
0x17 -> "CP_ExceptionLog"
|
||||||
|
0x18 -> "CP_SecurityPacket"
|
||||||
|
0x19 -> "CP_CharSelect"
|
||||||
|
0x1A -> "CP_AuthSecondPassword"
|
||||||
|
0x1D -> "CP_ClientDumpLog"
|
||||||
|
0x1E -> "CP_CreateSecurityHandle"
|
||||||
|
0x20 -> "RSA_KEY"
|
||||||
|
0x5001 -> "CP_HardwareInfo"
|
||||||
|
0x5004 -> "CP_WindowFocus"
|
||||||
|
|
||||||
|
# Migration/Channel
|
||||||
|
0x0D -> "CP_PlayerLoggedIn"
|
||||||
|
0x23 -> "CP_ChangeMap"
|
||||||
|
0x24 -> "CP_ChangeChannel"
|
||||||
|
0x25 -> "CP_EnterCashShop"
|
||||||
|
0x26 -> "CP_EnterPvp"
|
||||||
|
0x27 -> "CP_EnterPvpParty"
|
||||||
|
0x29 -> "CP_LeavePvp"
|
||||||
|
0xB4 -> "CP_EnterMts"
|
||||||
|
|
||||||
|
# Player Movement/Actions
|
||||||
|
0x2A -> "CP_MovePlayer"
|
||||||
|
0x2C -> "CP_CancelChair"
|
||||||
|
0x2D -> "CP_UseChair"
|
||||||
|
0x2F -> "CP_CloseRangeAttack"
|
||||||
|
0x30 -> "CP_RangedAttack"
|
||||||
|
0x31 -> "CP_MagicAttack"
|
||||||
|
0x32 -> "CP_PassiveEnergy"
|
||||||
|
0x34 -> "CP_TakeDamage"
|
||||||
|
0x35 -> "CP_PvpAttack"
|
||||||
|
0x36 -> "CP_GeneralChat"
|
||||||
|
0x37 -> "CP_CloseChalkboard"
|
||||||
|
0x38 -> "CP_FaceExpression"
|
||||||
|
0x75 -> "CP_CharInfoRequest"
|
||||||
|
0x76 -> "CP_SpawnPet"
|
||||||
|
0x78 -> "CP_CancelDebuff"
|
||||||
|
|
||||||
|
# NPC Interaction
|
||||||
|
0x40 -> "CP_NpcTalk"
|
||||||
|
0x41 -> "CP_NpcMove"
|
||||||
|
0x42 -> "CP_NpcTalkMore"
|
||||||
|
0x43 -> "CP_NpcShop"
|
||||||
|
0x44 -> "CP_Storage"
|
||||||
|
0x45 -> "CP_UseHiredMerchant"
|
||||||
|
0x47 -> "CP_MerchItemStore"
|
||||||
|
|
||||||
|
# Inventory/Items
|
||||||
|
0x4D -> "CP_ItemSort"
|
||||||
|
0x4E -> "CP_ItemGather"
|
||||||
|
0x4F -> "CP_ItemMove"
|
||||||
|
0x53 -> "CP_UseItem"
|
||||||
|
0x10C -> "CP_ItemPickup"
|
||||||
|
|
||||||
|
# Stats/Skills
|
||||||
|
0x6A -> "CP_DistributeAp"
|
||||||
|
0x6B -> "CP_AutoAssignAp"
|
||||||
|
0x6E -> "CP_DistributeSp"
|
||||||
|
0x6F -> "CP_SpecialMove"
|
||||||
|
|
||||||
|
# Social
|
||||||
|
0xA0 -> "CP_PartyChat"
|
||||||
|
0xA1 -> "CP_Whisper"
|
||||||
|
0xA4 -> "CP_PartyOperation"
|
||||||
|
0xA8 -> "CP_GuildOperation"
|
||||||
|
|
||||||
|
# Cash Shop
|
||||||
|
0x135 -> "CP_CsUpdate"
|
||||||
|
0x136 -> "CP_BuyCsItem"
|
||||||
|
0x137 -> "CP_CouponCode"
|
||||||
|
|
||||||
|
# Custom
|
||||||
|
0x5002 -> "CP_InjectPacket"
|
||||||
|
0x5003 -> "CP_SetCodePage"
|
||||||
|
|
||||||
|
_ -> "UNKNOWN"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_server_opcode_name(opcode) do
|
||||||
|
case opcode do
|
||||||
|
# General
|
||||||
|
0x0D -> "LP_AliveReq"
|
||||||
|
0x0C -> "LP_ChangeChannel"
|
||||||
|
0x15 -> "LP_LatestConnectedWorld"
|
||||||
|
0x16 -> "LP_RecommendWorldMessage"
|
||||||
|
|
||||||
|
# Login
|
||||||
|
0x00 -> "HELLO"
|
||||||
|
0x01 -> "LOGIN_STATUS"
|
||||||
|
0x03 -> "LP_ServerStatus"
|
||||||
|
0x06 -> "SERVERLIST"
|
||||||
|
0x07 -> "LP_CharList"
|
||||||
|
0x08 -> "LP_ServerIp"
|
||||||
|
0x09 -> "LP_CharNameResponse"
|
||||||
|
0x0A -> "LP_AddNewCharEntry"
|
||||||
|
0x0B -> "LP_DeleteCharResponse"
|
||||||
|
0x10 -> "LP_ChannelSelected"
|
||||||
|
0x12 -> "LP_RelogResponse"
|
||||||
|
0x13 -> "RSA_KEY"
|
||||||
|
0x17 -> "LOGIN_AUTH"
|
||||||
|
0x18 -> "LP_SecondPwError"
|
||||||
|
|
||||||
|
# Inventory/Stats
|
||||||
|
0x19 -> "LP_ModifyInventoryItem"
|
||||||
|
0x1A -> "LP_UpdateInventorySlot"
|
||||||
|
0x1B -> "LP_UpdateStats"
|
||||||
|
0x1C -> "LP_GiveBuff"
|
||||||
|
0x1D -> "LP_CancelBuff"
|
||||||
|
0x20 -> "LP_UpdateSkills"
|
||||||
|
0x22 -> "LP_FameResponse"
|
||||||
|
0x23 -> "LP_ShowStatusInfo"
|
||||||
|
0x25 -> "LP_TrockLocations"
|
||||||
|
|
||||||
|
# Social/Party/Guild
|
||||||
|
0x38 -> "LP_PartyOperation"
|
||||||
|
0x3A -> "LP_ExpeditionOperation"
|
||||||
|
0x3B -> "LP_BuddyList"
|
||||||
|
0x3D -> "LP_GuildOperation"
|
||||||
|
0x3E -> "LP_AllianceOperation"
|
||||||
|
|
||||||
|
# Map Effects/Environment
|
||||||
|
0x3F -> "LP_SpawnPortal"
|
||||||
|
0x40 -> "LP_MechPortal"
|
||||||
|
0x41 -> "LP_ServerMessage"
|
||||||
|
0x4A -> "LP_YellowChat"
|
||||||
|
|
||||||
|
# Family
|
||||||
|
0x6D -> "LP_SendPedigree"
|
||||||
|
0x6E -> "LP_OpenFamily"
|
||||||
|
0x73 -> "LP_Family"
|
||||||
|
|
||||||
|
# Misc UI/Messages
|
||||||
|
0x7E -> "LP_TopMsg"
|
||||||
|
0x7F -> "LP_MidMsg"
|
||||||
|
0x80 -> "LP_ClearMidMsg"
|
||||||
|
|
||||||
|
# Warps/Shops
|
||||||
|
0x90 -> "LP_WarpToMap"
|
||||||
|
0x91 -> "LP_MtsOpen"
|
||||||
|
0x92 -> "LP_CsOpen"
|
||||||
|
|
||||||
|
# Effects
|
||||||
|
0x99 -> "LP_ShowEquipEffect"
|
||||||
|
0x9A -> "LP_MultiChat"
|
||||||
|
0x9B -> "LP_Whisper"
|
||||||
|
0xA1 -> "LP_MapEffect"
|
||||||
|
0xA6 -> "LP_Clock"
|
||||||
|
|
||||||
|
# Players
|
||||||
|
0xB8 -> "LP_SpawnPlayer"
|
||||||
|
0xB9 -> "LP_RemovePlayerFromMap"
|
||||||
|
0xBA -> "LP_ChatText"
|
||||||
|
0xBC -> "LP_Chalkboard"
|
||||||
|
|
||||||
|
# Pets
|
||||||
|
0xD1 -> "LP_SpawnPet"
|
||||||
|
0xD4 -> "LP_MovePet"
|
||||||
|
0xD5 -> "LP_PetChat"
|
||||||
|
|
||||||
|
# Player Actions
|
||||||
|
0xE2 -> "LP_MovePlayer"
|
||||||
|
0xE4 -> "LP_CloseRangeAttack"
|
||||||
|
0xE5 -> "LP_RangedAttack"
|
||||||
|
0xE6 -> "LP_MagicAttack"
|
||||||
|
0xE8 -> "LP_SkillEffect"
|
||||||
|
0xEB -> "LP_DamagePlayer"
|
||||||
|
0xEC -> "LP_FacialExpression"
|
||||||
|
0xF0 -> "LP_ShowChair"
|
||||||
|
0xF1 -> "LP_UpdateCharLook"
|
||||||
|
|
||||||
|
# Summons
|
||||||
|
0x131 -> "LP_SpawnSummon"
|
||||||
|
0x132 -> "LP_RemoveSummon"
|
||||||
|
0x133 -> "LP_MoveSummon"
|
||||||
|
0x134 -> "LP_SummonAttack"
|
||||||
|
|
||||||
|
# Monsters
|
||||||
|
0x13A -> "LP_SpawnMonster"
|
||||||
|
0x13B -> "LP_KillMonster"
|
||||||
|
0x13C -> "LP_SpawnMonsterControl"
|
||||||
|
0x13D -> "LP_MoveMonster"
|
||||||
|
0x144 -> "LP_DamageMonster"
|
||||||
|
|
||||||
|
# NPCs
|
||||||
|
0x156 -> "LP_SpawnNpc"
|
||||||
|
0x157 -> "LP_RemoveNpc"
|
||||||
|
0x158 -> "LP_SpawnNpcRequestController"
|
||||||
|
0x159 -> "LP_NpcAction"
|
||||||
|
0x1A3 -> "LP_NpcTalk"
|
||||||
|
0x1A5 -> "LP_OpenNpcShop"
|
||||||
|
|
||||||
|
# Merchants
|
||||||
|
0x161 -> "LP_SpawnHiredMerchant"
|
||||||
|
0x162 -> "LP_DestroyHiredMerchant"
|
||||||
|
|
||||||
|
# Map Objects
|
||||||
|
0x165 -> "LP_DropItemFromMapObject"
|
||||||
|
0x167 -> "LP_RemoveItemFromMap"
|
||||||
|
0x16B -> "LP_SpawnMist"
|
||||||
|
0x16C -> "LP_RemoveMist"
|
||||||
|
0x16D -> "LP_SpawnDoor"
|
||||||
|
0x16E -> "LP_RemoveDoor"
|
||||||
|
|
||||||
|
# Reactors
|
||||||
|
0x171 -> "LP_ReactorHit"
|
||||||
|
0x173 -> "LP_ReactorSpawn"
|
||||||
|
0x174 -> "LP_ReactorDestroy"
|
||||||
|
|
||||||
|
# NPC/Shop Interactions
|
||||||
|
0x1A6 -> "LP_ConfirmShopTransaction"
|
||||||
|
0x1A9 -> "LP_OpenStorage"
|
||||||
|
0x1AB -> "LP_MerchItemStore"
|
||||||
|
|
||||||
|
# Cash Shop
|
||||||
|
0x1B8 -> "LP_CsUpdate"
|
||||||
|
0x1B9 -> "LP_CsOperation"
|
||||||
|
|
||||||
|
# Input
|
||||||
|
0x1C5 -> "LP_Keymap"
|
||||||
|
|
||||||
|
# Custom
|
||||||
|
0x5000 -> "LP_DamageSkin"
|
||||||
|
0x5001 -> "LP_OpenWebsite"
|
||||||
|
|
||||||
|
0x15 -> "LP_LatestConnectedWorld"
|
||||||
|
0x16 -> "LP_RecommendWorldMessage"
|
||||||
|
|
||||||
|
_ -> "UNKNOWN"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -163,11 +163,11 @@ defmodule Odinsea.Net.Processor do
|
|||||||
|
|
||||||
# Password check (login authentication)
|
# Password check (login authentication)
|
||||||
@cp_login_password ->
|
@cp_login_password ->
|
||||||
Handler.on_login_password(packet, state)
|
Handler.on_check_password(packet, state)
|
||||||
|
|
||||||
# World info request (server list)
|
# World info request (server list)
|
||||||
@cp_serverlist_request ->
|
@cp_serverlist_request ->
|
||||||
Handler.on_serverlist_request(state)
|
Handler.on_world_info_request(state)
|
||||||
|
|
||||||
# Select world
|
# Select world
|
||||||
@cp_select_world ->
|
@cp_select_world ->
|
||||||
@@ -179,11 +179,11 @@ defmodule Odinsea.Net.Processor do
|
|||||||
|
|
||||||
# Check duplicated ID (character name availability)
|
# Check duplicated ID (character name availability)
|
||||||
@cp_check_char_name ->
|
@cp_check_char_name ->
|
||||||
Handler.on_check_char_name(packet, state)
|
Handler.on_check_duplicated_id(packet, state)
|
||||||
|
|
||||||
# Create new character
|
# Create new character
|
||||||
@cp_create_char ->
|
@cp_create_char ->
|
||||||
Handler.on_create_char(packet, state)
|
Handler.on_create_new_character(packet, state)
|
||||||
|
|
||||||
# Create ultimate (Cygnus Knights)
|
# Create ultimate (Cygnus Knights)
|
||||||
@cp_create_ultimate ->
|
@cp_create_ultimate ->
|
||||||
@@ -191,15 +191,15 @@ defmodule Odinsea.Net.Processor do
|
|||||||
|
|
||||||
# Delete character
|
# Delete character
|
||||||
@cp_delete_char ->
|
@cp_delete_char ->
|
||||||
Handler.on_delete_char(packet, state)
|
Handler.on_delete_character(packet, state)
|
||||||
|
|
||||||
# Select character (enter game)
|
# Select character (enter game)
|
||||||
@cp_char_select ->
|
@cp_char_select ->
|
||||||
Handler.on_char_select(packet, state)
|
Handler.on_select_character(packet, state)
|
||||||
|
|
||||||
# Second password check
|
# Second password check
|
||||||
@cp_auth_second_password ->
|
@cp_auth_second_password ->
|
||||||
Handler.on_auth_second_password(packet, state)
|
Handler.on_check_spw_request(packet, state)
|
||||||
|
|
||||||
# RSA key request
|
# RSA key request
|
||||||
@cp_rsa_key ->
|
@cp_rsa_key ->
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ defmodule Odinsea.Shop.Client do
|
|||||||
opcode == Opcodes.cp_player_loggedin() ->
|
opcode == Opcodes.cp_player_loggedin() ->
|
||||||
handle_migrate_in(packet, state)
|
handle_migrate_in(packet, state)
|
||||||
|
|
||||||
opcode == Opcodes.cp_cash_shop_update() ->
|
opcode == Opcodes.cp_cs_update() ->
|
||||||
# Cash shop operations
|
# Cash shop operations
|
||||||
handle_cash_shop_operation(packet, state)
|
handle_cash_shop_operation(packet, state)
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def enable_cs_use(socket) do
|
def enable_cs_use(socket) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> encode_cs_update()
|
|> encode_cs_update()
|
||||||
|> Out.to_data()
|
|> Out.to_data()
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
cash_items = character.cash_inventory || []
|
cash_items = character.cash_inventory || []
|
||||||
|
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x4A) # Operation code for inventory
|
|> Out.encode_byte(0x4A) # Operation code for inventory
|
||||||
|> encode_cash_items(cash_items)
|
|> encode_cash_items(cash_items)
|
||||||
|> Out.to_data()
|
|> Out.to_data()
|
||||||
@@ -178,7 +178,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def show_nx_maple_tokens(socket, character) do
|
def show_nx_maple_tokens(socket, character) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x3D) # Operation code for balance
|
|> Out.encode_byte(0x3D) # Operation code for balance
|
||||||
|> Out.encode_int(character.nx_cash || 0)
|
|> Out.encode_int(character.nx_cash || 0)
|
||||||
|> Out.encode_int(character.maple_points || 0)
|
|> Out.encode_int(character.maple_points || 0)
|
||||||
@@ -196,7 +196,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def show_bought_cs_item(socket, item, sn, account_id) do
|
def show_bought_cs_item(socket, item, sn, account_id) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x53) # Bought item operation
|
|> Out.encode_byte(0x53) # Bought item operation
|
||||||
|> Out.encode_int(account_id)
|
|> Out.encode_int(account_id)
|
||||||
|> encode_bought_item(item, sn)
|
|> encode_bought_item(item, sn)
|
||||||
@@ -220,7 +220,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def show_bought_cs_package(socket, items, account_id) do
|
def show_bought_cs_package(socket, items, account_id) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x5D) # Package operation
|
|> Out.encode_byte(0x5D) # Package operation
|
||||||
|> Out.encode_int(account_id)
|
|> Out.encode_int(account_id)
|
||||||
|> Out.encode_short(length(items))
|
|> Out.encode_short(length(items))
|
||||||
@@ -239,7 +239,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def show_bought_cs_quest_item(socket, item, position) do
|
def show_bought_cs_quest_item(socket, item, position) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x73) # Quest item operation
|
|> Out.encode_byte(0x73) # Quest item operation
|
||||||
|> Out.encode_int(item.price)
|
|> Out.encode_int(item.price)
|
||||||
|> Out.encode_short(item.count)
|
|> Out.encode_short(item.count)
|
||||||
@@ -255,7 +255,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def confirm_from_cs_inventory(socket, item, position) do
|
def confirm_from_cs_inventory(socket, item, position) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x69) # From CS inventory
|
|> Out.encode_byte(0x69) # From CS inventory
|
||||||
|> Out.encode_byte(Odinsea.Game.InventoryType.get_type(
|
|> Out.encode_byte(Odinsea.Game.InventoryType.get_type(
|
||||||
Odinsea.Game.InventoryType.from_item_id(item.item_id)
|
Odinsea.Game.InventoryType.from_item_id(item.item_id)
|
||||||
@@ -271,7 +271,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def confirm_to_cs_inventory(socket, item, account_id) do
|
def confirm_to_cs_inventory(socket, item, account_id) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x5F) # To CS inventory
|
|> Out.encode_byte(0x5F) # To CS inventory
|
||||||
|> Out.encode_int(account_id)
|
|> Out.encode_int(account_id)
|
||||||
|> encode_cash_item_single(item)
|
|> encode_cash_item_single(item)
|
||||||
@@ -298,7 +298,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def send_gift(socket, price, item_id, count, partner) do
|
def send_gift(socket, price, item_id, count, partner) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x5B) # Gift sent operation
|
|> Out.encode_byte(0x5B) # Gift sent operation
|
||||||
|> Out.encode_int(price)
|
|> Out.encode_int(price)
|
||||||
|> Out.encode_int(item_id)
|
|> Out.encode_int(item_id)
|
||||||
@@ -314,7 +314,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def get_cs_gifts(socket, gifts) do
|
def get_cs_gifts(socket, gifts) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x48) # Gifts operation
|
|> Out.encode_byte(0x48) # Gifts operation
|
||||||
|> Out.encode_short(length(gifts))
|
|> Out.encode_short(length(gifts))
|
||||||
|> then(fn pkt ->
|
|> then(fn pkt ->
|
||||||
@@ -340,7 +340,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def send_wishlist(socket, _character, wishlist) do
|
def send_wishlist(socket, _character, wishlist) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x4D) # Wishlist operation
|
|> Out.encode_byte(0x4D) # Wishlist operation
|
||||||
|> then(fn pkt ->
|
|> then(fn pkt ->
|
||||||
Enum.reduce(wishlist, pkt, fn sn, p ->
|
Enum.reduce(wishlist, pkt, fn sn, p ->
|
||||||
@@ -361,7 +361,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def show_coupon_redeemed(socket, items, maple_points, mesos, client_state) do
|
def show_coupon_redeemed(socket, items, maple_points, mesos, client_state) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x4B) # Coupon operation
|
|> Out.encode_byte(0x4B) # Coupon operation
|
||||||
|> Out.encode_byte(if safe_map_size(items) > 0, do: 1, else: 0)
|
|> Out.encode_byte(if safe_map_size(items) > 0, do: 1, else: 0)
|
||||||
|> Out.encode_int(safe_map_size(items))
|
|> Out.encode_int(safe_map_size(items))
|
||||||
@@ -390,7 +390,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def redeem_response(socket) do
|
def redeem_response(socket) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0xA1) # Redeem response
|
|> Out.encode_byte(0xA1) # Redeem response
|
||||||
|> Out.encode_int(0)
|
|> Out.encode_int(0)
|
||||||
|> Out.encode_int(0)
|
|> Out.encode_int(0)
|
||||||
@@ -409,7 +409,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def send_cs_fail(socket, code) do
|
def send_cs_fail(socket, code) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x49) # Fail operation
|
|> Out.encode_byte(0x49) # Fail operation
|
||||||
|> Out.encode_byte(code)
|
|> Out.encode_byte(code)
|
||||||
|> Out.to_data()
|
|> Out.to_data()
|
||||||
@@ -422,7 +422,7 @@ defmodule Odinsea.Shop.Packets do
|
|||||||
"""
|
"""
|
||||||
def cash_item_expired(socket, unique_id) do
|
def cash_item_expired(socket, unique_id) do
|
||||||
packet =
|
packet =
|
||||||
Out.new(Opcodes.lp_cash_shop_update())
|
Out.new(Opcodes.lp_cs_update())
|
||||||
|> Out.encode_byte(0x4E) # Expired operation
|
|> Out.encode_byte(0x4E) # Expired operation
|
||||||
|> Out.encode_long(unique_id)
|
|> Out.encode_long(unique_id)
|
||||||
|> Out.to_data()
|
|> Out.to_data()
|
||||||
|
|||||||
1582
logs/odinsea.log
Normal file
1582
logs/odinsea.log
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@ defmodule Odinsea.Repo.Migrations.CreateBaseTables do
|
|||||||
add :name, :string, size: 13, null: false
|
add :name, :string, size: 13, null: false
|
||||||
add :password, :string, size: 128, null: false, default: ""
|
add :password, :string, size: 128, null: false, default: ""
|
||||||
add :salt, :string, size: 32
|
add :salt, :string, size: 32
|
||||||
add :secondpassword, :string, size: 134
|
add :second_password, :string, size: 134
|
||||||
add :salt2, :string, size: 32
|
add :second_salt, :string, size: 32
|
||||||
add :loggedin, :boolean, null: false, default: false
|
add :loggedin, :boolean, null: false, default: false
|
||||||
add :lastlogin, :naive_datetime
|
add :lastlogin, :naive_datetime
|
||||||
add :birthday, :date, null: false, default: "0000-01-01"
|
add :birthday, :date, null: false, default: "0000-01-01"
|
||||||
|
|||||||
60
test/crypto_simple_test.exs
Normal file
60
test/crypto_simple_test.exs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
defmodule Odinsea.CryptoSimpleTest do
|
||||||
|
@moduledoc """
|
||||||
|
Simple test module for MapleStory crypto implementation.
|
||||||
|
These tests don't require the full application to start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.{IGCipher, ShandaCipher, AESCipher}
|
||||||
|
|
||||||
|
describe "IGCipher (IV transformation)" do
|
||||||
|
test "inno_hash transforms IV correctly" do
|
||||||
|
iv = <<0x34, 0x9A, 0x0F, 0x0C>>
|
||||||
|
result = IGCipher.inno_hash(iv)
|
||||||
|
|
||||||
|
# Result should be 4 bytes
|
||||||
|
assert byte_size(result) == 4
|
||||||
|
|
||||||
|
# Result should be different from input (usually)
|
||||||
|
assert result != iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "inno_hash produces deterministic results" do
|
||||||
|
iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
result1 = IGCipher.inno_hash(iv)
|
||||||
|
result2 = IGCipher.inno_hash(iv)
|
||||||
|
|
||||||
|
assert result1 == result2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ShandaCipher" do
|
||||||
|
test "encrypt and decrypt are inverse operations" do
|
||||||
|
original = <<1, 0, 7, 112, 0, 4, 0>> # CP_PermissionRequest payload
|
||||||
|
encrypted = ShandaCipher.encrypt(original)
|
||||||
|
decrypted = ShandaCipher.decrypt(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original
|
||||||
|
end
|
||||||
|
|
||||||
|
test "encrypt produces different output" do
|
||||||
|
original = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
encrypted = ShandaCipher.encrypt(original)
|
||||||
|
|
||||||
|
assert encrypted != original
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AESCipher" do
|
||||||
|
test "crypt is self-inverse (XOR property)" do
|
||||||
|
data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
|
||||||
|
encrypted = AESCipher.crypt(data, iv)
|
||||||
|
decrypted = AESCipher.crypt(encrypted, iv)
|
||||||
|
|
||||||
|
assert decrypted == data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
185
test/crypto_test.exs
Normal file
185
test/crypto_test.exs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
defmodule Odinsea.CryptoTest do
|
||||||
|
@moduledoc """
|
||||||
|
Test module for MapleStory crypto implementation.
|
||||||
|
Run with: mix test test/crypto_test.exs
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.{ClientCrypto, IGCipher, ShandaCipher, AESCipher}
|
||||||
|
alias Odinsea.Util.BitTools
|
||||||
|
|
||||||
|
describe "IGCipher (IV transformation)" do
|
||||||
|
test "inno_hash transforms IV correctly" do
|
||||||
|
iv = <<0x34, 0x9A, 0x0F, 0x0C>>
|
||||||
|
result = IGCipher.inno_hash(iv)
|
||||||
|
|
||||||
|
# Result should be 4 bytes
|
||||||
|
assert byte_size(result) == 4
|
||||||
|
|
||||||
|
# Result should be different from input (usually)
|
||||||
|
assert result != iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "inno_hash produces deterministic results" do
|
||||||
|
iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
result1 = IGCipher.inno_hash(iv)
|
||||||
|
result2 = IGCipher.inno_hash(iv)
|
||||||
|
|
||||||
|
assert result1 == result2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ShandaCipher" do
|
||||||
|
test "encrypt and decrypt are inverse operations" do
|
||||||
|
original = <<1, 0, 7, 112, 0, 4, 0>> # CP_PermissionRequest payload
|
||||||
|
encrypted = ShandaCipher.encrypt(original)
|
||||||
|
decrypted = ShandaCipher.decrypt(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original
|
||||||
|
end
|
||||||
|
|
||||||
|
test "encrypt produces different output" do
|
||||||
|
original = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
encrypted = ShandaCipher.encrypt(original)
|
||||||
|
|
||||||
|
assert encrypted != original
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AESCipher" do
|
||||||
|
test "crypt is self-inverse (XOR property)" do
|
||||||
|
data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
|
||||||
|
encrypted = AESCipher.crypt(data, iv)
|
||||||
|
decrypted = AESCipher.crypt(encrypted, iv)
|
||||||
|
|
||||||
|
assert decrypted == data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ClientCrypto" do
|
||||||
|
test "new creates crypto with random IVs" do
|
||||||
|
crypto = ClientCrypto.new(112)
|
||||||
|
|
||||||
|
assert byte_size(crypto.send_iv) == 4
|
||||||
|
assert byte_size(crypto.recv_iv) == 4
|
||||||
|
assert crypto.version == 112
|
||||||
|
end
|
||||||
|
|
||||||
|
test "new_from_ivs creates crypto with specified IVs" do
|
||||||
|
send_iv = <<0x34, 0x9A, 0x0F, 0x0C>>
|
||||||
|
recv_iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
|
||||||
|
crypto = ClientCrypto.new_from_ivs(112, send_iv, recv_iv)
|
||||||
|
|
||||||
|
assert crypto.send_iv == send_iv
|
||||||
|
assert crypto.recv_iv == recv_iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "new_from_client_ivs swaps IVs correctly" do
|
||||||
|
# Client's perspective
|
||||||
|
client_send_iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
client_recv_iv = <<0x34, 0x9A, 0x0F, 0x0C>>
|
||||||
|
|
||||||
|
# Server's perspective (should be swapped)
|
||||||
|
crypto = ClientCrypto.new_from_client_ivs(112, client_send_iv, client_recv_iv)
|
||||||
|
|
||||||
|
# Server's send = client's recv
|
||||||
|
assert crypto.send_iv == client_recv_iv
|
||||||
|
# Server's recv = client's send
|
||||||
|
assert crypto.recv_iv == client_send_iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "decrypt updates recv_iv" do
|
||||||
|
crypto = ClientCrypto.new(112)
|
||||||
|
original_recv_iv = crypto.recv_iv
|
||||||
|
|
||||||
|
encrypted_data = <<0x7C, 0xA8, 0x7B, 0xA8, 0xBF, 0x0A, 0xCD, 0xDE>>
|
||||||
|
{new_crypto, _decrypted} = ClientCrypto.decrypt(crypto, encrypted_data)
|
||||||
|
|
||||||
|
# IV should be updated after decryption
|
||||||
|
assert new_crypto.recv_iv != original_recv_iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "encrypt updates send_iv" do
|
||||||
|
crypto = ClientCrypto.new(112)
|
||||||
|
original_send_iv = crypto.send_iv
|
||||||
|
|
||||||
|
data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
{new_crypto, _encrypted, _header} = ClientCrypto.encrypt(crypto, data)
|
||||||
|
|
||||||
|
# IV should be updated after encryption
|
||||||
|
assert new_crypto.send_iv != original_send_iv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "header encoding produces 4 bytes" do
|
||||||
|
crypto = ClientCrypto.new(112)
|
||||||
|
data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
|
||||||
|
{_new_crypto, _encrypted, header} = ClientCrypto.encrypt(crypto, data)
|
||||||
|
|
||||||
|
assert byte_size(header) == 4
|
||||||
|
end
|
||||||
|
|
||||||
|
test "header validation works correctly" do
|
||||||
|
crypto = ClientCrypto.new(112)
|
||||||
|
data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
|
||||||
|
{new_crypto, encrypted, header} = ClientCrypto.encrypt(crypto, data)
|
||||||
|
|
||||||
|
# Extract raw_seq from header
|
||||||
|
<<raw_seq::little-16, _raw_len::little-16>> = header
|
||||||
|
|
||||||
|
# Validation should pass
|
||||||
|
assert ClientCrypto.decode_header_valid?(new_crypto, raw_seq)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "full encrypt/decrypt roundtrip" do
|
||||||
|
# Create crypto instances with same IVs
|
||||||
|
send_iv = <<0x34, 0x9A, 0x0F, 0x0C>>
|
||||||
|
recv_iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
|
||||||
|
# Server's crypto (for sending)
|
||||||
|
server_crypto = ClientCrypto.new_from_ivs(112, send_iv, recv_iv)
|
||||||
|
|
||||||
|
# Client's crypto would have swapped IVs
|
||||||
|
# For testing, we'll use the same crypto to verify roundtrip
|
||||||
|
original_data = <<1, 0, 7, 112, 0, 4, 0>>
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
{server_crypto_after, encrypted, header} = ClientCrypto.encrypt(server_crypto, original_data)
|
||||||
|
|
||||||
|
# Decrypt (using recv_iv which should match server's send_iv after swap)
|
||||||
|
# For this test, we'll create a matching client crypto
|
||||||
|
client_crypto = ClientCrypto.new_from_ivs(112, recv_iv, send_iv)
|
||||||
|
{client_crypto_after, decrypted} = ClientCrypto.decrypt(client_crypto, encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "BitTools" do
|
||||||
|
test "roll_left rotates bits correctly" do
|
||||||
|
# 0b11001101 = 205
|
||||||
|
# rotate left by 3: 0b01101101 = 109 (wrapping around)
|
||||||
|
result = BitTools.roll_left(205, 3)
|
||||||
|
assert result == 109
|
||||||
|
end
|
||||||
|
|
||||||
|
test "roll_right rotates bits correctly" do
|
||||||
|
# Test that roll_right is inverse of roll_left
|
||||||
|
original = 205
|
||||||
|
rotated = BitTools.roll_left(original, 3)
|
||||||
|
back = BitTools.roll_right(rotated, 3)
|
||||||
|
assert back == original
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiply_bytes repeats correctly" do
|
||||||
|
input = <<1, 2, 3, 4>>
|
||||||
|
result = BitTools.multiply_bytes(input, 4, 2)
|
||||||
|
assert result == <<1, 2, 3, 4, 1, 2, 3, 4>>
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
80
test/debug_crypto.exs
Normal file
80
test/debug_crypto.exs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Debug script to trace crypto operations
|
||||||
|
# Run with: cd /home/ra/odinsea-elixir && elixir test/debug_crypto.exs
|
||||||
|
|
||||||
|
Code.require_file("lib/odinsea/util/bit_tools.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/ig_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/aes_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/shanda_cipher.ex", ".")
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.{IGCipher, AESCipher, ShandaCipher}
|
||||||
|
alias Odinsea.Util.BitTools
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
IO.puts("=== Debugging Crypto ===\n")
|
||||||
|
|
||||||
|
# From the packet log:
|
||||||
|
# Server's recv_iv = <<0xA8, 0xBC, 0x0D, 0xB3>> (client's send_iv)
|
||||||
|
recv_iv = <<0xA8, 0xBC, 0x0D, 0xB3>>
|
||||||
|
|
||||||
|
# First encrypted packet from client:
|
||||||
|
# [Data] 7C A8 7B A8 BF 0A CD DE C7 71 AC
|
||||||
|
packet = <<0x7C, 0xA8, 0x7B, 0xA8, 0xBF, 0x0A, 0xCD, 0xDE, 0xC7, 0x71, 0xAC>>
|
||||||
|
|
||||||
|
IO.puts("Raw packet: #{Base.encode16(packet)}")
|
||||||
|
IO.puts("Server recv_iv: #{Base.encode16(recv_iv)}")
|
||||||
|
|
||||||
|
# Extract header
|
||||||
|
<<raw_seq::little-16, raw_len::little-16, payload::binary>> = packet
|
||||||
|
IO.puts("\nHeader:")
|
||||||
|
IO.puts(" raw_seq: 0x#{Integer.to_string(raw_seq, 16)} (#{raw_seq})")
|
||||||
|
IO.puts(" raw_len: 0x#{Integer.to_string(raw_len, 16)} (#{raw_len})")
|
||||||
|
IO.puts(" payload: #{Base.encode16(payload)} (#{byte_size(payload)} bytes)")
|
||||||
|
|
||||||
|
# Validate header
|
||||||
|
<<_r0, _r1, r2, r3>> = recv_iv
|
||||||
|
enc_version = 112
|
||||||
|
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||||
|
calculated_version = Bitwise.bxor(raw_seq &&& 0xFFFF, seq_base)
|
||||||
|
|
||||||
|
IO.puts("\nHeader validation:")
|
||||||
|
IO.puts(" r2: 0x#{Integer.to_string(r2, 16)} (#{r2})")
|
||||||
|
IO.puts(" r3: 0x#{Integer.to_string(r3, 16)} (#{r3})")
|
||||||
|
IO.puts(" seq_base: 0x#{Integer.to_string(seq_base, 16)} (#{seq_base})")
|
||||||
|
IO.puts(" calculated_version: #{calculated_version}")
|
||||||
|
IO.puts(" expected_version: #{enc_version}")
|
||||||
|
IO.puts(" valid: #{calculated_version == enc_version}")
|
||||||
|
|
||||||
|
# Get packet length
|
||||||
|
packet_len = Bitwise.bxor(raw_seq, raw_len) &&& 0xFFFF
|
||||||
|
IO.puts("\nPacket length: #{packet_len}")
|
||||||
|
|
||||||
|
# Decrypt with AES
|
||||||
|
IO.puts("\n=== AES Decryption ===")
|
||||||
|
aes_decrypted = AESCipher.crypt(payload, recv_iv)
|
||||||
|
IO.puts("After AES: #{Base.encode16(aes_decrypted)}")
|
||||||
|
|
||||||
|
# Decrypt with Shanda
|
||||||
|
IO.puts("\n=== Shanda Decryption ===")
|
||||||
|
shanda_decrypted = ShandaCipher.decrypt(aes_decrypted)
|
||||||
|
IO.puts("After Shanda: #{Base.encode16(shanda_decrypted)}")
|
||||||
|
|
||||||
|
# Expected opcode 0x01 = 1
|
||||||
|
if byte_size(shanda_decrypted) >= 2 do
|
||||||
|
<<opcode::little-16, rest::binary>> = shanda_decrypted
|
||||||
|
IO.puts("\n=== Result ===")
|
||||||
|
IO.puts("Opcode: 0x#{Integer.to_string(opcode, 16)} (#{opcode})")
|
||||||
|
IO.puts("Rest: #{Base.encode16(rest)}")
|
||||||
|
|
||||||
|
if opcode == 1 do
|
||||||
|
IO.puts("\n✓ SUCCESS! This is CP_PermissionRequest (0x01)")
|
||||||
|
else
|
||||||
|
IO.puts("\n✗ FAILED! Expected opcode 0x01, got 0x#{Integer.to_string(opcode, 16)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update IV
|
||||||
|
new_recv_iv = IGCipher.inno_hash(recv_iv)
|
||||||
|
IO.puts("\nUpdated recv_iv: #{Base.encode16(new_recv_iv)}")
|
||||||
|
|
||||||
|
IO.puts("\n=== Done ===")
|
||||||
101
test/debug_crypto2.exs
Normal file
101
test/debug_crypto2.exs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Debug script to trace crypto operations with CORRECT IV
|
||||||
|
# Run with: cd /home/ra/odinsea-elixir && elixir test/debug_crypto2.exs
|
||||||
|
|
||||||
|
Code.require_file("lib/odinsea/util/bit_tools.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/ig_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/aes_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/shanda_cipher.ex", ".")
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.{IGCipher, AESCipher, ShandaCipher}
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
IO.puts("=== Debugging Crypto (Corrected IV) ===\n")
|
||||||
|
|
||||||
|
# From the hello packet sent by server:
|
||||||
|
# [Data] 0E 00 70 00 01 00 34 9A 0F 0C A8 BC 0D B3 E6 07
|
||||||
|
# Server sends: recv_iv=34 9A 0F 0C, send_iv=A8 BC 0D B3
|
||||||
|
#
|
||||||
|
# Client SWAPS these:
|
||||||
|
# Client's send_iv = server's recv_iv = 34 9A 0F 0C
|
||||||
|
# Client's recv_iv = server's send_iv = A8 BC 0D B3
|
||||||
|
#
|
||||||
|
# Client encrypts with its send_iv (34 9A 0F 0C)
|
||||||
|
# Server must decrypt with its recv_iv = 34 9A 0F 0C
|
||||||
|
|
||||||
|
server_send_iv = <<0xA8, 0xBC, 0x0D, 0xB3>> # Server sends this, client uses as recv
|
||||||
|
server_recv_iv = <<0x34, 0x9A, 0x0F, 0x0C>> # Server sends this, client uses as send
|
||||||
|
|
||||||
|
IO.puts("Server perspective:")
|
||||||
|
IO.puts(" server_send_iv: #{Base.encode16(server_send_iv)}")
|
||||||
|
IO.puts(" server_recv_iv: #{Base.encode16(server_recv_iv)}")
|
||||||
|
|
||||||
|
IO.puts("\nClient perspective (after swapping):")
|
||||||
|
IO.puts(" client_send_iv = server_recv_iv: #{Base.encode16(server_recv_iv)}")
|
||||||
|
IO.puts(" client_recv_iv = server_send_iv: #{Base.encode16(server_send_iv)}")
|
||||||
|
|
||||||
|
# First encrypted packet from client:
|
||||||
|
# [Data] 7C A8 7B A8 BF 0A CD DE C7 71 AC
|
||||||
|
packet = <<0x7C, 0xA8, 0x7B, 0xA8, 0xBF, 0x0A, 0xCD, 0xDE, 0xC7, 0x71, 0xAC>>
|
||||||
|
|
||||||
|
IO.puts("\nRaw packet: #{Base.encode16(packet)}")
|
||||||
|
|
||||||
|
# Server decrypts with server_recv_iv
|
||||||
|
recv_iv = server_recv_iv
|
||||||
|
IO.puts("Server decrypts with recv_iv: #{Base.encode16(recv_iv)}")
|
||||||
|
|
||||||
|
# Extract header
|
||||||
|
<<raw_seq::little-16, raw_len::little-16, payload::binary>> = packet
|
||||||
|
IO.puts("\nHeader:")
|
||||||
|
IO.puts(" raw_seq: 0x#{Integer.to_string(raw_seq, 16)} (#{raw_seq})")
|
||||||
|
IO.puts(" raw_len: 0x#{Integer.to_string(raw_len, 16)} (#{raw_len})")
|
||||||
|
IO.puts(" payload: #{Base.encode16(payload)} (#{byte_size(payload)} bytes)")
|
||||||
|
|
||||||
|
# Validate header
|
||||||
|
<<_r0, _r1, r2, r3>> = recv_iv
|
||||||
|
enc_version = 112
|
||||||
|
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xffff
|
||||||
|
calculated_version = bxor(raw_seq &&& 0xFFFF, seq_base)
|
||||||
|
|
||||||
|
IO.puts("\nHeader validation:")
|
||||||
|
IO.puts(" r2: 0x#{Integer.to_string(r2, 16)} (#{r2})")
|
||||||
|
IO.puts(" r3: 0x#{Integer.to_string(r3, 16)} (#{r3})")
|
||||||
|
IO.puts(" seq_base: 0x#{Integer.to_string(seq_base, 16)} (#{seq_base})")
|
||||||
|
IO.puts(" raw_seq ^ seq_base: #{calculated_version}")
|
||||||
|
IO.puts(" expected_version: #{enc_version}")
|
||||||
|
IO.puts(" valid: #{calculated_version == enc_version}")
|
||||||
|
|
||||||
|
# Get packet length
|
||||||
|
packet_len = bxor(raw_seq, raw_len) &&& 0xFFFF
|
||||||
|
IO.puts("\nPacket length: #{packet_len}")
|
||||||
|
|
||||||
|
# Decrypt with AES
|
||||||
|
IO.puts("\n=== AES Decryption ===")
|
||||||
|
IO.puts("Input: #{Base.encode16(payload)}")
|
||||||
|
aes_decrypted = AESCipher.crypt(payload, recv_iv)
|
||||||
|
IO.puts("After AES: #{Base.encode16(aes_decrypted)}")
|
||||||
|
|
||||||
|
# Decrypt with Shanda
|
||||||
|
IO.puts("\n=== Shanda Decryption ===")
|
||||||
|
shanda_decrypted = ShandaCipher.decrypt(aes_decrypted)
|
||||||
|
IO.puts("After Shanda: #{Base.encode16(shanda_decrypted)}")
|
||||||
|
|
||||||
|
# Expected opcode 0x01 = 1
|
||||||
|
if byte_size(shanda_decrypted) >= 2 do
|
||||||
|
<<opcode::little-16, rest::binary>> = shanda_decrypted
|
||||||
|
IO.puts("\n=== Result ===")
|
||||||
|
IO.puts("Opcode: 0x#{Integer.to_string(opcode, 16)} (#{opcode})")
|
||||||
|
IO.puts("Rest: #{Base.encode16(rest)}")
|
||||||
|
|
||||||
|
if opcode == 1 do
|
||||||
|
IO.puts("\n✓ SUCCESS! This is CP_PermissionRequest (0x01)")
|
||||||
|
else
|
||||||
|
IO.puts("\n✗ FAILED! Expected opcode 0x01, got 0x#{Integer.to_string(opcode, 16)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update IV
|
||||||
|
new_recv_iv = IGCipher.inno_hash(recv_iv)
|
||||||
|
IO.puts("\nUpdated recv_iv: #{Base.encode16(new_recv_iv)}")
|
||||||
|
|
||||||
|
IO.puts("\n=== Done ===")
|
||||||
53
test/find_iv.exs
Normal file
53
test/find_iv.exs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Find the correct IV by trying all combinations from hello packet
|
||||||
|
# Hello packet: 0E 00 70 00 01 00 34 9A 0F 0C A8 BC 0D B3 E6 07
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
# Raw packet from client
|
||||||
|
packet = <<0x7C, 0xA8, 0x7B, 0xA8, 0xBF, 0x0A, 0xCD, 0xDE, 0xC7, 0x71, 0xAC>>
|
||||||
|
<<raw_seq::little-16, _raw_len::little-16, _payload::binary>> = packet
|
||||||
|
|
||||||
|
IO.puts("Raw packet: #{Base.encode16(packet)}")
|
||||||
|
IO.puts("raw_seq: 0x#{Integer.to_string(raw_seq, 16)} (#{raw_seq})")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# For header validation to pass: raw_seq ^ seq_base == 112
|
||||||
|
target_seq_base = bxor(raw_seq, 112)
|
||||||
|
IO.puts("Need seq_base: 0x#{Integer.to_string(target_seq_base, 16)} (#{target_seq_base})")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# seq_base = (r2 & 0xFF) | ((r3 << 8) & 0xFF00)
|
||||||
|
# So: r2 = lower byte, r3 = upper byte
|
||||||
|
target_r2 = target_seq_base &&& 0xFF
|
||||||
|
target_r3 = (target_seq_base >>> 8) &&& 0xFF
|
||||||
|
IO.puts("Need recv_iv[2] = 0x#{Integer.to_string(target_r2, 16)} (#{target_r2})")
|
||||||
|
IO.puts("Need recv_iv[3] = 0x#{Integer.to_string(target_r3, 16)} (#{target_r3})")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# Bytes available in hello packet (positions 6-13):
|
||||||
|
# 34 9A 0F 0C A8 BC 0D B3
|
||||||
|
bytes = [0x34, 0x9A, 0x0F, 0x0C, 0xA8, 0xBC, 0x0D, 0xB3]
|
||||||
|
IO.puts("Available bytes from hello packet:")
|
||||||
|
Enum.each(Enum.with_index(bytes), fn {b, i} ->
|
||||||
|
IO.puts(" [#{i}]: 0x#{Integer.to_string(b, 16)}")
|
||||||
|
end)
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# Find matching bytes
|
||||||
|
IO.puts("Looking for matches...")
|
||||||
|
Enum.each(Enum.with_index(bytes), fn {b2, i2} ->
|
||||||
|
Enum.each(Enum.with_index(bytes), fn {b3, i3} ->
|
||||||
|
if b2 == target_r2 and b3 == target_r3 do
|
||||||
|
IO.puts("Found match! recv_iv[2]=0x#{Integer.to_string(b2, 16)} at [#{i2}], recv_iv[3]=0x#{Integer.to_string(b3, 16)} at [#{i3}]")
|
||||||
|
|
||||||
|
# Construct full IV (need to determine r0 and r1 too)
|
||||||
|
# Try different combinations for r0 and r1
|
||||||
|
Enum.each(Enum.with_index(bytes), fn {b0, i0} ->
|
||||||
|
Enum.each(Enum.with_index(bytes), fn {b1, i1} ->
|
||||||
|
iv = <<b0, b1, b2, b3>>
|
||||||
|
IO.puts(" Possible IV: #{Base.encode16(iv)} (bytes[#{i0}][#{i1}][#{i2}][#{i3}])")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
48
test/test_aes.exs
Normal file
48
test/test_aes.exs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Test AES cipher with known values
|
||||||
|
|
||||||
|
Code.require_file("lib/odinsea/util/bit_tools.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/ig_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/aes_cipher.ex", ".")
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.AESCipher
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
# Test: AES crypt should be self-inverse (since it's just XOR)
|
||||||
|
iv = <<0x0F, 0x0C, 0x0C, 0xA8>>
|
||||||
|
data = <<0xBF, 0x0A, 0xCD, 0xDE, 0xC7, 0x71, 0xAC>>
|
||||||
|
|
||||||
|
IO.puts("Testing AES cipher:")
|
||||||
|
IO.puts("IV: #{Base.encode16(iv)}")
|
||||||
|
IO.puts("Data: #{Base.encode16(data)}")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# Expand IV to 16 bytes
|
||||||
|
expanded_iv = :binary.copy(iv, 4)
|
||||||
|
IO.puts("Expanded IV (16 bytes): #{Base.encode16(expanded_iv)}")
|
||||||
|
|
||||||
|
# The AES key (16 bytes)
|
||||||
|
aes_key = <<
|
||||||
|
0x13, 0x00, 0x00, 0x00,
|
||||||
|
0x08, 0x00, 0x00, 0x00,
|
||||||
|
0x06, 0x00, 0x00, 0x00,
|
||||||
|
0xB4, 0x00, 0x00, 0x00
|
||||||
|
>>
|
||||||
|
|
||||||
|
# Encrypt the expanded IV to get keystream
|
||||||
|
keystream = :crypto.crypto_one_time(:aes_128_ecb, aes_key, expanded_iv, true)
|
||||||
|
IO.puts("Keystream: #{Base.encode16(keystream)}")
|
||||||
|
|
||||||
|
# XOR data with keystream (only use as many bytes as data)
|
||||||
|
keystream_bytes = :binary.bin_to_list(keystream) |> Enum.take(byte_size(data))
|
||||||
|
data_bytes = :binary.bin_to_list(data)
|
||||||
|
result_bytes = Enum.zip_with(data_bytes, keystream_bytes, fn x, y -> bxor(x, y) end)
|
||||||
|
result = :binary.list_to_bin(result_bytes)
|
||||||
|
|
||||||
|
IO.puts("XOR result: #{Base.encode16(result)}")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# Compare with AESCipher.crypt
|
||||||
|
aes_result = AESCipher.crypt(data, iv)
|
||||||
|
IO.puts("AESCipher.crypt result: #{Base.encode16(aes_result)}")
|
||||||
|
IO.puts("Match: #{result == aes_result}")
|
||||||
55
test/test_ivs.exs
Normal file
55
test/test_ivs.exs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Test specific IV candidates
|
||||||
|
|
||||||
|
Code.require_file("lib/odinsea/util/bit_tools.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/ig_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/aes_cipher.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/shanda_cipher.ex", ".")
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.{IGCipher, AESCipher, ShandaCipher}
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
packet = <<0x7C, 0xA8, 0x7B, 0xA8, 0xBF, 0x0A, 0xCD, 0xDE, 0xC7, 0x71, 0xAC>>
|
||||||
|
<<raw_seq::little-16, raw_len::little-16, payload::binary>> = packet
|
||||||
|
|
||||||
|
IO.puts("Testing IV candidates for packet: #{Base.encode16(packet)}")
|
||||||
|
IO.puts("raw_seq: 0x#{Integer.to_string(raw_seq, 16)}, raw_len: 0x#{Integer.to_string(raw_len, 16)}")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# IV candidates based on bytes [2]=0x0F, [3]=0x0C, [4]=0xA8, [5]=0xBC
|
||||||
|
candidates = [
|
||||||
|
<<0x0F, 0x0C, 0x0C, 0xA8>>, # [2][3][3][4] - overlapping
|
||||||
|
<<0x0C, 0x0C, 0x0C, 0xA8>>, # [3][3][3][4]
|
||||||
|
<<0x0C, 0xA8, 0x0C, 0xA8>>, # [3][4][3][4] - repeating pattern
|
||||||
|
<<0x0F, 0x0C, 0xA8, 0xBC>>, # [2][3][4][5] - recv_iv from hello?
|
||||||
|
<<0x34, 0x9A, 0x0C, 0xA8>>, # [0][1][3][4]
|
||||||
|
<<0x9A, 0x0F, 0x0C, 0xA8>>, # [1][2][3][4]
|
||||||
|
<<0x0C, 0xA8, 0xBC, 0x0D>>, # [3][4][5][6]
|
||||||
|
<<0xA8, 0xBC, 0x0C, 0xA8>>, # [4][5][3][4]
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.each(candidates, fn iv ->
|
||||||
|
<<_r0, _r1, r2, r3>> = iv
|
||||||
|
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||||
|
valid = bxor(raw_seq &&& 0xFFFF, seq_base) == 112
|
||||||
|
|
||||||
|
IO.puts("IV: #{Base.encode16(iv)}")
|
||||||
|
IO.puts(" seq_base: 0x#{Integer.to_string(seq_base, 16)}, valid: #{valid}")
|
||||||
|
|
||||||
|
if valid do
|
||||||
|
# Try full decryption
|
||||||
|
aes_result = AESCipher.crypt(payload, iv)
|
||||||
|
shanda_result = ShandaCipher.decrypt(aes_result)
|
||||||
|
|
||||||
|
if byte_size(shanda_result) >= 2 do
|
||||||
|
<<opcode::little-16, _rest::binary>> = shanda_result
|
||||||
|
IO.puts(" *** VALID IV! ***")
|
||||||
|
IO.puts(" Decrypted opcode: 0x#{Integer.to_string(opcode, 16)} (#{opcode})")
|
||||||
|
if opcode == 1 do
|
||||||
|
IO.puts(" *** CORRECT OPCODE! ***")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("")
|
||||||
|
end)
|
||||||
54
test/test_shanda.exs
Normal file
54
test/test_shanda.exs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Test Shanda cipher - encrypt then decrypt should give original
|
||||||
|
|
||||||
|
Code.require_file("lib/odinsea/util/bit_tools.ex", ".")
|
||||||
|
Code.require_file("lib/odinsea/net/cipher/shanda_cipher.ex", ".")
|
||||||
|
|
||||||
|
alias Odinsea.Net.Cipher.ShandaCipher
|
||||||
|
alias Odinsea.Util.BitTools
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
# Test with simple data
|
||||||
|
data = <<0x01, 0x00, 0x07, 0x70, 0x00, 0x04, 0x00>>
|
||||||
|
IO.puts("Original data: #{Base.encode16(data)}")
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
encrypted = ShandaCipher.encrypt(data)
|
||||||
|
IO.puts("Encrypted: #{Base.encode16(encrypted)}")
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
decrypted = ShandaCipher.decrypt(encrypted)
|
||||||
|
IO.puts("Decrypted: #{Base.encode16(decrypted)}")
|
||||||
|
|
||||||
|
IO.puts("Match: #{data == decrypted}")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# If they don't match, let's debug step by step
|
||||||
|
if data != decrypted do
|
||||||
|
IO.puts("MISMATCH! Let's debug...")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# Manual encrypt - first pass only
|
||||||
|
bytes = :binary.bin_to_list(data)
|
||||||
|
data_len = length(bytes)
|
||||||
|
data_length = data_len &&& 0xFF
|
||||||
|
|
||||||
|
IO.puts("Data length: #{data_len}")
|
||||||
|
IO.puts("Initial bytes: #{inspect(bytes)}")
|
||||||
|
IO.puts("")
|
||||||
|
|
||||||
|
# First pass (j=0, forward)
|
||||||
|
IO.puts("=== Pass 0 (forward) ===")
|
||||||
|
{result0, remember0} = Enum.reduce(Enum.with_index(bytes), {[], 0}, fn {cur, i}, {acc, remember} ->
|
||||||
|
cur = BitTools.roll_left(cur, 3)
|
||||||
|
cur = (cur + data_length) &&& 0xFF
|
||||||
|
cur = bxor(cur, remember)
|
||||||
|
new_remember = cur
|
||||||
|
cur = BitTools.roll_right(cur, data_length &&& 0xFF)
|
||||||
|
cur = bxor(cur, 0xFF)
|
||||||
|
cur = (cur + 0x48) &&& 0xFF
|
||||||
|
{[cur | acc], new_remember}
|
||||||
|
end)
|
||||||
|
result0 = Enum.reverse(result0)
|
||||||
|
IO.puts("After pass 0: #{inspect(result0)}, remember: #{remember0}")
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user