@forgeax/engine-pack
v0.3.11
Published
Disk schema, GUID tools, scanner, and fail-fast error chain for the ForgeaX asset package system. The unified ForgeaX asset command owns the public producer entry.
Readme
@forgeax/engine-pack
MaterialAsset 唯一成功路径
Pack 承载 paramSchema -> derive -> compile/reflect -> cook/load -> extract/record
中的 cook/load 边界:producer 先发布含 coordinateSet、transform、
physicalUvScale、artifact 与 layoutIdentity 的 receipt,再由 catalog 按 GUID
提供给 runtime。identity 或 receipt 过期时 inspect producer evidence,修复后
recook 并重新验证,不手改 cooked payload。
[!IMPORTANT] Pack 是发布与证据边界,不是第二份 schema;恢复动作依赖结构化状态与 receipt, 并沿 producer owner 回到源输入。
Authoring and recovery index
ScriptablePack 与 Pack authoring
A terrain ScriptablePack output supplies a validated TerrainSource. The terrain producer derives subsection meshes, height/control mips and Standard material arrays as one GUID-linked closure; the existing Pack cook, Catalog and runtime loader own publication. See the terrain contract for bounds and layer semantics.
[!IMPORTANT] 这里有两个一等 source 实体,且必须保持可见区别:
ScriptablePack是可执行的*.pack.tsTypeScript source;Pack是可编辑、可持久化的*.pack.jsonJSON source。参数化只是 ScriptablePack/Pack instance 的可选能力,不是第三种实体。 构建后的 Pack v2 是 transport 产物;runtime 只按 GUID 加载产物。
The current authoring contract keeps one Pack identity and derives every output
identity from the author-owned pair (packageId, sourceKey):
import { AssetGuid, definePack, definePackageId } from '@forgeax/engine/pack/source';
import { ok } from '@forgeax/engine/types';
const packageId = definePackageId('01900000-0000-7000-8000-000000000001');
export default definePack({
schemaVersion: '2.0.0',
packageId,
build: ({ packageId: subjectId }) =>
ok({
'mesh/main': { kind: 'mesh', materialSlots: [] },
'scene/hero': {
kind: 'scene',
entities: { body: { components: {} } },
},
'scene/main': {
kind: 'scene',
entities: {
root: { components: {} },
hero: {
components: {},
instance: {
source: AssetGuid.format(AssetGuid.derive(subjectId, 'scene/hero')),
overrides: [{ target: ['body'], components: {} }],
},
},
},
},
}),
});definePack omits parameters for the default zero-parameter path; its build
context then has packageId and readByGuid() only. A non-empty parameters
array opts into typed values, defaults, numeric bounds, enum choices, vectors,
colors, and asset-GUID kind constraints. The build return object is the whole
current output topology: there is no output manifest, output.add(),
externalAssets, or per-output GUID field.
Pack authoring (*.pack.json) uses schema 3.0.0. A direct document stores
assets[sourceKey]; an instance stores only its own packageId, parent, and
sparse values. These branches are mutually exclusive. Every instance has an
independent package identity, while the parent chain is resolved through the
current Source Index. The authoring form is lowered to the ordinary Pack v2
transport before runtime; player code still calls loadByGuid() and never
executes TypeScript or understands parameters.
@forgeax/engine-pack/build exposes the filesystem gateway
createFileSystemPackAuthoringGateway(). Its read operations are
asset.list, asset.inspect, asset.resolve, and asset.verify; mutations
are asset-source.create, asset-source.clone,
asset-source.create-instance, asset-source.apply-values,
asset-source.rebuild, and asset-source.cold-cook. Every operation carries a
caller-minted requestId; source mutations are confined to the game root,
atomic, and CAS-protected by expectedRevision.
| asset.inspect subject | Authored result |
|:--|:--|
| Pack source path | Source revision and output identities; no output payloads |
| Pack source path with sourceKey | The selected direct Pack output's payload, GUID and source revision |
| Pack instance path with sourceKey | The derived output GUID and inherited effectiveValues |
Consumers comparing a selected output with a live Catalog row must require the
returned GUID to match. A ScriptablePack source inspection does not execute
build to synthesize an authored payload.
AssetGuid.derive(packageId, sourceKey) is the browser-safe UUIDv5 projection.
PackageId and AssetGuid are distinct brands, and a derived identity alone
reports only identity; present and ready require current materialization
and validated Pack/artifact evidence respectively.
灯光 Pack/Catalog 入口
三条最短入口:
- scene Pack 只保存
RectAreaLight、SpotLight、LightProbe的 authored 字段,并把iesProfile/cookie写入refs。 IesProfileAsset由iesImporter在 build-time 生成 fixed cooked payload;SpotLight.cookie仍是普通TextureAsset。- runtime 按 GUID 读取 Catalog/Pack 记录;
current、accepted、lastKnownGood、verified分层检查,缺失时回到 producer 重建。
Pack 是 published 边界,不是 author authority、runtime registry 或 GPU owner。源文件与 Meta 保留 authored identity,DDC/Catalog 是可丢弃或可重建的 projection;sourceKey 只表达 producer 语义,不是 URL 替身。
Material contract index
Pack is the single material-cook and publication boundary. It publishes one
GUID-addressed receipt and artifact for the resolved contract; it does not
redefine paramSchema or mint runtime feature macros. Keep
materialContractDigest, sourceClosureDigest, layoutIdentity,
programIdentity, cookIdentity, and materialPublicationIdentity together
as layered identity. Inspect current versus generation, repair the owning
producer at the first divergence, cold-cook the same GUID, and verify receipt,
artifact, and provenance.
A raster ABI without sceneIndexEntry publishes a direct selection only. When
that entry exists, the validator requires both direct and scene-index selections
for the same pass/context/color inputs. A scene address without its matching ABI
entry is invalid; direct-only publications do not fabricate a paired program.
MaterialCookRasterContext.visibleSurface is an optional true capability
axis, orthogonal to pipeline: forward | deferred. Absence is the only off
representation. materialProgramContextForPass preserves it for color entries
and strips it for shadow/depth entries; the cooker and runtime share that
projection. It selects an additional output ABI from the same authored Surface,
not a second material definition.
MaterialCookRasterContext.geometry distinguishes sprite-instances from
ordinary mesh/sprite layouts. This is a derived program selection within
the same material GUID; it does not add a second authoring asset.
MaterialCookProgramContext is the closed union of raster and Surface-derivative
contexts. Derivatives use pipeline: ray, profile: forgeax-material-ray-v1 and
mesh/storage inputs: pass: ray-hit selects cs_surface, while pass: card-capture
selects vs_card with the fixed fs_card fragment entry. Both select an existing
authored pass without a raster address pair or submission ABI. Card cooking
validates its four rgba16float outputs. The executable validator and JSON schema
admit both contexts; raster-probe remains a build-time diagnostic. The complete
program-set digest covers both derivatives and raster programs. Neither derivative
satisfies the requirement to publish the authored raster pass.
Mesh binary boundary
Mesh artifacts use one canonical geometry projection and one strict wire
contract. The pack package owns the header facts and digest implementation in
src/mesh-bin-contract.ts; consumers must not
recreate that table.
| Fact | Owner | Recovery |
|:--|:--|:--|
| projection mask, schema version, stride, digest | @forgeax/engine-geometry + pack contract | Re-cook from source and Meta |
| vertex/index cardinality and payload bounds | build-time import producer | Repair source payload, then cold-cook |
| malformed or legacy mesh binary | strict runtime loader | Keep LKG; publish only a fully validated replacement |
[!WARNING] Producers emit v5; readers accept published v4 and v5. v2/v3 remain closed load errors. Runtime never re-cooks an artifact.
ScriptablePack in 30 seconds
The public happy path is: declare one stable packageId and return a complete
sourceKey -> Asset topology from definePack, run the Vite Pack producer,
publish Pack v2 plus the Catalog, then call AssetRegistry.loadByGuid(guid).
The durable consumer matrix is the exported
SCRIPTABLE_PACK_ASSET_KINDS list: mesh, material, scene, texture, equirect,
sampler, font, render-pipeline, tileset, video, skeleton, skin,
animation-clip, animation-graph, audio, particle-effect, and ies-profile.
Every successful row keeps its sourceKey identity, dependency refs, and
producer-owned artifacts. Audio keeps mediaType and bytes; particle effects
keep programFingerprint and the cooked program. Animation and tile consumers
turn a loaded payload into a World-owned shared reference at their own boundary.
For failure, switch on code, inspect expected, hint, and narrowed detail:
repair the definition or producer, rebuild/cold-cook, refresh the Catalog LKG,
or attach the missing Host capability. A missing audio/video/VFX capability is
an install/play/execute error, not a durable-load error. The machine contract
lives in pack.schema.json and
meta.schema.json; this section is guidance, not a
second manifest.
The machine contract is schemas/asset-authority.schema.json. The audit gate is check-asset-authority-audit.mjs; it reports subject, execution, author authority, runtime source, lifecycle, owner, producer, and sourceKey evidence for every named category.
The staged route is explicit: author-validation -> external-declaration ->
import -> native-cook -> ddc-validation -> runtime-parse ->
editor-capability. Each stage keeps its own evidence; a cache hit does not
replace author authority, and an Editor capability does not become a runtime
source.
| Need | Entry | Safe action |
|:--|:--|:--|
| Inspect one asset | forgeax asset inspect --subject <guid> --root <project> --json | Read structured evidence and diagnostics |
| Verify a result | forgeax asset verify --root <project> --json | Repair the producer or package, then retry |
| Rebuild a cooked result | Pack/import runner and the declared producer | Cold cook after discarding invalid DDC |
| Preview old output | Catalog evidence with explicit last-known-good state | Preview only; never publish it as current |
Pack or external source plus Meta owns author facts. DDC and Catalog are derived projections; they are not author databases or write authorities.
Engine builtin mesh descriptors
The Engine-owned primitive mesh identities are published from one UUIDv5 table
(@forgeax/engine-pack/builtin). A standalone DevKit build materializes only
the missing rows as an ordinary Pack v2 tuple; Geometry then derives the mesh
payload from the procedural-* token at load time.
| Descriptor | GUID source | Geometry token |
|:--|:--|:--|
| Cube | HANDLE_CUBE | procedural-cube |
| Triangle | HANDLE_TRIANGLE | procedural-triangle |
| Quad | HANDLE_QUAD | procedural-quad |
| Sphere | HANDLE_SPHERE | procedural-sphere |
| Nine-slice quad | HANDLE_NINESLICE_QUAD | procedural-nine-slice-quad |
| Cylinder | HANDLE_CYLINDER | procedural-cylinder |
This keeps the runtime registry generic while ensuring a packaged game can resolve legacy scene references without a second process-static asset owner.
[!IMPORTANT] The pack contract has one material authoring shape: a
MaterialAssetpayload. The cook stage resolves inheritance, values, texture coordinates, module references, artifact bytes, and a receipt into one record. Runtime consumers use the GUID and catalog locator; they do not author a second shader resource.
MaterialAsset cook contract
Put the material payload in the package assets[] row and keep its refs[] graph complete. The passes, values, parent, and texture coordinates remain one authored route. A valid cooked material record contains resolved, refs, artifact, and receipt. If any part is absent, recover by fixing the producer payload or re-running the cook; this is the recovery route. Then repeat lookup/verify --guid --project --catalog --json.
当 parameters 将字段声明为 texture 时,values 可保留纹理 GUID 字符串简写;省略
parameters 的默认材质也只对 MATERIAL_TEXTURE_SLOTS 中的纹理槽启用该简写。只有
sampler、强度或非默认坐标等附加元数据才使用结构化纹理对象。坐标字段的省略由运行时
统一解析为 identity,但 cook/load 不得把 identity 坐标自动展开并写回创作 payload。
[!IMPORTANT] The producer publishes facts; the consumer does not guess.
packageId,provenance,revision,sourceKey, relations, and diagnostics survive package moves and DDC relocation. Paths and array positions are locators, not identities.
Disk schema, GUID tools (AssetGuid brand + UUIDv7/v5), mesh-binary wire facts, and scanner fail-fast chain (13-member PackErrorCode) for the ForgeaX asset package system. The unified asset list, asset inspect, and asset verify commands call this owner in filesystem mode; they are offline and require no WS connection.
Package name vs directory: this package is published as
@forgeax/engine-packbut lives atpackages/packon disk. The@forgeax/engine-prefix is the IDE-autocomplete entrypoint AI users discover the package family by; the directory drops the prefix to keep tree depth flat (mirrors thepackages/runtime/@forgeax/engine-runtimepair). All other packages in the engine family follow the same convention.
AssetEvidence: the offline proof chain
Pack owns the offline half of the GUID evidence chain: source inventory -> catalog packageUrl/cookReceiptUrl -> producer CookReceipt -> Pack v2 package and artifact verification -> AssetEvidence.
The catalog is a locator, not proof. lookup/verify --guid --project --catalog --json joins the source meta or authored pack, the catalog row, the receipt, and the package descriptors. Both commands emit one JSON record on stdout or one structured {code, expected, hint, detail} record on stderr; there is no runtime or WebSocket dependency.
VFX Program v3 in Pack v2
VFX source v3 is cooked in one atomic producer step and stored in the ordinary
Pack v2 envelope. The executable contract is
scripts/asset-cook-contract.mjs; a package-local scripts path is not valid.
The cook publishes the Pack payload and
particle-effect/program.json with the same source fingerprint. Runtime loads
the GUID-indexed artifact and never compiles author WGSL.
When adding a Batch B renderer, update the source declaration, explicit material
or mesh refs, native-cook catalog, and receipt together. Re-run lookup and
verify; stale output is repaired by the owning importer or native cooker, not
by a demo-side mesh substitute.
notCooked means a source declaration has no successful receipt. ready/current means the receipt fingerprint matches the source; ready/stale means it does not. unknown is reserved for missing evidence. Artifact and package status remain notChecked, passed, or failed; a historical receipt never upgrades an unchecked package.
Build-time importers write source meta and producer receipts; the Vite plugin publishes the locator. Runtime packages consume the resulting Pack v2 bytes and must not import this Node-only evidence adapter. See packages/types/src/asset-evidence.ts for the exact schema and closed errors.
Browser runtime code uses the focused Pack subpaths instead of the Node-oriented root barrel. The root entry remains the build-time scanner/evidence surface.
| Runtime need | Browser-safe entry |
|:--|:--|
| Pack v2 validation and parsing | @forgeax/engine-pack/runtime |
| Artifact locator validation | @forgeax/engine-pack/artifact-path |
| Cooked material records | @forgeax/engine-pack/material-cook |
| Mesh wire facts | @forgeax/engine-pack/mesh-bin-contract |
Quick start
ScriptablePack source (*.pack.ts)
Use @forgeax/engine-pack/source when one trusted TypeScript source declares
and builds a multi-asset package. The definition owns one stable packageId;
each build returns ordinary typed Assets keyed by author-owned sourceKey.
The Pack boundary derives every output GUID as
AssetGuid.derive(packageId, sourceKey), so the source does not contain a
per-output GUID table, externalAssets, or an output writer.
import { AssetGuid, definePackageId, definePack } from '@forgeax/engine/pack/source';
import { ok } from '@forgeax/engine/types';
const packageId = definePackageId('01900000-0000-7000-8000-000000000001');
export default definePack({
schemaVersion: '2.0.0',
packageId,
build: ({ packageId: subjectId }) =>
ok({
'mesh/main': { kind: 'mesh', payload: {}, refs: [] },
'scene/main': {
kind: 'scene',
payload: { mesh: AssetGuid.derive(subjectId, 'mesh/main') },
refs: [],
},
}),
});Omitting parameters selects the zero-parameter path; a non-empty
parameters array adds typed values, defaults, ranges, enum choices,
vectors, colors, and asset-GUID constraints. The isolated Pack worker passes
only serializable authoring data to the build and never exposes filesystem or
runtime state.
Custom build-only content uses PackCookSource in the same output map:
{ 'volume/main': { kind: 'game-volume', execution: 'cooked', source: { size: values.size } } }Register the matching NativeCooker in the build host. The host supplies the
derived GUID, source key/path and source data; the cooker returns matching-kind
metadata, references and artifact bytes. Missing cookers, rewritten GUIDs and
invalid products fail before publication. Custom sources can coexist with
ordinary Assets and inherit normal Pack parameters and instance identities.
They cannot override the closed ordinary Asset kinds. Authoring readByGuid
can explicitly request a PackCookSource from the current build generation;
that is source data, not a decoded runtime asset. Runtime custom kinds still
need their normal Registry loader/decoder. No cooker enters the player.
forgeax asset inspect --subject <source.pack.ts> --root <project> --json executes module initialization, validates the default export, and projects canonical Meta without calling build. @forgeax/engine-pack/source-node accepts a host executor with load and optional dispose; timeoutMs bounds module initialization and buildTimeoutMs bounds one build(context) call. A build timeout returns one structured pack-source-load-failed Result with detail.phase: 'build', the configured timeoutMs, and deterministic cleanup of the isolated worker and compile root.
The default worker executes the complete relative TypeScript module closure on the supported Node floor, including Node 22 hosts that do not load .ts files directly. It transpiles that closure into a disposable ESM directory, resolves bare imports through the source project's nearest node_modules, and removes the directory when the worker is disposed. Bulk producers can use the internal createScriptablePackModuleExecutorPool() with two recyclable workers; a pooled lease is released after metadata projection or one build.
Path-only scans and explicit metadataOnly inventories read at most four ScriptablePack modules concurrently, each in its own isolated worker. They settle all started reads before reporting failure and publish declarations in source order. Inventories retaining build closures and scans using a caller-owned executor remain serial; metadata parallelism never shares or recycles those live leases.
| Standalone project stage | Worker ownership | |:--|:--| | Inventory | Metadata workers release before the inventory returns | | Plugin bootstrap | One recyclable execution lease, acquired when each source or instance builds | | Formal production | One recyclable execution lease per worklist attempt; the pool closes on success or failure |
createLazyScriptablePackDefinition() reloads from the inventory's captured
ScriptablePackSourceSnapshot, preserving its module bytes, parameters and source
evidence without keeping an idle worker per declaration. It retains the default
module/build deadlines, package identity fence and reader context. Later author
edits do not replace the captured body; existing publication source fences still
own whether the candidate can publish. The lazy path adds no per-build disk
verification. A direct producer lacking the inventory snapshot captures and
compares its source closure before executing, so a new body cannot use old evidence.
Caller-supplied metadata inventories must carry their captured snapshot;
legacy inventories without a snapshot continue to consume their retained definitions.
inventoryScriptablePackSource(path, initialSourceText?, resolveImport?) inventories
bytes without evaluation. Its default resolver follows these authoring rules;
runtime delivery may inject its bundler resolver for each literal dependency,
so published JavaScript keeps its actual file and package resolution semantics.
createScriptablePackSourceSnapshot() shares file bytes, content digests and
resolution observations within one build. Pass it through scanner/loader
sourceSnapshot options or the inventory helper's fourth argument. Captured
relative modules execute from those bytes in the standard Worker. moduleSources()
returns the captured canonical entry together with its sources; the loader uses
that entry for relative resolution, including a symlinked project path. Alias changes
remain publication conflicts rather than a second module identity. Bare packages
remain the host's pinned dependency context. verify() rereads contents and
resolution probes, including newly preferred candidates and symlink changes.
The build owner must verify again immediately before publication and discard a
stale candidate. A fresh build creates a fresh snapshot; only content-keyed
syntax parsing survives across generations. No caller maintains an invalidation list.
[!IMPORTANT] Source authoring is an explicit Node contract. Every gateway call carries a caller-minted
requestId; mutations may carryexpectedRevisionfor SHA-256 CAS. The filesystem port confines paths to one game root and only writes constrained generated source or structured v3 JSON. Arbitrary valid TypeScript remains inspectable and rebuildable, but unsupported structural edits fail closed with stablepack-source-*errors.
PACK_AUTHORING_OPERATION_DESCRIPTORS is the producer-owned structured capability manifest for tools. The existing asset.preflight operation returns the current revision, canonical Meta, incoming references, and shape-derived mutation capabilities before any write.
import { AssetGuid } from "@forgeax/engine/pack/guid";
import { scan, scanInventory } from "@forgeax/engine/pack/scanner";
// Runtime: resolve a known GUID at build time
const result = AssetGuid.parse("cbe42beb-8975-5096-b3a1-3dda4cb4c077");
if (!result.ok) throw result.error; // PackError with .code 'pack-guid-malformed'
const guid = result.value;
// Build / CI: validate an asset directory
const scanResult = await scan(["apps/hello/cube/assets"]);
if (!scanResult.ok) throw scanResult.error; // PackError with .code/.hint/.detail
console.log(scanResult.value); // PackEntry[]
// Build hosts can retain one validated inventory and project paths from it.
const inventory = await scanInventory(["apps/hello/cube/assets"]);
if (!inventory.ok) throw inventory.error;
console.log(inventory.value.paths, inventory.value.declarations);Schema shapes
External imports and authored Packs have different identity owners. An external
source keeps importer-owned sub-asset GUIDs in its .meta.json; an authored
Pack keeps one packageId and names outputs with sourceKey.
.meta.json -- external-asset-package
{
"schemaVersion": "1.0.0",
"kind": "external-asset-package",
"importer": "gltf",
"source": "<source-filename>",
"importSettings": {},
"subAssets": [
{ "guid": "<UUIDv7-or-UUIDv5>", "sourceIndex": 0, "kind": "mesh", "compression": "zstd" }
]
}[!NOTE]
compression?: 'none' | 'zstd'onPackIndexEntry(andsubAssets[].compression) indicates whether the asset's.binis zstd-compressed. See@forgeax/engine-codecREADME for the full codec API and error codes. RuntimefetchBinarytransparently decompresses when this field is'zstd'.Declaring compression intent (AC-01): set
importSettings.compression('none' | 'zstd') to override the build-time default strategy for this asset (default: mesh ->zstd, texture ->none;.pack.jsonnever compressed). The importer honors the override and writes the resultingcompressiononto the output catalog row. Omit it to accept the kind-keyed default.
.pack.json -- authored Pack v3
Direct authoring names its outputs by sourceKey; it never stores output GUIDs:
{
"schemaVersion": "3.0.0",
"packageId": "01900000-0000-7000-8000-000000000010",
"assets": {
"mesh/main": { "kind": "mesh", "payload": {}, "refs": [] }
}
}An instance uses the same extension and schema version, but has only its own identity, parent, and sparse parameter values:
{
"schemaVersion": "3.0.0",
"packageId": "01900000-0000-7000-8000-000000000011",
"parent": "01900000-0000-7000-8000-000000000010",
"values": { "count": 4 }
}The two v3 branches are mutually exclusive. The build lowers them to the
runtime Pack v2 projection, whose rows contain derived GUIDs and kind:
"internal-text-package"; runtime still loads only that cooked projection.
Producer provenance and topology
Authored and cooked Pack projections carry producer-owned packageId,
provenance, revision, and structured diagnostics. Asset/output rows may
declare a stable sourceKey; sourceIndex is positional evidence only. The
runtime function diffTopology(previous, next) preserves GUIDs by sourceKey,
reports additions, removals, and kind changes, and marks multi-output
source-index-only matching as ambiguous.
| Fact | Published by | Consumer rule |
|:--|:--|:--|
| packageId | Producer declaration | Keep stable across path or URL relocation |
| provenance | Producer declaration | Copy without replacing it with importer guesses |
| revision | Producer/DDC | Use to detect stale updates; do not silently overwrite a verified snapshot |
| sourceKey | Imported-output producer | Match topology only when the producer supplies a stable semantic key |
| sourceIndex | Imported-output producer | Display or inspect position; never use it as identity |
| relations / diagnostics | Producer facts | Preserve the complete structured values |
[!WARNING] A missing
sourceKeyin a multi-output declaration is evidence insufficiency, not permission to hash a path or promotesourceIndex. Keep the result ambiguous and expose the repair hint.
The DDC path is also lossless: the import runner copies package-level facts to
the .pack.json envelope and copies each declared output's sourceKey,
sourceIndex, and relations onto the matching GUID row. The catalog builder
then projects those rows into PackIndexEntry; it does not create a second
authoritative fact store.
MaterialAsset shape -- pass-based material in .pack.json
When kind: 'material', the payload object carries passes[] + values (feat-20260527-material-registration-unification M3).
{
"guid": "<UUIDv7>",
"kind": "material",
"payload": {
"kind": "material",
"passes": [
{
"name": "Forward",
"program": { "module": "forgeax::default-standard-pbr" }
}
],
"values": {
"baseColor": [1.0, 0.8, 0.2],
"metallic": 0.3
}
},
"refs": []
}MaterialAsset fields in pack.json:
| Field | Type | Description |
|:--|:--|:--|
| passes | MaterialPass[] | Root pass descriptors; each pass names a program.module. |
| parameters | MaterialParameter[] | Root parameter contract used by cook and reflection. |
| values | Record<string, unknown> | Child-owned values, including per-slot texture coordinates. |
| parent | AssetGuid | Optional serialized parent edge. |
Validation is performed by the material schema and build-time cook. The cook resolves the parent chain, validates values and pass programs, and emits a material-cook/4 record, artifact, references, and receipt. The receipt keeps material, source, layout, program, pipeline, publication, and cook identities together with compiler/WASM provenance and generation counters. Runtime reports a structured missing-cook error instead of compiling a material; stale generations are recooked by the producer.
AssetGuid API
| Function | Signature | Description |
|:--|:--|:--|
| AssetGuid.parse | (input: string) => Result<AssetGuid, PackError> | Parse dash-separated UUID string; returns Ok(AssetGuid) on success or Err(PackError{code:'pack-guid-malformed'}) on failure. Never throws. |
| AssetGuid.format | (guid: AssetGuid) => string | Format as lowercase dash-separated UUID string |
| AssetGuid.equals | (a: AssetGuid, b: AssetGuid) => boolean | Constant-time equality |
| AssetGuid.random | () => AssetGuid | Generate a random UUIDv7 GUID |
| deriveBuiltin | (name: string) => AssetGuid | Derive a deterministic UUIDv5 synchronously from the exact UTF-8 name within the ForgeaX namespace; shares the internal SHA-1 kernel with AssetGuid.derive |
PackErrorCode -- 13-member closed union
Exhaustive switch (err.code) without default -- TS guards completeness.
| Code | err.detail shape |
|:--|:--|
| pack-malformed-meta | { path: string; ajvErrors: string[] } |
| pack-malformed-pack | { path: string; ajvErrors: string[] } |
| pack-guid-malformed | { raw: string; reason: string } |
| pack-orphan-meta | { metaPath: string; expectedFile: string } |
| pack-meta-missing | { sourcePath: string; expectedMetaPath: string } |
| pack-guid-collision | { guid: string; paths: [string, string] } |
| pack-cyclic-reference | { code; kind: 'childof' \| 'mount-asset'; cycle: string[] } -- first and last element repeated; kind distinguishes runtime ChildOf cycle from build-time mount-asset cycle (R10) |
| pack-subasset-index-out-of-range | { metaPath: string; sourceIndex: number; maxIndex: number } |
| payload-schema-mismatch | { guid: string; errors: { instancePath: string; message: string }[] } -- material payload failed buildMaterialAssetValidator check (scanner step-7) |
| pack-mount-localid-overlap | { overlapping: number[]; sources: string[] } -- mount memberFirst windows collide |
| pack-mount-count-mismatch | { mountLocalId; declared; actual } -- mount.memberCount disagrees with referenced child SceneAsset.entities.length |
| pack-mount-override-localid-out-of-range | { overrideLocalId; mountLocalId; memberCount } -- override.localId outside mount window |
| pack-mount-override-unknown-field | { comp; field; mountLocalId } -- override.comp / override.field unknown to schema vocab |
Access err.detail.<field> directly after narrowing via switch (err.code) -- full IDE autocomplete.
Unified asset commands
DevKit loads the Pack producer behind the unified forgeax asset command;
this package does not publish an independent executable.
| Subcommand | Description | Exit code |
|:--|:--|:--|
| forgeax asset list --root <project> --json | Print a bounded page (default limit=100, maximum 256); add --type and --cursor to continue | 0 on valid roots |
| forgeax asset inspect --subject <guid> --root <project> --json | Print matching asset evidence | 0 found / 1 not found |
| forgeax asset verify --root <project> --json | Run the fail-fast scanner and return a bounded asset-verification-v1 source/output report | 0 clean / 1 error |
# Direct invocation (after pnpm -F @forgeax/engine-pack build)
forgeax asset list --root ./game --json
forgeax asset inspect --subject 01935f3b-aaaa-7000-8000-000000000001 --root ./game --json
forgeax asset verify --root ./game --jsonScanner 7-step validation chain
The verify subcommand runs a fail-fast 7-step chain:
| Step | Check | Error code on failure |
|:--|:--|:--|
| 1 | Schema validation (.meta.json / .pack.json ajv) | pack-malformed-meta / pack-malformed-pack |
| 2 | GUID format check (UUIDv5/v7 dash format) | pack-guid-malformed |
| 3 | GUID collision detection (cross-file duplicate) | pack-guid-collision |
| 4 | Orphan .meta.json check (.meta.json without source file) | pack-orphan-meta |
| 5 | Missing .meta.json check (source file without sidecar) | pack-meta-missing |
| 6 | Subasset index bounds check (.meta.json subAssets[].sourceIndex) | pack-subasset-index-out-of-range |
| 7 | Material payload schema check (buildMaterialAssetValidator(MATERIAL_PARAM_TYPES_V1) for kind: 'material') | payload-schema-mismatch |
Mesh-binary artifact contract
MESH_BIN_VERSION and MESH_BIN_HEADER_BYTES are the Pack-owned facts for
the v5 <guid>.bin mesh artifact. The header carries the geometry projection
version, attribute mask, stride, digest, cardinality, and payload byte lengths;
the import encoder and assets-runtime decoder consume those facts from the
package root. Independent test fixtures may keep literal header values only as
wire-format oracles, never as a second layout table.
The 80-byte header stores morphBytes at offset 40; offset 44 remains zero.
Payload order is header, interleaved vertices, morph streams, indices, JSON.
V5 metadata stores morphTargetMasks (one to eight targets): bits 1/2/4 select
position/normal/tangent with 3/3/4 little-endian float32 components per vertex.
Bits 8/16/32 mark present channels containing only positive zero; those lanes
occupy no bytes and decode to private zero arrays. Negative zero remains in the
binary stream. Streams are target-major, then position/normal/tangent; offsets
derive from cardinality and the mask. Morph weights remain small JSON metadata. Readers reject unknown
masks, inconsistent sizes, and non-finite morph values. V4 keeps its existing
inline numeric morph metadata and has no morph byte section.
[!WARNING] Producers emit v5 and readers retain v4 support. A v2/v3 artifact is a closed load error, not a compatibility input. Preserve the last-known-good catalog row, repair the source plus its
.meta.jsonsidecar, then re-run the owning build-time importer/native cooker andlookup/verify --guid --project --catalog --jsonbefore publishing the replacement.
Owner product and inventory contract
scanInventory(...) is the Pack-owned source inventory boundary. It preserves
GUID identity, source revision, source key, and source index before importer
work begins.
finalizePackageProduct(...) accepts the terminal producer product, validates
one receipt per GUID, validates asset-local artifact paths and bytes, and emits
the deterministic package URL and digest. Producers provide policy through the
finalizer sink; Pack owns package serialization and publication facts.
Transport finalization reuses each artifact's content hash for the source revision and integrity descriptor within that invocation. The package digest still hashes its canonical metadata and binary stream. No cache survives finalization: changing input bytes before the next call must change the appropriate digests. All existing digest and file formats are preserved; callers need no caching policy. Canonical serialization retains portable scalar arrays instead of cloning large mesh arrays. Objects within arrays still sort recursively, typed byte values keep their existing encoding, and neither input order nor payload values are mutated. Transport finalization returns the Pack document and artifact bytes without retaining an unused serialized document. A supplied sink receives canonical publication bytes; build producers that emit the document themselves omit the sink rather than serialize and discard a second copy.
Source inventory reuses only TypeScript import syntax by the freshly computed source-content digest. Its internal LRU is bounded by 512 entries and 16 MiB of source bytes. Every inventory still reads and hashes files and resolves imports again, so equal-size edits, new resolution candidates, and custom resolvers stay authoritative. No timestamp-based freshness or caller invalidation is introduced.
Mesh LOD relation closure
LOD is a relation inside the root mesh payload, not an independent runtime
asset kind. A valid Pack row contains the lower MeshAsset GUID in both the
payload and root refs[], and the lower row is present in the same package:
{
"kind": "mesh",
"payload": { "lods": [{ "mesh": "<lod-guid>", "screenCoverage": 0.5 }] },
"refs": ["<lod-guid>"]
}The finalizer preserves this relation while sorting rows deterministically. Missing closure, duplicate GUIDs, invalid artifacts, or incomplete receipts fail before publication; repair the producer and retry the same source Meta.
Runtime program artifacts
@forgeax/engine/pack/runtime validates and links native JS ESM closures through
preparePackProgram, verifyPackProgram, linkPackProgram and loadPackProgram.
Preparation checks source bytes and literal module imports without executing
implementation modules. A program records an entry, selected export, module
sources, fixed Host import identities and a content digest. TypeScript conversion
belongs to the DevKit producer, before this compiler-free contract.
Browser HTTP module delivery uses the separate @forgeax/engine/pack/runtime-browser
entry (prepareBrowserPackProgramScope, createBrowserPackProgramHost, and
PACK_PROGRAM_WORKER_SOURCE). The general runtime entry remains usable by source
consumers without DOM globals.
FixedPackPublication keeps complete Pack/Catalog/receipt evidence and original
artifact bytes. PackBlob writes canonical base64 strings and reads both those
strings and previously saved numeric byte arrays. encodePackBlob / decodePackBlob
are the shared JSON boundary; artifact format, compression and integrity are unchanged.
Its optional executions[target] groups portable programs and
explicit tool contracts; each plugin must be covered by at least one prepared
target. A contract's GUID grants membership in that group, even with no commands.
Same GUIDs and program keys may have different artifacts across targets. Missing
coverage, unknown targets and mismatched tool realms are invalid archives.
packProgramModuleIdentity derives native module URLs from the complete module
graph and its declared Host bindings. Selecting another entry/export or adding
an unrelated Host capability preserves shared module state. Changing module
bytes or a declared binding creates a different module identity. The artifact
digest still covers the selected entry/export for integrity checking.
For browser delivery, the page calls prepareBrowserPackProgramScope(workerUrl)
and passes the returned absolute application scope to
createBrowserPackProgramHost(scope). The latter works in either main or a
Dedicated Worker and writes the complete linked closure before returning its
native entry URL. Registration and page control have one bounded preparation
period; failed or late preparation cannot claim success. Host imports must use
the current realm's exact modules so component tokens and Cordis retain identity.
A Fiber disposal withdraws effects, not ESM modules; CacheStorage is not the
unique copy of authored program content.
Entry subpaths
| Subpath | Exports |
|:--|:--|
| . | Re-exports from all subpaths |
| ./schema | Compiled ajv validators for .meta.json and .pack.json |
| ./guid | AssetGuid and PackageId brands; UUID parse/format utilities and synchronous AssetGuid.derive(packageId, sourceKey) |
| ./builtin | ForgeaX namespace, synchronous deriveBuiltin(name), builtin GUIDs and mesh descriptors |
| ./errors | PackError class + PackErrorCode closed union + PackErrorDetail discriminated union |
| ./bridge | AssetRegistry GUID bridge helpers |
| ./scanner | File tree scanner with fail-fast 7-step validation chain |
Plugin authoring and source transfer
kind: 'plugin' is a Pack output. A TypeScript source returns { kind, module, config?, toolContract? }; a direct JSON Pack places these fields beneath its existing payload. Module locators are literal relative paths or declared npm specifiers with an optional explicit export (default is default). Same-file Pack plugins require named exports.
| Input | Published definition |
|:--|:--|
| packageId + sourceKey | Stable GUID |
| module specifier + export | Portable project:...#export or npm:...#export program key |
| JSON configuration | Fresh configuration per native installation |
| toolContract module reference or inline ToolCommandContract | Pure tool declarations with executor references lowered to existing program keys |
| { "$asset": "canonical-guid" } | GUID string and producer-derived dependency reference |
Plugin references are derived, never hand-authored in refs. Configuration rejects non-finite numbers, nested undefined, functions, class instances and cycles before JSON transport. The asset decoder defers reference loading so a plugin can install a loader before consuming that loader's content.
Inline tool declarations use the same contract as a referenced declaration module.
Their executor paths are relative to the declaring Pack; a referenced contract's
executors remain relative to that module. lowerPluginToolContract validates and
copies declaration data, resolves executor/export pairs, and removes exportName
after the program key selects it. It never evaluates a module. Runtime-authored
plugins use inline declarations; module-reference contracts require producer preparation.
The build subpath exposes collectAuthorPackClosure: author Pack, statically declared modules/resources, Meta sidecars (including the required .ui.html / .ui.css pair) and referenced output owners, plus project WGSL module identities and transitive imports, with file digests and declared npm dependencies. Computed source-discoverable outputs are evaluated by the existing isolated Pack executor. A source that requires cooked reads is rejected for transfer with its owning path. The Pack authoring gateway delegates import/clone to DevKit's project transaction, which validates the dependency lock, complete candidate source closure, and candidate compilation. File-only cloning has been removed.
Runtime binary data capture
copyPackData captures lossless metadata and owned binary views without invoking
accessors or caller serialization hooks. SharedArrayBuffer storage and typed-array
subclasses are rejected. Views retain byte offsets, lengths and backing aliases;
unreferenced backing bytes are cleared. Runtime publication uses this same capture
contract at the consuming owner boundary. Encoded Pack artifacts remain the portable
storage and transport representation, with real integrity descriptors produced only
when their bytes exist. See Import's runtime-pack-source/2 snapshot contract for
portable source archives.
Material publication uniqueness and direct/scene-index completeness include the color input derived from each ABI receipt. Plain and colored programs may share a Pass/context, but each color choice must retain its own complete address pair.
The navigation-mesh Scriptable Pack kind carries portable baked navigation
polygons. Its explicit build-time producer is @forgeax/engine/import/navigation-bake;
ordinary Cook/Catalog delivery preserves the GUID and producer freshness digest.
Player loading carries no Recast compiler dependency.
Deferred artifact delivery
An artifact may declare delivery: 'stream'. Finalization retains it and includes it in transport/publication identity; it still emits the complete artifact as part of the build closure. The consuming domain validates its manifest and each fetched window. Pack owns no native decoder or request scheduler. Audio streaming requires Range-capable HTTP hosting.
Immutable build catalog transport
pack-index.json keeps the legacy array when its rows have no repeated complete
publication. Repeated source envelopes use pack-index-publications/1: entries
carry a publicationIndex into publications. encodeCatalogWire preserves every
row and every field of the complete envelope; decodeCatalogWire restores the
ordinary Catalog rows before existing identity, revision and scene-fence checks.
Different receipts, output sets or source revisions remain separate table entries.
The decoder returns Result<readonly CatalogEntry[], AssetError>; invalid
references, inline conflicts and unused table entries return the existing
asset-parse-failed code with the failing field and index.
Scoped live snapshots retain their existing schema and publication authority.
Use the public decoder when inspecting emitted build catalogs; the compact wire
is a transport projection, and does not grant asset readiness or skip artifact
integrity checks.
