e7299b17e9
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)
218 lines
8.3 KiB
Markdown
218 lines
8.3 KiB
Markdown
# Tests — Tactical Shooter Netcode Bug Bash
|
|
|
|
This directory contains tests and tools for the Phase 6 netcode bug bash:
|
|
netcode edge cases, packet loss simulation, and high ping testing.
|
|
|
|
## Quick Start
|
|
|
|
Run all tests headlessly:
|
|
|
|
```bash
|
|
# Option A: Direct test runner script
|
|
./tests/RUN_TESTS.sh
|
|
|
|
# Option B: Manual Godot invocation
|
|
godot --headless --script tests/test_anti_cheat.gd
|
|
godot --headless --script tests/test_net_sim.gd
|
|
godot --headless --script tests/test_rcon_edge_cases.gd
|
|
```
|
|
|
|
Run OS-level network simulation (requires root):
|
|
|
|
```bash
|
|
# Apply a network profile to the game server port
|
|
sudo ./tests/netem_profiles.sh apply cellular
|
|
|
|
# Test under extreme conditions
|
|
sudo ./tests/netem_profiles.sh apply warzone
|
|
|
|
# Remove all netem rules
|
|
sudo ./tests/netem_profiles.sh off
|
|
|
|
# Show current tc qdisc configuration
|
|
sudo ./tests/netem_profiles.sh status
|
|
```
|
|
|
|
## Test Files
|
|
|
|
| File | What It Tests |
|
|
|------|---------------|
|
|
| `test_utils.gd` | Minimal assertion framework for headless Godot tests |
|
|
| `test_anti_cheat.gd` | Anti-cheat validation pipeline: sequence, movement, aim (including yaw wraparound), fire rate, command rate, ammo edge cases, first-position-at-origin bypass |
|
|
| `test_net_sim.gd` | Network condition simulator: packet loss, latency, jitter, reordering, duplication, burst loss, bandwidth + 6 new edge case tests (jitter > latency, 25%+500ms combined, burst+reorder, 10k rapid sequence, bandwidth+loss, zero-condition passthrough) |
|
|
| `test_rcon_edge_cases.gd` | RCON protocol edge cases: auth state machine, buffer overflow, malformed commands |
|
|
|
|
## Network Simulator (`server/scripts/net_sim.gd`)
|
|
|
|
Wraps any `MultiplayerPeer` (ENet, WebSocket, etc.) to inject simulated
|
|
adverse network conditions. Use it to test gameplay under real-world
|
|
network scenarios without needing actual bad connections.
|
|
|
|
### Quick Profiles
|
|
|
|
```gdscript
|
|
var sim = NetSim.new()
|
|
sim.set_profile("lan") # 0% loss, 1ms latency
|
|
sim.set_profile("dsl") # 0.5% loss, 20ms ±5ms
|
|
sim.set_profile("cellular") # 2% loss, 60ms ±20ms
|
|
sim.set_profile("satellite") # 1% loss, 600ms ±50ms
|
|
sim.set_profile("congested") # 5% loss, 100ms ±40ms, reorder
|
|
sim.set_profile("warzone") # 10% loss, 200ms ±60ms, burst loss
|
|
```
|
|
|
|
### Manual Configuration
|
|
|
|
```gdscript
|
|
sim.packet_loss = 0.05 # 5% packet loss
|
|
sim.latency_ms = 100 # 100ms one-way delay
|
|
sim.jitter_ms = 30 # ±30ms variable delay
|
|
sim.reorder_window = 4 # shuffle groups of 4
|
|
sim.duplicate_rate = 0.01 # 1% duplicate packets
|
|
sim.bandwidth_limit = 50000 # 50KB/s cap
|
|
sim.burst_loss_count = 3 # drop 3 consecutive
|
|
sim.burst_loss_interval = 50 # every 50 packets
|
|
```
|
|
|
|
### Usage with ENet
|
|
|
|
```gdscript
|
|
# Create real peer
|
|
var real_peer = ENetMultiplayerPeer.new()
|
|
real_peer.create_server(7777, 16)
|
|
|
|
# Wrap with simulator
|
|
var sim = NetSim.new()
|
|
# IMPORTANT: You must modify game code to call sim.send_packet()/sim.receive_packet()
|
|
# instead of the real peer directly. See net_sim.gd for the API.
|
|
```
|
|
|
|
### Debug Stats
|
|
|
|
```gdscript
|
|
print(sim.get_stats_string())
|
|
# Example output:
|
|
# Sent: 1500
|
|
# Lost (loss): 75
|
|
# Lost (burst): 12
|
|
# Duplicated: 8
|
|
# Effective loss: 5.1%
|
|
# Config: loss=5.0% lat=100ms jitter=±30ms ...
|
|
```
|
|
|
|
## Bugs Found & Fixed (Phase 6 Bug Bash)
|
|
|
|
### Critical: NetSim packet delivery pipeline broken (`net_sim.gd`)
|
|
|
|
**Root cause**: `_deliver_to_game()` was a no-op (`pass`). When `_process()` ran every
|
|
frame, it found packets whose delay had expired, removed them from the queue, and
|
|
called `_deliver_to_game()` which did nothing. The packets vanished into the void.
|
|
Additionally, `_send_to_real_peer()` was defined but never called — packets were
|
|
queued internally but never forwarded to the real ENet peer.
|
|
|
|
**Fix**:
|
|
- `_send_to_real_peer()` is now called from `_process()` when outbound delay expires
|
|
- `_deliver_to_game()` removed; the outbound pipeline is now: `send_packet` → queue →
|
|
`_process` → `_send_to_real_peer` → real peer's `put_packet()`
|
|
- Separated outbound and inbound queues (`_outbound_queue` for game→network,
|
|
`_inbound_queue` for network→game). Previously everything used `_inbound_queue`.
|
|
- `receive_packet()` and `has_packet()` now fall through to the real peer when
|
|
the inbound simulation queue is empty
|
|
- Added `_queue_delayed_outbound()` and `_queue_delayed_inbound()` as dedicated
|
|
queue functions
|
|
- Removed dead `_queue_delayed_packet()` function
|
|
|
|
### Bug: `_outbound_queue` dead code (`net_sim.gd`)
|
|
|
|
**Root cause**: `var _outbound_queue` was declared (line 82) but never written to.
|
|
All delayed packets went into `_inbound_queue`, mixing outbound and inbound traffic
|
|
in the same queue with no directional separation.
|
|
|
|
**Fix**: `send_packet()` now queues in `_outbound_queue`. `_process()` forwards
|
|
expired outbound packets to the real peer. `_inbound_queue` is now truly for
|
|
inbound packets read from the real peer.
|
|
|
|
### Bug: Anti-cheat aim yaw wraparound false positives (`anti_cheat.gd`)
|
|
|
|
**Root cause**: `_validate_aim()` computed `(angles - state.last_view_angles).abs()`
|
|
without handling yaw circular wraparound. If a player's last yaw was 359° and they
|
|
turned right 2° to 1°, the delta was `|1 - 359| = 358°`, triggering a false positive
|
|
aim snap violation.
|
|
|
|
**Fix**: Added shortest-angular-distance calculation using `fmod(raw_delta, 360.0)`
|
|
with wraparound correction: values > 180° are subtracted from 360° to get the
|
|
true shortest path. Applied to both violation detection and aim angle clamping.
|
|
|
|
### Bug: First-position-at-origin teleport bypass (`anti_cheat.gd`)
|
|
|
|
**Root cause**: `_validate_movement()` had this early-return guard:
|
|
```
|
|
if prev_pos == Vector3.ZERO and state.last_seq != -1:
|
|
if state.violation_count == 0:
|
|
return # SKIPS ALL MOVEMENT VALIDATION
|
|
```
|
|
If a player spawned at `Vector3(0,0,0)` (a valid map origin), the second tick's
|
|
movement validation was entirely skipped, allowing undetected teleport on tick 2.
|
|
|
|
**Fix**: Removed the entire guard. The first packet is still protected by the
|
|
`state.last_seq != -1` check in the teleport/speed tests (which correctly skips
|
|
the first-ever packet). All subsequent packets are validated normally regardless
|
|
of position value.
|
|
|
|
### Bug: Companion file `net_sim_peer.gd` referenced but missing
|
|
|
|
**Root cause**: `net_sim.gd` line 254 references a companion file `net_sim_peer.gd`
|
|
for the "full MultiplayerPeer adapter," but this file does not exist anywhere in
|
|
the project.
|
|
|
|
**Status**: Documented. The architecture relies on game code calling `send_packet()`
|
|
and `receive_packet()` directly. A transparent `MultiplayerPeer` wrapper would be
|
|
needed for drop-in replacement without game code changes.
|
|
|
|
### Missing Gitea Issues (to be created manually)
|
|
|
|
1. `net_sim.gd`: Packet delivery pipeline broken — packets never reach real peer
|
|
2. `anti_cheat.gd`: Aim validation yaw wraparound causes false positives
|
|
3. `anti_cheat.gd`: First position at origin bypasses movement validation on tick 2
|
|
4. `net_sim.gd`: `_outbound_queue` dead code (minor)
|
|
5. `net_sim.gd`: Bandwidth check order — random loss reduces effective bandwidth
|
|
|
|
## Bugs Found & Fixed
|
|
|
|
### Bug: Multi-jump detection bypass (anti_cheat.gd)
|
|
|
|
**Root cause**: `jump_count` was reset to 0 every time the jump button was released,
|
|
regardless of whether the player was airborne. This let players tap-jump repeatedly
|
|
while mid-air, bypassing the single-jump limit.
|
|
|
|
**Fix**: `jump_count` now only resets on ground contact (`was_on_ground = true`).
|
|
Button release while airborne no longer resets the counter.
|
|
|
|
### Bug: Sequence number not corrected in output (anti_cheat.gd)
|
|
|
|
**Root cause**: When `_validate_sequence` detected a non-monotonic seq number,
|
|
the corrected output packet was never updated — it still contained the invalid seq.
|
|
|
|
**Fix**: On seq regression, `corrected["seq"]` is now set to `state.last_seq + 1`.
|
|
|
|
### Bug: Duplicate cleanup loop (anti_cheat.gd)
|
|
|
|
**Root cause**: The command rate sliding window cleanup had two identical
|
|
`while state.command_times.size() > limit + 8` loops, the second being dead code.
|
|
|
|
**Fix**: Removed the duplicate loop. One cleanup pass is sufficient.
|
|
|
|
## Adding New Tests
|
|
|
|
1. Create a `.gd` file in this directory
|
|
2. Extend `Node` and implement `_ready()` to run tests
|
|
3. Use `TestUtils` for assertions:
|
|
```gdscript
|
|
var tu = TestUtils.new()
|
|
add_child(tu)
|
|
tu.describe("My Feature")
|
|
tu.assert_eq(result, expected, "what we're checking")
|
|
tu.finish()
|
|
get_tree().quit(tu.exit_code())
|
|
```
|
|
4. Add to `RUN_TESTS.sh`
|