import { Instance } from "./Instance.js"; import { BaseService } from "./BaseService.js"; import { sha256, encoders } from "../core/util.js"; // Since SubtleCrypto isnt available in http only, we use a JS implementation export class ReplicatorService extends BaseService { constructor(datamodel, network) { super("ReplicatorService"); // Get tickloop from RenderService this._renderService = datamodel.GetService("RenderService"); if (!this._renderService) { throw new Error("ReplicatorService requires RenderService in DataModel"); } this._networkService = network; if (!this._networkService) { throw new Error("ReplicatorService requires NetworkService in DataModel"); } this.bytesSent = 0; // For client stats this.bytesReceived = 0; // For client stats this.frameCounter = 0; // To prevent spamming the replication, run it every 10 frames if (!this._networkService.isServer) { this._renderService.Heartbeat.Connect((dt) => this._onHeartbeatCli(dt)); this._networkService.registerHandler(0x00, 0x02, (payload) => { this.bytesReceived += payload.byteLength + 2; // +2 for ptype and psub const data = JSON.parse(new TextDecoder().decode(payload)); // Find instance by InstanceId using FindInstanceRecursively on datamodel const inst = this.Parent.FindInstanceRecursively(data.InstanceId); if (!inst) { // Instance not found, create a new one import(`./${data.ClassName}.js`).then((module) => { const NewClass = module[data.ClassName]; if (!NewClass) { console.warn(`ReplicatorService: Class ${data.ClassName} not found for replication.`); return; } const newInst = new NewClass(); newInst.InstanceId = data.InstanceId; // Set the same InstanceId newInst.Name = data.Name; // Set properties for (const [key, value] of Object.entries(data.Properties)) { if (value && typeof value === 'object' && value._type && encoders[value._type]) { newInst[key] = encoders[value._type].decode(value.value); } else { newInst[key] = value; } } newInst.IsReplicated = true; // Mark as replicated to disable physics etc if needed console.debug('ReplicatorService: Received new instance!'); // Set parent if (data.ParentId) { // Find parent by InstanceId using FindInstanceRecursively const parentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId); if (parentInst) { newInst.SetParent(parentInst); } else { console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found. Double check server code.`); } newInst.OnReplicated(); // Call hook for when instance is replicated } else { console.warn(`ReplicatorService: No ParentId provided for instance ${data.InstanceId}! Double check server code.`); } console.log(`ReplicatorService: Created new instance ${newInst.Name} (${newInst.InstanceId}) from server.`); }).catch(err => { console.error(`ReplicatorService: Failed to load class ${data.ClassName} for replication.`, err); }); } else { // Instance found, update properties for (const [key, value] of Object.entries(data.Properties)) { if (value && typeof value === 'object' && value._type && encoders[value._type]) { inst[key] = encoders[value._type].decode(value.value); } else { inst[key] = value; } } // Handle destruction if ParentId is not provided (instance destroyed) if (!data.ParentId) { // Remove instance from parent/children and data model if (typeof inst.Destroy === 'function') { inst.Destroy(); } console.log(`ReplicatorService: Instance ${inst.Name} (${inst.InstanceId}) destroyed (ParentId is nil).`); return; } // Update parent if changed if (data.ParentId) { if (!inst.Parent || inst.Parent.InstanceId !== data.ParentId) { // Find new parent by InstanceId using FindInstanceRecursively const newParentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId); if (newParentInst) { inst.SetParent(newParentInst); } else { console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found for existing instance. Double check server code.`); } } } else { console.warn(`ReplicatorService: No ParentId provided for existing instance ${data.InstanceId}! Double check server code.`); } inst.OnReplicated(); // Call hook for when instance is replicated //console.log(`ReplicatorService: Updated instance ${inst.Name} (${inst.InstanceId}) from server.`); } }); } else { this._renderService.Heartbeat.Connect((dt) => this._onHeartbeat(dt)); this._networkService.registerHandler(0x01, 0x02, (payload,ws) => { }); // Server side replicator reciever, will be used later for client to server replication // aka what roblox does for certain instances like under the player character } this.SetParent(datamodel); this.lastClearedStatCheck = 0; // If above 1 second, clear stats console.log("ReplicatorService initialized."); } // Client-side heartbeat, receive updates from server _onHeartbeatCli(delta) { this.lastClearedStatCheck += delta; if (this.lastClearedStatCheck >= 1) { console.log(`Client stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`); this.bytesSent = 0; this.bytesReceived = 0; this.lastClearedStatCheck = 0; } } _onHeartbeat(delta) { // Clear stats every second this.lastClearedStatCheck += delta; if (this.lastClearedStatCheck >= 1) { console.log(`Server stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`); this.bytesSent = 0; this.bytesReceived = 0; this.lastClearedStatCheck = 0; } this.frameCounter += delta; if (this.frameCounter < (1/30)) { // Run replication at ~30fps max return; } else { this.frameCounter = 0; }; // For each instance in valid services, replicate its state to clients. Keep old state hash to avoid spamming unchanged data. const toReplicate = []; // Get workspace and its children const workspace = this.Parent.GetService("Workspace"); const players = this.Parent.GetService("Players"); if (workspace) { toReplicate.push(...workspace.Children); } if (players) { toReplicate.push(...players.Children); // Replicate player instances too // (so players can see each other) } // Send over the pipe for (const inst of toReplicate) { // Check property hashes to see if anything changed let hashState = ""; for (const key of Object.keys(inst)) { if (key === "Parent" || key === "Children") continue; let val; try { val = JSON.stringify(inst[key]); } catch { val = "[unserializable]"; } hashState += `${key}:${val};`; } const hash = sha256(hashState); if (inst._lastHash !== hash) { // Something changed, replicate inst._lastHash = hash; const payload = { InstanceId: inst.InstanceId, ClassName: inst.ClassName, Name: inst.Name, Properties: {}, ParentId: inst.Parent ? inst.Parent.InstanceId : null, }; for (const key of Object.keys(inst)) { // Skip internals and heavy engine data if ( key.startsWith("_") || // private/internal fields key === "Parent" || key === "Children" || key === "mesh" || key === "body" || key === "renderer" || key === "scene" || key === "controls" || key === "physicsWorld" || key === "loader" || key === "LuaState" // Script Lua state ) { continue; } // Get type const val = inst[key]; const type = val && val.constructor ? val.constructor.name : typeof val; if (type === "function" || type === "undefined" || type === "symbol" || type === "BO3ScriptSignal") { continue; // skip non-serializable types } // If we have an encoder, encode and store type info if (encoders[type]) { payload.Properties[key] = { _type: type, value: encoders[type].encode(val) }; } else { payload.Properties[key] = val; } } //console.debug(JSON.stringify(payload)); // Send to all clients let outpay = JSON.stringify(payload); this.bytesSent += outpay.length + 2; // +2 for ptype and psub this._networkService.broadcast(0x00, 0x02, outpay,true,(ws) => { const player = players ? players.getByWs(ws) : null; // Dont send to the player if its their own character if (player && inst.InstanceId === player.InstanceId) { return true; } return false; }); // Refer to ptype and psub definitions to see what these mean // Update the _lastHash property to avoid re-sending unchanged data inst._lastHash = hash; //console.log(`Replicated instance ${inst.Name} (${inst.InstanceId}) to clients.`); } } } requestReplicateDestroy(instance,ignoreIS) { // Server side function to request destruction of an instance on clients if (!this._networkService.isServer) { if (!ignoreIS) console.warn("ReplicatorService: requestReplicateDestroy can only be called on server."); // ignoreIS is for internal server calls to avoid spamming the warning return; } if (!instance || !instance.InstanceId) { console.warn("ReplicatorService: requestReplicateDestroy requires a valid instance with InstanceId."); return; } const payload = { InstanceId: instance.InstanceId, ClassName: instance.ClassName, Name: instance.Name, Properties: {}, // No properties needed for destruction ParentId: null, // Null ParentId indicates destruction }; let outpay = JSON.stringify(payload); this.bytesSent += outpay.length + 2; // +2 for ptype and psub this._networkService.broadcast(0x00, 0x02, outpay); // Refer to ptype and psub definitions to see what these mean console.log(`ReplicatorService: Requested destruction of instance ${instance.Name} (${instance.InstanceId}) on clients.`); } }