Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QtTest/QtTest>
++
++ #include <QtQuick3DUtils/private/qssgmesh_p.h>
++
++ #include <QtCore/qbuffer.h>
++ #include <QtCore/qdir.h>
++ #include <QtCore/qendian.h>
++ #include <QtCore/qfile.h>
++
++ using namespace Qt::StringLiterals;
++ using namespace QSSGMesh;
++
++ // A device that stops accepting data part way through, the way a full disk or a
++ // read only location does.
++ class ShortDevice : public QIODevice
++ {
++ public:
++ explicit ShortDevice(qint64 limit) : m_limit(limit) { }
++
++ protected:
++ qint64 readData(char *, qint64) override { return -1; }
++ qint64 writeData(const char *, qint64 len) override
++ {
++ const qint64 room = m_limit - m_written;
++ if (room <= 0)
++ return -1;
++ const qint64 taken = qMin(room, len);
++ m_written += taken;
++ return taken;
++ }
++
++ private:
++ qint64 m_limit;
++ qint64 m_written = 0;
++ };
++
++ // Assembles a .mesh container byte by byte, from the format description in
++ // src/quick3d/doc/src/qtquick3d-mesh-format.qdoc rather than by mirroring the
++ // reader, so that a wrong reading of the format is not masked.
++ class MeshBuilder
++ {
++ public:
++ struct Entry
++ {
++ QByteArray name;
++ quint32 componentType = 10; // Float32
++ quint32 componentCount = 3;
++ quint32 offset = 0;
++ };
++
++ struct Lod
++ {
++ quint32 count = 0;
++ quint32 offset = 0;
++ float distance = 0.0f;
++ };
++
++ struct Subset
++ {
++ QString name;
++ quint32 count = 0;
++ quint32 offset = 0;
++ float minX = -1.0f, minY = -1.0f, minZ = -1.0f;
++ float maxX = 1.0f, maxY = 1.0f, maxZ = 1.0f;
++ quint32 lightmapWidth = 0;
++ quint32 lightmapHeight = 0;
++ QList<Lod> lods;
++ };
++
++ // The alignment step adds a full four bytes when the position is already
++ // aligned, and the position is always aligned when a block starts, so the
++ // padding is only ever a function of the block size. See the format page.
++ static QByteArray padded(const QByteArray &block)
++ {
++ return block + QByteArray(4 - (block.size() % 4), '\0');
++ }
++
++ MeshBuilder &setVersion(quint16 version)
++ {
++ m_version = version;
++ return *this;
++ }
++ MeshBuilder &setStride(quint32 stride)
++ {
++ m_stride = stride;
++ return *this;
++ }
++ MeshBuilder &setEntries(const QList<Entry> &entries)
++ {
++ m_entries = entries;
++ return *this;
++ }
++ MeshBuilder &setVertexData(const QByteArray &data)
++ {
++ m_vertexData = data;
++ return *this;
++ }
++ MeshBuilder &setIndexData(const QByteArray &data, quint32 componentType)
++ {
++ m_indexData = data;
++ m_indexComponentType = componentType;
++ return *this;
++ }
++ MeshBuilder &setSubsets(const QList<Subset> &subsets)
++ {
++ m_subsets = subsets;
++ return *this;
++ }
++ MeshBuilder &setJointCount(quint32 count)
++ {
++ m_jointCount = count;
++ return *this;
++ }
++ MeshBuilder &setTargetEntries(const QList<Entry> &entries)
++ {
++ m_targetEntries = entries;
++ return *this;
++ }
++ MeshBuilder &setTargetData(const QByteArray &data, quint32 numTargets)
++ {
++ m_targetData = data;
++ m_numTargets = numTargets;
++ return *this;
++ }
++ MeshBuilder &setDrawMode(quint32 drawMode)
++ {
++ m_drawMode = drawMode;
++ return *this;
++ }
++ MeshBuilder &setWinding(quint32 winding)
++ {
++ m_winding = winding;
++ return *this;
++ }
++ // Replaces everything after the 12 byte MeshDataHeader.
++ MeshBuilder &setRawPayload(const QByteArray &payload)
++ {
++ m_payload = payload;
++ m_hasPayload = true;
++ return *this;
++ }
++ // Overrides one of the fourteen quint32 fields of the Mesh struct by index.
++ MeshBuilder &patchMeshField(int index, quint32 value)
++ {
++ m_patches.insert(index, value);
++ return *this;
++ }
++
++ // Everything from the MeshDataHeader onwards.
++ QByteArray mesh() const
++ {
++ const QByteArray payload = m_hasPayload ? m_payload : body();
++ QByteArray out;
++ append32(&out, MeshFileId);
++ append16(&out, m_version);
++ append16(&out, 0); // headerFlags
++ append32(&out, quint32(12 + payload.size()));
++ return out + payload;
++ }
++
++ // A whole container holding this one mesh.
++ QByteArray build(quint32 id = 1) const { return container({ mesh() }, { id }); }
++
++ static QByteArray container(const QList<QByteArray> &meshes, const QList<quint32> &ids)
++ {
++ QByteArray out;
++ QList<quint64> offsets;
++ for (const QByteArray &m : meshes) {
++ offsets.append(quint64(out.size()));
++ out += m;
++ }
++ const quint32 entriesOffset = quint32(out.size());
++ for (qsizetype i = 0; i < meshes.size(); ++i) {
++ append64(&out, offsets.at(i));
++ append32(&out, ids.at(i));
++ append32(&out, 0); // padding
++ }
++ append32(&out, ContainerFileId);
++ append32(&out, 1); // container version
++ append32(&out, entriesOffset);
++ append32(&out, quint32(meshes.size()));
++ return out;
++ }
++
++ static constexpr quint32 MeshFileId = 3365961549U;
++ static constexpr quint32 ContainerFileId = 555777497U;
++
++ private:
++ static void append16(QByteArray *out, quint16 v)
++ {
++ char buf[2];
++ qToLittleEndian(v, buf);
++ out->append(buf, 2);
++ }
++ static void append32(QByteArray *out, quint32 v)
++ {
++ char buf[4];
++ qToLittleEndian(v, buf);
++ out->append(buf, 4);
++ }
++ static void append64(QByteArray *out, quint64 v)
++ {
++ char buf[8];
++ qToLittleEndian(v, buf);
++ out->append(buf, 8);
++ }
++ static void appendFloat(QByteArray *out, float v)
++ {
++ char buf[4];
++ qToLittleEndian(v, buf);
++ out->append(buf, 4);
++ }
++
++ QByteArray body() const
++ {
++ const bool v7 = m_version >= 7;
++ QByteArray out;
++
++ // The Mesh struct: fourteen quint32 fields, three of them repurposed in
++ // version 7.
++ QList<quint32> fields = {
++ v7 ? quint32(m_targetEntries.size()) : 0u, // entriesOffset / targetBufferEntriesCount
++ quint32(m_entries.size()),
++ m_stride,
++ v7 ? quint32(m_targetData.size()) : 0u, // dataOffset / targetBufferDataSize
++ quint32(m_vertexData.size()),
++ m_indexComponentType,
++ 0u, // indexBuffer.dataOffset
++ quint32(m_indexData.size()),
++ v7 ? m_numTargets : 0u, // subsetsOffset / targetCount
++ quint32(m_subsets.size()),
++ 0u, // jointsOffset
++ m_jointCount,
++ m_drawMode,
++ m_winding,
++ };
++ for (auto it = m_patches.cbegin(); it != m_patches.cend(); ++it)
++ fields[it.key()] = it.value();
++ for (quint32 f : fields)
++ append32(&out, f);
++
++ out += padded(entryBlock(m_entries));
++ for (const Entry &e : m_entries)
++ out += nameBlock(e.name);
++
++ out += padded(m_vertexData);
++ out += padded(m_indexData);
++
++ QByteArray subsetBlock;
++ for (const Subset &s : m_subsets) {
++ append32(&subsetBlock, s.count);
++ append32(&subsetBlock, s.offset);
++ appendFloat(&subsetBlock, s.minX);
++ appendFloat(&subsetBlock, s.minY);
++ appendFloat(&subsetBlock, s.minZ);
++ appendFloat(&subsetBlock, s.maxX);
++ appendFloat(&subsetBlock, s.maxY);
++ appendFloat(&subsetBlock, s.maxZ);
++ append32(&subsetBlock, 0); // nameOffset
++ append32(&subsetBlock, quint32(s.name.size() + 1)); // with the terminator
++ if (m_version >= 5) {
++ append32(&subsetBlock, s.lightmapWidth);
++ append32(&subsetBlock, s.lightmapHeight);
++ if (m_version >= 6)
++ append32(&subsetBlock, quint32(s.lods.size()));
++ }
++ }
++ out += padded(subsetBlock);
++
++ for (const Subset &s : m_subsets) {
++ QByteArray name;
++ for (QChar c : s.name)
++ append16(&name, c.unicode());
++ append16(&name, 0); // terminator
++ out += padded(name);
++ }
++
++ if (m_version >= 6) {
++ QByteArray lodBlock;
++ for (const Subset &s : m_subsets) {
++ for (const Lod &l : s.lods) {
++ append32(&lodBlock, l.count);
++ append32(&lodBlock, l.offset);
++ appendFloat(&lodBlock, l.distance);
++ }
++ }
++ out += padded(lodBlock);
++ }
++
++ // Joint records are 136 bytes and carry no padding of their own.
++ out += QByteArray(qsizetype(m_jointCount) * 136, '\0');
++
++ if (v7) {
++ out += padded(entryBlock(m_targetEntries));
++ for (const Entry &e : m_targetEntries)
++ out += nameBlock(e.name);
++ out += m_targetData; // the last block, never padded
++ }
++ return out;
++ }
++
++ static QByteArray entryBlock(const QList<Entry> &entries)
++ {
++ QByteArray out;
++ for (const Entry &e : entries) {
++ append32(&out, 0); // nameOffset
++ append32(&out, e.componentType);
++ append32(&out, e.componentCount);
++ append32(&out, e.offset);
++ }
++ return out;
++ }
++
++ static QByteArray nameBlock(const QByteArray &name)
++ {
++ QByteArray out;
++ append32(&out, quint32(name.size() + 1));
++ return out + padded(name + '\0');
++ }
++
++ quint16 m_version = 7;
++ quint32 m_stride = 12;
++ QList<Entry> m_entries = { { "attr_pos"_ba, 10, 3, 0 } };
++ QByteArray m_vertexData = QByteArray(36, '\x01');
++ QByteArray m_indexData;
++ quint32 m_indexComponentType = 5; // UnsignedInt32
++ QList<Subset> m_subsets = { { QStringLiteral("sub"), 3, 0, -1, -1, -1, 1, 1, 1, 0, 0, {} } };
++ quint32 m_jointCount = 0;
++ QList<Entry> m_targetEntries;
++ QByteArray m_targetData;
++ quint32 m_numTargets = 0;
++ quint32 m_drawMode = 7; // Triangles
++ quint32 m_winding = 2; // CounterClockwise
++ QByteArray m_payload;
++ bool m_hasPayload = false;
++ QMap<int, quint32> m_patches;
++ };
++
++ class tst_QSSGMesh : public QObject
++ {
++ Q_OBJECT
++
++ private slots:
++ void builtMeshDecodes_data();
++ void builtMeshDecodes();
++ void subsetLayoutPerVersion_data();
++ void subsetLayoutPerVersion();
++ void entryNamesOfEveryLength_data();
++ void entryNamesOfEveryLength();
++ void indexBufferComponentTypes_data();
++ void indexBufferComponentTypes();
++ void multiMeshContainer();
++ void saveAndLoadRoundTrip_data();
++ void saveAndLoadRoundTrip();
++ void rejectsBadFileIds_data();
++ void rejectsBadFileIds();
++ void truncatedAtEveryOffset_data();
++ void truncatedAtEveryOffset();
++ void countsAndSizesAreBounded_data();
++ void countsAndSizesAreBounded();
++ void subsetsShareTheLevelOfDetailBudget();
++ void malformedLegacyMorphTargets_data();
++ void malformedLegacyMorphTargets();
++ void drawRangesAreValidated_data();
++ void drawRangesAreValidated();
++ void unknownComponentTypes_data();
++ void unknownComponentTypes();
++ void jointBlockIsSkipped();
++ void jointCountIsBounded();
++ void targetCountBeforeVersion7_data();
++ void targetCountBeforeVersion7();
++ void saveReportsAFailedWrite_data();
++ void saveReportsAFailedWrite();
++ void fuzzRegressions_data();
++ void fuzzRegressions();
++
++ private:
++ static Mesh load(const QByteArray &blob, quint32 id = 0)
++ {
++ QByteArray copy = blob;
++ QBuffer buffer(©);
++ buffer.open(QIODevice::ReadOnly);
++ return Mesh::loadMesh(&buffer, id);
++ }
++ // Finds a little endian quint32 with a distinctive value, so a field can be
++ // located without counting bytes by hand.
++ static qsizetype offsetOf(const QByteArray &blob, quint32 marker)
++ {
++ char needle[4];
++ qToLittleEndian(marker, needle);
++ return blob.indexOf(QByteArray(needle, 4));
++ }
++ // Overwrites a little endian quint32 at a byte offset, counted from the end
++ // of the blob when negative.
++ static QByteArray patched(const QByteArray &blob, qsizetype at, quint32 value)
++ {
++ QByteArray out = blob;
++ const qsizetype pos = at < 0 ? out.size() + at : at;
++ qToLittleEndian(value, out.data() + pos);
++ return out;
++ }
++ static QMap<quint32, Mesh> loadAll(const QByteArray &blob)
++ {
++ QByteArray copy = blob;
++ QBuffer buffer(©);
++ buffer.open(QIODevice::ReadOnly);
++ return Mesh::loadAll(&buffer);
++ }
++ };
++
++ void tst_QSSGMesh::builtMeshDecodes_data()
++ {
++ QTest::addColumn<int>("version");
++ for (int v = 3; v <= 7; ++v)
++ QTest::addRow("v%d", v) << v;
++ }
++
++ void tst_QSSGMesh::builtMeshDecodes()
++ {
++ QFETCH(int, version);
++
++ MeshBuilder builder;
++ builder.setVersion(quint16(version))
++ .setStride(20)
++ .setEntries({ { "attr_pos"_ba, 10, 3, 0 }, { "attr_uv0"_ba, 10, 2, 12 } })
++ .setVertexData(QByteArray(20 * 6, '\x2a'))
++ .setIndexData(QByteArray(2 * 6, '\x03'), 3 /* UnsignedInt16 */)
++ .setSubsets({ { QStringLiteral("first"), 3, 0, -2, -2, -2, 2, 2, 2, 0, 0, {} },
++ { QStringLiteral("second"), 3, 3, -1, -1, -1, 1, 1, 1, 0, 0, {} } })
++ .setDrawMode(7)
++ .setWinding(2);
++
++ const Mesh mesh = load(builder.build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(mesh.drawMode(), Mesh::DrawMode::Triangles);
++ QCOMPARE(mesh.winding(), Mesh::Winding::CounterClockwise);
++
++ const Mesh::VertexBuffer vb = mesh.vertexBuffer();
++ QCOMPARE(vb.stride, 20u);
++ QCOMPARE(vb.data, QByteArray(20 * 6, '\x2a'));
++ QCOMPARE(vb.entries.size(), 2);
++ QCOMPARE(vb.entries.at(0).name, "attr_pos"_ba);
++ QCOMPARE(vb.entries.at(0).componentCount, 3u);
++ QCOMPARE(vb.entries.at(0).offset, 0u);
++ QCOMPARE(vb.entries.at(1).name, "attr_uv0"_ba);
++ QCOMPARE(vb.entries.at(1).componentCount, 2u);
++ QCOMPARE(vb.entries.at(1).offset, 12u);
++
++ const Mesh::IndexBuffer ib = mesh.indexBuffer();
++ QCOMPARE(ib.componentType, Mesh::ComponentType::UnsignedInt16);
++ QCOMPARE(ib.data, QByteArray(2 * 6, '\x03'));
++
++ const QVector<Mesh::Subset> subsets = mesh.subsets();
++ QCOMPARE(subsets.size(), 2);
++ QCOMPARE(subsets.at(0).name, QStringLiteral("first"));
++ QCOMPARE(subsets.at(0).count, 3u);
++ QCOMPARE(subsets.at(0).offset, 0u);
++ QCOMPARE(subsets.at(0).bounds.min, QVector3D(-2, -2, -2));
++ QCOMPARE(subsets.at(0).bounds.max, QVector3D(2, 2, 2));
++ QCOMPARE(subsets.at(1).name, QStringLiteral("second"));
++ QCOMPARE(subsets.at(1).offset, 3u);
++ }
++
++ void tst_QSSGMesh::subsetLayoutPerVersion_data()
++ {
++ QTest::addColumn<int>("version");
++ QTest::addColumn<bool>("hasLightmapHint");
++ QTest::addColumn<bool>("hasLods");
++
++ QTest::newRow("v3") << 3 << false << false;
++ QTest::newRow("v4") << 4 << false << false;
++ QTest::newRow("v5") << 5 << true << false;
++ QTest::newRow("v6") << 6 << true << true;
++ QTest::newRow("v7") << 7 << true << true;
++ }
++
++ void tst_QSSGMesh::subsetLayoutPerVersion()
++ {
++ QFETCH(int, version);
++ QFETCH(bool, hasLightmapHint);
++ QFETCH(bool, hasLods);
++
++ // The subset record grew twice, so a reader that gets the size wrong for a
++ // version loses its place in the file and everything after it.
++ MeshBuilder::Subset subset;
++ subset.name = QStringLiteral("sub");
++ subset.count = 6;
++ subset.lightmapWidth = 64;
++ subset.lightmapHeight = 32;
++ subset.lods = { { 3, 0, 10.0f }, { 6, 3, 20.0f } };
++
++ const Mesh mesh = load(MeshBuilder()
++ .setVersion(quint16(version))
++ .setIndexData(QByteArray(4 * 9, '\0'), 5)
++ .setSubsets({ subset })
++ .build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(mesh.subsets().size(), 1);
++
++ // subsets() returns by value, so this has to be a copy.
++ const Mesh::Subset got = mesh.subsets().first();
++ QCOMPARE(got.name, QStringLiteral("sub"));
++ QCOMPARE(got.count, 6u);
++ QCOMPARE(got.lightmapSizeHint, hasLightmapHint ? QSize(64, 32) : QSize(0, 0));
++ QCOMPARE(got.lods.size(), hasLods ? 2 : 0);
++ if (hasLods) {
++ QCOMPARE(got.lods.at(0).count, 3u);
++ QCOMPARE(got.lods.at(0).offset, 0u);
++ QCOMPARE(got.lods.at(0).distance, 10.0f);
++ QCOMPARE(got.lods.at(1).count, 6u);
++ QCOMPARE(got.lods.at(1).distance, 20.0f);
++ }
++ }
++
++ void tst_QSSGMesh::entryNamesOfEveryLength_data()
++ {
++ QTest::addColumn<int>("length");
++ for (int n = 1; n <= 12; ++n)
++ QTest::addRow("len%d", n) << n;
++ }
++
++ void tst_QSSGMesh::entryNamesOfEveryLength()
++ {
++ QFETCH(int, length);
++
++ // Names are the only blocks whose size is not a multiple of four, so they
++ // are what exercises the padding rule.
++ const QByteArray name = QByteArray(length, 'a');
++ const Mesh mesh = load(MeshBuilder().setEntries({ { name, 10, 3, 0 } }).build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(mesh.vertexBuffer().entries.size(), 1);
++ QCOMPARE(mesh.vertexBuffer().entries.first().name, name);
++ QCOMPARE(mesh.subsets().size(), 1);
++ QCOMPARE(mesh.subsets().first().name, QStringLiteral("sub"));
++ }
++
++ void tst_QSSGMesh::indexBufferComponentTypes_data()
++ {
++ QTest::addColumn<int>("componentType");
++ QTest::addColumn<int>("expected");
++
++ QTest::newRow("u8") << 1 << int(Mesh::ComponentType::UnsignedInt8);
++ QTest::newRow("u16") << 3 << int(Mesh::ComponentType::UnsignedInt16);
++ QTest::newRow("u32") << 5 << int(Mesh::ComponentType::UnsignedInt32);
++ QTest::newRow("f32") << 10 << int(Mesh::ComponentType::Float32);
++ }
++
++ void tst_QSSGMesh::indexBufferComponentTypes()
++ {
++ QFETCH(int, componentType);
++ QFETCH(int, expected);
++
++ const Mesh mesh =
++ load(MeshBuilder().setIndexData(QByteArray(16, '\0'), quint32(componentType)).build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(int(mesh.indexBuffer().componentType), expected);
++ }
++
++ void tst_QSSGMesh::multiMeshContainer()
++ {
++ const QByteArray first = MeshBuilder()
++ .setStride(12)
++ .setVertexData(QByteArray(36, '\x11'))
++ .setSubsets({ { QStringLiteral("one"), 3, 0, -1, -1, -1, 1, 1,
++ 1, 0, 0, {} } })
++ .mesh();
++ const QByteArray second = MeshBuilder()
++ .setStride(16)
++ .setVertexData(QByteArray(48, '\x22'))
++ .setSubsets({ { QStringLiteral("two"), 3, 0, -2, -2, -2, 2, 2,
++ 2, 0, 0, {} } })
++ .mesh();
++ const QByteArray blob = MeshBuilder::container({ first, second }, { 1, 7 });
++
++ const QMap<quint32, Mesh> all = loadAll(blob);
++ QCOMPARE(all.size(), 2);
++ QVERIFY(all.contains(1));
++ QVERIFY(all.contains(7));
++ QCOMPARE(all.value(1).vertexBuffer().stride, 12u);
++ QCOMPARE(all.value(1).subsets().first().name, QStringLiteral("one"));
++ QCOMPARE(all.value(7).vertexBuffer().stride, 16u);
++ QCOMPARE(all.value(7).subsets().first().name, QStringLiteral("two"));
++
++ // By id, and id 0 meaning "the first one".
++ QCOMPARE(load(blob, 7).vertexBuffer().stride, 16u);
++ QCOMPARE(load(blob, 0).vertexBuffer().stride, 12u);
++ QVERIFY(!load(blob, 99).isValid());
++ }
++
++ void tst_QSSGMesh::saveAndLoadRoundTrip_data()
++ {
++ QTest::addColumn<int>("version");
++ for (int v = 3; v <= 7; ++v)
++ QTest::addRow("v%d", v) << v;
++ }
++
++ void tst_QSSGMesh::saveAndLoadRoundTrip()
++ {
++ QFETCH(int, version);
++
++ // Whatever version came in, save() writes the current one, and the result
++ // has to describe the same geometry.
++ MeshBuilder::Subset subset;
++ subset.name = QStringLiteral("round");
++ subset.count = 9;
++ subset.lightmapWidth = 16;
++ subset.lightmapHeight = 8;
++ subset.lods = { { 6, 0, 5.0f } };
++
++ const Mesh loaded = load(MeshBuilder()
++ .setVersion(quint16(version))
++ .setStride(12)
++ .setEntries({ { "attr_pos"_ba, 10, 3, 0 } })
++ .setVertexData(QByteArray(12 * 9, '\x7f'))
++ .setIndexData(QByteArray(4 * 9, '\x05'), 5)
++ .setSubsets({ subset })
++ .build());
++ QVERIFY(loaded.isValid());
++
++ QByteArray saved;
++ QBuffer out(&saved);
++ QVERIFY(out.open(QIODevice::ReadWrite));
++ QCOMPARE(loaded.save(&out), 1u);
++
++ const Mesh again = load(saved);
++ QVERIFY(again.isValid());
++ QCOMPARE(again.vertexBuffer().stride, loaded.vertexBuffer().stride);
++ QCOMPARE(again.vertexBuffer().data, loaded.vertexBuffer().data);
++ QCOMPARE(again.indexBuffer().data, loaded.indexBuffer().data);
++ QCOMPARE(int(again.indexBuffer().componentType), int(loaded.indexBuffer().componentType));
++ QCOMPARE(again.subsets().size(), loaded.subsets().size());
++ QCOMPARE(again.subsets().first().name, loaded.subsets().first().name);
++ QCOMPARE(again.subsets().first().count, loaded.subsets().first().count);
++ QCOMPARE(again.subsets().first().offset, loaded.subsets().first().offset);
++ QCOMPARE(again.subsets().first().lightmapSizeHint, loaded.subsets().first().lightmapSizeHint);
++ QCOMPARE(again.subsets().first().lods.size(), loaded.subsets().first().lods.size());
++ QCOMPARE(again.vertexBuffer().entries.size(), loaded.vertexBuffer().entries.size());
++ QCOMPARE(again.vertexBuffer().entries.first().name,
++ loaded.vertexBuffer().entries.first().name);
++ }
++
++ void tst_QSSGMesh::rejectsBadFileIds_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ const QByteArray good = MeshBuilder().build();
++
++ QTest::newRow("empty") << QByteArray();
++ QTest::newRow("shorter-than-the-footer") << good.left(8);
++
++ QByteArray badContainerId = good;
++ badContainerId[badContainerId.size() - 16] = char(0);
++ QTest::newRow("bad-container-id") << badContainerId;
++
++ QByteArray badContainerVersion = good;
++ badContainerVersion[badContainerVersion.size() - 12] = char(9);
++ QTest::newRow("bad-container-version") << badContainerVersion;
++
++ QTest::newRow("bad-mesh-id") << MeshBuilder().patchMeshField(0, 0).build().replace(
++ 0, 4, QByteArray(4, '\0'));
++
++ QTest::newRow("version-too-old")
++ << MeshBuilder().setVersion(2).build();
++ QTest::newRow("version-too-new")
++ << MeshBuilder().setVersion(8).build();
++ }
++
++ void tst_QSSGMesh::rejectsBadFileIds()
++ {
++ QFETCH(QByteArray, data);
++
++ // A rejected file has to come back as an invalid Mesh, never as a crash and
++ // never as something half parsed.
++ const Mesh mesh = load(data);
++ QVERIFY(!mesh.isValid());
++ QVERIFY(mesh.subsets().isEmpty());
++ }
++
++ void tst_QSSGMesh::truncatedAtEveryOffset_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ for (int v = 3; v <= 7; ++v) {
++ MeshBuilder builder;
++ builder.setVersion(quint16(v))
++ .setEntries({ { "attr_pos"_ba, 10, 3, 0 } })
++ .setIndexData(QByteArray(4 * 3, '\x01'), 5);
++ if (v >= 6) {
++ MeshBuilder::Subset subset;
++ subset.name = QStringLiteral("s");
++ subset.count = 3;
++ subset.lods = { { 3, 0, 1.0f } };
++ builder.setSubsets({ subset });
++ }
++ QTest::addRow("v%d", v) << builder.build();
++ }
++ }
++
++ void tst_QSSGMesh::truncatedAtEveryOffset()
++ {
++ QFETCH(QByteArray, data);
++
++ // Cutting a known good file at every length at once. Nothing may crash,
++ // hang or allocate wildly, and the result is either the whole mesh or none
++ // of it.
++ for (qsizetype n = 0; n < data.size(); ++n) {
++ const Mesh mesh = load(data.left(n));
++ if (mesh.isValid())
++ QCOMPARE(n, data.size());
++ }
++ }
++
++ void tst_QSSGMesh::countsAndSizesAreBounded_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ const quint32 huge = 0xfffffff0u;
++
++ // Every count in the header sizes an allocation or bounds a loop, and none
++ // of them can be larger than the file that holds the data.
++ QTest::newRow("vertex-entry-count") << MeshBuilder().patchMeshField(1, huge).build();
++ QTest::newRow("vertex-data-size") << MeshBuilder().patchMeshField(4, huge).build();
++ QTest::newRow("index-data-size") << MeshBuilder().patchMeshField(7, huge).build();
++ QTest::newRow("subset-count") << MeshBuilder().patchMeshField(9, huge).build();
++ QTest::newRow("target-entry-count") << MeshBuilder().patchMeshField(0, huge).build();
++ QTest::newRow("target-data-size")
++ << MeshBuilder()
++ .setTargetEntries({ { "attr_pos"_ba, 10, 3, 0 } })
++ .setTargetData(QByteArray(16, '\0'), 1)
++ .patchMeshField(3, huge)
++ .build();
++
++ // The mesh count is in the container footer, which is the last four bytes.
++ QTest::newRow("mesh-count") << patched(MeshBuilder().build(), -4, huge);
++
++ // An attribute name length, which is the first quint32 after the entry list
++ // and its padding: 56 bytes of Mesh struct, 16 of entry, 4 of padding.
++ QTest::newRow("attribute-name-length")
++ << patched(MeshBuilder().build(), 12 + 56 + 16 + 4, huge);
++
++ // A subset name length. The subset gets a distinctive count so its record
++ // can be found, and nameLength is nine quint32 fields further on.
++ MeshBuilder::Subset marked;
++ marked.name = QStringLiteral("sub");
++ marked.count = 0x51525354u;
++ const QByteArray oneSubset = MeshBuilder().setSubsets({ marked }).build();
++ const qsizetype subsetAt = offsetOf(oneSubset, marked.count);
++ QVERIFY(subsetAt >= 0);
++ QTest::newRow("subset-name-length") << patched(oneSubset, subsetAt + 9 * 4, huge);
++ }
++
++ void tst_QSSGMesh::countsAndSizesAreBounded()
++ {
++ QFETCH(QByteArray, data);
++
++ const Mesh mesh = load(data);
++ QVERIFY(!mesh.isValid());
++ QVERIFY(mesh.vertexBuffer().data.isEmpty());
++ QVERIFY(mesh.targetBuffer().data.isEmpty());
++ }
++
++ void tst_QSSGMesh::subsetsShareTheLevelOfDetailBudget()
++ {
++ // One subset carries real levels, so there are bytes for the others to claim.
++ constexpr int levels = 100;
++ MeshBuilder::Subset withLods;
++ withLods.name = QStringLiteral("a");
++ withLods.count = 0x41414141u;
++ for (int i = 0; i < levels; ++i)
++ withLods.lods.append({ 0, 0, float(i) });
++
++ MeshBuilder::Subset second;
++ second.name = QStringLiteral("b");
++ second.count = 0x42424242u;
++ second.lods.append({ 0, 0, 0.0f });
++
++ MeshBuilder::Subset third;
++ third.name = QStringLiteral("c");
++ third.count = 0x43434343u;
++ third.lods.append({ 0, 0, 0.0f });
++
++ QByteArray blob = MeshBuilder().setSubsets({ withLods, second, third }).build();
++
++ // lodCount is the thirteenth quint32 of a subset record.
++ for (quint32 marker : { 0x42424242u, 0x43434343u }) {
++ const qsizetype at = offsetOf(blob, marker);
++ QVERIFY(at >= 0);
++ blob = patched(blob, at + 12 * 4, quint32(levels));
++ }
++
++ const Mesh mesh = load(blob);
++ QVERIFY(!mesh.isValid());
++ }
++
++ void tst_QSSGMesh::malformedLegacyMorphTargets_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ // Before version 7 morph targets were extra vertex buffer entries, and the
++ // reader rebuilds a target buffer out of them. Every step of that layout
++ // comes from the file.
++ QTest::newRow("no-target-zero-to-copy-from")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(16)
++ .setEntries({ { "attr_tpos1"_ba, 10, 3, 0 },
++ { "attr_unsupported"_ba, 10, 3, 12 } })
++ .setVertexData(QByteArray(32, '\0'))
++ .build();
++
++ QList<MeshBuilder::Entry> nineTargets;
++ for (int i = 0; i < 9; ++i)
++ nineTargets.append({ QByteArrayLiteral("attr_tpos") + QByteArray::number(i), 10, 3, 0 });
++ QTest::newRow("more-targets-than-the-format-allows")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(12)
++ .setEntries(nineTargets)
++ .setVertexData(QByteArray(12, '\0'))
++ .build();
++
++ QList<MeshBuilder::Entry> fiveComponents;
++ for (int i = 0; i < 5; ++i)
++ fiveComponents.append({ "attr_tpos0"_ba, 10, 3, 0 });
++ QTest::newRow("more-components-per-target-than-exist")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(12)
++ .setEntries(fiveComponents)
++ .setVertexData(QByteArray(12, '\0'))
++ .build();
++
++ QTest::newRow("stride-zero")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(0)
++ .setEntries({ { "attr_tpos0"_ba, 10, 3, 0 } })
++ .setVertexData(QByteArray(16, '\0'))
++ .build();
++
++ // One entry claiming to belong to target 7, so eight targets are inferred
++ // from one entry and the components per target round down to zero.
++ QTest::newRow("fewer-entries-than-targets")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(16)
++ .setEntries({ { "attr_tpos7"_ba, 10, 3, 0 } })
++ .setVertexData(QByteArray(16, '\0'))
++ .build();
++
++ // No vertex data at all, so there is no vertex to build a target from and
++ // the vertex count the layout is derived from is zero.
++ QTest::newRow("no-vertices")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(16)
++ .setEntries({ { "attr_tpos0"_ba, 10, 3, 0 } })
++ .setVertexData(QByteArray())
++ .build();
++
++ // A name too short to slice from the seventh byte. Once one target
++ // attribute has been seen every later name takes that path, whatever it is.
++ QTest::newRow("short-attribute-name-after-a-target")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(16)
++ .setEntries({ { "attr_tpos0"_ba, 10, 3, 0 }, { "ab"_ba, 10, 3, 12 } })
++ .setVertexData(QByteArray(32, '\0'))
++ .build();
++
++ // An attribute offset past the end of the vertex data it is read from.
++ QTest::newRow("entry-offset-past-the-vertex-data")
++ << MeshBuilder()
++ .setVersion(6)
++ .setStride(4)
++ .setEntries({ { "attr_tpos0"_ba, 10, 3, 0x7ffffff0 } })
++ .setVertexData(QByteArray(64, '\0'))
++ .build();
++ }
++
++ void tst_QSSGMesh::malformedLegacyMorphTargets()
++ {
++ QFETCH(QByteArray, data);
++
++ const Mesh mesh = load(data);
++ QVERIFY(!mesh.isValid());
++ QVERIFY(mesh.targetBuffer().data.isEmpty());
++ }
++
++ void tst_QSSGMesh::drawRangesAreValidated_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++ QTest::addColumn<bool>("valid");
++
++ // Nine indices of four bytes, so nine are available to draw.
++ auto indexed = [](quint32 count, quint32 offset) {
++ MeshBuilder::Subset subset;
++ subset.name = QStringLiteral("s");
++ subset.count = count;
++ subset.offset = offset;
++ return MeshBuilder()
++ .setIndexData(QByteArray(4 * 9, '\0'), 5)
++ .setSubsets({ subset })
++ .build();
++ };
++ QTest::newRow("whole-index-buffer") << indexed(9, 0) << true;
++ QTest::newRow("tail-of-the-index-buffer") << indexed(3, 6) << true;
++ QTest::newRow("one-index-too-many") << indexed(10, 0) << false;
++ QTest::newRow("one-past-the-end") << indexed(3, 7) << false;
++ QTest::newRow("offset-past-the-end") << indexed(1, 4000) << false;
++ QTest::newRow("count-wraps") << indexed(0xffffffffu, 8) << false;
++
++ // With no index buffer the ranges are vertices instead: 36 bytes at a
++ // stride of 12 is three of them.
++ auto nonIndexed = [](quint32 count, quint32 offset) {
++ MeshBuilder::Subset subset;
++ subset.name = QStringLiteral("s");
++ subset.count = count;
++ subset.offset = offset;
++ return MeshBuilder()
++ .setStride(12)
++ .setVertexData(QByteArray(36, '\0'))
++ .setSubsets({ subset })
++ .build();
++ };
++ QTest::newRow("whole-vertex-buffer") << nonIndexed(3, 0) << true;
++ QTest::newRow("one-vertex-too-many") << nonIndexed(4, 0) << false;
++
++ // A level of detail range is drawn the same way and gets the same check.
++ MeshBuilder::Subset withLods;
++ withLods.name = QStringLiteral("s");
++ withLods.count = 9;
++ withLods.lods = { { 6, 0, 1.0f }, { 4, 6, 2.0f } };
++ QTest::newRow("level-of-detail-past-the-end")
++ << MeshBuilder()
++ .setVersion(6)
++ .setIndexData(QByteArray(4 * 9, '\0'), 5)
++ .setSubsets({ withLods })
++ .build()
++ << false;
++ }
++
++ void tst_QSSGMesh::drawRangesAreValidated()
++ {
++ QFETCH(QByteArray, data);
++ QFETCH(bool, valid);
++
++ QCOMPARE(load(data).isValid(), valid);
++ }
++
++ void tst_QSSGMesh::unknownComponentTypes_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ // getSizeOfType() is Q_UNREACHABLE for anything outside the enumeration, so
++ // a component type from the file has to be checked before it gets there.
++ for (quint32 bad : { 0u, 12u, 0xffffffffu }) {
++ QTest::addRow("index-buffer-%u", bad)
++ << MeshBuilder().setIndexData(QByteArray(16, '\0'), bad).build();
++ QTest::addRow("attribute-%u", bad)
++ << MeshBuilder().setEntries({ { "attr_pos"_ba, bad, 3, 0 } }).build();
++ QTest::addRow("morph-target-attribute-%u", bad)
++ << MeshBuilder()
++ .setTargetEntries({ { "attr_pos"_ba, bad, 3, 0 } })
++ .setTargetData(QByteArray(16, '\0'), 1)
++ .build();
++ }
++ }
++
++ void tst_QSSGMesh::unknownComponentTypes()
++ {
++ QFETCH(QByteArray, data);
++
++ QVERIFY(!load(data).isValid());
++ }
++
++ void tst_QSSGMesh::jointBlockIsSkipped()
++ {
++ // The joint records sit between the level of detail data and the morph
++ // target entries, so a reader that does not step over them reads the target
++ // entries out of joint data. Nothing Qt writes has joints, but the format
++ // has them and Qt 3D Studio wrote them.
++ MeshBuilder builder;
++ builder.setStride(12)
++ .setEntries({ { "attr_pos"_ba, 10, 3, 0 } })
++ .setVertexData(QByteArray(36, '\x11'))
++ .setJointCount(2)
++ .setTargetEntries({ { "attr_pos"_ba, 10, 3, 0 } })
++ .setTargetData(QByteArray(48, '\x22'), 1);
++
++ const Mesh mesh = load(builder.build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(mesh.targetBuffer().numTargets, 1u);
++ QCOMPARE(mesh.targetBuffer().entries.size(), 1);
++ QCOMPARE(mesh.targetBuffer().entries.first().name, "attr_pos"_ba);
++ QCOMPARE(mesh.targetBuffer().data, QByteArray(48, '\x22'));
++ QCOMPARE(mesh.vertexBuffer().data, QByteArray(36, '\x11'));
++ }
++
++ void tst_QSSGMesh::jointCountIsBounded()
++ {
++ const quint32 huge = 0xfffffff0u;
++ QVERIFY(!load(MeshBuilder().patchMeshField(11, huge).build()).isValid());
++ }
++
++ void tst_QSSGMesh::targetCountBeforeVersion7_data()
++ {
++ QTest::addColumn<int>("version");
++ QTest::addColumn<quint32>("expected");
++
++ // Field nine is a target count only from version 7. Before that it is a
++ // subset offset the writer left behind, which is not a count of anything.
++ for (int v = 3; v <= 6; ++v)
++ QTest::addRow("v%d", v) << v << 0u;
++ QTest::newRow("v7") << 7 << 3u;
++ }
++
++ void tst_QSSGMesh::targetCountBeforeVersion7()
++ {
++ QFETCH(int, version);
++ QFETCH(quint32, expected);
++
++ const Mesh mesh = load(MeshBuilder()
++ .setVersion(quint16(version))
++ .patchMeshField(8, 3)
++ .build());
++ QVERIFY(mesh.isValid());
++ QCOMPARE(mesh.targetBuffer().numTargets, expected);
++ }
++
++ void tst_QSSGMesh::saveReportsAFailedWrite_data()
++ {
++ QTest::addColumn<int>("limit");
++
++ // Enough to get through the header and part of the payload, so the failure
++ // lands in a different write each time.
++ for (int limit : { 0, 16, 80, 100, 140, 200 })
++ QTest::addRow("limit%d", limit) << limit;
++ }
++
++ void tst_QSSGMesh::saveReportsAFailedWrite()
++ {
++ QFETCH(int, limit);
++
++ const Mesh mesh = load(MeshBuilder()
++ .setStride(12)
++ .setVertexData(QByteArray(36, '\x33'))
++ .setIndexData(QByteArray(4 * 3, '\x01'), 5)
++ .build());
++ QVERIFY(mesh.isValid());
++
++ // A device that fills up must not come back as a successful save, or the
++ // caller writes a truncated file and reports success.
++ ShortDevice device(limit);
++ QVERIFY(device.open(QIODevice::WriteOnly));
++ QCOMPARE(mesh.save(&device), 0u);
++ }
++
++ void tst_QSSGMesh::fuzzRegressions_data()
++ {
++ QTest::addColumn<QByteArray>("data");
++
++ const QString dir = QFINDTESTDATA(QStringLiteral("data/fuzz"));
++ if (dir.isEmpty())
++ return;
++ const QFileInfoList entries = QDir(dir).entryInfoList({ u"*.mesh"_s }, QDir::Files, QDir::Name);
++ for (const QFileInfo &info : entries) {
++ QFile f(info.absoluteFilePath());
++ if (!f.open(QIODevice::ReadOnly))
++ continue;
++ QTest::newRow(qPrintable(info.fileName())) << f.readAll();
++ }
++ }
++
++ void tst_QSSGMesh::fuzzRegressions()
++ {
++ if (QTest::currentDataTag() == nullptr)
++ QSKIP("no fuzz findings recorded yet");
++ QFETCH(QByteArray, data);
++
++ // Minimised fuzzer findings. Each one has to either come back invalid or
++ // describe itself consistently, never crash.
++ const Mesh mesh = load(data);
++ if (!mesh.isValid())
++ return;
++ const Mesh::VertexBuffer vb = mesh.vertexBuffer();
++ for (const Mesh::Subset &subset : mesh.subsets())
++ QVERIFY(subset.count <= quint32(vb.data.size()) + quint32(mesh.indexBuffer().data.size()));
++ }
++
++ QTEST_APPLESS_MAIN(tst_QSSGMesh)
++
++ #include "tst_qssgmesh.moc"