Files
odinsea-elixir/lib/odinsea/login/packets.ex
2026-02-25 12:26:26 -07:00

673 lines
20 KiB
Elixir

defmodule Odinsea.Login.Packets do
@moduledoc """
Login server packet builders.
Ported from Java LoginPacket.java
Builds outgoing packets for the login server:
- Authentication responses
- Server/world lists
- Character lists
- Character creation/deletion responses
"""
alias Odinsea.Net.Packet.Out
alias Odinsea.Net.Opcodes
alias Odinsea.Constants.Server
# ==================================================================================================
# Connection & Handshake
# ==================================================================================================
@doc """
Builds the initial hello/handshake packet sent to the client.
Contains maple version, patch version, send/recv IVs, and locale.
## Parameters
- `maple_version` - MapleStory version (342 for GMS v342)
- `send_iv` - 4-byte send IV for encryption
- `recv_iv` - 4-byte recv IV for encryption
## Returns
Raw binary packet (with length header prepended)
"""
def get_hello(maple_version, send_iv, recv_iv) when byte_size(send_iv) == 4 and byte_size(recv_iv) == 4 do
packet_length = 13 + byte_size(Server.maple_patch())
Out.new()
|> Out.encode_short(packet_length)
|> Out.encode_short(maple_version)
|> Out.encode_string(Server.maple_patch())
|> Out.encode_buffer(recv_iv)
|> Out.encode_buffer(send_iv)
|> Out.encode_byte(Server.maple_locale())
|> Out.to_iodata()
end
@doc """
Builds a ping/alive request packet.
Client should respond with CP_AliveAck.
"""
def get_ping do
Out.new(Opcodes.lp_alive_req())
|> Out.to_iodata()
end
@doc """
Sends the login background image path to the client.
"""
def get_login_background(background_path) do
# Uses LOGIN_AUTH (0x17) opcode
Out.new(Opcodes.lp_login_auth())
|> Out.encode_string(background_path)
|> 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_rsa_key())
|> Out.encode_string(public_key)
|> Out.to_iodata()
end
# ==================================================================================================
# Authentication
# ==================================================================================================
@doc """
Login failed response with reason code.
## Reason Codes
- 3: ID deleted or blocked
- 4: Incorrect password
- 5: Not a registered ID
- 6: System error
- 7: Already logged in
- 8: System error
- 10: Cannot process so many connections
- 13: Unable to log on as master at this IP
- 16: Please verify your account through email
- 32: IP blocked
"""
def get_login_failed(reason) do
packet =
Out.new(Opcodes.lp_check_password_result())
|> Out.encode_byte(reason)
packet =
cond do
reason == 84 ->
# Password change required
Out.encode_long(packet, get_time(-2))
reason == 7 ->
# Already logged in
Out.encode_buffer(packet, <<0, 0, 0, 0, 0>>)
true ->
packet
end
Out.to_iodata(packet)
end
@doc """
Permanent ban response.
"""
def get_perm_ban(reason) do
Out.new(Opcodes.lp_check_password_result())
|> Out.encode_short(2) # Account is banned
|> Out.encode_int(0)
|> Out.encode_short(reason)
|> Out.encode_buffer(<<1, 1, 1, 1, 0>>)
|> Out.to_iodata()
end
@doc """
Temporary ban response.
"""
def get_temp_ban(timestamp_till, reason) do
Out.new(Opcodes.lp_check_password_result())
|> Out.encode_int(2)
|> Out.encode_short(0)
|> Out.encode_byte(reason)
|> Out.encode_long(timestamp_till)
|> Out.to_iodata()
end
@doc """
Successful authentication response.
## Parameters
- `account_id` - Account ID
- `account_name` - Account username
- `gender` - Gender (0=male, 1=female, 2=unset)
- `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
admin_byte = if is_gm, do: 1, else: 0
Out.new(Opcodes.lp_check_password_result())
|> 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.
# 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_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
# ==================================================================================================
# World/Server List
# ==================================================================================================
@doc """
Sends a single world/server entry to the client.
## Parameters
- `server_id` - World ID (0-based)
- `world_name` - World name (e.g., "Scania")
- `flag` - World flag/ribbon (0=none, 1=event, 2=new, 3=hot)
- `event_message` - Event message shown on world
- `channel_load` - Map of channel_id => population
"""
def get_server_list(server_id, world_name, flag, event_message, channel_load) do
last_channel = get_last_channel(channel_load)
packet =
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
# NO encode_byte(0) for MSEA - this is GMS only!
|> Out.encode_byte(last_channel)
# Encode channel list
packet =
Enum.reduce(1..last_channel, packet, fn channel_id, acc ->
load = Map.get(channel_load, channel_id, 1200)
acc
|> Out.encode_string("#{world_name}-#{channel_id}")
|> Out.encode_int(load)
|> Out.encode_byte(server_id)
|> Out.encode_short(channel_id - 1)
end)
packet
|> Out.encode_short(0) # Balloon message size
|> Out.encode_int(0)
|> Out.to_iodata()
end
@doc """
Sends the end-of-server-list marker.
"""
def get_end_of_server_list do
Out.new(Opcodes.lp_server_list())
|> Out.encode_byte(0xFF)
|> Out.to_iodata()
end
@doc """
Sends server status (population indicator).
## Status codes
- 0: Normal
- 1: Highly populated
- 2: Full
"""
def get_server_status(status) do
Out.new(Opcodes.lp_server_status())
|> Out.encode_short(status)
|> Out.to_iodata()
end
@doc """
Sends latest connected world ID.
"""
def get_latest_connected_world(world_id) do
Out.new(Opcodes.lp_latest_connected_world())
|> Out.encode_int(world_id)
|> Out.to_iodata()
end
@doc """
Sends recommended world message.
"""
def get_recommend_world_message(world_id, message) do
packet = Out.new(Opcodes.lp_recommend_world_message())
packet =
if message do
packet
|> Out.encode_byte(1)
|> Out.encode_int(world_id)
|> Out.encode_string(message)
else
Out.encode_byte(packet, 0)
end
Out.to_iodata(packet)
end
# ==================================================================================================
# Character List
# ==================================================================================================
@doc """
Sends character list for selected world.
MSEA v112.4 specific encoding.
## Parameters
- `characters` - List of character maps
- `second_password` - Second password (nil if not set)
- `char_slots` - Number of character slots (default 3)
"""
def get_char_list(characters, second_password, char_slots \\ 3) do
# 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_char_list())
|> Out.encode_byte(0)
|> Out.encode_byte(length(characters))
# Encode each character entry with MSEA-specific encoding
packet =
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.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_char_name_response())
|> Out.encode_string(char_name)
|> Out.encode_byte(if name_used, do: 1, else: 0)
|> Out.to_iodata()
end
@doc """
Character creation response.
"""
def get_add_new_char_entry(character, worked) do
# Uses LP_AddNewCharEntry (0x0A) opcode
packet = Out.new(Opcodes.lp_add_new_char_entry())
|> Out.encode_byte(if worked, do: 0, else: 1)
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
# Uses LP_DeleteCharResponse (0x0B) opcode
Out.new(Opcodes.lp_delete_char_response())
|> Out.encode_int(character_id)
|> Out.encode_byte(state)
|> Out.to_iodata()
end
# ==================================================================================================
# Second Password
# ==================================================================================================
@doc """
Second password error response.
## Mode codes
- 14: Invalid password
- 15: Second password is incorrect
"""
def get_second_pw_error(mode) do
Out.new(Opcodes.lp_check_spw_result())
|> Out.encode_byte(mode)
|> Out.to_iodata()
end
# ==================================================================================================
# Migration (Channel Change)
# ==================================================================================================
@doc """
Sends migration command to connect to channel/cash shop.
## Parameters
- `is_cash_shop` - true for cash shop, false for channel
- `host` - IP address to connect to
- `port` - Port to connect to
- `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_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_buffer(<<0, 0>>)
|> Out.to_iodata()
end
# ==================================================================================================
# Helper Functions
# ==================================================================================================
@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
channel_load
|> Map.keys()
|> Enum.max(fn -> 1 end)
end
@doc """
Converts a Unix timestamp (milliseconds) to MapleStory time format.
MapleStory uses Windows FILETIME (100-nanosecond intervals since 1601-01-01).
"""
def get_time(timestamp_ms) when timestamp_ms < 0 do
# Special values
0
end
def get_time(timestamp_ms) do
# Convert Unix epoch (1970-01-01) to Windows epoch (1601-01-01)
# Difference: 11644473600 seconds = 11644473600000 milliseconds
windows_epoch_offset = 11_644_473_600_000
# Convert to 100-nanosecond intervals
(timestamp_ms + windows_epoch_offset) * 10_000
end
defp parse_ip(host) when is_binary(host) do
case String.split(host, ".") do
[a, b, c, d] ->
{
String.to_integer(a),
String.to_integer(b),
String.to_integer(c),
String.to_integer(d)
}
_ ->
{127, 0, 0, 1} # Default to localhost
end
end
defp encode_ip(packet, {a, b, c, d}) do
packet
|> Out.encode_byte(a)
|> Out.encode_byte(b)
|> 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