-
Notifications
You must be signed in to change notification settings - Fork 345
/
ActivityWatcher.cs
389 lines (309 loc) · 16 KB
/
ActivityWatcher.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
namespace Bloxstrap.Integrations
{
public class ActivityWatcher : IDisposable
{
private const string GameMessageEntry = "[FLog::Output] [BloxstrapRPC]";
private const string GameJoiningEntry = "[FLog::Output] ! Joining game";
// these entries are technically volatile!
// they only get printed depending on their configured FLog level, which could change at any time
// while levels being changed is fairly rare, please limit the number of varying number of FLog types you have to use, if possible
private const string GameTeleportingEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToPlace";
private const string GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer";
private const string GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer";
private const string GameJoiningUniverseEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:";
private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = ";
private const string GameJoinedEntry = "[FLog::Network] serverId:";
private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:";
private const string GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal";
private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)";
private const string GameJoiningPrivateServerPattern = @"""accessCode"":""([0-9a-f\-]{36})""";
private const string GameJoiningUniversePattern = @"universeid:([0-9]+).*userid:([0-9]+)";
private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+";
private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+";
private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)";
private int _logEntriesRead = 0;
private bool _teleportMarker = false;
private bool _reservedTeleportMarker = false;
public event EventHandler<string>? OnLogEntry;
public event EventHandler? OnGameJoin;
public event EventHandler? OnGameLeave;
public event EventHandler? OnLogOpen;
public event EventHandler? OnAppClose;
public event EventHandler<Message>? OnRPCMessage;
private DateTime LastRPCRequest;
public string LogLocation = null!;
public bool InGame = false;
public ActivityData Data { get; private set; } = new();
/// <summary>
/// Ordered by newest to oldest
/// </summary>
public List<ActivityData> History = new();
public bool IsDisposed = false;
public ActivityWatcher(string? logFile = null)
{
if (!String.IsNullOrEmpty(logFile))
LogLocation = logFile;
}
public async void Start()
{
const string LOG_IDENT = "ActivityWatcher::Start";
// okay, here's the process:
//
// - tail the latest log file from %localappdata%\roblox\logs
// - check for specific lines to determine player's game activity as shown below:
//
// - get the place id, job id and machine address from '! Joining game '{{JOBID}}' place {{PLACEID}} at {{MACHINEADDRESS}}' entry
// - confirm place join with 'serverId: {{MACHINEADDRESS}}|{{MACHINEPORT}}' entry
// - check for leaves/disconnects with 'Time to disconnect replication data: {{TIME}}' entry
//
// we'll tail the log file continuously, monitoring for any log entries that we need to determine the current game activity
FileInfo logFileInfo;
if (String.IsNullOrEmpty(LogLocation))
{
string logDirectory = Path.Combine(Paths.LocalAppData, "Roblox\\logs");
if (!Directory.Exists(logDirectory))
return;
// we need to make sure we're fetching the absolute latest log file
// if roblox doesn't start quickly enough, we can wind up fetching the previous log file
// good rule of thumb is to find a log file that was created in the last 15 seconds or so
App.Logger.WriteLine(LOG_IDENT, "Opening Roblox log file...");
while (true)
{
logFileInfo = new DirectoryInfo(logDirectory)
.GetFiles()
.Where(x => x.Name.Contains("Player", StringComparison.OrdinalIgnoreCase) && x.CreationTime <= DateTime.Now)
.OrderByDescending(x => x.CreationTime)
.First();
if (logFileInfo.CreationTime.AddSeconds(15) > DateTime.Now)
break;
App.Logger.WriteLine(LOG_IDENT, $"Could not find recent enough log file, waiting... (newest is {logFileInfo.Name})");
await Task.Delay(1000);
}
LogLocation = logFileInfo.FullName;
}
else
{
logFileInfo = new FileInfo(LogLocation);
}
OnLogOpen?.Invoke(this, EventArgs.Empty);
var logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
App.Logger.WriteLine(LOG_IDENT, $"Opened {LogLocation}");
using var streamReader = new StreamReader(logFileStream);
while (!IsDisposed)
{
string? log = await streamReader.ReadLineAsync();
if (log is null)
await Task.Delay(1000);
else
ReadLogEntry(log);
}
}
private void ReadLogEntry(string entry)
{
const string LOG_IDENT = "ActivityWatcher::ReadLogEntry";
OnLogEntry?.Invoke(this, entry);
_logEntriesRead += 1;
// debug stats to ensure that the log reader is working correctly
// if more than 1000 log entries have been read, only log per 100 to save on spam
if (_logEntriesRead <= 1000 && _logEntriesRead % 50 == 0)
App.Logger.WriteLine(LOG_IDENT, $"Read {_logEntriesRead} log entries");
else if (_logEntriesRead % 100 == 0)
App.Logger.WriteLine(LOG_IDENT, $"Read {_logEntriesRead} log entries");
if (entry.Contains(GameLeavingEntry))
{
App.Logger.WriteLine(LOG_IDENT, "User is back into the desktop app");
OnAppClose?.Invoke(this, EventArgs.Empty);
if (Data.PlaceId != 0 && !InGame)
{
App.Logger.WriteLine(LOG_IDENT, "User appears to be leaving from a cancelled/errored join");
Data = new();
}
}
if (!InGame && Data.PlaceId == 0)
{
// We are not in a game, nor are in the process of joining one
if (entry.Contains(GameJoiningPrivateServerEntry))
{
// we only expect to be joining a private server if we're not already in a game
Data.ServerType = ServerType.Private;
var match = Regex.Match(entry, GameJoiningPrivateServerPattern);
if (match.Groups.Count != 2)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join private server entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
Data.AccessCode = match.Groups[1].Value;
}
else if (entry.Contains(GameJoiningEntry))
{
Match match = Regex.Match(entry, GameJoiningEntryPattern);
if (match.Groups.Count != 4)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game join entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
InGame = false;
Data.PlaceId = long.Parse(match.Groups[2].Value);
Data.JobId = match.Groups[1].Value;
Data.MachineAddress = match.Groups[3].Value;
if (App.Settings.Prop.ShowServerDetails && Data.MachineAddressValid)
_ = Data.QueryServerLocation();
if (_teleportMarker)
{
Data.IsTeleport = true;
_teleportMarker = false;
}
if (_reservedTeleportMarker)
{
Data.ServerType = ServerType.Reserved;
_reservedTeleportMarker = false;
}
App.Logger.WriteLine(LOG_IDENT, $"Joining Game ({Data})");
}
}
else if (!InGame && Data.PlaceId != 0)
{
// We are not confirmed to be in a game, but we are in the process of joining one
if (entry.Contains(GameJoiningUniverseEntry))
{
var match = Regex.Match(entry, GameJoiningUniversePattern);
if (match.Groups.Count != 3)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join universe entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
Data.UniverseId = Int64.Parse(match.Groups[1].Value);
Data.UserId = Int64.Parse(match.Groups[2].Value);
if (History.Any())
{
var lastActivity = History.First();
if (Data.UniverseId == lastActivity.UniverseId && Data.IsTeleport)
Data.RootActivity = lastActivity.RootActivity ?? lastActivity;
}
}
else if (entry.Contains(GameJoiningUDMUXEntry))
{
var match = Regex.Match(entry, GameJoiningUDMUXPattern);
if (match.Groups.Count != 3 || match.Groups[2].Value != Data.MachineAddress)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join UDMUX entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
Data.MachineAddress = match.Groups[1].Value;
if (App.Settings.Prop.ShowServerDetails)
_ = Data.QueryServerLocation();
App.Logger.WriteLine(LOG_IDENT, $"Server is UDMUX protected ({Data})");
}
else if (entry.Contains(GameJoinedEntry))
{
Match match = Regex.Match(entry, GameJoinedEntryPattern);
if (match.Groups.Count != 2 || match.Groups[1].Value != Data.MachineAddress)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game joined entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
App.Logger.WriteLine(LOG_IDENT, $"Joined Game ({Data})");
InGame = true;
Data.TimeJoined = DateTime.Now;
OnGameJoin?.Invoke(this, EventArgs.Empty);
}
}
else if (InGame && Data.PlaceId != 0)
{
// We are confirmed to be in a game
if (entry.Contains(GameDisconnectedEntry))
{
App.Logger.WriteLine(LOG_IDENT, $"Disconnected from Game ({Data})");
Data.TimeLeft = DateTime.Now;
History.Insert(0, Data);
InGame = false;
Data = new();
OnGameLeave?.Invoke(this, EventArgs.Empty);
}
else if (entry.Contains(GameTeleportingEntry))
{
App.Logger.WriteLine(LOG_IDENT, $"Initiating teleport to server ({Data})");
_teleportMarker = true;
}
else if (entry.Contains(GameJoiningReservedServerEntry))
{
_teleportMarker = true;
_reservedTeleportMarker = true;
}
else if (entry.Contains(GameMessageEntry))
{
var match = Regex.Match(entry, GameMessageEntryPattern);
if (match.Groups.Count != 2)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for RPC message entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
string messagePlain = match.Groups[1].Value;
Message? message;
App.Logger.WriteLine(LOG_IDENT, $"Received message: '{messagePlain}'");
if ((DateTime.Now - LastRPCRequest).TotalSeconds <= 1)
{
App.Logger.WriteLine(LOG_IDENT, "Dropping message as ratelimit has been hit");
return;
}
try
{
message = JsonSerializer.Deserialize<Message>(messagePlain);
}
catch (Exception)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)");
return;
}
if (message is null)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)");
return;
}
if (string.IsNullOrEmpty(message.Command))
{
App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (Command is empty)");
return;
}
if (message.Command == "SetLaunchData")
{
string? data;
try
{
data = message.Data.Deserialize<string>();
}
catch (Exception)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)");
return;
}
if (data is null)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)");
return;
}
if (data.Length > 200)
{
App.Logger.WriteLine(LOG_IDENT, "Data cannot be longer than 200 characters");
return;
}
Data.RPCLaunchData = data;
}
OnRPCMessage?.Invoke(this, message);
LastRPCRequest = DateTime.Now;
}
}
}
public void Dispose()
{
IsDisposed = true;
GC.SuppressFinalize(this);
}
}
}