Skip to content

Generate the reflection table beside the C++ headers - #173

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/blissful-euler-fzuap2
Sep 12, 2026
Merged

Generate the reflection table beside the C++ headers#173
matt-edmondson merged 3 commits into
mainfrom
claude/blissful-euler-fzuap2

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

⚠️ CI will be red on restore until ktsu.Coder 3.7.0 finishes indexing on nuget.org. ktsu-dev/Coder#53 is merged and VERSION.md on that repo's main reads 3.7.0, but the package was not yet resolvable when this was opened. Nothing here needs changing — the checks want a re-run once it lands, and I'll do that.

A generated struct carries a name, a type and an order. Everything else the schema says about a member — its unit, its range, whether it wraps, whether two states of it can be blended, how it is quantised on the wire, what an editor should draw — the header can only put in a comment, which is to say it cannot say it at all.

CppGeneratorOptions.Reflection is where those facts become data. Two files: reflect (the vocabulary) and reflection (the table). Off by default, because they are two files a target that does not read them did not ask for.

inline constexpr MemberInfo kRigidBodyMembers[] = {
    {
        .name = "Mass",
        .description = "",
        .unit = "kg",
        .kind = TypeKind::Semantic,
        .representation = TypeKind::Float,
        .dimension = { 0, 1, 0, 0, 0, 0, 0, 0 },
        .offset = static_cast<std::uint32_t>(offsetof(holo::components::RigidBody, mass)),
        .size = static_cast<std::uint32_t>(sizeof(holo::components::RigidBody::mass)),
        .has_range = true,
        .range = { 0.001, 1000000, false },
        ...
    },
};

template <>
struct Describe<holo::components::RigidBody>
{
    static constexpr ClassInfo info = { .name = "RigidBody", ..., .members = kRigidBodyMembers };
};

The offsets are the compiler's

offsetof(Class, member) and sizeof(Class::member), so the numbers are whatever the compiler chose for that target, that ABI, those packing rules. Reflection therefore cannot drift from the layout it describes — it is derived from it, by the only thing that knows. A generator that worked offsets out itself would be a second implementation of the C++ ABI and would be wrong somewhere eventually.

AsksTheCompilerForTheLayout asserts both halves: that offsetof is there, and that no literal offset is.

kind and representation are both carried

One field could not do both jobs. kind is what the schema declares — Semantic for a member typed Kilograms. representation is what the bytes are, with a semantic type followed down its chain of refinement to Float.

Reading a value out of a save file needs the second. Showing the member to a person wants the first, because "Kilograms" is the answer and "Float" is not.

The dimension comes from the unit

The member's unit text resolves through UnitRegistry, and the unit's own DimensionInfo supplies the eight exponents — so the numbers cannot disagree with the unit written next to them. m/s gives { 1, 0, -1, 0, 0, 0, 0, 0 }; a member that measures nothing is dimensionless, which is the same shape rather than a missing one.

reflect is shipped, and two of its lists are not

Same reason ktsu.Semantics.Cpp ships its prelude: template <typename T> struct Describe; declares a type parameter and concept Reflected is a concept, and the AST models neither — a generator names a generic type, it never declares one.

Two lists in it are substituted, and that is the point of substituting rather than copying:

  • TypeKind is every [JsonDerivedType] on BaseType — 24 enumerators today.
  • Interpolation is the enum of that name.

So a type added to the schema appears in C++ with no edit in either repository. Each is written twice — as the enumeration and as the names beside it — from one list, because the alternative is a switch, which the AST cannot say and which nothing would then keep in step. A test counts both against the attribute count.

What reads the table is written once, by hand

A validator, a serialiser, a network codec and an editor all walk it rather than each being a generator with its own copy of the same facts — which is also what lets them work on a schema loaded at run time. The rule that suggests: generate what has to be a type, and write by hand what only needs to read a type's description.

Tests

Thirteen new, 67 in the project. The one that matters is TheTableCompilesAndSaysWhatItWasGeneratedToSay — a program of static_asserts over a plain-types schema, checked with -fsyntax-only so it needs no run, asserting the two things text assertions cannot reach:

static_assert(describe<plain::Body>().member("Mass").offset == offsetof(plain::Body, mass));
static_assert(describe<plain::Body>().member("Mass").representation == TypeKind::Float);
static_assert(describe<plain::Body>().member("Heading").range.wrap);
static_assert(to_string(TypeKind::Float) == "Float");

That the offset in the table equals offsetof of the same member, and that describe<T>() finds the right descriptor by type, are exactly what asserting on generated text cannot check.

Schema.Test: 413 passed, unchanged. Verified locally against a project reference to the merged ktsu.Coder before the pin was set.

⚠️ One caveat

Radian comes out dimensionless in the table, because this repo pins ktsu.Semantics.Quantities 4.0.0, which predates the angle axis added in ktsu-dev/Semantics#214. The table is correct for the version in use; the angle exponent arrives when that pin moves, which is separate work.

Requires ktsu.Coder 3.7.0

For ClassDeclaration.SpecialisationArguments, which is what makes template<> struct Describe<Class> expressible. Without it the table would have to be a DescribeRigidBody every consumer spells for itself — which is what a lookup by type exists to avoid.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu


Generated by Claude Code

A generated struct carries a name, a type and an order. Everything else the
schema says about a member - its unit, its range, whether it wraps, whether two
states of it can be blended, how it is quantised on the wire, what an editor
should draw - the header can only put in a comment, which is to say it cannot
say it at all. CppGeneratorOptions.Reflection is where those facts become data.

Two files. `reflect` is the vocabulary and `reflection` is the table, and the
option is off by default because they are two files a target that does not read
them did not ask for.

The offsets are the compiler's. The table says offsetof(Class, member) and
sizeof(Class::member), so the numbers are whatever the compiler chose for that
target, that ABI, those packing rules - and reflection therefore cannot drift
from the layout it describes, because it is derived from it by the only thing
that knows. A generator that worked offsets out itself would be a second
implementation of the C++ ABI and would be wrong somewhere eventually. One of
the tests asserts that no literal offset appears at all.

A member's kind and its representation are both carried, and one field could not
have done both. `kind` is what the schema declares - Semantic for a member typed
Kilograms - and `representation` is what the bytes are, with a semantic type
followed down its chain of refinement to Float. Reading a value out of a save
file needs the second; showing the member to a person wants the first, because
"Kilograms" is the answer and "Float" is not.

The dimension comes from the unit rather than beside it. The member's unit text
resolves through UnitRegistry and the unit's own DimensionInfo supplies the eight
exponents, so the numbers cannot disagree with the unit written next to them.

`reflect` is shipped rather than generated, for the same reason ktsu.Semantics.Cpp
ships its prelude: `template <typename T> struct Describe;` declares a type
parameter and `concept Reflected` is a concept, and the AST models neither - a
generator names a generic type, it never declares one. Two lists in it are
substituted, though, and that is the point of substituting rather than copying:
TypeKind is every [JsonDerivedType] on BaseType and Interpolation is the enum of
that name, so a type added to the schema appears in C++ with no edit in either
repository. Each is written twice, as the enumeration and as the names beside it,
from one list - because the alternative is a switch, which the AST cannot say and
which nothing would then keep in step.

What reads the table is written once, by hand. A validator, a serialiser, a
network codec and an editor all walk it rather than each being a generator with
its own copy of the same facts, which is also what lets them work on a schema
loaded at run time. The rule that suggests: generate what has to be a type, and
write by hand what only needs to read a type's description.

Thirteen tests. The one that matters is that the table compiles and holds: a
program of static_asserts over a plain-types schema, checked with -fsyntax-only
so it needs no run, asserting the two things text assertions cannot - that an
offset in the table equals offsetof of the same member, and that describe<T>()
finds the right descriptor by type.

Requires ktsu.Coder 3.7.0 for ClassDeclaration.SpecialisationArguments, which is
what makes template<> struct Describe<Class> expressible. Without it the table
would have to be a DescribeRigidBody every consumer spells for itself, which is
what a lookup by type exists to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
Comment thread Schema.Cpp/CppReflectionBuilder.cs Fixed

Copy link
Copy Markdown
Contributor Author

Test on ubuntu-latest failed on restore, and the cause has since cleared. Nothing in this diff needs changing.

The failure is NU1102 on every project that reaches ktsu.Coder, and only that:

error NU1102: Unable to find package ktsu.Coder with version (>= 3.7.0)
error NU1102:   - Found 17 version(s) in nuget.org [ Nearest version: 3.6.0 ]

3.7.0 is the release carrying ktsu-dev/Coder#53, which this PR needs for ClassDeclaration.SpecialisationArguments. That PR merged at 03:48; its own publish run was cancelled as superseded when a second PR merged six minutes later, and the replacement run — which carries both changes — pushed the package at 04:05. This run's restore ran at 04:07, inside the window before nuget.org had indexed it.

It has indexed now. ktsu.Coder 3.7.0 resolves, and Schema.Cpp.Test passes 67/67 against the published package rather than the project reference the work was developed against.

Re-running the failed jobs is refused while the other platforms in this run are still going (403 This workflow is already running), so I'll re-run once it completes. No push is involved — the same commit restores cleanly now.


Generated by Claude Code

matt-edmondson and others added 2 commits September 12, 2026 04:10
A member that names no enum contributes nothing to the values tables, and saying
that as an empty sequence rather than as an if inside the loop leaves the loop
doing one thing to everything it is handed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
The vocabulary's TypeKind is every [JsonDerivedType] on BaseType, read off the
attribute itself. On net10.0 that type is the shared framework's and needs no
reference; on net9.0 it arrives transitively, and KTSU0006 is right that a
project using a package directly should say so.

Only the net9.0 leg failed, and only in CI, because the local verification built
one framework rather than both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
@matt-edmondson
matt-edmondson merged commit 0d6cb7f into main Sep 12, 2026
4 checks passed
@matt-edmondson
matt-edmondson deleted the claude/blissful-euler-fzuap2 branch September 12, 2026 04:14
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant