diff --git a/.gitignore b/.gitignore index 597f9a43..d95a9c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ build # Files modified by the user and/or server that don't have defaults system/config.json +system/ep3/battle-records/*.mzr system/ep3/battle-records/*.mzrd system/ep3/tournament-state.json system/licenses.nsi diff --git a/src/ChatCommands.cc b/src/ChatCommands.cc index c096146b..20c3cd1d 100644 --- a/src/ChatCommands.cc +++ b/src/ChatCommands.cc @@ -1882,13 +1882,13 @@ ChatCommandDefinition cc_ping( co_return; }); -static string file_path_for_recording(const std::string& args, uint32_t account_id) { +static string file_path_for_recording(const std::string& args, uint32_t account_id, bool compressed) { for (char ch : args) { if (ch <= 0x20 || ch > 0x7E || ch == '/') { throw runtime_error("invalid recording name"); } } - return std::format("system/ep3/battle-records/{:010}_{}.mzrd", account_id, args); + return std::format("system/ep3/battle-records/{:010}_{}.mzr{}", account_id, args, compressed ? "" : "d"); } ChatCommandDefinition cc_playrec( @@ -1908,13 +1908,16 @@ ChatCommandDefinition cc_playrec( if (!start_battle_player_immediately) { filename = filename.substr(1); } - string file_path = file_path_for_recording(filename, a.c->login->account->account_id); string data; try { - data = phosg::load_file(file_path); + data = phosg::load_file(file_path_for_recording(filename, a.c->login->account->account_id, false)); } catch (const phosg::cannot_open_file&) { - throw precondition_failed("$C4The recording does\nnot exist"); + try { + data = prs_decompress(phosg::load_file(file_path_for_recording(filename, a.c->login->account->account_id, true))); + } catch (const phosg::cannot_open_file&) { + throw precondition_failed("$C4The recording does\nnot exist"); + } } auto record = make_shared(data); auto battle_player = make_shared(s->io_context, record); @@ -2288,7 +2291,7 @@ ChatCommandDefinition cc_saverec( if (!a.c->ep3_prev_battle_record) { throw precondition_failed("$C4No finished\nrecording is\npresent"); } - string file_path = file_path_for_recording(a.text, a.c->login->account->account_id); + string file_path = file_path_for_recording(a.text, a.c->login->account->account_id, false); string data = a.c->ep3_prev_battle_record->serialize(); phosg::save_file(file_path, data); send_text_message(a.c, "$C7Recording saved"); diff --git a/src/Episode3/BattleRecord.cc b/src/Episode3/BattleRecord.cc index ce77edb7..c0b5d9f6 100644 --- a/src/Episode3/BattleRecord.cc +++ b/src/Episode3/BattleRecord.cc @@ -174,21 +174,6 @@ string BattleRecord::serialize() const { return std::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, @@ -256,16 +241,6 @@ void BattleRecord::add_random_data(const void* data, size_t size) { this->random_stream.append(reinterpret_cast(data), size); } -vector BattleRecord::get_all_server_data_commands() const { - vector ret; - for (const auto& event : this->events) { - if (event.type == Event::Type::SERVER_DATA_COMMAND) { - ret.emplace_back(event.data); - } - } - return ret; -} - const string& BattleRecord::get_random_stream() const { return this->random_stream; } diff --git a/src/Episode3/BattleRecord.hh b/src/Episode3/BattleRecord.hh index 1296c7ba..23a23730 100644 --- a/src/Episode3/BattleRecord.hh +++ b/src/Episode3/BattleRecord.hh @@ -62,10 +62,25 @@ public: explicit BattleRecord(const std::string& data); std::string serialize() const; - bool writable() const; - bool battle_in_progress() const; + inline bool writable() const { + return this->is_writable; + } - const Event* get_first_event() const; + inline uint32_t get_behavior_flags() const { + return this->behavior_flags; + } + + inline bool battle_in_progress() const { + return (this->battle_start_timestamp != 0); + } + + inline const Event* get_first_event() const { + return this->events.empty() ? nullptr : &this->events.front(); + } + + inline std::deque get_all_events() const { + return this->events; + } void add_player( const PlayerLobbyDataDCGC& lobby_data, @@ -86,7 +101,6 @@ public: void print(FILE* stream) const; - std::vector get_all_server_data_commands() const; const std::string& get_random_stream() const; private: diff --git a/src/Episode3/Server.cc b/src/Episode3/Server.cc index c6b61192..416ec094 100644 --- a/src/Episode3/Server.cc +++ b/src/Episode3/Server.cc @@ -231,6 +231,11 @@ int8_t Server::get_winner_team_id() const { void Server::send(const void* data, size_t size, uint8_t command, bool enable_masking) const { // Note: This function is (obviously) not part of the original implementation. + + if (this->options.output_queue) { + this->options.output_queue->emplace_back(reinterpret_cast(data), size); + } + if (this->has_lobby) { auto l = this->lobby.lock(); if (!l) { @@ -2579,8 +2584,7 @@ void Server::send_6xB6x41_to_all_clients() const { // in the playback lobby for (string& data : map_commands_by_language) { if (!data.empty()) { - this->battle_record->add_command( - BattleRecord::Event::Type::BATTLE_COMMAND, std::move(data)); + this->battle_record->add_command(BattleRecord::Event::Type::BATTLE_COMMAND, std::move(data)); break; } } diff --git a/src/Episode3/Server.hh b/src/Episode3/Server.hh index 83fe19a2..cb43203b 100644 --- a/src/Episode3/Server.hh +++ b/src/Episode3/Server.hh @@ -76,6 +76,7 @@ public: std::shared_ptr rand_crypt; std::shared_ptr tournament; std::array, 5> trap_card_ids; + std::shared_ptr> output_queue; // For replay testing inline bool is_nte() const { return (this->behavior_flags & BehaviorFlag::IS_TRIAL_EDITION); diff --git a/src/Lobby.cc b/src/Lobby.cc index 033131a5..61864218 100644 --- a/src/Lobby.cc +++ b/src/Lobby.cc @@ -313,6 +313,7 @@ void Lobby::create_ep3_server() { .rand_crypt = this->rand_crypt, .tournament = tourn, .trap_card_ids = s->ep3_trap_card_ids, + .output_queue = nullptr, }; if (is_nte) { options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION; diff --git a/src/Main.cc b/src/Main.cc index 80b2f5f0..f6675852 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -3340,6 +3340,7 @@ Action a_replay_ep3_battle_commands( .rand_crypt = make_shared(seed), .tournament = nullptr, .trap_card_ids = {}, + .output_queue = nullptr, }; if (is_trial) { options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION; @@ -3365,36 +3366,131 @@ Action a_replay_ep3_battle_commands( Action a_replay_ep3_battle_record( "replay-ep3-battle-record", nullptr, +[](phosg::Arguments& args) { - auto rec = make_shared(read_input_data(args)); + auto record_data = read_input_data(args); + if (args.get("compressed")) { + record_data = prs_decompress(record_data); + } + auto rec = make_shared(record_data); + + bool use_color = isatty(fileno(stdout)); auto s = make_shared(get_config_filename(args)); s->load_ep3_cards(); s->load_ep3_maps(); - bool is_trial = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3_NTE); - + bool is_nte = rec->get_behavior_flags() & Episode3::BehaviorFlag::IS_TRIAL_EDITION; + auto output_queue = std::make_shared>(); Episode3::Server::Options options = { .card_index = s->ep3_card_index, .map_index = s->ep3_map_index, - .behavior_flags = (Episode3::BehaviorFlag::IGNORE_CARD_COUNTS | - Episode3::BehaviorFlag::ENABLE_STATUS_MESSAGES | - Episode3::BehaviorFlag::DISABLE_MASKING | - Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING), + .behavior_flags = rec->get_behavior_flags() & ~(Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING), .opt_rand_stream = make_shared(rec->get_random_stream()), .rand_crypt = make_shared(), .tournament = nullptr, .trap_card_ids = {}, + .output_queue = output_queue, }; - if (is_trial) { - options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION; - } - options.behavior_flags |= Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING; auto server = make_shared(nullptr, std::move(options)); server->init(); - for (const auto& command : rec->get_all_server_data_commands()) { - phosg::log_info_f("Server data command"); - phosg::print_data(stderr, command, 0, nullptr, phosg::PrintDataFlags::PRINT_ASCII | phosg::PrintDataFlags::DISABLE_COLOR | phosg::PrintDataFlags::OFFSET_16_BITS); - server->on_server_data_input(nullptr, command); + + // Ignore commands generated by the server when it's constructed (these + // are not included in the battle record) + output_queue->clear(); + + std::array players_present = {false, false, false, false}; + for (const auto& ev : rec->get_all_events()) { + switch (ev.type) { + case Episode3::BattleRecord::Event::Type::SET_INITIAL_PLAYERS: + ev.print(stdout); + for (const auto& player : ev.players) { + players_present.at(player.lobby_data.client_id) = true; + phosg::fwrite_fmt(stderr, "Player {} is present\n", player.lobby_data.client_id.load()); + } + break; + case Episode3::BattleRecord::Event::Type::PLAYER_JOIN: + case Episode3::BattleRecord::Event::Type::PLAYER_LEAVE: + case Episode3::BattleRecord::Event::Type::CHAT_MESSAGE: + case Episode3::BattleRecord::Event::Type::GAME_COMMAND: + case Episode3::BattleRecord::Event::Type::EP3_GAME_COMMAND: + ev.print(stdout); + break; + case Episode3::BattleRecord::Event::Type::BATTLE_COMMAND: + // Ignore the map command (this is handled separately) and 6xB4x4B + // (which is only generated when a lobby is present) + if (ev.data.empty() || (static_cast(ev.data[0]) == 0xB6) || (ev.data.at(4) == 0x4B)) { + ev.print(stdout); + } else { + if (use_color) { + phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_RED, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END); + } + ev.print(stdout); + if (use_color) { + phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END); + fflush(stdout); + } + if (output_queue->empty()) { + phosg::fwrite_fmt(stderr, "Output queue is empty, but expected battle command:\n"); + phosg::print_data(stderr, ev.data, 0, nullptr, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII); + throw std::runtime_error("Output did not match expectations"); + } + // Hack: don't check the last field in 6xB4x46 since it contains + // a timestamp on non-NTE + bool matched = false; + if ((ev.data.at(4) == 0x46) && !is_nte) { + auto received_cmd = check_size_t(output_queue->front()); + auto expected_cmd = check_size_t(ev.data); + received_cmd.date_str2.clear(0); + expected_cmd.date_str2.clear(0); + matched = !memcmp(&received_cmd, &expected_cmd, sizeof(received_cmd)); + } else { + matched = (output_queue->front() == ev.data); + } + if (!matched) { + const void* prev = (ev.data.size() == output_queue->front().size()) ? ev.data.data() : nullptr; + phosg::fwrite_fmt(stderr, "Output queue front did not match expected command; expected:\n"); + phosg::print_data(stderr, ev.data, 0, nullptr, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII); + phosg::fwrite_fmt(stderr, "Received:\n"); + phosg::print_data(stderr, output_queue->front(), 0, prev, phosg::PrintDataFlags::OFFSET_16_BITS | phosg::PrintDataFlags::PRINT_ASCII); + throw std::runtime_error("Output did not match expectations"); + } + output_queue->pop_front(); + } + break; + case Episode3::BattleRecord::Event::Type::SERVER_DATA_COMMAND: + if (use_color) { + phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_GREEN, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END); + } + ev.print(stdout); + if (use_color) { + phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END); + fflush(stdout); + } + if (!output_queue->empty()) { + phosg::fwrite_fmt(stderr, "Received extra output after preceding SERVER_DATA event:\n"); + phosg::print_data(stderr, output_queue->front()); + throw std::runtime_error("Output did not match expectations"); + } + // Hack: Set the CPU player flag if the player isn't present in the + // recording (normally this is done by checking the Lobby, but + // there's no Lobby during a replay) + if (ev.data.at(4) == 0x1B) { + string mutable_data = ev.data; + auto& cmd = check_size_t(mutable_data); + cmd.entry.is_cpu_player = !players_present.at(cmd.entry.client_id); + phosg::fwrite_fmt(stderr, "Overriding is_cpu_player with {}\n", cmd.entry.is_cpu_player ? "true" : "false"); + server->on_server_data_input(nullptr, mutable_data); + } else { + server->on_server_data_input(nullptr, ev.data); + } + break; + default: + throw std::runtime_error("unknown event type: {}"); + } + } + if (!output_queue->empty()) { + phosg::fwrite_fmt(stderr, "Received extra output after recording completed:\n"); + phosg::print_data(stderr, output_queue->front()); + throw std::runtime_error("Output did not match expectations"); } }); diff --git a/tests/replay-ep3-battle-input.mzr b/tests/replay-ep3-battle-input.mzr new file mode 100644 index 00000000..5e7b9c64 Binary files /dev/null and b/tests/replay-ep3-battle-input.mzr differ diff --git a/tests/replay-ep3-battle.test.sh b/tests/replay-ep3-battle.test.sh new file mode 100755 index 00000000..8a9abef3 --- /dev/null +++ b/tests/replay-ep3-battle.test.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -e + +EXECUTABLE="$1" +if [ -z "$EXECUTABLE" ]; then + EXECUTABLE="./newserv" +fi + +$EXECUTABLE --config=tests/config.json replay-ep3-battle-record --compressed tests/replay-ep3-battle-input.mzr