diff --git a/README.md b/README.md index 01ff3e9f..33aa3ec1 100644 --- a/README.md +++ b/README.md @@ -283,23 +283,31 @@ Some commands only work on the game server and not on the proxy server. The chat * Information commands * `$li`: Shows basic information about the lobby or game you're in. If you're on the proxy server, shows information about your connection instead (remote Guild Card number, client ID, etc.). - * `$si`: Shows basic information about the server. + * `$si` (game server only): Shows basic information about the server. * `$ping`: Shows round-trip ping time from the server to you. On the proxy server, shows the ping time from you to the proxy and from the proxy to the server. * `$matcount` (game server only): Shows how many of each type of material you've used. * `$what` (game server only): Shows the type, name, and stats of the nearest item on the ground. * `$where` (game server only): Shows your current floor number and coordinates. Mainly useful for debugging. * Debugging commands - * `$debug` (game server only): Enable or disable debug. You need the DEBUG permission in your user license to use this command. When debug is enabled, you'll see in-game messages from the server when you take certain actions. You'll also be placed into the highest available slot in lobbies and games instead of the lowest, which is useful for finding commands for which newserv doesn't handle client IDs properly. This setting also disables certain safeguards and allows you to do some things that might crash your client. - * `$quest `: Load a quest by quest number. Can be used to load battle or challenge quests with only one player present. + * `$debug` (game server only): Enable or disable debug. You need the DEBUG permission in your user license to use this command. Enabling debug does a few things: + * You'll see in-game messages from the server when you take certain actions, like killing an enemy in BB. + * You'll see the rare seed value and floor variations when you join a game. + * You'll be placed into the highest available slot in lobbies and games instead of the lowest, unless you're joining a BB solo-mode game. + * The rest of the commands in this section are enabled on the game server. (They are always enabled on the proxy server.) + * `$quest ` (game server only): Load a quest by quest number. Can be used to load battle or challenge quests with only one player present. * `$qcall `: Call a quest function on your client. - * `$qcheck `: Show the value of a quest flag. + * `$qcheck ` (game server only): Show the value of a quest flag. * `$qset ` or `$qclear `: Set or clear a global quest flag for everyone in the game. - * `$qsync `: Set a quest register's value on your client. `` should be either rXX (e.g. r60) or fXX (e.g. f60); if the latter, `` is parsed as a floating-point value instead of as an integer. + * `$qsync `: Set a quest register's value for yourself only. `` should be either rXX (e.g. r60) or fXX (e.g. f60); if the latter, `` is parsed as a floating-point value instead of as an integer. + * `$qsyncall `: Set a quest register's value for everyone in the game. `` should be either rXX (e.g. r60) or fXX (e.g. f60); if the latter, `` is parsed as a floating-point value instead of as an integer. * `$gc` (game server only): Send your own Guild Card to yourself. * `$persist` (game server only): Enable or disable persistence for the current game. When persistence is on, the game will not be deleted when the last player leaves. The state of enemies and objects on the map will be reset when the last player leaves. * `$sc `: Send a command to yourself. * `$ss ` (proxy server only): Send a command to the remote server. + * `$meseta ` (game server only; Episode 3 only): Add the given amount to your Meseta total. + * `$auction` (Episode 3 only): Bring up the CARD Auction menu, regardless of how many players are in the game or if you have a VIP card. + * `$ep3battledebug` (game server only; Episode 3 only): Enable or disable TCard00_Select. If enabled, the game will enter the debug menu when you start a battle. * Personal state commands * `$arrow `: Changes your lobby arrow color. @@ -310,7 +318,7 @@ Some commands only work on the game server and not on the proxy server. The chat * `$exit`: If you're in a lobby, sends you to the main menu (which ends your proxy session, if you're in one). If you're in a game or spectator team, sends you to the lobby (but does not end your proxy session if you're in one). Does nothing if you're in a non-Episode 3 game and no quest is in progress. * `$patch `: Run a patch on your client. `` must exactly match the name of a patch on the server. -* Character data commands +* Character data commands (game server only) * `$savechar `: Saves your current character data on the server in the specified slot (each serial number has 4 slots, numbered 1-4). These slots are separate from BB character slots; using this command does not affect BB characters. * `$loadchar ` (v1 and v2 only): Loads your character data from the specified slot. The changes will be undone if you join a game - to save your changes, disconnect from the lobby. * `$bbchar `: Use this command when playing on a non-BB version of PSO. If the username and password are correct, this command converts your current character to BB format and saves it on the server in the given slot (1-4). Any character already in that slot is overwritten. (This command is similar to `$savechar`, except it overwrites a BB character slot, and can transfer characters across accounts.) Note that the character's chat data, quick menu config, and bank contents are not copied, since there is no way for the server to request those types of data. @@ -336,13 +344,13 @@ Some commands only work on the game server and not on the proxy server. The chat * `$playrec `: Plays a battle recording. This command creates a spectator team and replays the specified battle log within it. There is a bug in Dolphin that makes use of this command unstable in emulation (see the "Battle records" section above). * Cheat mode commands - * `$cheat`: Enables or disables cheat mode for the current game. All other cheat mode commands do nothing if cheat mode is disabled. By default, cheat mode is off in new games but can be enabled; there is an option in config.json that allows you to disable cheat mode entirely, or set it to on by default in new games. - * `$infhp` / `$inftp`: Enables or disables infinite HP or TP mode. Applies to only you. In infinite HP mode, one-hit KO attacks will still kill you. - * `$warpme `: Warps yourself to the given floor. + * `$cheat` (game server only): Enables or disables cheat mode for the current game. All other cheat mode commands do nothing if cheat mode is disabled. By default, cheat mode is off in new games but can be enabled; there is an option in config.json that allows you to disable cheat mode entirely, or set it to on by default in new games. Cheat mode is always enabled on the proxy server, unless cheat mode is disabled on the entire server. + * `$infhp` / `$inftp`: Enables or disables infinite HP or TP mode. Applies to only you. In infinite HP mode, one-hit KO attacks will still kill you. On V1 and V2, infinite HP also automatically cures status ailments. + * `$warpme ` (or `$warp `): Warps yourself to the given floor. * `$warpall `: Warps everyone in the game to the given floor. You must be the leader to use this command, unless you're on the proxy server. * `$next`: Warps yourself to the next floor. * `$item ` (or `$i `): Create an item. `desc` may be a description of the item (e.g. "Hell Saber +5 0/10/25/0/10") or a string of hex data specifying the item code. Item codes are 16 hex bytes; at least 2 bytes must be specified, and all unspecified bytes are zeroes. If you are on the proxy server, you must not be using Blue Burst for this command to work. On the game server, this command works for all versions. - * `$unset `: In an Episode 3 battle, removes one of your set cards from the field. `` is the index of the set card as it appears on your screen - 1 is the card next to your SC's icon, 2 is the card to the right of 1, etc. This does not cause a Hunters-side SC to lose HP, as they normally do when their items are destroyed. + * `$unset ` (game server only): In an Episode 3 battle, removes one of your set cards from the field. `` is the index of the set card as it appears on your screen - 1 is the card next to your SC's icon, 2 is the card to the right of 1, etc. This does not cause a Hunters-side SC to lose HP, as they normally do when their items are destroyed. * Configuration commands * `$event `: Sets the current holiday event in the current lobby. Holiday events are documented in the "Using $event" item in the information menu. If you're on the proxy server, this applies to all lobbies and games you join, but only you will see the new event - other players will not. diff --git a/src/ChatCommands.cc b/src/ChatCommands.cc index b4e19729..cd53199f 100644 --- a/src/ChatCommands.cc +++ b/src/ChatCommands.cc @@ -375,7 +375,7 @@ static void proxy_command_qclear(shared_ptr ses, con return proxy_command_qset_qclear(ses, args, false); } -static void server_command_qsync(shared_ptr c, const std::string& args) { +static void server_command_qsync_qsyncall(shared_ptr c, const std::string& args, bool send_to_lobby) { if (!c->config.check_flag(Client::Flag::DEBUG_ENABLED)) { send_text_message(c, "$C6This command can only\nbe run in debug mode\n(run %sdebug first)"); return; @@ -404,10 +404,22 @@ static void server_command_qsync(shared_ptr c, const std::string& args) send_text_message(c, "$C6First argument must\nbe a register"); return; } - send_command_t(c, 0x60, 0x00, cmd); + if (send_to_lobby) { + send_command_t(l, 0x60, 0x00, cmd); + } else { + send_command_t(c, 0x60, 0x00, cmd); + } } -static void proxy_command_qsync(shared_ptr ses, const std::string& args) { +static void server_command_qsync(shared_ptr c, const std::string& args) { + server_command_qsync_qsyncall(c, args, false); +} + +static void server_command_qsyncall(shared_ptr c, const std::string& args) { + server_command_qsync_qsyncall(c, args, true); +} + +static void proxy_command_qsync_qsyncall(shared_ptr ses, const std::string& args, bool send_to_lobby) { if (!ses->is_in_game) { send_text_message(ses->client_channel, "$C6This command cannot\nbe used in the lobby"); return; @@ -432,6 +444,17 @@ static void proxy_command_qsync(shared_ptr ses, cons return; } ses->client_channel.send(0x60, 0x00, cmd); + if (send_to_lobby) { + ses->server_channel.send(0x60, 0x00, cmd); + } +} + +static void proxy_command_qsync(shared_ptr ses, const std::string& args) { + proxy_command_qsync_qsyncall(ses, args, false); +} + +static void proxy_command_qsyncall(shared_ptr ses, const std::string& args) { + proxy_command_qsync_qsyncall(ses, args, true); } static void server_command_qcall(shared_ptr c, const std::string& args) { @@ -1922,6 +1945,7 @@ static const unordered_map chat_commands({ {"$qclear", {server_command_qclear, proxy_command_qclear}}, {"$qset", {server_command_qset, proxy_command_qset}}, {"$qsync", {server_command_qsync, proxy_command_qsync}}, + {"$qsyncall", {server_command_qsyncall, proxy_command_qsyncall}}, {"$quest", {server_command_quest, nullptr}}, {"$rand", {server_command_rand, proxy_command_rand}}, {"$save", {server_command_save, nullptr}}, diff --git a/src/Client.cc b/src/Client.cc index 5619ecfe..27600c71 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -24,6 +24,15 @@ static atomic next_id(1); void Client::Config::set_flags_for_version(Version version, int64_t sub_version) { this->set_flag(Flag::PROXY_CHAT_COMMANDS_ENABLED); + // BB shares some sub_version values with GC Episode 3, so we handle it + // separately first. + if (version == Version::BB_V4) { + this->set_flag(Flag::NO_D6); + this->set_flag(Flag::SAVE_ENABLED); + this->set_flag(Flag::SEND_FUNCTION_CALL_NO_CACHE_PATCH); + return; + } + switch (sub_version) { case -1: // Initial check (before sub_version recognition) switch (version) { @@ -55,11 +64,6 @@ void Client::Config::set_flags_for_version(Version version, int64_t sub_version) this->set_flag(Flag::NO_D6_AFTER_LOBBY); this->set_flag(Flag::SEND_FUNCTION_CALL_NO_CACHE_PATCH); break; - case Version::BB_V4: - this->set_flag(Flag::NO_D6); - this->set_flag(Flag::SAVE_ENABLED); - this->set_flag(Flag::SEND_FUNCTION_CALL_NO_CACHE_PATCH); - break; default: throw logic_error("invalid game version"); } @@ -108,7 +112,7 @@ void Client::Config::set_flags_for_version(Version version, int64_t sub_version) this->set_flag(Flag::NO_D6_AFTER_LOBBY); this->set_flag(Flag::NO_SEND_FUNCTION_CALL); break; - case 0x40: // GC Ep3 JP and Trial Edition + case 0x40: // GC Ep3 JP and Trial Edition (and BB) this->set_flag(Flag::NO_D6_AFTER_LOBBY); this->set_flag(Flag::ENCRYPTED_SEND_FUNCTION_CALL); this->set_flag(Flag::SEND_FUNCTION_CALL_NO_CACHE_PATCH); @@ -116,7 +120,7 @@ void Client::Config::set_flags_for_version(Version version, int64_t sub_version) // instead look at header.flag in the 61 command and set the // IS_EP3_TRIAL_EDITION flag there. break; - case 0x41: // GC Ep3 US + case 0x41: // GC Ep3 US (and BB) this->set_flag(Flag::NO_D6_AFTER_LOBBY); this->set_flag(Flag::USE_OVERFLOW_FOR_SEND_FUNCTION_CALL); this->set_flag(Flag::SEND_FUNCTION_CALL_NO_CACHE_PATCH); diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index f68dd6ab..f6ebe213 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -1684,12 +1684,12 @@ struct C_LoginExtendedV1_DC_93 : C_LoginV1_DC_93 { // 93 (C->S): Log in (BB) -struct C_Login_BB_93 { +struct C_LoginBase_BB_93 { le_uint32_t player_tag = 0x00010000; le_uint32_t guild_card_number = 0; le_uint32_t sub_version = 0; - uint8_t language; - int8_t character_slot; + uint8_t language = 0; + int8_t character_slot = 0; // Values for connection_phase: // 00 - initial connection (client will request system file, characters, etc.) // 01 - choose character @@ -1697,9 +1697,9 @@ struct C_Login_BB_93 { // 03 - apply updates from dressing room // 04 - login server // 05 - lobby server (and beyond) - uint8_t connection_phase; - uint8_t client_code; - le_uint32_t team_id = 0; + uint8_t connection_phase = 0; + uint8_t client_code = 0; + le_uint32_t security_token = 0; pstring username; pstring password; @@ -1708,20 +1708,21 @@ struct C_Login_BB_93 { // doesn't use it anyway). le_uint32_t menu_id = 0; le_uint32_t preferred_lobby_id = 0; +} __packed__; +struct C_LoginWithoutHardwareInfo_BB_93 : C_LoginBase_BB_93 { // Note: Unlike other versions, BB puts the version string in the client // config at connect time. So the first time the server gets this command, it - // will be something like "Ver. 1.24.3". Note also that some old versions - // (before 1.23.8?) omit the hardware_info field before the client config, so - // the client config starts 8 bytes earlier on those versions and the entire - // command is 8 bytes shorter, hence this odd-looking union. - union VariableLengthSection { - parray old_client_config; - struct NewFormat { - parray hardware_info; - parray client_config; - } __packed__ new_clients; - } __packed__ var; + // will be something like "Ver. 1.24.3". This format is used on older client + // versions (before 1.23.8?) + parray client_config; +} __packed__; + +struct C_LoginWithHardwareInfo_BB_93 : C_LoginBase_BB_93 { + // See the comment in the above structure. This format is used on newer client + // versions. + parray hardware_info; + parray client_config; } __packed__; // 94: Invalid command @@ -3080,7 +3081,7 @@ struct S_ClientInit_BB_00E6 { le_uint32_t error_code = 0; le_uint32_t player_tag = 0x00010000; le_uint32_t guild_card_number = 0; - le_uint32_t team_id = 0; + le_uint32_t security_token = 0; parray client_config; uint8_t can_create_team = 1; uint8_t episode_4_unlocked = 1; @@ -4122,6 +4123,10 @@ struct G_HitByEnemy_6x2F { // 6x30: Level up +struct G_LevelUp_DCNTE_6x30 { + G_ClientIDHeader header; +} __packed__; + struct G_LevelUp_6x30 { G_ClientIDHeader header; le_uint16_t atp = 0; @@ -4569,13 +4574,14 @@ struct G_CreateTelepipe_6x68 { } __packed__; // 6x69: NPC control +// Note: NPCs cannot be destroyed with 6x69; 6x1C is used instead for that. struct G_NPCControl_6x69 { G_UnusedHeader header; - le_uint16_t state = 0; - le_uint16_t npc_entity_id = 0; + le_uint16_t param1; // Commands 0/3: state; commands 1/2: npc_entity_id + le_uint16_t param2; // Commands 0/3: npc_entity_id; commands 1/2: unused le_uint16_t command = 0; // 0 = create follower NPC, 1 = stop acting, 2 = start acting, 3 = create attacker NPC - le_uint16_t npc_template_index = 0; // Specifies which NPC to create if command == 0 or 3; unused otherwise + le_uint16_t param3; // Commands 0/3: npc_template_index; commands 1/2: unused } __packed__; // 6x6A: Use boss warp (not valid on Episode 3) diff --git a/src/LevelTable.cc b/src/LevelTable.cc index 80e8fdd0..82e73bbc 100644 --- a/src/LevelTable.cc +++ b/src/LevelTable.cc @@ -5,6 +5,7 @@ #include #include "Compression.hh" +#include "PSOEncryption.hh" using namespace std; @@ -31,43 +32,159 @@ void PlayerStats::advance_to_level(uint8_t char_class, uint32_t level, shared_pt } } -LevelTable::LevelTable(shared_ptr data, bool compressed) { +LevelTableV2::LevelTableV2(const string& data, bool compressed) { + struct Offsets { + // TODO: The overall format of this file on V2 has much more data than we + // actually use. What's known of the structure so far: + le_uint32_t level_deltas; // -> u32[9] -> LevelStatsDelta[200] + le_uint32_t unknown_a1; // -> float[6] + le_uint32_t max_stats; // -> PlayerStats[9] + le_uint32_t level_100_stats; // -> Level100Entry[9] + le_uint32_t base_stats; // -> u32[9] -> CharacterStats + le_uint32_t unknown_a2; // -> (0x120 zero bytes) + le_uint32_t attack_data; // -> AttackData[9] + le_uint32_t unknown_a4; // -> (0x14-byte struct)[9] + le_uint32_t unknown_a5; // -> float[9] + le_uint32_t unknown_a6; // -> (0x30 bytes) + le_uint32_t unknown_a7; // -> (0x2D bytes) + le_uint32_t unknown_a8; // -> u32[3] -> float[0x2D] + le_uint32_t unknown_a9; // -> (0x90 bytes) + le_uint32_t unknown_a10; // -> u32[3] -> (0x10-byte struct)[0x0C] + le_uint32_t unknown_a11; // -> u32[3] -> (0x30-bytes) + le_uint32_t unknown_a12; // -> u32[3] -> (0x14-byte struct)[0x0F] + } __attribute__((packed)); + + StringReader r; + string decompressed_data; if (compressed) { - this->data = make_shared(prs_decompress(*data)); + decompressed_data = prs_decompress(data); + r = StringReader(decompressed_data); } else { - this->data = data; + r = StringReader(data); } - if (this->data->size() < sizeof(Table)) { - throw invalid_argument("level table size is incorrect"); + const auto& offsets = r.pget(r.pget_u32l(r.size() - 0x10)); + const auto& level_deltas_offsets = r.pget>(offsets.level_deltas); + const auto& base_stats_offsets = r.pget>(offsets.base_stats); + for (size_t char_class = 0; char_class < 9; char_class++) { + const auto& src_level_deltas = r.pget>(level_deltas_offsets[char_class]); + for (size_t level = 0; level < 200; level++) { + this->level_deltas[char_class][level] = src_level_deltas[level]; + } + this->max_stats[char_class] = r.pget(offsets.max_stats + char_class * sizeof(PlayerStats)); + this->level_100_stats[char_class] = r.pget(offsets.level_100_stats + char_class * sizeof(Level100Entry)); + this->base_stats[char_class] = r.pget(base_stats_offsets[char_class]); } - this->table = reinterpret_cast(this->data->data()); } -const CharacterStats& LevelTable::base_stats_for_class(uint8_t char_class) const { - if (char_class >= 12) { - throw out_of_range("invalid character class"); - } - return this->table->base_stats[char_class]; +const CharacterStats& LevelTableV2::base_stats_for_class(uint8_t char_class) const { + return this->base_stats.at(char_class); } -const LevelTable::LevelStats& LevelTable::stats_delta_for_level( - uint8_t char_class, uint8_t level) const { - if (char_class >= 12) { - throw invalid_argument("invalid character class"); - } - if (level >= 200) { - throw invalid_argument("invalid character level"); - } - return this->table->levels[char_class][level]; +const LevelTableV2::Level100Entry& LevelTableV2::level_100_stats_for_class(uint8_t char_class) const { + return this->level_100_stats.at(char_class); } -void LevelTable::LevelStats::apply(CharacterStats& ps) const { - ps.ata += this->ata; - ps.atp += this->atp; - ps.dfp += this->dfp; - ps.evp += this->evp; - ps.hp += this->hp; - ps.mst += this->mst; - ps.lck += this->lck; +const PlayerStats& LevelTableV2::max_stats_for_class(uint8_t char_class) const { + return this->max_stats.at(char_class); +} + +const LevelStatsDelta& LevelTableV2::stats_delta_for_level(uint8_t char_class, uint8_t level) const { + return this->level_deltas.at(char_class).at(level); +} + +LevelTableV3BE::LevelTableV3BE(const string& data, bool encrypted) { + StringReader r; + string decompressed_data; + if (encrypted) { + auto decrypted = decrypt_pr2_data(data); + decompressed_data = prs_decompress(decrypted.compressed_data); + if (decompressed_data.size() != decrypted.decompressed_size) { + throw runtime_error("decompressed data size does not match expected size"); + } + r = StringReader(decompressed_data); + } else { + r = StringReader(data); + } + + // The GC format is very simple (but everything is big-endian): + // root: + // u32 offset: + // u32[12] offsets: + // LevelStatsDeltaBE[200] level_deltas + const auto& offsets = r.pget>(r.pget_u32b(r.pget_u32b(r.size() - 0x10))); + for (size_t char_class = 0; char_class < 12; char_class++) { + const auto& src_deltas = r.pget>(offsets[char_class]); + for (size_t level = 0; level < 200; level++) { + const auto& src_delta = src_deltas[level]; + auto& dest_delta = this->level_deltas[char_class][level]; + dest_delta.atp = src_delta.atp; + dest_delta.mst = src_delta.mst; + dest_delta.evp = src_delta.evp; + dest_delta.hp = src_delta.hp; + dest_delta.dfp = src_delta.dfp; + dest_delta.ata = src_delta.ata; + dest_delta.lck = src_delta.lck; + dest_delta.tp = src_delta.tp; + dest_delta.experience = src_delta.experience.load(); + } + } +} + +const CharacterStats& LevelTableV3BE::base_stats_for_class(uint8_t char_class) const { + static const array data = { + // ATP MST EVP HP DFP ATA LCK + CharacterStats{0x0023, 0x001D, 0x002D, 0x0014, 0x0011, 0x001E, 0x000A}, + CharacterStats{0x001E, 0x0028, 0x003C, 0x0013, 0x0016, 0x0019, 0x000A}, + CharacterStats{0x0023, 0x0000, 0x0023, 0x0016, 0x0012, 0x0023, 0x000A}, + CharacterStats{0x0012, 0x0014, 0x0024, 0x0010, 0x000D, 0x0028, 0x000A}, + CharacterStats{0x0019, 0x0000, 0x001F, 0x0012, 0x0012, 0x002D, 0x000A}, + CharacterStats{0x0014, 0x0000, 0x001F, 0x0011, 0x0017, 0x002D, 0x000A}, + CharacterStats{0x000D, 0x0035, 0x0023, 0x0014, 0x000A, 0x000F, 0x000A}, + CharacterStats{0x000D, 0x003C, 0x0032, 0x0013, 0x0007, 0x000C, 0x000A}, + CharacterStats{0x000A, 0x003A, 0x0035, 0x0013, 0x000D, 0x000A, 0x000A}, + CharacterStats{0x0023, 0x0000, 0x0023, 0x0016, 0x0012, 0x0023, 0x000A}, + CharacterStats{0x000D, 0x0035, 0x0023, 0x0014, 0x000A, 0x000F, 0x000A}, + CharacterStats{0x0012, 0x0014, 0x0024, 0x0010, 0x000D, 0x0028, 0x000A}, + }; + return data.at(char_class); +} + +const LevelStatsDelta& LevelTableV3BE::stats_delta_for_level(uint8_t char_class, uint8_t level) const { + return this->level_deltas.at(char_class).at(level); +} + +LevelTableV4::LevelTableV4(const string& data, bool compressed) { + struct Offsets { + le_uint32_t base_stats; // -> u32[12] -> CharacterStats + le_uint32_t level_deltas; // -> u32[12] -> LevelStatsDelta[200] + } __attribute__((packed)); + + StringReader r; + string decompressed_data; + if (compressed) { + decompressed_data = prs_decompress(data); + r = StringReader(decompressed_data); + } else { + r = StringReader(data); + } + + const auto& offsets = r.pget(r.pget_u32l(r.size() - 0x10)); + const auto& level_deltas_offsets = r.pget>(offsets.level_deltas); + const auto& base_stats_offsets = r.pget>(offsets.base_stats); + for (size_t char_class = 0; char_class < 12; char_class++) { + const auto& src_level_deltas = r.pget>(level_deltas_offsets[char_class]); + for (size_t level = 0; level < 200; level++) { + this->level_deltas[char_class][level] = src_level_deltas[level]; + } + this->base_stats[char_class] = r.pget(base_stats_offsets[char_class]); + } +} + +const CharacterStats& LevelTableV4::base_stats_for_class(uint8_t char_class) const { + return this->base_stats.at(char_class); +} + +const LevelStatsDelta& LevelTableV4::stats_delta_for_level(uint8_t char_class, uint8_t level) const { + return this->level_deltas.at(char_class).at(level); } diff --git a/src/LevelTable.hh b/src/LevelTable.hh index 8b1bfd5a..ba39cf75 100644 --- a/src/LevelTable.hh +++ b/src/LevelTable.hh @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -9,13 +10,14 @@ class LevelTable; struct CharacterStats { - le_uint16_t atp = 0; - le_uint16_t mst = 0; - le_uint16_t evp = 0; - le_uint16_t hp = 0; - le_uint16_t dfp = 0; - le_uint16_t ata = 0; - le_uint16_t lck = 0; + /* 00 */ le_uint16_t atp = 0; + /* 02 */ le_uint16_t mst = 0; + /* 04 */ le_uint16_t evp = 0; + /* 06 */ le_uint16_t hp = 0; + /* 08 */ le_uint16_t dfp = 0; + /* 0A */ le_uint16_t ata = 0; + /* 0C */ le_uint16_t lck = 0; + /* 0E */ } __attribute__((packed)); struct PlayerStats { @@ -32,66 +34,99 @@ struct PlayerStats { void advance_to_level(uint8_t char_class, uint32_t level, std::shared_ptr level_table); } __attribute__((packed)); -class LevelTable { // from PlyLevelTbl.prs +template +struct LevelStatsDeltaBase { + using U32T = typename std::conditional::type; + + /* 00 */ uint8_t atp; + /* 01 */ uint8_t mst; + /* 02 */ uint8_t evp; + /* 03 */ uint8_t hp; + /* 04 */ uint8_t dfp; + /* 05 */ uint8_t ata; + /* 06 */ uint8_t lck; + /* 07 */ uint8_t tp; + /* 08 */ U32T experience; + /* 0C */ + + void apply(CharacterStats& ps) const { + ps.ata += this->ata; + ps.atp += this->atp; + ps.dfp += this->dfp; + ps.evp += this->evp; + ps.hp += this->hp; + ps.mst += this->mst; + ps.lck += this->lck; + } +} __attribute__((packed)); + +struct LevelStatsDelta : LevelStatsDeltaBase { +} __attribute__((packed)); +struct LevelStatsDeltaBE : LevelStatsDeltaBase { +} __attribute__((packed)); + +class LevelTable { + // This is the base class for all the LevelTable implementations. The public + // interface here only defines functions that the server needs to handle + // requests, but some subclasses implement more functionality. See the + // comments and Offsets structures inside the subclasses' constructor + // implementations for more details on the file formats. public: - struct LevelStats { - uint8_t atp; - uint8_t mst; - uint8_t evp; - uint8_t hp; - uint8_t dfp; - uint8_t ata; - uint8_t lck; - uint8_t tp; - le_uint32_t experience; + virtual ~LevelTable() = default; + virtual const CharacterStats& base_stats_for_class(uint8_t char_class) const = 0; + virtual const LevelStatsDelta& stats_delta_for_level(uint8_t char_class, uint8_t level) const = 0; - void apply(CharacterStats& ps) const; +protected: + LevelTable() = default; +}; + +class LevelTableV2 : public LevelTable { // from PlayerTable.prs (PC) +public: + struct Level100Entry { + /* 00 */ CharacterStats char_stats; + /* 0E */ le_uint16_t unknown_a1 = 0; + /* 10 */ le_float height = 0.0; + /* 14 */ le_float unknown_a3 = 0.0; + /* 18 */ le_uint32_t level = 0; + /* 1C */ } __attribute__((packed)); - struct Table { - CharacterStats base_stats[12]; - le_uint32_t unknown[12]; - LevelStats levels[12][200]; - } __attribute__((packed)); + LevelTableV2(const std::string& data, bool compressed); + virtual ~LevelTableV2() = default; - LevelTable(std::shared_ptr data, bool compressed); - - const CharacterStats& base_stats_for_class(uint8_t char_class) const; - const LevelStats& stats_delta_for_level(uint8_t char_class, uint8_t level) const; + virtual const CharacterStats& base_stats_for_class(uint8_t char_class) const; + const Level100Entry& level_100_stats_for_class(uint8_t char_class) const; + const PlayerStats& max_stats_for_class(uint8_t char_class) const; + virtual const LevelStatsDelta& stats_delta_for_level(uint8_t char_class, uint8_t level) const; private: - // TODO: Currently we only support the BB version of this file. It'd be nice - // to support non-BB versions, but their formats are very different: - // - // BB: - // root: - // u32 offset: - // u32[12] unknown - // u32 offset: - // u32[12] offsets: - // LevelStats[200] level_stats - // u32 offset: - // CharacterStats[12] base_stats - // GC: - // root: - // u32 offset: - // u32[12] offsets: - // LevelStats[200] level_stats - // PC: - // root: - // u32 offset: - // u32 offset[9]: - // LevelStats[200] level_stats - // u32 offset: - // (0x18 bytes) - // u32 offset: - // PlayerStats[9] max_stats - // u32 offset: - // PlayerStats[9] level100_stats - // u32 offset: - // u32 offset[9]: - // CharacterStats level1_stats - // (11 more pointers) - std::shared_ptr data; - const Table* table; + std::array base_stats; + std::array level_100_stats; + std::array max_stats; + std::array, 9> level_deltas; +}; + +class LevelTableV3BE : public LevelTable { // from PlyLevelTbl.cpt (GC) +public: + LevelTableV3BE(const std::string& data, bool encrypted); + virtual ~LevelTableV3BE() = default; + + virtual const CharacterStats& base_stats_for_class(uint8_t char_class) const; + virtual const LevelStatsDelta& stats_delta_for_level(uint8_t char_class, uint8_t level) const; + +private: + std::array, 12> level_deltas; +}; + +class LevelTableV4 : public LevelTable { // from PlyLevelTbl.prs (BB) +public: + LevelTableV4(const std::string& data, bool compressed); + virtual ~LevelTableV4() = default; + + virtual const CharacterStats& base_stats_for_class(uint8_t char_class) const; + virtual const LevelStatsDelta& stats_delta_for_level(uint8_t char_class, uint8_t level) const; + +private: + std::array base_stats; + std::array, 12> level_deltas; }; diff --git a/src/Lobby.cc b/src/Lobby.cc index 23b37a54..4c272cb2 100644 --- a/src/Lobby.cc +++ b/src/Lobby.cc @@ -123,7 +123,7 @@ void Lobby::FloorItemManager::clear() { } uint32_t Lobby::FloorItemManager::reassign_all_item_ids(uint32_t next_item_id) { - unordered_map> old_items; + ::map> old_items; old_items.swap(this->items); for (auto& queue : this->queue_for_client) { queue.clear(); diff --git a/src/Lobby.hh b/src/Lobby.hh index 3efacc4b..e68f7d10 100644 --- a/src/Lobby.hh +++ b/src/Lobby.hh @@ -36,7 +36,9 @@ struct Lobby : public std::enable_shared_from_this { struct FloorItemManager { PrefixedLogger log; uint64_t next_drop_number; - std::unordered_map> items; // Keyed on item_id + // It's important that this is a map and not an unordered_map. See the + // comment in send_game_item_state for more details. + std::map> items; // Keyed on item_id std::array>, 12> queue_for_client; FloorItemManager(uint32_t lobby_id, uint8_t floor); diff --git a/src/PlayerSubordinates.cc b/src/PlayerSubordinates.cc index fca44136..e78ceb02 100644 --- a/src/PlayerSubordinates.cc +++ b/src/PlayerSubordinates.cc @@ -33,8 +33,8 @@ PlayerInventoryItem::PlayerInventoryItem(const ItemData& item, bool equipped) uint32_t PlayerVisualConfig::compute_name_color_checksum(uint32_t name_color) { uint8_t x = (random_object() % 0xFF) + 1; uint8_t y = (random_object() % 0xFF) + 1; - // name_color = ABCDEFGHabcdefghIJKLMNOPijklmnop - // name_color_checksum = ---------ijklmabcdeIJKLM-------- ^ (xxxxxxxxyyyyyyyyxxxxxxxxyyyyyyyy) + // name_color (ARGB) = ABCDEFGHabcdefghIJKLMNOPijklmnop + // name_color_checksum = 000000000ijklmabcdeIJKLM00000000 ^ xxxxxxxxyyyyyyyyxxxxxxxxyyyyyyyy uint32_t xbrgx95558 = ((name_color << 15) & 0x007C0000) | ((name_color >> 6) & 0x0003E000) | ((name_color >> 3) & 0x00001F00); uint32_t mask = (x << 24) | (y << 16) | (x << 8) | y; return xbrgx95558 ^ mask; diff --git a/src/PlayerSubordinates.hh b/src/PlayerSubordinates.hh index 28066e5c..2f3dd54d 100644 --- a/src/PlayerSubordinates.hh +++ b/src/PlayerSubordinates.hh @@ -116,8 +116,11 @@ struct PlayerVisualConfig { /* 18 */ le_uint32_t name_color = 0xFFFFFFFF; // ARGB /* 1C */ uint8_t extra_model = 0; /* 1D */ parray unused; - // See compute_name_color_checksum for details on how this is computed. This - // field is ignored on V3. + // See compute_name_color_checksum for details on how this is computed. If the + // value is incorrect, V1 and V2 will ignore the name_color field and use the + // default color instead. This field is ignored on GC; on BB (and presumably + // Xbox), if this has a nonzero value, the "Change Name" option appears in the + // character selection menu. /* 2C */ le_uint32_t name_color_checksum = 0; /* 30 */ uint8_t section_id = 0; /* 31 */ uint8_t char_class = 0; diff --git a/src/ProxyServer.cc b/src/ProxyServer.cc index a905a18a..86555a2f 100644 --- a/src/ProxyServer.cc +++ b/src/ProxyServer.cc @@ -372,7 +372,7 @@ void ProxyServer::UnlinkedSession::on_input(Channel& ch, uint16_t command, uint3 if (command != 0x93) { throw runtime_error("command is not 93"); } - const auto& cmd = check_size_t(data); + const auto& cmd = check_size_t(data, 0xFFFF); try { ses->license = s->license_index->verify_bb(cmd.username.decode(), cmd.password.decode()); } catch (const LicenseIndex::missing_license&) { diff --git a/src/Quest.cc b/src/Quest.cc index e4633848..06ee575c 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -462,10 +462,14 @@ QuestIndex::QuestIndex( : directory(directory), category_index(category_index) { - map> bin_files; - map> dat_files; - map> pvr_files; - map> json_files; + struct FileData { + std::string filename; + shared_ptr data; + }; + map bin_files; + map dat_files; + map pvr_files; + map json_files; map categories; for (const auto& cat : this->category_index->categories) { // Don't index Ep3 download categories for non-Ep3 quest indexing, and vice @@ -474,13 +478,13 @@ QuestIndex::QuestIndex( continue; } - auto add_file = [&](map>& files, const string& name, string&& value) { - if (categories.emplace(name, cat->category_id).first->second != cat->category_id) { - throw runtime_error("file " + name + " exists in multiple categories"); + auto add_file = [&](map& files, const string& basename, const string& filename, string&& value) { + if (categories.emplace(basename, cat->category_id).first->second != cat->category_id) { + throw runtime_error("file " + basename + " exists in multiple categories"); } auto data_ptr = make_shared(std::move(value)); - if (!files.emplace(name, data_ptr).second) { - throw runtime_error("file " + name + " already exists"); + if (!files.emplace(basename, FileData{filename, data_ptr}).second) { + throw runtime_error("file " + basename + " already exists"); } }; @@ -492,6 +496,7 @@ QuestIndex::QuestIndex( for (string filename : list_directory_sorted(cat_path)) { string file_path = cat_path + "/" + filename; try { + string orig_filename = filename; string file_data; if (ends_with(filename, ".gci")) { file_data = decode_gci_data(load_file(file_path)); @@ -523,26 +528,26 @@ QuestIndex::QuestIndex( } if (extension == "json") { - add_file(json_files, file_basename, std::move(file_data)); + add_file(json_files, file_basename, orig_filename, std::move(file_data)); } else if (extension == "bin" || extension == "mnm") { - add_file(bin_files, file_basename, std::move(file_data)); + add_file(bin_files, file_basename, orig_filename, std::move(file_data)); } else if (extension == "bind" || extension == "mnmd") { - add_file(bin_files, file_basename, prs_compress_optimal(file_data)); + add_file(bin_files, file_basename, orig_filename, prs_compress_optimal(file_data)); } else if (extension == "dat") { - add_file(dat_files, file_basename, std::move(file_data)); + add_file(dat_files, file_basename, orig_filename, std::move(file_data)); } else if (extension == "datd") { - add_file(dat_files, file_basename, prs_compress_optimal(file_data)); + add_file(dat_files, file_basename, orig_filename, prs_compress_optimal(file_data)); } else if (extension == "pvr") { - add_file(pvr_files, file_basename, std::move(file_data)); + add_file(pvr_files, file_basename, orig_filename, std::move(file_data)); } else if (extension == "qst") { auto files = decode_qst_data(file_data); for (auto& it : files) { if (ends_with(it.first, ".bin")) { - add_file(bin_files, file_basename, std::move(it.second)); + add_file(bin_files, file_basename, orig_filename, std::move(it.second)); } else if (ends_with(it.first, ".dat")) { - add_file(dat_files, file_basename, std::move(it.second)); + add_file(dat_files, file_basename, orig_filename, std::move(it.second)); } else if (ends_with(it.first, ".pvr")) { - add_file(pvr_files, file_basename, std::move(it.second)); + add_file(pvr_files, file_basename, orig_filename, std::move(it.second)); } else { throw runtime_error("qst file contains unsupported file type: " + it.first); } @@ -562,7 +567,7 @@ QuestIndex::QuestIndex( // should be indexed for (auto& bin_it : bin_files) { const string& basename = bin_it.first; - shared_ptr bin_contents = bin_it.second; + const auto* bin_filedata = &bin_it.second; try { // Quest .bin filenames are like K###-VERS-LANG.EXT, where: @@ -616,31 +621,25 @@ QuestIndex::QuestIndex( uint8_t language = language_code_for_char(language_token[0]); // Find the corresponding dat and pvr files - string dat_filename; - string pvr_filename; - shared_ptr dat_contents; - shared_ptr pvr_contents; + const FileData* dat_filedata = nullptr; + const FileData* pvr_filedata = nullptr; if (!::is_ep3(version)) { // Look for dat and pvr files with the same basename as the bin file; if // not found, look for them without the language suffix try { - dat_filename = basename; - dat_contents = dat_files.at(dat_filename); + dat_filedata = &dat_files.at(basename); } catch (const out_of_range&) { try { - dat_filename = quest_number_token + "-" + version_token; - dat_contents = dat_files.at(dat_filename); + dat_filedata = &dat_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { throw runtime_error("no dat file found for bin file " + basename); } } try { - pvr_filename = basename; - pvr_contents = pvr_files.at(pvr_filename); + pvr_filedata = &pvr_files.at(basename); } catch (const out_of_range&) { try { - pvr_filename = quest_number_token + "-" + version_token; - pvr_contents = pvr_files.at(pvr_filename); + pvr_filedata = &pvr_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { // pvr files aren't required (and most quests do not have them), so // don't fail if it's missing @@ -649,28 +648,25 @@ QuestIndex::QuestIndex( } // Load the quest's metadata JSON file, if it exists - string json_filename; - JSON metadata_json = nullptr; + const FileData* json_filedata = nullptr; shared_ptr battle_rules; ssize_t challenge_template_index = -1; shared_ptr available_expression; shared_ptr enabled_expression; try { - json_filename = basename; - metadata_json = JSON::parse(*json_files.at(json_filename)); + json_filedata = &json_files.at(basename); } catch (const out_of_range&) { try { - json_filename = quest_number_token + "-" + version_token; - metadata_json = JSON::parse(*json_files.at(json_filename)); + json_filedata = &json_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { try { - json_filename = quest_number_token; - metadata_json = JSON::parse(*json_files.at(json_filename)); + json_filedata = &json_files.at(quest_number_token); } catch (const out_of_range&) { } } } - if (!metadata_json.is_null()) { + if (json_filedata) { + auto metadata_json = JSON::parse(*json_filedata->data); try { battle_rules = make_shared(metadata_json.at("BattleRules")); } catch (const out_of_range&) { @@ -694,36 +690,40 @@ QuestIndex::QuestIndex( category_id, version, language, - bin_contents, - dat_contents, - pvr_contents, + bin_filedata->data, + dat_filedata ? dat_filedata->data : nullptr, + pvr_filedata ? pvr_filedata->data : nullptr, battle_rules, challenge_template_index, available_expression, enabled_expression); auto category_name = this->category_index->at(vq->category_id)->name; - string dat_str = dat_filename.empty() ? "" : (" with layout from " + dat_filename + ".dat"); - string battle_rules_str = battle_rules ? (" with battle rules from " + json_filename + ".json") : ""; - string challenge_template_str = (challenge_template_index >= 0) ? string_printf(" with challenge template index %zd", vq->challenge_template_index) : ""; + string filenames_str = bin_filedata->filename; + if (dat_filedata) { + filenames_str += string_printf("/%s", dat_filedata->filename.c_str()); + } + if (pvr_filedata) { + filenames_str += string_printf("/%s", pvr_filedata->filename.c_str()); + } + if (json_filedata) { + filenames_str += string_printf("/%s", json_filedata->filename.c_str()); + } auto q_it = this->quests_by_number.find(vq->quest_number); if (q_it != this->quests_by_number.end()) { q_it->second->add_version(vq); - static_game_data_log.info("(%s) Added %s %c version of quest %" PRIu32 " (%s)%s%s%s", - basename.c_str(), + static_game_data_log.info("(%s) Added %s %c version of quest %" PRIu32 " (%s)", + filenames_str.c_str(), name_for_enum(vq->version), char_for_language_code(vq->language), vq->quest_number, - vq->name.c_str(), - dat_str.c_str(), - battle_rules_str.c_str(), - challenge_template_str.c_str()); + vq->name.c_str()); } else { auto q = make_shared(vq); this->quests_by_number.emplace(vq->quest_number, q); this->quests_by_category_id_and_number[q->category_id].emplace(vq->quest_number, q); - static_game_data_log.info("(%s) Created %s %c quest %" PRIu32 " (%s) (%s, %s (%" PRIu32 "), %s)%s%s%s", - basename.c_str(), + static_game_data_log.info("(%s) Created %s %c quest %" PRIu32 " (%s) (%s, %s (%" PRIu32 "), %s)", + filenames_str.c_str(), name_for_enum(vq->version), char_for_language_code(vq->language), vq->quest_number, @@ -731,10 +731,7 @@ QuestIndex::QuestIndex( name_for_episode(vq->episode), category_name.c_str(), vq->category_id, - vq->joinable ? "joinable" : "not joinable", - dat_str.c_str(), - battle_rules_str.c_str(), - challenge_template_str.c_str()); + vq->joinable ? "joinable" : "not joinable"); } } catch (const exception& e) { static_game_data_log.warning("(%s) Failed to index quest file: (%s)", basename.c_str(), e.what()); diff --git a/src/RareItemSet.cc b/src/RareItemSet.cc index 29ad4d01..eef1ce5d 100644 --- a/src/RareItemSet.cc +++ b/src/RareItemSet.cc @@ -136,10 +136,16 @@ std::string RareItemSet::ParsedRELData::serialize_t(bool is_v1) const { for (const auto& drop : this->box_rares) { w.put_u8(drop.area); } + for (size_t z = this->box_rares.size(); z < 30; z++) { + w.put_u8(0xFF); + } root.box_rares_offset = w.size(); for (const auto& drop : this->box_rares) { w.put(PackedDrop(drop.drop)); } + for (size_t z = this->box_rares.size(); z < 30; z++) { + w.put_u32l(0x00000000); + } while (w.size() & 3) { w.put_u8(0); } diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 09699ea2..b15732f1 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -1043,24 +1043,58 @@ static void on_9E_XB(shared_ptr c, uint16_t, uint32_t, string& data) { send_command(c, 0x9F, 0x00); } +static void scramble_bb_security_data(parray& data, uint8_t which, bool reverse) { + static const uint8_t forward_orders[8][5] = { + {2, 0, 1, 4, 3}, + {3, 4, 0, 1, 2}, + {2, 3, 4, 0, 1}, + {2, 3, 0, 1, 4}, + {0, 2, 3, 4, 1}, + {1, 4, 2, 3, 0}, + {2, 0, 1, 4, 3}, + {1, 0, 3, 4, 2}, + }; + static const uint8_t reverse_orders[8][5] = { + {1, 2, 0, 4, 3}, + {2, 3, 4, 0, 1}, + {3, 4, 0, 1, 2}, + {2, 3, 0, 1, 4}, + {0, 4, 1, 2, 3}, + {4, 0, 2, 3, 1}, + {1, 2, 0, 4, 3}, + {1, 0, 4, 2, 3}, + }; + const auto& order = reverse ? reverse_orders[which & 7] : forward_orders[which & 7]; + parray scrambled_data; + for (size_t z = 0; z < 5; z++) { + for (size_t x = 0; x < 8; x++) { + scrambled_data[(z * 8) + x] = data[(order[z] * 8) + x]; + } + } + data = scrambled_data; +} + static void on_93_BB(shared_ptr c, uint16_t, uint32_t, string& data) { - const auto& cmd = check_size_t(data, sizeof(C_Login_BB_93) - 8, sizeof(C_Login_BB_93)); + const auto& base_cmd = check_size_t(data, 0xFFFF); auto s = c->require_server_state(); - bool is_old_format; - if (data.size() == sizeof(C_Login_BB_93) - 8) { - is_old_format = true; - } else if (data.size() == sizeof(C_Login_BB_93)) { - is_old_format = false; - } else { - throw runtime_error("invalid size for 93 command"); + parray config_data = (data.size() == sizeof(C_LoginWithoutHardwareInfo_BB_93)) + ? check_size_t(data).client_config + : check_size_t(data).client_config; + + // If security_token is zero, the game scrambles the client config data based + // on the first character in the username. We undo the scramble here. + if (base_cmd.security_token == 0) { + scramble_bb_security_data(config_data, base_cmd.username.at(0), true); } - c->config.set_flags_for_version(c->version(), -1); - c->channel.language = cmd.language; + c->config.set_flags_for_version(c->version(), base_cmd.sub_version); + c->channel.language = base_cmd.language; + string username = base_cmd.username.decode(); + string password = base_cmd.password.decode(); try { - auto l = s->license_index->verify_bb(cmd.username.decode(), cmd.password.decode()); + auto l = s->license_index->verify_bb(username, password); c->set_license(l); } catch (const LicenseIndex::no_username& e) { @@ -1080,9 +1114,9 @@ static void on_93_BB(shared_ptr c, uint16_t, uint32_t, string& data) { return; } else { auto l = s->license_index->create_license(); - l->serial_number = fnv1a32(cmd.username.decode()) & 0x7FFFFFFF; - l->bb_username = cmd.username.decode(); - l->bb_password = cmd.password.decode(); + l->serial_number = fnv1a32(username) & 0x7FFFFFFF; + l->bb_username = username; + l->bb_password = password; s->license_index->add(l); if (!s->is_replay) { l->save(); @@ -1093,16 +1127,10 @@ static void on_93_BB(shared_ptr c, uint16_t, uint32_t, string& data) { } } - try { - if (is_old_format) { - c->config.parse_from(cmd.var.old_client_config); - } else { - c->config.parse_from(cmd.var.new_clients.client_config); - } - } catch (const invalid_argument&) { - string version_string = is_old_format - ? cmd.var.old_client_config.as_string() - : cmd.var.new_clients.client_config.as_string(); + if (base_cmd.guild_card_number != 0) { + c->config.parse_from(config_data); + } else { + string version_string = config_data.as_string(); strip_trailing_zeroes(version_string); // Note: Tethealla PSOBB is actually Japanese PSOBB, but with most of the // files replaced with English text/graphics/etc. For this reason, it still @@ -1113,17 +1141,17 @@ static void on_93_BB(shared_ptr c, uint16_t, uint32_t, string& data) { c->config.set_flag(Client::Flag::FORCE_ENGLISH_LANGUAGE_BB); } } - c->channel.language = c->config.check_flag(Client::Flag::FORCE_ENGLISH_LANGUAGE_BB) ? 1 : cmd.language; - c->bb_connection_phase = cmd.connection_phase; - c->bb_character_index = cmd.character_slot; + c->channel.language = c->config.check_flag(Client::Flag::FORCE_ENGLISH_LANGUAGE_BB) ? 1 : base_cmd.language; + c->bb_connection_phase = base_cmd.connection_phase; + c->bb_character_index = base_cmd.character_slot; - if (cmd.menu_id == MenuID::LOBBY) { - c->preferred_lobby_id = cmd.preferred_lobby_id; + if (base_cmd.menu_id == MenuID::LOBBY) { + c->preferred_lobby_id = base_cmd.preferred_lobby_id; } send_client_init_bb(c, 0); - if (cmd.guild_card_number == 0) { + if (base_cmd.guild_card_number == 0) { // On first login, send the client to the data server port send_reconnect(c, s->connect_address_for_client(c), s->name_to_port_config.at("bb-data1")->port); @@ -3091,15 +3119,22 @@ static void on_61_98(shared_ptr c, uint16_t command, uint32_t flag, stri bb_player->disp.visual.version = 4; bb_player->disp.visual.name_color_checksum = 0x00000000; bb_player->inventory = player->inventory; - bb_player->disp.stats.advance_to_level(bb_player->disp.visual.char_class, player->disp.stats.level, s->level_table); - bb_player->disp.stats.char_stats.atp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::POWER) * 2; - bb_player->disp.stats.char_stats.mst += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::MIND) * 2; - bb_player->disp.stats.char_stats.evp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::EVADE) * 2; - bb_player->disp.stats.char_stats.dfp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::DEF) * 2; - bb_player->disp.stats.char_stats.lck += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::LUCK) * 2; - bb_player->disp.stats.char_stats.hp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::HP) * 2; - bb_player->disp.stats.experience = player->disp.stats.experience; - bb_player->disp.stats.meseta = player->disp.stats.meseta; + // Before V3, player stats can't be correctly computed from other fields + // because material usage isn't stored anywhere. For these versions, we + // have to trust the stats field from the player's data. + if (is_v1_or_v2(c->version())) { + bb_player->disp.stats = player->disp.stats; + } else { + bb_player->disp.stats.advance_to_level(bb_player->disp.visual.char_class, player->disp.stats.level, s->level_table); + bb_player->disp.stats.char_stats.atp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::POWER) * 2; + bb_player->disp.stats.char_stats.mst += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::MIND) * 2; + bb_player->disp.stats.char_stats.evp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::EVADE) * 2; + bb_player->disp.stats.char_stats.dfp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::DEF) * 2; + bb_player->disp.stats.char_stats.lck += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::LUCK) * 2; + bb_player->disp.stats.char_stats.hp += bb_player->get_material_usage(PSOBBCharacterFile::MaterialType::HP) * 2; + bb_player->disp.stats.experience = player->disp.stats.experience; + bb_player->disp.stats.meseta = player->disp.stats.meseta; + } bb_player->disp.technique_levels_v1 = player->disp.technique_levels_v1; bb_player->auto_reply = player->auto_reply; bb_player->info_board = player->info_board; @@ -4203,6 +4238,17 @@ static void on_0C_C1_E7_EC(shared_ptr c, uint16_t command, uint32_t, str if (game) { s->change_client_lobby(c, game); c->config.set_flag(Client::Flag::LOADING); + + // There is a bug in DC NTE and 11/2000 that causes them to assign item IDs + // twice when joining a game. If there are other players in the game, this + // isn't an issue because the equivalent of the 6x6D command resets the next + // item ID before the second assignment, so the item IDs stay in sync with + // the server. If there was no one else in the game, however (as in this + // case, when it was just created), we need to artificially change the next + // item IDs during the client's loading procedure. + if (is_pre_v1(c->version())) { + c->config.set_flag(Client::Flag::SHOULD_SEND_ARTIFICIAL_ITEM_STATE); + } } } diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index d509f273..5c153f61 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -905,12 +905,9 @@ static void on_change_floor_6x1F(shared_ptr c, uint8_t command, uint8_t if (is_pre_v1(c->version())) { check_size_t(data, size); // DC NTE and 11/2000 don't send 6F when they're done loading, so we clear - // the loading flag here instead. On these versions, it also seems to be - // necessary to assign item IDs again here. + // the loading flag here instead. if (c->config.check_flag(Client::Flag::LOADING)) { c->config.clear_flag(Client::Flag::LOADING); - auto l = c->require_lobby(); - l->assign_inventory_and_bank_item_ids(c); } } else { @@ -1028,11 +1025,26 @@ static void on_npc_control(shared_ptr c, uint8_t command, uint8_t flag, // Don't allow NPC control commands if there is a player in the relevant slot const auto& l = c->require_lobby(); if (!l->is_game()) { - throw runtime_error("cannot create NPCs in the lobby"); + throw runtime_error("cannot create or modify NPC in the lobby"); } - if ((cmd.npc_entity_id < 4) && l->clients[cmd.npc_entity_id]) { - throw runtime_error("cannot overwrite existing player with NPC"); + + uint16_t npc_entity_id = 0xFFFF; + switch (cmd.command) { + case 0: + case 3: + npc_entity_id = cmd.param2; + break; + case 1: + case 2: + npc_entity_id = cmd.param1; + break; + default: + throw runtime_error("invalid 6x69 command"); } + if ((npc_entity_id < 4) && l->clients[npc_entity_id]) { + throw runtime_error("cannot create or modify NPC in existing player slot"); + } + forward_subcommand(c, command, flag, data, size); } @@ -2126,21 +2138,36 @@ static void on_charge_attack_bb(shared_ptr c, uint8_t command, uint8_t f } static void on_level_up(shared_ptr c, uint8_t command, uint8_t flag, void* data, size_t size) { - const auto& cmd = check_size_t(data, size); - auto l = c->require_lobby(); if (!l->is_game()) { return; } + // On the DC prototypes, this command doesn't include any stats - it just + // increments the player's level by 1. auto p = c->character(); - p->disp.stats.char_stats.atp = cmd.atp; - p->disp.stats.char_stats.mst = cmd.mst; - p->disp.stats.char_stats.evp = cmd.evp; - p->disp.stats.char_stats.hp = cmd.hp; - p->disp.stats.char_stats.dfp = cmd.dfp; - p->disp.stats.char_stats.ata = cmd.ata; - p->disp.stats.level = cmd.level.load(); + if (is_pre_v1(c->version())) { + check_size_t(data, size); + auto s = c->require_server_state(); + const auto& level_incrs = s->level_table->stats_delta_for_level(p->disp.visual.char_class, p->disp.stats.level + 1); + p->disp.stats.char_stats.atp += level_incrs.atp; + p->disp.stats.char_stats.mst += level_incrs.mst; + p->disp.stats.char_stats.evp += level_incrs.evp; + p->disp.stats.char_stats.hp += level_incrs.hp; + p->disp.stats.char_stats.dfp += level_incrs.dfp; + p->disp.stats.char_stats.ata += level_incrs.ata; + p->disp.stats.char_stats.lck += level_incrs.lck; + p->disp.stats.level++; + } else { + const auto& cmd = check_size_t(data, size); + p->disp.stats.char_stats.atp = cmd.atp; + p->disp.stats.char_stats.mst = cmd.mst; + p->disp.stats.char_stats.evp = cmd.evp; + p->disp.stats.char_stats.hp = cmd.hp; + p->disp.stats.char_stats.dfp = cmd.dfp; + p->disp.stats.char_stats.ata = cmd.ata; + p->disp.stats.level = cmd.level.load(); + } forward_subcommand(c, command, flag, data, size); } diff --git a/src/ReplaySession.cc b/src/ReplaySession.cc index 34970593..712b1502 100644 --- a/src/ReplaySession.cc +++ b/src/ReplaySession.cc @@ -203,7 +203,7 @@ void ReplaySession::check_for_password(shared_ptr ev) const { if (header.command == 0x04) { check_pw(check_size_t(cmd_data, cmd_size).password.decode()); } else if (header.command == 0x93) { - check_pw(check_size_t(cmd_data, cmd_size).password.decode()); + check_pw(check_size_t(cmd_data, cmd_size, 0xFFFF).password.decode()); } else if (header.command == 0x9C) { check_pw(check_size_t(cmd_data, cmd_size).password.decode()); } else if (header.command == 0x9E) { @@ -420,7 +420,7 @@ void ReplaySession::apply_default_mask(shared_ptr ev) { } case 0x00E6: { auto& mask = check_size_t(mask_data, mask_size); - mask.team_id = 0; + mask.security_token = 0; break; } } diff --git a/src/SendCommands.cc b/src/SendCommands.cc index 987cae7b..b761796f 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -506,7 +506,7 @@ void send_client_init_bb(shared_ptr c, uint32_t error_code) { cmd.error_code = error_code; cmd.player_tag = 0x00010000; cmd.guild_card_number = c->license->serial_number; - cmd.team_id = team ? team->team_id : 0; + cmd.security_token = team ? team->team_id : 0; c->config.serialize_into(cmd.client_config); cmd.can_create_team = 1; cmd.episode_4_unlocked = 1; @@ -2360,12 +2360,37 @@ void send_game_item_state(shared_ptr c) { G_SyncItemState_6x6D_Decompressed decompressed_header; for (size_t z = 0; z < 12; z++) { - decompressed_header.next_item_id_per_player[z] = l->next_item_id_for_client[z]; + if (z == c->lobby_client_id) { + // If the player is joining, adjust the next item ID to use the value + // before inventory item IDs are assigned + size_t num_items = c->character()->inventory.num_items; + uint32_t next_id = l->next_item_id_for_client[z] - num_items; + if ((next_id & 0xFFE00000) != (l->next_item_id_for_client[z] & 0xFFE00000)) { + throw runtime_error("next item ID underflow during joining player item state generation"); + } + decompressed_header.next_item_id_per_player[z] = next_id; + } else { + decompressed_header.next_item_id_per_player[z] = l->next_item_id_for_client[z]; + } } + l->log.info("Sending next item IDs to client: %08" PRIX32 " %08" PRIX32 " %08" PRIX32 " %08" PRIX32, + decompressed_header.next_item_id_per_player[0].load(), + decompressed_header.next_item_id_per_player[1].load(), + decompressed_header.next_item_id_per_player[2].load(), + decompressed_header.next_item_id_per_player[3].load()); + for (size_t floor = 0; floor < 0x10; floor++) { const auto& m = l->floor_item_managers.at(floor); - for (const auto& it : m.queue_for_client.at(c->lobby_client_id)) { + // It's important that these are added in increasing order of item_id (hence + // why items is a map and not an unordered_map), since the game uses binary + // search to find floor items when picking them up. If items aren't in the + // correct order, the game may fail to find an item when attempting to pick + // it up, causing "ghost items" which are visible but can't be picked up. + for (const auto& it : m.items) { const auto& item = it.second; + if (!item->visible_to_client(c->lobby_client_id)) { + continue; + } FloorItem fi; fi.floor = floor; diff --git a/src/ServerState.cc b/src/ServerState.cc index 54ff645e..934621cb 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -1099,7 +1099,7 @@ void ServerState::load_battle_params() { void ServerState::load_level_table() { config_log.info("Loading level table"); - this->level_table = make_shared(this->load_bb_file("PlyLevelTbl.prs"), true); + this->level_table = make_shared(*this->load_bb_file("PlyLevelTbl.prs"), true); } void ServerState::load_word_select_table() { diff --git a/tests/DCNTE-GameSmokeTest.test.txt b/tests/DCNTE-GameSmokeTest.test.txt index 3a91e3be..36853d93 100644 --- a/tests/DCNTE-GameSmokeTest.test.txt +++ b/tests/DCNTE-GameSmokeTest.test.txt @@ -887,6 +887,13 @@ I 25793 2023-11-24 23:08:15 - [Commands] Sending to C-A (ABCDEFGHIJKL) (version= I 25793 2023-11-24 23:08:15 - [Commands] Received from C-A (ABCDEFGHIJKL) (version=DC_NTE command=1D flag=00) 0000 | 1D 00 04 00 | I 25793 2023-11-24 23:08:15 - [C-A] Sending 0 queued command(s) +I 58396 2023-12-27 10:39:54 - [Lobby:15] Sending next item IDs to client: 00010000 00210000 00410000 00610000 +I 58396 2023-12-27 10:39:54 - [Commands] Sending to C-A (ABCDEFGHIJKL) (version=DC_NTE command=6D flag=00) +0000 | 6D 00 44 00 5E 00 00 00 40 00 00 00 8C 00 00 00 | m D ^ @ +0010 | A9 00 EE FF 00 0C 01 0D 00 21 11 00 41 AA 15 00 | ! A +0020 | 61 19 00 81 1D 00 A1 21 00 C1 52 25 00 E1 0D 01 | a ! R% +0030 | 10 00 21 31 00 41 35 00 01 61 10 01 40 0F 52 0F | !1 A5 a @ R +0040 | 64 0F 29 00 | d ) I 25793 2023-11-24 23:08:27 - [Commands] Received from C-A (ABCDEFGHIJKL) (version=DC_NTE command=60 flag=00) 0000 | 60 00 1C 00 36 06 00 00 00 00 00 00 00 00 00 00 | ` 6 0010 | 5C 0F C6 43 00 00 00 00 C3 75 95 42 | \ C u B