diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
index b0ab76a7f..342b80915 100644
--- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
+++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
@@ -415,7 +415,7 @@ object GamePacketOpcode extends Enumeration {
// OPCODES 0x50-5f
case 0x50 => game.TargetingInfoMessage.decode
- case 0x51 => noDecoder(TriggerEffectMessage)
+ case 0x51 => game.TriggerEffectMessage.decode
case 0x52 => game.WeaponDryFireMessage.decode
case 0x53 => noDecoder(DroppodLaunchRequestMessage)
case 0x54 => noDecoder(HackMessage)
diff --git a/common/src/main/scala/net/psforever/packet/game/TriggerEffectMessage.scala b/common/src/main/scala/net/psforever/packet/game/TriggerEffectMessage.scala
new file mode 100644
index 000000000..a8c2cc9be
--- /dev/null
+++ b/common/src/main/scala/net/psforever/packet/game/TriggerEffectMessage.scala
@@ -0,0 +1,83 @@
+// Copyright (c) 2017 PSForever
+package net.psforever.packet.game
+
+import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
+import net.psforever.types.Vector3
+import scodec.Codec
+import scodec.codecs._
+
+/**
+ * na
+ * @param unk1 na;
+ * `true` to apply the effect usually
+ * @param unk2 na
+ */
+final case class TriggeredEffect(unk1 : Boolean,
+ unk2 : Long)
+
+/**
+ * Activate an effect that is not directly associated with an existing game object.
+ * Without a game object from which to inherit position and orientation, those explicit parameters must be provided.
+ * @param pos the position in the game world
+ * @param roll the amount of roll that affects orientation
+ * @param pitch the amount of pitch that affects orientation
+ * @param yaw the amount of yaw that affects orientation
+ */
+final case class TriggeredEffectLocation(pos : Vector3,
+ roll : Int,
+ pitch : Int,
+ yaw : Int)
+
+/**
+ * Dispatched by the server to cause a client to display a special graphical effect.
+ *
+ * The effect being triggered can be based around a specific game object or replayed freely, absent of an anchoring object.
+ * If object-based then the kinds of effects that can be activated are specific to the object.
+ * If unbound, then a wider range of effects can be displayed.
+ * Regardless, one category will rarely ever be activated under the same valid conditions of the other category.
+ * For example, the effect "on" will only work on objects that accept "on" normally, like a deployed `motionalarmsensor`.
+ * The effect "spawn_object_effect" can be applied anywhere in the environment;
+ * but, it can not be activated in conjunction with an existing object.
+ * @param obj an object that accepts the effect
+ * @param effect the name of the effect
+ * @param unk na;
+ * when activating an effect on an existing object
+ * @param location an optional position where the effect will be displayed;
+ * when activating an effect independently
+ */
+final case class TriggerEffectMessage(obj : PlanetSideGUID,
+ effect : String,
+ unk : Option[TriggeredEffect] = None,
+ location : Option[TriggeredEffectLocation] = None
+ ) extends PlanetSideGamePacket {
+ type Packet = TriggerEffectMessage
+ def opcode = GamePacketOpcode.TriggerEffectMessage
+ def encode = TriggerEffectMessage.encode(this)
+}
+
+object TriggerEffectMessage extends Marshallable[TriggerEffectMessage] {
+ /**
+ * A `Codec` for `TriggeredEffect` data.
+ */
+ private val effect_codec : Codec[TriggeredEffect] = (
+ ("unk1" | bool) ::
+ ("unk2" | uint32L)
+ ).as[TriggeredEffect]
+
+ /**
+ * A `Codec` for `TriggeredEffectLocation` data.
+ */
+ private val effect_location_codec : Codec[TriggeredEffectLocation] = (
+ ("pos" | Vector3.codec_pos) ::
+ ("roll" | uint8L) ::
+ ("pitch" | uint8L) ::
+ ("yaw" | uint8L)
+ ).as[TriggeredEffectLocation]
+
+ implicit val codec : Codec[TriggerEffectMessage] = (
+ ("obj" | PlanetSideGUID.codec) >>:~ { obj =>
+ ("effect" | PacketHelpers.encodedString) ::
+ optional(bool, "unk" | effect_codec) ::
+ conditional(obj.guid == 0, "location" | effect_location_codec)
+ }).as[TriggerEffectMessage]
+}
diff --git a/common/src/test/scala/game/TriggerEffectMessageTest.scala b/common/src/test/scala/game/TriggerEffectMessageTest.scala
new file mode 100644
index 000000000..9e3e6faa5
--- /dev/null
+++ b/common/src/test/scala/game/TriggerEffectMessageTest.scala
@@ -0,0 +1,73 @@
+// 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 TriggerEffectMessageTest extends Specification {
+ val string_motionalarmsensor = hex"51 970B 82 6F6E FA00C00000"
+ val string_boomer = hex"51 0000 93 737061776E5F6F626A6563745F656666656374 417BB2CB3B4F8E00000000"
+
+ "decode (motion alarm sensor)" in {
+ PacketCoding.DecodePacket(string_motionalarmsensor).require match {
+ case TriggerEffectMessage(guid, effect, unk, location) =>
+ guid mustEqual PlanetSideGUID(2967)
+ effect mustEqual "on"
+ unk.isDefined mustEqual true
+ unk.get.unk1 mustEqual true
+ unk.get.unk2 mustEqual 1000L
+ location.isDefined mustEqual false
+ case _ =>
+ ko
+ }
+ }
+
+ "decode (boomer)" in {
+ PacketCoding.DecodePacket(string_boomer).require match {
+ case TriggerEffectMessage(guid, effect, unk, location) =>
+ guid mustEqual PlanetSideGUID(0)
+ effect mustEqual "spawn_object_effect"
+ unk.isDefined mustEqual false
+ location.isDefined mustEqual true
+ location.get.pos.x mustEqual 3567.0156f
+ location.get.pos.y mustEqual 3278.6953f
+ location.get.pos.z mustEqual 114.484375f
+ location.get.roll mustEqual 0
+ location.get.pitch mustEqual 0
+ location.get.yaw mustEqual 0
+ case _ =>
+ ko
+ }
+ }
+
+ "encode (motion alarm sensor)" in {
+ val msg = TriggerEffectMessage(
+ PlanetSideGUID(2967),
+ "on",
+ Some(TriggeredEffect(true, 1000L)),
+ None
+ )
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+
+ pkt mustEqual string_motionalarmsensor
+ }
+
+ "encode (boomer)" in {
+ val msg = TriggerEffectMessage(
+ PlanetSideGUID(0),
+ "spawn_object_effect",
+ None,
+ Some(TriggeredEffectLocation(
+ Vector3(3567.0156f, 3278.6953f, 114.484375f),
+ 0, 0, 0
+ ))
+ )
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+
+ pkt mustEqual string_boomer
+ }
+}
+