![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
The engine persists scenes and prefabs to JSON, and resolves the assets they name through a cooked asset database. The IO layer is three serializers that compose - scene, component, asset - plus the library that maps an asset name to the files holding it.
The engine runs projects: a directory becomes one by containing a project.json. That file is what lets a game live nowhere near the engine's own repo, and Vkm::Engine::Project (src/engine/io/project.h) is everything it says:
| Field | Purpose |
|---|---|
| name | Display name; titles the window, names the packaged exe by default |
| version | The game's own version: vkm package names the package by it and stamps it into a Windows executable's properties |
| engineVersion | Engine version the project was made for; logged on load, and the one record a module's build checks (vkm_check_engine_version) |
| entryScene | Scene to boot, relative to the project root |
| tickRate | Simulation ticks per second; 64 by default, clamped to a sane range |
| maxPlayers | Seats the game has, clamped to 64. A property of the game, not of a run: a scene with four characters authored into it is a four-player game wherever it is served. Zero is kept and means it - the session refuses to host rather than opening a seat the author closed |
| netPort | Port the game is served on unless a run says otherwise, so serving a project and joining it need no argument to agree |
| splash | Logos the runtime shows after the engine's own, in order. Usually empty; the editor shows only the engine's mark, because the editor is not the game |
| render | What the game looks like: the pass toggles and their parameters, written and read through visitShippedRenderFields. Absent fields keep the engine's defaults, so a file without the block opens as the engine's own look. renderMode and grid are not in it - see Which root owns a path |
tickRate is the project's rather than the engine's because it is not only a simulation detail: for a networked game it is the rate the wire is clocked by, and a competitive shooter and a turn-based game want different answers out of the same build. Each host applies it where it learns its project - the runtime once before the loop, the editor on every project open, since two projects opened in one session are two cadences.
A missing or malformed project.json is not fatal - the defaults stand and an unnamed project opens, which is what a fresh directory should do. A render setting of the wrong type is narrower still: it keeps its value and warns, and the rest of the file reads.
saveProject writes one back, for the editor's Project Settings window; New Project stamps name and engineVersion into its template's copy directly. saveProject is a read-modify-write: the file is parsed, the fields the struct describes are overwritten, and every other key is left exactly as it was, because a project.json is hand-authored as often as it is written by a tool and a writer that rebuilt the document would silently drop what it did not know about. splash is the one field read but not written - loadProject skips an entry naming no image, so writing the list back would delete it from the file.
The Project Settings window stamps engineVersion with the running engine before it saves. That field is provenance rather than a format version - nothing refuses a file over it, it only warns - and what it records after a save is the engine that last wrote the file.
findProjectRoot(start) accepts a directory or any file inside it and walks up looking for project.json, so passing a scene finds the project owning it.
ProjectPaths (src/engine/io/project_paths.h) splits on-disk locations by who owns them, because three different things live on disk:
| Root | Owns | Helpers |
|---|---|---|
| engineRoot() | What ships with the engine, read-only to a game. One copy serves every project | engineShaders(), engineAssets(), engineFonts() |
| projectRoot() | The game being made: its content, its code, its asset database | assets(), scenes(), prefabs(), envs(), screenshots(), library(), cooked(), projectBin() |
| userRoot() | How one person likes their tools, across every project and every engine install | userLogs(), a sibling under the platform's state directory rather than a child of this root |
engineRoot() resolves once at first use. projectRoot() returns the override when one is set and falls back to engineRoot() otherwise, so setProjectRoot() takes effect immediately - but call it before anything composes a project path, because a path already built from the old root is a plain string by then and will not follow. The editor re-roots in that order when it opens a project (see editor.md).
userRoot() exists because the first two can both be read-only. An SDK installed to /usr/local or Program Files is; so is a game installed there. Anything the engine writes that is not a project's own content therefore goes here:
| File | Where | Why not the project |
|---|---|---|
| editor_user.json | userRoot() | The settings that follow a person rather than a project: the projects you have opened, which is how you get from one to the next and which inside a project could only ever list itself, and every Preferences field - keybinds, snapping, the fly camera's feel, the UI scale, vsync, the frame cap and the window mode - which are facts about you and the monitor in front of you |
| imgui.ini | userRoot() | The editor's docking layout - where each panel is docked or floats, how big it is - and table column widths are one person's arrangement, not something a project hands the next person (editor.md) |
| logs/<project>/log.log (cook.log for vkm_cook) | userLogs(), only when the project cannot hold a logs/ | A developer looks beside the project, so that stays the first choice |
The platform decides the actual directory, and configuration and state are different places on both: $XDG_CONFIG_HOME (or ~/.config) and $XDG_STATE_HOME (or ~/.local/state) on Linux, APPDATA% and LOCALAPPDATA% on Windows, each under a vkmEngine folder. With no home directory at all - a service account, a stripped container - both fall back to the engine root (userLogs() to its logs/), and userRoot() does the same when it cannot create its folder inside the one it names. That fallback is why the repository ignores imgui.ini and editor_user.json at its root. userRoot() creates its directory when first asked for; userLogs() does not, because bootHost creates the per-project subdirectory it writes into.
editor_settings.json is the deliberate exception: it stays in the project root, because what it holds (which panels are shown, the active tool, recent scenes, which debug buffer the viewport is showing) is per-project. A project you are authoring is writable by definition.
What the game looks like is not in it. project.json carries a render block
Two consequences worth knowing before you add a path:
Every host opens a project by one rule, in bootProjectWorld (src/tools/project_boot.h), because they have to agree on it: the authored entryScene, else the world the project's module builds through vkmBuildScene, else the engine's default scene. Exactly one of them runs - seeding a scene first would leave a stray camera, light and cube under whatever the project then builds.
A scene is always standing afterwards, so the return value says whose it is rather than whether there is one:
| SceneBoot | Meaning |
|---|---|
| Project | The project's own world opened - its entry scene, or one its module built |
| Default | The project names no world of its own; the default scene stands in |
| Failed | The project names an entry scene that did not load; the default scene stands in |
SceneBootResult carries the path beside it, and only one of the three worlds has one: the authored entry scene. A module-built world and the default scene standing in for a load that failed are both worlds with no file behind them, which is what stops a stand-in being saved over the file it replaced.
The editor adopts that path as the scene it is editing (SceneIOController::adoptPath, from both its startup and File > Open Project), because the world it opens on is the one scene it never read itself. Unadopted, a file the editor had just loaded would be a scene with no file: Save would ask for a name, offer scene.json rather than the name it has, and writing it would leave the project's entryScene untouched and the session saved where the project never looks - with the title bar calling it untitled the whole time. The path is stated by the function that opened the file rather than re-derived by each caller, since re-deriving it means restating the rule this one exists to hold.
The exit code is the only answer a shell gets, so each host has to spend it on what is actually fatal for that host. The runtime plays a finished game; the editor is the tool you repair one with.
SceneSerializer::load returns true after a load in which every reference went unresolved - each one is a component slot left empty, not a parse failure - so the last row is not something the loader can answer for. The cooker installs an EngineErrorLog sink around the load and counts what reportError puts in it, because a scene whose references resolved to nothing is exactly the state that gets packaged and ships a world with empty slots. That sink catches every failure the load reports, not only unresolved assets - a prefab file that will not open is in it too - so the message counts failures and names the two remedies apart rather than telling the author of a deleted file to save the project. vkm package needs no rule of its own: it already returns on a non-zero cook.
| Condition | vkm_runtime | vkm_editor | vkm_cook |
|---|---|---|---|
| Log file cannot be opened | exit 1 | exit 1 | exit 1 |
| Window / GL context cannot be created | exit 1 (throws) | exit 1 (throws) | n/a - headless |
| No gameplay module in the project's bin/ | exit 1 | opens, logs WARNING | n/a |
| Module present but will not load (version, entry, unreadable) | exit 1 | opens, logs ERROR | n/a |
| Entry scene named but will not load (SceneBoot::Failed) | exit 1 | opens on the default scene; error toast and an Errors panel entry | exit 1 |
| No entry scene and no vkmBuildScene (SceneBoot::Default) | exit 1 | opens on the default scene | cooks every scene in scenes/; exit 0 if there is none |
| No entry scene, module builds the world (SceneBoot::Project) | plays it | opens it | cooks every scene in scenes/; exit 0 if there is none |
| An asset fails to cook | n/a | reported, the session continues | exit 1 |
| Entry scene loads, but something in it does not (unresolved reference, prefab file missing) | plays it with what loaded | reported, the session continues | exit 1 |
The cooker reaches its rows by its own path rather than through bootProjectWorld - it has no Scene to boot into and no module to ask. It first re-bakes every asset the library holds whose source changed since its last cook (AssetCooker::cookStaleAssets), then loads every .json in the project's scenes/, in sorted order, plus entryScene when that file lives elsewhere, and cooks each in turn. A scene that will not load, or loads with a failure reported, stops the cook with exit 1.
vkm_server has no column because it answers the runtime's column for every row in it: it plays the same world, so a project it cannot open is a game it cannot referee. It adds one condition of its own - a project whose module exports no vkmSetupNetwork has not said what a player is, so there is nothing to serve and it exits 1 where the runtime would have played it offline.
Two judgments behind that table:
| Layer | File | Purpose |
|---|---|---|
| Scene serializer | src/engine/io/scene/scene_serializer.h | Top-level save/load for a Scene + the assets it references. Transactional. |
| Prefab | src/engine/io/scene/prefab.h | One entity subtree in its own file, instanced by reference from a scene. |
| Asset serializer | src/engine/io/asset/asset_serializer.h | Save name-only asset references; on load resolve them through the asset library: the cooked file, else the recipe. |
| Asset library | src/engine/io/asset/asset_library.h | The cooked-asset database manifest: maps an asset name to its type + recipe hash, and derives its file locations. |
| Asset cooker | src/tools/cook/asset_cooker.h (editor) | Bakes assets from their recipe into the library + cooked binary cache (cooked/). |
| Cooked format | src/engine/io/asset/asset_cook.h | The .vkmc binary reader/writer. Readers are defensive: every count and size is validated before it is used. |
| Component serializer | src/engine/io/scene/component_serializer.h | Per-component to/from JSON. Mechanical, one save/load pair per component type. |
| Shader reload | modules/vkmGL/src/shader/gl_shader_reload.h | Live-shader registry + reloadChangedShaders; recompiles every live shader when the newest write time under shaders/ moves, and each one that no longer compiles keeps its old program. |
Every JSON write in this layer - scene, prefab, manifest - goes through detail::writeJsonFile (src/engine/io/json_file.h): the dump lands in a sibling .tmp and is renamed over the target only once the stream reports a clean write, so a full disk cannot leave a truncated file where a good one was. detail::readJsonFile is the matching read.
SceneSerializer::save emits a JSON object with four top-level blocks:
Every number in the finished document is finite. JSON cannot spell an infinity or a NaN - nlohmann writes both as null - and a null where a float belongs is a type error the component loaders throw on, which fails the whole load: one bad field would cost every entity in the file. So the document is held to the rule where it is written (detail::writeNonFiniteAsZero, which detail::writeJsonFile runs on every document the engine writes to disk and saveToString on the snapshot): a non-finite value is written as 0 and named in the log by its path, e.g. /entities/12/components/AudioSource/volume. The play-mode snapshot goes through the same builder, the same rule and the same cook, so a scene that could not be saved cannot fail to restore on Stop either - the cook is what makes that true of its assets, because the snapshot names them exactly as the file does and a name is restorable only once the library holds a record for it. A finite number writes as itself, and the read side stays strict: a null in a scalar field is a type error, never "keep the default".
Where the snapshot stops being the file is the list beside it. A scene document names the assets the scene uses and nothing else, which is right for something somebody saves and not enough for something that promises to put a session back: a sound imported and not yet assigned to a source is in the Asset Browser, in every picker, and in no component, so the scene never mentions it, and a session that edited it would leave that edit standing. So captureSnapshot records AssetSerializer::saveAllAssets - every live asset that has a name and is not hidden - alongside the document, and restoreSnapshot feeds that list back through loadAssets before it reads the scene. The extra list never reaches disk and the file format is untouched; it is the session's half of the snapshot, and the cook above is what makes it restorable.
Restoring keeps the graph and rebuilds its contents. The two halves of the snapshot go back differently from the way a file load puts a scene in place, and the difference is the undo history the editor keeps across Stop. Its steps hold the assets they are to put back, as handles, and a handle is a slot index into one ResourceManager - so loadFromString reads the scene with AssetPolicy::Merge, resolving names against the graph the snapshot was captured from rather than swapping a rebuilt one in behind them, and the asset list goes back with LoadMode::Reload, which rebuilds each material into the slot it already occupies (ResourceManager::swapValue: contents exchanged, identity and name left with the slot, version bumped so the backend re-uploads). Both halves are needed. Without the merge, a rebuilt graph restarts at the same indices and generations, so a surviving step resolves to whatever landed in its slot, and an undo of a mesh assignment would silently restore a different mesh. Without the reload, a material edited during the session would keep that edit, because nothing would have put the old contents back.
Only materials are rebuilt. Play cooks first, so every other kind the graph holds resolves to its cooked file, and the cooked loaders answer a name already resident with the asset already there. Rebuilding them anyway would re-read every mesh and texture the project cooked on every Stop, and a rebuild through an import still decoding would hand the live slot a loading stub whose decode lands on the discarded shell. The Material Editor is the one tool that edits an asset during play; a mesh, texture, rig, clip or sound a behavior edits keeps the edit after Stop.
What the session created goes: after the reload, AssetSerializer::dropAssetsNotIn removes every named, visible asset the snapshot's list does not carry, so a world a session generated does not leave its assets behind in the Asset Browser and in every picker. That cannot strand an undo step - an asset created during the session is held by no step taken before it, and the steps taken during it are dropped by the same Stop. An import made before Play is in the list, and stays.
Opening a scene answers this the other way, on purpose. Stop promises to put one session back; an open is leaving that world for another, and it drops the undo stack, the selection and the material previews on the way through. So the strays go with the session that imported them - carrying them would grow the graph by a scene's worth of assets per open and cook every one of them into the library at the next save - and the editor names them in the log and counts them into a toast rather than letting them vanish quietly. See the editor.
SceneSerializer::load is transactional for both entities and assets:
An asset name the load cannot answer leaves the component's slot empty, and an empty slot is indistinguishable from one nobody ever filled - so a save that went by the slot alone would write "" over the name, drop the entry from the assets block, and report it as an ordinary successful save. Opening a scene whose gitignored library/ a teammate never committed and pressing Ctrl+S out of habit would be enough to lose every reference in it, permanently.
The name is the author's work, so the load keeps it: resolveAssetRef records what it could not resolve, SceneSerializer attaches it to the entity as a MissingAssets component (present only on the entities that have one, never written as a component of its own), and the save puts it back in two places -
A reference with nowhere to return to is never recorded in the first place, so neither half has to know about it: LOD's levels are a ramp rather than a name, and the holes in that ramp are its own decision.
With both, restoring the library and reopening the scene brings the reference back to life. The editor also names them on the entity: the Inspector heads a selection that has any with the component, field and name it could not load, which is what separates "the mesh is gone" from "I never assigned one".
What reads the record asks whether each field is still empty. The other way an author can answer a missing reference is to pick something else. The save already leaves a filled slot alone, but the banner would go on saying the reference did not load, and the assets block would go on declaring a name the scene has stopped using - so every later load would report it. The record itself is never trimmed, though, because a filled field is one an undo can empty again, and the name has to still be there when it does. So SceneSerializer::unresolvedRefs returns the entries whose field is still empty, and the Inspector's banner and the assets block read that rather than the record. It answers by writing the entity and reading the field back, which is the same question the save asks and so cannot drift from it.
Scene::createEntityAt(slotIndex) is what makes step 3 possible: entities recreate at their saved slot, so Hierarchy::parent indices in the file resolve directly without a remap step.
Because both the scene and the assets are staged and swapped only on full success, a malformed or partial file leaves the live Scene and the live ResourceManager untouched - there are no orphaned half-loaded assets. The .cpp documents this inline.
After load, the caller should:
SceneIOController in the editor handles these.
An asset's recipe - the source descriptor its import or generator wrote, with a kind - is the editable source of truth. Its cooked file is a binary cache derived from it. Every asset is its own file:
Neither filename is recorded: AssetLibrary::recipePath() and cookedPath() derive them from (type, name), so the cooker that writes a file and the loader that reads it cannot disagree about where it is. The uid is a hash of "<type>:<name>", since a name may hold separators and colons.
An imported asset is named by its project-relative path, and so is the path in its recipe - ProjectPaths::toProjectRelative at the loader boundary, resolveProjectPath to open it again. An absolute name would make the library's layout a function of one machine's home directory. A source outside the project keeps its absolute path, and the conversion happens before the by-name dedup, or one file reached by two spellings becomes two assets. A model file's parts are named by the reference and the part - assets/props/crate.glb:mesh0, ...:skeleton, ...:clip0 (resources.md lists the parts).
AssetSerializer::loadAssets reads a document's sections in ASSET_DEPENDENCY_ORDER (resource/asset_type.h) - textures before the materials that name them, rigs before the clips and meshes bound to them - and skips a name the graph already holds. For every kind but a material it asks the cooked loader (io/asset/cooked_loader.h), which finds the manifest row, probes the file with AssetCook::isCookedCurrent and reads it - a mesh or texture on the ThreadPool, a skeleton, clip or sound at once - giving the asset a {"kind":"cooked","name":...} stand-in for a source. A rig or clip is too small to earn an async lane; a sound needs no decode, and a worker hop would open a window in which a scene's sounds exist and are silent.
When no current file serves the name, the recipe is imported through the AssetFactory seam (io/asset/asset_factory.h): one import per kind that cooks, which the editor and vkm_cook install (registerRecipeAssetFactories, in src/tools/cook/recipe_registration.cpp) and the runtime and server leave null, so they link no importer. A material has no cooked file and no slot: its recipe is its runtime form, and io applies it itself (AssetSerializer::applyInline). A name the manifest has no row for still loads from its recipe when one is on disk, since the recipe's path is derived from the name alone: a lost or refused manifest costs a re-cook, not the project.
| kind | Read by | Resolves to |
|---|---|---|
| inline | every host | A MaterialAsset from PBR scalars + texture refs - every material's recipe, whatever it was imported from |
| generator / decimate | editor, cooker | Procedural / LOD meshes |
| file / model / model-image | editor, cooker | stb texture import and sound decode; cgltf / ufbx mesh, rig and clip import (a clip recipe also carries its authored markers) |
| solid | editor, cooker | Single-colour textures, the engine's defaults among them |
A decimate recipe is an LOD level kept as the instruction that makes it: {"kind": "decimate", "base": "<mesh>", "ratio": 0.5}, the share of the base's triangles the level aims for. decimateRecipe and decimateRatioFromRecipe (src/tools/cook/lod_generator.h) are its one writer and one reader; a missing or non-numeric ratio asks for a half.
What an import returns is filed under the name the document asked for. An importer dedupes by its own identity - a texture by its path, a model's parts by names derived from the file - so when two library entries import one file it can answer with an asset held under another name. That entry is left unresolved, with a warning, rather than taking the other asset's name from everything resolving it.
Adding a new asset kind is a row in VKM_ASSET_KINDS, which brings its enum value, its directory and its section of the assets block, and then the hand work the comment above that list names: a case in cookedKind, a place in ASSET_DEPENDENCY_ORDER (the build fails until it has one), a cooked loader, and an AssetFactory slot with its import.
Save - AssetSerializer::saveAssetsForScene walks the components that name assets (Collider, Mesh, LOD, Decal, Animator, AudioSource, UIImage, plus the asset fields a behavior declares) and emits name-only references. In the editor, SceneIOController first calls AssetCooker::cookAllAssets, which records every non-hidden asset in the library and bakes its cooked file, skipping one whose hash is unchanged and whose file this build can read or is baking. It waits for imports still in flight first (awaitAsyncLoads): an asset that has not landed has nothing to bake. It returns false when any asset failed, which is vkm_cook's exit code. An asset with a recipe and nothing in it - its source did not load - is a failure, since the manifest would promise a file nothing produced; one with no recipe at all is only a warning, since a project may build assets in code and name them, as examples/potion_runner does.
Recording an asset - its recipe and manifest row - is a few small writes and is what makes a saved name resolve, so Save and Play do it before they return. Baking a texture or mesh takes seconds, and the editor hands it to the ThreadPool with a copy (AssetCooker::Bake::InBackground); until it lands, loads fall back to the recipe. One already under way is not started twice, and one that fails is logged and baked again by the next cook. vkm_cook bakes before returning (Bake::Now).
finalizeAsyncLoads / awaitAsyncLoads (system/async/async_loader_system.h) land async decodes without a frame - drain once, and drain until quiet. A completion carries the worker's whole asset, swapped into the slot it was requested for, which keeps its identity and source. The second lands only the completions meant for the graph it was given and puts the rest back, because a scene load waits on its staging graph while the live graph's imports are in flight. AsyncLoaderSystem calls the first every frame; the cooker and the decimate recipe, which would otherwise simplify a base mesh that has not arrived, call the second.
A row's recipe hash is a 64-bit FNV-1a (core/fnv1a.h) over the recipe bytes, folded with the bytes of every source file the import reads (AssetCooker::sourceFiles, AssetCooker::foldSourceContent): the file the recipe names, a .gltf's buffers in files beside it, and an .obj's material libraries. A re-export leaves the recipe byte-identical, so without the content the hash never moves. It is hashed by content rather than write time, which differs between machines, and at cook time rather than written into the recipe, which describes the import rather than one machine's bytes. The row records those files as its sources, which vkm package reads to leave them behind. A derived asset - a level decimated from another mesh, a clip bound to a rig from another file - names its dependency only by name, so AssetCooker::foldDependency folds the dependency's recorded key into its own; that is why the cooker cooks in dependency order, a decimated level after its base. The cook reads an asset's art once a session and keeps it while the asset's version and recipe stand, so a save does not read the project's art again.
An asset served from the cache carries the cooked stand-in, not its recipe, so no open or save sees re-exported art behind it. vkm_cook does: before it loads a scene, AssetCooker::cookStaleAssets hashes every manifest row's recipe off disk and re-imports and re-bakes each asset whose hash moved, whose base did, or whose recorded file is not current.
A cooked file is named for what it was derived from: <uid>-<key>.vkmc, where the key mixes the recorded recipe hash, the kind and COOKER_VERSION (AssetCook::cacheKey, io/asset/asset_cook.h). So:
COOKER_VERSION is the one version: bumped when anything the cooker writes changes - a kind's layout, an importer flag, a mip policy, a welding rule. A change that forgets the bump leaves every project serving what the old cooker made, so testTheCookersOutputIsPinnedToItsVersion runs every decision a cook makes over fixed inputs - the vertex orderer, the BC4/BC5 encoder, a cut-out through the mip filter and BC7, a model through the import's weld and tangents - and fails when the bytes move under an unchanged version. Its inputs are integers and its float code rounds alike in every build type, as the x86-64 baseline has no fused multiply-add.
Every .vkmc is the same header - magic, endian sentinel, asset kind, cooker version, payload length - followed by a body. The kind tag is its own numbering, not the enum's value: cookedKind in asset_cook.cpp maps an AssetType to it. A texture's colour space is its internal format's (TextureAsset::isSrgb).
| Body | Holds |
|---|---|
| Mesh | Bounds, the vertex, index and skin counts, the skin radius, the rig name's length, then bulk vertices, indices and skin, then the rig name |
| Texture | The TextureParams fields - the level count among them - then every level, level 0 first: texels, or 4x4 blocks for a BC format |
| Skeleton | Bone count, a {parent, nameLen} record per bone, bulk inverse-bind matrices, bulk bind-pose TRS, then the concatenated names |
| Animation clip | Bone count, duration, the six key-array counts, the skeleton name length, the marker count and marker-name length, then the bulk ClipBone table, the six key arrays, the rig name, a {time, nameLen} record per marker and the concatenated marker names |
| Audio clip | Sample rate, channel count, sample count, then the interleaved 16-bit PCM |
Every body puts its counts before what they size, and a reader bounds every count by division against what is left of the payload before multiplying it, then requires the remainder to land on exactly zero.
A mesh body is baked in the order a GPU draws fastest: the cook reorders a copy with optimizeMeshForGpu (src/tools/cook/mesh_processing.h) - triangles for the post-transform vertex cache, then vertices in first-use order, the skin stream remapped with them. It is the one place a mesh is reordered, so a mesh drawn from its recipe and the same mesh read from its cooked file hold the same triangles in different orders.
Past the size math, each reader checks what a correctly-sized file can still get wrong. A skeleton and a clip are checked by the asset's own rules, findSkeletonFault and findClipFault, which the writer and SkeletalAnimationSystem apply too:
The rest belong to the cooked format alone, and nothing downstream re-checks them:
A re-cook, not a re-import. Refusing an old file is affordable because the recipe can still produce a new one: vkm_cook rebuilds every file the manifest promises, and opening the project and saving rebuilds the ones its scenes name.
One probe, AssetCook::isCookedCurrent, serves both ends, so they cannot disagree. It reads the header and measures the file: absent, foreign, of another kind, or longer or shorter than its header declares is not current - the last is what an interrupted write leaves. The loader asks it before reading, and loads the recipe instead on a no; the cooker asks it before skipping an asset (isUpToDate), because a file this build cannot read is not an output to skip. The probe logs nothing: a stale cache is normal, and the caller says what the miss meant. A host with no importers - the runtime - logs that it has no cooked file and imports no recipes: a shipped build cannot rebuild its cache.
Which is why a packaged game carries no recipes but its materials, and no source art: vkm package ships cooked/, the manifest and library/materials/, and leaves out every file a row lists under sources. A material's recipe is its runtime form; any other kind's describes an import the runtime cannot perform. So the hosts read one manifest two ways (AssetLibrary::Truth): the editor and the cooker take the recipes as the truth and drop a row whose recipe is gone, while the runtime and the server take the manifest's word, because in a package the recipes are missing on purpose.
A re-cook does not recover a recipe whose source art is gone; that fails the cook, as above.
For every component, there is a save(const T&) -> json and a load(const json&, T&). They are intentionally mechanical, one pair per component.
Two kinds take a second argument, for the same reason: a reference cannot survive a file as the handle it is in memory. A component that names assets takes the ResourceManager, which turns a handle into a name and back. A component that names entities takes the carrier - an EntityNamer to save, an EntityResolver to load - because what an entity is called depends entirely on what is carrying the reference. A scene names it by its slot, a prefab by its place in the file, an undo snapshot by the slot it is restoring, and a connection will name it its own way.
The carrier is an argument because the same raw number means three different things depending on which carrier it came from - a live slot in this Scene, a slot saved in a file, a one-shifted index into a prefab's entity list - and a field like Joint::connected cannot say which. Taking the carrier as an argument makes the number impossible to read without naming the namespace it is in, and EntityId being its own type makes writing one back impossible.
What the round trip carries is VKM_SCENE_COMPONENTS, at the top of component_serializer.h. Read the macro for the set; the rest of this section is what the list cannot say.
A row's letter is which of ComponentSerializer::save's shapes it takes: R names assets, so its save and load take the ResourceManager that turns a handle into a name and back (Collider, Mesh, LOD, Decal, AudioSource, Animator, UIImage); E names other entities, so it takes the carrier described above (Joint, Ragdoll); P writes itself.
Hierarchy is written beside them but is not a row: only parent is serialized, and the sibling pointers are rebuilt on load by re-running HierarchyOperations::setParent.
What a component deliberately leaves out is the other half, and it is the same rule every time - a field that describes a session rather than the thing does not round-trip:
A Collider part writes its shape by name alongside every shape's fields, so switching a part to a capsule and back does not lose the half-extents it was authored with. It is reflected like the rest, parts included, so a part missing a key keeps that field's default - a part with no shape reads as the default shape - the way every reflected field reads an absent key. A mesh part names its mesh, which makes Collider an R row; its triangles are built from that mesh on load (syncMeshCollider) and never written.
Environment (sky / night / fog groups) and PhysicsSettings are scene-global rather than per-entity, so they are written as their own top-level objects. Both are fully reflected: each field list lives once, in ecs/environment.h and ecs/physics_settings.h, and both directions walk it. Render tuning (GTAO / bloom / MSAA / ...) lives in RenderSettings on the Engine, not in a serialized component.
Mesh references handles by name rather than by Storage index; that is what makes assets a stable identity across save/load. Hierarchy::parent stores an index into the file's own entity table, not an EntityId, and it is the one component the flat list does not carry: the parent it names may not exist when the child is read, so saveComponents writes it and the loader's second pass wires it up once every entity exists.
Two localised edits, no registry table, no virtual dispatch:
As four hand-kept lists they would fail silently in both directions: a key saved and registered but never loaded would round-trip to nothing, while the drift warning that exists to catch it stayed quiet because the key was still known; and a component whose assets a hand-written walk forgot would save its handle as a name the assets block never listed, so the next load resolved it to nothing. An R row with no emitAssetRefs overload fails to compile at the walk, naming the component that needs one.
The key is spelled out in the row rather than derived from the type name, since it is the format - ScriptComponent is stored as "Script". Hierarchy is not a row: it is written explicitly and read by the loader's second pass.
Scene load runs in two passes for the same reason an E row exists: every entity is created at its saved slot before any component is read, because a slot is only an entity once they all exist. The entry checks - an unusable id, a repeated one - belong to that first pass, since after it every id is alive and a second aliveness test would call every entity a duplicate of itself.
The editor solves the same problem the same way one directory over (VKM_EDITOR_SNAPSHOT_COMPONENTS), and a component's field list is already single-sourced by VKM_REFLECT_BEGIN / VKM_F.
loadInto overwrites a component the entity already carries instead of adding a second one, because a prefab instance root is loaded twice, once from the scene block that placed it and once from the prefab file.
A prefab is a scene fragment - one entity and its descendants, with the same per-entity component shape a scene uses, in its own file:
The assets block is the same one a scene carries, for the subtree this file describes, and it is what makes a prefab instantiable anywhere. Component references are asset names, and a name resolves to nothing unless something already loaded it, so without the block a prefab would build correctly only where a scene had loaded the same assets first. Every path that reads components out of a prefab loads the block first, Prefab::reloadComponent included, since reverting an override re-reads an asset name too. Loading is idempotent by name, so an instance after the first costs a lookup per entry.
Parents precede children and a child names its parent by index into this file's own array, because entity ids mean nothing outside the scene that issued them. Prefab::save drops the Hierarchy block for that reason and rewrites the link as an index.
That file is one a person can write, so readPrefab establishes its shape and its identities once, and every entry point reports what it cannot use rather than raising: the callers are an editor showing a toast beside its own file picker and a scene load with other entities to build. Past that check an entry is an object, its component block is one too, and no two entries answer to the same uid; the numbers still read out of it - version, uid, parent - treat a key of the wrong type as an absent one.
A duplicate uid is a hard refusal because it makes an override's address ambiguous, and the four places that resolve one disagree about what to do with it: applyOverrides patches every match, while reloadComponent, definesComponent and entityWithUid each stop at the first. Prefab::save keeps a uid only when it is that entity's alone, so the writer cannot produce a file its own reader refuses - a collision, or a child wearing the root's zero, costs one renumbered entity instead of an unloadable prefab.
A scene's own assets block still walks the entities inside its instances, even though the prefab lists them: an instance may override a Mesh or a Decal at an asset the prefab file never names, and only the scene's walk sees that.
A scene stores an instance as a PrefabInstance (the source path) plus the root's Transform and Hierarchy - where it sits and what it hangs off belong to the scene - and the saver skips the whole subtree beneath it. The loader expands it after the entity pass, so the roots keep their saved slots and the prefab's own entities take whatever is free - unless the caller says where they stood: loadFromString takes Prefab::instanceSlotsOf the world it was written from, which is how the editor's Stop puts every slot back for an undo history that addresses entities by slot. Editing the prefab therefore changes every instance the next time a scene loads, which is the point.
Prefab::save turns the subtree it wrote into an instance of the file, so the master copy is not a loose subtree the next scene save would inline. Saving an instance back over its own source is how a prefab is edited; its overrides are baked into the file and then cleared, because keeping them would pin that one instance to the values every other instance just adopted. Nesting is refused in both directions - a subtree containing an instance, and a root inside one.
What varies per instance is the root's Transform and any number of overrides: one field of one component of one entity in the subtree, stored on the root's PrefabInstance and written beside the reference as uid -> component -> field.
The entity half of that address is why the file carries a uid per entity and a nextUid high-water mark. Ids are runtime slots, array position moves when the prefab is re-saved, and Name is user-editable and not unique, so none of them survives an edit to the prefab; a uid is handed out once and never reused. PrefabEntity carries it at runtime, and a scene never writes it - a scene never writes a prefab's entities at all. nextUid is seeded from the file being overwritten, because the entity that held the highest number may be the one just deleted and only the file still remembers it.
The value is the field's own serialized JSON as text, and the merge happens on the document before loadComponents, not as a patch on a built component: the loaders construct a fresh component and assign, and several cannot be run twice.
Overrides are stored, never re-derived by diffing an instance against its file. load(save(x)) is not x here - an unresolvable asset name comes back as "" - so a diff would manufacture overrides out of load failures. When the prefab changes underneath an override (the uid, component, field or type is gone, or it addresses the root's Transform, which is the instance's own pose) the entry is kept, reported once, and not applied, so renaming a field and renaming it back does not lose the edit.
Script is refused the same way, and for a reason that is about the address rather than about drift: the component serializes as one field holding the whole behavior list, so the only override this format can spell replaces every behavior on the instance. The editor neither writes one nor shows one, so an applied one would be invisible and unrevertable. A hand-edited file that names it is reported and left alone - see scripting.md.
The type check walks the prefab's value and the override's together rather than comparing their top-level kinds, because an array of the right kind holding the wrong elements throws inside the component loader - the failure the check exists to prevent. Only a numeric array is length-checked: that is a fixed-width vector whose length is part of its type, where Collider::parts and LOD::levels are lists their loaders read at whatever length they find.
They are authored in the inspector: editing a component on an entity that belongs to an instance records the changed fields there and then (src/editor/command/prefab_overrides.h), each card marks the fields the instance owns, and Prefab::reloadComponent gives one back to the prefab.
Shaders are not assets and not part of the library - they are source files read CWD-relative from shaders/, so hot reload is a matter of noticing that one changed. Vkm::GL::reloadChangedShaders (modules/vkmGL/src/shader/gl_shader_reload.h) takes the newest write time under a directory and, when it moves, recompiles every live shader; a program that no longer compiles keeps its previous one and logs the error.
The editor drives it, polling once a second (EditorSystem::DISK_POLL_INTERVAL) and toasting what it reloaded. The same interval paces the other thing a build rewrites under a running editor - pollScriptRebuild stats the gameplay module