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:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+519
View File
@@ -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.01.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)