Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
//! `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 [`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<Self, CodecError> {
|
||||
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 [`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<Self, CodecError> {
|
||||
// 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<u8>`. Mirrors
|
||||
/// `NmxItemControlMessage.Encode` (`cs:121-143`).
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user