Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user