From 3d48514182f3c055b9614d752edf0896ba190f16 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Fri, 25 Sep 2026 02:21:10 +0200 Subject: [PATCH] Enhance native wheel handling with topology management --- OPENXR.md | 17 +- runtime/include/vr/native_wheel_mesh.h | 149 ++++++++++++++---- runtime/include/vr/native_wheel_topology.h | 174 +++++++++++++++++++++ runtime/src/vr/mkw_vr_first_person.cpp | 31 +++- runtime/tests/vr_cockpit_tests.cpp | 157 ++++++++++++++++++- 5 files changed, 484 insertions(+), 44 deletions(-) create mode 100644 runtime/include/vr/native_wheel_topology.h diff --git a/OPENXR.md b/OPENXR.md index 609ef06..39e903a 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -453,9 +453,13 @@ bike's handlebar turns with your steering: the left stick's deflection at the fu (`wheel_kart_degrees` 90, `wheel_bike_degrees` 45), eased so a flicked stick does not snap it round, or the hands' own angle while they hold it. `native_steering_wheel = true` turns the vehicle's own model. Karts bake the wheel into the body, so at the race draw boundary the runtime decodes the -body's MDL0 position arrays, turns only the disc around the authored hand grips on a copy, and hands -the copy to the GX thread; Aurora substitutes it into the draws that bind that array with the -player's own model-view matrix (`aurora_set_native_wheel_vertices`), checking each changed vertex's +body's MDL0 position arrays and shape connectivity. Hand grips locate the wheel, but its complete +rim determines the rotation centre, radius and tilt: grip height/spacing varies by character. +Whole rim and spoke components turn together on a copy; a column or chassis component crossing +the selection stays intact. The root bone's authored transform is included when locating and +turning the wheel (the Baby Booster authors its body with rotated axes). The copy goes to the GX +thread; Aurora substitutes it into draws that bind that array with the player's model-view +matrix including the root transform (`aurora_set_native_wheel_vertices`), checking each changed vertex's matrix slot, so an opponent sharing the asset and other joints of the same draw are untouched. The guest's own vertices are never written, and the copies are dropped after the frame's draws. Bikes turn their handle part in the game already; its copy is only re-seated on the cockpit frame so the @@ -465,6 +469,13 @@ the copy (for 30 frames running; the race's opening pan does this) a separate VR which is also what `native_steering_wheel = false` draws. The copy keeps being published, so the vehicle's own wheel returns as soon as draws take it again, and the log notes both switches. +Validated on the extracted PAL disc's 216 single-player kart/character and Mii combinations +(all 18 kart types): each selects the complete 21-position rim and 15-position spoke assembly, +with the remaining positions unchanged and connected-piece distances preserved. Regression tests +also cover raised/narrow grips, domed hubs, rotated roots, child joints, chassis triangles crossing +the wheel volume, degenerate strip connectors and malformed MDL0 data. This asset check does not +by itself verify every combination's live draw matching or modded vehicle models. + The substitution is decided per draw, and a draw that folds into a neighbour renders through that neighbour's array binding, so only draws that reached the same decision may merge. Deciding this per array instead, and so refusing to merge every primitive that binds the vehicle's array, cost 6 ms diff --git a/runtime/include/vr/native_wheel_mesh.h b/runtime/include/vr/native_wheel_mesh.h index 262205c..54b3633 100644 --- a/runtime/include/vr/native_wheel_mesh.h +++ b/runtime/include/vr/native_wheel_mesh.h @@ -2,44 +2,127 @@ // Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later). #pragma once #include "vr/mkw_vr_first_person.h" +#include "vr/native_wheel_topology.h" #include namespace mkw::vr { -// A number of MKW karts bake the steering wheel into their single body bone. -// Find its thin disc around the authored hand targets, including the hub and -// spokes, and rotate only that disc. Work on a render copy, never guest assets. -inline unsigned RotateNativeWheelVertices(std::vector& points, - detail::Vec3 center,float radius,float angle,const Mtx34* bodyCorrection=nullptr) { - if (!(radius>4 && radius<100) || !detail::IsFiniteFloat(&angle)) return 0; - if(bodyCorrection && !detail::IsFiniteMtx34(*bodyCorrection)) return 0; - float meanY=0,meanZ=0; unsigned count=0; - const auto candidate=[&](const detail::Vec3& p) { - return std::abs(p.x-center.x) &points, NativeWheelTopology &topology, + detail::Vec3 gripCenter, float gripRadius, float angle, + const Mtx34 *bodyCorrection = nullptr, + const Mtx34 &bodyFromVertices = kIdentityMtx34) { + if (!(gripRadius > 4 && gripRadius < 100) || !detail::IsFiniteFloat(&angle) || + topology.parents.size() != points.size() || topology.used.size() != points.size() || + topology.rootOwned.size() != points.size()) + return 0; + if (bodyCorrection && !detail::IsFiniteMtx34(*bodyCorrection)) + return 0; + // Some karts (Baby Booster) author the body in rotated bone coordinates. + // Fit/turn in the kart frame, then convert only selected vertices back. + Mtx34 verticesFromBody; + if (!detail::IsFiniteMtx34(bodyFromVertices) || !InvertMtx(bodyFromVertices, verticesFromBody)) + return 0; + auto bodyPoints = points; + for (auto &p : bodyPoints) + p = detail::TransformPoint(bodyFromVertices, p.x, p.y, p.z); + struct Piece { + detail::Vec3 min{INFINITY, INFINITY, INFINITY}, max{-INFINITY, -INFINITY, -INFINITY}; + float sumY = 0, sumZ = 0; + unsigned count = 0; + bool selected = true; }; - for(const auto& p:points) if(candidate(p)) { meanY+=p.y; meanZ+=p.z; ++count; } - if(count<8) return 0; - meanY/=count; meanZ/=count; - float yy=0,yz=0; - for(const auto& p:points) if(candidate(p)) { yy+=(p.y-meanY)*(p.y-meanY); yz+=(p.y-meanY)*(p.z-meanZ); } - if(yyradius*radius*2.25f || std::abs(z)>radius*0.30f) continue; - const float rx=c*x-s*y,ry=s*x+c*y; - p={center.x+rx,center.y+up.y*ry+normal.y*z,center.z+up.z*ry+normal.z*z}; - // The body may spin during tricks/damage while the seated reference - // stays level. Compensate only the wheel, leaving chassis animation intact. - if(bodyCorrection) p=detail::TransformPoint(*bodyCorrection,p.x,p.y,p.z); - ++changed; + std::vector pieces(points.size()); + for (uint32_t i = 0; i < points.size(); ++i) + if (topology.used[i]) { + const auto &p = bodyPoints[i]; + auto &piece = pieces[topology.Root(i)]; + piece.min = {std::min(piece.min.x, p.x), std::min(piece.min.y, p.y), std::min(piece.min.z, p.z)}; + piece.max = {std::max(piece.max.x, p.x), std::max(piece.max.y, p.y), std::max(piece.max.z, p.z)}; + piece.sumY += p.y; + piece.sumZ += p.z; + ++piece.count; + if (!topology.rootOwned[i]) + piece.selected = false; + } + uint32_t rim = uint32_t(points.size()); + float bestScore = INFINITY, rimSlope = 0, rimRadius = 0; + detail::Vec3 center{}; + for (uint32_t component = 0; component < pieces.size(); ++component) { + const auto &piece = pieces[component]; + const float radius = (piece.max.x - piece.min.x) * 0.5f; + const detail::Vec3 mid{(piece.min.x + piece.max.x) * 0.5f, (piece.min.y + piece.max.y) * 0.5f, 0}; + if (!piece.selected || piece.count < 8 || radius < gripRadius * 0.65f || radius > gripRadius * 2.2f || + std::abs(mid.x - gripCenter.x) > gripRadius * 0.35f || std::abs(mid.y - gripCenter.y) > gripRadius * 1.5f) + continue; + const float meanY = piece.sumY / piece.count, meanZ = piece.sumZ / piece.count; + float yy = 0, yz = 0; + for (uint32_t i = 0; i < points.size(); ++i) + if (topology.used[i] && topology.Root(i) == component) { + yy += (bodyPoints[i].y - meanY) * (bodyPoints[i].y - meanY); + yz += (bodyPoints[i].y - meanY) * (bodyPoints[i].z - meanZ); + } + if (yy < radius * radius) + continue; + const float slope = yz / yy; + if (std::abs(slope) > 1.0f) + continue; + const float inv = 1.0f / std::sqrt(1.0f + slope * slope); + const float height = (piece.max.y - piece.min.y) / inv; + const float z = meanZ + slope * (mid.y - meanY); + if (height < radius * 1.3f || height > radius * 2.6f || std::abs(z - gripCenter.z) > gripRadius) + continue; + bool planar = true; + for (uint32_t i = 0; i < points.size(); ++i) + if (topology.used[i] && topology.Root(i) == component) { + if (std::abs((bodyPoints[i].z - meanZ - slope * (bodyPoints[i].y - meanY)) * inv) > radius * 0.3f) + planar = false; + } + if (!planar) + continue; + // Prefer the enclosing rim over the smaller spoke assembly. + const float score = -radius; + if (score >= bestScore) + continue; + bestScore = score; + rim = component; + rimSlope = slope; + rimRadius = std::max(radius, height * 0.5f); + center = {mid.x, mid.y, z}; } + if (rim == points.size()) + return 0; + const float inv = 1.0f / std::sqrt(1.0f + rimSlope * rimSlope); + const detail::Vec3 up{0, inv, rimSlope * inv}, normal{0, -rimSlope * inv, inv}; + for (uint32_t i = 0; i < points.size(); ++i) + if (topology.used[i]) { + const auto &p = bodyPoints[i]; + const detail::Vec3 delta{p.x - center.x, p.y - center.y, p.z - center.z}; + const float y = detail::Dot(delta, up), z = detail::Dot(delta, normal); + // Domed hubs (Royal Racer) protrude further than the rim's thin slab. + if (delta.x * delta.x + y * y > rimRadius * rimRadius * 1.21f || std::abs(z) > rimRadius * 0.45f) + pieces[topology.Root(i)].selected = false; + } + // The entire rim is selected even if its polygonal corners exceed a circle. + pieces[rim].selected = true; + const float c = std::cos(angle), s = std::sin(angle); + unsigned changed = 0; + for (uint32_t i = 0; i < points.size(); ++i) + if (topology.used[i] && pieces[topology.Root(i)].selected) { + auto p = bodyPoints[i]; + const detail::Vec3 delta{p.x - center.x, p.y - center.y, p.z - center.z}; + const float y = detail::Dot(delta, up), z = detail::Dot(delta, normal); + const float rx = c * delta.x - s * y, ry = s * delta.x + c * y; + p = {center.x + rx, center.y + up.y * ry + normal.y * z, center.z + up.z * ry + normal.z * z}; + if (bodyCorrection) + p = detail::TransformPoint(*bodyCorrection, p.x, p.y, p.z); + points[i] = detail::TransformPoint(verticesFromBody, p.x, p.y, p.z); + ++changed; + } return changed; } } // namespace mkw::vr diff --git a/runtime/include/vr/native_wheel_topology.h b/runtime/include/vr/native_wheel_topology.h new file mode 100644 index 0000000..ea80fc3 --- /dev/null +++ b/runtime/include/vr/native_wheel_topology.h @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +namespace mkw::vr { + +// Connected position indices in an MDL0 array. Material/normal/UV seams do not +// split a component; disconnected rim, spokes, column and chassis pieces do. +class NativeWheelTopology { + public: + explicit NativeWheelTopology(size_t count) : parents(count), used(count, false), rootOwned(count, true) { + std::iota(parents.begin(), parents.end(), 0u); + } + + uint32_t Root(uint32_t i) { + while (parents[i] != i) { + parents[i] = parents[parents[i]]; + i = parents[i]; + } + return i; + } + + bool Triangle(uint32_t a, uint32_t b, uint32_t c) { + if (a >= parents.size() || b >= parents.size() || c >= parents.size()) + return false; + // Degenerate strip connectors must not join disconnected pieces. + if (a == b || b == c || a == c) + return true; + used[a] = used[b] = used[c] = true; + const auto root = Root(a); + parents[Root(b)] = root; + parents[Root(c)] = root; + return true; + } + + // MDL0 shape primitive data uses the shape's CP VCD. Unsupported direct + // attributes/commands fail closed instead of guessing a vertex stride. + bool AddPrimitives(const uint8_t *data, size_t size, uint32_t vcdLo, uint32_t vcdHi, uint32_t fixedNode = 0) { + std::array nodes; + nodes.fill(UINT32_MAX); + uint32_t positionOffset = 0; + for (unsigned bit = 0; bit < 9; ++bit) + positionOffset += (vcdLo >> bit) & 1u; + const uint32_t positionType = (vcdLo >> 9) & 3u; + if (positionType < 2) + return false; + uint32_t stride = positionOffset; + for (unsigned attr = 0; attr < 12; ++attr) { + const uint32_t type = attr < 4 ? (vcdLo >> (9 + attr * 2)) & 3u : (vcdHi >> ((attr - 4) * 2)) & 3u; + if (type == 1) + return false; + if (type) + stride += type - 1; + } + const auto read16 = [](const uint8_t *p) { return (uint32_t(p[0]) << 8) | p[1]; }; + size_t at = 0; + while (at < size) { + const uint8_t command = data[at++]; + if (!command) + continue; + if (command == 0x20 || command == 0x28 || command == 0x30 || command == 0x38) { + if (size - at < 4) + return false; + if (command == 0x20) { + const auto address = read16(data + at + 2) & 0xfffu; + if (address % 12 || address / 12 >= nodes.size()) + return false; + nodes[address / 12] = read16(data + at); + } + at += 4; + continue; + } + const auto primitive = command & 0xf8; + if (primitive != 0x80 && primitive != 0x90 && primitive != 0x98 && primitive != 0xa0) + return false; + if (size - at < 2) + return false; + const uint32_t count = read16(data + at); + at += 2; + if (count > (size - at) / stride || count < 3 || (primitive == 0x80 && count % 4) || + (primitive == 0x90 && count % 3)) + return false; + const auto index = [&](uint32_t i) { + const auto *p = data + at + size_t(i) * stride + positionOffset; + return positionType == 2 ? uint32_t(*p) : read16(p); + }; + for (uint32_t i = 0; i < count; ++i) { + if (index(i) >= parents.size()) + return false; + uint32_t node = fixedNode; + if (vcdLo & 1u) { + const uint32_t selector = data[at + size_t(i) * stride]; + if (selector % 3 || selector / 3 >= nodes.size()) + return false; + node = nodes[selector / 3]; + } + if (node != 0) + rootOwned[index(i)] = false; + } + if (primitive == 0x80) { + for (uint32_t i = 0; i < count; i += 4) { + if (!Triangle(index(i), index(i + 1), index(i + 2)) || + !Triangle(index(i), index(i + 2), index(i + 3))) + return false; + } + } else if (primitive == 0x90) { + for (uint32_t i = 0; i < count; i += 3) + if (!Triangle(index(i), index(i + 1), index(i + 2))) + return false; + } else { + for (uint32_t i = 2; i < count; ++i) + if (!Triangle(index(primitive == 0xa0 ? 0 : i - 2), index(i - 1), index(i))) + return false; + } + at += size_t(count) * stride; + } + return true; + } + + std::vector parents; + std::vector used; + std::vector rootOwned; +}; + +// MDL0 v8/9 have the shape dictionary at 0x30; v10/11 insert two fur +// dictionaries before it. All offsets below are checked within the MDL0. +inline bool ReadNativeWheelTopology(const uint8_t *mdl, size_t size, uint32_t arrayId, NativeWheelTopology &topology) { + if (!mdl || size < 0x40) + return false; + const auto read32 = [&](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 contains = [&](size_t at, size_t length) { return at <= size && length <= size - at; }; + const auto version = read32(8); + if (read32(0) != 0x4d444c30 || version < 8 || version > 11 || read32(4) != size) + return false; + const size_t dictionary = read32(version >= 10 ? 0x38 : 0x30); + if (!dictionary || !contains(dictionary, 8)) + return false; + const auto count = read32(dictionary + 4); + if (count > 4096 || !contains(dictionary + 8, size_t(count + 1) * 16)) + return false; + bool found = false; + for (uint32_t entry = 1; entry <= count; ++entry) { + const size_t offset = read32(dictionary + 8 + entry * 16 + 12); + if (offset > size - dictionary) + return false; + const size_t shape = dictionary + offset; + if (!contains(shape, 0x60)) + return false; + const auto positionId = (uint32_t(mdl[shape + 0x48]) << 8) | mdl[shape + 0x49]; + if (positionId != arrayId) + continue; + // NBT triplets can carry three normal indices; do not use the ordinary + // one-index stride for them. Kart body shapes use XYZ normals. + if (((read32(shape + 0x14) >> 2) & 3u) > 1) + return false; + const size_t group = shape + 0x24, dataOffset = read32(group + 8), length = read32(group + 4); + if (dataOffset > size - group || !contains(group + dataOffset, length) || length > 0x400000) + return false; + if (!topology.AddPrimitives(mdl + group + dataOffset, length, read32(shape + 0x0c), read32(shape + 0x10), + read32(shape + 8))) + return false; + found = true; + } + return found; +} + +} // namespace mkw::vr diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp index a0d2dfc..3f38199 100644 --- a/runtime/src/vr/mkw_vr_first_person.cpp +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -740,6 +740,24 @@ bool PublishNativeWheelMesh(uint32_t part, const Mtx34& model_view, const Mtx34& if (version < 8 || version > 11) { return false; } + Mtx34 body_from_vertices = kIdentityMtx34; + Mtx34 wheel_model_view = model_view; + if (!whole_part) { + // Body::mtx is the model placement, while the root bone may have + // its own authored rotation (notably the Baby Booster). GX draws + // its positions through placement * root, so both wheel selection + // and Aurora's local-player matrix match must include that root. + const uint32_t bone_dic_offset = Memory::Read32(mdl + 0x14); + if (!bone_dic_offset || bone_dic_offset > 0x100000) return false; + const uint32_t bone_dic = mdl + bone_dic_offset; + if (!Memory::Contains(bone_dic, 40) || !Memory::Read32(bone_dic + 4)) return false; + const uint32_t bone_offset = Memory::Read32(bone_dic + 36); + if (bone_offset > 0x100000) return false; + const uint32_t root_bone = bone_dic + bone_offset; + if (!Memory::Contains(root_bone, 0xa0) || Memory::Read32(root_bone + 0x10) != 0 || + !ReadGuestMtx34(root_bone + 0x70, body_from_vertices)) return false; + wheel_model_view = ComposeMtx(model_view, body_from_vertices); + } const uint32_t dic_offset = Memory::Read32(mdl + 0x18); if (dic_offset == 0 || dic_offset > 0x100000) { return false; @@ -795,8 +813,15 @@ bool PublishNativeWheelMesh(uint32_t part, const Mtx34& model_view, const Mtx34& for (auto& point : points) { point = detail::TransformPoint(correction, point.x, point.y, point.z); } - } else if (RotateNativeWheelVertices(points, center, radius, angle, &correction) < 8) { - continue; + } else { + const uint32_t mdl_size = Memory::Read32(mdl + 4); + const uint8_t* mdl_bytes = mdl_size <= 0x1000000 ? Memory::GetPointer(mdl, mdl_size) : nullptr; + NativeWheelTopology topology(num); + if (!ReadNativeWheelTopology(mdl_bytes, mdl_size, Memory::Read32(header + 0x10), topology) || + RotateNativeWheelVertices(points, topology, center, radius, angle, &correction, + body_from_vertices) < 8) { + continue; + } } const uint8_t* source = Memory::GetPointer(data, size); if (source == nullptr) { @@ -825,7 +850,7 @@ bool PublishNativeWheelMesh(uint32_t part, const Mtx34& model_view, const Mtx34& } } } - if (valid && GxNativeWheel::PostVertices(data, bytes.data(), size, model_view.data())) { + if (valid && GxNativeWheel::PostVertices(data, bytes.data(), size, wheel_model_view.data())) { published = true; g_state.wheel_arrays_posted = true; } diff --git a/runtime/tests/vr_cockpit_tests.cpp b/runtime/tests/vr_cockpit_tests.cpp index f1c3d0e..3df1ed2 100644 --- a/runtime/tests/vr_cockpit_tests.cpp +++ b/runtime/tests/vr_cockpit_tests.cpp @@ -20,14 +20,14 @@ using namespace mkw::vr; int g_failures = 0; -void Check(bool condition, const char* what) { +void Check(bool condition, const char *what) { if (!condition) { ++g_failures; std::cerr << "FAILED: " << what << '\n'; } } -void CheckNear(float actual, float expected, const char* what, float tolerance = 1.0e-3f) { +void CheckNear(float actual, float expected, const char *what, float tolerance = 1.0e-3f) { if (!(std::fabs(actual - expected) <= tolerance)) { ++g_failures; std::cerr << "FAILED: " << what << " (expected " << expected << ", got " << actual << ")\n"; @@ -181,7 +181,10 @@ void TestNativeWheelVertices() { points.push_back({100.0f, 0.0f, 0.0f}); points.push_back({0.0f, 50.0f, 200.0f}); const auto original = points; - const unsigned changed = RotateNativeWheelVertices(points, {0, 50, 60}, 20.0f, 0.5f); + NativeWheelTopology topology(points.size()); + for (uint32_t i = 2; i < 64; ++i) + topology.Triangle(0, i - 1, i); + const unsigned changed = RotateNativeWheelVertices(points, topology, {0, 50, 60}, 20.0f, 0.5f); Check(changed == 64, "every disc vertex turns"); CheckNear(points[64].x, original[64].x, "chassis vertex untouched"); CheckNear(points[65].z, original[65].z, "vertex off the disc plane untouched"); @@ -190,8 +193,150 @@ void TestNativeWheelVertices() { CheckNear(std::hypot(points[i].x, points[i].y - 50.0f), 20.0f, "disc vertex stays on the rim", 1e-2f); } auto sparse = std::vector(points.begin(), points.begin() + 4); - Check(RotateNativeWheelVertices(sparse, {0, 50, 60}, 20.0f, 0.5f) == 0, "too few candidates leaves the mesh"); - Check(RotateNativeWheelVertices(points, {0, 50, 60}, 2.0f, 0.5f) == 0, "an implausible radius leaves the mesh"); + NativeWheelTopology sparseTopology(sparse.size()); + Check(RotateNativeWheelVertices(sparse, sparseTopology, {0, 50, 60}, 20.0f, 0.5f) == 0, + "too few candidates leaves the mesh"); + Check(RotateNativeWheelVertices(points, topology, {0, 50, 60}, 2.0f, 0.5f) == 0, + "an implausible radius leaves the mesh"); +} + +void TestNativeWheelWithRaisedGrips() { + // The same tilted wheel is gripped near its centre by one driver and near + // its upper rim by another. Uneven spoke density must not move the pivot. + constexpr float radius = 17.0f, slope = 0.3f; + const float inv = 1.0f / std::sqrt(1.0f + slope * slope); + const detail::Vec3 center{0, 28, -9}; + std::vector original; + for (int i = 0; i < 64; ++i) { + const float a = float(i) * 6.2831853f / 64.0f; + const float y = radius * std::sin(a); + original.push_back({radius * std::cos(a), center.y + inv * y, center.z + slope * inv * y}); + } + for (int i = 0; i < 12; ++i) { + original.push_back({float(i % 3) - 1.0f, center.y + 5.0f, center.z + slope * 5.0f}); + } + const auto wheelCount = original.size(); + // Inside the broad search box, but off the wheel plane: the chassis must + // neither bias the fit nor be pulled along with the wheel. + original.push_back({-18.0f, 8.0f, 6.0f}); + original.push_back(center); // A chassis triangle crosses the wheel volume. + original.push_back({18.0f, 9.0f, 6.0f}); + NativeWheelTopology topology(original.size()); + for (uint32_t i = 2; i < 64; ++i) + topology.Triangle(0, i - 1, i); + for (uint32_t i = 66; i < wheelCount; ++i) + topology.Triangle(64, i - 1, i); + topology.Triangle(wheelCount, wheelCount + 1, wheelCount + 2); + for (float angle : {-0.7f, 0.7f}) { + auto lowerGrip = original, raisedGrip = original; + Check(RotateNativeWheelVertices(lowerGrip, topology, {0, 27, -5}, 13.0f, angle) == wheelCount, + "all wheel vertices turn with lower grips"); + Check(RotateNativeWheelVertices(raisedGrip, topology, {0, 36.7f, -5.9f}, 13.0f, angle) == wheelCount, + "raised grips still turn the entire lower rim"); + for (size_t i = 0; i < wheelCount; ++i) { + CheckNear(raisedGrip[i].x, lowerGrip[i].x, "driver hand height does not change wheel rotation X"); + CheckNear(raisedGrip[i].y, lowerGrip[i].y, "driver hand height does not change wheel rotation Y"); + CheckNear(raisedGrip[i].z, lowerGrip[i].z, "driver hand height does not change wheel rotation Z"); + const float x = original[i].x, y = (original[i].y - center.y) / inv; + const float rx = std::cos(angle) * x - std::sin(angle) * y; + const float ry = std::sin(angle) * x + std::cos(angle) * y; + CheckNear(raisedGrip[i].x, rx, "wheel rotates rigidly about its geometric centre X"); + CheckNear(raisedGrip[i].y, center.y + inv * ry, "wheel rotates rigidly about its geometric centre Y"); + CheckNear(raisedGrip[i].z, center.z + slope * inv * ry, "wheel rotates rigidly in its tilted plane"); + } + for (size_t i = wheelCount; i < original.size(); ++i) { + CheckNear(raisedGrip[i].x, original[i].x, "nearby chassis X untouched"); + CheckNear(raisedGrip[i].y, original[i].y, "nearby chassis Y untouched"); + CheckNear(raisedGrip[i].z, original[i].z, "nearby chassis Z untouched"); + } + } + auto corrected = original; + const auto correction = Translation(2, 3, 4); + Check(RotateNativeWheelVertices(corrected, topology, {0, 36.7f, -5.9f}, 13.0f, 0.0f, &correction) == wheelCount, + "the complete wheel also receives cockpit stabilization"); + for (size_t i = 0; i < wheelCount; ++i) { + CheckNear(corrected[i].y, original[i].y + 3.0f, "lower rim receives body correction"); + } + for (size_t i = wheelCount; i < original.size(); ++i) { + CheckNear(corrected[i].y, original[i].y, "chassis does not receive wheel stabilization"); + } + auto narrowGrip = original; + Check(RotateNativeWheelVertices(narrowGrip, topology, {0, 36.7f, -5.9f}, 9.0f, 0.7f) == wheelCount, + "hands inside a wide rim still select the entire wheel"); + + // Baby Booster's root exchanges the authored lateral/vertical axes. + const Mtx34 bodyFromVertices{0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0}; + Mtx34 verticesFromBody; + Check(InvertMtx(bodyFromVertices, verticesFromBody), "authored body basis is invertible"); + auto authored = original, expected = original; + for (auto &p : authored) + p = detail::TransformPoint(verticesFromBody, p.x, p.y, p.z); + Check(RotateNativeWheelVertices(authored, topology, {0, 36.7f, -5.9f}, 13.0f, 0.7f, &correction, + bodyFromVertices) == wheelCount, + "a rotated root bone does not hide the wheel"); + RotateNativeWheelVertices(expected, topology, {0, 36.7f, -5.9f}, 13.0f, 0.7f, &correction); + for (size_t i = 0; i < original.size(); ++i) { + const auto p = detail::TransformPoint(bodyFromVertices, authored[i].x, authored[i].y, authored[i].z); + CheckNear(p.x, expected[i].x, "authored basis preserves rotation and stabilization X"); + CheckNear(p.y, expected[i].y, "authored basis preserves rotation and stabilization Y"); + CheckNear(p.z, expected[i].z, "authored basis preserves rotation and stabilization Z"); + } + auto domed = original; + for (size_t i = 64; i < wheelCount; ++i) { + domed[i].y -= slope * inv * radius * 0.37f; + domed[i].z += inv * radius * 0.37f; + } + Check(RotateNativeWheelVertices(domed, topology, {0, 36.7f, -5.9f}, 13.0f, 0.7f) == wheelCount, + "a domed hub turns with the rim"); + topology.rootOwned[0] = false; + auto foreignJoint = original; + Check(RotateNativeWheelVertices(foreignJoint, topology, {0, 36.7f, -5.9f}, 13.0f, 0.7f) == 0, + "geometry on another animated joint cannot be mistaken for the wheel"); +} + +void TestNativeWheelTopology() { + const uint8_t strip[]{0x98, 0, 8, 0, 1, 2, 2, 3, 3, 4, 5}; + NativeWheelTopology topology(6); + Check(topology.AddPrimitives(strip, sizeof(strip), 2u << 9, 0), "decode an indexed strip"); + Check(topology.Root(0) == topology.Root(2) && topology.Root(3) == topology.Root(5), + "strip triangles connect their positions"); + Check(topology.Root(0) != topology.Root(3), "degenerate strip connectors do not join pieces"); + Check(!topology.AddPrimitives(strip, sizeof(strip) - 1, 2u << 9, 0), "truncated primitive rejected"); + Check(!topology.AddPrimitives(strip, sizeof(strip), 1u << 9, 0), "unsupported direct positions rejected"); + NativeWheelTopology tooSmall(5); + Check(!tooSmall.AddPrimitives(strip, sizeof(strip), 2u << 9, 0), "out-of-range position rejected"); + const uint8_t quads[]{0x80, 0, 4, 0, 0, 0, 1, 0, 2, 0, 3}; + NativeWheelTopology quad(4); + Check(quad.AddPrimitives(quads, sizeof(quads), 3u << 9, 0) && quad.Root(0) == quad.Root(3), + "16-bit quad positions connect both triangles"); + const uint8_t indexed[]{0x20, 0, 0, 0xb0, 0, 0x20, 0, 1, 0xb0, 12, 0x90, 0, 3, 0, 0, 0, 1, 3, 2}; + NativeWheelTopology joints(3); + Check(joints.AddPrimitives(indexed, sizeof(indexed), (2u << 9) | 1u, 0), "decode indexed bone ownership"); + Check(joints.rootOwned[0] && joints.rootOwned[1] && !joints.rootOwned[2], + "matrix loads distinguish the body from an animated child joint"); + + // Minimal MDL0 exercising shape offsets, array IDs and bounds without game assets. + std::vector mdl(320, 0); + 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)); + }; + put32(0, 0x4d444c30); + put32(4, uint32_t(mdl.size())); + put32(8, 11); + put32(0x38, 64); + put32(68, 1); + put32(100, 40); + constexpr size_t shape = 104; + put32(shape + 0x0c, 3u << 9); + put32(shape + 0x28, sizeof(quads)); + put32(shape + 0x2c, 256 - (shape + 0x24)); + std::copy(std::begin(quads), std::end(quads), mdl.begin() + 256); + NativeWheelTopology model(4); + Check(ReadNativeWheelTopology(mdl.data(), mdl.size(), 0, model), "MDL0 shape topology decoded"); + Check(!ReadNativeWheelTopology(mdl.data(), mdl.size(), 1, model), "unrelated position array ignored"); + put32(shape + 0x2c, UINT32_MAX); + Check(!ReadNativeWheelTopology(mdl.data(), mdl.size(), 0, model), "escaping primitive offset rejected"); } } // namespace @@ -203,6 +348,8 @@ int main() { TestWheelGeometry(); TestStabilizer(); TestNativeWheelVertices(); + TestNativeWheelWithRaisedGrips(); + TestNativeWheelTopology(); if (g_failures != 0) { std::cerr << g_failures << " check(s) failed\n"; return 1;