Crafter.Asset/implementations/Crafter.Asset-GLB.cpp

1120 lines
62 KiB
C++
Raw Normal View History

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
// nlohmann/json stays inside this unit. Included from the interface, it
// would be parsed again by everything that imports Crafter.Asset.
#include "../lib/json.hpp"
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// errno, for why a file that exists doesn't open.
#include <cerrno>
module Crafter.Asset;
import std;
import Crafter.Math;
namespace fs = std::filesystem;
// Json, not json: GlbAsset has a member called json, which would hide it
// inside GlbAsset::Load.
using Json = nlohmann::json;
using namespace Crafter;
namespace {
constexpr std::uint32_t kMagic = 0x46546C67; // "glTF"
constexpr std::uint32_t kChunkJson = 0x4E4F534A; // "JSON"
constexpr std::uint32_t kChunkBin = 0x004E4942; // "BIN\0"
constexpr std::uint32_t kByte = 5120;
constexpr std::uint32_t kUnsignedByte = 5121;
constexpr std::uint32_t kShort = 5122;
constexpr std::uint32_t kUnsignedShort = 5123;
constexpr std::uint32_t kUnsignedInt = 5125;
constexpr std::uint32_t kFloat = 5126;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Primitive modes; those below kTriangles are points and lines.
constexpr std::int64_t kTriangles = 4;
constexpr std::int64_t kTriangleStrip = 5;
constexpr std::int64_t kTriangleFan = 6;
// The decode limit when Load isn't given one: 1 GiB, the largest image a
// texture takes, and 64 bytes for each byte of the file, more than a
// model's vertices and images decode to unless the same data is decoded
// over and over.
constexpr std::uint64_t kDecodeFloor = std::uint64_t{1} << 30;
constexpr std::uint64_t kDecodePerByte = 64;
// The extensions this loader implements. A file that requires another
// can't be loaded right without it, so, as glTF asks, it isn't loaded.
constexpr std::array<std::string_view, 4> kImplemented = {
"KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_ior", "KHR_materials_transmission",
};
// An index the file gives that can't name anything, as GlbAsset's int32
// keeps it; CheckReferences turns it down.
constexpr std::int32_t kBadIndex = std::numeric_limits<std::int32_t>::min();
// A texCoord past the two UV sets a primitive keeps, likewise.
constexpr std::uint32_t kBadTexCoord = std::numeric_limits<std::uint32_t>::max();
using Pixel = Vector<std::uint8_t, 4, 4>;
using Image = TextureAsset<Pixel>;
std::unexpected<std::string> Error(std::string message) {
return std::unexpected("GLB: " + std::move(message));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::uint32_t U32(std::span<const std::byte> bytes, std::size_t offset) {
std::uint32_t value;
std::memcpy(&value, bytes.data() + offset, sizeof(value));
return value;
}
// Reads from the parsed JSON that never throw (a -fno-exceptions build
// aborts on a throw): a missing value or one of the wrong type reads as
// the fallback.
const Json* Find(const Json& object, const char* key) {
if (!object.is_object()) return nullptr;
const auto it = object.find(key);
return it == object.end() ? nullptr : &*it;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// A whole number: 24, but also 24.0 or 2.4e1, as JSON may write one.
// One past int64, which no index or count reaches, comes back as
// int64's limit of its sign, which range checks still turn down.
std::optional<std::int64_t> AsInteger(const Json& value) {
if (value.is_number_unsigned()) {
return static_cast<std::int64_t>(std::min<std::uint64_t>(value.get<std::uint64_t>(), std::numeric_limits<std::int64_t>::max()));
}
if (value.is_number_integer()) return value.get<std::int64_t>();
if (!value.is_number_float()) return std::nullopt;
const double number = value.get<double>();
if (number != std::trunc(number)) return std::nullopt; // a fraction, or NaN
constexpr double kTwoTo63 = 9223372036854775808.0;
if (number >= kTwoTo63) return std::numeric_limits<std::int64_t>::max();
if (number < -kTwoTo63) return std::numeric_limits<std::int64_t>::min();
return static_cast<std::int64_t>(number);
}
std::int64_t Integer(const Json& object, const char* key, std::int64_t fallback) {
const Json* value = Find(object, key);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
return value != nullptr ? AsInteger(*value).value_or(fallback) : fallback;
}
// An index into one of the file's arrays, as GlbAsset keeps it: -1 when
// `key` is absent, kBadIndex when it can't name an element (negative,
// not a whole number, past int32). Checked before it is narrowed, so
// that 4294967296 can't wrap round to 0.
std::int32_t Index(const Json& object, const char* key) {
const Json* value = Find(object, key);
if (value == nullptr) return -1;
const std::optional<std::int64_t> index = AsInteger(*value);
return index && *index >= 0 && *index <= std::numeric_limits<std::int32_t>::max() ? static_cast<std::int32_t>(*index) : kBadIndex;
}
// An element of a list of indices (children, joints, a scene's nodes)
// that names one of `count`, or nullopt.
std::optional<std::uint32_t> Element(const Json& value, std::size_t count) {
const std::optional<std::int64_t> index = AsInteger(value);
if (!index || *index < 0 || static_cast<std::uint64_t>(*index) >= count || *index > std::numeric_limits<std::uint32_t>::max()) {
return std::nullopt;
}
return static_cast<std::uint32_t>(*index);
}
float Number(const Json& object, const char* key, float fallback) {
const Json* value = Find(object, key);
return value != nullptr && value->is_number() ? value->get<float>() : fallback;
}
bool Boolean(const Json& object, const char* key, bool fallback) {
const Json* value = Find(object, key);
return value != nullptr && value->is_boolean() ? value->get<bool>() : fallback;
}
std::string String(const Json& object, const char* key) {
const Json* value = Find(object, key);
return value != nullptr && value->is_string() ? value->get<std::string>() : std::string();
}
const Json& Array(const Json& object, const char* key) {
static const Json empty = Json::array();
const Json* value = Find(object, key);
return value != nullptr && value->is_array() ? *value : empty;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// An object's extras as JSON text, or empty when it has none. Written
// without recursion: Json::dump recurses once a level, and extras nested
// deep enough run it out of stack, where the parser and the destructor
// don't recurse.
std::string Extras(const Json& object) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const Json* extras = Find(object, "extras");
if (extras == nullptr || extras->is_null()) return {};
std::string text;
// The arrays and objects being written, each with its next element.
std::vector<std::pair<const Json*, Json::const_iterator>> open;
const Json* value = extras;
while (true) {
if (value != nullptr) {
if (value->is_structured() && !value->empty()) {
text += value->is_object() ? '{' : '[';
open.emplace_back(value, value->cbegin());
} else {
text += value->dump(); // a scalar, {} or []: nothing nested
}
}
if (open.empty()) return text;
auto& [container, next] = open.back();
if (next == container->cend()) {
text += container->is_object() ? '}' : ']';
open.pop_back();
value = nullptr;
continue;
}
if (next != container->cbegin()) text += ',';
if (container->is_object()) {
text += Json(next.key()).dump();
text += ':';
}
value = &*next;
++next;
}
}
// The object an extension keeps in `object`'s extensions, or null.
const Json* Extension(const Json& object, const char* name) {
const Json* extensions = Find(object, "extensions");
return extensions != nullptr ? Find(*extensions, name) : nullptr;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Numbers from a JSON array into `out`, which keeps its values where the
// array is shorter or holds something else.
void Numbers(const Json& object, const char* key, std::span<float> out) {
const Json& values = Array(object, key);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
for (std::size_t i = 0; i < out.size() && i < values.size(); ++i) {
if (values[i].is_number()) out[i] = values[i].get<float>();
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::size_t ComponentSize(std::int64_t componentType) {
switch (componentType) {
case kByte: case kUnsignedByte: return 1;
case kShort: case kUnsignedShort: return 2;
case kUnsignedInt: case kFloat: return 4;
default: return 0;
}
}
std::uint32_t ComponentCount(const std::string& type) {
if (type == "SCALAR") return 1;
if (type == "VEC2") return 2;
if (type == "VEC3") return 3;
if (type == "VEC4") return 4;
if (type == "MAT4") return 16;
return 0;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// An accessor resolved to its bytes: in the BIN chunk, or, for one that
// is sparse or has no bufferView, in `dense`, where they were put
// together.
struct Accessor {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::byte* data = nullptr;
std::size_t count = 0;
std::size_t stride = 0;
std::uint32_t componentType = 0;
std::uint32_t components = 0;
bool normalized = false;
std::vector<std::byte> dense;
const std::byte* At(std::size_t element, std::uint32_t component) const {
return (dense.empty() ? data : dense.data()) + element * stride + component * ComponentSize(componentType);
}
float Float(std::size_t element, std::uint32_t component) const {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::byte* p = At(element, component);
switch (componentType) {
case kFloat: { float v; std::memcpy(&v, p, sizeof(v)); return v; }
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 255.0f : v; }
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 65535.0f : v; }
case kByte: { std::int8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 127.0f, -1.0f) : v; }
case kShort: { std::int16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 32767.0f, -1.0f) : v; }
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return static_cast<float>(v); }
default: return 0.0f;
}
}
std::uint32_t Unsigned(std::size_t element, std::uint32_t component) const {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::byte* p = At(element, component);
switch (componentType) {
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return v; }
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return v; }
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return v; }
default: return 0;
}
}
};
struct Context {
const Json& root;
std::span<const std::byte> bin;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The decode limit, and what is left of it.
std::uint64_t limit;
std::uint64_t budget;
};
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Takes `count` elements of `size` bytes out of the budget; false when
// they don't fit in it, or in a size_t.
bool Spend(Context& ctx, std::uint64_t count, std::uint64_t size) {
if (count > ctx.budget / size || count > std::numeric_limits<std::size_t>::max() / size) return false;
ctx.budget -= count * size;
return true;
}
std::unexpected<std::string> OverLimit(const Context& ctx, std::string_view what) {
return Error(std::format("{} decodes past the limit of {} bytes", what, ctx.limit));
}
// Sizes `stream` to `count` elements, out of the budget.
template <typename T>
bool Allocate(Context& ctx, std::vector<T>& stream, std::size_t count) {
if (!Spend(ctx, count, sizeof(T))) return false;
stream.resize(count);
return true;
}
// The bytes of buffer view `index`, which must lie in the BIN chunk.
std::expected<std::span<const std::byte>, std::string> ViewBytes(const Context& ctx, std::int64_t index, std::size_t* strideOut) {
const Json& views = Array(ctx.root, "bufferViews");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (index < 0 || static_cast<std::uint64_t>(index) >= views.size()) {
return Error(std::format("bufferView {} doesn't exist", index));
}
const Json& view = views[static_cast<std::size_t>(index)];
if (Integer(view, "buffer", -1) != 0) {
return Error(std::format("bufferView {} uses an external buffer, which a GLB loader doesn't read", index));
}
const std::int64_t offset = Integer(view, "byteOffset", 0);
const std::int64_t length = Integer(view, "byteLength", -1);
// Compared so that nothing can overflow.
const std::uint64_t size = ctx.bin.size();
if (offset < 0 || length < 0 || static_cast<std::uint64_t>(offset) > size || static_cast<std::uint64_t>(length) > size - static_cast<std::uint64_t>(offset)) {
return Error(std::format("bufferView {} lies outside the BIN chunk", index));
}
// glTF allows 4 to 252 bytes between elements; 0 or none packs them.
const std::int64_t stride = Integer(view, "byteStride", 0);
if (stride != 0 && (stride < 4 || stride > 252)) {
return Error(std::format("bufferView {} has a byteStride of {}, outside 4..252", index, stride));
}
if (strideOut != nullptr) *strideOut = static_cast<std::size_t>(stride);
return ctx.bin.subspan(static_cast<std::size_t>(offset), static_cast<std::size_t>(length));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// `count` elements of `size` bytes, packed at the bufferView and
// byteOffset `object` names: a sparse accessor's indices or values.
std::expected<std::span<const std::byte>, std::string> Packed(const Context& ctx, const Json& object, std::uint64_t count, std::size_t size) {
const auto view = ViewBytes(ctx, Integer(object, "bufferView", -1), nullptr);
if (!view) return std::unexpected(view.error());
const std::int64_t offset = Integer(object, "byteOffset", 0);
const std::uint64_t available = view->size();
if (offset < 0 || static_cast<std::uint64_t>(offset) > available || count > (available - static_cast<std::uint64_t>(offset)) / size) {
return Error("sparse data reads past the end of its bufferView");
}
return view->subspan(static_cast<std::size_t>(offset), static_cast<std::size_t>(count * size));
}
// A sparse accessor's values, written over the elements its indices name
// in `accessor`, whose elements are already in `dense`.
std::expected<void, std::string> ApplySparse(const Context& ctx, const Json& sparse, Accessor& accessor, std::int64_t index) {
const std::int64_t count = Integer(sparse, "count", -1);
const Json* indices = Find(sparse, "indices");
const Json* values = Find(sparse, "values");
const std::int64_t indexType = indices != nullptr ? Integer(*indices, "componentType", 0) : 0;
if (count < 0 || static_cast<std::uint64_t>(count) > accessor.count || values == nullptr
|| (indexType != kUnsignedByte && indexType != kUnsignedShort && indexType != kUnsignedInt)) {
return Error(std::format("accessor {} has a malformed sparse part", index));
}
const std::size_t indexSize = ComponentSize(indexType);
const auto indexBytes = Packed(ctx, *indices, static_cast<std::uint64_t>(count), indexSize);
if (!indexBytes) return std::unexpected(std::format("{} (sparse indices of accessor {})", indexBytes.error(), index));
const std::size_t elementSize = accessor.stride;
const auto valueBytes = Packed(ctx, *values, static_cast<std::uint64_t>(count), elementSize);
if (!valueBytes) return std::unexpected(std::format("{} (sparse values of accessor {})", valueBytes.error(), index));
const Accessor elements{
.data = indexBytes->data(),
.count = static_cast<std::size_t>(count),
.stride = indexSize,
.componentType = static_cast<std::uint32_t>(indexType),
.components = 1,
};
std::int64_t previous = -1;
for (std::size_t i = 0; i < elements.count; ++i) {
const std::uint32_t element = elements.Unsigned(i, 0);
// glTF has them strictly increase, which also keeps each one
// inside the accessor once.
if (element <= previous || element >= accessor.count) {
return Error(std::format("accessor {}'s sparse indices don't rise within its {} elements", index, accessor.count));
}
previous = element;
std::memcpy(accessor.dense.data() + std::size_t{element} * elementSize, valueBytes->data() + i * elementSize, elementSize);
}
return {};
}
std::expected<Accessor, std::string> ReadAccessor(Context& ctx, std::int64_t index) {
const Json& accessors = Array(ctx.root, "accessors");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (index < 0 || static_cast<std::uint64_t>(index) >= accessors.size()) {
return Error(std::format("accessor {} doesn't exist", index));
}
const Json& accessor = accessors[static_cast<std::size_t>(index)];
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Compared as the file has it, so that 4294972422 can't wrap round
// to FLOAT, 5126.
const std::int64_t componentType = Integer(accessor, "componentType", 0);
const std::size_t componentSize = ComponentSize(componentType);
const std::uint32_t components = ComponentCount(String(accessor, "type"));
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (componentSize == 0 || components == 0) {
return Error(std::format("accessor {} has an unsupported type or componentType", index));
}
// Matrices of 1- and 2-byte components pad their columns; nothing
// here reads those.
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (components == 16 && componentSize != 4) {
return Error(std::format("accessor {} is a MAT4 of small components, which isn't supported", index));
}
const std::int64_t count = Integer(accessor, "count", -1);
if (count < 0) return Error(std::format("accessor {} has no count", index));
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::size_t elementSize = componentSize * components;
Accessor result{
.stride = elementSize,
.componentType = static_cast<std::uint32_t>(componentType),
.components = components,
.normalized = Boolean(accessor, "normalized", false),
};
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const bool hasView = Find(accessor, "bufferView") != nullptr;
if (hasView) {
std::size_t viewStride = 0;
const auto view = ViewBytes(ctx, Integer(accessor, "bufferView", -1), &viewStride);
if (!view) return std::unexpected(view.error());
if (viewStride != 0 && viewStride < elementSize) {
return Error(std::format("accessor {}'s elements overlap: {} bytes in a stride of {}", index, elementSize, viewStride));
}
const std::size_t stride = viewStride != 0 ? viewStride : elementSize;
const std::int64_t offset = Integer(accessor, "byteOffset", 0);
// The last element ends inside the view, compared so that nothing
// can overflow, whatever the count.
const std::uint64_t size = view->size();
const bool inside = offset >= 0 && static_cast<std::uint64_t>(offset) <= size
&& (count == 0 || (elementSize <= size - static_cast<std::uint64_t>(offset)
&& static_cast<std::uint64_t>(count - 1) <= (size - static_cast<std::uint64_t>(offset) - elementSize) / stride));
if (!inside) {
return Error(std::format("accessor {} reads past the end of its bufferView", index));
}
result.data = view->data() + offset;
result.stride = stride;
}
const Json* sparse = Find(accessor, "sparse");
if (hasView && sparse == nullptr) {
result.count = static_cast<std::size_t>(count); // inside its view, so a size_t holds it
return result;
}
// Put together in `dense`: the view's elements, or zeros without one,
// as glTF says, with the sparse values written over them.
if (!Spend(ctx, static_cast<std::uint64_t>(count), elementSize)) return OverLimit(ctx, std::format("accessor {}", index));
result.count = static_cast<std::size_t>(count);
result.dense.resize(result.count * elementSize);
if (hasView) {
for (std::size_t i = 0; i < result.count; ++i) {
std::memcpy(result.dense.data() + i * elementSize, result.data + i * result.stride, elementSize);
}
}
result.stride = elementSize;
if (sparse != nullptr) {
if (auto applied = ApplySparse(ctx, *sparse, result, index); !applied) return std::unexpected(applied.error());
}
return result;
}
// An accessor of `components` per element whose component type is one of
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// `allowed`, with exactly `count` elements when a count is given.
std::expected<Accessor, std::string> Attribute(Context& ctx, const Json& attributes, const char* name, std::uint32_t components,
std::initializer_list<std::uint32_t> allowed, std::optional<std::size_t> count,
const std::string& where) {
auto accessor = ReadAccessor(ctx, Integer(attributes, name, -1));
if (!accessor) return std::unexpected(std::format("{} ({} of {})", accessor.error(), name, where));
if (accessor->components != components || std::ranges::find(allowed, accessor->componentType) == allowed.end()) {
return Error(std::format("{} of {} has the wrong type", name, where));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (count && accessor->count != *count) {
return Error(std::format("{} of {} has {} elements for {} vertices", name, where, accessor->count, *count));
}
return accessor;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The vertex stream `name` into `out`: `components` numbers for each of
// the `count` vertices. Left empty when `attributes` doesn't have it.
template <typename T>
std::expected<void, std::string> Stream(Context& ctx, const Json& attributes, const char* name, std::uint32_t components,
std::initializer_list<std::uint32_t> allowed, std::size_t count, const std::string& where,
std::vector<T>& out) {
if (Find(attributes, name) == nullptr) return {};
const auto accessor = Attribute(ctx, attributes, name, components, allowed, count, where);
if (!accessor) return std::unexpected(accessor.error());
if (!Allocate(ctx, out, count)) return OverLimit(ctx, where);
for (std::size_t i = 0; i < count; ++i) {
for (std::uint32_t c = 0; c < components; ++c) out[i].v[c] = accessor->Float(i, c);
}
return {};
}
// Per-vertex tangents from the gradients of `uv`, accumulated over the
// triangles a vertex is in and made orthogonal to its normal. glTF's v
// runs down the image while a normal map's +Y points up, so its
// bitangent is the one along -v: hence the sign below, the same
// correction three.js applies to MikkTSpace output for glTF.
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
void ComputeTangents(GlbPrimitive& p, const std::vector<Vector<float, 2, 2>>& uv) {
using V3 = Vector<float, 3, 0>;
const std::size_t n = p.positions.size();
std::vector<V3> tangentSum(n, V3(0, 0, 0));
std::vector<V3> bitangentSum(n, V3(0, 0, 0));
for (std::size_t t = 0; t + 2 < p.indices.size(); t += 3) {
const std::uint32_t i[3] = {p.indices[t], p.indices[t + 1], p.indices[t + 2]};
const V3 p0(p.positions[i[0]].x, p.positions[i[0]].y, p.positions[i[0]].z);
const V3 e1 = V3(p.positions[i[1]].x, p.positions[i[1]].y, p.positions[i[1]].z) - p0;
const V3 e2 = V3(p.positions[i[2]].x, p.positions[i[2]].y, p.positions[i[2]].z) - p0;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const float du1 = uv[i[1]].x - uv[i[0]].x, dv1 = uv[i[1]].y - uv[i[0]].y;
const float du2 = uv[i[2]].x - uv[i[0]].x, dv2 = uv[i[2]].y - uv[i[0]].y;
const float determinant = du1 * dv2 - du2 * dv1;
if (std::abs(determinant) < 1e-12f) continue; // degenerate UVs carry no direction
const float f = 1.0f / determinant;
const V3 tangent = (e1 * dv2 - e2 * dv1) * f;
const V3 bitangent = (e2 * du1 - e1 * du2) * f;
for (std::uint32_t v : i) {
tangentSum[v] += tangent;
bitangentSum[v] += bitangent;
}
}
p.tangents.resize(n);
for (std::size_t v = 0; v < n; ++v) {
const V3 normal(p.normals[v].x, p.normals[v].y, p.normals[v].z);
V3 tangent = tangentSum[v] - normal * V3::Dot(normal, tangentSum[v]);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Written so that NaN takes these branches too.
if (!(tangent.LengthSq() >= 1e-20f)) {
// No usable UV gradient: any direction perpendicular to the
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// normal keeps the frame valid, and any at all where the
// normal is zero.
tangent = V3::Cross(std::abs(normal.x) < 0.9f ? V3(1, 0, 0) : V3(0, 1, 0), normal);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (!(tangent.LengthSq() >= 1e-20f)) tangent = V3(1, 0, 0);
}
tangent.Normalize();
const float sign = V3::Dot(V3::Cross(normal, tangent), bitangentSum[v]) < 0 ? 1.0f : -1.0f;
p.tangents[v] = Vector<float, 4, 4>(tangent.x, tangent.y, tangent.z, sign);
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// JOINTS_n and WEIGHTS_n, which come in pairs from n = 0 up, as up to
// four influences a vertex: the heaviest, a joint named twice counted
// once with its weights added, and the weights made to sum to 1.
std::expected<void, std::string> ReadInfluences(Context& ctx, const Json& attributes, std::size_t n, const std::string& where, GlbPrimitive& p) {
std::size_t sets = 0;
while (Find(attributes, std::format("JOINTS_{}", sets).c_str()) != nullptr
&& Find(attributes, std::format("WEIGHTS_{}", sets).c_str()) != nullptr) {
++sets;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Every JOINTS_ and WEIGHTS_ is one of those pairs: no half of one,
// and no gap before one.
std::size_t named = 0;
for (auto it = attributes.begin(); it != attributes.end(); ++it) {
named += it.key().starts_with("JOINTS_") || it.key().starts_with("WEIGHTS_");
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (named != 2 * sets) return Error(std::format("{}'s JOINTS_n and WEIGHTS_n don't pair up from n = 0", where));
if (sets == 0) return {};
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::vector<std::pair<Accessor, Accessor>> influences;
for (std::size_t set = 0; set < sets; ++set) {
const std::string joints = std::format("JOINTS_{}", set);
const std::string weights = std::format("WEIGHTS_{}", set);
auto j = Attribute(ctx, attributes, joints.c_str(), 4, {kUnsignedByte, kUnsignedShort}, n, where);
if (!j) return std::unexpected(j.error());
auto w = Attribute(ctx, attributes, weights.c_str(), 4, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
if (!w) return std::unexpected(w.error());
influences.emplace_back(std::move(*j), std::move(*w));
}
// Each set is read for every vertex, so each beyond the first is
// paid for as if it were kept too.
if (!Spend(ctx, static_cast<std::uint64_t>(n) * (sets - 1), sizeof(std::array<std::uint16_t, 4>) + sizeof(std::array<float, 4>))
|| !Allocate(ctx, p.joints, n) || !Allocate(ctx, p.weights, n)) {
return OverLimit(ctx, where);
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
using Influence = std::pair<std::uint16_t, float>;
std::vector<Influence> heaviest;
for (std::size_t v = 0; v < n; ++v) {
heaviest.clear();
for (const auto& [joints, weights] : influences) {
for (std::uint32_t c = 0; c < 4; ++c) {
const float weight = weights.Float(v, c);
// A weight of nothing: zero, negative, or not a number.
if (!(weight > 0.0f) || !std::isfinite(weight)) continue;
const auto joint = static_cast<std::uint16_t>(joints.Unsigned(v, c));
const auto same = std::ranges::find(heaviest, joint, &Influence::first);
if (same != heaviest.end()) same->second += weight;
else heaviest.emplace_back(joint, weight);
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (heaviest.empty()) {
p.joints[v][0] = static_cast<std::uint16_t>(influences[0].first.Unsigned(v, 0));
p.weights[v][0] = 1.0f;
continue;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (heaviest.size() > 4) {
std::ranges::stable_sort(heaviest, std::ranges::greater(), &Influence::second);
heaviest.resize(4);
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
float total = 0;
for (const auto& [joint, weight] : heaviest) total += weight;
for (std::size_t k = 0; k < heaviest.size(); ++k) {
p.joints[v][k] = heaviest[k].first;
p.weights[v][k] = heaviest[k].second / total;
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
return {};
}
std::expected<GlbPrimitive, std::string> ReadPrimitive(Context& ctx, const Json& primitive, const std::vector<GlbMaterial>& materials,
const std::string& where) {
GlbPrimitive p;
p.material = Index(primitive, "material");
const std::int64_t mode = Integer(primitive, "mode", kTriangles);
if (mode < 0 || mode > kTriangleFan) return Error(std::format("{} has mode {}, which glTF doesn't define", where, mode));
// Points and lines: no triangles to read.
if (mode < kTriangles) return p;
const Json* attributes = Find(primitive, "attributes");
if (attributes == nullptr || Find(*attributes, "POSITION") == nullptr) {
return Error(std::format("{} has no POSITION", where));
}
const auto positions = Attribute(ctx, *attributes, "POSITION", 3, {kFloat}, std::nullopt, where);
if (!positions) return std::unexpected(positions.error());
const std::size_t n = positions->count;
if (n > std::numeric_limits<std::uint32_t>::max()) return Error(std::format("{} has more vertices than 32-bit indices reach", where));
if (!Allocate(ctx, p.positions, n)) return OverLimit(ctx, where);
for (std::size_t i = 0; i < n; ++i) {
for (std::uint32_t c = 0; c < 3; ++c) p.positions[i].v[c] = positions->Float(i, c);
}
if (auto read = Stream(ctx, *attributes, "NORMAL", 3, {kFloat}, n, where, p.normals); !read) return std::unexpected(read.error());
for (const auto& [name, set] : { std::pair { "TEXCOORD_0", &p.uv0 }, std::pair { "TEXCOORD_1", &p.uv1 } }) {
if (auto read = Stream(ctx, *attributes, name, 2, {kFloat, kUnsignedByte, kUnsignedShort}, n, where, *set); !read) {
return std::unexpected(read.error());
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (auto read = Stream(ctx, *attributes, "TANGENT", 4, {kFloat}, n, where, p.tangents); !read) return std::unexpected(read.error());
if (auto read = ReadInfluences(ctx, *attributes, n, where, p); !read) return std::unexpected(read.error());
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The vertices in the order the file lists them, then as a list of
// triangles.
std::vector<std::uint32_t> listed;
if (Find(primitive, "indices") != nullptr) {
const auto indices = ReadAccessor(ctx, Integer(primitive, "indices", -1));
if (!indices) return std::unexpected(std::format("{} (indices of {})", indices.error(), where));
if (indices->components != 1 || (indices->componentType != kUnsignedByte && indices->componentType != kUnsignedShort
&& indices->componentType != kUnsignedInt)) {
return Error(std::format("indices of {} have the wrong type", where));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (!Allocate(ctx, listed, indices->count)) return OverLimit(ctx, where);
for (std::size_t i = 0; i < indices->count; ++i) listed[i] = indices->Unsigned(i, 0);
} else {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (!Allocate(ctx, listed, n)) return OverLimit(ctx, where);
std::iota(listed.begin(), listed.end(), 0u);
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (std::ranges::any_of(listed, [n](std::uint32_t i) { return i >= n; })) {
return Error(std::format("{} indexes past its {} vertices", where, n));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (mode == kTriangles) {
if (listed.size() % 3 != 0) {
return Error(std::format("{} has {} indices, not whole triangles", where, listed.size()));
}
p.indices = std::move(listed);
} else {
// A strip's or a fan's triangles as glTF numbers them: a strip's
// odd ones swap their last two corners to keep the winding, and
// a fan's all end at its first vertex.
const std::size_t triangles = listed.size() >= 3 ? listed.size() - 2 : 0;
if (!Allocate(ctx, p.indices, triangles * 3)) return OverLimit(ctx, where);
for (std::size_t t = 0; t < triangles; ++t) {
const std::array<std::size_t, 3> corners = mode == kTriangleStrip ? std::array<std::size_t, 3>{t, t + 1 + t % 2, t + 2 - t % 2}
: std::array<std::size_t, 3>{t + 1, t + 2, 0};
for (std::size_t c = 0; c < 3; ++c) p.indices[t * 3 + c] = listed[corners[c]];
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const Json& targets = Array(primitive, "targets");
p.targets.resize(targets.size());
for (std::size_t t = 0; t < targets.size(); ++t) {
const std::string target = std::format("{} morph target {}", where, t);
GlbMorphTarget& morph = p.targets[t];
for (const auto& [name, stream] : { std::pair { "POSITION", &morph.positions }, std::pair { "NORMAL", &morph.normals },
std::pair { "TANGENT", &morph.tangents } }) {
if (auto read = Stream(ctx, targets[t], name, 3, {kFloat}, n, target, *stream); !read) return std::unexpected(read.error());
}
}
// Tangents the file leaves out, from the UV set its normal map is
// sampled with, as glTF asks.
if (p.tangents.empty() && !p.normals.empty()) {
std::uint32_t set = 0;
if (p.material >= 0 && static_cast<std::size_t>(p.material) < materials.size()) {
const GlbTextureRef& normalTexture = materials[static_cast<std::size_t>(p.material)].normalTexture;
if (normalTexture.texture != -1) set = normalTexture.texCoord;
}
const std::vector<Vector<float, 2, 2>>* uv = set == 0 ? &p.uv0 : set == 1 ? &p.uv1 : nullptr;
if (uv != nullptr && !uv->empty()) {
if (!Spend(ctx, n, sizeof(Vector<float, 4, 4>))) return OverLimit(ctx, where);
ComputeTangents(p, *uv);
}
}
return p;
}
GlbTextureRef TextureRef(const Json& material, const char* key, const char* scaleKey) {
GlbTextureRef ref;
if (const Json* info = Find(material, key)) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
ref.texture = Index(*info, "index");
const std::int64_t texCoord = Integer(*info, "texCoord", ref.texCoord);
ref.texCoord = texCoord == 0 || texCoord == 1 ? static_cast<std::uint32_t>(texCoord) : kBadTexCoord;
if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, ref.scale);
}
return ref;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Core glTF's image formats, PNG and JPEG, told by their first bytes.
// stb_image reads more (GIF, PSD, TGA, ...), which have no business in a
// GLB, so those don't reach it.
bool PngOrJpeg(std::span<const std::byte> bytes) {
constexpr std::array<std::uint8_t, 8> kPng = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
constexpr std::array<std::uint8_t, 3> kJpeg = {0xFF, 0xD8, 0xFF};
auto startsWith = [bytes](std::span<const std::uint8_t> magic) {
return bytes.size() >= magic.size() && std::memcmp(bytes.data(), magic.data(), magic.size()) == 0;
};
return startsWith(kPng) || startsWith(kJpeg);
}
// A sampler's enum, which must be one of `allowed`: `fallback` when the
// file leaves it out, nullopt when it is anything else.
std::optional<std::uint32_t> Enum(const Json& sampler, const char* key, std::uint32_t fallback, std::initializer_list<std::uint32_t> allowed) {
if (Find(sampler, key) == nullptr) return fallback;
const std::int64_t value = Integer(sampler, key, -1);
if (std::ranges::find(allowed, value) == allowed.end()) return std::nullopt;
return static_cast<std::uint32_t>(value);
}
// Each index points at an element that exists, and what the interface
// promises of them holds, so callers can index without checking.
std::expected<void, std::string> CheckReferences(const GlbAsset& a) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast<std::uint64_t>(index) < size); };
for (std::size_t i = 0; i < a.nodes.size(); ++i) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const GlbNode& node = a.nodes[i];
if (!valid(node.mesh, a.meshes.size())) return Error(std::format("node {} uses a mesh that doesn't exist", i));
if (!valid(node.skin, a.skins.size())) return Error(std::format("node {} uses a skin that doesn't exist", i));
if (!valid(node.camera, a.cameras.size())) return Error(std::format("node {} uses a camera that doesn't exist", i));
if (!valid(node.light, a.lights.size())) return Error(std::format("node {} uses a light that doesn't exist", i));
if (node.skin != -1 && node.mesh == -1) return Error(std::format("node {} has a skin and no mesh to skin", i));
if (!node.weights.empty() && (node.mesh == -1 || node.weights.size() != a.meshes[static_cast<std::size_t>(node.mesh)].weights.size())) {
return Error(std::format("node {} has {} morph weights, not one for each of its mesh's targets", i, node.weights.size()));
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
for (const GlbMaterial& m : a.materials) {
for (const GlbTextureRef* ref : {&m.baseColorTexture, &m.metallicRoughnessTexture, &m.normalTexture, &m.occlusionTexture, &m.emissiveTexture,
&m.transmissionTexture}) {
if (!valid(ref->texture, a.textures.size())) return Error(std::format("material '{}' uses a texture that doesn't exist", m.name));
if (ref->texture != -1 && ref->texCoord == kBadTexCoord) {
return Error(std::format("material '{}' samples a UV set other than TEXCOORD_0 and TEXCOORD_1, the ones the loader reads", m.name));
}
}
}
// For each mesh, once however many nodes draw it: its highest joint
// index, and whether every primitive with vertices has joints.
std::vector<std::int64_t> highestJoint(a.meshes.size(), -1);
std::vector<bool> skinnable(a.meshes.size(), true);
for (std::size_t m = 0; m < a.meshes.size(); ++m) {
for (std::size_t p = 0; p < a.meshes[m].primitives.size(); ++p) {
const GlbPrimitive& primitive = a.meshes[m].primitives[p];
if (!valid(primitive.material, a.materials.size())) {
return Error(std::format("mesh '{}' uses a material that doesn't exist", a.meshes[m].name));
}
if (primitive.positions.empty()) continue; // points, lines, or nothing to draw
if (primitive.material != -1) {
const GlbMaterial& material = a.materials[static_cast<std::size_t>(primitive.material)];
for (const GlbTextureRef* ref : {&material.baseColorTexture, &material.metallicRoughnessTexture, &material.normalTexture,
&material.occlusionTexture, &material.emissiveTexture, &material.transmissionTexture}) {
if (ref->texture != -1 && (ref->texCoord == 0 ? primitive.uv0 : primitive.uv1).empty()) {
return Error(std::format("material '{}' samples TEXCOORD_{}, which mesh {} primitive {} doesn't have", material.name,
ref->texCoord, m, p));
}
}
}
skinnable[m] = skinnable[m] && !primitive.joints.empty();
for (const auto& joints : primitive.joints) {
for (std::uint16_t joint : joints) highestJoint[m] = std::max<std::int64_t>(highestJoint[m], joint);
}
}
}
// A vertex's joint indices pick from the joint list of the skin on
// the node that draws its mesh.
for (const GlbNode& node : a.nodes) {
if (node.mesh == -1 || node.skin == -1) continue;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::size_t mesh = static_cast<std::size_t>(node.mesh);
if (!skinnable[mesh]) {
return Error(std::format("node '{}' skins mesh '{}', which has a primitive without JOINTS_0 and WEIGHTS_0", node.name, a.meshes[mesh].name));
}
const std::size_t jointCount = a.skins[static_cast<std::size_t>(node.skin)].joints.size();
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (highestJoint[mesh] >= static_cast<std::int64_t>(jointCount)) {
return Error(std::format("node '{}' skins mesh '{}' with joint indices beyond its skin's {} joints", node.name, a.meshes[mesh].name,
jointCount));
}
}
for (const GlbSkin& skin : a.skins) {
if (!valid(skin.skeleton, a.nodes.size())) return Error(std::format("skin '{}' has a skeleton node that doesn't exist", skin.name));
}
for (std::size_t i = 0; i < a.textures.size(); ++i) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const GlbTexture& texture = a.textures[i];
if (!valid(texture.image, a.images.size())) return Error(std::format("texture {} uses an image that doesn't exist", i));
if (texture.image != -1 && a.images[static_cast<std::size_t>(texture.image)].pixels.empty()) {
return Error(std::format("texture {} uses image {}, which is neither a PNG nor a JPEG", i, texture.image));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (!valid(texture.sampler, a.samplers.size())) return Error(std::format("texture {} uses a sampler that doesn't exist", i));
}
return {};
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The 12-byte header: the length the file says it has, no less than the
// header's own.
std::expected<std::uint32_t, std::string> Header(std::span<const std::byte> glb) {
if (glb.size() < 12) return Error("too short to be a GLB");
if (U32(glb, 0) != kMagic) return Error("bad magic, not a .glb");
if (U32(glb, 4) != 2) return Error(std::format("container version {}, only 2 is supported", U32(glb, 4)));
if (U32(glb, 8) < 12) return Error(std::format("the header says the file has {} bytes, fewer than the header", U32(glb, 8)));
return U32(glb, 8);
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::expected<GlbAsset, std::string> GlbAsset::Load(const fs::path& path, std::optional<std::uint64_t> decodeLimit) {
std::error_code error;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const fs::file_status status = fs::status(path, error);
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
// A directory or a pipe opens too, with no size to read.
if (!fs::is_regular_file(status)) return Error(std::format("{} isn't a file", path.string()));
const std::uintmax_t size = fs::file_size(path, error);
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
errno = 0;
std::ifstream file(path, std::ios::binary);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (!file) {
return Error(std::format("can't open {}: {}", path.string(), errno != 0 ? std::generic_category().message(errno) : "reason unknown"));
}
// The header first. A file that isn't a GLB is turned down unread, and
// one that is says how much of it to read, which can't pass 4 GiB
// however large the file.
std::array<std::byte, 12> header;
if (size < header.size() || !file.read(reinterpret_cast<char*>(header.data()), header.size())) {
return Error(std::format("too short to be a GLB ({})", path.string()));
}
const auto length = Header(header);
if (!length) return std::unexpected(std::format("{} ({})", length.error(), path.string()));
if (*length > size) return Error(std::format("truncated: the header says {} bytes, there are {} ({})", *length, size, path.string()));
std::vector<std::byte> bytes(*length);
std::memcpy(bytes.data(), header.data(), header.size());
if (!file.read(reinterpret_cast<char*>(bytes.data() + header.size()), static_cast<std::streamsize>(bytes.size() - header.size()))) {
return Error(std::format("can't read {}", path.string()));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
auto asset = Load(std::span<const std::byte>(bytes), decodeLimit);
if (!asset) return std::unexpected(std::format("{} ({})", asset.error(), path.string()));
return asset;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::expected<GlbAsset, std::string> GlbAsset::Load(std::span<const std::byte> glb, std::optional<std::uint64_t> decodeLimit) {
const auto header = Header(glb);
if (!header) return std::unexpected(header.error());
const std::uint32_t length = *header;
if (length > glb.size()) return Error(std::format("truncated: the header says {} bytes, there are {}", length, glb.size()));
// Chunks follow the header back to back. Unknown chunk types are skipped,
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// as the spec asks. The arithmetic is in 32 bits, like the header's, and
// ordered so that it can't wrap round.
std::span<const std::byte> jsonChunk;
std::span<const std::byte> binChunk;
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
for (std::uint32_t offset = 12; length - offset >= 8;) {
const std::uint32_t chunkLength = U32(glb, offset);
const std::uint32_t chunkType = U32(glb, offset + 4);
// Not offset + 8 + chunkLength > length, which wraps round.
if (chunkLength > length - offset - 8) return Error("truncated chunk");
const std::span<const std::byte> chunk = glb.subspan(offset + 8, chunkLength);
if (chunkType == kChunkJson && jsonChunk.empty()) jsonChunk = chunk;
else if (chunkType == kChunkBin && binChunk.empty()) binChunk = chunk;
offset += 8 + chunkLength;
}
if (jsonChunk.empty()) return Error("no JSON chunk");
GlbAsset asset;
asset.json.assign(reinterpret_cast<const char*>(jsonChunk.data()), jsonChunk.size());
const Json root = Json::parse(asset.json, nullptr, false);
if (root.is_discarded() || !root.is_object()) return Error("the JSON chunk doesn't parse");
const Json* info = Find(root, "asset");
if (info == nullptr || !String(*info, "version").starts_with("2")) return Error("not glTF 2.x");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
for (const Json& name : Array(root, "extensionsRequired")) {
if (!name.is_string()) return Error("extensionsRequired names something that isn't an extension");
if (std::ranges::find(kImplemented, name.get_ref<const std::string&>()) == kImplemented.end()) {
return Error(std::format("the file requires {}, which this loader doesn't implement", name.get_ref<const std::string&>()));
}
}
const std::uint64_t limit = decodeLimit.value_or(kDecodeFloor + kDecodePerByte * length);
Context ctx{.root = root, .bin = binChunk, .limit = limit, .budget = limit};
// Nodes, then the parent links their children lists imply.
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const Json& nodes = Array(root, "nodes");
for (std::size_t i = 0; i < nodes.size(); ++i) {
const Json& node = nodes[i];
GlbNode& n = asset.nodes.emplace_back();
n.name = String(node, "name");
for (const Json& child : Array(node, "children")) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::optional<std::uint32_t> index = Element(child, nodes.size());
if (!index) return Error(std::format("node {} has a child that isn't one of the {} nodes", i, nodes.size()));
n.children.push_back(*index);
}
Numbers(node, "translation", n.translation.v);
Numbers(node, "rotation", n.rotation.v);
Numbers(node, "scale", n.scale.v);
if (Array(node, "matrix").size() == 16) {
std::array<float, 16> m = {};
Numbers(node, "matrix", m);
n.hasMatrix = true;
n.matrix = MatrixRowMajor<float, 4, 4, 1>(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
n.mesh = Index(node, "mesh");
n.skin = Index(node, "skin");
n.camera = Index(node, "camera");
if (const Json* punctual = Extension(node, "KHR_lights_punctual")) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
n.light = Index(*punctual, "light");
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
for (const Json& weight : Array(node, "weights")) n.weights.push_back(weight.is_number() ? weight.get<float>() : 0.0f);
n.extras = Extras(node);
}
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
for (std::uint32_t child : asset.nodes[i].children) {
if (asset.nodes[child].parent != -1) return Error(std::format("node {} has two parents", child));
asset.nodes[child].parent = static_cast<std::int32_t>(i);
}
}
// Every node reaches a root, or the children lists form a cycle. Each
// walk up stops at a node an earlier one found reaching a root, so every
// node is stepped through once, however deep the hierarchy.
enum class Seen : std::uint8_t { No, OnThisWalk, ReachesRoot };
std::vector<Seen> seen(asset.nodes.size(), Seen::No);
std::vector<std::size_t> walked;
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
walked.clear();
for (std::int32_t at = static_cast<std::int32_t>(i); at != -1 && seen[static_cast<std::size_t>(at)] != Seen::ReachesRoot;
at = asset.nodes[static_cast<std::size_t>(at)].parent) {
if (seen[static_cast<std::size_t>(at)] == Seen::OnThisWalk) return Error("the node hierarchy has a cycle");
seen[static_cast<std::size_t>(at)] = Seen::OnThisWalk;
walked.push_back(static_cast<std::size_t>(at));
}
for (std::size_t node : walked) seen[node] = Seen::ReachesRoot;
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The default scene, or the first when the file names none.
const Json& scenes = Array(root, "scenes");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
std::int64_t scene = Index(root, "scene");
if (scene == -1 && !scenes.empty()) scene = 0;
if (scene != -1) {
if (scene < 0 || static_cast<std::uint64_t>(scene) >= scenes.size()) {
return Error(std::format("the default scene isn't one of the {} scenes", scenes.size()));
}
std::vector<bool> isRoot(asset.nodes.size(), false);
for (const Json& node : Array(scenes[static_cast<std::size_t>(scene)], "nodes")) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::optional<std::uint32_t> index = Element(node, asset.nodes.size());
if (!index) return Error("the default scene names a node that doesn't exist");
if (asset.nodes[*index].parent != -1) return Error(std::format("the default scene has node {}, a child, as a root", *index));
if (isRoot[*index]) return Error(std::format("the default scene names node {} twice", *index));
isRoot[*index] = true;
asset.sceneRoots.push_back(*index);
}
asset.extras = Extras(scenes[static_cast<std::size_t>(scene)]);
} else {
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
if (asset.nodes[i].parent == -1) asset.sceneRoots.push_back(static_cast<std::uint32_t>(i));
}
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// Materials before meshes: a primitive's tangents depend on its
// material's normal texture.
for (const Json& material : Array(root, "materials")) {
GlbMaterial& m = asset.materials.emplace_back();
m.name = String(material, "name");
if (const Json* pbr = Find(material, "pbrMetallicRoughness")) {
Numbers(*pbr, "baseColorFactor", m.baseColorFactor);
m.metallicFactor = Number(*pbr, "metallicFactor", m.metallicFactor);
m.roughnessFactor = Number(*pbr, "roughnessFactor", m.roughnessFactor);
m.baseColorTexture = TextureRef(*pbr, "baseColorTexture", nullptr);
m.metallicRoughnessTexture = TextureRef(*pbr, "metallicRoughnessTexture", nullptr);
}
m.normalTexture = TextureRef(material, "normalTexture", "scale");
m.occlusionTexture = TextureRef(material, "occlusionTexture", "strength");
m.emissiveTexture = TextureRef(material, "emissiveTexture", nullptr);
Numbers(material, "emissiveFactor", m.emissiveFactor);
const std::string alphaMode = String(material, "alphaMode");
m.alphaMode = alphaMode == "MASK" ? GlbAlphaMode::Mask : alphaMode == "BLEND" ? GlbAlphaMode::Blend : GlbAlphaMode::Opaque;
m.alphaCutoff = Number(material, "alphaCutoff", m.alphaCutoff);
m.doubleSided = Boolean(material, "doubleSided", m.doubleSided);
if (const Json* strength = Extension(material, "KHR_materials_emissive_strength")) {
m.emissiveStrength = Number(*strength, "emissiveStrength", m.emissiveStrength);
}
if (const Json* transmission = Extension(material, "KHR_materials_transmission")) {
m.transmissionFactor = Number(*transmission, "transmissionFactor", m.transmissionFactor);
m.transmissionTexture = TextureRef(*transmission, "transmissionTexture", nullptr);
}
if (const Json* ior = Extension(material, "KHR_materials_ior")) {
m.ior = Number(*ior, "ior", m.ior);
}
m.extras = Extras(material);
}
const Json& meshes = Array(root, "meshes");
for (std::size_t m = 0; m < meshes.size(); ++m) {
GlbMesh& mesh = asset.meshes.emplace_back();
mesh.name = String(meshes[m], "name");
const Json& primitives = Array(meshes[m], "primitives");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// glTF has every primitive of a mesh carry the same morph targets,
// which the mesh's weights weigh.
const std::size_t targets = primitives.empty() ? 0 : Array(primitives[0], "targets").size();
for (std::size_t p = 0; p < primitives.size(); ++p) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
if (Array(primitives[p], "targets").size() != targets) {
return Error(std::format("mesh {}'s primitives don't have the same number of morph targets", m));
}
auto primitive = ReadPrimitive(ctx, primitives[p], asset.materials, std::format("mesh {} primitive {}", m, p));
if (!primitive) return std::unexpected(primitive.error());
mesh.primitives.push_back(std::move(*primitive));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
mesh.weights.assign(targets, 0.0f);
if (Find(meshes[m], "weights") != nullptr && Array(meshes[m], "weights").size() != targets) {
return Error(std::format("mesh {} has {} morph weights for {} targets", m, Array(meshes[m], "weights").size(), targets));
}
Numbers(meshes[m], "weights", mesh.weights);
mesh.extras = Extras(meshes[m]);
}
for (const Json& skin : Array(root, "skins")) {
GlbSkin& s = asset.skins.emplace_back();
s.name = String(skin, "name");
for (const Json& joint : Array(skin, "joints")) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::optional<std::uint32_t> index = Element(joint, asset.nodes.size());
if (!index) return Error(std::format("skin '{}' has a joint that isn't one of the {} nodes", s.name, asset.nodes.size()));
s.joints.push_back(*index);
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
s.skeleton = Index(skin, "skeleton");
if (Find(skin, "inverseBindMatrices") != nullptr) {
const auto ibm = ReadAccessor(ctx, Integer(skin, "inverseBindMatrices", -1));
if (!ibm) return std::unexpected(std::format("{} (inverseBindMatrices of skin '{}')", ibm.error(), s.name));
if (ibm->components != 16 || ibm->componentType != kFloat || ibm->count < s.joints.size()) {
return Error(std::format("inverseBindMatrices of skin '{}' aren't one float MAT4 per joint", s.name));
}
for (std::size_t j = 0; j < s.joints.size(); ++j) {
float m[16];
for (std::uint32_t c = 0; c < 16; ++c) m[c] = ibm->Float(j, c);
// glTF's column-major array as the rows: the row-vector form.
s.inverseBindMatrices.emplace_back(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
}
} else {
s.inverseBindMatrices.assign(s.joints.size(), MatrixRowMajor<float, 4, 4, 1>::Identity());
}
}
for (const Json& camera : Array(root, "cameras")) {
GlbCamera& c = asset.cameras.emplace_back();
c.name = String(camera, "name");
c.orthographic = String(camera, "type") == "orthographic";
if (const Json* orthographic = Find(camera, "orthographic"); c.orthographic && orthographic != nullptr) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
c.xmag = Number(*orthographic, "xmag", c.xmag);
c.ymag = Number(*orthographic, "ymag", c.ymag);
c.znear = Number(*orthographic, "znear", c.znear);
c.zfar = Number(*orthographic, "zfar", c.zfar);
} else if (const Json* perspective = Find(camera, "perspective"); !c.orthographic && perspective != nullptr) {
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
c.yfov = Number(*perspective, "yfov", c.yfov);
c.aspectRatio = Number(*perspective, "aspectRatio", c.aspectRatio);
c.znear = Number(*perspective, "znear", c.znear);
c.zfar = Number(*perspective, "zfar", c.zfar);
} else {
return Error(std::format("camera '{}' has no {} properties", c.name, c.orthographic ? "orthographic" : "perspective"));
}
}
if (const Json* punctual = Extension(root, "KHR_lights_punctual")) {
for (const Json& light : Array(*punctual, "lights")) {
GlbLight& l = asset.lights.emplace_back();
l.name = String(light, "name");
const std::string type = String(light, "type");
if (type == "directional") l.type = GlbLightType::Directional;
else if (type == "point") l.type = GlbLightType::Point;
else if (type == "spot") l.type = GlbLightType::Spot;
else return Error(std::format("light '{}' is of type '{}', which KHR_lights_punctual doesn't define", l.name, type));
Numbers(light, "color", l.color);
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
l.intensity = Number(light, "intensity", l.intensity);
l.range = Number(light, "range", l.range);
if (const Json* spot = Find(light, "spot")) {
l.innerConeAngle = Number(*spot, "innerConeAngle", l.innerConeAngle);
l.outerConeAngle = Number(*spot, "outerConeAngle", l.outerConeAngle);
}
l.extras = Extras(light);
}
}
for (const Json& texture : Array(root, "textures")) {
asset.textures.push_back({
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
.image = Index(texture, "source"),
.sampler = Index(texture, "sampler"),
});
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const Json& samplers = Array(root, "samplers");
for (std::size_t i = 0; i < samplers.size(); ++i) {
GlbSampler& s = asset.samplers.emplace_back();
const auto magFilter = Enum(samplers[i], "magFilter", s.magFilter, {9728, 9729});
const auto minFilter = Enum(samplers[i], "minFilter", s.minFilter, {9728, 9729, 9984, 9985, 9986, 9987});
const auto wrapS = Enum(samplers[i], "wrapS", s.wrapS, {33071, 33648, 10497});
const auto wrapT = Enum(samplers[i], "wrapT", s.wrapT, {33071, 33648, 10497});
if (!magFilter || !minFilter || !wrapS || !wrapT) return Error(std::format("sampler {} has a filter or wrap mode glTF doesn't define", i));
s = {.magFilter = *magFilter, .minFilter = *minFilter, .wrapS = *wrapS, .wrapT = *wrapT};
}
const Json& images = Array(root, "images");
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
// The image that decoded each bufferView first: another naming it is
// copied rather than decoded again.
std::unordered_map<std::int64_t, std::size_t> decodedFrom;
for (std::size_t i = 0; i < images.size(); ++i) {
if (Find(images[i], "uri") != nullptr) {
return Error(std::format("image {} is an external file or data URI, which a GLB loader doesn't read", i));
}
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
const std::int64_t view = Integer(images[i], "bufferView", -1);
const auto bytes = ViewBytes(ctx, view, nullptr);
if (!bytes) return std::unexpected(std::format("{} (image {})", bytes.error(), i));
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
Image& image = asset.images.emplace_back();
// An extension's format (EXT_texture_webp's WebP, KHR_texture_basisu's
// KTX2, ...) stays undecoded; CheckReferences turns down a texture
// that has one as its image.
if (!PngOrJpeg(*bytes)) continue;
if (const auto same = decodedFrom.find(view); same != decodedFrom.end()) {
const Image& decoded = asset.images[same->second];
if (!Spend(ctx, decoded.pixels.size(), sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i));
image = decoded;
continue;
}
// Its size from its header, so that it is paid for before it is
// decoded.
const auto size = Image::SizeFromMemory(*bytes);
if (!size) return Error(std::format("image {} doesn't decode: {}", i, size.error()));
if (!Spend(ctx, std::uint64_t{size->first} * size->second, sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i));
auto decoded = Image::LoadPNGFromMemory<std::uint8_t>(*bytes);
if (!decoded) return Error(std::format("image {} doesn't decode: {}", i, decoded.error()));
image = std::move(*decoded);
decodedFrom.emplace(view, i);
}
if (auto valid = CheckReferences(asset); !valid) return std::unexpected(valid.error());
fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:25:12 +01:00
asset.bin.assign(binChunk.begin(), binChunk.end());
return asset;
}