diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 9392839d..3f1f27d6 100644 --- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -506,7 +506,7 @@ object GamePacketOpcode extends Enumeration { case 0x9c => noDecoder(DebugDrawMessage) case 0x9d => noDecoder(SoulMarkMessage) case 0x9e => noDecoder(UplinkPositionEvent) - case 0x9f => noDecoder(HotSpotUpdateMessage) + case 0x9f => game.HotSpotUpdateMessage.decode // OPCODES 0xa0-af case 0xa0 => game.BuildingInfoUpdateMessage.decode diff --git a/common/src/main/scala/net/psforever/packet/PSPacket.scala b/common/src/main/scala/net/psforever/packet/PSPacket.scala index 2fe9a563..8942a681 100644 --- a/common/src/main/scala/net/psforever/packet/PSPacket.scala +++ b/common/src/main/scala/net/psforever/packet/PSPacket.scala @@ -3,12 +3,15 @@ package net.psforever.packet import java.nio.charset.Charset -import scodec.{DecodeResult, Err, Codec, Attempt} +import scodec.Attempt.Successful +import scodec.{Attempt, Codec, DecodeResult, Err} import scodec.bits._ import scodec.codecs._ import scodec._ import shapeless._ +import scala.util.Success + /** The base of all packets */ sealed trait PlanetSidePacket extends Serializable { def encode : Attempt[BitVector] @@ -203,4 +206,136 @@ object PacketHelpers { def encodedStringWithLimit(limit : Int) : Codec[String] = variableSizeBytes(encodedStringSizeWithLimit(limit), ascii) */ + + /** + * Codec that encodes/decodes a list of `n` elements, where `n` is known at compile time.
+ *
+ * This function is copied almost verbatim from its source, with exception of swapping the parameter that is normally a `Nat` `literal`. + * The modified function takes a normal unsigned `Integer` and assures that the parameter is non-negative before further processing. + * It casts to a `Long` and passes onto an overloaded method. + * @param size the known size of the `List` + * @param codec a codec that describes each of the contents of the `List` + * @tparam A the type of the `List` contents + * @see codec\package.scala, sizedList + * @see codec\package.scala, listOfN + * @return a codec that works on a List of A but excludes the size from the encoding + */ + def listOfNSized[A](size : Int, codec : Codec[A]) : Codec[List[A]] = listOfNSized(if(size < 0) 0L else size.asInstanceOf[Long], codec) + + /** + * Codec that encodes/decodes a list of `n` elements, where `n` is known at compile time.
+ *
+ * This function is copied almost verbatim from its source, with exception of swapping the parameter that is normally a `Nat` `literal`. + * The modified function takes a normal unsigned `Long` and assures that the parameter is non-negative before further processing. + * @param size the known size of the `List` + * @param codec a codec that describes each of the contents of the `List` + * @tparam A the type of the `List` contents + * @see codec\package.scala, sizedList + * @see codec\package.scala, listOfN + * @see codec\package.scala, provide + * @return a codec that works on a List of A but excludes the size from the encoding + */ + def listOfNSized[A](size : Long, codec : Codec[A]) : Codec[List[A]] = listOfNAligned(provide(if(size < 0) 0 else size), 0, codec) + + /** + * Encode and decode a byte-aligned `List`.
+ *
+ * This function is copied almost verbatim from its source, but swapping the normal `ListCodec` for a new `AlignedListCodec`. + * It also changes the type of the list length `Codec` from `Int` to `Long`. + * Due to type erasure, this method can not be overloaded for both `Codec[Int]` and `Codec[Long]`. + * The compiler would resolve both internally into type `Codec[T]` and their function definitions would be identical. + * For the purposes of use, `longL(n)` will cast to an `Int` for the same acceptable values of `n` as in `uintL(n)`. + * @param countCodec the codec that represents the prefixed size of the `List` + * @param alignment the number of bits padded between the `List` size and the `List` contents + * @param valueCodec a codec that describes each of the contents of the `List` + * @tparam A the type of the `List` contents + * @see codec\package.scala, listOfN + * @return a codec that works on a List of A + */ + def listOfNAligned[A](countCodec : Codec[Long], alignment : Int, valueCodec : Codec[A]) : Codec[List[A]] = { + countCodec. + flatZip { + count => + new AlignedListCodec(countCodec, valueCodec, alignment, Some(count)) + }. + narrow[List[A]] ( + { + case (cnt, xs) => + if(xs.size == cnt) + Attempt.successful(xs) + else + Attempt.failure(Err(s"Insufficient number of elements: decoded ${xs.size} but should have decoded $cnt")) + }, + { + xs => + (xs.size, xs) + } + ). + withToString(s"listOfN($countCodec, $valueCodec)") + } +} + +/** + * The greater `Codec` class that encodes and decodes a byte-aligned `List`.
+ *
+ * This class is copied almost verbatim from its source, with two major modifications. + * First, heavy modifications to its `encode` process account for the alignment value. + * Second, the length field is parsed as a `Codec[Long]` value and type conversion is accounted for at several points. + * @param countCodec the codec that represents the prefixed size of the `List` + * @param valueCodec a codec that describes each of the contents of the `List` + * @param alignment the number of bits padded between the `List` size and the `List` contents (on successful) + * @param limit the number of elements in the `List` + * @tparam A the type of the `List` contents + * @see ListCodec.scala + */ +private class AlignedListCodec[A](countCodec : Codec[Long], valueCodec: Codec[A], alignment : Int, limit: Option[Long] = None) extends Codec[List[A]] { + /** + * Convert a `List` of elements into a byte-aligned `BitVector`.
+ *
+ * Bit padding after the encoded size of the `List` is only added if the `alignment` value is greater than zero and the initial encoding process was successful. + * The padding is rather heavy-handed and a completely different `BitVector` is returned if successful. + * @param list the `List` to be encoded + * @return the `BitVector` encoding, if successful + */ + override def encode(list : List[A]) : Attempt[BitVector] = { + var solve : Attempt[BitVector] = Encoder.encodeSeq(valueCodec)(list) + if(alignment > 0) { + solve match { + case Attempt.Successful(vector) => + val countCodecSize : Long = countCodec.sizeBound.lowerBound + solve = Attempt.successful(vector.take(countCodecSize) ++ BitVector.fill(alignment)(false) ++ vector.drop(countCodecSize)) + case _ => + solve = Attempt.failure(Err("failed to create a list")) + } + } + solve + } + + /** + * Convert a byte-aligned `BitVector` into a `List` of elements. + * @param buffer the encoded bits in the `List`, preceded by the alignment bits + * @return the decoded `List` + */ + override def decode(buffer: BitVector) = { + val lim = Option( if(limit.isDefined) limit.get.asInstanceOf[Int] else 0 ) //TODO potentially unsafe size conversion + Decoder.decodeCollect[List, A](valueCodec, lim)(buffer.drop(alignment)) + } + + /** + * The size of the encoded `List`. + * @return the size as calculated by the size of each element for each element + */ + override def sizeBound = limit match { + case None => + SizeBound.unknown + case Some(lim : Long) => + valueCodec.sizeBound * lim + } + + /** + * Get a `String` representation of this `List`. + * Unchanged from original. + * @return the `String` representation + */ + override def toString = s"list($valueCodec)" } diff --git a/common/src/main/scala/net/psforever/packet/game/HotSpotUpdateMessage.scala b/common/src/main/scala/net/psforever/packet/game/HotSpotUpdateMessage.scala new file mode 100644 index 00000000..d475a001 --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/HotSpotUpdateMessage.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2016 PSForever.net to present +package net.psforever.packet.game + +import net.psforever.newcodecs.newcodecs +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import scodec.Codec +import scodec.codecs._ + +/** + * Information for positioning a hotspot on the continental map.
+ *
+ * The origin point is the lowest left corner of the map grid. + * The coordinates of the hotspot do necessarily match up to the map's internal coordinate system - what you learn using the `/loc` command. + * Instead, all maps use a 0 - 8192 coordinate overlay. + * @param x the x-coord of the center of the hotspot + * @param y the y-coord of the center of the hotspot + * @param scale how big the hotspot explosion icon appears + */ +final case class HotSpotInfo(x : Float, + y : Float, + scale : Float) + +/** + * A list of data for creating hotspots on a continental map. + * Hotspots indicate player activity, almost always some form of combat or aggressive encounter.
+ *
+ * The hotspot system is an all-or-nothing affair. + * The received packet indicates the hotspots to display and the map will display only those hotspots. + * Inversely, if the received packet indicates no hotspots, the map will display no hotspots at all. + * This "no hotspots" packet is always initially sent during zone setup during server login. + * To clear away only some hotspots, but retains others, a continental `List` would have to be pruned selectively for the client.
+ *
+ * Exploration:
+ * What does (zone) priority entail? + * @param continent_guid the zone (continent) + * @param priority na + * @param spots a List of HotSpotInfo + */ +final case class HotSpotUpdateMessage(continent_guid : PlanetSideGUID, + priority : Int, + spots : List[HotSpotInfo] = Nil) + extends PlanetSideGamePacket { + type Packet = HotSpotUpdateMessage + def opcode = GamePacketOpcode.HotSpotUpdateMessage + def encode = HotSpotUpdateMessage.encode(this) +} + +object HotSpotInfo extends Marshallable[HotSpotInfo] { + /* + the scale is technically not "correct" + the client is looking for a normal 0-8192 value + we are trying to enforce a more modest graphic scale at 128.0f + */ + implicit val codec : Codec[HotSpotInfo] = { + ("x" | newcodecs.q_float(0.0, 8192.0, 20)) :: + ("y" | newcodecs.q_float(0.0, 8192.0, 20)) :: + ("scale" | newcodecs.q_float(0.0, 524288.0, 20)) + }.as[HotSpotInfo] +} + +object HotSpotUpdateMessage extends Marshallable[HotSpotUpdateMessage] { + implicit val codec : Codec[HotSpotUpdateMessage] = ( + ("continent_guid" | PlanetSideGUID.codec) :: + ("priority" | uint4L) :: + ("spots" | PacketHelpers.listOfNAligned(longL(8), 4, HotSpotInfo.codec)) + ).as[HotSpotUpdateMessage] +} diff --git a/common/src/test/scala/GamePacketTest.scala b/common/src/test/scala/GamePacketTest.scala index 2f7e8a11..6369c876 100644 --- a/common/src/test/scala/GamePacketTest.scala +++ b/common/src/test/scala/GamePacketTest.scala @@ -5,6 +5,7 @@ import org.specs2.mutable._ import net.psforever.packet._ import net.psforever.packet.game._ import net.psforever.types._ +import scodec.Attempt import scodec.Attempt.Successful import scodec.bits._ @@ -915,6 +916,93 @@ class GamePacketTest extends Specification { } } + "HotSpotInfo" should { + val string = hex"00 D0 70 08 CA 80 00 00" // note: completing that last byte is required to avoid it being placed at the start of the vector + "decode" in { + HotSpotInfo.codec.decode(string.toBitVector) match { + case Attempt.Successful(decoded) => + decoded.value.x mustEqual 4000.0f + decoded.value.y mustEqual 5400.0f + decoded.value.scale mustEqual 64.0f + case _ => + ko + } + } + + "encode" in { + val msg = HotSpotInfo(4000.0f, 5400.0f, 64.0f) + val pkt = HotSpotInfo.codec.encode(msg).require.toByteVector + + pkt mustEqual string + } + } + + "HotSpotUpdateMessage" should { + val stringClear = hex"9F 0500 1 00 0" + val stringOne = hex"9F 0500 1 01 0 00 2E9 00 145 80000 0" + val stringTwo = hex"9F 0500 5 02 0 00 D07 00 8CA 80000 00 BEA 00 4C4 80000" + + "decode (clear)" in { + PacketCoding.DecodePacket(stringClear).require match { + case HotSpotUpdateMessage(continent_guid, unk, spots) => + continent_guid mustEqual PlanetSideGUID(5) + unk mustEqual 1 + spots.size mustEqual 0 + case _ => + ko + } + } + + "decode (one)" in { + PacketCoding.DecodePacket(stringOne).require match { + case HotSpotUpdateMessage(continent_guid, unk, spots) => + continent_guid mustEqual PlanetSideGUID(5) + unk mustEqual 1 + spots.size mustEqual 1 + spots.head.x mustEqual 4700.0f + spots.head.y mustEqual 2600.0f + spots.head.scale mustEqual 64.0f + case _ => + ko + } + } + + "decode (two)" in { + PacketCoding.DecodePacket(stringTwo).require match { + case HotSpotUpdateMessage(continent_guid, unk, spots) => + continent_guid mustEqual PlanetSideGUID(5) + unk mustEqual 5 + spots.size mustEqual 2 + spots.head.x mustEqual 4000.0f + spots.head.y mustEqual 5400.0f + spots.head.scale mustEqual 64.0f + spots(1).x mustEqual 5500.0f + spots(1).y mustEqual 2200.0f + spots(1).scale mustEqual 64.0f + case _ => + ko + } + } + + "encode (clear)" in { + val msg = HotSpotUpdateMessage(PlanetSideGUID(5),1) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual stringClear + } + + "encode (one)" in { + val msg = HotSpotUpdateMessage(PlanetSideGUID(5),1, HotSpotInfo(4700.0f, 2600.0f, 64.0f)::Nil) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual stringOne + } + + "encode (two)" in { + val msg = HotSpotUpdateMessage(PlanetSideGUID(5),5, HotSpotInfo(4000.0f, 5400.0f, 64.0f)::HotSpotInfo(5500.0f, 2200.0f, 64.0f)::Nil) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual stringTwo + } + } + "BuildingInfoUpdateMessage" should { val string = hex"a0 04 00 09 00 16 00 00 00 00 80 00 00 00 17 00 00 00 00 00 00 40"