Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions Multiplayer/Components/IdMonoBehaviour.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ public T NetId {
set {
if (_netId.Equals(value))
return;
if ((_netId as dynamic).CompareTo(default(T)) != 0)
idPool.ReleaseId(_netId);
Register(value);
}
}
Expand All @@ -29,7 +27,7 @@ public T NetId {

protected static bool Get(T netId, out IdMonoBehaviour<T, I> obj)
{
if (indexToObject.TryGetValue(netId, out obj))
if (indexToObject.TryGetValue(netId, out obj) && obj != null)
return true;
obj = null;
if ((netId as dynamic).CompareTo(default(T)) != 0)
Expand All @@ -39,7 +37,7 @@ protected static bool Get(T netId, out IdMonoBehaviour<T, I> obj)

protected static bool TryGet(T netId, out IdMonoBehaviour<T, I> obj)
{
if (indexToObject.TryGetValue(netId, out obj))
if (indexToObject.TryGetValue(netId, out obj) && obj != null)
return true;

obj = null;
Expand All @@ -55,13 +53,26 @@ protected virtual void Awake()

public void Register(T id)
{
if (_netId.Equals(id) && indexToObject.TryGetValue(id, out var existing) && ReferenceEquals(existing, this))
return;
Unregister();
_netId = id;
indexToObject[id] = this;
if (!id.Equals(default(T)))
indexToObject[id] = this;
}

private void Unregister()
{
if (!_netId.Equals(default(T)) && indexToObject.TryGetValue(_netId, out var current) && ReferenceEquals(current, this))
{
indexToObject.Remove(_netId);
idPool.ReleaseId(_netId);
}
}

protected virtual void OnDestroy()
{
idPool.ReleaseId(NetId);
Unregister();
if (!UnloadWatcher.isUnloading)
return;
idPool.Reset();
Expand Down
56 changes: 54 additions & 2 deletions Multiplayer/Components/Networking/NetworkLifecycle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MPAPI;
using Multiplayer.API;
using Multiplayer.Components.Networking.UI;
using Multiplayer.Components.Networking.World;
using Multiplayer.Networking.Data;
using Multiplayer.Networking.Managers.Client;
using Multiplayer.Networking.Managers.Server;
Expand Down Expand Up @@ -33,6 +34,7 @@ public class NetworkLifecycle : SingletonBehaviour<NetworkLifecycle>
public NetworkClient Client { get; private set; }

public uint Tick { get; internal set; }
public uint SynchronizedTick => IsServerRunning || clientTickSynchronized ? Tick : 0;
public Action<uint> OnTick;

public bool IsServerRunning => Server?.IsRunning ?? false;
Expand All @@ -46,6 +48,42 @@ public class NetworkLifecycle : SingletonBehaviour<NetworkLifecycle>
private NetworkStatsGui Stats;
private readonly ExecutionTimer tickTimer = new();
private readonly ExecutionTimer tickWatchdog = new(0.25f);
private bool clientTickSynchronized;
private uint clientTicksToHold;

public static int SignedTickDelta(uint newer, uint older) => unchecked((int)(newer - older));

public float SecondsSinceTick(uint sampleTick)
{
return Mathf.Max(0f, SignedTickDelta(Tick, sampleTick) / (float)TICK_RATE);
}

internal void SynchronizeClientTick(uint serverTick, int oneWayLatencyMs)
{
uint latencyTicks = (uint)Mathf.Max(0, Mathf.RoundToInt(oneWayLatencyMs / 1000f * TICK_RATE));
uint estimatedServerTick = unchecked(serverTick + latencyTicks);

if (!clientTickSynchronized)
{
Tick = estimatedServerTick;
clientTickSynchronized = true;
clientTicksToHold = 0;
return;
}

int drift = SignedTickDelta(estimatedServerTick, Tick);
if (drift > 0)
{
Tick = estimatedServerTick;
clientTicksToHold = 0;
}
else if (drift < 0)
{
// Never rewind the public tick: pause local generation briefly while
// the server clock catches up. Cap one correction against bad samples.
clientTicksToHold = (uint)Math.Min(-(long)drift, TICK_RATE * 2L);
}
}

/// <summary>
/// Whether the provided ITransportPeer is the host.
Expand Down Expand Up @@ -126,6 +164,8 @@ public bool StartServer(IDifficulty difficulty)
if (Server != null)
throw new InvalidOperationException("NetworkManager already exists!");

ShopPurchaseCoordinator.ResetSessionState();

if (!IsSinglePlayer)
{
if (serverData != null)
Expand Down Expand Up @@ -159,6 +199,12 @@ public void StartClient(string address, int port, string password, bool isSingle
{
if (Client != null)
throw new InvalidOperationException("NetworkManager already exists!");
if (!IsServerRunning)
{
clientTickSynchronized = false;
clientTicksToHold = 0;
}

NetworkClient client = new(Multiplayer.Settings, isSinglePlayer);
client.Start(address, port, password, isSinglePlayer, onDisconnect);

Expand All @@ -175,13 +221,18 @@ private IEnumerator PollEvents()
{
while (!UnloadWatcher.isQuitting)
{
Tick++;
bool advanceTick = IsServerRunning || clientTicksToHold == 0;
if (advanceTick)
Tick++;
else
clientTicksToHold--;

tickTimer.Start();

tickWatchdog.Start();
try
{
if (!UnloadWatcher.isUnloading && !UnloadWatcher.isQuitting && !IsReturningToMenu)
if (advanceTick && !UnloadWatcher.isUnloading && !UnloadWatcher.isQuitting && !IsReturningToMenu)
OnTick?.Invoke(Tick);
}
catch (Exception e)
Expand Down Expand Up @@ -230,6 +281,7 @@ private void TickManager(NetworkManager manager)
public void Stop()
{
Stats?.Hide();
ShopPurchaseCoordinator.ResetSessionState();

if (Server != null)
{
Expand Down
28 changes: 26 additions & 2 deletions Multiplayer/Components/Networking/Player/NetworkedPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public string DisplayName
private Quaternion targetRotation;
private Vector2 moveDir;
private Vector2 targetMoveDir;
private uint lastTrackingTick;
private bool hasTrackingTick;
private float pendingTrackingAge;

private float currentLeanAngle;
private float angleSmoothRefVel;
Expand Down Expand Up @@ -281,7 +284,11 @@ protected void Update()
return;
}

float t = Time.deltaTime * LERP_SPEED;
// Consume packet age once so smoothing does not add its full delay on top
// of the time this sample has already spent in transit.
float interpolationDelta = Time.deltaTime + pendingTrackingAge;
pendingTrackingAge = 0f;
float t = 1f - Mathf.Exp(-LERP_SPEED * interpolationDelta);

Vector3 position = Vector3.Lerp(
IsOnCar ? selfTransform.localPosition : selfTransform.position,
Expand Down Expand Up @@ -375,7 +382,7 @@ private void ApplySpineAndHeadRotation()
// Side lean is always spinning around the root's global FORWARD axis
Quaternion leanOffset = Quaternion.AngleAxis(currentLeanAngle, selfTransform.forward);

// Directly assign the uniform world rotation
// Directly assign the uniform world rotation
spineTransform.rotation = leanOffset * currentModelSpineBase;
}

Expand Down Expand Up @@ -413,6 +420,23 @@ private void ApplyHandTracking()
/// <param name="trackingData"></param>
/// <param name="posture"></param>
/// <param name="movePacketIsOnCar"></param>
public bool TryAcceptTrackingTick(uint sampleTick)
{
if (sampleTick == 0)
return true;

if (hasTrackingTick && NetworkLifecycle.SignedTickDelta(sampleTick, lastTrackingTick) < 0)
return false;

lastTrackingTick = sampleTick;
hasTrackingTick = true;
pendingTrackingAge = Mathf.Clamp(
NetworkLifecycle.Instance.SecondsSinceTick(sampleTick),
0f,
0.25f);
return true;
}

public void UpdatePosition(PlayerTrackingData trackingData, PlayerPostureFlags posture, bool movePacketIsOnCar)
{
if (trackingData.Position.HasValue)
Expand Down
97 changes: 65 additions & 32 deletions Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public static bool TryGet(ushort netId, out NetworkedTrainCar obj)
return b;
}

public static IEnumerable<NetworkedTrainCar> GetAll() => trainCarsToNetworkedTrainCars.Values;

public static bool TryGet(ushort netId, out TrainCar trainCar)
{
bool b = TryGet(netId, out NetworkedTrainCar networkedTrainCar);
Expand Down Expand Up @@ -162,6 +164,9 @@ static string GetFuse(uint netId)

public string CurrentID { get; private set; }
public TrainCar TrainCar;
public bool HasAttachedCustomizations => TrainCar?.Customization != null &&
((TrainCar.Customization.Customizers?.Count ?? 0) > 0 || TrainCar.Customization.HoleCount > 0);
public bool IsInteriorPinnedForNetworkSync => interiorSyncLeaseCount > 0;
public uint TicksSinceSync = uint.MaxValue;

public uint lastTickProcessed = 0;
Expand All @@ -184,6 +189,7 @@ static string GetFuse(uint netId)
private readonly Dictionary<string, float> lastSentTrainDamages = [];

private InteriorControlsManager interiorControlsManager;
private int interiorSyncLeaseCount;
private readonly Dictionary<ControlImplBase, Action<ValueChangedEventArgs>> scrollableControlDelegates = [];
private Dictionary<InteriorControlsManager.ControlType, OverridableBaseControl> controlTypeToControl = [];
private readonly Dictionary<uint, OverridableBaseControl> portToBaseControl = [];
Expand Down Expand Up @@ -482,50 +488,40 @@ protected void Start()
NetworkLifecycle.Instance?.Client.SendTrainSyncRequest(NetId);
}

private Coroutine interiorHookRoutine;

private void OnTrainCarInteriorLoaded(GameObject interior)
{
Multiplayer.LogDebug(() => $"OnTrainCarInteriorLoaded() {CurrentID}, interior is null: {interior == null}");

StartCoroutine(WaitForInterior());
// Streaming can unload the cab while controls are still initializing.
// Cancel work for the previous instance before binding the new one.
OnTrainCarInteriorUnloaded(interior);
if (interior != null)
interiorHookRoutine = StartCoroutine(WaitForInterior(interior));
}

private IEnumerator WaitForInterior()
private IEnumerator WaitForInterior(GameObject interior)
{
float time = Time.time;
interiorControlsManager = null;

yield return new WaitUntil
(
() =>
{
return TrainCar?.loadedInterior != null || Time.time - time > INTERIOR_CONTROLS_TIMEOUT;
}
);

yield return new WaitForFixedUpdate();

if (TrainCar?.loadedInterior == null)
float deadline = Time.time + INTERIOR_CONTROLS_TIMEOUT;
InteriorControlsManager controls = null;
while (interior != null && !interior.TryGetComponent(out controls) && Time.time < deadline)
yield return null;
if (interior == null)
yield break;
if (controls == null)
{
Multiplayer.LogError($"TrainCar {CurrentID} failed to load an interior");
Multiplayer.LogWarning($"TrainCar {CurrentID} interior has no controls manager");
yield break;
}

time = Time.time;

yield return new WaitUntil
(
() =>
{
return TrainCar.loadedInterior.TryGetComponent<InteriorControlsManager>(out interiorControlsManager) || Time.time - time > INTERIOR_CONTROLS_TIMEOUT;
}
);

CoroutineManager.Instance.StartCoroutine(HookControls(interiorControlsManager));
interiorControlsManager = controls;
yield return HookControls(controls);
interiorHookRoutine = null;
}

private IEnumerator HookControls(InteriorControlsManager interiorControlsManager)
{
yield return new WaitUntil(() => interiorControlsManager.Initialized);
yield return new WaitUntil(() => interiorControlsManager == null || interiorControlsManager.Initialized);
if (interiorControlsManager == null)
yield break;

Multiplayer.LogDebug(() => $"HookControls() Hooking controls for car {CurrentID}, found {interiorControlsManager?.controls?.Count} controls");

Expand All @@ -535,6 +531,11 @@ private IEnumerator HookControls(InteriorControlsManager interiorControlsManager
var control = kvp.Value;
var key = kvp.Key;

// Controls without an override port (e.g. the handbrake) use their
// dedicated synchronization path, not control-port authority.
if (control.overridableBaseControl == null)
continue;

float timeOut = Time.time;
yield return new WaitUntil
(
Expand Down Expand Up @@ -600,6 +601,11 @@ private IEnumerator HookControls(InteriorControlsManager interiorControlsManager
private void OnTrainCarInteriorUnloaded(GameObject interior)
{
Multiplayer.LogDebug(() => $"OnTrainCarInteriorUnloaded() {CurrentID}");
if (interiorHookRoutine != null)
{
StopCoroutine(interiorHookRoutine);
interiorHookRoutine = null;
}

foreach (var control in controlToPortNetId.Keys)
{
Expand Down Expand Up @@ -628,6 +634,7 @@ private void OnTrainCarInteriorUnloaded(GameObject interior)
kvp.Key.ValueChanged -= kvp.Value;
}

scrollableControlDelegates.Clear();
portNetIdToControl.Clear();
controlToPortNetId.Clear();

Expand All @@ -641,6 +648,13 @@ public void OnDisable()
if (UnloadWatcher.isQuitting)
return;

OnTrainCarInteriorUnloaded(null);
if (TrainCar != null)
{
TrainCar.InteriorLoaded -= OnTrainCarInteriorLoaded;
TrainCar.InteriorAboutToBeUnloaded -= OnTrainCarInteriorUnloaded;
}

//Clean dictionaries
trainCarsToNetworkedTrainCars.Remove(TrainCar);
trainCarIdToNetworkedTrainCars.Remove(CurrentID);
Expand Down Expand Up @@ -1186,6 +1200,25 @@ public void Server_PlayerOnCar(ServerPlayer player)

serverPlayersInCar.Add(player);
TrainCar?.visitChecker?.OnPlayerCarChanged(TrainCar);

// The vanilla ECS interior streamer is based on the host's local camera and
// can ignore CarVisitChecker's remote-player state. Customization components
// live in the instantiated interior, so explicitly load it for remote users.
if (TrainCar != null && !TrainCar.IsInteriorLoaded)
TrainCar.LoadInterior();
}

public void BeginInteriorNetworkSync()
{
interiorSyncLeaseCount++;
if (TrainCar != null && !TrainCar.IsInteriorLoaded)
TrainCar.LoadInterior();
}

public void EndInteriorNetworkSync()
{
if (interiorSyncLeaseCount > 0)
interiorSyncLeaseCount--;
}

public void Server_RemovePlayer(ServerPlayer player)
Expand Down
Loading