//! `NmxTransferEnvelope` — 46-byte NMX wire envelope. //! //! Direct port of `src/MxNativeCodec/NmxTransferEnvelope.cs`. The Rust port //! adds `reserved6_10: [u8; 4]` preservation per CLAUDE.md unknown-bytes rule //! — the .NET reference reads only Version/InnerLength/ProtocolMarker/MessageKind //! and discards bytes 6..10 (`NmxTransferEnvelope.cs:39-75`); the Rust codec //! round-trips them. // Direct byte indexing — see reference_handle.rs for rationale. #![allow(clippy::indexing_slicing)] use crate::error::CodecError; /// Encoded layout per `NmxTransferEnvelope.cs:23-37`: /// /// ```text /// offset size field /// 0 2 version u16 LE = 1 /// 2 4 inner_length i32 LE = body.len() - 46 /// 6 4 reserved6_10 [u8; 4] preserved verbatim by Rust port /// 10 4 message_kind i32 LE 1=Metadata, 2=ItemControl, 3=Write /// 14 4 source_galaxy_id i32 LE /// 18 4 source_platform_id i32 LE /// 22 4 local_engine_id i32 LE /// 26 4 target_galaxy_id i32 LE /// 30 4 target_platform_id i32 LE /// 34 4 target_engine_id i32 LE /// 38 4 protocol_marker i32 LE = 0x0201 (bytes: 01 02 00 00) /// 42 4 timeout_ms i32 LE default 30000 /// 46+ body... /// ``` pub const ENVELOPE_HEADER_LEN: usize = 46; const VERSION: u16 = 1; const PROTOCOL_MARKER: i32 = 0x0201; const DEFAULT_TIMEOUT_MS: i32 = 30000; const INNER_LENGTH_OFFSET: usize = 2; const RESERVED_OFFSET: usize = 6; const MESSAGE_KIND_OFFSET: usize = 10; const SOURCE_GALAXY_OFFSET: usize = 14; const SOURCE_PLATFORM_OFFSET: usize = 18; const LOCAL_ENGINE_OFFSET: usize = 22; const TARGET_GALAXY_OFFSET: usize = 26; const TARGET_PLATFORM_OFFSET: usize = 30; const TARGET_ENGINE_OFFSET: usize = 34; const PROTOCOL_MARKER_OFFSET: usize = 38; const TIMEOUT_OFFSET: usize = 42; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[non_exhaustive] #[repr(u8)] pub enum NmxTransferMessageKind { #[default] Unknown = 0, Metadata = 1, ItemControl = 2, Write = 3, } impl NmxTransferMessageKind { fn from_i32(value: i32) -> Self { match value { 1 => Self::Metadata, 2 => Self::ItemControl, 3 => Self::Write, _ => Self::Unknown, } } fn to_i32(self) -> i32 { match self { Self::Unknown => 0, Self::Metadata => 1, Self::ItemControl => 2, Self::Write => 3, } } } /// 46-byte envelope. `reserved6_10` is preserved verbatim — the .NET reference /// discards these bytes on parse and writes 0 on encode. The Rust port carries /// them through so captured envelopes with non-zero values at offset 6..10 /// round-trip byte-identical. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NmxTransferEnvelope { pub message_kind: NmxTransferMessageKind, pub source_galaxy_id: i32, pub source_platform_id: i32, pub local_engine_id: i32, pub target_galaxy_id: i32, pub target_platform_id: i32, pub target_engine_id: i32, pub timeout_ms: i32, /// Bytes 6..10 of the envelope. The .NET reference does not retain these; /// the Rust port preserves them per CLAUDE.md unknown-bytes rule. /// Defaults to `[0; 4]` for newly-constructed envelopes. pub reserved6_10: [u8; 4], } impl Default for NmxTransferEnvelope { fn default() -> Self { Self { message_kind: NmxTransferMessageKind::default(), source_galaxy_id: 1, source_platform_id: 1, local_engine_id: 0, target_galaxy_id: 0, target_platform_id: 0, target_engine_id: 0, timeout_ms: DEFAULT_TIMEOUT_MS, reserved6_10: [0; 4], } } } impl NmxTransferEnvelope { /// Header length in bytes. pub const HEADER_LEN: usize = ENVELOPE_HEADER_LEN; /// Parse a transfer body — the 46-byte header followed by the inner body. /// Returns the parsed envelope and the inner body length (the inner bytes /// themselves are accessed by the caller via `&transfer_body[46..]`). /// /// Mirrors `NmxTransferEnvelope.Parse` (`NmxTransferEnvelope.cs:39-75`). /// /// # Errors /// /// - [`CodecError::ShortRead`] if `transfer_body.len() < 46`. /// - [`CodecError::UnsupportedVersion`] if version != 1. /// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length` /// does not match `transfer_body.len() - 46`. /// - [`CodecError::UnsupportedProtocolMarker`] if the marker != 0x0201. pub fn parse(transfer_body: &[u8]) -> Result { if transfer_body.len() < Self::HEADER_LEN { return Err(CodecError::ShortRead { expected: Self::HEADER_LEN, actual: transfer_body.len(), }); } let version = read_u16_le(transfer_body, 0); if version != VERSION { return Err(CodecError::UnsupportedVersion { expected: VERSION, actual: version, }); } let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET); let actual_inner = transfer_body.len() - Self::HEADER_LEN; if inner_length != actual_inner as i32 { return Err(CodecError::InnerLengthMismatch { declared: inner_length, actual: actual_inner, }); } let protocol_marker = read_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET); if protocol_marker != PROTOCOL_MARKER { return Err(CodecError::UnsupportedProtocolMarker(protocol_marker)); } let mut reserved6_10 = [0u8; 4]; reserved6_10.copy_from_slice(&transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4]); Ok(Self { message_kind: NmxTransferMessageKind::from_i32(read_i32_le( transfer_body, MESSAGE_KIND_OFFSET, )), source_galaxy_id: read_i32_le(transfer_body, SOURCE_GALAXY_OFFSET), source_platform_id: read_i32_le(transfer_body, SOURCE_PLATFORM_OFFSET), local_engine_id: read_i32_le(transfer_body, LOCAL_ENGINE_OFFSET), target_galaxy_id: read_i32_le(transfer_body, TARGET_GALAXY_OFFSET), target_platform_id: read_i32_le(transfer_body, TARGET_PLATFORM_OFFSET), target_engine_id: read_i32_le(transfer_body, TARGET_ENGINE_OFFSET), timeout_ms: read_i32_le(transfer_body, TIMEOUT_OFFSET), reserved6_10, }) } /// Encode the envelope header into the front of `transfer_body`. The /// caller is responsible for providing a buffer of length /// `46 + inner_body.len()` and copying the inner body into the tail /// before transmission. /// /// Mirrors `NmxTransferEnvelope.Encode` (`NmxTransferEnvelope.cs:77-103`) /// but additionally writes `reserved6_10` (the .NET version always writes 0). /// /// # Errors /// /// Returns [`CodecError::InnerLengthMismatch`] if `transfer_body.len() < 46`. pub fn write_to(self, transfer_body: &mut [u8]) -> Result<(), CodecError> { if transfer_body.len() < Self::HEADER_LEN { return Err(CodecError::InnerLengthMismatch { declared: 0, actual: transfer_body.len(), }); } let inner_len = transfer_body.len() - Self::HEADER_LEN; write_u16_le(transfer_body, 0, VERSION); write_i32_le(transfer_body, INNER_LENGTH_OFFSET, inner_len as i32); transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4].copy_from_slice(&self.reserved6_10); write_i32_le( transfer_body, MESSAGE_KIND_OFFSET, self.message_kind.to_i32(), ); write_i32_le(transfer_body, SOURCE_GALAXY_OFFSET, self.source_galaxy_id); write_i32_le( transfer_body, SOURCE_PLATFORM_OFFSET, self.source_platform_id, ); write_i32_le(transfer_body, LOCAL_ENGINE_OFFSET, self.local_engine_id); write_i32_le(transfer_body, TARGET_GALAXY_OFFSET, self.target_galaxy_id); write_i32_le( transfer_body, TARGET_PLATFORM_OFFSET, self.target_platform_id, ); write_i32_le(transfer_body, TARGET_ENGINE_OFFSET, self.target_engine_id); write_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET, PROTOCOL_MARKER); write_i32_le(transfer_body, TIMEOUT_OFFSET, self.timeout_ms); Ok(()) } /// Convenience encoder that allocates a buffer of `46 + inner_body.len()`, /// writes the header, and copies `inner_body` into the tail. Mirrors the /// shape of `NmxTransferEnvelope.Encode`. pub fn encode_with_inner(self, inner_body: &[u8]) -> Vec { let mut out = vec![0u8; Self::HEADER_LEN + inner_body.len()]; // write_to validates and never errors when the buffer is large enough, // so this branch is unreachable in practice. We propagate the bug as // an empty buffer rather than panicking. if self.write_to(&mut out).is_err() { return Vec::new(); } out[Self::HEADER_LEN..].copy_from_slice(inner_body); out } } #[inline] fn read_u16_le(bytes: &[u8], offset: usize) -> u16 { u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } #[inline] fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { i32::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_i32_le(bytes: &mut [u8], offset: usize, value: i32) { 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_envelope() -> NmxTransferEnvelope { NmxTransferEnvelope { message_kind: NmxTransferMessageKind::Write, source_galaxy_id: 1, source_platform_id: 1, local_engine_id: 5, target_galaxy_id: 1, target_platform_id: 2, target_engine_id: 17, timeout_ms: 30000, reserved6_10: [0; 4], } } #[test] fn round_trip_default_envelope() { let env = sample_envelope(); let inner = [0xab, 0xcd, 0xef]; let encoded = env.encode_with_inner(&inner); assert_eq!(encoded.len(), 46 + 3); let parsed = NmxTransferEnvelope::parse(&encoded).unwrap(); assert_eq!(env, parsed); assert_eq!(&encoded[46..], &inner); } #[test] fn protocol_marker_bytes_are_le() { let env = sample_envelope(); let encoded = env.encode_with_inner(&[]); // 0x0201 LE = 01 02 00 00 assert_eq!(encoded[38], 0x01); assert_eq!(encoded[39], 0x02); assert_eq!(encoded[40], 0x00); assert_eq!(encoded[41], 0x00); } #[test] fn version_bytes_are_le_one() { let env = sample_envelope(); let encoded = env.encode_with_inner(&[]); assert_eq!(encoded[0], 0x01); assert_eq!(encoded[1], 0x00); } #[test] fn reserved_bytes_round_trip() { // Construct an envelope with non-zero reserved bytes (as if parsed // from a captured frame). Encode and re-parse — they must survive. let env = NmxTransferEnvelope { reserved6_10: [0xde, 0xad, 0xbe, 0xef], ..sample_envelope() }; let encoded = env.encode_with_inner(&[]); assert_eq!(&encoded[6..10], &[0xde, 0xad, 0xbe, 0xef]); let parsed = NmxTransferEnvelope::parse(&encoded).unwrap(); assert_eq!(parsed.reserved6_10, [0xde, 0xad, 0xbe, 0xef]); } #[test] fn parse_rejects_wrong_version() { let mut encoded = sample_envelope().encode_with_inner(&[]); encoded[0] = 0x02; // version = 2 let err = NmxTransferEnvelope::parse(&encoded).unwrap_err(); assert!(matches!(err, CodecError::UnsupportedVersion { .. })); } #[test] fn parse_rejects_inner_length_mismatch() { let mut encoded = sample_envelope().encode_with_inner(&[0; 4]); // Corrupt the inner_length field to claim 100 inner bytes when // there are only 4. write_i32_le(&mut encoded, INNER_LENGTH_OFFSET, 100); let err = NmxTransferEnvelope::parse(&encoded).unwrap_err(); assert!(matches!(err, CodecError::InnerLengthMismatch { .. })); } #[test] fn parse_rejects_wrong_protocol_marker() { let mut encoded = sample_envelope().encode_with_inner(&[]); write_i32_le(&mut encoded, PROTOCOL_MARKER_OFFSET, 0x0102); let err = NmxTransferEnvelope::parse(&encoded).unwrap_err(); assert!(matches!(err, CodecError::UnsupportedProtocolMarker(0x0102))); } #[test] fn parse_rejects_short_buffer() { let err = NmxTransferEnvelope::parse(&[0u8; 45]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } #[test] fn message_kind_round_trips() { for kind in [ NmxTransferMessageKind::Metadata, NmxTransferMessageKind::ItemControl, NmxTransferMessageKind::Write, ] { let env = NmxTransferEnvelope { message_kind: kind, ..sample_envelope() }; let encoded = env.encode_with_inner(&[]); let parsed = NmxTransferEnvelope::parse(&encoded).unwrap(); assert_eq!(parsed.message_kind, kind); } } #[test] fn header_length_constant() { assert_eq!(NmxTransferEnvelope::HEADER_LEN, 46); assert_eq!(ENVELOPE_HEADER_LEN, 46); } }