Forráskód Böngészése

Gun prediction stuff (#158)

* WIP gun prediction stuff

* fixes, from goob

* changelog

* fixing missing vars
Taislin 7 hónapja
szülő
commit
fb8ba77c1d
32 módosított fájl, 1794 hozzáadás és 873 törlés
  1. 1 1
      Content.Client/Projectiles/ProjectileSystem.cs
  2. 29 100
      Content.Client/Weapons/Ranged/Systems/GunSystem.cs
  3. 177 0
      Content.Client/_RMC14/Weapons/Ranged/Prediction/GunPredictionSystem.cs
  4. 1 1
      Content.Server/Movement/Systems/LagCompensationSystem.cs
  5. 1 124
      Content.Server/Projectiles/ProjectileSystem.cs
  6. 1 0
      Content.Server/Weapons/Ranged/Systems/GunSystem.AutoFire.cs
  7. 7 396
      Content.Server/Weapons/Ranged/Systems/GunSystem.cs
  8. 274 0
      Content.Server/_RMC14/Weapons/Ranged/Prediction/GunPredictionSystem.cs
  9. 0 2
      Content.Shared/ItemRecall/SharedItemRecallSystem.cs
  10. 14 2
      Content.Shared/Projectiles/ProjectileComponent.cs
  11. 147 93
      Content.Shared/Projectiles/SharedProjectileSystem.cs
  12. 3 2
      Content.Shared/Weapons/Ranged/Components/RangedDamageSoundComponent.cs
  13. 1 0
      Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs
  14. 1 1
      Content.Shared/Weapons/Ranged/Systems/SharedFlyBySoundSystem.cs
  15. 1 0
      Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Ballistic.cs
  16. 556 150
      Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs
  17. 195 0
      Content.Shared/_RMC14/CCVar/RMCCVars.cs
  18. 62 0
      Content.Shared/_RMC14/Projectiles/Penetration/RMCPenetratingProjectileComponent.cs
  19. 120 0
      Content.Shared/_RMC14/Projectiles/Penetration/RMCPenetratingProjectileSystem.cs
  20. 39 0
      Content.Shared/_RMC14/Random/SplitMix64.cs
  21. 65 0
      Content.Shared/_RMC14/Random/Xoroshiro64S.cs
  22. 7 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/IgnorePredictionHideComponent.cs
  23. 14 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileClientComponent.cs
  24. 16 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileHitComponent.cs
  25. 11 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileHitEvent.cs
  26. 20 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileServerComponent.cs
  27. 16 0
      Content.Shared/_RMC14/Weapons/Ranged/Prediction/SharedGunPredictionSystem.cs
  28. 7 1
      Resources/Changelog/Changelog.yml
  29. 2 0
      Scripts/bat/runclient-Tools.bat
  30. 2 0
      Scripts/bat/runclient.bat
  31. 2 0
      Scripts/bat/runserver-Tools.bat
  32. 2 0
      Scripts/bat/runserver.bat

+ 1 - 1
Content.Client/Projectiles/ProjectileSystem.cs

@@ -15,7 +15,7 @@ public sealed class ProjectileSystem : SharedProjectileSystem
     public override void Initialize()
     {
         base.Initialize();
-        SubscribeNetworkEvent<ImpactEffectEvent>(OnProjectileImpact);
+        SubscribeAllEvent<ImpactEffectEvent>(OnProjectileImpact);
     }
 
     private void OnProjectileImpact(ImpactEffectEvent ev)

+ 29 - 100
Content.Client/Weapons/Ranged/Systems/GunSystem.cs

@@ -1,18 +1,18 @@
+using System.Linq;
 using System.Numerics;
 using Content.Client.Animations;
 using Content.Client.Gameplay;
 using Content.Client.Items;
 using Content.Client.Weapons.Ranged.Components;
-using Content.Shared.Camera;
+using Content.Shared._RMC14.Weapons.Ranged.Prediction;
 using Content.Shared.CombatMode;
-using Content.Shared.Weapons.Ranged;
-using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
 using Content.Shared.Weapons.Ranged.Systems;
 using Robust.Client.Animations;
 using Robust.Client.GameObjects;
 using Robust.Client.Graphics;
 using Robust.Client.Input;
+using Robust.Client.Physics;
 using Robust.Client.Player;
 using Robust.Client.State;
 using Robust.Shared.Animations;
@@ -35,9 +35,8 @@ public sealed partial class GunSystem : SharedGunSystem
     [Dependency] private readonly IStateManager _state = default!;
     [Dependency] private readonly AnimationPlayerSystem _animPlayer = default!;
     [Dependency] private readonly InputSystem _inputSystem = default!;
-    [Dependency] private readonly SharedCameraRecoilSystem _recoil = default!;
     [Dependency] private readonly SharedMapSystem _maps = default!;
-    [Dependency] private readonly SharedTransformSystem _xform = default!;
+    [Dependency] private readonly PhysicsSystem _physics = default!;
 
     [ValidatePrototypeId<EntityPrototype>]
     public const string HitscanProto = "HitscanEffect";
@@ -78,7 +77,6 @@ public override void Initialize()
         base.Initialize();
         UpdatesOutsidePrediction = true;
         SubscribeLocalEvent<AmmoCounterComponent, ItemStatusCollectMessage>(OnAmmoCounterCollect);
-        SubscribeLocalEvent<AmmoCounterComponent, UpdateClientAmmoEvent>(OnUpdateClientAmmo);
         SubscribeAllEvent<MuzzleFlashEvent>(OnMuzzleFlash);
 
         // Plays animated effects on the client.
@@ -88,28 +86,16 @@ public override void Initialize()
         InitializeSpentAmmo();
     }
 
-    private void OnUpdateClientAmmo(EntityUid uid, AmmoCounterComponent ammoComp, ref UpdateClientAmmoEvent args)
-    {
-        UpdateAmmoCount(uid, ammoComp);
-    }
-
     private void OnMuzzleFlash(MuzzleFlashEvent args)
     {
         var gunUid = GetEntity(args.Uid);
 
-        CreateEffect(gunUid, args, gunUid);
+        CreateEffect(gunUid, args, gunUid, _player.LocalEntity);
     }
 
     private void OnHitscan(HitscanEvent ev)
     {
         // ALL I WANT IS AN ANIMATED EFFECT
-
-        // TODO EFFECTS
-        // This is very jank
-        // because the effect consists of three unrelatd entities, the hitscan beam can be split appart.
-        // E.g., if a grid rotates while part of the beam is parented to the grid, and part of it is parented to the map.
-        // Ideally, there should only be one entity, with one sprite that has multiple layers
-        // Or at the very least, have the other entities parented to the same entity to make sure they stick together.
         foreach (var a in ev.Sprites)
         {
             if (a.Sprite is not SpriteSpecifier.Rsi rsi)
@@ -117,17 +103,13 @@ private void OnHitscan(HitscanEvent ev)
 
             var coords = GetCoordinates(a.coordinates);
 
-            if (!TryComp(coords.EntityId, out TransformComponent? relativeXform))
+            if (Deleted(coords.EntityId))
                 continue;
 
             var ent = Spawn(HitscanProto, coords);
             var sprite = Comp<SpriteComponent>(ent);
-
             var xform = Transform(ent);
-            var targetWorldRot = a.angle + _xform.GetWorldRotation(relativeXform);
-            var delta = targetWorldRot - _xform.GetWorldRotation(xform);
-            _xform.SetLocalRotationNoLerp(ent, xform.LocalRotation + delta, xform);
-
+            xform.LocalRotation = a.angle;
             sprite[EffectLayers.Unshaded].AutoAnimated = false;
             sprite.LayerSetSprite(EffectLayers.Unshaded, rsi);
             sprite.LayerSetState(EffectLayers.Unshaded, rsi.RsiState);
@@ -175,7 +157,7 @@ public override void Update(float frameTime)
 
         var useKey = gun.UseKey ? EngineKeyFunctions.Use : EngineKeyFunctions.UseSecondary;
 
-        if (_inputSystem.CmdStates.GetState(useKey) != BoundKeyState.Down && !gun.BurstActivated)
+        if (_inputSystem.CmdStates.GetState(useKey) != BoundKeyState.Down)
         {
             if (gun.ShotCounter != 0)
                 EntityManager.RaisePredictiveEvent(new RequestStopShootEvent { Gun = GetNetEntity(gunUid) });
@@ -202,87 +184,22 @@ public override void Update(float frameTime)
         if (_state.CurrentState is GameplayStateBase screen)
             target = GetNetEntity(screen.GetClickedEntity(mousePos));
 
+        if (_player.LocalSession is not { } session)
+            return;
+
         Log.Debug($"Sending shoot request tick {Timing.CurTick} / {Timing.CurTime}");
 
-        EntityManager.RaisePredictiveEvent(new RequestShootEvent
+        var projectiles = ShootRequested(GetNetEntity(gunUid), GetNetCoordinates(coordinates), target, null, session);
+
+        RaisePredictiveEvent(new RequestShootEvent()
         {
             Target = target,
             Coordinates = GetNetCoordinates(coordinates),
             Gun = GetNetEntity(gunUid),
+            Shot = projectiles?.Select(e => e.Id).ToList(),
         });
     }
 
-    public override void Shoot(EntityUid gunUid, GunComponent gun, List<(EntityUid? Entity, IShootable Shootable)> ammo,
-        EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, out bool userImpulse, EntityUid? user = null, bool throwItems = false)
-    {
-        userImpulse = true;
-
-        // Rather than splitting client / server for every ammo provider it's easier
-        // to just delete the spawned entities. This is for programmer sanity despite the wasted perf.
-        // This also means any ammo specific stuff can be grabbed as necessary.
-        var direction = TransformSystem.ToMapCoordinates(fromCoordinates).Position - TransformSystem.ToMapCoordinates(toCoordinates).Position;
-        var worldAngle = direction.ToAngle().Opposite();
-
-        foreach (var (ent, shootable) in ammo)
-        {
-            if (throwItems)
-            {
-                Recoil(user, direction, gun.CameraRecoilScalarModified);
-                if (IsClientSide(ent!.Value))
-                    Del(ent.Value);
-                else
-                    RemoveShootable(ent.Value);
-                continue;
-            }
-
-            switch (shootable)
-            {
-                case CartridgeAmmoComponent cartridge:
-                    if (!cartridge.Spent)
-                    {
-                        SetCartridgeSpent(ent!.Value, cartridge, true);
-                        MuzzleFlash(gunUid, cartridge, worldAngle, user);
-                        Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
-                        Recoil(user, direction, gun.CameraRecoilScalarModified);
-                        // TODO: Can't predict entity deletions.
-                        //if (cartridge.DeleteOnSpawn)
-                        //    Del(cartridge.Owner);
-                    }
-                    else
-                    {
-                        userImpulse = false;
-                        Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
-                    }
-
-                    if (IsClientSide(ent!.Value))
-                        Del(ent.Value);
-
-                    break;
-                case AmmoComponent newAmmo:
-                    MuzzleFlash(gunUid, newAmmo, worldAngle, user);
-                    Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
-                    Recoil(user, direction, gun.CameraRecoilScalarModified);
-                    if (IsClientSide(ent!.Value))
-                        Del(ent.Value);
-                    else
-                        RemoveShootable(ent.Value);
-                    break;
-                case HitscanPrototype:
-                    Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
-                    Recoil(user, direction, gun.CameraRecoilScalarModified);
-                    break;
-            }
-        }
-    }
-
-    private void Recoil(EntityUid? user, Vector2 recoil, float recoilScalar)
-    {
-        if (!Timing.IsFirstTimePredicted || user == null || recoil == Vector2.Zero || recoilScalar == 0)
-            return;
-
-        _recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar);
-    }
-
     protected override void Popup(string message, EntityUid? uid, EntityUid? user)
     {
         if (uid == null || user == null || !Timing.IsFirstTimePredicted)
@@ -291,7 +208,7 @@ protected override void Popup(string message, EntityUid? uid, EntityUid? user)
         PopupSystem.PopupEntity(message, uid.Value, user.Value);
     }
 
-    protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? tracked = null)
+    protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? tracked = null, EntityUid? player = null)
     {
         if (!Timing.IsFirstTimePredicted)
             return;
@@ -360,7 +277,7 @@ protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message,
         _animPlayer.Play(ent, anim, "muzzle-flash");
         if (!TryComp(gunUid, out PointLightComponent? light))
         {
-            light = (PointLightComponent) _factory.GetComponent(typeof(PointLightComponent));
+            light = (PointLightComponent)_factory.GetComponent(typeof(PointLightComponent));
             light.NetSyncEnabled = false;
             AddComp(gunUid, light);
         }
@@ -405,4 +322,16 @@ protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message,
         _animPlayer.Stop(gunUid, uidPlayer, "muzzle-flash-light");
         _animPlayer.Play((gunUid, uidPlayer), animTwo, "muzzle-flash-light");
     }
+
+    public override void ShootProjectile(EntityUid uid,
+        Vector2 direction,
+        Vector2 gunVelocity,
+        EntityUid gunUid,
+        EntityUid? user = null,
+        float speed = 20)
+    {
+        EnsureComp<PredictedProjectileClientComponent>(uid);
+        _physics.UpdateIsPredicted(uid);
+        base.ShootProjectile(uid, direction, gunVelocity, gunUid, user, speed);
+    }
 }

+ 177 - 0
Content.Client/_RMC14/Weapons/Ranged/Prediction/GunPredictionSystem.cs

@@ -0,0 +1,177 @@
+using System.Linq;
+using Content.Client.Projectiles;
+using Content.Shared._RMC14.Weapons.Ranged.Prediction;
+using Content.Shared.Projectiles;
+using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Weapons.Ranged.Systems;
+using Robust.Client.GameObjects;
+using Robust.Client.Physics;
+using Robust.Client.Player;
+using Robust.Shared.Map;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Physics.Systems;
+using Robust.Shared.Timing;
+
+namespace Content.Client._RMC14.Weapons.Ranged.Prediction;
+
+public sealed class GunPredictionSystem : SharedGunPredictionSystem
+{
+    [Dependency] private readonly SharedGunSystem _gun = default!;
+    [Dependency] private readonly PhysicsSystem _physics = default!;
+    [Dependency] private readonly IPlayerManager _player = default!;
+    [Dependency] private readonly ProjectileSystem _projectile = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+    private EntityQuery<IgnorePredictionHideComponent> _ignorePredictionHideQuery;
+    private EntityQuery<SpriteComponent> _spriteQuery;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        _ignorePredictionHideQuery = GetEntityQuery<IgnorePredictionHideComponent>();
+        _spriteQuery = GetEntityQuery<SpriteComponent>();
+
+        SubscribeLocalEvent<PhysicsUpdateBeforeSolveEvent>(OnBeforeSolve);
+        SubscribeLocalEvent<PhysicsUpdateAfterSolveEvent>(OnAfterSolve);
+        SubscribeLocalEvent<RequestShootEvent>(OnShootRequest);
+
+        SubscribeLocalEvent<PredictedProjectileClientComponent, UpdateIsPredictedEvent>(OnClientProjectileUpdateIsPredicted);
+        SubscribeLocalEvent<PredictedProjectileClientComponent, StartCollideEvent>(OnClientProjectileStartCollide);
+
+        SubscribeLocalEvent<PredictedProjectileServerComponent, ComponentStartup>(OnServerProjectileStartup);
+
+        UpdatesBefore.Add(typeof(TransformSystem));
+    }
+
+    private void OnBeforeSolve(ref PhysicsUpdateBeforeSolveEvent ev)
+    {
+        var query = EntityQueryEnumerator<PredictedProjectileClientComponent>();
+        while (query.MoveNext(out var uid, out var predicted))
+        {
+            predicted.Coordinates = Transform(uid).Coordinates;
+        }
+    }
+
+    private void OnAfterSolve(ref PhysicsUpdateAfterSolveEvent ev)
+    {
+        var query = EntityQueryEnumerator<PredictedProjectileClientComponent>();
+        while (query.MoveNext(out var uid, out var predicted))
+        {
+            if (_timing.IsFirstTimePredicted)
+                continue;
+
+            if (predicted.Coordinates is { } coordinates)
+                _transform.SetCoordinates(uid, coordinates);
+
+            predicted.Coordinates = null;
+        }
+    }
+
+    private void OnShootRequest(RequestShootEvent ev, EntitySessionEventArgs args)
+    {
+        if (_timing.IsFirstTimePredicted)
+            return;
+
+        _gun.ShootRequested(ev.Gun, ev.Coordinates, ev.Target, null, args.SenderSession);
+    }
+
+    private void OnClientProjectileUpdateIsPredicted(Entity<PredictedProjectileClientComponent> ent, ref UpdateIsPredictedEvent args)
+    {
+        args.IsPredicted = true;
+    }
+
+    private void OnClientProjectileStartCollide(Entity<PredictedProjectileClientComponent> ent, ref StartCollideEvent args)
+    {
+        if (ent.Comp.Hit)
+            return;
+
+        if (!TryComp(ent, out ProjectileComponent? projectile) ||
+            !TryComp(ent, out PhysicsComponent? physics))
+        {
+            return;
+        }
+
+        var netEnt = GetNetEntity(args.OtherEntity);
+        var pos = _transform.GetMapCoordinates(args.OtherEntity);
+        var hit = new HashSet<(NetEntity, MapCoordinates)> { (netEnt, pos) };
+        var ev = new PredictedProjectileHitEvent(ent.Owner.Id, hit);
+        RaiseNetworkEvent(ev);
+
+        _projectile.ProjectileCollide((ent, projectile, physics), args.OtherEntity);
+    }
+
+    private void OnServerProjectileStartup(Entity<PredictedProjectileServerComponent> ent, ref ComponentStartup args)
+    {
+        if (!GunPrediction)
+            return;
+
+        if (ent.Comp.ClientEnt != _player.LocalEntity)
+            return;
+
+        if (_ignorePredictionHideQuery.HasComp(ent))
+            return;
+
+        if (_spriteQuery.TryComp(ent, out var sprite))
+            sprite.Visible = false;
+    }
+
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        if (!_timing.IsFirstTimePredicted)
+            return;
+
+        // TODO gun prediction remove this once the client reliably detects collisions
+        var projectiles = EntityQueryEnumerator<PredictedProjectileClientComponent, ProjectileComponent, PhysicsComponent>();
+        while (projectiles.MoveNext(out var uid, out var predicted, out var projectile, out var physics))
+        {
+            if (predicted.Hit)
+                continue;
+
+            var contacts = _physics.GetContactingEntities(uid, physics, true);
+            if (contacts.Count == 0)
+                continue;
+
+            var hit = new HashSet<(NetEntity, MapCoordinates)>();
+            foreach (var contact in contacts)
+            {
+                var netEnt = GetNetEntity(contact);
+                var pos = _transform.GetMapCoordinates(contact);
+                hit.Add((netEnt, pos));
+            }
+
+            var ev = new PredictedProjectileHitEvent(uid.Id, hit);
+            RaiseNetworkEvent(ev);
+
+            _projectile.ProjectileCollide((uid, projectile, physics), contacts.First());
+        }
+
+        var predictedQuery = EntityQueryEnumerator<PredictedProjectileHitComponent, SpriteComponent, TransformComponent>();
+        while (predictedQuery.MoveNext(out var hit, out var sprite, out var xform))
+        {
+            var origin = hit.Origin;
+            var coordinates = xform.Coordinates;
+            if (!origin.TryDistance(EntityManager, _transform, coordinates, out var distance) ||
+                distance >= hit.Distance)
+            {
+                sprite.Visible = false;
+            }
+        }
+    }
+
+    public override void FrameUpdate(float frameTime)
+    {
+        base.FrameUpdate(frameTime);
+
+        // TODO bullet prediction remove this when lerping doesnt make the client's entity slightly slower
+        var projectiles = EntityQueryEnumerator<PredictedProjectileClientComponent, TransformComponent>();
+        while (projectiles.MoveNext(out _, out var xform))
+        {
+            xform.ActivelyLerping = false;
+        }
+    }
+}

+ 1 - 1
Content.Server/Movement/Systems/LagCompensationSystem.cs

@@ -70,7 +70,7 @@ private void OnLagMove(EntityUid uid, LagCompensationComponent component, ref Mo
 
         var angle = Angle.Zero;
         var coordinates = EntityCoordinates.Invalid;
-        var ping = pSession.Ping;
+        var ping = pSession.Channel.Ping;
         // Use 1.5 due to the trip buffer.
         var sentTime = _timing.CurTime - TimeSpan.FromMilliseconds(ping * 1.5);
 

+ 1 - 124
Content.Server/Projectiles/ProjectileSystem.cs

@@ -1,128 +1,5 @@
-using Content.Server.Administration.Logs;
-using Content.Server.Destructible;
-using Content.Server.Effects;
-using Content.Server.Weapons.Ranged.Systems;
-using Content.Shared.Camera;
-using Content.Shared.Damage;
-using Content.Shared.Database;
-using Content.Shared.FixedPoint;
 using Content.Shared.Projectiles;
-using Robust.Shared.Physics.Events;
-using Robust.Shared.Player;
 
 namespace Content.Server.Projectiles;
 
-public sealed class ProjectileSystem : SharedProjectileSystem
-{
-    [Dependency] private readonly IAdminLogManager _adminLogger = default!;
-    [Dependency] private readonly ColorFlashEffectSystem _color = default!;
-    [Dependency] private readonly DamageableSystem _damageableSystem = default!;
-    [Dependency] private readonly DestructibleSystem _destructibleSystem = default!;
-    [Dependency] private readonly GunSystem _guns = default!;
-    [Dependency] private readonly SharedCameraRecoilSystem _sharedCameraRecoil = default!;
-
-    public override void Initialize()
-    {
-        base.Initialize();
-        SubscribeLocalEvent<ProjectileComponent, StartCollideEvent>(OnStartCollide);
-    }
-
-    private void OnStartCollide(EntityUid uid, ProjectileComponent component, ref StartCollideEvent args)
-    {
-        // This is so entities that shouldn't get a collision are ignored.
-        if (args.OurFixtureId != ProjectileFixture || !args.OtherFixture.Hard
-            || component.ProjectileSpent || component is { Weapon: null, OnlyCollideWhenShot: true })
-            return;
-
-        var target = args.OtherEntity;
-        // it's here so this check is only done once before possible hit
-        var attemptEv = new ProjectileReflectAttemptEvent(uid, component, false);
-        RaiseLocalEvent(target, ref attemptEv);
-        if (attemptEv.Cancelled)
-        {
-            SetShooter(uid, component, target);
-            return;
-        }
-
-        var ev = new ProjectileHitEvent(component.Damage * _damageableSystem.UniversalProjectileDamageModifier, target, component.Shooter);
-        RaiseLocalEvent(uid, ref ev);
-
-        var otherName = ToPrettyString(target);
-        var damageRequired = _destructibleSystem.DestroyedAt(target);
-        if (TryComp<DamageableComponent>(target, out var damageableComponent))
-        {
-            damageRequired -= damageableComponent.TotalDamage;
-            damageRequired = FixedPoint2.Max(damageRequired, FixedPoint2.Zero);
-        }
-        var modifiedDamage = _damageableSystem.TryChangeDamage(target, ev.Damage, component.IgnoreResistances, damageable: damageableComponent, origin: component.Shooter);
-        var deleted = Deleted(target);
-
-        if (modifiedDamage is not null && EntityManager.EntityExists(component.Shooter))
-        {
-            if (modifiedDamage.AnyPositive() && !deleted)
-            {
-                _color.RaiseEffect(Color.Red, new List<EntityUid> { target }, Filter.Pvs(target, entityManager: EntityManager));
-            }
-
-            _adminLogger.Add(LogType.BulletHit,
-                HasComp<ActorComponent>(target) ? LogImpact.Extreme : LogImpact.High,
-                $"Projectile {ToPrettyString(uid):projectile} shot by {ToPrettyString(component.Shooter!.Value):user} hit {otherName:target} and dealt {modifiedDamage.GetTotal():damage} damage");
-        }
-
-        // If penetration is to be considered, we need to do some checks to see if the projectile should stop.
-        if (modifiedDamage is not null && component.PenetrationThreshold != 0)
-        {
-            // If a damage type is required, stop the bullet if the hit entity doesn't have that type.
-            if (component.PenetrationDamageTypeRequirement != null)
-            {
-                var stopPenetration = false;
-                foreach (var requiredDamageType in component.PenetrationDamageTypeRequirement)
-                {
-                    if (!modifiedDamage.DamageDict.Keys.Contains(requiredDamageType))
-                    {
-                        stopPenetration = true;
-                        break;
-                    }
-                }
-                if (stopPenetration)
-                    component.ProjectileSpent = true;
-            }
-
-            // If the object won't be destroyed, it "tanks" the penetration hit.
-            if (modifiedDamage.GetTotal() < damageRequired)
-            {
-                component.ProjectileSpent = true;
-            }
-
-            if (!component.ProjectileSpent)
-            {
-                component.PenetrationAmount += damageRequired;
-                // The projectile has dealt enough damage to be spent.
-                if (component.PenetrationAmount >= component.PenetrationThreshold)
-                {
-                    component.ProjectileSpent = true;
-                }
-            }
-        }
-        else
-        {
-            component.ProjectileSpent = true;
-        }
-
-        if (!deleted)
-        {
-            _guns.PlayImpactSound(target, modifiedDamage, component.SoundHit, component.ForceSound);
-
-            if (!args.OurBody.LinearVelocity.IsLengthZero())
-                _sharedCameraRecoil.KickCamera(target, args.OurBody.LinearVelocity.Normalized());
-        }
-
-        if (component.DeleteOnCollide && component.ProjectileSpent)
-            QueueDel(uid);
-
-        if (component.ImpactEffect != null && TryComp(uid, out TransformComponent? xform))
-        {
-            RaiseNetworkEvent(new ImpactEffectEvent(component.ImpactEffect, GetNetCoordinates(xform.Coordinates)), Filter.Pvs(xform.Coordinates, entityMan: EntityManager));
-        }
-    }
-}
+public sealed class ProjectileSystem : SharedProjectileSystem;

+ 1 - 0
Content.Server/Weapons/Ranged/Systems/GunSystem.AutoFire.cs

@@ -6,6 +6,7 @@ namespace Content.Server.Weapons.Ranged.Systems;
 
 public sealed partial class GunSystem
 {
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
     public override void Update(float frameTime)
     {
         base.Update(frameTime);

+ 7 - 396
Content.Server/Weapons/Ranged/Systems/GunSystem.cs

@@ -1,27 +1,19 @@
-using System.Linq;
 using System.Numerics;
 using Content.Server.Cargo.Systems;
+using Content.Server.Interaction;
 using Content.Server.Power.EntitySystems;
-using Content.Server.Weapons.Ranged.Components;
-using Content.Shared.Damage;
+using Content.Server.Stunnable;
 using Content.Shared.Damage.Systems;
-using Content.Shared.Database;
 using Content.Shared.Effects;
-using Content.Shared.Projectiles;
-using Content.Shared.Weapons.Melee;
 using Content.Shared.Weapons.Ranged;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
 using Content.Shared.Weapons.Ranged.Systems;
-using Content.Shared.Weapons.Reflect;
-using Content.Shared.Damage.Components;
-using Robust.Shared.Audio;
+using Robust.Shared.Containers;
 using Robust.Shared.Map;
-using Robust.Shared.Physics;
 using Robust.Shared.Player;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Utility;
-using Robust.Shared.Containers;
 
 namespace Content.Server.Weapons.Ranged.Systems;
 
@@ -31,12 +23,6 @@ public sealed partial class GunSystem : SharedGunSystem
     [Dependency] private readonly BatterySystem _battery = default!;
     [Dependency] private readonly DamageExamineSystem _damageExamine = default!;
     [Dependency] private readonly PricingSystem _pricing = default!;
-    [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
-    [Dependency] private readonly SharedTransformSystem _transform = default!;
-    [Dependency] private readonly StaminaSystem _stamina = default!;
-    [Dependency] private readonly SharedContainerSystem _container = default!;
-
-    private const float DamagePitchVariation = 0.05f;
 
     public override void Initialize()
     {
@@ -60,395 +46,20 @@ private void OnBallisticPrice(EntityUid uid, BallisticAmmoProviderComponent comp
         args.Price += price * component.UnspawnedCount;
     }
 
-    public override void Shoot(EntityUid gunUid, GunComponent gun, List<(EntityUid? Entity, IShootable Shootable)> ammo,
-        EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, out bool userImpulse, EntityUid? user = null, bool throwItems = false)
-    {
-        userImpulse = true;
-
-        if (user != null)
-        {
-            var selfEvent = new SelfBeforeGunShotEvent(user.Value, (gunUid, gun), ammo);
-            RaiseLocalEvent(user.Value, selfEvent);
-            if (selfEvent.Cancelled)
-            {
-                userImpulse = false;
-                return;
-            }
-        }
-
-        var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem);
-        var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
-        var mapDirection = toMap - fromMap.Position;
-        var mapAngle = mapDirection.ToAngle();
-        var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle());
-
-        // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
-        var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out var grid)
-            ? fromCoordinates.WithEntityId(gridUid, EntityManager)
-            : new EntityCoordinates(MapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
-
-        // Update shot based on the recoil
-        toMap = fromMap.Position + angle.ToVec() * mapDirection.Length();
-        mapDirection = toMap - fromMap.Position;
-        var gunVelocity = Physics.GetMapLinearVelocity(fromEnt);
-
-        // I must be high because this was getting tripped even when true.
-        // DebugTools.Assert(direction != Vector2.Zero);
-        var shotProjectiles = new List<EntityUid>(ammo.Count);
-
-        foreach (var (ent, shootable) in ammo)
-        {
-            // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling
-            if (throwItems && ent != null)
-            {
-                ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user);
-                continue;
-            }
-
-            switch (shootable)
-            {
-                // Cartridge shoots something else
-                case CartridgeAmmoComponent cartridge:
-                    if (!cartridge.Spent)
-                    {
-                        var uid = Spawn(cartridge.Prototype, fromEnt);
-                        CreateAndFireProjectiles(uid, cartridge);
-
-                        RaiseLocalEvent(ent!.Value, new AmmoShotEvent()
-                        {
-                            FiredProjectiles = shotProjectiles,
-                        });
-
-                        SetCartridgeSpent(ent.Value, cartridge, true);
-
-                        if (cartridge.DeleteOnSpawn)
-                            Del(ent.Value);
-                    }
-                    else
-                    {
-                        userImpulse = false;
-                        Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
-                    }
-
-                    // Something like ballistic might want to leave it in the container still
-                    if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value))
-                        EjectCartridge(ent.Value, angle);
-
-                    Dirty(ent!.Value, cartridge);
-                    break;
-                // Ammo shoots itself
-                case AmmoComponent newAmmo:
-                    if (ent == null)
-                        break;
-                    CreateAndFireProjectiles(ent.Value, newAmmo);
-
-                    break;
-                case HitscanPrototype hitscan:
-
-                    EntityUid? lastHit = null;
-
-                    var from = fromMap;
-                    // can't use map coords above because funny FireEffects
-                    var fromEffect = fromCoordinates;
-                    var dir = mapDirection.Normalized();
-
-                    //in the situation when user == null, means that the cannon fires on its own (via signals). And we need the gun to not fire by itself in this case
-                    var lastUser = user ?? gunUid;
-
-                    if (hitscan.Reflective != ReflectType.None)
-                    {
-                        for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
-                        {
-                            var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
-                            var rayCastResults =
-                                Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
-                            if (!rayCastResults.Any())
-                                break;
-
-                            var result = rayCastResults[0];
-
-                            // Check if laser is shot from in a container
-                            if (!_container.IsEntityOrParentInContainer(lastUser))
-                            {
-                                // Checks if the laser should pass over unless targeted by its user
-                                foreach (var collide in rayCastResults)
-                                {
-                                    if (collide.HitEntity != gun.Target &&
-                                        CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
-                                    {
-                                        continue;
-                                    }
-
-                                    result = collide;
-                                    break;
-                                }
-                            }
-
-                            var hit = result.HitEntity;
-                            lastHit = hit;
-
-                            FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
-
-                            var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
-                            RaiseLocalEvent(hit, ref ev);
-
-                            if (!ev.Reflected)
-                                break;
-
-                            fromEffect = Transform(hit).Coordinates;
-                            from = fromEffect.ToMap(EntityManager, _transform);
-                            dir = ev.Direction;
-                            lastUser = hit;
-                        }
-                    }
-
-                    if (lastHit != null)
-                    {
-                        var hitEntity = lastHit.Value;
-                        if (hitscan.StaminaDamage > 0f)
-                            _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user);
-
-                        var dmg = hitscan.Damage;
-
-                        var hitName = ToPrettyString(hitEntity);
-                        if (dmg != null)
-                            dmg = Damageable.TryChangeDamage(hitEntity, dmg * Damageable.UniversalHitscanDamageModifier, origin: user);
-
-                        // check null again, as TryChangeDamage returns modified damage values
-                        if (dmg != null)
-                        {
-                            if (!Deleted(hitEntity))
-                            {
-                                if (dmg.AnyPositive())
-                                {
-                                    _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
-                                }
-
-                                // TODO get fallback position for playing hit sound.
-                                PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
-                            }
-
-                            if (user != null)
-                            {
-                                Logs.Add(LogType.HitScanHit,
-                                    $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
-                            }
-                            else
-                            {
-                                Logs.Add(LogType.HitScanHit,
-                                    $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
-                            }
-                        }
-                    }
-                    else
-                    {
-                        FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
-                    }
-
-                    Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
-                    break;
-                default:
-                    throw new ArgumentOutOfRangeException();
-            }
-        }
-
-        RaiseLocalEvent(gunUid, new AmmoShotEvent()
-        {
-            FiredProjectiles = shotProjectiles,
-        });
-
-        void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
-        {
-            if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
-            {
-                var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
-                RaiseLocalEvent(gunUid, ref spreadEvent);
-
-                var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
-                    mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
-
-                ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user);
-                shotProjectiles.Add(ammoEnt);
-
-                for (var i = 1; i < ammoSpreadComp.Count; i++)
-                {
-                    var newuid = Spawn(ammoSpreadComp.Proto, fromEnt);
-                    ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user);
-                    shotProjectiles.Add(newuid);
-                }
-            }
-            else
-            {
-                ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user);
-                shotProjectiles.Add(ammoEnt);
-            }
-
-            MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user);
-            Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
-        }
-    }
-
-    private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user)
-    {
-        if (gun.Target is { } target && !TerminatingOrDeleted(target))
-        {
-            var targeted = EnsureComp<TargetedProjectileComponent>(uid);
-            targeted.Target = target;
-            Dirty(uid, targeted);
-        }
-
-        // Do a throw
-        if (!HasComp<ProjectileComponent>(uid))
-        {
-            RemoveShootable(uid);
-            // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack.
-            ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user);
-            return;
-        }
-
-        ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified);
-    }
-
-    /// <summary>
-    /// Gets a linear spread of angles between start and end.
-    /// </summary>
-    /// <param name="start">Start angle in degrees</param>
-    /// <param name="end">End angle in degrees</param>
-    /// <param name="intervals">How many shots there are</param>
-    private Angle[] LinearSpread(Angle start, Angle end, int intervals)
-    {
-        var angles = new Angle[intervals];
-        DebugTools.Assert(intervals > 1);
-
-        for (var i = 0; i <= intervals - 1; i++)
-        {
-            angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
-        }
-
-        return angles;
-    }
-
-    private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction)
-    {
-        var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds;
-        var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta);
-        component.CurrentAngle = new Angle(newTheta);
-        component.LastFire = component.NextFire;
-
-        // Convert it so angle can go either side.
-        var random = Random.NextFloat(-0.5f, 0.5f);
-        var spread = component.CurrentAngle.Theta * random;
-        var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random);
-        DebugTools.Assert(spread <= component.MaxAngleModified.Theta);
-        return angle;
-    }
-
     protected override void Popup(string message, EntityUid? uid, EntityUid? user) { }
 
-    protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null)
+    protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null, EntityUid? player = null)
     {
         var filter = Filter.Pvs(gunUid, entityManager: EntityManager);
-
         if (TryComp<ActorComponent>(user, out var actor))
             filter.RemovePlayer(actor.PlayerSession);
 
+        if (GunPrediction && TryComp(player, out actor))
+            filter.RemovePlayer(actor.PlayerSession);
+
         RaiseNetworkEvent(message, filter);
     }
 
-    public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound)
-    {
-        DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
-
-        // Like projectiles and melee,
-        // 1. Entity specific sound
-        // 2. Ammo's sound
-        // 3. Nothing
-        var playedSound = false;
-
-        if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
-        {
-            var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager);
-
-            if (type != null && rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true)
-            {
-                Audio.PlayPvs(damageSoundType, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation));
-                playedSound = true;
-            }
-            else if (type != null && rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true)
-            {
-                Audio.PlayPvs(damageSoundGroup, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation));
-                playedSound = true;
-            }
-        }
-
-        if (!playedSound && weaponSound != null)
-        {
-            Audio.PlayPvs(weaponSound, otherEntity);
-        }
-    }
 
     // TODO: Pseudo RNG so the client can predict these.
-    #region Hitscan effects
-
-    private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle angle, HitscanPrototype hitscan, EntityUid? hitEntity = null)
-    {
-        // Lord
-        // Forgive me for the shitcode I am about to do
-        // Effects tempt me not
-        var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
-        var fromXform = Transform(fromCoordinates.EntityId);
-
-        // We'll get the effects relative to the grid / map of the firer
-        // Look you could probably optimise this a bit with redundant transforms at this point.
-
-        var gridUid = fromXform.GridUid;
-        if (gridUid != fromCoordinates.EntityId && TryComp(gridUid, out TransformComponent? gridXform))
-        {
-            var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform);
-            var map = _transform.ToMapCoordinates(fromCoordinates);
-            fromCoordinates = new EntityCoordinates(gridUid.Value, Vector2.Transform(map.Position, gridInvMatrix));
-            angle -= gridRot;
-        }
-        else
-        {
-            angle -= _transform.GetWorldRotation(fromXform);
-        }
-
-        if (distance >= 1f)
-        {
-            if (hitscan.MuzzleFlash != null)
-            {
-                var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
-                var netCoords = GetNetCoordinates(coords);
-
-                sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
-            }
-
-            if (hitscan.TravelFlash != null)
-            {
-                var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
-                var netCoords = GetNetCoordinates(coords);
-
-                sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
-            }
-        }
-
-        if (hitscan.ImpactFlash != null)
-        {
-            var coords = fromCoordinates.Offset(angle.ToVec() * distance);
-            var netCoords = GetNetCoordinates(coords);
-
-            sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
-        }
-
-        if (sprites.Count > 0)
-        {
-            RaiseNetworkEvent(new HitscanEvent
-            {
-                Sprites = sprites,
-            }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
-        }
-    }
-
-    #endregion
 }

+ 274 - 0
Content.Server/_RMC14/Weapons/Ranged/Prediction/GunPredictionSystem.cs

@@ -0,0 +1,274 @@
+using Content.Server.Movement.Components;
+using Content.Server.Weapons.Ranged.Systems;
+using Content.Shared._RMC14.CCVar;
+using Content.Shared._RMC14.Weapons.Ranged.Prediction;
+using Content.Shared.GameTicking;
+using Content.Shared.Projectiles;
+using Content.Shared.Weapons.Ranged.Events;
+using Robust.Server.GameObjects;
+using Robust.Shared.Configuration;
+using Robust.Shared.Map;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Physics.Systems;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+
+namespace Content.Server._RMC14.Weapons.Ranged.Prediction;
+
+public sealed class GunPredictionSystem : SharedGunPredictionSystem
+{
+    [Dependency] private readonly IConfigurationManager _config = default!;
+    [Dependency] private readonly GunSystem _gun = default!;
+    [Dependency] private readonly SharedPhysicsSystem _physics = default!;
+    [Dependency] private readonly SharedProjectileSystem _projectile = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly TransformSystem _transform = default!;
+
+    private readonly Dictionary<(Guid, int), EntityUid> _predicted = new();
+    private readonly List<(PredictedProjectileHitEvent Event, ICommonSession Player)> _predictedHits = new();
+    private bool _preventCollision;
+    private bool _logHits;
+    private float _coordinateDeviation;
+    private float _lowestCoordinateDeviation;
+    private float _aabbEnlargement;
+
+    private EntityQuery<FixturesComponent> _fixturesQuery;
+    private EntityQuery<LagCompensationComponent> _lagCompensationQuery;
+    private EntityQuery<PhysicsComponent> _physicsQuery;
+    private EntityQuery<ProjectileComponent> _projectileQuery;
+    private EntityQuery<PredictedProjectileServerComponent> _predictedProjectileServerQuery;
+    private EntityQuery<TransformComponent> _transformQuery;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        _fixturesQuery = GetEntityQuery<FixturesComponent>();
+        _lagCompensationQuery = GetEntityQuery<LagCompensationComponent>();
+        _physicsQuery = GetEntityQuery<PhysicsComponent>();
+        _projectileQuery = GetEntityQuery<ProjectileComponent>();
+        _predictedProjectileServerQuery = GetEntityQuery<PredictedProjectileServerComponent>();
+        _transformQuery = GetEntityQuery<TransformComponent>();
+
+        SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundRestartCleanup);
+        SubscribeNetworkEvent<RequestShootEvent>(OnShootRequest);
+        SubscribeNetworkEvent<PredictedProjectileHitEvent>(OnPredictedProjectileHit);
+
+        SubscribeLocalEvent<PredictedProjectileServerComponent, MapInitEvent>(OnPredictedMapInit);
+        SubscribeLocalEvent<PredictedProjectileServerComponent, ComponentRemove>(OnPredictedRemove);
+        SubscribeLocalEvent<PredictedProjectileServerComponent, EntityTerminatingEvent>(OnPredictedRemove);
+        SubscribeLocalEvent<PredictedProjectileServerComponent, PreventCollideEvent>(OnPredictedPreventCollide);
+
+        Subs.CVar(_config, RMCCVars.RMCGunPredictionPreventCollision, v => _preventCollision = v, true);
+        Subs.CVar(_config, RMCCVars.RMCGunPredictionLogHits, v => _logHits = v, true);
+        Subs.CVar(_config, RMCCVars.RMCGunPredictionCoordinateDeviation, v => _coordinateDeviation = v, true);
+        Subs.CVar(_config, RMCCVars.RMCGunPredictionLowestCoordinateDeviation, v => _lowestCoordinateDeviation = v, true);
+        Subs.CVar(_config, RMCCVars.RMCGunPredictionAabbEnlargement, v => _aabbEnlargement = v, true);
+
+    }
+
+    private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+    {
+        _predicted.Clear();
+    }
+
+    private void OnShootRequest(RequestShootEvent ev, EntitySessionEventArgs args)
+    {
+        _gun.ShootRequested(ev.Gun, ev.Coordinates, ev.Target, ev.Shot, args.SenderSession);
+    }
+
+    private void OnPredictedMapInit(Entity<PredictedProjectileServerComponent> ent, ref MapInitEvent args)
+    {
+        if (ent.Comp.Shooter == null)
+        {
+            Log.Warning($"{nameof(PredictedProjectileServerComponent)} map initialized with a null shooter session!");
+            return;
+        }
+
+        _predicted[(ent.Comp.Shooter.UserId, ent.Comp.ClientId)] = ent;
+    }
+
+    private void OnPredictedRemove<T>(Entity<PredictedProjectileServerComponent> ent, ref T args)
+    {
+        if (ent.Comp.Shooter == null)
+            return;
+
+        _predicted.Remove((ent.Comp.Shooter.UserId, ent.Comp.ClientId));
+    }
+
+    private void OnPredictedProjectileHit(PredictedProjectileHitEvent ev, EntitySessionEventArgs args)
+    {
+        _predictedHits.Add((ev, args.SenderSession));
+    }
+
+    private void OnPredictedPreventCollide(Entity<PredictedProjectileServerComponent> ent, ref PreventCollideEvent args)
+    {
+        if (!_preventCollision)
+            return;
+
+        if (args.Cancelled)
+            return;
+
+        var other = args.OtherEntity;
+        if (!_lagCompensationQuery.TryComp(other, out var otherLagComp) ||
+            !_fixturesQuery.TryComp(other, out var otherFixtures) ||
+            !_transformQuery.TryComp(other, out var otherTransform))
+        {
+            return;
+        }
+
+        if (!_physicsQuery.TryComp(ent, out var entPhysics))
+            return;
+
+        if (!Collides(
+                (ent, ent, entPhysics),
+                (other, otherLagComp, otherFixtures, args.OtherBody, otherTransform),
+                null))
+        {
+            args.Cancelled = true;
+        }
+    }
+
+    private bool Collides(
+        Entity<PredictedProjectileServerComponent, PhysicsComponent> projectile,
+        Entity<LagCompensationComponent, FixturesComponent, PhysicsComponent, TransformComponent> other,
+        MapCoordinates? clientCoordinates)
+    {
+        var projectileCoordinates = _transform.GetMapCoordinates(projectile);
+        var projectilePosition = projectileCoordinates.Position;
+
+        MapCoordinates lowestCoordinate = default;
+        var otherCoordinates = EntityCoordinates.Invalid;
+        var ping = projectile.Comp1.Shooter?.Channel.Ping ?? 0;
+        // Use 1.5 due to the trip buffer.
+        var sentTime = _timing.CurTime - TimeSpan.FromMilliseconds(ping * 1.5);
+        var pingTime = TimeSpan.FromMilliseconds(ping);
+
+        foreach (var pos in other.Comp1.Positions)
+        {
+            otherCoordinates = pos.Item2;
+            if (pos.Item1 >= sentTime)
+                break;
+            else if (lowestCoordinate == default && pos.Item1 >= sentTime - pingTime)
+                lowestCoordinate = _transform.ToMapCoordinates(pos.Item2);
+        }
+
+        var otherMapCoordinates = otherCoordinates == default
+            ? _transform.GetMapCoordinates(other)
+            : _transform.ToMapCoordinates(otherCoordinates);
+
+        if (clientCoordinates != null &&
+            (clientCoordinates.Value.InRange(otherMapCoordinates, _coordinateDeviation) ||
+             clientCoordinates.Value.InRange(lowestCoordinate, _lowestCoordinateDeviation)))
+        {
+            otherMapCoordinates = clientCoordinates.Value;
+        }
+
+        var transform = new Transform(otherMapCoordinates.Position, 0);
+        var bounds = new Box2(transform.Position, transform.Position);
+
+        foreach (var fixture in other.Comp2.Fixtures.Values)
+        {
+            if ((fixture.CollisionLayer & projectile.Comp2.CollisionMask) == 0)
+                continue;
+
+            for (var i = 0; i < fixture.Shape.ChildCount; i++)
+            {
+                var boundy = fixture.Shape.ComputeAABB(transform, i);
+                bounds = bounds.Union(boundy);
+            }
+        }
+
+        bounds = bounds.Enlarged(_aabbEnlargement);
+        if (bounds.Contains(projectilePosition))
+            return true;
+
+        var projectileVelocity = _physics.GetLinearVelocity(projectile, projectile.Comp2.LocalCenter);
+        projectilePosition = projectileCoordinates.Position + projectileVelocity / _timing.TickRate / 1.5f;
+        if (bounds.Contains(projectilePosition))
+            return true;
+
+        return false;
+    }
+
+    private void ProcessPredictedHit(PredictedProjectileHitEvent ev, ICommonSession player)
+    {
+        if (!_predicted.TryGetValue((player.UserId, ev.Projectile), out var projectile))
+            return;
+
+        if (!_predictedProjectileServerQuery.TryComp(projectile, out var predictedProjectile) ||
+            predictedProjectile.Hit)
+        {
+            return;
+        }
+
+        if (predictedProjectile.Shooter?.UserId != player.UserId.UserId)
+            return;
+
+        if (!_projectileQuery.TryComp(projectile, out var projectileComp) ||
+            !_physicsQuery.TryComp(projectile, out var projectilePhysics))
+        {
+            return;
+        }
+
+        predictedProjectile.Hit = true;
+        foreach (var (netEnt, clientPos) in ev.Hit)
+        {
+            if (GetEntity(netEnt) is not { Valid: true } hit)
+                continue;
+
+            if (!_lagCompensationQuery.TryComp(hit, out var otherLagComp) ||
+                !_fixturesQuery.TryComp(hit, out var otherFixtures) ||
+                !_physicsQuery.TryComp(hit, out var otherPhysics) ||
+                !_transformQuery.TryComp(hit, out var otherTransform))
+            {
+                continue;
+            }
+
+            if (!Collides(
+                    (projectile, predictedProjectile, projectilePhysics),
+                    (hit, otherLagComp, otherFixtures, otherPhysics, otherTransform),
+                    clientPos))
+            {
+                if (_logHits)
+                    Log.Info("missed");
+
+                continue;
+            }
+
+            if (_logHits)
+                Log.Info("hit");
+
+            _projectile.ProjectileCollide((projectile, projectileComp, projectilePhysics), hit, true);
+        }
+    }
+
+    public override void Update(float frameTime)
+    {
+        try
+        {
+            foreach (var ev in _predictedHits)
+            {
+                ProcessPredictedHit(ev.Event, ev.Player);
+            }
+        }
+        finally
+        {
+            _predictedHits.Clear();
+        }
+
+        var predicted = EntityQueryEnumerator<PredictedProjectileHitComponent, TransformComponent>();
+        while (predicted.MoveNext(out var uid, out var hit, out var xform))
+        {
+            var origin = hit.Origin;
+            var coordinates = xform.Coordinates;
+            if (!origin.TryDistance(EntityManager, _transform, coordinates, out var distance) ||
+                distance >= hit.Distance)
+            {
+                QueueDel(uid);
+            }
+        }
+    }
+}

+ 0 - 2
Content.Shared/ItemRecall/SharedItemRecallSystem.cs

@@ -81,8 +81,6 @@ private void RecallItem(Entity<RecallMarkerComponent?> ent)
         if (actionOwner == null)
             return;
 
-        if (TryComp<EmbeddableProjectileComponent>(ent, out var projectile))
-            _proj.EmbedDetach(ent, projectile, actionOwner.Value);
 
         _popups.PopupPredicted(Loc.GetString("item-recall-item-summon-self", ("item", ent)),
                                Loc.GetString("item-recall-item-summon-others", ("item", ent), ("name", Identity.Entity(actionOwner.Value, EntityManager))),

+ 14 - 2
Content.Shared/Projectiles/ProjectileComponent.cs

@@ -42,7 +42,8 @@ public sealed partial class ProjectileComponent : Component
     /// <summary>
     ///     The amount of damage the projectile will do.
     /// </summary>
-    [DataField(required: true)] [ViewVariables(VVAccess.ReadWrite)]
+    [DataField(required: true)]
+    [ViewVariables(VVAccess.ReadWrite)]
     public DamageSpecifier Damage = new();
 
     /// <summary>
@@ -78,7 +79,7 @@ public sealed partial class ProjectileComponent : Component
     /// <summary>
     ///     If true, the projectile has hit enough targets and should no longer interact with further collisions pending deletion.
     /// </summary>
-    [DataField]
+    [DataField, AutoNetworkedField]
     public bool ProjectileSpent;
 
     /// <summary>
@@ -98,4 +99,15 @@ public sealed partial class ProjectileComponent : Component
     /// </summary>
     [DataField]
     public FixedPoint2 PenetrationAmount = FixedPoint2.Zero;
+
+    /// <summary>
+    /// Sets the maximum range for a projectile fired with ShootAtFixedPointComponent.
+    /// This can be set on both the Projectile and ShootAtFixedPoint Components.
+    /// The default value is null for no cap. The minimum value between the two is used.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float? MaxFixedRange;
+
+    [DataField, AutoNetworkedField]
+    public bool DamagedEntity;
 }

+ 147 - 93
Content.Shared/Projectiles/SharedProjectileSystem.cs

@@ -1,21 +1,25 @@
 using System.Numerics;
+using Content.Shared._RMC14.Weapons.Ranged.Prediction;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Camera;
 using Content.Shared.CombatMode.Pacification;
 using Content.Shared.Damage;
+using Content.Shared.Database;
 using Content.Shared.DoAfter;
+using Content.Shared.Effects;
 using Content.Shared.Hands.EntitySystems;
 using Content.Shared.Interaction;
-using Content.Shared.Mobs.Components;
 using Content.Shared.Throwing;
+using Content.Shared.Weapons.Ranged.Systems;
 using Robust.Shared.Audio.Systems;
 using Robust.Shared.Map;
 using Robust.Shared.Network;
 using Robust.Shared.Physics;
 using Robust.Shared.Physics.Components;
-using Robust.Shared.Physics.Dynamics;
 using Robust.Shared.Physics.Events;
 using Robust.Shared.Physics.Systems;
+using Robust.Shared.Player;
 using Robust.Shared.Serialization;
-using Robust.Shared.Utility;
 
 namespace Content.Shared.Projectiles;
 
@@ -23,111 +27,146 @@ public abstract partial class SharedProjectileSystem : EntitySystem
 {
     public const string ProjectileFixture = "projectile";
 
-    [Dependency] private readonly INetManager _net = default!;
+    [Dependency] private readonly INetManager _netManager = default!;
     [Dependency] private readonly SharedAudioSystem _audio = default!;
     [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
     [Dependency] private readonly SharedHandsSystem _hands = default!;
     [Dependency] private readonly SharedPhysicsSystem _physics = default!;
     [Dependency] private readonly SharedTransformSystem _transform = default!;
+    [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+    [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
+    [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+    [Dependency] private readonly SharedGunSystem _guns = default!;
+    [Dependency] private readonly SharedCameraRecoilSystem _sharedCameraRecoil = default!;
 
     public override void Initialize()
     {
         base.Initialize();
 
+        SubscribeLocalEvent<ProjectileComponent, StartCollideEvent>(OnStartCollide);
         SubscribeLocalEvent<ProjectileComponent, PreventCollideEvent>(PreventCollision);
         SubscribeLocalEvent<EmbeddableProjectileComponent, ProjectileHitEvent>(OnEmbedProjectileHit);
         SubscribeLocalEvent<EmbeddableProjectileComponent, ThrowDoHitEvent>(OnEmbedThrowDoHit);
         SubscribeLocalEvent<EmbeddableProjectileComponent, ActivateInWorldEvent>(OnEmbedActivate);
         SubscribeLocalEvent<EmbeddableProjectileComponent, RemoveEmbeddedProjectileEvent>(OnEmbedRemove);
-
-        SubscribeLocalEvent<EmbeddedContainerComponent, EntityTerminatingEvent>(OnEmbeddableTermination);
     }
 
-    private void OnEmbedActivate(Entity<EmbeddableProjectileComponent> embeddable, ref ActivateInWorldEvent args)
+    private void OnStartCollide(EntityUid uid, ProjectileComponent component, ref StartCollideEvent args)
     {
-        // Unremovable embeddables moment
-        if (embeddable.Comp.RemovalTime == null)
-            return;
-
-        if (args.Handled || !args.Complex || !TryComp<PhysicsComponent>(embeddable, out var physics) ||
-            physics.BodyType != BodyType.Static)
+        // This is so entities that shouldn't get a collision are ignored.
+        if (args.OurFixtureId != ProjectileFixture || !args.OtherFixture.Hard
+            || component.DamagedEntity || component is { Weapon: null, OnlyCollideWhenShot: true })
             return;
 
-        args.Handled = true;
-
-        _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager,
-            args.User,
-            embeddable.Comp.RemovalTime.Value,
-            new RemoveEmbeddedProjectileEvent(),
-            eventTarget: embeddable,
-            target: embeddable));
+        ProjectileCollide((uid, component, args.OurBody), args.OtherEntity);
     }
 
-    private void OnEmbedRemove(Entity<EmbeddableProjectileComponent> embeddable, ref RemoveEmbeddedProjectileEvent args)
+    public void ProjectileCollide(Entity<ProjectileComponent, PhysicsComponent> projectile, EntityUid target, bool predicted = false)
     {
-        // Whacky prediction issues.
-        if (args.Cancelled || _net.IsClient)
-            return;
+        var (uid, component, ourBody) = projectile;
+        if (projectile.Comp1.DamagedEntity)
+        {
+            if (_netManager.IsServer && component.DeleteOnCollide)
+                QueueDel(uid);
 
-        EmbedDetach(embeddable, embeddable.Comp, args.User);
+            return;
+        }
 
-        // try place it in the user's hand
-        _hands.TryPickupAnyHand(args.User, embeddable);
-    }
+        // it's here so this check is only done once before possible hit
+        var attemptEv = new ProjectileReflectAttemptEvent(uid, component, false);
+        RaiseLocalEvent(target, ref attemptEv);
+        if (attemptEv.Cancelled)
+        {
+            SetShooter(uid, component, target);
+            return;
+        }
 
-    private void OnEmbedThrowDoHit(Entity<EmbeddableProjectileComponent> embeddable, ref ThrowDoHitEvent args)
-    {
-        if (!embeddable.Comp.EmbedOnThrow)
+        var ev = new ProjectileHitEvent(component.Damage, target, component.Shooter);
+        RaiseLocalEvent(uid, ref ev);
+        if (ev.Handled)
             return;
 
-        EmbedAttach(embeddable, args.Target, null, embeddable.Comp);
-    }
+        var coordinates = Transform(projectile).Coordinates;
+        var otherName = ToPrettyString(target);
+        var direction = ourBody.LinearVelocity.Normalized();
+        var modifiedDamage = _netManager.IsServer
+            ? _damageableSystem.TryChangeDamage(target,
+                ev.Damage,
+                component.IgnoreResistances,
+                origin: component.Shooter)
+            : new DamageSpecifier(ev.Damage);
+        var deleted = Deleted(target);
+
+        var filter = Filter.Pvs(coordinates, entityMan: EntityManager);
+        if (_guns.GunPrediction && TryComp(projectile, out PredictedProjectileServerComponent? serverProjectile))
+            filter = filter.RemovePlayer(serverProjectile.Shooter);
+
+        if (modifiedDamage is not null && (EntityManager.EntityExists(component.Shooter) || EntityManager.EntityExists(component.Weapon)))
+        {
+            if (modifiedDamage.AnyPositive() && !deleted)
+            {
+                _color.RaiseEffect(Color.Red, new List<EntityUid> { target }, filter);
+            }
 
-    private void OnEmbedProjectileHit(Entity<EmbeddableProjectileComponent> embeddable, ref ProjectileHitEvent args)
-    {
-        EmbedAttach(embeddable, args.Target, args.Shooter, embeddable.Comp);
+            var shooterOrWeapon = EntityManager.EntityExists(component.Shooter) ? component.Shooter!.Value : component.Weapon!.Value;
 
-        // Raise a specific event for projectiles.
-        if (TryComp(embeddable, out ProjectileComponent? projectile))
+            _adminLogger.Add(LogType.BulletHit,
+                HasComp<ActorComponent>(target) ? LogImpact.Extreme : LogImpact.High,
+                $"Projectile {ToPrettyString(uid):projectile} shot by {ToPrettyString(shooterOrWeapon):source} hit {otherName:target} and dealt {modifiedDamage.GetTotal():damage} damage");
+        }
+
+        if (!deleted)
         {
-            var ev = new ProjectileEmbedEvent(projectile.Shooter!.Value, projectile.Weapon!.Value, args.Target);
-            RaiseLocalEvent(embeddable, ref ev);
+            _guns.PlayImpactSound(target, modifiedDamage, component.SoundHit, component.ForceSound, filter, projectile);
+            _sharedCameraRecoil.KickCamera(target, direction);
         }
-    }
 
-    private void EmbedAttach(EntityUid uid, EntityUid target, EntityUid? user, EmbeddableProjectileComponent component)
-    {
-        TryComp<PhysicsComponent>(uid, out var physics);
-        _physics.SetLinearVelocity(uid, Vector2.Zero, body: physics);
-        _physics.SetBodyType(uid, BodyType.Static, body: physics);
-        var xform = Transform(uid);
-        _transform.SetParent(uid, xform, target);
+        component.DamagedEntity = true;
+        Dirty(uid, component);
 
-        if (component.Offset != Vector2.Zero)
+        if (!predicted && component.DeleteOnCollide && (_netManager.IsServer || IsClientSide(uid)))
+            QueueDel(uid);
+        else if (_netManager.IsServer && component.DeleteOnCollide)
         {
-            var rotation = xform.LocalRotation;
-            if (TryComp<ThrowingAngleComponent>(uid, out var throwingAngleComp))
-                rotation += throwingAngleComp.Angle;
-            _transform.SetLocalPosition(uid, xform.LocalPosition + rotation.RotateVec(component.Offset), xform);
+            var predictedComp = EnsureComp<PredictedProjectileHitComponent>(uid);
+            predictedComp.Origin = _transform.GetMoverCoordinates(coordinates);
+
+            var targetCoords = _transform.GetMoverCoordinates(target);
+            if (predictedComp.Origin.TryDistance(EntityManager, _transform, targetCoords, out var distance))
+                predictedComp.Distance = distance;
+
+            Dirty(uid, predictedComp);
         }
 
-        _audio.PlayPredicted(component.Sound, uid, null);
-        component.EmbeddedIntoUid = target;
-        var ev = new EmbedEvent(user, target);
-        RaiseLocalEvent(uid, ref ev);
-        Dirty(uid, component);
+        if ((_netManager.IsServer || IsClientSide(uid)) && component.ImpactEffect != null)
+        {
+            var impactEffectEv = new ImpactEffectEvent(component.ImpactEffect, GetNetCoordinates(coordinates));
+            if (_netManager.IsServer)
+                RaiseNetworkEvent(impactEffectEv, filter);
+            else
+                RaiseLocalEvent(impactEffectEv);
+        }
+    }
 
-        EnsureComp<EmbeddedContainerComponent>(target, out var embeddedContainer);
+    private void OnEmbedActivate(EntityUid uid, EmbeddableProjectileComponent component, ActivateInWorldEvent args)
+    {
+        // Nuh uh
+        if (component.RemovalTime == null)
+            return;
 
-        //Assert that this entity not embed
-        DebugTools.AssertEqual(embeddedContainer.EmbeddedObjects.Contains(uid), false);
+        if (args.Handled || !args.Complex || !TryComp<PhysicsComponent>(uid, out var physics) || physics.BodyType != BodyType.Static)
+            return;
 
-        embeddedContainer.EmbeddedObjects.Add(uid);
+        args.Handled = true;
+
+        _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, args.User, component.RemovalTime.Value,
+            new RemoveEmbeddedProjectileEvent(), eventTarget: uid, target: uid));
     }
 
-    public void EmbedDetach(EntityUid uid, EmbeddableProjectileComponent? component, EntityUid? user = null)
+    private void OnEmbedRemove(EntityUid uid, EmbeddableProjectileComponent component, RemoveEmbeddedProjectileEvent args)
     {
-        if (!Resolve(uid, ref component))
+        // Whacky prediction issues.
+        if (args.Cancelled || _netManager.IsClient)
             return;
 
         if (component.DeleteOnRemove)
@@ -136,53 +175,68 @@ public void EmbedDetach(EntityUid uid, EmbeddableProjectileComponent? component,
             return;
         }
 
-        if (component.EmbeddedIntoUid is not null)
-        {
-            if (TryComp<EmbeddedContainerComponent>(component.EmbeddedIntoUid.Value, out var embeddedContainer))
-                embeddedContainer.EmbeddedObjects.Remove(uid);
-        }
-
         var xform = Transform(uid);
         TryComp<PhysicsComponent>(uid, out var physics);
         _physics.SetBodyType(uid, BodyType.Dynamic, body: physics, xform: xform);
         _transform.AttachToGridOrMap(uid, xform);
-        component.EmbeddedIntoUid = null;
-        Dirty(uid, component);
 
         // Reset whether the projectile has damaged anything if it successfully was removed
         if (TryComp<ProjectileComponent>(uid, out var projectile))
         {
             projectile.Shooter = null;
             projectile.Weapon = null;
-            projectile.ProjectileSpent = false;
-
-            Dirty(uid, projectile);
-        }
-
-        if (user != null)
-        {
-            // Land it just coz uhhh yeah
-            var landEv = new LandEvent(user, true);
-            RaiseLocalEvent(uid, ref landEv);
+            projectile.DamagedEntity = false;
         }
 
+        // Land it just coz uhhh yeah
+        var landEv = new LandEvent(args.User, true);
+        RaiseLocalEvent(uid, ref landEv);
         _physics.WakeBody(uid, body: physics);
+
+        // try place it in the user's hand
+        _hands.TryPickupAnyHand(args.User, uid);
     }
 
-    private void OnEmbeddableTermination(Entity<EmbeddedContainerComponent> container, ref EntityTerminatingEvent args)
+    private void OnEmbedThrowDoHit(EntityUid uid, EmbeddableProjectileComponent component, ThrowDoHitEvent args)
     {
-        DetachAllEmbedded(container);
+        if (!component.EmbedOnThrow)
+            return;
+
+        Embed(uid, args.Target, null, component);
     }
 
-    public void DetachAllEmbedded(Entity<EmbeddedContainerComponent> container)
+    private void OnEmbedProjectileHit(EntityUid uid, EmbeddableProjectileComponent component, ref ProjectileHitEvent args)
     {
-        foreach (var embedded in container.Comp.EmbeddedObjects)
+        Embed(uid, args.Target, args.Shooter, component);
+
+        // Raise a specific event for projectiles.
+        if (TryComp(uid, out ProjectileComponent? projectile))
         {
-            if (!TryComp<EmbeddableProjectileComponent>(embedded, out var embeddedComp))
-                continue;
+            var ev = new ProjectileEmbedEvent(projectile.Shooter!.Value, projectile.Weapon!.Value, args.Target);
+            RaiseLocalEvent(uid, ref ev);
+        }
+    }
 
-            EmbedDetach(embedded, embeddedComp);
+    private void Embed(EntityUid uid, EntityUid target, EntityUid? user, EmbeddableProjectileComponent component)
+    {
+        TryComp<PhysicsComponent>(uid, out var physics);
+        _physics.SetLinearVelocity(uid, Vector2.Zero, body: physics);
+        _physics.SetBodyType(uid, BodyType.Static, body: physics);
+        var xform = Transform(uid);
+        _transform.SetParent(uid, xform, target);
+
+        if (component.Offset != Vector2.Zero)
+        {
+            var rotation = xform.LocalRotation;
+            if (TryComp<ThrowingAngleComponent>(uid, out var throwingAngleComp))
+                rotation += throwingAngleComp.Angle;
+            _transform.SetLocalPosition(uid, xform.LocalPosition + rotation.RotateVec(component.Offset),
+                xform);
         }
+
+        _audio.PlayPredicted(component.Sound, uid, null);
+        var ev = new EmbedEvent(user, target);
+        RaiseLocalEvent(uid, ref ev);
     }
 
     private void PreventCollision(EntityUid uid, ProjectileComponent component, ref PreventCollideEvent args)
@@ -193,9 +247,9 @@ private void PreventCollision(EntityUid uid, ProjectileComponent component, ref
         }
     }
 
-    public void SetShooter(EntityUid id, ProjectileComponent component, EntityUid shooterId)
+    public void SetShooter(EntityUid id, ProjectileComponent component, EntityUid? shooterId = null)
     {
-        if (component.Shooter == shooterId)
+        if (component.Shooter == shooterId || shooterId == null)
             return;
 
         component.Shooter = shooterId;
@@ -232,4 +286,4 @@ public record struct ProjectileReflectAttemptEvent(EntityUid ProjUid, Projectile
 /// Raised when a projectile hits an entity
 /// </summary>
 [ByRefEvent]
-public record struct ProjectileHitEvent(DamageSpecifier Damage, EntityUid Target, EntityUid? Shooter = null);
+public record struct ProjectileHitEvent(DamageSpecifier Damage, EntityUid Target, EntityUid? Shooter = null, bool Handled = false);

+ 3 - 2
Content.Server/Weapons/Ranged/Components/RangedDamageSoundComponent.cs → Content.Shared/Weapons/Ranged/Components/RangedDamageSoundComponent.cs

@@ -1,13 +1,14 @@
 using Content.Shared.Damage.Prototypes;
 using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
 using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
 
-namespace Content.Server.Weapons.Ranged.Components;
+namespace Content.Shared.Weapons.Ranged.Components;
 
 /// <summary>
 /// Plays the specified sound upon receiving damage of that type.
 /// </summary>
-[RegisterComponent]
+[RegisterComponent, NetworkedComponent]
 public sealed partial class RangedDamageSoundComponent : Component
 {
     // TODO: Limb damage changing sound type.

+ 1 - 0
Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs

@@ -12,4 +12,5 @@ public sealed class RequestShootEvent : EntityEventArgs
     public NetEntity Gun;
     public NetCoordinates Coordinates;
     public NetEntity? Target;
+    public List<int>? Shot;
 }

+ 1 - 1
Content.Shared/Weapons/Ranged/Systems/SharedFlyBySoundSystem.cs

@@ -31,7 +31,7 @@ private void OnStartup(EntityUid uid, FlyBySoundComponent component, ComponentSt
 
         var shape = new PhysShapeCircle(component.Range);
 
-        _fixtures.TryCreateFixture(uid, shape, FlyByFixture, collisionLayer: (int) CollisionGroup.MobMask, hard: false, body: body);
+        //_fixtures.TryCreateFixture(uid, shape, FlyByFixture, collisionLayer: (int) CollisionGroup.MobMask, hard: false, body: body);
     }
 
     private void OnShutdown(EntityUid uid, FlyBySoundComponent component, ComponentShutdown args)

+ 1 - 0
Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Ballistic.cs

@@ -8,6 +8,7 @@
 using Robust.Shared.Containers;
 using Robust.Shared.Map;
 using Robust.Shared.Serialization;
+using Content.Shared._RMC14.Weapons.Ranged;
 
 namespace Content.Shared.Weapons.Ranged.Systems;
 

+ 556 - 150
Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs

@@ -1,18 +1,30 @@
 using System.Diagnostics.CodeAnalysis;
+using System.Linq;
 using System.Numerics;
+using Content.Shared._RMC14.CCVar;
+using Content.Shared._RMC14.Random;
+using Content.Shared._RMC14.Weapons.Ranged.Prediction;
 using Content.Shared.ActionBlocker;
 using Content.Shared.Actions;
 using Content.Shared.Administration.Logs;
 using Content.Shared.Audio;
+using Content.Shared.Camera;
 using Content.Shared.CombatMode;
 using Content.Shared.Containers.ItemSlots;
 using Content.Shared.Damage;
+using Content.Shared.Damage.Components;
+using Content.Shared.Damage.Systems;
+using Content.Shared.Database;
+using Content.Shared.Effects;
 using Content.Shared.Examine;
 using Content.Shared.Gravity;
 using Content.Shared.Hands;
 using Content.Shared.Hands.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Interaction.Components;
 using Content.Shared.Popups;
 using Content.Shared.Projectiles;
+using Content.Shared.Stunnable;
 using Content.Shared.Tag;
 using Content.Shared.Throwing;
 using Content.Shared.Timing;
@@ -21,14 +33,18 @@
 using Content.Shared.Weapons.Melee.Events;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Weapons.Reflect;
 using Content.Shared.Whitelist;
 using Robust.Shared.Audio;
 using Robust.Shared.Audio.Systems;
+using Robust.Shared.Configuration;
 using Robust.Shared.Containers;
 using Robust.Shared.Map;
 using Robust.Shared.Network;
+using Robust.Shared.Physics;
 using Robust.Shared.Physics.Components;
 using Robust.Shared.Physics.Systems;
+using Robust.Shared.Player;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Random;
 using Robust.Shared.Serialization;
@@ -39,23 +55,23 @@ namespace Content.Shared.Weapons.Ranged.Systems;
 
 public abstract partial class SharedGunSystem : EntitySystem
 {
-    [Dependency] private   readonly ActionBlockerSystem _actionBlockerSystem = default!;
+    [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
     [Dependency] protected readonly IGameTiming Timing = default!;
     [Dependency] protected readonly IMapManager MapManager = default!;
-    [Dependency] private   readonly INetManager _netManager = default!;
+    [Dependency] private readonly INetManager _netManager = default!;
     [Dependency] protected readonly IPrototypeManager ProtoManager = default!;
     [Dependency] protected readonly IRobustRandom Random = default!;
     [Dependency] protected readonly ISharedAdminLogManager Logs = default!;
     [Dependency] protected readonly DamageableSystem Damageable = default!;
     [Dependency] protected readonly ExamineSystemShared Examine = default!;
-    [Dependency] private   readonly ItemSlotsSystem _slots = default!;
-    [Dependency] private   readonly RechargeBasicEntityAmmoSystem _recharge = default!;
+    [Dependency] private readonly ItemSlotsSystem _slots = default!;
+    [Dependency] private readonly RechargeBasicEntityAmmoSystem _recharge = default!;
     [Dependency] protected readonly SharedActionsSystem Actions = default!;
     [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
     [Dependency] protected readonly SharedAudioSystem Audio = default!;
-    [Dependency] private   readonly SharedCombatModeSystem _combatMode = default!;
+    [Dependency] private readonly SharedCombatModeSystem _combatMode = default!;
     [Dependency] protected readonly SharedContainerSystem Containers = default!;
-    [Dependency] private   readonly SharedGravitySystem _gravity = default!;
+    [Dependency] private readonly SharedGravitySystem _gravity = default!;
     [Dependency] protected readonly SharedPointLightSystem Lights = default!;
     [Dependency] protected readonly SharedPopupSystem PopupSystem = default!;
     [Dependency] protected readonly SharedPhysicsSystem Physics = default!;
@@ -63,8 +79,13 @@ public abstract partial class SharedGunSystem : EntitySystem
     [Dependency] protected readonly SharedTransformSystem TransformSystem = default!;
     [Dependency] protected readonly TagSystem TagSystem = default!;
     [Dependency] protected readonly ThrowingSystem ThrowingSystem = default!;
-    [Dependency] private   readonly UseDelaySystem _useDelay = default!;
+    [Dependency] private readonly UseDelaySystem _useDelay = default!;
     [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
+    [Dependency] private readonly StaminaSystem _stamina = default!;
+    [Dependency] private readonly SharedStunSystem _stun = default!;
+    [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
+    [Dependency] private readonly SharedCameraRecoilSystem _recoil = default!;
+    [Dependency] private readonly IConfigurationManager _config = default!;
 
     private const float InteractNextFire = 0.3f;
     private const double SafetyNextFire = 0.5;
@@ -72,10 +93,12 @@ public abstract partial class SharedGunSystem : EntitySystem
     protected const string AmmoExamineColor = "yellow";
     protected const string FireRateExamineColor = "yellow";
     public const string ModeExamineColor = "cyan";
+    public const float GunClumsyChance = 0.5f;
+    private const float DamagePitchVariation = 0.05f;
+    public bool GunPrediction { get; private set; }
 
     public override void Initialize()
     {
-        SubscribeAllEvent<RequestShootEvent>(OnShootRequest);
         SubscribeAllEvent<RequestStopShootEvent>(OnStopShootRequest);
         SubscribeLocalEvent<GunComponent, MeleeHitEvent>(OnGunMelee);
 
@@ -97,6 +120,7 @@ public override void Initialize()
         SubscribeLocalEvent<GunComponent, CycleModeEvent>(OnCycleMode);
         SubscribeLocalEvent<GunComponent, HandSelectedEvent>(OnGunSelected);
         SubscribeLocalEvent<GunComponent, MapInitEvent>(OnMapInit);
+        Subs.CVar(_config, RMCCVars.RMCGunPrediction, v => GunPrediction = v, true);
     }
 
     private void OnMapInit(Entity<GunComponent> gun, ref MapInitEvent args)
@@ -119,29 +143,10 @@ private void OnGunMelee(EntityUid uid, GunComponent component, MeleeHitEvent arg
         if (melee.NextAttack > component.NextFire)
         {
             component.NextFire = melee.NextAttack;
-            EntityManager.DirtyField(uid, component, nameof(GunComponent.NextFire));
+            Dirty(uid, component);
         }
     }
 
-    private void OnShootRequest(RequestShootEvent msg, EntitySessionEventArgs args)
-    {
-        var user = args.SenderSession.AttachedEntity;
-
-        if (user == null ||
-            !_combatMode.IsInCombatMode(user) ||
-            !TryGetGun(user.Value, out var ent, out var gun))
-        {
-            return;
-        }
-
-        if (ent != GetEntity(msg.Gun))
-            return;
-
-        gun.ShootCoordinates = GetCoordinates(msg.Coordinates);
-        gun.Target = GetEntity(msg.Target);
-        AttemptShoot(user.Value, ent, gun);
-    }
-
     private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args)
     {
         var gunUid = GetEntity(ev.Gun);
@@ -200,7 +205,7 @@ private void StopShooting(EntityUid uid, GunComponent gun)
         gun.ShotCounter = 0;
         gun.ShootCoordinates = null;
         gun.Target = null;
-        EntityManager.DirtyField(uid, gun, nameof(GunComponent.ShotCounter));
+        Dirty(uid, gun);
     }
 
     /// <summary>
@@ -211,7 +216,6 @@ public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, Ent
         gun.ShootCoordinates = toCoordinates;
         AttemptShoot(user, gunUid, gun);
         gun.ShotCounter = 0;
-        EntityManager.DirtyField(gunUid, gun, nameof(GunComponent.ShotCounter));
     }
 
     /// <summary>
@@ -219,24 +223,22 @@ public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, Ent
     /// </summary>
     public void AttemptShoot(EntityUid gunUid, GunComponent gun)
     {
-        var coordinates = new EntityCoordinates(gunUid, gun.DefaultDirection);
+        var coordinates = new EntityCoordinates(gunUid, new Vector2(0, -1));
         gun.ShootCoordinates = coordinates;
         AttemptShoot(gunUid, gunUid, gun);
         gun.ShotCounter = 0;
     }
 
-    private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
+    private List<EntityUid>? AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, List<int>? predictedProjectiles = null, ICommonSession? userSession = null)
     {
         if (gun.FireRateModified <= 0f ||
             !_actionBlockerSystem.CanAttack(user))
-        {
-            return;
-        }
+            return null;
 
         var toCoordinates = gun.ShootCoordinates;
 
         if (toCoordinates == null)
-            return;
+            return null;
 
         var curTime = Timing.CurTime;
 
@@ -248,22 +250,19 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
         };
         RaiseLocalEvent(gunUid, ref prevention);
         if (prevention.Cancelled)
-            return;
+            return null;
 
         RaiseLocalEvent(user, ref prevention);
         if (prevention.Cancelled)
-            return;
+            return null;
 
         // Need to do this to play the clicking sound for empty automatic weapons
         // but not play anything for burst fire.
         if (gun.NextFire > curTime)
-            return;
+            return null;
 
         var fireRate = TimeSpan.FromSeconds(1f / gun.FireRateModified);
 
-        if (gun.SelectedMode == SelectiveFire.Burst || gun.BurstActivated)
-            fireRate = TimeSpan.FromSeconds(1f / gun.BurstFireRate);
-
         // First shot
         // Previously we checked shotcounter but in some cases all the bullets got dumped at once
         // curTime - fireRate is insufficient because if you time it just right you can get a 3rd shot out slightly quicker.
@@ -280,28 +279,22 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
         }
 
         // NextFire has been touched regardless so need to dirty the gun.
-        EntityManager.DirtyField(gunUid, gun, nameof(GunComponent.NextFire));
+        Dirty(gunUid, gun);
 
         // Get how many shots we're actually allowed to make, due to clip size or otherwise.
         // Don't do this in the loop so we still reset NextFire.
-        if (!gun.BurstActivated)
-        {
-            switch (gun.SelectedMode)
-            {
-                case SelectiveFire.SemiAuto:
-                    shots = Math.Min(shots, 1 - gun.ShotCounter);
-                    break;
-                case SelectiveFire.Burst:
-                    shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
-                    break;
-                case SelectiveFire.FullAuto:
-                    break;
-                default:
-                    throw new ArgumentOutOfRangeException($"No implemented shooting behavior for {gun.SelectedMode}!");
-            }
-        } else
+        switch (gun.SelectedMode)
         {
-            shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
+            case SelectiveFire.SemiAuto:
+                shots = Math.Min(shots, 1 - gun.ShotCounter);
+                break;
+            case SelectiveFire.Burst:
+                shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
+                break;
+            case SelectiveFire.FullAuto:
+                break;
+            default:
+                throw new ArgumentOutOfRangeException($"No implemented shooting behavior for {gun.SelectedMode}!");
         }
 
         var attemptEv = new AttemptShootEvent(user, null);
@@ -313,12 +306,14 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
             {
                 PopupSystem.PopupClient(attemptEv.Message, gunUid, user);
             }
-            gun.BurstActivated = false;
-            gun.BurstShotsCount = 0;
+
             gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
-            return;
+            return null;
         }
 
+        if (!Timing.IsFirstTimePredicted)
+            return null;
+
         var fromCoordinates = Transform(user).Coordinates;
         // Remove ammo
         var ev = new TakeAmmoEvent(shots, new List<(EntityUid? Entity, IShootable Shootable)>(), fromCoordinates, user);
@@ -334,7 +329,6 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
         // Even if we don't actually shoot update the ShotCounter. This is to avoid spamming empty sounds
         // where the gun may be SemiAuto or Burst.
         gun.ShotCounter += shots;
-        EntityManager.DirtyField(gunUid, gun, nameof(GunComponent.ShotCounter));
 
         if (ev.Ammo.Count <= 0)
         {
@@ -342,44 +336,27 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
             var emptyGunShotEvent = new OnEmptyGunShotEvent();
             RaiseLocalEvent(gunUid, ref emptyGunShotEvent);
 
-            gun.BurstActivated = false;
-            gun.BurstShotsCount = 0;
-            gun.NextFire += TimeSpan.FromSeconds(gun.BurstCooldown);
-
             // Play empty gun sounds if relevant
             // If they're firing an existing clip then don't play anything.
             if (shots > 0)
             {
-                PopupSystem.PopupCursor(ev.Reason ?? Loc.GetString("gun-magazine-fired-empty"));
+                if (ev.Reason != null && Timing.IsFirstTimePredicted)
+                {
+                    PopupSystem.PopupCursor(ev.Reason);
+                }
 
                 // Don't spam safety sounds at gun fire rate, play it at a reduced rate.
                 // May cause prediction issues? Needs more tweaking
                 gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
                 Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
-                return;
+                return null;
             }
 
-            return;
-        }
-
-        // Handle burstfire
-        if (gun.SelectedMode == SelectiveFire.Burst)
-        {
-            gun.BurstActivated = true;
-        }
-        if (gun.BurstActivated)
-        {
-            gun.BurstShotsCount += shots;
-            if (gun.BurstShotsCount >= gun.ShotsPerBurstModified)
-            {
-                gun.NextFire += TimeSpan.FromSeconds(gun.BurstCooldown);
-                gun.BurstActivated = false;
-                gun.BurstShotsCount = 0;
-            }
+            return null;
         }
 
         // Shoot confirmed - sounds also played here in case it's invalid (e.g. cartridge already spent).
-        Shoot(gunUid, gun, ev.Ammo, fromCoordinates, toCoordinates.Value, out var userImpulse, user, throwItems: attemptEv.ThrowItems);
+        var projectiles = Shoot(gunUid, gun, ev.Ammo, fromCoordinates, toCoordinates.Value, out var userImpulse, user, throwItems: attemptEv.ThrowItems, predictedProjectiles, userSession);
         var shotEv = new GunShotEvent(user, ev.Ammo);
         RaiseLocalEvent(gunUid, ref shotEv);
 
@@ -388,6 +365,9 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
             if (_gravity.IsWeightless(user, userPhysics))
                 CauseImpulse(fromCoordinates, toCoordinates.Value, user, userPhysics);
         }
+
+        Dirty(gunUid, gun);
+        return projectiles;
     }
 
     public void Shoot(
@@ -404,7 +384,7 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
         Shoot(gunUid, gun, new List<(EntityUid? Entity, IShootable Shootable)>(1) { (ammo, shootable) }, fromCoordinates, toCoordinates, out userImpulse, user, throwItems);
     }
 
-    public abstract void Shoot(
+    public List<EntityUid>? Shoot(
         EntityUid gunUid,
         GunComponent gun,
         List<(EntityUid? Entity, IShootable Shootable)> ammo,
@@ -412,9 +392,458 @@ private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
         EntityCoordinates toCoordinates,
         out bool userImpulse,
         EntityUid? user = null,
-        bool throwItems = false);
+        bool throwItems = false,
+        List<int>? predictedProjectiles = null,
+        ICommonSession? userSession = null)
+    {
+        userImpulse = true;
+
+
+        var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem);
+        var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
+        var mapDirection = toMap - fromMap.Position;
+        var mapAngle = mapDirection.ToAngle();
+        var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle());
+
+        // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
+        var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out var grid)
+            ? fromCoordinates.WithEntityId(gridUid, EntityManager)
+            : new EntityCoordinates(MapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
+
+        // Update shot based on the recoil
+        toMap = fromMap.Position + angle.ToVec() * mapDirection.Length();
+        mapDirection = toMap - fromMap.Position;
+        var gunVelocity = Physics.GetMapLinearVelocity(fromEnt);
+
+        // I must be high because this was getting tripped even when true.
+        // DebugTools.Assert(direction != Vector2.Zero);
+        var shotProjectiles = new List<EntityUid>(ammo.Count);
+
+        void MarkPredicted(EntityUid projectile, int index)
+        {
+            if (!GunPrediction)
+                return;
+
+            if (predictedProjectiles == null || userSession == null)
+                return;
+
+            if (predictedProjectiles.TryGetValue(index, out var predicted))
+            {
+                var comp = new PredictedProjectileServerComponent
+                {
+                    Shooter = userSession,
+                    ClientId = predicted,
+                    ClientEnt = user,
+                };
+                AddComp(projectile, comp, true);
+                Dirty(projectile, comp);
+            }
+        }
+
+        foreach (var (ent, shootable) in ammo)
+        {
+            // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling
+            if (throwItems && ent != null)
+            {
+                Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
+                ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user);
+                continue;
+            }
+
+            switch (shootable)
+            {
+                // Cartridge shoots something else
+                case CartridgeAmmoComponent cartridge:
+                    if (!cartridge.Spent)
+                    {
+                        if (_netManager.IsServer || GunPrediction)
+                        {
+                            var uid = Spawn(cartridge.Prototype, fromEnt);
+                            shotProjectiles.Add(uid);
+                            CreateAndFireProjectiles(uid, cartridge);
+
+                            RaiseLocalEvent(ent!.Value, new AmmoShotEvent()
+                            {
+                                FiredProjectiles = shotProjectiles,
+                            });
+
+                            SetCartridgeSpent(ent.Value, cartridge, true);
+
+                            if (cartridge.DeleteOnSpawn &&
+                                (_netManager.IsServer || IsClientSide(ent.Value)))
+                            {
+                                Del(ent.Value);
+                            }
+                        }
+                        else
+                        {
+                            MuzzleFlash(gunUid, cartridge, mapDirection.ToAngle(), user);
+                            Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
+                        }
+                    }
+                    else
+                    {
+                        userImpulse = false;
+                        Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
+                    }
+
+                    Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
+
+                    // Something like ballistic might want to leave it in the container still
+                    if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value))
+                        EjectCartridge(ent.Value, angle);
+
+                    if (IsClientSide(ent!.Value))
+                        Del(ent.Value);
+                    else
+                        Dirty(ent!.Value, cartridge);
+                    break;
+                // Ammo shoots itself
+                case AmmoComponent newAmmo:
+                    if (_netManager.IsServer || GunPrediction)
+                    {
+                        shotProjectiles.Add(ent!.Value);
+                        CreateAndFireProjectiles(ent.Value, newAmmo);
+                    }
+                    else
+                    {
+                        MuzzleFlash(gunUid, newAmmo, mapDirection.ToAngle(), user);
+                        Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
+                    }
+
+                    Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
+
+                    if (IsClientSide(ent!.Value))
+                        Del(ent.Value);
+                    else if (_netManager.IsClient)
+                        RemoveShootable(ent.Value);
+                    MarkPredicted(ent!.Value, 0);
+                    break;
+                case HitscanPrototype hitscan:
+                    EntityUid? lastHit = null;
+
+                    var from = fromMap;
+                    // can't use map coords above because funny FireEffects
+                    var fromEffect = fromCoordinates;
+                    var dir = mapDirection.Normalized();
+
+                    //in the situation when user == null, means that the cannon fires on its own (via signals). And we need the gun to not fire by itself in this case
+                    var lastUser = user ?? gunUid;
+
+                    if (hitscan.Reflective != ReflectType.None)
+                    {
+                        for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
+                        {
+                            var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
+                            var rayCastResults =
+                                Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
+                            if (!rayCastResults.Any())
+                                break;
+
+                            var result = rayCastResults[0];
+
+                            // Check if laser is shot from in a container
+                            if (!Containers.IsEntityOrParentInContainer(lastUser))
+                            {
+                                // Checks if the laser should pass over unless targeted by its user
+                                foreach (var collide in rayCastResults)
+                                {
+                                    if (collide.HitEntity != gun.Target &&
+                                        CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
+                                    {
+                                        continue;
+                                    }
+
+                                    result = collide;
+                                    break;
+                                }
+                            }
+
+                            var hit = result.HitEntity;
+                            lastHit = hit;
+
+                            FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
+
+                            var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
+                            RaiseLocalEvent(hit, ref ev);
+
+                            if (!ev.Reflected)
+                                break;
+
+                            fromEffect = Transform(hit).Coordinates;
+                            from = fromEffect.ToMap(EntityManager, TransformSystem);
+                            dir = ev.Direction;
+                            lastUser = hit;
+                        }
+                    }
+
+                    if (lastHit != null)
+                    {
+                        var hitEntity = lastHit.Value;
+                        if (hitscan.StaminaDamage > 0f)
+                            _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user);
+
+                        var dmg = hitscan.Damage;
+
+                        var hitName = ToPrettyString(hitEntity);
+                        if (dmg != null)
+                            dmg = Damageable.TryChangeDamage(hitEntity, dmg, origin: user);
+
+                        // check null again, as TryChangeDamage returns modified damage values
+                        if (dmg != null)
+                        {
+                            if (!Deleted(hitEntity))
+                            {
+                                if (dmg.AnyPositive())
+                                {
+                                    _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
+                                }
+
+                                // TODO get fallback position for playing hit sound.
+                                PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
+                            }
+
+                            if (user != null)
+                            {
+                                Logs.Add(LogType.HitScanHit,
+                                    $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
+                            }
+                            else
+                            {
+                                Logs.Add(LogType.HitScanHit,
+                                    $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
+                            }
+                        }
+                    }
+                    else
+                    {
+                        FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
+                    }
+
+                    Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
+                    Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
+                    break;
+                default:
+                    throw new ArgumentOutOfRangeException();
+            }
+        }
+
+        RaiseLocalEvent(gunUid, new AmmoShotEvent()
+        {
+            FiredProjectiles = shotProjectiles,
+        });
+
+        void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
+        {
+            predictedProjectiles ??= new List<int>();
+            MarkPredicted(ammoEnt, 0);
+            if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
+            {
+                var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
+                RaiseLocalEvent(gunUid, ref spreadEvent);
+
+                var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
+                    mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
+
+                ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user);
+                shotProjectiles.Add(ammoEnt);
+
+                for (var i = 1; i < ammoSpreadComp.Count; i++)
+                {
+                    var newuid = Spawn(ammoSpreadComp.Proto, fromEnt);
+                    ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user);
+                    shotProjectiles.Add(newuid);
+                    MarkPredicted(newuid, i + 1);
+                }
+            }
+            else
+            {
+                ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user);
+                shotProjectiles.Add(ammoEnt);
+            }
+
+            MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user);
+            Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
+        }
+
+        return shotProjectiles;
+    }
+
+    private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction)
+    {
+        var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds;
+        var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta);
+        component.CurrentAngle = new Angle(newTheta);
+        component.LastFire = component.NextFire;
+
+        // Convert it so angle can go either side.
+        long tick = Timing.CurTick.Value;
+        tick = tick << 32;
+        tick = tick | (uint)GetNetEntity(component.Owner).Id;
+        Logger.Info(Timing.CurTick.ToString());
+        var random = new Xoroshiro64S(tick).NextFloat(-0.5f, 0.5f);
+        var spread = component.CurrentAngle.Theta * random;
+        var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random);
+        DebugTools.Assert(spread <= component.MaxAngleModified.Theta);
+        return angle;
+    }
+
+    private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user)
+    {
+        if (gun.Target is { } target && !TerminatingOrDeleted(target))
+        {
+            var targeted = EnsureComp<TargetedProjectileComponent>(uid);
+            targeted.Target = target;
+            Dirty(uid, targeted);
+        }
+
+        // Do a throw
+        if (!HasComp<ProjectileComponent>(uid))
+        {
+            RemoveShootable(uid);
+            // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack.
+            ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user);
+            return;
+        }
+        ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified);
+    }
+
+    #region Hitscan effects
+
+    private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle mapDirection, HitscanPrototype hitscan, EntityUid? hitEntity = null)
+    {
+        // Lord
+        // Forgive me for the shitcode I am about to do
+        // Effects tempt me not
+        var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
+        var gridUid = fromCoordinates.GetGridUid(EntityManager);
+        var angle = mapDirection;
+
+        // We'll get the effects relative to the grid / map of the firer
+        // Look you could probably optimise this a bit with redundant transforms at this point.
+        var xformQuery = GetEntityQuery<TransformComponent>();
+
+        if (xformQuery.TryGetComponent(gridUid, out var gridXform))
+        {
+            var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform, xformQuery);
+
+            fromCoordinates = new EntityCoordinates(gridUid.Value,
+                Vector2.Transform(fromCoordinates.ToMapPos(EntityManager, TransformSystem), gridInvMatrix));
+
+            // Use the fallback angle I guess?
+            angle -= gridRot;
+        }
+
+        if (distance >= 1f)
+        {
+            if (hitscan.MuzzleFlash != null)
+            {
+                var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
+                var netCoords = GetNetCoordinates(coords);
+
+                sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
+            }
+
+            if (hitscan.TravelFlash != null)
+            {
+                var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
+                var netCoords = GetNetCoordinates(coords);
+
+                sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
+            }
+        }
+
+        if (hitscan.ImpactFlash != null)
+        {
+            var coords = fromCoordinates.Offset(angle.ToVec() * distance);
+            var netCoords = GetNetCoordinates(coords);
+
+            sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
+        }
+
+        if (_netManager.IsServer && sprites.Count > 0)
+        {
+            RaiseNetworkEvent(new HitscanEvent
+            {
+                Sprites = sprites,
+            }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
+        }
+    }
+
+    #endregion
+
+
+    /// <summary>
+    /// Gets a linear spread of angles between start and end.
+    /// </summary>
+    /// <param name="start">Start angle in degrees</param>
+    /// <param name="end">End angle in degrees</param>
+    /// <param name="intervals">How many shots there are</param>
+    private Angle[] LinearSpread(Angle start, Angle end, int intervals)
+    {
+        var angles = new Angle[intervals];
+        DebugTools.Assert(intervals > 1);
+
+        for (var i = 0; i <= intervals - 1; i++)
+        {
+            angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
+        }
+
+        return angles;
+    }
+
+    public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound, Filter? filter = null, EntityUid? projectile = null)
+    {
+        DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
+
+        // Like projectiles and melee,
+        // 1. Entity specific sound
+        // 2. Ammo's sound
+        // 3. Nothing
+        if (_netManager.IsClient && HasComp<PredictedProjectileServerComponent>(projectile))
+            return;
+
+        filter ??= Filter.Pvs(otherEntity);
+        var playedSound = false;
+
+        if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
+        {
+            var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager);
+
+            if (type != null &&
+                rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true &&
+                filter.Count > 0)
+            {
+                Audio.PlayEntity(damageSoundType, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
+                playedSound = true;
+            }
+            else if (type != null &&
+                     rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true &&
+                     filter.Count > 0)
+            {
+                Audio.PlayEntity(damageSoundGroup, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
+                playedSound = true;
+            }
+        }
 
-    public void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid gunUid, EntityUid? user = null, float speed = 20f)
+        if (!playedSound && weaponSound != null && filter.Count > 0)
+        {
+            Audio.PlayEntity(weaponSound, filter, otherEntity, true);
+        }
+    }
+
+    private void Recoil(EntityUid? user, Vector2 recoil, float recoilScalar)
+    {
+        if (_netManager.IsServer)
+            return;
+
+        if (!Timing.IsFirstTimePredicted || user == null || recoil == Vector2.Zero || recoilScalar == 0)
+            return;
+
+        _recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar);
+    }
+
+    public virtual void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid gunUid, EntityUid? user = null, float speed = 20f)
     {
         var physics = EnsureComp<PhysicsComponent>(uid);
         Physics.SetBodyStatus(uid, physics, BodyStatus.InAir);
@@ -428,7 +857,26 @@ public void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocit
         Projectiles.SetShooter(uid, projectile, user ?? gunUid);
         projectile.Weapon = gunUid;
 
-        TransformSystem.SetWorldRotation(uid, direction.ToWorldAngle() + projectile.Angle);
+        TransformSystem.SetWorldRotationNoLerp(uid, direction.ToWorldAngle());
+    }
+
+    public List<EntityUid>? ShootRequested(NetEntity netGun, NetCoordinates coordinates, NetEntity? target, List<int>? projectiles, ICommonSession session)
+    {
+        var user = session.AttachedEntity;
+
+        if (user == null ||
+            !_combatMode.IsInCombatMode(user) ||
+            !TryGetGun(user.Value, out var ent, out var gun))
+        {
+            return null;
+        }
+
+        if (ent != GetEntity(netGun))
+            return null;
+
+        gun.ShootCoordinates = GetCoordinates(coordinates);
+        gun.Target = GetEntity(target);
+        return AttemptShoot(user.Value, ent, gun, projectiles, session);
     }
 
     protected abstract void Popup(string message, EntityUid? uid, EntityUid? user);
@@ -436,12 +884,12 @@ public void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocit
     /// <summary>
     /// Call this whenever the ammo count for a gun changes.
     /// </summary>
-    protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) {}
+    protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) { }
 
     protected void SetCartridgeSpent(EntityUid uid, CartridgeAmmoComponent cartridge, bool spent)
     {
         if (cartridge.Spent != spent)
-            DirtyField(uid, cartridge, nameof(CartridgeAmmoComponent.Spent));
+            Dirty(uid, cartridge);
 
         cartridge.Spent = spent;
         Appearance.SetData(uid, AmmoVisuals.Spent, spent);
@@ -505,7 +953,7 @@ protected void MuzzleFlash(EntityUid gun, AmmoComponent component, Angle worldAn
             return;
 
         var ev = new MuzzleFlashEvent(GetNetEntity(gun), sprite, worldAngle);
-        CreateEffect(gun, ev, user);
+        CreateEffect(gun, ev, gun, user);
     }
 
     public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, EntityUid user, PhysicsComponent userPhysics)
@@ -515,7 +963,7 @@ public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates to
         var shotDirection = (toMap - fromMap).Normalized();
 
         const float impulseStrength = 25.0f;
-        var impulseVector =  shotDirection * impulseStrength;
+        var impulseVector = shotDirection * impulseStrength;
         Physics.ApplyLinearImpulse(user, -impulseVector, body: userPhysics);
     }
 
@@ -540,62 +988,20 @@ public void RefreshModifiers(Entity<GunComponent?> gun)
 
         RaiseLocalEvent(gun, ref ev);
 
-        if (comp.SoundGunshotModified != ev.SoundGunshot)
-        {
-            comp.SoundGunshotModified = ev.SoundGunshot;
-            DirtyField(gun, nameof(GunComponent.SoundGunshotModified));
-        }
-
-        if (!MathHelper.CloseTo(comp.CameraRecoilScalarModified, ev.CameraRecoilScalar))
-        {
-            comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
-            DirtyField(gun, nameof(GunComponent.CameraRecoilScalarModified));
-        }
-
-        if (!comp.AngleIncreaseModified.EqualsApprox(ev.AngleIncrease))
-        {
-            comp.AngleIncreaseModified = ev.AngleIncrease;
-            DirtyField(gun, nameof(GunComponent.AngleIncreaseModified));
-        }
-
-        if (!comp.AngleDecayModified.EqualsApprox(ev.AngleDecay))
-        {
-            comp.AngleDecayModified = ev.AngleDecay;
-            DirtyField(gun, nameof(GunComponent.AngleDecayModified));
-        }
-
-        if (!comp.MaxAngleModified.EqualsApprox(ev.MinAngle))
-        {
-            comp.MaxAngleModified = ev.MaxAngle;
-            DirtyField(gun, nameof(GunComponent.MaxAngleModified));
-        }
-
-        if (!comp.MinAngleModified.EqualsApprox(ev.MinAngle))
-        {
-            comp.MinAngleModified = ev.MinAngle;
-            DirtyField(gun, nameof(GunComponent.MinAngleModified));
-        }
-
-        if (comp.ShotsPerBurstModified != ev.ShotsPerBurst)
-        {
-            comp.ShotsPerBurstModified = ev.ShotsPerBurst;
-            DirtyField(gun, nameof(GunComponent.ShotsPerBurstModified));
-        }
-
-        if (!MathHelper.CloseTo(comp.FireRateModified, ev.FireRate))
-        {
-            comp.FireRateModified = ev.FireRate;
-            DirtyField(gun, nameof(GunComponent.FireRateModified));
-        }
-
-        if (!MathHelper.CloseTo(comp.ProjectileSpeedModified, ev.ProjectileSpeed))
-        {
-            comp.ProjectileSpeedModified = ev.ProjectileSpeed;
-            DirtyField(gun, nameof(GunComponent.ProjectileSpeedModified));
-        }
+        comp.SoundGunshotModified = ev.SoundGunshot;
+        comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
+        comp.AngleIncreaseModified = ev.AngleIncrease;
+        comp.AngleDecayModified = ev.AngleDecay;
+        comp.MaxAngleModified = ev.MaxAngle;
+        comp.MinAngleModified = ev.MinAngle;
+        comp.ShotsPerBurstModified = ev.ShotsPerBurst;
+        comp.FireRateModified = ev.FireRate;
+        comp.ProjectileSpeedModified = ev.ProjectileSpeed;
+
+        Dirty(gun);
     }
 
-    protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null);
+    protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null, EntityUid? player = null);
 
     /// <summary>
     /// Used for animated effects on the client.

+ 195 - 0
Content.Shared/_RMC14/CCVar/RMCCVars.cs

@@ -0,0 +1,195 @@
+using Robust.Shared;
+using Robust.Shared.Configuration;
+
+namespace Content.Shared._RMC14.CCVar;
+
+[CVarDefs]
+public sealed class RMCCVars : CVars
+{
+    public static readonly CVarDef<float> CMXenoDamageDealtMultiplier =
+        CVarDef.Create("rmc.xeno_damage_dealt_multiplier", 1f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> CMXenoDamageReceivedMultiplier =
+        CVarDef.Create("rmc.xeno_damage_received_multiplier", 1f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> CMXenoSpeedMultiplier =
+        CVarDef.Create("rmc.xeno_speed_multiplier", 1f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesArachnid =
+        CVarDef.Create("rmc.play_voicelines_arachnid", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesDiona =
+        CVarDef.Create("rmc.play_voicelines_diona", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesDwarf =
+        CVarDef.Create("rmc.play_voicelines_dwarf", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesFelinid =
+        CVarDef.Create("rmc.play_voicelines_felinid", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesHuman =
+        CVarDef.Create("rmc.play_voicelines_human", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesMoth =
+        CVarDef.Create("rmc.play_voicelines_moth", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesReptilian =
+        CVarDef.Create("rmc.play_voicelines_reptilian", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<bool> CMPlayVoicelinesSlime =
+        CVarDef.Create("rmc.play_voicelines_slime", true, CVar.REPLICATED | CVar.CLIENT | CVar.ARCHIVE);
+
+    public static readonly CVarDef<string> CMOocWebhook =
+        CVarDef.Create("rmc.ooc_webhook", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+    public static readonly CVarDef<int> CMMaxHeavyAttackTargets =
+        CVarDef.Create("rmc.max_heavy_attack_targets", 3, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> CMBloodlossMultiplier =
+        CVarDef.Create("rmc.bloodloss_multiplier", 1.5f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> CMBleedTimeMultiplier =
+        CVarDef.Create("rmc.bleed_time_multiplier", 1f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> CMMarinesPerXeno =
+        CVarDef.Create("rmc.marines_per_xeno", 7f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCPatronLobbyMessageTimeSeconds =
+        CVarDef.Create("rmc.patron_lobby_message_time_seconds", 30, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCPatronLobbyMessageInitialDelaySeconds =
+        CVarDef.Create("rmc.patron_lobby_message_initial_delay_seconds", 5, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<string> RMCDiscordAccountLinkingMessageLink =
+        CVarDef.Create("rmc.discord_account_linking_message_link", "", CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCRequisitionsStartingBalance =
+        CVarDef.Create("rmc.requisitions_starting_balance", 0, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCRequisitionsBalanceGain =
+        CVarDef.Create("rmc.requisitions_balance_gain", 500, CVar.REPLICATED | CVar.SERVER);
+
+    // TODO RMC14 400
+    public static readonly CVarDef<int> RMCRequisitionsStartingDollarsPerMarine =
+        CVarDef.Create("rmc.requisitions_starting_dollars_per_marine", 1900, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<string> RMCDiscordToken =
+        CVarDef.Create("rmc.discord_token", "", CVar.SERVER | CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+    public static readonly CVarDef<ulong> RMCDiscordAdminChatChannel =
+        CVarDef.Create("rmc.discord_admin_chat_channel", 0UL, CVar.SERVER | CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+    /// <summary>
+    ///     Comma-separated list of maps to load as the planet in the distress signal gamemode.
+    /// </summary>
+    public static readonly CVarDef<string> RMCPlanetMaps =
+        CVarDef.Create("rmc.planet_maps", "/Maps/_RMC14/lv624.yml,/Maps/_RMC14/solaris.yml,/Maps/_RMC14/prison.yml,/Maps/_RMC14/shiva.yml", CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCPlanetCoordinateVariance =
+        CVarDef.Create("rmc.planet_coordinate_variance", 500, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCDrawStorageIconLabels =
+        CVarDef.Create("rmc.draw_storage_icon_labels", true, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCFTLCrashLand =
+        CVarDef.Create("rmc.ftl_crash_land", true, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> RMCDropshipInitialDelayMinutes =
+        CVarDef.Create("rmc.dropship_initial_delay_minutes", 15f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> RMCLandingZonePrimaryAutoMinutes =
+        CVarDef.Create("rmc.landing_zone_primary_auto_minutes", 25f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCCorrosiveAcidTickDelaySeconds =
+        CVarDef.Create("rmc.corrosive_acid_tick_delay_seconds", 10, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<string> RMCCorrosiveAcidDamageType =
+        CVarDef.Create("rmc.corrosive_acid_damage_type", "Heat", CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCCorrosiveAcidDamageTimeSeconds =
+        CVarDef.Create("rmc.corrosive_acid_damage_time_seconds", 45, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCTailStabMaxTargets =
+        CVarDef.Create("rmc.tail_stab_max_targets", 1, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCEvolutionPointsRequireOvipositorMinutes =
+        CVarDef.Create("rmc.evolution_points_require_ovipositor_minutes", 5, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCEvolutionPointsAccumulateBeforeMinutes =
+        CVarDef.Create("rmc.evolution_points_accumulate_before_minutes", 15, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCAtmosTileEqualize =
+        CVarDef.Create("rmc.atmos_tile_equalize", false, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCGasTileOverlayUpdate =
+        CVarDef.Create("rmc.gas_tile_overlay_update", false, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCActiveInputMoverEnabled =
+        CVarDef.Create("rmc.active_input_mover_enabled", true, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<string> RMCAdminFaxAreaMap =
+        CVarDef.Create("rmc.admin_fax_area_map", "Maps/_RMC14/admin_fax.yml", CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCBioscanInitialDelaySeconds =
+        CVarDef.Create("rmc.bioscan_initial_delay_seconds", 300, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCBioscanCheckDelaySeconds =
+        CVarDef.Create("rmc.bioscan_check_delay_seconds", 60, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCBioscanMinimumCooldownSeconds =
+        CVarDef.Create("rmc.bioscan_minimum_cooldown_seconds", 300, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCBioscanBaseCooldownSeconds =
+        CVarDef.Create("rmc.bioscan_base_cooldown_seconds", 1800, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCBioscanVariance =
+        CVarDef.Create("rmc.bioscan_variance", 2, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCDropshipFabricatorStartingPoints =
+        CVarDef.Create("rmc.dropship_fabricator_starting_points", 20000, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<float> RMCDropshipFabricatorGainEverySeconds =
+        CVarDef.Create("rmc.dropship_fabricator_gain_every_seconds", 3.33333f, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCDropshipCASDebug =
+        CVarDef.Create("rmc.dropship_cas_debug", false, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCDropshipFlyByTimeSeconds =
+        CVarDef.Create("rmc.dropship_fly_by_time_seconds", 100, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<int> RMCDropshipHijackTravelTimeSeconds =
+        CVarDef.Create("rmc.dropship_hijack_travel_time_seconds", 180, CVar.REPLICATED | CVar.SERVER);
+
+    public static readonly CVarDef<bool> RMCEntitiesLogDelete =
+        CVarDef.Create("rmc.entities_log_delete", false, CVar.SERVER | CVar.SERVERONLY);
+
+    public static readonly CVarDef<bool> RMCPlanetMapVote =
+        CVarDef.Create("rmc.planet_map_vote", true, CVar.SERVER | CVar.SERVERONLY);
+
+    public static readonly CVarDef<int> RMCTacticalMapAnnounceCooldownSeconds =
+        CVarDef.Create("rmc.tactical_map_announce_cooldown_seconds", 240, CVar.SERVER | CVar.SERVERONLY);
+
+    public static readonly CVarDef<int> RMCTacticalMapLineLimit =
+        CVarDef.Create("rmc.tactical_map_line_limit", 1000, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<int> RMCTacticalMapAdminHistorySize =
+        CVarDef.Create("rmc.tactical_map_admin_history_size", 100, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<bool> RMCGunPrediction =
+        CVarDef.Create("rmc.gun_prediction", true, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<bool> RMCGunPredictionPreventCollision =
+        CVarDef.Create("rmc.gun_prediction_prevent_collision", false, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<bool> RMCGunPredictionLogHits =
+        CVarDef.Create("rmc.gun_prediction_log_hits", false, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<float> RMCGunPredictionCoordinateDeviation =
+        CVarDef.Create("rmc.gun_prediction_coordinate_deviation", 0.75f, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<float> RMCGunPredictionLowestCoordinateDeviation =
+        CVarDef.Create("rmc.gun_prediction_lowest_coordinate_deviation", 0.5f, CVar.SERVER | CVar.REPLICATED);
+
+    public static readonly CVarDef<float> RMCGunPredictionAabbEnlargement =
+        CVarDef.Create("rmc.gun_prediction_aabb_enlargement", 0.3f, CVar.SERVER | CVar.REPLICATED);
+}

+ 62 - 0
Content.Shared/_RMC14/Projectiles/Penetration/RMCPenetratingProjectileComponent.cs

@@ -0,0 +1,62 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+
+namespace Content.Shared._RMC14.Projectiles.Penetration;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class RMCPenetratingProjectileComponent : Component
+{
+    /// <summary>
+    ///     The remaining range of the projectile.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float Range = 32f;
+
+    /// <summary>
+    ///     The coordinates the projectile was shot from.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public EntityCoordinates? ShotFrom;
+
+    /// <summary>
+    ///     The multiplier for range and damage loss if a membrane is hit.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public List<EntityUid> HitTargets = new();
+
+    /// <summary>
+    ///     The amount of range lost per hit entity.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RangeLossPerHit = 3f;
+
+    /// <summary>
+    ///     The amount of damage lost per hit entity.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float DamageMultiplierLossPerHit = 0.2f;
+
+    /// <summary>
+    ///     The multiplier for range and damage loss if a wall is hit.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float WallMultiplier = 3f;
+
+    /// <summary>
+    ///     The multiplier for range and damage loss if a big xeno is hit.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float BigXenoMultiplier = 2f;
+
+    /// <summary>
+    ///     The multiplier for range and damage loss if a thick membrane is hit.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float ThickMembraneMultiplier = 1.5f;
+
+    /// <summary>
+    ///     The multiplier for range and damage loss if a membrane is hit.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float MembraneMultiplier = 1f;
+}

+ 120 - 0
Content.Shared/_RMC14/Projectiles/Penetration/RMCPenetratingProjectileSystem.cs

@@ -0,0 +1,120 @@
+
+using Content.Shared.Physics;
+using Content.Shared.Projectiles;
+using Robust.Shared.Physics.Events;
+
+namespace Content.Shared._RMC14.Projectiles.Penetration;
+
+public sealed class RMCPenetratingProjectileSystem : EntitySystem
+{
+    private const int HardCollisionGroup = (int)(CollisionGroup.HighImpassable | CollisionGroup.Impassable);
+
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<RMCPenetratingProjectileComponent, MapInitEvent>(OnMapInit);
+        SubscribeLocalEvent<RMCPenetratingProjectileComponent, PreventCollideEvent>(OnPreventCollide);
+        SubscribeLocalEvent<RMCPenetratingProjectileComponent, StartCollideEvent>(OnStartCollide, after: [typeof(SharedProjectileSystem)]);
+        SubscribeLocalEvent<RMCPenetratingProjectileComponent, ProjectileHitEvent>(OnProjectileHit);
+        SubscribeLocalEvent<RMCPenetratingProjectileComponent, AfterProjectileHitEvent>(OnAllowAdditionalHits);
+    }
+
+    /// <summary>
+    ///     Store the coordinates the projectile was shot from.
+    /// </summary>
+    private void OnMapInit(Entity<RMCPenetratingProjectileComponent> ent, ref MapInitEvent args)
+    {
+        ent.Comp.ShotFrom = _transform.GetMoverCoordinates(ent);
+        Dirty(ent);
+    }
+
+    /// <summary>
+    ///     Prevent collision with an already hit entity.
+    /// </summary>
+    private void OnPreventCollide(Entity<RMCPenetratingProjectileComponent> ent, ref PreventCollideEvent args)
+    {
+        if (!ent.Comp.HitTargets.Contains(args.OtherEntity))
+            return;
+
+        args.Cancelled = true;
+    }
+
+    /// <summary>
+    ///     Add the hit target to a list of hit targets that won't be hit another time.
+    /// </summary>
+    private void OnProjectileHit(Entity<RMCPenetratingProjectileComponent> ent, ref ProjectileHitEvent args)
+    {
+        if (ent.Comp.HitTargets.Contains(args.Target))
+        {
+            args.Handled = true;
+            return;
+        }
+
+        ent.Comp.HitTargets.Add(args.Target);
+        Dirty(ent);
+    }
+
+    /// <summary>
+    ///     Reduce the projectile damage and range based on what kind of target the projectile is colliding with.
+    /// </summary>
+    private void OnStartCollide(Entity<RMCPenetratingProjectileComponent> ent, ref StartCollideEvent args)
+    {
+        if (!TryComp(ent, out ProjectileComponent? projectile) || ent.Comp.ShotFrom == null)
+            return;
+
+        var rangeLoss = ent.Comp.RangeLossPerHit;
+        var damageLoss = ent.Comp.DamageMultiplierLossPerHit;
+
+        // Apply damage and range loss multipliers depending on target hit.
+        if ((args.OtherFixture.CollisionLayer & HardCollisionGroup) != 0)
+        {
+            // Thick Membranes have a lower multiplier.
+            if (TryComp(args.OtherEntity, out OccluderComponent? occluder) &&
+                !occluder.Enabled)
+            {
+                rangeLoss *= ent.Comp.ThickMembraneMultiplier;
+                damageLoss *= ent.Comp.ThickMembraneMultiplier;
+
+            }
+            else
+            {
+                rangeLoss *= ent.Comp.WallMultiplier;
+                damageLoss *= ent.Comp.WallMultiplier;
+            }
+        }
+        ent.Comp.Range -= rangeLoss;
+        Dirty(ent);
+
+        projectile.Damage *= 1 - damageLoss;
+        Dirty(ent, projectile);
+    }
+
+    /// <summary>
+    ///     Make sure additional hits are allowed if range is still above 0.
+    /// </summary>
+    private void OnAllowAdditionalHits(Entity<RMCPenetratingProjectileComponent> ent, ref AfterProjectileHitEvent args)
+    {
+        if (ent.Comp.ShotFrom == null)
+            return;
+
+        var distanceTravelled =
+            (_transform.GetMoverCoordinates(ent).Position - ent.Comp.ShotFrom.Value.Position).Length();
+        var range = ent.Comp.Range - distanceTravelled;
+
+        ent.Comp.HitTargets.Add(args.Target);
+        Dirty(ent);
+
+        if (range < 0)
+            return;
+
+        args.Projectile.Comp.ProjectileSpent = false;
+        Dirty(args.Projectile);
+    }
+}
+
+/// <summary>
+///     Raised on a projectile after it has hit an entity.
+/// </summary>
+[ByRefEvent]
+public record struct AfterProjectileHitEvent(Entity<ProjectileComponent> Projectile, EntityUid Target);
+

+ 39 - 0
Content.Shared/_RMC14/Random/SplitMix64.cs

@@ -0,0 +1,39 @@
+namespace Content.Shared._RMC14.Random;
+
+/// <summary>
+/// Seed initializer PRNG (splitmix64).
+/// </summary>
+/// <remarks>http://prng.di.unimi.it/splitmix64.c</remarks>
+public record struct SplitMix64
+{
+    /// <summary>
+    /// Creates a new instance.
+    /// </summary>
+    public SplitMix64()
+        : this(DateTime.UtcNow.Ticks)
+    {
+    }
+
+    /// <summary>
+    /// Creates a new instance.
+    /// </summary>
+    /// <param name="seed">Seed value.</param>
+    public SplitMix64(long seed)
+    {
+        x = (UInt64) seed;
+    }
+
+
+    private UInt64 x;
+
+    /// <summary>
+    /// Returns the next 64-bit pseudo-random number.
+    /// </summary>
+    public long Next()
+    {
+        UInt64 z = unchecked(x += 0x9e3779b97f4a7c15);
+        z = unchecked((z ^ (z >> 30)) * 0xbf58476d1ce4e5b9);
+        z = unchecked((z ^ (z >> 27)) * 0x94d049bb133111eb);
+        return unchecked((Int64) (z ^ (z >> 31)));
+    }
+}

+ 65 - 0
Content.Shared/_RMC14/Random/Xoroshiro64S.cs

@@ -0,0 +1,65 @@
+using System.Runtime.CompilerServices;
+
+namespace Content.Shared._RMC14.Random;
+
+// https://github.com/medo64/Medo.ScrambledLinear/blob/main/src/Xoroshiro/Xoroshiro64S.cs
+/// <summary>
+/// 32-bit random number generator intended for floating point numbers with 64-bit state (xoroshiro64*).
+/// </summary>
+/// <remarks>http://prng.di.unimi.it/xoroshiro64star.c</remarks>
+public record struct Xoroshiro64S
+{
+    /// <summary>
+    /// Creates a new instance.
+    /// </summary>
+    public Xoroshiro64S()
+        : this(DateTime.UtcNow.Ticks)
+    {
+    }
+
+    /// <summary>
+    /// Creates a new instance.
+    /// </summary>
+    /// <param name="seed">Seed value.</param>
+    public Xoroshiro64S(long seed)
+    {
+        var sm64 = new SplitMix64(seed);
+        _s0 = unchecked((UInt32) sm64.Next());
+        _s1 = unchecked((UInt32) sm64.Next());
+    }
+
+
+    private UInt32 _s0;
+    private UInt32 _s1;
+
+    /// <summary>
+    /// Returns the next 32-bit pseudo-random number.
+    /// </summary>
+    public int Next()
+    {
+        UInt32 s0 = _s0;
+        UInt32 s1 = _s1;
+        UInt32 result = unchecked(s0 * (UInt32) 0x9E3779BB);
+
+        s1 ^= s0;
+        _s0 = RotateLeft(s0, 26) ^ s1 ^ (s1 << 9);
+        _s1 = RotateLeft(s1, 13);
+
+        return Math.Abs((int) result);
+    }
+
+    public float NextFloat()
+    {
+        return Next() * 4.6566128752458E-10f;
+    }
+
+    public float NextFloat(float min, float max)
+    {
+        return NextFloat() * (max - min) + min;
+    }
+
+    private static UInt32 RotateLeft(UInt32 x, int k)
+    {
+        return (x << k) | (x >> (32 - k));
+    }
+}

+ 7 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/IgnorePredictionHideComponent.cs

@@ -0,0 +1,7 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(SharedGunPredictionSystem))]
+public sealed partial class IgnorePredictionHideComponent : Component;

+ 14 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileClientComponent.cs

@@ -0,0 +1,14 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+[RegisterComponent]
+public sealed partial class PredictedProjectileClientComponent : Component
+{
+    [DataField]
+    public bool Hit;
+
+    [DataField]
+    public EntityCoordinates? Coordinates;
+}

+ 16 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileHitComponent.cs

@@ -0,0 +1,16 @@
+using Content.Shared.Projectiles;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedGunPredictionSystem), typeof(SharedProjectileSystem))]
+public sealed partial class PredictedProjectileHitComponent : Component
+{
+    [DataField, AutoNetworkedField]
+    public EntityCoordinates Origin;
+
+    [DataField, AutoNetworkedField]
+    public float Distance;
+}

+ 11 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileHitEvent.cs

@@ -0,0 +1,11 @@
+using Robust.Shared.Map;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+[Serializable, NetSerializable]
+public sealed class PredictedProjectileHitEvent(int projectile, HashSet<(NetEntity Id, MapCoordinates Coordinates)> hit) : EntityEventArgs
+{
+    public readonly int Projectile = projectile;
+    public readonly HashSet<(NetEntity Id, MapCoordinates Coordinates)> Hit = hit;
+}

+ 20 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/PredictedProjectileServerComponent.cs

@@ -0,0 +1,20 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class PredictedProjectileServerComponent : Component
+{
+    public ICommonSession Shooter;
+
+    [DataField, AutoNetworkedField]
+    public int ClientId;
+
+    [DataField, AutoNetworkedField]
+    public EntityUid? ClientEnt;
+
+    [DataField]
+    public bool Hit;
+}

+ 16 - 0
Content.Shared/_RMC14/Weapons/Ranged/Prediction/SharedGunPredictionSystem.cs

@@ -0,0 +1,16 @@
+using Robust.Shared.Configuration;
+using Content.Shared._RMC14.CCVar;
+
+namespace Content.Shared._RMC14.Weapons.Ranged.Prediction;
+
+public abstract class SharedGunPredictionSystem : EntitySystem
+{
+    [Dependency] private readonly IConfigurationManager _config = default!;
+
+    public bool GunPrediction { get; private set; }
+
+    public override void Initialize()
+    {
+        Subs.CVar(_config, RMCCVars.RMCGunPrediction, v => GunPrediction = v, true);
+    }
+}

+ 7 - 1
Resources/Changelog/Changelog.yml

@@ -113,7 +113,6 @@ Entries:
     id: 9
     time: "2025-04-19T00:00:00.0000000+00:00"
     url: https://github.com/Civ13/Civ14/pull/124
-
   - author: Taislin
     changes:
       - message: Adds goobstation's limb targeting system.
@@ -145,3 +144,10 @@ Entries:
     id: 12
     time: "2025-04-25T00:00:00.0000000+00:00"
     url: https://github.com/Civ13/Civ14/pull/159
+  - author: Taislin
+    changes:
+      - message: Adds RMC-14/GoobStations gun prediction mechanics, reducing lag between clicks and firing a weapon.
+        type: Add
+    id: 13
+    time: "2025-04-25T00:00:00.0000000+00:00"
+    url: https://github.com/Civ13/Civ14/pull/158

+ 2 - 0
Scripts/bat/runclient-Tools.bat

@@ -1,2 +1,4 @@
 @echo off
+cd..
+cd..
 dotnet run --project Content.Client --configuration Tools

+ 2 - 0
Scripts/bat/runclient.bat

@@ -1,2 +1,4 @@
 @echo off
+cd..
+cd..
 dotnet run --project Content.Client

+ 2 - 0
Scripts/bat/runserver-Tools.bat

@@ -1,3 +1,5 @@
 @echo off
+cd..
+cd..
 dotnet run --project Content.Server --configuration Tools
 pause

+ 2 - 0
Scripts/bat/runserver.bat

@@ -1,4 +1,6 @@
 @echo off
+cd..
+cd..
 python3 mapGeneration.py
 dotnet run --project Content.Server
 pause