Skip to content

C++ ABI compatibility ​

This page is the authoritative answer to one question: when does a change to meshio++ break a C++ consumer that was already compiled?

It exists because the honest answer is narrower than "every release". find_package(... X.Y.Z EXACT ...) keys on the release version, which moves whenever anything in the project does — a Python-side fix, a new format, a doc change. v9.3.0's entire installed-header delta was one new inline constexpr string in formats/exodus.hpp, which cannot affect a compiled consumer at all, yet the version rule still demanded that every C++ consumer re-pin and rebuild. MESHIOPLUSPLUS_ABI_VERSION is the finer signal.

This page covers COMPONENTS CXX only. The C API is a different contract and is unaffected by everything here: it exposes no header-defined types, its option structs grow only into their reserved tails, and it keeps SOVERSION 0 — pin the major and see the C API page.

The criterion ​

Scope is every header under src/cpp/include/meshioplusplus/. All of them are installed, and all of them are compiled by consumers, so all of them participate. This is deliberately not a curated list of "the important headers": such a list would have been wrong. The releases that really did break ABI touched formats/mdpa.hpp (MdpaInfo), formats/xdmf_time_series.hpp, kratos_bridge.hpp and mesh_api.hpp — none of which look like "the mesh types" — while every operations/*.hpp options and result struct (PartitionOptions, SmoothOptions, DecimateOptions, QualityReport, …) crosses the boundary by value and merely happens not to have changed yet.

src/cpp/src/**.cpp never matters: a consumer does not compile it.

What matters is what the change does, not which file it lives in.

The decision flow from a changed installed header to Tier A, Tier B or Tier C, with the gate that catches each

Tier A — layout. Bump the ABI version. ​

Anything that changes the shape of a type a consumer can name:

  • adding, removing, reordering or retyping a data member;
  • adding or changing base classes or virtual functions;
  • changing alignment, or an enum's underlying type;
  • changing a template's parameter list.

The consumer and the library then disagree about where the fields of an argument are. Nothing diagnoses this at run time; it presents as unrelated memory corruption.

tests/cpp/test_abi_layout.cpp catches this tier mechanically — a committed snapshot of sizeof/alignof for every boundary type, per backend, checked by static_assert.

Tier B — ODR. Bump the ABI version. ​

Changing the body of an existing inline function, a template, or a default argument.

This is the tier people miss, so it is worth being precise: both the library and the consumer emit their own copy of an inline function, with vague linkage, and the linker keeps exactly one of them — arbitrarily. If the two copies differ, the program may run the old body in a place the new one was intended, or vice versa, with no diagnostic. Formally it is an ODR violation and the behaviour is undefined.

So yes: an inline function body change counts, even though no type changed size. Nothing mechanical can catch it (a new inline function and an edited one look the same textually), which is why tools/check-abi-version.sh demands a recorded human judgement instead of guessing.

Tier C — additive. Do not bump. ​

  • a new inline function, constexpr variable, type, free-function declaration, or header;
  • an appended enumerator — one that comes after every existing enumerator. CellType ends in a catch-all Custom that follows its X-macro, so a new cell type moves Custom and is Tier A (ABI 16).

Nothing that already existed changes definition, so an already-compiled consumer's translation unit is byte-for-byte what it was. It simply does not have the new name. v9.3.0's kExodusAttributePrefix is exactly this.

Record these in doc/abi_reviews.md, which is what lets the CI gate pass while keeping the reasoning on the record. The entry is keyed on the release and must name every header that changed: the gate reads both columns, so a review of an earlier release does not carry a later one, and a header you leave off is a red build naming it.

The one asymmetry: appended enumerators ​

Appending to an enum with a fixed underlying type (RemeshMetric, CardMode; the width never moves) is safe in the direction that matters: older headers + newer library. The reverse — a consumer compiled against newer headers running against an older library — can hand the library an enumerator it has never heard of. The guard below enforces exact agreement, so this cannot arise silently; it is documented because the tiers would otherwise seem to promise more than they do.

What is out of scope entirely ​

Same-toolchain is a precondition, not something meshio++ can check. A consumer built with a different compiler, standard library, _GLIBCXX_USE_CXX11_ABI setting, or with flags that change standard-library layout is incompatible no matter what this page says, and no sentinel symbol can detect it.

The ABI version ​

MESHIOPLUSPLUS_ABI_VERSION lives in one place, src/cpp/include/meshioplusplus/abi_version.hpp. CMakeLists.txt parses it back out of that header rather than keeping a copy, so the number in a compiled object and the number in the CMake package can never disagree.

ABIreleaseswhat moved
1v9.0.0baseline
2v9.1.0GeometricalEntity gained a member; ModelPart, MdpaInfo, PropertySet, ReadOptions, XdmfTimeSeriesWriter, kratos_bridge.hpp
3v9.2.0 – v9.4.1KratosMesh, MeshioMesh, NativeMesh, PropertySet, mesh_api.hpp, XdmfTimeSeriesWriter
4v9.5.0RefineOptions gained the selective-refinement fields (a cell list, a region name, a cell_data predicate, the closure mode and the level flag)
5v9.9.0 – v9.19.0MedInfo gained four members (mFieldUnits, mStepMeta, mFieldTimeValues, mSkippedConstructs) for the lenient-read surface
6v9.20.0 – v10.0.0OpenFoamInfo gained mPatchTypes, so the OpenFOAM writer can round-trip a patch's type
7v10.1.0RefineOptions gained mRecordHierarchy, the persistent refine:cell_id/refine:parent_id parent/child hierarchy
8v10.11.0RemeshOptions gained mGradation/mPreserveBoundary, RemeshResult gained mNumNonManifoldVertices
9v10.12.0RemeshOptions gained mMaxAnisotropy, RemeshMetric gained Anisotropic
10v10.13.0SmoothMethod gained an explicit : std::uint8_t underlying type — a genuine 4-byte-to-1-byte narrowing, and Tier A independently of the Odt enumerator that rode in on it
11v10.17.0 – v10.34.0MeshMetadata gained mProvenance/mProvenanceRecognised (256 → 288 bytes), so read_metadata can report the block a file carries
12v10.35.0Tier B, and the first entry here that is not a layout change: NDArray::Size()'s inline body. It reported 0 for a rank-0 array, so Nbytes() was 0 and every clone silently dropped a 0-d scalar's single element; it now counts what the buffer holds. sizeof(NDArray) is unchanged at 72, which is exactly why the layout snapshot could not see this and the version had to move by hand
13v11.4.0OpenFoamInfo gained mRegion (multi-region case selection, roadmap §1 tier B2), 96 → 128 bytes
14v14.0.0ReadOptions gained mPiece/mPieceSet, the merge-or-select-pieces switch for partitioned files (VTKHDF), 56 → 72 bytes, and with it the four aggregates that embed it by value (PipelineInput, Pipeline, SequenceInput, SequencePipeline; +16 each)
15v15.0.0Tier A, and sizeof did not move: ReadOptions gained mGhosts (GhostPolicy, std::uint8_t), the keep-or-drop switch for the ghost cells of a partitioned file (pvtu/pvtp/pvd). It was appended into the 7 bytes of tail padding after mPieceSet, so sizeof(ReadOptions) stays 72 and PipelineInput, Pipeline, SequenceInput and SequencePipeline do not move either — but a consumer compiled against v14 headers leaves that byte indeterminate and a v15 library reads it as the policy, so it is a break all the same. The layout snapshot cannot see it beyond the new offset pin; the ABI number is the record.
16v16.0.0Tier A, from an appended enumerator: CellType gained Triangle7 (Code_Aster's TRIA7, VTK 34). The X-macro that generates the enum sits before Custom, so Custom moved from 76 to 77, and the inline cell_type_name/cell_type_num_nodes/cell_type_dimension tables grew; the C API's mio_cell_type mirror moved with it (MIO_CELL_Custom 76 → 77). Appending is Tier C only while nothing follows the new enumerator, which Custom does. test_abi_layout.cpp now pins both values.
17v16.14.0Tier B: three inline bodies in ndarray.hpp. The zeroing constructor and MakeOwned() no longer call memset/memcpy on an empty buffer's null pointer (undefined behaviour UBSan found), and the two allocating constructors size their buffer through a new private AllocBytes, which throws std::length_error when a shape's byte count overflows size_t instead of wrapping to a small allocation. sizeof(NDArray) is unchanged. Also Tier B: detail::read_int (detail/value_io.hpp) reads a NaN float as 0 and saturates one outside int64 instead of casting it (undefined behaviour), and detail::parse_double (detail/fast_number.hpp) bounds its from_chars call to the number instead of strlen of the rest of the buffer, which made every reader parsing a whole file through it quadratic. The fuzz campaign's findings and the performance audit are why. The same release adds, additively: Unsupported in exceptions.hpp, detail::check_vtk_cell_arrays and detail::vtk_xml_header_bytes in detail/vtk_cells.hpp, a vtu_encode_binary overload taking the header_type width and detail::vtu_header_bytes_for in detail/vtu_binary.hpp, a vtu_write_field_array overload taking it in detail/vtk_xml.hpp, and the new headers detail/read_guard.hpp and detail/parse_guard.hpp; detail/polyhedron.hpp changed only in a comment.
18v16.16.0Tier A, layouts (roadmap §3). The MESHIO backend's CellBlock stores ragged blocks as CSR -- mFlat, mRowOffsets, mFaceOffsets, the NATIVE layout -- instead of the nested mPolygonRows/mPolyhedronRows; detail::FaceLookup and detail::FacetIndex hold sorted vectors instead of unordered_maps; detail::DistanceQuery replaces its mEdgeNormal map with mEdgeOfCorner and mEdgeNormals; and detail::SurfaceEdgeMap (the return type of the exported build_surface_edges) becomes a sorted std::vector of (edge, record) pairs. All four are now pinned in test_abi_layout.cpp (CellBlock on the MESHIO configuration). Added in the same release: CSR overloads of AddPolygonBlock/AddPolyhedronBlock on every backend and the new header detail/ragged_csr.hpp; parallel_sort, parallel_reduce and parallel_exclusive_scan in parallel.hpp (new inline templates; parallel_for's body is unchanged); detail::ProvenanceSlotlessWrite in detail/provenance.hpp.
19v16.25.0Tier A, layouts (roadmap §5). AgglomerateOptions gains mMergeCoplanarFaces, mCoplanarAngleDeg and mMinSphericity (coplanar face merging and the sphericity absorption gate) and AgglomerateResult the counters mNumFacesMerged and mNumRejected; SequencePipeline gains mResample (std::optional<SequenceResample>, time resampling), so sizeof(SequencePipeline) moves. AgglomerateOptions is pinned in test_abi_layout.cpp from this release (it was not, so it only looked free to grow). Also taken with the bump: the deprecated detail::decim_mark_features (no caller since v16.23.0) is removed from detail/decimate_common.hpp. Added in the same release: the new header operations/blend.hpp (blend_steps), and in operations/sequence.hpp the new types ResampleMethod, ResampleExtrapolate, SequenceResample, ResampleSlot and the functions resample_plan, resample_times_range, resample_method_from_name and sequence_times.
20v16.27.0Tier A, layouts (roadmap §4). MdpaInfo gains mModelPartData, mTables, mGeometries, mMeshBlocks, mSubModelParts and mRawBlocks (72 -> 216 bytes), so the blocks a Mesh cannot hold (top-level tables, geometries, Mesh blocks, sub-model-part data, text ModelPartData, raw blocks such as Constraints) travel with it; the new element types MdpaGeometryBlock, MdpaMeshBlock, MdpaSubModelPart and MdpaRawBlock are pinned in test_abi_layout.cpp from this release. Added in the same release: kratos_geometry_name in backends/kratos_names.hpp; carry_regions_to_facet_mesh in detail/region_remap.hpp (remap_region now carries side regions by containment -- a body change to an exported function, not a layout change); a ragged-aware detail::facet_nodes; and a reconstruct_cells overload in detail/vtk_cells.hpp that reports where each file cell landed.
21unreleasedTier A, layout: MdpaSubModelPart gains mGeometryIds and mConstraintIds (80 → 128 bytes), carrying nested sub-model-part geometry/constraint membership. MdpaInfo remains 216 bytes, but its vector elements have changed layout. The C ABI adds array fields without changing signatures or layouts.
22unreleasedTier A, layout: GmshInfo gains mPeriodic (24 → 48 bytes), holding GmshPeriodicLink records (112 bytes on the reference layout). The added 2.2 info-bearing writer overload and C metadata accessors are additive; existing C layouts stay unchanged.
23unreleasedTier A, layout (roadmap §3.3.1.2): DistanceQuery::mGrid changes type from SpatialGrid to the new read-only BucketTable (232 → 272 bytes), the bucket grid held as flat keys, offsets, ids and an open-addressing index instead of an unordered_map of vectors. SpatialGrid itself is unchanged, so merge, interpolate, conservative_interpolate and periodic are unaffected. BucketTable and BucketView come from the new header detail/bucket_table.hpp and are pinned in test_abi_layout.cpp.
24v16.34.0Tier A, layout (roadmap §7.1): RenderOptions gains the field-rendering options — mReduce, mExpr, the percentile clip mClipLow/mClipHigh, mSymmetric, mScale and mScaleThreshold, mCategorical, mColorRegions, mCategoryEdges, mIsolines, mIsoLevels, mIsoColor, the vector options, the warp options, mDiagnostic and mQualityMetric (320 → 608 bytes) — and two new enums, RenderScale and RenderDiagnostic. RenderOptions came in with v16.33.0 and is pinned in test_abi_layout.cpp, which is what caught the growth. The v16.33.0 defaults are unchanged: with none of the new fields set a frame is byte-identical.
25v16.36.0Tier A, layout (roadmap §7.2): RenderOptions gains the cut-away planes — mCutaways, a std::vector<RenderCutaway> of at most two planes, and mCutawayTint (608 → 640 bytes) — and a new struct, RenderCutaway (a point and a normal, pinned at 48 bytes). v16.37.0 grows RenderOptions once more for the streamlines (mStreamlines, mStreamSeeds, mStreamLength, mStreamColor, 640 → 696 bytes) and v16.38.0 for the synthwave theme (mTheme, mScanlines, mBloom, mFringe, mGridFloor, mThemePhase, 696 → 704 bytes) without a new ABI number: no release carried ABI 25 before them, so the three growths ship together. With no plane and no streamline set a frame is byte-identical to ABI 24's (the render_frame digests are unchanged). test_abi_layout.cpp caught the growth again.
26v16.40.0Tier A, from appended enumerators (the NurbsInfo side channel): CellType gains the seven VTK Bézier types (VtkBezierCurve … VtkBezierPyramid, VTK 75–81) after Triangle7, so Custom moves from 77 to 84 and the inline cell_type_name/cell_type_num_nodes/cell_type_dimension tables grow (the C API's mio_cell_type mirror moves with it, MIO_CELL_Custom 77 → 84); the inline is_special_cell (vtk_common.hpp) and the topological_dimension() table (types.hpp) accept them. Riding along, additive: the new header nurbs.hpp (NurbsPatch, NurbsTopology, NurbsInfo, pinned in test_abi_layout.cpp) and the read_mfem/write_mfem overloads that take a NurbsInfo (formats/mfem.hpp). test_abi_layout.cpp pins the new enumerator values.
27v16.42.0Tier A, layouts (roadmap 6.3.2, the freeform fallback): FitCadOptions gains mFreeform, mFreeformDegree and mFreeformMaxControl (32 → 48 bytes) and FitCadResult gains mNumBspline and mBrep, the solid as a BrepInfo (it embeds a Mesh and is not pinned); the C API's mio_fit_cad_opts and mio_fit_cad_report take the new fields from their reserved tails, so their sizes are unchanged. Riding along, additive: the new header brep.hpp (BrepCurve, BrepTrim, BrepLoop, BrepRef, BrepFace, BrepEdge, BrepVertex, Brep, BrepInfo, CadReadOptions, cad_read_has_occt), the new headers formats/cad_json.hpp (CadJsonReadOptions, read_cad_json, read_cad_json_info, sample_brep, write_cad_json) and formats/iges.hpp (read_iges), the read_step overloads in formats/step.hpp, the mdpa_cad_json_path function and the read_mdpa/write_mdpa overloads taking a BrepInfo in formats/mdpa.hpp, and kFitCadBsplineType in operations/fit_cad.hpp; the new structs are pinned in test_abi_layout.cpp.
28v16.42.0Tier A, layouts (roadmap 6.3.1, 6.3.2): FitCadOptions gains mFreeformSplit, mFillets and mThreads (48 → 56 bytes) and FitCadResult gains mNumSplitPatches, mNumFillet and mNumHelical; the new fit_cad:type values 7 (fillet) and 8 (thread flank) come with the kFitCadFilletType and kFitCadHelicalType constants. The C API's mio_fit_cad_opts and mio_fit_cad_report take the new fields from their reserved tails, so the C sizes are unchanged.
29v16.44.0Tier A, layouts (roadmap 6.3.1, closing it): FitCadResult gains mNumVariableFillet, mNumBlend and mNumRoundedThread; FitCadOptions is unchanged, and kFitCadFilletType and kFitCadHelicalType keep their values with wider meanings (a fillet of varying radius or a blend; a helical face of any profile). The C API's mio_fit_cad_report takes num_variable_fillet and num_blend from its reserved tail and num_rounded_thread (an int32_t) from its former reserved0, so its size is unchanged at 192 bytes and its tail is now full.
30v17.2.0Tier A, layouts (roadmap 6.3, closing it): FitCadResult gains mNumVertexBlend, mNumChamfer, mNumConicBlend, mNumTaperedThread and mNumVariablePitchThread; FitCadOptions is unchanged, and two fit_cad:type values are new (kFitCadVertexBlendType 9, kFitCadChamferType 10). The C API's mio_fit_cad_report is unchanged at 192 bytes; the new counts are in the new mio_fit_cad_report_ex (128 bytes, an 88-byte reserved tail), filled by the new mio_fit_cad_ex.
31v17.9.0Tier A, layouts (clean skin-to-CAD): SegmentOptions gains mMethod (SegmentMethod, angle or primitive growth) and mTolerance, 96 -> 112 bytes; FitCadOptions gains mMergeFaces and mBackend (FitCadBackend) in its tail padding, its size unchanged at 56 bytes, and FitCadResult gains mBackend. formats/step.hpp adds StepFaces, StepWriteOptions (16 bytes) and a write_step overload. The C API's mio_segment_opts takes method and tolerance from its reserved tail, mio_fit_cad_opts takes merge_faces from its former reserved1 and backend from its tail, and mio_fit_cad_report_ex takes backend from its tail, so all three keep their sizes (96, 80 and 128 bytes).
32v17.11.0Tier A, layouts (iterative skin to CAD, phase 1): FitCadOptions appends mRefineSegmentation and mRefine (a RefinePatchesOptions, phase 1) and mFreeformGeneral, mFreeformTolerance, mFreeformSmoothing, mMaxFreeformPoles, mMaxFreeformIterations and mMaxFreeformSplits (phase 2) and mSewTolerance, mExtendSurfaces, mSnapJunctions and mFaceDeviation (phase 3), 56 -> 224 bytes, and FitCadResult appends the refinement's report (mRefined, mRefineIterations, mNumRefineSplits, mNumRefineMerges, mInitialExplainedAreaFraction, mExplainedAreaFraction) and the general freeform counts (mNumFreeformGeneral, mNumFreeformRefinements, mAvgFreeformPoles) and mRefinementKept, and the phase 3 report (mNumExtendedEdges, mNumSnappedJunctions, mHausdorffMax, mHausdorffRms, mClosed), and operations/fit_cad.hpp gains the kFitCadFaceHausdorffName and kFitCadFaceRmsName constants. Riding along, additive: the new header operations/refine_patches.hpp (RefineProxy, RefineDemotion, RefineStop, the kRefine*Name constants, kRefineDefaultSeed, RefinePatchesOptions (96 bytes, pinned in test_abi_layout.cpp), RefinePatchesResult (embeds a Mesh, not pinned) and refine_patches), and fit_cad_occt_version in operations/fit_cad.hpp. The C API keeps mio_fit_cad_opts (80 bytes), mio_fit_cad_report (192) and mio_fit_cad_report_ex (128) unchanged and adds new structs for the new options and counts.
33v17.12.0Tier A, layouts (skin-to-CAD freeform modes): FitCadOptions appends mFreeformMode (FitCadFreeformMode), mFreeformParameterization (FitCadParameterization), mExtraordinaryVertex (FitCadExtraordinaryVertex), mLocalRefine (FitCadLocalRefine), mFreeformG1ToleranceDeg, mFreeformMaxControlPoints, mLayoutTargetPatchSize, mLayoutMaxValenceDeviation, mResegmentRounds, mLayoutSnapToFeatures and mValidateBySurfaceSampling, 224 -> 288 bytes, and FitCadResult appends mBrepChecked, mFreeformMode, the mode's counts (mNumFreeformAdaptive, mNumNetworkPatches, mNumNetworkEdges, mNumExtraordinaryVertices, mNumResegmented, mNumEscalations), its measures (mFacetAreaFraction, mMaxG1AngleDeg, mMaxFreeformDistance, mRmsFreeformDistance) and mFaces, a vector of the new FitCadFaceRecord (96 bytes, pinned in test_abi_layout.cpp). operations/fit_cad.hpp also gains the FitCadDemotionReason and FitCadParamMethod enums and the kFitCadFaceTableName constant. The C API keeps every existing struct and entry point: mio_fit_cad_opts_ex2 (128 bytes) and mio_fit_cad_report_ex3 (184 bytes), both with reserved tails, are new, mio_fit_cad3 takes them, and mio_fit_cad2 forwards to it with the defaults. The bump covers the whole freeform-modes feature until it ships.
34v17.15.0Tier A, layouts (roadmap §6.2, Delaunay refinement): MeshCadSurfaceOptions appends mMinAngleDeg, mSmoothIterations and mSmoothMethod (MeshCadSmoothing), and, laid out now so 6.3, 6.4 and 6.6 take no second bump, mInsertion (MeshCadInsertion), mGrading, mCurvatureDeviation, mCellsAcrossGaps, mMetric (MeshCadMetric), mPoleRings, mReparamConditionNumber and mOrder, 48 -> 120 bytes; a field whose item has not landed is refused unless left at its default. MeshCadFaceRecord appends mNumRefinementPoints, mNumSmoothed, mMinInputAngleDeg, mBelowBound, mBoundDeg and mSmallAngleCorners, a vector of the new MeshCadCorner (88 bytes), 112 -> 176 bytes; MeshCadSurfaceResult appends mNumRefinementPoints, mNumFacesBelowBound, mNumSmoothed, mAngleHistogram and mNumCurvedStraightened. TriangulatePolygon2dOptions (24 bytes) and its triangulate_polygon_2d overload are new, as is mesh_cad_smoothing_name. All pinned in test_abi_layout.cpp. With mMinAngleDeg 0 and no smoothing a mesh is byte-identical. The C API grows mio_mesh_cad_surface_opts and its report within their reserved tails.
35unreleased (after v17.21.0)Tier A, layout (roadmap §6.10.2, a metric source): MeshCadSizeSources appends pMetricBackground (a borrowed const Mesh*), mMetricArray (std::string) and mMetricError (double), 96 -> 144 bytes, pinned in test_abi_layout.cpp. Riding along, comment-only: MeshCadSurfaceExtras::mModelTolerances (vertex and file-level tolerances, trim pairing), MeshCadToleranceUse ("pairing", mEdge -1) and MeshCadReparamFace::mMethod (3, the annulus). With no metric background a mesh is byte-identical. The C API's mio_mesh_cad_surface_opts_ex takes metric_background, metric_array and metric_error from its reserved tail, keeping its 136 bytes.

It reaches consumers three ways:

  1. The CMake package — MESHIOPLUSPLUS_ABI_VERSION is set by meshioplusplusConfig.cmake beside the existing MESHIOPLUSPLUS_MESH_BACKEND / MESHIOPLUSPLUS_WITH_* introspection.
  2. SOVERSION — the C++ variants install as libmeshioplusplus_core_<backend>.so.<abi>, so the dynamic linker refuses to load an incompatible library into an already-linked binary, with no cooperation from anyone's build system. (The C library keeps SOVERSION 0; its contract is unchanged.)
  3. A link-time sentinel — see below.

detail/abi_version_check.hpp plants, in every translation unit that includes mesh.hpp, a reference to meshioplusplus::detail::abi_version_is_<N>(), where N is the ABI version that TU's headers declare. The library defines exactly one such symbol. Disagree, and the link fails naming the version the consumer expected:

undefined reference to `meshioplusplus::detail::abi_version_is_99()'

instead of corrupting memory at run time. It is the twin of detail/mesh_backend_check.hpp, deliberately a separate symbol so that a consumer with the right backend and stale headers is told about the headers rather than misleadingly about the backend. Cost is one relocation per TU per axis and no static initializer.

Three properties worth knowing:

  • gnu::used is load-bearing. An unused inline variable has vague linkage and is emitted lazily, so without it no relocation reaches the object file and the guard is silently inert. That was the backend guard's state through v9.1.0. CI asserts the reference really is present (nm -uC) rather than assuming it.
  • MSVC + a shared build is a gap. The MSVC arm is #pragma detect_mismatch, whose /FAILIFMISMATCH records live in .obj files and are not reliably carried through a DLL import library. Static MSVC builds and every GNU/Clang configuration are covered. On that one configuration the SOVERSION-derived DLL name and the version pin are what remain.
  • Opt out with MESHIOPLUSPLUS_NO_ABI_VERSION_CHECK, mirroring MESHIOPLUSPLUS_NO_BACKEND_LINK_CHECK. MESHIOPLUSPLUS_ABI_VERSION itself is deliberately not -D-overridable: a knob to change it would be a knob to silence the guard while still miscompiling.

The one false positive ​

Introducing the guard in v9.4.0 means v9.4.0 headers reference abi_version_is_3, which a v9.2.0 or v9.3.0 library does not define — so that pairing fails to link even though it is genuinely compatible by the criterion above. This is a one-time cost at the boundary, and it fails closed, which is the direction to fail in.

Keeping the number honest ​

A hand-bumped integer has the same "someone forgets" failure mode as the prose rule it replaces. Two gates, covering different tiers:

gatecatcheshow
tests/cpp/test_abi_layout.cppTier Astatic_asserts on sizeof/alignof, per backend, in the cpp-tests matrix. Objective; no judgement.
tools/check-abi-version.shTier BDiffs installed headers against the previous release tag. Headers changed + ABI unchanged + no review entry for this release naming those headers = red build.

Neither is sufficient alone: the first cannot see an inline body change, and the second cannot tell an edited inline function from a new one.

The emphasis in that second row is load-bearing, and was a bug fixed in v9.4.1: matching a review row on the ABI number alone passes vacuously as soon as one such row exists, so the lookup is keyed on the release version and on the set of headers the row accounts for. tests/python/test_check_abi_version.py asserts the gate actually fires — the same lesson the backend guard learned in v9.1.0, when it shipped inert for a full release because nothing tested that it did.

What to pin, in practice ​

See the C++ API page for the full guidance. In short:

cmake
# Finer, and true: pin what actually constrains you.
find_package(meshioplusplus CONFIG REQUIRED COMPONENTS CXX)
if(NOT MESHIOPLUSPLUS_ABI_VERSION EQUAL 26)
  message(FATAL_ERROR "meshio++ ABI 26 required, found ${MESHIOPLUSPLUS_ABI_VERSION}")
endif()
cmake
# Conservative, and still fully supported: pin the release.
find_package(meshioplusplus 16.22.0 EXACT CONFIG REQUIRED COMPONENTS CXX)

Both are correct. The second is stricter than it needs to be, and that is a legitimate choice — it is one line, it needs no reasoning about tiers, and re-pinning is a small price for never thinking about this page again.

Released under the MIT License.