VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
WorkshopDownloads.cpp
Go to the documentation of this file.
2
4
12#include <eiface.h>
13#include <iserver.h>
14#include <networkbasetypes.pb.h>
15#include <networksystem/inetworkmessages.h>
16#include <networksystem/netmessage.h>
17#include <string>
18#include <tier1/utlstring.h>
19#include <utility>
20
21namespace VoltMod
22{
23
24/** How soon a client must reconnect for its addon to count as downloaded. */
25static constexpr double DownloadTimeoutSeconds = 30.0;
26/** Offers of one addon before a declining client is dropped. */
27static constexpr int MaxDownloadAttempts = 3;
28/** A player back within this long is not asked to download again. */
29static constexpr double ForgetAfterSeconds = 12.0 * 60.0 * 60.0;
30
34
36{
37 if (addonId == 0)
38 {
39 return false;
40 }
41 if (Status hooked = Install(); !hooked)
42 {
43 Log::Warn("Addons: {} is not sent to clients: {}", addonId, hooked.error().Detail);
44 return false;
45 }
46 return _queue.Add(addonId);
47}
48
50{
51 _queue.Remove(addonId);
52 RemoveHooksIfUnused();
53}
54
56{
57 const bool nothingToCheck = _queue.Empty() || !IsValidSlot(slot) || !_engine;
59 {
60 return true;
61 }
62 return !_queue.HasMissing(static_cast<int64_t>(_engine->GetClientXUID(CPlayerSlot(slot))));
63}
64
72
80
82{
83 for (const Kick& kick : std::exchange(_kicks, {}))
84 {
85 // The slot may have changed hands since.
86 if (static_cast<int64_t>(_engine->GetClientXUID(CPlayerSlot(kick.Slot))) == kick.SteamId)
87 {
88 _engine->DisconnectClient(CPlayerSlot(kick.Slot), NETWORK_DISCONNECT_TIMEDOUT,
89 "Required workshop addon download was declined");
90 }
91 }
92}
93
94Status WorkshopDownloads::Install()
95{
96 if (_joinMessageHook)
97 {
98 return {};
99 }
100
101 // A listen server host needs no download step.
102 if (!_engine || !_engine->IsDedicatedServer())
103 {
104 return std::unexpected(Error::Unsupported("addon downloads need a dedicated server"));
105 }
106 if (!_bindings.ClientSteamId || !_bindings.ServerAddons)
107 {
108 return std::unexpected(Error::Unsupported("the client SteamID or server addons offset did not bind"));
109 }
110
111 const auto onJoinMessage = [this](EngineClient& client, const CNetMessage* message, NetChannelBufType_t) {
112 OnJoinMessage(message, &client);
113 };
114 auto join = HookVirtual("Workshop addon download", _bindings.SendNetMessage, onJoinMessage);
115 if (!join)
116 {
117 return std::unexpected(Error::Unsupported(join.error().Detail));
118 }
119
120 const auto beforeReply = [this](CNetworkGameServerBase& server, EngineClient* client) { AddToReply(server, client); };
121 const auto afterReply = [this](CNetworkGameServerBase& server, EngineClient*) { RestoreReply(server); };
122 auto reply = HookFunction("Workshop addon mount", _bindings.ReplyConnection, beforeReply, afterReply);
123 if (!reply)
124 {
125 return std::unexpected(Error::Unsupported(reply.error().Detail));
126 }
127
128 _joinMessageHook = std::move(*join);
129 _connectionReplyHook = std::move(*reply);
130 return {};
131}
132
133void WorkshopDownloads::RemoveHooksIfUnused()
134{
135 if (!_queue.Empty())
136 {
137 return;
138 }
139
140 _kicks.clear();
141 _joinMessageHook.Reset();
142 _connectionReplyHook.Reset();
143 _addedToReply.clear();
144 _queue.ClearProgress();
145}
146
147/** The server's addon list, or nullptr when the offset misses the string GetAddonName returns. */
149{
150 auto* list = MemberPtr<CUtlString>(&server, bindings.ServerAddons.Value());
151 // An empty list has no buffer to compare, and each side then returns its own "".
152 const char* stored = list->Get();
153 const char* reported = server.GetAddonName();
154 const bool same = stored == reported || (*stored == '\0' && *reported == '\0');
155 if (!same)
156 {
157 Log::Error("Addons: the CNetworkGameServer::m_szAddons offset {} is stale; not mounting addons.",
158 bindings.ServerAddons.Value());
159 return nullptr;
160 }
161 return list;
162}
163
164void WorkshopDownloads::AddToReply(CNetworkGameServerBase& server, const EngineClient* client)
165{
166 const int64_t steamId = _bindings.ClientSteamId.Read(client);
168 {
169 return;
170 }
171
172 const std::vector<uint64_t> mountList = _queue.ClientMountList(steamId);
173 if (mountList.empty())
174 {
175 return;
176 }
177
178 // The client mounts only what the connection reply names.
179 CUtlString* list = AddonList(_bindings, server);
180 if (!list)
181 {
182 return;
183 }
184 std::string field = list->Get();
185 _addedToReply = AppendToAddonList(field, mountList);
186 if (_addedToReply.empty())
187 {
188 return;
189 }
190
191 list->Set(field.c_str());
192 Log::Info("Addons: telling {} to mount {}.", steamId, field);
193}
194
195void WorkshopDownloads::RestoreReply(CNetworkGameServerBase& server)
196{
197 if (_addedToReply.empty())
198 {
199 return;
200 }
201
202 // Only our entries; the map's stay.
203 CUtlString* list = AddonList(_bindings, server);
204 if (!list)
205 {
206 _addedToReply.clear();
207 return;
208 }
209 std::string field = list->Get();
210 RemoveFromAddonList(field, _addedToReply);
211 list->Set(field.c_str());
212 _addedToReply.clear();
213}
214
215void WorkshopDownloads::OnJoinMessage(const CNetMessage* message, void* client)
216{
217 INetworkMessageInternal* info = message ? message->GetNetMessage() : nullptr;
218 if (!info || info->GetNetMessageInfo()->m_MessageId != net_SignonState)
219 {
220 return;
221 }
222
223 const int64_t steamId = _bindings.ClientSteamId.Read(client);
225 {
226 return;
227 }
228
229 // Rewritten in place, before the engine serializes it.
231 const bool reconnect = joinMessage->signon_state() == SIGNONSTATE_CHANGELEVEL;
232 const AddonDecision decision = _queue.DecideJoinMessage(steamId, reconnect, joinMessage->addons(),
234
235 switch (decision.Action)
236 {
238 return;
240 Log::Info("Addons: a reconnect message named {} addons; sending {} and holding the rest.",
241 decision.Remaining + 1, decision.Id);
242 [[fallthrough]];
244 joinMessage->set_addons(std::to_string(decision.Id));
245 return;
247 Log::Warn("Addons: {} did not take addon {} in {} attempts; dropping the client.", steamId, decision.Id,
249 if (const int slot = ClientSlot(_bindings, client); IsValidSlot(slot))
250 {
251 _kicks.push_back({.Slot = slot, .SteamId = steamId});
252 }
253 return;
255 joinMessage->set_addons(std::to_string(decision.Id));
256 joinMessage->set_signon_state(SIGNONSTATE_CHANGELEVEL);
257 Log::Info("Addons: sending addon {} to {} ({} left after it).", decision.Id, steamId, decision.Remaining);
258 return;
259 }
260}
261
262} // namespace VoltMod
Typed read/write of engine fields at a base pointer + byte offset.
bool Add(uint64_t id)
AddonDecision DecideJoinMessage(int64_t steamId, bool reconnect, std::string_view addons, double now, int maxAttempts)
bool HasMissing(int64_t steamId) const
void Remove(uint64_t id)
void ClientReconnected(int64_t steamId, double now, double timeoutSec)
std::vector< uint64_t > ClientMountList(int64_t steamId) const
void ClientLeft(int64_t steamId, double now, double forgetAfterSec)
T Read(const void *base) const
static bool IsValid(int64_t steamId64)
Definition SteamId.cpp:76
static double MonotonicSeconds()
Definition Durations.hpp:29
void OnClientDisconnected(int64_t steamId)
WorkshopDownloads(const Bindings &bindings, const EngineInterfaces &engine)
void Remove(uint64_t addonId) override
bool IsReady(int slot) override
bool Add(uint64_t addonId) override
void OnClientConnected(int64_t steamId)
void Error(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:97
void Info(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:79
void Warn(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:88
static constexpr double ForgetAfterSeconds
Result< Subscription > HookVirtual(std::string_view name, const VirtualFn< Ret(Object *, Args...)> &function, Before &&before, After &&after=nullptr)
Hook a gamedata-bound virtual function on objects sharing its class vtable.
Definition Hook.hpp:168
Result< Subscription > HookFunction(std::string_view name, const Fn< Ret(Object *, Args...)> &function, Before &&before, After &&after=nullptr)
Hook a signature-bound function at its entry.
Definition Hook.hpp:200
int ClientSlot(const Bindings &bindings, const void *client)
static constexpr double DownloadTimeoutSeconds
@ Engine
The engine or SDK refused, returned nothing, or is not where it was expected.
static std::string ReadFile(const std::filesystem::path &path)
Definition Loader.cpp:56
std::vector< uint64_t > AppendToAddonList(std::string &field, const std::vector< uint64_t > &ids)
constexpr bool IsValidSlot(int slot)
Definition Slot.hpp:13
void RemoveFromAddonList(std::string &field, const std::vector< uint64_t > &ids)
static constexpr int MaxDownloadAttempts
std::expected< void, Error > Status
Definition Result.hpp:67
@ TrimToFirst
the message names several addons; keep only AddonDecision::Id
@ Kick
the client refused AddonDecision::Id too often
@ Unchanged
send the message as it is
@ Send
point the client at AddonDecision::Id and wait for its reconnect
@ KeepMounted
a map change names no addon; name AddonDecision::Id so the client keeps it
static CUtlString * AddonList(const Bindings &bindings, CNetworkGameServerBase &server)
VirtualFn< bool(EngineClient *, const CNetMessage *, NetChannelBufType_t)> SendNetMessage
Definition Bindings.hpp:91
Offset< int64_t > ClientSteamId
Definition Bindings.hpp:99
Offset< void > ServerAddons
Definition Bindings.hpp:103
Fn< void(CNetworkGameServerBase *, EngineClient *)> ReplyConnection
Definition Bindings.hpp:67
static Error Unsupported(std::string detail)
Definition Result.hpp:57