Merge pull request #2989 from HeatXD/nat_traversal

Netplay / Nat traversal
This commit is contained in:
Connor McLaughlin 2023-07-30 19:28:06 +10:00 committed by GitHub
commit 05fc4c3dd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 655 additions and 173 deletions

View File

@ -197,6 +197,9 @@ GGPOErrorCode Peer2PeerBackend::NetworkIdle()
for (UdpProtocol& udp : _endpoints)
udp.NetworkIdle();
for (UdpProtocol& udp : _spectators)
udp.NetworkIdle();
return GGPO_OK;
}
@ -657,7 +660,9 @@ Peer2PeerBackend::SetFrameDelay(GGPOPlayerHandle player, int delay)
result = PlayerHandleToQueue(player, &queue);
if (!GGPO_SUCCEEDED(result)) {
return result;
} _sync.SetFrameDelay(queue, delay);
}
_sync.SetFrameDelay(queue, delay);
for (int i = 0; i < _num_players; i++) {
if (_endpoints[i].IsInitialized()) {
@ -665,7 +670,7 @@ Peer2PeerBackend::SetFrameDelay(GGPOPlayerHandle player, int delay)
}
}
;
return GGPO_OK;
}

View File

@ -11,7 +11,7 @@
#include <cmath>
#include <iostream>
static const int UDP_HEADER_SIZE = 28; /* Size of IP + UDP headers */
static const int NUM_SYNC_PACKETS = 5;
static const int NUM_SYNC_PACKETS = 1;
static const int SYNC_RETRY_INTERVAL = 2000;
static const int SYNC_FIRST_RETRY_INTERVAL = 500;
static const int RUNNING_RETRY_INTERVAL = 200;

View File

@ -313,7 +313,7 @@ void GPU::UpdateDMARequest()
case BlitterState::ReadingVRAM:
m_GPUSTAT.ready_to_send_vram = true;
m_GPUSTAT.ready_to_recieve_dma = false;
m_GPUSTAT.ready_to_recieve_dma = m_fifo.IsEmpty();
break;
}

View File

@ -14,6 +14,11 @@
#include "host_settings.h"
#include "netplay_packets.h"
#include "pad.h"
#include "rapidjson/document.h"
#include "rapidjson/error/en.h"
#include "rapidjson/pointer.h"
#include "rapidjson/stringbuffer.h"
#include "rapidjson/writer.h"
#include "save_state_version.h"
#include "settings.h"
#include "spu.h"
@ -47,9 +52,12 @@ struct Input
};
// TODO: Might be a bit generous... should we move this to config?
static constexpr float MAX_CONNECT_TIME = 15.0f;
static constexpr float MAX_CONNECT_TIME = 30.0f;
static constexpr float MAX_CLOSE_TIME = 3.0f;
static constexpr u32 MAX_CONNECT_RETRIES = 4;
// TODO: traversal info. maybe should also be in a config
static constexpr u16 TRAVERSAL_PORT = 37373;
static constexpr const char* TRAVERSAL_IP = "127.0.0.1";
static bool NpAdvFrameCb(void* ctx, int flags);
static bool NpSaveFrameCb(void* ctx, unsigned char** buffer, int* len, int* checksum, int frame);
@ -101,6 +109,7 @@ static void HandleMessageFromNewPeer(ENetPeer* peer, const ENetPacket* pkt);
static void HandleControlMessage(s32 player_id, const ENetPacket* pkt);
static void HandleConnectResponseMessage(s32 player_id, const ENetPacket* pkt);
static void HandleJoinResponseMessage(s32 player_id, const ENetPacket* pkt);
static void HandlePreResetMessage(s32 player_id, const ENetPacket* pkt);
static void HandleResetMessage(s32 player_id, const ENetPacket* pkt);
static void HandleResetCompleteMessage(s32 player_id, const ENetPacket* pkt);
static void HandleResumeSessionMessage(s32 player_id, const ENetPacket* pkt);
@ -110,10 +119,17 @@ static void HandleDropPlayerMessage(s32 player_id, const ENetPacket* pkt);
static void HandleCloseSessionMessage(s32 player_id, const ENetPacket* pkt);
static void HandleChatMessage(s32 player_id, const ENetPacket* pkt);
// Nat Traversal
static void HandleTraversalMessage(ENetPeer* peer, const ENetPacket* pkt);
static bool SendTraversalRequest(const rapidjson::Document& request);
static void SendTraversalHostRegisterRequest();
static void SendTraversalHostLookupRequest();
static void SendTraversalPingRequest();
// GGPO session.
static void CreateGGPOSession();
static void DestroyGGPOSession();
static bool Start(bool is_hosting, std::string nickname, const std::string& remote_addr, s32 port, s32 ldelay);
static bool Start(bool is_hosting, std::string nickname, const std::string& remote_addr, s32 port, s32 ldelay, bool traversal);
static void CloseSession();
// Host functions.
@ -121,6 +137,7 @@ static void HandlePeerConnectionAsHost(ENetPeer* peer);
static void HandlePeerConnectionAsNonHost(ENetPeer* peer, s32 claimed_player_id);
static void HandlePeerDisconnectionAsHost(s32 player_id);
static void HandlePeerDisconnectionAsNonHost(s32 player_id);
static void PreReset();
static void Reset();
static void UpdateResetState();
static void UpdateConnectingState();
@ -146,6 +163,7 @@ static void GenerateChecksumForFrame(int* checksum, int frame, unsigned char* bu
static MemorySettingsInterface s_settings_overlay;
static SessionState s_state;
static bool send_desync_notifications = true;
/// Enet
struct Peer
@ -170,7 +188,12 @@ static std::array<Peer, MAX_SPECTATORS> s_spectators;
static std::bitset<MAX_SPECTATORS> s_reset_spectators;
static s32 s_num_spectators = 0;
static s32 s_spectating_failed_count = 0;
static bool s_local_spectating;
static bool s_local_spectating = false;
// Nat Traversal
static ENetPeer* s_traversal_peer;
static ENetAddress s_traversal_address;
static std::string s_traversal_host_code;
/// GGPO
static std::string s_local_nickname;
@ -293,7 +316,7 @@ static const T* CheckReceivedPacket(s32 player_id, const ENetPacket* pkt)
// Netplay Impl
bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& remote_addr, s32 port, s32 ldelay)
bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& remote_addr, s32 port, s32 ldelay, bool traversal)
{
if (IsActive())
{
@ -343,7 +366,7 @@ bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& re
ENetAddress server_address;
server_address.host = ENET_HOST_ANY;
server_address.port = is_hosting ? static_cast<u16>(port) : ENET_PORT_ANY;
s_enet_host = enet_host_create(&server_address, MAX_PLAYERS + MAX_SPECTATORS - 1, NUM_ENET_CHANNELS, 0, 0);
s_enet_host = enet_host_create(&server_address, MAX_PLAYERS + MAX_SPECTATORS, NUM_ENET_CHANNELS, 0, 0);
if (!s_enet_host)
{
Log_ErrorPrintf("Failed to create enet host.");
@ -357,6 +380,24 @@ bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& re
s_reset_players.reset();
s_reset_spectators.reset();
if (traversal)
{
// connect to traversal server if the option is selected
s_traversal_address.port = TRAVERSAL_PORT;
if (enet_address_set_host(&s_traversal_address, TRAVERSAL_IP))
{
Log_InfoPrint("Failed to set traversal server address");
return false;
}
s_traversal_peer = enet_host_connect(s_enet_host, &s_traversal_address, 1, 0);
if (!s_traversal_peer)
{
Log_InfoPrint("Failed to setup traversal server peer");
return false;
}
}
// If we're the host, we can just continue on our merry way, the others will join later.
if (is_hosting)
{
@ -379,6 +420,9 @@ bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& re
s_player_id = -1;
// Connect to host.
// when using traversal skip this step and do it later when host is known.
if (!traversal)
{
s_host_address.port = static_cast<u16>(port);
if (enet_address_set_host(&s_host_address, remote_addr.c_str()) != 0)
{
@ -393,6 +437,7 @@ bool Netplay::Start(bool is_hosting, std::string nickname, const std::string& re
Log_ErrorPrintf("Failed to start connection to host.");
return false;
}
}
// Wait until we're connected to the main host. They'll send us back state to load and a full player list.
s_state = SessionState::Connecting;
@ -425,6 +470,8 @@ void Netplay::CloseSession()
// Shut down the VM too, if we're not the host.
if (!was_host)
System::ShutdownSystem(false);
s_local_spectating = false;
}
bool Netplay::IsActive()
@ -449,6 +496,9 @@ void Netplay::CloseSessionWithError(const std::string_view& message)
{
Host::ReportErrorAsync(Host::TranslateString("Netplay", "Netplay Error"), message);
s_state = SessionState::ClosingSession;
if (s_peers[s_host_player_id].peer)
enet_peer_disconnect_now(s_peers[s_host_player_id].peer, 0);
}
void Netplay::RequestCloseSession(CloseSessionMessage::Reason reason)
@ -463,7 +513,7 @@ void Netplay::RequestCloseSession(CloseSessionMessage::Reason reason)
for (s32 i = 0; i < MAX_SPECTATORS; i++)
{
if (s_spectators[i].peer)
enet_peer_disconnect(s_spectators[i].peer, 0);
enet_peer_disconnect_now(s_spectators[i].peer, 0);
}
}
@ -476,10 +526,14 @@ void Netplay::RequestCloseSession(CloseSessionMessage::Reason reason)
if (IsHost())
enet_peer_disconnect_later(s_peers[i].peer, 0);
else
enet_peer_disconnect(s_peers[i].peer, 0);
enet_peer_disconnect_now(s_peers[i].peer, 0);
}
}
// close connection with traversal server if active
if (s_traversal_peer)
enet_peer_disconnect_now(s_traversal_peer, 0);
// but wait for them to actually drop
s_state = SessionState::ClosingSession;
s_reset_start_time.Reset();
@ -506,7 +560,10 @@ void Netplay::RequestCloseSession(CloseSessionMessage::Reason reason)
const s32 spectator_slot = GetSpectatorSlotForPeer(event.peer);
if (spectator_slot >= 0)
{
s_spectators[spectator_slot].peer = nullptr;
return;
}
}
break;
@ -570,6 +627,8 @@ void Netplay::ShutdownEnetHost()
s_spectators[i] = {};
}
s_traversal_peer = nullptr;
enet_host_destroy(s_enet_host);
s_enet_host = nullptr;
}
@ -589,6 +648,19 @@ void Netplay::HandleEnetEvent(const ENetEvent* event)
{
case ENET_EVENT_TYPE_CONNECT:
{
// handle traversal peer
if (event->peer == s_traversal_peer)
{
Log_InfoPrintf("Traversal server connected: %s", PeerAddressString(event->peer).c_str());
if (IsHost())
SendTraversalHostRegisterRequest();
else
SendTraversalHostLookupRequest();
return;
}
if (IsHost())
HandlePeerConnectionAsHost(event->peer);
else
@ -600,6 +672,15 @@ void Netplay::HandleEnetEvent(const ENetEvent* event)
case ENET_EVENT_TYPE_DISCONNECT:
{
// handle traversal peer
if (event->peer == s_traversal_peer)
{
Log_InfoPrint("Traversal server disconnected");
enet_peer_disconnect_now(event->peer, 0);
s_traversal_peer = nullptr;
return;
}
const s32 spectator_slot = GetSpectatorSlotForPeer(event->peer);
const s32 player_id = GetPlayerIdForPeer(event->peer);
@ -635,6 +716,12 @@ void Netplay::HandleEnetEvent(const ENetEvent* event)
case ENET_EVENT_TYPE_RECEIVE:
{
if (event->peer == s_traversal_peer && event->channelID == ENET_CHANNEL_CONTROL)
{
HandleTraversalMessage(event->peer, event->packet);
return;
}
s32 player_id = GetPlayerIdForPeer(event->peer);
const s32 spectator_slot = GetSpectatorSlotForPeer(event->peer);
@ -693,6 +780,10 @@ void Netplay::PollEnet(Common::Timer::Value until_time)
// make sure s_enet_host exists
Assert(s_enet_host);
// might need resending
if (s_ggpo)
ggpo_network_idle(s_ggpo);
const int res = enet_host_service(s_enet_host, &event, enet_timeout);
if (res > 0)
{
@ -766,10 +857,9 @@ std::string_view Netplay::GetNicknameForPlayer(s32 player_id)
void Netplay::CreateGGPOSession()
{
/*
TODO: since saving every frame during rollback loses us time to do actual gamestate iterations it might be better to
hijack the update / save / load cycle to only save every confirmed frame only saving when actually needed.
*/
// TODO: since saving every frame during rollback loses us time to do actual gamestate iterations it might be better to
// hijack the update / save / load cycle to only save every confirmed frame only saving when actually needed.
GGPOSessionCallbacks cb = {};
cb.advance_frame = NpAdvFrameCb;
cb.save_game_state = NpSaveFrameCb;
@ -831,6 +921,7 @@ void Netplay::CreateGGPOSession()
result = ggpo_add_player(s_ggpo, &player, &s_peers[i].ggpo_handle);
}
Log_InfoPrintf("Adding player: %d", i);
// It's a new session, this should always succeed...
Assert(GGPO_SUCCEEDED(result));
}
@ -874,6 +965,10 @@ void Netplay::HandleControlMessage(s32 player_id, const ENetPacket* pkt)
HandleJoinResponseMessage(player_id, pkt);
break;
case ControlMessage::PreReset:
HandlePreResetMessage(player_id, pkt);
break;
case ControlMessage::Reset:
HandleResetMessage(player_id, pkt);
break;
@ -1033,6 +1128,9 @@ void Netplay::SendConnectRequest()
bool Netplay::IsSpectator(const ENetPeer* peer)
{
if (!peer)
return false;
for (s32 i = 0; i < MAX_SPECTATORS; i++)
{
if (s_spectators[i].peer == peer)
@ -1064,16 +1162,20 @@ s32 Netplay::GetSpectatorSlotForPeer(const ENetPeer* peer)
void Netplay::DropSpectator(s32 slot_id, DropPlayerReason reason)
{
Assert(IsHost());
DebugAssert(s_spectators[slot_id].peer);
Log_InfoPrintf("Dropping Spectator %d: %s", slot_id, s_spectators[slot_id].nickname.c_str());
Host::OnNetplayMessage(
fmt::format(Host::TranslateString("Netplay", "Spectator {} left the session: {}").GetCharArray(), slot_id,
s_spectators[slot_id].nickname, DropPlayerReasonToString(reason)));
if (s_spectators[slot_id].peer)
enet_peer_disconnect_now(s_spectators[slot_id].peer, 0);
s_spectators[slot_id] = {};
s_num_spectators--;
if (s_num_spectators == 0 && s_num_players == 1)
Reset();
}
void Netplay::UpdateConnectingState()
@ -1086,7 +1188,8 @@ void Netplay::UpdateConnectingState()
// MAX_CONNECT_RETRIES peer to host connection attempts
// dividing by MAX_CONNECT_RETRIES + 1 because the last attempt will never happen.
if (s_last_host_connection_attempt.GetTimeSeconds() >= MAX_CONNECT_TIME / (MAX_CONNECT_RETRIES + 1) &&
if (s_peers[s_host_player_id].peer &&
s_last_host_connection_attempt.GetTimeSeconds() >= MAX_CONNECT_TIME / (MAX_CONNECT_RETRIES + 1) &&
s_peers[s_host_player_id].peer->state != ENetPeerState::ENET_PEER_STATE_CONNECTED)
{
// we want to do this because the peer might have initiated a connection
@ -1199,6 +1302,116 @@ void Netplay::HandleMessageFromNewPeer(ENetPeer* peer, const ENetPacket* pkt)
NotifyPlayerJoined(new_player_id);
}
void Netplay::HandleTraversalMessage(ENetPeer* peer, const ENetPacket* pkt)
{
rapidjson::Document doc;
char* data = reinterpret_cast<char*>(pkt->data);
bool err = doc.Parse<0>(data).HasParseError();
if (err || !doc.HasMember("msg_type"))
{
Log_ErrorPrintf("Failed to parse traversal server message");
return;
}
auto msg_type = std::string(rapidjson::Pointer("/msg_type").Get(doc)->GetString());
Log_VerbosePrintf("Received message from traversal server %s", msg_type.c_str());
if (msg_type == "PingResponse")
{
SendTraversalPingRequest();
return;
}
if (msg_type == "HostRegisterResponse")
{
// TODO: show host code somewhere to share
if (!doc.HasMember("host_code"))
{
Log_ErrorPrintf("Failed to retrieve host code from HostRegisterResponse");
return;
}
s_traversal_host_code = rapidjson::Pointer("/host_code").Get(doc)->GetString();
Host::OnNetplayMessage("Host code has been copied to clipboard");
Host::CopyTextToClipboard(s_traversal_host_code);
Log_VerbosePrintf("Host code: %s", s_traversal_host_code.c_str());
return;
}
if (msg_type == "HostLookupResponse")
{
if (!doc.HasMember("success") || !rapidjson::Pointer("/success").Get(doc)->GetBool())
{
Log_ErrorPrintf("No host found with host code: %s", s_traversal_host_code.c_str());
return;
}
if (!doc.HasMember("host_info"))
{
Log_ErrorPrintf("Failed to retrieve host code from HostLookupResponse");
return;
}
auto host_addr = std::string_view(rapidjson::Pointer("/host_info").Get(doc)->GetString());
auto info = StringUtil::SplitNewString(host_addr, ':');
std::string_view host_ip = info[0];
u16 host_port = static_cast<u16>(std::stoi(info[1].data()));
s_host_address.port = host_port;
if (enet_address_set_host(&s_host_address, host_ip.data()) != 0)
{
Log_ErrorPrintf("Failed to parse host: '%s'", host_ip.data());
return;
}
s_peers[s_host_player_id].peer =
enet_host_connect(s_enet_host, &s_host_address, NUM_ENET_CHANNELS, static_cast<u32>(s_player_id));
if (!s_peers[s_host_player_id].peer)
{
Log_ErrorPrintf("Failed to start connection to host.");
return;
}
return;
}
if (msg_type == "ClientLookupResponse")
{
// try to connect to the given client using the information supplied.
if (!doc.HasMember("client_info"))
{
Log_ErrorPrintf("Failed to retrieve client code from ClientLookupResponse");
return;
}
auto client_addr = std::string_view(rapidjson::Pointer("/client_info").Get(doc)->GetString());
auto info = StringUtil::SplitNewString(client_addr, ':');
std::string_view client_ip = info[0];
u16 client_port = static_cast<u16>(std::stoi(info[1].data()));
ENetAddress client_address;
client_address.port = client_port;
if (enet_address_set_host(&client_address, client_ip.data()) != 0)
{
Log_ErrorPrintf("Failed to parse client: '%s'", client_ip.data());
return;
}
if (!enet_host_connect(s_enet_host, &client_address, NUM_ENET_CHANNELS, 0))
{
Log_ErrorPrintf("Failed to start connection to client.");
return;
}
return;
}
}
void Netplay::HandlePeerConnectionAsNonHost(ENetPeer* peer, s32 claimed_player_id)
{
if (s_state == SessionState::Connecting)
@ -1267,6 +1480,29 @@ void Netplay::HandleJoinResponseMessage(s32 player_id, const ENetPacket* pkt)
s_reset_start_time.Reset();
}
void Netplay::HandlePreResetMessage(s32 player_id, const ENetPacket* pkt)
{
if (player_id != s_host_player_id)
{
// This shouldn't ever happen, unless someone's being cheeky.
Log_ErrorPrintf("Dropping Pre-reset from non-host player %d", player_id);
return;
}
if (s_state != SessionState::Resetting)
{
// Destroy session to stop sending ggpo packets
DestroyGGPOSession();
// Setup a fake resetting situation,
// the real reset message will come and override this one.
s_num_players = 0;
s_state = SessionState::Resetting;
s_reset_players.reset();
s_reset_spectators.reset();
s_reset_start_time.Reset();
}
}
void Netplay::HandlePeerDisconnectionAsHost(s32 player_id)
{
Log_InfoPrintf("Player %d disconnected from host, reclaiming their slot", player_id);
@ -1287,8 +1523,21 @@ void Netplay::HandlePeerDisconnectionAsNonHost(s32 player_id)
RequestReset(ResetRequestMessage::Reason::ConnectionLost, player_id);
}
void Netplay::PreReset()
{
Assert(IsHost());
Log_VerbosePrintf("Pre-Resetting...");
SendControlPacketToAll(NewControlPacket<PreResetMessage>(), true);
}
void Netplay::Reset()
{
// In high latency situations it smart to send a pre-reset message before sending the reset message.
// To prepare them and not timeout.
PreReset();
Assert(IsHost());
Log_VerbosePrintf("Resetting...");
@ -1526,7 +1775,7 @@ void Netplay::HandleResumeSessionMessage(s32 player_id, const ENetPacket* pkt)
void Netplay::UpdateResetState()
{
const s32 num_players = (s_local_spectating ? 1 : s_num_players);
const s32 num_players = (s_local_spectating && s_num_players > 1 ? 1 : s_num_players);
if (IsHost())
{
if (static_cast<s32>(s_reset_players.count()) == num_players &&
@ -1556,7 +1805,7 @@ void Netplay::UpdateResetState()
for (s32 i = 0; i < MAX_SPECTATORS; i++)
{
if (s_reset_spectators.test(i))
if (!IsSpectator(s_spectators[i].peer) || s_reset_spectators.test(i))
continue;
// we'll check if we're done again next loop
@ -1586,7 +1835,7 @@ void Netplay::UpdateResetState()
pkt->cookie = s_reset_cookie;
SendControlPacket(s_host_player_id, pkt);
}
}
// cancel ourselves if we didn't get another synchronization request from the host
if (s_reset_start_time.GetTimeSeconds() >= (MAX_CONNECT_TIME * 2.0f))
{
@ -1594,16 +1843,13 @@ void Netplay::UpdateResetState()
return;
}
}
}
// Log_InfoPrintf("p:%d/s:%d", num_players, s_num_spectators);
const s32 min_progress = IsHost() ? static_cast<int>(s_reset_players.count() + s_reset_spectators.count()) :
static_cast<int>(s_reset_players.count());
const s32 max_progress = IsHost() ? s_num_players + s_num_spectators : num_players;
PollEnet(Common::Timer::GetCurrentValue() + Common::Timer::ConvertMillisecondsToValue(16));
Host::DisplayLoadingScreen("Netplay synchronizing", 0, min_progress, max_progress);
Host::DisplayLoadingScreen("Netplay synchronizing", 0, max_progress, min_progress);
Host::PumpMessagesOnCPUThread();
}
@ -1726,6 +1972,60 @@ void Netplay::HandleChatMessage(s32 player_id, const ENetPacket* pkt)
ShowChatMessage(player_id, msg->GetMessage());
}
bool Netplay::SendTraversalRequest(const rapidjson::Document& request)
{
if (!s_traversal_peer)
return false;
rapidjson::StringBuffer buffer;
rapidjson::Writer writer(buffer);
request.Accept(writer);
auto data = buffer.GetString();
auto len = buffer.GetLength();
if (!data || len == 0)
return false;
auto packet = enet_packet_create(data, len, ENET_PACKET_FLAG_RELIABLE);
auto err = enet_peer_send(s_traversal_peer, ENET_CHANNEL_CONTROL, packet);
if (err != 0)
{
Log_ErrorPrintf("Traversal send error: %d", err);
return false;
}
return true;
}
void Netplay::SendTraversalHostRegisterRequest()
{
rapidjson::Document request;
rapidjson::Pointer("/msg_type").Set(request, "HostRegisterRequest");
if (!SendTraversalRequest(request))
Log_InfoPrint("Failed to send HostRegisterRequest to the traversal server");
}
void Netplay::SendTraversalHostLookupRequest()
{
rapidjson::Document request;
rapidjson::Pointer("/msg_type").Set(request, "HostLookupRequest");
rapidjson::Pointer("/host_code").Set(request, s_traversal_host_code.c_str());
if (!SendTraversalRequest(request))
Log_InfoPrint("Failed to send HostLookupRequest to the traversal server");
}
void Netplay::SendTraversalPingRequest()
{
rapidjson::Document request;
rapidjson::Pointer("/msg_type").Set(request, "PingRequest");
if (!SendTraversalRequest(request))
Log_InfoPrint("Failed to send PingRequest to the traversal server");
}
//////////////////////////////////////////////////////////////////////////
// Settings Overlay
//////////////////////////////////////////////////////////////////////////
@ -1874,10 +2174,18 @@ void Netplay::UpdateThrottlePeriod()
Common::Timer::ConvertSecondsToValue(1.0 / (static_cast<double>(System::GetThrottleFrequency()) * s_target_speed));
}
void Netplay::ToggleDesyncNotifications()
{
bool was_enabled = send_desync_notifications;
send_desync_notifications = send_desync_notifications ? false : true;
if (was_enabled)
Host::ClearNetplayMessages();
}
void Netplay::HandleTimeSyncEvent(float frame_delta, int update_interval)
{
// only activate timesync if its worth correcting.
if (std::abs(frame_delta) < 1.0f)
if (std::abs(frame_delta) < 1.75f)
return;
// Distribute the frame difference over the next N * 0.75 frames.
// only part of the interval time is used since we want to come back to normal speed.
@ -1975,8 +2283,8 @@ void Netplay::RunFrame()
if (s_local_spectating && result != GGPO_OK)
{
s_spectating_failed_count++;
// after 5 seconds and still not spectating close since you are stuck.
if (s_spectating_failed_count >= 300)
// after 15 seconds and still not spectating? you should close since you are stuck.
if (s_spectating_failed_count > 900)
CloseSessionWithError("Failed to sync spectator with host. Please try again.");
}
@ -1985,6 +2293,7 @@ void Netplay::RunFrame()
// enable again when rolling back done
SPU::SetAudioOutputMuted(false);
NetplayAdvanceFrame(inputs, disconnect_flags);
s_spectating_failed_count = 0;
}
}
}
@ -2020,7 +2329,8 @@ void Netplay::SendChatMessage(const std::string_view& msg)
auto pkt = NewControlPacket<ChatMessage>(sizeof(ChatMessage) + static_cast<u32>(msg.length()));
std::memcpy(pkt.pkt->data + sizeof(ChatMessage), msg.data(), msg.length());
// TODO: turn chat on for spectators? it's kind of weird to handle. probably has to go through the host and be relayed to the players.
// TODO: turn chat on for spectators? it's kind of weird to handle. probably has to go through the host and be relayed
// to the players.
SendControlPacketToAll(pkt, false);
// add own netplay message locally to netplay messages
@ -2049,6 +2359,11 @@ u32 Netplay::GetMaxPrediction()
return MAX_ROLLBACK_FRAMES;
}
std::string_view Netplay::GetHostCode()
{
return s_traversal_host_code;
}
void Netplay::SetInputs(Netplay::Input inputs[2])
{
for (u32 i = 0; i < 2; i++)
@ -2060,7 +2375,7 @@ void Netplay::SetInputs(Netplay::Input inputs[2])
}
}
bool Netplay::CreateSession(std::string nickname, s32 port, s32 max_players, std::string password, int inputdelay)
bool Netplay::CreateSession(std::string nickname, s32 port, s32 max_players, std::string password, int inputdelay, bool traversal)
{
s_local_session_password = std::move(password);
@ -2068,7 +2383,7 @@ bool Netplay::CreateSession(std::string nickname, s32 port, s32 max_players, std
// to have the same data, and we don't want to trash their local memcards. We should therefore load
// the memory cards for this game (based on game/global settings), and copy that to the temp card.
if (!Netplay::Start(true, std::move(nickname), std::string(), port, inputdelay))
if (!Netplay::Start(true, std::move(nickname), std::string(), port, inputdelay, traversal))
{
CloseSession();
return false;
@ -2083,12 +2398,14 @@ bool Netplay::CreateSession(std::string nickname, s32 port, s32 max_players, std
}
bool Netplay::JoinSession(std::string nickname, const std::string& hostname, s32 port, std::string password,
bool spectating, int inputdelay)
bool spectating, int inputdelay, bool traversal, const std::string& hostcode)
{
s_local_session_password = std::move(password);
s_local_spectating = spectating;
if (!Netplay::Start(false, std::move(nickname), hostname, port, inputdelay))
s_traversal_host_code = hostcode;
if (!Netplay::Start(false, std::move(nickname), hostname, port, inputdelay, traversal))
{
CloseSession();
return false;
@ -2255,6 +2572,9 @@ bool Netplay::NpOnEventCb(void* ctx, GGPOEvent* ev)
HandleTimeSyncEvent(ev->u.timesync.frames_ahead, ev->u.timesync.timeSyncPeriodInFrames);
break;
case GGPOEventCode::GGPO_EVENTCODE_DESYNC:
if (!send_desync_notifications)
break;
Host::OnNetplayMessage(fmt::format("Desync Detected: Current Frame: {}, Desync Frame: {}, Diff: {}, L:{}, R:{}",
CurrentFrame(), ev->u.desync.nFrameOfDesync,
CurrentFrame() - ev->u.desync.nFrameOfDesync, ev->u.desync.ourCheckSum,

View File

@ -31,8 +31,9 @@ enum : u8
NUM_ENET_CHANNELS,
};
bool CreateSession(std::string nickname, s32 port, s32 max_players, std::string password, int inputdelay);
bool JoinSession(std::string nickname, const std::string& hostname, s32 port, std::string password, bool spectating, int inputdelay);
bool CreateSession(std::string nickname, s32 port, s32 max_players, std::string password, int inputdelay, bool traversal);
bool JoinSession(std::string nickname, const std::string& hostname, s32 port, std::string password, bool spectating,
int inputdelay, bool traversal, const std::string& hostcode);
bool IsActive();
@ -49,8 +50,11 @@ void SendChatMessage(const std::string_view& msg);
s32 GetPing();
u32 GetMaxPrediction();
std::string_view GetHostCode();
/// Updates the throttle period, call when target emulation speed changes.
void UpdateThrottlePeriod();
void ToggleDesyncNotifications();
} // namespace Netplay

View File

@ -26,6 +26,7 @@ enum class ControlMessage : u32
// host->player
ConnectResponse,
JoinResponse,
PreReset,
Reset,
ResumeSession,
PlayerJoined,
@ -172,6 +173,13 @@ struct JoinResponseMessage
static ControlMessage MessageType() { return ControlMessage::JoinResponse; }
};
struct PreResetMessage
{
ControlMessageHeader header;
static ControlMessage MessageType() { return ControlMessage::PreReset; }
};
struct ResetMessage
{
struct PlayerAddress

View File

@ -521,4 +521,5 @@ bool IsFullscreen();
void SetFullscreen(bool enabled);
// netplay
void OnNetplayMessage(std::string message);
void ClearNetplayMessages();
} // namespace Host

View File

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>496</width>
<height>267</height>
<height>302</height>
</rect>
</property>
<property name="windowTitle">
@ -53,7 +53,7 @@
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Select a nickname and port to open your current game session to other players via netplay. A password may optionally be supplied to restrict who can join.</string>
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Select a nickname and port to open your current game session to other players via netplay. A password may optionally be supplied to restrict who can join. The traversal mode option can be enabled to allow other players to join via a host code without needing to portforward.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
@ -142,7 +142,7 @@
<number>128</number>
</property>
<property name="echoMode">
<enum>QLineEdit::Normal</enum>
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
@ -158,6 +158,15 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QCheckBox" name="traversal">
<property name="text">
<string>Enable Traversal Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
@ -166,6 +175,8 @@
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources>
<include location="resources/resources.qrc"/>

View File

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>480</width>
<height>254</height>
<height>308</height>
</rect>
</property>
<property name="windowTitle">
@ -65,14 +65,33 @@
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QTabWidget" name="tabConnectMode">
<property name="minimumSize">
<size>
<width>0</width>
<height>190</height>
</size>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tabDirect">
<attribute name="title">
<string>Direct Mode</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
@ -92,6 +111,30 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Input Delay:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="inputDelay"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Hostname:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="hostname">
<property name="text">
<string>localhost</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
@ -112,20 +155,6 @@
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Hostname:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="hostname">
<property name="text">
<string>localhost</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
@ -139,21 +168,80 @@
<number>128</number>
</property>
<property name="echoMode">
<enum>QLineEdit::Normal</enum>
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabTraversal">
<attribute name="title">
<string>Traversal Mode</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QFormLayout" name="formLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Nickname:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nicknameTraversal">
<property name="text">
<string>Netplay Peer</string>
</property>
<property name="maxLength">
<number>128</number>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="inputDelayTraversal"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_13">
<property name="text">
<string>Password:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="passwordTraversal">
<property name="maxLength">
<number>128</number>
</property>
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Host Code:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="hostCode"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Input Delay:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="inputDelay"/>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">

View File

@ -700,7 +700,7 @@ void MainWindow::onApplicationStateChanged(Qt::ApplicationState state)
// Clear the state of all keyboard binds.
// That way, if we had a key held down, and lost focus, the bind won't be stuck enabled because we never
// got the key release message, because it happened in another window which "stole" the event.
InputManager::ClearBindStateFromSource(InputManager::MakeHostKeyboardKey(0));
g_emu_thread->clearInputBindStateFromSource(InputManager::MakeHostKeyboardKey(0));
}
else
{

View File

@ -38,9 +38,10 @@ void CreateNetplaySessionDialog::accept()
const int inputdelay = m_ui.inputDelay->value();
const QString& nickname = m_ui.nickname->text();
const QString& password = m_ui.password->text();
const bool traversal = m_ui.traversal->isChecked();
QDialog::accept();
g_emu_thread->createNetplaySession(nickname.trimmed(), port, players, password, inputdelay);
g_emu_thread->createNetplaySession(nickname.trimmed(), port, players, password, inputdelay, traversal);
}
bool CreateNetplaySessionDialog::validate()
@ -64,9 +65,14 @@ JoinNetplaySessionDialog::JoinNetplaySessionDialog(QWidget* parent)
connect(m_ui.port, &QSpinBox::valueChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.inputDelay, &QSpinBox::valueChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.inputDelayTraversal, &QSpinBox::valueChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.nickname, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.nicknameTraversal, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.password, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.passwordTraversal, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.hostname, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.hostCode, &QLineEdit::textChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.tabConnectMode, &QTabWidget::currentChanged, this, &JoinNetplaySessionDialog::updateState);
connect(m_ui.buttonBox->button(QDialogButtonBox::Ok), &QAbstractButton::clicked, this,
&JoinNetplaySessionDialog::accept);
@ -80,18 +86,22 @@ JoinNetplaySessionDialog::~JoinNetplaySessionDialog() = default;
void JoinNetplaySessionDialog::accept()
{
if (!validate())
const bool direct_mode = m_ui.tabTraversal->isHidden();
const bool valid = direct_mode ? validate() : validateTraversal();
if (!valid)
return;
const int port = m_ui.port->value();
const int inputdelay = m_ui.inputDelay->value();
const QString& nickname = m_ui.nickname->text();
int port = m_ui.port->value();
int inputdelay = direct_mode ? m_ui.inputDelay->value() : m_ui.inputDelayTraversal->value();
const QString& nickname = direct_mode ? m_ui.nickname->text() : m_ui.nicknameTraversal->text();
const QString& password = direct_mode ? m_ui.password->text() : m_ui.passwordTraversal->text();
const QString& hostname = m_ui.hostname->text();
const QString& password = m_ui.password->text();
const QString& hostcode = m_ui.hostCode->text();
const bool spectating = m_ui.spectating->isChecked();
QDialog::accept();
g_emu_thread->joinNetplaySession(nickname.trimmed(), hostname.trimmed(), port, password, spectating, inputdelay);
g_emu_thread->joinNetplaySession(nickname.trimmed(), hostname.trimmed(), port, password, spectating, inputdelay,
!direct_mode, hostcode.trimmed());
}
bool JoinNetplaySessionDialog::validate()
@ -100,10 +110,20 @@ bool JoinNetplaySessionDialog::validate()
const int inputdelay = m_ui.inputDelay->value();
const QString& nickname = m_ui.nickname->text();
const QString& hostname = m_ui.hostname->text();
return (!nickname.isEmpty() && !hostname.isEmpty() && port > 0 && port <= 65535 && inputdelay >= 0 && inputdelay <= 10);
return (!nickname.isEmpty() && !hostname.isEmpty() && port > 0 && port <= 65535 && inputdelay >= 0 &&
inputdelay <= 10);
}
bool JoinNetplaySessionDialog::validateTraversal()
{
const int inputdelay = m_ui.inputDelayTraversal->value();
const QString& nickname = m_ui.nicknameTraversal->text();
const QString& hostcode = m_ui.hostCode->text();
return (!nickname.isEmpty() && !hostcode.isEmpty() && inputdelay >= 0 && inputdelay <= 10);
}
void JoinNetplaySessionDialog::updateState()
{
m_ui.buttonBox->button(QDialogButtonBox::Ok)->setEnabled(validate());
m_ui.buttonBox->button(QDialogButtonBox::Ok)
->setEnabled(m_ui.tabTraversal->isHidden() ? validate() : validateTraversal());
}

View File

@ -44,6 +44,7 @@ private Q_SLOTS:
private:
bool validate();
bool validateTraversal();
private:
Ui::JoinNetplaySessionDialog m_ui;

View File

@ -1071,13 +1071,13 @@ void EmuThread::reloadPostProcessingShaders()
System::ReloadPostProcessingShaders();
}
void EmuThread::createNetplaySession(const QString& nickname, qint32 port, qint32 max_players, const QString& password, int inputdelay)
void EmuThread::createNetplaySession(const QString& nickname, qint32 port, qint32 max_players, const QString& password, int inputdelay, bool traversal)
{
if (!isOnThread())
{
QMetaObject::invokeMethod(this, "createNetplaySession", Qt::QueuedConnection, Q_ARG(const QString&, nickname),
Q_ARG(qint32, port), Q_ARG(qint32, max_players), Q_ARG(const QString&, password),
Q_ARG(int, inputdelay));
Q_ARG(int, inputdelay), Q_ARG(bool, traversal));
return;
}
@ -1085,7 +1085,7 @@ void EmuThread::createNetplaySession(const QString& nickname, qint32 port, qint3
if (!System::IsValid())
return;
if (!Netplay::CreateSession(nickname.toStdString(), port, max_players, password.toStdString(), inputdelay))
if (!Netplay::CreateSession(nickname.toStdString(), port, max_players, password.toStdString(), inputdelay, traversal))
{
errorReported(tr("Netplay Error"), tr("Failed to create netplay session. The log may contain more information."));
return;
@ -1093,18 +1093,19 @@ void EmuThread::createNetplaySession(const QString& nickname, qint32 port, qint3
}
void EmuThread::joinNetplaySession(const QString& nickname, const QString& hostname, qint32 port,
const QString& password, bool spectating, int inputdelay)
const QString& password, bool spectating, int inputdelay, bool traversal,
const QString& hostcode)
{
if (!isOnThread())
{
QMetaObject::invokeMethod(this, "joinNetplaySession", Qt::QueuedConnection, Q_ARG(const QString&, nickname),
Q_ARG(const QString&, hostname), Q_ARG(qint32, port), Q_ARG(const QString&, password),
Q_ARG(bool, spectating), Q_ARG(int, inputdelay));
Q_ARG(bool, spectating), Q_ARG(int, inputdelay), Q_ARG(bool, traversal),
Q_ARG(const QString&, hostcode));
return;
}
if (!Netplay::JoinSession(nickname.toStdString(), hostname.toStdString(), port, password.toStdString(), spectating,
inputdelay))
if (!Netplay::JoinSession(nickname.toStdString(), hostname.toStdString(), port, password.toStdString(), spectating, inputdelay, traversal, hostcode.toStdString()))
{
errorReported(tr("Netplay Error"), tr("Failed to join netplay session. The log may contain more information."));
return;
@ -1114,6 +1115,17 @@ void EmuThread::joinNetplaySession(const QString& nickname, const QString& hostn
g_emu_thread->wakeThread();
}
void EmuThread::clearInputBindStateFromSource(InputBindingKey key)
{
if (!isOnThread())
{
QMetaObject::invokeMethod(this, "clearInputBindStateFromSource", Qt::QueuedConnection, Q_ARG(InputBindingKey, key));
return;
}
InputManager::ClearBindStateFromSource(key);
}
void EmuThread::runOnEmuThread(std::function<void()> callback)
{
callback();
@ -2245,11 +2257,11 @@ int main(int argc, char* argv[])
params->override_fast_boot = true;
params->fast_forward_to_first_frame = true;
g_emu_thread->bootSystem(std::move(params));
g_emu_thread->createNetplaySession(nickname, port, 2, QString(), 0);
g_emu_thread->createNetplaySession(nickname, port, 2, QString(), 0, false);
}
else
{
g_emu_thread->joinNetplaySession(nickname, remote, port, QString(), false, 0);
g_emu_thread->joinNetplaySession(nickname, remote, port, QString(), false, 0, false, "");
}
});
}

View File

@ -188,9 +188,10 @@ public Q_SLOTS:
void applyCheat(quint32 index);
void reloadPostProcessingShaders();
void createNetplaySession(const QString& nickname, qint32 port, qint32 max_players, const QString& password,
int inputdelay);
int inputdelay, bool traversal);
void joinNetplaySession(const QString& nickname, const QString& hostname, qint32 port, const QString& password,
bool spectating, int inputdelay);
bool spectating, int inputdelay, bool traversal, const QString& hostcode);
void clearInputBindStateFromSource(InputBindingKey key);
private Q_SLOTS:
void stopInThread();

View File

@ -176,8 +176,8 @@ bool RegTestHostDisplay::Render(bool skip_present)
return true;
}
bool RegTestHostDisplay::RenderScreenshot(u32 width, u32 height, std::vector<u32>* out_pixels, u32* out_stride,
GPUTexture::Format* out_format)
bool RegTestHostDisplay::RenderScreenshot(u32 width, u32 height, const Common::Rectangle<s32>& draw_rect,
std::vector<u32>* out_pixels, u32* out_stride, GPUTexture::Format* out_format)
{
return false;
}

View File

@ -53,8 +53,8 @@ public:
void SetVSync(bool enabled) override;
bool Render(bool skip_present) override;
bool RenderScreenshot(u32 width, u32 height, std::vector<u32>* out_pixels, u32* out_stride,
GPUTexture::Format* out_format) override;
bool RenderScreenshot(u32 width, u32 height, const Common::Rectangle<s32>& draw_rect, std::vector<u32>* out_pixels,
u32* out_stride, GPUTexture::Format* out_format) override;
bool SupportsTextureFormat(GPUTexture::Format format) const override;

View File

@ -77,6 +77,12 @@ void Host::OnNetplayMessage(std::string message)
Common::Timer::ConvertSecondsToValue(NETPLAY_MESSAGE_DURATION));
}
void Host::ClearNetplayMessages()
{
while (s_netplay_messages.size() > 0)
s_netplay_messages.pop_front();
}
void ImGuiManager::RenderNetplayOverlays()
{
DrawNetplayMessages();
@ -135,7 +141,12 @@ void ImGuiManager::DrawNetplayStats()
// We'll probably want to draw a graph too..
LargeString text;
text.AppendFmtString("Ping: {}", Netplay::GetPing());
text.AppendFmtString("Ping: {}\n", Netplay::GetPing());
// temporary show the hostcode here for now
auto hostcode = Netplay::GetHostCode();
if (!hostcode.empty())
text.AppendFmtString("Host Code: {}", hostcode);
const float scale = ImGuiManager::GetGlobalScale();
const float shadow_offset = 1.0f * scale;

View File

@ -3,7 +3,7 @@
SET VERSIONFILE="scmversion.cpp"
FOR /F "tokens=* USEBACKQ" %%g IN (`git rev-parse HEAD`) do (SET "HASH=%%g")
FOR /F "tokens=* USEBACKQ" %%g IN (`git rev-parse --abbrev-ref HEAD`) do (SET "BRANCH=%%g")
FOR /F "tokens=* USEBACKQ" %%g IN (`git describe --tags --dirty --exclude latest --exclude preview --exclude legacy --exclude play-store-release`) do (SET "TAG=%%g")
FOR /F "tokens=* USEBACKQ" %%g IN (`git describe --tags --dirty --exclude latest --exclude preview --exclude legacy --exclude previous-latest`) do (SET "TAG=%%g")
FOR /F "tokens=* USEBACKQ" %%g IN (`git log -1 --date=iso8601-strict "--format=%%cd"`) do (SET "CDATE=%%g")
SET SIGNATURELINE=// %HASH% %BRANCH% %TAG% %CDATE%

View File

@ -12,7 +12,7 @@ fi
HASH=$(git rev-parse HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD | tr -d '\r\n')
TAG=$(git describe --tags --dirty --exclude latest --exclude preview --exclude legacy --exclude play-store-release | tr -d '\r\n')
TAG=$(git describe --tags --dirty --exclude latest --exclude preview --exclude legacy --exclude previous-latest | tr -d '\r\n')
DATE=$(git log -1 --date=iso8601-strict --format=%cd)
cd $CURDIR