diff --git a/CLAUDE.md b/CLAUDE.md
index cda3152a..9ab5c691 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1087,7 +1087,7 @@ re-emit a concrete default. The defaults differ per driver (final review, 2026-1
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
- CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only.
+ CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — array/STRING/UDT-member and fragmented writes are `ab_server`-only.
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`;
`S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC
diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md
index 276a1735..15da2cea 100644
--- a/docs/drivers/AbCip.md
+++ b/docs/drivers/AbCip.md
@@ -237,17 +237,20 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
## Known gaps
-- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
- real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
- (2026-10-08) and nothing else. Results:
+- ⚠️ **Native hardware evidence is narrow.** The read-only real-press gate (Task 21, 2026-10-08) proved native reads, browse
+ and templates on Z-35162; a user-approved one-off on 2026-10-09 added **scalar native writes on a real CompactLogix**
+ (DINT `test2`, BOOL `Program:UN01_Press.xxtest`, REAL `Program:ES03_EM03_Zone3._ff_test`: write, read back, restore, all
+ Good). Still ab_server-only: array, STRING and UDT-member writes and fragmented writes. Mixed STRING/array/scalar
+ Multiple Service Packet reads (#582) are proven on the press (one 5-read MSP, values equal to single reads). Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
bench controller exists.
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
- are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
- lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
+ are paced, and a browse's template reads share one lease. Observed on Z-35162 (2026-10-09, 41
+ pages / 3162 tags): every page strictly ascending, so out-of-order listing has never been seen on a real controller — the
+ reader is correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md
index ddf566c2..383a4eab 100644
--- a/docs/drivers/AbLegacy.md
+++ b/docs/drivers/AbLegacy.md
@@ -219,6 +219,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported |
+| Bit-within-word write (`B3:0/5`) | `ab_server` SLC500 + MicroLogix, both transports (`AbLegacyBitWriteTests`): the driver RMWs the parent word, only the addressed bit changes. PLC-5 native refuses it (`BadNotSupported`, #571) |
| Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
index 5d81422c..6877f513 100644
--- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -477,3 +477,85 @@ Per-pass detail (pass 2):
```
+## hw4 evidence batch — 2026-10-09 (Gitea #592; #582, #572, ab_server bit write)
+
+Branch `hw4-evidence`, harness `Differential/EvidenceTests.cs` (same folder, same read-only source/IL guard, extra opt-in
+env gates so a standard `Differential` run never repeats them). Press: Z-35162, CompactLogix 5069-L320ERS2, VPN, pace 50 ms,
+one short-lived native session per leg (1 Forward Open, 1 Forward Close acked), reads only. A diagnostics-only tap
+(`ConnectionDiagnostics.ExchangeObserver`, null in production, exceptions swallowed) lets the harness see each CIP request and
+its raw reply; no packet capture was used because `tcpdump` needs sudo on this Mac.
+
+### Real-controller scalar writes (user-approved one-off, 2026-10-09)
+
+Done by the controller session, not by this harness, through the AbCip CLI on the **native** transport against Z-35162. The
+tags were chosen because a census of the L5X export shows no routine references them and they are absent from the Galaxy map.
+Every write and read-back returned Good, and each tag was restored:
+
+| Tag | Type | Sequence |
+|---|---|---|
+| `test2` | DINT | 0 -> 4242 -> 0 |
+| `Program:UN01_Press.xxtest` | BOOL | false -> true -> false |
+| `Program:ES03_EM03_Zone3._ff_test` | REAL | 0 -> 42.5 -> 0 |
+
+This is the first native write on a real controller: scalar DINT/BOOL/REAL Write Tag is proven on a CompactLogix. The user's
+write approval covered only that one-off and is consumed; every other leg of this batch was read-only. Still unproven on
+hardware: fragmented wide-struct writes (#567), array writes, STRING writes, UDT member writes.
+
+### #582 - mixed STRING / array / scalar Multiple Service Packet (PASS)
+
+`AB_EVID_MIX=1 AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_PACE_MS=50` -> `EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet`
+(raw output: [hw4-mix-msp.txt](2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt)). Five reads were issued concurrently on one native session,
+five times (>= 1 s apart), bracketed by sequential single reads of the same tags:
+
+| Tag | Type | Value (single before / all 5 batches / single after) |
+|---|---|---|
+| `Program:UN01_Press.Sts_CycleTime_STR` | STRING | `'2:23:37'` throughout (matches the L5X export) |
+| `Program:EM01_PowerUnit._SEQ_EP[1]` | DINT element | `300` throughout |
+| `Program:EM01_PowerUnit._SEQ_EP` | DINT[32] | `[0,300,0 x30]` throughout |
+| `test2` | DINT | `0` |
+| `Program:UN01_Press.xxtest` | BOOL | `False` |
+
+- **5/5 attempts put all five reads in ONE channel send**: a single 204 B Multiple Service Packet (service 0x0A) answered by a
+ 273 B reply with `embedded=5`, outer status 0x00, embedded statuses all 0x00 with data lengths 92 B (STRING), 6 B, 130 B
+ (32 x DINT + type header), 6 B, 3 B. No embedded 0x06, no fragment re-fetch, `RequestsSent` delta = 1 each time.
+- Every batch value equalled both bracketing singles, so reply packing/offsets decode correctly for a mix of 3-byte, 6-byte,
+ 92-byte and 130-byte replies. The values are stable (static config/test tags), so equality is not a coincidence of drift.
+- The tags are non-zero where it matters (the string and the array element), so decode equality is not vacuous; `test2` /
+ `xxtest` are zero by design and are only there to pin the scalar slots.
+- On `ab_server` (loopback) the same harness showed the pump can wake mid-queue and split a batch (attempt 1 of 5: an MSP of 4
+ plus a bare read). That is a scheduling race, not a packing defect, which is why the leg repeats the batch and requires at
+ least one whole-batch MSP; on the press all five attempts were whole.
+
+### #572 - does a real controller list symbol instances out of order? (observed: no)
+
+`AB_EVID_BROWSE=1` -> `EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller` (raw: [hw4-listing-order.txt](2026-10-07-native-ab-transport-t21/hw4-listing-order.txt)).
+One native `@tags` browse of Z-35162 through the production enumerator (default paging and pacing): 3162 tags, **41 listing
+pages** (controller scope 8 pages, then each of the 15 programs, up to 6 pages for `ES03_TemperatureControl`).
+
+- **0 non-ascending pages.** Within every page the instance ids were strictly ascending, and each page's first id was above the
+ previous page's maximum (e.g. controller scope 1..105, 106..207, ... 670..671).
+- Instance ids are not contiguous: ids can be skipped (e.g. `EM02_Clamp` page ends `...,281,282,288,289`; `UN01_Press` page 1
+ ends at 109 and page 2 starts at 111). The reader's "next start = highest id + 1" rule handles that and the out-of-order case
+ alike; this is evidence only, no code changed.
+- One empty page (`Program:Auxiliary`, 0 entries) was observed and handled.
+
+### ab_server PCCC bit-file write (`B3:0/5`): not an ab_server gap (no patch needed)
+
+The reported failure `0x803D0000` is `BadNotSupported`. Reproduced with new `AbLegacyBitWriteTests`
+(writes `B3:0` = 0x0A0A, sets bit 5, clears bit 3, asserts the whole word each time so neighbour bits are proven preserved),
+local ab_server (libplctag v2.7.0 + patches 0001/0002, built in OrbStack), `AB_LEGACY_ENDPOINT` pinned to a lane-local port:
+
+| Family | Libplctag | Native |
+|---|---|---|
+| SLC500 | pass | pass |
+| MicroLogix | pass | pass |
+| PLC-5 | pass | `BadNotSupported` (0x803D0000), by design |
+
+- The driver does a per-parent-word read-modify-write (typed read, then typed write of the word), never a masked CMD 0x0F/FNC
+ 0xAB write, so the bit write needs nothing ab_server lacks. (ab_server does implement FNC 0xAB, `handle_slc_rmw_request`, for
+ 16-bit elements; the driver simply does not use it.)
+- The 0x803D0000 is the **native PLC-5 write refusal** (#571, `NativeAbLegacyTagRuntime.WriteRefusal`: all PLC-5 writes are
+ refused before the wire). The test pins it for PLC-5 + Native and requires success everywhere else. Because nothing was broken,
+ there is no fix to revert for a red/green proof; the PLC-5 expectation was the part that failed first (red) when the test
+ wrongly assumed success, which shows the assertions discriminate. No Dockerfile or patch change.
+
diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt
new file mode 100644
index 00000000..7fbf1f9f
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt
@@ -0,0 +1,60 @@
+
+Test run for /Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
+VSTest version 18.0.1 (arm64)
+
+Starting test execution, please wait...
+A total of 1 test files matched the specified pattern.
+/Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller [4 s]
+ Standard Output Messages:
+ ## Listing order — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 50, pace 50 ms
+ Tags browsed: 3162; listing pages observed: 41; Forward Opens 1; Forward Closes 1/1
+ page 1: scope=controller entries=105 first=1 last=105 max=105 more=True ascending=True ids=[1,2,3,4,5,6,...,102,103,104,105]
+ page 2: scope=controller entries=102 first=106 last=207 max=207 more=True ascending=True ids=[106,107,108,109,110,111,...,204,205,206,207]
+ page 3: scope=controller entries=89 first=208 last=296 max=296 more=True ascending=True ids=[208,209,210,211,212,213,...,293,294,295,296]
+ page 4: scope=controller entries=84 first=297 last=380 max=380 more=True ascending=True ids=[297,298,299,300,301,302,...,377,378,379,380]
+ page 5: scope=controller entries=94 first=381 last=474 max=474 more=True ascending=True ids=[381,382,383,384,385,386,...,471,472,473,474]
+ page 6: scope=controller entries=95 first=475 last=569 max=569 more=True ascending=True ids=[475,476,477,478,479,480,...,566,567,568,569]
+ page 7: scope=controller entries=100 first=570 last=669 max=669 more=True ascending=True ids=[570,571,572,573,574,575,...,666,667,668,669]
+ page 8: scope=controller entries=2 first=670 last=671 max=671 more=False ascending=True ids=[670,671]
+ page 9: scope=Program:Recipes entries=67 first=1 last=67 max=67 more=False ascending=True ids=[1,2,3,4,5,6,...,64,65,66,67]
+ page 10: scope=Program:EM01_PowerUnit entries=101 first=1 last=101 max=101 more=True ascending=True ids=[1,2,3,4,5,6,...,98,99,100,101]
+ page 11: scope=Program:EM01_PowerUnit entries=93 first=102 last=194 max=194 more=False ascending=True ids=[102,103,104,105,106,107,...,191,192,193,194]
+ page 12: scope=Program:EM02_Clamp entries=102 first=1 last=102 max=102 more=True ascending=True ids=[1,2,3,4,5,6,...,99,100,101,102]
+ page 13: scope=Program:EM02_Clamp entries=102 first=103 last=204 max=204 more=True ascending=True ids=[103,104,105,106,107,108,...,201,202,203,204]
+ page 14: scope=Program:EM02_Clamp entries=78 first=205 last=289 max=289 more=False ascending=True ids=[205,206,207,208,209,210,...,281,282,288,289]
+ page 15: scope=Program:ES03_EM01_Zone1 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103]
+ page 16: scope=Program:ES03_EM01_Zone1 entries=98 first=104 last=202 max=202 more=True ascending=True ids=[104,105,106,107,108,109,...,199,200,201,202]
+ page 17: scope=Program:ES03_EM01_Zone1 entries=49 first=203 last=283 max=283 more=False ascending=True ids=[203,204,205,206,207,208,...,278,279,280,283]
+ page 18: scope=Program:ES03_TemperatureControl entries=104 first=1 last=104 max=104 more=True ascending=True ids=[1,2,3,4,5,6,...,101,102,103,104]
+ page 19: scope=Program:ES03_TemperatureControl entries=102 first=105 last=207 max=207 more=True ascending=True ids=[105,106,107,108,109,110,...,204,205,206,207]
+ page 20: scope=Program:ES03_TemperatureControl entries=102 first=208 last=309 max=309 more=True ascending=True ids=[208,209,210,211,212,213,...,306,307,308,309]
+ page 21: scope=Program:ES03_TemperatureControl entries=94 first=310 last=403 max=403 more=True ascending=True ids=[310,311,312,313,314,315,...,400,401,402,403]
+ page 22: scope=Program:ES03_TemperatureControl entries=97 first=404 last=500 max=500 more=True ascending=True ids=[404,405,406,407,408,409,...,497,498,499,500]
+ page 23: scope=Program:ES03_TemperatureControl entries=58 first=501 last=559 max=559 more=False ascending=True ids=[501,502,503,504,505,506,...,556,557,558,559]
+ page 24: scope=Program:HMI entries=9 first=1 last=9 max=9 more=False ascending=True ids=[1,2,3,4,5,6,7,8,9]
+ page 25: scope=Program:IO_Mapping entries=54 first=1 last=54 max=54 more=False ascending=True ids=[1,2,3,4,5,6,...,51,52,53,54]
+ page 26: scope=Program:SafetyProgram entries=1 first=1 last=1 max=1 more=False ascending=True ids=[1]
+ page 27: scope=Program:UN01_Press entries=109 first=1 last=109 max=109 more=True ascending=True ids=[1,2,3,4,5,6,...,106,107,108,109]
+ page 28: scope=Program:UN01_Press entries=103 first=111 last=213 max=213 more=True ascending=True ids=[111,112,113,114,115,116,...,210,211,212,213]
+ page 29: scope=Program:UN01_Press entries=2 first=214 last=215 max=215 more=False ascending=True ids=[214,215]
+ page 30: scope=Program:ES03_EM02_Zone2 entries=102 first=1 last=102 max=102 more=True ascending=True ids=[1,2,3,4,5,6,...,99,100,101,102]
+ page 31: scope=Program:ES03_EM02_Zone2 entries=98 first=103 last=201 max=201 more=True ascending=True ids=[103,104,105,106,107,108,...,198,199,200,201]
+ page 32: scope=Program:ES03_EM02_Zone2 entries=47 first=202 last=248 max=248 more=False ascending=True ids=[202,203,204,205,206,207,...,245,246,247,248]
+ page 33: scope=Program:ES03_EM03_Zone3 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103]
+ page 34: scope=Program:ES03_EM03_Zone3 entries=98 first=104 last=201 max=201 more=True ascending=True ids=[104,105,106,107,108,109,...,198,199,200,201]
+ page 35: scope=Program:ES03_EM03_Zone3 entries=47 first=202 last=249 max=249 more=False ascending=True ids=[202,203,204,205,206,207,...,246,247,248,249]
+ page 36: scope=Program:ES03_EM04_Zone4 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103]
+ page 37: scope=Program:ES03_EM04_Zone4 entries=98 first=104 last=201 max=201 more=True ascending=True ids=[104,105,106,107,108,109,...,198,199,200,201]
+ page 38: scope=Program:ES03_EM04_Zone4 entries=45 first=202 last=247 max=247 more=False ascending=True ids=[202,203,204,205,206,207,...,244,245,246,247]
+ page 39: scope=Program:Wonderware entries=90 first=1 last=90 max=90 more=True ascending=True ids=[1,2,3,4,5,6,...,87,88,89,90]
+ page 40: scope=Program:Wonderware entries=32 first=91 last=122 max=122 more=False ascending=True ids=[91,92,93,94,95,96,...,119,120,121,122]
+ page 41: scope=Program:Auxiliary entries=0 first=0 last=0 max=0 more=False ascending=True ids=[]
+ Non-ascending pages: 0
+
+
+
+Test Run Successful.
+Total tests: 1
+ Passed: 1
+ Total time: 5.3771 Seconds
diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt
new file mode 100644
index 00000000..d2031dc6
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt
@@ -0,0 +1,37 @@
+
+Test run for /Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
+VSTest version 18.0.1 (arm64)
+
+Starting test execution, please wait...
+A total of 1 test files matched the specified pattern.
+/Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet [18 s]
+ Standard Output Messages:
+ ## Mixed batch — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 50, pace 50 ms
+ Tags: Program:UN01_Press.Sts_CycleTime_STR (String) ; Program:EM01_PowerUnit._SEQ_EP[1] (DInt) ; Program:EM01_PowerUnit._SEQ_EP (DInt[32]) ; test2 (DInt) ; Program:UN01_Press.xxtest (Bool)
+ Native Forward Opens: 1; Forward Closes sent/acked: 1/1
+ Attempt 1: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all:
+ - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B
+ Attempt 2: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all:
+ - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B
+ Attempt 3: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all:
+ - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B
+ Attempt 4: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all:
+ - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B
+ Attempt 5: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all:
+ - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B
+ | Tag | single (before) | batch 1 | batch 2 | batch 3 | batch 4 | batch 5 | single (after) | all batch values match a bracketing single |
+ |---|---|---|---|---|---|---|---|---|
+ | `Program:UN01_Press.Sts_CycleTime_STR` | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | yes |
+ | `Program:EM01_PowerUnit._SEQ_EP[1]` | 300 | 300 | 300 | 300 | 300 | 300 | 300 | yes |
+ | `Program:EM01_PowerUnit._SEQ_EP` | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | yes |
+ | `test2` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | yes |
+ | `Program:UN01_Press.xxtest` | False | False | False | False | False | False | False | yes |
+ Attempts that carried every read in ONE MSP: 5/5
+
+
+
+Test Run Successful.
+Total tests: 1
+ Passed: 1
+ Total time: 18.9272 Seconds
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
index eb0fa39b..52861634 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
@@ -247,6 +247,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
};
_diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started);
+ _diagnostics.NotifyExchange(cip, reply);
if (_o.Routing == CipRouting.Connected && ConnectionManagerServices.IsConnectionGone(reply.Span) is { } extended)
{
await RetireClosedByTargetAsync(open).ConfigureAwait(false);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs
index f89cf3f3..2bc12f38 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs
@@ -91,6 +91,21 @@ internal sealed class ConnectionDiagnostics
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
}
+ ///
+ /// Evidence tap for the gate harness (#582 / #572): sees the CIP request and the raw CIP reply of every
+ /// completed exchange. Null in production; set only by the read-only differential harness. A throwing
+ /// observer is swallowed so it can never affect a request.
+ ///
+ internal Action, ReadOnlyMemory>? ExchangeObserver { get; set; }
+
+ internal void NotifyExchange(ReadOnlyMemory request, ReadOnlyMemory reply)
+ {
+ var observer = ExchangeObserver;
+ if (observer is null) return;
+ try { observer(request, reply); }
+ catch { /* evidence only */ }
+ }
+
internal void RecordExchange(long ticks)
{
Interlocked.Increment(ref _exchangeCount);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs
new file mode 100644
index 00000000..6deb8453
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs
@@ -0,0 +1,260 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
+
+///
+/// Hardware-evidence legs of Gitea #592 (hw4 lane evidence), READ-ONLY by construction and covered by the same
+/// source/IL guard as the rest of this folder. Both legs are opt-in on top of AB_DIFF_ENDPOINT because each
+/// is its own press session (the browse is ~3000 tags):
+///
+/// AB_EVID_MIX=1 — #582: reads a STRING, an array element, a whole array and scalars concurrently
+/// through the native transport and proves, from the exchange tap on the connection, that they travelled
+/// as ONE Multiple Service Packet and decode to what individual reads return. Tag set:
+/// AB_EVID_TAGS = name|Type|count entries separated by ; (count > 1 or
+/// [] suffix on Type ⇒ array).
+/// AB_EVID_BROWSE=1 — #572: one native @tags browse with every listing page's instance-id
+/// sequence recorded, reporting any page that is not ascending.
+///
+///
+[Collection(DifferentialCollection.Name)]
+[Trait("Category", "LiveIntegration")]
+public sealed class EvidenceTests
+{
+ private const string DefaultMix =
+ "Program:UN01_Press.Sts_CycleTime_STR|String|1;Program:EM01_PowerUnit._SEQ_EP[1]|DInt|1;" +
+ "Program:EM01_PowerUnit._SEQ_EP|DInt|32;test2|DInt|1;Program:UN01_Press.xxtest|Bool|1";
+
+ private sealed record ExchangeRecord(byte Service, int RequestBytes, int ReplyBytes, int Embedded, string Detail);
+
+ [Fact]
+ public async Task Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet()
+ {
+ var target = DifferentialTarget.FromEnvironment();
+ if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
+ if (Environment.GetEnvironmentVariable("AB_EVID_MIX") != "1") Assert.Skip("AB_EVID_MIX=1 not set.");
+
+ var specs = ParseMix(Environment.GetEnvironmentVariable("AB_EVID_TAGS") is { Length: > 0 } t ? t : DefaultMix);
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+ var ct = cts.Token;
+ var diagnostics = DeviceSessionPool.Shared.Diagnostics;
+ var exchanges = new List();
+ var factory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
+
+ const int Attempts = 5;
+ var ((before, attempts, after), opens, closes) =
+ await DifferentialRun.WithNativeAnchorAsync("evidence mix", target, async checkOpens =>
+ {
+ var runtimes = new List<(MixSpec Spec, IAbCipTagRuntime Rt)>();
+ try
+ {
+ foreach (var s in specs)
+ {
+ await DifferentialRun.PaceAsync(target, ct);
+ var rt = factory.Create(target.Params(s.Name, s.Count, s.Count > 1, s.Type));
+ await rt.InitializeAsync(ct); // one read each (create-read)
+ checkOpens($"create of '{s.Name}'");
+ rt.GetStatus().ShouldBe((int)AbStatus.Ok, $"'{s.Name}' must create/read Ok");
+ runtimes.Add((s, rt));
+ }
+
+ async Task> SequentialAsync()
+ {
+ var values = new List();
+ foreach (var (spec, rt) in runtimes)
+ {
+ await DifferentialRun.PaceAsync(target, ct);
+ await rt.ReadAsync(ct);
+ checkOpens($"single read of '{spec.Name}'");
+ values.Add(Format(spec, rt));
+ await Task.Delay(1000, ct); // >= 1 s between repeated reads
+ }
+ return values;
+ }
+
+ var beforeValues = await SequentialAsync();
+
+ // The batch: every read issued at once so the scheduler packs them. Whether the pump wakes
+ // before or after the last request is queued is a scheduling race, so the batch is repeated
+ // (>= 1 s apart) and each attempt reports how it was packed.
+ var done = new List<(int Requests, List Records, List Values)>();
+ for (var attempt = 0; attempt < Attempts; attempt++)
+ {
+ exchanges.Clear();
+ diagnostics.ExchangeObserver = (req, rep) => exchanges.Add(Describe(req, rep));
+ var requestsBefore = diagnostics.RequestsSent;
+ await Task.WhenAll(runtimes.Select(r => r.Rt.ReadAsync(ct)));
+ var requests = diagnostics.RequestsSent - requestsBefore;
+ diagnostics.ExchangeObserver = null;
+ checkOpens($"batch {attempt + 1}");
+ done.Add((requests, exchanges.ToList(), runtimes.Select(r => Format(r.Spec, r.Rt)).ToList()));
+ await Task.Delay(1000, ct);
+ }
+
+ var afterValues = await SequentialAsync();
+ return (beforeValues, done, afterValues);
+ }
+ finally
+ {
+ diagnostics.ExchangeObserver = null;
+ foreach (var (_, rt) in runtimes) rt.Dispose();
+ }
+ });
+
+ Log($"## Mixed batch — {target}");
+ Log($"Tags: {string.Join(" ; ", specs.Select(s => $"{s.Name} ({s.Type}{(s.Count > 1 ? $"[{s.Count}]" : "")})"))}");
+ Log($"Native Forward Opens: {opens}; Forward Closes sent/acked: {closes.Sent}/{closes.Acked}");
+ var oneMsp = 0;
+ for (var n = 0; n < attempts.Count; n++)
+ {
+ var (requests, records, values) = attempts[n];
+ var single = requests == 1 && records.Count == 1 && records[0].Service == 0x0A && records[0].Embedded == specs.Count;
+ if (single) oneMsp++;
+ Log($"Attempt {n + 1}: {specs.Count} concurrent reads -> {requests} channel send(s); {(single ? "ONE MSP carrying all" : "split")}:");
+ foreach (var e in records)
+ Log($" - request service 0x{e.Service:X2}, {e.RequestBytes} B -> reply {e.ReplyBytes} B, embedded={e.Embedded}: {e.Detail}");
+ records.ShouldAllBe(e => !e.Detail.Contains("status=0x06"), "a 0x06 inside the MSP would mean a truncated reply");
+ records.Sum(e => e.Embedded).ShouldBe(specs.Count);
+ records.ShouldAllBe(e => e.Service == 0x0A || e.Embedded == 1);
+ }
+ Log("| Tag | single (before) | " + string.Join(" | ", Enumerable.Range(1, attempts.Count).Select(i => $"batch {i}")) + " | single (after) | all batch values match a bracketing single |");
+ Log("|---|---|" + string.Concat(Enumerable.Repeat("---|", attempts.Count)) + "---|---|");
+ var failures = new List();
+ for (var i = 0; i < specs.Count; i++)
+ {
+ var ok = attempts.All(a => a.Values[i] == before[i] || a.Values[i] == after[i]);
+ if (!ok) failures.Add(specs[i].Name);
+ Log($"| `{specs[i].Name}` | {before[i]} | {string.Join(" | ", attempts.Select(a => a.Values[i]))} | {after[i]} | {(ok ? "yes" : "**NO**")} |");
+ }
+ Log($"Attempts that carried every read in ONE MSP: {oneMsp}/{attempts.Count}");
+
+ opens.ShouldBe(1);
+ closes.Sent.ShouldBe(1);
+ oneMsp.ShouldBeGreaterThan(0, "at least one attempt must put the whole mixed batch in a single Multiple Service Packet");
+ failures.ShouldBeEmpty("a batch value matched neither bracketing single read");
+ }
+
+ [Fact]
+ public async Task Symbol_listing_pages_are_ascending_on_a_real_controller()
+ {
+ var target = DifferentialTarget.FromEnvironment();
+ if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
+ if (Environment.GetEnvironmentVariable("AB_EVID_BROWSE") != "1") Assert.Skip("AB_EVID_BROWSE=1 not set.");
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
+ var ct = cts.Token;
+ var diagnostics = DeviceSessionPool.Shared.Diagnostics;
+ var pages = new List<(string Path, List Ids, bool More)>();
+ var gate = new object();
+ var factory = AbCipTransportFactories.For(AbCipTransport.Native).Enumerators;
+
+ var (count, opens, closes) = await DifferentialRun.WithNativeAnchorAsync("evidence browse", target, async checkOpens =>
+ {
+ diagnostics.ExchangeObserver = (req, rep) =>
+ {
+ if (req.Span.Length == 0 || req.Span[0] != 0x55) return;
+ var page = ParseListingPage(req.Span, rep.Span);
+ if (page is { } p) lock (gate) pages.Add(p);
+ };
+ try
+ {
+ var n = 0;
+ await DifferentialRun.PaceAsync(target, ct);
+ using var enumerator = factory.Create();
+ await foreach (var _ in enumerator.EnumerateAsync(target.Params("@tags"), ct))
+ {
+ n++;
+ if (n % 200 == 0) checkOpens($"browse entry {n}");
+ }
+ checkOpens("browse");
+ return n;
+ }
+ finally { diagnostics.ExchangeObserver = null; }
+ });
+
+ Log($"## Listing order — {target}");
+ Log($"Tags browsed: {count}; listing pages observed: {pages.Count}; Forward Opens {opens}; Forward Closes {closes.Sent}/{closes.Acked}");
+ var nonAscending = 0;
+ for (var i = 0; i < pages.Count; i++)
+ {
+ var (path, ids, more) = pages[i];
+ var strictly = true;
+ for (var k = 1; k < ids.Count; k++) if (ids[k] <= ids[k - 1]) { strictly = false; break; }
+ if (!strictly) nonAscending++;
+ var sample = ids.Count <= 12
+ ? string.Join(",", ids)
+ : string.Join(",", ids.Take(6)) + ",...," + string.Join(",", ids.Skip(ids.Count - 4));
+ Log($"page {i + 1}: scope={path} entries={ids.Count} first={(ids.Count > 0 ? ids[0] : 0)} last={(ids.Count > 0 ? ids[^1] : 0)} " +
+ $"max={(ids.Count > 0 ? ids.Max() : 0)} more={more} ascending={strictly} ids=[{sample}]");
+ }
+ Log($"Non-ascending pages: {nonAscending}");
+ pages.ShouldNotBeEmpty();
+ opens.ShouldBe(1);
+ }
+
+ private sealed record MixSpec(string Name, AbCipDataType Type, int Count);
+
+ private static List ParseMix(string raw) => raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(e =>
+ {
+ var p = e.Split('|');
+ return new MixSpec(p[0], Enum.Parse(p[1], ignoreCase: true),
+ p.Length > 2 ? int.Parse(p[2], CultureInfo.InvariantCulture) : 1);
+ }).ToList();
+
+ private static string Format(MixSpec spec, IAbCipTagRuntime rt)
+ {
+ if (spec.Count > 1)
+ return rt.DecodeArray(spec.Type, spec.Count) is System.Collections.IEnumerable e
+ ? "[" + string.Join(",", e.Cast