diff --git a/OPENXR.md b/OPENXR.md index 8a486ed..1587466 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -405,14 +405,22 @@ to the local driver's head instead, at one of two seats: - `first_person_seat = "cockpit"`, the default, sits you at the driver's own eyes, behind the steering wheel, at a life-size scale, so the wheel or handlebar is within reach of your hands. The eye is measured once per race from the character's head bone, while the kart drives straight, - undamaged and at normal size, and then frozen; until then the bind pose, or the vehicle's authored - seat height, stands in. It is kept at least 0.45 m behind the wheel so a long face or a - leaned-forward riding pose cannot put it over the controls. The world scale is + undamaged and at normal size, and then frozen. Custom models without a separate eye mesh use + the animated head with a small upward/forward offset; until calibration completes the bind pose, + or the vehicle's authored seat height, stands in. Rejected measurements never alter that fallback. + Eight consecutive samples must stay within two units of the first sample, so a slowly moving + starting animation cannot qualify just by moving little each frame. The eye is kept at least + 0.45 m behind the wheel and at most 0.40 m above the neutral hand targets, so long faces and necks + do not leave the controls below comfortable reach. The world scale is `cockpit_units_per_meter` (default 100) multiplied by the character's eye height over 100 units, so tall characters sit at a comparable height, and by the player's current size, so a lightning strike or a mega mushroom resizes the view, the wheel and the grab reach together. The seat follows the simulation's position and driving direction, never the animated chassis, so damage spins and tricks do not throw it around. + **Recenter view** (including its key binding) and headset reference-space recentering request a + fresh cockpit calibration as well as resetting the headset position. The previous seat stays + in place until a new stable, neutral measurement is ready; then height and wheel clearance are + recalculated together. Recenter while driving straight at normal size to replace a bad initial seat. - `first_person_seat = "custom"` places the head at `first_person_head_up_meters` and its two companions in the kart's own frame, at `first_person_units_per_meter`. @@ -476,6 +484,15 @@ only, so the other racers are untouched, and the original values are restored wh stops or the race ends. This is the one place the first-person camera modifies the game rather than only reading it. +In cockpit view, your Bullet Bill keeps its animated arms but hides the body, eyes and rear +exhaust cone, which otherwise fills the view from inside. The runtime resolves the local racer's +Killer model and registers its rigid body position arrays with the renderer for that frame. +Only draws at that Bullet Bill's own model-view transform are skipped; opponents sharing the +model, arm joints, and the separate ground shadow remain visible. The requests are cleared after +the frame and when cockpit view stops. This changes rendering only, without modifying game assets +or the item's behaviour. `mkw_vr_bullet_bill_tests` and Aurora's `HiddenModelTest` cover body/arm +selection, malformed models, instance matching, and both FIFO and raw draw paths. + One limitation is worth knowing: Mario Kart still culls the scene from its own chase camera, so a wide head turn in first person can reveal the edge of what the game decided to draw. As with the rest of the race instrumentation, the object offsets this reads are specific to the project's diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index cdd7143..7510ece 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -342,6 +342,11 @@ void aurora_clear_native_wheel_vertices(void); void aurora_set_native_wheel_vertices(const void* source, const void* replacement, uint32_t size, const float* modelView); uint32_t aurora_native_wheel_draw_count(void); +// GX thread, ordered with draws. Hide only rigid draws binding this array at +// this instance's model-view transform. Clear at the end of each guest frame. +void aurora_clear_hidden_model_arrays(void); +void aurora_hide_model_array(const void* source, const float* modelView); +uint32_t aurora_hidden_model_draw_count(void); typedef void (*AuroraFrameWorkerWaitCallback)(); // Called from the producer thread at bounded intervals while Aurora waits for // the asynchronous frame worker. The callback must not enter Aurora. diff --git a/aurora-main/lib/dolphin/gx/GXAurora.cpp b/aurora-main/lib/dolphin/gx/GXAurora.cpp index f2776f1..d817500 100644 --- a/aurora-main/lib/dolphin/gx/GXAurora.cpp +++ b/aurora-main/lib/dolphin/gx/GXAurora.cpp @@ -11,6 +11,23 @@ #include "../../gfx/common.hpp" #include "../../gx/fifo.hpp" #include "../../gx/native_wheel.hpp" +#include "../../gx/model_visibility.hpp" + +extern "C" void aurora_clear_hidden_model_arrays() { + if (aurora::gx::hiddenModelArrays.empty()) return; + aurora::gx::fifo::drain(); + aurora::gx::hiddenModelLastDraws.store(aurora::gx::hiddenModelDraws); + aurora::gx::hiddenModelDraws = 0; + aurora::gx::hiddenModelArrays.clear(); +} +extern "C" void aurora_hide_model_array(const void* source, const float* modelView) { + if (!source || !modelView) return; + aurora::gx::fifo::drain(); + aurora::gx::HiddenModelArray hidden{source, {}}; + std::memcpy(hidden.modelView.data(), modelView, sizeof(float) * 12); + aurora::gx::hiddenModelArrays.push_back(hidden); +} +extern "C" uint32_t aurora_hidden_model_draw_count() { return aurora::gx::hiddenModelLastDraws.load(); } // GX-thread entry points for the VR native steering wheel (native_wheel.hpp). // The runtime posts these in order with the frame's draws. diff --git a/aurora-main/lib/gx/command_processor.cpp b/aurora-main/lib/gx/command_processor.cpp index 64ab6c3..8c71bc8 100644 --- a/aurora-main/lib/gx/command_processor.cpp +++ b/aurora-main/lib/gx/command_processor.cpp @@ -6,6 +6,7 @@ #include "dolphin/gx/GXAurora.h" #include "gx.hpp" #include "native_wheel.hpp" +#include "model_visibility.hpp" #include "gx_fmt.hpp" #include "pipeline.hpp" #include "shader_info.hpp" @@ -2367,6 +2368,7 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui // This entry point bypasses process(), so it owns the renderer lock itself. std::lock_guard gpuLock(aurora::renderer_gpu_mutex()); + if (model_array_hidden()) return true; const gfx::Range vertRange = push_draw_vertices(vertices, vtxCount, vtxSize); const bool interpolationIdentityActive = frame_interpolation_identity_needed(); const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{}; @@ -2401,6 +2403,11 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi return false; } + if (model_array_hidden()) { + pos += totalVtxBytes; + return true; + } + // Push raw vertex data to buffer const uint8_t* vertices = data + pos; gfx::Range vertRange = push_draw_vertices(vertices, vtxCount, vtxSize); diff --git a/aurora-main/lib/gx/model_visibility.hpp b/aurora-main/lib/gx/model_visibility.hpp new file mode 100644 index 0000000..88691de --- /dev/null +++ b/aurora-main/lib/gx/model_visibility.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "gx.hpp" +#include +#include +#include +#include +#include + +namespace aurora::gx { +struct HiddenModelArray { + const void* source = nullptr; + std::array modelView{}; +}; +inline std::vector hiddenModelArrays; +inline uint32_t hiddenModelDraws = 0; +inline std::atomic hiddenModelLastDraws{0}; + +// Called before uploading vertices or merging draws. Matrix-indexed shapes +// are deliberately excluded: Bullet Bill's arms have their own animated joints. +inline bool model_array_hidden() { + if (hiddenModelArrays.empty() || g_gxState.projType != GX_PERSPECTIVE || + (g_gxState.vtxDesc[GX_VA_POS] != GX_INDEX8 && g_gxState.vtxDesc[GX_VA_POS] != GX_INDEX16) || + g_gxState.vtxDesc[GX_VA_PNMTXIDX] != GX_NONE || g_gxState.currentPnMtx >= MaxPnMtx) + return false; + const auto* matrix = reinterpret_cast(&g_gxState.pnMtx[g_gxState.currentPnMtx].pos); + for (const auto& hidden : hiddenModelArrays) { + if (g_gxState.arrays[GX_VA_POS].data != hidden.source) continue; + bool matches = true; + for (int i = 0; i < 12; ++i) { + uint32_t actual, expected; + std::memcpy(&actual, &matrix[i], 4); + std::memcpy(&expected, &hidden.modelView[i], 4); + if ((actual & 0x7f800000u) == 0x7f800000u || (expected & 0x7f800000u) == 0x7f800000u || + std::abs(matrix[i] - hidden.modelView[i]) > (i % 4 == 3 ? 0.1f : 0.002f)) { + matches = false; + break; + } + } + if (matches) { + ++hiddenModelDraws; + return true; + } + } + return false; +} +} // namespace aurora::gx diff --git a/aurora-main/tests/CMakeLists.txt b/aurora-main/tests/CMakeLists.txt index 0275736..e2958e7 100644 --- a/aurora-main/tests/CMakeLists.txt +++ b/aurora-main/tests/CMakeLists.txt @@ -64,6 +64,7 @@ if (AURORA_ENABLE_GX) eye_pass_plan_test.cpp foveation_test.cpp native_wheel_test.cpp + model_visibility_test.cpp cockpit_geometry_test.cpp texture_bind_group_cache_key_test.cpp ../lib/gfx/efb_ram_encoder.cpp diff --git a/aurora-main/tests/model_visibility_test.cpp b/aurora-main/tests/model_visibility_test.cpp new file mode 100644 index 0000000..f661abd --- /dev/null +++ b/aurora-main/tests/model_visibility_test.cpp @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "gx_test_common.hpp" +#include "gx/model_visibility.hpp" +#include + +namespace { +class HiddenModelTest : public GXFifoTest { +protected: + void SetUp() override { + GXFifoTest::SetUp(); + flush_and_capture(); + aurora::gx::hiddenModelArrays.clear(); + aurora::gx::hiddenModelDraws = 0; + aurora::gx::hiddenModelLastDraws = 0; + auto& state = gxState(); + state.lastVtxFmt = GX_VTXFMT0; + state.lastVtxSize = 1; + state.projType = GX_PERSPECTIVE; + state.vtxDesc[GX_VA_POS] = GX_INDEX8; + state.arrays[GX_VA_POS].data = source.data(); + state.arrays[GX_VA_POS].size = source.size(); + state.arrays[GX_VA_POS].stride = 12; + std::memcpy(pos(), matrix.data(), sizeof(float) * 12); + state.stateDirty = true; + aurora_hide_model_array(source.data(), matrix.data()); + } + void TearDown() override { aurora_clear_hidden_model_arrays(); } + float* pos() { return reinterpret_cast(&gxState().pnMtx[0].pos); } + void draw() { decode_fifo({static_cast(GX_TRIANGLES), 0, 3, 0, 1, 2}); } + std::array source{}; + std::array matrix{1, 0, 0, 10, 0, 1, 0, 20, 0, 0, 1, 30}; +}; + +TEST_F(HiddenModelTest, LocalBodyIsSkippedBeforeVertexUploadAndClearRestoresIt) { + draw(); + EXPECT_TRUE(aurora::gfx::testing::last_pushed_vertices().empty()); + aurora_clear_hidden_model_arrays(); + EXPECT_EQ(aurora_hidden_model_draw_count(), 1u); + draw(); + EXPECT_FALSE(aurora::gfx::testing::last_pushed_vertices().empty()); +} + +TEST_F(HiddenModelTest, OpponentUsingSameArrayIsDrawn) { + pos()[3] += 100; + draw(); + EXPECT_FALSE(aurora::gfx::testing::last_pushed_vertices().empty()); + EXPECT_EQ(aurora::gx::hiddenModelDraws, 0u); +} + +TEST_F(HiddenModelTest, ArmsAndIndexedJointsAreKept) { + std::array arms{}; + gxState().arrays[GX_VA_POS].data = arms.data(); + EXPECT_FALSE(aurora::gx::model_array_hidden()); + gxState().arrays[GX_VA_POS].data = source.data(); + gxState().vtxDesc[GX_VA_PNMTXIDX] = GX_DIRECT; + EXPECT_FALSE(aurora::gx::model_array_hidden()); +} + +TEST_F(HiddenModelTest, DirectPositionsOrthographicAndInvalidMatricesAreKept) { + gxState().vtxDesc[GX_VA_POS] = GX_DIRECT; + EXPECT_FALSE(aurora::gx::model_array_hidden()); + gxState().vtxDesc[GX_VA_POS] = GX_INDEX8; + gxState().projType = GX_ORTHOGRAPHIC; + EXPECT_FALSE(aurora::gx::model_array_hidden()); + gxState().projType = GX_PERSPECTIVE; + pos()[0] = __builtin_nanf(""); + EXPECT_FALSE(aurora::gx::model_array_hidden()); +} + +TEST_F(HiddenModelTest, RawBridgeDrawAlsoSkipsLocalBody) { + const std::array vertices{0, 1, 2}; + ASSERT_TRUE(aurora::gx::fifo::submit_raw_draw(GX_TRIANGLES, GX_VTXFMT0, vertices.data(), 3, vertices.size())); + EXPECT_TRUE(aurora::gfx::testing::last_pushed_vertices().empty()); + EXPECT_EQ(aurora::gx::hiddenModelDraws, 1u); +} +} // namespace diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 284ff9f..96082cb 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -417,7 +417,7 @@ add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests) # native wheel vertices) and hand steering (grab, turn, hand-off to the game), # ported from heurazy's mario-kart-wii-VR-port, and tracked hands (grasp, bare-hand # buttons, flick). All header-only. -foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests +foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_bullet_bill_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests mkw_vr_hand_tracking_tests) string(REGEX REPLACE "^mkw_" "" test_source "${test_name}") add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp") diff --git a/runtime/include/gx_model_visibility.h b/runtime/include/gx_model_visibility.h new file mode 100644 index 0000000..629b3f2 --- /dev/null +++ b/runtime/include/gx_model_visibility.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include + +namespace GxModelVisibility { +// Game thread. These requests are ordered with the frame's GX commands. +void PostClear(); +// Hide rigid draws using this array and this instance's model-view matrix. +// Shared models at other transforms and indexed joints are kept visible. +bool PostHiddenArray(uint32_t guestArray, uint32_t size, const float modelView[12]); +uint32_t LastDrawCount(); +} // namespace GxModelVisibility diff --git a/runtime/include/vr/bullet_bill_model.h b/runtime/include/vr/bullet_bill_model.h new file mode 100644 index 0000000..aa71702 --- /dev/null +++ b/runtime/include/vr/bullet_bill_model.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace mkw::vr { + +struct BulletBillBodyArray { + uint32_t offset = 0; + uint32_t size = 0; +}; + +// kart_killer's body, eye and exhaust shapes are rigid on matrix 0. Its arms +// use other matrices. Select only position arrays used exclusively by rigid +// body shapes, so even a mod sharing positions with an arm keeps those draws. +// Offsets are relative to the MDL0; no names or game assets are embedded here. +inline std::vector ReadBulletBillBodyArrays(const uint8_t* mdl, size_t size) { + if (!mdl || size < 0x40) return {}; + const auto contains = [size](size_t at, size_t length) { return at <= size && length <= size - at; }; + const auto read32 = [mdl](size_t at) { + return (uint32_t(mdl[at]) << 24) | (uint32_t(mdl[at + 1]) << 16) | + (uint32_t(mdl[at + 2]) << 8) | mdl[at + 3]; + }; + const auto read16 = [mdl](size_t at) { return (uint32_t(mdl[at]) << 8) | mdl[at + 1]; }; + const uint32_t version = read32(8); + if (read32(0) != 0x4d444c30 || read32(4) != size || version < 8 || version > 11) return {}; + const size_t positions = read32(0x18), shapes = read32(version >= 10 ? 0x38 : 0x30); + if (!positions || !shapes || !contains(positions, 8) || !contains(shapes, 8)) return {}; + const uint32_t positionCount = read32(positions + 4), shapeCount = read32(shapes + 4); + if (positionCount > 64 || shapeCount > 4096 || + !contains(positions + 8, size_t(positionCount + 1) * 16) || + !contains(shapes + 8, size_t(shapeCount + 1) * 16)) return {}; + std::vector result; + for (uint32_t i = 1; i <= positionCount; ++i) { + const size_t array = positions + size_t(read32(positions + 8 + i * 16 + 12)); + if (!contains(array, 0x38)) return {}; + const uint32_t id = read32(array + 0x10); + bool body = false, other = false; + for (uint32_t j = 1; j <= shapeCount; ++j) { + const size_t shape = shapes + size_t(read32(shapes + 8 + j * 16 + 12)); + if (!contains(shape, 0x60)) return {}; + if (read16(shape + 0x48) != id) continue; + const bool rigidBody = read32(shape + 8) == 0 && (read32(shape + 0xc) & 1u) == 0; + body |= rigidBody; + other |= !rigidBody; + } + if (!body || other) continue; + const size_t data = array + size_t(read32(array + 8)); + const uint32_t bytes = uint32_t(mdl[array + 0x1d]) * read16(array + 0x1e); + if (!bytes || !contains(data, bytes)) return {}; + result.push_back({static_cast(data), bytes}); + } + return result; +} + +} // namespace mkw::vr diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h index 9d83519..1a4125b 100644 --- a/runtime/include/vr/mkw_vr_first_person.h +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -206,6 +206,17 @@ inline float EyeBehindControls(float eyeForward, float controlsForward, float un return std::min(eyeForward, controlsForward - clearance); } +// Once the eye is pulled behind the controls, a long neck or snout must not +// leave them down at the player's knees. Keep the measured character scale, +// but cap the seated eye at 40 cm above the neutral hand targets. +inline float EyeAboveControls(float eyeHeight, float controlsHeight, float units) noexcept { + if (!detail::IsFiniteFloat(&controlsHeight) || !detail::IsFiniteFloat(&units) || units <= 0.0f) { + return eyeHeight; + } + const float limit = controlsHeight + 0.40f * units; + return limit >= 5.0f ? std::min(eyeHeight, limit) : eyeHeight; +} + // Tall characters sit higher; normalise them to a comfortable perceived cockpit // height by growing the world scale with the measured eye height. inline float CharacterCockpitScale(float eyeHeight) noexcept { @@ -237,6 +248,15 @@ inline bool NeutralPlayerScale(const std::array& scale) noexcept { return true; } +inline bool ValidSeatedEye(const std::array& eye) noexcept { + for (const auto& value : eye) { + if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { + return false; + } + } + return eye[1] >= 5.0f; +} + // Eye position resources are in the face bone's local coordinates, whose axes // differ between characters. Transform their centre through the complete bind // matrix before applying the vehicle-specific driver placement. @@ -258,12 +278,7 @@ inline bool ComputeDriverEyeFromBounds(const Mtx34& face, const Mtx34& placement (minimum.z + maximum.z) * 0.5f); const auto seat = detail::TransformPoint(placement, model.x, model.y, model.z); const std::array result{seat.x, seat.y, seat.z}; - for (const auto& value : result) { - if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { - return false; - } - } - if (seat.y < 5.0f) { + if (!ValidSeatedEye(result)) { return false; } eye = result; @@ -288,29 +303,50 @@ inline bool ComputeSeatedEye(const Mtx34& faceWorld, const Mtx34& bodyWorld, det const detail::Vec3 delta{world.x - bodyWorld[3], world.y - bodyWorld[7], world.z - bodyWorld[11]}; const std::array result{detail::Dot(bc, delta) / det, detail::Dot(ca, delta) / det, detail::Dot(ab, delta) / det}; - for (const auto& value : result) { - if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) { - return false; - } - } - if (result[1] < 5.0f) { + if (!ValidSeatedEye(result)) { return false; } eye = result; return true; } +// Mods may have no separately named eye geometry. Estimate just above/ahead +// of the evaluated head, in vehicle axes, not the head bone's rotated axes. +// The animated pose includes the character animation's scale and placement; +// adding the driver's placement again would put some mods below the vehicle. +inline bool ComputeDriverEyeFromHead(const Mtx34& headWorld, const Mtx34& bodyWorld, + std::array& eye) noexcept { + std::array head{}; + if (!ComputeSeatedEye(headWorld, bodyWorld, {0, 0, 0}, head)) { + return false; + } + head[1] += 8.0f; + head[2] += 8.0f; + if (!ValidSeatedEye(head)) { + return false; + } + eye = head; + return true; +} + // The neutral seated eye, accepted once eight consecutive safe samples agree -// within two units, then frozen until the driver or the race changes. +// within two units of the first sample, then frozen until the driver or the +// race changes, or a recenter asks for another calibration. Keep the previous +// seat while waiting for a safe replacement. struct SeatedEyeReference { std::array value{}, candidate{}; unsigned stable = 0; bool valid = false; + bool recalibrating = false; + void Recalibrate() noexcept { + stable = 0; + recalibrating = true; + } void Observe(const std::array& sample, bool safe, bool freeze) { - if (freeze && valid) { + if (freeze && valid && !recalibrating) { return; } - if (!safe) { + if (!safe || !ValidSeatedEye(sample)) { stable = 0; return; } @@ -318,11 +354,16 @@ struct SeatedEyeReference { for (int i = 0; i < 3; ++i) { delta = std::max(delta, std::abs(sample[i] - candidate[i])); } - stable = stable && delta < 2.0f ? stable + 1 : 1; - candidate = sample; + if (stable && delta < 2.0f) { + ++stable; + } else { + stable = 1; + candidate = sample; + } if (stable >= 8) { value = sample; valid = true; + recalibrating = false; stable = 8; } } @@ -521,6 +562,10 @@ void MkwVRFirstPersonCommit() noexcept; // Drops every captured pointer and the held anchor. Call on race entry/exit. void MkwVRFirstPersonReset() noexcept; +// Thread-safe request; the guest thread remeasures the cockpit on subsequent +// neutral frames. Keeps the current seat until a replacement is ready. +void MkwVRFirstPersonRecenter() noexcept; + // Producer-side read. Thread-safe. A valid anchor is also what marks the mode // as engaged, and so what selects the first-person world scale: it is invalid // whenever the mode is off, the race has not produced a usable anchor, or the diff --git a/runtime/src/hle/gx/gx_model_visibility.cpp b/runtime/src/hle/gx/gx_model_visibility.cpp new file mode 100644 index 0000000..f4047e2 --- /dev/null +++ b/runtime/src/hle/gx/gx_model_visibility.cpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "gx_model_visibility.h" +#include "gx_internal.h" +#include +#include + +namespace GxModelVisibility { +namespace records { +struct HiddenArray { + const void* source; + float modelView[12]; +}; +void Clear(const uint8_t*, uint32_t) { aurora_clear_hidden_model_arrays(); } +void Hide(const uint8_t* payload, uint32_t size) { + if (size != sizeof(HiddenArray)) return; + HiddenArray array{}; + std::memcpy(&array, payload, sizeof(array)); + aurora_hide_model_array(array.source, array.modelView); +} +void Post(const void* source, const float modelView[12]) { + if (!GxThread::Enabled()) { + aurora_hide_model_array(source, modelView); + return; + } + HiddenArray array{source, {}}; + std::memcpy(array.modelView, modelView, sizeof(array.modelView)); + GxThread::detail::PostRecord(&Hide, &array, sizeof(array)); +} +} // namespace records + +void PostClear() { + if (!GxThread::Enabled()) { + aurora_clear_hidden_model_arrays(); + return; + } + GxThread::detail::PostRecord(&records::Clear, nullptr, 0); +} +bool PostHiddenArray(uint32_t guestArray, uint32_t size, const float modelView[12]) { + if (!guestArray || !size || size > 65536 || !modelView) return false; + const void* sdk = GuestToHostPtr(guestArray, size); + const void* cp = GuestToHostPtr(DecodeCpArrayBaseGuestAddress(guestArray), size); + if (sdk) records::Post(sdk, modelView); + if (cp && cp != sdk) records::Post(cp, modelView); + return sdk || cp; +} +uint32_t LastDrawCount() { return aurora_hidden_model_draw_count(); } +} // namespace GxModelVisibility diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 2fbf47d..3c67825 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -1526,7 +1526,8 @@ void DrawVrSettings() { "Makes where you are sitting right now the centre of the view, and brings " "the menu screen back upright in front of you. The race view moves in " "position only, so the horizon stays level and forward is unchanged; use " - "your headset's own recenter to change forward."); + "your headset's own recenter to change forward. In cockpit view it also " + "remeasures the seat when the driver is straight, undamaged and normal size."); } ImGui::SameLine(); // Click to arm, then the next key press is captured in HandleEvents. diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp index 2bf3363..9c6e5b9 100644 --- a/runtime/src/vr/mkw_vr_first_person.cpp +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -3,15 +3,18 @@ #include "vr/mkw_vr_first_person.h" #include "gx_native_wheel.h" +#include "gx_model_visibility.h" #include "memory.h" #include "runtime_config.h" #include "runtime_log.h" #include "vr/cockpit_stabilizer.h" +#include "vr/bullet_bill_model.h" #include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_player.h" #include "vr/native_wheel_mesh.h" #include "vr/openxr_driving.h" +#include #include #include #include @@ -102,6 +105,15 @@ constexpr uint32_t kMaxPlayerModels = 32; // Kart::Link::GetDriverController (0x80590A40) returns accessor+0x14. constexpr uint32_t kKartAccessorDriverOffset = 0x14u; +// Killer::Activate (0x8059B7B8) sets bit 27 of KartState+0xC through +// accessor+4: the IN_A_BULLET flag, retained throughout the transformation. +constexpr uint32_t kKartAccessorStateOffset = 0x4u; +constexpr uint32_t kKartStateFlagsOffset = 0xCu; +constexpr uint32_t kKartStateInBullet = 1u << 27; +// Kart::Link::GetKiller (0x80591618): accessor+0x60. Killer::__ct +// (0x8059B658) stores the ModelDirector at +0x34 (+0x38 is its shadow). +constexpr uint32_t kKartAccessorKillerOffset = 0x60u; +constexpr uint32_t kKillerModelOffset = 0x34u; // Kart::Link::GetMovement (0x8059077C) returns accessor+0x28. Movement's // driving direction at +0x5C excludes damage spin, trick rotation and visual // pitch/roll; Kart::Movement::SetScale (0x80581720) stores the player's scale @@ -348,14 +360,17 @@ struct FirstPersonState { // The vehicle's own wheel would have been animated but the XR side has // not published its first driving snapshot yet: it shows unturned. bool waiting_for_driving = false; + uint32_t hidden_bullet_arrays = 0; } cockpit; CockpitStabilizer stabilizer{}; uint64_t stabilized_frame = 0; SeatedEyeReference seated_eye{}; uint32_t seated_driver = 0; std::optional cockpit_forward; + std::optional cockpit_height; // Native wheel copies handed to the GX side and not yet dropped. bool wheel_arrays_posted = false; + bool hidden_arrays_posted = false; uint32_t native_wheel_body = 0; uint32_t native_wheel_unmatched = 0; // The VR wheel is standing in: recent copies have not been taken. @@ -366,6 +381,7 @@ struct FirstPersonState { std::mutex g_mutex; FirstPersonState g_state; ModelVisibilityState g_visibility; +std::atomic_bool g_recenter_requested{false}; // Walks to the player's ModelsVisibility, or zero when the race is not up. uint32_t ResolveModelsVisibility(uint32_t accessor) noexcept { @@ -620,15 +636,17 @@ bool ReadEyeBounds(uint32_t driver, detail::Vec3& minimum, detail::Vec3& maximum // The seated eye in the vehicle's own frame, in its units. Measured once from // the driver's head bone while the kart drives straight and undamaged, then -// frozen until the driver or the race changes; the bind pose serves until then. +// frozen until the driver/race changes or a recenter; the bind pose serves +// until the first calibration completes. bool ReadDriverEye(const KartPoseRead& kart, std::array& eye) noexcept { const uint32_t driver = LocalDriver(kart.accessor); if (g_state.seated_driver != driver) { g_state.seated_driver = driver; g_state.seated_eye = {}; g_state.cockpit_forward.reset(); + g_state.cockpit_height.reset(); } - if (driver != 0 && g_state.seated_eye.valid) { + if (driver != 0 && g_state.seated_eye.valid && !g_state.seated_eye.recalibrating) { eye = g_state.seated_eye.value; return true; } @@ -637,8 +655,14 @@ bool ReadDriverEye(const KartPoseRead& kart, std::array& eye) noexcept if (driver == 0 || !ReadGuestPointer(driver + kDriverBonesOffset, bones) || !Memory::Contains(bones, kDriverBoneCount * kDriverBoneRecordBytes) || !ReadGuestMtx34(driver + kDriverPlacementOffset, placement)) { + g_state.seated_eye.Observe({}, false, true); + if (driver != 0 && g_state.seated_eye.valid) { + eye = g_state.seated_eye.value; + return true; + } return false; } + bool observed = false; try { for (uint32_t i = 0; i < kDriverBoneCount; ++i) { uint32_t name = 0, node = 0; @@ -660,14 +684,12 @@ bool ReadDriverEye(const KartPoseRead& kart, std::array& eye) noexcept const bool bounds_found = ReadEyeBounds(driver, minimum, maximum); uint32_t model = 0, ex = 0, scn = 0, palette = 0, mtx_id = 0; Mtx34 face_world{}, body_world{}; - if (bounds_found && ReadGuestPointer(driver + kDriverModelOffset, model) && + if (ReadGuestPointer(driver + kDriverModelOffset, model) && ReadGuestPointer(model + kModelDirectorScnMdlExOffset, ex) && ReadGuestPointer(ex, scn) && ReadGuestPointer(scn + kScnMdlWorldMtxArrayOffset, palette) && Memory::TryRead32(node + kResNodeMtxIdOffset, mtx_id) && mtx_id < 128 && ReadGuestMtx34(palette + mtx_id * 48u, face_world) && ReadGuestMtx34(kart.body + kKartBodyMtxOffset, body_world)) { - const detail::Vec3 local_eye{(minimum.x + maximum.x) * 0.5f, (minimum.y + maximum.y) * 0.5f, - (minimum.z + maximum.z) * 0.5f}; uint32_t damage = 0, damage_type = 0; const DrivingSnapshot driving = OpenXRReadDriving(); // Only a neutral pose may define the seat: straight ahead, at @@ -677,16 +699,29 @@ bool ReadDriverEye(const KartPoseRead& kart, std::array& eye) noexcept Memory::TryRead32(damage + kDamageTypeOffset, damage_type) && damage_type == UINT32_MAX && std::abs(driving.steering_input) < 0.15f; std::array measured{}; - if (ComputeSeatedEye(face_world, body_world, local_eye, measured)) { - const bool had_reference = g_state.seated_eye.valid; + const detail::Vec3 local_eye{(minimum.x + maximum.x) * 0.5f, (minimum.y + maximum.y) * 0.5f, + (minimum.z + maximum.z) * 0.5f}; + const bool measured_eye = + bounds_found ? ComputeSeatedEye(face_world, body_world, local_eye, measured) + : ComputeDriverEyeFromHead(face_world, body_world, measured); + if (measured_eye) { + observed = true; + const bool calibrating = !g_state.seated_eye.valid || g_state.seated_eye.recalibrating; g_state.seated_eye.Observe(measured, safe, true); - if (!had_reference && g_state.seated_eye.valid) { + if (calibrating && g_state.seated_eye.valid && !g_state.seated_eye.recalibrating) { g_state.cockpit_forward.reset(); - RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] cockpit: seated eye calibrated at (" << measured[0] - << ", " << measured[1] << ", " << measured[2] << ") units" + g_state.cockpit_height.reset(); + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] cockpit: seated eye calibrated from " + << (bounds_found ? "eye geometry" : "animated head") + << " at (" << measured[0] << ", " << measured[1] << ", " + << measured[2] << ") units" << std::endl; } + } else { + g_state.seated_eye.Observe({}, false, true); } + } else { + g_state.seated_eye.Observe({}, false, true); } if (g_state.seated_eye.valid) { eye = g_state.seated_eye.value; @@ -697,16 +732,25 @@ bool ReadDriverEye(const KartPoseRead& kart, std::array& eye) noexcept } // No eye geometry: a point just above and ahead of the head bone. const std::array point{bind[3], bind[7] + 8.0f, bind[11] + 8.0f}; + std::array fallback{}; for (int row = 0; row < 3; ++row) { - eye[row] = placement[row * 4 + 3] + placement[row * 4] * point[0] + - placement[row * 4 + 1] * point[1] + placement[row * 4 + 2] * point[2]; + fallback[row] = placement[row * 4 + 3] + placement[row * 4] * point[0] + + placement[row * 4 + 1] * point[1] + placement[row * 4 + 2] * point[2]; } - if (std::abs(eye[0]) < 300.0f && eye[1] > 10.0f && eye[1] < 500.0f && std::abs(eye[2]) < 400.0f) { + if (ValidSeatedEye(fallback)) { + eye = fallback; return true; } } } catch (const Memory::AccessViolation&) { } + if (!observed) { + g_state.seated_eye.Observe({}, false, true); + } + if (driver != 0 && g_state.seated_eye.valid) { + eye = g_state.seated_eye.value; + return true; + } return false; } @@ -854,6 +898,43 @@ bool PublishNativeWheelMesh(uint32_t part, const Mtx34& model_view, const Mtx34& return published; } +void DropHiddenModelsLocked() noexcept { + if (g_state.hidden_arrays_posted) { + GxModelVisibility::PostClear(); + g_state.hidden_arrays_posted = false; + } +} + +uint32_t PublishBulletBillBodyLocked(uint32_t accessor, const Mtx34& view) noexcept { + uint32_t state = 0, flags = 0, killer = 0, model = 0, mdl = 0, ex = 0, scn = 0, palette = 0; + Mtx34 body_world{}; + if (!ReadGuestPointer(accessor + kKartAccessorStateOffset, state) || + !Memory::TryRead32(state + kKartStateFlagsOffset, flags) || !(flags & kKartStateInBullet) || + !ReadGuestPointer(accessor + kKartAccessorKillerOffset, killer) || + !ReadGuestPointer(killer + kKillerModelOffset, model) || + !ReadGuestPointer(model + kModelDirectorResMdlOffset, mdl) || !Memory::Contains(mdl, 0x40) || + !ReadGuestPointer(model + kModelDirectorScnMdlExOffset, ex) || !ReadGuestPointer(ex, scn) || + !ReadGuestPointer(scn + kScnMdlWorldMtxArrayOffset, palette) || !ReadGuestMtx34(palette, body_world)) { + return 0; + } + uint32_t posted = 0; + try { + const uint32_t size = Memory::Read32(mdl + 4); + if (size > 0x1000000) return 0; + const uint8_t* bytes = Memory::GetPointer(mdl, size); + const auto arrays = ReadBulletBillBodyArrays(bytes, size); + const auto model_view = ComposeMtx(view, body_world); + for (const auto& array : arrays) { + if (GxModelVisibility::PostHiddenArray(mdl + array.offset, array.size, model_view.data())) { + ++posted; + g_state.hidden_arrays_posted = true; + } + } + } catch (const Memory::AccessViolation&) { + } + return posted; +} + // At the race draw boundary, before any of the frame's draws: the kart state // the cockpit seat needs, and the animated copy of the vehicle's own wheel. void LatchCockpitLocked(uint64_t guest_frame_index) noexcept { @@ -929,6 +1010,10 @@ void LatchCockpitLocked(uint64_t guest_frame_index) noexcept { ReadRaceCameraViewMatrix(TryGetCpuContext(), g_state.camera_address, latch.predicted_view, 0.0f); latch.valid = true; + if (g_state.anchor.valid && g_state.anchor.cockpit && latch.predicted_view_valid) { + latch.hidden_bullet_arrays = PublishBulletBillBodyLocked(kart.accessor, latch.predicted_view); + } + if (g_state.native_wheel_body != kart.body) { g_state.native_wheel_body = kart.body; g_state.native_wheel_unmatched = 0; @@ -987,6 +1072,7 @@ bool ComputeCockpitAnchorLocked(const Mtx34& view_from_world, const KartPoseRead const float base_units = g_state.cockpit_units_per_meter; std::array eye{0.0f, 1.1f * base_units, 0.0f}; if (!ReadDriverEye(kart, eye)) { + eye = {0.0f, 1.1f * base_units, 0.0f}; // KartDriverDispParams: the character's seat height and depth here. uint32_t settings = 0, seat = 0; try { @@ -995,8 +1081,10 @@ bool ComputeCockpitAnchorLocked(const Mtx34& view_from_world, const KartPoseRead const float y = Memory::ReadFloat32(seat), z = Memory::ReadFloat32(seat + 4); if (detail::IsFiniteFloat(&y) && detail::IsFiniteFloat(&z) && std::abs(y) < 400.0f && std::abs(z) < 400.0f) { - eye[1] += y; - eye[2] = z; + const std::array fallback{0.0f, eye[1] + y, z}; + if (ValidSeatedEye(fallback)) { + eye = fallback; + } } } } catch (const Memory::AccessViolation&) { @@ -1025,11 +1113,15 @@ bool ComputeCockpitAnchorLocked(const Mtx34& view_from_world, const KartPoseRead if (valid && detail::IsFiniteFloat(¢er.z)) { g_state.cockpit_forward = EyeBehindControls(eye[2], center.z, render_units, std::abs(left[3] - right[3]) * 0.5f); + g_state.cockpit_height = EyeAboveControls(eye[1], center.y, render_units); } } if (g_state.cockpit_forward) { eye[2] = *g_state.cockpit_forward; } + if (g_state.cockpit_height) { + eye[1] = *g_state.cockpit_height; + } for (int axis = 0; axis < 3; ++axis) { eye[axis] *= scale[axis]; } @@ -1125,6 +1217,11 @@ void LogCockpitLocked(uint64_t frame, const Mtx34& view_from_world) noexcept { << ", native mesh=" << anchor.native_mesh_prepared << ", draws animated=" << GxNativeWheel::LastDrawCount() << std::endl; const auto& latch = g_state.cockpit; + if (latch.hidden_bullet_arrays != 0) { + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] cockpit Bullet Bill: " << latch.hidden_bullet_arrays + << " body arrays, " << GxModelVisibility::LastDrawCount() + << " draws hidden; arms retained" << std::endl; + } if (latch.predicted_view_valid) { float rotation = 0.0f, translation = 0.0f; for (int i = 0; i < 12; ++i) { @@ -1252,6 +1349,10 @@ void MkwVRFirstPersonApplyConfiguredSettings() noexcept { g_state.hold_frames = 0; } g_state.seat = seat; + if (g_state.cockpit_units_per_meter != cockpit_units) { + g_state.cockpit_forward.reset(); + g_state.cockpit_height.reset(); + } g_state.cockpit_units_per_meter = cockpit_units; g_state.steering_wheel = RuntimeConfigFile::VrSteeringWheel(); g_state.native_steering_wheel = RuntimeConfigFile::VrNativeSteeringWheel(); @@ -1282,19 +1383,29 @@ void MkwVRFirstPersonReset() noexcept { g_state.logged_frame = 0; DropNativeWheelLocked(); g_state.cockpit = {}; + DropHiddenModelsLocked(); g_state.stabilizer = {}; g_state.stabilized_frame = 0; g_state.seated_eye = {}; g_state.seated_driver = 0; g_state.cockpit_forward.reset(); + g_state.cockpit_height.reset(); g_state.native_wheel_body = 0; g_state.native_wheel_unmatched = 0; g_state.native_wheel_fallback = false; g_state.native_wheel_switch_logs = 0; } +void MkwVRFirstPersonRecenter() noexcept { + g_recenter_requested.store(true, std::memory_order_release); +} + void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_address) noexcept { std::lock_guard lock(g_mutex); + DropHiddenModelsLocked(); + if (g_recenter_requested.exchange(false, std::memory_order_acq_rel)) { + g_state.seated_eye.Recalibrate(); + } g_state.camera_address = race_camera_address; if (!g_state.enabled) { g_state.anchor = {}; @@ -1337,7 +1448,10 @@ void MkwVRFirstPersonCommit() noexcept { std::lock_guard lock(g_mutex); // After this frame's draws, whatever the anchor makes of it. struct FinishWheel { - ~FinishWheel() { FinishNativeWheelFrameLocked(); } + ~FinishWheel() { + FinishNativeWheelFrameLocked(); + DropHiddenModelsLocked(); + } } finish_wheel; if (!g_state.armed) { return; diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index 7ff06ab..5cb989b 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -1529,6 +1529,7 @@ private: // this frame's head pose rather than the next one's. void ServiceRecenterRequest() noexcept { if (recenter_requested_.exchange(false, std::memory_order_acq_rel)) { + MkwVRFirstPersonRecenter(); ResetTrackingOrigin(); } } @@ -1799,6 +1800,7 @@ private: void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept { if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) { + MkwVRFirstPersonRecenter(); ResetTrackingOrigin(); } } diff --git a/runtime/tests/vr_bullet_bill_tests.cpp b/runtime/tests/vr_bullet_bill_tests.cpp new file mode 100644 index 0000000..8e1a08a --- /dev/null +++ b/runtime/tests/vr_bullet_bill_tests.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "vr/bullet_bill_model.h" +#include +#include + +int main() { + // Synthetic MDL0: three rigid body arrays and a fourth on the arm joints. + // No game data is needed to check ownership and offset validation. + std::vector mdl(0x600); + const auto put32 = [&](size_t at, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) mdl[at + i] = uint8_t(value >> ((3 - i) * 8)); + }; + const auto put16 = [&](size_t at, uint32_t value) { + mdl[at] = uint8_t(value >> 8); + mdl[at + 1] = uint8_t(value); + }; + int failures = 0; + const auto check = [&](bool condition, const char* message) { + if (!condition) { std::cerr << "FAILED: " << message << '\n'; ++failures; } + }; + put32(0, 0x4d444c30); + put32(4, uint32_t(mdl.size())); + put32(0x18, 0x40); + put32(0x30, 0xa0); + put32(0x38, 0xa0); + put32(0x44, 4); + put32(0xa4, 4); + for (uint32_t i = 0; i < 4; ++i) { + const uint32_t array = 0x160 + i * 0x40, shape = 0x280 + i * 0x60, data = 0x480 + i * 24; + put32(0x40 + 8 + (i + 1) * 16 + 12, array - 0x40); + put32(array + 8, data - array); + put32(array + 0x10, i); + mdl[array + 0x1d] = 12; + put16(array + 0x1e, 2); + put32(0xa0 + 8 + (i + 1) * 16 + 12, shape - 0xa0); + put32(shape + 8, i == 3 ? UINT32_MAX : 0); + put32(shape + 0xc, i == 3 ? 1 : 0); + put16(shape + 0x48, i); + } + for (uint32_t version : {8u, 9u, 10u, 11u}) { + put32(8, version); + const auto arrays = mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size()); + check(arrays.size() == 3, "body, eyes and cone selected, arms excluded"); + for (size_t i = 0; i < arrays.size(); ++i) + check(arrays[i].offset == 0x480 + i * 24 && arrays[i].size == 24, "array data range resolved"); + } + put16(0x3a0 + 0x48, 0); + auto shared = mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size()); + check(shared.size() == 2 && shared.front().offset == 0x498, "an array shared with an arm stays visible"); + put16(0x3a0 + 0x48, 3); + put32(0x280 + 0xc, 1); + check(mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size()).size() == 2, + "an indexed matrix shape cannot be assumed to belong to the body"); + put32(0x280 + 0xc, 0); + put32(0x160 + 8, UINT32_MAX); + check(mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size()).empty(), "escaping array offset rejected"); + put32(0x160 + 8, 0x480 - 0x160); + put32(0xa0 + 8 + 16 + 12, UINT32_MAX); + check(mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size()).empty(), "escaping shape offset rejected"); + check(mkw::vr::ReadBulletBillBodyArrays(mdl.data(), mdl.size() - 1).empty(), "truncated model rejected"); + check(mkw::vr::ReadBulletBillBodyArrays(nullptr, 0).empty(), "missing model rejected"); + if (failures) return 1; + std::cout << "Bullet Bill model tests passed\n"; +} diff --git a/runtime/tests/vr_cockpit_tests.cpp b/runtime/tests/vr_cockpit_tests.cpp index 1aab0ab..7df2a96 100644 --- a/runtime/tests/vr_cockpit_tests.cpp +++ b/runtime/tests/vr_cockpit_tests.cpp @@ -79,6 +79,11 @@ void TestSeatHelpers() { CheckNear(EyeBehindControls(50.0f, 60.0f, 100.0f, 0.0f), 60.0f - 45.0f, "eye pulled behind the wheel"); CheckNear(EyeBehindControls(50.0f, 60.0f, 100.0f, 50.0f), 60.0f - 55.0f, "a wider wheel keeps more clearance"); CheckNear(EyeBehindControls(-10.0f, 60.0f, 100.0f, 18.0f), -10.0f, "an eye already behind stays put"); + CheckNear(EyeAboveControls(69.0f, 37.0f, 100.0f), 69.0f, "normal seated height is unchanged"); + CheckNear(EyeAboveControls(126.0f, 58.0f, 126.0f), 108.4f, "long neck does not put the wheel at knee height"); + CheckNear(EyeAboveControls(180.0f, 45.0f, 180.0f), 117.0f, "tall driver's reach uses character scale"); + CheckNear(EyeAboveControls(69.0f, NAN, 100.0f), 69.0f, "invalid controls leave the eye alone"); + CheckNear(EyeAboveControls(69.0f, -100.0f, 100.0f), 69.0f, "bad controls cannot push the eye below the seat"); } void TestDriverEye() { @@ -92,6 +97,7 @@ void TestDriverEye() { Check(!ComputeDriverEyeFromBounds(face, placement, {2, 8, 0}, {-2, 12, 4}, eye), "inverted bounds rejected"); Check(!ComputeDriverEyeFromBounds(Translation(0, -50, 0), placement, {0, 0, 0}, {1, 1, 1}, eye), "an eye below the seat is rejected"); + CheckNear(eye[1], 95.0f, "a rejected measurement cannot corrupt the fallback height"); // The same eye through the animated world matrices: the body's own motion // must not leak into the seat. @@ -102,6 +108,17 @@ void TestDriverEye() { CheckNear(eye[1], 90.0f, "seated eye up", 1e-3f); CheckNear(eye[2], 15.0f, "seated eye forward", 1e-3f); + // Custom models may have no _eye group and a small/rotated bind head with + // a large negative bike placement. Only the evaluated head includes the + // animation's scale and riding posture. Do not apply placement twice. + Mtx34 customHead{0, 0, 1, 0, 0, -1, 0, 60, 1, 0, 0, 12}; + Check(ComputeDriverEyeFromHead(ComposeMtx(body, customHead), body, eye), "custom animated head fallback"); + CheckNear(eye[1], 68.0f, "custom head fallback stays above the bike"); + CheckNear(eye[2], 20.0f, "fallback offset uses vehicle axes despite rotated head"); + Check(!ComputeDriverEyeFromHead(ComposeMtx(body, Translation(0, -20, 0)), body, eye), + "an invalid head is rejected"); + CheckNear(eye[1], 68.0f, "failed head fallback leaves caller output intact"); + SeatedEyeReference reference; for (int i = 0; i < 7; ++i) { reference.Observe({0, 90, 15}, true, true); @@ -111,6 +128,30 @@ void TestDriverEye() { Check(reference.valid, "eight stable samples calibrate the seat"); reference.Observe({0, 200, 15}, true, true); CheckNear(reference.value[1], 90.0f, "a calibrated seat is frozen"); + reference.Recalibrate(); + reference.Observe({0, 120, 15}, false, true); + Check(reference.valid && reference.recalibrating, "recenter holds the seat until a neutral pose is ready"); + CheckNear(reference.value[1], 90.0f, "unsafe recenter keeps old height"); + for (int i = 0; i < 7; ++i) { + reference.Observe({0, 75, 15}, true, true); + } + CheckNear(reference.value[1], 90.0f, "partial recalibration keeps old height"); + reference.Observe({0, 75, 15}, true, true); + Check(reference.valid && !reference.recalibrating, "recenter accepts fresh stable samples"); + CheckNear(reference.value[1], 75.0f, "recenter replaces a bad initial height"); + reference.Observe({0, 100, 15}, true, true); + CheckNear(reference.value[1], 75.0f, "replacement freezes again"); + SeatedEyeReference drifting; + for (int i = 0; i < 30; ++i) { + drifting.Observe({0, 60.0f + float(i), 15}, true, true); + } + Check(!drifting.valid, "a slowly changing start animation is not a stable seat"); + for (int i = 0; i < 7; ++i) { + drifting.Observe({0, 100, 15}, true, true); + } + drifting.Observe({0, NAN, 15}, true, true); + drifting.Observe({0, 100, 15}, true, true); + Check(!drifting.valid, "invalid samples break calibration continuity"); SeatedEyeReference interrupted; for (int i = 0; i < 5; ++i) { interrupted.Observe({0, 90, 15}, true, true);