[Linux-only] basically bloxstap for sober
at dev 410 lines 12 kB view raw
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}