using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; namespace codelanguage { public partial class NetworkServer { private byte[]? _resourceBundleCache; private string? _resourceBundleCachePath; private DateTime _resourceBundleCacheWriteTime; private Interpreter? _tappedInterpreter; private Action? _outputTapHandler; /// /// Mirrors the hosted interpreter's output to connected clients. World-global /// output (world << msg) broadcasts to every authenticated client; /// atom-targeted output (usr << msg) reaches only the owning /// client's connection. Idempotent per interpreter and detached on stop. /// public void AttachOutputTap(Interpreter interpreter) { ArgumentNullException.ThrowIfNull(interpreter); if (ReferenceEquals(_tappedInterpreter, interpreter)) return; DetachOutputTap(); _outputTapHandler = HandleWorldOutput; interpreter.OutputEmitted += _outputTapHandler; _tappedInterpreter = interpreter; } private void DetachOutputTap() { if (_tappedInterpreter != null && _outputTapHandler != null) _tappedInterpreter.OutputEmitted -= _outputTapHandler; _tappedInterpreter = null; _outputTapHandler = null; } private void HandleWorldOutput(string text, CraftValue? target) { if (_disposed || string.IsNullOrWhiteSpace(text)) return; var clients = _connections.GetAuthenticatedClients(); if (clients.Count == 0) return; string payload = text.Length > 4096 ? text[..4096] : text; var message = new NetworkMessage(MessageType.Output) { Data = { ["text"] = payload } }; if (target == null) { foreach (var client in clients) SendToClient(client.ClientId, message); return; } int? ownerClientId = TryResolveOutputOwner(target); if (ownerClientId is int clientId) SendToClient(clientId, message); // Unowned target (world.log, a client-less atom): BYOND drops it for // network clients too — the local OutputCallback already recorded it. } /// Maps an output target atom to the connection whose player owns it: /// replica ownership first, then the reverse player-atom lookup; client atoms /// resolve through their mob. private int? TryResolveOutputOwner(CraftValue target) { CraftValue atom = target; if (TypeRegistry.IsSubtypeOf(atom.Type, "/client")) { var mob = atom.GetVar("mob"); if (mob != null && mob.Type != "null") atom = mob; } lock (_worldSyncLock) { if (_worldObjectIds.TryGetValue(atom, out int replicaId)) { var replica = _objects.GetObject(replicaId); if (replica?.OwnerClientId is int owner && owner > 0) return owner; } } foreach (var pair in _clientPlayerAtoms) { if (ReferenceEquals(pair.Value, atom)) return pair.Key; } return null; } /// /// Queues the world's .rsc resource cache for one client as a chunked /// ResourceBundleStart/Chunk/End sequence. Called right after the login /// response so the FIFO outgoing queue delivers login first. The bundle /// bytes are cached and re-read only when the file changes on disk. /// private void SendResourceBundle(int clientId) { string? path = ResourceBundlePath; if (string.IsNullOrWhiteSpace(path)) return; byte[] bytes = LoadResourceBundle(path); if (bytes == null) return; string world = Path.GetFileNameWithoutExtension(path); foreach (NetworkMessage message in ResourceBundleTransfer.BuildMessages(bytes, world)) SendToClient(clientId, message); } private byte[]? LoadResourceBundle(string path) { try { if (!File.Exists(path)) return null; var info = new FileInfo(path); if (_resourceBundleCache == null || !string.Equals(_resourceBundleCachePath, path, StringComparison.Ordinal) || _resourceBundleCacheWriteTime != info.LastWriteTimeUtc) { byte[] loaded = File.ReadAllBytes(path); if (loaded.Length > ResourceBundleTransfer.MaxBundleBytes) { Trace.TraceWarning("Resource bundle exceeds the transfer limit and will not be sent: {0}", path); return null; } _resourceBundleCache = loaded; _resourceBundleCachePath = path; _resourceBundleCacheWriteTime = info.LastWriteTimeUtc; } return _resourceBundleCache; } catch (Exception ex) { Trace.TraceWarning("Resource bundle could not be read: {0}", ex.Message); return null; } } private void RouteAuthenticatedMessage(int clientId, NetworkMessage message) { switch (message.Type) { case MessageType.Heartbeat: { int latency = GetInt32(message, "latency"); if (latency == 0 && message.Data.TryGetValue("latencyMs", out var latencyMilliseconds)) latency = GetInt32(message, "latencyMs"); _connections.UpdateHeartbeat(clientId, Math.Max(0, latency)); } break; case MessageType.Chat: var now = DateTime.UtcNow; if (_lastChat.TryGetValue(clientId, out var last) && now - last < TimeSpan.FromMilliseconds(300)) break; _lastChat[clientId] = now; var text = message.Data.TryGetValue("message", out var chat) ? chat?.ToString()?.Trim() ?? "" : ""; if (text.Length == 0 || text.Length > 512) break; Broadcast(new NetworkMessage(MessageType.Chat, clientId) { Data = new() { ["message"] = text, ["username"] = _connections.GetConnection(clientId)?.Username ?? "Player" } }); break; case MessageType.Movement: HandleMovement(clientId, message); break; case MessageType.ObjectMove: HandleObjectMove(clientId, message); break; case MessageType.VariableChanged: HandleVariableChanged(clientId, message); break; case MessageType.ProcCall: HandleProcedureCall(clientId, message); break; case MessageType.Disconnect: DisconnectClient(clientId); break; default: break; } } private void HandleMovement(int clientId, NetworkMessage message) { float x = GetFloat(message, "x"); float y = GetFloat(message, "y"); float z = GetFloat(message, "z"); int objectId; lock (_clientPlayerObjects) { if (!_clientPlayerObjects.TryGetValue(clientId, out objectId)) return; } var player = _objects.GetObject(objectId); if (player == null) return; if (message.Data.ContainsKey("direction")) { var now = DateTime.UtcNow; if (_lastMovement.TryGetValue(clientId, out var last) && now - last < TimeSpan.FromMilliseconds(80)) return; _lastMovement[clientId] = now; int direction = GetInt32(message, "direction"); if (direction is not (1 or 2 or 4 or 8)) return; player.Direction = direction; if (_clientPlayerAtoms.TryGetValue(clientId, out var atom)) { atom.SetVar("dir", CraftValue.FromNumber(direction)); BuiltinProcs.Execute("step", new List { atom, CraftValue.FromNumber(direction) }); x = (float)(atom.GetVar("x")?.ToNumber() ?? player.X); y = (float)(atom.GetVar("y")?.ToNumber() ?? player.Y); z = (float)(atom.GetVar("z")?.ToNumber() ?? player.Z); } else { x = player.X + (direction == 4 ? 1 : direction == 8 ? -1 : 0); y = player.Y + (direction == 1 ? 1 : direction == 2 ? -1 : 0); z = player.Z; } } else if (ServerCertificate != null) return; // Remote players send input, not authoritative positions. if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z)) return; _objects.MoveObject(objectId, x, y, z); _objects.UpdateClientPosition(clientId, x, y); } private void HandleObjectMove(int clientId, NetworkMessage message) { int objectId = GetInt32(message, "objectId"); if (objectId <= 0) return; var replicatedObject = _objects.GetObject(objectId); if (replicatedObject == null) return; if (ServerCertificate != null || replicatedObject.OwnerClientId != clientId) { SendToClient(clientId, new NetworkMessage(MessageType.Disconnect, clientId) { Data = new Dictionary { { "reason", "object_ownership_violation" } } }); return; } float x = GetFloat(message, "x"); float y = GetFloat(message, "y"); float z = GetFloat(message, "z"); int duration = GetInt32(message, "duration"); _objects.MoveObject(objectId, x, y, z, duration); _objects.UpdateClientPosition(clientId, x, y); } private void HandleVariableChanged(int clientId, NetworkMessage message) { int objectId = GetInt32(message, "objectId"); string variableName = message.Data.TryGetValue("variableName", out var variableValue) ? variableValue?.ToString() ?? string.Empty : string.Empty; if (objectId <= 0 || string.IsNullOrWhiteSpace(variableName)) return; var replicatedObject = _objects.GetObject(objectId); if (replicatedObject == null) return; if (ServerCertificate != null || replicatedObject.OwnerClientId != clientId) { return; } _objects.UpdateObjectVariable(objectId, variableName, message.Data.TryGetValue("value", out var value) ? value : null); _objects.BroadcastObjectUpdate(objectId); } private void HandleProcedureCall(int clientId, NetworkMessage message) { long callId = GetInt64(message, "callId"); string procName = message.Data.TryGetValue("procName", out var procValue) ? procValue?.ToString() ?? string.Empty : string.Empty; if (string.IsNullOrWhiteSpace(procName)) return; int targetObjectId = GetInt32(message, "targetObjectId"); if (targetObjectId > 0 && _objects.GetObject(targetObjectId) == null) { SendToClient(clientId, _rpc.CreateErrorMessage(callId, $"Target object not found: {targetObjectId}")); return; } object[] arguments = ParseRpcArguments(message.Data.TryGetValue("args", out var argumentValue) ? argumentValue : string.Empty); // World-backed dispatch: run the DM proc in the hosted interpreter so // remote verbs behave like local ones (BYOND executes verbs server-side). if (PlayerProcedureHandler is { } worldHandler) { CraftValue? src = ResolveProcSource(clientId, targetObjectId); if (src == null) { SendToClient(clientId, _rpc.CreateErrorMessage(callId, targetObjectId > 0 ? $"Target object not found: {targetObjectId}" : "No player is bound to this connection.")); return; } CraftValue usr = _clientPlayerAtoms.TryGetValue(clientId, out var playerAtom) ? playerAtom : CraftValue.Null(); var craftArgs = arguments.Select(ToCraftValueArgument).ToList(); _ = InvokeWorldProcedureAsync(clientId, callId, worldHandler, procName, src, usr, craftArgs); return; } RpcResponse response = _rpc.Invoke(callId, procName, arguments); NetworkMessage reply = response.Success ? _rpc.CreateReturnMessage(response.CallId == 0 ? callId : response.CallId, response.ReturnValue) : _rpc.CreateErrorMessage(callId, response.ErrorMessage ?? "Unknown RPC error"); SendToClient(clientId, reply); } /// Resolves the src atom for a remote ProcCall: the caller's player /// mob for verbs (target 0 or the player's own object id) or a world atom /// for object-targeted calls such as Click. private CraftValue? ResolveProcSource(int clientId, int targetObjectId) { if (targetObjectId <= 0) return _clientPlayerAtoms.TryGetValue(clientId, out var fallback) ? fallback : null; int playerObjectId = 0; lock (_clientPlayerObjects) { _clientPlayerObjects.TryGetValue(clientId, out playerObjectId); } if (playerObjectId != 0 && playerObjectId == targetObjectId) return _clientPlayerAtoms.TryGetValue(clientId, out var player) ? player : null; lock (_worldSyncLock) { foreach (var pair in _worldObjectIds) { if (pair.Value == targetObjectId) return pair.Key; } } return null; } private async Task InvokeWorldProcedureAsync(int clientId, long callId, Func, Task> handler, string procName, CraftValue src, CraftValue usr, List args) { try { CraftValue result = await handler(src, usr, procName, args).ConfigureAwait(false); SendToClient(clientId, _rpc.CreateReturnMessage(callId, ToRpcResult(result))); } catch (Exception ex) { SendToClient(clientId, _rpc.CreateErrorMessage(callId, ex.Message)); } } /// Collects the non-hidden verbs of an atom's type hierarchy for /// the LoginResponse, mirroring the Seeker's local verb panel discovery. private static List CollectVerbMetadata(CraftValue atom) { var verbs = new List(); var seen = new HashSet(StringComparer.Ordinal); string? current = atom.Type; while (current != null) { var definition = TypeRegistry.Get(current); if (definition == null) break; foreach (var pair in definition.Verbs) { if (!seen.Add(pair.Key)) continue; var proc = pair.Value; if (proc.VerbMetadata.TryGetValue("hidden", out var hidden) && hidden.IsTruthy()) continue; string category = proc.VerbMetadata.TryGetValue("category", out var cat) ? cat.ToString() ?? "" : ""; verbs.Add(new Dictionary { ["name"] = pair.Key, ["category"] = category, ["parameters"] = proc.Parameters.Select(parameter => new Dictionary { ["name"] = parameter.Value, ["type"] = proc.ParameterTypes.TryGetValue(parameter.Value, out var type) ? type : "" }).ToList() }); } current = definition.ParentPath; } return verbs; } private static CraftValue ToCraftValueArgument(object? value) => value switch { null => CraftValue.Null(), bool flag => CraftValue.FromNumber(flag ? 1 : 0), int number => CraftValue.FromNumber(number), long number => CraftValue.FromNumber(number), float number => CraftValue.FromNumber(number), double number => CraftValue.FromNumber(number), string text => CraftValue.FromString(text), _ => CraftValue.FromString(value.ToString() ?? string.Empty) }; private static object ToRpcResult(CraftValue value) { if (value.Type is "num" or "bool") return value.ToNumber(); if (value.Type == "list") return string.Join(",", value.ListItems.Select(item => item.ToString())); if (value.Type == "null") return string.Empty; return value.ToString(); } private void CleanupDeadConnection(int clientId) { _objects.HandleClientDisconnect(clientId); DisconnectClient(clientId); } private static int GetInt32(NetworkMessage message, string name) { if (!message.Data.TryGetValue(name, out var rawValue)) return 0; if (rawValue is int intValue) return intValue; if (rawValue is long longValue) return longValue >= int.MinValue && longValue <= int.MaxValue ? (int)longValue : 0; if (rawValue is double doubleValue) return checked((int)Math.Round(doubleValue)); return int.TryParse(rawValue?.ToString(), out int parsedValue) ? parsedValue : 0; } private static long GetInt64(NetworkMessage message, string name) { if (!message.Data.TryGetValue(name, out var rawValue)) return 0; if (rawValue is long longValue) return longValue; if (rawValue is int intValue) return intValue; if (rawValue is double doubleValue) return checked((long)Math.Round(doubleValue)); return long.TryParse(rawValue?.ToString(), out long parsedValue) ? parsedValue : 0; } private static float GetFloat(NetworkMessage message, string name) { if (!message.Data.TryGetValue(name, out var rawValue)) return 0f; if (rawValue is float floatValue) return floatValue; if (rawValue is double doubleValue) return (float)doubleValue; if (rawValue is int intValue) return intValue; if (rawValue is long longValue) return longValue; return float.TryParse(rawValue?.ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float parsedValue) ? parsedValue : 0f; } private static object[] ParseRpcArguments(object? rawValue) { if (rawValue == null) return Array.Empty(); if (rawValue is IEnumerable arguments) return arguments.ToArray(); string text = rawValue.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); return text.Split(',') .Select(part => int.TryParse(part.Trim(), out int integer) ? (object)integer : part.Trim()) .ToArray(); } } }