--- name: c-api-extend description: Use when extending the C API at YseEngine/c_api/ — wrapping a new engine class, method, enum, or callback — or when auditing the C API for drift from the engine's public surface. Applies the project's RT-safety, ownership, and ABI conventions established in PRs #63–#67. Triggers on phrases like "wrap X in the C API", "update the C API after I added Y to the engine", "audit C API drift", "expose to Dart", and similar. Do NOT trigger for engine-internal changes that don't need FFI exposure, or for modifications to existing C-API entry points that don't add surface. --- # Extending the YSE C API for new engine surface `YseEngine/c_api/` is the only entry point for FFI consumers (Dart, Python, …). The C API has explicit rules — RT-safe callback bridges, opaque-handle ownership, null-safe no-op semantics, `YSE_C_CALLBACK` ABI defence, exception-to-`YseStatus` translation, enum drift guard — that the engine's own public C++ API does not enforce. New wrapping must follow all of them; a careless callback bridge can stall the audio thread. This skill governs how to wrap new engine surface in the C API. Read it fully before editing any file under `YseEngine/c_api/`. ## Issue tracking — GitHub Issues first CLAUDE.md mandates an issue before code. For this skill: - **File the issue first** with `gh issue create --title "c-api: ..."`. Use the `enhancement` or `task` template; tag the layer as `c-api`. - **Branch from `dev`** as `-c-api-` and PR back to `dev`. Not `master` — see CLAUDE.md §1. - **Read context** with `gh issue view ` before starting if the request mentions an existing issue number. The `gh` CLI is authenticated in the project environment. ## When to invoke this skill Yes: - A new engine class / method / enum was added and needs C-ABI exposure. - The user asks to audit the C API for drift from the engine. - The user is starting a new C-API wrapping pass (e.g. a sub-issue of a C API audit epic such as #898). No: - Modifying behaviour of an *existing* C-API function without adding surface — that's a regular fix, use the normal workflow. - Internal engine refactors that don't change the public C++ surface. - Adding audio-thread-reachable callbacks (`dspSourceObject` user callback, `customFileReader`) on a casual wrapping pass. Those need design work — see the "Callback bridge rules" block in [yse_c_internal.hpp](../../../YseEngine/c_api/yse_c_internal.hpp). ## The six conventions Every new C-API entry point must respect all six. ### 1. Opaque handle shape and ownership Each engine type that gets a C handle is declared with `typedef struct YseFoo YseFoo;` and accessed only via `YseFoo*` pointers. The `.cpp` file does `reinterpret_cast(handle)`. **Never expose any C++ type, class, namespace, reference, or template across the C ABI.** Every typedef in its home header carries a one-line ownership comment: ```c /* Owned — release with yse_foo_destroy. */ typedef struct YseFoo YseFoo; ``` ```c /* Borrowed singleton — owned by the engine, never destroy. Obtain via yse_foo_get(). */ typedef struct YseFoo YseFoo; ``` ```c /* Borrowed — owned by parent YsePatcher. Release with yse_patcher_delete_object(patcher, handle); never call a destroy on the handle directly. */ typedef struct YsePHandle YsePHandle; ``` Dual-shape types (e.g. `YseChannel` is owned via `yse_channel_create` but borrowed via the pre-built accessors) get both lines. Forward-declaration typedefs (in headers that don't own the type) point the reader at the home header rather than duplicating the ownership note: ```c /* Forward declarations — see yse_channel.h / yse_dsp.h for ownership. */ typedef struct YseChannel YseChannel; typedef struct YseDspBuffer YseDspBuffer; ``` Every handle typedef — home header and forward declaration alike — sits behind its own `YSE_C_HANDLE_` guard (#976), because repeating a typedef is a C11 feature and the headers must stay C99-clean: ```c #ifndef YSE_C_HANDLE_YseFoo #define YSE_C_HANDLE_YseFoo /** Owned — release with yse_foo_destroy. */ typedef struct YseFoo YseFoo; #endif ``` Keep the comment *inside* the guard, directly above the typedef: placed above the `#ifndef`, Doxygen attaches it to the `#define` instead, and the Sphinx build fails on the duplicated macro. The `yse_c99_header_check` object library in `Tests/CMakeLists.txt` compiles every public header as `-std=c99 -pedantic-errors`, so an unguarded typedef fails the test build. Canonical examples: [yse_patcher.h](../../../YseEngine/c_api/include/yse_c/yse_patcher.h) (owned + borrowed pair), [yse_reverb.h](../../../YseEngine/c_api/include/yse_c/yse_reverb.h) (dual-shape). ### 2. Header layout Every `include/yse_c/yse_.h`: - Top-of-file block comment naming purpose, the mirrored engine source, scope notes, and the null-safe-no-op convention statement. - `#ifndef` / `#define` / `#endif` include guards. No `#pragma once`. - `#include "yse_common.h"` (and `yse_enums.h` if needed) — never an engine C++ header. - `#ifdef __cplusplus / extern "C" { ... } / #endif` wraps the body. - `YSE_C_API` on every exported function declaration. - `YSE_C_CALLBACK` on every callback typedef, named `YseCallback` in PascalCase (`YseBusTapCallback`, `YseScriptErrorCallback`, `YsePatcherSendCallback`) — never a lowercase `yse_*_cb` (#911 renamed the last of those). - Functions carry their header's module prefix: `yse__...` (`yse_python_run_script`, not `yse_run_script`). - General-purpose helpers live in `yse_common.h` (`yse_last_error`, `yse_free_string` for library-allocated strings), not in whichever module first needed them. - All booleans as `int` (0/1), never `bool`. All sizes as `size_t` or `unsigned int`. Strings as `const char*` (in) or `char* + size_t` (snprintf-style out). Canonical example: [yse_sound.h](../../../YseEngine/c_api/include/yse_c/yse_sound.h). ### 3. Function shape | Engine signature | C API shape | | ----------------------------------------------- | --------------------------------------------------------------------------------- | | `void play()` — can't fail | `void yse_x_play(YseX* h)` — null-safe no-op | | `void setFoo(T v)` | `void yse_x_set_foo(YseX* h, T v)` — null-safe no-op | | `T getFoo() const` | `T yse_x_get_foo(YseX* h)` — return `0` / `false` / `NULL` on null | | `bool create(...)` — can fail | `YseStatus yse_x_load_...(...)` — `set_last_error` on failure | | `const char* getName() const` | `size_t yse_x_get_name(YseX* h, char* buf, size_t cap)` — snprintf style | | `std::string getBlob() const` (engine-internal) | Same as above; never return `const char*` to engine-owned storage that can free | The header-top convention paragraph captures the void-no-op rule once for the whole header. Per-function comments are reserved for genuinely surprising behaviour. **Body pattern for fallible operations** (lift from [yse_sound.cpp](../../../YseEngine/c_api/yse_sound.cpp)): ```cpp YSE_C_API YseStatus yse_x_load(YseX* h, const char* arg) { if (!h) return YSE_ERR_INVALID_HANDLE; if (!arg) return YSE_ERR_INVALID_ARGUMENT; try { if (!to_cpp(h)->load(arg)) { yse_c::set_last_error(std::string("x load failed for: ") + arg); return YSE_ERR_; } return YSE_OK; } catch (const std::exception& e) { yse_c::set_last_error(e.what()); return YSE_ERR_EXCEPTION; } catch (...) { yse_c::set_last_error("yse_x_load: unknown C++ exception"); return YSE_ERR_EXCEPTION; } } ``` **Body pattern for void state-change**: ```cpp YSE_C_API void yse_x_play(YseX* h) { if (h) to_cpp(h)->play(); } ``` **Body pattern for string-out**: ```cpp YSE_C_API size_t yse_x_get_name(YseX* h, char* buf, size_t cap) { if (!h) { if (buf && cap > 0) buf[0] = '\0'; return 0; } return copy_string(to_cpp(h)->getName(), buf, cap); } ``` Canonical example for the snprintf pattern: [yse_device.cpp](../../../YseEngine/c_api/yse_device.cpp). ### 4. Callback bridges — RT-safe shape When the engine invokes a user-provided callback on a **non-host thread** (audio callback, RtMidi input thread, file streaming worker, the control-thread occlusion driver, future `dspSourceObject` / `customFileReader` paths), the bridge: 1. Holds the callback + user_data as **one immutable pair node behind a single `std::atomic`** — never two separate atomics, which let a dispatch between the two stores of a re-install pair one install's cb with another's user_data (#902, #916). **Never `std::mutex` or `std::lock_guard` on the dispatch path.** 2. Installs by allocating the new pair (inside the ABI exception barrier) and publishing it with one atomic `exchange`. 3. Dispatches by loading the pointer once (return if null) and reading cb and user_data from that same node. The replaced node is freed only after a named grace period proves no dispatch still reads it: the log bridge waits for the sink mutex every dispatch runs under; the MIDI raw bridge uses a seq_cst reader-count handshake around the pointer load, with no user code inside the window the installer waits on. A straight passthrough to an engine setter is fine only when that setter keeps the pair as one node itself (`YSE::midiIn`'s raw / parsed setters do since #917); still wrap the install in the exception barrier. 4. **Does not `malloc` / `new` / construct `std::string` on dispatch when the bridge can fire from the audio callback.** For raw byte buffers needed by an async-Dart host, preallocate a per-handle pool keyed by the handle. 5. Uses `YSE_C_CALLBACK` on the typedef. 6. If the bridge transfers heap ownership to the host (Dart's `NativeCallable.listener` requires this), pair the callback with a `yse__free_message` function and document the contract beside the typedef. Canonical examples: - [yse_midi.cpp](../../../YseEngine/c_api/yse_midi.cpp) — `c_raw_bridge`, pair-pointer swap reclaimed by a reader-count handshake; malloc per call is acceptable because the RtMidi input thread is not the audio callback. - [yse_log.cpp](../../../YseEngine/c_api/yse_log.cpp) — `CallbackBridge`, same pair-pointer swap reclaimed behind the log sink mutex, same Dart-ownership malloc. The "Callback bridge rules" block at the head of [yse_c_internal.hpp](../../../YseEngine/c_api/yse_c_internal.hpp) restates these rules in detail. Read it before designing any new bridge. ### 5. Enum mirroring + drift guard When the engine adds an enum value that should be visible from the C API: 1. Add a matching `Yse_` entry to [yse_enums.h](../../../YseEngine/c_api/include/yse_c/yse_enums.h). Mirror the integer value exactly. Use C-compatible `typedef enum {...} Yse;` syntax (not `enum class`). 2. Add a `YSE_ASSERT_ENUM(YSE_..., YSE::...);` line to [yse_enums_check.cpp](../../../YseEngine/c_api/yse_enums_check.cpp). The build fails loudly if the values drift. If the engine adds a brand-new enum, add the full mirror block + a full set of `YSE_ASSERT_ENUM` lines. The drift guard is the only safety net for the hand-mirrored file. ### 6. Build + test integration - **New `.cpp` file** → append to `YSE_C_API_SRCS` in [c_api/CMakeLists.txt](../../../YseEngine/c_api/CMakeLists.txt). - **New public functions** → add at least one smoke test under `Tests//`. The doctest harness picks up `TEST_CASE` without further wiring; see existing tests for patterns. Tests that need a null device use [`TestHelpers::engineInit()`](../../../Tests/support/null_device.hpp). - **Build with the project wrapper**, never raw cmake invocations: `python yse.py build && python yse.py test`. CTest must stay green across all 44 entries (count them with `ctest --test-dir build-tests -N`; the number grows as suites are isolated). - **C API mirror suites run per process.** capilowcov, capilowcovlife, capisurface, close_interleaving, buscapi and the other C-API suites are registered as their own ctest entries and are *not* all exercised by the monolithic `yse_unit_tests` binary. When touching the registry, metadata, protocols or lifecycle, run the relevant entries individually (`ctest --test-dir build-tests -R `), not just the monolithic binary. - **Python-gated surface** (`yse_python.h`, `yse_module`) only builds its live half under `python yse.py build --python` / `python yse.py test --python` — run those when touching it. ## Workflow 1. **Inventory the new engine surface.** Read the engine header (e.g. `YseEngine/sound/soundInterface.hpp`); list every public method, enum, and callback. Skip private helpers and the implementation pimpls. 2. **Map ownership.** For each new class, decide: *owned* (user create/destroy), *borrowed singleton*, or *borrowed by parent*. 3. **Plan the module file.** New subsystem → new `yse_.h` + `yse_.cpp` pair + CMakeLists entry. Extending an existing subsystem → add to the existing pair. 4. **Apply the six conventions** as you write. Lift bodies from the canonical examples — don't re-invent the shape. 5. **Update the enum mirror + drift guard** if any new enum value landed in the engine. 6. **Update [c_api/CMakeLists.txt](../../../YseEngine/c_api/CMakeLists.txt)** if new files were added. 7. **Add a test stub** under `Tests//`. At minimum: a `TEST_CASE` that exercises create/destroy and one method. 8. **Build + test:** `python yse.py build && python yse.py test`. Must be green. 9. **PR to `dev`** with a body that references the closing issue and lists every new public function in the summary. ## Hard refusals Stop and surface a design note rather than emit code that: - Contains `std::mutex` or `std::lock_guard` in any callback bridge body. (PR #63 had to remove these from `yse_log.cpp`; do not put them back.) The rule is about bridge dispatch paths and anything the audio thread can reach. The one allowed exception today is the live-handle registry in [yse_instrument.cpp](../../../YseEngine/c_api/yse_instrument.cpp) (`registryMutex()`): SFZ-instrument / DX7-bank handles are validated against it to turn double-free and use-after-destroy into logged no-ops (#178), and every caller is a control / setup-thread entry point that already allocates and reads files. Nothing on the audio thread or in a callback touches it. A new mutex needs the same argument, written beside it in a comment, or it is refused. - Allocates memory (`malloc`, `new`, `std::string` construction) in a callback that fires on the audio thread. If the host genuinely needs byte ownership, design a preallocated pool. - Exposes any C++ type, namespace, reference, or template in a public `yse_c/*.h` header. - Catches `std::exception` and rethrows, swallows, or returns 0/NULL without first calling `yse_c::set_last_error`. - Adds a void function that throws on null instead of being a null-safe no-op. - Adds a callback typedef without `YSE_C_CALLBACK`. - Adds an enum to `yse_enums.h` without a matching `YSE_ASSERT_ENUM` in `yse_enums_check.cpp`. If the engine's design genuinely requires one of these (rare), file an issue describing the conflict and wait for a decision before proceeding. ## Things this skill DOES NOT do - Modify vendored dependencies under `dependencies/` or `build*/_deps/`. - Add new public methods, classes, or enums to the engine. Engine changes go through the engine's own workflow first; this skill mirrors them. - Refactor existing C-API surface unless the engine change requires it. - Wrap callbacks that fire on the audio thread (`dspSourceObject`, `customFileReader`) — those need additional design work (preallocated pools, stack-only return paths) per [yse_c_internal.hpp](../../../YseEngine/c_api/yse_c_internal.hpp)'s rules block. Surface a design proposal first. (Occlusion is no longer deferred: since #209 it runs on the control thread inside `System().update()`, and #906 bridged it as `yse_system_set_occlusion_callback` in `yse_system.cpp`.) - Modify CLAUDE.md or PROJECT_OVERVIEW.md unless the wrapping pass introduces a structural change worth documenting at the project root. ## Verification Before reporting the wrapping task complete: - `python yse.py build` succeeds with **no new warnings**. - `python yse.py test` passes **all 44 CTest entries** (the project's full suite, not just the new test), plus the per-process C-API entries you touched run individually. - `grep "std::mutex\|std::lock_guard" YseEngine/c_api/` returns **nothing** beyond the documented `yse_instrument.cpp` handle-registry exception (see Hard refusals). - Every new public function in `include/yse_c/*.h` is either covered by the header-top void-no-op convention or carries its own explanatory comment. - Every new opaque typedef has the ownership one-liner. - Every new callback typedef has `YSE_C_CALLBACK`. - Every new enum value has a matching `YSE_ASSERT_ENUM` line. If any of these fail, the wrapping is incomplete — keep iterating rather than reporting done.