diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
index 9da9c70c..76c7a0b8 100644
--- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
+++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
@@ -495,7 +495,7 @@ object GamePacketOpcode extends Enumeration {
case 0x92 => noDecoder(PlanetsideStringAttributeMessage)
case 0x93 => noDecoder(DataChallengeMessage)
case 0x94 => noDecoder(DataChallengeMessageResp)
- case 0x95 => noDecoder(WeatherMessage)
+ case 0x95 => game.WeatherMessage.decode
case 0x96 => noDecoder(SimDataChallenge)
case 0x97 => noDecoder(SimDataChallengeResp)
// 0x98
diff --git a/common/src/main/scala/net/psforever/packet/game/WeatherMessage.scala b/common/src/main/scala/net/psforever/packet/game/WeatherMessage.scala
new file mode 100644
index 00000000..753deb7c
--- /dev/null
+++ b/common/src/main/scala/net/psforever/packet/game/WeatherMessage.scala
@@ -0,0 +1,115 @@
+// 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._
+import shapeless.{::, HNil}
+
+/**
+ * Cloud data.
+ *
+ * The remaining fields should be divided between a "location" and a "velocity" as per debug output.
+ * The values are probably paired.
+ * The converted data, however, seems weird for the kind of information those fields would suggest.
+ * @param id the id of the cloud;
+ * zero-indexed counter (probably)
+ * @param unk1 na;
+ * the z-component is always `0.0f`
+ * @param unk2 na;
+ * the z-component is always `0.0f`
+ */
+final case class CloudInfo(id : Int,
+ unk1 : Vector3,
+ unk2 : Vector3)
+
+/**
+ * Storm data.
+ * @param loc the location of the storm;
+ * the z-component is always `0.0f`
+ * @param intensity na
+ * @param radius na;
+ * 100 is about 819.2
+ */
+final case class StormInfo(loc : Vector3,
+ intensity : Int,
+ radius : Int)
+
+/**
+ * Dispatched by the server to update weather conditions.
+ * On former live (Gemini), the server sent a new packet to connected clients once every ~60s.
+ *
+ * Information about the fields in this packet come from extracted debug information.
+ * It is not necessarily "correct" but it is the best approximation for now.
+ *
+ * `
+ * Message type: %d (%s)\n length: %d\n
+ * Number of Clouds : %d\n
+ * Cloud ID: %d\n
+ * \tCloud Location: %f %f\n
+ * \tCloud Velocity: %f %f\n
+ * Number of Storms : %d\n
+ * Storm:\n
+ * \tStorm Location: %f %f\n
+ * \tStorm Intensity: %d\n
+ * \tStorm Radius: %d\n
+ * `
+ * @param clouds a list of cloud data;
+ * typically, just one entry
+ * @param storms a list of storm data;
+ * typically, fluctuates between nine and eleven entries
+ */
+final case class WeatherMessage(clouds : List[CloudInfo],
+ storms : List[StormInfo])
+ extends PlanetSideGamePacket {
+ type Packet = WeatherMessage
+ def opcode = GamePacketOpcode.WeatherMessage
+ def encode = WeatherMessage.encode(this)
+}
+
+object WeatherMessage extends Marshallable[WeatherMessage] {
+ /**
+ * `Codec` for `CloudInfo` data.
+ */
+ private val cloudCodec : Codec[CloudInfo] = (
+ ("id" | uint8L) ::
+ ("unk1x" | floatL) ::
+ ("unk1y" | floatL) ::
+ ("unk2x" | floatL) ::
+ ("unk2y" | floatL)
+ ).xmap[CloudInfo] (
+ {
+ case id :: x1 :: y1 :: x2 :: y2 :: HNil =>
+ CloudInfo(id, Vector3(x1, y1, 0.0f), Vector3(x2, y2, 0.0f))
+ },
+ {
+ case CloudInfo(id, Vector3(x1, y1, _), Vector3(x2, y2, _)) =>
+ id :: x1 :: y1 :: x2 :: y2 :: HNil
+ }
+ )
+
+ /**
+ * `Codec` for `StormInfo` data.
+ */
+ private val stormCodec : Codec[StormInfo] = (
+ ("unkx" | floatL) ::
+ ("unky" | floatL) ::
+ ("i" | uint8L) ::
+ ("r" | uint8L)
+ ).xmap[StormInfo] (
+ {
+ case x :: y :: i :: r :: HNil =>
+ StormInfo(Vector3(x, y, 0.0f), i, r)
+ },
+ {
+ case StormInfo(Vector3(x, y, _), i, r) =>
+ x :: y :: i :: r :: HNil
+ }
+ )
+
+ implicit val codec : Codec[WeatherMessage] = (
+ ("clouds" | PacketHelpers.listOfNAligned(uint32L, 0, cloudCodec)) ::
+ ("storms" | PacketHelpers.listOfNAligned(uint32L, 0, stormCodec))
+ ).as[WeatherMessage]
+}
diff --git a/common/src/test/scala/game/WeatherMessageTest.scala b/common/src/test/scala/game/WeatherMessageTest.scala
new file mode 100644
index 00000000..438517f1
--- /dev/null
+++ b/common/src/test/scala/game/WeatherMessageTest.scala
@@ -0,0 +1,104 @@
+// Copyright (c) 2017 PSForever
+package game
+
+import org.specs2.mutable._
+import net.psforever.packet._
+import net.psforever.packet.game.{WeatherMessage, CloudInfo, StormInfo}
+import net.psforever.types.Vector3
+import scodec.bits._
+
+class WeatherMessageTest extends Specification {
+ val string = hex"9501000000004A0807C0D65B8FBF2427663F178608BE0B000000006CE13E0C390E3F64445CB7BF3E0C2FF23DA46264A3193FBA522E3F597D9A96093F95B99E3D0800096FE53E6CD6523F39198EAF683F9BA0363D01009C35503F9E5F3E3F3C304E46F23EF9668E3E6B56C8277F3FB084F33EB6C10291423FB17F663F00008C077F3E3135D03E320A"
+
+ "decode" in {
+ PacketCoding.DecodePacket(string).require match {
+ case WeatherMessage(clouds, storms) =>
+ clouds.size mustEqual 1
+ clouds.head.id mustEqual 0
+ clouds.head.unk1.x mustEqual -2.109881f
+ clouds.head.unk1.y mustEqual -1.1199901f
+ clouds.head.unk2.x mustEqual 0.89903474f
+ clouds.head.unk2.y mustEqual -0.13332401f
+
+ storms.size mustEqual 11
+ //0
+ storms.head.loc.x mustEqual 0.4402771f
+ storms.head.loc.y mustEqual 0.55555797f
+ storms.head.intensity mustEqual 100
+ storms.head.radius mustEqual 68
+ //1
+ storms(1).loc.x mustEqual 0.3744458f
+ storms(1).loc.y mustEqual 0.1182538f
+ storms(1).intensity mustEqual 164
+ storms(1).radius mustEqual 98
+ //2
+ storms(2).loc.x mustEqual 0.6001494f
+ storms(2).loc.y mustEqual 0.6809498f
+ storms(2).intensity mustEqual 89
+ storms(2).radius mustEqual 125
+ //3
+ storms(3).loc.x mustEqual 0.53745425f
+ storms(3).loc.y mustEqual 0.07750241f
+ storms(3).intensity mustEqual 8
+ storms(3).radius mustEqual 0
+ //4
+ storms(4).loc.x mustEqual 0.44811276f
+ storms(4).loc.y mustEqual 0.8235843f
+ storms(4).intensity mustEqual 57
+ storms(4).radius mustEqual 25
+ //5
+ storms(5).loc.x mustEqual 0.90892875f
+ storms(5).loc.y mustEqual 0.04458676f
+ storms(5).intensity mustEqual 1
+ storms(5).radius mustEqual 0
+ //6
+ storms(6).loc.x mustEqual 0.813318f
+ storms(6).loc.y mustEqual 0.7436465f
+ storms(6).intensity mustEqual 60
+ storms(6).radius mustEqual 48
+ //7
+ storms(7).loc.x mustEqual 0.47319263f
+ storms(7).loc.y mustEqual 0.27812937f
+ storms(7).intensity mustEqual 107
+ storms(7).radius mustEqual 86
+ //8
+ storms(8).loc.x mustEqual 0.99670076f
+ storms(8).loc.y mustEqual 0.4756217f
+ storms(8).intensity mustEqual 182
+ storms(8).radius mustEqual 193
+ //9
+ storms(9).loc.x mustEqual 0.76002514f
+ storms(9).loc.y mustEqual 0.9003859f
+ storms(9).intensity mustEqual 0
+ storms(9).radius mustEqual 0
+ //10
+ storms(10).loc.x mustEqual 0.24905223f
+ storms(10).loc.y mustEqual 0.40665582f
+ storms(10).intensity mustEqual 50
+ storms(10).radius mustEqual 10
+ case _ =>
+ ko
+ }
+ }
+
+ "encode" in {
+ val msg = WeatherMessage(
+ CloudInfo(0, Vector3(-2.109881f, -1.1199901f, 0.0f), Vector3(0.89903474f, -0.13332401f, 0.0f)) ::
+ Nil,
+ StormInfo(Vector3(0.4402771f, 0.55555797f, 0.0f), 100, 68) ::
+ StormInfo(Vector3(0.3744458f, 0.1182538f, 0.0f), 164, 98) ::
+ StormInfo(Vector3(0.6001494f, 0.6809498f, 0.0f), 89, 125) ::
+ StormInfo(Vector3(0.53745425f, 0.07750241f, 0.0f), 8, 0) ::
+ StormInfo(Vector3(0.44811276f, 0.8235843f, 0.0f), 57, 25) ::
+ StormInfo(Vector3(0.90892875f, 0.04458676f, 0.0f),1 ,0) ::
+ StormInfo(Vector3(0.813318f, 0.7436465f, 0.0f), 60, 48) ::
+ StormInfo(Vector3(0.47319263f, 0.27812937f, 0.0f), 107, 86) ::
+ StormInfo(Vector3(0.99670076f, 0.4756217f, 0.0f), 182, 193) ::
+ StormInfo(Vector3(0.76002514f, 0.9003859f, 0.0f), 0, 0) ::
+ StormInfo(Vector3(0.24905223f, 0.40665582f, 0.0f), 50, 10) ::
+ Nil
+ )
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+ pkt mustEqual string
+ }
+}