OpenADS Wire Protocol — v1.0.0-rc25
This document specifies the OpenADS-native wire protocol spoken
between an OpenADS client (ace64.dll opened with a
tcp://host:port/<dir> URI) and an OpenADS server
(tools/serverd/openads_serverd or the network::Server library
embedded in another process).
The protocol is not byte-compatible with the proprietary Advantage Database Server remote protocol. OpenADS implements its own clean-room wire format because publishing or implementing the SAP-owned protocol would require disassembly or other material covered by the Advantage SDK / ACE EULA.
This spec is the canonical reference for downstream consumers that want to write a non-C++ client (Python, Go, Rust, Harbour extension hosts) without reading the C++ source. The on-the-wire byte layout has only grown — opcode bytes are stable; new opcodes get appended.
1. Transport
- TCP/IP over an arbitrary port (no IANA allocation; the
server binds to whatever its CLI / API caller picks). The
reference daemon (
openads_serverd) defaults to127.0.0.1:6262. - Plaintext (
tcp://...) or TLS (tls://...). The TLS transport landed in v0.4.0 (M12.12 / M12.13) via vendoredmbedtls 3.6 LTS(Apache-2.0, statically linked since v1.0.0-rc8 — no runtimelibssl/libcrypto/mbedtlsDLL dependency). - No multiplexing. One connection = one session = one logical database connection. Statements + cursors are scoped to the session; multiple parallel SQL queries on the same TCP connection are serialised by the client mutex.
- Nagle disabled (
TCP_NODELAY, M12.20 / v1.0.0-rc18) — the wire is strict ping-pong, so Nagle’s accumulation delay was pure latency tax. - No multiplexing. One connection = one session = one logical database connection. Statements + cursors are scoped to the session; multiple parallel SQL queries on the same TCP connection are serialised by the client mutex.
2. Frame layout
Every message is a single frame:
+--------+--------+--------+--------+--------+--------+ ... +--------+
| payload length (BE u32) | opcode | payload bytes |
+--------+--------+--------+--------+--------+--------+ ... +--------+
bytes 0..3 (length) byte 4 bytes 5..(4+len)
payload length— 32-bit unsigned, big-endian, counts only the payload bytes (excludes the 5-byte header). 0 ⇒ no payload.opcode— 8-bit unsigned. See §4 for the full list.payload— opcode-specific. Numeric integers inside the payload are little-endian unless explicitly noted (e.g. the 4-byte BE length in the header). Strings are raw UTF-8 / OEM bytes with no NUL terminator unless an explicit length prefix precedes them.
3. Session lifecycle
client server
| |
|--Hello---------------------- --------->|
|<------------------ ---------HelloAck---| (banner = "openads/<ver>")
| |
|--Connect(dir,user,pw)----------------->|
|<-------------------- -----ConnectAck---| ("connected:<dir>")
| |
| ... opcode pairs (OpenTable / SQL / |
| Fetch / Skip / GetField / ...) |
| |
|--Disconnect--------------------------->|
| (server closes socket) |
Hello is optional from a strict-protocol point of view (the reference client skips it and goes straight to Connect), but the server always answers with the banner if asked.
Connect is mandatory before any table / SQL op. After
ConnectAck the session has an engine::Connection open against
the requested data dir.
Disconnect triggers an immediate server-side close with full cleanup (cursors, ABI statement, ABI connection). A peer-close without Disconnect also runs cleanup.
4. Opcodes
The byte values are stable; new opcodes only get appended.
The table below is generated from the canonical Opcode enum in
src/network/wire.h. Opcodes are listed in hex order; note the
byte values are not contiguous with milestone order — later
milestones reused gaps left by earlier ones.
| Op | Hex | Direction | Meaning | Milestone |
|---|---|---|---|---|
Hello |
0x01 |
C→S | Banner request | M12.3 |
HelloAck |
0x02 |
S→C | Banner reply | M12.3 |
Connect |
0x10 |
C→S | Open session | M12.3 |
ConnectAck |
0x11 |
S→C | Session opened | M12.3 |
Disconnect |
0x12 |
C→S | Close session | M12.3 |
OpenTable |
0x20 |
C→S | Open a DBF/CDX/NTX | M12.4 |
OpenTableAck |
0x21 |
S→C | Returns wire table-id | M12.4 |
CloseTable |
0x22 |
C→S | Close table | M12.4 |
CloseTableAck |
0x23 |
S→C | M12.4 | |
ExecuteSQL |
0x30 |
C→S | Run SQL statement | M12.7 |
ExecuteSQLAck |
0x31 |
S→C | Returns cursor table-id (or 0) | M12.7 |
Fetch |
0x32 |
C→S | Batch row read | M12.11 |
FetchAck |
0x33 |
S→C | Row matrix | M12.11 |
GotoTop |
0x40 |
C→S | M12.4 | |
GotoTopAck |
0x41 |
S→C | M12.4 | |
Skip |
0x42 |
C→S | Skip ±N rows | M12.4 |
SkipAck |
0x43 |
S→C | M12.4 | |
GetField |
0x44 |
C→S | Read one column at cursor | M12.4 |
GetFieldAck |
0x45 |
S→C | Column bytes | M12.4 |
GetRecordCount |
0x46 |
C→S | M12.4 | |
GetRecordCountAck |
0x47 |
S→C | M12.4 | |
AtEOF |
0x48 |
C→S | M12.4 | |
AtEOFAck |
0x49 |
S→C | 0 / 1 byte | M12.4 |
DescribeTable |
0x4A |
C→S | Schema in one round-trip | M12.14 |
DescribeTableAck |
0x4B |
S→C | Column list + types | M12.14 |
AtBOF |
0x4C |
C→S | M12.14 | |
AtBOFAck |
0x4D |
S→C | 0 / 1 byte | M12.14 |
GetRecordNum |
0x4E |
C→S | Current recno | M12.14 |
GetRecordNumAck |
0x4F |
S→C | M12.14 | |
AppendBlank |
0x50 |
C→S | M12.6 | |
AppendBlankAck |
0x51 |
S→C | M12.6 | |
SetField |
0x52 |
C→S | Write one column at cursor | M12.6 |
SetFieldAck |
0x53 |
S→C | M12.6 | |
DeleteRecord |
0x54 |
C→S | Mark deleted | M12.6 |
DeleteRecordAck |
0x55 |
S→C | M12.6 | |
RecallRecord |
0x56 |
C→S | Undelete | M12.6 |
RecallRecordAck |
0x57 |
S→C | M12.6 | |
GotoRecord |
0x58 |
C→S | Jump to recno | M12.6 |
GotoRecordAck |
0x59 |
S→C | M12.6 | |
FlushTable |
0x5A |
C→S | Force write-through | M12.6 |
FlushTableAck |
0x5B |
S→C | M12.6 | |
Reindex |
0x60 |
C→S | Rebuild bound indexes | M12.8 |
ReindexAck |
0x61 |
S→C | M12.8 | |
IsRecordDeleted |
0x62 |
C→S | M12.14 | |
IsRecordDeletedAck |
0x63 |
S→C | 0 / 1 byte | M12.14 |
GotoBottom |
0x64 |
C→S | M12.14 | |
GotoBottomAck |
0x65 |
S→C | M12.14 | |
IsFound |
0x66 |
C→S | Seek-hit flag | M12.15 |
IsFoundAck |
0x67 |
S→C | M12.15 | |
RefreshRecord |
0x68 |
C→S | Re-read current record | M12.15 |
RefreshRecordAck |
0x69 |
S→C | M12.15 | |
GetTableType |
0x6A |
C→S | DBF / CDX / NTX kind | M12.15 |
GetTableTypeAck |
0x6B |
S→C | M12.15 | |
GetRecordLength |
0x6C |
C→S | M12.15 | |
GetRecordLengthAck |
0x6D |
S→C | M12.15 | |
GetNumIndexes |
0x6E |
C→S | M12.15 | |
GetNumIndexesAck |
0x6F |
S→C | M12.15 | |
GetLastAutoinc |
0x70 |
C→S | Last autoinc value | M12.15 |
GetLastAutoincAck |
0x71 |
S→C | M12.15 | |
LockRecord |
0x72 |
C→S | Single-record byte-range lock | M12.15 |
LockRecordAck |
0x73 |
S→C | M12.15 | |
UnlockRecord |
0x74 |
C→S | M12.15 | |
UnlockRecordAck |
0x75 |
S→C | M12.15 | |
LockTable |
0x76 |
C→S | Whole-table lock | M12.15 |
LockTableAck |
0x77 |
S→C | M12.15 | |
UnlockTable |
0x78 |
C→S | M12.15 | |
UnlockTableAck |
0x79 |
S→C | M12.15 | |
PackTable |
0x7A |
C→S | Compact deleted rows | M12.15 |
PackTableAck |
0x7B |
S→C | M12.15 | |
ZapTable |
0x7C |
C→S | Empty table | M12.15 |
ZapTableAck |
0x7D |
S→C | M12.15 | |
FlushFileBuffers |
0x7E |
C→S | fsync table + index files | M12.15 |
FlushFileBuffersAck |
0x7F |
S→C | M12.15 | |
CloseAllIndexes |
0x80 |
C→S | M12.15 | |
CloseAllIndexesAck |
0x81 |
S→C | M12.15 | |
SetAOF |
0x82 |
C→S | Install Rushmore filter | M12.15 |
SetAOFAck |
0x83 |
S→C | OptLevel + opt-bitmap meta | M12.15 |
ClearAOFRemote |
0x84 |
C→S | Drop the installed AOF | M12.15 |
ClearAOFRemoteAck |
0x85 |
S→C | M12.15 | |
GetAOFOptLevel |
0x86 |
C→S | M12.15 | |
GetAOFOptLevelAck |
0x87 |
S→C | FULL / PART / NONE | M12.15 |
OpenIndex |
0x88 |
C→S | Open .cdx / .ntx index |
M12.16 |
OpenIndexAck |
0x89 |
S→C | Wire index-id | M12.16 |
CloseIndex |
0x8A |
C→S | M12.16 | |
CloseIndexAck |
0x8B |
S→C | M12.16 | |
SetOrder |
0x8C |
C→S | Switch active order by handle | M12.16 |
SetOrderAck |
0x8D |
S→C | M12.16 | |
SetOrderByName |
0x8E |
C→S | Switch active order by tag name | M12.16 |
SetOrderByNameAck |
0x8F |
S→C | M12.16 | |
Seek |
0x90 |
C→S | Index key seek (hit / miss) | M12.16 |
SeekAck |
0x91 |
S→C | Found flag + recno | M12.16 |
SeekLast |
0x92 |
C→S | Seek last matching key | M12.16 |
SeekLastAck |
0x93 |
S→C | M12.16 | |
CreateIndex |
0x94 |
C→S | CDX-on-the-wire CREATE INDEX |
M12.16 |
CreateIndexAck |
0x95 |
S→C | M12.16 | |
SkipUnique |
0x96 |
C→S | Skip to next unique key | M12.16 |
SkipUniqueAck |
0x97 |
S→C | M12.16 | |
SetScope |
0x98 |
C→S | Set index key-range scope | M12.16 |
SetScopeAck |
0x99 |
S→C | M12.16 | |
ClearScope |
0x9A |
C→S | M12.16 | |
ClearScopeAck |
0x9B |
S→C | M12.16 | |
FetchCurrentRow |
0x9C |
C→S | Read whole current row | M12.17 |
FetchCurrentRowAck |
0x9D |
S→C | Full record buffer | M12.17 |
GetLastTableUpdate |
0x9E |
C→S | DBF header last-update stamp | M12.24 |
GetLastTableUpdateAck |
0x9F |
S→C | Date as YYYYMMDD bytes |
M12.24 |
MgConnect |
0xA0 |
C→S | Open management telemetry channel | M9.25 (rc24) |
MgConnectAck |
0xA1 |
S→C | Channel opened / reachability ack | M9.25 (rc24) |
MgRequest |
0xA2 |
C→S | Request a telemetry snapshot | M9.25 (rc24) |
MgReplyAck |
0xA3 |
S→C | MgSnapshot payload |
M9.25 (rc24) |
Error |
0xFF |
S→C | Any failure (4-byte ACE-code prefix since M12.10) | M12.3 |
5. Payload formats
Notation:
u8,u16,u32— unsigned little-endian unless noted.len-prefixed string—[u16 byte_length][bytes...](M12.9 Connect frame uses this form for dir/user/pw).bytes— raw, length implied by frame length.
5.1 Hello / HelloAck
- Hello: empty.
- HelloAck:
bytes— server banner, e.g.openads/1.0.0-rc25. Since v1.0.0-rc13 the banner is driven fromgit describe, so it always reflects the actual build.
5.2 Connect / ConnectAck
- Connect:
[u16 dlen][dir][u16 ulen][user][u16 plen][password](M12.9 —userandpasswordmay be empty if the server doesn’t require auth). - ConnectAck:
bytes—connected:<dir>(informational).
5.3 Disconnect
- C→S only. Empty payload. No ack — server closes the socket.
5.4 OpenTable / OpenTableAck
- OpenTable:
bytes— table leaf path (e.g.data.dbf), resolved against the session’s data dir. - OpenTableAck:
[u32 wire_table_id]— opaque to the client; every subsequent table op echoes this id.
5.5 CloseTable / CloseTableAck
- CloseTable:
[u32 wire_table_id]. - Ack: empty.
5.6 ExecuteSQL / ExecuteSQLAck
- ExecuteSQL:
bytes— raw SQL text, ASCII / UTF-8. - ExecuteSQLAck:
[u32 cursor_id]—0for non-SELECT (INSERT / UPDATE / DELETE / DDL), otherwise a wire table-id the client uses with the read-side ops below.
5.7 Fetch / FetchAck
- Fetch:
[u32 tid][u32 max_rows][u8 ncols][per col: u8 nlen, name]. Walksmax_rowsrows from the cursor’s current position; works for both engine handles (returned byOpenTable) and SQL cursor handles (returned byExecuteSQL). - FetchAck:
[u32 nrows][u8 ncols][per row, per col: u16 vlen, val_bytes]. Rows are emitted in cursor order; column order matches the request.nrowsis the number actually returned; may be less thanmax_rows(EOF or skip failure stops the walk early).
5.8 GotoTop / GotoTopAck, GotoBottom / GotoBottomAck, Skip / SkipAck, GotoRecord / GotoRecordAck
- GotoTop / GotoBottom:
[u32 tid]. - Skip:
[u32 tid][u32 step_le](stepis signed; transmit as little-endian raw u32 bits). - GotoRecord:
[u32 tid][u32 recno]. - Acks carry a row trailer since M12.18 (v1.0.0-rc18):
[u32 recno][u8 deleted][u32 row_buf_len][row_buf bytes]. The trailer is empty (length 0) only when the cursor lands at EOF / Limbo. Clients that pre-date M12.18 can ignore extra bytes past the prior 0-length frame — the wire codec passes the full payload through.
5.9 GetField / GetFieldAck
- GetField:
[u32 tid][bytes field_name](no length prefix — field name runs to end of payload). - Ack:
bytes— column value as the engine’s textual rendering (DBF columns are textually formatted on disk; this is the same byte streamAdsGetFieldreturns locally, including trailing blank-padding for fixed-width columns).
5.10 GetRecordCount / GetRecordCountAck, AtEOF / AtEOFAck
- GetRecordCount:
[u32 tid]. Ack:[u32 record_count]. - AtEOF:
[u32 tid]. Ack: 1 byte (0= not EOF,1= EOF).
5.11 AppendBlank, DeleteRecord, RecallRecord, FlushTable, Reindex, Pack, Zap
- All seven:
[u32 tid], ack empty. AppendBlanksince M12.23 / v1.0.0-rc19 auto-acquires a record byte-range lock on the new row (ACE semantics for non-exclusive tables — X#’sGoHotrefuses to write a record it sees as unlocked).
5.12 SetField / SetFieldAck
- SetField:
[u32 tid][u16 namelen][name_bytes][value_bytes]. Value runs from5 + namelento end of payload. The engine applies the textual representation throughTable::set_field(idx, std::string), which handles all field types (C / N / D / L / M / V / Q / I / Y / B). - Ack: empty.
5.13 Error
- S→C only. Layout (M12.10 onwards):
[u32 ace_code_le][message_bytes]. ace_codeis one of the constants frominclude/openads/error.h(e.g.5004AE_FUNCTION_NOT_AVAILABLE,5018AE_NO_FILE_FOUND,5066AE_TABLE_NOT_FOUND,7077AE_LOGIN_FAILED,7200AE_PARSE_ERROR).messageis a human-readable diagnostic; not stable across versions, only for debugging / logs.
5.14 DescribeTable / DescribeTableAck (M12.14)
- DescribeTable:
[u32 tid]. - Ack:
[u8 ncols][per col: u8 nlen, name, u8 type, u16 len, u8 dec]. - Client caches the result on
RemoteTableso the entire field- metadata API (AdsGetNumFields,AdsGetFieldName/Type/Length/ Decimals) costs one round-trip per opened table.
5.15 FetchCurrentRow / FetchCurrentRowAck (M12.17)
- C→S:
[u32 tid]. - Ack: same row-trailer layout as the navigation acks (§5.8):
[u32 recno][u8 deleted][u32 row_buf_len][row_buf bytes]. - The client caches the row buffer and serves every cell read
(
AdsGetField/AdsGetLong/AdsGetDouble/AdsGetJulian) out of the cache until the next navigation, collapsing W cells per row to 1 RTT.
5.16 Lock / Unlock (M12.15)
LockRecord/UnlockRecord:[u32 tid][u32 recno].recno == 0means “current record” (M12.23 / rc19).LockTable/UnlockTable:[u32 tid]. Ack empty.
5.17 SetAOF / ClearAOF / GetAOFOptLevel (M12.15)
SetAOF:[u32 tid][bytes cond]— cond is the Clipper-style AOF expression text (TAG = 'AAAA',AGE BETWEEN 25 AND 40, …).SetAOFAck:[u32 opt_level](0NONE,1PART,2FULL).ClearAOF:[u32 tid], ack empty.- Non-optimisable expressions (since M12.24 / rc21) return
opt_level = 0rather than an Error frame, matching ACE.
5.18 OpenIndex / CloseIndex / Seek / CreateIndex (M12.16, M12.16b)
OpenIndex:[u32 tid][bytes index_path].OpenIndexAck:[u32 wire_index_id]. Server promotes an ABI index handle intbls_hand syncs the engine cursor.CloseIndex:[u32 wire_index_id], ack empty.Seek:[u32 tid][u32 hindex][u8 soft][u8 last][u16 klen][key].SeekAck:[u8 found][u32 recno].CreateIndex:[u32 tid][u16 tlen][tag][u16 elen][expr][u32 flags](flags =ADS_DESCENDING/ADS_UNIQUE/ADS_COMPOUND/ADS_DOUBLEKEY).- Ack: empty.
5.19 SetOrder / SetOrderByName (M12.16c)
SetOrder:[u32 tid][u32 hindex]—hindex == 0clears order.SetOrderByName:[u32 tid][bytes tag_name].- Both acks empty.
5.20 GetLastTableUpdate / Ack (M12.24)
- C→S:
[u32 tid]. - Ack:
[bytes date_str]— 8 bytesYYYYMMDD(raw, no format applied; client renders via the process-wide date format set byAdsSetDateFormat).
5.21 MgConnect / MgRequest (M9.25, rc24)
The management telemetry channel — what the AdsMg* ABID
functions and tools/mgprobe speak to a remote openads_serverd.
MgConnect:[bytes server]— thehost:port(or drive path) passed toAdsMgConnect.MgConnectAckis empty on success; the handshake doubles as an eager reachability probe.MgRequest:[u8 kind][u16 arg]— always 3 bytes.kind:0x01Snapshot (fullMgSnapshot, covers everyGet*),0x02KillUser (arg= connection number),0x03ResetCommStats,0x04DumpTables.MgReplyAck: a fully serializedMgSnapshot, little-endian — live counts (connections / work areas / tables / users / worker threads), per-entity lists, process RSS, listener port, and the cumulativeMgStats(uptime, comm packet totals, server-initiated disconnects, high-water marks).- An unknown
MgRequestKindis answered withError(0xFF).
6. Versioning
- This spec covers OpenADS v1.0.0-rc25. Bumps append new opcodes and document them here without breaking existing ones.
- Clients can probe the server version via
Hello→ the banner string isopenads/<semver>.
7. Error handling expectations
- Any frame may be replied with
Error(0xFF). Clients must parse the 4-byte ACE-code prefix before treating the rest as message text. - A peer-closed connection mid-frame is treated as
AE_INTERNAL_ERROR5000 with messagepeer closed connection— the wire layer bubbles this up viarecv_exact. AE_FUNCTION_NOT_AVAILABLE5004 fromConnectmeans the URI scheme isn’t supported, or the server was built without the matching transport (rare since TLS shipped in v0.4.0).
8. Reference impls
- Server:
src/network/server.{h,cpp}plus the standalonetools/serverd/openads_serverdCLI. - Client:
src/network/client.{h,cpp}(RemoteConnection)- the dual-mode dispatch in
src/abi/ace_exports.cpp’sAdsConnect60for the publictcp:///tls://URI integration.
- the dual-mode dispatch in
- Transport abstraction:
src/network/transport.hdefines theITransportpolymorphic surface (M12.13). Concrete impls:PlainTransport(TCP) andTlsTransport(mbedtls, vendored statically since v1.0.0-rc8). - Wire codec:
src/network/wire.{h,cpp}(frame encode / decode +Opcodeenum).