aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobTheBob <32057864+BobTheBob9@users.noreply.github.com>2021-08-14 18:30:36 +0100
committerBobTheBob <32057864+BobTheBob9@users.noreply.github.com>2021-08-14 18:30:36 +0100
commitf63b853468225e2bc675cde9484a27acfe8548b5 (patch)
tree931a0fc039d40a81897f3b2f1ee81473ff32a1a4
parentf425377e5b15dc97ce8caa484b3e282ec5df529c (diff)
downloadNorthstarLauncher-f63b853468225e2bc675cde9484a27acfe8548b5.tar.gz
NorthstarLauncher-f63b853468225e2bc675cde9484a27acfe8548b5.zip
add authentication with local server
-rw-r--r--NorthstarDedicatedTest/concommand.cpp11
-rw-r--r--NorthstarDedicatedTest/gameutils.cpp3
-rw-r--r--NorthstarDedicatedTest/gameutils.h3
-rw-r--r--NorthstarDedicatedTest/masterserver.cpp134
-rw-r--r--NorthstarDedicatedTest/masterserver.h4
-rw-r--r--NorthstarDedicatedTest/scriptserverbrowser.cpp28
-rw-r--r--NorthstarDedicatedTest/serverauthentication.cpp63
-rw-r--r--NorthstarDedicatedTest/serverauthentication.h10
-rw-r--r--NorthstarDedicatedTest/tier0.cpp13
-rw-r--r--NorthstarDedicatedTest/tier0.h4
10 files changed, 261 insertions, 12 deletions
diff --git a/NorthstarDedicatedTest/concommand.cpp b/NorthstarDedicatedTest/concommand.cpp
index d97ef2e5..2db46aa2 100644
--- a/NorthstarDedicatedTest/concommand.cpp
+++ b/NorthstarDedicatedTest/concommand.cpp
@@ -1,5 +1,6 @@
#include "pch.h"
#include "concommand.h"
+#include "gameutils.h"
#include <iostream>
typedef void(*ConCommandConstructorType)(ConCommand* newCommand, const char* name, void(*callback)(const CCommand&), const char* helpString, int flags, void* parent);
@@ -14,7 +15,17 @@ void RegisterConCommand(const char* name, void(*callback)(const CCommand&), cons
conCommandConstructor(newCommand, name, callback, helpString, flags, nullptr);
}
+void SetPlaylistCommand(const CCommand& args)
+{
+ if (args.ArgC() < 2)
+ return;
+
+ SetCurrentPlaylist(args.Arg(1));
+}
+
void InitialiseConCommands(HMODULE baseAddress)
{
conCommandConstructor = (ConCommandConstructorType)((char*)baseAddress + 0x415F60);
+
+ RegisterConCommand("setplaylist", SetPlaylistCommand, "", FCVAR_NONE);
} \ No newline at end of file
diff --git a/NorthstarDedicatedTest/gameutils.cpp b/NorthstarDedicatedTest/gameutils.cpp
index fc6b2dc7..add2639c 100644
--- a/NorthstarDedicatedTest/gameutils.cpp
+++ b/NorthstarDedicatedTest/gameutils.cpp
@@ -1,6 +1,7 @@
#include "pch.h"
#include "gameutils.h"
#include "convar.h"
+#include "concommand.h"
// cmd.h
Cbuf_GetCurrentPlayerType Cbuf_GetCurrentPlayer;
@@ -14,6 +15,7 @@ ConVar* Cvar_hostport;
// playlist stuff
GetCurrentPlaylistType GetCurrentPlaylistName;
+SetCurrentPlaylistType SetCurrentPlaylist;
// uid
char* g_LocalPlayerUserID;
@@ -28,6 +30,7 @@ void InitialiseEngineGameUtilFunctions(HMODULE baseAddress)
Cvar_hostport = (ConVar*)((char*)baseAddress + 0x13FA6070);
GetCurrentPlaylistName = (GetCurrentPlaylistType)((char*)baseAddress + 0x18C640);
+ SetCurrentPlaylist = (SetCurrentPlaylistType)((char*)baseAddress + 0x18EB20);
g_LocalPlayerUserID = (char*)baseAddress + 0x13F8E688;
}
diff --git a/NorthstarDedicatedTest/gameutils.h b/NorthstarDedicatedTest/gameutils.h
index ff987467..b637dfa7 100644
--- a/NorthstarDedicatedTest/gameutils.h
+++ b/NorthstarDedicatedTest/gameutils.h
@@ -84,6 +84,9 @@ extern ConVar* Cvar_hostport;
typedef const char*(*GetCurrentPlaylistType)();
extern GetCurrentPlaylistType GetCurrentPlaylistName;
+typedef void(*SetCurrentPlaylistType)(const char* playlistName);
+extern SetCurrentPlaylistType SetCurrentPlaylist;
+
// uid
extern char* g_LocalPlayerUserID;
diff --git a/NorthstarDedicatedTest/masterserver.cpp b/NorthstarDedicatedTest/masterserver.cpp
index f5ba55f0..aecf2f1a 100644
--- a/NorthstarDedicatedTest/masterserver.cpp
+++ b/NorthstarDedicatedTest/masterserver.cpp
@@ -171,6 +171,103 @@ void MasterServerManager::RequestServerList()
requestThread.detach();
}
+void MasterServerManager::AuthenticateWithOwnServer(char* uid, char* playerToken)
+{
+ // dont wait, just stop if we're trying to do 2 auth requests at once
+ if (m_authenticatingWithGameServer)
+ return;
+
+ m_authenticatingWithGameServer = true;
+ m_scriptAuthenticatingWithGameServer = true;
+ m_successfullyAuthenticatedWithGameServer = false;
+
+ std::thread requestThread([this, uid, playerToken]()
+ {
+ httplib::Client http(Cvar_ns_masterserver_hostname->m_pszString, Cvar_ns_masterserver_port->m_nValue);
+ http.set_connection_timeout(20);
+
+ if (auto result = http.Post(fmt::format("/client/auth_with_self?id={}&playerToken={}", uid, playerToken).c_str()))
+ {
+ m_successfullyConnected = true;
+
+ rapidjson::Document authInfoJson;
+ authInfoJson.Parse(result->body.c_str());
+
+ if (authInfoJson.HasParseError())
+ {
+ spdlog::error("Failed reading masterserver authentication response: encountered parse error \"{}\"", rapidjson::GetParseError_En(authInfoJson.GetParseError()));
+ goto REQUEST_END_CLEANUP;
+ }
+
+ if (!authInfoJson.IsObject())
+ {
+ spdlog::error("Failed reading masterserver authentication response: root object is not an object");
+ goto REQUEST_END_CLEANUP;
+ }
+
+ if (authInfoJson.HasMember("error"))
+ {
+ spdlog::error("Failed reading masterserver response: got fastify error response");
+ spdlog::error(result->body);
+ goto REQUEST_END_CLEANUP;
+ }
+
+ if (!authInfoJson["success"].IsTrue())
+ {
+ spdlog::error("Authentication with masterserver failed: \"success\" is not true");
+ goto REQUEST_END_CLEANUP;
+ }
+
+ if (!authInfoJson.HasMember("success") || !authInfoJson.HasMember("id") || !authInfoJson["id"].IsString() || !authInfoJson.HasMember("authToken") || !authInfoJson["authToken"].IsString() || !authInfoJson.HasMember("persistentData") || !authInfoJson["persistentData"].IsArray())
+ {
+ spdlog::error("Failed reading masterserver authentication response: malformed json object");
+ goto REQUEST_END_CLEANUP;
+ }
+
+ AuthData newAuthData;
+ strncpy(newAuthData.uid, authInfoJson["id"].GetString(), sizeof(newAuthData.uid));
+ newAuthData.uid[sizeof(newAuthData.uid) - 1] = 0;
+
+ newAuthData.pdataSize = authInfoJson["persistentData"].GetArray().Size();
+ newAuthData.pdata = new char[newAuthData.pdataSize];
+ //memcpy(newAuthData.pdata, authInfoJson["persistentData"].GetString(), newAuthData.pdataSize);
+
+ int i = 0;
+ // note: persistentData is a uint8array because i had problems getting strings to behave, it sucks but it's just how it be unfortunately
+ // potentially refactor later
+ for (auto& byte : authInfoJson["persistentData"].GetArray())
+ {
+ if (!byte.IsUint() || byte.GetUint() > 255)
+ {
+ spdlog::error("Failed reading masterserver authentication response: malformed json object");
+ goto REQUEST_END_CLEANUP;
+ }
+
+ newAuthData.pdata[i++] = byte.GetUint();
+ }
+
+ std::lock_guard<std::mutex> guard(g_ServerAuthenticationManager->m_authDataMutex);
+ g_ServerAuthenticationManager->m_authData.clear();
+ g_ServerAuthenticationManager->m_authData.insert(std::make_pair(authInfoJson["authToken"].GetString(), newAuthData));
+
+ m_successfullyAuthenticatedWithGameServer = true;
+ }
+ else
+ {
+ spdlog::error("Failed authenticating with own server: error {}", result.error());
+ m_successfullyConnected = false;
+ m_successfullyAuthenticatedWithGameServer = false;
+ m_scriptAuthenticatingWithGameServer = false;
+ }
+
+ REQUEST_END_CLEANUP:
+ m_authenticatingWithGameServer = false;
+ m_scriptAuthenticatingWithGameServer = false;
+ });
+
+ requestThread.detach();
+}
+
void MasterServerManager::AuthenticateWithServer(char* uid, char* playerToken, char* serverId, char* password)
{
// dont wait, just stop if we're trying to do 2 auth requests at once
@@ -183,6 +280,10 @@ void MasterServerManager::AuthenticateWithServer(char* uid, char* playerToken, c
std::thread requestThread([this, uid, playerToken, serverId, password]()
{
+ // esnure that any persistence saving is done, so we know masterserver has newest
+ while (m_savingPersistentData)
+ Sleep(100);
+
httplib::Client http(Cvar_ns_masterserver_hostname->m_pszString, Cvar_ns_masterserver_port->m_nValue);
http.set_connection_timeout(20);
@@ -385,6 +486,39 @@ void MasterServerManager::UpdateServerPlayerCount(int playerCount)
requestThread.detach();
}
+void MasterServerManager::WritePlayerPersistentData(char* playerId, char* pdata, size_t pdataSize)
+{
+ // dont call this if we don't have a server id
+ if (!*m_ownServerId)
+ return;
+
+ m_savingPersistentData = true;
+
+ std::string playerIdTemp(playerId);
+ std::thread requestThread([this, playerIdTemp, pdata, pdataSize] {
+ httplib::Client http(Cvar_ns_masterserver_hostname->m_pszString, Cvar_ns_masterserver_port->m_nValue);
+ http.set_connection_timeout(10);
+
+ httplib::MultipartFormDataItems requestItems = {
+ { "pdata", std::string(&pdata[0], pdataSize), "file.pdata", "application/octet-stream"}
+ };
+
+ // we dont process this at all atm, maybe do later, but atm not necessary
+ if (auto result = http.Post(fmt::format("/accounts/write_persistence?id={}", playerIdTemp).c_str(), requestItems))
+ {
+ m_successfullyConnected = true;
+ }
+ else
+ {
+ m_successfullyConnected = false;
+ }
+
+ m_savingPersistentData = false;
+ });
+
+ requestThread.detach();
+}
+
void MasterServerManager::RemoveSelfFromServerList()
{
// dont call this if we don't have a server id
diff --git a/NorthstarDedicatedTest/masterserver.h b/NorthstarDedicatedTest/masterserver.h
index 17c77a02..ca2df356 100644
--- a/NorthstarDedicatedTest/masterserver.h
+++ b/NorthstarDedicatedTest/masterserver.h
@@ -37,6 +37,7 @@ class MasterServerManager
private:
bool m_requestingServerList = false;
bool m_authenticatingWithGameServer = false;
+ bool m_savingPersistentData = false;
public:
char m_ownServerId[33];
@@ -55,11 +56,12 @@ public:
public:
void ClearServerList();
void RequestServerList();
+ void AuthenticateWithOwnServer(char* uid, char* playerToken);
void AuthenticateWithServer(char* uid, char* playerToken, char* serverId, char* password);
void AddSelfToServerList(int port, int authPort, char* name, char* description, char* map, char* playlist, int maxPlayers, char* password);
void UpdateServerMapAndPlaylist(char* map, char* playlist);
void UpdateServerPlayerCount(int playerCount);
- void WritePlayerPersistentData(char* playerId, char* pdata);
+ void WritePlayerPersistentData(char* playerId, char* pdata, size_t pdataSize);
void RemoveSelfFromServerList();
};
diff --git a/NorthstarDedicatedTest/scriptserverbrowser.cpp b/NorthstarDedicatedTest/scriptserverbrowser.cpp
index 5db5b719..022aa582 100644
--- a/NorthstarDedicatedTest/scriptserverbrowser.cpp
+++ b/NorthstarDedicatedTest/scriptserverbrowser.cpp
@@ -3,6 +3,7 @@
#include "squirrel.h"
#include "masterserver.h"
#include "gameutils.h"
+#include "serverauthentication.h"
// functions for viewing server browser
@@ -175,6 +176,11 @@ SQInteger SQ_TryAuthWithServer(void* sqvm)
return 0;
}
+ // send off persistent data first, don't worry about server/client stuff, since m_additionalPlayerData should only have entries when we're a local server
+ // note: this seems like it could create a race condition, test later
+ for (auto& pair : g_ServerAuthenticationManager->m_additionalPlayerData)
+ g_ServerAuthenticationManager->WritePersistentData(pair.first);
+
// do auth
g_MasterServerManager->AuthenticateWithServer(g_LocalPlayerUserID, (char*)"", g_MasterServerManager->m_remoteServers[serverIndex].id, (char*)password);
@@ -215,6 +221,25 @@ SQInteger SQ_ConnectToAuthedServer(void* sqvm)
return 0;
}
+// void function NSTryAuthWithLocalServer()
+SQInteger SQ_TryAuthWithLocalServer(void* sqvm)
+{
+ // do auth request
+ g_MasterServerManager->AuthenticateWithOwnServer(g_LocalPlayerUserID, (char*)"");
+
+ return 0;
+}
+
+// void function NSCompleteAuthWithLocalServer()
+SQInteger SQ_CompleteAuthWithLocalServer(void* sqvm)
+{
+ // literally just set serverfilter
+ // note: this assumes we have no authdata other than our own
+ Cbuf_AddText(Cbuf_GetCurrentPlayer(), fmt::format("serverfilter {}", g_ServerAuthenticationManager->m_authData.begin()->first).c_str(), cmd_source_t::kCommandSrcCode);
+
+ return 0;
+}
+
void InitialiseScriptServerBrowser(HMODULE baseAddress)
{
g_UISquirrelManager->AddFuncRegistration("void", "NSRequestServerList", "", "", SQ_RequestServerList);
@@ -236,4 +261,7 @@ void InitialiseScriptServerBrowser(HMODULE baseAddress)
g_UISquirrelManager->AddFuncRegistration("bool", "NSIsAuthenticatingWithServer", "", "", SQ_IsAuthComplete);
g_UISquirrelManager->AddFuncRegistration("bool", "NSWasAuthSuccessful", "", "", SQ_WasAuthSuccessful);
g_UISquirrelManager->AddFuncRegistration("void", "NSConnectToAuthedServer", "", "", SQ_ConnectToAuthedServer);
+
+ g_UISquirrelManager->AddFuncRegistration("void", "NSTryAuthWithLocalServer", "", "", SQ_TryAuthWithLocalServer);
+ g_UISquirrelManager->AddFuncRegistration("void", "NSCompleteAuthWithLocalServer", "", "", SQ_CompleteAuthWithLocalServer);
} \ No newline at end of file
diff --git a/NorthstarDedicatedTest/serverauthentication.cpp b/NorthstarDedicatedTest/serverauthentication.cpp
index 9df23502..68d98455 100644
--- a/NorthstarDedicatedTest/serverauthentication.cpp
+++ b/NorthstarDedicatedTest/serverauthentication.cpp
@@ -4,6 +4,7 @@
#include "hookutils.h"
#include "masterserver.h"
#include "httplib.h"
+#include "tier0.h"
#include <fstream>
#include <filesystem>
#include <thread>
@@ -172,7 +173,7 @@ void ServerAuthenticationManager::WritePersistentData(void* player)
// we use 0x4 internally to mark clients as using remote persistence
if (*((char*)player + 0x4A0) == (char)0x4)
{
-
+ g_MasterServerManager->WritePlayerPersistentData((char*)player + 0xF500, (char*)player + 0x4FA, m_additionalPlayerData[player].pdataSize);
}
else if (CVar_ns_auth_allow_insecure_write->m_nValue)
{
@@ -183,8 +184,6 @@ void ServerAuthenticationManager::WritePersistentData(void* player)
// auth hooks
-int playerCount = 0; // temp
-
// store these in vars so we can use them in CBaseClient::Connect
// this is fine because ptrs won't decay by the time we use this, just don't use it outside of cbaseclient::connect
char* nextPlayerToken;
@@ -209,7 +208,14 @@ char CBaseClient__ConnectHook(void* self, char* name, __int64 netchan_ptr_arg, c
else if (!g_ServerAuthenticationManager->AuthenticatePlayer(self, nextPlayerUid, nextPlayerToken))
CBaseClient__Disconnect(self, 1, "Authentication Failed");
- playerCount++;
+ if (!g_ServerAuthenticationManager->m_additionalPlayerData.count(self))
+ {
+ AdditionalPlayerData additionalData;
+ additionalData.pdataSize = g_ServerAuthenticationManager->m_authData[nextPlayerToken].pdataSize;
+ additionalData.usingLocalPdata = *((char*)self + 0x4a0) == (char)0x3;
+
+ g_ServerAuthenticationManager->m_additionalPlayerData.insert(std::make_pair(self, additionalData));
+ }
return ret;
}
@@ -221,7 +227,7 @@ void CBaseClient__ActivatePlayerHook(void* self)
if (*((char*)self + 0x4A0) >= (char)0x3 && !g_ServerAuthenticationManager->RemovePlayerAuthData(self))
{
g_ServerAuthenticationManager->WritePersistentData(self);
- g_MasterServerManager->UpdateServerPlayerCount(playerCount);
+ g_MasterServerManager->UpdateServerPlayerCount(g_ServerAuthenticationManager->m_additionalPlayerData.size());
}
CBaseClient__ActivatePlayer(self);
@@ -237,11 +243,20 @@ void CBaseClient__DisconnectHook(void* self, uint32_t unknownButAlways1, const c
vsprintf(buf, reason, va);
va_end(va);
- // dcing, write persistent data
- g_ServerAuthenticationManager->WritePersistentData(self);
- g_ServerAuthenticationManager->RemovePlayerAuthData(self); // won't do anything 99% of the time, but just in case
- g_MasterServerManager->UpdateServerPlayerCount(playerCount = std::max(playerCount - 1, 0));
+ // this reason is used while connecting to a local server, hacky, but just ignore it
+ if (strcmp(reason, "Connection closing"))
+ {
+ // dcing, write persistent data
+ g_ServerAuthenticationManager->WritePersistentData(self);
+ g_ServerAuthenticationManager->RemovePlayerAuthData(self); // won't do anything 99% of the time, but just in case
+ }
+
+ if (g_ServerAuthenticationManager->m_additionalPlayerData.count(self))
+ {
+ g_ServerAuthenticationManager->m_additionalPlayerData.erase(self);
+ g_MasterServerManager->UpdateServerPlayerCount(g_ServerAuthenticationManager->m_additionalPlayerData.size());
+ }
CBaseClient__Disconnect(self, unknownButAlways1, buf);
}
@@ -249,6 +264,26 @@ void CBaseClient__DisconnectHook(void* self, uint32_t unknownButAlways1, const c
// maybe this should be done outside of auth code, but effort to refactor rn and it sorta fits
char CGameClient__ExecuteStringCommandHook(void* self, uint32_t unknown, const char* pCommandString)
{
+ if (CVar_sv_quota_stringcmdspersecond->m_nValue != -1)
+ {
+ // note: this isn't super perfect, legit clients can trigger it in lobby, mostly good enough tho imo
+ // https://github.com/perilouswithadollarsign/cstrike15_src/blob/f82112a2388b841d72cb62ca48ab1846dfcc11c8/engine/sv_client.cpp#L1513
+ if (Plat_FloatTime() - g_ServerAuthenticationManager->m_additionalPlayerData[self].lastClientCommandQuotaStart >= 1.0f)
+ {
+ // reset quota
+ g_ServerAuthenticationManager->m_additionalPlayerData[self].lastClientCommandQuotaStart = Plat_FloatTime();
+ g_ServerAuthenticationManager->m_additionalPlayerData[self].numClientCommandsInQuota = 0;
+ }
+
+ g_ServerAuthenticationManager->m_additionalPlayerData[self].numClientCommandsInQuota++;
+ if (g_ServerAuthenticationManager->m_additionalPlayerData[self].numClientCommandsInQuota > CVar_sv_quota_stringcmdspersecond->m_nValue)
+ {
+ // too many stringcmds, dc player
+ CBaseClient__Disconnect(self, 1, "Sent too many stringcmd commands");
+ return false;
+ }
+ }
+
// todo later, basically just limit to CVar_sv_quota_stringcmdspersecond->m_nValue stringcmds per client per second
return CGameClient__ExecuteStringCommand(self, unknown, pCommandString);
}
@@ -260,7 +295,7 @@ void InitialiseServerAuthentication(HMODULE baseAddress)
CVar_ns_auth_allow_insecure = RegisterConVar("ns_auth_allow_insecure", "0", FCVAR_GAMEDLL, "Whether this server will allow unauthenicated players to connect");
CVar_ns_auth_allow_insecure_write = RegisterConVar("ns_auth_allow_insecure_write", "0", FCVAR_GAMEDLL, "Whether the pdata of unauthenticated clients will be written to disk when changed");
// literally just stolen from a fix valve used in csgo
- CVar_sv_quota_stringcmdspersecond = RegisterConVar("sv_quota_stringcmdspersecond", "40", FCVAR_NONE, "How many string commands per second clients are allowed to submit, 0 to disallow all string commands");
+ CVar_sv_quota_stringcmdspersecond = RegisterConVar("sv_quota_stringcmdspersecond", "60", FCVAR_NONE, "How many string commands per second clients are allowed to submit, 0 to disallow all string commands");
Cvar_ns_player_auth_port = RegisterConVar("Cvar_ns_player_auth_port", "8081", FCVAR_GAMEDLL, "");
HookEnabler hook;
@@ -287,4 +322,12 @@ void InitialiseServerAuthentication(HMODULE baseAddress)
*((char*)ptr + 5) = (char)0x90; // nop extra byte we no longer use
}
+
+ // patch to allow same of multiple account
+ {
+ void* ptr = (char*)baseAddress + 0x114510;
+ TempReadWrite rw(ptr);
+ *((char*)ptr) = (char)0xEB; // jz => jmp
+ }
+
} \ No newline at end of file
diff --git a/NorthstarDedicatedTest/serverauthentication.h b/NorthstarDedicatedTest/serverauthentication.h
index b26ce2b2..38008e9b 100644
--- a/NorthstarDedicatedTest/serverauthentication.h
+++ b/NorthstarDedicatedTest/serverauthentication.h
@@ -13,6 +13,15 @@ struct AuthData
size_t pdataSize;
};
+struct AdditionalPlayerData
+{
+ bool usingLocalPdata;
+ size_t pdataSize;
+
+ double lastClientCommandQuotaStart = 0;
+ int numClientCommandsInQuota = 0;
+};
+
class ServerAuthenticationManager
{
private:
@@ -21,6 +30,7 @@ private:
public:
std::mutex m_authDataMutex;
std::unordered_map<std::string, AuthData> m_authData;
+ std::unordered_map<void*, AdditionalPlayerData> m_additionalPlayerData;
bool m_runningPlayerAuthThread = false;
public:
diff --git a/NorthstarDedicatedTest/tier0.cpp b/NorthstarDedicatedTest/tier0.cpp
index 717432bf..ad533a9c 100644
--- a/NorthstarDedicatedTest/tier0.cpp
+++ b/NorthstarDedicatedTest/tier0.cpp
@@ -73,4 +73,17 @@ void Error(const char* fmt, ...)
// tier0Func(buf);
//else
std::cout << "FATAL ERROR " << buf << std::endl;
+}
+
+typedef double(*Tier0FloatTime)();
+double Plat_FloatTime()
+{
+ Tier0FloatTime tier0Func = (Tier0FloatTime)ResolveTier0Function("Plat_FloatTime");
+
+ if (tier0Func)
+ {
+ return tier0Func();
+ }
+ else
+ return 0.0f;
} \ No newline at end of file
diff --git a/NorthstarDedicatedTest/tier0.h b/NorthstarDedicatedTest/tier0.h
index 97a2d753..07d91fe5 100644
--- a/NorthstarDedicatedTest/tier0.h
+++ b/NorthstarDedicatedTest/tier0.h
@@ -24,4 +24,6 @@ void operator delete(void* p) throw();
// actual function defs
// would've liked to resolve these at compile time, but we load before tier0 so not really possible
-void Error(const char* fmt, ...); \ No newline at end of file
+void Error(const char* fmt, ...);
+
+double Plat_FloatTime(); \ No newline at end of file