Table of Contents

Class LagCompensationTick

Namespace
FishMMO.Shared
Assembly
FishMMO.Shared.dll

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:

  1. 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.
  2. The interpolation buffer the client deliberately renders its peers behind — SpectatorInterpolationTicks, mirroring NetworkObject._spectatorInterpolation because the field is private and reading it reflectively per hit would be absurd.
  3. The server's replicate queue. FishNet holds StateInterpolation entries 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

uint

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

uint

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

nob NetworkObject

The 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

roundTripMilliseconds double

TimeManager.RoundTripTime.

tickDelta double

TimeManager.TickDelta, in seconds.

wholeTicks byte

Whole ticks of view offset.

fraction byte

Sub-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

timeManager TimeManager

The 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

caster ICharacter

The character whose view is being reconstructed.

timeManager TimeManager

Server time manager.

target RewindTarget

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.