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
config :odinsea, :login,
port: 8584,
port: 8484,
user_limit: 1500,
max_characters: 3,
flag: 3,
@@ -28,8 +28,7 @@ config :odinsea, :game,
channel_ports: %{1 => 8585, 2 => 8586},
events: []
config :odinsea, :shop,
port: 8605
config :odinsea, :shop, port: 8605
config :odinsea, :features,
admin_mode: false,

View File

@@ -11,7 +11,8 @@ config :odinsea, :server,
host: System.get_env("ODINSEA_HOST", "127.0.0.1"),
revision: String.to_integer(System.get_env("ODINSEA_REV", "1")),
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")
# ====================================================================================================
@@ -31,7 +32,8 @@ config :odinsea, :login,
user_limit: String.to_integer(System.get_env("ODINSEA_USER_LIMIT", "1500")),
max_characters: String.to_integer(System.get_env("ODINSEA_MAX_CHARACTERS", "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
@@ -49,15 +51,17 @@ channel_ports =
config :odinsea, :game,
channels: channel_count,
channel_ports: channel_ports,
events: System.get_env("ODINSEA_EVENTS",
"MiniDungeon,Olivia,PVP,CygnusBattle,ScarTarBattle,VonLeonBattle"
) |> String.split(",")
events:
System.get_env(
"ODINSEA_EVENTS",
"MiniDungeon,Olivia,PVP,CygnusBattle,ScarTarBattle,VonLeonBattle"
)
|> String.split(",")
# ====================================================================================================
# Cash Shop Server
# ====================================================================================================
config :odinsea, :shop,
port: String.to_integer(System.get_env("ODINSEA_SHOP_PORT", "8605"))
config :odinsea, :shop, port: String.to_integer(System.get_env("ODINSEA_SHOP_PORT", "8605"))
# ====================================================================================================
# Database
@@ -94,7 +98,7 @@ config :odinsea, :features,
script_reload: System.get_env("ODINSEA_SCRIPT_RELOAD", "true") == "true",
family_disable: System.get_env("ODINSEA_FAMILY_DISABLE", "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"
# ====================================================================================================

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
if op == 22 do
handle_deny_invite(client_pid, character_id, char_state, guild_id)
handle_deny_invite(client_pid, char_state)
else
handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
end
@@ -255,8 +255,15 @@ defmodule Odinsea.Channel.Handler.Alliance do
Also called when op == 22 in alliance operation.
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
# invited_alliance_id = World.Guild.get_invited_id(guild_id)

View File

@@ -2016,6 +2016,221 @@ defmodule Odinsea.Channel.Packets do
|> Out.to_data()
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
# =============================================================================

View File

@@ -108,6 +108,16 @@ defmodule Odinsea.Channel.Players do
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 """
Updates player data.
"""

View File

@@ -4,9 +4,11 @@ defmodule Odinsea.Constants.Server do
These define the MapleStory client version and protocol details.
"""
# MapleStory Client Version (GMS v342)
@maple_version 342
@maple_patch "1"
# MapleStory Client Version (MapleSEA v112.4)
# Ported from ServerConstants.java
@maple_version 112
@maple_patch "4"
@maple_locale 7
@client_version 99
# Protocol constants
@@ -15,9 +17,10 @@ defmodule Odinsea.Constants.Server do
@block_size 1460
# RSA Keys (from ServerConstants.java)
@pub_key ""
@maplogin_default "default"
@maplogin_custom "custom"
# Default MapleStory RSA public key for password encryption
@pub_key "30819F300D06092A864886F70D010101050003818D0030818902818100994F4E66B003A7843C944E67BE4375203DAA203C676908E59839C9BADE95F53E848AAFE61DB9C09E80F48675CA2696F4E897B7F18CCB6398D221C4EC5823D11CA1FB9764A78F84711B8B6FCA9F01B171A51EC66C02CDA9308887CEE8E59C4FF0B146BF71F697EB11EDCEBFCE02FB0101A7076A3FEB64F6F6022C8417EB6B87270203010001"
@maplogin_default "MapLogin"
@maplogin_custom "MapLoginLuna"
# Packet sequence constants
@iv_length 4
@@ -35,6 +38,12 @@ defmodule Odinsea.Constants.Server do
"""
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 """
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]
schema "accounts" do
field :name, :string
field :password, :string
field :salt, :string
field :second_password, :string, source: :"2ndpassword"
field :salt2, :string
field :loggedin, :integer, default: 0
field :lastlogin, :naive_datetime
field :createdat, :naive_datetime
field :birthday, :date
field :banned, :integer, default: 0
field :banreason, :string
field :gm, :integer, default: 0
field :email, :string
field :macs, :string
field :tempban, :naive_datetime
field :greason, :integer
field :acash, :integer, default: 0, source: :ACash
field :mpoints, :integer, default: 0, source: :mPoints
field :gender, :integer, default: 0
field :session_ip, :string, source: :SessionIP
field :points, :integer, default: 0
field :vpoints, :integer, default: 0
field :totalvotes, :integer, default: 0
field :lastlogon, :naive_datetime
field :lastvoteip, :string
field(:name, :string)
field(:password, :string)
field(:salt, :string)
field(:second_password, :string, source: :second_password)
field(:second_salt, :string)
field(:loggedin, :integer, default: 0)
field(:lastlogin, :naive_datetime)
field(:createdat, :naive_datetime)
field(:birthday, :date)
field(:banned, :integer, default: 0)
field(:banreason, :string)
field(:gm, :integer, default: 0)
field(:email, :string)
field(:macs, :string)
field(:tempban, :naive_datetime)
field(:greason, :integer)
field(:acash, :integer, default: 0, source: :ACash)
field(:mpoints, :integer, default: 0, source: :mPoints)
field(:gender, :integer, default: 0)
field(:session_ip, :string, source: :SessionIP)
field(:points, :integer, default: 0)
field(:vpoints, :integer, default: 0)
field(:totalvotes, :integer, default: 0)
field(:lastlogon, :naive_datetime)
field(:lastvoteip, :string)
has_many :characters, Odinsea.Database.Schema.Character, foreign_key: :accountid
has_many(:characters, Odinsea.Database.Schema.Character, foreign_key: :accountid)
end
@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})
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
# ============================================================================
@@ -1014,6 +1021,14 @@ defmodule Odinsea.Game.Character do
{:noreply, new_state}
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
def handle_call({:add_item, inventory_type, item}, _from, state) do
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)
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 """
Gets the next available slot number.
Returns -1 if the inventory is full.

View File

@@ -124,4 +124,30 @@ defmodule Odinsea.Game.InventoryType do
def slot_limit(type) do
default_slot_limit(type)
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

View File

@@ -209,6 +209,13 @@ defmodule Odinsea.Game.Map do
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
end
@doc """
Gets all monsters on the map (default channel).
"""
def get_monsters(map_id) do
get_monsters(map_id, 1)
end
@doc """
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})
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 """
Hits a reactor, advancing its state and triggering effects.
"""

View File

@@ -1,7 +1,7 @@
defmodule Odinsea.Login.Client do
@moduledoc """
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
@@ -10,6 +10,10 @@ defmodule Odinsea.Login.Client do
alias Odinsea.Net.Packet.In
alias Odinsea.Net.Opcodes
alias Odinsea.Net.PacketLogger
alias Odinsea.Login.Packets
alias Odinsea.Net.Cipher.ClientCrypto
alias Odinsea.Util.BitTools
defstruct [
:socket,
@@ -25,7 +29,25 @@ defmodule Odinsea.Login.Client do
:second_password,
:gender,
: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
@@ -39,6 +61,16 @@ defmodule Odinsea.Login.Client do
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__{
socket: socket,
ip: ip_string,
@@ -53,9 +85,27 @@ defmodule Odinsea.Login.Client do
second_password: nil,
gender: 0,
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
send(self(), :receive)
@@ -66,8 +116,9 @@ defmodule Odinsea.Login.Client do
def handle_info(:receive, %{socket: socket} = state) do
case :gen_tcp.recv(socket, 0, 30_000) do
{:ok, data} ->
# Handle packet
new_state = handle_packet(data, state)
# Append to buffer and process all complete packets
new_state = %{state | buffer: state.buffer <> data}
new_state = process_buffer(new_state)
send(self(), :receive)
{:noreply, new_state}
@@ -96,23 +147,100 @@ defmodule Odinsea.Login.Client do
:ok
end
defp handle_packet(data, state) do
packet = In.new(data)
# Process all complete packets from the TCP buffer
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)
case In.decode_short(packet) do
{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)
:error ->
Logger.warning("Failed to read packet opcode")
Logger.warning("Failed to read packet opcode from #{state.ip}")
state
end
end
defp dispatch_packet(opcode, packet, state) do
# Use PacketProcessor to route packets
alias Odinsea.Net.Processor
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
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
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

View File

@@ -14,7 +14,8 @@ defmodule Odinsea.Login.Handler do
require Logger
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.Constants.Server
alias Odinsea.Database.Context
@@ -79,11 +80,18 @@ defmodule Odinsea.Login.Handler do
Logger.info("Login attempt: username=#{username} from #{state.ip}")
# 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)
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
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 do
@@ -91,7 +99,7 @@ defmodule Odinsea.Login.Handler do
end
response = Packets.get_login_failed(3)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
else
# Authenticate with database
@@ -106,7 +114,7 @@ defmodule Odinsea.Login.Handler do
format_timestamp(temp_ban_info.expires),
temp_ban_info.reason || ""
)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
else
# Check if already logged in - kick other session
@@ -132,6 +140,8 @@ defmodule Odinsea.Login.Handler do
account_info.second_password
)
state = send_packet(state, response)
new_state =
state
|> Map.put(:logged_in, true)
@@ -142,8 +152,8 @@ defmodule Odinsea.Login.Handler do
|> Map.put(:second_password, account_info.second_password)
|> Map.put(:login_attempts, 0)
send_packet(state, response)
{:ok, new_state}
# Send world info immediately after auth success (Java: LoginWorker.registerClient)
on_world_info_request(new_state)
end
{:error, :invalid_credentials} ->
@@ -156,7 +166,7 @@ defmodule Odinsea.Login.Handler do
else
# Send login failed (reason 4 = incorrect password)
response = Packets.get_login_failed(4)
send_packet(state, response)
state = send_packet(state, response)
new_state = Map.put(state, :login_attempts, login_attempts)
{:ok, new_state}
@@ -165,7 +175,7 @@ defmodule Odinsea.Login.Handler do
{:error, :account_not_found} ->
# Send login failed (reason 5 = not registered ID)
response = Packets.get_login_failed(5)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
{: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
response = Packets.get_login_failed(7)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
{:error, :banned} ->
response = Packets.get_perm_ban(0)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
end
end
@@ -209,19 +219,19 @@ defmodule Odinsea.Login.Handler do
channel_load
)
send_packet(state, server_list)
state = send_packet(state, server_list)
# Send 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
latest_world = Packets.get_latest_connected_world(0)
send_packet(state, latest_world)
state = send_packet(state, latest_world)
# Send recommended world message
recommend = Packets.get_recommend_world_message(0, "Join now!")
send_packet(state, recommend)
state = send_packet(state, recommend)
{:ok, state}
end
@@ -246,7 +256,7 @@ defmodule Odinsea.Login.Handler do
end
response = Packets.get_server_status(status)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
end
@@ -277,7 +287,7 @@ defmodule Odinsea.Login.Handler do
if world_id != 0 do
Logger.warning("Invalid world ID: #{world_id}")
response = Packets.get_login_failed(10)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
else
# TODO: Check if channel is available
@@ -299,7 +309,7 @@ defmodule Odinsea.Login.Handler do
3 # character slots
)
send_packet(state, response)
state = send_packet(state, response)
new_state =
state
@@ -332,7 +342,7 @@ defmodule Odinsea.Login.Handler do
name_used = check_name_used(char_name, state)
response = Packets.get_char_name_response(char_name, name_used)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
end
@@ -381,7 +391,7 @@ defmodule Odinsea.Login.Handler do
# Validate name is not forbidden and doesn't exist
if check_name_used(name, state) do
response = Packets.get_add_new_char_entry(nil, false)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
else
# TODO: Validate appearance items are eligible for gender/job type
@@ -420,7 +430,7 @@ defmodule Odinsea.Login.Handler do
# Reload character with full data
char_data = Context.load_character(character.id)
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
new_char_ids = [character.id | Map.get(state, :character_ids, [])]
@@ -430,7 +440,7 @@ defmodule Odinsea.Login.Handler do
{:error, changeset} ->
Logger.error("Failed to create character: #{inspect(changeset.errors)}")
response = Packets.get_add_new_char_entry(nil, false)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
end
end
@@ -511,7 +521,7 @@ defmodule Odinsea.Login.Handler do
end
response = Packets.get_delete_char_response(character_id, result)
send_packet(state, response)
state = send_packet(state, response)
# Update state if successful
new_state =
@@ -559,7 +569,7 @@ defmodule Odinsea.Login.Handler do
# Validate second password length
if String.length(new_spw) < 6 || String.length(new_spw) > 16 do
response = Packets.get_second_pw_error(0x14)
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
else
# Update second password
@@ -605,7 +615,7 @@ defmodule Odinsea.Login.Handler do
# Send migration command
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
send_packet(state, response)
state = send_packet(state, response)
new_state =
state
@@ -643,7 +653,7 @@ defmodule Odinsea.Login.Handler do
else
# Failure - send error
response = Packets.get_second_pw_error(15) # Incorrect SPW
send_packet(state, response)
state = send_packet(state, response)
{:ok, state}
end
end
@@ -662,14 +672,12 @@ defmodule Odinsea.Login.Handler do
# TODO: Send damage cap packet if custom client
# Send login background
background = Application.get_env(:odinsea, :login_background, "MapLogin")
bg_response = Packets.get_login_background(background)
send_packet(state, bg_response)
bg_response = Packets.get_login_background(Server.maplogin_default())
state = send_packet(state, bg_response)
# Send RSA public key
pub_key = Application.get_env(:odinsea, :rsa_public_key, "")
key_response = Packets.get_rsa_key(pub_key)
send_packet(state, key_response)
key_response = Packets.get_rsa_key(Server.pub_key())
state = send_packet(state, key_response)
{:ok, state}
end
@@ -678,27 +686,40 @@ defmodule Odinsea.Login.Handler do
# Helper Functions
# ==================================================================================================
defp send_packet(%{socket: socket} = state, packet_data) do
# Add header (2 bytes: packet length)
packet_length = byte_size(packet_data)
header = <<packet_length::little-size(16)>>
full_packet = header <> packet_data
defp send_packet(%{socket: socket, crypto: crypto} = state, packet_data) do
# Flatten iodata to binary for pattern matching
packet_data = IO.iodata_to_binary(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
:ok ->
Logger.debug("Sent packet: #{packet_length} bytes")
:ok
%{state | crypto: updated_crypto}
{:error, reason} ->
Logger.error("Failed to send packet: #{inspect(reason)}")
{:error, reason}
state
end
end
defp send_packet(_state, _packet_data) do
defp send_packet(state, _packet_data) do
# Socket not available in state
Logger.error("Cannot send packet: socket not in state")
:error
state
end
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(send_iv)
|> Out.encode_byte(Server.maple_locale())
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -49,27 +49,26 @@ defmodule Odinsea.Login.Packets do
"""
def get_ping do
Out.new(Opcodes.lp_alive_req())
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
Sends the login background image path to the client.
"""
def get_login_background(background_path) do
# Note: In Java this uses LoopbackPacket.LOGIN_AUTH
# Need to verify the correct opcode for this
Out.new(Opcodes.lp_set_client_key()) # TODO: Verify opcode
# Uses LOGIN_AUTH (0x17) opcode
Out.new(Opcodes.lp_login_auth())
|> Out.encode_string(background_path)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
Sends the RSA public key to the client for password encryption.
"""
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.to_data()
|> Out.to_iodata()
end
# ==================================================================================================
@@ -104,13 +103,13 @@ defmodule Odinsea.Login.Packets do
reason == 7 ->
# Already logged in
Out.encode_bytes(packet, <<0, 0, 0, 0, 0>>)
Out.encode_buffer(packet, <<0, 0, 0, 0, 0>>)
true ->
packet
end
Out.to_data(packet)
Out.to_iodata(packet)
end
@doc """
@@ -122,7 +121,7 @@ defmodule Odinsea.Login.Packets do
|> Out.encode_int(0)
|> Out.encode_short(reason)
|> Out.encode_buffer(<<1, 1, 1, 1, 0>>)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -134,7 +133,7 @@ defmodule Odinsea.Login.Packets do
|> Out.encode_short(0)
|> Out.encode_byte(reason)
|> Out.encode_long(timestamp_till)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -147,26 +146,23 @@ defmodule Odinsea.Login.Packets do
- `is_gm` - Admin/GM status
- `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
spw_byte = get_second_password_byte(second_password)
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_byte(gender)
|> 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_string(account_name)
|> Out.encode_int(3) # 3 for existing accounts, 0 for new
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>)
|> Out.encode_long(get_time(System.system_time(:millisecond))) # Account creation date
|> Out.encode_int(4) # 4 for existing accounts, 0 for new
|> Out.encode_byte(1) # 1 = PIN disabled, 0 = PIN enabled
|> Out.encode_byte(spw_byte) # Second password status
|> Out.encode_long(:rand.uniform(1_000_000_000_000_000_000)) # Random long for anti-hack
|> Out.to_data()
|> Out.encode_buffer(<<0, 0, 0, 0, 0, 0>>) # 6 bytes padding
# MSEA ending (different from GMS - no time long, PIN/SPW bytes, random long)
|> Out.encode_short(0)
|> Out.encode_int(get_current_date())
|> Out.to_iodata()
end
# ==================================================================================================
@@ -187,14 +183,14 @@ defmodule Odinsea.Login.Packets do
last_channel = get_last_channel(channel_load)
packet =
Out.new(Opcodes.lp_world_information())
Out.new(Opcodes.lp_server_list())
|> Out.encode_byte(server_id)
|> Out.encode_string(world_name)
|> Out.encode_byte(flag)
|> Out.encode_string(event_message)
|> Out.encode_short(100) # EXP 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)
# Encode channel list
@@ -212,16 +208,16 @@ defmodule Odinsea.Login.Packets do
packet
|> Out.encode_short(0) # Balloon message size
|> Out.encode_int(0)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
Sends the end-of-server-list marker.
"""
def get_end_of_server_list do
Out.new(Opcodes.lp_world_information())
Out.new(Opcodes.lp_server_list())
|> Out.encode_byte(0xFF)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -233,9 +229,9 @@ defmodule Odinsea.Login.Packets do
- 2: Full
"""
def get_server_status(status) do
Out.new(Opcodes.lp_select_world_result())
Out.new(Opcodes.lp_server_status())
|> Out.encode_short(status)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -244,7 +240,7 @@ defmodule Odinsea.Login.Packets do
def get_latest_connected_world(world_id) do
Out.new(Opcodes.lp_latest_connected_world())
|> Out.encode_int(world_id)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
@@ -263,7 +259,7 @@ defmodule Odinsea.Login.Packets do
Out.encode_byte(packet, 0)
end
Out.to_data(packet)
Out.to_iodata(packet)
end
# ==================================================================================================
@@ -272,6 +268,7 @@ defmodule Odinsea.Login.Packets do
@doc """
Sends character list for selected world.
MSEA v112.4 specific encoding.
## Parameters
- `characters` - List of character maps
@@ -279,55 +276,70 @@ defmodule Odinsea.Login.Packets do
- `char_slots` - Number of character slots (default 3)
"""
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 =
Out.new(Opcodes.lp_select_character_result())
Out.new(Opcodes.lp_char_list())
|> Out.encode_byte(0)
|> Out.encode_byte(length(characters))
# TODO: Encode each character entry
# For now, just encode empty list structure
# Encode each character entry with MSEA-specific encoding
packet =
Enum.reduce(characters, packet, fn _char, acc ->
# add_char_entry(acc, char)
acc # TODO: Implement character encoding
Enum.reduce(characters, packet, fn char, acc ->
add_char_entry(acc, char)
end)
packet
|> Out.encode_byte(spw_byte)
|> Out.encode_byte(0) # MSEA ONLY: extra byte after SPW
|> 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
@doc """
Character name check response.
"""
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_byte(if name_used, do: 1, else: 0)
|> Out.to_data()
|> Out.to_iodata()
end
@doc """
Character creation response.
"""
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)
# 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
@doc """
Character deletion response.
"""
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_byte(state)
|> Out.to_data()
|> Out.to_iodata()
end
# ==================================================================================================
@@ -344,7 +356,7 @@ defmodule Odinsea.Login.Packets do
def get_second_pw_error(mode) do
Out.new(Opcodes.lp_check_spw_result())
|> Out.encode_byte(mode)
|> Out.to_data()
|> Out.to_iodata()
end
# ==================================================================================================
@@ -361,28 +373,30 @@ defmodule Odinsea.Login.Packets do
- `character_id` - Character ID for migration
"""
def get_server_ip(is_cash_shop, host, port, character_id) do
# Uses LP_ServerIP (0x08) opcode
# Parse IP address
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)
|> encode_ip(ip_parts)
|> Out.encode_short(port)
|> Out.encode_int(character_id)
|> Out.encode_bytes(<<0, 0>>)
|> Out.to_data()
|> Out.encode_buffer(<<0, 0>>)
|> Out.to_iodata()
end
# ==================================================================================================
# Helper Functions
# ==================================================================================================
defp get_second_password_byte(second_password) do
cond do
second_password == nil -> 0
second_password == "" -> 2
true -> 1
end
@doc """
Returns current date in MSEA format: YYYYMMDD as integer.
Used in authentication success packet.
"""
def get_current_date do
{{year, month, day}, _} = :calendar.local_time()
year * 10000 + month * 100 + day
end
defp get_last_channel(channel_load) do
@@ -431,4 +445,228 @@ defmodule Odinsea.Login.Packets do
|> Out.encode_byte(c)
|> Out.encode_byte(d)
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

View File

@@ -6,9 +6,12 @@ defmodule Odinsea.Net.Cipher.AESCipher do
Ported from: src/handling/netty/cipher/AESCipher.java
"""
import Bitwise
@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 <<
0x13, 0x00, 0x00, 0x00,
0x08, 0x00, 0x00, 0x00,
@@ -21,7 +24,14 @@ defmodule Odinsea.Net.Cipher.AESCipher do
>>
@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
- data: Binary data to encrypt/decrypt
@@ -32,74 +42,52 @@ defmodule Odinsea.Net.Cipher.AESCipher do
"""
@spec crypt(binary(), binary()) :: binary()
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
# Recursive encryption/decryption function
@spec crypt_recursive(binary(), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: binary()
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
# Process data in blocks (first block: 1456 bytes, subsequent: 1460 bytes)
defp crypt_blocks([], _start, _length, _iv), do: []
defp crypt_recursive(data, iv, start, remaining, length) do
# Multiply the IV by 4
seq_iv = multiply_bytes(iv, byte_size(iv), 4)
# Adjust length if remaining is smaller
defp crypt_blocks(data, start, length, iv) do
remaining = Kernel.length(data)
actual_length = min(remaining, length)
# Extract the portion of data to process
data_bytes = :binary.bin_to_list(data)
# Expand 4-byte IV to 16 bytes (repeat 4 times)
seq_iv = :binary.copy(iv, 4)
seq_iv_list = :binary.bin_to_list(seq_iv)
# Process the data chunk
{new_data_bytes, _final_seq_iv} =
process_chunk(data_bytes, seq_iv, start, start + actual_length, 0)
# Process this block's bytes, re-encrypting keystream every 16 bytes
{block, rest} = Enum.split(data, actual_length)
{processed, _final_iv} = process_block_bytes(block, seq_iv_list, 0)
# Convert back to binary
new_data = :binary.list_to_bin(new_data_bytes)
# 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)
# Continue with next block using fresh IV expansion
processed ++ crypt_blocks(rest, start + actual_length, @block_size, iv)
end
# Process a single chunk of data
@spec process_chunk(list(byte()), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
{list(byte()), binary()}
defp process_chunk(data_bytes, seq_iv, x, end_x, _offset) when x >= end_x do
{data_bytes, seq_iv}
end
# Process bytes within a single block, re-encrypting keystream every 16 bytes
defp process_block_bytes([], iv_list, _offset), do: {[], iv_list}
defp process_chunk(data_bytes, seq_iv, x, end_x, offset) do
# Check if we need to re-encrypt the IV
{new_seq_iv, new_offset} =
if rem(offset, byte_size(seq_iv)) == 0 do
# Encrypt the IV using AES
encrypted_iv = aes_encrypt_block(seq_iv)
{encrypted_iv, 0}
defp process_block_bytes(data, iv_list, offset) do
# Re-encrypt keystream at every 16-byte boundary
iv_list =
if rem(offset, 16) == 0 do
new_iv = aes_encrypt_block(:binary.list_to_bin(iv_list))
:binary.bin_to_list(new_iv)
else
{seq_iv, offset}
iv_list
end
# XOR the data byte with the IV byte
seq_iv_bytes = :binary.bin_to_list(new_seq_iv)
iv_index = rem(new_offset, length(seq_iv_bytes))
iv_byte = Enum.at(seq_iv_bytes, iv_index)
data_byte = Enum.at(data_bytes, x)
xor_byte = Bitwise.bxor(data_byte, iv_byte)
[byte | rest] = data
iv_byte = Enum.at(iv_list, rem(offset, 16))
xored = bxor(byte, iv_byte)
# Update the data
updated_data = List.replace_at(data_bytes, x, xor_byte)
# Continue processing
process_chunk(updated_data, new_seq_iv, x + 1, end_x, new_offset + 1)
{rest_result, final_iv} = process_block_bytes(rest, iv_list, offset + 1)
{[xored | rest_result], final_iv}
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()
defp aes_encrypt_block(block) do
# 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)
end
# Perform AES encryption in ECB mode
:crypto.crypto_one_time(:aes_128_ecb, @aes_key, padded_block, true)
end
# 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)
# Perform AES encryption in ECB mode (AES-256)
result = :crypto.crypto_one_time(:aes_256_ecb, @aes_key, padded_block, true)
# Take only first 16 bytes (OpenSSL may add PKCS padding)
binary_part(result, 0, 16)
end
end

View File

@@ -6,9 +6,9 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
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 [
:version,
@@ -32,7 +32,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
Creates a new ClientCrypto instance with random IVs.
## 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
## Returns
@@ -50,38 +50,97 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
}
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 """
Encrypts outgoing packet data and updates the send IV.
Applies Shanda encryption first, then AES encryption.
## Parameters
- crypto: ClientCrypto state
- data: Binary packet data to encrypt
## 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
# Backup current 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 =
if crypto.use_custom_crypt do
basic_cipher(data)
basic_cipher(shanda_encrypted)
else
AESCipher.crypt(data, crypto.send_iv)
AESCipher.crypt(shanda_encrypted, crypto.send_iv)
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)
final_crypto = %{updated_crypto | send_iv: new_send_iv}
{final_crypto, encrypted_data}
{final_crypto, encrypted_data, header}
end
@doc """
Decrypts incoming packet data and updates the recv IV.
Applies AES decryption first, then Shanda decryption.
## Parameters
- crypto: ClientCrypto state
@@ -95,15 +154,18 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
# Backup current recv IV
updated_crypto = %{crypto | recv_iv_old: crypto.recv_iv}
# Decrypt the data
decrypted_data =
# Apply AES decryption
aes_decrypted =
if crypto.use_custom_crypt do
basic_cipher(data)
else
AESCipher.crypt(data, crypto.recv_iv)
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)
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()
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
new_version = -(crypto.version + 1) &&& 0xFFFF
enc_version = (((new_version >>> 8) &&& 0xFF) ||| ((new_version <<< 8) &&& 0xFF00)) &&& 0xFFFF
# 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)
# Calculate raw length
@@ -155,8 +218,8 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
## Returns
- Decoded packet length
"""
@spec decode_header_len(integer(), integer()) :: integer()
def decode_header_len(raw_seq, raw_len) do
@spec decode_header_len(t(), integer(), integer()) :: integer()
def decode_header_len(%__MODULE__{}, raw_seq, raw_len) do
bxor(raw_seq, raw_len) &&& 0xFFFF
end
@@ -175,6 +238,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
<<_r0, _r1, r2, r3>> = crypto.recv_iv
enc_version = crypto.version &&& 0xFFFF
# Note: Using r2 and r3 as in Java version
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
bxor((raw_seq &&& 0xFFFF), seq_base) == enc_version
@@ -197,7 +261,7 @@ defmodule Odinsea.Net.Cipher.ClientCrypto do
defp basic_cipher(data) do
data
|> :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()
end
end

View File

@@ -6,7 +6,28 @@ defmodule Odinsea.Net.Cipher.IGCipher do
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 """
Applies the InnoGames hash transformation to a 4-byte IV.
@@ -39,11 +60,12 @@ defmodule Odinsea.Net.Cipher.IGCipher do
input = value &&& 0xFF
table = shuffle_byte(input)
# Apply the transformation operations
new_k0 = k0 + shuffle_byte(k1) - input
new_k1 = k1 - bxor(k2, table)
new_k2 = bxor(k2, shuffle_byte(k3) + input)
new_k3 = k3 - (k0 - table)
# Apply the transformation operations SEQUENTIALLY
# Java modifies key[0] first, then uses modified key[0] for key[3]
new_k0 = (k0 + (shuffle_byte(k1) - input)) &&& 0xFF
new_k1 = (k1 - bxor(k2, table)) &&& 0xFF
new_k2 = bxor(k2, (shuffle_byte(k3) + input) &&& 0xFF)
new_k3 = (k3 - (new_k0 - table)) &&& 0xFF
# Combine into 32-bit value (little-endian)
val =
@@ -69,24 +91,4 @@ defmodule Odinsea.Net.Cipher.IGCipher do
defp shuffle_byte(index) do
elem(@shuffle_table, index &&& 0xFF)
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

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
def cp_client_hello(), do: 0x01
@spec cp_login_password() :: 2
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_charlist_request(), do: 0x05
def cp_serverstatus_request(), do: 0x06
# CP_SelectWorld(5) - Java: ClientPacket.java
@spec cp_select_world() :: 5
def cp_select_world(), do: 0x05
def cp_check_char_name(), do: 0x0E
def cp_create_char(), do: 0x12
def cp_create_ultimate(), do: 0x14
def cp_delete_char(), do: 0x15
def cp_exception_log(), do: 0x17
def cp_security_packet(), do: 0x18
def cp_hardware_info(), do: 0x70
def cp_window_focus(), do: 0x71
def cp_hardware_info(), do: 0x5001 # Java: CP_HardwareInfo(0x5001) - was 0x70 (collided with cp_cancel_buff)
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_auth_second_password(), do: 0x1A
def cp_rsa_key(), do: 0x20
def cp_client_dump_log(), do: 0x1D
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
# Migration/Channel
@@ -75,7 +82,7 @@ defmodule Odinsea.Net.Opcodes do
# NPC Interaction
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_shop(), do: 0x43
def cp_storage(), do: 0x44
@@ -255,6 +262,12 @@ defmodule Odinsea.Net.Opcodes do
# Cash Shop
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_coupon_code(), do: 0x137
@@ -263,6 +276,9 @@ defmodule Odinsea.Net.Opcodes do
def cp_touching_mts(), do: 0x159
def cp_mts_tab(), do: 0x15A
# MTS operation opcode (client -> server)
def cp_mts_operation(), do: 0xB4
# Custom (server-specific)
def cp_inject_packet(), do: 0x5002
def cp_set_code_page(), do: 0x5003
@@ -279,16 +295,16 @@ defmodule Odinsea.Net.Opcodes do
# General
def lp_alive_req(), do: 0x0D
def lp_enable_action(), do: 0x0C
def lp_set_field(), do: 0x14
def lp_set_cash_shop_opened(), do: 0x15
def lp_migrate_command(), do: 0x16
def lp_enable_action(), do: 0x1B # Java: enableActions() uses UPDATE_STATS(27) - was 0x0C (collided with lp_change_channel)
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: 0x92 # Java: LP_SetCashShop(146) - was 0x15 (collided with lp_latest_connected_world)
def lp_migrate_command(), do: 0x0C # Java: CHANGE_CHANNEL(12) - was 0x16 (collided with lp_recommend_world_message)
# Login
def lp_login_status(), do: 0x01
def lp_serverstatus(), do: 0x03
def lp_serverlist(), do: 0x06
def lp_charlist(), do: 0x07
def lp_check_password_result(), do: 0x01
def lp_server_status(), do: 0x03
def lp_server_list(), do: 0x06
def lp_char_list(), do: 0x07
def lp_server_ip(), do: 0x08
def lp_char_name_response(), do: 0x09
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_relog_response(), do: 0x12
def lp_rsa_key(), do: 0x13
def lp_enable_recommended(), do: 0x15
def lp_send_recommended(), do: 0x16
def lp_latest_connected_world(), do: 0x15
def lp_recommend_world_message(), do: 0x16
def lp_login_auth(), do: 0x17
def lp_secondpw_error(), do: 0x18
def lp_check_spw_result(), do: 0x18
# Inventory/Stats
def lp_modify_inventory_item(), do: 0x19
@@ -390,6 +406,13 @@ defmodule Odinsea.Net.Opcodes do
# Warps/Shops
def lp_warp_to_map(), do: 0x90
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_login_welcome(), do: 0x94
def lp_server_blocked(), do: 0x97
@@ -397,6 +420,9 @@ defmodule Odinsea.Net.Opcodes do
# Effects
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_whisper(), do: 0x9B
def lp_boss_env(), do: 0x9D
@@ -614,6 +640,10 @@ defmodule Odinsea.Net.Opcodes do
# Cash Shop
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_xmas_surprise(), do: 0x1BD
@@ -657,16 +687,26 @@ defmodule Odinsea.Net.Opcodes do
# ==================================================================================================
@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.
"""
def name_for(opcode) when is_integer(opcode) do
def name_for_client(opcode) when is_integer(opcode) do
case opcode do
# Client opcodes (common ones for debugging)
0x01 -> "CP_CLIENT_HELLO"
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"
0x0E -> "CP_CHECK_CHAR_NAME"
0x12 -> "CP_CREATE_CHAR"
0x14 -> "CP_CREATE_ULTIMATE"
0x15 -> "CP_DELETE_CHAR"
0x19 -> "CP_CHAR_SELECT"
0x1A -> "CP_AUTH_SECOND_PASSWORD"
0x20 -> "CP_RSA_KEY"
0x23 -> "CP_CHANGE_MAP"
0x24 -> "CP_CHANGE_CHANNEL"
0x2A -> "CP_MOVE_PLAYER"
@@ -675,11 +715,24 @@ defmodule Odinsea.Net.Opcodes do
0xA0 -> "CP_PARTYCHAT"
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)
0x01 -> "LP_LOGIN_STATUS"
0x06 -> "LP_SERVERLIST"
0x07 -> "LP_CHARLIST"
0x08 -> "LP_SERVER_IP"
0x0D -> "LP_ALIVE_REQ"
0x13 -> "LP_RSA_KEY"
0x17 -> "LP_LOGIN_AUTH"
0xB8 -> "LP_SPAWN_PLAYER"
0xB9 -> "LP_REMOVE_PLAYER_FROM_MAP"
0xBA -> "LP_CHATTEXT"
@@ -688,21 +741,30 @@ defmodule Odinsea.Net.Opcodes do
0x9B -> "LP_WHISPER"
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
@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 """
Validates if an opcode is a known client packet.
"""
def valid_client_opcode?(opcode) when is_integer(opcode) do
opcode in [
# Add all valid client opcodes here for validation
0x01,
0x02,
0x04,
0x05,
0x06,
# Login opcodes
0x01, # CP_ClientHello
0x02, # CP_LoginPassword
0x03, # CP_ViewServerList
0x04, # CP_ServerListRequest
0x05, # CP_SelectWorld
0x06, # CP_CheckUserLimit
0x0D,
0x0E,
0x12,
@@ -895,7 +957,11 @@ defmodule Odinsea.Net.Opcodes do
0x143,
0x144,
0x159,
0x15A
0x15A,
0x5001, # CP_HardwareInfo
0x5002, # CP_InjectPacket
0x5003, # CP_SetCodePage
0x5004 # CP_WindowFocus
]
end

View File

@@ -68,6 +68,18 @@ defmodule Odinsea.Net.Packet.Out do
%__MODULE__{data: [data | <<length::signed-integer-little-16, value::binary>>]}
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 """
Encodes a boolean (1 byte, 0 = false, 1 = true).
"""
@@ -88,6 +100,12 @@ defmodule Odinsea.Net.Packet.Out do
%__MODULE__{data: [data | buffer]}
end
@doc """
Alias for encode_buffer/2.
"""
@spec encode_bytes(t(), binary()) :: t()
def encode_bytes(packet, data), do: encode_buffer(packet, data)
@doc """
Encodes a fixed-size buffer, padding with zeros if necessary.
"""
@@ -172,6 +190,12 @@ defmodule Odinsea.Net.Packet.Out do
data
end
@doc """
Alias for to_iodata/1.
"""
@spec to_data(t()) :: iodata()
def to_data(packet), do: to_iodata(packet)
@doc """
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)
@cp_login_password ->
Handler.on_login_password(packet, state)
Handler.on_check_password(packet, state)
# World info request (server list)
@cp_serverlist_request ->
Handler.on_serverlist_request(state)
Handler.on_world_info_request(state)
# Select world
@cp_select_world ->
@@ -179,11 +179,11 @@ defmodule Odinsea.Net.Processor do
# Check duplicated ID (character name availability)
@cp_check_char_name ->
Handler.on_check_char_name(packet, state)
Handler.on_check_duplicated_id(packet, state)
# Create new character
@cp_create_char ->
Handler.on_create_char(packet, state)
Handler.on_create_new_character(packet, state)
# Create ultimate (Cygnus Knights)
@cp_create_ultimate ->
@@ -191,15 +191,15 @@ defmodule Odinsea.Net.Processor do
# Delete character
@cp_delete_char ->
Handler.on_delete_char(packet, state)
Handler.on_delete_character(packet, state)
# Select character (enter game)
@cp_char_select ->
Handler.on_char_select(packet, state)
Handler.on_select_character(packet, state)
# Second password check
@cp_auth_second_password ->
Handler.on_auth_second_password(packet, state)
Handler.on_check_spw_request(packet, state)
# RSA key request
@cp_rsa_key ->

View File

@@ -103,7 +103,7 @@ defmodule Odinsea.Shop.Client do
opcode == Opcodes.cp_player_loggedin() ->
handle_migrate_in(packet, state)
opcode == Opcodes.cp_cash_shop_update() ->
opcode == Opcodes.cp_cs_update() ->
# Cash shop operations
handle_cash_shop_operation(packet, state)

View File

@@ -126,7 +126,7 @@ defmodule Odinsea.Shop.Packets do
"""
def enable_cs_use(socket) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> encode_cs_update()
|> Out.to_data()
@@ -145,7 +145,7 @@ defmodule Odinsea.Shop.Packets do
cash_items = character.cash_inventory || []
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x4A) # Operation code for inventory
|> encode_cash_items(cash_items)
|> Out.to_data()
@@ -178,7 +178,7 @@ defmodule Odinsea.Shop.Packets do
"""
def show_nx_maple_tokens(socket, character) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x3D) # Operation code for balance
|> Out.encode_int(character.nx_cash || 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
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x53) # Bought item operation
|> Out.encode_int(account_id)
|> encode_bought_item(item, sn)
@@ -220,7 +220,7 @@ defmodule Odinsea.Shop.Packets do
"""
def show_bought_cs_package(socket, items, account_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x5D) # Package operation
|> Out.encode_int(account_id)
|> Out.encode_short(length(items))
@@ -239,7 +239,7 @@ defmodule Odinsea.Shop.Packets do
"""
def show_bought_cs_quest_item(socket, item, position) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x73) # Quest item operation
|> Out.encode_int(item.price)
|> Out.encode_short(item.count)
@@ -255,7 +255,7 @@ defmodule Odinsea.Shop.Packets do
"""
def confirm_from_cs_inventory(socket, item, position) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x69) # From CS inventory
|> Out.encode_byte(Odinsea.Game.InventoryType.get_type(
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
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x5F) # To CS inventory
|> Out.encode_int(account_id)
|> encode_cash_item_single(item)
@@ -298,7 +298,7 @@ defmodule Odinsea.Shop.Packets do
"""
def send_gift(socket, price, item_id, count, partner) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x5B) # Gift sent operation
|> Out.encode_int(price)
|> Out.encode_int(item_id)
@@ -314,7 +314,7 @@ defmodule Odinsea.Shop.Packets do
"""
def get_cs_gifts(socket, gifts) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x48) # Gifts operation
|> Out.encode_short(length(gifts))
|> then(fn pkt ->
@@ -340,7 +340,7 @@ defmodule Odinsea.Shop.Packets do
"""
def send_wishlist(socket, _character, wishlist) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x4D) # Wishlist operation
|> then(fn pkt ->
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
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x4B) # Coupon operation
|> Out.encode_byte(if safe_map_size(items) > 0, do: 1, else: 0)
|> Out.encode_int(safe_map_size(items))
@@ -390,7 +390,7 @@ defmodule Odinsea.Shop.Packets do
"""
def redeem_response(socket) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0xA1) # Redeem response
|> Out.encode_int(0)
|> Out.encode_int(0)
@@ -409,7 +409,7 @@ defmodule Odinsea.Shop.Packets do
"""
def send_cs_fail(socket, code) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x49) # Fail operation
|> Out.encode_byte(code)
|> Out.to_data()
@@ -422,7 +422,7 @@ defmodule Odinsea.Shop.Packets do
"""
def cash_item_expired(socket, unique_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
Out.new(Opcodes.lp_cs_update())
|> Out.encode_byte(0x4E) # Expired operation
|> Out.encode_long(unique_id)
|> 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 :password, :string, size: 128, null: false, default: ""
add :salt, :string, size: 32
add :secondpassword, :string, size: 134
add :salt2, :string, size: 32
add :second_password, :string, size: 134
add :second_salt, :string, size: 32
add :loggedin, :boolean, null: false, default: false
add :lastlogin, :naive_datetime
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