Fixed RemoteProjectileData codec

It switches endianness mid-byte, which scodec can't handle.
This is awkward, but it works.
This commit is contained in:
Jakob Gillich 2026-04-10 04:25:09 +02:00
parent 697547da25
commit 45f2fa41c9
3 changed files with 202 additions and 27 deletions

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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
}
}
}
}