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
+49
View File
@@ -71,6 +71,50 @@ slow relative to bullet speed, so this reads as latency rather than unfairness.
If it becomes a complaint, the fix is to lag-compensate **player bullets against
enemies only**, never enemy bullets against players.
## Input timing, and a bug worth remembering
The client numbers its input frames `INPUT_TARGET_LEAD` ticks ahead of the
server's tick; the server accepts anything within `INPUT_MAX_LEAD` and re-syncs
nothing itself. **These two numbers must be chosen together.**
They originally were not — the server dropped inputs past a lead of 12, while
the client only corrected itself past 16. A client whose lead drifted into 1316
therefore had *every* input silently rejected, while believing its own timing
was fine. The server, receiving nothing, coasted on the last input it had
(`held_input`) and then stopped; the client kept predicting forward. The ship
and the authoritative position separated permanently, the reconciler fought it
every snapshot, and the player got shoved around. It needed two independent
clocks to drift apart, so it appeared only after minutes of play — and never
recovered, because nothing in the loop could notice.
Two defences now:
1. `INPUT_MAX_LEAD` (40) is far wider than the client's correction band
(`INPUT_LEAD_MIN` 3 … `INPUT_LEAD_MAX` 20), so the client always re-syncs
long before the server starts refusing anything. `test_input_lead.gd`
asserts that ordering directly, so narrowing the window fails a test rather
than shipping.
2. An **ack-stall detector**: if `last_input_tick` does not advance across
`INPUT_ACK_STALL_LIMIT` snapshots, the client concludes its numbering is
outside the window and hard re-syncs. Lead alone cannot detect this, because
a wrong lead looks perfectly normal from the client's side. This is the
backstop that makes the whole class of failure self-healing regardless of
cause.
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
returns to normal; it exits non-zero if it does not.
## No invulnerability frames
A hit grants no immunity — every bullet that touches you deals its damage. In a
bullet hell the wall *is* the threat, and i-frames invert that: you are punished
for the first bullet of a pattern and gifted the next thirty, which makes dense
patterns *safer* than sparse ones. Measured cost: a stationary player survives
~13.6s of the Warden's opening phase, ~17.5s while drifting aimlessly.
Arrival protection (`SPAWN_GRACE_TICKS`) is the sole exception, and it is a
transition, not a combat mechanic.
## Input validation
`SimWorld.queue_input()` is the single audit point. It drops:
@@ -81,6 +125,11 @@ enemies only**, never enemy bullets against players.
| Stale input | `f.tick < tick - INPUT_MAX_AGE` |
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
The respawn lockout is worth calling out: the HUD disables its button for the
same three seconds, but that is presentation. A client that ignores its own UI
and holds the bit down still waits, because the check lives in `SimWorld`.
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`