diff --git a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 2dab10d6..7955e49e 100644 --- a/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/common/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -328,7 +328,7 @@ object GamePacketOpcode extends Enumeration { case 0x06 => noDecoder(UnknownMessage6) case 0x07 => noDecoder(UnknownMessage7) // 0x08 - case 0x08 => noDecoder(PlayerStateMessage) + case 0x08 => game.PlayerStateMessage.decode case 0x09 => game.HitMessage.decode case 0x0a => noDecoder(HitHint) case 0x0b => noDecoder(DamageMessage) diff --git a/common/src/main/scala/net/psforever/packet/game/PlayerStateMessage.scala b/common/src/main/scala/net/psforever/packet/game/PlayerStateMessage.scala new file mode 100644 index 00000000..d5053bba --- /dev/null +++ b/common/src/main/scala/net/psforever/packet/game/PlayerStateMessage.scala @@ -0,0 +1,329 @@ +// Copyright (c) 2016 PSForever.net to present +package net.psforever.packet.game + +import net.psforever.newcodecs.newcodecs +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import net.psforever.types.Vector3 +import scodec.Codec +import scodec.codecs._ +import shapeless.{::, HNil} + +import scala.collection.mutable + +/** + * The server instructs some clients to render a player (usually not that client's avatar) to move in a certain way.
+ *
+ * This packet instructs the basic aspects of how the player character is positioned and how the player character moves. + * Each client keeps track of where a character "currently" is according to that client. + * `pos` reflects an update in regards to where the character should be moved. + * Data between this "currently" and "new" are interpolated over a fixed time interval. + * Position and velocity data is standard to normal PlanetSide ranges. + * All angles follow the convention that every `0x1` is about 2.8125 degrees; so, `0x10` is 45.0 degrees.
+ *
+ * The avatar model normally moves from where it "currently" is to `pos`. + * When `vel` is defined, `pos` is treated as where the avatar model starts its animation. + * In that case, it sppears to teleport to `pos` to carry out the interpolated movement according to `vel`. + * After the move, it remains at essentially `pos + vel * t`. + * The repositioning always takes the same amount of time. + * The player model is left in a walking/running animation (in place) until directed otherwise.
+ *
+ * If the model must interact with the environment during a velocity-driven move, it copes with local physics. + * A demonstration of this is what happens when one player "runs past"/"into" another player running up stairs. + * The climbing player is frequently reported by the other to appear to bounce over that player's head. + * If the other player is off the ground, passing too near to the observer can cause a rubber band effect on trajectory. + * This effect is entirely client-side to the observer and affects the moving player in no way.
+ *
+ * facingYaw:
+ * `0x00` -- E
+ * `0x10` -- NE
+ * `0x20` -- N
+ * `0x30` -- NW
+ * `0x40` -- W
+ * `0x50` -- SW
+ * `0x60` -- S
+ * `0x70` -- SE
+ * `0x80` -- E
+ *
+ * facingPitch:
+ * `0x00`-`0x20` -- downwards-facing angles, with `0x00` as forwards-facing
+ * `0x21`-`0x40` -- downwards-facing
+ * `0x41`-`0x59` -- upwards-facing
+ * `0x60`-`0x80` -- upwards-facing angles, with `0x80` as forwards-facing
+ *
+ * facingYawUpper:
+ * `0x00`-`0x20` -- turning to left, with `0x00` being forward-facing
+ * `0x21`-`0x40` -- facing leftwards
+ * `0x41`-`0x59` -- facing rightwards
+ * `0x60`-`0x80` -- turning to right, with `0x80` being forward-facing + * + * @param guid the avatar's guid + * @param pos the position of the avatar in the world environment (in three coordinates) + * @param vel an optional velocity + * @param facingYaw the angle with respect to the horizon towards which the avatar is looking; + * the model's whole body is facing this direction; + * measurements are counter-clockwise from East + * @param facingPitch the angle with respect to the sky and the ground towards which the avatar is looking + * @param facingYawUpper the angle of the avatar's upper body with respect to its forward-facing direction + * @param unk1 na + * @param is_crouching avatar is crouching + * @param is_jumping avatar is jumping; + * must remain flagged for jump to maintain animation + * @param unk2 na + * @param is_cloaked avatar is cloaked by virtue of an Infiltration Suit + */ +final case class PlayerStateMessage(guid : PlanetSideGUID, + pos : Vector3, + vel : Option[Vector3], + facingYaw : Int, + facingPitch : Int, + facingYawUpper : Int, + unk1 : Int, + is_crouching : Boolean = false, + is_jumping : Boolean = false, + unk2 : Boolean = false, + is_cloaked : Boolean = false) + extends PlanetSideGamePacket { + type Packet = PlayerStateMessage + def opcode = GamePacketOpcode.PlayerStateMessage + def encode = PlayerStateMessage.encode(this) +} + +object PlayerStateMessage extends Marshallable[PlayerStateMessage] { + type fourBoolPattern = Boolean :: Boolean :: Boolean :: Boolean :: HNil + + /** + * A `Codec` for reading out the four `Boolean` values near the end of the formal packet. + */ + val booleanCodec : Codec[fourBoolPattern] = ( + ("is_crouching" | bool) :: + ("is_jumping" | bool) :: + ("unk2" | bool) :: + ("is_cloaked" | bool) + ).as[fourBoolPattern] + + /** + * A `Codec` for ignoring the four values at the end of the formal packet (all set to `false`). + */ + val defaultCodec : Codec[fourBoolPattern] = ignore(0).hlist.xmap[fourBoolPattern] ( + { + case _ :: HNil => + false :: false :: false :: false :: HNil + }, + { + case _ :: _ :: _ :: _ :: HNil => + () :: HNil + } + ).as[fourBoolPattern] + + implicit val codec : Codec[PlayerStateMessage] = ( + ("guid" | PlanetSideGUID.codec) :: + ("pos" | Vector3.codec_pos) :: + optional(bool, "unk1" | Vector3.codec_vel) :: + ("facingYaw" | uint8L) :: + ("facingPitch" | uint8L) :: + ("facingYawUpper" | uint8L) :: + ("unk1" | uintL(10)) :: + (bool >>:~ { fourBools => + newcodecs.binary_choice(!fourBools, booleanCodec, defaultCodec) + }) + ).xmap[PlayerStateMessage] ( + { + case uid :: pos :: vel :: f1 :: f2 :: f3 :: u :: _ :: b1 :: b2 :: b3 :: b4 :: HNil => + PlayerStateMessage(uid, pos, vel, f1, f2, f3, u, b1, b2, b3, b4) + }, + { + case PlayerStateMessage(uid, pos, vel, f1, f2, f3, u, b1, b2, b3, b4) => + val b : Boolean = !(b1 || b2 || b3 || b4) + uid :: pos :: vel :: f1 :: f2 :: f3 :: u :: b :: b1 :: b2 :: b3 :: b4 :: HNil + } + ) +} + +//TODO the following logic is unimplemented +/* +There is a boolean that is currently unhandled(?) that determines if the packet is aware that this code would run. +If it passes, the first 8-bit value is the number of times the data will be iterated over. +On each pass, a 4-bit value is extracted from the packet and compared against 15. +When 15 is read, an 8-bit value is read on that same turn. +On each subsequent turn, 8-bit values will be read until the number of iterations or until there is an exception. +Until I find a packet that responds somehow, I have no clue what any of this is supposed to do. + */ +/** + * na + * @param size a length to be applied to the next list, but not necessarily the length of that list + * (if I could prove that size == list.size always then I could eliminate superfluous logic from `Extra1`) + * @param data a list of data that comes as either an 8-bit value, or as a 4-bit value and, maybe, an 8-bit value + */ +final case class Extra1(size : Int, + data : List[Extra2]) + +/** + * na + * @param unk1 na; + * the first 8-bit value in one-value form or the first 4-bit value in two-value form; + * in two-value form, when equal to 15, the second value is read + * @param unk2 na; + * the potential second 8-bit value in two-value form + * @param more the next data in the sequence + */ +final case class Extra2(unk1 : Int, + unk2 : Option[Int], + more : Option[Extra2] = None) + +object Extra1 { + /** + * Take a chain of `Extra2` objects produced from decoding and compress it into a `List`. + * @param lst the list in which the `Extra2` data will be stored + * @param nesting the current link in the chain of `Extra2` objects + */ + private def packExtraList(lst : mutable.ListBuffer[Extra2], nesting : Option[Extra2]) : Unit = { + if(nesting.isEmpty) { //escape case + return + } + val elem : Extra2 = nesting.get + lst += Extra2(elem.unk1, elem.unk2) + packExtraList(lst, elem.more) //tail recursion + } + + /** + * Take a `List` of `Extra2` objects for encoding and expand it into a chain. + * @param iter the iterator for a `List` of `Extra2` data + * @return the head of a chain of `Extra2` objects + */ + private def unpackExtraList(iter : Iterator[Extra2]) : Option[Extra2] = { + //TODO as I don't think I can use tail recursion, how do I do this iteratively? + if(!iter.hasNext) + return None + val elem : Extra2 = iter.next + Some(Extra2(elem.unk1, elem.unk2, unpackExtraList(iter))) + } + + implicit val codec : Codec[Extra1] = ( + ("size" | uint8L) >>:~ { sz => + //external logic: the client checks sz < dword_D33D38 before decoding beyond this point + conditional(sz != 0, "data" | Extra2.processData(sz)).hlist + } + ).xmap[Extra1] ( + { + case a :: None :: HNil => + Extra1(a, List.empty) //it's okay if a != 0 + case a :: b :: HNil => + val list = mutable.ListBuffer[Extra2]() + packExtraList(list, b) + Extra1(a, list.toList) + }, + { + case Extra1(a, Nil) => + a :: None :: HNil + case Extra1(a, b) => + a :: unpackExtraList(b.iterator) :: HNil + } + ) +} + +object Extra2 { + /** + * An abbreviated constructor for the one-value form. + * @param a na + * @return an `Extra2` object + */ + def apply(a : Int) : Extra2 = { + Extra2(a, None) + } + + /** + * An abbreviated constructor for the two-value form. + * @param a na + * @param b na + * @return an `Extra2` object + */ + def apply(a : Int, b : Int) : Extra2 = { + Extra2(a, Some(b)) + } + + /** + * A `Codec` for reading a single value. + */ + private val oneValueCodec : Codec[Extra2] = ("unk2" | uint8L).hlist.xmap[Extra2] ( + { + case a :: HNil => + Extra2(a, None, None) + }, + { + case Extra2(a, None, _) => + a :: HNil + } + ) + + /** + * A `Codec` for reading potentially two values. + */ + private val twoValueCodec : Codec[Extra2] = ( + ("unk1" | uint4L) >>:~ { unk => + conditional(unk == 15, "unk2" | uint8L).hlist + } + ).xmap[Extra2] ( + { + case a :: b :: HNil => + Extra2(a, b, None) + }, + { + case Extra2(a, b, _) => + a :: b :: HNil + } + ) + + /** + * Half of a recursive `Codec` that allows for swapping between different `Codec`s in between `List` elements.
+ *
+ * The function calls itself to process each element in the sequence of data in the same manner until complete. + * The `Extra2` object that is recovered from the first choice of `Codec`s is merely an intermediary object. + * Due to immutability, the initial object is repackaged to append the chain of `Extra2` in an `Extra2` object. + * Eventually, `processData` will parse a 4-bit value of 15 and will pass control over to `processDataSingle`. + * @param size the number of iterations of the looping process left to perform, including this one + * @return a `Codec` translating a chain of `Extra2` data + * @see Extra2.processDataSingle + */ + def processData(size : Int) : Codec[Extra2] = ( + //TODO: without tail recursion, this might cause a stack overflow + twoValueCodec >>:~ { elem => + conditional(size > 0, newcodecs.binary_choice(elem.unk2.isDefined, + processDataSingle(size - 1), + processData(size - 1)) + ).hlist + } + ).xmap[Extra2] ( + { + case a :: b :: HNil => + Extra2(a.unk1, a.unk2, b) + }, + { + case Extra2(a, b, c) => + Extra2(a, b) :: c :: HNil + } + ) + + /** + * Latter half of a recursive `Codec` that allows for swapping between different `Codec`s in between `List` elements. + * This `Codec` no longer performs swapping and merely runs out the data.
+ *
+ * @param size the number of iterations of the looping process left to perform, including this one + * @return a `Codec` translating a chain of `Extra2` data + * @see Extra2.processData + */ + private def processDataSingle(size : Int) : Codec[Extra2] = ( + //TODO: without tail recursion, this might cause a stack overflow + oneValueCodec >>:~ { elem => + conditional(size > 0, processDataSingle(size - 1)).hlist + } + ).xmap[Extra2] ( + { + case a :: b :: HNil => + Extra2(a.unk1, a.unk2, b) + }, + { + case Extra2(a, b, c) => + Extra2(a, b) :: c :: HNil + } + ) +} diff --git a/common/src/test/scala/GamePacketTest.scala b/common/src/test/scala/GamePacketTest.scala index 18f32d92..e9cda565 100644 --- a/common/src/test/scala/GamePacketTest.scala +++ b/common/src/test/scala/GamePacketTest.scala @@ -119,6 +119,114 @@ class GamePacketTest extends Specification { } } + "PlayerStateMessage" should { + val string_short = hex"08 A006 DFD17 B5AEB 380B 0F80002990" + val string_mod = hex"08 A006 DFD17 B5AEB 380B 0F80002985" //slightly modified from above to demonstrate active booleans + val string_vel = hex"08 A006 4DD47 CDB1B 0C0B A8C1A5000403008014A4" + + "decode (short)" in { + PacketCoding.DecodePacket(string_short).require match { + case PlayerStateMessage(guid, pos, vel, facingYaw, facingPitch, facingUpper, unk1, crouching, jumping, unk2, unk3) => + guid mustEqual PlanetSideGUID(1696) + pos.x mustEqual 4003.7422f + pos.y mustEqual 5981.414f + pos.z mustEqual 44.875f + vel.isDefined mustEqual false + facingYaw mustEqual 31 + facingPitch mustEqual 0 + facingUpper mustEqual 0 + unk1 mustEqual 83 + crouching mustEqual false + jumping mustEqual false + unk2 mustEqual false + unk3 mustEqual false + case default => + ko + } + } + + "decode (mod)" in { + PacketCoding.DecodePacket(string_mod).require match { + case PlayerStateMessage(guid, pos, vel, facingYaw, facingPitch, facingUpper, unk1, crouching, jumping, unk2, unk3) => + guid mustEqual PlanetSideGUID(1696) + pos.x mustEqual 4003.7422f + pos.y mustEqual 5981.414f + pos.z mustEqual 44.875f + vel.isDefined mustEqual false + facingYaw mustEqual 31 + facingPitch mustEqual 0 + facingUpper mustEqual 0 + unk1 mustEqual 83 + crouching mustEqual false + jumping mustEqual true + unk2 mustEqual false + unk3 mustEqual true + case default => + ko + } + } + + "decode (vel)" in { + PacketCoding.DecodePacket(string_vel).require match { + case PlayerStateMessage(guid, pos, vel, facingYaw, facingPitch, facingUpper, unk1, crouching, jumping, unk2, unk3) => + guid mustEqual PlanetSideGUID(1696) + pos.x mustEqual 4008.6016f + pos.y mustEqual 5987.6016f + pos.z mustEqual 44.1875f + vel.isDefined mustEqual true + vel.get.x mustEqual 2.53125f + vel.get.y mustEqual 6.5625f + vel.get.z mustEqual 0.0f + facingYaw mustEqual 24 + facingPitch mustEqual 4 + facingUpper mustEqual 0 + unk1 mustEqual 165 + crouching mustEqual false + jumping mustEqual false + unk2 mustEqual false + unk3 mustEqual false + case default => + ko + } + } + + "encode (short)" in { + val msg = PlayerStateMessage( + PlanetSideGUID(1696), + Vector3(4003.7422f, 5981.414f, 44.875f), + None, + 31, 0, 0, 83, + false, false, false, false) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_short + } + + "encode (mod)" in { + val msg = PlayerStateMessage( + PlanetSideGUID(1696), + Vector3(4003.7422f, 5981.414f, 44.875f), + None, + 31, 0, 0, 83, + false, true, false, true) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_mod + } + + "encode (vel)" in { + val msg = PlayerStateMessage( + PlanetSideGUID(1696), + Vector3(4008.6016f, 5987.6016f, 44.1875f), + Some(Vector3(2.53125f, 6.5625f, 0f)), + 24, 4, 0, 165, + false, false, false, false) + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + + pkt mustEqual string_vel + } + } + "ActionResultMessage" should { "decode" in { PacketCoding.DecodePacket(hex"1f 80").require match {