extends Node class_name NetSim # ═══════════════════════════════════════════════════════════════════════════ # NetSim — Network Condition Simulator for ENet Traffic # # Wraps any MultiplayerPeer (ENet, WebSocket, Steam) and injects simulated # adverse network conditions: # - Packet loss (random % drop on send/receive) # - Latency (fixed delay on all packets) # - Jitter (variable added delay on each packet) # - Packet reordering (swap delivery order within a window) # - Packet duplication (random % duplicate on send) # - Bandwidth throttling (cap bytes per second) # - Burst loss (drop N consecutive packets every M packets) # # Architecture: # NetSim sits BETWEEN the game and the real MultiplayerPeer. # It wraps the peer object and intercepts put_packet() / get_packet(), # applying simulated conditions before forwarding to/from the real peer. # # Usage: # var real_peer = ENetMultiplayerPeer.new() # real_peer.create_server(7777, 16) # var sim = NetSim.new() # sim.wrap_peer(real_peer) # # configure conditions # sim.packet_loss = 0.1 # 10% loss # sim.latency_ms = 150 # 150ms round-trip # sim.jitter_ms = 30 # ±30ms variable delay # get_tree().set_multiplayer(sim.multiplayer_api, "/root/SimMp") # # For test/diagnostic use only — NEVER enable on production servers. # ═══════════════════════════════════════════════════════════════════════════ # ─── Configuration ──────────────────────────────────────────────────────── # All probabilities are 0.0–1.0. All times in milliseconds. ## Probability a packet is silently dropped on send (0.0 = no loss, 1.0 = all lost) @export var packet_loss: float = 0.0 ## Additional fixed one-way latency applied to every packet (ms) @export var latency_ms: float = 0.0 ## Additional random jitter on top of latency (±jitter_ms, ms). ## Actual per-packet delay = latency_ms + randf_range(-jitter_ms, +jitter_ms) @export var jitter_ms: float = 0.0 ## Packet reorder window: number of consecutive packets buffered and shuffled ## before delivery. 0 = no reordering. Higher values = more aggressive reorder. ## (e.g., 3 means shuffle every 3-pack group on the receive side). @export var reorder_window: int = 0 ## Probability a packet is duplicated on send (0.0 = no duplicates, 1.0 = every packet doubled) @export var duplicate_rate: float = 0.0 ## Max outbound bandwidth in bytes/second (0 = unlimited) @export var bandwidth_limit: int = 0 ## Burst loss: drop N consecutive packets every M packets sent. ## Set to 0 to disable burst loss. @export var burst_loss_count: int = 0 @export var burst_loss_interval: int = 0 ## Enable verbose logging of every simulated condition hit @export var verbose: bool = false ## Seed for deterministic simulation (useful for reproducible tests). -1 = random seed. @export var seed_value: int = -1 # ─── Constants ──────────────────────────────────────────────────────────── const CHANNEL_UNRELIABLE: int = 0 const CHANNEL_RELIABLE: int = 1 const CHANNEL_ORDERED: int = 2 # ─── Internal State ─────────────────────────────────────── var _wrapped_peer: MultiplayerPeer = null var _rng: RandomNumberGenerator # Queues of {time: float, packet: PackedByteArray, channel: int} # _outbound_queue: packets from game → network (delayed send) # _inbound_queue: packets from network → game (delayed receive) var _outbound_queue: Array[Dictionary] = [] var _inbound_queue: Array[Dictionary] = [] # Reorder buffer var _reorder_buffer: Array[Dictionary] = [] # Bandwidth tracking var _bandwidth_used_this_second: int = 0 var _bandwidth_timer: float = 0.0 # Burst loss tracking var _packets_sent_since_burst: int = 0 var _burst_dropping: bool = false var _burst_drop_remaining: int = 0 # Debug stats var _total_sent: int = 0 var _total_received: int = 0 var _total_lost: int = 0 var _total_duplicated: int = 0 var _total_reordered: int = 0 var _total_delayed: int = 0 var _total_burst_dropped: int = 0 var _total_bandwidth_dropped: int = 0 # ─── Public API ─────────────────────────────────────────────────────────── func _init() -> void: _rng = RandomNumberGenerator.new() if seed_value >= 0: _rng.seed = seed_value else: _rng.randomize() ## Wrap an existing MultiplayerPeer to inject simulated conditions. ## The wrapped peer's connection state (multiplayer_api) is replaced ## transparently — all game code interacts with NetSim as it would the real peer. func wrap_peer(peer: MultiplayerPeer) -> void: _wrapped_peer = peer print("[NetSim] Wrapped peer: %s" % peer.get_class()) # Configure a custom MultiplayerAPI that delegates to us if not Engine.has_singleton("NetSimDebug"): # Just for logging pass reset_stats() ## Reset all stats counters (keeps configuration intact). func reset_stats() -> void: _total_sent = 0 _total_received = 0 _total_lost = 0 _total_duplicated = 0 _total_reordered = 0 _total_delayed = 0 _total_burst_dropped = 0 _total_bandwidth_dropped = 0 _outbound_queue.clear() _inbound_queue.clear() _reorder_buffer.clear() _bandwidth_used_this_second = 0 _bandwidth_timer = 0.0 _packets_sent_since_burst = 0 _burst_dropping = false _burst_drop_remaining = 0 ## Get debug stats as a formatted string. func get_stats_string() -> String: var lines: PackedStringArray = [] lines.append("=== NetSim Stats ===") lines.append(" Sent: %d" % _total_sent) lines.append(" Received: %d" % _total_received) lines.append(" Lost (loss): %d" % _total_lost) lines.append(" Lost (burst): %d" % _total_burst_dropped) lines.append(" Lost (bw): %d" % _total_bandwidth_dropped) lines.append(" Duplicated: %d" % _total_duplicated) lines.append(" Reordered: %d" % _total_reordered) lines.append(" Delayed: %d" % _total_delayed) var effective_loss = 0.0 var total_accounted = _total_sent + _total_duplicated if total_accounted > 0: effective_loss = float(_total_lost + _total_burst_dropped + _total_bandwidth_dropped) / float(total_accounted) * 100.0 lines.append(" Effective loss: %.1f%%" % effective_loss) lines.append(" Config: loss=%.1f%% lat=%dms jitter=±%dms reorder=%d dupe=%.1f%% bw=%d/s burst=%d/%d" % [ packet_loss * 100.0, int(latency_ms), int(jitter_ms), reorder_window, duplicate_rate * 100.0, bandwidth_limit, burst_loss_count, burst_loss_interval ]) return "\n".join(lines) ## Apply a quick-set of common network profiles. ## "lan" — 0% loss, 1ms latency, 0 jitter ## "dsl" — 0.5% loss, 20ms latency, ±5ms jitter ## "cellular" — 2% loss, 60ms latency, ±20ms jitter ## "satellite" — 1% loss, 600ms latency, ±50ms jitter ## "congested" — 5% loss, 100ms latency, ±40ms jitter, some reordering ## "warzone" — 10% loss, 200ms latency, ±60ms jitter, burst loss, reorder func set_profile(name: String) -> bool: match name.to_lower(): "lan": packet_loss = 0.0; latency_ms = 1; jitter_ms = 0 reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0 burst_loss_count = 0; burst_loss_interval = 0 return true "dsl": packet_loss = 0.005; latency_ms = 20; jitter_ms = 5 reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0 burst_loss_count = 0; burst_loss_interval = 0 return true "cellular": packet_loss = 0.02; latency_ms = 60; jitter_ms = 20 reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0 burst_loss_count = 0; burst_loss_interval = 0 return true "satellite": packet_loss = 0.01; latency_ms = 600; jitter_ms = 50 reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0 burst_loss_count = 0; burst_loss_interval = 0 return true "congested": packet_loss = 0.05; latency_ms = 100; jitter_ms = 40 reorder_window = 3; duplicate_rate = 0.01; bandwidth_limit = 0 burst_loss_count = 0; burst_loss_interval = 0 return true "warzone": packet_loss = 0.10; latency_ms = 200; jitter_ms = 60 reorder_window = 4; duplicate_rate = 0.02; bandwidth_limit = 50000 burst_loss_count = 3; burst_loss_interval = 100 return true _: push_error("[NetSim] Unknown profile: '%s'" % name) return false # ─── Per-Frame Processing ───────────────────────────────── func _process(delta: float) -> void: if not _wrapped_peer: return var now = Time.get_ticks_msec() # --- Process delayed outbound packets --- # Forward expired packets to the real peer var to_send: Array[int] = [] for i in _outbound_queue.size(): if now >= _outbound_queue[i].time: to_send.append(i) # Send in reverse index order so removal doesn't shift earlier indices for i in to_send.size(): var idx = to_send[to_send.size() - 1 - i] var entry = _outbound_queue[idx] _send_to_real_peer(entry.packet, entry.channel) _outbound_queue.remove_at(idx) # --- Process real peer inbound --- # Read packets from the real peer and queue them for the game # with inbound simulation while _wrapped_peer.get_available_packet_count() > 0: var packet = _wrapped_peer.get_packet() _total_received += 1 _queue_delayed_inbound(packet, CHANNEL_RELIABLE) # actual channel unknown # --- Process delayed inbound packets --- # Reorder buffer flush _flush_reorder_buffer() # Note: delayed inbound packets are consumed by receive_packet() directly # from _inbound_queue — no separate delivery step needed ## Must be called from the game's _process (not physics) to keep latency ## timing accurate. Alternative: connect to SceneTree's idle signal. func process_conditions(delta: float) -> void: _process(delta) # ─── Packet Interception (call from game code) ──────────────────────────── # The game should call these instead of directly calling put_packet/get_packet # on the multiplayer peer. Alternatively, implement a full MultiplayerPeer # override — see the companion `net_sim_peer.gd` for the full adapter. ## Simulate sending a packet. Returns true if the packet was queued/dropped. func send_packet(data: PackedByteArray, channel: int = CHANNEL_UNRELIABLE) -> bool: if not _wrapped_peer: return false _total_sent += 1 # ── 1. Burst loss check ── if _check_burst_loss(): _total_burst_dropped += 1 if verbose: print("[NetSim] BURST DROP (packet #%d)" % _total_sent) return false # packet dropped # ── 2. Random loss ── if _rng.randf() < packet_loss: _total_lost += 1 if verbose: print("[NetSim] LOST (packet #%d, seq=%d, loss=%.1f%%)" % [_total_sent, _total_sent, packet_loss * 100.0]) return false # packet dropped # ── 3. Bandwidth check ── if bandwidth_limit > 0 and _check_bandwidth(data.size()): _total_bandwidth_dropped += 1 if verbose: print("[NetSim] BW DROP (packet #%d, %d bytes, limit=%d/s)" % [_total_sent, data.size(), bandwidth_limit]) return false # packet dropped # ── 4. Duplication ── if _rng.randf() < duplicate_rate: _total_duplicated += 1 if verbose: print("[NetSim] DUPLICATE (packet #%d)" % _total_sent) # Send the duplicate (with same delay treatment) _queue_delayed_outbound(data, channel) # ── 5. Queue for latency/jitter ── _queue_delayed_outbound(data, channel) return true ## Simulate receiving a packet. Returns PackedByteArray or empty if none available. ## First checks the inbound delay queue for expired packets, then falls ## through to the real peer for immediate (non-simulated) delivery. func receive_packet() -> PackedByteArray: if not _wrapped_peer: return PackedByteArray() var now = Time.get_ticks_msec() # Check delayed inbound queue first (simulated receive) if _inbound_queue.size() > 0 and _inbound_queue[0].time <= now: var pkt = _inbound_queue[0].packet _inbound_queue.remove_at(0) return pkt # Fall through to real peer for immediate delivery if _wrapped_peer.get_available_packet_count() > 0: return _wrapped_peer.get_packet() return PackedByteArray() ## Check if any packet is available for receiving. ## Checks delayed inbound queue, then real peer. func has_packet() -> bool: if not _wrapped_peer: return false var now = Time.get_ticks_msec() # Check delayed inbound queue for pkt in _inbound_queue: if pkt.time <= now: return true # Check real peer if _wrapped_peer.get_available_packet_count() > 0: return true return false ## Get total pending packet count (inbound and outbound). func get_pending_count() -> int: return _inbound_queue.size() + _outbound_queue.size() ## Check if the wrapped peer is properly connected. func is_peer_connected() -> bool: if _wrapped_peer: return _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED return false # ─── Bandwidth tracking ─────────────────────────────────────────────────── func _check_bandwidth(packet_size: int) -> bool: var now = Time.get_ticks_msec() / 1000.0 if now - _bandwidth_timer >= 1.0: _bandwidth_timer = now _bandwidth_used_this_second = 0 if _bandwidth_used_this_second + packet_size > bandwidth_limit: return true # drop _bandwidth_used_this_second += packet_size return false # ─── Burst loss ────────────────────────────────────────────────────────── func _check_burst_loss() -> bool: if burst_loss_count <= 0 or burst_loss_interval <= 0: return false if _burst_dropping: _burst_drop_remaining -= 1 if _burst_drop_remaining <= 0: _burst_dropping = false return true _packets_sent_since_burst += 1 if _packets_sent_since_burst >= burst_loss_interval: _packets_sent_since_burst = 0 _burst_dropping = true _burst_drop_remaining = burst_loss_count - 1 # current packet is also dropped return true return false # ─── Reorder buffer ────────────────────────────────────────────────────── return a.time < b.time # ─── Reorder buffer ────────────────────────────────────────────────────── func _flush_reorder_buffer() -> void: if reorder_window <= 0: return if _reorder_buffer.size() >= reorder_window: _total_reordered += _reorder_buffer.size() _reorder_buffer.shuffle() for pkt in _reorder_buffer: # Re-inject shuffled packets at front of inbound queue _inbound_queue.push_front(pkt) _inbound_queue.sort_custom(_sort_by_time) _reorder_buffer.clear() # ─── Direct peer passthrough ────────────────────────────────────────────── ## Forward a packet to the real peer immediately (no simulation). func _send_to_real_peer(data: PackedByteArray, channel: int) -> void: if _wrapped_peer and _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED: _wrapped_peer.put_packet(data) # ─── Latency queue (outbound) ──────────────────────────── ## Queue a packet for delayed delivery to the real network peer. func _queue_delayed_outbound(data: PackedByteArray, channel: int) -> void: var delay_ms = latency_ms if jitter_ms > 0.0: delay_ms += _rng.randf_range(-jitter_ms, jitter_ms) delay_ms = max(0.0, delay_ms) var deliver_at = Time.get_ticks_msec() + delay_ms var entry := { "time": deliver_at, "packet": data, "channel": channel, } if delay_ms > 0: _total_delayed += 1 _outbound_queue.append(entry) _outbound_queue.sort_custom(_sort_by_time) # ─── Latency queue (inbound) ───────────────────────────── ## Queue a packet for delayed delivery to the game (from the real network). func _queue_delayed_inbound(data: PackedByteArray, channel: int) -> void: var delay_ms = 0.0 if jitter_ms > 0.0: delay_ms = _rng.randf_range(-jitter_ms, jitter_ms) delay_ms = max(0.0, delay_ms) var deliver_at = Time.get_ticks_msec() + delay_ms var entry := { "time": deliver_at, "packet": data, "channel": channel, } if delay_ms > 0: _total_delayed += 1 _inbound_queue.append(entry) _inbound_queue.sort_custom(_sort_by_time) # ─── Utility ────────────────────────────────────────────────────────────── ## Generate a test packet with the given sequence data. static func make_test_packet(seq: int, data: String = "") -> PackedByteArray: var payload = { "seq": seq, "data": data, "time": Time.get_ticks_usec() } var json_str = JSON.stringify(payload) return json_str.to_utf8_buffer() ## Decode a test packet from the simulator. static func decode_test_packet(data: PackedByteArray) -> Dictionary: var text = data.get_string_from_utf8() var json = JSON.new() var err = json.parse(text) if err == OK and json.data is Dictionary: return json.data return {} ## Generate a packet loss report as a formatted string. static func format_test_report(results: Array) -> String: var lines: PackedStringArray = [] var total = results.size() var received = 0 var min_seq = 0 var max_seq = 0 var gaps: Array[int] = [] for r in results: if r.get("received", false): received += 1 var loss_pct = 0.0 if total == 0 else (1.0 - float(received) / float(total)) * 100.0 lines.append("=== Test Report ===") lines.append(" Sent: %d" % total) lines.append(" Received: %d (%.1f%%)" % [received, 100.0 - loss_pct]) lines.append(" Lost: %d" % (total - received)) lines.append(" Loss rate: %.1f%%" % loss_pct) return "\n".join(lines)