Files
Faset_Engine/docs/studies/16-native-gameplay-and-metadata.md

45 KiB
Raw Permalink Blame History

C++ gameplay и метаданные: от объявления поля до безопасного изменения игры

Дата: 17 сентября 2026. Это углубление исследований Godot UX, Unity extensions, Blender RNA/operators и ECS. Здесь исследуется нижележащий контракт: как описание C++-типа становится доступным инструментам, как вызывается код, что переживает пересборку и кто отвечает за время жизни данных.

Принятые решения 18.09.2026: C++ core/gameplay первым, затем Lua отдельным модулем (необязательным для конкретной игры); EnTT; собственная явная C++ metadata registration; статическая gameplay-библиотека в отдельном Player dev/release; schema export отдельным служебным процессом; custom inspectors в Editor DLL/SO под точный SDK с restart. MCP только в Editor/headless editor services, без runtime inspection/mutation и без MCP в Player, игре или SchemaExporter. Канон — архитектура, этапы — PLAN.md. Исследование ниже объясняет решения, но не является реализацией или отчётом о тестах движка.

Исходники сверены с manifest: UE 5.8.2, 16d75d84714512edfb744e1fd0a59e9c74d57873; Godot 4.8.0 dev, 9c776068d6ed23acd0c78bfe534272d1d2a3a619. Оба checkout остались чистыми. Изучены тела функций и генератора; движки и тестовый gameplay-модуль не собирались. Дополнительно прочитаны официальные документы UE 5.8, Lua 5.5 и текущий Godot docs source, commit e1129cb5c3f27e0e3a12e816ae405ab5ce032e3f. Последний нужен потому, что разделы GDExtension перемещены относительно прежних URL; это документация разработки, а не гарантия поведения любого стабильного Godot.

Главный вывод: небольшая явная схема экспортируемого API важнее универсальной reflection. Нужны отдельно идентичность данных, способ доступа к памяти текущего бинарного модуля и правила исполнения операций. Inspector, MCP и будущий Lua используют выбранную часть этой схемы; обычный C++ сохраняет типизированные вызовы и не обязан превращать каждое присваивание в динамический вызов.

1. UE: генерация дескриптора связывает C++ layout с выбранным набором возможностей

Путь исполнения. UBT запускает UHT до обычного C++-компилятора: UHT разбирает размеченные заголовки и генерирует код UObject-системы, затем компилятор собирает результат. Это отдельная стадия сборки, а не обнаружение произвольных типов уже работающей программы. Поддерживаемый механизм стороннего расширения UHT — exporters; возможность добавить свой exporter не означает возможность без ограничений расширить весь язык описаний UE. Официальный UHT.

У UhtProperty.Validate есть проверки допустимости описания: например, статические массивы контейнеров отклоняются. ValidateMember проверяет согласованность edit-флагов и требует Category для определённых экспортируемых полей engine-модулей. Это валидация объявления, а не проверка каждого будущего значения Health. UhtProperty.cs:2367, ValidateMember:2547.

Далее AppendParamsDefStart выводит имя поля, property flags, указатели на доступные getter/setter wrappers, размер массива и STRUCT_OFFSET. В Params-пути ConstructUClassHelper строит зависимости, регистрирует класс, связывает функции, вызывает ConstructFProperties, а затем StaticLink. ConstructFProperty выбирает специализированный тип свойства и рекурсивно создаёт дочерние описания контейнеров. NewFProperty выбирает вариант с accessor-функциями, если они заданы; конструктор FProperty сохраняет offset текущего бинарного layout. Генерация параметров, конструирование класса, создание свойств, сохранение offset.

В этом checkout нельзя описывать Params как единственный актуальный механизм. AppendPropertiesDecl/Defs отдельно поддерживают ConstInit и Params, с условием UE_WITH_CONSTINIT_UOBJECT. ConstInit-генерация тоже содержит имя, флаги, связи и STRUCT_OFFSET, но создаёт непосредственно инициализируемые описания. Здесь проверены обе ветки генератора; активная конфигурация конкретной сборки не устанавливалась. Выбор формата, ConstInit-параметры.

Методам тоже требуется glue code. AppendFunctionThunk генерирует получение аргументов, завершение разбора параметров, вызов C++-реализации и запись результата. Дополнительная проверка _Validate здесь включается для NetValidate, а не для каждого экспортированного метода. Следовательно, наличие thunk само по себе не даёт доменную валидацию, транзакцию или право вызывать метод из редактора. Генерация thunk.

Что перенять. Для Faset принята явная регистрация выбранных компонентов, полей и методов в C++ gameplay-модуле. Шаблонные helpers могут принимать типизированные member pointers/accessors и проверять поддерживаемые сигнатуры компилятором. Не начинать с собственного парсера всего C++; codegen поверх ограниченных аннотаций имеет смысл позже, когда повторяющиеся регистрации станут измеримой проблемой. Сгенерированные файлы должны зависеть от исходной схемы, версии генератора и конфигурации, попадать в build diagnostics и обновляться только при изменении содержимого.

Отдельно хранить UI-подсказки и обязательные runtime-правила. UE прямо предупреждает, что metadata предназначена для editor; просмотренный codegen ограждает её WITH_METADATA, тогда как runtime property flags и типизированные описания остаются отдельными сущностями. Нельзя строить правило «отрицательная масса запрещена» только на строке для slider. Metadata specifiers, условное добавление metadata.

2. UE: загрузка свойства — сопоставление схем, а не копирование старого layout

Путь исполнения. SerializeTaggedProperties выбирает versioned либо unversioned-представление архива. Ниже исследован именно versioned tagged path, поэтому его особенности нельзя приписывать всем cooked-данным UE. Развилка сериализации.

При загрузке SerializeVersionedTaggedProperties читает FPropertyTag, сначала сопоставляет его ожидаемому свойству, затем при несовпадении ищет по имени. При разрешённых условиях применяются property redirects. GUID-ветка также условная: код отдельно отмечает доступность property GUID для поддерживающих их классов, в частности Blueprint-generated classes. Это не доказательство наличия постоянного GUID у каждого native UPROPERTY. Чтение и сопоставление, redirect имени.

После сопоставления проверяются editor-only policy, индекс массива и ShouldSerializeValue. Затем вызывается ConvertFromType: результат может означать выполненное преобразование, собственную десериализацию, обычный SerializeItem либо невозможность преобразования. Только у подходящего свойства вычисляется нынешний адрес через ContainerPtrToValuePtr, после чего читается значение. При несовпадении типа есть диагностический путь; обработка неизвестных свойств имеет дополнительные editor-only условия и не равна обещанию автоматически сохранить произвольные потерянные поля. Проверки и преобразование, адрес текущего поля, unknown-property tracking.

Что перенять. В authoring-формате Faset определить TypeId, FieldId и версию схемы независимо от C++-имени, typeid, offset и порядка деклараций. Читаемая подпись и путь к исходнику — изменяемые атрибуты. Fingerprint текущего layout полезен для проверки совместимости бинарного модуля, но не должен становиться постоянной идентичностью сохраняемого поля.

Переименование open_speed в angular_speed при неизменном смысле сохраняет FieldId; смена единиц с градусов на радианы требует явной миграции значения. Разделение одного поля на несколько требует миграции с доступом к документу. Новые поля получают defaults по правилам версии, удалённые — явную политику сохранения/удаления неизвестных данных. Один UUID этого не решает. Миграцию следует выполнять над копией authoring-документа, валидировать результат и публиковать целиком, с диагностикой и возможностью отката.

Отсутствующий gameplay-пакет должен оставлять читаемые непрозрачные записи компонентов и их ссылки, а не превращать открытие и сохранение сцены в потерю данных. Запуск сцены может быть запрещён до восстановления обязательного типа. Это предлагаемая политика Faset, а не универсальное поведение изученных UE-архивов. Cooked-представление можно уплотнять после миграции и проверки; оно привязывается к конкретному schema/build fingerprint и при несовместимости пересобирается.

3. Reflection и владение объектами — связанные, но разные механизмы

Путь исполнения. UE FObjectProperty::EmitReferenceInfo добавляет в GC schema позиции объектных ссылок, учитывая offset и элементы массива. Базовая реализация FProperty::EmitReferenceInfo пустая: понимание того, что поле содержит объектную ссылку, обеспечивается специализированным descriptor. Это полезный пример того, что тип свойства участвует не только в Inspector. GC reference schema.

Но слабая ссылка решает другую задачу. FWeakObjectPtr при присваивании сохраняет index/serial; при разрешении проверяет соответствие текущему элементу object array, а Internal_Get дополнительно проверяет пригодность объекта. В checkout есть отдельная конфигурация remote-object handles; приведённое объяснение index/serial относится к обычному локальному пути. Создание weak handle, сопоставление serial, разрешение указателя.

Godot даёт независимую проверку идеи: ObjectDB::get_instance извлекает slot и validator из ObjectID, возвращает null при несовпадении и освобождает spinlock перед возвратом указателя. Это проверка идентичности, а не автоматически удерживаемая блокировка на всё дальнейшее использование объекта. ObjectDB lookup.

Что перенять. Не нужно копировать UObject GC, чтобы получить удобный C++ gameplay. Нужны явные категории: значение компонента, ссылка на entity, удерживаемый ресурс, наблюдаемая слабая ссылка и временный доступ к памяти. При принятом EnTT descriptor поля не вправе хранить постоянный pointer на компонент: structural change может переместить данные, что подробно разобрано в исследовании ECS.

Принятый runtime handle включает world/session и generation; постоянная ссылка authoring-документа использует другую идентичность. Runtime handles не открываются для inspection/mutation через MCP. Доступ к компоненту разрешается на время фазы/заимствования, а дальнейшее использование требует повторного resolve. Проверка handle не заменяет scheduler и владение при многопоточности. Подписки, отложенные callbacks и Lua userdata должны хранить проверяемую ссылку либо явно владеть ресурсом; завершение сессии инвалидирует её даже при повторном использовании локального index.

4. Godot: поле регистрируется через методы, а ошибка вызова не заменяет проверку смысла

Путь исполнения. У Light3D::_bind_methods зарегистрирован set_param; несколько полей света используют ADD_PROPERTYI с одним setter/getter и разными индексами параметра. Например, light_energy описан как float с range hint. Это конкретный способ не писать отдельный универсальный getter/setter для каждой записи схемы. Регистрация метода, описания полей.

В данном dev-снимке ClassDB::add_property передаёт описание в GDType, поэтому сводить реализацию только к старым таблицам ClassDB было бы неточно. GDType::add_property разрешает регистрацию в mutable-фазе и на потоке владельца, отклоняет дубликаты, находит MethodBind getter/setter, проверяет число аргументов с учётом индекса и сохраняет property record. Отдельный ordered list нужен перечислению. ClassDB bridge, GDType registration.

Object::set сначала предлагает изменение script-instance и extension callbacks, затем вызывает native setter. set_native ищет свойство по имени, формирует один аргумент либо пару «индекс + значение» и вызывает сохранённый MethodBind. r_valid определяется через Callable::CallError. У Light3D::set_param проверяется индекс, затем значение сохраняется, передаётся RenderingServer, при нужных параметрах обновляются gizmos/warnings. Здесь range hint не читается и не превращается автоматически в clamp входного числа. Общий set, native dispatch, setter света.

Ещё одна важная граница: Object::validate_property(PropertyInfo&) вызывает native/extension/script callbacks, которые могут изменить тип, hint, имя и usage описания. Эта функция не получает новое значение свойства. ClassDB::get_property_list использует её при перечислении для конкретного объекта. Название _validate_property поэтому нельзя понимать как единый валидатор входных данных. Изменение PropertyInfo, валидация при перечислении.

Что перенять. Разделить минимум три контракта: describe(context) для представления и доступности поля; чистый validate(proposed_state) для типа, диапазона и связанных инвариантов; commit(changes) для публикации и уведомлений. Если setter меняет второе поле, посылает renderer-команду или событие, простой вызов нескольких setters не даёт атомарный MCP batch и надёжный Undo.

В Faset UI и MCP должны валидировать один proposed authoring-state до commit, а дорогую invalidation собирать после него. Gameplay hot loop при этом вправе работать напрямую с разрешёнными компонентами: не нужно прогонять каждое обновление Position через editor transaction. Доменные операции вроде «применить импульс» и «перестроить collider» отличаются от редактирования сохраняемого параметра; публичный API обязан выражать это явно.

Официальный C++-пример Godot показывает ручную регистрацию methods, properties и signals и появление полей в Inspector после компиляции. Это подтверждает практичность явного экспортируемого подмножества C++, но не автоматическую поддержку любого класса или шаблона. Текущий пример в Godot docs.

5. GDExtension: ABI требует версий и правил восстановления, даже когда функции уже описаны

Путь исполнения. GDExtensionLibraryLoader::initialize получает entry symbol из динамической библиотеки и вызывает функцию инициализации с get_proc_address, library pointer и структурой результата. Официальная документация разделяет C interface, экспортируемый API и .gdextension-описание загрузки. Это организованная граница между движком и библиотекой, не загрузка произвольного C++-объекта по его имени. Инициализация библиотеки, устройство GDExtension.

_register_extension_class_method создаёт GDExtensionMethodBind и передаёт его в ClassDB. update копирует callback pointers, argument/return descriptions и defaults. call передаёт аргументы extension-функции и преобразует её error record обратно в Callable::CallError; validated_call/ptrcall образуют отдельные пути для уже подготовленных данных. Эти пути не означают одинаковую стоимость и одинаковый набор проверок. При получении engine method bind API учитывает hash сигнатуры и compatibility fallback; несовместимый метод не просто вызывается по совпавшему имени. Регистрация, описание и dispatch, копирование method info, signature lookup.

Reload особенно показателен. try_update проверяет static/vararg, наличие и тип результата, число и типы аргументов. При несовместимости старая привязка помечается invalid и создаётся новая; вызов invalid bind в tools-сборке имеет отдельную ошибку. prepare_reload сохраняет выбранные PROPERTY_USAGE_STORAGE значения, пропуская часть defaults/nulls. finish_reload восстанавливает extension-части и затем вызывает set для сохранённых свойств. Смена parent type при регистрации отдельно отклоняется с требованием restart. Это восстановление через описанные свойства и специальные lifecycle hooks, не универсальное сохранение всей native памяти. Совместимость метода, состояние до reload, восстановление, ограничение parent type.

Документация ориентирует расширения на совместимость с более поздними minor-версиями, указывает исключение Godot 4.0 и требует согласованной floating-point precision; для custom engine предлагается генерировать свой API description. Это поддерживаемая политика с условиями, а не обещание вечной совместимости любого бинарного файла. Godot-cpp compatibility.

Принято для Faset. Gameplay — отдельная статическая библиотека, связанная с Player в development и release. Linux использует Clang, Windows — clang-cl с согласованными SDK/CRT. Editor plugins поставляются DLL/SO под конкретный Editor SDK/build и загружаются при запуске; обновление требует restart Editor. Manifest задаёт module ID/version, зависимости, назначение runtime/editor и build fingerprint. Entry function согласует API version, ownership и ошибки; произвольные compiler/stdlib combinations не получают обещания стабильного ABI. EnTT registry не экспортируется как универсальный native plugin ABI.

Принятый Play: stop → build → restart. Сохранение существующих vtables, component pointers, closures, задач и layout не обещается. Native editor plugin способен уронить Editor; отдельный Player изолирует игровой код, который действительно остаётся вне редактора.

Schema export после сборки — отдельная обязательная стадия. Служебная target SchemaExporter связывается с теми же C++ registration units, выполняет регистрацию без мира, renderer и lifecycle callbacks и пишет декларативный manifest: TypeId/FieldId, версии, value types, явные defaults, constraints, units, hints и source locations. Editor запускает процесс, проверяет exit status/manifest/build fingerprint и лишь затем публикует новую схему Inspector. Сбой оставляет последнюю корректную схему и diagnostics. C++ регистрация всё же исполняется, поэтому отдельный процесс не называется sandbox; произвольные static initializers нельзя считать безопасными только из-за имени режима. MCP в этом процессе отсутствует.

Custom Inspector реализуется отдельным Editor module над authoring command API. Декларативные constraints проверяет AuthoringService; исполняемые дополнительные валидаторы/миграции требуют явного editor/helper-контракта. Inspector не вызывает runtime accessor по адресу из manifest и не загружает gameplay DLL ради перечисления полей. Сборка отбрасывает зависимости runtime от editor/MCP; JSON схемы не содержит pointers или сериализованных C++ layouts.

Принятый контракт Faset: C++ первым, Lua следующим этапом

Компонент. TypeId, version, module owner, зависимости, правила создания/удаления и список полей. Отдельно — runtime size/alignment, callbacks construction/destruction/move и registration generation. Выбран EnTT, но descriptor отделён от его runtime storage и не сериализует registry. Для полиморфных или нестандартных layout использовать типизированные accessors; не публиковать вычисленные offsets через MCP.

Поле. FieldId, переносимый value type, default, единицы, hard constraints, UI hints, read/write policy и категории invalidation. AssetRef и SceneEntityRef — явные типы, не произвольные строки. Коллекциям нужны правила адресации элементов и конфликтов после вставки/удаления. В JSON сохраняются смысловые значения и ID; memory adapters принадлежат конкретной сборке.

Метод. MethodId, именованные аргументы/результат, error schema, домен исполнения, требуемая фаза, side effects и lifetime результата. Обычный native метод не получает автоматически права editor-команды. OpenDoor — runtime-действие gameplay/Lua, SetDoorDefaults — authoring-транзакция, InspectDoorAuthoring — запрос к документу. MCP может вызывать только editor authoring-контракты; runtime method exposure не предоставляется. Общая metadata не объединяет права и домены исполнения.

Событие. EventId, payload schema, фаза доставки и ownership подписки. Начать с очереди копируемых payload и scope, освобождаемого при удалении поведения/мира. Listener не удерживает случайный pointer на переехавший компонент; callback другого языка не вызывается из произвольного потока. Metadata описывает payload, но сама не выбирает порядок, рекурсию и гарантию доставки.

Ручное программирование. Отдельный Gameplay модуль, понятный typed API и обычный C++ debugger; минимальная регистрация нужна только инструментально доступной части. Для двери автор пишет реакцию на взаимодействие и целевой угол, для тысячи объектов — batch system. Метаданные дают Inspector и schema.describe один источник имён, типов и документации, но MCP вызывает только authoring/build/import/PlayStop/editor-log операции из контракта автоматизации, без произвольного native address и без runtime world inspection/mutation.

Lifecycle. C++-поведения используют OnStart, FixedUpdate, Update, LateUpdate, OnDestroy; Lua повторит те же фазы. Первый scheduler последовательный. Spawn/despawn/add/remove записываются в очередь и применяются в начале следующего fixed tick после завершения прежних задач. OnStart идёт после создания, OnDestroy — до освобождения допустимых данных; подписки имеют ограниченный lifetime. Fixed tick 60 Гц по умолчанию, bounded catch-up и render interpolation описаны в архитектуре.

Lua после C++. Реализация Lua-модуля принята как следующий этап после C++ gameplay; конкретный срок определяется отдельно. Необязательным остаётся использование Lua отдельной игрой. На первом этапе проектируем пригодную границу для его подключения. Модуль добавит VM, conversion wrappers, binding registry, сообщения об ошибках и scope подписок. Экспортируются явно разрешённые компоненты/методы; templates, arbitrary pointers и все C++ overloads не становятся скриптовым API автоматически. Генерация справки/IDE declarations полезна, но не заменяет runtime-проверки.

Lua full userdata предоставляет память для wrapper, а luaL_checkudata проверяет его metatable type; это не проверка существования entity. Поэтому wrapper хранит handle и повторно разрешает его при обращении. lua_pcall возвращает ошибку защищённого вызова, а message handler позволяет собрать traceback. Lua использует longjmp либо C++ exceptions в зависимости от сборки: bindings должны явно учитывать эту границу и не рассчитывать, что RAII cleanup автоматически сработает через любой Lua error. Lua 5.5: userdata, проверка wrapper, ошибки, protected call.

Это адаптация для Faset, не доказательство безопасности будущего binding. Lua GC освобождает wrapper по его правилам, а игровая entity живёт по правилам мира; владение ресурсом следует указывать отдельно. Базовая C++-игра должна собираться без Lua headers/runtime и без динамической ветки на каждом компонентном доступе.

Первый прототип и критерии принятия

Объём первого сквозного прототипа по плану: один gameplay-модуль Door, поля угла/скорости и asset reference, метод взаимодействия, событие открытия; одна 2D- и одна 3D-сцена используют тот же контракт описания, сохраняя разные spatial/physics-типы. Здесь не требуется реализовать полный animation graph, native hot reload или универсальный binding generator.

  1. Регистрация и discovery. Дубликат TypeId/FieldId, несовместимый accessor и неподдерживаемый тип дают ошибку со ссылкой на исходник. Inspector и MCP описывают одинаковые поля, units и read-only правила. Новый layout меняет build fingerprint, но не IDs сохранённых данных.
  2. Одинаковая правка. Ручной multi-edit и MCP batch дают эквивалентный документ; недопустимая скорость и конфликт revision не применяют половину изменений. Побочная invalidation выполняется после commit, Undo восстанавливает связанные поля. Прямая runtime-правка не записывает authoring-сцену.
  3. Эволюция данных. Переименовать C++-поле, изменить порядок членов, добавить поле с default, затем выполнить отдельную миграцию единиц. Сохранённые overrides остаются привязаны к правильным полям. Неудачная миграция и временное отсутствие пакета не уничтожают исходные записи.
  4. Время жизни. Сохранить handle, удалить entity, переиспользовать slot, перезапустить Play. Старый handle и поздний callback отвергаются; ссылка на компонент не переживает structural change. Если позже подключён Lua, тот же сценарий выполняется с userdata и сборкой мусора.
  5. Сборка и границы. Редактирование gameplay .cpp пересобирает реальные зависимости и статически связанный Player. SchemaExporter выдаёт manifest без OnStart/Update и без загрузки gameplay в Editor; сбой не заменяет рабочую схему. GUI/editor MCP получают compiler/schema diagnostics и build/schema IDs. Crash Player не закрывает Editor; изменение layout требует restart. Несовместимый editor plugin manifest отклоняется до загрузки. Player и exporter не содержат MCP, запросы runtime inspection/mutation через Editor отклоняются.
  6. Опциональность. C++-вариант собирается и запускается без Lua. В отдельном будущем эксперименте Lua-реакция вызывает ту же доменную операцию и видит те же компоненты; ошибки аргументов, traceback и cleanup подписок проверяются явно. Это проверка запланированного Lua-модуля и его необязательности для проектов только на C++.

Измерять edit→diagnostic, edit→Play, стоимость schema export и загрузки сцены, количество allocations на вызов, время массового typed-path относительно динамического вызова и число действий/ошибок при создании двери. Не подменять удобство краткостью регистрации: если автор не понимает, почему setter изменил другое поле или почему ссылка устарела, reflection ещё не решила задачу API.