diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
index ac612f1c..d7bc550b 100644
--- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
+++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
@@ -392,7 +392,7 @@ object GamePacketOpcode extends Enumeration {
case 0x3c => game.GenericCollisionMsg.decode
case 0x3d => game.QuantityUpdateMessage.decode
case 0x3e => game.ArmorChangedMessage.decode
- case 0x3f => noDecoder(ProjectileStateMessage)
+ case 0x3f => game.ProjectileStateMessage.decode
// OPCODES 0x40-4f
case 0x40 => noDecoder(MountVehicleCargoMsg)
diff --git a/common/src/main/scala/net/psforever/packet/game/ProjectileStateMessage.scala b/common/src/main/scala/net/psforever/packet/game/ProjectileStateMessage.scala
new file mode 100644
index 00000000..329d6742
--- /dev/null
+++ b/common/src/main/scala/net/psforever/packet/game/ProjectileStateMessage.scala
@@ -0,0 +1,65 @@
+// Copyright (c) 2017 PSForever
+package net.psforever.packet.game
+
+import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket}
+import net.psforever.types.Vector3
+import scodec.Codec
+import scodec.codecs._
+
+/**
+ * Dispatched to deliberately render certain projectiles of a weapon on other players' clients.
+ *
+ * This packet is generated by firing specific weapons in specific fire modes.
+ * For example, the Phoenix (`hunterseeker`) discharged in its primary fire mode generates this packet;
+ * but, the Phoenix in secondary fire mode does not.
+ * The Striker (`striker`) discharged in its primary fire mode generates this packet;
+ * but, the Striker in secondary fire mode does not.
+ * The chosen fire mode(s) are not a straight-fire projectile but one that has special control asserted over it.
+ * For the Phoenix, it is user-operated.
+ * For the Striker, it tracks towards a target while the weapon's reticle hovers over that target.
+ *
+ * This packet will continue to be dispatched by the client for as long as the projectile being tracked is in the air.
+ * All projectiles have a maximum lifespan before they will lose control and either despawn and/or explode.
+ * This number is tracked in the packet for simplicity.
+ * If the projectile strikes a valid target, the count will jump to a significantly enormous value beyond its normal lifespan.
+ * This ensures that the projectile - locally and the shared model - will despawn.
+ * @param projectile_guid the projectile
+ * @param shot_pos the position of the projectile
+ * @param shot_vel the velocity of the projectile
+ * @param unk1 na;
+ * usually 0
+ * @param unk2 na;
+ * will remain consistent for the lifespan of a given projectile in most cases
+ * @param unk3 na;
+ * will remain consistent for the lifespan of a given projectile in most cases
+ * @param unk4 na;
+ * usually false
+ * @param time_alive how long the projectile has been in the air;
+ * often expressed in multiples of 2
+ */
+final case class ProjectileStateMessage(projectile_guid : PlanetSideGUID,
+ shot_pos : Vector3,
+ shot_vel : Vector3,
+ unk1 : Int,
+ unk2 : Int,
+ unk3 : Int,
+ unk4 : Boolean,
+ time_alive : Int)
+ extends PlanetSideGamePacket {
+ type Packet = ProjectileStateMessage
+ def opcode = GamePacketOpcode.ProjectileStateMessage
+ def encode = ProjectileStateMessage.encode(this)
+}
+
+object ProjectileStateMessage extends Marshallable[ProjectileStateMessage] {
+ implicit val codec : Codec[ProjectileStateMessage] = (
+ ("projectile_guid" | PlanetSideGUID.codec) ::
+ ("shot_pos" | Vector3.codec_pos) ::
+ ("shot_vel" | Vector3.codec_float) ::
+ ("unk1" | uint8L) ::
+ ("unk2" | uint8L) ::
+ ("unk3" | uint8L) ::
+ ("unk4" | bool) ::
+ ("time_alive" | uint16L)
+ ).as[ProjectileStateMessage]
+}
diff --git a/common/src/test/scala/game/ProjectileStateMessageTest.scala b/common/src/test/scala/game/ProjectileStateMessageTest.scala
new file mode 100644
index 00000000..f62e181e
--- /dev/null
+++ b/common/src/test/scala/game/ProjectileStateMessageTest.scala
@@ -0,0 +1,44 @@
+// Copyright (c) 2017 PSForever
+package game
+
+import org.specs2.mutable._
+import net.psforever.packet._
+import net.psforever.packet.game._
+import net.psforever.types.Vector3
+import scodec.bits._
+
+class ProjectileStateMessageTest extends Specification {
+ val string = hex"3f 259d c5019 30e4a 9514 c52c9541 d9ba05c2 c5973941 00 f8 ec 020000"
+
+ "decode" in {
+ PacketCoding.DecodePacket(string).require match {
+ case ProjectileStateMessage(projectile, pos, vel, unk1, unk2, unk3, unk4, time_alive) =>
+ projectile mustEqual PlanetSideGUID(40229)
+ pos.x mustEqual 4611.539f
+ pos.y mustEqual 5576.375f
+ pos.z mustEqual 82.328125f
+ vel.x mustEqual 18.64686f
+ vel.y mustEqual -33.43247f
+ vel.z mustEqual 11.599553f
+ unk1 mustEqual 0
+ unk2 mustEqual 248
+ unk3 mustEqual 236
+ unk4 mustEqual false
+ time_alive mustEqual 4
+ case _ =>
+ ko
+ }
+ }
+
+ "encode" in {
+ val msg = ProjectileStateMessage(
+ PlanetSideGUID(40229),
+ Vector3(4611.539f, 5576.375f, 82.328125f),
+ Vector3(18.64686f, -33.43247f, 11.599553f),
+ 0, 248, 236, false, 4
+ )
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+
+ pkt mustEqual string
+ }
+}
diff --git a/pslogin/src/main/scala/WorldSessionActor.scala b/pslogin/src/main/scala/WorldSessionActor.scala
index 852833d5..dcaae96a 100644
--- a/pslogin/src/main/scala/WorldSessionActor.scala
+++ b/pslogin/src/main/scala/WorldSessionActor.scala
@@ -255,6 +255,9 @@ class WorldSessionActor extends Actor with MDCContextAware {
case msg @ ChildObjectStateMessage(object_guid : PlanetSideGUID, pitch : Int, yaw : Int) =>
//log.info("ChildObjectState: " + msg)
+ case msg @ ProjectileStateMessage(projectile_guid, shot_pos, shot_vector, unk1, unk2, unk3, unk4, time_alive) =>
+ //log.info("ProjectileState: " + msg)
+
case msg @ ChatMsg(messagetype, has_wide_contents, recipient, contents, note_contents) =>
// TODO: Prevents log spam, but should be handled correctly
if (messagetype != ChatMessageType.CMT_TOGGLE_GM) {