Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
// Regression tests for invalid/edge-case input that previously crashed or was
// silently mishandled instead of being rejected or failing gracefully.
import QtQuick
import QtTest
import QtQuick3D
import QtQuick3D.Physics
import QtQuick3D.Physics.Helpers
import QtQuick3D.Physics.TestUtils
import PhysicsTest.InvalidInput
Item {
width: 64
height: 64
TriggerBody {
id: filterGroupNode
collisionShapes: BoxShape {}
}
DynamicRigidBody {
id: kinematicTestNode
isKinematic: true
collisionShapes: BoxShape {}
}
SphereShape {
id: sphereShapeTestNode
}
CapsuleShape {
id: capsuleShapeTestNode
}
BoxShape {
id: boxShapeTestNode
}
CapsuleGeometry {
id: capsuleGeometryTestNode
}
CharacterController {
id: characterControllerTestNode
}
TestCase {
name: "invalidinput_shapeAndControllerProperties"
// SphereShape.diameter/CapsuleShape.diameter,height/BoxShape.extents reached
// PxSphereGeometry/PxCapsuleGeometry/PxBoxGeometry unvalidated: the character
// controller's per-frame controller->setRadius()/resize() (qphysxcharactercontroller.cpp)
// bypasses the null-createShape() guard that protects the regular shape-building path
// (see the SphereShape/BoxShape/CapsuleShape entries in invalidShapeScene below), so a
// zero/negative/NaN dimension needed rejecting at the property level instead.
function test_sphereDiameterRejectsInvalid() {
sphereShapeTestNode.diameter = 10
sphereShapeTestNode.diameter = 0
compare(sphereShapeTestNode.diameter, 10, "zero diameter should be rejected")
sphereShapeTestNode.diameter = -1
compare(sphereShapeTestNode.diameter, 10, "negative diameter should be rejected")
sphereShapeTestNode.diameter = NaN
compare(sphereShapeTestNode.diameter, 10, "NaN diameter should be rejected")
}
function test_capsuleDimensionsRejectInvalid() {
capsuleShapeTestNode.diameter = 10
capsuleShapeTestNode.height = 10
capsuleShapeTestNode.diameter = 0
capsuleShapeTestNode.height = NaN
compare(capsuleShapeTestNode.diameter, 10, "zero diameter should be rejected")
compare(capsuleShapeTestNode.height, 10, "NaN height should be rejected")
}
function test_boxExtentsRejectsInvalid() {
boxShapeTestNode.extents = Qt.vector3d(10, 10, 10)
boxShapeTestNode.extents = Qt.vector3d(10, -1, 10)
compare(boxShapeTestNode.extents, Qt.vector3d(10, 10, 10),
"a negative extents component should be rejected")
}
// CapsuleGeometry.longitudes/latitudes/rings only had a lower-bound clamp; an
// unreasonably large value made the uint32_t vertex/face-count arithmetic in
// updateData() (qcapsulegeometry.cpp) overflow and wrap around to a small buffer
// size while the fill loops still iterated the real, huge count, writing past the
// allocation.
function test_capsuleGeometryTessellationClampsUnreasonableValues() {
capsuleGeometryTestNode.longitudes = 1000000
verify(capsuleGeometryTestNode.longitudes <= 1024,
"an unreasonably large longitudes should be clamped")
capsuleGeometryTestNode.latitudes = 1000000
verify(capsuleGeometryTestNode.latitudes <= 1024,
"an unreasonably large latitudes should be clamped")
capsuleGeometryTestNode.rings = 1000000
verify(capsuleGeometryTestNode.rings <= 1024,
"an unreasonably large rings should be clamped")
}
// CharacterController.movement/gravity reach PxController::move() with no
// finiteness check anywhere in PhysX's character-kinematic code, checked build or not.
function test_characterControllerRejectsNonFinite() {
characterControllerTestNode.movement = Qt.vector3d(1, 2, 3)
characterControllerTestNode.movement = Qt.vector3d(NaN, 0, 0)
compare(characterControllerTestNode.movement, Qt.vector3d(1, 2, 3),
"non-finite movement should be rejected")
characterControllerTestNode.gravity = Qt.vector3d(0, -9, 0)
characterControllerTestNode.gravity = Qt.vector3d(0, -Infinity, 0)
compare(characterControllerTestNode.gravity, Qt.vector3d(0, -9, 0),
"non-finite gravity should be rejected")
}
// CharacterController.teleport() forwards its argument to
// PxController::setPosition() -> Cct::Controller::setPos() with no validation of any
// kind, not even a checked-build-only one. Actual crash-safety is exercised by
// teleportTestNode in invalidShapeScene below; this just confirms the invokable
// itself doesn't misbehave.
function test_teleportRejectsNonFinite() {
characterControllerTestNode.teleport(Qt.vector3d(NaN, 0, 0))
}
}
TestCase {
name: "invalidinput_worldProperties"
// PxScene::setGravity()/PxScene::simulate() have no validation of their own (not
// even a checked-build-only one for gravity; the elapsedTime check on simulate()
// is checked-build-only and this project only enables that in Debug). A NaN value
// used to reach them directly.
function test_gravityRejectsNonFinite() {
invalidShapeWorld.gravity = Qt.vector3d(0, -900, 0)
compare(invalidShapeWorld.gravity, Qt.vector3d(0, -900, 0))
invalidShapeWorld.gravity = Qt.vector3d(NaN, 0, 0)
compare(invalidShapeWorld.gravity, Qt.vector3d(0, -900, 0),
"non-finite gravity should be rejected, keeping the previous value")
}
function test_timestepRejectsNonFinite() {
invalidShapeWorld.minimumTimestep = 15
invalidShapeWorld.minimumTimestep = NaN
verify(!isNaN(invalidShapeWorld.minimumTimestep),
"non-finite minimumTimestep should be rejected")
invalidShapeWorld.maximumTimestep = 15
invalidShapeWorld.maximumTimestep = NaN
verify(!isNaN(invalidShapeWorld.maximumTimestep),
"non-finite maximumTimestep should be rejected")
}
// DynamicRigidBody.kinematicPosition/Rotation/EulerRotation/Pivot used to reach
// PxRigidDynamic::setKinematicTarget() with no finiteness check at all.
function test_kinematicPositionRejectsNonFinite() {
kinematicTestNode.kinematicPosition = Qt.vector3d(1, 2, 3)
compare(kinematicTestNode.kinematicPosition, Qt.vector3d(1, 2, 3))
kinematicTestNode.kinematicPosition = Qt.vector3d(NaN, 0, 0)
compare(kinematicTestNode.kinematicPosition, Qt.vector3d(1, 2, 3),
"non-finite kinematicPosition should be rejected, keeping the previous value")
}
// DynamicRigidBody.centerOfMassPosition/centerOfMassRotation feed
// PxRigidBody::setCMassLocalPose() (via QPhysicsCommandSetMassAndInertiaTensor/Matrix in
// qphysicscommands.cpp) with no finiteness check of their own; that call's own
// PX_CHECK_AND_RETURN(pose.isSane(), ...) is compiled out outside PX_CHECKED (Debug-only)
// builds.
function test_centerOfMassRejectsNonFinite() {
kinematicTestNode.centerOfMassPosition = Qt.vector3d(1, 2, 3)
compare(kinematicTestNode.centerOfMassPosition, Qt.vector3d(1, 2, 3))
kinematicTestNode.centerOfMassPosition = Qt.vector3d(NaN, 0, 0)
compare(kinematicTestNode.centerOfMassPosition, Qt.vector3d(1, 2, 3),
"non-finite centerOfMassPosition should be rejected, keeping the previous value")
kinematicTestNode.centerOfMassRotation = Qt.quaternion(1, 0, 0, 0)
compare(kinematicTestNode.centerOfMassRotation, Qt.quaternion(1, 0, 0, 0))
kinematicTestNode.centerOfMassRotation = Qt.quaternion(NaN, 0, 0, 0)
compare(kinematicTestNode.centerOfMassRotation, Qt.quaternion(1, 0, 0, 0),
"non-finite centerOfMassRotation should be rejected, keeping the previous value")
}
// PhysicsWorld.defaultDensity bypasses DynamicRigidBody.setDensity()'s validation
// entirely -- updateDefaultDensity() enqueues a QPhysicsCommandSetDensity directly --
// so it needs the same clamp applied independently, or a non-finite/non-positive value
// reaches PxRigidBodyExt::updateMassAndInertia() unguarded for every DefaultDensity body.
function test_defaultDensityRejectsInvalid() {
invalidShapeWorld.defaultDensity = 5
invalidShapeWorld.defaultDensity = 0
verify(invalidShapeWorld.defaultDensity > 0,
"zero defaultDensity should be clamped to a small positive value")
invalidShapeWorld.defaultDensity = NaN
verify(!isNaN(invalidShapeWorld.defaultDensity),
"non-finite defaultDensity should be clamped")
}
function test_numThreadsClampsUnreasonableValues() {
invalidShapeWorld.numThreads = 1000000
verify(invalidShapeWorld.numThreads <= 256,
"an unreasonably large numThreads should be clamped")
}
}
TestCase {
name: "invalidinput_filterGroup"
// PhysicsNode.filterGroup is only meaningful in [0, 31] (see
// physxnode/qphysxworld.cpp's isFilteredOut()/isBitSet()). Values outside
// that range used to be accepted silently and made the collision filter
// fail open (the pair would collide normally instead of being filtered).
function test_filterGroupRejectsOutOfRange() {
filterGroupNode.filterGroup = 5
compare(filterGroupNode.filterGroup, 5)
filterGroupNode.filterGroup = 31
compare(filterGroupNode.filterGroup, 31, "boundary value 31 should be accepted")
filterGroupNode.filterGroup = 32
compare(filterGroupNode.filterGroup, 31,
"out-of-range value 32 should be rejected, keeping the previous value")
filterGroupNode.filterGroup = -1
compare(filterGroupNode.filterGroup, 31,
"negative value should be rejected, keeping the previous value")
}
}
// Shared smoke-test scene: bodies below previously crashed the simulation on
// malformed input. Each PhysicsTestCase asserts the world keeps running instead.
// No rendering is needed for any of this, so scene is a plain Node rather than
// a View3D.
Node {
id: invalidShapeScene
// A Geometry with no position attribute used to hit a reachable
// Q_UNREACHABLE() in QQuick3DPhysicsMesh's attributeBySemantic() helper.
StaticRigidBody {
position: Qt.vector3d(0, 100, 0)
collisionShapes: ConvexMeshShape { geometry: NoPositionGeometry {} }
}
StaticRigidBody {
position: Qt.vector3d(0, -100, 0)
collisionShapes: TriangleMeshShape { geometry: NoPositionGeometry {} }
}
// A Geometry whose position attribute offset doesn't fit within its stride
// used to make convexMeshGeometrySource()/triangleMeshGeometrySource() read
// past the end of the vertex buffer while cooking the mesh.
StaticRigidBody {
position: Qt.vector3d(0, 300, 0)
collisionShapes: ConvexMeshShape { geometry: BadStrideGeometry {} }
}
StaticRigidBody {
position: Qt.vector3d(0, -300, 0)
collisionShapes: TriangleMeshShape { geometry: BadStrideGeometry {} }
}
// Shapes with zero/negative dimensions used to crash
// QPhysXActorBody::rebuildShapes() via an unchecked null return from
// PxPhysics::createShape(). None of these are expected to do anything
// physically meaningful -- the goal here is just that the world keeps
// simulating instead of crashing.
StaticRigidBody {
position: Qt.vector3d(-100, 0, 0)
collisionShapes: SphereShape { diameter: 0 }
}
StaticRigidBody {
position: Qt.vector3d(100, 0, 0)
collisionShapes: BoxShape { extents: Qt.vector3d(-1, 5, 5) }
}
StaticRigidBody {
position: Qt.vector3d(200, 0, 0)
collisionShapes: CapsuleShape { diameter: 0; height: 0 }
}
// A shape's own diameter/height/extents is already validated finite and positive at
// its own setter, but the *scaled* value used to build the actual PxGeometry
// (updatePhysXGeometry(), which multiplies by sceneScale() -- the ancestor
// QQuick3DNode chain's scale, not validated by this module) was never re-checked, so a
// degenerate ancestor scale still reached PxPhysics::createShape() with an invalid
// geometry, completely unvalidated outside PX_CHECKED (Debug-only) builds.
StaticRigidBody {
position: Qt.vector3d(-200, 0, 0)
collisionShapes: SphereShape { diameter: 100; scale: Qt.vector3d(0, 0, 0) }
}
// scale is listed before extents here (unlike the SphereShape/CapsuleShape cases
// above): QML applies same-object-literal properties in source order, and if extents
// is set first it builds one (valid, then-discarded) geometry with the still-default
// scale before scale is applied -- exercising the same fix either way, just via a
// different code path.
StaticRigidBody {
position: Qt.vector3d(-300, 0, 0)
collisionShapes: BoxShape { scale: Qt.vector3d(0, 0, 0); extents: Qt.vector3d(10, 10, 10) }
}
StaticRigidBody {
position: Qt.vector3d(-400, 0, 0)
collisionShapes: CapsuleShape {
diameter: 100
height: 100
scale: Qt.vector3d(0, 0, 0)
}
}
// A non-finite position used to reach PxPhysics::createRigidStatic() at creation
// and, every synced frame afterwards, PxRigidStatic::setGlobalPose() -- neither is
// validated in Release builds, and worse: fuzzyEquals(Infinity, Infinity) is false
// (Infinity - Infinity is NaN), so the "only sync if moved" check never short-circuits
// and setGlobalPose() would otherwise be called with the bad pose every single frame.
StaticRigidBody {
position: Qt.vector3d(1 / 0, 0, 0)
collisionShapes: BoxShape {}
}
// teleport()'s position reaches PxController::setPosition() with no validation at
// all, in every build configuration (not even a checked-build-only guard exists).
CharacterController {
id: teleportTestNode
position: Qt.vector3d(0, 500, 0)
collisionShapes: CapsuleShape { height: 100; diameter: 100 }
Component.onCompleted: teleport(Qt.vector3d(NaN, 0, 0))
}
// A CharacterController's capsule scaled by a degenerate ancestor scene scale used to
// reach PxShape::setGeometry() with a non-positive capsule geometry, completely
// unvalidated (PxCapsuleGeometry::isValid()'s own check is compiled out outside
// PX_CHECKED, Debug-only, builds -- see physxnode/qphysxcharactercontroller.cpp). The
// scale only degenerates after a few frames so the controller is already created
// (with a valid capsule) by the time it happens -- a zero scale from the start instead
// makes PxControllerManager::createController() itself reject the geometry immediately
// (unconditionally, not a Debug-only check), which never exercises the per-frame
// resize()/setRadius() path this guards.
Node {
id: badScaleControllerAncestor
CharacterController {
position: Qt.vector3d(0, 550, 0)
collisionShapes: CapsuleShape { height: 100; diameter: 100 }
}
}
// FixedJoint.positionA/positionB/orientationA/orientationB reach e.g.
// PxFixedJointCreate()'s PX_CHECK_AND_RETURN_NULL(localFrame.isSane(), ...) --
// compiled out (PX_CHECKED, Debug-only) -- with no validation of our own either.
StaticRigidBody {
id: jointAnchor
position: Qt.vector3d(600, 0, 0)
collisionShapes: BoxShape {}
}
DynamicRigidBody {
id: jointDynamicBody
position: Qt.vector3d(600, 100, 0)
collisionShapes: BoxShape {}
}
FixedJoint {
bodyA: jointAnchor
bodyB: jointDynamicBody
positionA: Qt.vector3d(NaN, 0, 0)
}
// DynamicRigidBody's velocity setters, force/impulse appliers, and reset() all
// forward straight to e.g. PxRigidDynamic::setLinearVelocity()/addForce()/
// setGlobalPose() with no validation of their own; those calls' own
// PX_CHECK_AND_RETURN(...isFinite()/isSane()...) guards are compiled out
// (PX_CHECKED, Debug-only).
DynamicRigidBody {
id: nonFiniteInvokablesTestNode
position: Qt.vector3d(700, 0, 0)
collisionShapes: BoxShape {}
Component.onCompleted: {
setLinearVelocity(Qt.vector3d(NaN, 0, 0));
setAngularVelocity(Qt.vector3d(NaN, 0, 0));
applyCentralForce(Qt.vector3d(1 / 0, 0, 0));
applyForce(Qt.vector3d(1 / 0, 0, 0), Qt.vector3d(0, 0, 0));
applyTorque(Qt.vector3d(NaN, 0, 0));
applyCentralImpulse(Qt.vector3d(NaN, 0, 0));
applyImpulse(Qt.vector3d(NaN, 0, 0), Qt.vector3d(0, 0, 0));
applyTorqueImpulse(Qt.vector3d(NaN, 0, 0));
reset(Qt.vector3d(NaN, 0, 0), Qt.vector3d(0, 0, 0));
}
}
// A running body with massMode: CustomDensity and density: 0 shouldn't hang or
// crash the simulation.
DynamicRigidBody {
id: zeroDensityBody
position: Qt.vector3d(0, 200, 0)
massMode: DynamicRigidBody.CustomDensity
density: 0
collisionShapes: SphereShape { diameter: 10 }
}
// inertiaTensor must reject negative components.
DynamicRigidBody {
position: Qt.vector3d(0, 400, 0)
massMode: DynamicRigidBody.MassAndInertiaTensor
inertiaTensor: Qt.vector3d(-1, -1, -1)
collisionShapes: SphereShape { diameter: 10 }
}
// A non-finite centerOfMassPosition/centerOfMassRotation used to reach
// PxRigidBody::setCMassLocalPose() completely unvalidated.
DynamicRigidBody {
position: Qt.vector3d(0, 500, 0)
massMode: DynamicRigidBody.MassAndInertiaTensor
centerOfMassPosition: Qt.vector3d(NaN, 0, 0)
centerOfMassRotation: Qt.quaternion(NaN, 0, 0, 0)
collisionShapes: SphereShape { diameter: 10 }
}
// A collision shape's own position/rotation (its local offset within the body, as
// opposed to the body's own position tested elsewhere in this scene) is a plain
// QQuick3DNode property with no finiteness validation anywhere in this module, and
// reached PxShape::setLocalPose() completely unvalidated.
StaticRigidBody {
position: Qt.vector3d(0, 550, 0)
collisionShapes: BoxShape { position: Qt.vector3d(NaN, 0, 0) }
}
// A non-finite position reached PxRigidDynamic::setGlobalPose() every synced frame via
// QPhysXTriggerBody::sync() with no isSane() check at all -- unlike the analogous
// static-body per-frame sync just above, which already guards this.
TriggerBody {
position: Qt.vector3d(1 / 0, 600, 0)
collisionShapes: BoxShape {}
}
// A kinematic body parented under a Node with a non-finite position used to reach
// PxRigidDynamic::setKinematicTarget() with a non-finite target: kinematicPosition/
// kinematicRotation are validated at their own setters, but the ancestor chain
// calculateKinematicNodeTransform() walks (physxnode/qphysxdynamicbody.cpp) is not,
// and the composed result was never checked before reaching PhysX.
Node {
position: Qt.vector3d(NaN, 0, 0)
DynamicRigidBody {
position: Qt.vector3d(0, 700, 0)
isKinematic: true
collisionShapes: BoxShape {}
}
}
// PhysicsMaterial properties must reject NaN.
StaticRigidBody {
position: Qt.vector3d(300, 0, 0)
collisionShapes: BoxShape {}
physicsMaterial: PhysicsMaterial {
staticFriction: NaN
dynamicFriction: NaN
restitution: NaN
}
}
}
PhysicsWorld {
id: invalidShapeWorld
scene: invalidShapeScene
running: true
minimumTimestep: 15
maximumTimestep: 15
property int frameCount: 0
onFrameDone: {
invalidShapeWorld.frameCount++;
// Let the character controller and shapes above get built successfully with a
// valid scale first, then degenerate it.
if (invalidShapeWorld.frameCount === 3)
badScaleControllerAncestor.scale = Qt.vector3d(0, 0, 0);
}
}
// A shape that cannot be simulated is only allowed on a kinematic body, so a
// body given one while it is not kinematic is made kinematic, before it is
// given the shape rather than after. What shows that the shape really
// reached the actor is the plane holding a body up, and that needs a scene
// and a world to itself: the shared one above is full of bodies built from
// degenerate input, and they shove whatever is near them hard enough that
// where a body ends up there says nothing about why.
Node {
id: forcedKinematicScene
DynamicRigidBody {
id: forcedKinematicBody
isKinematic: false
// Laid flat through the kinematic pose, since that is what drives
// the actor once the body has been forced kinematic.
eulerRotation: Qt.vector3d(-90, 0, 0)
kinematicEulerRotation: Qt.vector3d(-90, 0, 0)
collisionShapes: PlaneShape {}
}
// Comes to rest on the plane above, which it can only do if the plane
// reached the actor: PhysX drops a shape it will not attach without
// telling the caller anything it checks.
DynamicRigidBody {
id: forcedKinematicLander
position: Qt.vector3d(0, 200, 0)
collisionShapes: SphereShape {}
}
}
PhysicsWorld {
id: forcedKinematicWorld
scene: forcedKinematicScene
running: true
minimumTimestep: 15
maximumTimestep: 15
property int frameCount: 0
property int restingFrames: 0
onFrameDone: {
forcedKinematicWorld.frameCount++;
// The lander's radius above the plane at y = 0 is where it comes
// to rest, and only a frame it spends there counts: one falling
// past covers this whole window in a single step.
if (Math.abs(forcedKinematicLander.position.y - 50) < 0.5)
forcedKinematicWorld.restingFrames++;
else
forcedKinematicWorld.restingFrames = 0;
}
}
PhysicsTestCase {
name: "invalidinput_staticShapeForcesKinematic"
goalReached: forcedKinematicBody.isKinematic
&& forcedKinematicWorld.restingFrames > 30
}
PhysicsTestCase {
name: "invalidinput_noPositionGeometry"
goalReached: invalidShapeWorld.frameCount > 5
}
PhysicsTestCase {
name: "invalidinput_shapeDimensions"
goalReached: invalidShapeWorld.frameCount > 5
}
PhysicsTestCase {
name: "invalidinput_badStride"
goalReached: invalidShapeWorld.frameCount > 5
}
PhysicsTestCase {
name: "invalidinput_massAndMaterial"
goalReached: invalidShapeWorld.frameCount > 5
}
PhysicsTestCase {
name: "invalidinput_teleportAndJoint"
goalReached: invalidShapeWorld.frameCount > 5
}
TestCase {
name: "invalidinput_density"
// DynamicRigidBody.density's setter now clamps to (0, inf], matching its
// documented range, instead of silently storing an invalid value that only
// failed later in the deferred command execution.
function test_zeroDensityIsClamped() {
verify(zeroDensityBody.density > 0,
"density: 0 should be clamped to a small positive value, not stored as-is")
}
}
}