NI/CPIC Framing Layer¶
Status: CONFIRMED — live capture (2026-06-26), golden fixtures committed under
tests/golden/framing/.
Confidence: HIGH — byte-exact STFC_CONNECTION capture against SAP NetWeaver 7.58,
replayed in CI.
Overview¶
The SAP Network Interface (NI) layer is the lowest-level framing abstraction in the SAP RFC protocol stack. It sits directly above TCP and provides message-boundary framing on the byte-stream TCP transport. Every RFC message — function call, handshake packet, or PING — is wrapped in an NI frame.
The layering from the application down to the wire:
RFC call — function name + ABAP parameters
→ TLV serialization — parameters encoded as tagged records
→ session/call records — COM_HEAD (logon frame only) + session and call TLVs
→ NI framing — prepend 4-byte big-endian length
→ TCP — a single writev() of header + payload
Framing is symmetric: the read path strips the 4-byte header before handing the payload up.
Live capture finding (2026-06-26): RFC connects to port 3300 (SAP Gateway =
3300 + sysnr), not port 3200 (dispatcher). The gateway adds a 76-byte APPC/CPI-C transport
header to each RFC data frame, between the NI header and the RFC TLV stream. See the
"APPC/Gateway Header" section below.
NI Frame Format¶
Wire Layout¶
Offset Length Type Name Notes
0x00 4 uint32-BE payload_length Length of payload ONLY. Does NOT include these 4 bytes.
0x04 N bytes payload RFC message body (N = payload_length)
Total frame size on wire: 4 + payload_length bytes.
Evidence¶
Tier: live capture. Every frame in tests/golden/framing/ begins with a 4-byte
big-endian length whose value equals len(frame) - 4. The relationship holds across all
committed fixtures — request and response, minimum 128 bytes and maximum 6091 bytes — so the
header is fixed-width and the length excludes itself.
Both directions use the same framing: the read path consumes 4 bytes, byte-swaps them from network order, and then reads exactly that many payload bytes.
# The whole of NI framing, verified against every golden fixture
assert struct.unpack_from(">I", frame, 0)[0] == len(frame) - 4
The header and payload are written together in a single scatter-gather write, so a frame is
not observable in two pieces on a healthy connection — do not rely on that for parsing,
though: TCP may still split it, and transport.py reassembles.
RAW_MODE¶
Some internal connection types run unframed — no length header in either direction. Standard
RFC always uses framed mode, and saprfclib implements only the framed path.
NI Control Messages¶
The NI layer handles 8-byte ASCII control messages itself, before passing data up to the RFC layer.
| Message | Bytes (ASCII+NUL) | Purpose |
|---|---|---|
NI_PING\0 |
4e 49 5f 50 49 4e 47 00 |
NI-level keepalive ping |
NI_PONG\0 |
4e 49 5f 50 4f 4e 47 00 |
Response to NI_PING |
NI_RTERR\0 |
4e 49 5f 52 54 45 52 52 |
Router error |
NI_ROUTEAVI |
4e 49 5f 52 4f 55 54 45 |
Route availability check |
These are NI payloads of exactly 8 bytes — the NI header's payload_length is 8. Regular RFC
data is passed through without inspection.
Full Wire Stack (Confirmed by Live Capture)¶
TCP stream
└── NI frame
├── [4B BE] ni_payload_length (excludes these 4 bytes)
├── [76B] APPC/GW header (SAP Gateway transport header, see below)
├── [4B] rfc_stream_marker (ffff0001=client request, 00000001=server response)
├── [12B] COM_HEAD (ONLY on logon frame; absent from call frames)
└── [N×6+] TLV records ([tag 2B][len 2B][data][tag 2B], see TLV section)
└── [6B] terminator (tag=0xFFFF, len=0)
Port: SAP Gateway at 3300 + sysnr (e.g., sysnr=00 → port 3300).
APPC/Gateway Header (76 bytes)¶
The SAP Gateway inserts a 76-byte header in every RFC data frame (type 06 CB). This is the
APPC (Advanced Program-to-Program Communications) transport layer that CPI-C uses.
Offset Length Notes
0 2 GW message type: 06=data CB=RFC_DATA (06CE and 0609 also observed)
2 2 Protocol version: 02 00
4 4 Max NI message size BE uint32 (FF FF 00 00 in client frames)
8 2 zero in all 85 frames observed
10 1 Sub-protocol version (0x01 in registration response)
11 2 zero in all 85 frames observed
13 1 FRAME SEQUENCE NUMBER within one response, 1-based. 0 on client
requests and on empty 06CE frames. Confirmed: a 22-frame reply
numbered its frames 1..22 consecutively with no exceptions.
14 2 zero in all 85 frames observed
16 1 1 on server responses, 0 on client requests (all 85 frames)
17 4 BE int32. 500 on a frame that completes the response, -1 on one
that does not. See "multi-frame responses" below: this tracks
continuation but must NOT drive reassembly, because it also
reads -1 on complete terminal replies.
21 1 Frame sub-type (0x06 in registration response; echoed by the peer)
22 2 zero in all 85 frames observed
24 4 CPIC handle/sequence (0x00000175 = 373 in registration response)
28 2 zero in all 85 frames observed
30 2 BE uint16 POSITION MARKER, consistent across 76 frames:
0x0108 this frame does not complete the response — the first
frame of a multi-frame reply, and also a single-frame
reply that does not complete the exchange (a refused
logon, an incomplete signon)
0x0100 a middle frame of a multi-frame reply
0x050C this frame completes the response (including every
single-frame reply, and client requests)
32 2 zero in all 85 frames observed
34 1 CPIC rc-valid flags (bit 0x10: rc fields at [32]/[36] are valid;
0x05 in registration response → bit clear = no error info)
35 1 0x12 on exactly the two captured refusals — cpic_logon_error_response
and signon_incomplete_752_response — and 0 on the other 83
frames. [ASSUMED] that it marks a refused exchange: two frames
is a correlation with an independently identified category, not
an enumeration.
36 4 CPIC return code BE int32 (0 = success)
40 4 Additional CPIC status BE int32
44 8 GW connection handle: 4 ASCII digits + 4 binary bytes
52 4 BE uint32, always 2 on responses and 0 on requests
56 4 BE uint32: THIS FRAME'S OWN PAYLOAD LENGTH. Exact on all 16 frames
cross-checked, and 0 on client requests.
60 4 BE uint32: 1 when this frame completes the response, 0 otherwise
64 8 SAP GW service / dispatcher name (e.g. "sapdp00 ", null-padded)
72 4 Trailing bytes (0x49 0x01 0x00 0x00 in registration response)
Sources: gw_connect_response.bin for the registration-response values, plus a
differential analysis over 85 GW-framed captures — every golden fixture, a
2-frame RFC_READ_TABLE reply and a 22-frame one.
Corrections to an earlier version of this table. Three entries were wrong, and each was wrong in a way that looked plausible:
- 52–63 was recorded as "RFC library name + version, null-padded". It is three BE uint32. The 12 bytes are readable as a padded string, which is why the reading survived; nothing in a single capture contradicts it.
- 16 was a 4-byte word and 20 was a separate unknown byte. The boundary is one later: 16 is a single flag byte, and 17–20 is the BE int32 above. Splitting it at 16 makes the int32 read as 0x01000001 / 0x01FFFFFF, which looks like a flags word and is not one.
- 30 and 31 were two separate unknown bytes. They are one BE uint16, and the three values it takes are the position marker documented above.
Key observation from capture: - All client→server RFC data frames share an identical 76-byte APPC header within a session (only the connection handle changes between sessions). - Server→client frames have variable content in bytes 10-75 (session state, CPIC return codes, etc.) - The header immediately follows the 4-byte NI header and precedes the RFC TLV stream.
The GW handshake (frames 4-13 in the session) uses different message types (02 03, 06 01,
06 0F, 06 05) for session establishment. 06 CB = RFC data appears only after handshake.
Implementation note: Only bytes [44..51] (the GW handle) are consumed by session.py
(_GW_HANDLE_OFFSET = 40, _GW_HANDLE_LEN = 8, measured from the start of the NI payload).
Bytes [8-39] are CPIC internal state that saprfclib does not need to parse.
Fields [0x20..0x23] (CPIC rc) and [0x24..0x27] (additional status) are only non-zero on error;
successful registration returns zeros at both positions.
RFC Marker (4 bytes)¶
Immediately follows the APPC header (at frame offset 80, i.e. NI[4] + GW[76]).
The FF FF vs 00 00 high bytes discriminate client→server from server→client frames. The
low 2 bytes carry a conversation-protocol value, byte-swapped into 00 04.
Version note: The golden fixture stfc_connection_request.bin was captured from an older
client version and shows FF FF 00 01 here. Newer clients emit FF FF 00 04 on all client
frames. Both are accepted by the systems tested; saprfclib emits FF FF 00 04.
RFC Message Format (NG-RFC)¶
The RFC TLV stream (after APPC header + RFC marker) carries the RFC message. For NG-RFC (next-generation) connections, the structure is:
Offset Length Name Notes
0x00 12 COM_HEAD RFC message eyecatcher (EBCDIC "RFC" + padding)
ONLY present on the first (logon) frame of a session.
0x0C var TLV records Session + call data in tagged format
IMPORTANT: COM_HEAD appears only on the logon frame (session establishment). RFC function call frames (STFC_CONNECTION, etc.) start the TLV stream directly after the RFC marker — no COM_HEAD. This was confirmed by live capture: COM_HEAD found only in frame 14 (logon); all subsequent frames (16, 19, 21, ...) start TLV immediately.
COM_HEAD (12 bytes)¶
Decoded as EBCDIC:
- Bytes 0-2: D9 C6 C3 = R F C (EBCDIC code points for "RFC")
- Bytes 3-11: F0 × 9 = 0 0 0 0 0 0 0 0 0 (EBCDIC digit '0')
- Full string: "RFC000000000" in EBCDIC
Trace variant (TRACE_COM_HEAD): byte 11 = E3 = EBCDIC 'T' → "RFC00000000T".
Evidence: live capture — the 12-byte sequence appears at payload offset 80 of the logon frame and nowhere else in the session. The trace variant is emitted only when the client has tracing enabled, which is why it does not appear in the fixtures.
COM_HEAD is written on NG-RFC connections only; classic CPI-C connections do not carry it.
TLV Record Format¶
Each piece of RFC session/call data is encoded as a tagged record, with a matched opening and closing marker.
Standard record (length < 65535):
+--------+--------+----------------+--------+
| tag BE | len BE | data | tag BE |
| 2 B | 2 B | len bytes | 2 B |
+--------+--------+----------------+--------+
Extended record (length ≥ 65535, i.e., len field = 0xFFFF):
+--------+------+----------+----------------+--------+
| tag BE | FFFF | ext-len | data | tag BE |
| 2 B | 2 B | 4 B BE | ext-len B | 2 B |
+--------+------+----------+----------------+--------+
Tags are stored big-endian on the wire, unlike the ABAP scalar values they wrap.
Every record has BOTH an opening marker (carrying the length) and a closing marker (the same
tag, no length). An empty record is therefore 6 bytes: [tag BE 2B] [00 00] [tag BE 2B].
The closing tag is a cheap structural check — saprfclib asserts it matches on every record,
which catches a desynchronised parse immediately rather than 200 bytes later.
Known TLV Tags¶
Evidence column: capture = the tag was observed on the wire in a committed fixture; analysis = the tag is known to exist from reference-client behaviour but has not yet been seen in a capture, so its role is provisional.
Captures drawn on: STFC_CONNECTION, STFC_EXCEPTION, STFC_DEEP_TABLE, RFC_READ_TABLE, STFC_CHANGING, STFC_STRUCTURE.
| Tag (hex) | Evidence | Role |
|---|---|---|
| 0x000b | capture | RFC version string (e.g. "754" UTF-16LE) |
| 0x0101 | analysis | Session info (version, codepage) |
| 0x0102 | capture | Function name (UTF-16LE, e.g. "STFC_CONNECTION") |
| 0x0103 | analysis | Connection flags (4B BE uint32) |
| 0x0106 | analysis | Protocol version |
| 0x0130 | capture | Program name (UTF-16LE, e.g. "SAPLSTFC", padded to 80 bytes) |
| 0x0131 | analysis | EPP (Extended Performance Profile) |
| 0x0160 | analysis | Int16 field |
| 0x0201 | capture | Parameter name (UTF-16LE, scalar IMPORTING/EXPORTING pair) |
| 0x0203 | capture | Parameter value CHAR (UTF-16LE, fixed-width space-padded) |
| 0x0205 | capture | Output table/param declaration (name UTF-16LE, one per return param) |
| 0x0301 | capture | TABLE param name (UTF-16LE) — binary row format |
| 0x0302 | capture | TABLE row descriptor (8 bytes; contains row count + row size) |
| 0x0303 | capture | TABLE type descriptor (402 bytes; from RFC_GET_FUNCTION_INTERFACE) |
| 0x0304 | capture | TABLE row data (UTF-16LE, fixed-width padded to ABAP row length) |
| 0x0330 | capture | TABLE header (4 bytes; part of binary TABLE encoding) |
| 0x0337 | analysis | Marker / empty record |
| 0x0401 | capture | ABAP exception key/name (kernel 793; e.g. "FU_NOT_FOUND") |
| 0x0402 | capture | ABAP exception message text (kernel 752; e.g. "Logon data incomplete.") |
| 0x0403 | capture | ABAP exception key/name (kernel 752; alternative to 0x0401) |
| 0x0411 | capture | Message variable V1 |
| 0x0415 | capture | Message class (2 chars, e.g. "00", "FL") |
| 0x0416 | capture | Message type (1 char, e.g. "X", "E") |
| 0x0417 | capture | ABAP exception/message number (3 chars, e.g. "000", "341") |
| 0x0418 | capture | ABAP call-stack breadcrumb (;W=…,E=…;S=…;D=… — not parsed) |
| 0x0420 | capture | RFC return code (4B BE uint32; 0=success) |
| 0x0421 | analysis | Auth / call context |
| 0x0500 | capture | Call-end / response-start marker (empty record) |
| 0x0502 | capture+analysis | Call-start marker (empty on call frames) |
| 0x0503 | capture | Success marker (empty record) — present iff 0x0417 is absent |
| 0x0504 | analysis | Function call begin (type A) |
| 0x0512 | capture+analysis | Parameter section start / end of RFC exchange |
| 0x0513 | analysis | Function call begin (type B) |
| 0x0514 | capture+analysis | Session token / connection ID (16B binary, random per session) |
| 0x0667 | capture+probe | 8-byte float64 LE: server-side call duration, microseconds |
| 0x3c02 | capture | BASXML section marker (empty; < = 0x3C, , = 0x02) |
| 0x3c05 | capture | BASXML content — raw ASCII XML (NOT UTF-16LE) |
| 0xFFFF | capture | TLV stream terminator (empty record, mandatory last) |
RFC Function Call Sequence¶
Confirmed by live STFC_CONNECTION capture. Golden fixtures:
tests/golden/framing/stfc_connection_request.bin, stfc_connection_response.bin.
Request (client → server)¶
NI header (4B) + APPC header (76B) + RFC marker ffff0004 (4B)
└── TLV stream:
0x0502 len=0 call-start marker
0x000b len=6 RFC version "754" (UTF-16LE)
0x0102 len=30 function name "STFC_CONNECTION" (UTF-16LE)
0x0512 len=0 parameter section start
0x0205 len=16 export param decl "ECHOTEXT" (UTF-16LE) } one per
0x0205 len=16 export param decl "RESPTEXT" (UTF-16LE) } export param
0x0201 len=16 import param name "REQUTEXT" (UTF-16LE) }
0x0203 ext len=510 import param value (CHAR(255) UTF-16LE, space-padded)
0xFFFF len=0 TLV stream terminator
Response (server → client)¶
NI header (4B) + APPC header (76B) + RFC marker 00000004 (4B)
└── TLV stream:
0x0500 len=0 call-end / response-start marker
0x0503 len=0 success marker — a result follows
0x0514 len=16 session token (16B binary)
0x0420 len=4 return code uint32 BE (0=success)
0x0512 len=0 parameter section start
0x0205 len=16 export param decl (schema — echos request)
...
0x0201 len=16 result param name (UTF-16LE)
0x0203 ext len=510 result param value (CHAR(255) UTF-16LE, space-padded)
... (one name/value pair per output param)
0x0130 len=80 calling program name "SAPLSTFC" (UTF-16LE padded)
0x0667 len=8 float64 LE = server call duration in microseconds
0xFFFF len=0 TLV stream terminator
RFCPING — CONFIRMED (2026-08-26)¶
RFCPING is an ordinary zero-parameter function call, not a special frame. It needs
the same GW header, RFC marker, TLV body and invoke footer as any other call; a bare
TLV body is rejected by the gateway, which reads the function name where it expects
the 76-byte header and answers with a plain-text error beginning *ERR.
Golden fixtures: tests/golden/framing/rfcping_request.bin (138 B),
rfcping_response.bin (236 B). Both were captured above the NI layer, so — unlike
the other framing fixtures — they carry no 4-byte NI length prefix and start at
the GW header. Captured from A4H kernel 793 / release 758, unicode, codepage 4103.
Request (client → server), 138 B total
APPC header (76B) + RFC marker ffff0004 (4B)
└── TLV stream (50 B):
0x0502 len=0 call-begin marker
0x000b len=6 RFC version "754" (UTF-16LE)
0x0102 len=14 function name "RFCPING" (UTF-16LE)
0x0512 len=0 end of the call-begin block
0xFFFF len=0 TLV stream terminator
+ invoke footer (8B): 0x0000 | BE16 len(tlv)=0x32 | 0x0000 | 0x8500
Response (server → client), 236 B total
APPC header (76B) + RFC marker 00000002 (4B)
└── TLV stream:
0x0500 len=0 response-start marker
0x0503 len=0 success marker — a result follows
0x0514 len=16 session token (16B binary)
0x0420 len=4 return code uint32 BE (0 = success)
0x0512 len=0 parameter section start (no parameters follow)
0x0130 len=80 handling program "SAPLSYSU" (UTF-16LE, padded to 40 chars)
0x0667 len=8 float64 LE = 138.0 here = 138 us of server time
0xFFFF len=0 TLV stream terminator
Every record above is followed by its repeated close tag. Two consequences for any reader, both of which produced real bugs (issue #7):
- Strip the 80-byte GW header first. Parsing from offset 0 reads
gw_version(0x0200) as a TLV length, which surfaces aslength 512 exceeds remaining payload. - Skip the repeated close tag. The return code
0x0420is the fourth record, not the first. A walk that does not skip close tags desynchronises by two bytes before it gets there and misreads every subsequent tag.
Note the response RFC marker is 00000002 here, where the STFC_CONNECTION response
above shows 00000004. The marker value varies; the 80-byte strip keys off the
leading 0x06 GW-frame byte and is unaffected either way.
XML-encoded table rows (0x3c02 / 0x3c05) — CONFIRMED (2026-08-28)¶
Some tables come back as plain-text XML instead of binary rows. An empty 0x3c02
brackets the block on both sides and the XML is carried in 0x3c05 chunks:
0x0205 len=14 'ET_DATA' export declaration (UTF-16LE)
0x3c02 len=0 block begin
0x3c05 len=9 '<ET_DATA>' ASCII — NOT UTF-16LE
0x3c05 len=211 '<item><LINE>a|b|c</LINE></item></ET_DATA>'
0x3c02 len=0 block end
RFC_READ_TABLE uses this for ET_DATA when called with USE_ET_DATA_4_RETURN='X',
the flag that avoids truncating STRING columns into DATA's fixed work area. With
the flag set, DATA still arrives declared (0x0301/0x0330/0x0302) but carries
no rows.
Golden fixtures: tests/golden/framing/rfc_read_table_response.bin (empty table) and
basxml_et_data_response.bin (one populated row).
Two properties that are easy to get wrong:
- The payload is ASCII. Every other string-bearing tag in the protocol is UTF-16LE; decoding these chunks that way yields mojibake.
- The fragments are one document split at arbitrary points, so only the first
names the table. In the capture above, chunk 2 begins
<item>— re-deriving the name per chunk files the rows under a table calleditemand loses them.
Row shape observed is the shortcut form: one <LINE> element holding the whole
delimited row. The documented alternative puts one element per field. Both work under
the same rule — whatever elements an <item> contains become that row's keys.
Multi-row is confirmed by capture, not inferred. A ten-row T100 read arrives as ten
<item> elements split across two 0x3c05 fragments of 9 and 773 bytes, the first
holding only the opening tag — so fragment boundaries fall wherever the server
chooses, not on item boundaries. Golden fixture:
tests/golden/framing/basxml_et_data_multirow_response.bin.
The XML form is not blank-padded
Unlike the binary encoding, the XML form does not pad fields to their DDIC
width. The same query returns ARBGB as FL here and as FL followed by
eighteen spaces through DATA. Row content is otherwise identical field for
field, but a caller splitting the delimited row gets trimmed values on one path
and padded values on the other. Verified by running the identical query with and
without USE_ET_DATA_4_RETURN.
This is NOT SAP's BASXML
They share a TLV tag and nothing else. SAP's BASXML is a binary tokenised
format: BasXmlRenderer writes a header beginning with the literal magic
BXML, then token bytes and a string table, under the
http://www.sap.com/abapxml namespace — an element open is the byte 0x3c
followed by a string-table index, not the character <. BasXMLParser reads it
back with length-prefixed strings.
That format is not implemented. A payload carrying the BXML magic is
refused with NotImplementedError rather than fed to the text reader, which
would silently produce nonsense.
Compressed tables — CONFIRMED (2026-08-26)¶
A table larger than roughly 8 KB is sent SAPCOMPRESS-compressed under tag 0x0305
instead of one 0x0303/0x0304 record per row. The switch happens when
row_size × row_count >= 0x2001 (8193). This is not a rare path: it is every
function module with enough parameters, so RFC_GET_FUNCTION_INTERFACE metadata for
most BAPIs arrives compressed.
Golden fixture: tests/golden/framing/gfi_compressed_params_response.bin — the GFI
response for BAPI_USER_GET_DETAIL (44 parameters, 404 × 44 = 17776 bytes).
0x0301 len=12 table name "PARAMS" (UTF-16LE)
0x0330 len=4 DM table id
0x0302 len=8 [BE row_size=404][BE row_count=44]
0x0310 len=4 used row width (402) — the layout width without padding
0x0305 len=250 compressed fragment } eight fragments of
0x0305 len=250 compressed fragment } ONE stream, 2000 bytes joined
...
0x0306 len=0 table end
The 0x0305 records are fragments of a single stream, not independently
compressed blocks — decompressing one on its own fails. Concatenate them all first.
The joined payload then carries an 8-byte wrapper before the SAPCOMPRESS stream:
[0:4] unidentified
[4:8] BE uint32 — length of the compressed stream (1921 here)
[8:] SAPCOMPRESS stream:
[0:4] LE uint32 uncompressed length (17776)
[4] algorithm byte (0x12 → LZH)
[5:7] magic 1f 9d
[7] config
Trailing bytes after the compressed stream pad the last record to its fixed size.
Two row shapes, two slicing rules
Per-row records and a compressed blob cannot be handled the same way.
- Per-row
0x0303/0x0304— each record is one row at its used width. The0x0302stride may be larger: a structure-definition response declaredrow_size=140while every record was 138 bytes. - Compressed
0x0305— the decompressed blob carries no row boundaries, so it must be sliced by the0x0302stride.
Slicing per-row records by the declared stride misaligns every row after the
first; slicing a decompressed blob by the record length is impossible. The
0x0302 row size is authoritative only for the compressed form.
Exception Response (server → client when ABAP exception raised)¶
Confirmed from live STFC_EXCEPTION call capture (2026-06-28). Golden fixture:
tests/golden/framing/stfc_exception_response.bin (128 bytes).
NI header (4B) + APPC header (76B) + RFC marker 00000004 (4B)
└── TLV stream:
0x0500 len=0 call-end marker (empty, with suffix)
0x0417 len=6 exception number: 3 UTF-16LE chars, e.g. "000"
0x0401 len=14 exception key/name: N UTF-16LE chars, e.g. "EXAMPLE"
0xFFFF len=0 TLV stream terminator
Note: No 0x0503 or 0x0514 tags on exception response — the frame is minimal.
The exception KEY matches the ABAP RAISE statement (RAISE EXAMPLE).
The exception NUMBER is the ABAP MESSAGE number (3-digit "000" when no explicit message).
Server registration (registered server → gateway)¶
Confirmed from live capture — golden fixture
tests/golden/framing/server_registration_request.bin (457 bytes including the NI header).
Parameter validation applied before the frame is built: PROGRAM_ID must be non-empty, at most
64 characters, and must not contain *; the gateway host string is capped at 2048 characters;
the gateway service must be non-empty. saprfclib enforces the same limits in
server_session.py so an invalid registration fails locally rather than at the gateway.
Registration request (server → gateway) — the same 0x0601 GW_CONNECT frame a client
emits, and independent of the PROGRAM_ID on the wire:
NI header (4B) + GW header (0x0601 GW_CONNECT)
[0] 0x06 GW frame
[1] 0x01 GW_CONNECT type
[10] 0x01 / [16] 0xC0 / [21] 0x04 / [22] 0x00 STIInit constants
[40:48] 8 spaces no GW handle yet on outbound connect
[48:56] "NWRFC " remote RFC partner LU name (NOT the PROGRAM_ID)
[76:80] ffff ffff request marker (ACK flips [78:80] → 0004)
variable: local IP / NI hostname / service / OS user / time() blob @ [122:138]
PROGRAM_ID is NOT carried in this 0x0601 frame (verified: it appears in no encoding in the capture). The "NWRFC" string is the fixed partner LU name. The PROGRAM_ID/tpname is conveyed to the gateway by the follow-up
SAP_CMACCPTP3accept exchange (Wave 2). RFC marker direction:ffffXXXX= request,0000XXXX= the gateway-accepted reply.
Registration ACK (gateway → server) — echoes the request with: [21] 0x04→0x06
(acceptance flag), [40:48] = gateway-assigned ASCII connection handle (e.g.
36964135), [78:80] ffff→0004 (accepted tail).
Inbound call (gateway → registered server) reuses the 06-family data frame
(0x060F data + 0x0605 GW_DONE) carrying the assigned handle and the peer IP. It is
deserialized by the same TLV path as a client-side response — the server direction is the
client direction run backwards, which is why server_session.py mirrors session.py rather
than reimplementing the parse.
All inbound bytes are peer-influenced and therefore untrusted: parse them with the bounds-checked TLV walker, never with offset arithmetic that trusts a length field.
CHANGING Parameter Encoding¶
Confirmed from live STFC_CHANGING capture (2026-06-28). Golden fixtures:
tests/golden/framing/stfc_changing_request.bin (260B),
tests/golden/framing/stfc_changing_response.bin (336B).
CHANGING params use the SAME 0x0201 + 0x0203 tag pair as IMPORTING/EXPORTING.
The direction (IMPORT / EXPORT / CHANGING) is determined entirely by PARAMCLASS metadata
from RFC_GET_FUNCTION_INTERFACE ('I'/'E'/'C') — no distinct TLV tag exists for CHANGING.
CHANGING params appear in BOTH the request (call-time value) AND the response (post-call value):
Client → server (STFC_CHANGING: START_VALUE=10, COUNTER=1):
0x0502 len=0 call-start marker
0x000b len=6 '754' (version)
0x0102 len=26 'STFC_CHANGING' (function name)
0x0512 len=0 param section start
0x0205 len=14 'COUNTER' (declare as export: will be returned)
0x0205 len=12 'RESULT' (declare as export: will be returned)
0x0201 len=14 'COUNTER' ← CHANGING param (PARAMCLASS='C')
0x0203 len=4 01 00 00 00 ← value = 1 (INT4 LE)
0x0201 len=22 'START_VALUE' ← IMPORTING param (PARAMCLASS='I')
0x0203 len=4 0a 00 00 00 ← value = 10 (INT4 LE)
0xFFFF len=0 terminator
Server → client (response: COUNTER=2, RESULT=11):
0x0500 len=0 call-end marker
[session tags 0x0503, 0x0514, 0x0420, 0x0512]
0x0205 len=12 'RESULT'
0x0205 len=14 'COUNTER'
0x0201 len=12 'RESULT' ← EXPORTING param
0x0203 len=4 0b 00 00 00 ← value = 11 (INT4 LE)
0x0201 len=14 'COUNTER' ← CHANGING param (new value)
0x0203 len=4 02 00 00 00 ← value = 2 (INT4 LE)
0x0130 len=80 'SAPLMRFC' (program name)
0x0667 len=8 float64 LE = server call duration in microseconds
0xFFFF len=0 terminator
Scalar type encoding in 0x0203 values:
- INT1 (byte): 1 byte LE
- INT2 (short): 2 bytes LE
- INT4 (int): 4 bytes LE — e.g. 0a 00 00 00 = 10
- CHAR(n) (ABAP CHAR): 2n bytes UTF-16LE, space-padded — e.g. CHAR(255) = 510 bytes
- FLOAT (double): 8 bytes IEEE-754 double LE
- RAW(n) (ABAP RAW): n bytes verbatim
- DATE (YYYYMMDD): 16 bytes UTF-16LE
- TIME (HHMMSS): 12 bytes UTF-16LE
STRUCTURE Parameter Encoding¶
Confirmed from STFC_STRUCTURE capture (2026-06-28). Golden fixtures:
tests/golden/framing/stfc_structure_request.bin (526B),
tests/golden/framing/stfc_structure_response.bin (1438B).
STRUCTURE params (ABAP structures) use the same 0x0201 + 0x0203 pair as scalars.
The value is the raw binary struct (each field encoded per its ABAP type, in declaration order,
no padding between fields beyond each field's natural size). Length = sum of field byte widths.
Client → server (IMPORTSTRUCT with RFCTEST struct):
0x0201 len=24 'IMPORTSTRUCT' (param name)
0x0203 len=264 [raw binary: RFCFLOAT(8B) + RFCCHAR1(2B) + RFCINT2(2B) + ...]
The server echos the struct back in ECHOSTRUCT via the same 0x0201 + 0x0203 encoding.
TABLE Parameter Encoding¶
Two formats observed for TABLE parameters, depending on param direction and type.
BASXML format (0x3c02 / 0x3c05) — heterogeneous / STRING-containing tables¶
Used when tables contain STRING/XSTRING fields or when REMOTE_BASXML_SUPPORTED capability
is negotiated. Confirmed from STFC_DEEP_TABLE capture (2026-06-28). Golden fixtures:
tests/golden/framing/stfc_deep_table_request.bin (441B),
tests/golden/framing/stfc_deep_table_response.bin (1144B).
Client → server (IMPORTING table with rows):
...
0x0205 len=20 expected export table name "EXPORT_TAB" (UTF-16LE)
0x3c02 len=0 BASXML section start (marker, empty)
0x3c05 len=12 raw ASCII: <IMPORT_TAB>
0x3c05 len=217 raw ASCII: <item><I>1</I><C>ROW01</C><STR>string1</STR>...</item>...
0x3c02 len=0 BASXML section end (marker, empty)
0xFFFF len=0 TLV terminator
Server → client (EXPORTING table with rows in response):
0x0205 len=20 exported table name "EXPORT_TAB"
0x0205 len=16 exported scalar name "RESPTEXT"
0x3c02 len=0 BASXML section start
0x3c05 len=12 raw ASCII: <EXPORT_TAB>
0x3c05 len=282 raw ASCII: <item>...</item><item>...</item>...
0x3c02 len=0 BASXML section end
0x0201 len=16 scalar name "RESPTEXT" (UTF-16LE)
0x0203 len=510 scalar value (UTF-16LE, space-padded)
...
BASXML values (0x3c05) are raw ASCII/UTF-8 XML, NOT UTF-16LE.
The tag prefix 0x3c = < is the ASCII '<' character — a mnemonic, not a coincidence.
XML structure per table: <TABLE_NAME><item><field>val</field>...</item>...</TABLE_NAME>
XSTRING fields are base64-encoded within the XML.
Binary format (0x0301 / 0x0302 / 0x0304) — flat CHAR-based TABLES params¶
Used for TABLES parameters with flat structure (no STRING/XSTRING). Confirmed from
RFC_READ_TABLE capture (2026-06-28). Golden fixtures:
tests/golden/framing/rfc_read_table_request.bin (354B),
tests/golden/framing/rfc_read_table_response.bin (6091B).
Server → client (TABLES param with rows):
0x0301 len=8 table name "DATA" (UTF-16LE)
0x0330 len=4 table header [UNKNOWN; 4 bytes]
0x0302 len=8 row descriptor [UNKNOWN; 8 bytes: contains row count + width]
0x0304 len=1024 row 1 data (UTF-16LE, padded to ABAP table row width)
0x0304 len=1024 row 2 data (UTF-16LE, padded to ABAP table row width)
... one 0x0304 per row
0x0301 len=12 next table name "FIELDS"
0x0330 len=4 ...
0x0302 len=8 ...
0x0304 len=206 FIELDS row (UTF-16LE, padded)
...
Row data is fixed-width UTF-16LE, padded to the ABAP table row length with spaces.
The ABAP row width determines the 0x0304 value length (e.g. 512 chars = 1024 bytes for RFC_READ_TABLE.DATA).
CPIC / Gateway Layer Clarification¶
Updated by live capture: an early working assumption was "no CPIC header on the wire". That was wrong. CPIC itself is a state machine and contributes no framing, but the SAP Gateway transport (APPC layer) does add a 76-byte header to every RFC data frame on port 3300.
The correct picture:
The CPIC-side conversions are internal data-format handling (8-bit byte representation ↔ CPIC native format), not wire framing — they leave no trace in the bytes.
Open question: connecting directly to port 3200 (dispatcher rather than gateway) — does the APPC header still appear? Not confirmed by capture. Port 3300 is the path every client tested uses by default, so this has not been exercised.
Python Reference Implementation¶
import struct
NI_HEADER_SIZE = 4 # confirmed by every golden fixture
# RFC eyecatcher (EBCDIC "RFC000000000")
RFC_COM_HEAD = bytes([0xD9, 0xC6, 0xC3] + [0xF0] * 9) # 12 bytes
def parse_ni_frame(data: bytes) -> tuple[int, bytes]:
"""Return (payload_length, payload). Raises ValueError if truncated."""
if len(data) < NI_HEADER_SIZE:
raise ValueError(f"Frame too short: {len(data)} < {NI_HEADER_SIZE}")
(payload_length,) = struct.unpack_from(">I", data, 0)
if len(data) < NI_HEADER_SIZE + payload_length:
raise ValueError(f"Incomplete frame: have {len(data)}, need {NI_HEADER_SIZE + payload_length}")
return payload_length, data[NI_HEADER_SIZE : NI_HEADER_SIZE + payload_length]
def build_ni_frame(payload: bytes) -> bytes:
"""Wrap payload in a 4-byte NI header."""
return struct.pack(">I", len(payload)) + payload
def parse_tlv(data: bytes, offset: int = 0) -> tuple[int, bytes, int]:
"""Parse one TLV record. Returns (tag, data, next_offset)."""
tag = struct.unpack_from(">H", data, offset)[0]
offset += 2
raw_len = struct.unpack_from(">H", data, offset)[0]
offset += 2
if raw_len == 0xFFFF:
length = struct.unpack_from(">I", data, offset)[0]
offset += 4
else:
length = raw_len
payload = data[offset : offset + length]
offset += length
close_tag = struct.unpack_from(">H", data, offset)[0]
assert close_tag == tag, f"TLV close tag mismatch: {close_tag:#06x} != {tag:#06x}"
offset += 2
return tag, payload, offset
Hex Example¶
Source: tests/golden/framing/stfc_connection_request.bin (740 bytes)
Live capture SAP A4H sysnr=00 port=3300, STFC_CONNECTION call.
Note on offsets: All frame offsets below are from byte 0 of the frame (including NI prefix). GW header occupies bytes [0x04:0x50] = 76 bytes. RFC marker at [0x50:0x54]. TLV stream starts at [0x54].
Offset Bytes Field
──────── ──────────────────────────────────────── ────────────────────────────────────
0x0000: 00 00 02 e0 NI payload_length = 736 (BE uint32)
0x0004: 06 cb 02 00 ff ff 00 00 00 00 00 00 APPC/GW header byte [0:11]
0x0010: 00 00 00 00 00 00 00 00 00 00 00 08 APPC/GW header byte [12:23]
0x0020: 00 00 05 0c 00 00 00 00 00 00 00 00 APPC/GW header byte [24:35]
0x002c: 37 35 35 36 38 34 34 32 connection handle "75568442" (ASCII) [GW40:48]
0x0034: 00 00 00 00 00 00 00 00 ...×28 zeros APPC/GW header trailing zeros
0x0050: ff ff 00 01 RFC stream marker (NOTE: this golden
was captured from older SDK; current
binary uses ff ff 00 04 — see RFC Marker section)
0x0054: 05 02 00 00 05 02 TLV 0x0502 len=0 (call-start)
0x005a: 00 0b 00 06 37 00 35 00 34 00 00 0b TLV 0x000b len=6 "754" (version)
0x0066: 01 02 00 1e TLV 0x0102 len=30 (func name open)
0x006a: 53 00 54 00 46 00 43 00 5f 00 43 00 "STFC_" (UTF-16LE)
0x0072: 4f 00 4e 00 4e 00 45 00 43 00 54 00 "ONNECT" (UTF-16LE)
0x007a: 49 00 4f 00 4e 00 "ION" (UTF-16LE)
0x007d: 01 02 TLV 0x0102 close
0x007f: 05 12 00 00 05 12 TLV 0x0512 len=0 (param section)
... [export param declarations + REQUTEXT name/value pair]
0x02e6: ff ff 00 00 ff ff TLV 0xFFFF len=0 (terminator)
See tests/golden/framing/stfc_connection_request.json for full field-by-field annotation.
Open Questions¶
RESOLVED: APPC/Gateway header (76 bytes) field layout
Fields [8-39] were decoded against the gw_connect_response.bin golden fixture. Key
fields: [16] flags, [21] sub-type, [24-27] CPIC handle/sequence, [34] rc-valid flag,
[36-39] CPIC return code, [40-43] additional status, [44-51] gateway handle.
saprfclib consumes only [44-51]; everything else is CPIC internal state it does not
need. Full layout in the "APPC/Gateway Header" section above.
MEDIUM: Port 3200 (dispatcher) wire format
All captures used port 3300 (gateway). If a client connects directly to port 3200 (dispatcher), does the APPC header still appear? Resolve by capturing both ports against the same system.
LOW: COM_HEAD scope
COM_HEAD is confirmed present in the logon frame and absent from subsequent call frames. Not yet verified: whether it also appears on the first message of a tRFC/qRFC/bgRFC exchange, or is strictly session establishment.
LOW: Classic RFC (non-NG) wire format
NG-RFC uses COM_HEAD + TLV. Classic CPI-C connections use a different format. Every system tested (NetWeaver 7.58) negotiates NG-RFC, so the classic format is undocumented here and unimplemented.
Known Gaps¶
Items where the wire behaviour is documented but incompletely confirmed, or confirmed but not yet implemented. Each states what is known, what is not, and the consequence of the gap.
SAProuter NI_ROUTE prefix — CONFIRMED (2026-06-27)¶
Wire format confirmed from live capture: NI_ROUTE\0 (9B) + talk_mode (0x02) + 0x28 +
version (0x02) + hop_count (4B BE) + total_data_length (4B BE) + per-hop entries
(entry_len [4B] + host\0 + svc [6B]) + final destination (host\0 + svc [6B]).
Golden fixture: tests/golden/router/ni_route_payload.bin. Implemented and replayed in CI.
Message-server SAPMS group logon — CONFIRMED (2026-06-27)¶
The **MESSAGE** server-list frame is parsed by parse_sapms_server_list in
src/saprfclib/router.py and validated against tests/golden/router/sapms_server_list.bin
(598 bytes, 3 entries).
SAPMS MESSAGE frame layout (server-list response, wire-captured 2026-06-27):
Offset Size Field Notes
------ ---- ----- -----
0 4 NI length prefix BE uint32 = frame_total - 4. wire-captured.
4 11 magic "**MESSAGE**" (ASCII). wire-captured.
15 1 key 0x00. [ASSUMED] purpose unknown. wire-captured value.
16 1 version 0x04. wire-captured.
17 1 padding 0x00. wire-captured value.
18 1 sender_type 0x2D ('-') in server responses. wire-captured.
19 40 sender_name Space-padded ASCII + null, 40 bytes total. wire-captured.
59 11 zeros 11 zero bytes. wire-captured value.
70 1 msg_type 0x03 = MSG_SERVER class. [ASSUMED] purpose. wire-captured.
71 1 direction 0x01 = server→client response. [ASSUMED]. wire-captured.
72 10 opcode_name "MSG_SERVER" (ASCII, space-padded). wire-captured.
82 30 opcode_padding 30 space bytes. wire-captured.
112 2 unknown 0x0000. wire-captured value.
114 2 opcode_field 0x0500 in server-list response. [ASSUMED]. wire-captured.
116 2 sub_opcode 0x0403. [ASSUMED]. wire-captured.
118 N×160 server entries N = (frame_total - 118) / 160. wire-captured entry size.
Per-server entry layout (160 bytes each, wire-captured from 3 entries):
Entry Size Field Notes
----- ---- ----- -----
0 40 instance_name Space-padded ASCII (e.g. "vhcala4hci_A4H_00"). wire-captured.
40 40 hostname_string Space-padded ASCII dotted-IPv4 or hostname. wire-captured.
80 40 field3 [ASSUMED] secondary name / "tick-port" string. wire-captured.
120 15 unknown_zeros Leading space(s) then zero bytes. wire-captured.
135 2 ffff_marker 0xFFFF. Confirmed in all 3 entries. wire-captured.
137 4 ip_addr_primary 4-byte BE IPv4 (e.g. 0xC0A85807 = 192.168.88.7). wire-captured.
141 4 ip_addr_secondary Duplicate of primary. [ASSUMED]. wire-captured.
145 2 port BE uint16; 0 = inactive, 0x0C80 = 3200. wire-captured.
147 13 trailing_flags [ASSUMED] flags/load score. wire-captured values vary.
Entry count: N = (frame_size - 118) / 160. No confirmed count field exists in the header
— the count is inferred from the remaining bytes. [ASSUMED] that a frame always contains whole
entries (size % 160 == 0).
Server selection: the first entry with port > 0 is the active application server.
sysnr = (port - 3200) // 100 (port 3200 → sysnr 0).
Still [ASSUMED]: key (offset 15), msg_type / direction (70/71), opcode_field /
sub_opcode (114/116), field3 (80-120), ip_addr_secondary (141), trailing_flags (147),
and the inferred entry-count mechanism. None of these block correct server selection, which is
why they remain open.
Function-interface metadata column layout — CONFIRMED (2026-06-27)¶
RFC_GET_FUNCTION_INTERFACE returns 12 columns: PARAMCLASS, PARAMETER, TABNAME, FIELDNAME,
EXID, POSITION, OFFSET, INTLENGTH, DECIMALS, DEFAULT, PARAMTEXT, OPTIONAL. EXID is a
single-character type code ('C' = CHAR, 'I' = INT4, …); INTLENGTH and OFFSET are
Unicode byte counts, not character counts — the usual 2× trap. Parsed by _parse_params_row
in metadata.py, live-verified.
TABLES params: the direction types the parameter, not EXID — CONFIRMED¶
A TABLES parameter is declared with the EXID of its row structure, not of the table.
RFC_READ_TABLE's DATA, FIELDS and OPTIONS all come back as PARAMCLASS='T' with
EXID='u' (structure) and TABNAME naming the row type (TAB512, RFC_DB_FLD,
RFC_DB_OPT). Typing the parameter from EXID alone therefore mistypes every TABLES param
as a bare structure.
PARAMCLASS is what decides. 'T' means the wire carries a table, and
tests/golden/framing/rfc_read_table_response.bin shows it directly: all three params are
transported with the table tag sequence 0x0301 / 0x0330 / 0x0302 / 0x0304, never as a
0x0203 scalar value. _parse_params_row promotes PARAMCLASS='T' rows to RFCTYPE_TABLE
on that basis.
The promotion applies only to top-level rows (blank FIELDNAME). Nested rows describe fields
inside the row structure and repeat the parent's PARAMCLASS, so they keep their EXID
type.
Consequence of getting this wrong, both directions: the request emits the scalar
0x0201/0x0203 pair and the server rejects the call with CALL_FUNCTION_ILLEGAL_P_TYPE;
the response decodes concatenated row bytes as a single work area, silently dropping every
row past the first.
A TABLES param also needs its row layout attached — the secondary
RFC_GET_STRUCTURE_DEFINITION lookup keyed on TABNAME runs for RFCTYPE_TABLE as well as
RFCTYPE_STRUCTURE, otherwise the descriptor reaches the encoder with type_desc=None and
no rows can be laid out.
Unset fields in a structure or table row¶
ABAP initialises a work area before an RFC fills it, so callers routinely supply only the
fields they care about — RFC_READ_TABLE's FIELDS rows are the canonical case, where only
FIELDNAME is set. Fields absent from a row dict are encoded at their type's initial value
rather than skipped: fixed-width character fields must land blank-padded and numeric fields
zero-padded, so leaving the buffer's NUL fill in place would put the wrong bytes on the wire.
Logon password scrambling (tag 0x0117) — CONFIRMED¶
Tag 0x0117 (17 bytes) is not a hash. It is a reversible byte cipher over the password:
seed (4B LE) + scramble(password_bytes, seed). The seed is stored little-endian, not
big-endian. Implemented as _scramble_password in src/saprfclib/connection.py and verified
against a live logon. See handshake.md for the derivation.
This is obfuscation, not encryption
The scheme is reversible by anyone holding the frame. Passwords on a plain RFC connection are effectively in the clear on the network. Use SNC or WebSocket RFC over TLS for any connection that leaves a trusted segment.
System-failure error detail — NOT IMPLEMENTED¶
AbapSystemFailure currently exposes only message. The full error-info field set
(return code, ABAP message class/type/number, message variables V1–V4, group, key) is not
parsed out of the response TLV, and the tags carrying it on a system failure have not been
identified in a capture. Consequence: less diagnostic detail on system failures than a C-SDK
client provides. Not a correctness problem for the returned data.
Multi-frame responses¶
A reply larger than one gateway frame arrives as several NI frames whose TLV bodies concatenate directly — no per-frame trailer, no length preamble on the continuation, no re-framing. The second frame carries the remaining bytes of the record the first was cut inside.
Captured on A4H, kernel 793, RFC_READ_TABLE on DD03L with ROWCOUNT=2000:
| frame | GW type | total | body | walk |
|---|---|---|---|---|
| part 1 | 0x06cb |
28080 | 28000 | consumes 27799, needs 250 more bytes of a 0x0305 record with 197 left |
| part 2 | 0x0609 |
25593 | 25513 | not a TLV stream — begins mid-record |
Joined: 53513 bytes, walking cleanly to the 0xFFFF terminator with 2 trailing.
Fixtures multiframe_read_table_part1.bin / _part2.bin.
Before this was understood, Connection.call issued one recv_message() per
invoke, so any such reply failed with malformed TLV: tag 0x0305 length 250
exceeds remaining payload (197 bytes) — and left the remainder queued for the
next call to misread.
What drives reassembly, and what must not¶
Two header fields do track continuation, and neither can drive the loop.
| field | continuing frame | last frame |
|---|---|---|
| bytes 17–20, BE int32 | -1 |
500 |
| bytes 60–63, BE uint32 | 0 |
1 |
Confirmed by a 591337-byte reply on A4H kernel 793 that arrived as 22 frames:
all twenty-one continuing frames read -1/0, the last read 500/1. A
two-frame capture could not have established this — with two frames, "continues
the response" and "does not end the stream" are the same statement, so a real
marker and a coincidence look identical. Twenty-one agreeing observations are not
a coincidence.
They are still not the reassembly condition. Both read the continuing value
on signon_incomplete_752_response.bin and cpic_logon_error_response.bin —
complete terminal replies with nothing following them. Whatever the field means,
it is broader than "another frame follows", and a loop keyed on it would wait
forever on a refused logon.
So reassembly is driven by the stream's own 0xFFFF terminator, which is
confirmed structure and answers exactly the question being asked.
invoke.tlv_stream_status classifies a buffer three ways, and only the middle one
reads another frame:
- complete — the walk reached the terminator.
- truncated — at least one record parsed, then a record ran past the end.
- not_tlv — nothing parsed. A CPIC-layer refusal lands here: its body is EBCDIC, so record zero claims 50629 bytes inside a 97-byte frame.
The marker is used in the one direction it is safe in. A frame that reports itself
final while the stream is still short is a genuine inconsistency, and reading on
would consume the next call's reply — so _frame_reports_itself_final refuses
there and nowhere else.
The gateway chunks at 28000 payload bytes¶
Every continuing frame in the 22-frame reply carried exactly 28000 bytes of
payload (28080 with the header). That is why a DD03L read crosses into several
frames at around 2000 rows, and why 20000 rows needed 22 of them.
Confirmed: bytes 56–59 are the frame's own payload length¶
BE uint32, exact on all 16 frames checked — the 2 above plus 9 independently
captured golden fixtures, where it equals the body length on every server
response and reads 0 on client requests. This disproves the earlier mapping
of 52–63 as an RFC library name string; the region is three BE uint32
(2 / payload length / the flag above). Not needed for reassembly, since the NI
prefix already gives the length, but a free cross-check that the 80-byte header
split is right.
One unexplained observation¶
The capture continued past part 2 with 40 identical 80-byte frames of GW type
0x06ce — a bare GW header plus RFC marker, zero TLV payload. What produces
them is not established. They contribute nothing, and the loop stops at the
terminator before reaching them, but a reader that kept going on an empty frame
would spin to its cap rather than progress — so an empty continuation is refused
explicitly rather than skipped.
Tag 0x0667 — server-side call duration, microseconds¶
Settled. 8 bytes, little-endian IEEE-754 double, carrying the server-side duration of the call that is being answered, in microseconds. Per call, not cumulative. Bytes at capture tier; meaning at behavioural-probe tier (3).
Two golden fixtures used to disagree about this — rfcping_response.json called
it a duration in microseconds, stfc_connection_response.json an [ASSUMED]
timeout in seconds — and neither could be right on the strength of a capture,
because a capture shows 138.0 without saying what 138.0 counts.
How it was settled¶
The first probe varied rows read and watched the value move by a factor of 400. That ruled out a fixed setting, but its verdict — "it tracks the work, so it is a duration" — did not follow: rows read moves the server's processing time and the size of the response together, so a byte counter fit the numbers just as well.
RFC_PING_AND_WAIT separates them. It sleeps for SECONDS and returns a reply of
constant size, so the clock moves while the response does not. On A4H, kernel 793:
SECONDS |
wall | response | 0x0667 |
|---|---|---|---|
| 0 | 182.09 ms | 236 B | 593.0 |
| 1 | 1036.75 ms | 236 B | 1001468.0 |
| 3 | 3038.57 ms | 236 B | 3001166.0 |
Four independent things fall out of those three rows:
- Not a byte counter. The response stayed at 236 bytes while the value went from 593 to 3001166. This is the hypothesis the earlier probe could not exclude, and it is now excluded outright.
- Microseconds. The rise over the 0-second call is 1000875 for a 1-second sleep and 3000573 for a 3-second one — 1.001 s and 3.001 s read as microseconds, matching the independent variable to 0.1%. Read as milliseconds those are 1000 s and 3000 s, which is absurd.
- Per call, not cumulative. A running total would put the third call at 593 + 1001468 + 3000573 ≈ 4002634. It reads 3001166 — its own duration alone.
- It is the server's own measurement, not ours. Every value is bracketed by
the sleep below it and the wall clock above: 1000000 < 1001468 < 1036750, and
3000000 < 3001166 < 3038570. A field that included network time would exceed
the wall clock; one that measured something other than this call would not
track
SECONDSat all. The ~600 µs floor atSECONDS=0is the server-side cost of the call itself.
The [ASSUMED] labels on this tag are removed. The timeout reading is disproven,
not merely unconfirmed.
Why this is worth having¶
It is the one number that separates server time from network time. A call that
takes 3 s of wall clock is a very different problem depending on whether
0x0667 says 2.99 s (the ABAP is slow) or 40 ms (the network or the gateway is).
ConnectionMetrics measures latency with a local clock, which cannot make that
distinction; the field is what closes the gap.
The tag is not guaranteed present in every response — it appears in the RFCPING, STFC_CONNECTION and RFC_PING_AND_WAIT replies captured so far, and no rule has been established that requires it. Any reader must treat absence as "unknown" rather than zero.
Message variables, and a free-text tag that does not exist¶
0x0411–0x0414 carry message variables V1–V4, consecutively. Confirmed against
a purpose-built RFM on A4H kernel 793:
| tag | value |
|---|---|
0x0415 |
00 (message class) |
0x0416 |
E (type) |
0x0417 |
398 (number) |
0x0411–0x0414 |
ALPHA1, BRAVO2, CHARLIE3, DELTA4 |
0x0401 |
FOUR_VARIABLES (key) |
The four values are distinct on purpose — four copies of one string would parse
identically with the tags in any order. Message 00/398 is & & & &, read from
T100 rather than assumed; 00/001 is &1&2&3&4&5&6&7&8 and would have run the
values together. Fixture exception_msg_variables_response.bin.
0x040B is removed. It had been carried as a free-text message tag, never
observed in any capture, and tried first when resolving the message — ahead of
0x0402, which is captured and confirmed on kernel 752. It was kept on the
reasoning that dropping an untested fallback is no better evidenced than keeping
it, which was sound while nothing had been aimed at it. This capture aims at it
directly: a reply carrying a genuine four-variable message is exactly what would
populate a free-text tag, and 0x040B is absent. One untested guess outranking
one confirmed fact is the wrong way round.
Kernel 793 sends no assembled message text for a classic exception at all —
0x0402 is absent too. The client is expected to build the sentence from the
class, number and variables via a T100 lookup, which this library does not make.
AbapApplicationError.message is therefore None on this path, and the
exception's diagnostic string carries the class, number and variables instead of
dropping them. On this kernel that is the common case, not an edge case.
Tag 0x0503 — the success marker¶
An empty record, and its presence is the signal. Across all ten RFC-layer replies
in tests/golden/, 0x0503 is present exactly when 0x0417 is absent:
0x0503 |
0x0417 |
0x0420 |
|
|---|---|---|---|
| successful replies (7) | present | absent | present |
| exception replies (3) | absent | present | absent |
So the two are complementary markers for the same question — whether a result or
an exception follows — and 0x0420 (the return code) tracks the success side.
This was recorded as "response flag 2, meaning unknown"; the meaning falls out of
comparing the corpus rather than any one frame, which is why a single capture
never settled it.
Reading either marker alone is enough to classify a reply. parse_invoke_response
keys on 0x0417, which is the more conservative choice: a reply carrying neither
marker is then treated as a result and fails on the missing data, rather than
being reported as an exception with nothing to say.
Exception TLV semantics — the tag set varies by release¶
0x0417 is the marker: its presence is what makes a response an exception rather than a
result (an exception response carries no 0x0420 at all). It holds the message number —
3 characters, e.g. "000", "341". The mapping to exception key and message number
was confirmed by matching a RAISE EXAMPLE in ABAP against the captured frame, and again
against FU_NOT_FOUND from RFC_GET_FUNCTION_INTERFACE for a non-remote-enabled module.
The tag carrying the key is not the same on every release. Kernel 793 puts it in
0x0401; a 7.52 system puts it in 0x0403 and adds the free message text in 0x0402,
neither of which appears in any 793 capture. A reader that knows only the 793 tags gets
key=None and message=None from a 7.52 exception while the text sits unread in the
frame — this is exactly what happened before
tests/golden/framing/signon_incomplete_752_response.bin was captured. Both spellings are
now read, 793's first.
The text encoding is not fixed either. 793 sends these fields as UTF-16LE; the 7.52
capture sends them single-byte. The two are not distinguishable by "are all bytes < 0x80"
— ASCII text in UTF-16LE passes that test too and then decodes as "L o g o n" — so the
width is detected per value from the interleaved-NUL pattern rather than assumed from the
connection's Unicode flag.
Both of the [ASSUMED] labels this section used to list are now settled, by a
purpose-built function module raising one message with four distinct variables.
| Tag | Was assumed to be | Outcome |
|---|---|---|
0x0412–0x0414 |
Message variables V2–V4 | Confirmed. ALPHA1, BRAVO2, CHARLIE3, DELTA4 each landed in its own tag, in order. The values were distinct on purpose — four copies of one string would have parsed identically with the tags in any order. Fixture exception_msg_variables_response.bin. |
0x040B |
Free-text exception message | Disproven and removed. A reply carrying a genuine four-variable message is exactly what would populate a free-text tag, and 0x040B is absent from it. So is 0x0402: kernel 793 sends no assembled text for a classic exception at all, leaving the client to build the sentence from class, number and variables. |
The 0x0418 call-stack breadcrumb is now parsed, though only for the E=<n> code
inside it — that is where the number in issue #14 came from, and it had been
hardcoded in three places rather than read. The rest of its grammar is still
unparsed.
Binary TABLE descriptor layout (0x0302 / 0x0330) — CONFIRMED¶
0x0302 is 8 bytes: [BE uint32 row width][BE uint32 row count]. The order was
previously inferred by pattern-matching and is now confirmed independently, from
a single reply carrying two tables with different counts:
| table | 0x0330 |
0x0302 |
0x0303 records that followed |
|---|---|---|---|
PARAMS |
00000001 |
00000194 00000003 → 404, 3 |
3 |
RESUMABLE_EXCEPTIONS |
00000002 |
0000003e 00000000 → 62, 0 |
0 |
Counts of 3 and 0 against 3 and 0 records settle which uint32 is which — a single
table could not, since either reading fits one number. The widths match what the
same reply declares for those fields (uc-len 404 and uc-len 62), and 0x0330
is a per-table sequential id rather than an opaque four bytes.
The declared width is not always the transmitted record length. In that same
reply each 0x0303 record is 402 bytes where the width says 404. It matters
because the two forms are read differently and only one of them may use the
width:
- uncompressed
0x0303— each record is one row, so row boundaries come from the records. Splitting the concatenated bytes by the declared width instead would drift two bytes per row. - compressed
0x0305— there are no per-row boundaries, so the width is the row stride and is correct there: a 44-rowBAPI_USER_GET_DETAILinterface decompresses to exactly 17776 bytes, which is 44 × 404.
saprfclib treats 0x0302 as informational when reading and takes rows from the
records, so it is right in both cases; the note is here because the obvious
simplification is wrong.
Unsupplied optional CHANGING parameters — UNCONFIRMED¶
Supplied CHANGING parameters use 0x0201 + 0x0203, same as IMPORTING — confirmed. Not
confirmed: whether an unsupplied optional CHANGING parameter should be omitted from the
request entirely or sent as a zero-value placeholder. saprfclib omits it, matching its policy
for IMPORTING and EXPORTING. If a given function module expects an explicit placeholder, the
call would fail at the ABAP layer rather than silently misbehave.
Cross-References¶
- Serialization — ABAP type encoding inside TLV data payloads
- Handshake — logon handshake TLV sequence
- Methodology — how this documentation was derived and how to extend it
Last updated: 2026-06-28. Captures: SAP NetWeaver 7.58, sysnr=00, port=3300.