Skip to content
13 changes: 12 additions & 1 deletion CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ public static class ProtocolConstants
{
/// <summary>
/// Wire-format version. Bump when message layout changes to refuse handshake on mismatch.
/// v70 adds command id 34, service building state: one command per
/// abandonment, condemnation or destruction marker change on a non-spawnable
/// building, carrying the standing prefab, its position and the resulting marker
/// set. Growables keep their own lifecycle command and removals stay with delete
/// sync; a v69 peer knows neither the id nor the marker ownership and is refused
/// at the handshake instead of diverging silently.
/// v69 adds command id 33, fire ignition: one command per building or tree
/// fire start, carrying the target's prefab and position plus the ignition intensity.
/// Only starts travel; the burn, the spread and the extinguish run locally on every
/// machine, the same start-only shape as disaster events. A v68 peer does not know
/// id 33, so the bump refuses it at the handshake instead of dropping its fires silently.
/// v65 adds the barrier-only Begin stage: a join streams its world only to whoever joined,
/// and every other peer crosses the same barrier without being sent or installing one.
/// v65 also widens the accepted range of a course endpoint's split position. A
Expand Down Expand Up @@ -221,7 +232,7 @@ public static class ProtocolConstants
// v66 adds bounded display-only hover geometry to player presence updates.
// v68 batches one brush frame so dense tree strokes do not overflow or trickle in.
// Object-brush display markers are also excluded from terrain synchronization.
public const int ProtocolVersion = 68;
public const int ProtocolVersion = 70;

/// <summary>
/// Hard cap on a single payload, guarding against corrupt length prefixes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ internal static class GameplayCommandRegistry
GrowableLifecycleCommand.Id,
ModTypeTableCommand.Id, ModStateCommand.Id,
ObjectPlacementBatchCommand.Id, ObjectDeleteBatchCommand.Id,
FireIgniteCommand.Id,
ServiceBuildingStateCommand.Id,
};

internal static void Register(MultiplayerSession session)
Expand Down Expand Up @@ -73,6 +75,8 @@ internal static string Name(ushort id)
case ModStateCommand.Id: return "mod-state";
case ObjectPlacementBatchCommand.Id: return "object-place-batch";
case ObjectDeleteBatchCommand.Id: return "object-delete-batch";
case FireIgniteCommand.Id: return "fire-ignite";
case ServiceBuildingStateCommand.Id: return "service-building-state";
default: return "unknown";
}
}
Expand Down
114 changes: 114 additions & 0 deletions CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using CS2MultiplayerMod.Core.Protocol;
using CS2MultiplayerMod.Core.Sync;

namespace CS2MultiplayerMod.Game.Sync.Commands
{
/// <summary>
/// "Something caught fire, here, this strongly." Carries a fire start the receiving
/// game cannot derive for itself - which building or tree ignited and how hard -
/// and never the burn itself. Each machine then runs the fire with its own
/// simulation (escalation, spread, rescue, extinguish), so one small message covers
/// a fire of any length. Same start-only shape as <see cref="DisasterEventCommand"/>.
///
/// Only starts travel, in both directions: every machine rolls its own ignitions
/// and reports them, so both cities converge on the union of fires. Ends stay local:
/// each simulation extinguishes on its own clock, and the damage left behind is
/// already host-authoritative through the growable condition sync.
/// </summary>
public sealed class FireIgniteCommand : ISimulationCommand
{
public const ushort Id = 33;
public const int MaxEncodedBytes = 256;

/// <summary>
/// Ceiling on the ignition intensity. The game's own range is small; this only
/// stops a forged unsurvivable inferno, it never constrains a real fire.
/// </summary>
public const float MaxIntensityValue = 1000f;

/// <summary>Name of the ignited building or tree prefab, resolved locally by the receiver.</summary>
public string PrefabName;

/// <summary>World position of the ignited target (buildings do not move).</summary>
public float X, Y, Z;

/// <summary>Ignition strength as the sender's simulation rolled it.</summary>
public float Intensity;

public ushort CommandId => Id;

public void Write(NetworkWriter writer)
{
ValidateForWrite();
writer.WriteString(PrefabName);
writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z);
writer.WriteFloat(Intensity);
}

public void Read(NetworkReader reader)
{
PrefabName = WireGuard.ReadName(reader);
X = WireGuard.ReadCoordinate(reader);
Y = WireGuard.ReadCoordinate(reader);
Z = WireGuard.ReadCoordinate(reader);
Intensity = ReadIntensity(reader);

if (reader.Remaining != 0)
throw new ProtocolException("Trailing bytes in fire ignite: " + reader.Remaining + ".");
}

public byte[] Encode()
{
var writer = new NetworkWriter(96);
Write(writer);
if (writer.Length > MaxEncodedBytes)
throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap.");
return writer.ToArray();
}

public static FireIgniteCommand Decode(byte[] body)
{
if (body == null)
throw new ProtocolException("Missing fire ignite body.");
if (body.Length > MaxEncodedBytes)
throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap.");
var command = new FireIgniteCommand();
command.Read(new NetworkReader(body));
return command;
}

private void ValidateForWrite()
{
if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength)
throw new ProtocolException("Invalid fire target prefab name.");
for (int i = 0; i < PrefabName.Length; i++)
if (char.IsControl(PrefabName[i]))
throw new ProtocolException("Control character in fire target prefab name.");
ValidateCoordinate(X, "X");
ValidateCoordinate(Y, "Y");
ValidateCoordinate(Z, "Z");
ValidateIntensity(Intensity);
}

private static float ReadIntensity(NetworkReader reader)
{
float value = WireGuard.ReadFinite(reader);
ValidateIntensity(value);
return value;
}

private static void ValidateIntensity(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value) ||
value < 0f || value > MaxIntensityValue)
throw new ProtocolException("Implausible fire intensity: " + value + ".");
}

private static void ValidateCoordinate(float value, string label)
{
if (float.IsNaN(value) || float.IsInfinity(value) ||
value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate)
throw new ProtocolException("Invalid fire coordinate " + label + ".");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using CS2MultiplayerMod.Core.Protocol;
using CS2MultiplayerMod.Core.Sync;

namespace CS2MultiplayerMod.Game.Sync.Commands
{
/// <summary>
/// "This service building's state changed." Carries the abandonment, condemnation or
/// destruction markers of a NON-spawnable building - power plants, fire stations,
/// hospitals, signature buildings - the part of building state no other pipeline owns:
/// growable lifecycle sync only watches spawnables, and delete sync only watches removals.
/// A burned-down power plant that stays pristine on the peer is exactly the divergence
/// this closes: the ruin provides no coverage there while it provides none here either.
///
/// Marker gain and marker loss both travel (repairs converge too); removal itself stays
/// with delete sync, which replicates every non-spawnable removal already.
/// </summary>
public sealed class ServiceBuildingStateCommand : ISimulationCommand
{
public const ushort Id = 34;
public const int MaxEncodedBytes = 256;

public const byte StateAbandoned = 1 << 0;
public const byte StateCondemned = 1 << 1;
public const byte StateDestroyed = 1 << 2;

/// <summary>Name of the standing building's prefab, resolved locally by the receiver.</summary>
public string PrefabName;

/// <summary>World position of the building (service buildings do not move).</summary>
public float X, Y, Z;

/// <summary>Resulting marker set; zero means "all markers cleared".</summary>
public byte StateFlags;

public ushort CommandId => Id;

public void Write(NetworkWriter writer)
{
ValidateForWrite();
writer.WriteString(PrefabName);
writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z);
writer.WriteByte(StateFlags);
}

public void Read(NetworkReader reader)
{
PrefabName = WireGuard.ReadName(reader);
X = WireGuard.ReadCoordinate(reader);
Y = WireGuard.ReadCoordinate(reader);
Z = WireGuard.ReadCoordinate(reader);
StateFlags = ReadStateFlags(reader);

if (reader.Remaining != 0)
throw new ProtocolException("Trailing bytes in service building state: " +
reader.Remaining + ".");
}

public byte[] Encode()
{
var writer = new NetworkWriter(64);
Write(writer);
if (writer.Length > MaxEncodedBytes)
throw new ProtocolException("Service building state exceeds the " +
MaxEncodedBytes + "-byte cap.");
return writer.ToArray();
}

public static ServiceBuildingStateCommand Decode(byte[] body)
{
if (body == null)
throw new ProtocolException("Missing service building state body.");
if (body.Length > MaxEncodedBytes)
throw new ProtocolException("Service building state exceeds the " +
MaxEncodedBytes + "-byte cap.");
var command = new ServiceBuildingStateCommand();
command.Read(new NetworkReader(body));
return command;
}

private void ValidateForWrite()
{
if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength)
throw new ProtocolException("Invalid service building prefab name.");
for (int i = 0; i < PrefabName.Length; i++)
if (char.IsControl(PrefabName[i]))
throw new ProtocolException("Control character in service building prefab name.");
ValidateCoordinate(X, "X");
ValidateCoordinate(Y, "Y");
ValidateCoordinate(Z, "Z");
ValidateStateFlags(StateFlags);
}

private static byte ReadStateFlags(NetworkReader reader)
{
byte value = reader.ReadByte();
ValidateStateFlags(value);
return value;
}

private static void ValidateStateFlags(byte value)
{
if ((value & ~(StateAbandoned | StateCondemned | StateDestroyed)) != 0)
throw new ProtocolException("Unknown service building state flags: " + value + ".");
}

private static void ValidateCoordinate(float value, string label)
{
if (float.IsNaN(value) || float.IsInfinity(value) ||
value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate)
throw new ProtocolException("Invalid service building coordinate " + label + ".");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public partial class SyncRealizeSystem : GameSystemBase
private RouteSyncSystem _routeSync;
private TilePurchaseSyncSystem _tileSync;
private DisasterSyncSystem _disasterSync;
private FireSyncSystem _fireSync;
private ServiceBuildingStateSyncSystem _serviceBuildingStateSync;
private GrowableSyncSystem _growableSync;
private Mods.ModStateSyncSystem _modStateSync;

Expand All @@ -45,6 +47,8 @@ protected override void OnCreate()
_routeSync = World.GetOrCreateSystemManaged<RouteSyncSystem>();
_tileSync = World.GetOrCreateSystemManaged<TilePurchaseSyncSystem>();
_disasterSync = World.GetOrCreateSystemManaged<DisasterSyncSystem>();
_fireSync = World.GetOrCreateSystemManaged<FireSyncSystem>();
_serviceBuildingStateSync = World.GetOrCreateSystemManaged<ServiceBuildingStateSyncSystem>();
_growableSync = World.GetOrCreateSystemManaged<GrowableSyncSystem>();
_modStateSync = World.GetOrCreateSystemManaged<Mods.ModStateSyncSystem>();
}
Expand Down Expand Up @@ -179,6 +183,11 @@ protected override void OnUpdate()
// dependency - but they must still be created here: the game's event initialization
// runs later this frame and only ever looks at freshly Created events.
Step("DisasterSync", _disasterSync.RealizePending);
// A realized ignition only sets OnFire on an existing building or tree -
// no definitions, no terrain - so it rides the same slot as disasters.
Step("FireSync", _fireSync.RealizePending);
// Marker writes next to fire realizes: same plain-component shape, same slot.
Step("ServiceBuildingState", _serviceBuildingStateSync.RealizePending);
// Last: what another mod stores is stored against a road, a junction or a building,
// so everything that could still be creating one this frame has to have run. A
// closure whose carrier is genuinely still in the backlog waits in its own hold
Expand Down
Loading