View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

Baking Pipeline

This document explains the engine resource baking pipeline: where it is wired, which source files own baker behavior, and how to validate changes. For the broader engine tool map, see Tools.md.

Use this for reusable engine behavior. Game-specific content folder rules and product package policy belong in the embedding project’s docs.

Source paths inspected

What baking does

Baking turns project resources and configuration into runtime-ready output. The build pipeline creates BakeResources and ForceBakeResources command targets in BuildTools/cmake/stages/ScriptsAndBaking.cmake. Those targets run the project baker application with the embedding project’s main config applied.

At runtime/source level, baking is owned by:

Output names are reconciled with the names bakers addressed

MasterBaker::BakeAllInternal() reconciles the output tree with the names the bakers actually addressed, in two steps around the outdated-output sweep:

  1. ReconcileStaleCasedOutputDirs() walks the expected output directories shallowest first and renames any that differ from the expected spelling by letter case only, logging Rename stale-cased dir <from> to <to>. Directories go first because renaming a file into a differently-cased parent resolves straight back to the existing directory and leaves its name untouched, and shallowest-first means every rename lands inside a parent whose own name is already correct.
  2. After the sweep, the same comparison is applied to files, logging Rename stale-cased file <from> to <to>.

This exists because a case-only rename of an input is invisible to everything else in the pipeline. On a case-insensitive filesystem (Windows, default macOS) an output stream opened on the new name reuses the pre-rename directory entry and keeps its name; creating a directory that differs only by case reuses the existing one the same way; the outdated sweep compares through exclude_all_ext, which folds to lower case, so the stale entry still matches an expected resource and is kept; and incremental baking then skips the file entirely once it looks up to date. The runtime resolves baked packs by exact name, so the stale entry becomes an unresolvable resource — reported far from the rename, and only for the content that happens to use it.

The reconciliation runs once per bake over the set the bakers already produced, so it costs no per-write work, never deletes and recreates a file, and — unlike a check on the write path — also repairs outputs that the current bake skipped as up to date. On a case-sensitive filesystem the pre-rename name does not collide with the new one, the outdated sweep removes it normally, and both steps find nothing to do.

Covered by BakerMasterRenamesStaleCasedOutputAfterCaseOnlyInputRename and BakerMasterRenamesStaleCasedOutputDirAfterCaseOnlyInputDirRename in Source/Tests/Test_BakerSetup.cpp. The underlying per-primitive behavior — fs_rename() establishes the requested spelling, fs_write_file() and fs_create_directories() keep whatever is already there — is pinned on both filesystem kinds by DiskFileSystemNameCase in Source/Tests/Test_DiskFileSystem.cpp.

CMake entry points

BuildTools/cmake/stages/ScriptsAndBaking.cmake creates baking commands after application targets are available.

Current target responsibilities:

AddBakingTarget(<target> [SUB_CONFIG <name>] [FORCE] [COMMENT <text>]) creates another target from the same recipe without duplicating the baker and build-hash commands in the embedding project. Because the helper is defined by the ScriptsAndBaking stage, projects call it after SetupScriptsAndBaking():

SetupScriptsAndBaking()
AddBakingTarget(BakePublicResources
    SUB_CONFIG PublicGame
    COMMENT "Bake public resources")

The actual final target names that depend on these commands are project/preset-dependent. Do not document one embedding project’s target names as universal engine behavior.

Runtime classes

BakingContext

Defined in Source/Tools/Baker.h. It carries shared bake state:

For a MasterBaker pass, the context also carries the shared report collector and the stable baker name used for attribution. WriteData returns a BakingWriteResult, which distinguishes a content-changing write from an unchanged file whose timestamp was refreshed.

BaseBaker

The abstract base for individual baker implementations. Each baker provides:

Cross-pack dependencies use distinct stages: ParticleBaker runs before ModelInfoBaker, which runs before ProtoBaker, which runs before MapBaker. Model descriptions may reference baked particle resources, prototypes may reference the baked model descriptions, and maps consume baked prototypes; none of those validation steps may race publication by its dependency during a clean or forced rebuild.

BaseBaker::SetupBakers() in Source/Tools/Baker.cpp creates requested bakers and then calls SetupBakersHook() so external/project code can extend the baker list.

Each baker receives its own copy of the shared context. When a master-bake report is active, BaseBaker wraps that copy’s check and write callbacks with the baker name. This keeps output attribution correct even when a baker performs work on its own asynchronous tasks. Built-in and project-provided bakers can add domain-specific counters and histograms through the protected report helpers; the helpers are no-ops when no report collector is attached.

MasterBaker

MasterBaker coordinates a configured bake pass through BakeAll(). It is the app-facing type used by both BakerApp.cpp and BakerLib.cpp.

MasterBaker also owns the one report collector for that bake attempt and finalizes the report after either success or failure.

BakingReport

BakingReport is defined in its own Source/Tools/BakingReport.h / .cpp module. The header owns the common report DTOs and write-result contract; the implementation owns aggregation, sprite-mesh analysis, JSON construction, and the output report path. Baker.cpp only drives its lifecycle and forwards bake events to it.

BakerDataSource

BakerDataSource adapts resource inputs/outputs to the engine DataSource interface. It tracks input resource packs, output resources, cache checks, and output path construction. Reindex() reconstructs its input mounts, baker instances, file collections, and output index, returning whether the indexed paths or source write times changed. Long-running tools can therefore discover and on-demand bake added or changed resources without replacing cached directory lookup with repeated disk scans. BakerDataSource adapts resource inputs/outputs to the engine DataSource interface. It tracks input resource packs, output resources, cache checks, and output path construction. Its output-discovery dry runs and later lazy, per-file baking do not attach the master-bake report collector and are therefore deliberately absent from the report.

Master bake report

Every MasterBaker::BakeAll() attempt with a non-empty BakeOutput regenerates Baking.report.json in the output directory. A successful complete rebuild also updates Baking.full.report.json:

BakeOutput = Baking
runtime resource directory = Baking/
report = Baking/Baking.report.json
last complete-corpus report = Baking/Baking.full.report.json

The previous report is removed before baking starts. After the bake finishes, MasterBaker marks the new report success or failed, serializes all data collected up to that point, and writes it even when resource preparation or a baker failed. failureMessage carries the caught exception text, and baker entries that were registered but never reached remain not_run. Failure to write the report is itself a failed BakeAll() result, so every successful bake pass has its matching report.

Incremental and failed passes never overwrite Baking.full.report.json. The full snapshot therefore remains available for corpus analysis after ordinary incremental development bakes.

Outdated runtime-resource cleanup skips any file named *.report.json sitting directly in the BakeOutput root (REPORT_FILE_SUFFIX in Baker.h), which covers both reports above. The rule is a suffix rather than a list of known names because a baker in an embedding project may write its own diagnostic artifact beside them — such a file is nobody’s registered output, so without the rule the sweep would delete it in the same pass that produced it. Baked resources always live under a pack directory, so restricting the rule to the root cannot spare a genuinely stale resource.

The report is written directly into the BakeOutput root after runtime-resource cleanup finishes. It is never mounted in the baked FileSystem, registered as a baked output, or consumed as a runtime resource. It is removed before the next bake attempt starts, so analysis data does not change the resource set delivered to a client or server.

The top-level lifecycle fields are:

measurementScope prevents incremental samples from being mistaken for corpus statistics. Input counts always describe the complete configured input collection. Output activity and baker-specific details describe work performed in the current pass. In particular, Image.details.spriteMesh measures only frames rebuilt in this pass. completeCorpusDetails is true only when mode.fullRebuild is true; run a force bake before using form percentages as a distribution over the complete art corpus.

totals summarizes packs, distinct baker types, invocations, pack input files and bytes, output checks, scheduled and up-to-date artifacts, submitted files and bytes, changed and unchanged files, and removed outdated files. The same data is available in two analysis views:

Every baker entry has its order, success/failed/not_run state, invocation counts, elapsed time, failure messages, and availableInputFiles/availableInputBytes. The latter describe the complete input collection visible to that baker, not only files that match its own extension filter. Output statistics distinguish:

Raw checkCalls, scheduledCheckCalls, upToDateCheckCalls, submitCalls, and submittedBytesAcrossCalls remain available because some bakers intentionally check or submit a path more than once. File and byte groups include distributions by extension and the 25 largest paths. The details.counters and details.histograms objects hold optional baker-specific measurements while preserving the same common schema for every built-in or externally registered baker.

Image and sprite-mesh statistics

ImageBaker adds collection, direction, frame-slot, unique-frame, and shared frame-reference counters plus a source-format histogram. Its detailed geometry analysis is stored under details.spriteMesh in both the aggregate Image baker entry and each per-pack Image entry.

The settings object records the effective Enabled, AlphaThreshold, MaxTriangles, and AreaSavingsWeight values together with the internal base-dilation and maximum-padding policies used by that bake. Per-frame diagnostics separately record the actual dilation and simplification tolerance of the selected candidate, so expanded detailed candidates are not reported as if they used only the base dilation.

frames separates unique serialized frames from shared animation references. The mesh, quad, and empty percentages use unique, non-shared frames as their denominator; shared references are reported in slots and sharedReferences but never inflate form percentages. The triangleHistogram likewise reports both percentOfMeshes and percentOfUniqueFrames. The vertex histogram uses mesh frames as its denominator. Source-alpha connected components are counted for every unique frame; dilated components have their own histogram and measured denominator. Selected-candidate tolerance and actual-dilation histograms make fallback usage visible without inspecting individual resources.

selectionOrigins explains which winning candidate family produced each mesh:

quadReasons explains why a unique non-empty frame retained quad geometry:

For score_preferred_quad, bestRejectedCandidates summarizes the best valid candidate that lost to the quad: origin, triangle count, score, tolerance, and actual-dilation distributions. The same candidate’s vertices and area are included in the affected top-list row. This is the direct diagnostic for checking whether AreaSavingsWeight exchanges alpha area for triangles as intended. Aggregate geometry and area blocks quantify the candidate triangles, additional triangles over quads, recoverable frame area, and saved pixels per additional triangle. Individual candidates also report the breakEvenAreaSavingsWeight at which their area saving balances their triangle cost.

geometry compares the actual submitted vertex and triangle counts (mesh geometry plus four vertices/two triangles for every retained quad) with all-quad baselines of four vertices and two triangles per unique frame. Empty geometry submits no triangles, while the baseline intentionally represents how the same frame slot behaved before polygonal sprites. It also reports mesh-only vertices and triangles, the triangle delta, and averages per mesh.

area keeps exact integer doubled areas as the canonical values, then adds pixel-area and percentage views. It compares original unpadded quad area, submitted geometry area, and visible pixels, and reports both total frame-area savings and reduction of transparent overdraw. cropping reports how many mesh frames serialize a smaller canvas than their source plus the saved RGBA pixels and bytes. padding separately reports the serialized texture canvas, frames that still expand beyond their source, added RGBA pixels and bytes, padded-frame count, maximum padding, and the padding histogram. Expansion and cropping are accumulated independently, so savings in one frame cannot hide padding overhead in another.

The section also contains selection-score minimum/average/maximum values and the fixed quad score, plus a per-resource classification (mesh_only, quad_only, empty_only, or mixed). Five deterministic top-25 lists retain the frame identity and all relevant geometry fields for direct investigation:

Each top-list row includes separate source and actual baked-output paths, direction, frame index, form, selection origin or quad reason, triangles, vertices, source and dilated components, padding, chosen tolerance/dilation, source and baked canvas pixels, visible pixels, submitted doubled area, potential transparent pixels, padding overhead, cropping savings, and selection score where one exists. Full tie-breaking includes both paths, direction, and frame index, so the top lists remain deterministic under parallel baking.

During output discovery it visits resource packs in configured order so a later baker can read outputs declared by an earlier dependency (for example, model baking can load client metadata). When packs declare the same logical output path, the later registration replaces the earlier one; file resolution also searches packs from last to first, preserving the normal resource-overlay rule that later packs shadow earlier packs.

Built-in baker types

Source/Tools/Baker.cpp registers built-in bakers when requested and enabled:

The particle/model/prototype/map stages intentionally form a strict dependency chain: particle outputs at order 5 are visible to model-info validation at order 6, model descriptions are visible to prototype validation at order 7, and baked prototypes are visible to map baking at order 8. Bakers at the same order may run concurrently across resource packs and therefore must not consume one another’s outputs.

Prototype inheritance merge order

ProtoBaker and ProtoTextBaker resolve $Parent over the same algorithm, differing only in which keys they merge: ProtoBaker takes every key that does not start with $, ProtoTextBaker takes every $Text* key, and neither inherits $Name or $Parent. $Parent holds a space-separated list, looked up across all prototypes of that entity type rather than per file, with each name passed through the Proto migration rules first.

The walk applies each parent’s own ancestors before the parent itself, left to right, and the prototype’s own fields last. So the rightmost parent wins a contested field, a parent beats its own ancestors, and the prototype beats everything. Values are whole strings, so a list-valued field is replaced rather than merged.

Reaching one ancestor through two parents is the one case where that order would become surprising, so a repeated ancestor contributes its fields only where it is first reached. Walking it again would override whatever the earlier parent had customized, with nothing in the source to hint at it.

Baking.AllowRepeatedProtoParents (default true) decides whether such a prototype is allowed at all: when false it fails baking with Proto reaches the same parent through several inheritance paths, so a game that wants each facet stated once gets the diagnostic rather than a merge to reason about.

A $Parent cycle is rejected regardless of that setting (Proto parent chain contains a cycle); without that guard the walk recurses until the stack is exhausted.

The setting, the first-reach merge and the cycle guard are pinned for each baker by the RejectsRepeatedProtoParent*, AppliesRepeatedProtoParentOnce and RejectsProtoParentCycle* sections of Source/Tests/Test_ProtoBaker.cpp and Source/Tests/Test_ProtoTextBaker.cpp.

Prototype output follows the resolved prototype set alone: types and ids are collected into ordered maps, so which file carries a proto and the order the files arrive in do not reach the bytes. That makes Protos.fopro-bin-server / -client / -mapper byte-comparable across bakes to prove a content refactor changed nothing, including one that moves prototypes between files. Pinned by the BakesIdenticalBytesWhateverFileCarriesEachProto section of Source/Tests/Test_ProtoBaker.cpp.

When documenting a specific asset type, inspect the relevant baker class and its tests rather than inferring behavior from file extensions alone.

Text-language fallback overlays

TextPack::ParseBakeLanguages(...) accepts Baking.BakeLanguages declarations in either language or child:parent form. It validates uniqueness and requires each parent to precede its child, then exposes bare language ids to filenames, output packs, and runtime consumers. TextPack::FixPacks(...) uses the first declaration to define the legal pack and key domain. A plain later language inherits missing packs and keys from that first language; an inline parent selects another family. For example, russ engl ru18:russ en18:engl supports sparse derived editions without forcing adult English to inherit Russian text.

An authored child key replaces the parent’s complete variant set for that key; an omitted key inherits it. Extra child-only packs or keys remain invalid and are removed during normalization. TextBaker, ProtoTextBaker, and external dialog-text bakers parse the same declaration list, so .fotxt, prototype $Text, and dialog [Text] sources obey one contract. The generic behavior and invalid configurations are pinned in Source/Tests/Test_TextPack.cpp.

Shared animation metadata uses AnimationInfo as the aggregate record. The generic record contains a SpriteInfo payload for 2D frame count, duration, directions, and resolved per-frame bounds, plus a ModelAnimationInfo payload in FO_ENABLE_3D builds for model and animation AABBs and typed animation durations. ReadSpriteResource fills the sprite payload from the baked sprite header and frame table. For metadata queries that must not load pixel payloads, ImageBaker also writes one compact version 1 SpriteInfo/<PackName>.foinfo index per resource pack. The index is an aggregate output over every image source in that pack; normal scan baking merges changed entries with the existing complete index from the same pack’s output directory, while an explicit request for the index rebuilds every entry. Pack-local previous outputs are mounted separately from the shared cross-pack baked-file registry so a pack can read its own aggregate before its first baker invocation. Introducing or losing the index is a full-rebake condition rather than a reason to decode all sprite pixels at runtime. The common ReadAnimationInfo path reads those 2D indexes in every build, reads ModelAnimationInfo.foinfo when 3D is enabled, and merges both payloads by resource name in EngineMetadata. Baker-local statistics follow the same ownership names (SpriteInfoBakingStats and ModelAnimationInfoBakingStats) instead of using the aggregate AnimationInfo name.

When FO_ENABLE_3D is active, ModelInfoBaker emits the regular baked model descriptions together with a model-specific ModelAnimationInfo.foinfo companion. Bounds schema version 2 adds three required root-space contracts to every model section:

The baker samples every animation key, the midpoint between neighbouring keys, and a 60 Hz grid, building deterministic envelopes independent of camera angle, projection factor, model-sprite resolution, and renderer backend. That timeline is the same in both measurement modes: a coarser one misses the extreme of a fast arc, which would clip a model rather than over-size it.

Baking.PreciseModelBounds selects how the posed geometry is measured at each sample:

ModelBoundsSampler prepares one baked model - hierarchy, the geometry selected by one disabled-mesh set, and the per-bone envelopes - once, and answers every clip of that model from it. A rigid attachment reads only where its link bone travels, so one sampled bone track per clip answers every attachment on that bone instead of re-walking the parent hierarchy per attachment, and each model section of ModelAnimationInfo.foinfo is produced independently and concatenated in sorted file order. Missing or invalid aggregate or animation bounds are baking errors in the version 2 contract. Each binary link contains an explicit hasGeometry discriminator before the optional geometry payload. The discriminator must match the link type: a non-empty ChildName with IsParticles == false writes 1 followed by the required min/max AABB; the default link and particle links write 0 and no geometry AABB. Readers reject discriminator values outside 0 and 1. In FO_ENABLE_3D builds, the common EngineMetadata loader reads the companion once at startup and strictly validates its version, required bounds, and every parallel duration/bounds array before publishing the parsed model records. Duration and animation-bounds groups are optional and validated independently: durations use the alias-expanded animation key domain, while bounds use the raw animation key domain, so either group can legitimately appear without the other. A static section can carry neither group; a present companion with no model sections is malformed. Enabled animation bounds size the logical scratch frame, the dedicated view bound seeds the stable body/name rectangle, and aggregate bounds seed the horizontal-lighting reference. Runtime layer/child-model envelopes extend both contracts. Each selected geometry link contributes its baked absolute AABB; runtime projects only envelope corners and does not read or skin mesh vertices to determine dimensions. Live particles can still expand and rerender the scratch frame when their measured bounds exceed the baked geometry envelope.

Source/Common/ModelBounds.h/.cpp, guarded by FO_ENABLE_3D, owns the shared root-space AABB contract used by the baker and client: finite/ordered validation, non-point extent checks, point and bounds accumulation, eight-corner transformed accumulation, and the common max(0.01, maxAbs * 0.001) guard. ModelBoundsCalculator is limited to reading baked model data and sampling geometry; it does not maintain a parallel bounds type or bounds-manipulation implementation. If the default link disables every base mesh, the baker retries against the unfiltered source model as a conservative layout envelope; genuinely empty or invalid geometry remains a baking error.

DrawSize and ViewSize are no longer .fo3d grammar. ModelInfoBaker rejects those removed tokens instead of serializing authored dimensions; frame and view layout are runtime projections of the baked bounds. Because the companion output is global, its incremental timestamp covers every input in the pack, including animation FBX files referenced by .fo3d data. The ModelInfo baking report records model sections, aggregate model bounds, duration entries, animation bounds, view-bound idle/fallback selection, calculator/cache counts, and a maximum-axis-extent histogram (<1, 1-2, 2-3, 3-5, 5-10, 10+ model world units) for coverage and density analysis. Individual model descriptions are validated and baked before the aggregate companion, so a broken .fo3d is reported through the normal per-file diagnostic path and cannot leave a newly written companion beside rejected model descriptions.

The companion also contains each model’s effective (state, action) cycle durations after authored AnimSpeed is applied. The duration arrays materialize the same one-step StateAnimEqual / ActionAnimEqual resolution used by the client model runtime, including alias priority over an exact entry with the alias source key. Any pack can select ModelInfo; behavior never branches on PackName. Put that pack on every runtime side that needs model metadata. In FO_ENABLE_3D builds, EngineMetadata registers the complete parsed model-animation records alongside prototypes at startup, including aggregate/view bounds, the alias-resolved duration table, the raw-animation bounds table, and model names in hash storage. The duration and bounds key sets intentionally remain separate because aliases can produce duration-only keys while raw .fo3d entries can be bounds-only. Client model code requests the parsed record instead of reopening the config; common scripts query its duration table through Game.GetModelAnimDuration(modelName, stateAnim, actionAnim). The script method returns a timespan, or zero when the resource, model, or resolved tuple is absent. The config representation is an internal baker/runtime contract and should not be parsed by embedding-project code.

3D model baking architecture

The model pipeline is split into source extraction, compatibility analysis, conversion, native wire contracts, and the two final bakers. These are engine responsibilities, not interchangeable animation-backend interfaces. The animation-specific production code groups them into one same-named .h/.cpp pair per owner: ModelSourceLoader, ModelAnimationConverter, and ModelAnimationData.

Module Layer Responsibility
ModelSourceLoader Tools Backend-neutral source skeleton/clip/TRS data, ufbx import, complete source validation, and the per-bake single-flight ModelSourceAssetCache.
ModelAnimationConverter Tools Deterministic canonical hierarchy construction, compatibility diagnostics, contributed-joint/root-alias and parent analysis, and conversion into runtime rig artifacts using the pinned Ozz offline implementation internally.
ModelAnimationData Common Versioned little-endian LF animation envelopes and the native rig manifest: identity and signatures, canonical skeleton, base/clip remaps, clip payloads, presence/nearest data, and state/action bindings.
ModelMeshData Common Passive mesh-wire DTOs plus the versioned LFMODMSH reader, writer, and shared structural validation. It contains no runtime animation implementation.
ModelMeshBaker Tools Validates and writes mesh-only hierarchy, bind, vertex, index, influence, and drawable data.
ModelInfoBaker Tools Resolves .fo3d descriptions and dependencies, invokes source loading/compatibility/conversion, then writes LFMODINF, the required rig payload, and ModelAnimationInfo.foinfo. Compatibility failures remain bake errors; successful per-description compatibility reports are not emitted to the routine bake log.

The main data flow is:

FBX/.obj + .fo3d -> ModelSourceLoader -> ModelAnimationConverter -> ModelAnimationData -> ModelInfoBaker -> ModelInformation / ModelAnimation.

Mesh data follows the parallel FBX/.obj -> ModelMeshBaker -> ModelMeshData -> ModelManager / ModelHierarchy path. The two streams meet in ModelInformation; clips and mutable poses are never serialized into the shared mesh hierarchy.

The model-specific bounds and duration contract is described immediately above; the Ozz conversion changes its source representation, not the common AnimationInfo query surface.

EffectBaker compiles each .fofx pass once with glslang (Vulkan 1.0 client, SPIR-V 1.0) and emits, per stage, the native -spv (consumed by Rendering-Vulkan, and cross-compiled by SPIRV-Cross to -glsl / -glsl_es / -hlsl) plus, for the opt-in SDL_GPU backend, a -spv_sdl flavor and SDL-remapped -msl_mac/-msl_ios. The native SPIR-V follows the engine’s 2-set descriptor convention (set 0 = uniform buffers, set 1 = combined image samplers, shared by both stages); -spv_sdl is that same SPIR-V with its descriptor decorations rewritten in place to SDL_GPU’s per-stage convention (vertex samplers = set 0 / UBOs = set 1, fragment samplers = set 2 / UBOs = set 3, dense 0..N-1 slots). The per-pass -info artifact carries two sections: [EffectInfo] (program-wide bindings the GL/D3D/Vulkan backends consume, plus a CHECK_BUF size validation against the RenderEffect uniform structs) and [EffectInfoSdl] (per-stage SDL slot per resource plus the sampler/UBO counts SDL_CreateGPUShader needs). The baker hard-fails an effect that exceeds SDL_GPU per-stage limits (4 uniform buffers, 16 samplers), declares storage buffers/images, uses duplicate/missing explicit bindings, or declares a resource it never uses.

ImageBaker imports PNG/TGA plus classic frame/image formats such as FRM, FRx, FOFRM, ART, SPR, ZAR, MOS, BAM, and TIL. FOFRM nested frame references forward $ options to the referenced image loader. ART options accept palette selection (0..3), transparent-alpha derivation (t/T), horizontal and vertical mirroring (h/H, v/V), and frame selection/ranges (f/F, for example f5 or f7-5). SPR options accept zero or more [part,r,g,b] color-offset entries, using either commas or whitespace as separators, followed by the sequence name. BAM options accept a cycle index and optional cycle-frame selector separated by - (for example $1 or $1-3); out-of-range cycle and frame selectors fall back to the first available cycle/frame.

ImageBaker can also bake an indexed silhouette mesh for every unique RGBA frame. The controls live in the dedicated SpriteMesh.* setting group inherited by BakingSettings:

The polygonization algorithm is isolated in SpriteMeshing. It accepts an RGBA frame, its dimensions, and the resolved mesh settings, then owns mask construction, component and contour analysis, candidate generation, triangulation, validation, scoring, and the diagnostic result. ImageBaker owns image-format decoding, frame sequences and shared frames, adaptive per-frame padding and cropping, mesh serialization, and baking-report integration. This keeps polygonization independent of ImageBaker::FrameShot and allows the geometry builder to be exercised without an image-resource container.

Geometric safety is internal policy rather than project tuning. The baker uses a one-pixel mask guard band and may probe up to 20 pixels of temporary padding when a candidate needs room beyond the source bounds. After selecting a mesh, it takes the exact bounds of the final vertices, crops the RGBA payload to those bounds, and translates the vertices to the cropped frame origin. The serialized per-frame offset is adjusted on both axes so the logical root remains at the same screen position even when different animation frames have different bounds. At runtime AtlasSprite reverses that storage adjustment for its public logical size and offset, maps mesh positions through the stored source origin, and keeps UVs local to the cropped atlas allocation. A quad or empty result is not cropped or padded. Candidate profitability is always compared against the original unpadded frame, so increasing the search area cannot manufacture an artificial saving. Each unique frame is searched on the maximum temporary canvas, then the retained mesh is translated into its minimum required canvas without repeating candidate generation. A maximum-canvas quad is re-evaluated on the unpadded frame, because the bounded contour search can produce a useful border-sensitive candidate there.

Mesh generation builds enclosing candidates for every reachable triangle count from one through MaxTriangles. It starts with exact convex-hull support lines. A greedy removal path quickly reaches the configured range, then a deterministic bounded beam explores alternate support-line removals and retains the smallest area found for every triangle count. The exhaustive minimum-area triangle and parallelogram candidates remain additional optimized cases. Candidates that do not contain the complete hull are rejected before triangulation.

If the mask has multiple disconnected outer contours, the same candidate ladder is built for every component. Nearby components are also merged into a deterministic hierarchy. Every partition that can fit the triangle budget is evaluated, so the search compares one global primitive, all-independent primitives, and intermediate groupings such as two close door details plus a separate leaf. A bounded dynamic program keeps several low-area alternatives per triangle count while distributing the budget. The final validator rejects local primitives that overlap each other or miss any guarded visible pixel. Selection scores each valid candidate as savedFrameAreaRatio * AreaSavingsWeight - triangleCount and compares it with the ordinary quad score of -2. Equal scores prefer the smaller covered area. This lets projects explicitly trade submission complexity for fill-rate savings: for example, a weight of 64 makes each additional 1.5625% frame-area saving worth one extra triangle. A closed double door can still use one common primitive, while sufficiently separated leaves use independent primitives without covering the gap.

The baker also traces exact pixel-cell boundaries, preserves disconnected opaque islands and transparent holes, and automatically tries a deterministic ladder of closed-contour simplification tolerances. Clipper2 normalizes touching paths, offsets simplified geometry by the internal guard band, unions it with the exact source polygon so no visible area can be clipped, and intersects it with the exact raster-dilated region so simplification cannot bridge arbitrary transparent pockets. A second constrained cleanup removes long pixel staircases. Every simplified outer-ring edge is moved to the supporting line that encloses its complete replaced source chain before adjacent lines are intersected; hole rings retain ordinary inward simplification. This keeps the cleanup from cutting across visible stair-step corners. Earcut only triangulates the resulting outer rings and holes.

Every valid detailed result up to MaxTriangles competes directly with the enclosing and per-component candidates under the same score. At every tolerance, the constrained offset, guard-band simplified, and tolerance-expanded cover families are generated independently; finding a merely valid candidate in one family never suppresses another family. The tolerance ladder likewise runs to its deterministic bound instead of stopping after several early valid results.

The simplified families use a bounded removal beam rather than probing only one vertex. Each step tries replacing an exterior corner with a supporting chord through that corner; adjacent edge intersections preserve coverage. The beam keeps the lowest-area distinct states and records the best result for every reachable triangle count. Tolerance-expanded covers convert the Douglas-Peucker error bound into an enclosure instead of clipping thin visible features. They still undergo exact pixel-cell coverage and topology validation, but their transparent overdraw competes through AreaSavingsWeight rather than a separate tolerance mask.

Generated triangles must cover the complete unit-square area of every original visible pixel; the validator clips triangles against pixel cells and compares their accumulated area instead of relying on a finite set of sample points. For the constrained detailed-contour path, additional transparent coverage is limited to the internal one-pixel guard band plus one pixel for final staircase cleanup and integer vertex rounding. Expanded-cover and enclosing candidates are not controlled by a separate overdraw gate: their actual covered area is accounted for directly by the score. Failure reasons remain distinct internally (contour extraction/offset, vertex limit, triangulation, area, coverage, and tolerance-mask failures), so retry policy does not conflate complexity with geometric invalidity. An empty mask is recorded explicitly as empty geometry; a fully opaque, unprofitable, oversized, or otherwise unsafe result is recorded as the ordinary quad. The optional Earcut Delaunay refinement pass is deliberately not used: it changes neither triangle count nor covered area and does not promise bit-identical output across compilers.

Baked sprite blobs have an explicit engine-owned magic/version and store each unique frame’s individual draw offset before its cropped RGBA payload, followed by the mesh kind. SpriteResource owns this shared format contract, mesh data type, and strict whole-resource decoder used by the client, particle editor, and project-side server image loader. Its public entry point accepts the exact resource byte span and creates its own reader, so decoding is independent of any caller-owned stream position while footer and trailing-data checks remain scoped to that resource slice. The decoder returns animation timing, directions, per-frame offsets, shared-frame references, RGBA pixels, and optional mesh geometry. Mesh records use fixed-width cropped-frame coordinates and indices plus the original logical source size and the cropped-frame origin within that source. The origin may be negative when selected geometry uses the internal safety border. The decoder requires mesh vertices to occupy the exact serialized frame bounds. Shared animation frames continue to refer to the original frame and do not duplicate its offset, pixels, or geometry. The runtime rejects legacy or malformed blobs rather than guessing their layout. Drawable atlas sprites retain the original logical size, anchor, scaling, and hit-test coordinate space while using the cropped pixels and mesh for texture storage and rendering. Thus transparent-canvas cropping is an internal storage optimization rather than a change to the authored sprite contract. Consumers that sample the image as a plain rectangular texture rather than a sprite (FontManager, ParticleEditor, and project-side server image sampling) restore the cropped payload into the original logical canvas so their size and pixel-coordinate contracts do not change. In particular, font glyph positions are authored against the original sheet dimensions; SpriteManager::LoadSpriteAsQuad must be used for font sheets so mesh padding/cropping cannot shift glyph UVs. After this format changes, or when SpriteMesh.* values change without a new build hash, run ForceBakeResources; source-file timestamps alone cannot prove that an existing image output was baked with the same mesh settings.

MapBaker writes separate server and client map blobs. The client blob serializes visible static items, and its hash dictionary is also accumulated from client-side properties of hidden static items so Common hstring values can resolve later without exposing the hidden item entities.

Both blobs open with a format header - BAKED_MAP_FILE_MAGIC and BAKED_MAP_FILE_VERSION from Source/Common/MapLoader.h - which MapLoader::ReadBakedFileHeader validates before MapManager::LoadStaticMaps and MapView::LoadStaticData read anything else. Without it a stale output would be read as element counts, because the rest of the layout is bare numbers. The hash table is written and read through the DataWriter::WriteString / DataReader::ReadString pair, whose length check cannot be skipped at a call site, and every remaining count and size is preflighted with DataReader::VerifyPayloadCount before it drives an allocation or a loop, so a damaged file raises DataReadingException instead of reserving whatever the bytes happened to say. When the layout changes, bump BAKED_MAP_FILE_VERSION and run ForceBakeResources in the same change: source-file timestamps alone cannot prove that an existing map output was baked with the current layout.

ParticleBaker exposes only the formats whose backend is enabled at build time. FO_SPARK_PARTICLES enables text .spark input and generated .spk output; FO_EFFEKSEER_PARTICLES enables text .efkproj input and generated .efk output. Both options default to OFF, in which case the registered baker has no particle formats to process.

For SPARK, ParticleBaker loads native .spark XML with the engine’s registered SparkQuadRenderer type and writes deterministic SPARK binary to .spk with the same path stem. Renderer texture paths are resolved relative to the .spark resource; absolute paths and paths which escape the resource source are hard errors. Unknown object types, malformed XML, authored .spk, and binary-save failures are also hard errors; publishing a graph after silently omitting an object is forbidden. The client and baker use the same SparkExtension.cpp implementation through ClientLib; do not compile a layout-changing headless copy into BakerLib. The binary loader enforces exact memory payload length, bounded object/attribute counts, bounds-checked reads, descriptor signatures, and valid typed object references, so changing a custom serialized descriptor requires rebaking its resources.

For Effekseer, .efkproj must be an on-disk XML project normalized by Editor 1.80.5 with project version 3. ParticleBaker calls the native C++ EffekseerCompiler module directly. The fixed-profile exporter produces raw SKFE bytes and a dependency list; the baker validates each result with the pinned C++ Effekseer Core and publishes it under the same path stem with the .efk extension. The compiler is part of BakerLib and is never linked into production clients. Native Mapper’s on-demand baker uses the same path when a tracked source edit is detected. Web targets consume .efk resources baked before packaging.

The compiler also exposes the referenced resource paths for each project. ParticleBaker resolves them relative to that project, rejects paths which escape the project’s physical directory resource source, and stores a per-output snapshot containing the project path, size, and write time plus each dependency’s path, size, and write time under <BakeOutput>/.baker-cache/Effekseer/<pack>/<output>.deps. On the next incremental check it stats those same physical files which the native compiler reads. This remains correct when a resource pack has multiple overlaid input directories: the snapshot follows the selected project’s disk source rather than a same-named virtual file from another source. A changed, deleted, or renamed dependency dirties every effect that references it, while an unrelated file does not trigger a corpus-wide recompile. A missing or stale snapshot makes the compiler inspect the project’s dependency list before the normal single BakeChecker call. ParticleBaker then removes only that effect’s stale physical .efk output, so the ordinary missing-output path schedules the compile without a special timestamp or a callback in the common baker infrastructure.

An .efkmodel is not an effect container: it is a binary runtime dependency requested by Model nodes and remains subject to the embedding project’s ordinary resource-pack/raw-copy policy.

Authored .efk files are hard errors rather than copy-through inputs. Project resource sources retain reproducible .efkproj XML, while runtime code accepts only the generated .efk. Baking proves the compiler/Core parser boundary only: it does not load FOnline atlas textures or enforce the client/Mapper CPU Sprite/Ring renderer capability policy. A compiler implementation change requires a forced bake so all generated .efk files are refreshed. ModelMeshBaker builds the passive ModelMeshData tree and its common codec writes a versioned mesh-only payload. Every baked model mesh starts with LFMODMSH, schema 1, and zero flags, followed by the recursive bone, bind, and drawable-mesh data. The writer does not serialize clips or a TRS tail. ModelInfoBaker traverses the decoded DTO for metadata, while the client adapts it into hashed runtime hierarchy and render types; neither owns a second byte parser. Both require this header, consume the mesh payload exactly, and reject old headerless files, unknown schemas or flags, truncation, and trailing data. There is no legacy mesh-format fallback. Runtime readers preflight every serialized length/count against the unread byte span before allocation. Mesh vertices are limited by vindex_t addressability, each index must address a serialized vertex, skin palettes may not exceed MODEL_MAX_BONES, and offset counts must match. Every serialized blend weight/index must be finite, weights stay in [0, 1], indices are integral and address that palette, and each vertex’s weights sum to one. The hierarchy has at most 1024 joints and at most 128 nodes on one parent chain; the mesh baker, ModelInfoBaker, source loader, and client all enforce the applicable limits. Malformed resources therefore fail with contextual DataReadingException instead of allocation, out-of-bounds palette access, or recursive-stack failure.

ModelMeshBaker also rejects a mesh node whose geometry_to_world determinant is negative. That is a node exported with a negative scale — a mirrored object — and the reflection flips surface orientation: stored normals point into the model and triangles wind the other way, so the shader lights the mesh from its inside while back-face culling drops the front faces, and the model renders flat black. The baker does not compensate for it. Flipping normals and winding at bake time would leave the broken source in the repository, where the next export and every other tool reading that file keep the reflection; the exporter is where a mirrored object has to be frozen back to a positive scale.

ModelInfoBaker gates the size of a directly attached model — an Attach link pointing at a bare .fbx rather than a .fo3d description. Such a model is drawn on the parent skeleton, so an export authored in foreign units looks correct in the render and only shows up through the model’s own bounds, which size its client sprite frame: a centimetre export asks for a frame two orders of magnitude too large, and CalculateModelSpriteLayout cannot build it at all. The bake fails when the model’s static bounds leave the band Baking.ModelAttachmentMinExtent .. Baking.ModelAttachmentMaxExtent, naming the file, the measured extent and the limit.

The same band also gates each .fo3d section’s aggregate ModelBounds (the union of animation envelopes, or static geometry when the model has no mappings). That envelope sizes the client lighting frame independently of the current clip. A full bake checks it while writing ModelAnimationInfo.foinfo; a targeted .fo3d bake (which does not rebuild that companion) runs the same check before writing the description. A .fo3d Scale token does not change the baked envelope, so a centimetre-space root model is not exempt. Baker code does not read AppRender::MAX_ATLAS_WIDTH / HEIGHT: those are the bake host’s GPU, not the game device. Portable layout math uses AppRender::MIN_ATLAS_SIZE / FRAME_SCALE instead. Runtime preview zoom can still push a valid in-band envelope past Render.ModelSpriteMaxTextureWidth / Height; RefreshFrameLayout clamps that scratch texture and draws cropped instead of terminating. Schema 1 keeps the existing DataWriter native-endian mesh payload; all current engine targets are little-endian. Unlike the explicitly little-endian Ozz envelopes below, a future big-endian mesh consumer requires a converted wire format and a new schema rather than interpreting schema 1 in place.

Before writing the mesh payload, ModelMeshBaker validates node matrices, vertex attributes, normalized colors, skin weights, skin offsets, the 1024-node total limit, and the same 128-node safe hierarchy-depth limit. Values must be finite and representable as float32. Every skinned vertex must retain at least one positive influence; cluster indices must resolve, weights must be non-negative, and the retained top four are normalized and rechecked to sum to one. The final serialized vertex is checked again after UV flipping and skin-weight normalization. Diagnostics name the source asset, node, field, element, and component. Invalid values are never clamped or replaced with identity.

After ufbx_generate_indices has removed byte-identical duplicate vertices, the baker runs the pinned meshoptimizer v1.2 release (tag commit 9d9890c73011d75920af614485296d1e03e95448) on each drawable mesh. Vertex-cache optimization first reorders whole triangles to improve transformed-vertex reuse; vertex-fetch optimization then reorders the interleaved Vertex3D buffer to first-use order and rewrites its 32-bit working indices. Both passes write temporary buffers. The baker verifies triangle-list shape and index ranges before the library call, requires fetch optimization to retain every vertex produced by ufbx, validates the resulting indices again, and only then commits the buffers and narrows indices to vindex_t. The library’s temporary allocator is installed once before parallel mesh jobs and uses SafeAllocator, preserving the engine rpmalloc, backup-pool retry, and fail-fast OOM policy.

These passes are lossless reorderings, so LFMODMSH remains schema 1; existing runtime readers need no meshoptimizer dependency. Overdraw reordering is not enabled because the same assets target desktop and tiled mobile GPUs, where its benefit is workload-dependent. Quantization, mesh codecs, LODs, meshlets, and a packed vertex layout remain a separate measured schema-2 design rather than an implicit extension of this wire format.

Animation extraction is owned by ModelSourceLoader, not by the baked mesh format. For each source FBX selected through a resolved .fo3d, the loader uses ufbx to extract the source skeleton and every clip, validates the complete source asset, and returns immutable source data to ModelInfoBaker. Clip names must be unique case-insensitively. Source/clip identity, positive finite duration, hierarchy/output pairing, every S/R/T time and value, joint/clip/key limits, the shared 128-node source-hierarchy depth, and quaternion normalization are validated before Ozz conversion. Individual S/R/T channels must be non-empty and key times must be finite, strictly ascending, and have finite intervals. The ModelSourceAssetCache is created once per ModelInfoBaker::BakeFiles() call and implements single-flight loading with one shared future per source path: parallel descriptions share one successful parse or the same exception, while FBX parsing occurs outside the cache mutex.

ModelInfoBaker audits the base skeleton and every selected external animation clip before writing a .fo3d description. ModelAnimationConverter builds a deterministic, parent-first canonical joint map as part of its compatibility analysis, and the baker emits per-description reports in path order. Animation-only joints are permitted and reported as contributions. An FBX’s unique technical scene root may have an empty name; empty non-root joint names remain invalid. Duplicate joint names or full hierarchies and incompatible parents fail baking because they cannot be remapped to an index-based runtime skeleton without ambiguity. Different source-root names are normalized to the base root and reported explicitly.

Rest-pose divergence between the base model and external clip FBXs is report-only: selected clip outputs are authored absolute local TRS, so existing assets do not require one shared rest pose. Reports retain every divergent clip/joint mapping and log deterministic counts, the maximum matrix-component delta, and its source. The canonical converter keeps base-model local rest transforms and gives animation-only hierarchy nodes identity rest; a clip’s different rest pose does not replace either fallback.

Source key times are deliberately not constrained to [0, duration]: ufbx playback ranges and key ranges are independent, and trim_start_time shifts shared curves without cropping them. ModelAnimationConverter evaluates the source curve at 0 and duration, keeps only keys strictly inside that playback window, and preserves the original duration. It never stretches the take, rescales all key times, or clamps each out-of-window key. Smooth quaternion segments are adaptively subdivided at SLERP values with a 0.05-degree conversion budget, leaving room for Ozz runtime quantization under the tested 0.1-degree parity limit. Descriptions with disabled interpolation must use one shared cropped S/R/T timeline per clip and contain exact keys at playback times 0 and duration in every authored track. This preserves nearest-key midpoint thresholds when source tracks also contain keys outside the playback window. The converter serializes the shared timeline so the runtime sampler can select the exact nearest key before sampling. Smooth multi-key tracks may start at or before time 0, or wholly after the playback window; a first key inside (0, duration] is rejected because the required discontinuity cannot be represented by continuous Ozz interpolation.

Direct FBX attachments are validated as rest-only resources. If an Attach ...fbx target contains embedded animation clips, baking fails and requires an Attach ...fo3d description with explicit animation mappings; the direct runtime path has neither a controller nor a clip-binding table. This check reads the attachment’s validated source asset through the same per-call single-flight loader.

External animation FBXs should contain transform hierarchy and animation only. Baking rejects drawable geometry in an external Anim source unless the resolved model description names that exact selected file with one validation-only AllowAnimationGeometry <file> token. Duplicate resolved exceptions, exceptions for non-selected sources, and exceptions whose target no longer contains geometry are hard errors, preventing broad or stale allowlists. When duplicate drawable geometry shares transforms with linked child models, source repair must preserve the helper names, parents, and tracks as geometry-free helper/bone nodes; it must not prune the complete branches. Remove each exception with its repaired export. The embedding project’s content workflow owns the exact temporary allowlist and source-repair inventory; reusable engine documentation deliberately does not enumerate project assets.

Incremental .fo3d invalidation includes the maximum write time of the resolved description/include graph and every replacement-expanded Model, external Anim, direct Attach, and Cut source dependency. A missing referenced source is a hard error. This is required because an animation-only FBX change does not change the mesh-only LFMODMSH output by itself but must still rebuild the canonical Ozz rig and ModelAnimationInfo data.

The native model-animation format is implemented with the ozz-animation 0.16.0 release tag, whose exact tagged commit is 6cbdc790123aa4731d82e255df187b3a8a808256. ModelAnimationData defines the versioned LF envelope for generated skeleton, animation, and joint-remap payloads together with the containing rig manifest. The envelope writes its magic, schema, payload kind/flags, rig/source/cache signatures, exact pinned ozz revision, source/object identity, payload length, and FNV-1a payload hash field-by-field in little-endian order. Schema 1 reserves all flag bits and requires zero. Source and object identities are non-empty valid UTF-8. The envelope stores, but does not derive, three caller-owned deterministic content signatures: canonical rig data, the exact cropped payload source, and converter policy/settings. ModelAnimationConverter hashes fields in explicit little-endian order and includes retained hierarchy/rest/fallback data, cropped TRS plus presence/remap data, the interpolation policy/error threshold, optimizer state, and pinned ozz revision. These signatures are deterministic invalidation inputs, not security identities. The production payload stores them, but a persisted or shared conversion cache additionally requires canonical signed-zero/quaternion handling (or source-bit signatures) and Windows/Linux golden-signature tests before these derived floating-point hashes can be treated as cross-platform cache keys.

Before any runtime or offline codec object is constructed, InitializeModelAnimationMemory() installs a private engine allocator for the statically linked Ozz state in that module. It provides arbitrary power-of-two alignment over SafeAllocator<uint8_t>, so codec allocations use the same rpmalloc backend, backup-memory retry, OOM diagnostics, and fail-fast policy as engine containers. No Ozz allocator type is exposed through a public model API, and the vendored allocator source remains byte-identical to upstream 0.16.0.

The reader requires the caller’s expected identity and rejects mismatches, unknown kinds, reserved flags, truncation, trailing bytes, and payload hash mismatches before the animation codec reads the opaque payload. FNV-1a detects accidental corruption only; it is non-cryptographic and does not authenticate the archive. Ozz’s binary deserializer is therefore used only after LF framing validation and only for trusted baked resources. A deployment that accepts attacker-rewritable resource packs must authenticate the pack before model loading; recomputing the non-cryptographic LF hash is not a security boundary.

After resolving a .fo3d, ModelInfoBaker now builds the complete canonical runtime skeleton, every unique selected runtime animation, the base/clip remaps, canonical presence masks, and nearest timelines in memory. Runtime ozz objects are serialized with explicit little-endian OArchive, wrapped in the LF envelope, read back, and checked for exact type/tag/length plus canonical name/parent/count/rest/identity invariants. Signed TRS decomposition supports mirrored rest matrices by placing reflection in X scale and requires T*R*S matrix round-trip within a relative 1e-4 tolerance; shear, non-affine/zero-scale transforms, non-unit quaternions, animation translation/scale outside ozz FP16 range, non-positive durations or durations whose reciprocal is not finite, effective (state, action) cycle durations that round below one millisecond, collapsed or non-finite normalized runtime timepoints, more than 1024 joints, more than 65535 time points, and animated aliased roots fail baking. Absent base tracks are explicitly filled from canonical rest; absent animation-only tracks are identity, while a separate presence byte remains zero.

The baked .fo3d contract is now explicitly versioned. Every description starts with LFMODINF, schema 2, and zero flags, followed by the existing positional description and one required length-prefixed LFOZZRIG schema-1 payload. The rig payload stores the canonical rig/cache signatures, canonical skeleton, base remap, each unique resolved animation/remap pair, and a sorted (StateAnim, ActionAnim) -> (clip index, reversed) table. Clip identities are the actual baker-resolved source/name, so Base, case-insensitive authored names, relative paths, and multiple animation pairs sharing one clip do not need to be resolved again by the client.

Schema 2 appends an optional union AABB to every serialized link. A non-particle link with a child model must carry a finite, non-degenerate bound; default/root-edit and particle links must not. For an empty Link bone, ModelInfoBaker skins the child mesh with the parent description’s static pose and every unique mapped animation. For a named bone, it sweeps the child aggregate AABB through that parent’s bone in the static pose and every unique mapped animation. The child-default plus outer-link T/R/S and both child-default and outer-link DisableMesh selections are included. These calculations are submitted as bounded nested jobs from the ordinary per-description bake task. They reuse the bake pool and fall back to inline execution when it is full, so changing one attachment invalidates and recalculates its owning .fo3d without serially rebuilding a global equipment-configuration table.

Schema 3 extends every geometry-link payload after that union AABB with a counted, (StateAnim, ActionAnim)-keyed set of per-animation AABBs. The client maps those keys to runtime clip indices and uses the active clip’s envelope instead of keeping the union envelope for every pose. This is an incompatible binary layout: any future field added to LFMODINF must bump MODEL_DESCRIPTION_SCHEMA_VERSION in the same change. The schema-3 transition also uses compatibility marker 0.0.46, forcing a new runtime and a full resource bake to travel together.

Each rig archive manifest repeats the caller-owned source signature and source/object identity before its nested LFOZZARC. The reader constructs its expected metadata from that outer manifest and strictly compares the inner archive instead of trusting metadata read from the same envelope. Counts, lengths, ordering, duplicates, binding references, remap semantics, exact consumption, type tags, skeleton topology/rest transforms, track counts, and durations are validated before the immutable rig is published to ModelInformation. Old unversioned descriptions are rejected; there is no runtime fallback. The final mesh-only wire transition uses compatibility marker 0.0.30 and requires a full resource rebake.

The generated Ozz rig is the only production clip/pose payload. The client does not load a parallel TRS representation, and ModelAnimationController stores only LF timeline/event state plus direct Ozz clip index, duration, reverse, and bound-joint metadata. Sampling, per-joint body blending, movement replacement, procedural pre-rotations, and local-to-model evaluation are per-instance Ozz runtime work. Cross-model clip sharing or a persisted conversion cache remains an optional optimization that must be justified by measured pack and runtime memory results.

Script compilation relationship

ScriptsAndBaking.cmake also creates script compilation commands:

These are separate command targets from resource baking, but they share the same stage because generated/baked runtime inputs are part of the same build preparation workflow.

Tests to inspect

Baker behavior is covered by focused tests in Source/Tests/:

Use the smallest test that matches the baker you changed. If CMake target names are generated by the embedding project, discover them from that project’s presets/build files instead of hard-coding them here.

Change routing

Validation checklist

  1. Configure from an embedding project root.
  2. Build the project baker application/library target if the changed path affects app/library code.
  3. Run the relevant baker test(s) under Source/Tests/.
  4. Run BakeResources for incremental behavior when cache/build-hash logic matters.
  5. Run ForceBakeResources after model-source or Ozz conversion changes. This is the positive integration gate that parses and extracts animations from the project’s real FBX files instead of only synthetic unit-test assets.
  6. Inspect generated output only as output, not as hand-authored source.
  7. Update this document and BuildToolsPipeline.md if stage responsibilities change.