fix login issue*

This commit is contained in:
2026-02-25 12:26:26 -07:00
parent da581f5a20
commit 2c3d0ab580
37 changed files with 4708 additions and 721 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
View 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`

View File

@@ -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)

View File

@@ -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
# ============================================================================= # =============================================================================

View File

@@ -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.
""" """

View File

@@ -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

View File

@@ -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 """

View File

@@ -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))

View File

@@ -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.

View File

@@ -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

View File

@@ -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.
""" """

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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.
""" """

View 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

View File

@@ -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 ->

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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