# FOCAS wire protocol — what's authoritative vs. what's guessed Written during Stream B on 2026-04-23 after a research pass through `strangesast/fwlib` + public FOCAS documentation. Purpose: separate what we *know* about the FOCAS wire protocol (can quote with confidence) from what we're *guessing* (will need Wireshark traces to validate in Stream C). This document directly informs `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/server/`. ## Authoritative — from Fanuc's public `fwlib32.h` The header file is distributed with the FOCAS Developer Kit and mirrored in OSS repos (notably `strangesast/fwlib`). The **struct layouts** documented there are stable across FOCAS versions and authoritative for the payload shapes our Python mock has to emit. ### ODBM — macro variable read buffer ```c typedef struct odbm { short datano; // macro variable number short dummy; // reserved / alignment padding long mcr_val; // 32-bit signed macro value short dec_val; // decimal-point count (0-9) } ODBM; ``` With `#pragma pack(push, 4)` (the FOCAS default), total size is **10 bytes** on Windows: 2 + 2 + 4 + 2. Our `FwlibNative.cs` matches this exactly. Our mock's `_READ_RESP_STRUCT = struct.Struct(">iH")` is **only 6 bytes** — missing `datano` + `dummy`. A real Fwlib decoding the scaffold response will read garbage. Stream C fix: prepend two `short` fields. ### IODBPSD — CNC parameter read/write buffer ```c typedef struct iodbpsd { short datano; // parameter number short type; // axis index (0 for non-axis parameters) union { char cdata; short idata; long ldata; char cdatas[MAX_AXIS]; // MAX_AXIS varies — 8 on 0i, 32 on 30i short idatas[MAX_AXIS]; long ldatas[MAX_AXIS]; } u; } IODBPSD; ``` With `pack(4)` and `MAX_AXIS=8`, total size = 2 + 2 + 32 = **36 bytes**. Our `FwlibNative.cs` matches this (`[SizeConst = 32]` data buffer). Our mock's current param handler doesn't return bytes in IODBPSD shape — response payload is just the raw value. Stream C fix: wrap in 4-byte header + union-padded data. ### ODBST — status info ```c typedef struct odbst { short dummy; // reserved short tmmode; // Memory / Tape / MDI / EDIT / DNC short aut; // automatic mode short run; // running state short motion; // motion state short mstb; // M/S/T/B finish signal short emergency; // emergency stop short alarm; // alarm state short edit; // edit mode sub-state } ODBST; ``` 9 × short = **18 bytes**. Our mock already emits 18 bytes via `struct.Struct(">9h")`. ✓ correct. ### IODBPMC — PMC range read/write buffer ```c typedef struct iodbpmc { short type_a; // PMC address letter encoded as ADR_* numeric code short type_d; // data type: 0=byte, 1=word, 2=long, 4=float, 5=double unsigned short datano_s; // start address number unsigned short datano_e; // end address number union { char cdata[5]; short idata[5]; long ldata[5]; float fdata[5]; double dbdata[5]; } u; // 40-byte union (widest = dbdata = 5×8 bytes) } IODBPMC; ``` With `pack(4)` the union is 40 bytes; struct total = 8 + 40 = **48 bytes**. Our `FwlibNative.cs` matches this. Our mock's PMC handler takes a different layout (uint16 handle + uint8 letter + ...). Stream C fix: rewrite to IODBPMC shape. ## Reference trace findings (2026-04-23 dev-box reversing) **Good news** — we don't need a bench CNC for first-pass reversing. Loading `Fwlib64.dll` in `otopcua-focas-cli` + pointing it at our Python simulator on `127.0.0.1:8193` + enabling `OTOPCUA_FOCAS_RAW_CAPTURE=1` on the sim lets us observe Fwlib's outbound bytes + iterate on reply shapes. Each cycle is ~5s; progress measure is "Fwlib sends more bytes before disconnecting". ### Confirmed wire facts **Magic prefix** — every frame Fwlib sends begins with `0xA0 0xA0 0xA0 0xA0` (4 bytes). This is NOT a length prefix — our scaffold tried to decode it as uint32-big-endian = 2.7 GB and died. It's a fixed protocol marker. **Handshake request** — `cnc_allclibhndl3` produces this 8-byte frame: ``` a0 a0 a0 a0 00 01 01 01 └─ magic ─┘ └── negotiation ──┘ ``` The 4-byte negotiation field is stable across our observations (always `00 01 01 01`). Interpretation TBD — possibly `(version_major=0x0001, version_minor=0x0101)` or `(protocol=0x01, subtype=0x010101)`. **Handshake reply that Fwlib accepts** (empirically confirmed — doesn't disconnect): ``` a0 a0 a0 a0 00 01 01 01 00 XX 00 YY └─ magic ─┘ └── echo ──┘ handle api_version ``` 12 bytes: magic + echoed negotiation + 2-byte handle + 2-byte api_version code. ### Post-handshake frame shape — decoded via drain mode The simulator's `OTOPCUA_FOCAS_DRAIN_AFTER_HANDSHAKE=1` mode reads all inbound bytes for 1000 ms after the handshake reply without attempting any decode. Captured payload from `cnc_allclibhndl3`: ``` 00 02 00 02 a0 a0 a0 a0 00 01 21 01 00 00 └── prefix ─┘ └── magic ─┘ └─── body ────┘ 4 bytes 4 bytes 6 bytes (total = 14 bytes) ``` **Key discovery**: post-handshake frames have a **4-byte prefix BEFORE the magic**, not magic-first. Frame shape: ``` uint16 msg_counter // starts at 2; handshake was #1 implicitly uint16 handle_echo // matches the handle our open reply returned 4 bytes FOCAS_MAGIC // 0xA0A0A0A0 N bytes body // function-specific ``` Session 1's drain captured only the prefix (`00 02 00 01`) before timing out — TCP multiplexed the two test sessions's bytes differently. Session 2 caught the full 14-byte frame. ### Body bytes — first post-handshake request Body on `cnc_allclibhndl3` first post-handshake frame: ``` 00 01 21 01 00 00 ``` Informed guesses (unvalidated): - `00 01` = body length (1 useful byte?) or sub-request count - `21 01` = function code / operation tag — `0x21` is seen in public FOCAS reverse-engineering notes associated with "system info" / "controller identification" queries - `00 00` = padding / reserved Likely this is Fwlib's "tell me what CNC you are" query — part of `cnc_allclibhndl3`'s internal handshake continuation before the handle is fully established. Returning an empty or malformed response causes Fwlib to declare the far end "not a CNC" and error with `EW_FUNC` (16). ### Iteration 3 — echo response, error-code advances Sending back `` (14 bytes matching request shape) advances Fwlib's client-side error code from **`EW_-16` (socket-level)** to **`EW_-17` (protocol-level rejection)**. Fwlib reads our response in full before disconnecting with `peer closed mid-frame`. Meaning: our **frame structure is correct enough** that Fwlib parses it as a valid FOCAS frame; the **body content** (the 6 bytes after magic) is where the semantic mismatch now lives. Fwlib expects specific bytes back for the `0x2101` system-info query and an echo doesn't match. ### Current iteration block Going deeper without reference requires either: - **A bench CNC** (#54) to capture a real response to the `0x2101` query. Stream C.2 Wireshark trace gives us the exact byte pattern Fwlib expects. - **Published FOCAS response specs** for sub-function `0x2101` — not present in `strangesast/fwlib` headers; likely only in the licensed Developer Kit binary docs. - **Blind enumeration** — try N variations of the 6-byte body response until Fwlib's error code changes again. High cost, low signal. The first two are both blocked on resources we don't have. The third is ~hundreds of cycles with no guarantee of convergence. ### Diminishing-returns checkpoint **What we've proven without hardware**: 1. Magic prefix `0xA0A0A0A0` confirmed 2. Handshake request format decoded (`magic + 4-byte negotiation`) 3. Handshake response format that Fwlib accepts (`magic + echo + handle + api`) 4. Post-handshake frame format decoded (`prefix + magic + body`) 5. First post-handshake function code observed (`0x2101` — likely system-info) 6. Error code progression `EW_SOCKET` → `EW_PROTOCOL` confirms our framing is structurally correct **What we can't prove without bench CNC or reference docs**: 1. The exact 6-byte response body Fwlib expects for `0x2101` 2. The full list of post-handshake function codes + their body shapes 3. Whether subsequent frames use length prefixes or fixed body sizes **Recommendation**: checkpoint here. The framing discoveries above are preserved in `server/frames.py` + `server/state.py` + `server/focas_server.py` + `server/handlers/__init__.py`. When bench-CNC access unblocks Stream C.2's reference trace, the iteration loop (with the framing work already done) should converge in hours rather than days. ### Still unknown - **Response shape** for the post-handshake body request — we can frame the prefix + magic correctly now, but what the 6-byte body response should carry (CNC series ID? version? capability flags?) needs further iteration. - **Function-id numeric values** for the 9 FWLIB calls our driver makes — one per call, need to be observed separately. - **Error encoding** on the wire. ### Next iteration cycles With the handshake working, each subsequent function gets its own probe-and-observe loop. The simulator now has a `RAW_FRAME_MARKER = 0xFFFF` sentinel that lets a handler return exact wire bytes (bypassing the scaffold envelope) — use that to try different post-handshake replies and watch Fwlib's reaction. ## Stream C work order Given what's authoritative vs. guessed, here's the most efficient path: ### Phase 1 — payload shapes (no hardware required) - [ ] Rewrite `server/handlers/macro.py` response to return 10-byte ODBM: `short datano, short dummy, int32 mcr_val, short dec_val` - [ ] Rewrite `server/handlers/param.py` response to return 36-byte IODBPSD: `short datano, short type, bytes[32] u` - [ ] Rewrite `server/handlers/pmc.py` response to return 48-byte IODBPMC: `short type_a, short type_d, uint16 datano_s, uint16 datano_e, bytes[40] u` - [ ] Add unit tests asserting byte-exact sizes - [ ] Update validate_harness.py to match the new shapes Effect: when Stream C gets its first Wireshark trace, the payload-layer of the mock is already correct. Only the framing layer needs iteration. ### Phase 2 — framing (requires hardware) This is the iterative Wireshark loop — no point starting until the Windows rig + licensed Fwlib64.dll + real CNC are all available. See the implementer's checklist in [`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md`](../../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md). ### Phase 3 — flip the C# test gate Once Phase 2 proves Fwlib64 can talk to the mock: - [ ] Flip `OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1` in the CI env - [ ] Expand `tests/.../IntegrationTests/Series/WireCompatGatedTests.cs` with real per-series assertions - [ ] Update `scripts/e2e/test-focas.ps1` to accept `-ProfileName` - [ ] Close Stream D ## References - [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs) — P/Invoke surface, authoritative struct layouts - [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs) — reference C# implementation of each FWLIB call - [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs) — EW_* → OPC UA status mapping - Fanuc FOCAS Developer Kit (licensed, not in repo) — ultimate source of truth - `strangesast/fwlib` on GitHub — redistributes `fwlib32.h` + runtime binaries; no wire protocol docs