[Linux-only] basically bloxstap for sober
1import { ChildProcess } from "child_process";
2import { LOGFILE_PATH, RECENT_LOG_THRESHOLD_SECONDS } from "../constants";
3import { open } from "fs/promises";
4import path, { join } from "path";
5import { getMostRecentFile } from "../Utils";
6import type {
7 Message,
8 GameJoinAction,
9 PlrJoinLeaveAction,
10 BloxstrapRPCAction
11} from "../types";
12import { ServerType } from "../types";
13import { eventCollector } from "../EventCollector";
14
15function escapeRegExp(s: string) {
16 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17}
18
19// 2024-08-25T19:16:40.287Z,68.287468,b2e006c0,6 [FLog::Network] UDMUX Address = XXX.XXX.XX.XX, Port = XXXXX | RCC Server Address = XX.XX.X.XXX, Port = XXXXX
20const timestampRegExp =
21 /(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z,\d+\.\d+,[a-z0-9]+,\d+) /;
22
23function removeTimestamp(s: string) {
24 return s.replace(timestampRegExp, "");
25}
26
27// Regular Expressions and String Matches
28// https://github.com/pizzaboxer/bloxstrap/blob/main/Bloxstrap/Integrations/ActivityWatcher.cs
29
30const GameJoiningEntry = "[FLog::Output] ! Joining game";
31const GameJoiningPrivateServerEntry =
32 "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer";
33const GameJoiningReservedServerEntry =
34 "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer";
35const GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = ";
36const GameJoinedEntry = "[FLog::Network] serverId:";
37const GameDisconnectedEntry =
38 "[FLog::Network] Time to disconnect replication data:";
39const GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport";
40const GameMessageEntry = "[FLog::Output] [BloxstrapRPC]";
41const GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal";
42
43const GamePlayerJoinLeaveEntry = "[ExpChat/mountClientApp (Trace)] - Player ";
44const GameMessageLogEntry =
45 "[ExpChat/mountClientApp (Debug)] - Incoming MessageReceived Status: ";
46
47const GameJoiningEntryPattern =
48 /! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)/;
49const GameJoiningUDMUXPattern =
50 /UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+/;
51const GameJoinedEntryPattern = /serverId: ([0-9\.]+)\|[0-9]+/;
52const GameMessageEntryPattern = /\[BloxstrapRPC\] (.*)/;
53
54const GamePlayerJoinLeavePattern = /(added|removed): (.*) (.*[0-9])/;
55const GameMessageLogPattern = /Success Text: (.*)/;
56
57// The Real Thing
58// Most of it was taken from https://github.com/ocbwoy3/sober-bloxstraprpc-wrapper/tree/main/src/ActivityWatcher.ts
59
60export class ActivityWatcher {
61 public _teleportMarker: boolean = false;
62 public _reservedTeleportMarker: boolean = false;
63
64 public ActivityInGame: boolean = false;
65 public ActivityPlaceId: number = 0;
66 public ActivityJobId: string = "";
67 public ActivityMachineAddress: string = "";
68 public ActivityMachineUDMUX: boolean = false;
69 public ActivityIsTeleport: boolean = false;
70 public ActivityServerType: ServerType = ServerType.PUBLIC;
71
72 // OnGameJoin - Player joined the game
73 // OnGameLeave - Player left the game
74 // Message - BloxstrapRPC Message
75
76 private roblox: ChildProcess | undefined;
77 private lastChunk: string = "";
78
79 constructor(
80 process: ChildProcess,
81 public readonly options: {
82 verbose: boolean;
83 tuxstrapLaunchTime: number;
84 }
85 ) {
86 this.roblox = process;
87 console.log(
88 "[ActivityWatcher]",
89 `Obtained Roblox's Process with PID ${this.roblox.pid}`
90 );
91 }
92
93 private async onStdout(line_: string): Promise<void> {
94 const line: string = removeTimestamp(line_);
95 if (this.options.verbose)
96 console.log("\x1b[2m[STDOUT] %s\x1b[0m", line.toString());
97 if (!this.ActivityInGame && this.ActivityPlaceId === 0) {
98 if (line.includes(GameJoiningPrivateServerEntry)) {
99 this.ActivityServerType = ServerType.PRIVATE;
100 }
101
102 if (line.includes(GameJoiningEntry)) {
103 const match: RegExpMatchArray = line.match(
104 GameJoiningEntryPattern
105 ) as RegExpMatchArray;
106 match.splice(0, 1);
107 // console.debug("includes(GameJoiningEntry)",line,match);
108
109 if (match.length !== 3) {
110 console.error(
111 "[ActivityWatcher]",
112 "Failed to assert format for game join entry"
113 );
114 console.error("[ActivityWatcher]", line);
115 return;
116 }
117
118 this.ActivityInGame = false;
119 this.ActivityPlaceId = Number.parseInt(match[1] || "0");
120 this.ActivityJobId = match[0] || "";
121 this.ActivityMachineAddress = match[2] || "";
122
123 if (this._teleportMarker || this._reservedTeleportMarker) {
124 eventCollector.emitTeleport({
125 serverType: this._reservedTeleportMarker
126 ? ServerType.RESERVED
127 : ServerType.PUBLIC
128 });
129 }
130
131 if (this._teleportMarker) {
132 this.ActivityIsTeleport = true;
133 this._teleportMarker = false;
134 }
135
136 if (this._reservedTeleportMarker) {
137 this.ActivityServerType = ServerType.RESERVED;
138 this._reservedTeleportMarker = false;
139 }
140
141 console.log(
142 "[ActivityWatcher]",
143 `Joining Game (${this.ActivityPlaceId}/${this.ActivityJobId}/${this.ActivityMachineAddress})`
144 );
145 }
146 } else if (!this.ActivityInGame && this.ActivityPlaceId !== 0) {
147 if (line.includes(GameJoiningUDMUXEntry)) {
148 const match: RegExpMatchArray = line.match(
149 GameJoiningUDMUXPattern
150 ) as RegExpMatchArray;
151 match.splice(0, 1);
152 // console.debug("includes(GameJoiningUDMUXEntry)",line,match);
153
154 if (
155 match.length !== 2 ||
156 match[1] !== this.ActivityMachineAddress
157 ) {
158 console.error(
159 "[ActivityWatcher]",
160 "Failed to assert format for game join UDMUX entry"
161 );
162 console.error("[ActivityWatcher]", line);
163 return;
164 }
165
166 this.ActivityMachineAddress = match[0] || "";
167 this.ActivityMachineUDMUX = true;
168
169 console.log(
170 "[ActivityWatcher]",
171 `Server is UDMUX protected (${this.ActivityPlaceId}/${this.ActivityJobId}/${this.ActivityMachineAddress})`
172 );
173 } else if (line.includes(GameJoinedEntry)) {
174 const match: RegExpMatchArray = line.match(
175 GameJoinedEntryPattern
176 ) as RegExpMatchArray;
177 match.splice(0, 1);
178 // console.debug("includes(GameJoinedEntry)",line,match);
179
180 if (
181 match.length !== 1 ||
182 match[0] !== this.ActivityMachineAddress
183 ) {
184 // console.debug("includes(GameJoinedEntry)",match.length,match,this.ActivityMachineAddress)
185 console.error(
186 "[ActivityWatcher]",
187 "Failed to assert format for game joined entry"
188 );
189 console.error("[ActivityWatcher]", line);
190 return;
191 }
192
193 this.ActivityInGame = true;
194
195 // Emit game join event using global event collector
196 const gameJoinData: GameJoinAction = {
197 ipAddr: this.ActivityMachineAddress,
198 placeId: this.ActivityPlaceId.toString(),
199 jobId: this.ActivityJobId,
200 serverType: this.ActivityServerType,
201 ipAddrUdmux: this.ActivityMachineUDMUX
202 ? this.ActivityMachineAddress
203 : undefined
204 };
205 eventCollector.emitGameJoin(gameJoinData);
206
207 console.log(
208 "[ActivityWatcher]",
209 `Joined Game (${this.ActivityPlaceId}/${this.ActivityJobId}/${this.ActivityMachineAddress})`
210 );
211 }
212 } else if (this.ActivityInGame && this.ActivityPlaceId !== 0) {
213 if (line.includes(GameDisconnectedEntry)) {
214 console.log(
215 "[ActivityWatcher]",
216 `Disconnected from Game (${this.ActivityPlaceId}/${this.ActivityJobId}/${this.ActivityMachineAddress})`
217 );
218
219 this.ActivityInGame = false;
220 this.ActivityPlaceId = 0;
221 this.ActivityJobId = "";
222 this.ActivityMachineAddress = "";
223 this.ActivityMachineUDMUX = false;
224 this.ActivityIsTeleport = false;
225 this.ActivityServerType = ServerType.PUBLIC;
226
227 // Emit game leave event using global event collector
228 eventCollector.emitGameLeave();
229 } else if (line.includes(GameTeleportingEntry)) {
230 console.log(
231 "[ActivityWatcher]",
232 `Initiating teleport to server (${this.ActivityPlaceId}/${this.ActivityJobId}/${this.ActivityMachineAddress})`
233 );
234 this._teleportMarker = true;
235 } else if (
236 this._teleportMarker &&
237 line.includes(GameJoiningReservedServerEntry)
238 ) {
239 this._reservedTeleportMarker = true;
240 } else if (line.includes(GameMessageEntry)) {
241 const match: RegExpMatchArray = line.match(
242 GameMessageEntryPattern
243 ) as RegExpMatchArray;
244 match.splice(0, 1);
245
246 let message: Message | undefined;
247
248 try {
249 message = JSON.parse(match[0] || "{}");
250 } catch (e_) {
251 console.error(
252 "[ActivityWatcher]",
253 "Failed to parse BloxstrapRPC Message! (JSON deserialization threw an exception)"
254 );
255 console.error("[ActivityWatcher]", e_);
256 return;
257 }
258
259 if (!message) {
260 console.warn("[ActivityWatcher]", "Parsed JSON is null!");
261 return;
262 }
263
264 try {
265 // Emit BloxstrapRPC event using global event collector
266 const rpcData: BloxstrapRPCAction = {
267 type: message.command,
268 data: message.data
269 };
270 eventCollector.emitBloxstrapRPC(rpcData);
271 } catch {}
272 } else if (line.includes(GamePlayerJoinLeaveEntry)) {
273 const match: RegExpMatchArray = line.match(
274 GamePlayerJoinLeavePattern
275 ) as RegExpMatchArray;
276 match.splice(0, 1);
277
278 // Emit player join/leave event using global event collector
279 const playerAction: PlrJoinLeaveAction = {
280 name: match[1] || "",
281 id: match[2] || "",
282 action: match[0] === "added" ? "JOIN" : "LEAVE"
283 };
284
285 if (playerAction.action === "JOIN") {
286 eventCollector.emitPlayerJoin(playerAction);
287 } else {
288 eventCollector.emitPlayerLeave(playerAction);
289 }
290 } else if (line.includes(GameMessageLogEntry)) {
291 const match: RegExpMatchArray = line.match(
292 GameMessageLogPattern
293 ) as RegExpMatchArray;
294 match.splice(0, 1);
295
296 // Note: Chat messages are not part of the standard event system
297 // They can be handled separately if needed
298 console.log("[ActivityWatcher] Chat message:", match[0]);
299 }
300 }
301 }
302
303 public async getLogfile(): Promise<string> {
304 if (!this.roblox)
305 throw new Error("ActivityWatcher.roblox is undefined!");
306
307 console.log(
308 "[ActivityWatcher]",
309 `Finding Roblox's most recent logfile in: ${LOGFILE_PATH}`
310 );
311
312 const MAX_ATTEMPTS = 25;
313 const ATTEMPT_WAIT = 500;
314 let attempts = 0;
315
316 while (true) {
317 console.log(
318 "[ActivityWatcher]",
319 `Obtaining log file (attempt ${
320 attempts + 1
321 } of ${MAX_ATTEMPTS})`
322 );
323 const latestFile: { file: string; mtime: Date } | undefined =
324 getMostRecentFile(LOGFILE_PATH);
325
326 if (
327 latestFile &&
328 Date.now() - latestFile.mtime.getTime() <=
329 RECENT_LOG_THRESHOLD_SECONDS * 1000
330 ) {
331 if (
332 latestFile.mtime.getTime() < this.options.tuxstrapLaunchTime
333 ) {
334 } else {
335 return join(LOGFILE_PATH + latestFile.file);
336 }
337 }
338
339 await new Promise((resolve) => setTimeout(resolve, ATTEMPT_WAIT));
340 attempts++;
341 if (attempts > MAX_ATTEMPTS - 1) {
342 console.error(
343 "[ActivityWatcher]",
344 `Cannot find Roblox's newest logfile!`
345 );
346 this.roblox.kill("SIGKILL");
347 process.exit(1);
348 }
349 }
350 }
351
352 public async stdoutWatcher(): Promise<void> {
353 if (!this.roblox) throw `activityWatcher.roblox is undefined!`;
354 if (!this.roblox.stdout) {
355 console.error("[ActivityWatcher]", `Roblox doesn't have stdout!`);
356 this.roblox.kill("SIGKILL");
357 process.exit(1);
358 }
359
360 const robloxLogfile = await this.getLogfile();
361 const logHandle = await open(robloxLogfile, "r+");
362 console.log(
363 "[ActivityWatcher]",
364 `Obtained r+ logfile handle: ${robloxLogfile}`
365 );
366
367 // Log obtained - this event is not part of the standard event system
368 console.log(
369 "[ActivityWatcher] Log file obtained and ready for monitoring"
370 );
371
372 try {
373 let position = 0;
374 let line = "";
375
376 while (true) {
377 const bytesRead = await logHandle.read(
378 Buffer.alloc(1),
379 0,
380 1,
381 position
382 );
383 if (bytesRead.buffer.toString().charCodeAt(0) === 0) {
384 await new Promise((resolve) => setTimeout(resolve, 100));
385 } else {
386 const newChar = bytesRead.buffer.toString();
387 position += 1;
388 if (newChar === "\n") {
389 const line2 = line;
390 line = "";
391 this.onStdout(line2).catch((reason: string) => {
392 console.error(
393 "[ActivityWatcher]",
394 "onStdout promise rejected:",
395 reason
396 );
397 });
398 } else {
399 line += newChar;
400 }
401 }
402 }
403 } catch (e_) {
404 console.error("[ActivityWatcher]", "Failed to read from handle!");
405 console.error(e_);
406 } finally {
407 logHandle.close();
408 }
409 }
410}