REGISTRY_ROW_STRUCT_SPEC.md

systems/REGISTRY_ROW_STRUCT_SPEC.md

Registry row-struct spec — the column→UE-type contract

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.

1. The binding laws

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.

maps 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.

2. Type inference — precedence order

The first matching rule wins. Inference is deterministic: same header in, same struct out.

1. Explicit overridedocs/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.

2.1 THE NAMING LAW — new canon columns are always qualified (STANDING, ruled 2026-07-29)

**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.

3. Emission shape

// 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
};

(their default constructors are correct).

diff.

is the only place a reader meets the struct, so the exception has to be visible there.

4. Hard failures (never silently emitted)

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.

shape. A mistyped section name would otherwise silently drop every alias in it.

4.1 The alias mechanism, and why it is the ONLY vector

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:

generated header comment.

FillDataTableFromCSVString.

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.

4.2 The import read-back is the gate on the alias path

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 memberGetDataTableColumnAsString(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.

5. Regeneration discipline

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.

6. The rejected variant — a generated engine-facing CSV artifact

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 — protectedsite_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.

Generated by harness/site/structure_site.py — the URL path is the repo path. review root