Implement Bullet Bill model visibility and cockpit recentering improvements

- Added support for hiding model arrays in the rendering pipeline to optimize performance during VR gameplay.
- Introduced new functions in `gx_model_visibility` to manage hidden model arrays and their visibility based on game state.
- Enhanced cockpit recentering logic to ensure accurate seat measurements and eye positioning during gameplay.
- Updated `FirstPersonState` to track cockpit height and forward direction, improving VR experience.
- Added tests for Bullet Bill model visibility and cockpit height adjustments to ensure functionality and stability.
- Refactored existing code to accommodate new features and improve overall code organization.
This commit is contained in:
iChris4 committed 2026-09-28 23:21:01 +02:00
1 parent 8c5a7126da
commit 3c20f677eb
17 files changed
+592 -38

No files matched your search

+20 -3
View File
@@ -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
+5
View File
@@ -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.
+17
View File
@@ -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.
+7
View File
@@ -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);
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "gx.hpp"
#include <array>
#include <atomic>
#include <cmath>
#include <cstring>
#include <vector>
namespace aurora::gx {
struct HiddenModelArray {
const void* source = nullptr;
std::array<float, 12> modelView{};
};
inline std::vector<HiddenModelArray> hiddenModelArrays;
inline uint32_t hiddenModelDraws = 0;
inline std::atomic<uint32_t> 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<const float*>(&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
+1
View File
@@ -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
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "gx_test_common.hpp"
#include "gx/model_visibility.hpp"
#include <aurora/aurora.h>
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<float*>(&gxState().pnMtx[0].pos); }
void draw() { decode_fifo({static_cast<u8>(GX_TRIANGLES), 0, 3, 0, 1, 2}); }
std::array<uint8_t, 36> source{};
std::array<float, 12> 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<uint8_t, 36> 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<uint8_t, 3> 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
+1 -1
View File
@@ -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")
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <cstdint>
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
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
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<BulletBillBodyArray> 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<BulletBillBodyArray> 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<uint32_t>(data), bytes});
}
return result;
}
} // namespace mkw::vr
+62 -17
View File
@@ -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<float, 3>& scale) noexcept {
return true;
}
inline bool ValidSeatedEye(const std::array<float, 3>& 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<float, 3> 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<float, 3> 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<float, 3>& eye) noexcept {
std::array<float, 3> 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<float, 3> value{}, candidate{};
unsigned stable = 0;
bool valid = false;
bool recalibrating = false;
void Recalibrate() noexcept {
stable = 0;
recalibrating = true;
}
void Observe(const std::array<float, 3>& 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
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "gx_model_visibility.h"
#include "gx_internal.h"
#include <aurora/aurora.h>
#include <cstring>
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
+2 -1
View File
@@ -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.
+130 -16
View File
@@ -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 <atomic>
#include <cmath>
#include <mutex>
#include <optional>
@@ -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<float> cockpit_forward;
std::optional<float> 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<float, 3>& 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<float, 3>& 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<float, 3>& 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<float, 3>& eye) noexcept
Memory::TryRead32(damage + kDamageTypeOffset, damage_type) &&
damage_type == UINT32_MAX && std::abs(driving.steering_input) < 0.15f;
std::array<float, 3> 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<float, 3>& eye) noexcept
}
// No eye geometry: a point just above and ahead of the head bone.
const std::array<float, 3> point{bind[3], bind[7] + 8.0f, bind[11] + 8.0f};
std::array<float, 3> 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<float, 3> 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<float, 3> 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(&center.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;
+2
View File
@@ -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();
}
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/bullet_bill_model.h"
#include <iostream>
#include <vector>
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<uint8_t> 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";
}
+41
View File
@@ -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);