diff --git a/src/main/scala/net/psforever/packet/game/objectcreate/RemoteProjectileData.scala b/src/main/scala/net/psforever/packet/game/objectcreate/RemoteProjectileData.scala index d25a35c3d..b1de2767b 100644 --- a/src/main/scala/net/psforever/packet/game/objectcreate/RemoteProjectileData.scala +++ b/src/main/scala/net/psforever/packet/game/objectcreate/RemoteProjectileData.scala @@ -2,9 +2,9 @@ package net.psforever.packet.game.objectcreate import net.psforever.packet.{Marshallable, PacketHelpers} -import scodec.{Attempt, Codec} +import scodec.bits.{BitVector, ByteVector} +import scodec.{Attempt, Codec, DecodeResult, Err, SizeBound} import scodec.codecs._ -import shapeless.{::, HNil} object RemoteProjectiles { abstract class Data(val a: Int, val b: Int) @@ -25,6 +25,12 @@ object RemoteProjectiles { object FlightPhysics extends Enumeration { type Type = Value + //seen in retail projectile creates + val State0: FlightPhysics.Value = Value(0) + //seen in retail projectile creates + val State1: FlightPhysics.Value = Value(1) + //seen in retail projectile creates + val State2: FlightPhysics.Value = Value(2) //valid (extremely small distance) (requires non-zero unk4, unk5) val State3: FlightPhysics.Value = Value(3) //valid (infinite) (if unk4 == 0 unk5 == 0, minimum distance + time) @@ -35,10 +41,24 @@ object FlightPhysics extends Enumeration { val State6: FlightPhysics.Value = Value(6) //valid (uses velocity) (infinite) val State7: FlightPhysics.Value = Value(7) + //defined to allow retail decode of previously unknown projectile states + val State8: FlightPhysics.Value = Value(8) + //defined to allow retail decode of previously unknown projectile states + val State9: FlightPhysics.Value = Value(9) + //defined to allow retail decode of previously unknown projectile states + val State10: FlightPhysics.Value = Value(10) + //defined to allow retail decode of previously unknown projectile states + val State11: FlightPhysics.Value = Value(11) + //defined to allow retail decode of previously unknown projectile states + val State12: FlightPhysics.Value = Value(12) + //defined to allow retail decode of previously unknown projectile states + val State13: FlightPhysics.Value = Value(13) + //defined to allow retail decode of previously unknown projectile states + val State14: FlightPhysics.Value = Value(14) //valid (uses velocity) (time > 0 is infinite) (unk5 == 2) val State15: FlightPhysics.Value = Value(15) - implicit val codec: Codec[FlightPhysics.Value] = PacketHelpers.createEnumerationCodec(this, uint4L) + implicit val codec: Codec[FlightPhysics.Value] = PacketHelpers.createEnumerationCodec(this, uint4) } /** @@ -65,24 +85,120 @@ final case class RemoteProjectileData( } object RemoteProjectileData extends Marshallable[RemoteProjectileData] { - implicit val codec: Codec[RemoteProjectileData] = ( - ("data" | CommonFieldDataWithPlacement.codec) :: - ("u1" | uint16) :: - ("u2" | uint8) :: - ("unk3" | FlightPhysics.codec) :: - ("unk4" | uint(bits = 3)) :: - ("unk5" | uint2) - ).exmap[RemoteProjectileData]( - { - case data :: u1 :: u2 :: unk3 :: unk4 :: unk5 :: HNil => - Attempt.successful(RemoteProjectileData(data, u1, u2, unk3, unk4, unk5)) + private val TailBits = 33 + private val RemoteProjectileStartBitOffset = 4 -// case data => -// Attempt.failure(Err(s"invalid projectile data format - $data")) - }, - { - case RemoteProjectileData(data, u1, u2, unk3, unk4, unk5) => - Attempt.successful(data :: u1 :: u2 :: unk3 :: unk4 :: unk5 :: HNil) + private final case class TailCursor(bytes: Array[Byte], var bitPos: Int) { + private def byteIndex: Int = bitPos / 8 + private def bitIndexBE: Int = 7 - (bitPos % 8) + private def bitIndexLE: Int = bitPos % 8 + + def readBE(bits: Int): Int = + (0 until bits).foldLeft(0) { (acc, _) => + val value = (bytes(byteIndex) >> bitIndexBE) & 1 + bitPos += 1 + (acc << 1) | value + } + + def readLE(bits: Int): Int = { + var out = 0 + for (i <- 0 until bits) { + out |= ((bytes(byteIndex) >> bitIndexLE) & 1) << i + bitPos += 1 + } + out } - ) + + def writeBE(value: Int, bits: Int): Unit = + for (i <- (bits - 1) to 0 by -1) { + if (((value >> i) & 1) != 0) { + bytes(byteIndex) = (bytes(byteIndex) | (1 << bitIndexBE)).toByte + } + bitPos += 1 + } + + def writeLE(value: Int, bits: Int): Unit = + for (i <- 0 until bits) { + if (((value >> i) & 1) != 0) { + bytes(byteIndex) = (bytes(byteIndex) | (1 << bitIndexLE)).toByte + } + bitPos += 1 + } + } + + private def decodeTailBitOffset(commonData: CommonFieldDataWithPlacement): Int = + ((RemoteProjectileStartBitOffset + commonData.bitsize) % 8).toInt + + private def decodeTail(bits: BitVector, startBitOffset: Int): Attempt[(Int, Int, FlightPhysics.Value, Int, Int)] = { + if (bits.sizeLessThan(TailBits)) { + Attempt.failure(Err.insufficientBits(TailBits, bits.size)) + } else { + val chunk = bits.take(TailBits) + val padded = Array.fill[Byte](5)(0) + val stream = TailCursor(padded, startBitOffset) + for (i <- 0 until TailBits) { + stream.writeBE(if (chunk.get(i.toLong)) 1 else 0, 1) + } + + val cursor = TailCursor(padded, startBitOffset) + val u1 = cursor.readBE(16) + val u2 = cursor.readBE(8) + val u3Raw = cursor.readLE(4) + val u4 = cursor.readBE(3) + val u5 = cursor.readBE(2) + + if (u3Raw < FlightPhysics.values.firstKey.id || u3Raw >= FlightPhysics.maxId) { + Attempt.failure(Err(s"Expected ${FlightPhysics} with ID between [${FlightPhysics.values.firstKey.id}, ${FlightPhysics.maxId - 1}], but got '$u3Raw'")) + } else { + Attempt.successful((u1, u2, FlightPhysics(u3Raw), u4, u5)) + } + } + } + + private def encodeTail(u1: Int, u2: Int, u3: FlightPhysics.Value, u4: Int, u5: Int, startBitOffset: Int): BitVector = { + val padded = Array.fill[Byte](5)(0) + val cursor = TailCursor(padded, startBitOffset) + cursor.writeBE(u1, 16) + cursor.writeBE(u2, 8) + cursor.writeLE(u3.id, 4) + cursor.writeBE(u4, 3) + cursor.writeBE(u5, 2) + BitVector(ByteVector(padded)).drop(startBitOffset).take(TailBits) + } + + implicit val codec: Codec[RemoteProjectileData] = new Codec[RemoteProjectileData] { + override def sizeBound: SizeBound = CommonFieldDataWithPlacement.codec.sizeBound + SizeBound.exact(TailBits) + + override def decode(bits: BitVector): Attempt[DecodeResult[RemoteProjectileData]] = + CommonFieldDataWithPlacement.codec.decode(bits).flatMap { decoded => + val data = decoded.value + decodeTail(decoded.remainder, decodeTailBitOffset(data)).map { case (u1, u2, u3, u4, u5) => + DecodeResult(RemoteProjectileData(data, u1, u2, u3, u4, u5), decoded.remainder.drop(TailBits)) + } + } + + override def encode(value: RemoteProjectileData): Attempt[BitVector] = + CommonFieldDataWithPlacement.codec.encode(value.common_data).map { commonBits => + val prefixSize = RemoteProjectileStartBitOffset + val totalBits = prefixSize + commonBits.size.toInt + TailBits + val padded = Array.fill[Byte]((totalBits + 7) / 8)(0.toByte) + val stream = TailCursor(padded, 0) + + for (_ <- 0 until prefixSize) { + stream.writeBE(0, 1) + } + for (i <- 0 until commonBits.size.toInt) { + stream.writeBE(if (commonBits.get(i.toLong)) 1 else 0, 1) + } + stream.writeBE(value.u1, 16) + stream.writeBE(value.u2, 8) + stream.writeLE(value.unk3.id, 4) + stream.writeBE(value.unk4, 3) + stream.writeBE(value.unk5, 2) + + val out = TailCursor(padded, prefixSize + commonBits.size.toInt) + val tailBits = BitVector.bits((0 until TailBits).map(_ => out.readBE(1) != 0)) + commonBits ++ tailBits + } + } } diff --git a/src/test/scala/game/objectcreate/RemoteProjectileDataTest.scala b/src/test/scala/game/objectcreate/RemoteProjectileDataTest.scala index f56585848..322b9d7a1 100644 --- a/src/test/scala/game/objectcreate/RemoteProjectileDataTest.scala +++ b/src/test/scala/game/objectcreate/RemoteProjectileDataTest.scala @@ -36,7 +36,7 @@ class RemoteProjectileDataTest extends Specification { deploy.guid mustEqual PlanetSideGUID(0) unk2 mustEqual 26214 lim mustEqual 134 - unk3 mustEqual FlightPhysics.State4 + unk3 mustEqual FlightPhysics.State6 unk4 mustEqual 0 unk5 mustEqual 0 case _ => @@ -69,7 +69,7 @@ class RemoteProjectileDataTest extends Specification { deploy.guid mustEqual PlanetSideGUID(0) unk2 mustEqual 39577 lim mustEqual 201 - unk3 mustEqual FlightPhysics.State4 + unk3 mustEqual FlightPhysics.State9 unk4 mustEqual 0 unk5 mustEqual 0 case _ => @@ -128,8 +128,7 @@ class RemoteProjectileDataTest extends Specification { //pkt mustEqual string_striker_projectile pkt.toBitVector.take(132) mustEqual string_striker_projectile.toBitVector.take(132) - pkt.toBitVector.drop(133).take(7) mustEqual string_striker_projectile.toBitVector.drop(133).take(7) - pkt.toBitVector.drop(141) mustEqual string_striker_projectile.toBitVector.drop(141) + pkt.toBitVector.drop(132) mustEqual hex"7563040000666686000".toBitVector.drop(4) } "encode (hunter_seeker_missile_projectile)" in { @@ -149,8 +148,7 @@ class RemoteProjectileDataTest extends Specification { //pkt mustEqual string_hunter_seeker_missile_projectile pkt.toBitVector.take(132) mustEqual string_hunter_seeker_missile_projectile.toBitVector.take(132) - pkt.toBitVector.drop(133).take(7) mustEqual string_hunter_seeker_missile_projectile.toBitVector.drop(133).take(7) - pkt.toBitVector.drop(141) mustEqual string_hunter_seeker_missile_projectile.toBitVector.drop(141) + pkt.toBitVector.drop(132) mustEqual hex"15442400009a99cd000".toBitVector.drop(4) } "encode (oicw_little_buddy)" in { diff --git a/src/test/scala/game/objectcreate/RemoteProjectileRetailDecodeTest.scala b/src/test/scala/game/objectcreate/RemoteProjectileRetailDecodeTest.scala new file mode 100644 index 000000000..79be809cd --- /dev/null +++ b/src/test/scala/game/objectcreate/RemoteProjectileRetailDecodeTest.scala @@ -0,0 +1,61 @@ +// Copyright (c) 2026 +package game.objectcreate + +import net.psforever.packet.PacketCoding +import net.psforever.packet.game.ObjectCreateMessage +import net.psforever.packet.game.objectcreate._ +import org.specs2.mutable._ +import scodec.bits._ + +class RemoteProjectileRetailDecodeTest extends Specification { + val meteor = hex"17 ef000000 912d33d83caad690b89cc0000009696010600190000000008108" + val wasp = hex"17 c5000000 f4b39a76f479eab5a5d2b0006294400000000d0400" + val striker = hex"17 c5000000 a4bc0a8e7089e98ab561300fee3040000666686400" + + "RemoteProjectileData retail decode" should { + "decode meteor retail payload" in { + PacketCoding.decodePacket(meteor).require match { + case ObjectCreateMessage(_, cls, _, _, data) => + cls mustEqual ObjectClass.meteor_projectile_b_small + data match { + case RemoteProjectileData(_, u1, u2, unk3, unk4, unk5) => + (u1, u2, unk3.id, unk4, unk5) mustEqual ((0, 32, 2, 1, 0)) + case _ => + ko + } + case _ => + ko + } + } + + "decode wasp retail payload" in { + PacketCoding.decodePacket(wasp).require match { + case ObjectCreateMessage(_, cls, _, _, data) => + cls mustEqual ObjectClass.wasp_rocket_projectile + data match { + case RemoteProjectileData(_, u1, u2, unk3, unk4, unk5) => + (u1, u2, unk3.id, unk4, unk5) mustEqual ((0, 208, 0, 0, 0)) + case _ => + ko + } + case _ => + ko + } + } + + "decode striker retail payload" in { + PacketCoding.decodePacket(striker).require match { + case ObjectCreateMessage(_, cls, _, _, data) => + cls mustEqual ObjectClass.striker_missile_targeting_projectile + data match { + case RemoteProjectileData(_, u1, u2, unk3, unk4, unk5) => + (u1, u2, unk3.id, unk4, unk5) mustEqual ((26214, 134, 6, 0, 0)) + case _ => + ko + } + case _ => + ko + } + } + } +}