[R3/R4 Path A] mxaccess: port Lmx.dll FUN_10100ce0 synthesizer kernel
Path A landed for R3/R4. The byte->MxStatus synthesizer in Lmx.dll is
FUN_10100ce0 (`analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md`),
a 4-byte u32 LE -> 4-tuple MxStatus decoder used by every NMX-frame
parser in Lmx.dll. The kernel is byte-deterministic and context-free,
so it ports as a pure function -- the operation-tracking state
machine the original verdict deferred is NOT required for synthesis.
Bit layout (per FUN_10100ce0 lines 21-24):
bit 31: success (-1 if set, 0 if clear)
bits 27..24: category (4 bits)
bits 23..20: detected_by (4 bits)
bits 15..0: detail (i16 -- low 16 bits, signed)
bits 30..28, 19..16: reserved/padding
Codec changes:
- MxStatus::from_packed_u32() / ::to_packed_u32() -- the kernel +
inverse for round-trip parity.
- MxStatus::from_nmx_response_code() -- the constructed-from-response-
code switch in FUN_1010bd10:741-770 (six proven mappings: 0x01, 0x02
-> CommunicationError + RequestingNmx; 0x03 -> ConfigurationError +
RequestingNmx; 0x04 -> ConfigurationError + RespondingNmx; 0x05 ->
CommunicationError + RespondingNmx; 0x1A -> CommunicationError +
RequestingNmx).
- MxStatusCategory / MxStatusSource: from_i16/to_i16 promoted to const
fn so MxStatus::from_packed_u32 can be const.
- NmxOperationStatusMessage::try_parse_process_data_received_body() --
thin wrapper that peels the outer NmxObservedEnvelope before
delegating to try_parse_inner. Mirrors
NmxOperationStatusMessage.TryParseProcessDataReceivedBody (.NET cs:20-32).
- NmxOperationStatusMessage::promote_to_typed() -- entry point that
returns the existing Status field. Documented as a no-op pass-through
for now (the 5-byte inner-body wire shape is NOT the same field as
the 4-byte packed-u32 the kernel decodes); kept for API symmetry.
- 22 new round-trip tests covering the kernel, the response-code
switch, the proven 0x00/0x41/0xEF completion bytes, and round-trip
for every canonical sentinel.
mxaccess (Session) changes:
- New OperationKind enum (Write/WriteSecured/Read/Subscribe/
Unsubscribe/Activate/Suspend/Other).
- New OperationContext struct (correlation_id, op_kind, reference,
retry_count) -- ground for the F54 follow-on per-operation
correlation work.
- New OperationStatus event type {raw, status, context,
is_during_recovery}, mirroring MxNativeOperationStatusEvent (cs:73-78)
with the typed-MxStatus addition.
- Session::operation_status_events() -> broadcast::Receiver<Arc<
OperationStatus>> + operation_status_stream() Stream variant.
- callback_router() now tries operation-status parsing first, falling
through to subscription messages -- matches MxNativeSession
.OnCallbackReceived dispatch order (cs:574,582,590).
- recover_connection() flips a recovery_active counter (Arc<AtomicU32>
shared with the router) so OperationStatus.is_during_recovery is
populated correctly. Mirrors MxNativeSession._recoveryActive
Volatile.Read at cs:573.
- 3 new router tests covering: status-word frame dispatch + typed
promotion to WriteCompleteOk; completion-only frames stay verbatim;
is_during_recovery is stamped from the live counter.
Per-operation context tracking (correlating completion frames back to
outstanding writes/subscribes via the correlation_id) is filed as F54
in design/followups.md. The synthesizer kernel itself is byte-
deterministic, so the kernel and the correlation work are decoupled.
Ghidra evidence (the next-ring xref walk beyond FUN_10114a90):
- analysis/ghidra/exports/Lmx.dll.set-attribute-result-xrefs.md --
xrefs to OnSetAttributeResult / CancelWithStatus / OperationComplete.
- analysis/ghidra/exports/Lmx.dll.vtable-data-xrefs.md -- vtable-slot
data xrefs for the virtual-dispatch path.
- analysis/ghidra/exports/Lmx.dll.synthesizer-decompile.md --
ScanOnDemandCallback::OperationComplete/MultipleOperationComplete
(FUN_1010b990), RemotePlatformResolver::OperationComplete
(FUN_1010dc80), and the constructed-from-responseCode synthesizer
in FUN_1010bd10 (lines 698-770). FUN_1010bd10 is the wire-frame
receiver that drives the synthesis.
- analysis/ghidra/exports/Lmx.dll.synthesizer-helpers-decompile.md --
FUN_10003fc0 (the <success %d category %d ...> formatter; confirms
the 4-tuple layout), FUN_1008f150 (dispatch helper).
- analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md --
FUN_10100ce0 (the kernel itself), FUN_10100bc0 (3xu16 reader),
FUN_1005e580 (4-byte stream reader), FUN_1010ee00 (sister NMX-frame
parser using the same kernel).
- analysis/ghidra/exports/Lmx.dll.synthesizer-callers-xrefs.md --
caller graph; confirms the kernel is called from many wire-frame
parsers but each parser shares the single 4-byte decoder.
R3/R4 verdict updated in design/70-risks-and-open-questions.md from
"settled at verbatim-preserve" to "settled per Path A". F54 filed in
design/followups.md for the per-operation correlation work.
cargo build / test / clippy -D warnings / RUSTDOCFLAGS=-D warnings doc
all clean. cargo public-api baselines regenerated for mxaccess and
mxaccess-codec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,46 +40,74 @@ The `OnBufferedDataChange` **public event shape** the wwtools api-notes describe
|
||||
|
||||
**Settles when:** ✅ settled per option (a). Reopen only if a future capture surfaces a per-record layout that diverges from the established 15-byte fixed-prefix-plus-value shape — which would require evidence beyond what F44 found.
|
||||
|
||||
### R3 — `OperationComplete` trigger unproven **(settled 2026-05-06 — no mapping table exists; verbatim-preserve is the canonical answer)**
|
||||
### R3 — `OperationComplete` trigger unproven **(settled 2026-05-06 — Path A landed: synthesizer kernel + typed `OperationStatus` events ported)**
|
||||
|
||||
**Severity: P1** (was a blocker; now settled per option: verbatim preserve is the canonical native behaviour)
|
||||
**Severity: P1** (was a blocker; settled per Path A — typed promotion landed via `MxStatus::from_packed_u32`)
|
||||
|
||||
**Status (2026-05-06): SETTLED.** Five-stage Ghidra headless decompile traced the byte-to-`MXSTATUS_PROXY` synthesis path end-to-end across `Lmx.dll` and `LmxProxy.dll`. Logs:
|
||||
- `analysis/ghidra/exports/Lmx.dll.aadct-decompile.md` — `aaDCT` symbol
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.completion-status-decompile.md` — Fire_* event handlers
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.fire-event-xrefs.md` — xrefs to Fire_*
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.status-synthesis-decompile.md` — Fire_* callers (`FUN_1001657f` / `FUN_10016b50` / `FUN_10016d4b`)
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.mxstatus-safearray-decompile.md` — `FUN_10003f60` (the SafeArray creator)
|
||||
- `analysis/ghidra/exports/Lmx.dll.set-attribute-result-decompile.md` — `PreboundReference::OnSetAttributeResult` (`FUN_10114a90`)
|
||||
**Status (2026-05-06): SETTLED PER PATH A.** The five-stage Ghidra walk that previously settled the verdict at "verbatim preserve" was extended with a sixth stage that found the actual byte→`MXSTATUS_PROXY` synthesizer. It is **`Lmx.dll!FUN_10100ce0`** — a single 4-byte u32 LE → `MxStatus` decoder used by every NMX-frame parser in `Lmx.dll`. Bit layout:
|
||||
|
||||
```
|
||||
bit 31: success (-1 if set, 0 if clear)
|
||||
bits 27..24: category (4 bits, masked by 0xF)
|
||||
bits 23..20: detected_by (4 bits, masked by 0xF)
|
||||
bits 15..0: detail (i16 — low 16 bits, signed)
|
||||
bits 30..28, 19..16: reserved/padding
|
||||
```
|
||||
|
||||
The Rust port now ships this kernel as [`MxStatus::from_packed_u32`] (and the inverse `to_packed_u32` for round-trip parity). `Session::operation_status_events()` emits typed [`OperationStatus`] events for every `0x32`/`0x33`-or-similar callback the wire delivers; the synthesizer is byte-deterministic and context-free, so the operation-tracking state machine the original verdict deferred is **not** required for the kernel itself. Per-operation context tracking (correlating completion frames back to outstanding writes/subscribes) is filed as a follow-up: see F54 below.
|
||||
|
||||
A second mapping was also ported: `MxStatus::from_nmx_response_code` covers the constructed-from-response-code path in `Lmx.dll!FUN_1010bd10:741-770` (`ScanOnDemandCallback::GetResponse`), which builds an `MxStatus` from a 1-byte NMX `responseCode` field when no payload status word is present. Six proven mappings: `0x01`/`0x02` → `(CommunicationError, RequestingNmx)`, `0x03` → `(ConfigurationError, RequestingNmx)`, `0x04` → `(ConfigurationError, RespondingNmx)`, `0x05` → `(CommunicationError, RespondingNmx)`, `0x1A` → `(CommunicationError, RequestingNmx)`. Unmapped codes return `None` and the consumer falls back to verbatim preservation per CLAUDE.md "Do not fabricate protocol behavior."
|
||||
|
||||
**What about the 1-byte completion frames `0x00`/`0x41`/`0xEF`?** Those are NOT decoded by `FUN_10100ce0` — they're a different wire field (the NMX operation-status callback payload, not the `INmxService.GetResponse2 responseCode` parameter). `Lmx.dll`'s decoder for those frames does not invoke any status-synthesis logic; they propagate as raw byte → `MxStatus { success: 0, Unknown, Unknown, detail: byte }`. The Rust port preserves this exactly. R4 is settled by the same fact (see below).
|
||||
|
||||
Logs:
|
||||
- `analysis/ghidra/exports/Lmx.dll.aadct-decompile.md` — `aaDCT` symbol (stage 1)
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.completion-status-decompile.md` — Fire_* event handlers (stage 2)
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.fire-event-xrefs.md` — xrefs to Fire_* (stage 3)
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.status-synthesis-decompile.md` — Fire_* callers (stage 4)
|
||||
- `analysis/ghidra/exports/LmxProxy.dll.mxstatus-safearray-decompile.md` — `FUN_10003f60` (stage 5)
|
||||
- `analysis/ghidra/exports/Lmx.dll.set-attribute-result-decompile.md` — `PreboundReference::OnSetAttributeResult` (stage 6, entry to next ring)
|
||||
- `analysis/ghidra/exports/Lmx.dll.set-attribute-result-xrefs.md` — xrefs to `OnSetAttributeResult`/`CancelWithStatus`/`OperationComplete` (next-ring discovery)
|
||||
- `analysis/ghidra/exports/Lmx.dll.synthesizer-decompile.md` — `ScanOnDemandCallback::OperationComplete`/`MultipleOperationComplete` (`FUN_1010b990`), `RemotePlatformResolver::OperationComplete` (`FUN_1010dc80`), and the constructed-from-responseCode synthesizer `FUN_1010bd10` (lines 698-770)
|
||||
- `analysis/ghidra/exports/Lmx.dll.synthesizer-helpers-decompile.md` — `FUN_10003fc0` (the `<success %d category %d ...>` formatter), `FUN_1008f150` (the dispatch helper), `PreboundReference` constructors
|
||||
- `analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md` — **the synthesizer kernel `FUN_10100ce0`** (4-byte u32 → `MxStatus` decoder), `FUN_10100bc0` (3×u16 reader), `FUN_1005e580` (4-byte stream reader), `FUN_1010ee00` (sister NMX-frame parser using the same kernel)
|
||||
- `analysis/ghidra/exports/Lmx.dll.synthesizer-callers-xrefs.md` — caller graph for the synthesizer ring
|
||||
|
||||
Findings, layer by layer (the wire bytes flow inward; the synthesis flows outward):
|
||||
|
||||
1. **`Lmx.aaDCT`** at `0x10178fc0` is a `SysAllocString(L"Lmx.aaDCT")` into a global BSTR — a tracing category name, not a status-mapping table. No array / lookup logic.
|
||||
2. **`MXSTATUS_PROXY`** (16 bytes, Pack=4) is a 4-field marshalled struct: `success: i16` at offset 0, `category: i16` at offset 4, `detectedBy: i16` at offset 8, `detail: i16` at offset 12. It is the *output* of synthesis, not a lookup-table entry.
|
||||
3. **`LmxProxy.dll` Fire_* event handlers** (`FUN_10015f72`, `FUN_1001611f`, `FUN_10016271`, `FUN_100163c0`) take an *already-populated* `MXSTATUS_PROXY[]` and forward it through ATL connection-point dispatch. No synthesis here.
|
||||
4. **`LmxProxy.dll` Fire_* callers** (`FUN_1001657f` for OnDataChange / OnBufferedDataChange, `FUN_10016b50` for OnWriteComplete, `FUN_10016d4b` for OperationComplete) call **`FUN_10003f60(out_safearray, in_status_ptr, count=1)`** which creates the SafeArray. `FUN_10003f60` is **a verbatim memcpy** of an existing 14-byte buffer into the SAFEARRAY data — no transformation. Source confirms: bytes flow `*local_8 = *param_2; *(local_8+2) = *(param_2+2); *(local_8+4) = *(param_2+4); local_8[6] = param_2[6]`.
|
||||
5. **`Lmx.dll` `PreboundReference::OnSetAttributeResult`** (`FUN_10114a90`) — the CALLER of step 4's path — receives an already-populated `short *param_7` status buffer. Its log line confirms the layout: `swprintf_s(L"<success %d category %d detectedBy %d detail %d>", (i16)*p, *(p+2), *(p+4), p[6])`. Its dispatch logic checks typed values (`*local_b6c == -1`, `*(int *)(local_b6c + 2) == 3`) — synthesis is upstream of THIS function too.
|
||||
4. **`LmxProxy.dll` Fire_* callers** (`FUN_1001657f` for OnDataChange / OnBufferedDataChange, `FUN_10016b50` for OnWriteComplete, `FUN_10016d4b` for OperationComplete) call **`FUN_10003f60(out_safearray, in_status_ptr, count=1)`** which creates the SafeArray. `FUN_10003f60` is **a verbatim memcpy** of an existing 14-byte buffer into the SAFEARRAY data — no transformation.
|
||||
5. **`Lmx.dll` `PreboundReference::OnSetAttributeResult`** (`FUN_10114a90`) — the CALLER of step 4's path — receives an already-populated `short *param_7` status buffer; synthesis is upstream of THIS function too.
|
||||
6. **The synthesizer kernel itself**: **`Lmx.dll!FUN_10100ce0`** (see `analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md`). A 4-byte u32 LE read from a stream → 4-tuple `MxStatus` decoder. Pure transformation, no operation-context dependency. Used by every NMX-frame parser in `Lmx.dll` (`FUN_1010bd10` `ScanOnDemandCallback::GetResponse`, `FUN_1010ee00` `AccessManager::ProcessNmxRequest`, `FUN_10110986`, etc.) — the upstream decoder reads the wire bytes, the kernel translates them.
|
||||
7. **The constructed-when-no-bytes path**: when an NMX `responseCode != 0` arrives without a payload status word, `FUN_1010bd10:741-770` constructs an `MxStatus` from the responseCode itself via a fixed switch. Six proven response codes (1, 2, 3, 4, 5, 0x1A); see the table in the `MxStatus::from_nmx_response_code` doc.
|
||||
|
||||
**The synthesizer is the NMX-frame decoder in `Lmx.dll`** that calls `OnSetAttributeResult` / `OnGetAttributeResult` / equivalent OperationComplete handler. That decoder takes raw NMX bytes (e.g. 1-byte `0x00`/`0x41`/`0xEF` completion frames or 5-byte `00 00 50 80 00`-style status words) plus operation context (which item, which engine, retry state, last-write-correlation-id) and computes the populated `MXSTATUS_PROXY`. **There is no static lookup table** — the synthesis is per-message contextual.
|
||||
**Path A landed.** The synthesizer kernel and the constructed-from-response-code switch were both portable as pure functions — no operation-tracking state machine required for the kernel itself, because `FUN_10100ce0` is byte-deterministic. Rust port:
|
||||
|
||||
**Why this means R3/R4 stay at "verbatim preserve" canonically.** Two viable paths exist if a future consumer demands typed promotion (neither is a small Rust patch):
|
||||
- `mxaccess-codec::status::MxStatus::from_packed_u32(packed: u32) -> MxStatus` — the kernel.
|
||||
- `mxaccess-codec::status::MxStatus::to_packed_u32() -> u32` — inverse, for round-trip parity.
|
||||
- `mxaccess-codec::status::MxStatus::from_nmx_response_code(byte: u8) -> Option<MxStatus>` — the response-code switch.
|
||||
- `mxaccess::OperationKind` + `mxaccess::OperationContext` types for future correlation work (per-operation tracking is filed as F54).
|
||||
- `mxaccess::Session::operation_status_events()` returns `broadcast::Receiver<Arc<OperationStatus>>`; `operation_status_stream()` returns the `Stream<Item = Result<...>>` variant.
|
||||
- `mxaccess::OperationStatus { raw, status, context, is_during_recovery }` — matches `MxNativeOperationStatusEvent` (`MxNativeSession.cs:73-78`) plus typed `MxStatus` promotion.
|
||||
- The callback router (`session::callback_router`) now tries operation-status parsing first, mirroring `MxNativeSession.OnCallbackReceived:574`.
|
||||
|
||||
- **Path A — port the synthesizer.** Find every NMX-decoder callsite of `OnSetAttributeResult`/`OnGetAttributeResult`/`OperationComplete` in `Lmx.dll` (next xref ring beyond `FUN_10114a90`); decompile each; reverse-engineer the per-decoder synthesis logic; port to Rust. The synthesis depends on operation-tracking state (item handles, retry counters, correlation ids) the Rust codec does not currently track — so the port is more than a codec change; it's a session-state-machine extension. Estimate: ~1-2 weeks of focused work.
|
||||
- **Path B — empirical capture pairs.** Run the .NET probe in scenarios that produce each completion byte; capture the (input NMX bytes, observed `MXSTATUS_PROXY`) pair via Frida hooks on `LmxProxy.dll!FUN_10003f60`; build an empirical (byte + context → status) mapping. ~30 min × 6-10 scenarios. Output: a mapping table that approximates the synthesizer without re-implementing it. Risk: the mapping is only valid for the captured operation contexts; new contexts may produce statuses outside the table.
|
||||
**What about the 1-byte completion frames `0x00`/`0x41`/`0xEF`?** They are NOT decoded by `FUN_10100ce0` (they're a different wire field at a different layer — the NMX operation-status callback payload, not the `INmxService.GetResponse2` responseCode parameter). Per CLAUDE.md "Do not fabricate protocol behavior" they continue to propagate as `MxStatus { success: 0, Unknown, Unknown, detail: byte }`. R4 is settled by the same fact.
|
||||
|
||||
**Current best answer:** unchanged — `Session::operation_status_events()` exposes `Stream<Item = RawOperationStatus>` carrying frame bytes. Promote to a typed `WriteCompleted` only on the proven `00 00 50 80 00` 5-byte pattern. Other bytes stay raw as `MxStatus { Success: 0, Category: Unknown, DetectedBy: Unknown, Detail: byte }`. The Rust codec mirrors `src/MxNativeCodec/NmxOperationStatusMessage.cs:TryParseInner`. The .NET reference does the same, for the same reason: the synthesizer is too context-dependent to mirror without porting the entire operation-tracking state machine, and that exceeds V1 scope.
|
||||
**Current best answer:** Path A landed. `Session::operation_status_events()` emits typed `OperationStatus` events. The synthesizer kernel (`MxStatus::from_packed_u32`) is exposed for any consumer that holds a 4-byte packed status word (e.g. extracted from a subscription record's `status: i32` field). Per-operation context (correlating completion frames back to outstanding writes/subscribes) is the next step — filed as F54.
|
||||
|
||||
**Reopen when:** either (a) a consumer files a concrete use case for typed promotion of a specific byte+context combination — at which point Path B's empirical capture for that one combination is the cheapest answer; or (b) a major-version bump justifies the operation-tracking state-machine port (Path A). Until then, verbatim preservation is correct by construction.
|
||||
**Reopen when:** F54 lands per-operation correlation, or a future capture surfaces a fresh wire field whose synthesis logic doesn't reduce to `FUN_10100ce0` + `from_nmx_response_code` (no such field has been observed to date).
|
||||
|
||||
### R4 — Completion-only byte mapping **(settled 2026-05-06 — collapses into R3's resolution)**
|
||||
### R4 — Completion-only byte mapping **(settled 2026-05-06 — verbatim-preserve confirmed; synthesizer doesn't apply at this layer)**
|
||||
|
||||
**Severity: P1** (was a blocker; now settled per the same R3 finding)
|
||||
**Severity: P1** (was a blocker; now settled per the same R3 Path A finding — by exclusion)
|
||||
|
||||
**Status (2026-05-06): SETTLED.** Same Ghidra walk as R3. The 1-byte completion frames `0x00`, `0x41`, `0xEF` (`work_remain.md:164–174`) are the same intermediate NMX signals that R3 covers. There is no static `MXSTATUS_PROXY[]` byte-indexed table to mirror because the native LMX proxy synthesises `MXSTATUS_PROXY` structs per-event from operation context, not from a lookup.
|
||||
**Status (2026-05-06): SETTLED.** R3's Path A walk traced the byte→`MxStatus` synthesizer to **`Lmx.dll!FUN_10100ce0`**, a 4-byte u32 LE → `MxStatus` decoder. The 1-byte completion frames `0x00`, `0x41`, `0xEF` (`work_remain.md:164–174`) are NOT input to that decoder — they're a different wire field, observed at a different layer (the NMX operation-status callback payload, not the `INmxService.GetResponse2` responseCode parameter or any 4-byte packed status field). `Lmx.dll`'s decoder for the 1-byte completion-only inner body does not invoke any synthesis logic; the bytes propagate untransformed.
|
||||
|
||||
**Current best answer:** unchanged — preserve as `RawOperationStatus(u8)` mapping to `MxStatus { Success: 0, Category: Unknown, DetectedBy: Unknown, Detail: byte }`. The .NET reference does the same; the Rust port matches.
|
||||
**Current best answer:** unchanged — preserve as `MxStatus { Success: 0, Category: Unknown, DetectedBy: Unknown, Detail: byte }`. `mxaccess-codec::NmxOperationStatusMessage::promote_to_typed` returns the verbatim placeholder for these frames; `mxaccess::Session::operation_status_events()` surfaces them via the typed `OperationStatus.status` field with the byte preserved in `detail`.
|
||||
|
||||
**Reopen when:** same condition as R3 — a context-aware capture that establishes the synthesis logic per-byte under varying operation context.
|
||||
**Reopen when:** a fresh capture proves a synthesis rule for a specific 1-byte completion code under a specific operation context (e.g. via Frida pairs `LmxProxy.dll!FUN_10003f60` input vs. observed event payload). At that point file a sub-followup with the captured `(byte, context, observed status)` triple and decide whether to add a typed mapping.
|
||||
|
||||
### R5 — Activate / Suspend behaviour **(partially observed — F44 documented client-side trigger; wire-side residual gap filed as F46, hook landed pending live re-run)**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user