//! `NmxItemControlMessage` — NMX item-control body (advise / unadvise). //! //! Direct port of `src/MxNativeCodec/NmxItemControlMessage.cs`. The body //! carries an advise-supervisory or unadvise command together with a 16-byte //! item correlation GUID and a 14-byte projection of an [`crate::reference_handle::MxReferenceHandle`] //! (handle bytes 6..20 — `object_id` through `attribute_index`). //! //! ## Wire layout //! //! Per `NmxItemControlMessage.cs:24-36, 63-81, 121-142`: //! //! ```text //! offset size field notes //! 0 1 command (u8) 0x1f AdviseSupervisory, 0x21 UnAdvise //! 1 2 version (u16 LE) must be 1 //! 3 16 item_correlation_id (GUID) .NET layout (mixed-endian) //! 19 2 advise extra (u16 LE) ONLY when command == AdviseSupervisory //! [+2 if advise] //! 19/21 2 object_id (u16 LE) //! +2 2 object_signature (u16 LE) //! +4 2 primitive_id (i16 LE) //! +6 2 attribute_id (i16 LE) //! +8 2 property_id (i16 LE) //! +10 2 attribute_signature (u16 LE) //! +12 2 attribute_index (i16 LE) //! +14 4 tail (u32 LE) default 3 per cs:88 //! ``` //! //! Total: 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise. //! //! ## Opcode invariant //! //! `Advise` and `AdviseSupervisory` share opcode `0x1f` in the .NET enum //! (`NmxItemControlMessage.cs:7-8`). The parser explicitly rejects anything //! that is not `AdviseSupervisory` or `UnAdvise` (`cs:46-49`); there is no //! 37-byte plain-Advise wire shape. The Rust port mirrors this: the public //! command type only exposes `AdviseSupervisory` and `UnAdvise`. // Direct byte indexing — see reference_handle.rs for rationale. Every read or // write is preceded by an explicit length check that mirrors the .NET source's // `ReadOnlySpan` slicing, so the resulting code reads as a 1:1 mirror of // `BinaryPrimitives` calls. #![allow(clippy::indexing_slicing)] use crate::error::CodecError; /// NMX item-control command opcode. /// /// In the .NET reference this is a `byte` enum where `Advise` and /// `AdviseSupervisory` are aliases for `0x1f` (`NmxItemControlMessage.cs:5-10`). /// Only `AdviseSupervisory` and `UnAdvise` are accepted on the wire /// (`cs:46-49`), so the Rust enum collapses the alias and exposes just those /// two variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] pub enum NmxItemControlCommand { /// `0x1f`. Per `NmxItemControlMessage.cs:8`. AdviseSupervisory = 0x1f, /// `0x21`. Per `NmxItemControlMessage.cs:9`. UnAdvise = 0x21, } impl NmxItemControlCommand { /// Map a wire byte to a command, mirroring the parser check at /// `NmxItemControlMessage.cs:45-49`. fn from_u8(value: u8) -> Result { match value { 0x1f => Ok(Self::AdviseSupervisory), 0x21 => Ok(Self::UnAdvise), other => Err(CodecError::UnexpectedOpcode(other)), } } fn to_u8(self) -> u8 { self as u8 } } /// Wire-format constants from `NmxItemControlMessage.cs:24-28`. const VERSION: u16 = 1; const HEADER_LENGTH: usize = 3; // cmd(1) + version u16(2) const GUID_LENGTH: usize = 16; // cs:26 const ADVISE_EXTRA_LENGTH: usize = 2; // cs:27 const PAYLOAD_LENGTH: usize = 18; // cs:28 — 7×u16 + u32 tail = 18 bytes /// Default tail value used by `FromReferenceHandle` (`NmxItemControlMessage.cs:88`). pub const DEFAULT_TAIL: u32 = 3; /// Decoded NMX item-control body. The fields after `item_correlation_id` /// project bytes 6..20 of an [`crate::reference_handle::MxReferenceHandle`] — see /// `NmxItemControlMessage.cs:71-81, 134-141`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NmxItemControlMessage { pub command: NmxItemControlCommand, /// 16-byte GUID. Stored as raw bytes in .NET-`Guid` layout (the layout /// produced by `Guid.TryWriteBytes` and consumed by `new Guid(span)` — /// mixed-endian: little-endian `Data1`/`Data2`/`Data3`, big-endian /// `Data4`/`Data4Tail`). The Rust port stays at the byte level so the /// .NET-shape round-trips exactly. See `NmxItemControlMessage.cs:64, 127`. pub item_correlation_id: [u8; GUID_LENGTH], pub object_id: u16, pub object_signature: u16, pub primitive_id: i16, pub attribute_id: i16, pub property_id: i16, pub attribute_signature: u16, pub attribute_index: i16, /// Trailing u32. Default `3` per `NmxItemControlMessage.cs:88`. pub tail: u32, } impl NmxItemControlMessage { /// Encoded length for a given command. Matches /// `NmxItemControlMessage.GetEncodedLength` (`cs:30-36`): /// 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise. #[must_use] pub fn encoded_length(command: NmxItemControlCommand) -> usize { HEADER_LENGTH + GUID_LENGTH + match command { NmxItemControlCommand::AdviseSupervisory => ADVISE_EXTRA_LENGTH, NmxItemControlCommand::UnAdvise => 0, } + PAYLOAD_LENGTH } /// Construct a message from the bytes 6..20 of a reference handle. /// Mirrors `NmxItemControlMessage.FromReferenceHandle` (`cs:84-101`). /// /// `tail` defaults to [`DEFAULT_TAIL`] (`3`) per `cs:88`. #[must_use] #[allow(clippy::too_many_arguments)] pub fn from_reference_handle_fields( command: NmxItemControlCommand, item_correlation_id: [u8; GUID_LENGTH], object_id: u16, object_signature: u16, primitive_id: i16, attribute_id: i16, property_id: i16, attribute_signature: u16, attribute_index: i16, tail: u32, ) -> Self { Self { command, item_correlation_id, object_id, object_signature, primitive_id, attribute_id, property_id, attribute_signature, attribute_index, tail, } } /// Return a copy with `command = UnAdvise`. Mirrors `ToUnAdvise` /// (`NmxItemControlMessage.cs:145-148`). #[must_use] pub fn to_un_advise(self) -> Self { Self { command: NmxItemControlCommand::UnAdvise, ..self } } /// Return a copy with `command = AdviseSupervisory`. Mirrors /// `ToAdviseSupervisory` (`NmxItemControlMessage.cs:150-153`). #[must_use] pub fn to_advise_supervisory(self) -> Self { Self { command: NmxItemControlCommand::AdviseSupervisory, ..self } } /// Parse an item-control body. Mirrors `NmxItemControlMessage.Parse` /// (`cs:38-82`). /// /// # Errors /// /// - [`CodecError::ShortRead`] if the buffer is shorter than the /// minimum 37-byte UnAdvise body (`cs:40-43`). /// - [`CodecError::UnexpectedOpcode`] if the leading command byte is /// neither `0x1f` (AdviseSupervisory) nor `0x21` (UnAdvise) (`cs:45-49`). /// This is also what blocks the alias plain-`Advise` from being /// accepted on the wire. /// - [`CodecError::UnsupportedVersion`] if the version word is not 1 /// (`cs:51-55`). /// - [`CodecError::Decode`] if the buffer length does not match the /// per-command expected length (`cs:57-61`). pub fn parse(body: &[u8]) -> Result { // Minimum length is the UnAdvise body (no advise-extra). Mirrors cs:40. let min_len = HEADER_LENGTH + GUID_LENGTH + PAYLOAD_LENGTH; if body.len() < min_len { return Err(CodecError::ShortRead { expected: min_len, actual: body.len(), }); } let command = NmxItemControlCommand::from_u8(body[0])?; let version = read_u16_le(body, 1); if version != VERSION { return Err(CodecError::UnsupportedVersion { expected: VERSION, actual: version, }); } let expected_length = Self::encoded_length(command); if body.len() != expected_length { return Err(CodecError::Decode { offset: 0, reason: "unexpected item-control body length", buffer_len: body.len(), }); } let mut offset = HEADER_LENGTH; let mut item_correlation_id = [0u8; GUID_LENGTH]; item_correlation_id.copy_from_slice(&body[offset..offset + GUID_LENGTH]); offset += GUID_LENGTH; if command == NmxItemControlCommand::AdviseSupervisory { // Skip the 2-byte advise-extra word (cs:66-69). The .NET parser // does not retain it; the Rust port mirrors that drop on parse // and writes zeros on encode. offset += ADVISE_EXTRA_LENGTH; } Ok(Self { command, item_correlation_id, object_id: read_u16_le(body, offset), object_signature: read_u16_le(body, offset + 2), primitive_id: read_i16_le(body, offset + 4), attribute_id: read_i16_le(body, offset + 6), property_id: read_i16_le(body, offset + 8), attribute_signature: read_u16_le(body, offset + 10), attribute_index: read_i16_le(body, offset + 12), tail: read_u32_le(body, offset + 14), }) } /// Encode to a freshly allocated `Vec`. Mirrors /// `NmxItemControlMessage.Encode` (`cs:121-143`). #[must_use] pub fn encode(&self) -> Vec { let mut body = vec![0u8; Self::encoded_length(self.command)]; body[0] = self.command.to_u8(); write_u16_le(&mut body, 1, VERSION); let mut offset = HEADER_LENGTH; body[offset..offset + GUID_LENGTH].copy_from_slice(&self.item_correlation_id); offset += GUID_LENGTH; if self.command == NmxItemControlCommand::AdviseSupervisory { // Two zero bytes per cs:129-132 — the .NET source advances the // offset over already-zeroed buffer space. offset += ADVISE_EXTRA_LENGTH; } write_u16_le(&mut body, offset, self.object_id); write_u16_le(&mut body, offset + 2, self.object_signature); write_i16_le(&mut body, offset + 4, self.primitive_id); write_i16_le(&mut body, offset + 6, self.attribute_id); write_i16_le(&mut body, offset + 8, self.property_id); write_u16_le(&mut body, offset + 10, self.attribute_signature); write_i16_le(&mut body, offset + 12, self.attribute_index); write_u32_le(&mut body, offset + 14, self.tail); body } } #[inline] fn read_u16_le(bytes: &[u8], offset: usize) -> u16 { u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } #[inline] fn read_i16_le(bytes: &[u8], offset: usize) -> i16 { i16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } #[inline] fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { u32::from_le_bytes([ bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], ]) } #[inline] fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) { let le = value.to_le_bytes(); bytes[offset..offset + 2].copy_from_slice(&le); } #[inline] fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) { let le = value.to_le_bytes(); bytes[offset..offset + 2].copy_from_slice(&le); } #[inline] fn write_u32_le(bytes: &mut [u8], offset: usize, value: u32) { let le = value.to_le_bytes(); bytes[offset..offset + 4].copy_from_slice(&le); } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] mod tests { use super::*; fn sample(command: NmxItemControlCommand) -> NmxItemControlMessage { NmxItemControlMessage { command, item_correlation_id: [0x11; GUID_LENGTH], object_id: 0x1234, object_signature: 0xABCD, primitive_id: -1, attribute_id: 7, property_id: 0, attribute_signature: 0xBEEF, attribute_index: -1, tail: DEFAULT_TAIL, } } #[test] fn encoded_length_matches_dotnet() { // cs:30-36: AdviseSupervisory = 3+16+2+18 = 39, UnAdvise = 3+16+18 = 37. assert_eq!( NmxItemControlMessage::encoded_length(NmxItemControlCommand::AdviseSupervisory), 39 ); assert_eq!( NmxItemControlMessage::encoded_length(NmxItemControlCommand::UnAdvise), 37 ); } #[test] fn round_trip_advise_supervisory() { let msg = sample(NmxItemControlCommand::AdviseSupervisory); let encoded = msg.encode(); assert_eq!(encoded.len(), 39); let decoded = NmxItemControlMessage::parse(&encoded).unwrap(); assert_eq!(msg, decoded); } #[test] fn round_trip_un_advise() { let msg = sample(NmxItemControlCommand::UnAdvise); let encoded = msg.encode(); assert_eq!(encoded.len(), 37); let decoded = NmxItemControlMessage::parse(&encoded).unwrap(); assert_eq!(msg, decoded); } #[test] fn parse_rejects_plain_advise_alias() { // The .NET enum aliases `Advise = 0x1f = AdviseSupervisory`, so on // the wire the leading byte 0x1f always means AdviseSupervisory. // There is *no* 37-byte plain-Advise shape; a 37-byte body that // starts with 0x1f must be rejected because the per-command length // check (cs:57-61) demands 39 bytes for 0x1f. let mut bogus = vec![0u8; 37]; bogus[0] = 0x1f; write_u16_le(&mut bogus, 1, VERSION); let err = NmxItemControlMessage::parse(&bogus).unwrap_err(); assert!( matches!(err, CodecError::Decode { .. }), "expected length-mismatch decode error, got {err:?}" ); } #[test] fn parse_rejects_unknown_command_opcode() { // Leading byte that is neither 0x1f nor 0x21 — cs:46-49. let mut bogus = vec![0u8; 37]; bogus[0] = 0x42; write_u16_le(&mut bogus, 1, VERSION); let err = NmxItemControlMessage::parse(&bogus).unwrap_err(); assert!(matches!(err, CodecError::UnexpectedOpcode(0x42))); } #[test] fn parse_rejects_wrong_length_buffer_advise() { // 39 bytes is right for AdviseSupervisory; 38 is wrong. let msg = sample(NmxItemControlCommand::AdviseSupervisory); let mut encoded = msg.encode(); encoded.pop(); let err = NmxItemControlMessage::parse(&encoded).unwrap_err(); // 38 bytes still passes the 37-byte minimum, so we hit the // per-command length mismatch (cs:57-61) — Decode. assert!(matches!(err, CodecError::Decode { .. })); } #[test] fn parse_rejects_wrong_length_buffer_un_advise() { // 36 bytes is below the 37-byte UnAdvise minimum (cs:40). let err = NmxItemControlMessage::parse(&[0u8; 36]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } #[test] fn parse_rejects_oversized_un_advise() { // 38 bytes with leading 0x21 — UnAdvise demands exactly 37. let mut bogus = vec![0u8; 38]; bogus[0] = 0x21; write_u16_le(&mut bogus, 1, VERSION); let err = NmxItemControlMessage::parse(&bogus).unwrap_err(); assert!(matches!(err, CodecError::Decode { .. })); } #[test] fn parse_rejects_wrong_version() { let msg = sample(NmxItemControlCommand::UnAdvise); let mut encoded = msg.encode(); write_u16_le(&mut encoded, 1, 2); let err = NmxItemControlMessage::parse(&encoded).unwrap_err(); assert!(matches!(err, CodecError::UnsupportedVersion { .. })); } #[test] fn guid_round_trips_byte_identical() { // Use a distinctive byte pattern so a re-shuffle would be obvious. let guid: [u8; 16] = [ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, ]; let msg = NmxItemControlMessage { item_correlation_id: guid, ..sample(NmxItemControlCommand::AdviseSupervisory) }; let encoded = msg.encode(); // GUID lives at offset 3..19 (after cmd+version) per cs:63-65. assert_eq!(&encoded[3..19], &guid); let decoded = NmxItemControlMessage::parse(&encoded).unwrap(); assert_eq!(decoded.item_correlation_id, guid); } #[test] fn guid_round_trips_known_pattern() { // The brief calls out `[0x11; 16]` as a sanity vector. let guid = [0x11u8; 16]; let msg = NmxItemControlMessage { item_correlation_id: guid, ..sample(NmxItemControlCommand::UnAdvise) }; let encoded = msg.encode(); assert_eq!(&encoded[3..19], &guid); let decoded = NmxItemControlMessage::parse(&encoded).unwrap(); assert_eq!(decoded.item_correlation_id, [0x11; 16]); } #[test] fn default_tail_is_three() { // cs:88 — `uint tail = 3`. assert_eq!(DEFAULT_TAIL, 3); let msg = NmxItemControlMessage::from_reference_handle_fields( NmxItemControlCommand::AdviseSupervisory, [0x11; 16], 1, 2, 3, 4, 5, 6, 7, DEFAULT_TAIL, ); let encoded = msg.encode(); // Tail u32 lives at the last 4 bytes of the body. let n = encoded.len(); assert_eq!(&encoded[n - 4..], &3u32.to_le_bytes()); } #[test] fn advise_supervisory_extra_bytes_are_zero_on_encode() { // cs:129-132: the 2-byte advise-extra word at offset 19..21 is // skipped (left as zero in the freshly-allocated buffer). let msg = sample(NmxItemControlCommand::AdviseSupervisory); let encoded = msg.encode(); assert_eq!(&encoded[19..21], &[0x00, 0x00]); } #[test] fn handle_projection_offsets_advise_supervisory() { // For AdviseSupervisory the handle projection starts at offset 21 // (3 + 16 + 2). Verify object_id 0x1234 lands as 34 12 there. let msg = NmxItemControlMessage { object_id: 0x1234, ..sample(NmxItemControlCommand::AdviseSupervisory) }; let encoded = msg.encode(); assert_eq!(encoded[21], 0x34); assert_eq!(encoded[22], 0x12); } #[test] fn handle_projection_offsets_un_advise() { // For UnAdvise the handle projection starts at offset 19 (3 + 16). let msg = NmxItemControlMessage { object_id: 0x1234, ..sample(NmxItemControlCommand::UnAdvise) }; let encoded = msg.encode(); assert_eq!(encoded[19], 0x34); assert_eq!(encoded[20], 0x12); } #[test] fn version_word_is_one_le() { let encoded = sample(NmxItemControlCommand::UnAdvise).encode(); assert_eq!(encoded[1], 0x01); assert_eq!(encoded[2], 0x00); } #[test] fn command_byte_round_trips() { let advise = sample(NmxItemControlCommand::AdviseSupervisory).encode(); let unadvise = sample(NmxItemControlCommand::UnAdvise).encode(); assert_eq!(advise[0], 0x1f); assert_eq!(unadvise[0], 0x21); } #[test] fn to_un_advise_and_back() { // Mirrors cs:145-153 — `with` updates only the command. let advise = sample(NmxItemControlCommand::AdviseSupervisory); let unadvise = advise.to_un_advise(); assert_eq!(unadvise.command, NmxItemControlCommand::UnAdvise); // All other fields preserved. assert_eq!(advise.item_correlation_id, unadvise.item_correlation_id); assert_eq!(advise.object_id, unadvise.object_id); assert_eq!(advise.tail, unadvise.tail); let again = unadvise.to_advise_supervisory(); assert_eq!(again.command, NmxItemControlCommand::AdviseSupervisory); assert_eq!(again, advise); } }