This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Schema is a C# library for defining and managing data structure schemas. It consists of eight projects:
- Schema - Core library providing schema definition types (classes, enums, members, types)
- Schema.Test - MSTest unit tests for the core library
- Schema.Cpp - The C++ generator, in its own project because
ktsu.Coderships nonet8.0 - Schema.Cpp.Test - Its tests, including the three acceptance tests against Holotype's target document and one that compiles the generated reflection table
- Schema.Editor - ImGui-based visual editor application for creating and editing
.schema.jsonfiles - Schema.Editor.Test - The editor's tests that need no frame: the recent-files list and reading and writing the document on disk. Ordinary unit tests, so they run on every platform - which is the point of keeping them out of the suite below, since those are the two parts of the editor whose behaviour differs by operating system
- tests/Schema.Editor.UITests - The editor's tests that drive real frames through
EditorHarness/WidgetHarness. The shareddotnet.ymlruns it on Linux only, as it does every other ktsu UI suite: the CPU rasterizer measures the same on any host, so the other platforms pay the cost without learning anything. Both halves of the path are load-bearing -ktsubuild --exclude "**/*.UITests/*"needs the.UITestssuffix and a leading directory, because its**/does not match zero segments. A project at the repository root is never excluded, however it is named - Schema.Tool - The
dotnet tool(kschema) that validates schemas and runs their code generators
dotnet build # Build entire solution
dotnet test # Run all tests
dotnet test --filter "FullyQualifiedName~TestName" # Run specific test
dotnet run --project Schema.Editor # Launch the visual editor
dotnet run --project Schema.Tool -- generate my.schema.json # Run a schema's code generators
dotnet pack Schema.Tool -c Release # Build the `kschema` tool packageThe type system uses polymorphic JSON serialization with System.Text.Json:
SchemaChild<TName> (base for named elements)
├── SchemaClass : SchemaChild<ClassName>
├── SchemaEnum : SchemaChild<EnumName>
├── SchemaInterface : SchemaChild<InterfaceName>
├── SchemaSemanticType : SchemaChild<SemanticTypeName>
├── DataSource : SchemaChild<DataSourceName>
├── SchemaCodeGenerator : SchemaChild<CodeGeneratorName>
├── SchemaClassChild<TName> : SchemaChild<TName>
│ └── SchemaMember : SchemaClassChild<MemberName>
├── SchemaInterfaceChild<TName> : SchemaChild<TName>
│ └── SchemaFunction : SchemaInterfaceChild<FunctionName>
└── SchemaFunctionChild<TName> : SchemaChild<TName>
└── SchemaParameter : SchemaFunctionChild<ParameterName>
BaseType (types, in ktsu.Schema.Models.Types)
├── Primitives: Int, Long, Float, Double, String, Bool, DateTime, TimeSpan
├── Vectors: Vector2, Vector3, Vector4, ColorRGB, ColorRGBA
├── Complex: Array, Object, Enum, Interface, Semantic, Quantity, None, Void
└── Wrappers: Span, Handle, Result, Optional
A vector says how many components it has; Vector.ElementType says what each one is. A velocity is
three metres per second and a position is three metres, and a schema that says only "three floats"
leaves the one fact worth knowing about either of them to a comment - which is the argument
Semantic makes for a single value, applied to three of them.
That argument has been overtaken, and by the thing it appeals to. ktsu.Semantics.Quantities
already names the 3D forms - Velocity3D, Displacement3D, Force3D - so a member holding one
says Quantity naming that type rather than Vector3 of a component. Saying it both ways is two
spellings of one fact, and the first is the one that needs the schema to describe a shape the
vocabulary has already given a name. So a dimensional vector is a quantity, and Vector2/3/4 is
untyped geometry: a position on a screen, a pair of texture coordinates, three channels of
something that is not a colour.
ElementType stays as it is rather than narrowing to "which numeric type", because a vector of a
schema's own semantic type is still a thing a schema may want - Vec3<EntityId> is not a quantity
and never will be - and both generators already spell it. What changed is which way a dimensional
vector is written, not what the property can hold.
The component must be a number or a Semantic over one; anything else is a collection of things
rather than one value with components, which is what Array is for. It defaults to Float and is
omitted from the file when it is, so a file whose vectors are vectors of floats is written exactly
as it was before the property existed - the omission is SchemaSerializer.OmitDefaultVectorElement,
because a non-nullable property has no ignore condition that means "the same as saying nothing".
ColorRGB and ColorRGBA inherit the property because they derive from Vector3 and Vector4,
and validation holds them to Float: a colour's components are its channels.
The editor's type picker does not offer a component yet, for the same reason it does not offer a
Semantic, a Span or a Handle - Schema.GetSelectableElementTypes still lists only the
built-ins, the enums and the classes.
An entity id is a number, and so is a texture id, and adding one to the other is nonsense that
compiles. SchemaSemanticType is how the schema says they are different things: both stored as a
Long, neither interchangeable with the other or with a bare number. Refer to one with the
Semantic type.
Two conventions, held by the schema rather than restated per declaration: crossing into or out of
the underlying type is always explicit, and a semantic type refining another widens
implicitly and narrows explicitly (a Weight is a ForceMagnitude; not every force is a weight).
A semantic type may only shim something whose values would otherwise be interchangeable. An
Object, Interface or Enum is already a distinct type, and an Array, Span, Handle,
Result or Optional describes how a value is carried rather than what it is; both are refused,
as are refinement cycles.
ISchemaMetadataCarrier is the shared shape for the six semantic properties. A member carries them
because a use is often where a unit or range is decided; a semantic type carries them because for
some values those facts belong to the type (Metres is metres everywhere). The rules are identical
either way, so validation reads the interface rather than each carrier growing its own copy.
Both sides read the representation rather than the declared type, through Represented in
Schema.Validation.cs. A semantic type is a distinct name for something already representable, so
a member typed Semantic(Kilograms) over a Float holds a number and a unit, a range and a default
are as meaningful on it as on the bare Float. This is what lets a unit live on the type, stated
once, while the bounds and the default stay on the member, where they are facts about that field
rather than about kilograms.
A chain that reaches nothing real - a name that does not resolve, or a refinement cycle - leaves
Represented returning the Semantic it was given, and every caller treats that as "already
reported elsewhere" rather than walking further. That is what keeps a cycle a reported error instead
of a recursion with no bottom, and it is why an unresolved semantic type is one message rather than
one per property hanging off it.
An entity id is a number and so is a texture id, and SchemaSemanticType is how the schema says
they are different things. A mass is a different problem: the vocabulary for it already exists.
ktsu.Semantics.Quantities declares 220 physical quantities, ktsu.Semantics.Cpp emits the same
220 as C++ classes, and both are things a target already has. So the Quantity type names one
rather than asking for a copy of it - Quantity(Mass), Quantity(Velocity3D), Quantity(Ratio) -
and nothing is generated for it. That is the whole difference from Semantic, and both are wanted:
a semantic type is the schema's own, a quantity is everybody's.
Name the quantity, not the unit. What this replaces is a schema declaring a semantic type
called Kilograms and writing "unit": "kg" beside every member of it - the presentation said
twice, once as a name nothing reads and once as text something does. The kilograms are how the
stored number is read, which is a fact about the field; the mass is what the value is.
QuantityRegistry reads the vocabulary out of the assembly, the same arrangement UnitRegistry
has and for the same reason. A quantity is a generic readonly record struct implementing one of
the five IVectorN interfaces, and the arity of that interface says how many components a value
has. Its dimension takes three routes, in order: a magnitude declares one through
IPhysicalQuantity; a vector form does not, so its dimension is what Magnitude() answers with;
and a named overload of a vector form answers neither, so what is followed is the implicit
widening onto what it is an overload of. Six of the 220 are reachable only by that third route, and
220 is not a number chosen here - it is what the C++ projection emits, so the two counts agreeing
is what says a schema can name every quantity a C++ target has.
The ten it leaves out are the logarithmic scales and two hand-written audio types. A decibel does
not add and a pH does not scale, which is why ktsu.Semantics emits them from logarithmic.json
rather than as dimensions; they have no dimensional formula and no vector form, so accepting one
would put a type in the schema whose arithmetic means nothing and whose eight exponents a
reflection table would have to invent.
Three things follow from a quantity knowing its own dimension.
- A unit on one has to agree with it. The check the semantic type could never make: a unit is
text and
Kilogramswas a name, so the two had no way to disagree. The exponents are compared rather than the names, because 75 of the vocabulary's dimensions share 64 exponent vectors - a joule and a newton metre are the same eight numbers, and refusing what the physics allows is worse than not checking. - The reflection table asks the type first. The eight exponents used to come from the member's
unit text and nowhere else, so a member that measured something but named no unit was written
down as dimensionless - indistinguishable from a flag. A
Velocity3Dis a length over a time because of what it is. Storageis the rest of the type. Every quantity is generic over its storage, so a name alone is not yet something a generator can write. It defaults toFloatand is omitted from the file when it is, exactly as a vector'sElementTypeis, and it is held to a bare numeric: a generated semantic type is a record struct over a float implementing noINumber<T>, soMass<Kilograms>is not a type anything could write.
A default takes one more step than anything else here. A generated C++ quantity refuses a bare
number twice over: its own constructor is explicit - a bare value never becomes a Mass by
accident - and so is the constructor of the Quantity that one takes. So holo::Mass{ 1.0f } is
two conversions rather than one and does not compile; what does is the step said out loud,
holo::Mass{ holo::Mass::underlying{ 1.0f } }, and a vector form spells the same alias component
and takes one per component. Both names are ktsu.Semantics.Cpp's, which is a coupling rather than
a deduction - CppGeneratorOptions.Quantities is the target saying its vocabulary came from there -
so it is compiled rather than assumed, in QuantityCppTests against a stub that is explicit in both
places and in TheModernisedSampleCompiles against a sample whose gravity has a default.
C# spells it ktsu.Semantics.Quantities.Mass<float> and ClrTypeImporter reads it straight back
off the closed type - the one thing generated C# carries that needs no attribute recording what
it is. Everything else does, because a sequential struct or a record struct over a float is a
shape a hand-written type may have for its own reasons; a Mass<float> is not something a target
happened to write, it is the quantity. C++ spells it wherever the target's vocabulary went, which
CppGeneratorOptions.Quantities says in one entry for all 220 - the target named none of them, it
ran the generator. That entry also says what the vocabulary is stored in, because a C++ quantity is
a class rather than a template and the storage was fixed when it was generated: a member the schema
keeps in a double is refused on a float target rather than emitted as two languages quietly
disagreeing about the bytes.
SchemaClass.TravelsAsBytes says an instance is copied whole - across a language boundary, into a
save file, onto the wire - without anyone reading a field on the way. That makes member order
load-bearing rather than merely meaningful, and it constrains the members: a String, an Array, a
Span, an Optional, a Result, an Interface or an Object naming a class that makes no such
promise are all refused. A Handle is accepted, because an index and a generation is bytes whatever
it identifies.
Schema.Validation.cs reads the flag off a named class rather than walking into it, so two classes
holding each other validates rather than hanging the validator. It is off by default and omitted
from the file when it is, so a class that says nothing about how it travels is what a class has
always been.
Generated C# emits a promising class as a [StructLayout(LayoutKind.Sequential)] struct, and every
other class stays a class. A reference type could never keep the promise however carefully it was
written - an instance is an address, its fields are somewhere else, and the CLR orders them as it
likes - so a consumer needing the two languages' versions of a class to be the same bytes had
nothing to compare, and the promise was recorded but unkeepable. Sequential layout is what makes
member order load-bearing on this side too. It is narrow by design: it reshapes the classes that
make the promise and no others, rather than being a move to value types for generated C# in general.
Nothing about a CLR type says the promise was made, though - sequential layout is a fact about a
type that a hand-written struct may have for its own reasons - so generated C# still carries
SchemaTravelsAsBytesAttribute and ClrTypeImporter still reads it back, the same arrangement the
member metadata uses and covered by the same generate-compile-reimport test. The struct is how the
promise is kept; the attribute is how it is recorded. Reading a value type as a class is gated on
that attribute for the same reason: read as "any struct", a Guid would come back as a class with
none of its members, which says the wrong thing rather than nothing.
Two things follow on the C# side. A member holding a promising class is given no initialiser,
because a struct is already the value its declaration described; and a struct whose members do have
initialisers - from a default the schema declared - needs a parameterless constructor of its own
before it will compile, which the generator writes. default(T) is still all zeroes, which is what
it means for the bytes to be the whole of the value rather than a defect in the defaults.
The three types that used to defeat the promise are now spelled. A Handle, a Semantic and a
vector of anything but floats all fell through MapType to object?, which compiles and says
nothing - and inside a sequential struct says something worse, a reference field in the one kind of
class whose whole promise is that it has none. Validation accepts all three there, because they
travel as bytes in a language that can say them, so the gap was the generator's rather than the
schema's.
- A handle is
Handle<T>fromktsu.Schema.Runtime, an index and a generation withTcarried and never stored. That phantom parameter is what makes the schema's rule true in C#: a handle travels as bytes whatever it identifies, so a promising class may hold a handle to one that makes no promise at all, while the type still keeps a handle to a texture out of a slot meant for a mesh. - A vector of floats is still
System.Numerics.Vector3, which holds floats and nothing else; a vector of anything else isktsu.Schema.Runtime.Vector3<T>, whose components are fields ofT. Two spellings for one family, because keeping the first is what stops this changing the type of every vector member that already existed. - A semantic type is emitted, which it was not before - a
readonly record structover its representation, one file each, beside the classes and the enums. C# spellsexplicitandimpliciton a conversion directly, so the schema's two conventions are the conversions themselves rather than a comment beside them: crossing into or out of the representation is explicit both ways, and a type refining another widens implicitly and narrows explicitly.
SchemaSemanticTypeAttribute is what the shape cannot say, the same arrangement as
TravelsAsBytes. A semantic type and a promising class are both a struct, so without it Kilograms
reimports as a class with a member called Value; and a type that refines another stores the shared
representation exactly as the one it refines does, so the field says float either way and only
Refines can say that one arrived by way of the other. The six metadata attributes target a struct
as well as a member for the same reason, since a semantic type carries them too -
ISchemaMetadataCarrier is settable so the importer restores both through one path.
The proof is a test that pins an instance: the CLR refuses to pin a type holding a reference
anywhere in it, so a promising class holding all three either is bytes or the test fails. It is the
C# counterpart of the static_assert(std::is_trivially_copyable_v<T>) the C++ generator emits
beside the same class.
Being bytes is not the same as being the same bytes, and two members went on differing after
the layout was sequential. Sequential layout fixes the order of the members and says nothing
about the width of each one, so a generated class was the same bytes in both languages only where
every member happened to agree - and two did not. An enum is four bytes in C# unless the
declaration says otherwise and one in the C++ emitted here, which writes
enum class ... : std::uint8_t; a bool is one byte in the managed layout and four when
marshalled, so Marshal.SizeOf disagreed with the runtime before either disagreed with C++. Both
are now spelled: a generated enum is : byte, and a bool in a promising class carries
[field: MarshalAs(UnmanagedType.U1)] - field: because an auto-property's backing field is what
the marshaller lays out.
Neither width is anything a .schema.json states, so there is no third place for the two
generators to agree with; they agree by each naming the same width and each pinning its own half,
in GeneratedLayoutAgreesWithCppTests here and EnumUnderlyingTypeTests in Schema.Cpp.Test. The
first measures the type rather than the text - a bool, a one-byte enum and a bool is three bytes
when every member is what C++ makes it, nine when the enum is an int, and twelve when the bools
marshal as four each, so one assertion on the size tells the three cases apart.
The four that were left - a Span, a Result, an Optional and an Interface - were never a gap
in the mapping the way those three were. Each is a decision about the generated C# API, and the one
that had to come first is that an interface is emitted at all: until it was, an Interface
member named a type nothing produced. Generate now writes a file per interface as well as per
class, enum and semantic type.
- An interface is emitted under the name the schema gave it, with no
Iprefix. The prefix is the C# convention and it is not available: the compiled name is what the importer reads back, so one would have to be stripped again, and stripping cannot tell a prefix from a first letter - an interface calledItemwould come back astem. Same rule as the classes and the enums. - A view is a
ReadOnlySpan<T>when its elements areInand aSpan<T>otherwise, which is the same reading the C++ generator gives it, where the difference is aconston the element rather than a second type. It is spelled byMapParameterrather thanMapType, because the direction that decides which belongs to the parameter and a type has no route back to one. In a member position aSpanis stillobject?- a field of aref structis something C# forbids outright, which is a language limit rather than a gap. - A fallible return is
Result<T, TError>, orResult<TError>forResult<Void>, since C# has novoidtype argument to close the value-carrying form over. The arity is what tells the two apart on reimport.TErroris the schema's error enum, read through the type'sParentSchemathe same way aSemanticresolves its declaration. - An absent value is
Optional<T>.T?would be idiomatic and means two different things: over a value type it is a type, and over a reference type it is an annotation that is not part of the type at all. A generator writing it would round-trip anOptional<Int>and lose anOptional<Item>, which is the one asymmetry the mapping cannot afford.
Two things a signature says that C# cannot. IsQuery has no syntax - C++ writes a trailing const
- so it is recorded with
SchemaQueryAttribute. Direction is the parameter modifier, andInis no modifier at all, because an ordinary by-value parameter is already one the caller supplies and the callee does not modify;out,refand by-value are three different compiled signatures, so all three read back without an attribute.voidis spelled only as a return type, byMapReturnType, because there is no field, property or parameter of one.
Schema.AddInterface(Type) is the counterpart of AddClass(Type), and is what makes an interface
part of the round trip rather than something emitted and never read back.
What is still object? is a Span outside a signature and a Void outside a return type - both
declarations C# has no member for at all, and neither one a promising class may hold.
A class says what data is; an interface says what the program can do. SchemaInterface holds
SchemaFunctions, each holding ordered SchemaParameters and a return type, and a generator turns
one into the header an implementation is written against.
A signature carries no ownership, lifetime or error annotations because four conventions carry that
weight globally instead: fallibility is Result in the return type, something the caller may keep
is a Handle, a borrow valid for the call is a Span, and const-ness is
SchemaParameter.Direction. Every interface language that let signatures answer those individually
grew annotations until it was a worse version of the language it described; keeping them global is
what stops that. Schema.Validation.cs enforces the corollaries - no Array, Result or Void
parameters, no None anywhere generatable.
Both halves of that last sentence are checked on the way down, by ValidateTypeStandsHere,
rather than at the top of each declaration. A wrapper is transparent to everything else in
validation - a class named inside an Optional is checked exactly as one named directly is - and
that transparency is what used to let the two absent types through: a Void was refused as a
parameter and nowhere else, so a member typed Void, or an Optional<Void> or Span<Void>
anywhere, validated cleanly and then emitted void x{};, std::optional<void> or
std::span<void>, none of which are types. TypePosition is what the descent carries, and it
names the only two positions an absent type may stand in: a function's return type, and the
value a Result carries, which is how "can fail, produces nothing" is spelled. A None is
reported there too, but only below a declaration - a member, a parameter and a return type each
already say what is unfinished in their own words, and a vector says what its components have to
be, so those keep their own message rather than gaining a second one.
What a failure says is global for the same reason: Schema.ErrorType names the enum a failed
Result carries, once, rather than every fallible signature choosing its own. An enum rather than
any type, because an error is one of a closed set of reasons. A schema that never returns a Result
needs none; one that does is reported at the signature, which is the declaration whose meaning is
incomplete.
Whether a call changes the thing it is called on is the fifth, and the one the other four left
out. SchemaFunction.IsQuery says a query answers rather than acts, held per function rather than
globally because unlike the others it differs from one signature to the next - it is a property of
what the call does rather than a rule the program keeps. C++ writes it as a trailing const. A
query returning Void cannot be observed at all, which validation reports as a warning.
On a Span, direction describes the elements, not the view: In Span<Velocity> is
std::span<const Velocity> and Out Span<Position> is std::span<Position>.
A type is not a named child of the schema: it has no name or description of its own and exists only
as the type of the member holding it. BaseType.TypeName reports which type it is, and is the same
value written as the file's TypeName discriminator.
Schema.Cpp turns a schema into a ktsu.Coder AST and hands that to CppGenerator, which owns
every question about how C++ is spelled. Nothing in Schema.Cpp writes a brace.
It lives outside the core library because it cannot ship there: this library publishes net8.0 and
ktsu.Coder does not, which is the whole reason SchemaGenerator.Register exists.
Schema.Tool/Program.cs is the worked example of a host registering it.
CppGeneratorOptions is what a target says that its schema cannot. Almost everything comes from the
schema - a unit is a semantic type and the generator emits the class, a class is a struct, and the
standard library spells a string, a sequence, a view and an absent value. What is left is the short
list a program supplies for itself: a fixed-shape numeric vector, a colour, an identifier with a
generation, a fallible return, a calendar date. A target that has them says how it spells them and
which header they come from; a target that has not is refused those schema types by name, with
the option to set, rather than handed a header that will not compile. ExistingTypes is the other
direction - a semantic type the target already hand-wrote is named rather than generated a second
time.
A target says all of that in a file, not only in C#. CppGeneratorOptionsFile reads a
CppGeneratorOptions out of JSON in the same dialect as a .schema.json - camelCase, comments and
trailing commas tolerated - and kschema --cpp-options <file> is how a build supplies one. The
alternative was a growing list of command line flags spelling one record, or requiring every target
to be a C# program hosting the generator, and a build that runs a tool is neither.
An unrecognised property is refused rather than ignored, which is the one place this differs
from ordinary lenience. Every option here means something when absent - no handle refuses the
schema's handles by name, no reflection emits no table - so a typo would not fail. It would
select the other behaviour, and the generator would then refuse a schema for naming a type the
target believed it had just declared, with nothing pointing at the file. For the same reason the
refusal message names the file's key and the C# property, and asks NameOf for the first rather
than restating it.
A refusal reaches a caller as a result rather than a stack trace: CppGenerationException derives
from SchemaGenerationException, and SchemaGenerator.Generate catches that base and reports
SchemaGenerationStatus.TargetCannotExpress. Only that base - a generator with a bug in it still
throws, because a stack trace is the right answer to that and the wrong answer to being told a
target has no vector type.
Schema.Tool packs as a dotnet tool called kschema, under ktsu.Sdk.Tool rather than
ktsu.Sdk.ConsoleApp: the two differ in that the tool SDK clears RuntimeIdentifiers, and packing
a tool with the seven identifiers set writes seven runtime-specific packages instead of the one
everybody installs.
The commands themselves live in SchemaCommandLine in the library, where they are tested without
spawning a process; Program.cs is only the wiring. SchemaCommandLineHost is what the library
cannot know - the name the host was installed as, and the options the host adds - and it exists for
the usage text alone. A host parses its own options and hands on what is left, which is why
CppGeneratorOptionsFile.TryTake removes --cpp-options rather than passing it through: the
commands find the schema by taking the first argument that is not an option, stepping over the value
any option consumes, and the list of options that consume one belongs to the commands, which have
never heard of the C++ generator.
One file per element, and an enum goes to namespace scope in a header of its own rather than nested in the class that names it. Holotype's target document nests it, which was right when an enum belonged to the component that declared it; here an enum is a top-level element any class may name, so nesting it in the one class using it today would move the type the moment a second class used it.
CppGeneratorOptions.Reflection adds two files: reflect, the vocabulary, and reflection, the
table. It is off by default, because they are two files a target that does not read them did not
ask for.
What it is for. 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. The table is where those facts become data, and a validator, a serialiser, a network codec and an editor all read it rather than each being a generator with its own copy. That also makes them work on a schema loaded at run time, which generated code cannot. The rule it suggests: generate what has to be a type, and write by hand what only needs to read a type's description.
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. 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.
A member's kind and its representation are both carried. 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 (Float). One field could not do both: reading
a value out of a save file needs the second, and showing the member to a person wants the first.
The dimension comes from the unit. A member's unit text is resolved through UnitRegistry and
the unit's own DimensionInfo supplies the eight exponents, so the numbers in the table cannot
disagree with the unit beside them. A member that measures nothing is dimensionless, which is the
same shape rather than a missing one.
The fourth of those eight is angle, and it is the one that only means something if the unit
registry answers honestly. ktsu.Semantics carries the axis so an angular displacement is not the
same type as a ratio, but a unit claimed by two dimensions used to report whichever was declared
first - and Dimensionless is first in dimensions.json, so a member measured in radians wrote the
eight numbers of a unitless count. Nothing here could have caught that, because this table asks the
unit rather than restating it; so the fix is upstream (ktsu.Semantics v5.0.1) and the assertion is
here, in ReflectionTableTests.CarriesTheAngleOfARadianRatherThanNothing.
reflect is shipped rather than generated, like ktsu.Semantics.Cpp's prelude and for the same
reason: template <typename T> struct Describe; declares a type parameter and concept Reflected
is a concept, neither of which the AST models — a generator names a generic type, it never declares
one. Two lists in it are substituted, though: TypeKind is every [JsonDerivedType] on BaseType
and Interpolation is the enum of that name, each written twice (the enumeration and the names
beside it) from one list, because the alternative is a switch that nothing would keep in step.
That is why the C++ side emits its own vocabulary rather than being written against one a target
already has — a type added to the schema appears in C++ with no edit in either repository.
The table is anchored by template<> struct Describe<Class>, which is ktsu.Coder's
ClassDeclaration.SpecialisationArguments and was the last thing the AST could not say. The
alternative was a DescribeRigidBody every consumer spells for itself, which is what a lookup by
type exists to avoid.
ktsu.Schema.Contracts is the abstraction seam the models implement: Schema : ISchema,
SchemaClass : ISchemaClass, SchemaMember : ISchemaMember, SchemaEnum : ISchemaEnum,
BaseType : ISchemaType. Inject ISchema where a consumer only defines and reads schema elements.
Entities are abstracted; values are not. Name types (ClassName, MemberName, …) and
SchemaChildDescription appear in the contracts as themselves — a semantic string is already an
abstraction over string, and wrapping it again would make ISchemaChildSet<out TValue, TName>
unusable, since a covariant element type cannot coexist with a varying name type.
Collections on the contracts are read-only views (ISchemaChildSet). Mutation lives on the owning
element (ISchema.AddClass, ISchemaClass.AddMember), which is what enforces name uniqueness and
establishes parent association.
The library uses ktsu.Semantics.Strings for type-safe identifiers. Convert strings using .As<T>():
ClassName,MemberName,EnumName,EnumValueName,BaseTypeName,ContainerName,DataSourceName,CodeGeneratorName
Example: "User".As<ClassName>()
Schema elements maintain parent references via AssociateWith() methods. After deserialization, Schema.Reassociate() re-establishes these relationships.
A member carries six optional properties beside its type - Unit, Range, DefaultValue,
Interpolation, Network and Editor - modelled in Schema/Models/Metadata/ and validated
together in Schema/Models/Schema.Validation.cs, since most of them are only wrong in combination.
Units are not modelled here: a member stores the text and UnitRegistry resolves it against
ktsu.Semantics.Quantities, reflecting over that assembly rather than keeping a list that would
fall behind it. Two symbols there are ambiguous (g, rad), so resolution refuses an ambiguous
symbol and UnitRegistry.PreferredText is what a picker writes: the symbol when it identifies one
unit, the name when it does not. docs/schema-format.md documents the file's side of all of this.
Generated C# carries the metadata as attributes from ktsu.Schema.Runtime, and ClrTypeImporter
reads them back, because none of it is expressible in a CLR type. The two sides are inverses and
the generate-compile-reimport round-trip test fails if either changes alone. A default is emitted
as the property initialiser as well, so a generated instance starts at it.
Schema/Contracts/- TheISchemaabstraction seam implemented by the modelsSchema/Models/Schema.cs- Root container with CRUD operations for classes/enumsSchema/Models/SchemaChildSet.cs- Order-preserving, name-unique view owning the uniqueness ruleSchema/Models/Types/BaseType.cs- Abstract base with[JsonDerivedType]attributes for polymorphic serializationSchema/Models/SchemaClass.cs- Class definitions containingSchemaMembercollectionsSchema/Models/ClrTypeImporter.cs- Reads a .NET type into a schema; the exact inverse of the C# generatorSchema/Models/Metadata/QuantityRegistry.cs- The 220 quantities a schema may name, read out of the assembly that declares them rather than listed hereSchema/Runtime/SchemaMetadataAttributes.cs- What generated code carries that its C# types cannot saySchema.Editor/SchemaEditor.cs- Main editor application usingktsu.ImGui.AppSchema.Editor/MemberGridPanel.cs- The grid of member rows: add, reorder, retype, remove, and the two folds each row opensSchema.Editor/MemberSemanticsPanel.cs- The metadata behind a member's fold, and the only place that decides what a picker writes for a unitSchema.Editor/EditorHost.cs- Builds theImGuiAppConfig;CreateConfigis what the tests drive tooSchema.Editor/EditorTheme.cs- The ktsu.ThemeProvider theme, and the one definition of how a validation issue is colouredSchema.Editor/Program.cs- The entry point, and the only file excluded from coverage measurementtests/Schema.Editor.UITests/EditorHarness.cs- Runs a real editor headlessly, frames advanced by the testtests/Schema.Editor.UITests/WidgetHarness.cs- A headless frame containing only the widget under test, and an editor for a panel that is one
The editor's own draw code records where it put things, through ImGuiProbes.MarkItem from
ktsu.ImGui.Probes. That is what lets a test click a widget by name rather than by pixel position,
and it costs nothing when no probe is installed - which is every run that is not a test. Marking is
therefore part of drawing a control, not an afterthought: a new button, menu item or picker option
that a test will need is marked where it is submitted.
The names are qualified by the ImGui window and any pushed scope, and a test matches on the trailing
part: menu/New, field/ClassNameUser, memberId/Delete, diagnostic/Error:Users. Rows that share
a label push a probe scope alongside ImGui.PushID, so two members' fields do not collide.
The right-hand tab bar comes from a widget library that neither records its tabs nor takes a
selection from outside, so the editor measures them itself: MarkMainTabs records each one as
main-tab/<name>, sized the way Dear ImGui sizes a tab, and only when a probe is listening. That is
what lets the gallery reach the class graph and the diagnostics list by clicking. The tests of those
panels predate it and draw them directly in a WidgetHarness, which is what the tab's own delegate
does; that is still the quicker route to a panel's behaviour.
tests/Schema.Editor.UITests/Gallery stages the editor over a sample schema and photographs it.
The scenarios are ordinary tests, so a pull request proves every picture can still be staged; they
write to a temporary directory unless SCHEMA_GALLERY_OUT names one. editor-gallery.yml runs them
into docs/gallery after CI passes on main and commits the result, so neither the pictures nor
their README.md are written by hand. A picture has to come out byte-identical run to run: no
clock, no machine path (the sample schema's path is made up), nothing random.
The frame-driven suite runs on Linux alone, and the two tests that first failed elsewhere were failing for reasons that had nothing to do with the operating system - which is exactly what a one-platform suite is bad at telling you.
FileBrowserTests expected the path it made its scratch directory under. macOS reaches the
temporary directory through /var, a symbolic link to /private/var, and the working directory
resolves the link, so the browser answered with the other spelling of the same directory. The test
now reads the working directory back after setting it, which is a fact about symbolic links rather
than about macOS.
MenuTests clicked a recent file whose menu label was its whole path. A menu is as wide as its
widest label, and a submenu that will not fit beside the menu that opened it is placed on top of
that menu - which puts the item that opened it under the submenu rather than under the pointer,
so ImGui takes the pointer to have left and closes the submenu on the frame after it opens. The
temporary directory being 44 characters deeper on macOS is all that picked which platform noticed:
any schema saved somewhere deep enough did the same everywhere, and Open Recent was unusable for
whoever owned it. SchemaEditor.ElideRecentFileLabel bounds the label and the tooltip keeps the
path. Both halves are pinned - the mechanism in MenuTests.ARecentFileTooDeepToLabelWholeStillOpens,
and the elision itself in Schema.Editor.Test/RecentFileLabelTests.cs, which needs no frame and so
runs on every platform. That is the answer to promoting the suite: what was platform-sensitive here
was a string, and a string can be tested where the rasterizer cannot go.
EditorHarness.Click asked whether the probe had ever recorded a name, not whether the item was
on screen now, so an item drawn once and then gone still satisfied the wait and the click failed
later on a stale rectangle. It now asks IsOnScreen on both sides of the settle frames. That is a
better message, not a fix: with the wrong question it failed inside the click, with the right one it
fails at the wait, and only the elision makes it pass.
- ktsu.Semantics.Strings/Paths - Type-safe string and path wrappers
- ktsu.Semantics.Quantities - The quantities a member can hold, and the units its values can be measured in
- ktsu.ImGui.App/Widgets/Popups - ImGui application framework (editor only)
- ktsu.AppDataStorage - Persistent settings storage (editor only)
- Polyfill - .NET compatibility shims for multi-targeting
- ktsu.Coder - The language-agnostic AST and the C++ writer (
Schema.Cpponly)
Schema definitions are stored as .schema.json files using System.Text.Json with polymorphic type discriminators (TypeName property).
samples/ holds two real programs' whole schema vocabulary - Carbon Monoxide's and Dungeoneer's,
26 files and 45 elements - migrated from the {Classes, Enums} format they were written in. Every
other test here builds a schema to exercise one rule; these exist so a generator meets types nobody
chose for their shape. LegacySampleGenerationTests compiles the C# generated from both,
LegacySampleCppTests compiles the C++, and one test asserts that no member of either reaches C#
as object? - which compiling cannot catch, because object? compiles.
samples/modernised.schema.json is the same definitions said in the current vocabulary - built-in
vectors instead of four classes that meant "two floats", colours instead of strings, units on
semantic types, keyed containers, ranges, defaults and travelsAsBytes. The migrated pair is a
transliteration and therefore exercises almost none of the schema; this is what meets the
generators with the vocabulary they actually have. ModernisedSampleTests pins both halves: the
definitions still match the legacy files, and the vocabulary is still being used.
LegacySchemaReader is in the test project rather than the library: the legacy format is two
repositories' history and publishing a reader for it would commit this library to a dialect nobody
else has. The migration is re-run on every test run rather than trusted, so a committed sample
cannot drift from the legacy files beside it. samples/README.md records what the migration could
not preserve - chiefly the file boundaries, since a className resolves within one schema and 14
of the legacy references cross a file.