Class LagCompensationTick
Works out which past tick a caster's client was actually looking at, so a hit can be resolved against that instead of against the server's present.
public static class LagCompensationTick
- Inheritance
-
LagCompensationTick
- Inherited Members
Remarks
Three terms, and they are measured by two different peers. The gap between what the caster's client had on screen and the server's present, at the instant the replicate body runs, is:
- The full round trip. The state being looked at left the server one way ago, and the input built from it takes another one way to come back. Both halves are in the gap because the anchor is the tick the query RUNS on, not the tick the input was produced on.
- The interpolation buffer the client deliberately renders its peers behind —
SpectatorInterpolationTicks, mirroring
NetworkObject._spectatorInterpolationbecause the field is private and reading it reflectively per hit would be absurd. - The server's replicate queue. FishNet holds
StateInterpolationentries before consuming one, so an arriving input waits that long before it is run. Added by ResolveReplicateQueueTicks(NetworkObject), because only the server knows it.
The first two are measured on the client and sent in the input.
ResolveViewOffset(out byte, out byte) adds the round trip to
SpectatorInterpolationTicks and stamps the sum into
CharacterReplicateData.ViewOffsetTicks, to a 1/256 of a tick. The server cannot derive
them: FishNet measures ping on the CLIENT (TimeManager.ModifyPing runs from the pong
the client receives), and there is no per-connection round trip server side.
NetworkConnection.ReplicateTick.LocalTickDifference looks like a latency term and is
not one — ReplicateTick.LocalTick is stamped with the current server tick immediately
before the replicate body runs, so it reads 0 on every tick that carries real input, whatever
the client's RTT.
Half the round trip was the previous formulation, and it under-compensated everybody. It covered the state's trip out but not the input's trip back or the queue, so every shot resolved against a world roughly (one way + two ticks) newer than the one the shooter aimed in. At 200 ms and 6 m/s that is most of a character's width, and it grew with ping — the connections lag compensation exists for were the ones it served worst.
The offset is a DURATION; the anchor it is subtracted from is a server tick. This is
the distinction the whole file turns on, so it lives in one place —
ServerTickDomain(TimeManager) — which both this type and
FishMMO.Shared.CharacterPositionHistory.RecordTick() call. Anchoring on the replicate input's
own tick is WRONG and silently disables compensation: on the server a replicate carries the
OWNING CLIENT'S TimeManager.LocalTick, an unsynchronised counter that restarts at zero
when that client connects (NetworkBehaviour.Replicate_Reader stamps the read datas
from LastPacketTick.LastRemoteTick, which is the sender's own LocalTick). The
history is keyed by the SERVER'S tick, so a target built in the client's domain lands far
outside the recorded window, every Rewind declines, and the query runs against live
positions with nothing logged. PredictionTick exists to stop replicate-domain
ticks leaking into raw-tick code; it cannot help here because this reads a plain
uint, so the rule is stated instead.
Server-driven characters compensate nothing. An NPC's brain runs on the server against live positions, so there is no view offset to undo. Compensating one would rewind its targets away from where the brain actually aimed.
The claim is capped, and then the history clamps it.
MaximumCompensationTicks bounds it here as a first line, and
CharacterPositionHistory.TryResolve resolves anything still older than its recording to
the oldest sample it holds. The ceiling on how far into the past anybody can shoot is
therefore CharacterPositionHistory.MaximumRewindMilliseconds, which is the one number
to weigh when deciding how long a victim may have been behind cover and still be hit.
Clamping rather than refusing costs nothing in that ceiling — an attacker maximises rewind by
claiming a value just INSIDE the window, which was always accepted — and it removes the cliff
that gave a 380 ms player no compensation at all while a 360 ms player got the lot.
Fields
MaximumCompensationTicks
Hard ceiling on the client-claimed view offset, in ticks.
public const uint MaximumCompensationTicks = 30
Field Value
Remarks
The claim arrives in the replicate input, so it is attacker-controlled. This bounds it before it reaches the history, which independently clamps anything still older to the oldest sample it holds — two limits rather than one, because the history window is a serialized field somebody may widen for a legitimate reason.
Not the security ceiling. That is
CharacterPositionHistory.MaximumRewindMilliseconds, which is what actually bounds
how far into the past a shot can reach; this constant sits above it (30 ticks is a second
at the shipped tick rate, against a 500 ms recording) and only stops an absurd claim
reaching the history at all. It applies BEFORE the server's replicate queue term is added,
so the queue depth is never something a client can inflate.
The gap between the two is deliberate and settled. 500 ms RTT is the worst case
this game designs for, and the shipped 500 ms recording covers it; past that the
history clamps to its oldest sample, which is the safe direction because it returns a pose
that was actually recorded rather than falling through to a live one. Do not lower this
constant to match the ring — a claim capped BELOW the recorded window would be the real
defect. If the worst case moves, widen
CharacterPositionHistory.maximumRewindMilliseconds on the prefab instead.
SpectatorInterpolationTicks
Ticks a non-owned character is rendered behind the server, mirroring
NetworkObject._spectatorInterpolation as authored on the playable prefabs.
public const uint SpectatorInterpolationTicks = 2
Field Value
Remarks
Must be kept in step with the prefab setting by hand. If they diverge, compensation is wrong by the difference — which shows up as hits landing consistently ahead of or behind where the shooter aimed, rather than as an obvious failure.
Methods
ResolveReplicateQueueTicks(NetworkObject)
How many ticks an arriving input waits in the server's replicate queue before it is run.
public static uint ResolveReplicateQueueTicks(NetworkObject nob)
Parameters
nobNetworkObjectThe caster's network object.
Returns
- uint
The queue depth in ticks.
Remarks
PredictionManager.StateInterpolation is the buffer depth FishNet maintains on the
receiving side: Replicate_NonAuthoritative only consumes past that depth, so in
steady state an input sits in the queue for exactly this long. Zero when the manager
cannot be reached, which loses two ticks of compensation rather than throwing.
ResolveViewOffset(double, double, out byte, out byte)
The whole client-side half of the derivation: how far behind server-present this client's rendered view of its peers will be BY THE TIME THE SERVER RUNS the input it is producing.
public static void ResolveViewOffset(double roundTripMilliseconds, double tickDelta, out byte wholeTicks, out byte fraction)
Parameters
roundTripMillisecondsdoubleTimeManager.RoundTripTime.tickDeltadoubleTimeManager.TickDelta, in seconds.wholeTicksbyteWhole ticks of view offset.
fractionbyteSub-tick remainder, in 1/256ths of a tick.
Remarks
The FULL round trip plus the interpolation buffer, split into whole ticks and a 1/256 tick remainder. Both halves of the trip are in play and they are different halves: the state being looked at left the server one way trip ago and is rendered SpectatorInterpolationTicks behind even that, and the input built from that view takes another one way trip to reach the server.
Lives here rather than on KCCPlayer so both halves of the loop sit in one file and
can be composed in a test without a live TimeManager. KCCPlayer supplies
the two measurements and adds nothing of its own.
ServerTickDomain(TimeManager)
The one tick domain lag compensation works in: the server's own TimeManager tick.
public static uint ServerTickDomain(TimeManager timeManager)
Parameters
timeManagerTimeManagerThe server's time manager.
Returns
- uint
The current server tick, or FishNet.Managing.Timing.TimeManager.UNSET_TICK when there is no time manager.
Remarks
Exists so the recording side and the rewinding side cannot drift apart. Every sample in CharacterPositionHistory is keyed by this, and every RewindTarget is measured back from it; expressing that as one function makes a mismatch impossible to introduce by editing one side, which is exactly how the replicate-domain anchor got in.
On the server TimeManager.LocalTick returns TimeManager.Tick, so this is
the authoritative simulation tick. It is deliberately NOT the replicate input's tick —
see the remarks on LagCompensationTick for why that one belongs to the
owning client and cannot index this history.
TryResolve(ICharacter, TimeManager, out RewindTarget)
Resolves the tick caster's client was rendering its peers at.
public static bool TryResolve(ICharacter caster, TimeManager timeManager, out RewindTarget target)
Parameters
casterICharacterThe character whose view is being reconstructed.
timeManagerTimeManagerServer time manager.
targetRewindTarget
Returns
- bool
False when there is nothing to compensate — a server-driven character, an unowned one, or a connection whose tick bookkeeping is not yet established.