Files
workinf_Blender_Wasm/web/protocol/glb-import.ts
mes123456 380cbed4ff
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

486 lines
24 KiB
TypeScript

import type { GLBAssetBuffer } from "./glb-export";
import type { SceneSnapshotIR } from "./scene-ir";
const GLB_MAGIC = 0x46546c67;
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
interface GLBAccessor {
bufferView?: number;
byteOffset?: number;
componentType: number;
count: number;
type: string;
normalized?: boolean;
min?: number[];
max?: number[];
sparse?: Record<string, unknown>;
}
interface GLBBufferView {
buffer: number;
byteOffset?: number;
byteLength: number;
}
interface GLBPrimitive {
attributes?: Record<string, number>;
indices?: number;
material?: number;
mode?: number;
targets?: Array<Record<string, number>>;
}
interface GLBDocument {
asset?: { version?: string; generator?: string };
scene?: number;
scenes?: Array<{ nodes?: number[] }>;
extensionsUsed?: string[];
extensionsRequired?: string[];
buffers?: Array<{ byteLength?: number; uri?: string }>;
bufferViews?: GLBBufferView[];
accessors?: GLBAccessor[];
meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record<string, unknown> }>;
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record<string, unknown> }>;
samplers?: Array<Record<string, unknown>>;
textures?: Array<{ sampler?: number; source?: number }>;
materials?: Array<{
name?: string;
alphaMode?: string;
doubleSided?: boolean;
pbrMetallicRoughness?: {
baseColorFactor?: number[];
baseColorTexture?: Record<string, unknown>;
metallicFactor?: number;
roughnessFactor?: number;
};
normalTexture?: Record<string, unknown>;
emissiveFactor?: number[];
}>;
nodes?: Array<{
name?: string;
mesh?: number;
skin?: number;
children?: number[];
translation?: number[];
rotation?: number[];
scale?: number[];
}>;
skins?: Array<{ name?: string; joints?: number[]; inverseBindMatrices?: number; skeleton?: number; extras?: Record<string, unknown> }>;
animations?: Array<{
name?: string;
samplers?: Array<{ input?: number; output?: number; interpolation?: string }>;
channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>;
extras?: Record<string, unknown>;
}>;
}
export const GLB_IMPORT_BUDGET = {
maxBytes: 512 * 1024,
maxJsonBytes: 256 * 1024,
maxBufferViews: 4096,
maxAccessors: 8192,
} as const;
export interface ImportedGLBImage {
name?: string;
blenderId?: string;
mimeType?: string;
byteLength: number;
signature: number[];
}
export interface ImportedGLBSemantics {
version: 2;
meshCount: number;
primitiveCount: number;
meshes: Array<{ name?: string; blenderId?: string; primitiveCount: number; attributes: string[]; morphTargetCount: number }>;
images: ImportedGLBImage[];
skinCount: number;
skins: Array<{ jointCount: number; inverseBindType?: string; inverseBindCount?: number }>;
animationCount: number;
animationChannelCount: number;
animationPaths: string[];
}
export interface GLBSemanticComparison {
compatible: boolean;
mismatches: string[];
}
export interface GLBDesktopAccessorSemantics {
componentType: number;
count: number;
type: string;
normalized: boolean;
min: number[] | null;
max: number[] | null;
}
export interface GLBDesktopFixtureSemantics {
asset: { version?: string; generator?: string } | null;
extensionsUsed: string[];
extensionsRequired: string[];
scene: number | null;
nodeNames: Array<string | null>;
nodes: Array<{
name: string | null;
mesh: number | null;
skin: number | null;
children: number[];
translation: number[] | null;
rotation: number[] | null;
scale: number[] | null;
}>;
meshes: Array<{
name: string | null;
primitives: Array<{
attributes: Record<string, GLBDesktopAccessorSemantics>;
indices: GLBDesktopAccessorSemantics | null;
material: number | null;
mode: number;
targets: Array<Record<string, GLBDesktopAccessorSemantics>>;
}>;
}>;
materials: Array<{
name: string | null;
alphaMode: string;
doubleSided: boolean;
pbr: {
baseColorFactor: number[] | null;
baseColorTexture: Record<string, unknown> | null;
metallicFactor: number | null;
roughnessFactor: number | null;
};
normalTexture: Record<string, unknown> | null;
emissiveFactor: number[] | null;
}>;
textures: Array<Record<string, unknown>>;
images: Array<Record<string, unknown>>;
samplers: Array<Record<string, unknown>>;
skins: Array<{
name: string | null;
joints: number[];
inverseBindMatrices: GLBDesktopAccessorSemantics | null;
skeleton: number | null;
}>;
animations: Array<{
name: string | null;
samplers: Array<{
interpolation: string;
input: GLBDesktopAccessorSemantics | null;
output: GLBDesktopAccessorSemantics | null;
}>;
channels: Array<{ sampler: number; target: { node: number; path: string } }>;
}>;
}
function recordId(extras: Record<string, unknown> | undefined): string | undefined {
return typeof extras?.blenderId === "string" ? extras.blenderId : undefined;
}
function requireIndex(value: unknown, size: number, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= size) throw new Error(`${label} index is out of range`);
return value as number;
}
function jsonChunk(bytes: Uint8Array, length: number): GLBDocument {
if (bytes.byteLength > GLB_IMPORT_BUDGET.maxBytes) throw new Error(`GLB_IMPORT_BUDGET_EXCEEDED: file exceeds ${GLB_IMPORT_BUDGET.maxBytes} bytes`);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== 2) throw new Error("GLB header is invalid");
if (view.getUint32(8, true) !== bytes.byteLength) throw new Error("GLB length does not match header");
const jsonLength = view.getUint32(12, true);
if (jsonLength > GLB_IMPORT_BUDGET.maxJsonBytes || view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
let document: unknown;
try {
document = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trim());
}
catch (error) {
throw new Error(`GLB JSON is invalid: ${error instanceof Error ? error.message : "parse failed"}`);
}
if (typeof document !== "object" || document === null || Array.isArray(document)) throw new Error("GLB JSON document is invalid");
if ((document as GLBDocument).asset?.version !== "2.0") throw new Error("GLB asset version is not 2.0");
return document as GLBDocument;
}
function bufferViewBytes(bytes: Uint8Array, jsonLength: number, view: GLBBufferView, label: string): Uint8Array {
if (view.buffer !== 0 || !Number.isSafeInteger(view.byteLength) || view.byteLength < 0) throw new Error(`${label} bufferView is invalid`);
const binaryStart = 20 + jsonLength + 8;
const offset = view.byteOffset ?? 0;
if (!Number.isSafeInteger(offset) || offset < 0 || binaryStart + offset + view.byteLength > bytes.byteLength) throw new Error(`${label} bufferView exceeds BIN chunk`);
return bytes.subarray(binaryStart + offset, binaryStart + offset + view.byteLength);
}
export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
const bytes = new Uint8Array(glb);
const view = new DataView(glb);
const jsonLength = view.getUint32(12, true);
const document = jsonChunk(bytes, glb.byteLength);
if ((document.extensionsUsed?.length ?? 0) > 0 || (document.extensionsRequired?.length ?? 0) > 0) throw new Error("GLB_EXTENSION_UNSUPPORTED: extensions are outside the bounded importer");
if ((document.buffers ?? []).some((buffer) => buffer.uri !== undefined) || (document.images ?? []).some((image) => image.uri !== undefined)) throw new Error("GLB_EXTERNAL_URI_BLOCKED: external URI resources are not accepted");
const bufferViews = document.bufferViews ?? [];
const accessors = document.accessors ?? [];
if (bufferViews.length > GLB_IMPORT_BUDGET.maxBufferViews || accessors.length > GLB_IMPORT_BUDGET.maxAccessors) throw new Error("GLB_IMPORT_BUDGET_EXCEEDED: accessor or bufferView count exceeds the bounded importer");
for (const [index, bufferView] of bufferViews.entries()) bufferViewBytes(bytes, jsonLength, bufferView, `bufferViews[${index}]`);
const accessorType = (index: number): string => accessors[requireIndex(index, accessors.length, "accessor")]?.type ?? "";
for (const [index, accessor] of accessors.entries()) {
if (accessor.sparse !== undefined) throw new Error(`GLB_SPARSE_ACCESSOR_UNSUPPORTED: accessors[${index}]`);
if (!Number.isSafeInteger(accessor.count) || accessor.count < 0 || (accessor.byteOffset ?? 0) < 0) throw new Error(`accessors[${index}] is invalid`);
if (accessor.bufferView !== undefined) {
const bytesForAccessor = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(accessor.bufferView, bufferViews.length, `accessors[${index}]`)], `accessors[${index}]`);
const componentBytes = accessor.componentType === 5126 || accessor.componentType === 5125 ? 4 : accessor.componentType === 5123 ? 2 : 1;
const width = accessor.type === "SCALAR" ? 1 : accessor.type === "VEC2" ? 2 : accessor.type === "VEC3" ? 3 : accessor.type === "VEC4" ? 4 : accessor.type === "MAT4" ? 16 : 0;
if (width === 0 || (accessor.byteOffset ?? 0) + accessor.count * width * componentBytes > bytesForAccessor.byteLength) throw new Error(`accessors[${index}] exceeds bufferView`);
}
}
const images = (document.images ?? []).map((image, index) => {
if (image.bufferView === undefined) throw new Error(`images[${index}] has no embedded bufferView`);
const data = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(image.bufferView, bufferViews.length, `images[${index}]`)], `images[${index}]`);
return { name: image.name, blenderId: recordId(image.extras), mimeType: image.mimeType, byteLength: data.byteLength, signature: Array.from(data.subarray(0, 8)) };
});
const meshes = (document.meshes ?? []).map((mesh, index) => {
const primitives = mesh.primitives ?? [];
const attributes = new Set<string>();
let morphTargetCount = 0;
for (const [primitiveIndex, primitive] of primitives.entries()) {
for (const semantic of Object.keys(primitive.attributes ?? {})) {
const accessor = requireIndex(primitive.attributes?.[semantic], accessors.length, `meshes[${index}].primitives[${primitiveIndex}].${semantic}`);
accessorType(accessor);
attributes.add(semantic);
}
if (primitive.indices !== undefined) accessorType(requireIndex(primitive.indices, accessors.length, "primitive indices"));
morphTargetCount = Math.max(morphTargetCount, primitive.targets?.length ?? 0);
}
return { name: mesh.name, blenderId: recordId(mesh.extras), primitiveCount: primitives.length, attributes: [...attributes].sort(), morphTargetCount };
});
const skins = (document.skins ?? []).map((skin, index) => {
const jointCount = skin.joints?.length ?? 0;
if (jointCount === 0) throw new Error(`skins[${index}] has no joints`);
let inverseBindType: string | undefined;
let inverseBindCount: number | undefined;
if (skin.inverseBindMatrices !== undefined) {
const accessor = accessors[requireIndex(skin.inverseBindMatrices, accessors.length, `skins[${index}].inverseBindMatrices`)]!;
inverseBindType = accessor.type;
inverseBindCount = accessor.count;
if (inverseBindType !== "MAT4" || inverseBindCount !== jointCount) throw new Error(`skins[${index}] inverse bind matrices do not match joints`);
}
return { jointCount, inverseBindType, inverseBindCount };
});
const animations = document.animations ?? [];
const animationPaths = animations.flatMap((animation) => (animation.channels ?? []).map((channel) => channel.target?.path ?? "")).filter(Boolean).sort();
return { version: 2, meshCount: meshes.length, primitiveCount: meshes.reduce((sum, mesh) => sum + mesh.primitiveCount, 0), meshes, images, skinCount: skins.length, skins, animationCount: animations.length, animationChannelCount: animationPaths.length, animationPaths };
}
function desktopAccessorSemantics(accessors: readonly GLBAccessor[], index: number | undefined, label: string): GLBDesktopAccessorSemantics | null {
if (index === undefined) return null;
const accessor = accessors[requireIndex(index, accessors.length, label)]!;
return {
componentType: accessor.componentType,
count: accessor.count,
type: accessor.type,
normalized: accessor.normalized === true,
min: accessor.min ?? null,
max: accessor.max ?? null,
};
}
/**
* Imports the canonical, bounded semantic surface used by the M12 desktop GLB
* fixtures. This intentionally does not create Blender Main data; that writer
* and its stable-ID persistence gate belong to M12-06C.
*/
export function importGLBDesktopFixtureSemantics(glb: ArrayBuffer): GLBDesktopFixtureSemantics {
importGLBSemantics(glb);
const bytes = new Uint8Array(glb);
const jsonLength = new DataView(glb).getUint32(12, true);
const document = jsonChunk(bytes, glb.byteLength);
const accessors = document.accessors ?? [];
const meshes = document.meshes ?? [];
const materials = document.materials ?? [];
const nodes = document.nodes ?? [];
const textures = document.textures ?? [];
const images = document.images ?? [];
const samplers = document.samplers ?? [];
const skins = document.skins ?? [];
const animations = document.animations ?? [];
if (document.scene !== undefined) requireIndex(document.scene, document.scenes?.length ?? 0, "scene");
for (const [index, node] of nodes.entries()) {
if (node.mesh !== undefined) requireIndex(node.mesh, meshes.length, `nodes[${index}].mesh`);
if (node.skin !== undefined) requireIndex(node.skin, skins.length, `nodes[${index}].skin`);
for (const child of node.children ?? []) requireIndex(child, nodes.length, `nodes[${index}].children`);
}
for (const [index, texture] of textures.entries()) {
if (texture.source !== undefined) requireIndex(texture.source, images.length, `textures[${index}].source`);
if (texture.sampler !== undefined) requireIndex(texture.sampler, samplers.length, `textures[${index}].sampler`);
}
const importedMeshes = meshes.map((mesh, meshIndex) => ({
name: mesh.name ?? null,
primitives: (mesh.primitives ?? []).map((primitive, primitiveIndex) => {
if (primitive.material !== undefined) requireIndex(primitive.material, materials.length, `meshes[${meshIndex}].primitives[${primitiveIndex}].material`);
const attributes: Record<string, GLBDesktopAccessorSemantics> = {};
for (const [name, accessor] of Object.entries(primitive.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
attributes[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].attributes.${name}`)!;
}
return {
attributes,
indices: desktopAccessorSemantics(accessors, primitive.indices, `meshes[${meshIndex}].primitives[${primitiveIndex}].indices`),
material: primitive.material ?? null,
mode: primitive.mode ?? 4,
targets: (primitive.targets ?? []).map((target, targetIndex) => {
const imported: Record<string, GLBDesktopAccessorSemantics> = {};
for (const [name, accessor] of Object.entries(target).sort(([left], [right]) => left.localeCompare(right))) {
imported[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].targets[${targetIndex}].${name}`)!;
}
return imported;
}),
};
}),
}));
const importedSkins = skins.map((skin, skinIndex) => {
const joints = skin.joints ?? [];
for (const joint of joints) requireIndex(joint, nodes.length, `skins[${skinIndex}].joints`);
if (skin.skeleton !== undefined) requireIndex(skin.skeleton, nodes.length, `skins[${skinIndex}].skeleton`);
return {
name: skin.name ?? null,
joints,
inverseBindMatrices: desktopAccessorSemantics(accessors, skin.inverseBindMatrices, `skins[${skinIndex}].inverseBindMatrices`),
skeleton: skin.skeleton ?? null,
};
});
const importedAnimations = animations.map((animation, animationIndex) => {
const animationSamplers = animation.samplers ?? [];
return {
name: animation.name ?? null,
samplers: animationSamplers.map((sampler, samplerIndex) => ({
interpolation: sampler.interpolation ?? "LINEAR",
input: desktopAccessorSemantics(accessors, sampler.input, `animations[${animationIndex}].samplers[${samplerIndex}].input`),
output: desktopAccessorSemantics(accessors, sampler.output, `animations[${animationIndex}].samplers[${samplerIndex}].output`),
})),
channels: (animation.channels ?? []).map((channel, channelIndex) => {
const sampler = requireIndex(channel.sampler, animationSamplers.length, `animations[${animationIndex}].channels[${channelIndex}].sampler`);
const node = requireIndex(channel.target?.node, nodes.length, `animations[${animationIndex}].channels[${channelIndex}].target.node`);
const path = channel.target?.path;
if (path !== "translation" && path !== "rotation" && path !== "scale" && path !== "weights") throw new Error(`animations[${animationIndex}].channels[${channelIndex}].target.path is invalid`);
return { sampler, target: { node, path } };
}),
};
});
return {
asset: document.asset ?? null,
extensionsUsed: [...(document.extensionsUsed ?? [])].sort(),
extensionsRequired: [...(document.extensionsRequired ?? [])].sort(),
scene: document.scene ?? null,
nodeNames: nodes.map((node) => node.name ?? null),
nodes: nodes.map((node) => ({
name: node.name ?? null,
mesh: node.mesh ?? null,
skin: node.skin ?? null,
children: node.children ?? [],
translation: node.translation ?? null,
rotation: node.rotation ?? null,
scale: node.scale ?? null,
})),
meshes: importedMeshes,
materials: materials.map((material) => {
const pbr = material.pbrMetallicRoughness ?? {};
return {
name: material.name ?? null,
alphaMode: material.alphaMode ?? "OPAQUE",
doubleSided: material.doubleSided === true,
pbr: {
baseColorFactor: pbr.baseColorFactor ?? null,
baseColorTexture: pbr.baseColorTexture ?? null,
metallicFactor: pbr.metallicFactor ?? null,
roughnessFactor: pbr.roughnessFactor ?? null,
},
normalTexture: material.normalTexture ?? null,
emissiveFactor: material.emissiveFactor ?? null,
};
}),
textures: textures.map((texture) => ({ ...texture })),
images: images.map((image) => ({ ...image })),
samplers: samplers.map((sampler) => ({ ...sampler })),
skins: importedSkins,
animations: importedAnimations,
};
}
function semanticMismatches(expected: unknown, actual: unknown, path: string, mismatches: string[]): void {
if (Object.is(expected, actual)) return;
if (Array.isArray(expected) || Array.isArray(actual)) {
if (!Array.isArray(expected) || !Array.isArray(actual)) {
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
return;
}
if (expected.length !== actual.length) mismatches.push(`${path}.length: expected ${expected.length} got ${actual.length}`);
for (let index = 0; index < Math.min(expected.length, actual.length); index++) semanticMismatches(expected[index], actual[index], `${path}[${index}]`, mismatches);
return;
}
if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) {
const expectedRecord = expected as Record<string, unknown>;
const actualRecord = actual as Record<string, unknown>;
for (const key of [...new Set([...Object.keys(expectedRecord), ...Object.keys(actualRecord)])].sort()) {
semanticMismatches(expectedRecord[key], actualRecord[key], `${path}.${key}`, mismatches);
}
return;
}
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
}
export function compareGLBDesktopFixtureSemantics(expected: unknown, actual: GLBDesktopFixtureSemantics): GLBSemanticComparison {
const mismatches: string[] = [];
semanticMismatches(expected, actual, "$", mismatches);
return { compatible: mismatches.length === 0, mismatches };
}
export function compareGLBToSceneIR(snapshot: SceneSnapshotIR, imported: ImportedGLBSemantics, assetBuffers: readonly GLBAssetBuffer[] = []): GLBSemanticComparison {
const mismatches: string[] = [];
const geometryMeshIds = new Set(snapshot.meshes.filter((mesh) => mesh.geometryStatus !== "summary-only").map((mesh) => mesh.id));
const importedMeshIds = new Set(imported.meshes.map((mesh) => mesh.blenderId).filter((id): id is string => Boolean(id)));
for (const id of geometryMeshIds) if (!importedMeshIds.has(id)) mismatches.push(`mesh missing: ${id}`);
const expectedImages = assetBuffers.filter((asset) => snapshot.images.some((image) => image.assetId === asset.assetId && asset.data.byteLength > 0));
if (imported.images.length !== expectedImages.length) mismatches.push(`image count ${imported.images.length} != ${expectedImages.length}`);
for (const asset of expectedImages) {
const image = imported.images.find((candidate) => candidate.blenderId === snapshot.images.find((source) => source.assetId === asset.assetId)?.id);
if (!image || image.byteLength !== asset.data.byteLength || image.mimeType !== asset.mimeType) mismatches.push(`image semantic mismatch: ${asset.assetId}`);
}
const expectedSkins = snapshot.meshes.filter((mesh) => mesh.skinWeights?.armatureId && snapshot.armatures?.some((armature) => armature.id === mesh.skinWeights?.armatureId));
if (imported.skinCount !== expectedSkins.length) mismatches.push(`skin count ${imported.skinCount} != ${expectedSkins.length}`);
for (const mesh of expectedSkins) {
const expectedJoints = mesh.skinWeights?.jointIds?.length ?? 0;
if (!imported.skins.some((skin) => skin.jointCount === expectedJoints && skin.inverseBindType === "MAT4")) mismatches.push(`skin semantic mismatch: ${mesh.id}`);
}
const expectedMorphs = snapshot.meshes.filter((mesh) => (mesh.shapeKeys?.length ?? 0) > 0).reduce((sum, mesh) => sum + (mesh.shapeKeys?.length ?? 0), 0);
const importedMorphs = imported.meshes.reduce((sum, mesh) => sum + mesh.morphTargetCount, 0);
if (importedMorphs !== expectedMorphs) mismatches.push(`morph target count ${importedMorphs} != ${expectedMorphs}`);
const expectedAnimationPaths: string[] = [];
let expectedAnimations = 0;
for (const animation of snapshot.animations) {
const groups = new Set<string>();
for (const channel of animation.channels) {
const boneMatch = channel.path.match(/^pose\.bones\["(.+)"\]\.(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const objectMatch = channel.path.match(/^(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const property = boneMatch?.[2] ?? objectMatch?.[1];
if (!property || channel.keyframes.length === 0) continue;
if (boneMatch) {
const armature = (snapshot.armatures ?? []).find((candidate) => candidate.objectId === animation.targetId);
if (!armature?.bones.some((bone) => bone.name === boneMatch[1])) continue;
}
else if (!snapshot.nodes.some((node) => node.id === animation.targetId)) continue;
const path = property === "location" ? "translation" : property === "scale" ? "scale" : "rotation";
groups.add(`${boneMatch?.[1] ?? animation.targetId}:${path}`);
}
if (groups.size > 0) expectedAnimations++;
for (const group of groups) expectedAnimationPaths.push(group.slice(group.lastIndexOf(":") + 1));
}
expectedAnimationPaths.sort();
if (imported.animationCount !== expectedAnimations) mismatches.push(`animation count ${imported.animationCount} != ${expectedAnimations}`);
if (imported.animationPaths.join(",") !== expectedAnimationPaths.join(",")) mismatches.push(`animation paths ${imported.animationPaths.join(",")} != ${expectedAnimationPaths.join(",")}`);
return { compatible: mismatches.length === 0, mismatches };
}