Skip to content

feat(cosmetic): add a C ABI for cosmetics and the player-model hook - #21

Merged
Lynxifyy merged 5 commits into
lynxifyy/berryapi-android-build-fixfrom
lynxifyy/berryapi-cosmetic-c-abi
Nov 10, 2026
Merged

Lynxifyy merged 5 commits into
lynxifyy/berryapi-android-build-fixfrom
lynxifyy/berryapi-cosmetic-c-abi

Conversation

@Lynxifyy

Copy link
Copy Markdown
Owner

What

A C ABI for the cosmetics system, so a host that dlopens libBerryAPI.so (a launcher's native preloader) can drive it without linking BerryAPI's C++ surface.

Until now CosmeticService and the device render path were C++ only. berry/cosmetic_c.h closes that gap the same way berry/replay_c.h closed it for the replay: a plain C struct of function pointers from BERRY_GetCosmeticApi(), with a size/abi_version guard the host checks before calling a pointer and a named status on every call that can fail.

The surface

uint32_t (*reload_packs)(const char* pack_dir, const char* asset_dir);
int32_t  (*list)(BerryCosmeticAssetC* out, size_t cap, size_t* count);
uint32_t (*registry_count)(void);
int32_t  (*equip)(const char* asset_id);
int32_t  (*unequip)(int32_t slot);
int32_t  (*equipped)(int32_t slot, char* out, size_t cap);
int32_t  (*clear_loadout)(void);
int32_t  (*loadout_canonical)(char* out, size_t cap, size_t* out_len);
int32_t  (*set_loadout_canonical)(const char* text);
int32_t  (*save_preset)(const char* name);
int32_t  (*apply_preset)(const char* name);
int32_t  (*has_preset)(const char* name);
int32_t  (*preset_names)(char* names, size_t name_cap, size_t cap, size_t* count);
int32_t  (*set_local_only)(int32_t local_only);
int32_t  (*local_only)(void);
void     (*stats)(BerryCosmeticStatsC* out);
int32_t  (*set_player_model_hook)(void* target, const BerryPlayerModelBoneReaderC* reader);
void     (*clear_player_model_hook)(void);
int32_t  (*has_player_model_hook)(void);

Why the player-model hook is in the same ABI

A cosmetic resolves no bones unless the embedder installs a player-model hook, because the per-build player-render target is a signature-scan result the host owns (BerryAPI does not resolve it internally). set_player_model_hook(target, reader) installs the detour on that target and binds a host-supplied bone reader, so the surface is usable rather than existing-and-drawing-nothing. Without it the render path fails closed.

Config

BerryConfigC grew cosmetic_pack_directory and cosmetic_asset_directory, so a host can point the client at its packs through the C ABI. That is a public config layout change, so BERRY_ABI_VERSION moves 8 → 9. The world-seam struct layout is unchanged (sizeof(BerrySeamC) stays 112); only the number it reports moves with the global, matching how v3–v8 bumped it for config growth. The cosmetic surface itself has its own BERRY_COSMETIC_ABI_VERSION (1).

Shared code path

BerryClient gains public ReloadCosmeticPacks / InstallPlayerModelHook / ClearPlayerModelHook, and BuildCosmetics now calls them, so the attach path and the runtime C ABI path cannot drift.

Fail-closed contract

  • BERRY_GetCosmeticApi() returns null before attach.
  • A null / short / ABI-mismatched reader, a null target, no hook backend, a refused detour or an already-installed hook each return a named status and leave no hook behind.
  • A malformed canonical loadout leaves the loadout untouched; an unknown asset or preset is refused by name.

Test evidence

  • make test green, including the new tests/test_cosmetic_c.cpp (catalog/loadout/presets/stats round-trip, every fail-closed case, and the hook refused-without-target / installed-with-target / cleared cycle).
  • tests/dlopen_test.c extended to dlsym the cosmetic surface: refused before attach, self-consistent after.
  • nm -D on the built libBerryAPI.so (host and arm64-v8a) shows the five BERRY_* cosmetic symbols exported; the arm64-v8a NDK build is green.

Stacking

Based on lynxifyy/berryapi-android-build-fix (the NDK build fix), because this branch builds on that fix. Merge that first, then this one.

The cosmetics system was C++ only, so a host that dlopen's libBerryAPI.so - a
launcher's native preloader - could not drive it without linking the whole C++
surface. berry/cosmetic_c.h closes that gap the same way berry/replay_c.h did
for the replay: a plain C struct of function pointers obtained from
BERRY_GetCosmeticApi(), with a size/abi_version guard the host checks before
calling a pointer, and a named status on every call that can fail.

The surface covers reload_packs, the catalog (list/registry_count), the local
loadout (equip/unequip/equipped/clear/canonical), presets and stats. It also
carries the player-model plumbing: set_player_model_hook installs the detour on
the host's per-build player-render target and binds a host-supplied bone reader,
which is what makes a cosmetic resolve to a real rig. Without it the render path
fails closed and draws nothing.

BerryConfigC grew cosmetic_pack_directory and cosmetic_asset_directory, so a
host can point the client at its packs through the C ABI; the global
BERRY_ABI_VERSION moves 8 -> 9 (the world-seam layout is unchanged, sizeof stays
112). BerryClient gains the public ReloadCosmeticPacks / InstallPlayerModelHook
/ ClearPlayerModelHook that both the attach path and the C ABI use, so the two
cannot drift.

Verified: make test green (incl. the new tests/test_cosmetic_c.cpp and the
extended tests/dlopen_test.c); the five BERRY_* symbols export in the shared
library.
The world seam reported the global BERRY_ABI_VERSION, so any config-struct growth
moved the seam's ABI number too and the preloader (which pins
kHostSeamAbiVersion) would refuse a seam whose layout had not changed. The seam
struct now carries BERRY_SEAM_ABI_VERSION, an independent number bumped only when
BerrySeamC itself changes. Its value stays 8, so the preloader's pinned 8 keeps
matching; the global version is free to grow for config-only reasons.
The player-model hook test passed `int hookTarget = 0` as the detour target.
The shim decodes the target's prologue as x86-64 code, and reading past the
four-byte stack variable is a stack-buffer-overflow that ASan aborts on. It
also depended on an anonymous mapping being within a rel32 of the detour and
trampoline, which the shim's jmp needs.

Hook a real function in this module instead. The prologue is a normal
function, the distance is guaranteed to fit, and the test now actually
installs and clears the hook rather than skipping on a refused target.
The vendored config enabled MBEDTLS_AESCE_C, but only the trimmed client stack
is vendored and library/aesce.c is not one of its units. aes.c guards its
Armv8-A fast path on that option, so an arm64 build referenced the
mbedtls_aesce_* symbols with nothing to satisfy them: the arm64 test job failed
to link, and the shipping .so would carry the same unresolved symbols (a jump
to address 0 the first time AES ran on an arm64 device with the crypto
extension). The portable AES in aes.c is used instead.

tools/check-mbedtls.py now keeps the option off, and PROVENANCE.md records why.

Also stop the check-sigcheck self-test from tripping over the compiler's own
bytes: the pattern is the 16-byte ELF identification header, not the bare
4-byte magic, which clang embeds as an immediate in its generated loader checks
and so appears twice in a clang-built binary.
Three independent defects, each of which the g++ job either masked or never
reached:

- check-sigcheck pinned the ELF OSABI byte (e_ident[7]) to 00, but the g++
  toolchain writes 03 (GNU/Linux). Wildcard that byte so the 16-byte
  identification pattern matches either toolchain.
- LanCosmeticTransport::Magic() returned cosmeticfrag::kMagic, a 4-byte array
  with no terminator; the test builds a std::string from it, which clang read
  past the end. Return a NUL-terminated static, as the sibling transports do.
- test_packet_capture called its fake receive function directly, so clang
  inlined the body and the call never reached the patched prologue. Mark it
  noinline and reach it through a volatile pointer.
- async.http_get cast the raw double timeout to long before the call, so a NaN
  argument was UB at the call site even though HttpGetBlocking clamps. Guard
  the cast.
@Lynxifyy
Lynxifyy merged commit 28cb0f8 into lynxifyy/berryapi-android-build-fix Nov 10, 2026
7 checks passed
Lynxifyy added a commit that referenced this pull request Nov 10, 2026
* fix(android): make the NDK build compile and target the API floor

Two defects kept tools/build-android.sh from producing a library, both only
visible on the on-device build (the host build never set the flag and never
compiled for Android):

- COMMON_FLAGS passed -fno-exceptions, but include/berry/hook_chain.h wraps each
  chain handler in try/catch to isolate a faulting handler. The translation unit
  failed with "cannot use 'try' with exceptions disabled". Exceptions are now
  enabled to match the host build, so the fault-isolation path compiles.

- core/lifecycle/crash.cpp guarded backtrace(3) on __linux__, but on Android it
  is only declared from API 33 while the build targets API 24, so the symbol was
  undeclared. The guard now requires glibc, or a non-Android Linux, or Android
  API >= 33; below that the crash report still carries the signal, the fault
  address and the last log line.

Verified: tools/build-android.sh now emits libBerryAPI.so (AArch64) exporting
the full ABI-8 C surface (BERRY_SetWorldSeam / BERRY_GetReplayApi /
BERRY_GetGovernorApi and the struct-size/abi-version queries).

* feat(cosmetic): add a C ABI for cosmetics and the player-model hook (#21)

* feat(cosmetic): add a C ABI for cosmetics and the player-model hook

The cosmetics system was C++ only, so a host that dlopen's libBerryAPI.so - a
launcher's native preloader - could not drive it without linking the whole C++
surface. berry/cosmetic_c.h closes that gap the same way berry/replay_c.h did
for the replay: a plain C struct of function pointers obtained from
BERRY_GetCosmeticApi(), with a size/abi_version guard the host checks before
calling a pointer, and a named status on every call that can fail.

The surface covers reload_packs, the catalog (list/registry_count), the local
loadout (equip/unequip/equipped/clear/canonical), presets and stats. It also
carries the player-model plumbing: set_player_model_hook installs the detour on
the host's per-build player-render target and binds a host-supplied bone reader,
which is what makes a cosmetic resolve to a real rig. Without it the render path
fails closed and draws nothing.

BerryConfigC grew cosmetic_pack_directory and cosmetic_asset_directory, so a
host can point the client at its packs through the C ABI; the global
BERRY_ABI_VERSION moves 8 -> 9 (the world-seam layout is unchanged, sizeof stays
112). BerryClient gains the public ReloadCosmeticPacks / InstallPlayerModelHook
/ ClearPlayerModelHook that both the attach path and the C ABI use, so the two
cannot drift.

Verified: make test green (incl. the new tests/test_cosmetic_c.cpp and the
extended tests/dlopen_test.c); the five BERRY_* symbols export in the shared
library.

* fix(seam): give the world seam its own ABI version

The world seam reported the global BERRY_ABI_VERSION, so any config-struct growth
moved the seam's ABI number too and the preloader (which pins
kHostSeamAbiVersion) would refuse a seam whose layout had not changed. The seam
struct now carries BERRY_SEAM_ABI_VERSION, an independent number bumped only when
BerrySeamC itself changes. Its value stays 8, so the preloader's pinned 8 keeps
matching; the global version is free to grow for config-only reasons.

* fix(cosmetic): hook a real function in the C ABI test, not a stack int

The player-model hook test passed `int hookTarget = 0` as the detour target.
The shim decodes the target's prologue as x86-64 code, and reading past the
four-byte stack variable is a stack-buffer-overflow that ASan aborts on. It
also depended on an anonymous mapping being within a rel32 of the detour and
trampoline, which the shim's jmp needs.

Hook a real function in this module instead. The prologue is a normal
function, the distance is guaranteed to fit, and the test now actually
installs and clears the hook rather than skipping on a refused target.

* fix(mbedtls): drop MBEDTLS_AESCE_C; the unit it needs is not vendored

The vendored config enabled MBEDTLS_AESCE_C, but only the trimmed client stack
is vendored and library/aesce.c is not one of its units. aes.c guards its
Armv8-A fast path on that option, so an arm64 build referenced the
mbedtls_aesce_* symbols with nothing to satisfy them: the arm64 test job failed
to link, and the shipping .so would carry the same unresolved symbols (a jump
to address 0 the first time AES ran on an arm64 device with the crypto
extension). The portable AES in aes.c is used instead.

tools/check-mbedtls.py now keeps the option off, and PROVENANCE.md records why.

Also stop the check-sigcheck self-test from tripping over the compiler's own
bytes: the pattern is the 16-byte ELF identification header, not the bare
4-byte magic, which clang embeds as an immediate in its generated loader checks
and so appears twice in a clang-built binary.

* fix(ci): green the clang and sanitize host jobs

Three independent defects, each of which the g++ job either masked or never
reached:

- check-sigcheck pinned the ELF OSABI byte (e_ident[7]) to 00, but the g++
  toolchain writes 03 (GNU/Linux). Wildcard that byte so the 16-byte
  identification pattern matches either toolchain.
- LanCosmeticTransport::Magic() returned cosmeticfrag::kMagic, a 4-byte array
  with no terminator; the test builds a std::string from it, which clang read
  past the end. Return a NUL-terminated static, as the sibling transports do.
- test_packet_capture called its fake receive function directly, so clang
  inlined the body and the call never reached the patched prologue. Mark it
  noinline and reach it through a volatile pointer.
- async.http_get cast the raw double timeout to long before the call, so a NaN
  argument was UB at the call site even though HttpGetBlocking clamps. Guard
  the cast.

---------

Co-authored-by: Lynxifyy <297587416+Lynxifyy@users.noreply.github.com>

---------

Co-authored-by: Lynxifyy <297587416+Lynxifyy@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant