diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md
index 3724670d..0a62c406 100644
--- a/docs/drivers/AbCip.md
+++ b/docs/drivers/AbCip.md
@@ -132,13 +132,23 @@ Variables — in addition to the UDT container tag itself.
separate Variable node under the UDT's `Discovered/` sub-folder, addressable by its member
path (e.g. `MyUdt.Temperature`, `MyUdt.Flags`). Top-level atomic member discovery is
**functional in production**.
-- **Nested struct members** — when a UDT member is itself a struct, the driver walks into it up
- to a **depth cap of 8 levels**. Nested-struct expansion is a **documented deferral in
- production**: the Template Object member block carries no nested template ID, so the
- sub-shape cannot be re-fetched from the controller at discovery time. Nested struct leaves are
- never mis-emitted — they are simply dropped (the parent member is omitted from discovery if it
- is not atomic and its sub-shape is unavailable). Use pre-declared `Members` for nested structs
- that must be individually addressed.
+- **Nested struct members** — **shipped** (commits 3d8ce4e8 / d203f31c). When a UDT member is
+ itself a struct, the Template Object member block carries the nested UDT's template instance ID
+ in the low 12 bits of the member `info` field (struct flag `0x8000` set). The driver captures
+ this as `AbCipUdtMember.NestedTemplateId` and threads it into a second `FetchUdtShapeAsync`
+ call (`@udt/{id}`) so the nested struct's atomic leaves are expanded into dot-joined addressable
+ paths (e.g. `Motor1.Status.Code`, `Motor1.Status.Running`). Recursion is bounded by a
+ **depth cap of 8 levels**; members at or beyond the cap are dropped rather than emitted.
+ Nested struct leaves that resolve to no emittable atomic leaf produce no sub-folder (lazy
+ materialisation).
+
+ **Live verification is Emulate-tier only** (`AB_SERVER_PROFILE=emulate` + a Logix Emulate
+ instance): `ab_server` (the default Docker simulator) does not implement the CIP Template
+ Object service (class 0x6C), so the nested-id fetch cannot be exercised against it.
+ See `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs`
+ for the skip-gated live-gate smoke (backlog #6). Offline unit coverage lives in
+ `AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam`
+ and `CipTemplateObjectDecoderTests`.
### Bare-container reads
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs
new file mode 100644
index 00000000..0fcbb9a2
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs
@@ -0,0 +1,132 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
+
+///
+/// Live-gate smoke for backlog #6: controller-discovered nested-struct UDT expansion
+/// via Template Object (CIP class 0x6C) against Rockwell Studio 5000 Logix Emulate.
+/// Closes the gap left by the offline unit suite
+/// (AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam
+/// and CipTemplateObjectDecoderTests) — those tests proved correctness with
+/// golden-box byte buffers, but the Template Object service (class 0x6C) is NOT
+/// implemented by the default ab_server Docker simulator. Emulate's firmware
+/// speaks production CIP responses so the nested-template-id threading (commits
+/// 3d8ce4e8 / d203f31c) gets end-to-end wire-level coverage here.
+///
+///
+/// Required Emulate project state:
+///
+/// - UDT StatusNested_UDT with members Code : DINT,
+/// Running : BOOL.
+/// - UDT MotorNested_UDT with members Speed : DINT,
+/// Status : StatusNested_UDT — Status is the nested struct member
+/// whose template instance id the driver must re-fetch via @udt/{id}.
+/// - Controller-scope tag MotorNested1 : MotorNested_UDT.
+///
+/// Runs only when AB_SERVER_PROFILE=emulate. With ab_server
+/// (the default tier), skips cleanly — ab_server lacks CIP Template Object
+/// (class 0x6C) emulation so the nested-id fetch would time-out regardless.
+/// The Emulate tier is hardware-gated (Rockwell per-seat license, Windows-only,
+/// conflicts with Docker Desktop's WSL 2 backend) so a permanent skip in CI is expected
+/// — see docs/drivers/AbServer-Test-Fixture.md §"Logix Emulate golden-box tier"
+/// for the full rationale + the gap matrix this test closes.
+///
+[Collection("AbServerEmulate")]
+[Trait("Category", "Integration")]
+[Trait("Tier", "Emulate")]
+public sealed class AbCipEmulateNestedUdtTests
+{
+ ///
+ /// Verifies that a controller-discovered nested-struct UDT expands into atomic
+ /// leaves addressable as dot-joined paths, proving the nested template-id threading
+ /// runs the production @udt/{id} CIP fetch against Emulate's firmware.
+ ///
+ [AbServerFact]
+ public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch()
+ {
+ AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
+
+ var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
+ ?? throw new InvalidOperationException(
+ "AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
+ "(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
+
+ var options = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
+ EnableControllerBrowse = true,
+ Timeout = TimeSpan.FromSeconds(10),
+ };
+
+ await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-nested-udt-smoke");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ // Drive controller discovery — the driver walks @tags, encounters MotorNested1
+ // (a Structure symbol), reads its template via @udt/{motorTemplateId}, finds
+ // the nested Status member (struct flag set, nested template id in low 12 bits),
+ // then re-fetches @udt/{statusTemplateId} to expand the nested leaves.
+ var discovered = new DiscoveryRecorder();
+ await drv.DiscoverAsync(discovered, TestContext.Current.CancellationToken);
+
+ // The MotorNested1 UDT sub-folder is materialised under Discovered/.
+ discovered.FolderNames.ShouldContain("MotorNested1",
+ "expected a MotorNested1 sub-folder — check that the Emulate project has a " +
+ "MotorNested1 : MotorNested_UDT controller-scope tag and EnableControllerBrowse is true");
+
+ var byName = discovered.Variables.ToDictionary(v => v.FullName, v => v);
+
+ // Top-level atomic member — Speed : DINT.
+ byName.ShouldContainKey("MotorNested1.Speed",
+ "MotorNested1.Speed not discovered — top-level atomic member expansion failed");
+
+ // Nested struct atomic leaves — Status.Code : DINT, Status.Running : BOOL.
+ // These prove the nested template-id fetch actually ran (ab_server can't reach here).
+ byName.ShouldContainKey("MotorNested1.Status.Code",
+ "MotorNested1.Status.Code not discovered — nested template-id fetch + expansion did not run");
+ byName.ShouldContainKey("MotorNested1.Status.Running",
+ "MotorNested1.Status.Running not discovered — nested template-id fetch + expansion did not run");
+
+ // The nested struct's sub-folder is materialised (lazy, only when a leaf is emitted).
+ discovered.FolderNames.ShouldContain("MotorNested1.Status",
+ "expected a MotorNested1.Status sub-folder for the nested StatusNested_UDT expansion");
+
+ // The bare MotorNested1 Structure placeholder Variable must NOT be emitted — the
+ // container is replaced by the member fan-out, not emitted alongside it.
+ byName.ShouldNotContainKey("MotorNested1",
+ "MotorNested1 bare Structure placeholder should NOT be emitted when member fan-out succeeds");
+ }
+
+ // ---- minimal recorder wired to IAddressSpaceBuilder ----
+
+ private sealed class DiscoveryRecorder : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAddressSpaceBuilder
+ {
+ public List FolderNames { get; } = new();
+ public List<(string FullName, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType DataType)> Variables { get; } = new();
+
+ public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAddressSpaceBuilder Folder(string browseName, string displayName)
+ { FolderNames.Add(browseName); return this; }
+
+ public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IVariableHandle Variable(
+ string browseName, string displayName, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo info)
+ {
+ Variables.Add((info.FullName, info.DriverDataType));
+ return new Nop(info.FullName);
+ }
+
+ public void AddProperty(string _, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType __, object? ___) { }
+
+ private sealed class Nop(string fullRef) : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IVariableHandle
+ {
+ public string FullReference => fullRef;
+ public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmConditionSink MarkAsAlarmCondition(
+ ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmConditionInfo info) => new NullSink();
+ }
+
+ private sealed class NullSink : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmConditionSink
+ {
+ public void OnTransition(ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmEventArgs args) { }
+ }
+ }
+}