extends Node ## Measures the gap between where the client *draws* the local player and where ## the server actually has it -- the number that decides whether bullets appear ## to leave the ship's nose or trail out of its back. ## ## godot --headless --path . res://tools/diag_prediction.tscn ## ## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual ## Net loopback), not a mock, so node process order is exactly production's. ## Tick at which to shove the client's input numbering out of sync, simulating ## the clock drift between two independent processes that produced the original ## "ship and bullets separate after a few minutes, permanently" bug. 0 disables. const DRIFT_AT := 120 const DRIFT_BY := 14 var _ticks: int = 0 var _worst: float = 0.0 var _sum: float = 0.0 var _samples: int = 0 ## Measured only after the injected drift, which is the interesting window. var _worst_after_drift: float = 0.0 var _drifted: bool = false func _ready() -> void: GameOpts.bot_client = true GameOpts.player_name = "diag" if Net.host(27300) != OK: push_error("could not host") get_tree().quit(1) return Net.start_local_client() func _physics_process(_delta: float) -> void: _ticks += 1 var srv := Net.server var cli := Net.client if srv == null or cli == null: return var inst := srv.instance_of(Net.LOCAL_PEER) if inst == null or not inst.world.players.has(Net.LOCAL_PEER): return var sp: SimPlayer = inst.world.players[Net.LOCAL_PEER] var delta_px := sp.pos.distance_to(cli.predicted_pos) # Skip the first few ticks while the handshake settles. if _ticks > 20: _worst = maxf(_worst, delta_px) _sum += delta_px _samples += 1 if DRIFT_AT > 0 and _ticks == DRIFT_AT: # Exactly what two drifting clocks do, only instantly. With the old # constants this landed in the gap between "server stops accepting" and # "client notices", and the two never re-converged. cli.input_tick += DRIFT_BY _drifted = true print("--- injected +%d tick input drift ---" % DRIFT_BY) # Give the client a moment to notice before judging it. if _drifted and _ticks > DRIFT_AT + 90: _worst_after_drift = maxf(_worst_after_drift, delta_px) if _ticks % 20 == 0: print("tick=%4d srv_tick=%4d srv_pos=%-22s pred=%-22s gap=%6.2fpx acked=%4d cli_tick=%4d queue=%d" % [ _ticks, inst.world.tick, str(sp.pos.round()), str(cli.predicted_pos.round()), delta_px, sp.last_input_tick, cli.input_tick, sp.input_queue.size()]) if _ticks >= 300: print("---") print("GAP worst=%.2fpx mean=%.2fpx over %d samples" % [_worst, _sum / maxf(_samples, 1), _samples]) print("player speed is %.0f u/s = %.2f px per tick" % [ SimConfig.PLAYER_SPEED, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA]) print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [ SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS, SimConfig.PLAYER_RADIUS]) var ok := true if _drifted: print("RECOVERED gap after drift: worst=%.2fpx" % _worst_after_drift) # One tick of movement is 4px; anything near that means the client # re-synced. Tens of px means it is stuck, which is the bug. ok = _worst_after_drift < 12.0 print("DRIFT_RECOVERY_" + ("PASS" if ok else "FAIL")) Net.shutdown() get_tree().quit(0 if ok else 1)