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