Skip to content

File plugin.cpp

File List > plugins > quest3 > unity > plugin.cpp

Go to the documentation of this file

#include "plugin.hpp"

#include "illixr/data_format/serialization/query_response.hpp"
#include "illixr/data_format/serialization/voice_query.hpp"
#include "illixr/error_util.hpp"

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace ILLIXR;
using namespace ILLIXR::data_format;

// Forward declaration so destroy_openxr() and _p_one_iteration() can reference
// this before acquire_depth_unity_thread() is defined.
static xr_sensor_capture* g_sensor_capture_instance = nullptr;

// Static members for Unity plugin interface.
IUnityInterfaces*     xr_sensor_capture::s_unity_interfaces_ = nullptr;
IUnityGraphicsVulkan* xr_sensor_capture::s_vk_interface_     = nullptr;

// ---------------------------------------------------------------------------
// Unity plugin lifecycle - called by Unity when the native library is loaded.
// Must be exported so Unity can find it by name.
// ---------------------------------------------------------------------------

void xr_sensor_capture::set_unity_interfaces(IUnityInterfaces* interfaces) {
    s_unity_interfaces_ = interfaces;
    if (interfaces != nullptr) {
        s_vk_interface_ = interfaces->Get<IUnityGraphicsVulkan>();
        if (s_vk_interface_ == nullptr) {
            __android_log_print(ANDROID_LOG_ERROR, "xr_sensor_capture",
                                "IUnityGraphicsVulkan not available - is Unity using Vulkan?");
        } else {
            __android_log_print(ANDROID_LOG_INFO, "xr_sensor_capture", "IUnityGraphicsVulkan acquired");
        }
    }
}

// ---------------------------------------------------------------------------
// Render thread callback - fired via GL.IssuePluginEvent from C#.
//
// This runs on Unity's render thread where the Vulkan device is current and
// safe to use. Event IDs:
//   EVENT_INIT   (0): acquire IUnityGraphicsVulkan and init_vulkan()
//   EVENT_UNINIT (1): destroy_vulkan() and release interface
//   EVENT_ACQUIRE(2): submit depth readback and release depth image
// ---------------------------------------------------------------------------
static constexpr int EVENT_INIT    = 0;
static constexpr int EVENT_UNINIT  = 1;
static constexpr int EVENT_ACQUIRE = 2;

static void UNITY_INTERFACE_API on_render_event(int event_id) {
    if (event_id == EVENT_INIT) {
        if (ILLIXR::xr_sensor_capture::s_unity_interfaces_ == nullptr) {
            __android_log_print(ANDROID_LOG_ERROR, "xr_sensor_capture", "on_render_event(INIT): s_unity_interfaces_ is null");
            return;
        }
        ILLIXR::xr_sensor_capture::s_vk_interface_ =
            ILLIXR::xr_sensor_capture::s_unity_interfaces_->Get<IUnityGraphicsVulkan>();
        if (ILLIXR::xr_sensor_capture::s_vk_interface_ == nullptr) {
            __android_log_print(ANDROID_LOG_ERROR, "xr_sensor_capture",
                                "on_render_event(INIT): IUnityGraphicsVulkan not available");
            return;
        }
        if (g_sensor_capture_instance != nullptr) {
            if (g_sensor_capture_instance->init_vulkan()) {
                __android_log_print(ANDROID_LOG_INFO, "xr_sensor_capture",
                                    "on_render_event(INIT): Vulkan resources initialized");
            }
        } else {
            // Plugin not yet constructed - interfaces stored, init_vulkan()
            // will be called when the plugin constructs and finds s_vk_interface_ set.
            __android_log_print(ANDROID_LOG_INFO, "xr_sensor_capture",
                                "on_render_event(INIT): interfaces stored, waiting for plugin construction");
        }
    } else if (event_id == EVENT_UNINIT) {
        if (g_sensor_capture_instance != nullptr)
            g_sensor_capture_instance->destroy_vulkan();
        ILLIXR::xr_sensor_capture::s_vk_interface_ = nullptr;
    } else if (event_id == EVENT_ACQUIRE) {
        // submit_depth_readback runs on the render thread where Unity's
        // graphics queue is exclusively owned - safe to call vkQueueSubmit.
        // acquire_depth_unity_thread() already ran on the main thread via
        // illixr_acquire_depth() and stored the pending readback parameters.
        if (g_sensor_capture_instance != nullptr) {
            g_sensor_capture_instance->submit_depth_readback();
            // Release must happen before xrEndFrame - do it here
            // while still on the render thread after submit completes.
            g_sensor_capture_instance->release_depth_after_submit();
        }
    }
}

extern "C" {
// Called by Unity at library load time - stores the interfaces pointer so
// on_render_event can retrieve IUnityGraphicsVulkan on the render thread.
UNITY_INTERFACE_EXPORT void UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* interfaces) {
    xr_sensor_capture::set_unity_interfaces(interfaces);
}

UNITY_INTERFACE_EXPORT void UNITY_INTERFACE_API UnityPluginUnload() {
    xr_sensor_capture::set_unity_interfaces(nullptr);
}

// Returns the render event callback pointer for GL.IssuePluginEvent.
// C# calls this once and caches the result.
UNITY_INTERFACE_EXPORT UnityRenderingEvent UNITY_INTERFACE_API illixr_get_render_event_callback() {
    return on_render_event;
}

void UNITY_INTERFACE_API illixr_release_depth() {
    if (g_sensor_capture_instance != nullptr)
        g_sensor_capture_instance->release_depth_after_submit();
}

int64_t UNITY_INTERFACE_API illixr_get_last_capture_time_ns() {
    if (g_sensor_capture_instance == nullptr)
        return 0;
    std::lock_guard<std::mutex> lock(g_sensor_capture_instance->capture_result_mutex_);
    // Find most recent valid capture time
    XrTime best = 0;
    for (const auto& e : g_sensor_capture_instance->capture_result_cache_) {
        if (e.valid && e.capture_time > best)
            best = e.capture_time;
    }
    return static_cast<int64_t>(best);
}

double UNITY_INTERFACE_API illixr_get_last_capture_ovr_time_sec() {
    if (g_sensor_capture_instance == nullptr)
        return 0.0;
    std::lock_guard<std::mutex> lock(g_sensor_capture_instance->capture_result_mutex_);
    XrTime                      best = 0;
    for (const auto& e : g_sensor_capture_instance->capture_result_cache_) {
        if (e.valid && e.capture_time > best)
            best = e.capture_time;
    }
    if (best == 0)
        return 0.0;
    const double boottime_sec = static_cast<double>(best) * 1e-9;
    return boottime_sec + g_sensor_capture_instance->ovr_time_offset_sec_;
}
} // extern "C"

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

static XrTime clock_boottime_xr() {
    struct timespec ts{};
    clock_gettime(CLOCK_BOOTTIME, &ts);
    return static_cast<XrTime>(static_cast<int64_t>(ts.tv_sec) * 1'000'000'000LL + ts.tv_nsec);
}

// Convert an XrPosef to a row-major 4x4 float matrix.
static void pose_to_matrix(const XrPosef& pose, float out[16]) {
    const float qx = pose.orientation.x;
    const float qy = pose.orientation.y;
    const float qz = pose.orientation.z;
    const float qw = pose.orientation.w;
    const float tx = pose.position.x;
    const float ty = pose.position.y;
    const float tz = pose.position.z;

    out[0]  = 1.f - 2.f * (qy * qy + qz * qz);
    out[1]  = 2.f * (qx * qy - qw * qz);
    out[2]  = 2.f * (qx * qz + qw * qy);
    out[3]  = tx;
    out[4]  = 2.f * (qx * qy + qw * qz);
    out[5]  = 1.f - 2.f * (qx * qx + qz * qz);
    out[6]  = 2.f * (qy * qz - qw * qx);
    out[7]  = ty;
    out[8]  = 2.f * (qx * qz - qw * qy);
    out[9]  = 2.f * (qy * qz + qw * qx);
    out[10] = 1.f - 2.f * (qx * qx + qy * qy);
    out[11] = tz;
    out[12] = 0.f;
    out[13] = 0.f;
    out[14] = 0.f;
    out[15] = 1.f;
}

// ---------------------------------------------------------------------------
// Constructor / destructor
// ---------------------------------------------------------------------------

[[maybe_unused]] xr_sensor_capture::xr_sensor_capture(const std::string& name, phonebook* pb)
    : threadloop{name, pb}
    , switchboard_{phonebook_->lookup_impl<switchboard>()}
    , writer_{switchboard_->get_network_writer<semantic_frame>("semantic_frame", {})} {
    uint8_t capture_fps = switchboard_->get_env_int("ILLIXR_CAPTURE_FPS", 2);
    int32_t bitrate_bps = switchboard_->get_env_int("ILLIXR_ENCODER_BITRATE_BPS", 5'000'000);
    max_depth_m_        = switchboard_->get_env_float("ILLIXR_CAPTURE_MAX_DEPTH", 0.f);
    tick_interval_ns_   = static_cast<int64_t>(1'000'000'000LL / capture_fps);

    struct timespec mono{}, boot{};
    clock_gettime(CLOCK_MONOTONIC, &mono);
    clock_gettime(CLOCK_BOOTTIME, &boot);
    clock_offset_ns_ = (static_cast<int64_t>(boot.tv_sec) * 1'000'000'000LL + boot.tv_nsec) -
        (static_cast<int64_t>(mono.tv_sec) * 1'000'000'000LL + mono.tv_nsec);

    spdlog::get("illixr")->info("xr_sensor_capture init: fps={} bitrate={} max_depth={}", capture_fps, bitrate_bps,
                                max_depth_m_);

    // Use init_failed_ instead of throwing - throwing from a threadloop-derived
    // constructor triggers the threadloop destructor assertion because the
    // stoplight was never started. Degrade gracefully and no-op in _p_should_skip.
    if (!init_openxr()) {
        spdlog::get("illixr")->error("xr_sensor_capture: OpenXR init failed - plugin will no-op");
        init_failed_ = true;
        return;
    }

    // Lazily create and start the depth provider on first tick.
    // xrCreateEnvironmentDepthProviderMETA requires a running session -
    // Unity's session is already running by the time ILLIXR starts.
    if (depth_ext_available_ && depth_provider_ == XR_NULL_HANDLE) {
        XrEnvironmentDepthProviderCreateInfoMETA prov_ci{XR_TYPE_ENVIRONMENT_DEPTH_PROVIDER_CREATE_INFO_META};
        prov_ci.createFlags = 0;
        XrResult r          = xr_create_depth_provider_(xr_session_, &prov_ci, &depth_provider_);
        if (XR_FAILED(r)) {
            spdlog::get("illixr")->error("xrCreateEnvironmentDepthProviderMETA failed: {}", static_cast<int>(r));
            depth_ext_available_ = false;
        } else {
            xr_start_depth_provider_(depth_provider_);
            spdlog::get("illixr")->info("Depth provider created and started");
        }
    }

    // Vulkan init: if IUnityGraphicsVulkan is already available (render event
    // fired before this constructor ran), init now. Otherwise on_render_event
    // will call init_vulkan() when it fires. Either way, acquire_depth_unity_thread()
    // checks vk_device_ != VK_NULL_HANDLE before proceeding.
    if (s_vk_interface_ != nullptr) {
        if (!init_vulkan()) {
            spdlog::get("illixr")->error("xr_sensor_capture: Vulkan init failed - depth will be unavailable");
            // Do not set init_failed_ - RGB capture still works without depth.
        }
    } else {
        spdlog::get("illixr")->warn("xr_sensor_capture: IUnityGraphicsVulkan not yet available - "
                                    "depth init deferred to render event callback");
    }

    // Encoder dimensions - must match what Camera2 delivers.
    // Quest 3 back camera common resolutions: 1280x960, 1920x1440, 2560x1920.
    // Override via env vars if the default does not match hardware.
    const int32_t camera_w = switchboard_->get_env_int("ILLIXR_CAMERA_WIDTH", 960);
    const int32_t camera_h = switchboard_->get_env_int("ILLIXR_CAMERA_HEIGHT", 960);
    spdlog::get("illixr")->info("xr_sensor_capture: encoder target {}x{} "
                                "(set ILLIXR_CAMERA_WIDTH/HEIGHT to override)",
                                camera_w, camera_h);

    encoder_ = std::make_unique<ndk_encoder>(camera_w, camera_h, bitrate_bps, capture_fps);
    if (!encoder_->initialize(switchboard_->get_env_int("ILLIXR_ENCODER_IFRAME_SEC", 1))) {
        spdlog::get("illixr")->error("xr_sensor_capture: encoder init failed - plugin will no-op");
        init_failed_ = true;
        return;
    }

    if (!init_camera()) {
        spdlog::get("illixr")->error("xr_sensor_capture: Camera2 init failed - plugin will no-op");
        init_failed_ = true;
        return;
    }

    // Register instance pointer for C-linkage functions and render event callback.
    g_sensor_capture_instance = this;
}

xr_sensor_capture::~xr_sensor_capture() {
    destroy_camera();
    encoder_->destroy_encoder();
    destroy_vulkan();
    destroy_openxr();
}

void xr_sensor_capture::release_depth_after_submit() {
    // Must be called on Unity's main thread - same thread that called
    // xrAcquireEnvironmentDepthImageMETA - before xrEndFrame closes the
    // frame. GL.IssuePluginEvent(EVENT_ACQUIRE) in LateUpdate() is
    // synchronous: the render thread finishes submit_depth_readback()
    // (including vkWaitForFences) before this returns to C#, so the
    // GPU readback is complete before we release the swapchain image.
    if (!needs_depth_release_)
        return;

    if (xr_release_depth_image_ != nullptr) {
        XrResult result = xr_release_depth_image_(depth_provider_);
        if (XR_FAILED(result)) {
            spdlog::get("illixr")->warn("xrReleaseEnvironmentDepthImageMETA failed: {}", static_cast<int>(result));
        }
    }
    needs_depth_release_ = false;
}

// ---------------------------------------------------------------------------
// OpenXR
// ---------------------------------------------------------------------------

bool xr_sensor_capture::init_openxr() {
    // Reuse Unity's existing XrInstance and XrSession if available.
    // The Quest 3 OpenXR loader does not support simultaneous XrInstances,
    // so creating our own would fail with XR_ERROR_LIMIT_REACHED (-10).
    // ILLIXRXrHandleProvider (C#) captures Unity's handles via OpenXRFeature
    // callbacks and passes them here via env vars before illixr_unity_init().
    const char* inst_str = getenv("ILLIXR_XR_INSTANCE");
    const char* sess_str = getenv("ILLIXR_XR_SESSION");

    if (inst_str && *inst_str && sess_str && *sess_str) {
        xr_instance_ = reinterpret_cast<XrInstance>(std::stoull(inst_str));
        xr_session_  = reinterpret_cast<XrSession>(std::stoull(sess_str));
        owns_xr_     = false;
        spdlog::get("illixr")->info("Reusing Unity XrInstance={} XrSession={}", reinterpret_cast<void*>(xr_instance_),
                                    reinterpret_cast<void*>(xr_session_));
    } else {
        spdlog::get("illixr")->error("ILLIXR_XR_INSTANCE / ILLIXR_XR_SESSION not set.");
        return false;
    }

    XrReferenceSpaceCreateInfo space_ci{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
    space_ci.poseInReferenceSpace = {{0.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 0.f}};

    // The RGB pose arrives from Unity's ovrp_GetNodePoseStateAtTime, which reports in
    // OVR floor-level tracking space: origin on the floor under the headset at app
    // start / last recenter. XR_EXT_local_floor is that same space. STAGE is NOT — it
    // is anchored to the centre of the Guardian play area, so it differs by a yaw and
    // a horizontal offset (measured 30.7 deg / 1.38 m in quest/dataset_0).
    space_ci.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR_EXT;
    if (xrCreateReferenceSpace(xr_session_, &space_ci, &local_space_) != XR_SUCCESS) {
        spdlog::get("illixr")->error("LOCAL_FLOOR unavailable — XR_EXT_local_floor is not enabled on Unity's "
                                     "XrInstance. Depth poses would land in a different space than the RGB pose; "
                                     "refusing to publish misaligned depth.");
        local_space_ = XR_NULL_HANDLE;
        return false;
    }
    // Load all XR_META_environment_depth entry points from openxr.h PFN types.
    // These are part of the standard Khronos SDK - no Meta SDK headers needed.
    auto load = [&](const char* fname, PFN_xrVoidFunction* out) {
        return xrGetInstanceProcAddr(xr_instance_, fname, out) == XR_SUCCESS;
    };

    bool ok = true;
    ok &= load("xrCreateEnvironmentDepthProviderMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_create_depth_provider_));
    ok &= load("xrDestroyEnvironmentDepthProviderMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_destroy_depth_provider_));
    ok &= load("xrStartEnvironmentDepthProviderMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_start_depth_provider_));
    ok &= load("xrCreateEnvironmentDepthSwapchainMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_create_depth_swapchain_));
    ok &= load("xrDestroyEnvironmentDepthSwapchainMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_destroy_depth_swapchain_));
    ok &= load("xrEnumerateEnvironmentDepthSwapchainImagesMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_enum_depth_images_));
    ok &= load("xrGetEnvironmentDepthSwapchainStateMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_get_depth_state_));
    ok &= load("xrAcquireEnvironmentDepthImageMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_acquire_depth_image_));

    // xrReleaseEnvironmentDepthImageMETA is absent from the entry point map on
    // some Quest 3 firmware versions. Treat as non-fatal.
    load("xrReleaseEnvironmentDepthImageMETA", reinterpret_cast<PFN_xrVoidFunction*>(&xr_release_depth_image_));
    if (xr_release_depth_image_ == nullptr) {
        spdlog::get("illixr")->warn("xrReleaseEnvironmentDepthImageMETA unavailable - "
                                    "relying on runtime auto-release");
    }

    if (!ok) {
        spdlog::get("illixr")->warn("XR_META_environment_depth entry points missing - depth disabled");
        depth_ext_available_ = false;
    } else {
        depth_ext_available_ = true;
        spdlog::get("illixr")->info("XR_META_environment_depth loaded");
    }

    return true;
}

void xr_sensor_capture::destroy_openxr() {
    if (depth_swapchain_ != XR_NULL_HANDLE && xr_destroy_depth_swapchain_) {
        xr_destroy_depth_swapchain_(depth_swapchain_);
        depth_swapchain_ = XR_NULL_HANDLE;
    }
    if (depth_provider_ != XR_NULL_HANDLE && xr_destroy_depth_provider_) {
        xr_destroy_depth_provider_(depth_provider_);
        depth_provider_ = XR_NULL_HANDLE;
    }
    // Reference spaces are always ours to destroy.
    if (local_space_ != XR_NULL_HANDLE) {
        xrDestroySpace(local_space_);
        local_space_ = XR_NULL_HANDLE;
    }
    // Clear the C-linkage instance pointer so illixr_acquire_depth() no-ops
    // after shutdown.
    g_sensor_capture_instance = nullptr;

    // Only destroy the session and instance if we created them.
    // When reusing Unity's handles (owns_xr_ == false), Unity owns their lifetime.
    if (owns_xr_) {
        if (xr_session_ != XR_NULL_HANDLE) {
            xrDestroySession(xr_session_);
            xr_session_ = XR_NULL_HANDLE;
        }
        if (xr_instance_ != XR_NULL_HANDLE) {
            xrDestroyInstance(xr_instance_);
            xr_instance_ = XR_NULL_HANDLE;
        }
    } else {
        xr_session_  = XR_NULL_HANDLE;
        xr_instance_ = XR_NULL_HANDLE;
    }
}

// ---------------------------------------------------------------------------
// Vulkan depth readback - init/destroy
// ---------------------------------------------------------------------------

bool xr_sensor_capture::init_vulkan() {
    if (s_vk_interface_ == nullptr) {
        spdlog::get("illixr")->error("init_vulkan: IUnityGraphicsVulkan not available");
        return false;
    }

    // Get Unity's Vulkan instance struct (device, physicalDevice, queue, etc.)
    UnityVulkanInstance vk = s_vk_interface_->Instance();
    vk_device_             = vk.device;
    vk_physical_           = vk.physicalDevice;
    vk_queue_              = vk.graphicsQueue;
    vk_queue_family_       = vk.queueFamilyIndex;

    if (vk_device_ == VK_NULL_HANDLE) {
        spdlog::get("illixr")->error("init_vulkan: VkDevice is null");
        return false;
    }

    // Command pool
    VkCommandPoolCreateInfo pool_ci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
    pool_ci.queueFamilyIndex = vk_queue_family_;
    pool_ci.flags            = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
    if (vkCreateCommandPool(vk_device_, &pool_ci, nullptr, &vk_cmd_pool_) != VK_SUCCESS) {
        spdlog::get("illixr")->error("init_vulkan: vkCreateCommandPool failed");
        return false;
    }

    // Command buffer
    VkCommandBufferAllocateInfo alloc_info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
    alloc_info.commandPool        = vk_cmd_pool_;
    alloc_info.level              = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    alloc_info.commandBufferCount = 1;
    if (vkAllocateCommandBuffers(vk_device_, &alloc_info, &vk_cmd_buf_) != VK_SUCCESS) {
        spdlog::get("illixr")->error("init_vulkan: vkAllocateCommandBuffers failed");
        return false;
    }

    // Fence starts signalled so the first vkWaitForFences returns immediately.
    VkFenceCreateInfo fence_ci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
    fence_ci.flags = VK_FENCE_CREATE_SIGNALED_BIT;
    if (vkCreateFence(vk_device_, &fence_ci, nullptr, &vk_fence_) != VK_SUCCESS) {
        spdlog::get("illixr")->error("init_vulkan: vkCreateFence failed");
        return false;
    }

    // Staging buffer - sized for depth frame, allocated lazily when dimensions known.
    // Actual allocation deferred to first acquire when depth_swapchain_width_ is set.
    spdlog::get("illixr")->info("init_vulkan: Vulkan readback resources created");
    return true;
}

void xr_sensor_capture::destroy_vulkan() {
    if (vk_device_ == VK_NULL_HANDLE)
        return;
    vkDeviceWaitIdle(vk_device_);
    if (vk_staging_buf_ != VK_NULL_HANDLE) {
        vkDestroyBuffer(vk_device_, vk_staging_buf_, nullptr);
        vk_staging_buf_ = VK_NULL_HANDLE;
    }
    if (vk_staging_mem_ != VK_NULL_HANDLE) {
        vkFreeMemory(vk_device_, vk_staging_mem_, nullptr);
        vk_staging_mem_ = VK_NULL_HANDLE;
    }
    if (vk_fence_ != VK_NULL_HANDLE) {
        vkDestroyFence(vk_device_, vk_fence_, nullptr);
        vk_fence_ = VK_NULL_HANDLE;
    }
    if (vk_cmd_pool_ != VK_NULL_HANDLE) {
        vkDestroyCommandPool(vk_device_, vk_cmd_pool_, nullptr);
        vk_cmd_pool_ = VK_NULL_HANDLE;
    }
    vk_device_ = VK_NULL_HANDLE;
}

uint32_t xr_sensor_capture::find_memory_type(uint32_t type_filter, VkMemoryPropertyFlags props) const {
    VkPhysicalDeviceMemoryProperties mem_props{};
    vkGetPhysicalDeviceMemoryProperties(vk_physical_, &mem_props);
    for (uint32_t i = 0; i < mem_props.memoryTypeCount; ++i) {
        if ((type_filter & (1u << i)) && (mem_props.memoryTypes[i].propertyFlags & props) == props)
            return i;
    }
    spdlog::get("illixr")->error("find_memory_type: no suitable memory type found");
    return UINT32_MAX;
}

// ---------------------------------------------------------------------------
// Camera2 capture completed callback
//
// Fires on the Camera2 callback thread immediately after each sensor exposure.
// Snapshots latest_head_pose_ (written by acquire_depth_unity_thread on Unity's
// main thread) into capture_result_cache_ under its own mutex. This gives each
// encoded frame the head pose that was current at sensor exposure time.
// ---------------------------------------------------------------------------

static void on_capture_completed(void* ctx, ACameraCaptureSession* /*session*/, ACaptureRequest* /*request*/,
                                 const ACameraMetadata* /*result*/) {
    auto* self = static_cast<xr_sensor_capture*>(ctx);

    // Snapshot the latest head pose sampled by acquire_depth_unity_thread().
    // The mutex is held only for a 16-float memcpy so contention is negligible.
    float pose[16]{};
    bool  pose_valid = false;
    {
        std::lock_guard<std::mutex> lock(self->latest_head_pose_mutex_);
        if (self->latest_head_pose_.valid) {
            std::memcpy(pose, self->latest_head_pose_.pose, sizeof(pose));
            pose_valid = true;
        }
    }

    if (!pose_valid) {
        spdlog::get("illixr")->warn("[capture_cb] no head pose available yet - skipping");
        return;
    }

    {
        std::lock_guard<std::mutex> lock(self->capture_result_mutex_);
        auto&                       slot = self->capture_result_cache_[self->capture_result_next_];
        slot.capture_time                = clock_boottime_xr();
        slot.valid                       = true;
        std::memcpy(slot.pose, pose, sizeof(pose));
        self->capture_result_next_ = (self->capture_result_next_ + 1) % xr_sensor_capture::CAPTURE_RESULT_CACHE_SIZE;
    }
}

static void on_capture_failed(void* /*ctx*/, ACameraCaptureSession* /*session*/, ACaptureRequest* /*request*/,
                              ACameraCaptureFailure* failure) {
    __android_log_print(ANDROID_LOG_WARN, "xr_sensor_capture", "Capture failed: reason=%d frameNumber=%" PRId64,
                        failure->reason, failure->frameNumber);
}

void xr_sensor_capture::acquire_depth_unity_thread(int64_t predicted_display_time_ns, double ovr_plugin_time_sec,
                                                   const float* rgb_camera_pose_lh, const float* head_pose_lh,
                                                   const float* tracking_to_world_lh) {
    // Compute offset between OVRPlugin time and CLOCK_BOOTTIME every call.
    // OVRPlugin time is what ovrp_GetNodePoseStateAtTime expects.
    // This offset lets C++ store capture times in OVRPlugin seconds.
    const double boottime_sec = static_cast<double>(clock_boottime_xr()) * 1e-9;
    ovr_time_offset_sec_      = ovr_plugin_time_sec - boottime_sec;

    const XrTime frame_time = static_cast<XrTime>(predicted_display_time_ns);

    const float* p = rgb_camera_pose_lh;
    spdlog::get("illixr")->debug("[acquire_depth] rgb_pose_lh col0=({:.3f},{:.3f},{:.3f},{:.3f})"
                                 " col1=({:.3f},{:.3f},{:.3f},{:.3f})"
                                 " col2=({:.3f},{:.3f},{:.3f},{:.3f})"
                                 " col3=({:.3f},{:.3f},{:.3f},{:.3f})",
                                 p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8], p[9], p[10], p[11], p[12], p[13], p[14],
                                 p[15]);
    // ---- 1. Store RGB camera pose (every call, 90Hz) ----
    // The poses arrive from Unity in left-handed world space (same convention
    // as StreamingOrchestrator.cs). Convert to right-handed to match the
    // OpenXR convention used everywhere else in this plugin.
    // LhToRh mirrors GrpcFramesClient.BuildMessage:
    //   m.m02 -> -m.m02, m.m12 -> -m.m12,
    //   m.m20 -> -m.m20, m.m21 -> -m.m21, m.m23 -> -m.m23,
    //   m.m32 -> -m.m32
    // Unity Matrix4x4 is stored column-major in memory:
    //   index = col*4 + row, so m.mRC = ptr[C*4+R]
    // Output is row-major float[16] as used throughout this plugin.
    {
        const float* p = rgb_camera_pose_lh;
        float        rh[16];
        rh[0]  = p[0];
        rh[1]  = p[4];
        rh[2]  = -p[8];
        rh[3]  = p[12];
        rh[4]  = p[1];
        rh[5]  = p[5];
        rh[6]  = -p[9];
        rh[7]  = p[13];
        rh[8]  = -p[2];
        rh[9]  = -p[6];
        rh[10] = p[10];
        rh[11] = -p[14];
        rh[12] = p[3];
        rh[13] = p[7];
        rh[14] = -p[11];
        rh[15] = p[15];

        std::lock_guard<std::mutex> lock(latest_head_pose_mutex_);
        std::memcpy(latest_head_pose_.pose, rh, sizeof(rh));
        latest_head_pose_.valid = true;
    }

    // ---- 2. Depth acquisition (throttled to ~10fps) ----
    if (!depth_ext_available_ || depth_provider_ == XR_NULL_HANDLE)
        return;

    // Lazy swapchain init - deferred to here so Unity's session is running.
    if (depth_swapchain_ == XR_NULL_HANDLE) {
        XrEnvironmentDepthSwapchainCreateInfoMETA sc_ci{XR_TYPE_ENVIRONMENT_DEPTH_SWAPCHAIN_CREATE_INFO_META};
        sc_ci.createFlags = 0;
        XrResult result   = xr_create_depth_swapchain_(depth_provider_, &sc_ci, &depth_swapchain_);
        if (XR_FAILED(result)) {
            spdlog::get("illixr")->error("xrCreateEnvironmentDepthSwapchainMETA failed: {}", static_cast<int>(result));
            return;
        }

        XrEnvironmentDepthSwapchainStateMETA state{XR_TYPE_ENVIRONMENT_DEPTH_SWAPCHAIN_STATE_META};
        xr_get_depth_state_(depth_swapchain_, &state);
        depth_swapchain_width_  = static_cast<int32_t>(state.width);
        depth_swapchain_height_ = static_cast<int32_t>(state.height);
        spdlog::get("illixr")->info("Depth swapchain: {}x{}", state.width, state.height);

        uint32_t img_count = 0;
        xr_enum_depth_images_(depth_swapchain_, 0, &img_count, nullptr);

        std::vector<XrSwapchainImageVulkanKHR> images(img_count, {XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR});
        xr_enum_depth_images_(depth_swapchain_, img_count, &img_count,
                              reinterpret_cast<XrSwapchainImageBaseHeader*>(images.data()));

        depth_vk_images_.resize(img_count);
        for (uint32_t i = 0; i < img_count; ++i)
            depth_vk_images_[i] = images[i].image;

        spdlog::get("illixr")->info("Depth swapchain: {} VkImage slots", img_count);

        if (img_count > 0 && vk_device_ != VK_NULL_HANDLE) {
            spdlog::get("illixr")->info("Depth VkImage[0] = {:p}", static_cast<void*>(depth_vk_images_[0]));
        }

        // Allocate staging buffer. R16_UNORM = 2 bytes/pixel.
        vk_staging_size_ = static_cast<VkDeviceSize>(depth_swapchain_width_ * depth_swapchain_height_ * 2);

        VkBufferCreateInfo buf_ci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
        buf_ci.size        = vk_staging_size_;
        buf_ci.usage       = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
        buf_ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        vkCreateBuffer(vk_device_, &buf_ci, nullptr, &vk_staging_buf_);

        VkMemoryRequirements mem_req{};
        vkGetBufferMemoryRequirements(vk_device_, vk_staging_buf_, &mem_req);

        VkMemoryAllocateInfo alloc_info{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
        alloc_info.allocationSize  = mem_req.size;
        alloc_info.memoryTypeIndex = find_memory_type(
            mem_req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);

        vkAllocateMemory(vk_device_, &alloc_info, nullptr, &vk_staging_mem_);
        vkBindBufferMemory(vk_device_, vk_staging_buf_, vk_staging_mem_, 0);
        spdlog::get("illixr")->info("Depth staging buffer allocated: {} bytes", vk_staging_size_);
    }

    XrEnvironmentDepthImageAcquireInfoMETA acq_info{XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_ACQUIRE_INFO_META};
    acq_info.space       = local_space_;
    acq_info.displayTime = frame_time;

    XrEnvironmentDepthImageMETA depth_image{XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_META};
    depth_image.views[0] = {XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_VIEW_META};
    depth_image.views[1] = {XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_VIEW_META};

    XrResult result = xr_acquire_depth_image_(depth_provider_, &acq_info, &depth_image);
    if (result == XR_ENVIRONMENT_DEPTH_NOT_AVAILABLE_META)
        return;
    if (XR_FAILED(result)) {
        if (static_cast<int>(result) != -37) {
            spdlog::get("illixr")->warn("xrAcquireEnvironmentDepthImageMETA failed: {}", static_cast<int>(result));
        }
        return;
    }

    const XrEnvironmentDepthImageViewMETA& view = depth_image.views[0];

    const float tan_left  = std::tan(view.fov.angleLeft);
    const float tan_right = std::tan(view.fov.angleRight);
    const float tan_top   = std::tan(view.fov.angleUp);
    const float tan_down  = std::tan(view.fov.angleDown);

    const float w_f      = static_cast<float>(depth_swapchain_width_);
    const float h_f      = static_cast<float>(depth_swapchain_height_);
    const float abs_left = std::abs(tan_left);
    const float abs_top  = std::abs(tan_top);

    camera_intrinsics intr{};
    intr.fx     = w_f / (std::abs(tan_right) + abs_left);
    intr.fy     = h_f / (std::abs(tan_top) + std::abs(tan_down));
    intr.cx     = abs_left * intr.fx;
    intr.cy     = abs_top * intr.fy;
    intr.width  = depth_swapchain_width_;
    intr.height = depth_swapchain_height_;

    // Depth pose from the acquire result is in OpenXR LOCAL/STAGE space (RH).
    // Convert to the same RH convention as rgb_camera_pose for consistency.
    float pose_mat[16]{};
    pose_to_matrix(view.pose, pose_mat);

    needs_depth_release_ = true;

    {
        std::lock_guard<std::mutex> lock(pending_readback_mutex_);
        pending_readback_.image      = depth_vk_images_[depth_image.swapchainIndex];
        pending_readback_.width      = intr.width;
        pending_readback_.height     = intr.height;
        pending_readback_.intrinsics = intr;
        pending_readback_.near_z     = depth_image.nearZ;
        pending_readback_.far_z      = depth_image.farZ;
        pending_readback_.timestamp  = acq_info.displayTime;
        std::memcpy(pending_readback_.pose, pose_mat, sizeof(pose_mat));
        pending_readback_.valid = true;
    }
}

void xr_sensor_capture::submit_depth_readback() {
    pending_readback rb;
    {
        std::lock_guard<std::mutex> lock(pending_readback_mutex_);
        if (!pending_readback_.valid)
            return;
        rb                      = pending_readback_;
        pending_readback_.valid = false;
    }

    if (vk_staging_buf_ == VK_NULL_HANDLE) {
        spdlog::get("illixr")->error("submit_depth_readback: staging buffer not allocated");
        return;
    }

    const VkImage src_image = rb.image;
    const int32_t w         = rb.width;
    const int32_t h         = rb.height;

    // Wait for previous submission to finish, then reset fence and cmd buffer.
    vkWaitForFences(vk_device_, 1, &vk_fence_, VK_TRUE, UINT64_MAX);
    vkResetFences(vk_device_, 1, &vk_fence_);
    vkResetCommandBuffer(vk_cmd_buf_, 0);

    // Record copy commands.
    VkCommandBufferBeginInfo begin_info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
    begin_info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    vkBeginCommandBuffer(vk_cmd_buf_, &begin_info);

    // Transition: GENERAL -> TRANSFER_SRC_OPTIMAL
    VkImageMemoryBarrier barrier_to_src{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
    barrier_to_src.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
        VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT;
    barrier_to_src.dstAccessMask       = VK_ACCESS_TRANSFER_READ_BIT;
    barrier_to_src.oldLayout           = VK_IMAGE_LAYOUT_GENERAL;
    barrier_to_src.newLayout           = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
    barrier_to_src.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier_to_src.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier_to_src.image               = src_image;
    barrier_to_src.subresourceRange    = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
    vkCmdPipelineBarrier(vk_cmd_buf_, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0,
                         nullptr, 1, &barrier_to_src);

    // Copy image to buffer - R16F row-major, top-down.
    VkBufferImageCopy copy_region{};
    copy_region.bufferOffset      = 0;
    copy_region.bufferRowLength   = 0;
    copy_region.bufferImageHeight = 0;
    copy_region.imageSubresource  = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
    copy_region.imageOffset       = {0, 0, 0};
    copy_region.imageExtent       = {static_cast<uint32_t>(w), static_cast<uint32_t>(h), 1};
    vkCmdCopyImageToBuffer(vk_cmd_buf_, src_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, vk_staging_buf_, 1, &copy_region);

    // Transition back: TRANSFER_SRC_OPTIMAL -> GENERAL
    VkImageMemoryBarrier barrier_to_read{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
    barrier_to_read.srcAccessMask       = VK_ACCESS_TRANSFER_READ_BIT;
    barrier_to_read.dstAccessMask       = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT;
    barrier_to_read.oldLayout           = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
    barrier_to_read.newLayout           = VK_IMAGE_LAYOUT_GENERAL;
    barrier_to_read.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier_to_read.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier_to_read.image               = src_image;
    barrier_to_read.subresourceRange    = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
    vkCmdPipelineBarrier(vk_cmd_buf_, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0,
                         nullptr, 1, &barrier_to_read);

    vkEndCommandBuffer(vk_cmd_buf_);

    // Submit and wait.
    VkSubmitInfo submit_info{VK_STRUCTURE_TYPE_SUBMIT_INFO};
    submit_info.commandBufferCount = 1;
    submit_info.pCommandBuffers    = &vk_cmd_buf_;
    if (vkQueueSubmit(vk_queue_, 1, &submit_info, vk_fence_) != VK_SUCCESS) {
        spdlog::get("illixr")->error("vkQueueSubmit for depth readback failed");
        // Re-signal the fence so the next call's wait doesn't block forever.
        vkResetFences(vk_device_, 1, &vk_fence_);
        VkFenceCreateInfo fence_ci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
        fence_ci.flags = VK_FENCE_CREATE_SIGNALED_BIT;
        vkDestroyFence(vk_device_, vk_fence_, nullptr);
        vkCreateFence(vk_device_, &fence_ci, nullptr, &vk_fence_);
        return;
    }
    vkWaitForFences(vk_device_, 1, &vk_fence_, VK_TRUE, UINT64_MAX);

    // Map staging buffer, flip vertically while copying.
    // The Vulkan image is bottom-up (OpenGL convention); server expects top-down.
    void* mapped = nullptr;
    vkMapMemory(vk_device_, vk_staging_mem_, 0, vk_staging_size_, 0, &mapped);

    // Flip vertically — the Vulkan image is bottom-up from the OpenXR
    // depth provider, matching OpenGL convention. The server expects
    // top-down to match the RGB image orientation.
    std::vector<uint8_t> r16(static_cast<size_t>(w * h * 2));
    const auto*          src       = static_cast<const uint8_t*>(mapped);
    const size_t         row_bytes = static_cast<size_t>(w * 2);
    for (int r = 0; r < h; ++r) {
        const uint8_t* src_row = src + (h - 1 - r) * row_bytes;
        uint8_t*       dst_row = r16.data() + r * row_bytes;
        std::memcpy(dst_row, src_row, row_bytes);
    }

    vkUnmapMemory(vk_device_, vk_staging_mem_);

    // Log the first 4 pixels once to identify the actual pixel format.
    static bool format_logged = false;
    if (!format_logged && r16.size() >= 8) {
        format_logged       = true;
        const uint16_t* u16 = reinterpret_cast<const uint16_t*>(r16.data());
        spdlog::get("illixr")->info("Depth pixel format probe - raw uint16: [{}, {}, {}, {}] nearZ={} farZ={}", u16[0], u16[1],
                                    u16[2], u16[3], rb.near_z, rb.far_z);
    }
    const uint16_t* u16 = reinterpret_cast<const uint16_t*>(r16.data());
    // Throttle: only store every DEPTH_ACQUIRE_EVERY LateUpdate ticks.
    // This gives ~10fps depth at 72Hz Unity, providing a spread of
    // timestamps to match against 2fps RGB frames.
    {
        std::lock_guard<std::mutex> lock(depth_mutex_);

        if (rb.near_z <= 0.f)
            spdlog::get("illixr")->warn("submit_depth_readback: nearZ={} is zero or negative", rb.near_z);

        depth_acquire_counter_++;
        if (depth_acquire_counter_ >= DEPTH_ACQUIRE_EVERY) {
            depth_acquire_counter_ = 0;
            depth_frame_data& slot = depth_cache_[depth_cache_next_];
            slot.data              = std::move(r16);
            slot.intrinsics        = rb.intrinsics;
            slot.near_z            = rb.near_z;
            slot.far_z             = rb.far_z;
            slot.timestamp         = rb.timestamp;
            std::memcpy(slot.pose, rb.pose, sizeof(rb.pose));
            slot.valid        = true;
            depth_cache_next_ = (depth_cache_next_ + 1) % DEPTH_CACHE_SIZE;
            spdlog::get("illixr")->debug("[depth] cached slot={} ts={} near_z={:.3f}",
                                         (depth_cache_next_ + DEPTH_CACHE_SIZE - 1) % DEPTH_CACHE_SIZE, rb.timestamp,
                                         rb.near_z);
        }
    }
}

const xr_sensor_capture::depth_frame_data* xr_sensor_capture::find_closest_depth(XrTime rgb_ts) const {
    // Called with depth_mutex_ held.
    const depth_frame_data* best    = nullptr;
    int64_t                 best_dt = INT64_MAX;
    for (const auto& entry : depth_cache_) {
        if (!entry.valid)
            continue;
        int64_t dt = std::abs(static_cast<int64_t>(rgb_ts) - static_cast<int64_t>(entry.timestamp));
        if (dt < best_dt) {
            best_dt = dt;
            best    = &entry;
        }
    }
    return best;
}

extern "C" void illixr_acquire_depth(int64_t predicted_display_time_ns, double ovr_plugin_time_sec, float* rgb_camera_pose_lh,
                                     float* head_pose_lh, float* tracking_to_world_lh) {
    if (g_sensor_capture_instance != nullptr)
        g_sensor_capture_instance->acquire_depth_unity_thread(predicted_display_time_ns, ovr_plugin_time_sec,
                                                              rgb_camera_pose_lh, head_pose_lh, tracking_to_world_lh);
}

// ---------------------------------------------------------------------------
// Camera2
// ---------------------------------------------------------------------------

static void on_camera_disconnected(void*, ACameraDevice* dev) {
    __android_log_print(ANDROID_LOG_ERROR, "xr_sensor_capture", "Camera disconnected: %p", static_cast<void*>(dev));
}

static void on_camera_error(void*, ACameraDevice* dev, int error) {
    __android_log_print(ANDROID_LOG_ERROR, "xr_sensor_capture", "Camera error %d on %p", error, static_cast<void*>(dev));
}

static void on_session_active(void*, ACameraCaptureSession*) {
    spdlog::get("illixr")->info("Capture session active");
}

static void on_session_ready(void*, ACameraCaptureSession*) {
    spdlog::get("illixr")->info("Capture session ready");
}

static void on_session_closed(void*, ACameraCaptureSession*) {
    spdlog::get("illixr")->info("Capture session closed");
}

bool xr_sensor_capture::init_camera() {
    camera_mgr_ = ACameraManager_create();
    if (camera_mgr_ == nullptr) {
        spdlog::get("illixr")->error("ACameraManager_create failed");
        return false;
    }

    ACameraIdList* id_list = nullptr;
    if (ACameraManager_getCameraIdList(camera_mgr_, &id_list) != ACAMERA_OK || id_list == nullptr || id_list->numCameras == 0) {
        spdlog::get("illixr")->error("No cameras found");
        return false;
    }

    // Quest 3 passthrough cameras report as back-facing with non-standard IDs.
    // Camera '1' is front-facing (logical/virtual), '50' and '51' are the
    // physical left and right RGB passthrough cameras.
    const char* selected_id = id_list->cameraIds[0]; // fallback
    for (int i = 0; i < id_list->numCameras; ++i) {
        if (std::strcmp(id_list->cameraIds[i], "51") == 0) {
            selected_id = id_list->cameraIds[i];
            break;
        }
    }
    spdlog::get("illixr")->info("Selected camera id='{}'", selected_id);

    // Log all cameras and their facing values for diagnosis.
    for (int i = 0; i < id_list->numCameras; ++i) {
        ACameraMetadata*            meta = nullptr;
        ACameraMetadata_const_entry entry{};
        ACameraManager_getCameraCharacteristics(camera_mgr_, id_list->cameraIds[i], &meta);
        ACameraMetadata_getConstEntry(meta, ACAMERA_LENS_FACING, &entry);
        spdlog::get("illixr")->info("Camera[{}] id='{}' facing={}", i, id_list->cameraIds[i], (int) entry.data.u8[0]);
        ACameraMetadata_free(meta);
    }

    // Cache RGB intrinsics from camera characteristics.
    ACameraMetadata* meta = nullptr;
    ACameraManager_getCameraCharacteristics(camera_mgr_, selected_id, &meta);
    if (meta != nullptr) {
        ACameraMetadata_const_entry intr{};
        if (ACameraMetadata_getConstEntry(meta, ACAMERA_LENS_INTRINSIC_CALIBRATION, &intr) == ACAMERA_OK && intr.count >= 5) {
            rgb_intrinsics_.fx    = intr.data.f[0];
            rgb_intrinsics_.fy    = intr.data.f[1];
            rgb_intrinsics_.cx    = intr.data.f[2];
            rgb_intrinsics_.cy    = intr.data.f[3];
            rgb_intrinsics_valid_ = (rgb_intrinsics_.fx > 0.f);
        }
        ACameraMetadata_free(meta);
    }
    rgb_intrinsics_.width  = encoder_->width_;
    rgb_intrinsics_.height = encoder_->height_;

    ACameraDevice_StateCallbacks device_cbs{};
    device_cbs.context        = nullptr;
    device_cbs.onDisconnected = on_camera_disconnected;
    device_cbs.onError        = on_camera_error;
    if (ACameraManager_openCamera(camera_mgr_, selected_id, &device_cbs, &camera_device_) != ACAMERA_OK) {
        spdlog::get("illixr")->error("ACameraManager_openCamera failed");
        ACameraManager_deleteCameraIdList(id_list);
        return false;
    }
    ACameraManager_deleteCameraIdList(id_list);

    ACaptureSessionOutputContainer_create(&session_output_container_);
    ACaptureSessionOutput_create(encoder_->get_window(), &session_output_);
    ACaptureSessionOutputContainer_add(session_output_container_, session_output_);
    ACameraOutputTarget_create(encoder_->get_window(), &camera_output_target_);

    // Log supported output sizes to help diagnose encoder dimension mismatches.
    {
        ACameraMetadata* char_meta = nullptr;
        ACameraManager_getCameraCharacteristics(camera_mgr_, selected_id, &char_meta);
        if (char_meta != nullptr) {
            ACameraMetadata_const_entry sizes{};
            if (ACameraMetadata_getConstEntry(char_meta, ACAMERA_SCALER_AVAILABLE_STREAM_CONFIGURATIONS, &sizes) ==
                ACAMERA_OK) {
                spdlog::get("illixr")->info("Camera2 supported output sizes (format/w/h/input):");
                for (uint32_t i = 0; i + 3 < sizes.count; i += 4) {
                    if (sizes.data.i32[i + 3] == 0) {
                        spdlog::get("illixr")->info("  format=0x{:X} {}x{}", sizes.data.i32[i], sizes.data.i32[i + 1],
                                                    sizes.data.i32[i + 2]);
                    }
                }
            }
            ACameraMetadata_free(char_meta);
        }
    }

    ACameraDevice_createCaptureRequest(camera_device_, TEMPLATE_PREVIEW, &capture_request_);
    ACaptureRequest_addTarget(capture_request_, camera_output_target_);

    // Lock Camera2 to the configured capture rate.
    const int32_t fps_range[2] = {static_cast<int32_t>(encoder_->capture_fps_), static_cast<int32_t>(encoder_->capture_fps_)};
    ACaptureRequest_setEntry_i32(capture_request_, ACAMERA_CONTROL_AE_TARGET_FPS_RANGE, 2, fps_range);
    spdlog::get("illixr")->info("Camera2 FPS range set to [{}, {}]", fps_range[0], fps_range[1]);

    ACameraCaptureSession_stateCallbacks session_cbs{};
    session_cbs.context  = nullptr;
    session_cbs.onActive = on_session_active;
    session_cbs.onReady  = on_session_ready;
    session_cbs.onClosed = on_session_closed;

    if (ACameraDevice_createCaptureSession(camera_device_, session_output_container_, &session_cbs, &capture_session_) !=
        ACAMERA_OK) {
        spdlog::get("illixr")->error("ACameraDevice_createCaptureSession failed");
        return false;
    }

    // Register per-frame callback. on_capture_completed snapshots latest_head_pose_
    // at sensor exposure time - no xrLocateSpace call happens here.
    ACameraCaptureSession_captureCallbacks capture_cbs{};
    capture_cbs.context            = this;
    capture_cbs.onCaptureCompleted = on_capture_completed;
    capture_cbs.onCaptureFailed    = on_capture_failed;

    ACameraCaptureSession_setRepeatingRequest(capture_session_, &capture_cbs, 1, &capture_request_, nullptr);
    spdlog::get("illixr")->info("Camera2 -> encoder surface: {}x{}", encoder_->width_, encoder_->height_);
    return true;
}

void xr_sensor_capture::destroy_camera() {
    if (capture_session_ != nullptr) {
        ACameraCaptureSession_stopRepeating(capture_session_);
        ACameraCaptureSession_close(capture_session_);
        capture_session_ = nullptr;
    }
    if (capture_request_ != nullptr) {
        ACaptureRequest_free(capture_request_);
        capture_request_ = nullptr;
    }
    if (camera_output_target_ != nullptr) {
        ACameraOutputTarget_free(camera_output_target_);
        camera_output_target_ = nullptr;
    }
    if (session_output_ != nullptr) {
        ACaptureSessionOutput_free(session_output_);
        session_output_ = nullptr;
    }
    if (session_output_container_ != nullptr) {
        ACaptureSessionOutputContainer_free(session_output_container_);
        session_output_container_ = nullptr;
    }
    if (camera_device_ != nullptr) {
        ACameraDevice_close(camera_device_);
        camera_device_ = nullptr;
    }
    if (camera_mgr_ != nullptr) {
        ACameraManager_delete(camera_mgr_);
        camera_mgr_ = nullptr;
    }
}

// ---------------------------------------------------------------------------
// Threadloop
// ---------------------------------------------------------------------------

threadloop::skip_option xr_sensor_capture::_p_should_skip() {
    if (init_failed_)
        return skip_option::skip_and_yield;
    if (clock_boottime_xr() - last_tick_ns_ < tick_interval_ns_)
        return skip_option::skip_and_yield;
    return skip_option::run;
}

void xr_sensor_capture::_p_one_iteration() {
    last_tick_ns_ = clock_boottime_xr();

    // ---- Drain encoder output (RGB) ----
    encoder_->drain_encoder_output(clock_offset_ns_);

    // ---- Publish one semantic_frame per pending encoded RGB frame ----
    for (auto& rgb : encoder_->pending_frames_) {
        // Use the most recent capture result pose. on_capture_completed()
        // snapshots latest_head_pose_ (sampled at 90Hz from Unity's main
        // thread) at the moment of each sensor exposure, so this pose is
        // accurate to within one Unity frame (~11ms at 90Hz).
        float rgb_matrix[16]{};
        bool  have_pose = false;
        {
            std::lock_guard<std::mutex> lock(capture_result_mutex_);
            XrTime                      best_time = 0;
            for (const auto& e : capture_result_cache_) {
                if (e.valid && e.capture_time > best_time) {
                    best_time = e.capture_time;
                    std::memcpy(rgb_matrix, e.pose, sizeof(rgb_matrix));
                    have_pose = true;
                }
                spdlog::get("illixr")->info("[publish] fr frameame={} rgb_pose pos=({:.3f},{:.3f},{:.3f})", frame_number_,
                                            rgb_matrix[3], rgb_matrix[7], rgb_matrix[11]);
            }
        }

        if (!have_pose) {
            spdlog::get("illixr")->warn("[frame={}] no capture result pose yet, dropping", frame_number_);
            frame_number_++;
            continue;
        }

        // Find the closest depth frame in the cache by timestamp.
        const depth_frame_data* depth_snap = nullptr;
        {
            std::lock_guard<std::mutex> lock(depth_mutex_);
            depth_snap = find_closest_depth(rgb.timestamp);
        }

        if (depth_snap == nullptr) {
            spdlog::get("illixr")->warn("[frame={}] No depth in cache yet, dropping", frame_number_);
            frame_number_++;
            continue;
        }

        const int64_t delta_ns = std::abs(static_cast<int64_t>(rgb.timestamp) - static_cast<int64_t>(depth_snap->timestamp));
        spdlog::get("illixr")->debug("[frame={}] closest depth delta={}ms", frame_number_, delta_ns / 1'000'000LL);

        semantic_frame frame{};
        frame.frame_number     = frame_number_++;
        frame.image            = std::move(rgb.encoded);
        frame.intrinsics       = rgb_intrinsics_;
        frame.rgb_timestamp_ns = static_cast<int64_t>(rgb.timestamp);
        frame.max_depth        = max_depth_m_;
        std::memcpy(frame.rgb_camera_pose, rgb_matrix, sizeof(rgb_matrix));

        frame.depth              = depth_snap->data;
        frame.depth_near_z       = depth_snap->near_z;
        frame.depth_intrinsics   = depth_snap->intrinsics;
        frame.depth_timestamp_ns = static_cast<int64_t>(depth_snap->timestamp);
        std::memcpy(frame.depth_pose, depth_snap->pose, sizeof(depth_snap->pose));

        spdlog::get("illixr")->info("[publish] frame={} image={}B depth={}B depth_delta={}ms near_z={:.3f}", frame.frame_number,
                                    frame.image.size(), frame.depth.size(), delta_ns / 1'000'000LL, frame.depth_near_z);

        writer_.put(writer_.allocate<semantic_frame>(std::move(frame)));
    }

    encoder_->pending_frames_.clear();
}

// ---------------------------------------------------------------------------
// Plugin registration
// ---------------------------------------------------------------------------

PLUGIN_MAIN(xr_sensor_capture)