Enhance native wheel handling with topology management

This commit is contained in:
iChris4 committed 2026-09-25 02:21:10 +02:00
1 parent 1d7f2549bd
commit 3d48514182
5 files changed
+484 -44

No files matched your search

+14 -3
View File
@@ -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
+116 -33
View File
@@ -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 <vector>
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<detail::Vec3>& 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)<radius*1.5f && std::abs(p.y-center.y)<radius*1.5f &&
std::abs(p.z-center.z)<radius*0.9f;
// Hand targets locate the wheel, but are not its centre/radius: Daisy holds
// the same Standard Kart higher than Mario, and Baby Mario grips inside the
// rim. Identify a complete rim component and fit its own plane and bounds.
// Rotate whole connected pieces only, so neither a rim nor a chassis triangle
// can stretch across the selection boundary. Work on a render copy.
inline unsigned RotateNativeWheelVertices(std::vector<detail::Vec3> &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(yy<radius*radius) return 0;
const float slope=std::clamp(yz/yy,-1.0f,1.0f);
center.z=meanZ+slope*(center.y-meanY);
const float inv=1/std::sqrt(1+slope*slope);
const detail::Vec3 up{0,inv,slope*inv},normal{0,-slope*inv,inv};
const float c=std::cos(angle),s=std::sin(angle);
unsigned changed=0;
for(auto& p:points) {
const detail::Vec3 delta{p.x-center.x,p.y-center.y,p.z-center.z};
const float x=delta.x,y=detail::Dot(delta,up),z=detail::Dot(delta,normal);
if(x*x+y*y>radius*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<Piece> 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
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <numeric>
#include <vector>
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<uint32_t, 10> 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<uint32_t> parents;
std::vector<bool> used;
std::vector<bool> 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
+28 -3
View File
@@ -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;
}
+152 -5
View File
@@ -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<detail::Vec3>(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<detail::Vec3> 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<uint8_t> 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;