feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
||||
|
||||
// CAPTURE DECISION: the brief gave timeout ticks 0x0E. Every libplctag capture (logix-forward-open,
|
||||
// logix-forward-close) carries priority/tick 0x0A, timeout ticks 0x05, so ConnectionManagerServices
|
||||
// uses 0x05 (brief: 0x0E -> capture: 0x05). The golden-byte tests below use 0x05 accordingly.
|
||||
public sealed class ConnectionManagerServicesTests
|
||||
{
|
||||
private static byte[] CapturedRequest(string file) =>
|
||||
CaptureFile.CipPayload(CaptureFile.Load(file).First(f => f.FromClient));
|
||||
|
||||
private static CipReply CapturedReply(string file) =>
|
||||
CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).First(f => !f.FromClient)));
|
||||
|
||||
[Fact]
|
||||
public void Forward_close_frame()
|
||||
{
|
||||
var bytes = ConnectionManagerServices.ForwardClose(0x1234, 0x1337, 0xCAFEBABE, [0x01, 0x00]);
|
||||
Convert.ToHexString(bytes).ShouldBe("4E02200624010A05" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forward_close_matches_capture()
|
||||
{
|
||||
var cap = CapturedRequest("logix-forward-close.hex");
|
||||
var serial = BitConverter.ToUInt16(cap, 8);
|
||||
var vendor = BitConverter.ToUInt16(cap, 10);
|
||||
var orig = BitConverter.ToUInt32(cap, 12);
|
||||
vendor.ShouldBe((ushort)0xF33D);
|
||||
ConnectionManagerServices.ForwardClose(serial, vendor, orig, [0x01, 0x00]).ShouldBe(cap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Large_forward_open_matches_capture()
|
||||
{
|
||||
var cap = CapturedRequest("logix-forward-open.hex");
|
||||
// Values read out of the capture: T->O id @12, serial @16, vendor @18, originator serial @20,
|
||||
// timeout multiplier @24, RPI @28, network params @32 (0x42000FA2 -> size 4002).
|
||||
var p = new ForwardOpenParams(
|
||||
Large: true, TOConnectionId: BitConverter.ToUInt32(cap, 12), ConnectionSerial: BitConverter.ToUInt16(cap, 16),
|
||||
VendorId: BitConverter.ToUInt16(cap, 18), OriginatorSerial: BitConverter.ToUInt32(cap, 20),
|
||||
TimeoutMultiplier: cap[24], RpiMicros: BitConverter.ToUInt32(cap, 28), ConnectionSize: 4002, Route: [0x01, 0x00]);
|
||||
p.RpiMicros.ShouldBe(1_000_000u);
|
||||
BitConverter.ToUInt32(cap, 32).ShouldBe(0x42000FA2u);
|
||||
ConnectionManagerServices.ForwardOpen(p).ShouldBe(cap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Large_forward_open_reply_parses_from_capture()
|
||||
{
|
||||
var reply = ConnectionManagerServices.ParseForwardOpenReply(CapturedReply("logix-forward-open.hex"));
|
||||
reply.OTConnectionId.ShouldBe(0x2CDF633Fu);
|
||||
reply.TOConnectionId.ShouldBe(0x073F9E60u);
|
||||
reply.OTApiMicros.ShouldBe(1_000_000u);
|
||||
reply.TOApiMicros.ShouldBe(1_000_000u);
|
||||
}
|
||||
|
||||
// No small Forward Open 0x54 was captured (the Large FO succeeded), so this layout is checked against
|
||||
// the CIP spec / the brief rather than a capture.
|
||||
[Fact]
|
||||
public void Regular_forward_open_layout_per_spec()
|
||||
{
|
||||
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(
|
||||
false, 0x11223344, 0x5566, 0x7788, 0x99AABBCC, 0x01, 1_000_000, 504, [0x01, 0x00]));
|
||||
Convert.ToHexString(bytes).ShouldBe(
|
||||
"540220062401" + "0A05" + "00000000" + "44332211" + "6655" + "8877" + "CCBBAA99" + "01000000"
|
||||
+ "40420F00" + "F843" + "40420F00" + "F843" + "A3" + "03" + "0100" + "20022401");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regular_forward_open_network_params_encoding()
|
||||
{
|
||||
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(false, 1, 2, 3, 4, 1, 1_000_000, 504, []));
|
||||
// 504 = 0x1F8; 0x4200 | 0x1F8 = 0x43F8 -> LE F8 43
|
||||
Convert.ToHexString(bytes).ShouldContain("40420F00" + "F843");
|
||||
bytes[0].ShouldBe((byte)0x54);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unconnected_send_even_embedded()
|
||||
{
|
||||
byte[] embedded = [0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00]; // 8 bytes
|
||||
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
|
||||
"520220062401" + "0A05" + "0800" + Convert.ToHexString(embedded) + "01" + "00" + "0100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unconnected_send_pads_odd_embedded()
|
||||
{
|
||||
byte[] embedded = [0x01, 0x02, 0x03];
|
||||
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
|
||||
"520220062401" + "0A05" + "0300" + "010203" + "00" + "01" + "00" + "0100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refused_forward_open_surfaces_statuses()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
|
||||
var ex = Should.Throw<AbTransportException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
||||
ex.Message.ShouldContain("0x01");
|
||||
ex.Message.ShouldContain("0x0109");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DB000800", true)] // Service Not Supported
|
||||
[InlineData("DB0001010901", true)] // general 1 / extended 0x0109
|
||||
[InlineData("DB0001010001", false)] // general 1 / extended 0x0100 (connection in use): no fallback
|
||||
[InlineData("DB000000", false)]
|
||||
public void Fallback_rules(string hex, bool expected) =>
|
||||
ConnectionManagerServices.ShouldFallBackToRegularOpen(CipReply.Parse(Convert.FromHexString(hex))).ShouldBe(expected);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
||||
|
||||
public sealed class MultipleServicePacketTests
|
||||
{
|
||||
[Fact]
|
||||
public void Msp_offsets_are_measured_from_the_count_field()
|
||||
{
|
||||
var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 };
|
||||
var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 };
|
||||
Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe(
|
||||
"0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b));
|
||||
MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSize_equals_built_length()
|
||||
{
|
||||
byte[][] reqs = [new byte[7], new byte[20], new byte[1]];
|
||||
MultipleServicePacket.Build(reqs).Length.ShouldBe(MultipleServicePacket.BuildSize([7, 20, 1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_splits_replies()
|
||||
{
|
||||
// outer reply data: count 2, offsets 6 and 14, reply1 (8 bytes), reply2 (6 bytes)
|
||||
var r1 = Convert.FromHexString("CC000000C400" + "0100"); // DINT-typed read reply, 8 bytes
|
||||
var r2 = Convert.FromHexString("CD000000" + "0000"); // write ok + 2 extra bytes
|
||||
var data = Convert.FromHexString("0200" + "0600" + "0E00").Concat(r1).Concat(r2).ToArray();
|
||||
var outer = new CipReply(0x0A, 0, [], data);
|
||||
var replies = MultipleServicePacket.Parse(outer, 2);
|
||||
replies.Count.ShouldBe(2);
|
||||
replies[0].Service.ShouldBe((byte)0x4C);
|
||||
Convert.ToHexString(replies[0].Data.ToArray()).ShouldBe("C4000100");
|
||||
replies[1].Service.ShouldBe((byte)0x4D);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_embedded_error_status_is_kept_per_reply()
|
||||
{
|
||||
var r1 = Convert.FromHexString("CC000400");
|
||||
var data = Convert.FromHexString("0100" + "0400").Concat(r1).ToArray();
|
||||
MultipleServicePacket.Parse(new CipReply(0x0A, 0x1E, [], data), 1)[0].GeneralStatus.ShouldBe((byte)0x04);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_wrong_count_throws() =>
|
||||
Should.Throw<AbTransportException>(() =>
|
||||
MultipleServicePacket.Parse(new CipReply(0x0A, 0, [], Convert.FromHexString("0100" + "0400" + "CC000000")), 2));
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
|
||||
|
||||
// CAPTURE DECISION: libplctag sends Read Tag FRAGMENTED (0x52, offset 0) for plain reads, connected,
|
||||
// with no MSP and no Unconnected Send wrapper. The brief's "ReadTag 0x4C" comparison is therefore made
|
||||
// against ReadTagFragmented(path, 1, 0). Replies are service 0xD2 (0x52|0x80).
|
||||
public sealed class LogixServicesTests
|
||||
{
|
||||
private static byte[] Req(string file, int i = 0) =>
|
||||
CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => f.FromClient).ElementAt(i));
|
||||
|
||||
private static CipReply Rep(string file, int i = 0) =>
|
||||
CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => !f.FromClient).ElementAt(i)));
|
||||
|
||||
[Fact]
|
||||
public void Read_request_matches_capture() =>
|
||||
LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestDINT"), 1, 0).ShouldBe(Req("logix-read-dint.hex"));
|
||||
|
||||
[Fact]
|
||||
public void Read_array_request_matches_capture() =>
|
||||
LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestArray"), 16, 0).ShouldBe(Req("logix-read-dint-array16.hex"));
|
||||
|
||||
[Fact]
|
||||
public void Read_dint_value()
|
||||
{
|
||||
var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-dint.hex"));
|
||||
type.ShouldBe(LogixTypeCode.DInt);
|
||||
handle.ShouldBeNull();
|
||||
data.Length.ShouldBe(4);
|
||||
BitConverter.ToInt32(data.Span).ShouldBe(123456);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_array_values()
|
||||
{
|
||||
var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-dint-array16.hex"));
|
||||
type.ShouldBe(LogixTypeCode.DInt);
|
||||
data.Length.ShouldBe(64);
|
||||
for (var i = 0; i < 16; i++)
|
||||
BitConverter.ToUInt32(data.Span[(i * 4)..]).ShouldBe((uint)(((i + 1) << 24) | (i + 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_bool_value()
|
||||
{
|
||||
var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-bool.hex"));
|
||||
type.ShouldBe(LogixTypeCode.Bool);
|
||||
data.Length.ShouldBe(1);
|
||||
data.Span[0].ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
// ab_server types STRING as atomic D0 00 (a real Logix STRING is A0 02 + handle): simulator behaviour.
|
||||
[Fact]
|
||||
public void Read_string_value_from_sim()
|
||||
{
|
||||
var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-string.hex"));
|
||||
type.ShouldBe((ushort)0x00D0);
|
||||
handle.ShouldBeNull();
|
||||
data.Length.ShouldBe(88);
|
||||
BitConverter.ToInt32(data.Span).ShouldBe(7);
|
||||
Encoding.ASCII.GetString(data.Span.Slice(4, 7)).ShouldBe("HelloAB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_unknown_tag_reply_status()
|
||||
{
|
||||
var r = Rep("logix-read-unknown-tag.hex");
|
||||
r.GeneralStatus.ShouldBe((byte)0x04);
|
||||
r.Status.ShouldBe(AbStatus.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_request_matches_capture() =>
|
||||
LogixServices.WriteTag(CipPath.EncodeSymbolic("TestDINT"), LogixTypeCode.DInt, null, 1, BitConverter.GetBytes(123456))
|
||||
.ShouldBe(Req("logix-write-dint.hex", 1));
|
||||
|
||||
[Fact]
|
||||
public void Write_reply_is_ok() => Rep("logix-write-dint.hex", 1).GeneralStatus.ShouldBe((byte)0);
|
||||
|
||||
[Fact]
|
||||
public void Read_tag_plain_layout() =>
|
||||
Convert.ToHexString(LogixServices.ReadTag(CipPath.EncodeSymbolic("Abc"), 3)).ShouldBe("4C03" + "910341626300" + "0300");
|
||||
|
||||
[Fact]
|
||||
public void Fragmented_write_layout() =>
|
||||
Convert.ToHexString(LogixServices.WriteTagFragmented(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.DInt, null, 2, 8, [1, 2, 3, 4]))
|
||||
.ShouldBe("5303" + "910341626300" + "C400" + "0200" + "08000000" + "01020304");
|
||||
|
||||
[Fact]
|
||||
public void Struct_write_includes_handle() =>
|
||||
Convert.ToHexString(LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, 0x0FCE, 1, [9]))
|
||||
.ShouldBe("4D03" + "910341626300" + "A002" + "CE0F" + "0100" + "09");
|
||||
|
||||
[Fact]
|
||||
public void Struct_write_without_handle_throws() =>
|
||||
Should.Throw<ArgumentException>(() => LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, null, 1, [9]));
|
||||
|
||||
[Fact]
|
||||
public void Read_reply_with_struct_handle()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F"));
|
||||
var (type, handle, data) = LogixServices.ParseReadReply(reply);
|
||||
type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
|
||||
|
||||
public sealed class SymbolObjectServicesTests
|
||||
{
|
||||
private static readonly string[] ExpectedNames =
|
||||
["Motor_Run", "Line1_Count", "Pump_1_Speed", "TestArray", "TestString", "TestSINT", "TestBOOL", "TestREAL", "TestDINT"];
|
||||
|
||||
[Fact]
|
||||
public void Request_matches_capture()
|
||||
{
|
||||
// libplctag requests attributes 2,7,8,1 in that order -- same as the brief; no change needed.
|
||||
var cap = CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => f.FromClient));
|
||||
SymbolObjectServices.GetInstanceAttributeList(null, 0).ShouldBe(cap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Request_with_program_scope_and_start_instance() =>
|
||||
Convert.ToHexString(SymbolObjectServices.GetInstanceAttributeList("Main", 0x0102)).ShouldBe(
|
||||
"550A" + "910C" + Convert.ToHexString(Encoding.ASCII.GetBytes("Program:Main"))
|
||||
+ "206B2500" + "0201" + "0400" + "0200" + "0700" + "0800" + "0100");
|
||||
|
||||
[Fact]
|
||||
public void Captured_reply_entries_have_the_decoder_layout()
|
||||
{
|
||||
var reply = CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => !f.FromClient)));
|
||||
var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
|
||||
more.ShouldBeFalse();
|
||||
last.ShouldBe(9u);
|
||||
|
||||
var names = new List<string>();
|
||||
var pos = 0;
|
||||
uint expectedInstance = 1;
|
||||
while (pos < entries.Length)
|
||||
{
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(entries.AsSpan(pos)).ShouldBe(expectedInstance++);
|
||||
var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(entries.AsSpan(pos + 20));
|
||||
names.Add(Encoding.ASCII.GetString(entries, pos + 22, nameLen));
|
||||
pos += 22 + nameLen;
|
||||
}
|
||||
names.ShouldBe(ExpectedNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void More_flag_follows_status_0x06()
|
||||
{
|
||||
var entry = Convert.FromHexString("05000000" + "C400" + "0400" + "00000000" + "00000000" + "00000000" + "0100" + "41");
|
||||
var reply = new CipReply(0x55, 0x06, [], entry);
|
||||
var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
|
||||
more.ShouldBeTrue(); last.ShouldBe(5u); entries.ShouldBe(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() =>
|
||||
SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x04, [], ReadOnlyMemory<byte>.Empty)));
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
|
||||
|
||||
// No template exchange exists in the golden captures (ab_server has no Template Object), so these
|
||||
// are layout tests against the brief / CIP spec.
|
||||
public sealed class TemplateObjectServicesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_attributes_request() =>
|
||||
Convert.ToHexString(TemplateObjectServices.GetAttributes(0x0123)).ShouldBe(
|
||||
"0303" + "206C2500" + "2301" + "0400" + "0400" + "0500" + "0200" + "0100");
|
||||
|
||||
[Fact]
|
||||
public void Parse_attributes()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0400"
|
||||
+ "0400" + "0000" + "2A000000"
|
||||
+ "0500" + "0000" + "18000000"
|
||||
+ "0200" + "0000" + "0300"
|
||||
+ "0100" + "0000" + "CE0F"));
|
||||
var a = TemplateObjectServices.ParseAttributes(reply);
|
||||
a.ShouldBe(new TemplateAttributes(42, 24, 3, 0x0FCE));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_attributes_nonzero_attribute_status_throws()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0100" + "0400" + "0100" + "2A000000"));
|
||||
Should.Throw<AbTransportException>(() => TemplateObjectServices.ParseAttributes(reply));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_template_request() =>
|
||||
Convert.ToHexString(TemplateObjectServices.ReadTemplate(0x0123, 0x20, 0x1F4)).ShouldBe(
|
||||
"4C03" + "206C2500" + "2301" + "20000000" + "F401");
|
||||
|
||||
[Fact]
|
||||
public void Read_more_follows_status_0x06()
|
||||
{
|
||||
TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x06, [], ReadOnlyMemory<byte>.Empty)).ShouldBeTrue();
|
||||
TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x00, [], ReadOnlyMemory<byte>.Empty)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decoder_buffer_header()
|
||||
{
|
||||
var buf = TemplateObjectServices.BuildDecoderBuffer(0x0123, new TemplateAttributes(42, 24, 3, 0x0FCE), [0xAA, 0xBB]);
|
||||
Convert.ToHexString(buf).ShouldBe("2301" + "2A000000" + "18000000" + "0300" + "CE0F" + "AABB");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user