diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
index 1ee7ca6f..c13f9cec 100644
--- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
+++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala
@@ -433,7 +433,7 @@ object GamePacketOpcode extends Enumeration {
case 0x5f => noDecoder(FavoritesResponse)
// OPCODES 0x60-6f
- case 0x60 => noDecoder(FavoritesMessage)
+ case 0x60 => game.FavoritesMessage.decode
case 0x61 => noDecoder(ObjectDetectedMessage)
case 0x62 => noDecoder(SplashHitMessage)
case 0x63 => noDecoder(SetChatFilterMessage)
diff --git a/common/src/main/scala/net/psforever/packet/game/FavoritesMessage.scala b/common/src/main/scala/net/psforever/packet/game/FavoritesMessage.scala
new file mode 100644
index 00000000..1969f637
--- /dev/null
+++ b/common/src/main/scala/net/psforever/packet/game/FavoritesMessage.scala
@@ -0,0 +1,82 @@
+// Copyright (c) 2016 PSForever.net to present
+package net.psforever.packet.game
+
+import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
+import scodec.Codec
+import scodec.codecs._
+import shapeless.{::, HNil}
+
+/**
+ * Load the designator for an entry in the player's favorites list.
+ *
+ * This entry defines a user-defined loadout label that appears on a "Favorites" tab list and can be selected.
+ * A subsequent server request - `ItemTransactionMessage` - must be made to retrieve the said loadout contents.
+ * Multiple separated favorites lists are present in the game.
+ * All entries are prepended with their destination list which indicates how from how that list is viewable.
+ * Different lists also have different numbers of available lines to store loadout entries.
+ *
+ * Infantry equipment favorites are appended with a code for the type of exo-suit that they will load on a player.
+ * This does not match the same two field numbering system as in `ArmorChangedMessage` packets.
+ *
+ * Lists:
+ * `
+ * 0 - Equipment Terminal (infantry)
+ * 1 - Repair/Rearm Silo (standard vehicles)
+ * `
+ *
+ * Armors:
+ * `
+ * 1 - Agile
+ * 2 - Reinforced
+ * 4 - AA MAX
+ * 5 - AI MAX
+ * 6 - AV MAX
+ * `
+ *
+ * Exploration 1:
+ * The identifier for the list is two bits so four separated lists of `Favorites` are supportable.
+ * Two of the lists are common enough and we can assume one of the others is related to Battleframe Robotics.
+ * These lists also do not include `Squad Defintion...` presets.
+ * What are the unknown lists?
+ *
+ * Exploration 2:
+ * There are three unaccounted exo-suit indices - 0, 3, and 7;
+ * and, there are two specific kinds of exo-suit that are not defined - Infiltration and Standard.
+ * It is possible that one of the indices also defines the generic MAX (see `ArmorChangedMessage`).
+ * Which exo-suit is associated with which index?
+ * @param list the destination list
+ * @param player_guid the player
+ * @param line the zero-indexed line number of this entry in its list
+ * @param label the identifier for this entry
+ * @param armor the type of exo-suit, if an Infantry loadout
+ */
+final case class FavoritesMessage(list : Int,
+ player_guid : PlanetSideGUID,
+ line : Int,
+ label : String,
+ armor : Option[Int] = None)
+ extends PlanetSideGamePacket {
+ type Packet = FavoritesMessage
+ def opcode = GamePacketOpcode.FavoritesMessage
+ def encode = FavoritesMessage.encode(this)
+}
+
+object FavoritesMessage extends Marshallable[FavoritesMessage] {
+ implicit val codec : Codec[FavoritesMessage] = (
+ ("list" | uint2L) >>:~ { value =>
+ ("player_guid" | PlanetSideGUID.codec) ::
+ ("line" | uint4L) ::
+ ("label" | PacketHelpers.encodedWideStringAligned(2)) ::
+ conditional(value == 0, "armor" | uintL(3))
+ }).xmap[FavoritesMessage] (
+ {
+ case lst :: guid :: ln :: str :: arm :: HNil =>
+ FavoritesMessage(lst, guid, ln, str, arm)
+ },
+ {
+ case FavoritesMessage(lst, guid, ln, str, arm) =>
+ val armset : Option[Int] = if(lst == 0 && arm.isEmpty) { Some(0) } else { arm }
+ lst :: guid :: ln :: str :: armset :: HNil
+ }
+ )
+}
diff --git a/common/src/main/scala/net/psforever/types/TransactionType.scala b/common/src/main/scala/net/psforever/types/TransactionType.scala
index c1b640b7..e8ed96f3 100644
--- a/common/src/main/scala/net/psforever/types/TransactionType.scala
+++ b/common/src/main/scala/net/psforever/types/TransactionType.scala
@@ -12,7 +12,7 @@ object TransactionType extends Enumeration {
Sell,
Unk4,
Unk5,
- Unk6,
+ Infantry_Loadout,
Unk7
= Value
diff --git a/common/src/test/scala/GamePacketTest.scala b/common/src/test/scala/GamePacketTest.scala
index 48d669c6..aebb8ab7 100644
--- a/common/src/test/scala/GamePacketTest.scala
+++ b/common/src/test/scala/GamePacketTest.scala
@@ -1081,6 +1081,52 @@ class GamePacketTest extends Specification {
}
}
+ "FavoritesMessage" should {
+ val stringVehicles = hex"60 5C 84 02 20 5300 6B00 7900 6700 7500 6100 7200 6400"
+ val stringInfantry = hex"60 2C 03 82 34 4100 6700 6900 6C00 6500 2000 2800 6200 6100 7300 6900 6300 2900 20"
+
+ "decode (for infantry)" in {
+ PacketCoding.DecodePacket(stringInfantry).require match {
+ case FavoritesMessage(list, player_guid, line, label, armor) =>
+ list mustEqual 0
+ player_guid mustEqual PlanetSideGUID(3760)
+ line mustEqual 0
+ label mustEqual "Agile (basic)"
+ armor.isDefined mustEqual true
+ armor.get mustEqual 1
+ case default =>
+ ko
+ }
+ }
+
+ "encode (for infantry)" in {
+ val msg = FavoritesMessage(0, PlanetSideGUID(3760), 0, "Agile (basic)", Option(1))
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+
+ pkt mustEqual stringInfantry
+ }
+
+ "decode (for vehicles)" in {
+ PacketCoding.DecodePacket(stringVehicles).require match {
+ case FavoritesMessage(list, player_guid, line, label, armor) =>
+ list mustEqual 1
+ player_guid mustEqual PlanetSideGUID(4210)
+ line mustEqual 0
+ label mustEqual "Skyguard"
+ armor.isDefined mustEqual false
+ case default =>
+ ko
+ }
+ }
+
+ "encode (for vehicles)" in {
+ val msg = FavoritesMessage(1, PlanetSideGUID(4210), 0, "Skyguard")
+ val pkt = PacketCoding.EncodePacket(msg).require.toByteVector
+
+ pkt mustEqual stringVehicles
+ }
+ }
+
"WeaponJammedMessage" should {
val string = hex"66 4C00"