diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f7da08a..3fce0be1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ add_executable(newserv src/Compression.cc src/DNSServer.cc src/Episode3/AssistServer.cc + src/Episode3/BattleRecord.cc src/Episode3/Card.cc src/Episode3/CardSpecial.cc src/Episode3/DataIndex.cc diff --git a/src/Channel.hh b/src/Channel.hh index 9b3a2e24..dc9a0096 100644 --- a/src/Channel.hh +++ b/src/Channel.hh @@ -82,6 +82,10 @@ struct Channel { // Sends a message with an automatically-constructed header. void send(uint16_t cmd, uint32_t flag = 0, const void* data = nullptr, size_t size = 0, bool print_contents = true); void send(uint16_t cmd, uint32_t flag, const std::string& data, bool print_contents = true); + template + void send(uint16_t cmd, uint32_t flag, const CmdT& data) { + this->send(cmd, flag, &data, sizeof(data)); + } // Sends a message with a pre-existing header (as the first few bytes in the // data) diff --git a/src/ChatCommands.cc b/src/ChatCommands.cc index 58ca397d..3cee3ead 100644 --- a/src/ChatCommands.cc +++ b/src/ChatCommands.cc @@ -392,6 +392,50 @@ static void server_command_lobby_type(shared_ptr, shared_ptr } } +static void server_command_saverec(shared_ptr, shared_ptr l, + shared_ptr c, const std::u16string& args) { + if (args.find(u'/') != string::npos) { + send_text_message(c, u"$C4Recording names\ncannot include\nthe / character"); + return; + } + if (!l->prev_battle_record) { + send_text_message(c, u"$C4No finished\nrecording is\npresent"); + return; + } + string filename = "system/ep3/battle-records/" + encode_sjis(args) + ".mzrd"; + string data = l->prev_battle_record->serialize(); + save_file(filename, data); + send_text_message(l, u"$C7Recording saved"); + l->prev_battle_record.reset(); +} + +static void server_command_playrec(shared_ptr, shared_ptr l, + shared_ptr c, const std::u16string& args) { + if (!(c->flags & Client::Flag::IS_EPISODE_3)) { + send_text_message(c, u"$C4This command can\nonly be used on\nEpisode 3"); + return; + } + if (args.find(u'/') != string::npos) { + send_text_message(c, u"$C4Recording names\ncannot include\nthe / character"); + return; + } + + if (l->is_game() && (l->flags & Lobby::Flag::EPISODE_3_ONLY) && (l->flags & Lobby::Flag::IS_SPECTATOR_TEAM) && l->battle_player) { + l->flags |= Lobby::Flag::BATTLE_IN_PROGRESS; + l->battle_player->start(); + + } else if (args.empty()) { + c->next_game_battle_record.reset(); + send_text_message(c, u"$C6Replay state\ncleared"); + + } else { + string filename = "system/ep3/battle-records/" + encode_sjis(args) + ".mzrd"; + string data = load_file(filename); + c->next_game_battle_record.reset(new Episode3::BattleRecord(data)); + send_text_message(c, u"$C6Replay state set"); + } +} + //////////////////////////////////////////////////////////////////////////////// // Game commands @@ -1013,7 +1057,9 @@ static const unordered_map chat_commands({ {u"$password", {server_command_password, nullptr, u"Usage:\nlock [password]\nomit password to\nunlock game"}}, {u"$patch", {server_command_patch, proxy_command_patch, u"Usage:\npatch "}}, {u"$persist", {server_command_persist, nullptr, u"Usage:\npersist"}}, + {u"$playrec", {server_command_playrec, nullptr, u"Usage:\nplayrec "}}, {u"$rand", {server_command_rand, proxy_command_rand, u"Usage:\nrand [hex seed]\nomit seed to revert\nto default"}}, + {u"$saverec", {server_command_saverec, nullptr, u"Usage:\nsaverec "}}, {u"$secid", {server_command_secid, proxy_command_secid, u"Usage:\nsecid [section ID]\nomit section ID to\nrevert to normal"}}, {u"$silence", {server_command_silence, nullptr, u"Usage:\nsilence "}}, // TODO: implement this on proxy server diff --git a/src/Client.hh b/src/Client.hh index acd38641..e88d4d10 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -14,6 +14,7 @@ #include "PSOEncryption.hh" #include "PSOProtocol.hh" #include "Text.hh" +#include "Episode3/BattleRecord.hh" @@ -124,6 +125,7 @@ struct Client { bool can_chat; std::string pending_bb_save_username; uint8_t pending_bb_save_player_index; + std::shared_ptr next_game_battle_record; bool proxy_block_events; bool proxy_block_function_calls; diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index 99c07fd4..0450bb26 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -2513,15 +2513,26 @@ struct S_JoinSpectatorTeam_GC_Ep3_E8 { PlayerDispDataDCPCV3 disp; // 0xD0 bytes } __packed__; // 0x43C bytes parray players; // 84-1174 - parray unknown_a2; // 1174-117C - le_uint32_t unknown_a3 = 0; // 117C-1180 - parray unknown_a4; // 1180-1184 + uint8_t client_id = 0; + uint8_t leader_id = 0; + uint8_t disable_udp = 1; + uint8_t difficulty = 0; + uint8_t battle_mode = 0; + uint8_t event = 0; + uint8_t section_id = 0; + uint8_t challenge_mode = 0; + le_uint32_t rare_seed = 0; + uint8_t episode = 0; + uint8_t unused2 = 1; + uint8_t solo_mode = 0; + uint8_t unused3 = 0; struct SpectatorEntry { - le_uint32_t player_tag = 0x00010000; + le_uint32_t player_tag = 0; le_uint32_t guild_card_number = 0; ptext name; - parray unknown_a3; - le_uint16_t unknown_a4 = 0; + uint8_t present = 0; + uint8_t unknown_a3 = 0; + le_uint16_t level = 0; parray unknown_a5; parray unknown_a6; } __packed__; // 0x38 bytes @@ -2529,7 +2540,7 @@ struct S_JoinSpectatorTeam_GC_Ep3_E8 { // battle - they appear in the first positions. Presumably the first 4 are // always for battlers, and the last 8 are always for spectators. parray entries; // 1184-1424 - ptext spectator_team_name; + ptext spectator_team_name; // This field doesn't appear to be actually used by the game, but some servers // send it anyway (and the game presumably ignores it) parray spectator_players; diff --git a/src/Episode3/BattleRecord.cc b/src/Episode3/BattleRecord.cc new file mode 100644 index 00000000..759edb76 --- /dev/null +++ b/src/Episode3/BattleRecord.cc @@ -0,0 +1,378 @@ +#include "BattleRecord.hh" + +#include + +#include "../CommandFormats.hh" +#include "../SendCommands.hh" + +using namespace std; + +namespace Episode3 { + + + +BattleRecord::Event::Event(StringReader& r) { + this->type = r.get(); + this->timestamp = r.get_u64l(); + switch (this->type) { + case Event::Type::PLAYER_JOIN: + this->players.emplace_back(r.get()); + break; + case Event::Type::PLAYER_LEAVE: + this->leaving_client_id = r.get_u8(); + break; + case Event::Type::SET_INITIAL_PLAYERS: { + uint8_t count = r.get_u8(); + while (this->players.size() < count) { + this->players.emplace_back(r.get()); + } + break; + } + case Event::Type::CHAT_MESSAGE: + this->guild_card_number = r.get_u32l(); + [[fallthrough]]; + case Event::Type::GAME_COMMAND: + case Event::Type::BATTLE_COMMAND: + case Event::Type::EP3_GAME_COMMAND: + this->data = r.read(r.get_u16l()); + break; + default: + throw logic_error("unknown event type"); + } +} + +void BattleRecord::Event::serialize(StringWriter& w) const { + w.put(this->type); + w.put_u64l(this->timestamp); + switch (this->type) { + case Event::Type::PLAYER_JOIN: + if (this->players.size() != 1) { + throw logic_error("player join event does not contain 1 player entry"); + } + w.put(this->players[0]); + break; + case Event::Type::PLAYER_LEAVE: + w.put_u8(this->leaving_client_id); + break; + case Event::Type::SET_INITIAL_PLAYERS: + w.put_u8(this->players.size()); + for (const auto& player : this->players) { + w.put(player); + } + break; + case Event::Type::CHAT_MESSAGE: + w.put_u32l(this->guild_card_number); + [[fallthrough]]; + case Event::Type::GAME_COMMAND: + case Event::Type::BATTLE_COMMAND: + case Event::Type::EP3_GAME_COMMAND: + w.put_u16l(this->data.size()); + w.write(this->data); + break; + default: + throw logic_error("unknown event type"); + } +} + + + +BattleRecord::BattleRecord(uint32_t behavior_flags) + : is_writable(true), + behavior_flags(behavior_flags), + battle_start_timestamp(0), + battle_end_timestamp(0) { } + +BattleRecord::BattleRecord(const string& data) + : is_writable(false), + behavior_flags(0), + battle_start_timestamp(0), + battle_end_timestamp(0) { + StringReader r(data); + uint64_t signature = r.get_u64l(); + if (signature != this->SIGNATURE) { + throw runtime_error("incorrect battle record signature"); + } + + this->battle_start_timestamp = r.get_u64l(); + this->battle_end_timestamp = r.get_u64l(); + this->behavior_flags = r.get_u32l(); + while (!r.eof()) { + this->events.emplace_back(r); + } +} + +string BattleRecord::serialize() const { + StringWriter w; + w.put_u64l(this->SIGNATURE); + w.put_u64l(this->battle_start_timestamp); + w.put_u64l(this->battle_end_timestamp); + w.put_u32l(this->behavior_flags); + for (const auto& ev : this->events) { + ev.serialize(w); + } + return move(w.str()); +} + +bool BattleRecord::writable() const { + return this->is_writable; +} + +bool BattleRecord::battle_in_progress() const { + return (this->battle_start_timestamp != 0); +} + +const BattleRecord::Event* BattleRecord::get_first_event() const { + if (this->events.empty()) { + return nullptr; + } + return &this->events.front(); +} + + +void BattleRecord::add_player( + const PlayerLobbyDataDCGC& lobby_data, + const PlayerInventory& inventory, + const PlayerDispDataDCPCV3& disp) { + if (!this->is_writable) { + throw logic_error("cannot write to battle record"); + } + if (this->battle_start_timestamp != 0) { + throw runtime_error("cannot add player during battle"); + } + Event& ev = this->events.emplace_back(); + ev.type = Event::Type::PLAYER_JOIN; + ev.timestamp = now(); + auto& player = ev.players.emplace_back(); + player.lobby_data = lobby_data; + player.inventory = inventory; + player.disp = disp; +} + +void BattleRecord::delete_player(uint8_t client_id) { + if (!this->is_writable) { + throw logic_error("cannot write to battle record"); + } + Event& ev = this->events.emplace_back(); + ev.type = Event::Type::PLAYER_LEAVE; + ev.timestamp = now(); + ev.leaving_client_id = client_id; +} + +void BattleRecord::add_command(Event::Type type, const void* data, size_t size) { + if (!this->is_writable) { + throw logic_error("cannot write to battle record"); + } + Event& ev = this->events.emplace_back(); + ev.type = type; + ev.timestamp = now(); + ev.data.assign(reinterpret_cast(data), size); +} + +void BattleRecord::add_command(Event::Type type, string&& data) { + if (!this->is_writable) { + throw logic_error("cannot write to battle record"); + } + Event& ev = this->events.emplace_back(); + ev.type = type; + ev.timestamp = now(); + ev.data = move(data); +} + +void BattleRecord::add_chat_message( + uint32_t guild_card_number, string&& data) { + if (!this->is_writable) { + throw logic_error("cannot write to battle record"); + } + Event& ev = this->events.emplace_back(); + ev.type = Event::Type::CHAT_MESSAGE; + ev.timestamp = now(); + ev.guild_card_number = guild_card_number; + ev.data = move(data); +} + +bool BattleRecord::is_map_definition_event(const Event& ev) { + if (ev.type == Event::Type::BATTLE_COMMAND) { + auto& header = check_size_t( + ev.data, sizeof(G_CardBattleCommandHeader), 0xFFFF); + if (header.subcommand == 0xB6) { + auto& header = check_size_t( + ev.data, sizeof(G_MapSubsubcommand_GC_Ep3_6xB6), 0xFFFF); + if (header.subsubcommand == 0x41) { + return true; + } + } + } + return false; +} + +void BattleRecord::set_battle_start_timestamp() { + if (this->battle_start_timestamp != 0) { + throw logic_error("battle start timestamp is already set"); + } + this->battle_start_timestamp = now(); + + // First, find the correct map definition subcommand to keep, and execute + // player join/leave events to get the present players + size_t num_map_events = 0; + PlayerEntry players[4]; + bool players_present[4]; + for (auto& ev : this->events) { + if (ev.type == Event::Type::PLAYER_JOIN) { + if (ev.players.size() != 1) { + throw logic_error("player join event does not contain 1 player entry"); + } + auto& player = ev.players[0]; + if (player.lobby_data.client_id >= 4) { + throw runtime_error("invalid client ID"); + } + players[player.lobby_data.client_id] = player; + players_present[player.lobby_data.client_id] = true; + + } else if (ev.type == Event::Type::PLAYER_LEAVE) { + if (ev.leaving_client_id >= 4) { + throw logic_error("invalid client ID"); + } + players_present[ev.leaving_client_id] = false; + + } else if (ev.type == Event::Type::SET_INITIAL_PLAYERS) { + throw logic_error("BattleRecord::set_battle_start_timestamp called twice"); + + } else if (this->is_map_definition_event(ev)) { + num_map_events++; + } + } + + deque new_events; + + // Generate the initial players event + Event initial_ev; + initial_ev.type = Event::Type::SET_INITIAL_PLAYERS; + initial_ev.timestamp = this->battle_start_timestamp; + for (size_t z = 0; z < 4; z++) { + if (players_present[z]) { + initial_ev.players.emplace_back(players[z]); + } + } + new_events.emplace_back(move(initial_ev)); + + // Skip all events before the last map definition event, and only retain + // battle commands between then and now (since these battle commands will all + // be replayed at once) + auto it = this->events.begin(); + for (; it != this->events.end(); it++) { + if (this->is_map_definition_event(*it)) { + num_map_events--; + if (num_map_events == 0) { + break; + } + } + } + for (; it != this->events.end(); it++) { + if (it->type == Event::Type::BATTLE_COMMAND) { + new_events.emplace_back(move(*it)); + } + } + this->events = move(new_events); +} + +void BattleRecord::set_battle_end_timestamp() { + this->battle_end_timestamp = now(); +} + + + +BattleRecordPlayer::BattleRecordPlayer( + shared_ptr rec, + shared_ptr base, + shared_ptr l) + : record(rec), + event_it(this->record->events.begin()), + play_start_timestamp(0), + base(base), + lobby(l), + next_command_ev(event_new(this->base.get(), -1, EV_TIMEOUT, &BattleRecordPlayer::dispatch_schedule_events, this), event_free) { } + +shared_ptr BattleRecordPlayer::get_record() const { + return this->record; +} + +void BattleRecordPlayer::start() { + this->play_start_timestamp = now(); + this->schedule_events(); +} + +void BattleRecordPlayer::dispatch_schedule_events( + evutil_socket_t, short, void* ctx) { + reinterpret_cast(ctx)->schedule_events(); +} + +void BattleRecordPlayer::schedule_events() { + // If the lobby is destroyed, we can't replay anything - just return without + // rescheduling + auto l = this->lobby.lock(); + if (!l) { + return; + } + + for (;;) { + uint64_t relative_ts = now() - this->play_start_timestamp + this->record->battle_start_timestamp; + + if (this->event_it == this->record->events.end()) { + if (relative_ts >= this->record->battle_end_timestamp) { + // If the record is complete and the end timestamp has been reached, so + // send exit commands to all players in the lobby, and don't reschedule + // the event (it will be deleted along with the Player when the lobby is + // destroyed, when the last client leaves) + send_command(l, 0xED, 0x00); + + } else { + // There are no more events to play, but the battle has not officially + // ended yet - reschedule the event for the end time + auto tv = usecs_to_timeval(this->record->battle_end_timestamp - relative_ts); + event_add(this->next_command_ev.get(), &tv); + } + break; + + } else { + if (this->event_it->timestamp <= relative_ts) { + // Play the next event + auto& ev = *this->event_it; + switch (ev.type) { + case BattleRecord::Event::Type::PLAYER_JOIN: + // Technically we can support this, but it should never happen + throw runtime_error("player join event during battle replay"); + case BattleRecord::Event::Type::PLAYER_LEAVE: + send_player_leave_notification(l, ev.leaving_client_id); + break; + case BattleRecord::Event::Type::SET_INITIAL_PLAYERS: + // This should have been handled before the lobby was even created + break; + case BattleRecord::Event::Type::BATTLE_COMMAND: + send_command(l, 0xC9, 0x00, ev.data); + break; + case BattleRecord::Event::Type::GAME_COMMAND: + send_command(l, 0x60, 0x00, ev.data); + break; + case BattleRecord::Event::Type::EP3_GAME_COMMAND: + send_command(l, 0xCB, 0x00, ev.data); + break; + case BattleRecord::Event::Type::CHAT_MESSAGE: + send_chat_message(l, ev.guild_card_number, decode_sjis(ev.data)); + break; + } + this->event_it++; + + } else { + // The next event should not occur yet, so reschedule for the time when + // it should occur + auto tv = usecs_to_timeval(this->event_it->timestamp - relative_ts); + event_add(this->next_command_ev.get(), &tv); + break; + } + } + } +} + + + +} // namespace Episode3 diff --git a/src/Episode3/BattleRecord.hh b/src/Episode3/BattleRecord.hh new file mode 100644 index 00000000..5bfafd22 --- /dev/null +++ b/src/Episode3/BattleRecord.hh @@ -0,0 +1,123 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "../Player.hh" + +struct Lobby; + +namespace Episode3 { + +// The comment in Server.hh does not apply to this file (and BattleRecord.cc). + + + +class BattleRecord { +public: + struct PlayerEntry { + PlayerLobbyDataDCGC lobby_data; + PlayerInventory inventory; + PlayerDispDataDCPCV3 disp; + } __attribute__((packed)); + + struct Event { + enum class Type : uint8_t { + PLAYER_JOIN = 0, + PLAYER_LEAVE = 1, + SET_INITIAL_PLAYERS = 2, + BATTLE_COMMAND = 3, + GAME_COMMAND = 4, + EP3_GAME_COMMAND = 5, + CHAT_MESSAGE = 6, + }; + + // Fields used for all events + Type type; + uint64_t timestamp; + // Fields used for PLAYER_JOIN and SET_INITIAL_PLAYERS only + std::vector players; + // Fields used for PLAYER_LEAVE only + uint8_t leaving_client_id; + // Fields used for CHAT_MESSAGE only + uint32_t guild_card_number; + // Fields used for the COMMAND types and CHAT_MESSAGE + std::string data; + + Event() = default; + explicit Event(StringReader& r); + void serialize(StringWriter& w) const; + }; + + explicit BattleRecord(uint32_t behavior_flags); + explicit BattleRecord(const std::string& data); + std::string serialize() const; + + bool writable() const; + bool battle_in_progress() const; + + const Event* get_first_event() const; + + void add_player( + const PlayerLobbyDataDCGC& lobby_data, + const PlayerInventory& inventory, + const PlayerDispDataDCPCV3& disp); + void delete_player(uint8_t client_id); + void add_command(Event::Type type, const void* data, size_t size); + void add_command(Event::Type type, std::string&& data); + void add_chat_message(uint32_t guild_card_number, std::string&& data); + // This function collapses all the existing player join/leave events into a + // single SET_INITIAL_PLAYERS event, and deletes all events before the latest + // BATTLE_COMMAND command that specifies the battle map. This should provide a + // minimal set of commands to set up and start the battle during a replay. + void set_battle_start_timestamp(); + void set_battle_end_timestamp(); + +private: + static constexpr uint64_t SIGNATURE = 0x14C946D56D1DAC5A; + + static bool is_map_definition_event(const Event& ev); + + bool is_writable; + + uint32_t behavior_flags; + uint64_t battle_start_timestamp; + uint64_t battle_end_timestamp; + std::deque events; + + friend class BattleRecordPlayer; +}; + +class BattleRecordPlayer { +public: + BattleRecordPlayer( + std::shared_ptr rec, + std::shared_ptr base, + std::shared_ptr l); + ~BattleRecordPlayer() = default; + + std::shared_ptr get_record() const; + void start(); + +private: + static void dispatch_schedule_events(evutil_socket_t, short, void* ctx); + void schedule_events(); + + std::shared_ptr record; + std::deque::const_iterator event_it; + uint64_t play_start_timestamp; + std::shared_ptr base; + std::weak_ptr lobby; + std::shared_ptr next_command_ev; +}; + + + + +} // namespace Episode3 diff --git a/src/Episode3/DataIndex.hh b/src/Episode3/DataIndex.hh index 5c33d42d..2b7ee98c 100644 --- a/src/Episode3/DataIndex.hh +++ b/src/Episode3/DataIndex.hh @@ -30,6 +30,8 @@ enum BehaviorFlag { DISABLE_TIME_LIMITS = 0x00000008, ENABLE_STATUS_MESSAGES = 0x00000010, LOAD_CARD_TEXT = 0x00000020, + ENABLE_RECORDING = 0x00000040, + ENABLE_MASKING = 0x00000080, }; diff --git a/src/Episode3/Server.cc b/src/Episode3/Server.cc index 57d0ecc5..965aaf6a 100644 --- a/src/Episode3/Server.cc +++ b/src/Episode3/Server.cc @@ -1,6 +1,7 @@ #include "Server.hh" #include +#include #include "../SendCommands.hh" @@ -146,7 +147,64 @@ void Server::send(const void* data, size_t size) const { if (!l) { throw runtime_error("lobby is deleted"); } + + string masked_data; + if (this->base()->data_index->behavior_flags & BehaviorFlag::ENABLE_MASKING) { + if (size >= 8) { + masked_data.assign(reinterpret_cast(data), size); + uint8_t mask_key = (random_object() % 0xFF) + 1; + set_mask_for_ep3_game_command(masked_data.data(), masked_data.size(), mask_key); + data = masked_data.data(); + size = masked_data.size(); + } + } + send_command(l, 0xC9, 0x00, data, size); + for (auto watcher_l : l->watcher_lobbies) { + send_command(watcher_l, 0xC9, 0x00, data, size); + } + if (l->battle_record && l->battle_record->writable()) { + l->battle_record->add_command( + BattleRecord::Event::Type::BATTLE_COMMAND, data, size); + } +} + +string Server::prepare_6xB6x41_map_definition( + shared_ptr map) { + const auto& compressed = map->compressed(); + + StringWriter w; + uint32_t subcommand_size = (compressed.size() + sizeof(G_MapData_GC_Ep3_6xB6x41) + 3) & (~3); + w.put({{{{0xB6, 0, 0}, subcommand_size}, 0x41, {}}, map->map.map_number.load(), compressed.size(), 0}); + w.write(compressed); + return move(w.str()); +} + +void Server::send_commands_for_joining_spectator(Channel& c) const { + bool should_send_state = true; + if (this->setup_phase == SetupPhase::REGISTRATION) { + // If registration is still in progress, we only need to send the map data + // (if a map is even chosen yet) + if ((this->registration_phase != RegistrationPhase::REGISTERED) && + (this->registration_phase != RegistrationPhase::BATTLE_STARTED)) { + should_send_state = false; + } + } + + if (this->last_chosen_map) { + string data = this->prepare_6xB6x41_map_definition(this->last_chosen_map); + c.send(0x6C, 0x00, data); + } + + if (should_send_state) { + // Note: Some servers send the commented-out commands here. Is there a + // situation where we should send them too? + c.send(0xC9, 0x00, this->prepare_6xB4x07_decks_update()); + c.send(0xC9, 0x00, this->prepare_6xB4x1C_names_update()); + // 6xB4x3B - unknown + c.send(0xC9, 0x00, this->prepare_6xB4x50_trap_tile_locations()); + // 6xB4x52 - unknown + } } __attribute__((format(printf, 2, 3))) @@ -411,7 +469,7 @@ void Server::check_for_destroyed_cards_and_send_6xB4x05_6xB4x02() { this->send_6xB4x02_for_all_players_if_needed(); } -bool Server::check_presence_entry(uint8_t client_id) { +bool Server::check_presence_entry(uint8_t client_id) const { return (client_id < 4) ? this->base()->presence_entries[client_id].player_present : false; } @@ -870,7 +928,7 @@ void Server::move_phase_after() { // randomness per pass. if (this->num_trap_tiles_of_type[trap_type] == 2) { this->chosen_trap_tile_index_of_type[trap_type] ^= 1; - this->send_6xB4x50(); + this->send_6xB4x50_trap_tile_locations(); } else if (this->num_trap_tiles_of_type[trap_type] > 2) { // Generate a new random index, but forbid it from matching the existing // index @@ -879,7 +937,7 @@ void Server::move_phase_after() { new_index++; } this->chosen_trap_tile_index_of_type[trap_type] = new_index; - this->send_6xB4x50(); + this->send_6xB4x50_trap_tile_locations(); } } @@ -898,12 +956,16 @@ void Server::action_phase_before() { } } -void Server::send_6xB4x1C_names_update() { +G_SetPlayerNames_GC_Ep3_6xB4x1C Server::prepare_6xB4x1C_names_update() const { G_SetPlayerNames_GC_Ep3_6xB4x1C cmd; for (size_t z = 0; z < 4; z++) { cmd.entries[z] = this->base()->name_entries[z]; } - this->send(cmd); + return cmd; +} + +void Server::send_6xB4x1C_names_update() { + this->send(this->prepare_6xB4x1C_names_update()); } int8_t Server::send_6xB4x33_remove_ally_atk_if_needed(const ActionState& pa) { @@ -963,7 +1025,7 @@ int8_t Server::send_6xB4x33_remove_ally_atk_if_needed(const ActionState& pa) { return 1; } -void Server::send_all_state_updates() { +G_UpdateDecks_GC_Ep3_6xB4x07 Server::prepare_6xB4x07_decks_update() const { G_UpdateDecks_GC_Ep3_6xB4x07 cmd07; for (size_t z = 0; z < 4; z++) { if (!this->check_presence_entry(z)) { @@ -975,7 +1037,11 @@ void Server::send_all_state_updates() { cmd07.entries[z] = *this->base()->deck_entries[z]; } } - this->send(cmd07); + return cmd07; +} + +void Server::send_all_state_updates() { + this->send(this->prepare_6xB4x07_decks_update()); G_UpdateMap_GC_Ep3_6xB4x05 cmd05; cmd05.state = *this->base()->map_and_rules1; @@ -1299,7 +1365,7 @@ void Server::setup_and_start_battle() { this->send_6xB4x1C_names_update(); this->registration_phase = RegistrationPhase::BATTLE_STARTED; this->update_battle_state_flags_and_send_6xB4x03_if_needed(true); - this->send_6xB4x50(); + this->send_6xB4x50_trap_tile_locations(); G_UpdateMap_GC_Ep3_6xB4x05 cmd05; cmd05.state = *this->base()->map_and_rules1; @@ -1818,6 +1884,13 @@ void Server::handle_6xB3x1D_start_battle(const string& data) { } this->battle_in_progress = false; } else { + auto l = this->base()->lobby.lock(); + if (!l) { + throw runtime_error("lobby is deleted"); + } + if (l->battle_record) { + l->battle_record->set_battle_start_timestamp(); + } this->setup_and_start_battle(); this->battle_in_progress = true; } @@ -2009,6 +2082,11 @@ void Server::handle_6xB3x40_map_list_request(const string& data) { G_MapList_GC_Ep3_6xB6x40{{{{0xB6, 0, 0}, subcommand_size}, 0x40, {}}, list_data.size(), 0}); w.write(list_data); send_command(l, 0x6C, 0x00, w.str()); + + if (l->battle_record && l->battle_record->writable()) { + l->battle_record->add_command( + BattleRecord::Event::Type::BATTLE_COMMAND, move(w.str())); + } } void Server::handle_6xB3x41_map_request(const string& data) { @@ -2021,14 +2099,14 @@ void Server::handle_6xB3x41_map_request(const string& data) { throw runtime_error("lobby is deleted"); } - auto entry = this->base()->data_index->definition_for_map_number(cmd.map_number); - const auto& compressed = entry->compressed(); + this->last_chosen_map = this->base()->data_index->definition_for_map_number(cmd.map_number); + auto out_cmd = this->prepare_6xB6x41_map_definition(this->last_chosen_map); + send_command(l, 0x6C, 0x00, out_cmd); - StringWriter w; - uint32_t subcommand_size = (compressed.size() + sizeof(G_MapData_GC_Ep3_6xB6x41) + 3) & (~3); - w.put({{{{0xB6, 0, 0}, subcommand_size}, 0x41, {}}, entry->map.map_number.load(), compressed.size(), 0}); - w.write(compressed); - send_command(l, 0x6C, 0x00, w.str()); + if (l->battle_record && l->battle_record->writable()) { + l->battle_record->add_command( + BattleRecord::Event::Type::BATTLE_COMMAND, move(out_cmd)); + } } void Server::handle_6xB3x48_end_turn(const string& data) { @@ -2509,7 +2587,7 @@ void Server::send_6xB4x02_for_all_players_if_needed(bool always_send) { } } -void Server::send_6xB4x50() const { +G_SetTrapTileLocations_GC_Ep3_6xB4x50 Server::prepare_6xB4x50_trap_tile_locations() const { G_SetTrapTileLocations_GC_Ep3_6xB4x50 cmd; for (size_t trap_type = 0; trap_type < 5; trap_type++) { uint8_t trap_index = this->chosen_trap_tile_index_of_type[trap_type]; @@ -2519,7 +2597,11 @@ void Server::send_6xB4x50() const { cmd.locations[trap_type].clear(0xFF); } } - this->send(cmd); + return cmd; +} + +void Server::send_6xB4x50_trap_tile_locations() const { + this->send(this->prepare_6xB4x50_trap_tile_locations()); } diff --git a/src/Episode3/Server.hh b/src/Episode3/Server.hh index 647cc7a3..d5977060 100644 --- a/src/Episode3/Server.hh +++ b/src/Episode3/Server.hh @@ -6,6 +6,7 @@ #include "../Text.hh" #include "../CommandFormats.hh" +#include "../Channel.hh" #include "AssistServer.hh" #include "CardSpecial.hh" #include "MapState.hh" @@ -109,6 +110,8 @@ public: } void send(const void* data, size_t size) const; + void send_commands_for_joining_spectator(Channel& ch) const; + __attribute__((format(printf, 2, 3))) void send_debug_message_printf(const char* fmt, ...) const; __attribute__((format(printf, 2, 3))) @@ -131,7 +134,7 @@ public: bool card_ref_is_empty_or_has_valid_card_id(uint16_t card_ref) const; bool check_for_battle_end(); void check_for_destroyed_cards_and_send_6xB4x05_6xB4x02(); - bool check_presence_entry(uint8_t client_id); + bool check_presence_entry(uint8_t client_id) const; void clear_player_flags_after_dice_phase(); void compute_all_map_occupied_bits(); void compute_team_dice_boost(uint8_t team_id); @@ -213,7 +216,13 @@ public: void send_6xB4x39() const; void send_6xB4x05(); // Recomputes the map occupied bits, so can't be const void send_6xB4x02_for_all_players_if_needed(bool always_send = false); - void send_6xB4x50() const; + void send_6xB4x50_trap_tile_locations() const; + + G_UpdateDecks_GC_Ep3_6xB4x07 prepare_6xB4x07_decks_update() const; + G_SetPlayerNames_GC_Ep3_6xB4x1C prepare_6xB4x1C_names_update() const; + static std::string prepare_6xB6x41_map_definition( + std::shared_ptr map); + G_SetTrapTileLocations_GC_Ep3_6xB4x50 prepare_6xB4x50_trap_tile_locations() const; std::vector> const_cast_set_cards_v( const std::vector>& cards); @@ -222,6 +231,7 @@ private: static const std::unordered_map subcommand_handlers; std::weak_ptr w_base; + std::shared_ptr last_chosen_map; public: uint32_t battle_finished; diff --git a/src/Lobby.cc b/src/Lobby.cc index c422e070..68d0ed8c 100644 --- a/src/Lobby.cc +++ b/src/Lobby.cc @@ -73,18 +73,19 @@ size_t Lobby::count_clients() const { void Lobby::add_client(shared_ptr c) { ssize_t index; + ssize_t min_client_id = (this->flags & Lobby::Flag::IS_SPECTATOR_TEAM) ? 4 : 0; if (c->prefer_high_lobby_client_id) { - for (index = max_clients - 1; index >= 0; index--) { + for (index = max_clients - 1; index >= min_client_id; index--) { if (!this->clients[index].get()) { this->clients[index] = c; break; } } - if (index < 0) { + if (index < min_client_id) { throw out_of_range("no space left in lobby"); } } else { - for (index = 0; index < max_clients; index++) { + for (index = min_client_id; index < max_clients; index++) { if (!this->clients[index].get()) { this->clients[index] = c; break; @@ -120,6 +121,17 @@ void Lobby::add_client(shared_ptr c) { } c->game_data.player()->print_inventory(stderr); } + + // If the lobby is recording a battle record, add the player join event + if (this->battle_record) { + PlayerLobbyDataDCGC lobby_data; + lobby_data.player_tag = 0x00010000; + lobby_data.guild_card = c->license->serial_number; + lobby_data.name = encode_sjis(c->game_data.player()->disp.name); + this->battle_record->add_player(lobby_data, + c->game_data.player()->inventory, + c->game_data.player()->disp.to_dcpcv3()); + } } void Lobby::remove_client(shared_ptr c) { @@ -141,6 +153,11 @@ void Lobby::remove_client(shared_ptr c) { } this->reassign_leader_on_client_departure(c->lobby_client_id); + + // If the lobby ios recording a battle record, add the player leave event + if (this->battle_record) { + this->battle_record->delete_player(c->lobby_client_id); + } } void Lobby::move_client_to_lobby(shared_ptr dest_lobby, diff --git a/src/Lobby.hh b/src/Lobby.hh index 800886b3..e27360f5 100644 --- a/src/Lobby.hh +++ b/src/Lobby.hh @@ -17,6 +17,7 @@ #include "Text.hh" #include "Quest.hh" #include "Items.hh" +#include "Episode3/BattleRecord.hh" #include "Episode3/Server.hh" struct Lobby { @@ -24,20 +25,23 @@ struct Lobby { GAME = 0x00000001, EPISODE_3_ONLY = 0x00000002, NON_V1_ONLY = 0x00000004, // DC NTE and DCv1 not allowed + PERSISTENT = 0x00000008, // Flags used only for games CHEATS_ENABLED = 0x00000100, QUEST_IN_PROGRESS = 0x00000200, - JOINABLE_QUEST_IN_PROGRESS = 0x00000400, - ITEM_TRACKING_ENABLED = 0x00000800, - BATTLE_MODE = 0x00002000, - CHALLENGE_MODE = 0x00004000, - SOLO_MODE = 0x00008000, + BATTLE_IN_PROGRESS = 0x00000400, + JOINABLE_QUEST_IN_PROGRESS = 0x00000800, + ITEM_TRACKING_ENABLED = 0x00001000, + IS_SPECTATOR_TEAM = 0x00002000, // EPISODE_3_ONLY must also be set + SPECTATORS_FORBIDDEN = 0x00004000, + BATTLE_MODE = 0x00008000, + CHALLENGE_MODE = 0x00010000, + SOLO_MODE = 0x00020000, // Flags used only for lobbies - PUBLIC = 0x00010000, - DEFAULT = 0x00020000, - PERSISTENT = 0x00040000, + PUBLIC = 0x01000000, + DEFAULT = 0x02000000, }; PrefixedLogger log; @@ -47,7 +51,7 @@ struct Lobby { uint32_t min_level; uint32_t max_level; - // item info + // Item info struct FloorItem { PlayerInventoryItem inv_item; float x; @@ -61,7 +65,7 @@ struct Lobby { std::unordered_map item_id_to_floor_item; parray variations; - // game config + // Game config GameVersion version; uint8_t section_id; uint8_t episode; // 1 = Ep1, 2 = Ep2, 3 = Ep4, 0xFF = Ep3 @@ -72,9 +76,26 @@ struct Lobby { uint32_t random_seed; std::shared_ptr random; std::shared_ptr common_item_creator; - std::shared_ptr ep3_server_base; - // lobby stuff + // Ep3 stuff + // There are three kinds of Episode 3 games. All of these types have the flag + // EPISODE_3_ONLY; types 2 and 3 additionally have the IS_SPECTATOR_TEAM flag. + // 1. Primary games. These are the lobbies where battles may take place. + // 2. Watcher games. These lobbies receive all the battle and chat commands + // from a primary game. (This the implementation of spectator teams.) + // 3. Replay games. These lobbies replay a sequence of battle commands and + // chat commands from a previous primary game. + // Types 2 and 3 may be distinguished by the presence of the battle_record + // field - in replay games, it will be present; in watcher games it will be + // absent. + std::shared_ptr ep3_server_base; // Only used in primary games + std::weak_ptr watched_lobby; // Only used in watcher games + std::unordered_set> watcher_lobbies; // Only used in primary games + std::shared_ptr battle_record; // Not used in watcher games + std::shared_ptr prev_battle_record; // Only used in primary games + std::shared_ptr battle_player; // Only used in replay games + + // Lobby stuff uint8_t event; uint8_t block; uint8_t type; // number to give to PSO for the lobby number diff --git a/src/Player.cc b/src/Player.cc index 391685e8..da028db3 100644 --- a/src/Player.cc +++ b/src/Player.cc @@ -593,34 +593,6 @@ PlayerBB ClientGameData::export_player_bb() { -XBNetworkLocation::XBNetworkLocation() noexcept - : internal_ipv4_address(0x0A0A0A0A), - external_ipv4_address(0x23232323), - port(9100), - account_id(0xFFFFFFFFFFFFFFFF) { - this->unknown_a1[0] = 0xCCCCCCCC; - this->unknown_a1[1] = 0xDDDDDDDD; - this->mac_address.clear(0x77); -} - -// There's a strange behavior (bug? "feature"?) in Episode 3 where the start -// button does nothing in the lobby (hence you can't "quit game") if the -// client's IP address is zero. So, we fill it in with a fake nonzero value to -// avoid this behavior, and to be consistent, we make IP addresses fake and -// nonzero on all other versions too. - -PlayerLobbyDataPC::PlayerLobbyDataPC() noexcept - : player_tag(0), guild_card(0), ip_address(0x7F000001), client_id(0) { } - -PlayerLobbyDataDCGC::PlayerLobbyDataDCGC() noexcept - : player_tag(0), guild_card(0), ip_address(0x7F000001), client_id(0) { } - -PlayerLobbyDataXB::PlayerLobbyDataXB() noexcept - : player_tag(0), guild_card(0), client_id(0) { } - -PlayerLobbyDataBB::PlayerLobbyDataBB() noexcept - : player_tag(0), guild_card(0), ip_address(0x7F000001), client_id(0), unknown_a2(0) { } - void PlayerLobbyDataPC::clear() { this->player_tag = 0; this->guild_card = 0; diff --git a/src/Player.hh b/src/Player.hh index 59ff9b27..d7ab0389 100644 --- a/src/Player.hh +++ b/src/Player.hh @@ -284,61 +284,63 @@ struct KeyAndTeamConfigBB { struct PlayerLobbyDataPC { - le_uint32_t player_tag; - le_uint32_t guild_card; - be_uint32_t ip_address; - le_uint32_t client_id; + le_uint32_t player_tag = 0; + le_uint32_t guild_card = 0; + // There's a strange behavior (bug? "feature"?) in Episode 3 where the start + // button does nothing in the lobby (hence you can't "quit game") if the + // client's IP address is zero. So, we fill it in with a fake nonzero value to + // avoid this behavior, and to be consistent, we make IP addresses fake and + // nonzero on all other versions too. + be_uint32_t ip_address = 0x7F000001; + le_uint32_t client_id = 0; ptext name; - PlayerLobbyDataPC() noexcept; void clear(); } __attribute__((packed)); struct PlayerLobbyDataDCGC { - le_uint32_t player_tag; - le_uint32_t guild_card; - be_uint32_t ip_address; - le_uint32_t client_id; + le_uint32_t player_tag = 0; + le_uint32_t guild_card = 0; + be_uint32_t ip_address = 0x7F000001; + le_uint32_t client_id = 0; ptext name; - PlayerLobbyDataDCGC() noexcept; void clear(); } __attribute__((packed)); struct XBNetworkLocation { - le_uint32_t internal_ipv4_address; - le_uint32_t external_ipv4_address; - le_uint16_t port; - parray mac_address; + le_uint32_t internal_ipv4_address = 0x0A0A0A0A; + le_uint32_t external_ipv4_address = 0x23232323; + le_uint16_t port = 9100; + parray mac_address = 0x77; parray unknown_a1; - le_uint64_t account_id; + le_uint64_t account_id = 0xFFFFFFFFFFFFFFFF; parray unknown_a2; - XBNetworkLocation() noexcept; void clear(); } __attribute__((packed)); struct PlayerLobbyDataXB { - le_uint32_t player_tag; - le_uint32_t guild_card; + le_uint32_t player_tag = 0; + le_uint32_t guild_card = 0; XBNetworkLocation netloc; - le_uint32_t client_id; + le_uint32_t client_id = 0; ptext name; - PlayerLobbyDataXB() noexcept; void clear(); } __attribute__((packed)); struct PlayerLobbyDataBB { - le_uint32_t player_tag; - le_uint32_t guild_card; - be_uint32_t ip_address; // Guess - the official builds didn't use this, but all other versions have it + le_uint32_t player_tag = 0; + le_uint32_t guild_card = 0; + // This field is a guess; the official builds didn't use this, but all other + // versions have it + be_uint32_t ip_address = 0x7F000001; parray unknown_a1; - le_uint32_t client_id; + le_uint32_t client_id = 0; ptext name; - le_uint32_t unknown_a2; + le_uint32_t unknown_a2 = 0; - PlayerLobbyDataBB() noexcept; void clear(); } __attribute__((packed)); diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 600928be..0fdcfae8 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -892,11 +892,15 @@ static void on_ep3_battle_table_confirm(shared_ptr s, } } -static void on_ep3_counter_state(shared_ptr, shared_ptr c, +static void on_ep3_counter_state(shared_ptr s, shared_ptr c, uint16_t, uint32_t flag, const string& data) { // DC check_size_v(data.size(), 0); + auto l = s->find_lobby(c->lobby_id); if (flag != 0) { send_command(c, 0xDC, 0x00); + l->flags |= Lobby::Flag::BATTLE_IN_PROGRESS; + } else { + l->flags &= ~Lobby::Flag::BATTLE_IN_PROGRESS; } } @@ -932,6 +936,29 @@ static void on_ep3_server_data_request(shared_ptr s, shared_ptrrandom_seed); } + + if (s->ep3_behavior_flags & Episode3::BehaviorFlag::ENABLE_RECORDING) { + if (l->battle_record) { + l->prev_battle_record = l->battle_record; + l->prev_battle_record->set_battle_end_timestamp(); + } + l->battle_record.reset(new Episode3::BattleRecord(s->ep3_behavior_flags)); + for (auto existing_c : l->clients) { + if (existing_c) { + PlayerLobbyDataDCGC lobby_data; + lobby_data.name = encode_sjis(existing_c->game_data.player()->disp.name); + lobby_data.player_tag = 0x00010000; + lobby_data.guild_card = existing_c->license->serial_number; + l->battle_record->add_player(lobby_data, + existing_c->game_data.player()->inventory, + existing_c->game_data.player()->disp.to_dcpcv3()); + } + } + if (l->prev_battle_record) { + send_text_message(l, u"$C6Recording complete"); + } + send_text_message(l, u"$C6Recording enabled"); + } } l->ep3_server_base->server->on_server_data_input(data); } @@ -1104,6 +1131,12 @@ static void on_menu_item_info_request(shared_ptr s, shared_ptrflags & Lobby::Flag::QUEST_IN_PROGRESS) { info += "$C4Quest in progress"; + } else if (game->flags & Lobby::Flag::BATTLE_IN_PROGRESS) { + info += "$C4Battle in progress"; + } + + if (game->flags & Lobby::Flag::SPECTATORS_FORBIDDEN) { + info += "$C4View Battle forbidden"; } send_ship_info(c, decode_sjis(info)); @@ -1350,6 +1383,10 @@ static void on_menu_selection(shared_ptr s, shared_ptr c, send_lobby_message_box(c, u"$C6You cannot join this\ngame because a\nquest is already\nin progress."); break; } + if (game->flags & Lobby::Flag::BATTLE_IN_PROGRESS) { + send_lobby_message_box(c, u"$C6You cannot join this\ngame because a\nbattle is already\nin progress."); + break; + } if (game->any_client_loading()) { send_lobby_message_box(c, u"$C6You cannot join this\ngame because\nanother player is\ncurrently loading.\nTry again soon."); break; @@ -1927,6 +1964,14 @@ static void on_chat_generic(shared_ptr s, shared_ptr c, c->game_data.player()->disp.name.data(), processed_text.c_str(), private_flags); } + + if (l->battle_record && l->battle_record->battle_in_progress()) { + auto prepared_message = prepare_chat_message( + c->version(), c->game_data.player()->disp.name.data(), + processed_text.c_str(), private_flags); + string prepared_message_sjis = encode_sjis(prepared_message); + l->battle_record->add_chat_message(c->license->serial_number, move(prepared_message_sjis)); + } } static void on_chat_pc_bb(shared_ptr s, shared_ptr c, @@ -2445,6 +2490,10 @@ static shared_ptr create_game_generic(shared_ptr s, shared_ptr game = s->create_lobby(); game->name = name; + game->flags = flags | + Lobby::Flag::GAME | + (is_ep3 ? Lobby::Flag::EPISODE_3_ONLY : 0) | + (item_tracking_enabled ? Lobby::Flag::ITEM_TRACKING_ENABLED : 0); game->password = password; game->version = c->version(); game->section_id = c->override_section_id >= 0 @@ -2455,16 +2504,17 @@ static shared_ptr create_game_generic(shared_ptr s, game->random_seed = c->override_random_seed; game->random->seed(game->random_seed); } + if (c->next_game_battle_record) { + game->battle_player.reset(new Episode3::BattleRecordPlayer( + c->next_game_battle_record, s->game_server->get_base(), game)); + c->next_game_battle_record.reset(); + game->flags |= Lobby::Flag::IS_SPECTATOR_TEAM; + } game->common_item_creator.reset(new CommonItemCreator( s->common_item_data, game->random)); game->event = Lobby::game_event_for_lobby_event(current_lobby->event); game->block = 0xFF; - game->max_clients = 4; - game->flags = - (is_ep3 ? Lobby::Flag::EPISODE_3_ONLY : 0) | - (item_tracking_enabled ? Lobby::Flag::ITEM_TRACKING_ENABLED : 0) | - Lobby::Flag::GAME | - flags; + game->max_clients = (game->flags & Lobby::Flag::IS_SPECTATOR_TEAM) ? 12 : 4; game->min_level = min_level; game->max_level = 0xFFFFFFFF; @@ -2555,8 +2605,8 @@ static void on_create_game_dc_v3(shared_ptr s, shared_ptr c const auto& cmd = check_size_t(data); // Only allow EC from Ep3 clients - bool client_is_ep3 = c->flags & Client::Flag::IS_EPISODE_3; - if ((command == 0xEC) && !client_is_ep3) { + bool client_is_ep3 = !!(c->flags & Client::Flag::IS_EPISODE_3); + if ((command == 0xEC) != client_is_ep3) { return; } @@ -2581,7 +2631,11 @@ static void on_create_game_dc_v3(shared_ptr s, shared_ptr c flags |= Lobby::Flag::BATTLE_MODE; } if (cmd.challenge_mode) { - flags |= Lobby::Flag::CHALLENGE_MODE; + if (client_is_ep3) { + flags |= Lobby::Flag::SPECTATORS_FORBIDDEN; + } else { + flags |= Lobby::Flag::CHALLENGE_MODE; + } } create_game_generic( s, c, name.c_str(), password.c_str(), episode, cmd.difficulty, flags); diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index 56b60d56..d2bc7579 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -97,6 +97,17 @@ static void forward_subcommand(shared_ptr l, shared_ptr c, } else { send_command_excluding_client(l, c, command, flag, data, size); } + + for (const auto& watcher_lobby : l->watcher_lobbies) { + forward_subcommand(watcher_lobby, c, command, flag, data, size); + } + + if (l->battle_record && l->battle_record->battle_in_progress()) { + auto type = ((command & 0xF0) == 0xC0) + ? Episode3::BattleRecord::Event::Type::EP3_GAME_COMMAND + : Episode3::BattleRecord::Event::Type::GAME_COMMAND; + l->battle_record->add_command(type, data, size); + } } } diff --git a/src/SendCommands.cc b/src/SendCommands.cc index 72f5ac62..ed986366 100644 --- a/src/SendCommands.cc +++ b/src/SendCommands.cc @@ -694,14 +694,13 @@ void send_text_message(shared_ptr s, const u16string& text) { } } -void send_chat_message(Channel& ch, const u16string& text) { - send_header_text(ch, 0x06, 0, text, false); -} - -void send_chat_message(shared_ptr c, uint32_t from_guild_card_number, - const u16string& from_name, const u16string& text, char private_flags) { +u16string prepare_chat_message( + GameVersion version, + const u16string& from_name, + const u16string& text, + char private_flags) { u16string data; - if (c->version() == GameVersion::BB) { + if (version == GameVersion::BB) { data.append(u"\tJ"); } data.append(remove_language_marker(from_name)); @@ -711,7 +710,31 @@ void send_chat_message(shared_ptr c, uint32_t from_guild_card_number, } data.append(u"\tJ"); data.append(text); - send_header_text(c->channel, 0x06, from_guild_card_number, data, false); + return data; +} + +void send_chat_message(Channel& ch, const u16string& text) { + send_header_text(ch, 0x06, 0, text, false); +} + +void send_chat_message(shared_ptr c, uint32_t from_guild_card_number, + const u16string& prepared_data) { + send_header_text(c->channel, 0x06, from_guild_card_number, prepared_data, false); +} + +void send_chat_message(shared_ptr l, uint32_t from_guild_card_number, + const u16string& prepared_data) { + for (auto c : l->clients) { + if (c) { + send_header_text(c->channel, 0x06, from_guild_card_number, prepared_data, false); + } + } +} + +void send_chat_message(shared_ptr c, uint32_t from_guild_card_number, + const u16string& from_name, const u16string& text, char private_flags) { + auto data = prepare_chat_message(c->version(), from_name, text, private_flags); + send_chat_message(c, from_guild_card_number, data); } template @@ -1071,7 +1094,7 @@ void send_game_menu_t(shared_ptr c, shared_ptr s) { e.episode = ((c->version() == GameVersion::BB) ? (l->max_clients << 4) : 0) | l->episode; } if (l->flags & Lobby::Flag::EPISODE_3_ONLY) { - e.flags = (l->password.empty() ? 0 : 2); + e.flags = (l->password.empty() ? 0 : 2) | ((l->flags & Lobby::Flag::BATTLE_IN_PROGRESS) ? 4 : 0); } else { e.flags = ((l->episode << 6) | (l->password.empty() ? 0 : 2)); if (l->flags & Lobby::Flag::BATTLE_MODE) { @@ -1210,8 +1233,114 @@ void send_lobby_list(shared_ptr c, shared_ptr s) { //////////////////////////////////////////////////////////////////////////////// // lobby joining +static void send_join_spectator_team(shared_ptr c, shared_ptr l) { + if (!(c->flags & Client::Flag::IS_EPISODE_3)) { + throw runtime_error("lobby is not Episode 3"); + } + if (!(l->flags & Lobby::Flag::EPISODE_3_ONLY)) { + throw runtime_error("lobby is not Episode 3"); + } + if (!(l->flags & Lobby::Flag::IS_SPECTATOR_TEAM)) { + throw runtime_error("lobby is not a spectator team"); + } + + S_JoinSpectatorTeam_GC_Ep3_E8 cmd; + + cmd.variations.clear(0); + cmd.client_id = c->lobby_client_id; + cmd.leader_id = l->leader_id; + cmd.event = l->event; + cmd.section_id = l->section_id; + cmd.rare_seed = l->random_seed; + cmd.episode = 3; + + uint8_t player_count = 0; + auto watched_lobby = l->watched_lobby.lock(); + if (watched_lobby) { + // Live spectating + for (size_t z = 0; z < 4; z++) { + if (watched_lobby->clients[z]) { + cmd.players[z].lobby_data.player_tag = 0x00010000; + cmd.players[z].lobby_data.guild_card = watched_lobby->clients[z]->license->serial_number; + cmd.players[z].lobby_data.client_id = watched_lobby->clients[z]->lobby_client_id; + cmd.players[z].lobby_data.name = watched_lobby->clients[z]->game_data.player()->disp.name; + remove_language_marker_inplace(cmd.players[z].lobby_data.name); + cmd.players[z].inventory = watched_lobby->clients[z]->game_data.player()->inventory; + cmd.players[z].disp = watched_lobby->clients[z]->game_data.player()->disp.to_dcpcv3(); + remove_language_marker_inplace(cmd.players[z].disp.name); + cmd.entries[z].player_tag = 0x00010000; + cmd.entries[z].guild_card_number = watched_lobby->clients[z]->license->serial_number; + cmd.entries[z].name = watched_lobby->clients[z]->game_data.player()->disp.name; + remove_language_marker_inplace(cmd.entries[z].name); + cmd.entries[z].present = 1; + cmd.entries[z].level = watched_lobby->clients[z]->game_data.player()->disp.level.load(); + player_count++; + } + } + + } else if (l->battle_player) { + // Battle record replay + const auto* ev = l->battle_player->get_record()->get_first_event(); + if (!ev) { + throw runtime_error("battle record contains no events"); + } + if (ev->type != Episode3::BattleRecord::Event::Type::SET_INITIAL_PLAYERS) { + throw runtime_error("battle record does not begin with set players event"); + } + for (const auto& entry : ev->players) { + uint8_t client_id = entry.lobby_data.client_id; + if (client_id >= 4) { + throw runtime_error("invalid client id in battle record"); + } + cmd.players[client_id].lobby_data = entry.lobby_data; + remove_language_marker_inplace(cmd.players[client_id].lobby_data.name); + cmd.players[client_id].inventory = entry.inventory; + cmd.players[client_id].disp = entry.disp; + remove_language_marker_inplace(cmd.players[client_id].disp.name); + cmd.entries[client_id].player_tag = 0x00010000; + cmd.entries[client_id].guild_card_number = entry.lobby_data.guild_card; + cmd.entries[client_id].name = entry.disp.name; + remove_language_marker_inplace(cmd.entries[client_id].name); + cmd.entries[client_id].present = 1; + cmd.entries[client_id].level = entry.disp.level.load(); + player_count++; + } + + } else { + throw runtime_error("neither a watched lobby nor a battle player are present"); + } + + for (size_t z = 4; z < 12; z++) { + if (l->clients[z]) { + cmd.spectator_players[z - 4].lobby_data.player_tag = 0x00010000; + cmd.spectator_players[z - 4].lobby_data.guild_card = l->clients[z]->license->serial_number; + cmd.spectator_players[z - 4].lobby_data.client_id = l->clients[z]->lobby_client_id; + cmd.spectator_players[z - 4].lobby_data.name = l->clients[z]->game_data.player()->disp.name; + remove_language_marker_inplace(cmd.spectator_players[z - 4].lobby_data.name); + cmd.spectator_players[z - 4].inventory = l->clients[z]->game_data.player()->inventory; + cmd.spectator_players[z - 4].disp = l->clients[z]->game_data.player()->disp.to_dcpcv3(); + remove_language_marker_inplace(cmd.spectator_players[z - 4].disp.name); + cmd.entries[z].player_tag = 0x00010000; + cmd.entries[z].guild_card_number = l->clients[z]->license->serial_number; + cmd.entries[z].name = l->clients[z]->game_data.player()->disp.name; + remove_language_marker_inplace(cmd.entries[z].name); + cmd.entries[z].present = 1; + cmd.entries[z].level = l->clients[z]->game_data.player()->disp.level.load(); + player_count++; + } + } + cmd.spectator_team_name = encode_sjis(l->name); + + send_command_t(c, 0xE8, player_count, cmd); +} + template void send_join_game_t(shared_ptr c, shared_ptr l) { + if (l->flags & Lobby::Flag::IS_SPECTATOR_TEAM) { + send_join_spectator_team(c, l); + return; + } + bool is_ep3 = (l->flags & Lobby::Flag::EPISODE_3_ONLY); string data(is_ep3 ? sizeof(S_JoinGame_GC_Ep3_64) : sizeof(S_JoinGame), '\0'); @@ -1233,7 +1362,7 @@ void send_join_game_t(shared_ptr c, shared_ptr l) { if (l->clients[x]) { cmd->lobby_data[x].player_tag = 0x00010000; cmd->lobby_data[x].guild_card = l->clients[x]->license->serial_number; - cmd->lobby_data[x].client_id = c->lobby_client_id; + cmd->lobby_data[x].client_id = l->clients[x]->lobby_client_id; cmd->lobby_data[x].name = l->clients[x]->game_data.player()->disp.name; if (cmd_ep3) { cmd_ep3->players_ep3[x].inventory = l->clients[x]->game_data.player()->inventory; @@ -1269,7 +1398,7 @@ void send_join_lobby_t(shared_ptr c, shared_ptr l, uint8_t command; if (l->is_game()) { if (joining_client) { - command = 0x65; + command = (l->flags & Lobby::Flag::IS_SPECTATOR_TEAM) ? 0xEB : 0x65; } else { throw logic_error("send_join_lobby_t should not be used for primary game join command"); } @@ -1405,7 +1534,16 @@ void send_player_join_notification(shared_ptr c, void send_player_leave_notification(shared_ptr l, uint8_t leaving_client_id) { S_LeaveLobby_66_69_Ep3_E9 cmd = {leaving_client_id, l->leader_id, 1, 0}; - send_command_t(l, l->is_game() ? 0x66 : 0x69, leaving_client_id, cmd); + uint8_t cmd_num; + if (l->is_game()) { + cmd_num = (l->flags & Lobby::Flag::IS_SPECTATOR_TEAM) ? 0xE9 : 0x66; + } else { + cmd_num = 0x69; + } + send_command_t(l, cmd_num, leaving_client_id, cmd); + for (const auto& watcher_l : l->watcher_lobbies) { + send_command_t(watcher_l, cmd_num, leaving_client_id, cmd); + } } void send_self_leave_notification(shared_ptr c) { diff --git a/src/SendCommands.hh b/src/SendCommands.hh index 63fca0d5..d566d059 100644 --- a/src/SendCommands.hh +++ b/src/SendCommands.hh @@ -173,11 +173,32 @@ void send_text_message(Channel& ch, const std::u16string& text); void send_text_message(std::shared_ptr c, const std::u16string& text); void send_text_message(std::shared_ptr l, const std::u16string& text); void send_text_message(std::shared_ptr l, const std::u16string& text); + +std::u16string prepare_chat_message( + GameVersion version, + const std::u16string& from_name, + const std::u16string& text, + char private_flags); void send_chat_message(Channel& ch, const std::u16string& text); -void send_chat_message(std::shared_ptr c, uint32_t from_serial_number, - const std::u16string& from_name, const std::u16string& text, char private_flags); -void send_simple_mail(std::shared_ptr c, uint32_t from_serial_number, - const std::u16string& from_name, const std::u16string& text); +void send_chat_message( + std::shared_ptr c, + uint32_t from_guild_card_number, + const std::u16string& prepared_data); +void send_chat_message( + std::shared_ptr l, + uint32_t from_guild_card_number, + const std::u16string& prepared_data); +void send_chat_message( + std::shared_ptr c, + uint32_t from_guild_card_number, + const std::u16string& from_name, + const u16string& text, + char private_flags); +void send_simple_mail( + std::shared_ptr c, + uint32_t from_serial_number, + const std::u16string& from_name, + const std::u16string& text); template __attribute__((format(printf, 2, 3))) void send_text_message_printf( diff --git a/src/Server.cc b/src/Server.cc index 86b6dfc9..b564e022 100644 --- a/src/Server.cc +++ b/src/Server.cc @@ -234,3 +234,7 @@ shared_ptr Server::get_client() const { } return this->channel_to_client.begin()->second; } + +shared_ptr Server::get_base() const { + return this->base; +} diff --git a/src/Server.hh b/src/Server.hh index e6a7467d..6bf8a5bc 100644 --- a/src/Server.hh +++ b/src/Server.hh @@ -31,6 +31,7 @@ public: GameVersion version, ServerBehavior initial_state); std::shared_ptr get_client() const; + std::shared_ptr get_base() const; private: std::shared_ptr base; diff --git a/src/ServerState.cc b/src/ServerState.cc index 434ac3ac..9f001696 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -156,6 +156,14 @@ void ServerState::send_lobby_join_notifications(shared_ptr l, send_player_join_notification(other_client, l, joining_client); } } + for (auto& watcher_l : l->watcher_lobbies) { + for (auto& watcher_c : watcher_l->clients) { + if (!watcher_c) { + continue; + } + send_player_join_notification(watcher_c, watcher_l, joining_client); + } + } } shared_ptr ServerState::find_lobby(uint32_t lobby_id) { @@ -184,7 +192,33 @@ void ServerState::remove_lobby(uint32_t lobby_id) { if (lobby_it == this->id_to_lobby.end()) { throw logic_error("attempted to remove nonexistent lobby"); } - lobby_it->second->log.info("Deleted lobby"); + + auto l = lobby_it->second; + if (l->count_clients() != 0) { + throw logic_error("attempted to delete lobby with clients in it"); + } + + if (l->flags & Lobby::Flag::IS_SPECTATOR_TEAM) { + auto primary_l = l->watched_lobby.lock(); + if (primary_l) { + primary_l->log.info("Unlinking watcher lobby %" PRIX32, l->lobby_id); + primary_l->watcher_lobbies.erase(l); + } else { + l->log.info("Watched lobby is missing"); + } + l->watched_lobby.reset(); + } else { + // Tell all players in all spectator teams to go back to the lobby + for (auto watcher_l : l->watcher_lobbies) { + if (!(watcher_l->flags & Lobby::Flag::EPISODE_3_ONLY)) { + throw logic_error("spectator team is not an Episode 3 lobby"); + } + l->log.info("Disbanding watcher lobby %" PRIX32, watcher_l->lobby_id); + send_command(watcher_l, 0xED, 0x00); + } + } + + l->log.info("Deleted lobby"); this->id_to_lobby.erase(lobby_it); } diff --git a/system/config.example.json b/system/config.example.json index ae80dfd0..0bbea883 100644 --- a/system/config.example.json +++ b/system/config.example.json @@ -262,6 +262,9 @@ // 0x00000020 => Load card text as well as card definitions (has no behavioral // effects; this flag exists to be used internally when the // --show-ep3-data option is given) + // 0x00000040 => Enable battle recording (after a battle, players can save the + // recording with the $saverec command) + // 0x00000080 => Enable command masking during battles "Episode3BehaviorFlags": 0x00000002, // Episode 3 card auction configuration. CardAuctionPoints specifies how many