diff --git a/Makefile b/Makefile index 3791ac1..fceda5d 100644 --- a/Makefile +++ b/Makefile @@ -313,11 +313,16 @@ berry-sigcheck: build/berry-sigcheck # Self-check: the tool must read a build-id from a real ELF and match a pattern # against it. Uses the tool's own binary as the ELF. +# The pattern is the 16-byte ELF identification header, not the bare 4-byte +# magic: a compiler may embed the magic as an immediate in its own code (clang +# does, in its generated loader checks), which makes the short pattern ambiguous. +# The OSABI byte (e_ident[7]) is wildcarded: it is 03 (GNU/Linux) with this +# toolchain but 00 (System V) with others, and pinning either breaks the other. .PHONY: check-sigcheck check-sigcheck: build/berry-sigcheck @id=$$(./build/berry-sigcheck build/berry-sigcheck --build-id) || { echo "check-sigcheck: no build-id"; exit 1; }; \ [ -n "$$id" ] || { echo "check-sigcheck: empty build-id"; exit 1; }; \ - ./build/berry-sigcheck build/berry-sigcheck --pattern "7F 45 4C 46" >/dev/null || { echo "check-sigcheck: ELF magic did not match"; exit 1; }; \ + ./build/berry-sigcheck build/berry-sigcheck --pattern "7F 45 4C 46 02 01 01 ?? 00 00 00 00 00 00 00 00" >/dev/null || { echo "check-sigcheck: ELF identification header did not match"; exit 1; }; \ echo "== berry-sigcheck OK ==" # End-to-end check for the developer CLI. It drives the real binary through diff --git a/README.md b/README.md index 29fe5d0..cd9c192 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,10 @@ See [docs/BUILD.md](docs/BUILD.md) for NDK details, Dobby CMake, and hook flags. ## Exported C ABI -`libBerryAPI.so` exports exactly these symbols (C, `extern "C"`): +`libBerryAPI.so` exports a small C surface (C, `extern "C"`) so a host that is +not linked against BerryAPI's C++ ABI can drive it through `dlopen` + `dlsym`. + +Core lifecycle: ```c bool BERRY_Inject(); @@ -88,6 +91,20 @@ int BERRY_ShimUnpatch(void* target); bool BERRY_ShimCanPatch(); ``` +Feature surfaces, each a plain C struct of function pointers with a `size` and +an `abi_version` the host checks before calling a pointer, and each obtained +from a `BERRY_Get*Api()` that returns null before attach: + +| surface | header | getter | +|---------|--------|--------| +| world seam | `berry/seam.h` | `BERRY_SetWorldSeam` | +| replay | `berry/replay_c.h` | `BERRY_GetReplayApi` | +| performance governor | `berry/governor_c.h` | `BERRY_GetGovernorApi` | +| cosmetics | `berry/cosmetic_c.h` | `BERRY_GetCosmeticApi` | + +The cosmetic surface also carries the player-model hook +(`set_player_model_hook`), which is what makes cosmetics resolve to a real rig. + `BerryConfigC` is a **plain C struct** (see `include/berry/config.h`) that starts with `uint32_t size; uint32_t abi_version;`. The library validates both and rejects mismatches at attach time (`BERRY_ABI_VERSION` in `version.h`). diff --git a/core/client/client.cpp b/core/client/client.cpp index db4b719..d37d61f 100644 --- a/core/client/client.cpp +++ b/core/client/client.cpp @@ -568,29 +568,10 @@ void BerryClient::BuildPacketCapture(const ClientConfig& cfg) { } void BerryClient::BuildCosmetics(const ClientConfig& cfg) { - // Cosmetic asset data loads first: a pack names geometry/texture handles, so - // the handles must exist before the pack's assets are registered (otherwise a - // pack asset would resolve to a retired handle and draw nothing). - if (!cfg.cosmeticAssetDirectory.empty()) { - cosmeticAssets_ = std::make_unique( - fs_.get(), cfg.cosmeticAssetDirectory); - const auto report = cosmeticAssets_->LoadDirectory(); - Log::Info("BerryClient: cosmetic assets: %zu meshes, %zu textures, " - "%zu refused%s", - report.meshes, report.textures, report.failed, - report.truncated ? " (truncated)" : ""); - } - - // Model packs register into the catalog the wardrobe reads. Loading after the - // assets means a pack's handles resolve; a pack that names a handle no asset - // supplied still registers (the asset simply draws nothing), which keeps a - // missing texture from discarding a whole cape. - if (!cfg.cosmeticPackDirectory.empty()) { - packs_->SetRoot(cfg.cosmeticPackDirectory); - const PackLoadReport report = packs_->LoadDirectory(); - Log::Info("BerryClient: cosmetic packs: %zu loaded, %zu refused%s", - report.packsLoaded, report.packsFailed, - report.truncated ? " (truncated)" : ""); + // Asset data + model packs: one code path with the runtime reload the C ABI + // host drives, so attach and reload can never drift. + if (!cfg.cosmeticAssetDirectory.empty() || !cfg.cosmeticPackDirectory.empty()) { + ReloadCosmeticPacks(cfg.cosmeticPackDirectory, cfg.cosmeticAssetDirectory); } // Author the built-in clips into the animator, so the built-in cape flaps and @@ -605,23 +586,73 @@ void BerryClient::BuildCosmetics(const ClientConfig& cfg) { // leaves the skeleton source fail-closed (nothing resolves), which is the // correct default for a build whose layout is unknown. if (cfg.playerModelRenderTarget && cfg.playerModelBones.valid()) { - playerModelSkeleton_->SetReader(cfg.playerModelBones); - playerModelHook_ = std::make_unique(); - playerModelHook_->SetReader(cfg.playerModelBones); - if (playerModelHook_->Install(cfg.playerModelRenderTarget, - HookManager::Instance().Backend())) { - Log::Info("BerryClient: player-model hook installed"); - } else { - Log::Warn("BerryClient: player-model hook refused; cosmetics resolve no " - "bones"); - playerModelHook_.reset(); - } + InstallPlayerModelHook(cfg.playerModelRenderTarget, cfg.playerModelBones); } else { Log::Info("BerryClient: no player-model target/reader; cosmetics resolve " "no bones (fail closed)"); } } +size_t BerryClient::ReloadCosmeticPacks(const std::string& packDir, + const std::string& assetDir) { + // Asset data first: a pack names geometry/texture handles, so the handles + // must exist before the pack's assets are registered, or a pack asset would + // resolve to a retired handle and draw nothing. This mirrors BuildCosmetics. + if (!assetDir.empty()) { + if (!cosmeticAssets_) { + cosmeticAssets_ = std::make_unique(fs_.get(), assetDir); + } + cosmeticAssets_->LoadDirectory(assetDir); + } + size_t registered = 0; + if (!packDir.empty()) { + if (!packs_) packs_ = std::make_unique(fs_.get()); + packs_->SetRoot(packDir); + const PackLoadReport report = packs_->LoadDirectory(packDir); + registered = report.assetsRegistered; + Log::Info("BerryClient: cosmetic reload: %zu packs loaded, %zu refused, " + "%zu assets", + report.packsLoaded, report.packsFailed, report.assetsRegistered); + } + // A reload can change the catalog the wardrobe reads, so drop any cached + // resolve: a cached item composed against a now-retired asset would be stale. + if (cosmetics_) cosmetics_->ClearResolveCache(); + return registered; +} + +bool BerryClient::InstallPlayerModelHook(void* target, + const PlayerModelBoneReader& reader) { + if (target == nullptr) return false; + if (!reader.valid()) return false; + if (playerModelHook_) return false; // already installed; clear it first + if (!playerModelSkeleton_) return false; + + // The hook refreshes the reader's cache from the rendered pose, so the + // skeleton source resolves bones the game actually computed. Bind the source + // first, so a bone is resolvable the instant the detour is live. + playerModelSkeleton_->SetReader(reader); + auto hook = std::make_unique(); + hook->SetReader(reader); + if (!hook->Install(target, HookManager::Instance().Backend())) { + // A refused install leaves the source bound to a reader nothing refreshes; + // clear it so the source fails closed rather than reading a stale cache. + playerModelSkeleton_->SetReader(PlayerModelBoneReader{}); + Log::Warn("BerryClient: player-model hook refused; cosmetics resolve no bones"); + return false; + } + playerModelHook_ = std::move(hook); + Log::Info("BerryClient: player-model hook installed (runtime)"); + return true; +} + +void BerryClient::ClearPlayerModelHook() { + if (playerModelHook_) playerModelHook_->Uninstall(); + playerModelHook_.reset(); + // Unbind the reader too: with no hook refreshing it, the cache would go + // stale, and the correct fail-closed state is "resolve nothing". + if (playerModelSkeleton_) playerModelSkeleton_->SetReader(PlayerModelBoneReader{}); +} + void BerryClient::RegisterBuiltinClips() { if (!animator_) return; // The cape's flap: the back bone lifts and rolls a little over a loop. Keys diff --git a/core/lifecycle/crash.cpp b/core/lifecycle/crash.cpp index befa14b..4edb9da 100644 --- a/core/lifecycle/crash.cpp +++ b/core/lifecycle/crash.cpp @@ -85,7 +85,13 @@ void SignalHandler(int sig, siginfo_t* info, void*) { n = snprintf(line, sizeof line, "fault address: %p\n", info->si_addr); if (n > 0) write(fd, line, (size_t)n); } -#if defined(__GLIBC__) || defined(__linux__) +// backtrace(3) is a glibc facility. On Android it is only declared from API 33, +// while the on-device build targets API 24, so the symbol would be undeclared +// there. Detect availability rather than assuming __linux__ implies glibc: when +// it is absent the report still carries the signal, the fault address and the +// last log line. +#if defined(__GLIBC__) || (defined(__linux__) && !defined(__ANDROID__)) || \ + (defined(__ANDROID__) && defined(__ANDROID_API__) && __ANDROID_API__ >= 33) void* frames[64]; int got = backtrace(frames, 64); backtrace_symbols_fd(frames, got, fd); diff --git a/core/lifecycle/engine.cpp b/core/lifecycle/engine.cpp index fe871f6..c2f261e 100644 --- a/core/lifecycle/engine.cpp +++ b/core/lifecycle/engine.cpp @@ -145,6 +145,12 @@ bool Engine::Bootstrap(const BerryConfigC& cfg) { if (cfg.script_data_directory) { internal.scriptDataDirectory = cfg.script_data_directory; } + if (cfg.cosmetic_pack_directory) { + internal.cosmeticPackDirectory = cfg.cosmetic_pack_directory; + } + if (cfg.cosmetic_asset_directory) { + internal.cosmeticAssetDirectory = cfg.cosmetic_asset_directory; + } ApplyConfig(internal); Log::Info("BerryAPI v%s bootstrapped (abi %u)", BERRY_VERSION_STRING, (unsigned)BERRY_ABI_VERSION); @@ -242,6 +248,8 @@ bool Engine::Attach() { ccfg.voiceRoomSecret = cfg_.voiceRoomSecret; ccfg.lanIface = cfg_.lanIface; ccfg.scriptDataDirectory = cfg_.scriptDataDirectory; + ccfg.cosmeticPackDirectory = cfg_.cosmeticPackDirectory; + ccfg.cosmeticAssetDirectory = cfg_.cosmeticAssetDirectory; client_ = std::make_unique(); if (!client_->Attach(*this, ccfg)) { Log::Error("BerryClient failed to attach; detaching modules"); diff --git a/core/modules/cosmetic.cpp b/core/modules/cosmetic.cpp index 8fe78bd..33dc5db 100644 --- a/core/modules/cosmetic.cpp +++ b/core/modules/cosmetic.cpp @@ -593,6 +593,12 @@ bool CosmeticService::Unequip(CosmeticSlot slot) { return local_.Unequip(slot); } +void CosmeticService::ClearLocalLoadout() { + std::lock_guard lk(mutex_); + local_.Clear(); + animClock_.store(0.0f); +} + std::string CosmeticService::Equipped(CosmeticSlot slot) const { std::lock_guard lk(mutex_); return local_.Equipped(slot); diff --git a/core/modules/cosmetic_c.cpp b/core/modules/cosmetic_c.cpp new file mode 100644 index 0000000..c0dfad2 --- /dev/null +++ b/core/modules/cosmetic_c.cpp @@ -0,0 +1,415 @@ +// The cosmetic C ABI (berry/cosmetic_c.h). A thin adapter over CosmeticService +// and BerryClient, so a host that dlopen's libBerryAPI.so can drive the local +// player's cosmetics and install the player-model hook without linking the C++ +// surface. +// +// Fail-closed: every entry point checks that the engine is attached and that +// the client (and so the cosmetic service) exists, and a failing call writes +// nothing. The returned struct is a single process-lifetime instance whose +// function pointers never change, so a host may cache it after the size/abi +// check. The one piece of process state - the installed player-model hook - +// lives on the client, guarded by the same ownership the C++ path uses. + +#include "berry/cosmetic_c.h" + +#include "berry/client.h" +#include "berry/engine.h" +#include "berry/hooks.h" +#include "berry/logger.h" + +#include +#include +#include + +namespace { + +/// The attached client, or null. Every call funnels through this, so a host can +/// never reach a service on an engine that is not running. +Berry::BerryClient* Client() { + Berry::Engine& engine = Berry::Engine::Instance(); + if (!engine.attached()) return nullptr; + return engine.Client(); +} + +Berry::CosmeticService* Cosmetics() { + Berry::BerryClient* c = Client(); + return c != nullptr ? &c->cosmetics() : nullptr; +} + +/// Copies `src` into a fixed-size buffer as a NUL-terminated string, truncating +/// (with the terminator always written) when it does not fit. Used for the +/// catalog records, where a fixed record is the contract. +template +void CopyFixed(char (&dst)[N], const std::string& src) { + static_assert(N > 0, "fixed buffer must hold a terminator"); + const size_t n = src.size() < N - 1 ? src.size() : N - 1; + std::memcpy(dst, src.data(), n); + dst[n] = '\0'; +} + +/// Maps a `Berry::CosmeticError` to a status. The two enums are not identical +/// (the C status carries engine-level failures the registry cannot produce), so +/// the mapping is explicit rather than a cast. +int32_t StatusOf(Berry::CosmeticError e) { + using E = Berry::CosmeticError; + switch (e) { + case E::Ok: return BERRY_COSMETIC_OK; + case E::UnknownAsset: return BERRY_COSMETIC_UNKNOWN_ASSET; + case E::NotOwned: return BERRY_COSMETIC_NOT_OWNED; + case E::UnknownSlot: return BERRY_COSMETIC_BAD_SLOT; + // The remaining registry failures are all "the value you gave me is not + // usable", which the host reports as a bad argument. + default: return BERRY_COSMETIC_BAD_ARGUMENT; + } +} + +//--- catalog + packs -------------------------------------------------------- + +uint32_t ReloadPacks(const char* pack_dir, const char* asset_dir) { + Berry::BerryClient* c = Client(); + if (c == nullptr) return 0; + const std::string packs = pack_dir != nullptr ? pack_dir : ""; + const std::string assets = asset_dir != nullptr ? asset_dir : ""; + return static_cast(c->ReloadCosmeticPacks(packs, assets)); +} + +int32_t List(BerryCosmeticAssetC* out, size_t cap, size_t* count) { + if (count == nullptr) return BERRY_COSMETIC_BAD_ARGUMENT; + *count = 0; + if (out == nullptr && cap != 0) return BERRY_COSMETIC_BAD_ARGUMENT; + const std::vector all = + Berry::CosmeticRegistry::Instance().All(); + const size_t n = all.size() < cap ? all.size() : cap; + for (size_t i = 0; i < n; ++i) { + const Berry::CosmeticAsset& a = all[i]; + BerryCosmeticAssetC& r = out[i]; + std::memset(&r, 0, sizeof(r)); + CopyFixed(r.id, a.id); + r.slot = static_cast(a.slot); + r.bone = static_cast(a.ResolvedBone()); + r.geometry = a.geometry; + r.texture = a.texture; + r.offset_x = a.offset.x; + r.offset_y = a.offset.y; + r.offset_z = a.offset.z; + r.rotation_pitch = a.rotation.x; + r.rotation_yaw = a.rotation.y; + r.rotation_roll = a.rotation.z; + r.scale = a.scale; + r.tint_r = a.tint.x; + r.tint_g = a.tint.y; + r.tint_b = a.tint.z; + r.trail = a.trail; + CopyFixed(r.animation, a.animation); + r.animation_speed = a.animationSpeed; + CopyFixed(r.pack, a.pack); + CopyFixed(r.author, a.author); + } + *count = n; + return BERRY_COSMETIC_OK; +} + +uint32_t RegistryCount(void) { + return static_cast(Berry::CosmeticRegistry::Instance().Count()); +} + +//--- local loadout ---------------------------------------------------------- + +int32_t Equip(const char* asset_id) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (asset_id == nullptr || asset_id[0] == '\0') return BERRY_COSMETIC_BAD_ARGUMENT; + Berry::CosmeticError err = Berry::CosmeticError::Ok; + if (!svc->Equip(std::string(asset_id), &err)) return StatusOf(err); + return BERRY_COSMETIC_OK; +} + +int32_t Unequip(int32_t slot) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (slot <= BERRY_COSMETIC_SLOT_NONE || slot >= BERRY_COSMETIC_SLOT_COUNT) { + return BERRY_COSMETIC_BAD_SLOT; + } + // An empty slot is not an error: "remove what is there" is satisfied by + // nothing being there. + svc->Unequip(static_cast(slot)); + return BERRY_COSMETIC_OK; +} + +int32_t Equipped(int32_t slot, char* out, size_t cap) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (slot <= BERRY_COSMETIC_SLOT_NONE || slot >= BERRY_COSMETIC_SLOT_COUNT) { + return BERRY_COSMETIC_BAD_SLOT; + } + if (out == nullptr || cap == 0) return BERRY_COSMETIC_BAD_ARGUMENT; + const std::string id = svc->Equipped(static_cast(slot)); + if (id.size() + 1 > cap) { + out[0] = '\0'; + return BERRY_COSMETIC_BUFFER_TOO_SMALL; + } + std::memcpy(out, id.c_str(), id.size() + 1); + return BERRY_COSMETIC_OK; +} + +int32_t ClearLoadout(void) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + // Clearing the loadout resets the animation clock too, so a fresh loadout + // starts its clips from zero. + svc->ClearLocalLoadout(); + return BERRY_COSMETIC_OK; +} + +int32_t LoadoutCanonical(char* out, size_t cap, size_t* out_len) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (out == nullptr || cap == 0) return BERRY_COSMETIC_BAD_ARGUMENT; + const std::string text = svc->localLoadout().Canonical(); + if (text.size() + 1 > cap) { + out[0] = '\0'; + if (out_len != nullptr) *out_len = text.size(); + return BERRY_COSMETIC_BUFFER_TOO_SMALL; + } + std::memcpy(out, text.c_str(), text.size() + 1); + if (out_len != nullptr) *out_len = text.size(); + return BERRY_COSMETIC_OK; +} + +int32_t SetLoadoutCanonical(const char* text) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (text == nullptr) return BERRY_COSMETIC_BAD_ARGUMENT; + // Parse into a fresh loadout first: FromCanonical leaves its target untouched + // on a malformed text, but building the loadout here keeps the service's own + // loadout entirely out of the failure path. + Berry::CosmeticLoadout parsed; + if (!parsed.FromCanonical(std::string(text))) return BERRY_COSMETIC_BAD_FORMAT; + // Apply each slot through Equip so the same registry/ownership rules a direct + // equip obeys also gate a parsed loadout (a canonical text naming an asset + // that is no longer registered must not smuggle it in). + svc->Unequip(Berry::CosmeticSlot::Head); + svc->Unequip(Berry::CosmeticSlot::Back); + svc->Unequip(Berry::CosmeticSlot::Shoulders); + svc->Unequip(Berry::CosmeticSlot::Body); + svc->Unequip(Berry::CosmeticSlot::Trail); + svc->Unequip(Berry::CosmeticSlot::Effect); + svc->Unequip(Berry::CosmeticSlot::Emote); + for (const auto& kv : parsed.slots()) { + if (kv.second.empty()) continue; + Berry::CosmeticError err = Berry::CosmeticError::Ok; + if (!svc->Equip(kv.second, &err)) return StatusOf(err); + } + return BERRY_COSMETIC_OK; +} + +//--- wardrobe --------------------------------------------------------------- + +int32_t SavePreset(const char* name) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (name == nullptr || name[0] == '\0') return BERRY_COSMETIC_BAD_ARGUMENT; + return svc->SavePreset(std::string(name)) ? BERRY_COSMETIC_OK + : BERRY_COSMETIC_BAD_ARGUMENT; +} + +int32_t ApplyPreset(const char* name) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (name == nullptr || name[0] == '\0') return BERRY_COSMETIC_BAD_ARGUMENT; + return svc->ApplyPreset(std::string(name)) ? BERRY_COSMETIC_OK + : BERRY_COSMETIC_UNKNOWN_PRESET; +} + +int32_t HasPreset(const char* name) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (name == nullptr || name[0] == '\0') return BERRY_COSMETIC_BAD_ARGUMENT; + return svc->HasPreset(std::string(name)) ? 1 : 0; +} + +int32_t PresetNames(char* names, size_t name_cap, size_t cap, size_t* count) { + if (count == nullptr) return BERRY_COSMETIC_BAD_ARGUMENT; + *count = 0; + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (names == nullptr && cap != 0) return BERRY_COSMETIC_BAD_ARGUMENT; + if (cap != 0 && name_cap == 0) return BERRY_COSMETIC_BAD_ARGUMENT; + const std::vector all = svc->PresetNames(); + const size_t n = all.size() < cap ? all.size() : cap; + for (size_t i = 0; i < n; ++i) { + char* dst = names + i * name_cap; + if (all[i].size() + 1 > name_cap) { + dst[0] = '\0'; + return BERRY_COSMETIC_BUFFER_TOO_SMALL; + } + std::memcpy(dst, all[i].c_str(), all[i].size() + 1); + } + *count = n; + return BERRY_COSMETIC_OK; +} + +//--- remote render gate ----------------------------------------------------- + +int32_t SetLocalOnly(int32_t local_only) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + svc->SetLocalOnly(local_only != 0); + return BERRY_COSMETIC_OK; +} + +int32_t LocalOnly(void) { + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + return svc->localOnly() ? 1 : 0; +} + +//--- stats ------------------------------------------------------------------ + +void Stats(BerryCosmeticStatsC* out) { + if (out == nullptr) return; + std::memset(out, 0, sizeof(*out)); + Berry::CosmeticService* svc = Cosmetics(); + if (svc == nullptr) return; + const Berry::CosmeticStats s = svc->stats(); + out->frames = s.frames; + out->submitted = s.submitted; + out->skipped_no_bone = s.skippedNoBone; + out->skipped_not_owned = s.skippedNotOwned; + out->cache_hits = s.cacheHits; + out->cache_misses = s.cacheMisses; + out->registry_count = static_cast(Berry::CosmeticRegistry::Instance().Count()); + out->preset_count = static_cast(svc->PresetNames().size()); +} + +//--- player-model bone plumbing -------------------------------------------- + +int32_t SetPlayerModelHook(void* target, + const BerryPlayerModelBoneReaderC* reader) { + Berry::BerryClient* c = Client(); + if (c == nullptr) return BERRY_COSMETIC_NOT_ATTACHED; + if (target == nullptr) return BERRY_COSMETIC_BAD_ARGUMENT; + if (reader == nullptr) return BERRY_COSMETIC_BAD_READER; + if (reader->size < sizeof(BerryPlayerModelBoneReaderC)) return BERRY_COSMETIC_BAD_READER; + if (reader->abi_version != BERRY_COSMETIC_ABI_VERSION) return BERRY_COSMETIC_BAD_READER; + if (reader->bone_transform == nullptr) return BERRY_COSMETIC_BAD_READER; + if (c->hasPlayerModelHook()) return BERRY_COSMETIC_ALREADY_HOOKED; + + Berry::IHook* backend = Berry::HookManager::Instance().Backend(); + if (backend == nullptr || !backend->CanHook()) return BERRY_COSMETIC_NO_HOOK_BACKEND; + + // Wrap the C reader as the C++ reader the client binds. The user pointer and + // the two callbacks are copied by value, so the host may free its struct + // after this call. `pose_revision` may be null, which disables the cache. + const BerryPlayerModelBoneReaderC r = *reader; + Berry::PlayerModelBoneReader cpp; + cpp.boneTransform = [r](Berry::EntityId entity, + Berry::Bone bone, + Berry::CosmeticBoneTransform* out) -> bool { + if (out == nullptr) return false; + BerryCosmeticBoneTransformC c{}; + if (r.bone_transform(entity.raw, entity.runtime, static_cast(bone), &c, + r.user) == 0) { + return false; + } + if (c.valid == 0) return false; + out->position = Berry::Vec3(c.px, c.py, c.pz); + out->rotation = Berry::Vec3(c.pitch, c.yaw, c.roll); + out->velocity = Berry::Vec3(c.vx, c.vy, c.vz); + out->scale = c.scale; + out->valid = true; + return true; + }; + if (r.pose_revision != nullptr) { + cpp.poseRevision = [r](Berry::EntityId entity, Berry::u64* out) -> bool { + if (out == nullptr) return false; + return r.pose_revision(entity.raw, entity.runtime, out, r.user) != 0; + }; + } + + if (!c->InstallPlayerModelHook(target, cpp)) { + // The backend refused the detour (bad arch or an unhookable target), which + // is a hook failure rather than a bad argument. + return BERRY_COSMETIC_HOOK_REFUSED; + } + return BERRY_COSMETIC_OK; +} + +void ClearPlayerModelHook(void) { + if (Berry::BerryClient* c = Client()) c->ClearPlayerModelHook(); +} + +int32_t HasPlayerModelHook(void) { + Berry::BerryClient* c = Client(); + return (c != nullptr && c->hasPlayerModelHook()) ? 1 : 0; +} + +/// The process-lifetime surface. Its pointers are set once, so a host may cache +/// it after the size/abi check. +const BerryCosmeticApi kApi = { + sizeof(BerryCosmeticApi), + BERRY_COSMETIC_ABI_VERSION, + &ReloadPacks, + &List, + &RegistryCount, + &Equip, + &Unequip, + &Equipped, + &ClearLoadout, + &LoadoutCanonical, + &SetLoadoutCanonical, + &SavePreset, + &ApplyPreset, + &HasPreset, + &PresetNames, + &SetLocalOnly, + &LocalOnly, + &Stats, + &SetPlayerModelHook, + &ClearPlayerModelHook, + &HasPlayerModelHook, +}; + +} // namespace + +extern "C" { + +BERRY_API const char* BERRY_CosmeticStatusName(int32_t status) { + switch (status) { + case BERRY_COSMETIC_OK: return "ok"; + case BERRY_COSMETIC_NOT_ATTACHED: return "not attached"; + case BERRY_COSMETIC_BAD_ARGUMENT: return "bad argument"; + case BERRY_COSMETIC_UNKNOWN_ASSET: return "unknown asset"; + case BERRY_COSMETIC_NOT_OWNED: return "not owned"; + case BERRY_COSMETIC_BAD_SLOT: return "bad slot"; + case BERRY_COSMETIC_BAD_FORMAT: return "bad format"; + case BERRY_COSMETIC_UNKNOWN_PRESET: return "unknown preset"; + case BERRY_COSMETIC_BUFFER_TOO_SMALL: return "buffer too small"; + case BERRY_COSMETIC_NO_HOOK_BACKEND: return "no hook backend"; + case BERRY_COSMETIC_HOOK_REFUSED: return "hook refused"; + case BERRY_COSMETIC_ALREADY_HOOKED: return "already hooked"; + case BERRY_COSMETIC_BAD_READER: return "bad reader"; + default: return "unknown status"; + } +} + +BERRY_API uint32_t BERRY_CosmeticAbiVersion(void) { + return static_cast(BERRY_COSMETIC_ABI_VERSION); +} + +BERRY_API uint32_t BERRY_CosmeticStructSize(void) { + return static_cast(sizeof(BerryCosmeticApi)); +} + +BERRY_API uint32_t BERRY_CosmeticBoneReaderStructSize(void) { + return static_cast(sizeof(BerryPlayerModelBoneReaderC)); +} + +BERRY_API const BerryCosmeticApi* BERRY_GetCosmeticApi(void) { + // A surface with no engine to drive is not returned: a host that got a + // non-null pointer would otherwise call into a service that does not exist. + if (Client() == nullptr) return nullptr; + return &kApi; +} + +} // extern "C" diff --git a/core/modules/seam.cpp b/core/modules/seam.cpp index f3e13a2..6ed5ee2 100644 --- a/core/modules/seam.cpp +++ b/core/modules/seam.cpp @@ -232,7 +232,7 @@ extern "C" { BERRY_API int32_t BERRY_SetWorldSeam(const BerrySeamC* seam) { if (seam == nullptr) return 0; if (seam->size < sizeof(BerrySeamC)) return 0; - if (seam->abi_version != BERRY_ABI_VERSION) return 0; + if (seam->abi_version != BERRY_SEAM_ABI_VERSION) return 0; Berry::Engine& engine = Berry::Engine::Instance(); // No seam can bind before the module set is attached: there is no World module @@ -267,7 +267,7 @@ BERRY_API uint32_t BERRY_WorldSeamStructSize(void) { } BERRY_API uint32_t BERRY_WorldSeamAbiVersion(void) { - return static_cast(BERRY_ABI_VERSION); + return static_cast(BERRY_SEAM_ABI_VERSION); } } // extern "C" diff --git a/core/net/lan_transport.cpp b/core/net/lan_transport.cpp index 457cf30..b428fd9 100644 --- a/core/net/lan_transport.cpp +++ b/core/net/lan_transport.cpp @@ -49,7 +49,14 @@ std::string PeerKey(const std::string& host, u16 port) { } // namespace -const char* LanCosmeticTransport::Magic() { return cosmeticfrag::kMagic; } +const char* LanCosmeticTransport::Magic() { + // Return a NUL-terminated copy: cosmeticfrag::kMagic is a 4-byte array with no + // terminator, so callers that build a std::string from it would read past the + // end. The other transports return a 5-byte static for the same reason. + static char m[5] = {cosmeticfrag::kMagic[0], cosmeticfrag::kMagic[1], + cosmeticfrag::kMagic[2], cosmeticfrag::kMagic[3], '\0'}; + return m; +} void LanCosmeticTransport::FillHeader(u8* out, u8 kind, u16 msgId, u16 index, u16 count, u32 totalLen, u16 payloadPer, diff --git a/core/script/async.cpp b/core/script/async.cpp index b0a3e2c..16143a7 100644 --- a/core/script/async.cpp +++ b/core/script/async.cpp @@ -473,11 +473,26 @@ void AsyncRuntime::InstallBindings(ScriptBindings* bindings, args.empty() ? std::string() : args[0].ToDisplay(); const f64 rawTimeout = args.size() >= 2 ? args[1].AsNumber() : 8000.0; + // Guard the cast: a NaN or out-of-range double converted + // to long is undefined behaviour, and HttpGetBlocking + // cannot undo a conversion that already happened at the + // call site. Out-of-range values clamp to the same + // 1s..60s window HttpGetBlocking applies. + long timeoutMs = 8000; + if (std::isfinite(rawTimeout)) { + if (rawTimeout <= 1000.0) { + timeoutMs = 1000; + } else if (rawTimeout >= 60000.0) { + timeoutMs = 60000; + } else { + timeoutMs = (long)rawTimeout; + } + } // Blocking HTTP on the script worker is intended: this // thread exists for exactly this and is never the // render thread. ScriptValue body = - HttpGetBlocking(ctx, url, (long)rawTimeout, self->policy_); + HttpGetBlocking(ctx, url, timeoutMs, self->policy_); return body; }); } diff --git a/docs/COSMETIC_C_ABI.md b/docs/COSMETIC_C_ABI.md new file mode 100644 index 0000000..0d923bd --- /dev/null +++ b/docs/COSMETIC_C_ABI.md @@ -0,0 +1,113 @@ +# Cosmetic C ABI (design) + +Status: implemented in `include/berry/cosmetic_c.h` + `core/modules/cosmetic_c.cpp`. +ABI: `BERRY_COSMETIC_ABI_VERSION` = 1 (independent of the global `BERRY_ABI_VERSION`). + +## Problem + +The cosmetics system (`berry/cosmetic.h`, `berry/cosmetic_device.h`) is a C++ surface. A +host that is not linked against BerryAPI's C++ ABI — a launcher's native preloader, which +`dlopen`s `libBerryAPI.so` and talks to it through a small C surface — cannot reach it at +all: there is no `BERRY_*` entry point for a loadout, a catalog read, a pack load, or the +player-model hook. The world seam, replay and governor each closed this gap with a plain C +struct of function pointers; cosmetics is the last major service without one. + +There is a second, subtler gap. Even with a C ABI, cosmetics resolve **no bones** unless the +embedder installs a player-model hook: `BerryClient::BuildCosmetics` installs it only when +`cfg.playerModelRenderTarget && cfg.playerModelBones.valid()`, and BerryAPI does not resolve +that per-build target itself (it is a signature-scan result the host owns). So the ABI must +also carry the player-model plumbing, or the surface would exist and draw nothing. + +## Shape + +Mirrors the replay/governor C ABIs exactly, so a host has one pattern to learn: + +* `BERRY_GetCosmeticApi()` returns a pointer to a single, process-lifetime + `BerryCosmeticApi` whose function pointers never change. Null before attach. +* The struct carries `size` and `abi_version`. The host checks both against + `BERRY_CosmeticStructSize()` / `BERRY_CosmeticAbiVersion()` before calling a pointer, so + a layout drift is caught by a number, never by a misread function pointer. +* Every call that can fail returns a named `BerryCosmeticStatus` and writes nothing on + failure. `BERRY_CosmeticStatusName()` maps it to a message for a diagnostics line. + +The surface (local player unless stated): + +| call | purpose | +|------|---------| +| `reload_packs(pack_dir, asset_dir)` | load model packs + asset data from directories | +| `list(out, cap, count)` | catalog snapshot (fixed-size records, no allocation crosses) | +| `equip(asset_id)` / `unequip(slot)` / `equipped(slot, out, cap)` | the local loadout | +| `clear_loadout()` | empty the local loadout | +| `loadout_canonical(out, cap)` | the canonical loadout text (hashing / wire framing) | +| `set_loadout_canonical(text)` | parse a canonical loadout back, fail-closed | +| `save_preset(name)` / `apply_preset(name)` / `preset_names(out, cap, count)` | wardrobe | +| `set_local_only(v)` | local-only vs. rendering verified remote loadouts | +| `stats(out)` | frames, submissions, skips, cache hits/misses, counts | +| `set_player_model_hook(target, reader)` / `clear_player_model_hook()` / `has_player_model_hook()` | bone plumbing | + +`slot` is a `BerryCosmeticSlotC` value (mirrors `CosmeticSlot`). `equip` takes an asset id +only: the registry resolves the slot, so a host cannot equip into the wrong one. + +## Bone plumbing (the part that makes it draw) + +```c +typedef struct BerryCosmeticBoneTransformC { + float px, py, pz; // world position of the bone origin + float pitch, yaw, roll; // degrees + float vx, vy, vz; // owning entity velocity (blocks/s), for trailing sway + float scale; + int32_t valid; +} BerryCosmeticBoneTransformC; + +typedef struct BerryPlayerModelBoneReaderC { + uint32_t size; // must equal sizeof(BerryPlayerModelBoneReaderC) + uint32_t abi_version; // must equal BERRY_COSMETIC_ABI_VERSION + void* user; // host context, passed back unchanged + int32_t (*bone_transform)(uint64_t raw_id, uint16_t runtime_id, int32_t bone, + BerryCosmeticBoneTransformC* out, void* user); + int32_t (*pose_revision)(uint64_t raw_id, uint16_t runtime_id, uint64_t* out, void* user); +} BerryPlayerModelBoneReaderC; +``` + +`set_player_model_hook(target, reader)` wraps the reader in the C++ `PlayerModelBoneReader` +(a pair of `std::function`s) and installs a `PlayerModelHook` on `target` through the active +hook backend, exactly as `BerryClient::BuildCosmetics` does for the C++ path. `pose_revision` +is optional: an empty one disables the resolve cache (correctness over speed), matching the +C++ contract. + +Fail-closed, in order: a null `target`, a null/short/ABI-mismatched reader, an unattached +engine, a backend that cannot hook, or a hook already installed all leave no hook behind and +return a named status. `clear_player_model_hook` uninstalls and unbinds, so the source +resolves nothing again. + +## Config + +`BerryConfigC` grows two borrowed-string fields, matching every other directory the struct +already carries (`script_directory`, `script_data_directory`): + +```c +const char* cosmetic_pack_directory; // *.pack.json root, or NULL +const char* cosmetic_asset_directory; // *.mesh.json / *.tex.json root, or NULL +``` + +`Engine::Bootstrap` forwards both into `ClientConfig`, so packs load at attach the same way +the C++ path loads them. This is a **public config layout change**, so the global +`BERRY_ABI_VERSION` is bumped 8 → 9. The world-seam struct layout is unchanged +(`sizeof(BerrySeamC)` stays 112); only the number the seam reports moves with the global, +which is BerryAPI's existing convention (v3–v8 all bumped it for config growth). + +## Threading and ownership + +`CosmeticService` is already thread-safe (short map operations under a lock, submission +outside it; the script worker mutates while the render thread resolves). The C ABI adds no +new shared state: every pointer in `BerryCosmeticApi` calls straight through to a service +method. The one piece of process state is the player-model hook, guarded by the same mutex +the C++ path uses. The returned `BerryCosmeticApi` is immutable after first construction, so +a host may cache it once the size/ABI check passes. + +## Why this is the right boundary + +The alternative — link the C++ surface (`_ZN5Berry*` is exported) — would pin the launcher to +BerryAPI's C++ ABI, its `std::string`/`std::function` layout, and its `libc++_shared` +version. The whole point of the C ABI is that a launcher can ship a BerryAPI build it did not +compile. Keeping the boundary at plain structs of function pointers preserves that. diff --git a/include/berry/client.h b/include/berry/client.h index d277042..7f2ec39 100644 --- a/include/berry/client.h +++ b/include/berry/client.h @@ -410,6 +410,34 @@ class BERRY_API BerryClient { /// The cosmetic asset loader (mesh/texture store), or null when no asset /// directory is configured. For diagnostics and tests. CosmeticAssetLoader* cosmeticAssets() { return cosmeticAssets_.get(); } + + //--- cosmetic runtime control (the C-ABI host's surface) ------------------ + // + // A host that dlopen's libBerryAPI.so and talks to it through the cosmetic C + // ABI (berry/cosmetic_c.h) drives these. They are the public form of the + // attach-time work `BuildCosmetics` does, so a host can reload packs and + // install the player-model hook after attach without rebuilding the client. + + /// Reloads model packs from `packDir` and asset data from `assetDir`, + /// replacing what is currently loaded. Either directory may be empty, which + /// leaves that half untouched. Returns the number of assets registered across + /// the pack load. Each pack loads atomically, so a failed pack changes nothing + /// while the other packs still load. + size_t ReloadCosmeticPacks(const std::string& packDir, + const std::string& assetDir); + + /// Installs the player-model hook on `target` and binds `reader` as the + /// per-build bone source, so cosmetics resolve to the rendered rig. Refuses a + /// null target, an empty reader, or a hook already installed. The hook uses + /// the process hook backend; a backend that cannot hook leaves no hook behind. + bool InstallPlayerModelHook(void* target, const PlayerModelBoneReader& reader); + + /// Removes the player-model hook and unbinds the reader, so the skeleton + /// source resolves nothing again. Idempotent. + void ClearPlayerModelHook(); + + /// True when a player-model hook is installed. + bool hasPlayerModelHook() const { return playerModelHook_ != nullptr; } /// The cosmetic asset store: the process-wide mesh/texture catalog the render /// seam resolves a draw item's handles against. static CosmeticAssetStore& assets() { return CosmeticAssetStore::Instance(); } diff --git a/include/berry/config.h b/include/berry/config.h index 0b65f59..459567f 100644 --- a/include/berry/config.h +++ b/include/berry/config.h @@ -117,6 +117,13 @@ typedef struct BerryConfigC { // refuses a symlink on every path component and writes through a temp file // plus rename, so it cannot escape the directory or expose a partial write. const char* script_data_directory; + // Root directory for cosmetic model packs (*.pack.json), or NULL. Loading at + // attach registers the catalog the wardrobe reads, so a fresh client has the + // host's assets without a rebuild. + const char* cosmetic_pack_directory; + // Root directory for cosmetic asset data (*.mesh.json / *.tex.json), or NULL. + // Loaded before the packs, so a pack's geometry/texture handles resolve. + const char* cosmetic_asset_directory; } BerryConfigC; #ifdef __cplusplus diff --git a/include/berry/cosmetic.h b/include/berry/cosmetic.h index 3676f4d..fe5253a 100644 --- a/include/berry/cosmetic.h +++ b/include/berry/cosmetic.h @@ -538,6 +538,9 @@ class BERRY_API CosmeticService { /// Equips an asset on the local player. Refuses an unknown asset. bool Equip(const std::string& assetId, CosmeticError* out = nullptr); bool Unequip(CosmeticSlot slot); + /// Empties the local loadout and resets the animation clock, so a fresh + /// loadout starts its clips from zero. + void ClearLocalLoadout(); /// The asset id equipped in `slot` on the local player, or "". std::string Equipped(CosmeticSlot slot) const; diff --git a/include/berry/cosmetic_c.h b/include/berry/cosmetic_c.h new file mode 100644 index 0000000..fe9c274 --- /dev/null +++ b/include/berry/cosmetic_c.h @@ -0,0 +1,265 @@ +#pragma once +// Cosmetic C ABI: lets a host that is not linked against BerryAPI's C++ ABI — a +// launcher's native preloader, which `dlopen`s `libBerryAPI.so` and talks to it +// through the small C surface — drive the local player's cosmetics and install +// the player-model hook that makes bones resolve. +// +// Until now the cosmetics system (`berry/cosmetic.h`, `berry/cosmetic_device.h`) +// was C++ only, so a host could not reach it without linking the whole C++ +// surface. This header closes that gap the same way `berry/seam.h` closed it for +// the world seam and `berry/replay_c.h` for the replay: a plain C struct of +// function pointers, obtained by calling `BERRY_GetCosmeticApi()`. +// +// Two halves: +// * the LOADOUT half — reload packs, list the catalog, equip/unequip, presets +// and stats, all over the local player; +// * the BONE half — `set_player_model_hook` installs a detour on the game's +// player-render function 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 (the correct default for a build whose +// layout is unknown). +// +// Fail-closed throughout: +// * `BERRY_GetCosmeticApi` returns null before attach, so a host cannot drive +// cosmetics on an engine that is not running. +// * The struct carries `size`/`abi_version`; the host checks both against +// `BERRY_CosmeticAbiVersion()`/`BERRY_CosmeticStructSize()` before it calls +// a single pointer, so a layout drift is caught by a number. +// * Every call that can fail returns a non-zero `BerryCosmeticStatus` and +// writes nothing on failure, so a refused equip leaves the loadout intact. + +#include "berry/export.h" + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// The cosmetic C ABI version. Independent of the global `BERRY_ABI_VERSION`: +/// the surface may gain entry points without moving the engine config ABI. +/// Bump whenever a struct here changes layout or a pointer's signature changes. +#define BERRY_COSMETIC_ABI_VERSION 1 + +/// The longest asset id a record carries, including the NUL. A longer id is +/// truncated in `list` and refused by `equip`, so a hostile pack cannot inflate +/// a record. +#define BERRY_COSMETIC_MAX_ID 128 +/// The longest custom-animation clip name, matching `CosmeticAsset::kMaxAnimationNameLen`. +#define BERRY_COSMETIC_MAX_ANIMATION 64 + +/// Status codes. 0 is success; a caller tests `!= 0` for failure and may map the +/// value to a message with `BERRY_CosmeticStatusName`. +typedef enum BerryCosmeticStatus { + BERRY_COSMETIC_OK = 0, + /// BerryAPI is not attached, so there is no cosmetic service to drive. + BERRY_COSMETIC_NOT_ATTACHED = 1, + /// A required argument was null, empty or out of range. + BERRY_COSMETIC_BAD_ARGUMENT = 2, + /// The asset id is not in the registry. + BERRY_COSMETIC_UNKNOWN_ASSET = 3, + /// The local player does not own the asset (remote loadouts only). + BERRY_COSMETIC_NOT_OWNED = 4, + /// The slot value is not a known `BerryCosmeticSlotC`. + BERRY_COSMETIC_BAD_SLOT = 5, + /// A canonical loadout / preset text was malformed; nothing was changed. + BERRY_COSMETIC_BAD_FORMAT = 6, + /// The named preset does not exist. + BERRY_COSMETIC_UNKNOWN_PRESET = 7, + /// A buffer the caller supplied was too small for the value. + BERRY_COSMETIC_BUFFER_TOO_SMALL = 8, + /// No usable hook backend, so the player-model hook cannot be installed. + BERRY_COSMETIC_NO_HOOK_BACKEND = 9, + /// The hook backend refused the detour (bad arch, or the target is unhookable). + BERRY_COSMETIC_HOOK_REFUSED = 10, + /// A player-model hook is already installed; clear it first. + BERRY_COSMETIC_ALREADY_HOOKED = 11, + /// The bone reader struct was null, too small, or ABI-mismatched. + BERRY_COSMETIC_BAD_READER = 12, +} BerryCosmeticStatus; + +/// Mirrors `Berry::CosmeticSlot`. `BERRY_COSMETIC_SLOT_NONE` is 0; the values are +/// the wire/ABI contract and must not be reordered. +typedef enum BerryCosmeticSlotC { + BERRY_COSMETIC_SLOT_NONE = 0, + BERRY_COSMETIC_SLOT_HEAD = 1, + BERRY_COSMETIC_SLOT_BACK = 2, + BERRY_COSMETIC_SLOT_SHOULDERS = 3, + BERRY_COSMETIC_SLOT_BODY = 4, + BERRY_COSMETIC_SLOT_TRAIL = 5, + BERRY_COSMETIC_SLOT_EFFECT = 6, + BERRY_COSMETIC_SLOT_EMOTE = 7, + BERRY_COSMETIC_SLOT_COUNT = 8, +} BerryCosmeticSlotC; + +/// One registry asset, flattened so no C++ type crosses the boundary. String +/// fields are fixed-size and NUL-terminated; a value too long is truncated. +typedef struct BerryCosmeticAssetC { + char id[BERRY_COSMETIC_MAX_ID]; + int32_t slot; // BerryCosmeticSlotC + int32_t bone; // Berry::Bone value + uint64_t geometry; // loaded mesh handle + uint64_t texture; // loaded texture handle + float offset_x, offset_y, offset_z; + float rotation_pitch, rotation_yaw, rotation_roll; + float scale; + float tint_r, tint_g, tint_b; + float trail; // 0 rigid .. 1 fully slack + char animation[BERRY_COSMETIC_MAX_ANIMATION]; + float animation_speed; + char pack[BERRY_COSMETIC_MAX_ID]; + char author[BERRY_COSMETIC_MAX_ID]; +} BerryCosmeticAssetC; + +/// The cosmetic service's counters, flattened. +typedef struct BerryCosmeticStatsC { + uint64_t frames; + uint64_t submitted; // geometry submissions + uint64_t skipped_no_bone; + uint64_t skipped_not_owned; + uint64_t cache_hits; + uint64_t cache_misses; + uint32_t registry_count; // assets in the catalog + uint32_t preset_count; // wardrobe presets +} BerryCosmeticStatsC; + +/// A bone's world transform, as the host's bone reader reports it. Mirrors +/// `Berry::CosmeticBoneTransform`. +typedef struct BerryCosmeticBoneTransformC { + float px, py, pz; // world position of the bone origin + float pitch, yaw, roll; // degrees + float vx, vy, vz; // owning entity velocity (blocks/s), for trailing sway + float scale; + int32_t valid; // 1 when the reading is usable +} BerryCosmeticBoneTransformC; + +/// The per-build bone accessors a host fills. Both callbacks are optional: a +/// null `bone_transform` makes the source resolve nothing (fail closed), and a +/// null `pose_revision` disables the resolve cache (correctness over speed). +/// +/// `raw_id`/`runtime_id` identify the entity the game is rendering; `bone` is a +/// `Berry::Bone` value. The callback returns non-zero and fills `out` on a hit. +typedef struct BerryPlayerModelBoneReaderC { + /// Must equal `sizeof(BerryPlayerModelBoneReaderC)`. + uint32_t size; + /// Must equal `BERRY_COSMETIC_ABI_VERSION`. + uint32_t abi_version; + /// Host context, passed back to every callback unchanged. May be null. + void* user; + int32_t (*bone_transform)(uint64_t raw_id, uint16_t runtime_id, int32_t bone, + BerryCosmeticBoneTransformC* out, void* user); + int32_t (*pose_revision)(uint64_t raw_id, uint16_t runtime_id, uint64_t* out, + void* user); +} BerryPlayerModelBoneReaderC; + +/// The cosmetic C surface. Every pointer is optional: a null one means this +/// build does not provide that call, and the host must not invoke it. The host +/// checks `size` and `abi_version` before using any pointer. +typedef struct BerryCosmeticApi { + /// Must equal `sizeof(BerryCosmeticApi)`. + uint32_t size; + /// Must equal `BERRY_COSMETIC_ABI_VERSION`. + uint32_t abi_version; + + //--- catalog + packs ------------------------------------------------------ + + /// Reloads model packs from `pack_dir` (`*.pack.json`) and asset data from + /// `asset_dir` (`*.mesh.json` / `*.tex.json`), replacing what is loaded. A + /// null/empty directory leaves that half untouched. Returns the number of + /// assets registered, or 0 on a refusal. Safe to call repeatedly. + uint32_t (*reload_packs)(const char* pack_dir, const char* asset_dir); + + /// Writes up to `cap` catalog records into `out`, writing the count to + /// `*count`. Returns `BERRY_COSMETIC_OK` even when the catalog is empty. + int32_t (*list)(BerryCosmeticAssetC* out, size_t cap, size_t* count); + + /// The number of registered assets. + uint32_t (*registry_count)(void); + + //--- local loadout -------------------------------------------------------- + + /// Equips the asset with `asset_id` on the local player, in the slot the + /// registry records for it. A second equip in the same slot replaces the + /// first. Refuses an unknown asset. + int32_t (*equip)(const char* asset_id); + /// Removes whatever is in `slot`. Refuses an unknown slot. + int32_t (*unequip)(int32_t slot); + /// Writes the asset id equipped in `slot` into `out` (a NUL-terminated string, + /// "" when the slot is empty). Refuses an unknown slot or a short buffer. + int32_t (*equipped)(int32_t slot, char* out, size_t cap); + /// Empties the local loadout. + int32_t (*clear_loadout)(void); + + /// Writes the local loadout's canonical text into `out` (NUL-terminated) and + /// its length into `*out_len` (excluding the NUL). Refuses a short buffer. + int32_t (*loadout_canonical)(char* out, size_t cap, size_t* out_len); + /// Parses `text` back into the local loadout. Fail closed: a malformed text + /// leaves the loadout untouched. + int32_t (*set_loadout_canonical)(const char* text); + + //--- wardrobe (presets) --------------------------------------------------- + + /// Saves the current local loadout as preset `name` (replacing one of the + /// same name). + int32_t (*save_preset)(const char* name); + /// Replaces the local loadout with preset `name`. + int32_t (*apply_preset)(const char* name); + /// 1 when preset `name` exists. + int32_t (*has_preset)(const char* name); + /// Writes up to `cap` preset names into `names` (each a NUL-terminated string + /// of at most `name_cap` bytes) and the count into `*count`. + int32_t (*preset_names)(char* names, size_t name_cap, size_t cap, size_t* count); + + //--- remote render gate --------------------------------------------------- + + /// When 1 (the default), only the local player renders. When 0, verified + /// remote loadouts (fed by the sync channel) render too. + int32_t (*set_local_only)(int32_t local_only); + /// The current local-only flag (1 or 0). + int32_t (*local_only)(void); + + //--- stats ---------------------------------------------------------------- + + /// Writes the service counters into `out`. Never fails. + void (*stats)(BerryCosmeticStatsC* out); + + //--- player-model bone plumbing ------------------------------------------ + + /// Installs the player-model hook on `target` (the game's player-render + /// function, resolved by the host's own signature scan) and binds `reader` as + /// the per-build bone source. This is what makes cosmetics resolve to a real + /// rig. Refuses a null target, a bad reader, an unattached engine, no usable + /// hook backend, a refused detour, or a hook already installed. + int32_t (*set_player_model_hook)(void* target, + const BerryPlayerModelBoneReaderC* reader); + /// Removes the hook and unbinds the reader, so the source resolves nothing. + /// Idempotent. + void (*clear_player_model_hook)(void); + /// 1 when a player-model hook is installed. + int32_t (*has_player_model_hook)(void); +} BerryCosmeticApi; + +/// Maps a status to a short message for a diagnostics line. Never null. +BERRY_API const char* BERRY_CosmeticStatusName(int32_t status); + +/// The `BERRY_COSMETIC_ABI_VERSION` this build was compiled with. The host +/// compares its own expectation against this before calling any pointer. +BERRY_API uint32_t BERRY_CosmeticAbiVersion(void); + +/// The exact `sizeof(BerryCosmeticApi)` this build expects, so a host that +/// mirrors the struct reads it before binding and refuses on a mismatch. +BERRY_API uint32_t BERRY_CosmeticStructSize(void); + +/// The exact `sizeof(BerryPlayerModelBoneReaderC)` this build expects, for the +/// same mirror check on the reader struct. +BERRY_API uint32_t BERRY_CosmeticBoneReaderStructSize(void); + +/// Returns the cosmetic C surface, or null when BerryAPI is not attached. The +/// returned pointer is stable for the process's lifetime; the host must check +/// `size`/`abi_version` before using it. +BERRY_API const BerryCosmeticApi* BERRY_GetCosmeticApi(void); + +#ifdef __cplusplus +} +#endif diff --git a/include/berry/engine.h b/include/berry/engine.h index 701f1c1..aede6a7 100644 --- a/include/berry/engine.h +++ b/include/berry/engine.h @@ -90,6 +90,11 @@ struct EngineConfig { std::string lanIface; /// Root directory for the sandboxed `fs.*` store. Empty keeps it in-memory. std::string scriptDataDirectory; + /// Root directory for cosmetic model packs (*.pack.json). Empty loads none. + std::string cosmeticPackDirectory; + /// Root directory for cosmetic asset data (*.mesh.json / *.tex.json). Empty + /// loads none. + std::string cosmeticAssetDirectory; }; class BERRY_API Engine { diff --git a/include/berry/seam.h b/include/berry/seam.h index 86a00f8..14f997c 100644 --- a/include/berry/seam.h +++ b/include/berry/seam.h @@ -82,6 +82,12 @@ typedef struct BerryLocalPlayerC { float pitch, yaw, roll; } BerryLocalPlayerC; +/// The seam ABI version. Independent of the global `BERRY_ABI_VERSION`: the +/// seam struct may gain a slot without moving the engine config ABI, and the +/// engine config may grow without moving the seam. Bump whenever `BerrySeamC` +/// changes layout or a slot's signature changes. +#define BERRY_SEAM_ABI_VERSION 8 + /// The seam a host fills and binds. /// /// Every function pointer is optional: a null one makes the matching read fail @@ -92,7 +98,7 @@ typedef struct BerryLocalPlayerC { typedef struct BerrySeamC { /// Must equal `sizeof(BerrySeamC)`. uint32_t size; - /// Must equal `BERRY_ABI_VERSION`. + /// Must equal `BERRY_SEAM_ABI_VERSION`. uint32_t abi_version; /// Host context, passed back to every callback. May be null. void *user; @@ -159,7 +165,7 @@ BERRY_API int32_t BERRY_HasWorldSeam(void); /// safe: a layout drift is caught by a number, not by a misread function pointer. BERRY_API uint32_t BERRY_WorldSeamStructSize(void); -/// The `BERRY_ABI_VERSION` this build was compiled with, for the same check. +/// The `BERRY_SEAM_ABI_VERSION` this build was compiled with, for the same check. BERRY_API uint32_t BERRY_WorldSeamAbiVersion(void); #ifdef __cplusplus diff --git a/include/berry/version.h b/include/berry/version.h index d17a926..8d7b91d 100644 --- a/include/berry/version.h +++ b/include/berry/version.h @@ -26,7 +26,13 @@ // BERRY_ClearWorldSeam / BERRY_HasWorldSeam and the BerrySeamC struct), so // a host that is not linked against the C++ ABI can bind a LevelSeam and // the World module's entity reads stop failing closed. -#define BERRY_ABI_VERSION 8 +// v9: BerryConfigC grew the cosmetic fields (cosmetic_pack_directory, +// cosmetic_asset_directory), so a host can point the client at its cosmetic +// packs and asset data through the C ABI. The cosmetic surface itself lives +// in berry/cosmetic_c.h under its own BERRY_COSMETIC_ABI_VERSION, and the +// world seam now reports BERRY_SEAM_ABI_VERSION (berry/seam.h) instead of +// this number, so a config-only growth no longer moves the seam's ABI. +#define BERRY_ABI_VERSION 9 // Legacy internal SDK ABI identifier (kept for compatibility). #define BERRY_SDK_ABI 1 diff --git a/tests/dlopen_test.c b/tests/dlopen_test.c index 38eb220..ae5cc04 100644 --- a/tests/dlopen_test.c +++ b/tests/dlopen_test.c @@ -12,6 +12,7 @@ #include #include "berry/config.h" +#include "berry/cosmetic_c.h" static int g_fail = 0; #define CHECK(c) do { if (!(c)) { \ @@ -79,6 +80,41 @@ int main(int argc, char** argv) { detach(); CHECK(isAttached() == 0); + // --- The cosmetic C surface: resolved by dlsym, refused before attach, and + // self-consistent (size + abi) after. This is the contract the launcher's + // native preloader binds; a missing export leaves cosmetics silently dark. --- + uint32_t (*cosmeticAbi)(void) = + (uint32_t (*)(void))dlsym(h, "BERRY_CosmeticAbiVersion"); + uint32_t (*cosmeticSize)(void) = + (uint32_t (*)(void))dlsym(h, "BERRY_CosmeticStructSize"); + uint32_t (*cosmeticReaderSize)(void) = + (uint32_t (*)(void))dlsym(h, "BERRY_CosmeticBoneReaderStructSize"); + const BerryCosmeticApi* (*getCosmeticApi)(void) = + (const BerryCosmeticApi* (*)(void))dlsym(h, "BERRY_GetCosmeticApi"); + const char* (*cosmeticStatusName)(int32_t) = + (const char* (*)(int32_t))dlsym(h, "BERRY_CosmeticStatusName"); + CHECK(cosmeticAbi && cosmeticSize && cosmeticReaderSize && getCosmeticApi && + cosmeticStatusName); + if (cosmeticAbi && cosmeticSize && cosmeticReaderSize && getCosmeticApi) { + CHECK(cosmeticAbi() == BERRY_COSMETIC_ABI_VERSION); + CHECK(cosmeticReaderSize() == sizeof(BerryPlayerModelBoneReaderC)); + CHECK(getCosmeticApi() == NULL); // detached: no surface + detach(); + CHECK(inject() == 1); + const BerryCosmeticApi* capi = getCosmeticApi(); + CHECK(capi != NULL); + if (capi != NULL) { + CHECK(capi->size == cosmeticSize()); + CHECK(capi->abi_version == cosmeticAbi()); + CHECK(capi->size == sizeof(BerryCosmeticApi)); + CHECK(capi->equip && capi->list && capi->stats && + capi->set_player_model_hook); + CHECK(capi->registry_count() > 0); + } + detach(); + CHECK(getCosmeticApi() == NULL); + } + // --- Plain Inject remains idempotent and re-injectable --- int wasAttached = isAttached(); CHECK(inject() == 1); // idempotent safe-call diff --git a/tests/test_cosmetic_c.cpp b/tests/test_cosmetic_c.cpp new file mode 100644 index 0000000..f2defb8 --- /dev/null +++ b/tests/test_cosmetic_c.cpp @@ -0,0 +1,246 @@ +// Host tests for the cosmetic C ABI (berry/cosmetic_c.h). +// +// Covers the contract a dlopen'ing host relies on: +// * The ABI version, struct size and reader size are reported and +// self-consistent with the mirrored structs. +// * `BERRY_GetCosmeticApi` refuses before attach and returns the surface after. +// * Every call fails closed before attach (no crash, a named status). +// * The catalog, loadout, presets and stats round-trip on the real service. +// * The bone reader is wrapped and the player-model hook is refused without a +// target and installed (then cleared) with one. +#include "berry/cosmetic_c.h" + +#include "berry/engine.h" + +#include +#include + +static int g_fail = 0; +#define CHECK(cond) do { if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); ++g_fail; } } while (0) + +namespace { + +// A bone reader that reports a fixed pose for every entity, so the hook path can +// be exercised headlessly. `pose_revision` bumps a counter, standing in for a +// moving pose. +struct ReaderState { + int boneCalls = 0; + unsigned long long revision = 0; +}; + +int32_t BoneTransform(uint64_t raw, uint16_t runtime, int32_t bone, + BerryCosmeticBoneTransformC* out, void* user) { + (void)raw; + (void)runtime; + (void)bone; + ReaderState* st = static_cast(user); + if (st != nullptr) ++st->boneCalls; + if (out == nullptr) return 0; + out->px = 1.0f; + out->py = 2.0f; + out->pz = 3.0f; + out->pitch = 0.0f; + out->yaw = 90.0f; + out->roll = 0.0f; + out->vx = 0.0f; + out->vy = 0.0f; + out->vz = 0.0f; + out->scale = 1.0f; + out->valid = 1; + return 1; +} + +int32_t PoseRevision(uint64_t raw, uint16_t runtime, uint64_t* out, void* user) { + (void)raw; + (void)runtime; + ReaderState* st = static_cast(user); + if (out == nullptr) return 0; + *out = st != nullptr ? ++st->revision : 0; + return 1; +} + +} // namespace + +// The player-model hook is installed on this function. It must be a real +// function in this module: the shim writes a `jmp rel32` from the target to the +// detour and a `jmp rel32` back from the trampoline, so the target has to be +// within 32 bits of both, which an anonymous mapping is not guaranteed to be. +// It is only patched and restored, never called, so a patched prologue is safe. +extern "C" __attribute__((noinline)) int CosmeticHookTargetProbe(int x) { + volatile int v = x + 1; + return v; +} + +int main() { + // --- Before attach: the surface is refused, every call fails closed --- + CHECK(BERRY_GetCosmeticApi() == nullptr); + CHECK(BERRY_CosmeticAbiVersion() == BERRY_COSMETIC_ABI_VERSION); + CHECK(BERRY_CosmeticStructSize() > 0); + CHECK(BERRY_CosmeticBoneReaderStructSize() == sizeof(BerryPlayerModelBoneReaderC)); + CHECK(std::strcmp(BERRY_CosmeticStatusName(BERRY_COSMETIC_NOT_ATTACHED), "not attached") == 0); + CHECK(std::strcmp(BERRY_CosmeticStatusName(BERRY_COSMETIC_OK), "ok") == 0); + CHECK(std::strcmp(BERRY_CosmeticStatusName(999), "unknown status") == 0); + + // --- Attach, then the surface is available and self-consistent --- + Berry::Engine& eng = Berry::Engine::Instance(); + eng.Bootstrap(); + CHECK(eng.Attach()); + + const BerryCosmeticApi* api = BERRY_GetCosmeticApi(); + CHECK(api != nullptr); + if (api == nullptr) { + printf("no cosmetic api after attach\n"); + return 1; + } + + // The guard a host checks before calling a single pointer. + CHECK(api->size == BERRY_CosmeticStructSize()); + CHECK(api->abi_version == BERRY_CosmeticAbiVersion()); + CHECK(api->size == sizeof(BerryCosmeticApi)); + CHECK(api->reload_packs && api->list && api->registry_count && api->equip && + api->unequip && api->equipped && api->clear_loadout && + api->loadout_canonical && api->set_loadout_canonical && api->save_preset && + api->apply_preset && api->has_preset && api->preset_names && + api->set_local_only && api->local_only && api->stats && + api->set_player_model_hook && api->clear_player_model_hook && + api->has_player_model_hook); + + // --- The built-in catalog is present, so equip has something to target --- + const uint32_t count = api->registry_count(); + CHECK(count > 0); + CHECK(api->list(nullptr, 0, nullptr) == BERRY_COSMETIC_BAD_ARGUMENT); + + size_t listed = 0; + CHECK(api->list(nullptr, 0, &listed) == BERRY_COSMETIC_OK); + CHECK(listed == 0); + CHECK(api->list(nullptr, 4, &listed) == BERRY_COSMETIC_BAD_ARGUMENT); + + BerryCosmeticAssetC records[64]; + CHECK(api->list(records, 64, &listed) == BERRY_COSMETIC_OK); + CHECK(listed > 0 && listed <= 64); + const char* firstId = records[0].id; + CHECK(firstId[0] != '\0'); + + // --- Equip / equipped / unequip round-trip --- + CHECK(api->equip(nullptr) == BERRY_COSMETIC_BAD_ARGUMENT); + CHECK(api->equip("") == BERRY_COSMETIC_BAD_ARGUMENT); + CHECK(api->equip("no.such.asset") == BERRY_COSMETIC_UNKNOWN_ASSET); + + CHECK(api->equip(firstId) == BERRY_COSMETIC_OK); + char buf[BERRY_COSMETIC_MAX_ID]; + CHECK(api->equipped(records[0].slot, buf, sizeof(buf)) == BERRY_COSMETIC_OK); + CHECK(std::strcmp(buf, firstId) == 0); + + // A short buffer is refused and reports too-small, not a truncation. + char tiny[2]; + CHECK(api->equipped(records[0].slot, tiny, sizeof(tiny)) == BERRY_COSMETIC_BUFFER_TOO_SMALL); + + // A bad slot is refused. + CHECK(api->equipped(0, buf, sizeof(buf)) == BERRY_COSMETIC_BAD_SLOT); + CHECK(api->unequip(999) == BERRY_COSMETIC_BAD_SLOT); + + // Canonical round-trip: the text names what is equipped. + char canonical[512]; + size_t clen = 0; + CHECK(api->loadout_canonical(canonical, sizeof(canonical), &clen) == BERRY_COSMETIC_OK); + CHECK(clen > 0); + CHECK(std::strstr(canonical, firstId) != nullptr); + + CHECK(api->clear_loadout() == BERRY_COSMETIC_OK); + CHECK(api->equipped(records[0].slot, buf, sizeof(buf)) == BERRY_COSMETIC_OK); + CHECK(buf[0] == '\0'); + + // A canonical loadout round-trips back to the same equip. + CHECK(api->set_loadout_canonical(canonical) == BERRY_COSMETIC_OK); + CHECK(api->equipped(records[0].slot, buf, sizeof(buf)) == BERRY_COSMETIC_OK); + CHECK(std::strcmp(buf, firstId) == 0); + + // A malformed canonical text is refused, and leaves the loadout intact. + CHECK(api->set_loadout_canonical("this is not canonical") == BERRY_COSMETIC_BAD_FORMAT); + CHECK(api->equipped(records[0].slot, buf, sizeof(buf)) == BERRY_COSMETIC_OK); + CHECK(std::strcmp(buf, firstId) == 0); + + // --- Presets --- + CHECK(api->save_preset(nullptr) == BERRY_COSMETIC_BAD_ARGUMENT); + CHECK(api->save_preset("pvp") == BERRY_COSMETIC_OK); + CHECK(api->has_preset("pvp") == 1); + CHECK(api->has_preset("nope") == 0); + CHECK(api->apply_preset("nope") == BERRY_COSMETIC_UNKNOWN_PRESET); + + char names[8 * 64]; + size_t ncount = 0; + CHECK(api->preset_names(names, 64, 8, &ncount) == BERRY_COSMETIC_OK); + CHECK(ncount >= 1); + CHECK(std::strcmp(names, "pvp") == 0); + + api->clear_loadout(); + CHECK(api->apply_preset("pvp") == BERRY_COSMETIC_OK); + CHECK(api->equipped(records[0].slot, buf, sizeof(buf)) == BERRY_COSMETIC_OK); + CHECK(std::strcmp(buf, firstId) == 0); + + // --- The local-only render gate round-trips --- + CHECK(api->local_only() == 1); + CHECK(api->set_local_only(0) == BERRY_COSMETIC_OK); + CHECK(api->local_only() == 0); + CHECK(api->set_local_only(1) == BERRY_COSMETIC_OK); + CHECK(api->local_only() == 1); + + // --- Stats reflect the catalog and presets --- + BerryCosmeticStatsC stats; + api->stats(&stats); + CHECK(stats.registry_count == count); + CHECK(stats.preset_count >= 1); + + // --- The player-model hook: refused without a target, installed with one --- + CHECK(api->has_player_model_hook() == 0); + CHECK(api->set_player_model_hook(nullptr, nullptr) == BERRY_COSMETIC_BAD_ARGUMENT); + + ReaderState readerState; + BerryPlayerModelBoneReaderC reader{}; + reader.size = sizeof(BerryPlayerModelBoneReaderC); + reader.abi_version = BERRY_COSMETIC_ABI_VERSION; + reader.user = &readerState; + reader.bone_transform = &BoneTransform; + reader.pose_revision = &PoseRevision; + + // A null reader, a short reader and an ABI-mismatched reader are refused. + CHECK(api->set_player_model_hook(&readerState, nullptr) == BERRY_COSMETIC_BAD_READER); + BerryPlayerModelBoneReaderC shortReader = reader; + shortReader.size = 4; + CHECK(api->set_player_model_hook(&readerState, &shortReader) == BERRY_COSMETIC_BAD_READER); + BerryPlayerModelBoneReaderC badAbi = reader; + badAbi.abi_version = 999; + CHECK(api->set_player_model_hook(&readerState, &badAbi) == BERRY_COSMETIC_BAD_READER); + BerryPlayerModelBoneReaderC noFn = reader; + noFn.bone_transform = nullptr; + CHECK(api->set_player_model_hook(&readerState, &noFn) == BERRY_COSMETIC_BAD_READER); + + // With a real target, the hook installs (the host harness backend can hook) + // and reports itself installed; a second install is refused until cleared. + void* const hookTarget = reinterpret_cast(&CosmeticHookTargetProbe); + const int32_t hookStatus = api->set_player_model_hook(hookTarget, &reader); + if (hookStatus == BERRY_COSMETIC_OK) { + CHECK(api->has_player_model_hook() == 1); + CHECK(api->set_player_model_hook(hookTarget, &reader) == BERRY_COSMETIC_ALREADY_HOOKED); + api->clear_player_model_hook(); + CHECK(api->has_player_model_hook() == 0); + } else { + // No usable hook backend on this host: the refusal is named, not a crash. + printf("note: hook backend unavailable (%s)\n", + BERRY_CosmeticStatusName(hookStatus)); + CHECK(hookStatus == BERRY_COSMETIC_NO_HOOK_BACKEND || + hookStatus == BERRY_COSMETIC_HOOK_REFUSED); + } + + // --- Detach: the surface is refused again --- + eng.Detach(); + CHECK(BERRY_GetCosmeticApi() == nullptr); + + if (g_fail == 0) { + printf("test_cosmetic_c: all checks passed\n"); + return 0; + } + printf("test_cosmetic_c: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_packet_capture.cpp b/tests/test_packet_capture.cpp index cdecb52..d61bd72 100644 --- a/tests/test_packet_capture.cpp +++ b/tests/test_packet_capture.cpp @@ -138,14 +138,31 @@ namespace { // A fake "game" receive function with the conventional Bedrock shape. It counts // calls, so the test can prove the hook calls the original. +// +// It is `noinline`, and every call site reaches it through a volatile pointer +// (see `CallReceive` below): a compiler is free to inline a direct call to a +// local function, and an inlined body would bypass the patched prologue +// entirely. clang does inline it at -O1/-O2, which is why the call below is +// indirect rather than `FakeReceive(...)`. std::atomic g_origCalls{0}; std::atomic g_lastPayload{0}; +__attribute__((noinline)) void FakeReceive(void* /*self*/, const void* packet) { ++g_origCalls; if (packet) g_lastPayload = *(const int*)packet; } +// Calls a fake receive function through a volatile pointer, so the call goes to +// the function's code (which a hook may have patched) instead of an inlined copy. +// The volatile read of the pointer is observable, so no optimizer can fold the +// call back to a direct one. +__attribute__((noinline)) +void CallReceive(void (*fn)(void*, const void*), const void* packet) { + void (*volatile vfn)(void*, const void*) = fn; + vfn(nullptr, packet); +} + /// A packet object the fake decode understands: the first 4 bytes are the id, /// the rest is the payload. struct FakePacket { @@ -184,7 +201,7 @@ static void TestHookCapturesAndCallsThrough() { CHECK(!dup.Install((void*)&FakeReceive, abi, HookManager::Instance().Backend(), &hub)); FakePacket pkt{57, "the-chunk"}; - FakeReceive(nullptr, &pkt); // goes through the detour + CallReceive(&FakeReceive, &pkt); // goes through the detour // The original ran, and the packet was captured and classified. CHECK(g_origCalls == 1); @@ -199,7 +216,7 @@ static void TestHookCapturesAndCallsThrough() { CHECK(!hook.installed()); // After uninstall the function is the original again. FakePacket pkt2{20, "block"}; - FakeReceive(nullptr, &pkt2); + CallReceive(&FakeReceive, &pkt2); CHECK(g_origCalls == 2); CHECK(obs.seen.size() == 1); // no capture after uninstall } @@ -234,7 +251,7 @@ static void TestHookFailClosed() { PacketReceiveHook h4; CHECK(h4.Install((void*)&FakeReceive, abi, HookManager::Instance().Backend(), &hub)); FakePacket bad{0xDEAD, "nope"}; - FakeReceive(nullptr, &bad); + CallReceive(&FakeReceive, &bad); CHECK(g_origCalls == 1); CHECK(h4.captured() == 0); CHECK(obs.seen.empty()); @@ -342,14 +359,6 @@ void ClientFakeReceive(void* /*self*/, const void* packet) { (void)packet; } -// Calls `ClientFakeReceive` through a volatile pointer, so the call goes to the -// function's code (which the hook patched) instead of being inlined at the -// call site. -void CallFakeReceive(const void* packet) { - static void (*volatile fn)(void*, const void*) = &ClientFakeReceive; - fn(nullptr, packet); -} - // The decode ABI the client's config carries: id in the first 4 bytes, the rest // is the payload. Matches `FakePacket`. bool ClientDecode(const void* p, u32* id, std::string* payload) { @@ -440,7 +449,7 @@ static void TestClientInstallsReceiveHookFromConfig() { client->replayPacketObserver()->capture()->Start(); const u64 before = client->packets().totals().total(); FakePacket pkt{20, "block-bytes"}; // UpdateBlock -> Chunk - CallFakeReceive(&pkt); // goes through the installed detour + CallReceive(&ClientFakeReceive, &pkt); // goes through the installed detour CHECK(g_clientOrigCalls == 1); // original still ran CHECK(client->packetReceiveHook()->captured() == 1); @@ -454,7 +463,7 @@ static void TestClientInstallsReceiveHookFromConfig() { // Detach must uninstall the hook: the target is the original again. client->Detach(); FakePacket pkt2{20, "after"}; - CallFakeReceive(&pkt2); + CallReceive(&ClientFakeReceive, &pkt2); CHECK(g_clientOrigCalls == 2); eng.Detach(); diff --git a/tests/test_seam.cpp b/tests/test_seam.cpp index 47ab33d..0eaeb44 100644 --- a/tests/test_seam.cpp +++ b/tests/test_seam.cpp @@ -155,7 +155,7 @@ int32_t GetLocalPlayer(BerryLocalPlayerC* out, void* user) { BerrySeamC MakeSeam(HostCtx* ctx) { BerrySeamC seam{}; seam.size = sizeof(BerrySeamC); - seam.abi_version = BERRY_ABI_VERSION; + seam.abi_version = BERRY_SEAM_ABI_VERSION; seam.user = ctx; seam.get_entity = GetEntity; seam.all_entities = AllEntities; @@ -195,7 +195,7 @@ int main() { shortSeam.size = sizeof(BerrySeamC) - 1; CHECK(BERRY_SetWorldSeam(&shortSeam) == 0); BerrySeamC badAbi = MakeSeam(&ctx); - badAbi.abi_version = BERRY_ABI_VERSION + 1; + badAbi.abi_version = BERRY_SEAM_ABI_VERSION + 1; CHECK(BERRY_SetWorldSeam(&badAbi) == 0); CHECK(BERRY_HasWorldSeam() == 0); @@ -284,7 +284,7 @@ int main() { HostCtx sparse; BerrySeamC minimal{}; minimal.size = sizeof(BerrySeamC); - minimal.abi_version = BERRY_ABI_VERSION; + minimal.abi_version = BERRY_SEAM_ABI_VERSION; minimal.user = &sparse; CHECK(BERRY_SetWorldSeam(&minimal) == 1); bound = BoundSeam(); @@ -300,7 +300,7 @@ int main() { // --- The struct-size / ABI probes a mirroring host checks before binding. --- CHECK(BERRY_WorldSeamStructSize() == sizeof(BerrySeamC)); - CHECK(BERRY_WorldSeamAbiVersion() == BERRY_ABI_VERSION); + CHECK(BERRY_WorldSeamAbiVersion() == BERRY_SEAM_ABI_VERSION); // --- Unbind. --- BERRY_ClearWorldSeam(); diff --git a/third_party/mbedtls/PROVENANCE.md b/third_party/mbedtls/PROVENANCE.md index 669d58f..2d35210 100644 --- a/third_party/mbedtls/PROVENANCE.md +++ b/third_party/mbedtls/PROVENANCE.md @@ -46,6 +46,14 @@ parts a client that only needs HTTPS/WSS does not use: - the debug/tracing and memory-buffer-allocator units, and the x86 AES-NI / Padlock / SSE2 fast paths (they are selected per build, not vendored). +`MBEDTLS_AESCE_C` (the Armv8-A AES extension, `library/aesce.c`) is also OFF. +That unit is not vendored, and `aes.c` guards its Armv8-A fast path on the +option: leaving it on makes `aes.c` reference the `mbedtls_aesce_*` symbols +with nothing to satisfy them, which is a link failure for the arm64 test build +and an unresolved symbol in the shipping `.so` (a call to address 0 at run +time). The portable AES in `aes.c` is used instead. `tools/check-mbedtls.py` +fails the build if the option is turned back on. + TLS 1.3 is kept (`MBEDTLS_SSL_PROTO_TLS1_3`), as is `MBEDTLS_PSA_CRYPTO_C` which TLS 1.3 requires. Certificate verification (`MBEDTLS_X509_CRT_PARSE_C`, `MBEDTLS_X509_USE_C`) and the trusted-CA store diff --git a/third_party/mbedtls/include/mbedtls/mbedtls_config.h b/third_party/mbedtls/include/mbedtls/mbedtls_config.h index 76d7ca0..ef51572 100644 --- a/third_party/mbedtls/include/mbedtls/mbedtls_config.h +++ b/third_party/mbedtls/include/mbedtls/mbedtls_config.h @@ -2382,7 +2382,12 @@ * * This module adds support for the AES Armv8-A Cryptographic Extensions on Armv8 systems. */ -#define MBEDTLS_AESCE_C +// The Armv8-A AES extension lives in library/aesce.c, which is not +// vendored (only the trimmed client stack is). Enabling it leaves aes.c +// with undefined mbedtls_aesce_* references on arm64, which is a link +// failure for the arm64 test build and an unresolved symbol in the +// shipping .so. The portable AES in aes.c is used instead. +//#define MBEDTLS_AESCE_C /** * \def MBEDTLS_AES_C diff --git a/tools/build-android.sh b/tools/build-android.sh index cf7ed42..2175ea4 100755 --- a/tools/build-android.sh +++ b/tools/build-android.sh @@ -135,7 +135,12 @@ OUT="$ROOT/build-android/$ABI" mkdir -p "$OUT" echo "build-android: ABI=$ABI out=$OUT" -COMMON_FLAGS=(-std=c++17 -O2 -fPIC -fvisibility=hidden -fno-exceptions -fno-rtti +# Exceptions are enabled to match the host build: the hook chain wraps each +# handler in try/catch so one faulting handler is isolated and auto-disabled +# rather than taking the whole chain (and the game) down. Compiling this +# translation unit with -fno-exceptions fails outright, which is why the host +# build never set it; the Android build must agree. +COMMON_FLAGS=(-std=c++17 -O2 -fPIC -fvisibility=hidden -fno-rtti "-DBERRY_BUILD_SHARED" "-DBERRY_PLATFORM_ANDROID=1" "-I$ROOT/include" "-I$ROOT/third_party/ed25519" "-I$ROOT/third_party/mbedtls/include" diff --git a/tools/check-mbedtls.py b/tools/check-mbedtls.py index a156b0d..b1ef146 100755 --- a/tools/check-mbedtls.py +++ b/tools/check-mbedtls.py @@ -86,6 +86,16 @@ ] +# Config options that pull in a source unit this tree does not vendor. Each must +# stay OFF, or the build references symbols that do not exist. AESCE is the case +# that bit us: `aes.c` guards its Armv8-A fast path on `MBEDTLS_AESCE_C`, but +# `library/aesce.c` is not vendored, so an arm64 build links with undefined +# `mbedtls_aesce_*` references. +UNVENDORED_FEATURES = [ + "MBEDTLS_AESCE_C", +] + + def defines(text: str) -> set[str]: return set(re.findall(r"^#define (MBEDTLS_[A-Z0-9_]+)", text, re.M)) @@ -126,6 +136,11 @@ def check_config(problems: list[str]) -> None: if feat in have: problems.append( f"{feat} is on; the trimmed client-only config is not applied") + for feat in UNVENDORED_FEATURES: + if feat in have: + problems.append( + f"{feat} is on but its source unit is not vendored; " + f"the build will reference missing symbols") def check_units(problems: list[str]) -> None: diff --git a/tools/sources.mk b/tools/sources.mk index fbf27a5..8a96c18 100644 --- a/tools/sources.mk +++ b/tools/sources.mk @@ -43,6 +43,7 @@ CORE_SRC := \ core/modules/cosmetic_assets.cpp \ core/modules/cosmetic_device.cpp \ core/modules/cosmetic_gles.cpp \ + core/modules/cosmetic_c.cpp \ core/modules/shader.cpp \ core/modules/governor.cpp \ core/modules/governor_c.cpp \ @@ -269,6 +270,7 @@ TEST_SRC := \ tests/test_api_bindings.cpp \ tests/test_cosmetic.cpp \ tests/test_cosmetic_assets.cpp \ + tests/test_cosmetic_c.cpp \ tests/test_cosmetic_device.cpp \ tests/test_client_lan.cpp \ tests/test_pack.cpp \