systems/REGISTRY_ROW_STRUCT_SPEC.md
Status: ACTIVE v1.1, 2026-07-29 (v1.0 2026-07-22; v1.1 lands the D-RESERVED-COLS ruling — the
§2 naming law, the §4 alias amendment, the two-namespace override shape, and the UE engine-source
citation that closes the alias question). Link 2 of the canon→engine trunk (see
docs/BUILD_PLAN_END_TO_END.md): five of seven build lanes independently named this as their
critical path. It is the contract Tools/gen_row_struct.py (game repo) consumes to turn a live
registry CSV header into a native USTRUCT : FTableRowBase.
Why it exists: the DataTable import seam is PROVEN, and UserDefinedStruct is a documented dead
end on it — so every registry needs a NATIVE C++ row struct before its canon can enter the engine.
Only 2 of 36 existed when this was written, both hand-authored, and both violated the localization
law below. Authoring the rest by hand is an O(34-session) task; this contract makes it one script
run.
the FText / String Table flow. Never hard-code player-facing text as FString. The two existing
hand-authored structs violate this (FChapterIndexRow::title is FString); regeneration fixes it.
FillDataTableFromCSVStringmaps by name, so any rename silently drops the column. This is deliberately un-idiomatic C++ and
must stay that way. The ONE sanctioned exception is a DECLARED alias for a reserved-word column
(§4) — declared in one file, honoured at both ends of the seam, and verified on every import.
field (so the struct mirrors the header 1:1) but stays default-valued after a standard import. The
generated header says so, per column, so nobody debugs that twice.
the rows. No generated shadow copy of a registry CSV is a legal import source — see §6.
The first matching rule wins. Inference is deterministic: same header in, same struct out.
1. Explicit override — docs/row_struct_overrides.json, section column_types, keyed
"<registry>": {"<csv_column>": "<UEType>"}. Always wins. This is the do-not-invent escape
hatch: where inference would guess, the override states the answer instead. Keys are the
trailing-tag-stripped registry stem (T0_Region_Index, not T0_Region_Index [ACTIVE v1.2]) and
the CANON csv column name — never an aliased member name.
2. Player-facing display text → FText. The column name matches
(^|_)(title|name|description|summary|prose|caption|label|display|line|text|blurb)$
AND does NOT end in _id, _ref, _tag, _tags, _status, _class, _type, _key.
The exclusion matters: boss_name is player-facing, region_id is a machine token that happens
to contain a word, and shipping a machine token through the localization flow is as wrong as
shipping display text around it.
3. Boolean → bool — every non-empty value in the live column is in
{TRUE, FALSE, true, false, 1, 0, yes, no}.
4. Integer → int32 — every non-empty value matches -?\d+.
5. Float → float — every non-empty value matches -?\d*\.?\d+ and at least one has a decimal
point. NOTE: several registries store integers with a trailing .0 (chapter = "0.0",
source_year = "2026.0"); those land as float by this rule, which is correct-by-data rather
than correct-by-intent. Override where intent differs.
6. JSON blob → FString — values begin { or [. Kept as raw text and parsed at runtime;
UE's CSV importer has no native JSON column type, and inventing a nested USTRUCT per blob would
be exactly the over-modelling the extensions column exists to avoid.
7. Default → FString — ids, refs, enums, delimited lists, machine tokens, everything else.
Delimited lists stay FString, deliberately. TArray<FString> is representable in a DataTable,
but the CSV import path for arrays is brittle and the canon delimiter is inconsistent across
registries (; and , both occur, sometimes inside one value). Splitting is a runtime accessor
concern, not a schema concern. Revisit only with a ratified delimiter.
**New canon columns are always qualified — <qualifier>_class, <qualifier>_default,
<qualifier>_type — never a bare C++/UE keyword.** This binds every future registry extension and
every new registry. It is a rule Josh ruled as a standing rule, not a per-column judgement call.
Why it needs saying: this vocabulary sits right on top of the C++ keyword set. register is a
keyword and this project says "register" constantly (layer_register, Reputation_Ladder,
T0_*_Registry); operator, union, template, case, default and class are all plausible
canon column names here. Extensions land in packs of six to nineteen columns at a time, so each
pack is a fresh chance to mint one. Without the law each occurrence re-raises a decision; with it,
the answer is already written down.
The law CODIFIES the project's own established convention rather than inventing one. Six qualified
columns are already declared in docs/fidelity_baseline.json added_columns, all minted under this
project's own hand: node_class, ontology_class, dex_completion_class, reward_class,
env_density_band_default, terrain_permanence_default. (Four further instances —
questline_class, weapon_class, species_class, row_class — live in stub_superseded
candidate headers; they reinforce the convention but the six above are the declared-extension
evidence. minigame_class in harness/rag/exr_conform.py is an ENUM VALUE, not a column name, and
is not counted.)
Consequence for a NEW occurrence: rename the column. The alias mechanism in §4 is NOT the general
answer — it exists only for a column whose rename the fidelity gate cannot legally accept (a frozen
_source reference column, where harness/registry_fidelity.py pins the header prefix
byte-for-byte and has no renamed_columns declaration slot; editing the frozen _source/00 T0
xlsx is forbidden by CLAUDE.md). Today exactly one column meets that test.
// GENERATED by Tools/gen_row_struct.py — DO NOT HAND-EDIT.
// Source: <registry csv path> (<N> columns, <M> rows at generation)
// Contract: docs/REGISTRY_ROW_STRUCT_SPEC.md
// ALIASED COLUMNS (when any): <csv_column> -> <member> [see §4]
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "<Name>.generated.h"
USTRUCT(BlueprintType)
struct HUMANITY_API F<Name> : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly) <type> <column>; // per-column, header order
};
= 0, = 0.f, = false); FString/FText do not(their default constructors are correct).
diff.
is the only place a reader meets the struct, so the exception has to be visible there.
The generator exits non-zero rather than producing a struct that would mis-import:
docs/row_struct_overrides.json column_aliases.** The reserved-word check runs AFTER alias
resolution, so a declared alias is reachable and an undeclared keyword still refuses. An alias
target that is itself a reserved word, or not a legal identifier, is also a hard failure.
is a silent no-op otherwise — the defect class this project keeps catching). Both sections are
checked against the RAW canon header.
__aliases__shape. A mistyped section name would otherwise silently drop every alias in it.
docs/row_struct_overrides.json carries two disjoint top-level sections:
{
"column_types": { "<registry>": { "<csv_column>": "<UEType>" } },
"column_aliases": { "<registry>": { "<csv_column>": "<member_name>" } }
}
They are separate namespaces on purpose. Under the earlier per-registry shape an alias entry
({"default": "variable_default"}) parsed as a §2 rule 1 TYPE override — "column default has UE type
variable_default" — and would have emitted a nonsense UPROPERTY; and nesting an __aliases__ block
under the registry key tripped the stale-override check and killed the whole --all run. Two
sections, one meaning each.
Three tools consume the alias map and all three MUST honour it, or the column silently fails to
bind — which is worse than a hard failure:
Tools/gen_row_struct.py emits the member under the aliased name and records the mapping in thegenerated header comment.
Tools/import_registry_datatable.py rewrites the header cell in build_csv_string before FillDataTableFromCSVString.
Tools/import_all_registries.py does the same in its per-entry aliases map (bulk driver).There is no engine-side alternative. Verified in UE 5.8 engine source, not asserted.
Engine/Source/Runtime/Engine/Private/DataTable.cpp:811-816 matches a CSV column by
FindFProperty<FProperty>(InRowStruct, PropName) and then falls back to iterating properties and
testing DataTableUtils::GetPropertyImportNames. That function
(Private/DataTableUtils.cpp:447-453) is three lines: it adds Prop->GetName() and
GetPropertyExportName(Prop); and GetPropertyExportName (DataTableUtils.cpp:431-438) returns
Prop->GetAuthoredName(), which differs from the field name only for UserDefinedStructs (GUID
stripping). **DisplayName / ExportName metadata does not participate in CSV column matching for
a native USTRUCT.** So the CSV header rewrite on our side of the seam is provably the only binding
vector, and this question is CLOSED — it is not to be re-opened as a smoke test.
Tools/import_registry_datatable.py's docstring used to carry a parenthetical asserting that a
property's export-name metadata also matched a CSV column. That claim was never tested, it is false
for native structs, and it has been DELETED at source rather than softened — a repository-wide grep
for it must return zero, which is why it is not restated verbatim anywhere in this spec either.
The alias's failure mode is a silently-dropped column reported as success — this project's single
most-repeated defect class. Two engine facts make that failure invisible to the obvious checks:
FillDataTableFromCSVString returns Result != nullptr && !bWasCancelled
(DataTableFunctionLibrary.cpp:314-338), so a per-column "Cannot find Property for column" problem
never reaches its bool; and a row-count read-back is structurally blind to a dropped COLUMN.
Tools/import_registry_datatable.py therefore asserts three things after every fill, each
exiting non-zero:
1. Column-level binding of every aliased member — GetDataTableColumnAsString(dt, member)
(DataTableUtils.cpp:468-491) must return one value per row, and the non-empty count must equal
the non-empty count in the source CSV. An unresolvable property returns an EMPTY array; a column
that failed to bind returns N empty strings. Both are caught. Row ORDER follows the RowMap
(a TMap), so the assertion compares counts, never positions.
2. Row count — a source-vs-table mismatch exits non-zero (it was previously a WARNING on a
zero exit).
3. fill_ok — previously captured and printed but never asserted.
The generated structs are a VIEW over the live registry headers, exactly like the merge-step queue
views. When a registry gains a column (a registry extension), regenerate rather than hand-patch, and
the diff is the review. A hand-edit to a generated struct is lost on the next run, so the header says
DO NOT HAND-EDIT and means it.
A canon-side column RENAME is a registry schema change: it takes the same
registry_fidelity.py --emit-baseline refresh in the same commit as any other registry edit, plus a
regeneration of the affected struct. Where the registry's frozen reference is a title-only stub, the
refresh lands in the baseline's stub_superseded block — which since 2026-07-29 is COMPARED and
stale-checked like every other baseline class, so skipping the refresh goes red rather than
unnoticed.
NAMED DEBT (recorded 2026-07-29, the D-RESERVED-COLS landing): the bulk vector is behind the
headers — 62 registry CSVs against 48 generated headers (19 stale, 23 absent). Do NOT run
gen_row_struct.py --all --apply until that backlog is reconciled deliberately; a bulk apply today
would regenerate against schemas nobody has reviewed. Second named debt, same date: on the bulk
path a NEW bare-keyword column prints its [DECISION] refusal but --all --apply still exits 0
(pre-existing behavior, not introduced by the alias work) — the naming law's tooth is per-registry
today and hard-fails only the single-registry path; promoting the bulk exit to non-zero is the
recorded fix when the 62-vs-48 backlog is reconciled.
Considered and rejected as the alias vector (recorded so it is not re-proposed): emit
build/dt_csv/<registry>.csv with aliased headers and have every import vector consume the
generated artifact instead of the canon CSV.
It was rejected on two grounds. First, it duplicates canon rows into a second location, which
contradicts §1's "the canon repo owns the rows" — the shadow copy becomes a second thing that can be
edited, imported stale, or diverge. Second, it trades a bypass hazard for a STALENESS class that
needs its own gate to detect (hash the canon CSV, compare, fail) — a new mechanism to maintain,
versus one alias map already read by all three consumers from a single source of truth for
frozen-reference-column aliases (scope note: one legacy alias predates this file and lives outside
it — protected → site_protected, carried by gen_row_struct's --alias CLI and hardcoded in
import_all_registries.py; it aliases a non-frozen column and migrates into column_aliases
whenever those files are next touched). The
in-memory header rewrite plus the §4.2 read-back detects the exact failure the artifact was meant to
prevent, at the moment it matters, without a new artifact class.
The residual bypass — a human drag-dropping the canon CSV into the editor — is unchanged by either
option (a generated artifact only helps if the human knows to use it), and it is covered by
discipline plus the fact that the aliased column is the ONLY one that would fail to bind, visibly
empty in the editor.