tRFC / qRFC / bgRFC Wire Protocol¶
Status: CONFIRMED for the call-type discriminator and structural format. Live capture 2026-07-05 confirmed TID composition and the ARFCSSTATE field positions. Confidence: HIGH for the discriminator and structure; MEDIUM for the exact byte-level encoding of individual payload fields — see Known Gaps.
Overview¶
tRFC, qRFC and bgRFC are not distinct wire frame types. They are ordinary synchronous RFC
calls to SAP system function modules. The call type is discriminated entirely by the
function name carried in the standard 0x0102 TLV tag — see
framing.md §"RFC Function Call Sequence". The normal invoke/dispatch paths are
reused unchanged for the payload; the transactional layer is orchestration on top.
tRFC / qRFC = synchronous RFC invoke of ARFC_DEST_SHIP (carrying TID in ARFCTID param)
+ optional later invoke of ARFC_DEST_CONFIRM
bgRFC = synchronous RFC invoke of BGRFC_DEST_SHIP (carrying UnitID in BGRFC_UNIT_ID param)
+ optional later invoke of BGRFC_DEST_CONFIRM
This is the single most useful fact on this page. If you are looking for a tRFC frame type in a capture, you will not find one — look for the function name instead.
Call-Type Discriminator¶
The discriminator is the function name TLV (tag 0x0102, UTF-16LE) in the inbound invoke
frame. A server compares the decoded name against the system function-module names below
before falling through to its normal handler lookup:
Function name (0x0102 TLV value) |
Call type |
|---|---|
ARFC_DEST_SHIP |
tRFC — or qRFC, see below |
ARFC_DEST_CONFIRM |
tRFC confirm |
API_CLEAR_TID |
Internal TID clear |
BGRFC_DEST_SHIP |
bgRFC submit |
BGRFC_DEST_CONFIRM |
bgRFC confirm |
BGRFC_CHECK_UNIT_STATE_SERVER |
bgRFC state query |
| (any other name) | Ordinary synchronous RFC |
tRFC vs qRFC: both arrive as ARFC_DEST_SHIP. The difference is a queue-name indicator
carried in the ARFCSSTATE table parameter — when a queue name is present, the call is qRFC
(queued). There is no separate function name and no separate frame type.
tRFC Wire Format: ARFC_DEST_SHIP¶
A tRFC submit is a standard synchronous call to ARFC_DEST_SHIP. The frame is an ordinary
invoke frame as defined in framing.md, with ARFC_DEST_SHIP in TLV 0x0102 and
the parameters below.
The call carries two tables: ARFCSSTATE (the control record, one row) and ARFCSDATA
(payload rows, one per ABAP function module invoked in the LUW).
ARFCSSTATE control-record fields¶
NUC = non-Unicode size, UC = Unicode (UTF-16) size. Offsets are non-Unicode.
| ABAP field | Type | NUC size | UC size | NUC offset | Notes |
|---|---|---|---|---|---|
ARFCIPID |
CHAR | 8 | 16B | 0 | IP identifier (host part of TID) |
ARFCPID |
INT4 | 4 | 8B | 8 | PID (process part of TID) |
ARFCTIME |
INT8 | 8 | 16B | 0x0c | Timestamp (time part of TID) |
ARFCTIDCNT |
INT4 | 4 | 8B | 0x14 | Counter (uniqueness part of TID) |
ARFCDEST |
CHAR | 32 | 64B | 0x18 | Destination name |
ARFCLUWCNT |
INT8 | 8 | 16B | 0x38 | LUW count |
ARFCSTATE |
CHAR | 8 | 16B | 0x40 | State |
ARFCFNAM |
CHAR | 30 | 60B | 0x48 | Function module name |
ARFCRETURN |
CHAR | 1 | 2B | 0x66 | Return code |
ARFCUZEIT |
TIME | 6 | 12B | 0x67 | Time |
ARFCDATUM |
DATE | 8 | 16B | 0x6d | Date |
ARFCUSER |
CHAR | 12 | 24B | 0x75 | User name |
ARFCRETRYS |
INT4 | 4 | 8B | 0x81 | Retry count |
ARFCTCODE |
CHAR | 20 | 40B | 0x85 | Transaction code |
ARFCRHOST |
CHAR | 8 | 16B | 0x99 | Remote host |
ARFCMSG |
CHAR | 50 | 100B | 0xa1 | Message |
RESERV |
BYTE | 255 | 510B | 0xd3 | Reserved |
HASH |
XSTRING | 40 | 40B | 0x1d2 | Hash |
The 24-character TID¶
The TID is a 24-character string over a non-hexadecimal alphabet:
It is built from four 4-byte words, each encoded as 6 characters by repeated modulo-41 into that alphabet — 4 × 6 = 24 characters:
own_ip— 4 bytes of the local IP address (resolved once per process)pid— the process ID (4 bytes, resolved once per process)time— Unix seconds (4 bytes)tidCount— an atomically incremented counter
Wire encoding: as ABAP CHAR values inside the ARFCIPID / ARFCPID / ARFCTIME /
ARFCTIDCNT fields of the ARFCSSTATE row. The 24-character TID a client API hands you is the
concatenation of those four fields — it is not sent as a standalone TLV tag. Looking for a
"TID tag" in a capture is a dead end.
Live confirmation (2026-07-05): a captured TID of C0A8580746006A4AA9F40003 decomposes as
ARFCIPID (8) + ARFCPID (4) + ARFCTIME (8) + ARFCTIDCNT (4), all uppercase hex —
IP 192.168.88.7 = C0A85807, PID 4600, time 6A4AA9F4, counter 0003.
A UUID-derived TID is valid, but is not the native format
uuid4().hex[:24].upper() yields characters from 0-9A-F, a strict subset of the TID
alphabet. The server range-checks the alphabet only, so such a TID is accepted. The native
format is the IP + PID + time + counter encoding above, and using it makes TIDs traceable
back to their originating host and process — which is worth having when debugging a stuck
LUW.
qRFC: Queue-Name Discrimination¶
qRFC is tRFC plus a queue name, sent through the same ARFC_DEST_SHIP function module. The
queue name travels in the ARFCSSTATE table parameter; a non-empty queue-name indicator is what
makes the call queued.
Queue names are bounded — a maximum length applies, and a unit may carry more than one. Reject empty and overlong names on the inbound path rather than passing them to a store.
bgRFC Wire Format: BGRFC_DEST_SHIP¶
bgRFC uses its own function module, BGRFC_DEST_SHIP:
Read from the dictionary on A4H kernel 793 (2026-09-04) via
RFC_GET_FUNCTION_INTERFACE, so these are the module's declared parameters, not
an inference. Widths are Unicode (uc_length); halve for the non-Unicode column.
| ABAP parameter | Dir | Type | Notes |
|---|---|---|---|
OUT_IN_QUEUE_NAME_TAB |
IMPORT | TABLE | rows of QRFC_QUEUE_NAME_TAB, one CHAR(40) field |
SDATA |
IMPORT | XSTRING | serialized payload — the calls buffered in the unit |
SSTATE |
IMPORT | STRUCTURE | BGRFC_SRV_STATE, 616 bytes — carries the unit ID |
SUPPORTABILITY_INFO |
IMPORT | STRUCTURE | BGRFC_SUPPORTABILITY_INFO, 112 bytes |
SHUTDOWN_ACTIVE |
CHANGING | CHAR(1) | |
BGRFC_RETRY_DELAY_TIME |
EXPORT | INT4 | retry delay, seconds |
BGRFC_RETRY_KEY |
EXPORT | CHAR(24) | |
BGRFC_RETRY_MAX_COUNT |
EXPORT | INT4 | |
SERVER_STATE |
EXPORT | INT4 | |
SHUTDOWN_INSTANCE |
EXPORT | CHAR(40) |
There is no BGRFC_UNIT_ID parameter, and no BGRFC_UNIT_TYPE. The unit ID
is the first field of the SSTATE structure:
BGRFC_SRV_STATE field |
Type | uc_offset | uc_length |
|---|---|---|---|
UNIT_ID |
BYTE | 0 | 16 |
UNIT_KIND |
INT4 | 16 | 4 |
DESTINATION |
CHAR(32) | 20 | 64 |
QUEUE_NAME |
CHAR(40) | 84 | 80 |
STATE |
INT4 | 164 | 4 |
UNIT_SIZE |
INT4 | 168 | 4 |
BGRFC_SUPPORTABILITY_INFO repeats UNIT_ID BYTE(16) at offset 0 and
UNIT_KIND INT4 at offset 96.
Two consequences for anything building this frame:
- The unit ID travels as 16 raw bytes, not as its 32-character hex rendering, and not in UTF-16LE. The hex form is a display convention.
UNIT_KINDis an INT4. The'T'/'Q'characters below are an SDK-level API convention; they are not what goes on the wire, and which integer means which is not yet established.
The two simple modules¶
BGRFC_DEST_CONFIRM and BGRFC_CHECK_UNIT_STATE_SERVER take the unit ID
directly rather than through a structure:
| Module | IMPORT | EXPORT |
|---|---|---|
BGRFC_DEST_CONFIRM |
UNIT_ID BYTE(16), UNIT_KIND INT4, five optional RS_* |
RS_SERVER_RESOURCES_RC INT4, RS_SYSTEM_IDENTITY |
BGRFC_CHECK_UNIT_STATE_SERVER |
UNIT_ID BYTE(16), UNIT_KIND INT4 |
STATE INT4, INSTANCE_NAME, MAX_WPRUN_TIME, COMMUNICATION_FAILURE, SYSTEM_FAILURE |
BGRFC_GET_UNIT_ID |
— | UNIT_ID BYTE(16) |
Both are ordinary signatures the dictionary describes, so neither needs a bespoke
TLV builder — the normal invoke path encodes them. BGRFC_GET_UNIT_ID works and
returns a 16-byte ID, so a client can take its unit IDs from the server.
The 32-character UnitID¶
A UnitID is 32 uppercase hex characters — a UUID with the dashes removed.
- With a connection: the client round-trips to
BGRFC_GET_UNIT_ID, receives a 16-byte UUID, and formats it as 32 uppercase hex characters. - Without a connection: the UUID is generated locally and formatted identically.
Example: A1B2C3D4E5F6789012345678901234AB
Unit type character¶
| Character | Meaning |
|---|---|
'T' (0x54) |
No queue names — synchronous execution |
'Q' (0x51) |
One or more queue names specified |
The rule is exactly "queue count < 1 → 'T', otherwise 'Q'". It must match the queue list you
actually send; a mismatch is rejected by the backend.
Client-Side Orchestration¶
tRFC / qRFC submit and confirm¶
conn.call_transactional("FM", tid=tid, ...)
│
├─ 1. build_invoke_request("ARFC_DEST_SHIP", ...)
│ ARFCSSTATE[0].ARFCTID_fields = encode_tid(tid)
│ ARFCSDATA rows = serialized FM calls (one or more)
│ [qRFC: ARFCSSTATE[0].queue_indicator = queue_name]
├─ 2. Transport.send_message()
└─ 3. receive response (RFC_OK or RFC_EXECUTED from server)
conn.confirm_tid(tid)
│
└─ 1. build_invoke_request("ARFC_DEST_CONFIRM", ...)
ARFCSSTATE[0].ARFCTID_fields = encode_tid(tid)
── after this, the backend removes the TID from its state table ──
bgRFC submit and confirm¶
with conn.create_unit(uid, queues=["Q1"]) as unit:
unit.call("FM1", ...) # buffered, not sent yet
unit.call("FM2", ...) # buffered
# __exit__ → build_invoke_request("BGRFC_DEST_SHIP", ...)
conn.confirm_unit(identifier)
└─ build_invoke_request("BGRFC_DEST_CONFIRM", ...)
conn.get_unit_state(identifier)
└─ build_invoke_request("BGRFC_CHECK_UNIT_STATE_SERVER", ...)
Server-Side Dispatch¶
When a registered RFC server receives an inbound call, the function name is matched before the normal handler lookup:
inbound frame
→ decode function name from TLV 0x0102
│
├─ "ARFC_DEST_SHIP" → read TID from ARFCIPID/PID/TIME/TIDCNT
│ → check_transaction(tid)
│ RFC_EXECUTED (0x10) → skip the handler, report success
│ RFC_OK (0) → mark received, run the handler
│
├─ "ARFC_DEST_CONFIRM" → confirm the transaction, drop duplicate protection
├─ "BGRFC_DEST_SHIP" → check the unit, then play it back
└─ *(else)* → ordinary handler dispatch
check_transaction callback contract¶
| Return | Meaning |
|---|---|
RFC_EXECUTED (0x10) |
TID already known and executed → skip the handler, return success |
RFC_OK (0) |
New TID → persist it and execute |
RFC_EXTERNAL_FAILURE (0xf) |
Handler error |
Callback order¶
check_transaction(tid) → RFC_EXECUTED? → skip
mark_received(tid) BEFORE execute ← crash safety: persist before you run
run_handler()
├─ success → on_commit(tid); on_confirm(tid)
└─ error → on_rollback(tid)
mark_received before execution is what makes exactly-once survive a crash mid-handler. If
you persist afterwards, a process death between execution and persistence replays the LUW.
Server context: call type¶
The inbound call type is exposed to the handler as:
| Value | Constant | Meaning |
|---|---|---|
| 0 | RFC_SYNCHRONOUS |
Standard synchronous call |
| 1 | RFC_TRANSACTIONAL |
tRFC |
| 2 | RFC_QUEUED |
qRFC — set when a queue name is present |
| 3 | RFC_BACKGROUND_UNIT |
bgRFC |
TID / UnitID Validation¶
Inbound TID and UnitID values arrive as ABAP CHAR fields inside function parameters. They are attacker-influenced input and become store keys, so validate before use:
| Field | Length | Valid charset | Action on invalid |
|---|---|---|---|
| TID | exactly 24 characters | A-Z0-9/_=@- |
Reject, return RFC_EXTERNAL_FAILURE |
| UnitID | exactly 32 characters | 0-9A-F (uppercase hex) |
Reject |
| Queue name | 1 to the maximum queue-name length | ABAP name characters | Reject empty or overlong |
Pitfalls¶
1. Treating tRFC as a distinct frame type¶
It is a synchronous call to ARFC_DEST_SHIP. Use build_invoke_request() unchanged.
2. Wrong TID character set¶
The TID alphabet is ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_=@- (41 characters), not hex
digits. A uuid4().hex[:24].upper() value is a valid subset but not the native format.
3. Confirming before success¶
confirm_tid() removes the backend's duplicate protection. Calling it before you have verified
the call landed defeats exactly-once. Submit and confirm are deliberately separate calls —
keep them that way.
4. Off-by-2× on TID / UnitID byte length¶
TID = 24 characters × 2 bytes = 48 bytes UTF-16LE. UnitID = 32 × 2 = 64 bytes. All length
arithmetic is in code units, not Python len().
5. bgRFC unit type 'T' vs 'Q'¶
The unit type must match the queue list exactly: 'T' when queues=[], 'Q' when queues are
specified.
6. Looking for a call-type byte¶
The discriminator is the function name string in TLV 0x0102, not a separate byte. Branch
on the decoded function name before the normal handler lookup.
Known Gaps¶
ARFCSSTATE row byte layout — PARTIALLY CONFIRMED¶
A live capture on 2026-07-05 confirmed the compact block encoding: CHAR values appear as
0x43 [len] 0x80 [value], with the TID fields (ARFCIPID / ARFCPID / ARFCTIME /
ARFCTIDCNT) at offsets 504 / 515 / 522 / 533 as 8 + 4 + 8 + 4 uppercase hex characters, and
ARFCFNAM at 1716. An 11-byte preamble at offset 14 —
34 08 0c 00 00 4c 07 00 00 f1 31 — is unexplained. It is stable across captures and not
blocking, but it violates the zero-unknowns rule and is recorded rather than glossed over.
BGRFC_DEST_SHIP payload byte layout — OPEN¶
The live bgRFC test submitted a unit with empty parameters, so no payload bytes were captured for bgRFC function parameters. Consequence: the bgRFC payload encoding is inferred from the tRFC path rather than confirmed. To close: capture a bgRFC unit carrying real parameters.
What is now established, by submitting against a live system (A4H kernel 793, 2026-09-04):
SSTATEreaches the module intact. An earlier submit answered "BGRFC_DEST_SHIP called without unit ID"; fillingSSTATE.UNIT_IDwith the 16 raw bytes clears that check, so the structure encoding itself works.- The module executes the unit inline. It is not a queue insert followed by
asynchronous execution — the submit deserializes
SDATAand runs the calls in it before returning, then updates the state row to "finished". - Therefore
SDATAcannot be isolated fromSSTATE. Submitting with an empty payload to test the state record alone does not work: the empty xstring still goes to the deserializer, which fails an assertion. Every attempt with an emptySDATAends inASSERTION_FAILED, whicheverUNIT_KINDis used and whether or not the optionalSUPPORTABILITY_INFOis supplied.
So the remaining unknown is the whole of it: SDATA is a serialized container holding
the buffered calls, and nothing can be submitted until its format is known. It is not
the concatenated invoke TLV streams this library buffers today.
That capture has now been taken (SDK trace, A4H kernel 793, 2026-09-07: one type T
unit carrying a single STFC_CONNECTION call with a marker string in REQUTEXT).
What a reference client actually sends¶
SSTATE field |
Value observed | Note |
|---|---|---|
UNIT_ID |
16 raw bytes | as issued by BGRFC_GET_UNIT_ID |
UNIT_KIND |
1409196105 |
tRFC outbound, for a type T unit |
DESTINATION |
the client's own hostname | not the inbound destination name |
CALLER_CLIENT |
"000" |
not the logon client |
CALLER_USER |
the OS user | not the SAP user |
CALLER_TCODE |
"NW RFC Library 754" |
the library identifies itself here |
CALLER_PROGRAM |
the executable name | |
SENDING_TIME |
BCD 20260807082915 |
|
SENDING_INSTANCE |
the client hostname |
UNIT_KIND is the trap. An external client sends the outbound value even though the
unit is going into the system, and a wrong choice is not reported: the state module
accepts all four values and selects on (unit_id, unit_kind), so the wrong kind makes a
real unit read as NOT_FOUND — indistinguishable from one that was never submitted.
SDATA container¶
offset size content
0 8 FF 06 02 01 02 02 80 00 fixed header [ASSUMED constant]
8 4 "4103" codepage, single-byte ASCII
12 4 00 00 00 00
16 4 uncompressed length, LE uint32
20 1 0x12 SAPCOMPRESS algorithm = LZH
21 ... LZH-compressed payload
The compressed block is ordinary SAPCOMPRESS: sapcompress_decompress() in compress.py
decodes the captured payload exactly (410 bytes in, 1071 out). Inside is an RFC-recorder
serialization — it opens with a SER_UNIT_TAB name, carries the called function name as
STFC_CONNECTION blank-padded to 30 characters in UTF-16LE, a basxml=0, option string,
and the call's parameter values.
Inside the compressed payload¶
The plaintext is not a new format. Decoding the captured 1071 bytes by hand gives an ordinary RFC TLV stream — the same tags this library already builds — preceded by a logon-style preamble:
0514 16 session token
0007 18 client IP address, UTF-16LE
0011 2 logon language, "E"
0012 6 release, "754"
0013 32 kernel release and server address
0008 10 client hostname
0006 18 partner hostname
0130 26 caller program name
0502 0 request marker
000b 6 release, "754"
0102 30 function name, blank-padded to 30 characters
0201 16 parameter name, "REQUTEXT"
0203 510 parameter value, blank-padded to 255 characters
Everything from 0502 onward is byte-for-byte what build_invoke_request emits for the
same call. Ahead of the preamble sits an index section naming SER_UNIT_TAB, the called
function blank-padded to 30 characters, and an option string basxml=0,.
The index section, by difference¶
Three captures differing in one way each — one call, two calls, one call to a shorter function — separate the constants from the fields. What holds across all three:
| Offset | Width | Meaning | Evidence |
|---|---|---|---|
0x09 |
BE uint16 | total plaintext length − 120 | 951/1892/399 against totals 1071/2012/519 |
0x7f |
uint8 | number of calls in the unit | 1 / 2 / 1 |
0x85 |
60 bytes | function name, UTF-16LE, blank-padded to 30 chars | changes only with the function |
0xc3 |
18 bytes | option string basxml=0, |
constant across all three |
0xfa |
BE uint16 | length of the first call record, which starts at 0x100 |
815/815/263; 256 + 815 = 1071 and 256 + 263 = 519 |
The unit is a chain: the first call record runs from 0x100 for its declared length, and a
second call follows immediately after it. In the two-call capture the first record ends at
1071 — exactly where the second begins — and the second runs to 2012. Each record carries
its own copy of the function name and its own invoke TLV stream (0502 at 469 in the
first record of every capture).
Still open: the descriptor grammar between 0x20 and 0x100. It is a tagged
structure — a SER_UNIT_TAB name, then records shaped <tag> <id> 00 00 00 00 <size>
with tags 0xaa, 0xad, 0xae, 0xaf, and two-byte tags around 0xbc/0xbd/0xbe —
but which of those are field descriptors of the container and which are values is not
established, and the second call record uses a shorter prefix than the first. Building a
submit needs that grammar; reading one does not.
Compression¶
sapcompress_compress_lzc() encodes the LZC variant, round-tripping the captured payload
and every code width. The reference client uses LZH, and whether a server accepts LZC
in its place is untested — a submit is the only way to find out, and that is blocked on
the descriptor grammar above.
Backend tables (A4H, kernel 793, observed 2026-09-04)¶
Which tables hold what, read off a live system rather than inferred from their names. This matters because the obvious guess is wrong in a way that fails silently:
| Table | Holds |
|---|---|
BGRFC_CUST_SUPER |
supervisor destination — bgRFC does not run without one |
BGRFC_MAIN_I_DST |
inbound destination definitions (name + RFC server group) |
BGRFC_REG_I_DEST |
inbound queue-prefix registration |
BGRFC_SRV_STATE |
live unit state: UNIT_ID, UNIT_KIND, DESTINATION, QUEUE_NAME, STATE, ... |
BGRFC_IUNIT_HIST |
inbound unit history, written once a unit leaves BGRFC_SRV_STATE |
BGRFC_CUST_I_SRV |
scheduler tuning per app server — not a destination definition |
BGRFC_CUST_I_DST / BGRFC_CUST_O_DST |
scheduler tuning per destination — not definitions |
The last two rows are the trap. BGRFC_CUST_I_SRV and BGRFC_CUST_I_DST read like "inbound
server" and "inbound destination", and both stay empty on a correctly configured system —
they only fill in if someone tunes the scheduler. A configuration check that reads them reports
"bgRFC is not configured" for a system with both a supervisor and an inbound destination
defined, which is exactly what happened here before the tables were enumerated.
BGRFC_SRV_STATE is what makes issue #15 answerable at all: it lets a confirm be verified
against the backend's own record instead of against get_unit_state(), the function being
tested. Checking a decoder with itself cannot detect a decoding fault.
ARFC_EXECUTE / ARFC_RUN_NOWAIT — OPEN¶
Whether either is ever called separately from ARFC_DEST_SHIP is unconfirmed. Only
ARFC_DEST_SHIP has been observed in dispatch, and no ARFC_EXECUTE frame has appeared in any
capture.
See also: Framing and Handshake for the framing and logon layers.