diff --git a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 3fcd0449..a2beebc0 100644 --- a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -468,7 +468,7 @@ object GamePacketOpcode extends Enumeration { case 0x89 => game.BugReportMessage.decode case 0x8a => game.PlayerStasisMessage.decode case 0x8b => noDecoder(UnknownMessage139) - case 0x8c => noDecoder(OutfitMembershipRequest) + case 0x8c => game.OutfitMembershipRequest.decode case 0x8d => noDecoder(OutfitMembershipResponse) case 0x8e => game.OutfitRequest.decode case 0x8f => noDecoder(OutfitEvent) diff --git a/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala b/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala new file mode 100644 index 00000000..55989fc6 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala @@ -0,0 +1,184 @@ +// Copyright (c) 2023 PSForever +package net.psforever.packet.game + +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.types.PlanetSideGUID +import scodec.{Attempt, Codec, Err} +import scodec.bits.BitVector +import scodec.codecs._ +import shapeless.{::, HNil} + +final case class OutfitMembershipRequest( + request_type: OutfitMembershipRequest.RequestType.Type, + avatar_guid: PlanetSideGUID, + unk1: Int, + action: OutfitAction + ) extends PlanetSideGamePacket { + type Packet = OutfitMembershipRequest + + def opcode: Type = GamePacketOpcode.OutfitMembershipRequest + + def encode: Attempt[BitVector] = OutfitMembershipRequest.encode(this) +} + +abstract class OutfitAction(val code: Int) +object OutfitAction { + + final case class CreateOutfit(unk2: String, unk3: Int, unk4: Boolean, outfit_name: String) extends OutfitAction(code = 0) + + final case class FormOutfit(unk2: String, unk3: Int, unk4: Boolean, outfit_name: String) extends OutfitAction(code = 1) + + final case class AcceptOutfitInvite(unk2: String) extends OutfitAction(code = 3) + + final case class RejectOutfitInvite(unk2: String) extends OutfitAction(code = 4) + + final case class CancelOutfitInvite(unk5: Int, unk6: Int, outfit_name: String) extends OutfitAction(code = 5) + + final case class Unknown(badCode: Int, data: BitVector) extends OutfitAction(badCode) + + /** + * The `Codec`s used to transform the input stream into the context of a specific action + * and extract the field data from that stream. + */ + object Codecs { + private val everFailCondition = conditional(included = false, bool) + + val CreateOutfitCodec: Codec[CreateOutfit] = + (PacketHelpers.encodedWideString :: uint4L :: bool :: PacketHelpers.encodedWideString).xmap[CreateOutfit]( + { + case unk2 :: unk3 :: unk4 :: outfit_name :: HNil => + CreateOutfit(unk2, unk3, unk4, outfit_name) + }, + { + case CreateOutfit(unk2, unk3, unk4, outfit_name) => + unk2 :: unk3 :: unk4 :: outfit_name :: HNil + } + ) + + val FormOutfitCodec: Codec[FormOutfit] = + (PacketHelpers.encodedWideString :: uint4L :: bool :: PacketHelpers.encodedWideString).xmap[FormOutfit]( + { + case unk2 :: unk3 :: unk4 :: outfit_name :: HNil => + FormOutfit(unk2, unk3, unk4, outfit_name) + }, + { + case FormOutfit(unk2, unk3, unk4, outfit_name) => + unk2 :: unk3 :: unk4 :: outfit_name :: HNil + } + ) + + val AcceptOutfitCodec: Codec[AcceptOutfitInvite] = + PacketHelpers.encodedWideString.xmap[AcceptOutfitInvite]( + { + case unk2 => + AcceptOutfitInvite(unk2) + }, + { + case AcceptOutfitInvite(unk2) => + unk2 + } + ) + + val RejectOutfitCodec: Codec[RejectOutfitInvite] = + PacketHelpers.encodedWideString.xmap[RejectOutfitInvite]( + { + case unk2 => + RejectOutfitInvite(unk2) + }, + { + case RejectOutfitInvite(unk2) => + unk2 + } + ) + + val CancelOutfitCodec: Codec[CancelOutfitInvite] = + (uint16L :: uint16L :: PacketHelpers.encodedWideStringAligned(5)).xmap[CancelOutfitInvite]( + { + case unk5 :: unk6 :: outfit_name :: HNil => + CancelOutfitInvite(unk5, unk6, outfit_name) + }, + { + case CancelOutfitInvite(unk5, unk6, outfit_name) => + unk5 :: unk6 :: outfit_name :: HNil + } + ) + + /** + * A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object. + * @param action the action behavior code + * @return a transformation between the action code and the unknown bit data + */ + def unknownCodec(action: Int): Codec[Unknown] = + bits.xmap[Unknown]( + data => Unknown(action, data), + { + case Unknown(_, data) => data + } + ) + + /** + * The action code was completely unanticipated! + * @param action the action behavior code + * @return nothing; always fail + */ + def failureCodec(action: Int): Codec[OutfitAction] = + everFailCondition.exmap[OutfitAction]( + _ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")), + _ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action")) + ) + } +} + +object OutfitMembershipRequest extends Marshallable[OutfitMembershipRequest] { + + object RequestType extends Enumeration { + type Type = Value + + val Create: RequestType.Value = Value(0) + val Form: RequestType.Value = Value(1) + val Unk2: RequestType.Value = Value(2) + val Accept: RequestType.Value = Value(3) + val Reject: RequestType.Value = Value(4) + val Cancel: RequestType.Value = Value(5) + val Unk6: RequestType.Value = Value(6) // 6 and 7 seen as failed decodes, validity unknown + val Unk7: RequestType.Value = Value(7) + + implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3)) + } + + private def selectFromType(code: Int): Codec[OutfitAction] = { + import OutfitAction.Codecs._ + import scala.annotation.switch + + ((code: @switch) match { + case 0 => CreateOutfitCodec + case 1 => FormOutfitCodec // so far same as Create + case 2 => unknownCodec(action = code) + case 3 => AcceptOutfitCodec + case 4 => RejectOutfitCodec // so far same as Accept + case 5 => CancelOutfitCodec + case 6 => unknownCodec(action = code) + case 7 => unknownCodec(action = code) + // 3 bit limit + case _ => failureCodec(code) + }).asInstanceOf[Codec[OutfitAction]] + } + + implicit val codec: Codec[OutfitMembershipRequest] = ( + ("request_type" | RequestType.codec) >>:~ { request_type => + ("avatar_guid" | PlanetSideGUID.codec) :: + ("unk1" | uint16L) :: + ("action" | selectFromType(request_type.id)) + } + ).xmap[OutfitMembershipRequest]( + { + case request_type :: avatar_guid :: u1 :: action :: HNil => + OutfitMembershipRequest(request_type, avatar_guid, u1, action) + }, + { + case OutfitMembershipRequest(request_type, avatar_guid, u1, action) => + request_type :: avatar_guid :: u1 :: action :: HNil + } + ) +} diff --git a/src/test/scala/game/OutfitMembershipRequestTest.scala b/src/test/scala/game/OutfitMembershipRequestTest.scala new file mode 100644 index 00000000..4b90df4e --- /dev/null +++ b/src/test/scala/game/OutfitMembershipRequestTest.scala @@ -0,0 +1,234 @@ +// Copyright (c) 2023 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.packet.game.OutfitAction.{AcceptOutfitInvite, CancelOutfitInvite, CreateOutfit, FormOutfit, RejectOutfitInvite} +import net.psforever.packet.game.OutfitMembershipRequest.RequestType +import net.psforever.types.PlanetSideGUID +import org.specs2.mutable._ +import scodec.bits._ + +class OutfitMembershipRequestTest extends Specification { + val create_ABC = hex"8c 0 0200 000 1000 83 410042004300" + val create_2222 = hex"8c 0 1000 000 1000 84 3200320032003200" + val form_abc = hex"8c 2 0200 000 1000 83 610062006300" + val form_1 = hex"8c 2 1000 000 1000 81 3100" + val unk3 = hex"8c 5 bb39 9e0 2000 0000 1080 750072006f006200" // -- "urob" -- could be false positive -- seems to gets an OMSResp -> 0x8d271bb399e025af8f405080550072006f0062008080 + val accept_1 = hex"8c 6 0200 000 1000" + val accept_2 = hex"8c 6 0400 000 1000" + val reject_1 = hex"8c 8 0200 000 1000" + val reject_2 = hex"8c 8 0400 000 1000" + val cancel_3 = hex"8c a 0600 000 0000 0000 1000" + val cancel_1_abc = hex"8c a 0200 000 0000 0000 1060 610062006300" + val cancel_3_def = hex"8c a 0600 000 0000 0000 1060 640065006600" // /outfitcancel 123 def -- first parameter is skipped + + "decode CreateOutfit ABC" in { + PacketCoding.decodePacket(create_ABC).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Create + avatar_id mustEqual PlanetSideGUID(1) + unk1 mustEqual 0 + action mustEqual CreateOutfit("", 0, unk4 = false, "ABC") + case _ => + ko + } + } + + "encode CreateOutfit ABC" in { + val msg = OutfitMembershipRequest(RequestType.Create, PlanetSideGUID(1), 0, CreateOutfit("", 0, unk4 = false, "ABC")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual create_ABC + } + + "decode CreateOutfit 2222" in { + PacketCoding.decodePacket(create_2222).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Create + avatar_id mustEqual PlanetSideGUID(8) + unk1 mustEqual 0 + action mustEqual CreateOutfit("", 0, unk4 = false, "2222") + case _ => + ko + } + } + + "encode CreateOutfit 2222" in { + val msg = OutfitMembershipRequest(RequestType.Create, PlanetSideGUID(8), 0, CreateOutfit("", 0, unk4 = false, "2222")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual create_2222 + } + + "decode FormOutfit abc" in { + PacketCoding.decodePacket(form_abc).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Form + avatar_id mustEqual PlanetSideGUID(1) + unk1 mustEqual 0 + action mustEqual FormOutfit("", 0, unk4 = false, "abc") + case _ => + ko + } + } + + "encode FormOutfit abc" in { + val msg = OutfitMembershipRequest(RequestType.Form, PlanetSideGUID(1), 0, FormOutfit("", 0, unk4 = false, "abc")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual form_abc + } + + "decode FormOutfit 1" in { + PacketCoding.decodePacket(form_1).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Form + avatar_id mustEqual PlanetSideGUID(8) + unk1 mustEqual 0 + action mustEqual FormOutfit("", 0, unk4 = false, "1") + case _ => + ko + } + } + + "encode FormOutfit 1" in { + val msg = OutfitMembershipRequest(RequestType.Form, PlanetSideGUID(8), 0, FormOutfit("", 0, unk4 = false, "1")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual form_1 + } + + "decode AcceptOutfitInvite 1" in { + PacketCoding.decodePacket(accept_1).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Accept + avatar_id mustEqual PlanetSideGUID(1) + unk1 mustEqual 0 + action mustEqual AcceptOutfitInvite("") + case _ => + ko + } + } + + "encode AcceptOutfitInvite 1" in { + val msg = OutfitMembershipRequest(RequestType.Accept, PlanetSideGUID(1), 0, AcceptOutfitInvite("")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual accept_1 + } + + "decode AcceptOutfitInvite 2" in { + PacketCoding.decodePacket(accept_2).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Accept + avatar_id mustEqual PlanetSideGUID(2) + unk1 mustEqual 0 + action mustEqual AcceptOutfitInvite("") + case _ => + ko + } + } + + "encode AcceptOutfitInvite 2" in { + val msg = OutfitMembershipRequest(RequestType.Accept, PlanetSideGUID(2), 0, AcceptOutfitInvite("")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual accept_2 + } + + "decode RejectOutfitInvite 1" in { + PacketCoding.decodePacket(reject_1).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Reject + avatar_id mustEqual PlanetSideGUID(1) + unk1 mustEqual 0 + action mustEqual RejectOutfitInvite("") + case _ => + ko + } + } + + "encode RejectOutfitInvite 1" in { + val msg = OutfitMembershipRequest(RequestType.Reject, PlanetSideGUID(1), 0, RejectOutfitInvite("")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual reject_1 + } + + "decode RejectOutfitInvite 2" in { + PacketCoding.decodePacket(reject_2).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Reject + avatar_id mustEqual PlanetSideGUID(2) + unk1 mustEqual 0 + action mustEqual RejectOutfitInvite("") + case _ => + ko + } + } + + "encode RejectOutfitInvite 2" in { + val msg = OutfitMembershipRequest(RequestType.Reject, PlanetSideGUID(2), 0, RejectOutfitInvite("")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual reject_2 + } + + "decode CancelOutfitInvite 3" in { + PacketCoding.decodePacket(cancel_3).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Cancel + avatar_id mustEqual PlanetSideGUID(3) + unk1 mustEqual 0 + action mustEqual CancelOutfitInvite(0, 0, "") + case _ => + ko + } + } + + "encode CancelOutfitInvite 3" in { + val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(3), 0, CancelOutfitInvite(0, 0, "")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual cancel_3 + } + + "decode CancelOutfitInvite 1 abc" in { + PacketCoding.decodePacket(cancel_1_abc).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Cancel + avatar_id mustEqual PlanetSideGUID(1) + unk1 mustEqual 0 + action mustEqual CancelOutfitInvite(0, 0, "abc") + case _ => + ko + } + } + + "encode CancelOutfitInvite 1 abc" in { + val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(1), 0, CancelOutfitInvite(0, 0, "abc")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual cancel_1_abc + } + + "decode CancelOutfitInvite 3 def" in { + PacketCoding.decodePacket(cancel_3_def).require match { + case OutfitMembershipRequest(request_type, avatar_id, unk1, action) => + request_type mustEqual RequestType.Cancel + avatar_id mustEqual PlanetSideGUID(3) + unk1 mustEqual 0 + action mustEqual CancelOutfitInvite(0, 0, "def") + case _ => + ko + } + } + + "encode CancelOutfitInvite 3 def" in { + val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(3), 0, CancelOutfitInvite(0, 0, "def")) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual cancel_3_def + } +} diff --git a/src/test/scala/game/SquadMembershipResponseTest.scala b/src/test/scala/game/SquadMembershipResponseTest.scala index 6492b4f9..4ef9ffd3 100644 --- a/src/test/scala/game/SquadMembershipResponseTest.scala +++ b/src/test/scala/game/SquadMembershipResponseTest.scala @@ -8,23 +8,23 @@ import org.specs2.mutable._ import scodec.bits._ class SquadMembershipResponseTest extends Specification { - val string_01 = hex"6f0 00854518050db2260108048006f006600440000" - val string_02 = hex"6f0 0049e8220112aa1e01100530050004f0049004c0045005200530080" - val string_11 = hex"6f1 995364f2040000000100080" - val string_12 = hex"6f1 90cadcf4040000000100080" - val string_21 = hex"6f2 010db2260085451805140560069007200750073004700690076006500720080" - val string_22 = hex"6f2 010db22601da03aa03140560069007200750073004700690076006500720080" - val string_31 = hex"6f3 07631db202854518050a048004d0046004900430000" - val string_32 = hex"6f3 04c34fb402854518050e0440041004e00310031003100310000" - val string_41 = hex"6f4 04cadcf405bbbef405140530041007200610069007300560061006e00750000" - val string_42 = hex"6f4 05c9c0f405d71aec0516041006900720049006e006a006500630074006f00720000" - val string_51 = hex"6f5 0249e8220049e822010e0430043005200490044004500520080" - val string_71 = hex"6f7 1049e822000000000100080" - val string_72 = hex"6f7 00cadcf4041355ae03100570069007a006b00690064003400350080" - val string_81 = hex"6f8 001355ae02cadcf405100570069007a006b00690064003400350000" - val string_91 = hex"6f9 008310080115aef40500080" - val string_92 = hex"6f9 001355ae02cadcf405100570069007a006b00690064003400350000" - val string_b1 = hex"6fb 021355ae02cadcf405140530041007200610069007300560061006e00750000" + val string_01 = hex"6f 0 00854518050db2260108048006f006600440000" + val string_02 = hex"6f 0 0049e8220112aa1e01100530050004f0049004c0045005200530080" + val string_11 = hex"6f 1 995364f2040000000100080" + val string_12 = hex"6f 1 90cadcf4040000000100080" + val string_21 = hex"6f 2 010db2260085451805140560069007200750073004700690076006500720080" + val string_22 = hex"6f 2 010db22601da03aa03140560069007200750073004700690076006500720080" + val string_31 = hex"6f 3 07631db202854518050a048004d0046004900430000" + val string_32 = hex"6f 3 04c34fb402854518050e0440041004e00310031003100310000" + val string_41 = hex"6f 4 04cadcf405bbbef405140530041007200610069007300560061006e00750000" + val string_42 = hex"6f 4 05c9c0f405d71aec0516041006900720049006e006a006500630074006f00720000" + val string_51 = hex"6f 5 0249e8220049e822010e0430043005200490044004500520080" + val string_71 = hex"6f 7 1049e822000000000100080" + val string_72 = hex"6f 7 00cadcf4041355ae03100570069007a006b00690064003400350080" + val string_81 = hex"6f 8 001355ae02cadcf405100570069007a006b00690064003400350000" + val string_91 = hex"6f 9 008310080115aef40500080" + val string_92 = hex"6f 9 001355ae02cadcf405100570069007a006b00690064003400350000" + val string_b1 = hex"6f b 021355ae02cadcf405140530041007200610069007300560061006e00750000" "SquadMembershipResponse" should { "decode (0-1)" in {