diff --git a/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala b/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala index 0a0f31f2..6f11c730 100644 --- a/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala +++ b/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala @@ -1,54 +1,152 @@ // Copyright (c) 2025 PSForever package net.psforever.packet.game +import net.psforever.packet.GamePacketOpcode.Type import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} -import scodec.Codec -import scodec.bits.ByteVector +import scodec.{Attempt, Codec, Err} +import scodec.bits.BitVector import scodec.codecs._ import shapeless.{::, HNil} /* - action is unimplemented! if action == 0 only outfit_id and member_id are sent - action2 is unimplemented! if action2 == 0 unk2 will contain one additional uint32L - unk2 contains one byte of padding. may contain 4byte of unknown data depending on action2 + packet_type is unimplemented! if packet_type == 0 only outfit_id and member_id are sent + action is unimplemented! if action == 0 unk2 will contain one additional uint32L + unk0_padding contains one byte of padding. may contain 4byte of unknown data depending on action */ final case class OutfitMemberEvent( - action: Int, // action is unimplemented + packet_type: Int, // only 0 is known // TODO: needs implementation outfit_id: Long, member_id: Long, - member_name: String, + member_name: String, // from here is packet_type == 0 only rank: Int, // 0-7 points: Long, // client divides this by 100 last_login: Long, // seconds ago from current time, 0 if online - action2: Int, // this should always be 1, otherwise there will be actual data in unk2! - padding: ByteVector, // only contains information if unk1 is 0, 1 byte of padding otherwise + action: OutfitMemberEvent.PacketType.Type, // this should always be 1, otherwise there will be actual data in unk0_padding! + unk0_padding: OutfitMemberEventAction // only contains information if action is 0, 1 byte of padding otherwise ) extends PlanetSideGamePacket { type Packet = OutfitMemberEvent - def opcode = GamePacketOpcode.OutfitMemberEvent + def opcode: Type = GamePacketOpcode.OutfitMemberEvent - def encode = OutfitMemberEvent.encode(this) + def encode: Attempt[BitVector] = OutfitMemberEvent.encode(this) +} + +abstract class OutfitMemberEventAction(val code: Int) +object OutfitMemberEventAction { + + final case class Unk0( + unk0: Long + ) extends OutfitMemberEventAction(code = 0) + + final case class Padding( + padding: Int + ) extends OutfitMemberEventAction(code = 1) + + final case class Unknown(badCode: Int, data: BitVector) extends OutfitMemberEventAction(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 UnkNonPaddingCodec: Codec[Unk0] = ( + ("unk0" | uint32L) + ).xmap[Unk0]( + { + case u0 => + Unk0(u0) + }, + { + case Unk0(u0) => + u0 + } + ) + + val PaddingCodec: Codec[Padding] = ( + ("padding" | uint4L) + ).xmap[Padding]( + { + case padding => + Padding(padding) + }, + { + case Padding(padding) => + padding + } + ) + + /** + * 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[OutfitMemberEventAction] = + everFailCondition.exmap[OutfitMemberEventAction]( + _ => 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 OutfitMemberEvent extends Marshallable[OutfitMemberEvent] { + + object PacketType extends Enumeration { + type Type = Value + + val Unk0: PacketType.Value = Value(0) + val Padding: PacketType.Value = Value(1) // Info: Player has been invited / response to OutfitMembershipRequest Unk2 for that player + + implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(1)) + } + + private def selectFromType(code: Int): Codec[OutfitMemberEventAction] = { + import OutfitMemberEventAction.Codecs._ + import scala.annotation.switch + + ((code: @switch) match { + case 0 => UnkNonPaddingCodec + case 1 => PaddingCodec + + case _ => failureCodec(code) + }).asInstanceOf[Codec[OutfitMemberEventAction]] + } + implicit val codec: Codec[OutfitMemberEvent] = ( - ("action" | uintL(2)) :: + ("packet_type" | uintL(2)) :: // this should selectFromType ("outfit_id" | uint32L) :: ("member_id" | uint32L) :: - ("member_name" | PacketHelpers.encodedWideStringAligned(6)) :: + ("member_name" | PacketHelpers.encodedWideStringAligned(6)) :: // from here is packet_type == 0 only ("rank" | uint(3)) :: ("points" | uint32L) :: ("last_login" | uint32L) :: - ("action2" | uintL(1)) :: - ("padding" | bytes) + (("action" | PacketType.codec) >>:~ { action => + ("action_part" | selectFromType(action.id)).hlist + }) ).xmap[OutfitMemberEvent]( { - case unk00 :: outfit_id :: member_id :: member_name :: rank :: points :: last_login :: u1 :: padding :: HNil => - OutfitMemberEvent(unk00, outfit_id, member_id, member_name, rank, points, last_login, u1, padding) + case packet_type :: outfit_id :: member_id :: member_name :: rank :: points :: last_login :: action :: unk0_padding :: HNil => + OutfitMemberEvent(packet_type, outfit_id, member_id, member_name, rank, points, last_login, action, unk0_padding) }, { - case OutfitMemberEvent(unk00, outfit_id, member_id, member_name, rank, points, last_login, action2, padding) => - unk00 :: outfit_id :: member_id :: member_name :: rank :: points :: last_login :: action2 :: padding :: HNil + // TODO: remove once implemented + // ensure we send packet_type 0 only + case OutfitMemberEvent(_, outfit_id, member_id, member_name, rank, points, last_login, action, unk0_padding) => + 0 :: outfit_id :: member_id :: member_name :: rank :: points :: last_login :: action :: unk0_padding :: HNil } ) } diff --git a/src/test/scala/game/OutfitMemberEventTest.scala b/src/test/scala/game/OutfitMemberEventTest.scala index 5dbcef00..f51fe952 100644 --- a/src/test/scala/game/OutfitMemberEventTest.scala +++ b/src/test/scala/game/OutfitMemberEventTest.scala @@ -2,56 +2,115 @@ package game import net.psforever.packet._ - -import net.psforever.packet.game._ +import net.psforever.packet.game.OutfitMemberEvent +import net.psforever.packet.game.OutfitMemberEventAction.Padding import org.specs2.mutable._ import scodec.bits._ class OutfitMemberEventTest extends Specification { //val unk0_ABC: ByteVector = hex"90 3518 4000 1a4e 4100 2 180 450078007000650072007400 8483 07e0 119d bfe0 70" // 0x90048640001030c28022404c0061007a00650072003100390038003200f43a45e00b4c604010 - val unk0_ABC_Lazer = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 f43a 45e0 0b4c 6040 10" + val Lazer = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 f43a 45e0 0b4c 6040 10" + val Lazer2 = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 e6dc 25a0 153e 6040 10" + val OpolE = hex"90 0 4864 0003 aad6 280a 14 0 4f0070006f006c004500 c9a1 80e0 0d03 2040 10" + val Billy = hex"90 0 4864 0003 a41a 280a 20 0 620069006c006c007900320035003600 935f 6000 186a b040 50" + val Virus = hex"90 0 4864 0002 1b64 4c02 28 0 5600690072007500730047006900760065007200 2f89 0080 0000 0000 10" + val PvtPa = hex"90 0 4864 0000 1e69 e80a 2c 0 500076007400500061006e00630061006b0065007300 705e a080 0a85 e060 10" + val Night = hex"90 0 4864 0002 4cf0 3802 28 0 4e006900670068007400770069006e0067003100 b8fb 9a40 0da6 ec80 50" - val OpolE = hex"90 0 4864 0003 aad6 280a 14 0 4f0070006f006c004500 c9a1 80e0 0d03 2040 10" - val Billy = hex"90 0 4864 0003 a41a 280a 20 0 620069006c006c007900320035003600 935f 6000 186a b040 50" - val Lazer = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 e6dc 25a0 153e 6040 10" - val Virus = hex"90 0 4864 0002 1b64 4c02 28 0 5600690072007500730047006900760065007200 2f89 0080 0000 0000 10" - val PvtPa = hex"90 0 4864 0000 1e69 e80a 2c 0 500076007400500061006e00630061006b0065007300 705e a080 0a85 e060 10" - val Night = hex"90 0 4864 0002 4cf0 3802 28 0 4e006900670068007400770069006e0067003100 b8fb 9a40 0da6 ec80 50" + val Unk1 = hex"90 5 40542002 3f61e808 0" - val Unk0 = hex"90 5 40542002 3f61e808 0" - - "decode Unk0 ABC" in { - PacketCoding.decodePacket(unk0_ABC_Lazer).require match { - case OutfitMemberEvent(action, outfit_id, member_id, member_name, rank, points, last_login, action2, padding) => - action mustEqual 0 - outfit_id mustEqual 6418L - member_id mustEqual 705344 - member_name mustEqual "Lazer1982" - rank mustEqual 7 - points mustEqual 3134113 - last_login mustEqual 156506 - action2 mustEqual 1 - padding mustEqual ByteVector(0x0) + "decode Lazer padding" in { + PacketCoding.decodePacket(Lazer).require match { + case OutfitMemberEvent(packet_type, outfit_id, member_id, member_name, rank, points, last_login, action, unk0_padding) => + packet_type mustEqual 0 + outfit_id mustEqual 6418 + member_id mustEqual 705344 + member_name mustEqual "Lazer1982" + rank mustEqual 7 + points mustEqual 3134113 + last_login mustEqual 156506 + action mustEqual OutfitMemberEvent.PacketType.Padding + unk0_padding mustEqual Padding(0) case _ => ko } } - "encode Unk0 ABC" in { + "encode Lazer padding" in { val msg = OutfitMemberEvent( - action = 0, - outfit_id = 6418L, + packet_type = 0, + outfit_id = 6418, member_id = 705344, member_name = "Lazer1982", rank = 7, points = 3134113, last_login = 156506, - action2 = 1, - ByteVector.empty + action = OutfitMemberEvent.PacketType.Padding, + unk0_padding = Padding(0) ) val pkt = PacketCoding.encodePacket(msg).require.toByteVector - pkt mustEqual unk0_ABC_Lazer + pkt mustEqual Lazer } + + "decode OpolE padding" in { + PacketCoding.decodePacket(OpolE).require match { + case OutfitMemberEvent(packet_type, outfit_id, member_id, member_name, rank, points, last_login, action, unk0_padding) => + packet_type mustEqual 0 + outfit_id mustEqual 6418 + member_id mustEqual 42644970 + member_name mustEqual "OpolE" + rank mustEqual 6 + points mustEqual 461901 + last_login mustEqual 137576 + action mustEqual OutfitMemberEvent.PacketType.Padding + unk0_padding mustEqual Padding(0) + case _ => + ko + } + } + + "encode OpolE padding" in { + val msg = OutfitMemberEvent( + packet_type = 0, + outfit_id = 6418, + member_id = 42644970, + member_name = "OpolE", + rank = 6, + points = 461901, + last_login = 137576, + action = OutfitMemberEvent.PacketType.Padding, + unk0_padding = Padding(0) + ) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual OpolE + } + + // TODO: these are broken because the decoder can only handle packet_type 0 + + /* + "decode Unk0" in { + PacketCoding.decodePacket(Unk1).require match { + case OutfitMemberEvent(packet_type, outfit_id, member_id) => + packet_type mustEqual 1 + outfit_id mustEqual 6418 + member_id mustEqual 42644970 + case _ => + ko + } + } + + "encode Unk0" in { + val msg = OutfitMemberEvent( + packet_type = 1, + outfit_id = 6418, + member_id = 42644970, + ) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual Unk1 + } + */ }