diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
new file mode 100644
index 0000000..000601e
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
@@ -0,0 +1,74 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
+/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
+/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
+/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
+/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
+/// "ToPdu" call.
+///
+///
+/// See docs/v2/dl205.md §V-memory for the full CPU-family matrix + rationale.
+/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
+/// addressing), AutomationDirect forum guidance on V40400 system-base.
+///
+public static class DirectLogicAddress
+{
+ ///
+ /// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
+ /// Accepts bare octal ("2000") or V-prefixed ("V2000"). Range
+ /// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
+ /// octal, DL260 extends to V77777 octal.
+ ///
+ /// Input is null / empty / contains non-octal digits (8,9).
+ /// Parsed value exceeds ushort.MaxValue (0xFFFF).
+ public static ushort UserVMemoryToPdu(string vAddress)
+ {
+ if (string.IsNullOrWhiteSpace(vAddress))
+ throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
+ var s = vAddress.Trim();
+ if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
+ if (s.Length == 0)
+ throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
+
+ // Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
+ // accept them silently because .NET has no built-in base-8 parser.
+ uint result = 0;
+ foreach (var ch in s)
+ {
+ if (ch < '0' || ch > '7')
+ throw new ArgumentException(
+ $"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
+ nameof(vAddress));
+ result = result * 8 + (uint)(ch - '0');
+ if (result > ushort.MaxValue)
+ throw new OverflowException(
+ $"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
+ }
+ return (ushort)result;
+ }
+
+ ///
+ /// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
+ /// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
+ /// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
+ /// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
+ /// within the system bank.
+ ///
+ public const ushort SystemVMemoryBasePdu = 0x2100;
+
+ ///
+ /// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
+ /// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
+ /// consecutive PDU addresses, so the offset is plain decimal.
+ ///
+ public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
+ {
+ var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
+ if (pdu > ushort.MaxValue)
+ throw new OverflowException(
+ $"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
+ return (ushort)pdu;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs
new file mode 100644
index 0000000..b8f91d4
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs
@@ -0,0 +1,91 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
+
+///
+/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use
+/// to translate V2000 octal into
+/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile
+/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed
+/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which
+/// the profile leaves at 0) and miss the marker entirely.
+///
+[Collection(ModbusSimulatorCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "DL205")]
+public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim)
+{
+ [Fact]
+ public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed V-memory markers).");
+ }
+
+ var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000");
+ pdu.ShouldBe((ushort)0x0400);
+
+ var options = new ModbusDriverOptions
+ {
+ Host = sim.Host,
+ Port = sim.Port,
+ UnitId = 1,
+ Timeout = TimeSpan.FromSeconds(2),
+ Tags =
+ [
+ new ModbusTagDefinition("DL205_V2000",
+ ModbusRegion.HoldingRegisters, Address: pdu,
+ DataType: ModbusDataType.UInt16, Writable: false),
+ ],
+ Probe = new ModbusProbeOptions { Enabled = false },
+ };
+ await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem");
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken);
+ results[0].StatusCode.ShouldBe(0u);
+ results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)");
+ }
+
+ [Fact]
+ public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
+ }
+
+ // V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the
+ // simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]).
+ // The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there.
+ var pdu = DirectLogicAddress.SystemVMemoryToPdu(0);
+ pdu.ShouldBe((ushort)0x2100);
+
+ var options = new ModbusDriverOptions
+ {
+ Host = sim.Host,
+ Port = sim.Port,
+ UnitId = 1,
+ Timeout = TimeSpan.FromSeconds(2),
+ Tags =
+ [
+ new ModbusTagDefinition("DL205_V40400",
+ ModbusRegion.HoldingRegisters, Address: pdu,
+ DataType: ModbusDataType.UInt16, Writable: false),
+ ],
+ Probe = new ModbusProbeOptions { Enabled = false },
+ };
+ await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv");
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken);
+ results[0].StatusCode.ShouldBe(0u);
+ results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs
new file mode 100644
index 0000000..75e64ed
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs
@@ -0,0 +1,77 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class DirectLogicAddressTests
+{
+ [Theory]
+ [InlineData("V0", (ushort)0x0000)]
+ [InlineData("V1", (ushort)0x0001)]
+ [InlineData("V7", (ushort)0x0007)]
+ [InlineData("V10", (ushort)0x0008)]
+ [InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
+ [InlineData("V7777", (ushort)0x0FFF)]
+ [InlineData("V10000", (ushort)0x1000)]
+ [InlineData("V17777", (ushort)0x1FFF)]
+ public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
+ => DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
+
+ [Theory]
+ [InlineData("0", (ushort)0)]
+ [InlineData("2000", (ushort)0x0400)]
+ [InlineData("v2000", (ushort)0x0400)] // lowercase v
+ [InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
+ public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
+ => DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
+
+ [Theory]
+ [InlineData("V8")] // 8 is not a valid octal digit
+ [InlineData("V19")]
+ [InlineData("V2009")]
+ public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
+ {
+ Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu(v))
+ .Message.ShouldContain("octal");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("V")]
+ public void UserVMemoryToPdu_rejects_empty_input(string? v)
+ => Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu(v!));
+
+ [Fact]
+ public void UserVMemoryToPdu_overflow_rejected()
+ {
+ // 200000 octal = 0x10000 — one past ushort range.
+ Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
+ }
+
+ [Fact]
+ public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
+ {
+ // V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
+ // which a naive octal-to-decimal of 40400 octal would give (= 16640).
+ DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
+ DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
+ }
+
+ [Fact]
+ public void SystemVMemoryToPdu_offsets_within_bank()
+ {
+ DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
+ DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
+ }
+
+ [Fact]
+ public void SystemVMemoryToPdu_rejects_overflow()
+ {
+ // ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
+ Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
+ Should.Throw(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
+ }
+}