diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 670153c..6207782 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -38,6 +38,7 @@
+
diff --git a/docs/drivers/README.md b/docs/drivers/README.md
index 899a479..4b48c19 100644
--- a/docs/drivers/README.md
+++ b/docs/drivers/README.md
@@ -45,7 +45,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
- [AB Legacy](AbLegacy-Test-Fixture.md) — no integration fixture, unit-only via `FakeAbLegacyTag` (libplctag PCCC)
-- [TwinCAT](TwinCAT-Test-Fixture.md) — no integration fixture, unit-only via `FakeTwinCATClient` with native-notification harness
+- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md
index ed92364..fdc2476 100644
--- a/docs/drivers/TwinCAT-Test-Fixture.md
+++ b/docs/drivers/TwinCAT-Test-Fixture.md
@@ -2,24 +2,62 @@
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
-**TL;DR: there is no integration fixture.** Every test uses a
-`FakeTwinCATClient` injected via `ITwinCATClientFactory`. Beckhoff's ADS
-library has no open-source simulator; ADS traffic against real TwinCAT
-runtimes is trusted from field deployments.
+**TL;DR:** Integration-test scaffolding lives at
+`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221).
+`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three
+smoke tests (read / write / native notification) run end-to-end through
+the real ADS stack when the VM is reachable, skip cleanly otherwise.
+**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a
+Hyper-V VM, author the `.tsproj` project documented at
+`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a
+paid runtime). Unit tests via `FakeTwinCATClient` still carry the
+exhaustive contract coverage.
-The silver lining: TwinCAT is the only driver outside Galaxy that uses
-**native notifications** (no polling) for `ISubscribable`, and the fake
-exposes a fire-event harness so notification routing is contract-tested
-rigorously — just not on the wire.
+TwinCAT is the only driver outside Galaxy that uses **native
+notifications** (no polling) for `ISubscribable`, and the fake exposes a
+fire-event harness so notification routing is contract-tested rigorously
+at the unit layer.
## What the fixture is
-Nothing at the integration layer.
-`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is unit-only.
-`FakeTwinCATClient` also fakes the `AddDeviceNotification` flow so tests can
-trigger callbacks without a running runtime.
+**Integration layer** (task #221, scaffolded):
+`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` —
+`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by
+`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the
+VM). No fixture-owned lifecycle — XAR can't run in Docker because it
+bypasses the Windows kernel scheduler, so the VM stays
+operator-managed. `TwinCatProject/README.md` documents the required
+`.tsproj` project state; the file itself lands once the XAR VM is up +
+the project is authored. Three smoke tests:
+`Driver_reads_seeded_DINT_through_real_ADS`,
+`Driver_write_then_read_round_trip_on_scratch_REAL`, and
+`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
+— all skip cleanly via `[TwinCATFact]` when the runtime isn't
+reachable.
-## What it actually covers (unit only)
+**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is
+still the primary coverage. `FakeTwinCATClient` also fakes the
+`AddDeviceNotification` flow so tests can trigger callbacks without a
+running runtime.
+
+## What it actually covers
+
+### Integration (XAR VM, task #221 — code scaffolded, needs VM + project)
+
+- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS
+ handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN
+ increments each cycle)
+- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` —
+ real ADS write + read on `GVL_Fixture.rSetpoint`
+- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
+ — real `AddDeviceNotification` against the cycle-incrementing counter;
+ observes `OnDataChange` firing within 3 s of subscribe
+
+All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
+vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
+vars are unset.
+
+### Unit
- `TwinCATAmsAddressTests` — `ads://:` parsing + routing
- `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs),
@@ -89,21 +127,30 @@ back an `IAlarmSource`, but shipping that is a separate feature.
## Follow-up candidates
-1. **TwinCAT 3 runtime on CI** — Beckhoff ships a free developer runtime
- (7-day trial, restartable). Could run on a Windows CI runner with a
- helper that auto-restarts the runtime every 6 days. Works but operational
- overhead.
-2. **AdsSimulator** — Beckhoff has a closed-source "ADS simulator" library
- used internally; not publicly available.
-3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; the
- only route that covers TC2 + real notification behavior + EtherCAT I/O
- effects.
-
-Without a rig, TwinCAT correctness is trusted from the fake matching
-reality, which has held across field deployments so far.
+1. **XAR VM live-population** — scaffolding is in place (this PR); the
+ remaining work is operational: stand up the Hyper-V VM, install XAR,
+ author the `.tsproj` per `TwinCatProject/README.md`, configure the
+ bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID`
+ on the dev box. Then the three smoke tests transition skip → pass.
+ Tracked as #221.
+2. **License-rotation automation** — XAR's 7-day trial expires on
+ schedule. Either automate `TcActivate.exe /reactivate` via a
+ scheduled task on the VM (not officially supported; reportedly works
+ for some TC3 builds), or buy a paid runtime license (~$1k one-time
+ per runtime per CPU) to kill the rotation. The doc at
+ `TwinCatProject/README.md` §License rotation walks through both.
+3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
+ the only route that covers TC2 + real EtherCAT I/O timing + cycle
+ jitter under CPU load.
## Key fixture / config files
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs`
+ — TCP probe + skip-attributes + env-var parsing
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs`
+ — three wire-level smoke tests
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md`
+ — project spec + VM setup + license-rotation notes
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
in-process fake with the notification-fire harness used by
`TwinCATNativeNotificationTests`
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs
new file mode 100644
index 0000000..65ebdc7
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs
@@ -0,0 +1,135 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
+
+///
+/// End-to-end smoke tests against a live TwinCAT 3 XAR runtime. Skipped via
+/// when the VM isn't reachable / the AmsNetId
+/// isn't set. Proves the driver's AMS route setup, ADS read/write, symbol browse,
+/// and native AddDeviceNotification subscription all work on the wire —
+/// coverage the FakeTwinCATClient-backed unit suite can only contract-test.
+///
+///
+/// Required VM project state (see TwinCatProject/README.md):
+///
+/// - GVL GVL_Fixture with nCounter : DINT (seed 1234),
+/// rSetpoint : REAL (scratch; smoke writes + reads), bFlag : BOOL
+/// (seed TRUE).
+/// - PLC program MAIN that increments GVL_Fixture.nCounter
+/// every cycle (so the native-notification test can observe monotonic changes
+/// without writing).
+///
+///
+[Collection("TwinCATXar")]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "TwinCAT-XAR")]
+public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
+{
+ [TwinCATFact]
+ public async Task Driver_reads_seeded_DINT_through_real_ADS()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = BuildOptions(sim);
+ await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-read");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var snapshots = await drv.ReadAsync(
+ ["Counter"], TestContext.Current.CancellationToken);
+
+ snapshots.Count.ShouldBe(1);
+ snapshots[0].StatusCode.ShouldBe(0u,
+ "ADS read against GVL_Fixture.nCounter must succeed end-to-end");
+ // MAIN increments the counter every cycle, so the seed value (1234) is only the
+ // minimum we can assert — value grows monotonically.
+ Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(1234);
+ }
+
+ [TwinCATFact]
+ public async Task Driver_write_then_read_round_trip_on_scratch_REAL()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = BuildOptions(sim);
+ await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-write");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ const float probe = 42.5f;
+ var writeResults = await drv.WriteAsync(
+ [new WriteRequest("Setpoint", probe)],
+ TestContext.Current.CancellationToken);
+ writeResults.Count.ShouldBe(1);
+ writeResults[0].StatusCode.ShouldBe(0u);
+
+ var readResults = await drv.ReadAsync(
+ ["Setpoint"], TestContext.Current.CancellationToken);
+ readResults.Count.ShouldBe(1);
+ readResults[0].StatusCode.ShouldBe(0u);
+ Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
+ }
+
+ [TwinCATFact]
+ public async Task Driver_subscribe_receives_native_ADS_notifications_on_counter_changes()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = BuildOptions(sim);
+ await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-sub");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var observed = new List();
+ var gate = new SemaphoreSlim(0);
+ drv.OnDataChange += (_, e) =>
+ {
+ lock (observed) observed.Add(e);
+ gate.Release();
+ };
+
+ var handle = await drv.SubscribeAsync(
+ ["Counter"], TimeSpan.FromMilliseconds(250),
+ TestContext.Current.CancellationToken);
+
+ // MAIN increments the counter every PLC cycle (default 10 ms task tick).
+ // Native ADS notifications fire on cycle boundaries so 3 s is generous for
+ // at least one OnDataChange to land.
+ var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
+ got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 3 s of subscribe");
+
+ int observedCount;
+ lock (observed) observedCount = observed.Count;
+ observedCount.ShouldBeGreaterThan(0);
+
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+ }
+
+ private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
+ {
+ Devices = [
+ new TwinCATDeviceOptions(
+ HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
+ DeviceName: "XAR-VM"),
+ ],
+ Tags = [
+ new TwinCATTagDefinition(
+ Name: "Counter",
+ DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
+ SymbolPath: "GVL_Fixture.nCounter",
+ DataType: TwinCATDataType.DInt),
+ new TwinCATTagDefinition(
+ Name: "Setpoint",
+ DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
+ SymbolPath: "GVL_Fixture.rSetpoint",
+ DataType: TwinCATDataType.Real,
+ Writable: true),
+ ],
+ UseNativeNotifications = true,
+ Timeout = TimeSpan.FromSeconds(5),
+ // Disable the probe loop — the smoke tests run their own reads; a background
+ // probe against GVL_Fixture.nCounter would race with them for the ADS client
+ // gate + inject flakiness unrelated to the code under test.
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ };
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs
new file mode 100644
index 0000000..5df1323
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs
@@ -0,0 +1,136 @@
+using System.Net.Sockets;
+using Xunit;
+using Xunit.Sdk;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
+
+///
+/// Reachability probe for a TwinCAT 3 XAR runtime on a Hyper-V VM or dedicated
+/// Windows box. TCP-probes ADS port 48898 on the operator-supplied host. Tests
+/// skip via /
+/// when the runtime isn't reachable, so dotnet test on a fresh clone without
+/// a TwinCAT VM stays green. Matches the
+/// /
+/// /
+/// OpcPlcFixture / AbServerFixture patterns.
+///
+///
+/// Why a VM, not a container: TwinCAT XAR bypasses the Windows
+/// kernel scheduler to hit real-time PLC cycles. It can't run inside Docker, and
+/// on bare metal it conflicts with Hyper-V / WSL 2 — that's why this repo's dev
+/// environment puts XAR in a dedicated Hyper-V VM per
+/// docs/v2/dev-environment.md §Integration host. The fixture treats the VM
+/// as a black-box ADS endpoint reachable over TCP.
+///
+/// License rotation: the free XAR trial license expires every 7 days.
+/// When it lapses the runtime goes silent + the fixture's TCP probe fails; tests
+/// skip with the reason message until the operator renews via
+/// TcActivate.exe /reactivate (or buys a paid runtime). Intentionally surfaces
+/// as a skip rather than a hang because "trial expired" is operator action, not a
+/// test failure.
+///
+/// Env var overrides:
+///
+/// - TWINCAT_TARGET_HOST — IP or hostname of the XAR VM (default
+/// localhost, assumed to be unset on the average dev box + result in a
+/// clean skip).
+/// - TWINCAT_TARGET_NETID — AMS NetId the tests address (e.g.
+/// 5.23.91.23.1.1). Seeded on the target VM via TwinCAT System
+/// Manager → Routes; the dev box's AmsNetId also needs a bilateral route
+/// entry on the VM side. No sensible default — tests skip if unset.
+/// - TWINCAT_TARGET_PORT — ADS target port (default 851 =
+/// TC3 PLC runtime 1). Set to 852 for runtime 2, etc.
+///
+///
+public sealed class TwinCATXarFixture : IAsyncLifetime
+{
+ private const string HostEnvVar = "TWINCAT_TARGET_HOST";
+ private const string NetIdEnvVar = "TWINCAT_TARGET_NETID";
+ private const string PortEnvVar = "TWINCAT_TARGET_PORT";
+
+ /// ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's
+ /// ).
+ public const int AdsTcpPort = 48898;
+
+ /// TC3 PLC runtime 1. Override via .
+ public const int DefaultAmsPort = 851;
+
+ public string TargetHost { get; }
+ public string? TargetNetId { get; }
+ public int AmsPort { get; }
+ public string? SkipReason { get; }
+
+ public TwinCATXarFixture()
+ {
+ TargetHost = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
+ TargetNetId = Environment.GetEnvironmentVariable(NetIdEnvVar);
+ AmsPort = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p)
+ ? p : DefaultAmsPort;
+
+ if (string.IsNullOrWhiteSpace(TargetNetId))
+ {
+ SkipReason = $"TwinCAT XAR unreachable: {NetIdEnvVar} is not set. " +
+ $"Start the XAR VM + set {HostEnvVar}= and {NetIdEnvVar}=.";
+ return;
+ }
+
+ if (!TcpProbe(TargetHost, AdsTcpPort, TimeSpan.FromSeconds(2)))
+ {
+ SkipReason = $"TwinCAT XAR at {TargetHost}:{AdsTcpPort} not reachable within 2 s. " +
+ $"Verify the XAR VM is running, its trial license hasn't expired " +
+ $"(run TcActivate.exe /reactivate on the VM), and {HostEnvVar}/{NetIdEnvVar} point at it.";
+ }
+ }
+
+ public ValueTask InitializeAsync() => ValueTask.CompletedTask;
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ /// true when the XAR runtime is reachable + the AmsNetId is set.
+ /// Used by the skip attributes to avoid spinning up the fixture for every test
+ /// class.
+ public static bool IsRuntimeAvailable()
+ {
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NetIdEnvVar))) return false;
+ var host = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
+ return TcpProbe(host, AdsTcpPort, TimeSpan.FromMilliseconds(500));
+ }
+
+ private static bool TcpProbe(string host, int port, TimeSpan timeout)
+ {
+ try
+ {
+ using var client = new TcpClient();
+ var task = client.ConnectAsync(host, port);
+ return task.Wait(timeout) && client.Connected;
+ }
+ catch { return false; }
+ }
+}
+
+[Xunit.CollectionDefinition(Name)]
+public sealed class TwinCATXarCollection : Xunit.ICollectionFixture
+{
+ public const string Name = "TwinCATXar";
+}
+
+/// [Fact]-equivalent gated on .
+public sealed class TwinCATFactAttribute : FactAttribute
+{
+ public TwinCATFactAttribute()
+ {
+ if (!TwinCATXarFixture.IsRuntimeAvailable())
+ Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
+ "for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
+ }
+}
+
+/// [Theory]-equivalent with the same gate as .
+public sealed class TwinCATTheoryAttribute : TheoryAttribute
+{
+ public TwinCATTheoryAttribute()
+ {
+ if (!TwinCATXarFixture.IsRuntimeAvailable())
+ Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
+ "for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md
new file mode 100644
index 0000000..1b019d4
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md
@@ -0,0 +1,145 @@
+# TwinCAT XAR fixture project
+
+This folder holds the TwinCAT 3 XAE project that the XAR VM runs for the
+integration-tests suite (`tests/.../TwinCAT.IntegrationTests/*.cs`).
+
+**Status today**: stub. The `.tsproj` isn't committed yet; once the XAR
+VM is up + a project with the required state exists, export via
+File → Export + drop it here as `OtOpcUaTwinCatFixture.tsproj` + its
+PLC `.library` / `.plcproj` companions.
+
+## Why `.tsproj`, not the binary bootproject
+
+TwinCAT ships two project forms: the XAE `.tsproj` (XML, source of
+truth) and the compiled bootproject that the XAR runtime actually
+loads. Ship the `.tsproj` because:
+
+- Text format — reviewable in PR diffs, diffable in git
+- Rebuildable across TC3 engineering versions (the XAE tool rebuilds
+ the bootproject from `.tsproj` on "Activate Configuration")
+- Doesn't carry per-install state (target AmsNetId, source licensing)
+
+Reconstruction workflow on the VM:
+
+1. Open TC3 XAE (Visual Studio shell)
+2. File → Open → `OtOpcUaTwinCatFixture.tsproj`
+3. Target system → the VM's AmsNetId (set in System Manager → Routes)
+4. Build → Build Solution (produces the bootproject)
+5. Activate Configuration → Run Mode (deploys to XAR + starts the
+ runtime)
+
+## Required project state
+
+The smoke tests in `TwinCAT3SmokeTests.cs` depend on this exact GVL +
+PLC setup. Missing or renamed symbols surface as ADS `DeviceSymbolNotFound`
+or wrong-type read failures, not silent skips.
+
+### Global Variable List: `GVL_Fixture`
+
+```st
+VAR_GLOBAL
+ // Monotonically-increasing counter; MAIN increments each cycle.
+ // Seed value 1234 picked so the smoke test can assert ">= 1234" without
+ // synchronising with the initial cycle.
+ nCounter : DINT := 1234;
+
+ // Scratch REAL for write-then-read round-trip test. Smoke test writes
+ // 42.5 + reads back.
+ rSetpoint : REAL := 0.0;
+
+ // Readable boolean with seed value TRUE. Reserved for future
+ // expansion (e.g. discovery / symbol-browse tests).
+ bFlag : BOOL := TRUE;
+END_VAR
+```
+
+### PLC program: `MAIN`
+
+```st
+PROGRAM MAIN
+VAR
+END_VAR
+
+// One-line program: increment the fixture counter every cycle.
+// The native-notification smoke test subscribes to GVL_Fixture.nCounter
+// + observes the monotonic changes without a write path.
+GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
+```
+
+### Task
+
+- `PlcTask` — cyclic, 10 ms interval, priority 20
+- Assigned to `MAIN`
+
+### Runtime ID
+
+- TC3 PLC runtime 1 (AMS port `851`) — the smoke-test fixture defaults
+ to this. Use runtime 2 / port `852` only if the single runtime is
+ already taken by another project on the same VM.
+
+## XAR VM setup (one-time)
+
+Full bootstrap lives in `docs/v2/dev-environment.md`. The TwinCAT-specific
+steps:
+
+1. **Create the Hyper-V VM** — Gen 2, Windows 10/11 64-bit, 4 GB RAM,
+ 2 CPUs. External virtual switch so the dev box can reach
+ `:48898`.
+2. **Install TwinCAT 3 XAE + XAR** — free download from Beckhoff
+ (`www.beckhoff.com/en-en/products/automation/twincat/`). Activate the
+ 7-day trial on first boot.
+3. **Note the VM's AmsNetId** — shown in the TwinCAT system tray icon →
+ Properties → AMS NetId (format like `5.23.91.23.1.1`).
+4. **Configure bilateral ADS route**:
+ - On the VM: System Manager → Routes → Add Route → dev box's
+ AmsNetId + IP
+ - On the dev box: edit `%TC_INSTALLPATH%\Target\StaticRoutes.xml` (or
+ use the dev box's own TwinCAT System Manager if installed) to add
+ the VM's AmsNetId + IP
+5. **Import this project** per the reconstruction workflow above.
+6. **Hit Activate Configuration + Run Mode**. The runtime starts; the
+ system tray icon goes green; port `48898` is live.
+
+## License rotation
+
+The XAR trial expires every 7 days. When it lapses:
+
+1. The runtime goes silent (red tray icon, ADS port `48898` stops
+ responding to new connections).
+2. Integration tests skip with the reason message pointing at this
+ folder's README.
+3. Operator runs `C:\TwinCAT\3.1\Target\StartUp\TcActivate.exe /reactivate`
+ on the VM console (not RDP — the trial activation wants the
+ interactive-login desktop).
+
+Options to eliminate the manual step:
+
+- **Scheduled task** that runs the reactivate every 6 days at 02:00 —
+ documented in the Beckhoff forums as working for some TC3 builds,
+ not officially supported.
+- **Paid runtime license** (~$1k one-time per runtime, per CPU) — kills
+ the rotation permanently, worth it if the integration host is
+ long-lived.
+
+## How to run the TwinCAT-tier tests
+
+On the dev box:
+
+```powershell
+$env:TWINCAT_TARGET_HOST = '10.0.0.42' # replace with the VM IP
+$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' # replace with the VM AmsNetId
+# $env:TWINCAT_TARGET_PORT = '852' # only if not using PLC runtime 1
+dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests
+```
+
+With any of those env vars unset, all three smoke tests skip cleanly via
+`[TwinCATFact]`; unit suite (`TwinCAT.Tests`) runs unchanged.
+
+## See also
+
+- [`docs/drivers/TwinCAT-Test-Fixture.md`](../../../docs/drivers/TwinCAT-Test-Fixture.md)
+ — coverage map
+- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
+ §Integration host — VM + route + license-rotation notes
+- Beckhoff Information System → TwinCAT 3 → Product overview + ADS +
+ PLC reference (licensed; internal link only)
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj
new file mode 100644
index 0000000..cfb2824
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+