Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s

The real cause of the ship/bullet separation, which the previous commit only
half-addressed. The server dropped inputs past a lead of 12 while the client
only re-synced past 16, so a client whose lead drifted into 13-16 had every
input silently rejected while believing its timing was fine. The server coasted
on held_input and then stopped; the client kept predicting. The two separated
permanently and the reconciler fought it every snapshot -- "shoved around".
It needed two independent clocks to drift, hence "only after some time", and
nothing in the loop could notice, hence "then persists". The listen-server
diagnostic could never reproduce it: one process, one physics tick, lead
constant by construction.

Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction
band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a
test; and an ack-stall detector re-syncs when last_input_tick stops advancing,
which catches the whole class regardless of cause -- lead alone cannot, because
a wrong lead looks normal from the client. diag_prediction.gd now injects a +14
tick drift and exits non-zero unless the gap recovers.

Also:
- No invulnerability frames. Every bullet that touches a player lands; i-frames
  made dense patterns safer than sparse ones, which inverts the genre. Measured:
  a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s
  drifting. spawn_grace remains the only invulnerable state.
- Death is exited with a HUD button, disabled for the first 3s. The lockout is
  enforced in SimWorld, not just by graying the button -- a client that ignores
  its own UI still waits. The interact key no longer respawns.
- Joining a server that is not there no longer drops the player into an empty
  lobby they cannot act in. Net.join() only creates an ENet object; the game
  scene now waits for the server to actually place us in an instance, with an
  8s timeout, and headless runs exit non-zero instead of idling.

Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 19:19:33 +02:00
parent 005679f1b5
commit f70de1b825
18 changed files with 423 additions and 51 deletions
+56 -4
View File
@@ -35,6 +35,13 @@ var my_escape: float = 0.0
var my_escaping: bool = false
## Arrival protection: invulnerable and unable to shoot.
var my_spawn_grace: bool = false
## Seconds left before the HUD's return-to-hub button becomes available. The
## server enforces the same lockout; this only drives the button's look.
var my_respawn_wait: float = 0.0
## Set by the HUD button, not by a key. Death is deliberately exited through a
## deliberate click rather than whatever the player happened to be holding.
var request_respawn: bool = false
## Set by the in-game menu's "return to hub" -- synthesises a held escape
## button, so the menu route runs the identical server-side channel as the key.
@@ -48,6 +55,12 @@ var roster: Array[Dictionary] = []
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Backstop for input-numbering drift: if the server stops acknowledging new
## inputs, our tick numbering has fallen outside its acceptance window and no
## amount of waiting fixes it. Counted in snapshots, not ticks.
var _last_acked: int = -1
var _ack_stall: int = 0
var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {}
var _interp: float = 0.0
@@ -98,6 +111,11 @@ func _physics_process(delta: float) -> void:
func _sample_input() -> InputFrame:
if GameOpts.bot_client:
return _bot_input()
if not my_alive:
# Downed: no movement, no fire, and the interact key does nothing. The
# only way out is the HUD button, which sets request_respawn.
var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0
return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons)
var move := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
var to_mouse := mouse - predicted_pos
@@ -142,6 +160,17 @@ func on_welcome(peer_id: int) -> void:
GameLog.info("client", "welcome, peer id %d" % peer_id)
## Re-number our input stream relative to the server and drop the history that
## was numbered under the old scheme -- replaying it would apply inputs the
## server never accepted.
func _resync_input_tick(server_tick: int, why: String) -> void:
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
pending.clear()
_ack_stall = 0
_last_acked = -1
GameLog.warn("client", "input re-sync: %s" % why)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
@@ -158,7 +187,9 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
predicted_pos = spawn
pending.clear()
server_tick_est = server_tick
input_tick = server_tick + 8
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
_last_acked = -1
_ack_stall = 0
my_alive = true
my_hp = SimConfig.PLAYER_MAX_HP
my_escape = 0.0
@@ -168,6 +199,8 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
# was triggered -- otherwise the synthesised button would keep firing and
# bounce the player straight back out of the hub.
request_escape = false
request_respawn = false
my_respawn_wait = 0.0
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
@@ -187,10 +220,12 @@ func on_snapshot(data: PackedByteArray) -> void:
if server_tick_est < tick or server_tick_est > tick + 12:
server_tick_est = tick
# Keep the client roughly one buffer ahead of the server so inputs arrive
# just before they are needed rather than late.
# just before they are needed rather than late. The band is deliberately
# narrower than the server's acceptance window (SimConfig.INPUT_MAX_LEAD),
# so we always correct before the server starts dropping anything.
var lead := input_tick - tick
if lead < 2 or lead > 16:
input_tick = tick + 8
if lead < SimConfig.INPUT_LEAD_MIN or lead > SimConfig.INPUT_LEAD_MAX:
_resync_input_tick(tick, "lead %d out of band" % lead)
for rec: Dictionary in snap["players"]:
if int(rec["peer"]) == my_peer:
@@ -206,9 +241,26 @@ func _reconcile(rec: Dictionary) -> void:
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
my_escape = float(rec["escape"])
my_respawn_wait = float(rec["respawn_wait"])
if my_alive:
request_respawn = false
hud_dirty.emit()
var acked := int(rec["last_input_tick"])
# If the server is not consuming anything we send, our numbering is outside
# its window; lead alone cannot detect that, because a wrong lead looks
# perfectly normal from here. This is what makes the failure self-healing.
if acked == _last_acked:
_ack_stall += 1
if _ack_stall >= SimConfig.INPUT_ACK_STALL_LIMIT:
_resync_input_tick(int(snap_curr["tick"]),
"server stopped acknowledging input")
return
else:
_last_acked = acked
_ack_stall = 0
while not pending.is_empty() and pending[0].tick <= acked:
pending.pop_front()