View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

Generated API and Metadata

This document explains the engine code-generation and metadata-registration flow. Use it when changing generated source, metadata annotations, property definitions, or script-visible API contracts.

Ownership model

The engine owns the reusable metadata/codegen machinery. An embedding project supplies project configuration, extra metadata sources, common headers, and script/content inputs through CMake options and project files.

Generated files are build artifacts. Document the source annotations, templates, generator inputs, and validation flow; do not treat generated output as hand-authored engine source.

Source paths inspected

CMake codegen stage

BuildTools/cmake/stages/Codegen.cmake constructs the generator command and output list.

Important command arguments include:

The stage creates normal and forced code-generation command targets and appends CodeGeneration to FO_GEN_DEPENDENCIES.

Generated outputs

Codegen.cmake declares generated outputs under GeneratedSource/, including:

These file names are useful for understanding build flow, but changes should usually be made in templates, annotations, metadata sources, or generator scripts rather than in generated output.

InternalConfig.gen.inc reserves an engine-owned fixed 10000-byte patch area. Embedding projects cannot resize it; project-only script settings are shipped through metadata instead of this bootstrap config.

Metadata registration entry points

Hand-authored declarations live in Source/Common/MetadataRegistration.h:

Source/Common/MetadataRegistration.template.cpp is the template used to generate side-specific registration files. It contains code-generation markers such as ///@ CodeGen RegisterHelpers and ///@ CodeGen Register.

Source/Common/GenericCode.template.cpp is the template for generated common code.

Engine hook tags

Project/native extension code can mark selected C++ functions with ///@ EngineHook. BuildTools/codegen.py validates hook names and emits no-op stubs for hooks that the embedding project does not implement. Current hook names recognized by the generator are:

ClientStartupSettingsHook is called by app entry points immediately before constructing a client engine. Use it for project-owned startup setting adjustments; do not use it as a gameplay authority bypass.

ApplicationShutdownHook is a native lifecycle hook for project-owned process integrations that must be stopped before a client runtime DLL is unloaded. It is intentionally not part of the compatibility hash because it does not change script metadata, saved data, or the network contract.

Script Entity promotion

The AngelScript Entity type has no single native counterpart. get_entity_from_target in BuildTools/codegen.py promotes every argument, array element and return that spells a script Entity to the entity class of the target: ServerEntity* on the server, ClientEntity* on client and mapper, and the base Entity* elsewhere. Generated entity methods are registered on each concrete script entity type for which they are available; the base script Entity exposes only its common built-in operations, so a prototype cannot reach a server-only method through an abstract Entity receiver.

The promotion is a claim about the caller, not something AngelScript checked. register_entity_protos and register_entity_abstract in Source/Scripting/AngelScript/AngelScriptEntity.cpp register an implicit cast from every Proto* and Abstract* type to Entity, so a prototype is an Entity to a script — but ProtoEntity and ServerEntity are siblings under a single Entity base, so the promotion is false for one. Under single inheritance the pointer value survives it, which means an unchecked promotion does not fail at the call; it fails later, inside the callee, on a member the object does not have.

NativeDataCaller::ConvertArg in Source/Common/ScriptSystem.h therefore reads every entity slot as the base and narrows it to the declared type, for scalar arguments and array elements alike, throwing ThrowScriptEntityTypeMismatch with the type and name that were actually passed. Array elements deliberately keep no destroyed-entity check: an array argument may carry entities that died since the caller built it, and its callees drop them.

A dict value carrying an entity handle is narrowed the same way. Two static_asserts bound what is not: an entity handle as a dict key, because a key is a script value type rather than a handle slot, and an array of handles nested inside a dict value, because that slot holds a whole array object.

Dynamic metadata

Source/Common/MetadataRegistration.cpp implements RegisterDynamicMetadata(). It reads binary metadata sections and dispatches them into typed registration steps such as:

This is the runtime side of metadata that can be loaded from generated/baked data rather than compiled static registration alone.

Metadata version

Invariant: a server and every client connected to it run on metadata produced by one bake. This is not a preference — the property index space that entity data travels by is the registration order of that metadata, so two sides holding different bakes silently address different properties. A divergence is a defect in how the build or the deploy was done; it is detected and refused, never tolerated or worked around.

Why the compatibility version does not cover it: BuildTools/codegen.py only receives the engine and embedding- project C++ meta sources, so FO_COMPATIBILITY_VERSION changes with the binaries. Project ///@ Property declarations live in scripts and are registered at runtime from the baked metadata (RegisterDynamicMetadataProperties), which means the property layout is a property of the resources, not of the executable.

MetadataBaker therefore derives a metadata version from every codegen tag it parsed, in a deterministic order. The input is the raw tag stream as read from the sources — before any target filtering — so client, server and mapper of one bake always derive the same value even though their section bodies differ (Entity, Event, Setting, RemoteCall are filtered per target on the way out). Hashing the finished file instead would not work for exactly that reason; hashing the raw tags has no such limit, so no kind of divergence stays invisible: a property insertion that shifts every reg index below it, a changed struct layout, a renamed enum entry, a new remote call — all of them change the version, and all of them mean the two sides came from different bakes.

Every Metadata.fometa-* therefore opens with a fixed header, ahead of the section table:

Field Type Purpose
magic uint32 METADATA_FILE_MAGIC — a foreign or truncated file is rejected at the first bytes
file version uint16 METADATA_FILE_VERSION — bumped when this file layout changes; a mismatch means “rebake”
metadata version uint16 length + bytes the value above

A change to the token layout of any section is a change to this file layout, so it bumps the file version. The metadata version cannot stand in for it: that hash is derived from the codegen tags, which do not move when the baker starts writing another token, so an unbumped pack from the previous engine passes the header and is then read record by record under the new layout — the failure surfaces as a section-level VerificationException deep inside registration instead of the “rebake” verdict. MakeMetadataHeader() writes the header and ReadMetadataHeader() reads it, both in MetadataRegistration.cpp, so the format lives in one place. RegisterDynamicMetadata() reads the header before any section and hands the version to EngineMetadata::RegisterMetadataVersion(); ReadMetadataVersion() reads only the header, which is what the updater and the server startup check use — neither walks the sections to answer “which bake is this”. The value is read back through EngineMetadata::GetMetadataVersion() — it is computed, not configured, so it is deliberately not a setting (Network.ForceMetadataVersion exists only to simulate a divergence in tests).

Four layers keep the invariant, in the order they apply:

  1. One bake produces both sides. Baking.ServerResources and Baking.ClientResources must be deployed together; refreshing one of them is the classic way to break this.
  2. The server refuses to distribute foreign resources. UpdaterBackend::LoadFromClientResources reads the layout version out of the client packs it is about to hand out and fails startup (UpdaterException) when it differs from the one the server itself loaded.
  3. The updater syncs before a client exists. Updater::FinishResourcesUpdate re-reads the version from the local packs after the sync and reports UpdaterResult::MetadataMismatch unless it equals the server’s, so a ClientEngine is never constructed against data the server cannot talk to.
  4. The handshake is the last line. The client sends its version, the server compares and answers with a verdict plus its own version; see ClientUpdater.md.

Deserialization is guarded independently of all four: Properties::VerifyRestoredPropertyData() checks every property write coming from a serialized payload (target enabled, non-virtual, plain size matches) and throws VerificationException instead of reaching the strong assert inside SetRawData — a mismatch has to be diagnosable, not a process termination inside a memcpy.

When a divergence is reported, find the cause — do not silence the check. The useful facts are in the logs: the server prints Metadata version: at startup and names both versions when it rejects a client; the updater prints the local version, the server version, and the resource directory it read. From there the question is always the same: which of the two resource directories came from a different bake, and why.

Tests: Test_MetadataBaker.cpp (one version shared by every target, changed by a property insertion; a pack written in an older file layout is refused), Test_Properties.cpp (PropertiesRestoreRejectsForeignMetadata), Test_ClientServerIntegration.cpp (ServerReportsMetadataMismatchInHandshake).

Properties and generated contracts

Source/Common/Properties.h and Source/Common/Properties.cpp define the property runtime model used by entities and metadata. Key concepts include:

Fixed value-type layouts are shared by native C++, AngelScript registration, and metadata field traversal. hstring therefore has an explicit ABI invariant: sizeof(hstring) == sizeof(hstring::hash_t) == 8 on every supported target. On 32-bit targets the pointer-backed handle carries trailing padding to preserve that width and keep composite offsets (for example TextPackKey) platform-independent. The padding is not wire data: RPC/property serializers still convert the handle through as_hash() and resolve the received hash through the target engine’s hash resolver.

When property metadata changes, inspect both the property runtime and the generator inputs/templates. Script-visible nullability or API changes should also update Scripting.md, ScriptMethodsMap.md, and Nullability.md as applicable.

Public API relationship

../PUBLIC_API.md documents public build/API knobs such as build toggles and project helper functions like resource/package additions. Keep public API docs high-level and stable; put generator internals here.

Metadata and baker relationship

Metadata generation and metadata baking are related but not identical:

For resource baking details, see BakingPipeline.md.

Tests to inspect

Relevant tests include:

If a generated script API change is involved, inspect AngelScript-related tests as well.

Change routing

Validation checklist

  1. Configure from an embedding project root so project metadata sources are available.
  2. Run normal code generation and verify generated files are updated as expected.
  3. Run forced code generation when generator caching/dependency behavior changes.
  4. Build the smallest target that compiles the generated files.
  5. Run metadata/property tests relevant to the change.
  6. If metadata is baked, run the relevant baker test and bake target.
  7. Update docs that expose changed public contracts, especially Scripting.md, ScriptMethodsMap.md, Nullability.md, and ../PUBLIC_API.md when applicable.