435 lines
12 KiB
Elixir
435 lines
12 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_data()
|
|
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_data()
|
|
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
|
|
|> Out.encode_string(background_path)
|
|
|> Out.to_data()
|
|
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.encode_string(public_key)
|
|
|> Out.to_data()
|
|
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_bytes(packet, <<0, 0, 0, 0, 0>>)
|
|
|
|
true ->
|
|
packet
|
|
end
|
|
|
|
Out.to_data(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_data()
|
|
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_data()
|
|
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
|
|
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_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
|
|
|> 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()
|
|
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_world_information())
|
|
|> 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
|
|
|> 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_data()
|
|
end
|
|
|
|
@doc """
|
|
Sends the end-of-server-list marker.
|
|
"""
|
|
def get_end_of_server_list do
|
|
Out.new(Opcodes.lp_world_information())
|
|
|> Out.encode_byte(0xFF)
|
|
|> Out.to_data()
|
|
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_select_world_result())
|
|
|> Out.encode_short(status)
|
|
|> Out.to_data()
|
|
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_data()
|
|
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_data(packet)
|
|
end
|
|
|
|
# ==================================================================================================
|
|
# Character List
|
|
# ==================================================================================================
|
|
|
|
@doc """
|
|
Sends character list for selected world.
|
|
|
|
## 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
|
|
spw_byte = get_second_password_byte(second_password)
|
|
|
|
packet =
|
|
Out.new(Opcodes.lp_select_character_result())
|
|
|> Out.encode_byte(0)
|
|
|> Out.encode_byte(length(characters))
|
|
|
|
# TODO: Encode each character entry
|
|
# For now, just encode empty list structure
|
|
packet =
|
|
Enum.reduce(characters, packet, fn _char, acc ->
|
|
# add_char_entry(acc, char)
|
|
acc # TODO: Implement character encoding
|
|
end)
|
|
|
|
packet
|
|
|> Out.encode_byte(spw_byte)
|
|
|> Out.encode_long(char_slots)
|
|
|> Out.to_data()
|
|
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.encode_string(char_name)
|
|
|> Out.encode_byte(if name_used, do: 1, else: 0)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Character creation response.
|
|
"""
|
|
def get_add_new_char_entry(character, worked) do
|
|
Out.new(Opcodes.lp_create_new_character_result())
|
|
|> Out.encode_byte(if worked, do: 0, else: 1)
|
|
# TODO: Add character entry if worked
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Character deletion response.
|
|
"""
|
|
def get_delete_char_response(character_id, state) do
|
|
Out.new(Opcodes.lp_delete_character_result())
|
|
|> Out.encode_int(character_id)
|
|
|> Out.encode_byte(state)
|
|
|> Out.to_data()
|
|
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_data()
|
|
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
|
|
# Parse IP address
|
|
ip_parts = parse_ip(host)
|
|
|
|
Out.new(Opcodes.lp_migrate_command())
|
|
|> 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()
|
|
end
|
|
|
|
# ==================================================================================================
|
|
# Helper Functions
|
|
# ==================================================================================================
|
|
|
|
defp get_second_password_byte(second_password) do
|
|
cond do
|
|
second_password == nil -> 0
|
|
second_password == "" -> 2
|
|
true -> 1
|
|
end
|
|
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
|
|
end
|