- docs/drivers/FOCAS.md and docs/v2/implementation/focas-wire-protocol.md pointed at focas-deployment.md and focas-simulator-plan.md, both of which were untracked drafts that have since been removed. Drop the refs (the wire-protocol companion now stands on its own; deployment guidance lives inline in the FOCAS driver doc). - Link the orphan v2 design docs from docs/README.md (multi-host dispatch, v2 release readiness, the historical lmx-followups tracker) and from modbus-test-plan.md (s7.md, mitsubishi.md per-family quirk catalogs, sibling to dl205.md). Surfaced by the doc audit; no content changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
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
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
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
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
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 count21 01= function code / operation tag —0x21is seen in public FOCAS reverse-engineering notes associated with "system info" / "controller identification" queries00 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 <prefix><magic><echoed body> (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
0x2101query. Stream C.2 Wireshark trace gives us the exact byte pattern Fwlib expects. - Published FOCAS response specs for sub-function
0x2101— not present instrangesast/fwlibheaders; 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:
- Magic prefix
0xA0A0A0A0confirmed - Handshake request format decoded (
magic + 4-byte negotiation) - Handshake response format that Fwlib accepts (
magic + echo + handle + api) - Post-handshake frame format decoded (
prefix + magic + body) - First post-handshake function code observed (
0x2101— likely system-info) - Error code progression
EW_SOCKET→EW_PROTOCOLconfirms our framing is structurally correct
What we can't prove without bench CNC or reference docs:
- The exact 6-byte response body Fwlib expects for
0x2101 - The full list of post-handshake function codes + their body shapes
- 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.pyresponse to return 10-byte ODBM:short datano, short dummy, int32 mcr_val, short dec_val - Rewrite
server/handlers/param.pyresponse to return 36-byte IODBPSD:short datano, short type, bytes[32] u - Rewrite
server/handlers/pmc.pyresponse 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.
Phase 3 — flip the C# test gate
Once Phase 2 proves Fwlib64 can talk to the mock:
- Flip
OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1in the CI env - Expand
tests/.../IntegrationTests/Series/WireCompatGatedTests.cswith real per-series assertions - Update
scripts/e2e/test-focas.ps1to accept-ProfileName - Close Stream D
References
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs— P/Invoke surface, authoritative struct layoutssrc/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs— reference C# implementation of each FWLIB callsrc/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/fwlibon GitHub — redistributesfwlib32.h+ runtime binaries; no wire protocol docs