diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index 1cd058402..0831790b7 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -22,7 +22,7 @@ import net.psforever.objects.vehicles.control.{CargoBehavior, CarrierBehavior} import net.psforever.objects.vital.{InGameHistory, IncarnationActivity, ReconstructionActivity, SpawningActivity} import net.psforever.objects.zones.blockmap.BlockMapEntity import net.psforever.packet.game.packets.GenericAction.FirstPersonViewWithEffect -import net.psforever.packet.game.packets.{CampaignStatistic, ChangeFireStateMessage_Start, CloudInfo, CreateShortcutMessage, DeployableInfo, Friend, GenericActionMessage, GenericObjectActionEnum, HackState7, MailMessage, ObjectDetectedMessage, SessionStatistic, StormInfo, TrainingZoneMessage, TriggeredSound, WeatherMessage} +import net.psforever.packet.game.packets.{CampaignStatistic, ChangeFireStateMessage_Start, CloudInfo, CreateShortcutMessage, DeployableInfo, Friend, GenericActionMessage, GenericObjectActionEnum, HackState7, MailMessage, ModuleLimitsMessage, ObjectDetectedMessage, SessionStatistic, StormInfo, TrainingZoneMessage, TriggeredSound, VanuModuleUpdateMessage, WeatherMessage} import net.psforever.services.avatar.support.{CorpseEnvelope, ReleaseEnvelope} import net.psforever.services.base.envelope.{BundledEnvelope, MessageEnvelope} import net.psforever.services.base.message.{GenericObjectAction, ObjectDelete, PlanetsideAttribute, SendResponse} @@ -623,8 +623,8 @@ class ZoningOperations( else sendResponse(ContinentalLockUpdateMessage(continentNumber, zone.lockedBy)) //CaptureFlagUpdateMessage() - //VanuModuleUpdateMessage() - //ModuleLimitsMessage() + sendResponse(VanuModuleUpdateMessage(continentNumber, Nil)) + sendResponse(ModuleLimitsMessage(255, 15, 3)) val isCavern = zone.map.cavern if (!isCavern) { sendResponse(ZoneInfoMessage(continentNumber, empire_status = true, 0L)) diff --git a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index f714c1f30..846460ff0 100644 --- a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -538,7 +538,7 @@ object GamePacketOpcode extends Enumeration { // OPCODES 0xc0-cf case 0xc0 => packets.CaptureFlagUpdateMessage.decode - case 0xc1 => noDecoder(VanuModuleUpdateMessage) + case 0xc1 => packets.VanuModuleUpdateMessage.decode case 0xc2 => packets.FacilityBenefitShieldChargeRequestMessage.decode case 0xc3 => packets.ProximityTerminalUseMessage.decode case 0xc4 => packets.QuantityDeltaUpdateMessage.decode @@ -547,7 +547,7 @@ object GamePacketOpcode extends Enumeration { case 0xc7 => packets.LongRangeProjectileInfoMessage.decode // 0xc8 case 0xc8 => packets.WeaponLazeTargetPositionMessage.decode - case 0xc9 => noDecoder(ModuleLimitsMessage) + case 0xc9 => packets.ModuleLimitsMessage.decode case 0xca => noDecoder(OutfitBenefitMessage) case 0xcb => noDecoder(EmpireChangeTimeMessage) case 0xcc => noDecoder(ClockCalibrationMessage) diff --git a/src/main/scala/net/psforever/packet/game/packets/ModuleLimitsMessage.scala b/src/main/scala/net/psforever/packet/game/packets/ModuleLimitsMessage.scala new file mode 100644 index 000000000..f8251d68a --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/packets/ModuleLimitsMessage.scala @@ -0,0 +1,32 @@ +// Copyright (c) 2026 PSForever +package net.psforever.packet.game.packets + +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket} +import scodec.{Attempt, Codec} +import scodec.bits.BitVector +import scodec.codecs._ + +/** + * na + * @param u1 na + * @param u2 na + * @param u3 na + */ +final case class ModuleLimitsMessage( + u1: Int, + u2: Int, + u3: Int + ) extends PlanetSideGamePacket { + type Packet = ModuleLimitsMessage + def opcode: Type = GamePacketOpcode.ModuleLimitsMessage + def encode: Attempt[BitVector] = ModuleLimitsMessage.encode(this) +} + +object ModuleLimitsMessage extends Marshallable[ModuleLimitsMessage] { + implicit val codec: Codec[ModuleLimitsMessage] = ( + ("u1" | uint8) :: + ("u2" | uint(bits = 6)) :: + ("u3" | uint(bits = 3)) + ).as[ModuleLimitsMessage] +} diff --git a/src/main/scala/net/psforever/packet/game/packets/VanuModuleUpdateMessage.scala b/src/main/scala/net/psforever/packet/game/packets/VanuModuleUpdateMessage.scala new file mode 100644 index 000000000..a1e63a499 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/packets/VanuModuleUpdateMessage.scala @@ -0,0 +1,116 @@ +// Copyright (c) 2026 PSForever +package net.psforever.packet.game.packets + +import enumeratum.values.{IntEnum, IntEnumEntry} +import net.psforever.newcodecs.newcodecs +import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} +import net.psforever.packet.GamePacketOpcode.Type +import net.psforever.types.Vector3 +import scodec.{Attempt, Codec} +import scodec.bits.BitVector +import scodec.codecs._ +import shapeless.{::, HNil} + +sealed abstract class ModuleIcon(val value: Int) extends IntEnumEntry + +object ModuleIcon extends IntEnum[ModuleIcon] { + val values: IndexedSeq[ModuleIcon] = findValues + + case object NonPowered extends ModuleIcon(value = 1) + case object Speed extends ModuleIcon(value = 2) + case object Defender extends ModuleIcon(value = 3) + case object Vehicle extends ModuleIcon(value = 4) + case object Weapon extends ModuleIcon(value = 5) + case object Healing extends ModuleIcon(value = 6) + case object Pain extends ModuleIcon(value = 7) + + case object TR extends ModuleIcon(value = 10) + case object NC extends ModuleIcon(value = 11) + case object VS extends ModuleIcon(value = 12) + + implicit val codec: Codec[ModuleIcon] = PacketHelpers.createIntEnumCodec(ModuleIcon, uint4) +} + +/** + * Whether the vanu module icon on the continental map alternates red or green in between displays of it's distinguishing color. + * There are four values - 0 to 3 - but 3 is the only green color and all of the other values result in red. + * @param value charge state as an integer value + */ +sealed abstract class ModuleIconPulseColor(val value: Int) extends IntEnumEntry + +object ModuleIconPulseColor extends IntEnum[ModuleIconPulseColor] { + val values: IndexedSeq[ModuleIconPulseColor] = findValues + + /** Red module */ + case object Red extends ModuleIconPulseColor(value = 0) + /** Green module */ + case object Green extends ModuleIconPulseColor(value = 3) + + implicit val codec: Codec[ModuleIconPulseColor] = uint2.xmap( + { + case 3 => Green + case _ => Red + }, + state => state.value + ) +} + +/** + * na + * When `u4 = true` and `u5 = false`, the icons do not display on the continental map. + * All other value combinations display correctly, though there's no apparent distinction between those combinations. + * @param module_type na + * @param pulse na + * @param u3 na + * @param u4 na + * @param u5 na + * @param pos_xy na + */ +final case class ModuleInfo(module_type: ModuleIcon, pulse: ModuleIconPulseColor, u3: Long, u4: Boolean, u5: Boolean, pos_xy: Vector3) + +object ModuleInfo { + def apply(module_type: ModuleIcon, pulseGreen: Boolean, u3: Long, pos_xy: Vector3): ModuleInfo = { + val intCharge: ModuleIconPulseColor = if (pulseGreen) ModuleIconPulseColor.Green else ModuleIconPulseColor.Red + new ModuleInfo(module_type, intCharge, u3, u4 = false, u5 = false, pos_xy) + } +} + +/** + * na + * @param zone_number zone's number + * @param info list of module information for this zone + */ +case class VanuModuleUpdateMessage( + zone_number: Int, + info: List[ModuleInfo] + ) extends PlanetSideGamePacket { + type Packet = VanuModuleUpdateMessage + def opcode: Type = GamePacketOpcode.VanuModuleUpdateMessage + def encode: Attempt[BitVector] = VanuModuleUpdateMessage.encode(this) +} + +object VanuModuleUpdateMessage extends Marshallable[VanuModuleUpdateMessage] { + private val moduleInfoCodec: Codec[ModuleInfo] = ( + ("module_type" | ModuleIcon.codec) :: + ("pulse" | ModuleIconPulseColor.codec) :: + ("u3" | uint32L) :: + ("u4" | bool) :: + ("u5" | bool) :: + newcodecs.q_float(min = 0.0, max = 8192.0, bits = 20) :: + newcodecs.q_float(min = 0.0, max = 8192.0, bits = 20) + ).xmap[ModuleInfo]( + { + case module_type :: pulse :: u3 :: u4 :: u5 :: x :: y :: HNil => + ModuleInfo(module_type, pulse, u3, u4, u5, Vector3(x, y, 0f)) + }, + { + case ModuleInfo(module_type, pulse, u3, u4, u5, Vector3(x, y, _)) => + module_type :: pulse :: u3 :: u4 :: u5 :: x :: y :: HNil + } + ) + + implicit val codec: Codec[VanuModuleUpdateMessage] = ( + ("zone_number" | uint16L) :: + ("info" | listOfN(uint(bits = 6), moduleInfoCodec)) + ).as[VanuModuleUpdateMessage] +} diff --git a/src/test/scala/game/MailMessageTest.scala b/src/test/scala/game/MailMessageTest.scala index 4d1e62d41..da4a8d800 100644 --- a/src/test/scala/game/MailMessageTest.scala +++ b/src/test/scala/game/MailMessageTest.scala @@ -3,7 +3,6 @@ package game import org.specs2.mutable._ import net.psforever.packet._ -import net.psforever.packet.game.packets._ import net.psforever.packet.game.packets.MailMessage import scodec.bits._ diff --git a/src/test/scala/game/ModuleLimitsMessageTest.scala b/src/test/scala/game/ModuleLimitsMessageTest.scala new file mode 100644 index 000000000..f01e9089d --- /dev/null +++ b/src/test/scala/game/ModuleLimitsMessageTest.scala @@ -0,0 +1,29 @@ +// Copyright (c) 2026 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game.packets.ModuleLimitsMessage +import scodec.bits._ +import org.specs2.mutable.Specification + +class ModuleLimitsMessageTest extends Specification { + val string = hex"C9 FF3D80" + + "decode" in { + PacketCoding.decodePacket(string).require match { + case ModuleLimitsMessage(u1, u2, u3) => + u1 mustEqual 255 + u2 mustEqual 15 + u3 mustEqual 3 + case _ => + ko + } + } + + "encode" in { + val msg = ModuleLimitsMessage(255, 15, 3) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual string + } +} diff --git a/src/test/scala/game/VanuModuleUpdateMessageTest.scala b/src/test/scala/game/VanuModuleUpdateMessageTest.scala new file mode 100644 index 000000000..c91ca5fd6 --- /dev/null +++ b/src/test/scala/game/VanuModuleUpdateMessageTest.scala @@ -0,0 +1,58 @@ +// Copyright (c) 2026 PSForever +package game + +import net.psforever.packet._ +import net.psforever.packet.game.packets.{ModuleIcon, ModuleIconPulseColor, ModuleInfo, VanuModuleUpdateMessage} +import net.psforever.types.Vector3 +import scodec.bits._ +import org.specs2.mutable.Specification + +class VanuModuleUpdateMessageTest extends Specification { + val string = hex"C1 020000" + val string_info = hex"c1 0200087000000000000100001140000000000006000008" //manufactured, not from packet captures + + "decode" in { + PacketCoding.decodePacket(string).require match { + case VanuModuleUpdateMessage(zone_number, info) => + zone_number mustEqual 2 + info.isEmpty mustEqual true + case _ => + ko + } + } + + "decode (info)" in { + PacketCoding.decodePacket(string_info).require match { + case VanuModuleUpdateMessage(zone_number, info) => + zone_number mustEqual 2 + info.size mustEqual 2 + info.head match { + case ModuleInfo(ModuleIcon.NonPowered, ModuleIconPulseColor.Green, 0L, false, false, Vector3(2048, 2048, 0)) => ok + case _ => ko + } + info(1) match { + case ModuleInfo(ModuleIcon.Weapon, ModuleIconPulseColor.Red, 0L, false, false, Vector3(4098, 1024, 0)) => ok + case _ => ko + } + case _ => + ko + } + } + + "encode" in { + val msg = VanuModuleUpdateMessage(2, Nil) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual string + } + + "encode (info)" in { + val msg = VanuModuleUpdateMessage(2, List( + ModuleInfo(ModuleIcon.NonPowered, pulseGreen = true, u3 = 0L, Vector3(2048, 2048, 0)), + ModuleInfo(ModuleIcon.Weapon, pulseGreen = false, u3 = 0L, Vector3(4098, 1024, 0)) + )) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual string_info + } +} diff --git a/src/test/scala/objects/ZoneTest.scala b/src/test/scala/objects/ZoneTest.scala index 5e4a8a506..d3771dcb6 100644 --- a/src/test/scala/objects/ZoneTest.scala +++ b/src/test/scala/objects/ZoneTest.scala @@ -248,7 +248,7 @@ class ZonePopulationTest extends ActorTest { assert(zone.Players.size == 1) assert(zone.Players.head == avatar) - zone.Population ! Zone.Population.Leave(avatar, _) + zone.Population ! Zone.Population.Leave(avatar, null) val reply = receiveOne(Duration.create(100, "ms")) assert(reply.isInstanceOf[Zone.Population.PlayerHasLeft]) assert(zone.Players.isEmpty)