From 8cb0a3b46db4dda3dd96934b4c4372200c455411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 03:35:18 +0000 Subject: [PATCH] shim: inspection getters - file table + piece availability (ABI v5) Batch 2 of surfacing more of libtorrent: let script see INSIDE a torrent. Until now there was no way to enumerate a torrent's files at all. New btx_* ABI (v4 -> v5, append-only): - btx_file_list - the torrent's files as a count-prefixed list of KV records, one per file: path (relative), size, bytes downloaded, and download priority. One FFI round-trip returns the whole file table (perf playbook). Empty until metadata arrives, so it doubles as a "do we have metadata yet" probe. Pulls per-file progress + priorities in two bulk engine calls, mirrors the peer-list framing exactly. - btx_piece_availability - one byte per piece (peers advertising it, clamped to 255) as a raw view, like btx_piece_bitfield; pair the two for a full piece map. Record schema: four append-only file-entry fields (F_FILE_PATH/SIZE/PROGRESS/ PRIORITY = 120..123), mirrored as kField* + keys path/size/progress/priority in _fieldKey. LCB: kABIVersion -> 5; two foreign decls + btFileList (List of Arrays) / btPieceAvailability (Data) wrappers. 56 -> 58 public handlers. Built + run under gcc ASan/UBSan against real libtorrent: 430 checks, 0 failures, clean -Wall -Wextra. Static gates green (check-livecodescript 7/7; record-registry 95 in sync; golden OK; ABI 5<->5). README + api-reference (entries + file-entry key table) updated. CI rebuilds the binaries on merge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01371AXB4CUUke7enHHS8okc --- README.md | 4 +- docs/api-reference.md | 25 ++++++++++ src/btx_abi.h | 12 ++++- src/btx_record.h | 8 +++- src/torrent.lcb | 78 ++++++++++++++++++++++++++++++- src/torrent_shim.cpp | 89 ++++++++++++++++++++++++++++++++++++ tests/torrent_smoke_test.cpp | 2 + 7 files changed, 213 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 45eafdd..ef6c058 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ These are load-bearing and enforced in the code: ## API at a glance -56 public `bt*` handlers (full signatures in **[api-reference](docs/api-reference.md)**): +58 public `bt*` handlers (full signatures in **[api-reference](docs/api-reference.md)**): | Group | Handlers | |---|---| @@ -161,7 +161,7 @@ These are load-bearing and enforced in the code: | Priorities / limits | `btSetFilePriority` · `btSetFilePriorities` · `btSetPiecePriority` · `btSetTorrentLimits` · `btSetMaxConnections` · `btSetMaxUploads` | | Flags / modes | `btSetTorrentFlags` · `btUnsetTorrentFlags` · `btSetSequentialDownload` · `btSetAutoManaged` · `btSetSuperSeeding` · `btSetShareMode` · `btSetUploadMode` | | Queue / storage | `btQueuePosition` · `btQueueUp` · `btQueueDown` · `btQueueTop` · `btQueueBottom` · `btMoveStorage` | -| Inspect | `btTorrentStatus` · `btTorrentCount` · `btTorrentHandleAt` · `btInfoHash` · `btPieceBitfield` · `btPeerList` | +| Inspect | `btTorrentStatus` · `btTorrentCount` · `btTorrentHandleAt` · `btInfoHash` · `btPieceBitfield` · `btPeerList` · `btFileList` · `btPieceAvailability` | | Events | `btPoll` | | DHT | `btDhtAddBootstrap` · `btDhtState` · `btDhtSaveState` · `btDhtLoadState` | | DHT key-value (BEP44) | `btDhtKeypair` · `btDhtPutImmutable` · `btDhtGetImmutable` · `btDhtPutMutable` · `btDhtGetMutable` | diff --git a/docs/api-reference.md b/docs/api-reference.md index 2282f30..849332b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -321,6 +321,22 @@ The connected peers as a `List` of `Array`s, each keyed by the **peer keys** Empty `List` on a bad handle. - **Usage:** function - `repeat for each element tPeer in btPeerList(tH)`. +### `btFileList(in pTorrent as Integer) returns List` +The torrent's files as a `List` of `Array`s, one per file, each with keys `path` +(relative path within the torrent), `size` (bytes), `progress` (bytes of that +file downloaded), and `priority` (`0..7`). The whole file table in **one FFI +round-trip**. Empty `List` until metadata arrives (a magnet has no file table +yet) or on a bad handle - so it doubles as a "do we have metadata" probe. +- **Usage:** function - `repeat for each element tFile in btFileList(tH)` then read `tFile["path"]`, `tFile["size"]`, `tFile["progress"]`, `tFile["priority"]`. + +### `btPieceAvailability(in pTorrent as Integer) returns Data` +Per-piece availability as raw `Data`: **one byte per piece**, the number of +connected peers advertising that piece (clamped to `255`). A read-only view for +an availability/rarity grid - pair it with `btPieceBitfield` (which pieces you +have) for a full piece map. Empty `Data` until the torrent has metadata and +peers, or on a bad handle. +- **Usage:** function - `put btPieceAvailability(tH) into tAvail`, then `byte i of tAvail`. + --- ## Events / poll @@ -502,6 +518,15 @@ arithmetic. | `progress` | 44 | real | peer's completion fraction, `0..1` | | `flags` | 45 | int | peer flag bits | +### File entry (`btFileList`, one array per file) + +| key | field id | type | meaning | +|---|---|---|---| +| `path` | 120 | utf8 | file path within the torrent (relative) | +| `size` | 121 | int | file size, bytes | +| `progress` | 122 | int | bytes of this file downloaded | +| `priority` | 123 | int | this file's download priority, `0..7` | + ### DHT state (`btDhtState`) | key | field id | type | meaning | diff --git a/src/btx_abi.h b/src/btx_abi.h index 6d36fcb..8248c84 100644 --- a/src/btx_abi.h +++ b/src/btx_abi.h @@ -47,7 +47,7 @@ extern "C" { * signature, a new record fieldId or alert code, or a framing change. The LCB * layer hard-codes the matching number in checkABI() and refuses to run on * skew. Start at 1. */ -#define BTX_ABI_VERSION 4 +#define BTX_ABI_VERSION 5 /* ----------------------------------------------------------- export linkage */ @@ -226,6 +226,16 @@ BTX_API int BTX_CALL btx_piece_bitfield(int t, void *out, int cap); /* Connected peers as a count-prefixed list of KV records (schema §). */ BTX_API int BTX_CALL btx_peer_list(int t, void *out, int cap); +/* The torrent's files as a count-prefixed list of KV records — one per file: + * path (relative), size, bytes downloaded, and download priority. Empty (count + * 0) until metadata arrives. One round-trip for the whole file table (§8). */ +BTX_API int BTX_CALL btx_file_list(int t, void *out, int cap); + +/* Per-piece availability (how many connected peers advertise each piece) as raw + * bytes — one byte per piece, clamped to 255 — a read-only view for an + * availability grid. bytes-written / -needed / 0, like btx_piece_bitfield. */ +BTX_API int BTX_CALL btx_piece_availability(int t, void *out, int cap); + /* ====================================================================== * * The alert drain (the event firehose) — one FFI round-trip per poll (§3) * ====================================================================== */ diff --git a/src/btx_record.h b/src/btx_record.h index b06d097..24a3e24 100644 --- a/src/btx_record.h +++ b/src/btx_record.h @@ -125,7 +125,13 @@ enum FieldId : uint8_t { F_DHT_AUTHORITATIVE = 110, /* int 0/1: mutable get response is authoritative */ F_DHT_NUM_SUCCESS = 111, /* int: nodes that accepted a put */ F_DHT_SECRET_KEY = 112, /* hex: 64-byte ed25519 secret key */ - F_DHT_SEED = 113 /* hex: 32-byte ed25519 seed (persist to keep identity) */ + F_DHT_SEED = 113, /* hex: 32-byte ed25519 seed (persist to keep identity) */ + + /* ---- file entry (120..139): one record per file in btx_file_list ---- */ + F_FILE_PATH = 120, /* utf8: file path within the torrent (relative) */ + F_FILE_SIZE = 121, /* int (64-bit): file size in bytes */ + F_FILE_PROGRESS = 122, /* int (64-bit): bytes of this file downloaded */ + F_FILE_PRIORITY = 123 /* int 0..7: this file's download priority */ }; /* ------------------------------------------------------------- alert codes */ diff --git a/src/torrent.lcb b/src/torrent.lcb index 9754b55..9496049 100644 --- a/src/torrent.lcb +++ b/src/torrent.lcb @@ -42,7 +42,7 @@ metadata title is "TorrentXT" -- ===================================================================== -- -- Must equal BTX_ABI_VERSION in src/btx_abi.h; _checkABI() throws on skew. -constant kABIVersion is 4 +constant kABIVersion is 5 -- libfoundation MCStringEncoding value for UTF-8 (ASCII=0, Windows1252=1, -- MacRoman=2, ISO8859_1=3, UTF8=4). Used by _decodeText via MCStringDecode. @@ -139,6 +139,12 @@ constant kFieldDhtNumSuccess is 111 constant kFieldDhtSecretKey is 112 constant kFieldDhtSeed is 113 +-- file entry fields (one record per file from btFileList) +constant kFieldFilePath is 120 +constant kFieldFileSize is 121 +constant kFieldFileProgress is 122 +constant kFieldFilePriority is 123 + -- Stable alert type codes (mirror btx_record.h AlertType -> kAlert*). constant kAlertTorrentAdded is 1 constant kAlertMetadataReceived is 2 @@ -231,6 +237,8 @@ private foreign handler _btx_torrent_handle_at(in pS as CInt, in pIndex as CInt) private foreign handler _btx_info_hash_hex(in pT as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_info_hash_hex!cdecl" private foreign handler _btx_piece_bitfield(in pT as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_piece_bitfield!cdecl" private foreign handler _btx_peer_list(in pT as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_peer_list!cdecl" +private foreign handler _btx_file_list(in pT as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_file_list!cdecl" +private foreign handler _btx_piece_availability(in pT as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_piece_availability!cdecl" private foreign handler _btx_pop_alerts(in pS as CInt, in pOut as Pointer, in pCap as CInt) returns CInt binds to "c:torrentxt>btx_pop_alerts!cdecl" @@ -527,6 +535,14 @@ private handler _fieldKey(in pId as Integer) returns String return "secretKey" else if pId is kFieldDhtSeed then return "seed" + else if pId is kFieldFilePath then + return "path" + else if pId is kFieldFileSize then + return "size" + else if pId is kFieldFileProgress then + return "progress" + else if pId is kFieldFilePriority then + return "priority" else return "" end if @@ -1140,6 +1156,66 @@ public handler btPeerList(in pTorrent as Integer) returns List return tPeers end handler +-- The torrent's files as a List of Arrays (one per file), each with keys +-- "path", "size", "progress" (bytes downloaded) and "priority" (0..7). Empty +-- until metadata arrives. One FFI round-trip returns the whole file table. +public handler btFileList(in pTorrent as Integer) returns List + variable tFiles as List + variable tCount as Integer + variable tOffset as Integer + variable tFileCount as Integer + variable tBodyLen as Integer + variable tFile as Array + variable tI as Integer + variable tBuf as Data + put [] into tFiles + _ensureStatus(kStatusCap) + unsafe + put _btx_file_list(pTorrent, sStatusPtr, sStatusCap) into tCount + end unsafe + if tCount < 0 then + _ensureStatus(-tCount) + unsafe + put _btx_file_list(pTorrent, sStatusPtr, sStatusCap) into tCount + end unsafe + end if + if tCount <= 0 then + return tFiles + end if + put _bufToData(sStatusPtr, tCount) into tBuf + put 1 into tOffset + put _readU16(tBuf, tOffset) into tFileCount + add 2 to tOffset + repeat with tI from 1 up to tFileCount + -- entry := [bodyLen:u16][kvrecord]; advance by the on-wire bodyLen + put _readU16(tBuf, tOffset) into tBodyLen + put _parseRecord(tBuf, tOffset + 2) into tFile + push tFile onto tFiles + add (2 + tBodyLen) to tOffset + end repeat + return tFiles +end handler + +-- Per-piece availability: one byte per piece (how many connected peers have +-- that piece, clamped to 255). Empty data until the torrent has metadata+peers. +public handler btPieceAvailability(in pTorrent as Integer) returns Data + variable tCount as Integer + _ensureStatus(kStatusCap) + unsafe + put _btx_piece_availability(pTorrent, sStatusPtr, sStatusCap) into tCount + end unsafe + if tCount < 0 then + _ensureStatus(-tCount) + unsafe + put _btx_piece_availability(pTorrent, sStatusPtr, sStatusCap) into tCount + end unsafe + end if + if tCount <= 0 then + return the empty data + end if + return _bufToData(sStatusPtr, tCount) +end handler + -- ===================================================================== -- -- Public API: the alert drain (the event firehose) -- ===================================================================== -- diff --git a/src/torrent_shim.cpp b/src/torrent_shim.cpp index d255f67..0250539 100644 --- a/src/torrent_shim.cpp +++ b/src/torrent_shim.cpp @@ -1168,6 +1168,95 @@ extern "C" BTX_API int BTX_CALL btx_peer_list(int t, void *out, int cap) { }); } +/* ====================================================================== * + * Inspection — file table & piece availability (ABI v5) + * ====================================================================== */ + +extern "C" BTX_API int BTX_CALL btx_file_list(int t, void *out, int cap) { + BTX_GUARD_BUFFER({ + bool ok = false; lt::torrent_handle h = torrent_only(t, nullptr, &ok); + if (!ok) return 0; /* stale -> empty */ + + /* The file table lives in the metainfo, which is null for a magnet until + * metadata arrives — that is a legitimately EMPTY list (count 0), not an + * error. file list := [fileCount:u16] then fileCount x [bodyLen:u16] + * [kvrecord], mirroring the peer-list framing so the LCB walker reuses + * the same byte arithmetic. */ + std::shared_ptr ti = h.torrent_file(); + + btx::RecordWriter w(out, cap); + const size_t countAt = w.pos(); + w.put_u16(0); /* fileCount placeholder */ + uint16_t emitted = 0; + + if (ti) { + const lt::file_storage &fs = ti->files(); + const int n = fs.num_files(); + /* per-file downloaded bytes + priorities in two bulk calls (cheaper + * than N round-trips); tiny control data, never payload. */ + std::vector prog; + h.file_progress(prog); + std::vector prio = h.get_file_priorities(); + for (int i = 0; i < n; ++i) { + lt::file_index_t fi{i}; + const size_t bodyAt = w.pos(); + w.put_u16(0); /* bodyLen placeholder */ + const size_t bodyStart = w.pos(); + { + btx::KVRecord r(w); + r.put_str(btx::F_FILE_PATH, fs.file_path(fi)); + r.put_int(btx::F_FILE_SIZE, + static_cast(fs.file_size(fi))); + r.put_int(btx::F_FILE_PROGRESS, + static_cast(i) < prog.size() + ? static_cast(prog[static_cast(i)]) + : 0); + r.put_int(btx::F_FILE_PRIORITY, + static_cast(i) < prio.size() + ? static_cast(static_cast( + prio[static_cast(i)])) + : 0); + r.finish(); + } + w.patch_u16(bodyAt, static_cast(w.pos() - bodyStart)); + ++emitted; + } + } + w.patch_u16(countAt, emitted); + + if (w.overflow()) return -static_cast(w.pos()); + return static_cast(w.pos()); + }); +} + +extern "C" BTX_API int BTX_CALL btx_piece_availability(int t, void *out, int cap) { + BTX_GUARD_BUFFER({ + bool ok = false; lt::torrent_handle h = torrent_only(t, nullptr, &ok); + if (!ok) return 0; /* stale -> empty */ + + /* One int per piece (peers advertising it); we hand back one byte each, + * clamped to 255 — a read-only availability VIEW, not payload. Empty when + * the torrent has no metadata / no peers yet (legitimately 0 bytes). */ + std::vector avail; + h.piece_availability(avail); + const size_t nbytes = avail.size(); + if (nbytes == 0) return 0; + + uint8_t *dst = static_cast(out); + const bool fits = (dst != nullptr) && (cap > 0) && + (nbytes <= static_cast(cap)); + if (fits) { + for (size_t i = 0; i < nbytes; ++i) { + int a = avail[i]; + dst[i] = static_cast(a < 0 ? 0 : (a > 255 ? 255 : a)); + } + } + if (nbytes > static_cast(cap < 0 ? 0 : cap)) + return -static_cast(nbytes); + return static_cast(nbytes); + }); +} + /* ====================================================================== * * The alert drain (the event firehose) — one FFI round-trip per poll (§3) * ====================================================================== */ diff --git a/tests/torrent_smoke_test.cpp b/tests/torrent_smoke_test.cpp index 20cb0c6..3b36146 100644 --- a/tests/torrent_smoke_test.cpp +++ b/tests/torrent_smoke_test.cpp @@ -185,6 +185,8 @@ static void test_bogus_handles_are_noops() { CHECK(btx_info_hash_hex(h, buf, sizeof buf) == 0); CHECK(btx_piece_bitfield(h, buf, sizeof buf) == 0); CHECK(btx_peer_list(h, buf, sizeof buf) == 0); + CHECK(btx_file_list(h, buf, sizeof buf) == 0); /* ABI v5 */ + CHECK(btx_piece_availability(h, buf, sizeof buf) == 0); /* ABI v5 */ } /* remove() needs a live session to even reach the torrent check; with one