From ceba5a3477f50349a1c7325cb07682d3430d9588 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Fri, 10 Jul 2026 17:07:31 +0800 Subject: [PATCH 1/8] feat(loader): support constructed values and nested calls --- .../resources/parser/ReflectionParser.ts | 58 ++++-- packages/loader/src/schema/CommonSchema.ts | 14 ++ tests/src/loader/SceneFormatV2.test.ts | 165 ++++++++++++++++++ 3 files changed, 222 insertions(+), 15 deletions(-) diff --git a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts index 2e038238ce..b815b3235f 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts @@ -40,13 +40,21 @@ export class ReflectionParser { for (let i = 0, n = calls.length; i < n; i++) { const call = calls[i]; chain = chain.then(() => { - const method = instance?.[call.method]; + let target = instance; + if (call.target !== undefined) { + if (!Array.isArray(call.target) || call.target.some((key) => typeof key !== "string" || key.length === 0)) { + return Promise.reject(new Error(`Call "${call.method}" target must be an array of non-empty strings`)); + } + for (const key of call.target) target = target?.[key]; + } + const method = target?.[call.method]; if (typeof method !== "function") { - return Promise.reject(new Error(`Call target does not have method "${call.method}"`)); + const path = call.target?.length ? `${call.target.join(".")}.` : ""; + return Promise.reject(new Error(`Call target does not have method "${path}${call.method}"`)); } return Promise.all((call.args ?? []).map((arg) => this._resolveValue(arg))) - .then((resolvedArgs) => Promise.resolve(method.apply(instance, resolvedArgs))) + .then((resolvedArgs) => Promise.resolve(method.apply(target, resolvedArgs))) .then((result) => { if (!call.result) return result; if (result == null || (typeof result !== "object" && typeof result !== "function")) { @@ -63,11 +71,13 @@ export class ReflectionParser { } /** - * Apply props and calls from the same mutation block without imposing ordering between them. + * Apply props before executing calls from the same mutation block. */ parseMutationBlock(target: any, block?: MutationBlock): Promise { if (!block) return Promise.resolve(target); - return Promise.all([this.parseProps(target, block.props), this.parseCalls(target, block.calls)]).then(() => target); + return this.parseProps(target, block.props) + .then(() => this.parseCalls(target, block.calls)) + .then(() => target); } /** @@ -77,12 +87,13 @@ export class ReflectionParser { * 1. null/undefined/primitive → passthrough * 2. Array → recurse each element * 3. { $ref } → asset reference - * 4. { $type } → polymorphic type construct - * 5. { $class } → registered class constructor - * 6. { $entity } → entity reference by path (flat index + optional children descent) - * 7. { $component } → component reference - * 8. { $signal } → signal binding - * 9. plain object → recurse values (modify originValue in place if exists) + * 4. { $number } → JSON-safe special number + * 5. { $type, $args? } → polymorphic type construct + * 6. { $class } → registered class constructor + * 7. { $entity } → entity reference by path (flat index + optional children descent) + * 8. { $component } → component reference + * 9. { $signal } → signal binding + * 10. plain object → recurse values (modify originValue in place if exists) */ private _resolveValue(value: unknown, originValue?: any): Promise { if (value == null || typeof value !== "object") return Promise.resolve(value); @@ -109,12 +120,25 @@ export class ReflectionParser { }); } - // $type — polymorphic type: construct instance and apply remaining props + if ("$number" in obj) { + if (Object.keys(obj).length !== 1 || obj.$number !== "Infinity") { + return Promise.reject(new Error('$number must be exactly "Infinity"')); + } + return Promise.resolve(Infinity); + } + + // $type — polymorphic type: resolve constructor args, construct instance, then apply remaining props if ("$type" in obj) { - const { $type, ...rest } = obj; + const { $type, $args, ...rest } = obj; + if ($args !== undefined && !Array.isArray($args)) { + return Promise.reject(new Error("$args must be an array when used with $type")); + } + const constructorArgs = Array.isArray($args) ? $args : []; return this._resolveRegisteredClass($type, "$type").then((Class) => { - const instance = new Class(); - return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; + return Promise.all(constructorArgs.map((arg) => this._resolveValue(arg))).then((args) => { + const instance = new Class(...args); + return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; + }); }); } @@ -138,6 +162,10 @@ export class ReflectionParser { return this._resolveSignal(originValue, obj.$signal as SignalListener[]); } + if ("$args" in obj) { + return Promise.reject(new Error("$args requires $type")); + } + // Plain object — recurse each value, modifying originValue in place or building a new object const target = originValue && typeof originValue === "object" && !Array.isArray(originValue) diff --git a/packages/loader/src/schema/CommonSchema.ts b/packages/loader/src/schema/CommonSchema.ts index ae61dc8ce4..83fb55c3c0 100644 --- a/packages/loader/src/schema/CommonSchema.ts +++ b/packages/loader/src/schema/CommonSchema.ts @@ -20,6 +20,18 @@ export interface ClassRef { $class: string; } +/** Registered runtime value with optional recursively-resolved constructor arguments. */ +export interface TypeValue { + $type: string; + $args?: unknown[]; + [key: string]: unknown; +} + +/** JSON-safe encoding for positive infinity. */ +export interface SpecialNumberValue { + $number: "Infinity"; +} + export interface SignalListener { target: { $component: ComponentRef }; methodName: string; @@ -27,6 +39,8 @@ export interface SignalListener { } export interface CallSpec { + /** Optional property path from the mutation root to the method owner. */ + target?: string[]; method: string; args?: unknown[]; result?: MutationBlock; diff --git a/tests/src/loader/SceneFormatV2.test.ts b/tests/src/loader/SceneFormatV2.test.ts index 95cdc3a693..c809c9e797 100644 --- a/tests/src/loader/SceneFormatV2.test.ts +++ b/tests/src/loader/SceneFormatV2.test.ts @@ -8,6 +8,9 @@ import { Entity, FogMode, Loader, + ParticleCurve, + ParticleCurveMode, + ParticleRenderer, PostProcess, Scene, Script, @@ -37,10 +40,26 @@ class TestValueType { } Loader.registerClass("TestValueType", TestValueType); +class ConstructorValueType { + label = ""; + + constructor( + readonly seed: unknown, + readonly child: TestValueType + ) {} +} +Loader.registerClass("ConstructorValueType", ConstructorValueType); + class CallOrderComponent extends Script { value = ""; receivedArgs: any[] = []; lastResult: any = null; + nested = { + value: "", + setValue(value: string) { + this.value = value; + } + }; appendSuffix(suffix: string): void { this.value += suffix; @@ -50,6 +69,10 @@ class CallOrderComponent extends Script { this.receivedArgs = args; } + captureCurrentValue(): void { + this.receivedArgs = [this.value]; + } + setBaseDelayed(base: string): Promise { return Promise.resolve().then(() => { this.value = base; @@ -331,6 +354,96 @@ describe("ReflectionParser calls resolution", () => { expect(target.lastResult.y).to.equal(2); }); + it("should invoke methods on a nested target path", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target = new CallOrderComponent(new Entity(engine, "host")); + + await parser.parseCalls(target, [{ target: ["nested"], method: "setValue", args: ["ready"] }]); + + expect(target.nested.value).to.equal("ready"); + }); + + it("should finish resolving props before executing calls", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + let resolveValue: (value: string) => void; + const valuePromise = new Promise((resolve) => { + resolveValue = resolve; + }); + const getResourceByRef = vi + .spyOn(engine.resourceManager as any, "getResourceByRef") + .mockReturnValue(valuePromise as any); + const parser = new ReflectionParser(context, [{ url: "delayed-value" }]); + const target = new CallOrderComponent(new Entity(engine, "host")); + + try { + const parsing = parser.parseMutationBlock(target, { + props: { value: { $ref: 0 } }, + calls: [{ method: "captureCurrentValue" }] + }); + await Promise.resolve(); + resolveValue!("ready"); + await parsing; + } finally { + getResourceByRef.mockRestore(); + } + + expect(target.receivedArgs).to.deep.equal(["ready"]); + }); + + it("should restore particle curves and bursts through existing public APIs", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const entity = new Entity(engine, "particle"); + const renderer = entity.addComponent(ParticleRenderer); + + await parser.parseMutationBlock(renderer, { + props: { + generator: { + main: { + startDelay: { + mode: ParticleCurveMode.Curve, + curveMax: { + $type: "ParticleCurve", + $args: [ + { $type: "CurveKey", $args: [0, 1] }, + { $type: "CurveKey", $args: [1, 2] } + ] + } + } + } + } + }, + calls: [ + { target: ["generator", "emission"], method: "clearBurst" }, + { + target: ["generator", "emission"], + method: "addBurst", + args: [ + { + $type: "Burst", + $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, 2, 0.1] + } + ] + } + ] + }); + + const curve = renderer.generator.main.startDelay.curve; + expect(curve).to.be.instanceOf(ParticleCurve); + expect(curve.keys.map(({ time, value }) => [time, value])).to.deep.equal([ + [0, 1], + [1, 2] + ]); + expect(renderer.generator.emission.bursts).to.have.length(1); + expect(renderer.generator.emission.bursts[0].time).to.equal(0.5); + expect(renderer.generator.emission.bursts[0].count.constant).to.equal(8); + entity.destroy(); + }); + it("should await each call before executing the next one", async () => { const scene = new Scene(engine); const context = new ParserContext(engine, ParserType.Scene, scene); @@ -799,6 +912,58 @@ describe("ReflectionParser $type resolution", () => { expect(target.value.y).to.equal(0); }); + it("should recursively resolve $args before construction and then apply props", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target: any = {}; + await parser.parseProps(target, { + value: { + $type: "ConstructorValueType", + $args: ["seed", { $type: "TestValueType", x: 3, y: 4 }], + label: "ready" + } + }); + expect(target.value).to.be.instanceOf(ConstructorValueType); + expect(target.value.seed).to.equal("seed"); + expect(target.value.child).to.be.instanceOf(TestValueType); + expect(target.value.child).to.deep.include({ x: 3, y: 4 }); + expect(target.value.label).to.equal("ready"); + }); + + it("should resolve JSON-safe positive infinity values", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target: any = {}; + + await parser.parseProps(target, { + value: { + $type: "ConstructorValueType", + $args: [{ $number: "Infinity" }, { $type: "TestValueType" }] + } + }); + + expect(target.value.seed).to.equal(Infinity); + await expect(parser.parseProps({}, { value: { $number: "NaN" } })).rejects.toThrow( + '$number must be exactly "Infinity"' + ); + }); + + it("should reject $args without an array-valued $type constructor contract", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target: any = {}; + + await expect( + parser.parseProps(target, { + value: { $type: "TestValueType", $args: "invalid" } as any + }) + ).rejects.toThrow("$args must be an array when used with $type"); + await expect(parser.parseProps(target, { value: { $args: [] } })).rejects.toThrow("$args requires $type"); + }); + it("should throw a clear error when $type references an unregistered class", async () => { const scene = new Scene(engine); const context = new ParserContext(engine, ParserType.Scene, scene); From 84b614111ec59f2fade92d12ae83d41aadf17671 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Jul 2026 00:39:07 +0800 Subject: [PATCH 2/8] fix(loader): decode infinite burst cycles --- .../resources/parser/ReflectionParser.ts | 28 +++++++------- packages/loader/src/schema/CommonSchema.ts | 5 --- tests/src/loader/SceneFormatV2.test.ts | 38 +++++++++---------- 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts index b815b3235f..dde0a61d41 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts @@ -87,13 +87,12 @@ export class ReflectionParser { * 1. null/undefined/primitive → passthrough * 2. Array → recurse each element * 3. { $ref } → asset reference - * 4. { $number } → JSON-safe special number - * 5. { $type, $args? } → polymorphic type construct - * 6. { $class } → registered class constructor - * 7. { $entity } → entity reference by path (flat index + optional children descent) - * 8. { $component } → component reference - * 9. { $signal } → signal binding - * 10. plain object → recurse values (modify originValue in place if exists) + * 4. { $type, $args? } → polymorphic type construct + * 5. { $class } → registered class constructor + * 6. { $entity } → entity reference by path (flat index + optional children descent) + * 7. { $component } → component reference + * 8. { $signal } → signal binding + * 9. plain object → recurse values (modify originValue in place if exists) */ private _resolveValue(value: unknown, originValue?: any): Promise { if (value == null || typeof value !== "object") return Promise.resolve(value); @@ -120,13 +119,6 @@ export class ReflectionParser { }); } - if ("$number" in obj) { - if (Object.keys(obj).length !== 1 || obj.$number !== "Infinity") { - return Promise.reject(new Error('$number must be exactly "Infinity"')); - } - return Promise.resolve(Infinity); - } - // $type — polymorphic type: resolve constructor args, construct instance, then apply remaining props if ("$type" in obj) { const { $type, $args, ...rest } = obj; @@ -136,6 +128,14 @@ export class ReflectionParser { const constructorArgs = Array.isArray($args) ? $args : []; return this._resolveRegisteredClass($type, "$type").then((Class) => { return Promise.all(constructorArgs.map((arg) => this._resolveValue(arg))).then((args) => { + if ($type === "Burst" && args.length > 2) { + const cycles = args[2]; + if (cycles === "Infinity") { + args[2] = Infinity; + } else if (cycles != null && (typeof cycles !== "number" || !Number.isInteger(cycles) || cycles < 1)) { + throw new Error('Burst $args[2] must be a positive integer or "Infinity"'); + } + } const instance = new Class(...args); return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; }); diff --git a/packages/loader/src/schema/CommonSchema.ts b/packages/loader/src/schema/CommonSchema.ts index 83fb55c3c0..b162231768 100644 --- a/packages/loader/src/schema/CommonSchema.ts +++ b/packages/loader/src/schema/CommonSchema.ts @@ -27,11 +27,6 @@ export interface TypeValue { [key: string]: unknown; } -/** JSON-safe encoding for positive infinity. */ -export interface SpecialNumberValue { - $number: "Infinity"; -} - export interface SignalListener { target: { $component: ComponentRef }; methodName: string; diff --git a/tests/src/loader/SceneFormatV2.test.ts b/tests/src/loader/SceneFormatV2.test.ts index c809c9e797..0d54f9cfae 100644 --- a/tests/src/loader/SceneFormatV2.test.ts +++ b/tests/src/loader/SceneFormatV2.test.ts @@ -425,7 +425,7 @@ describe("ReflectionParser calls resolution", () => { args: [ { $type: "Burst", - $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, 2, 0.1] + $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, "Infinity", 0.1] } ] } @@ -441,6 +441,23 @@ describe("ReflectionParser calls resolution", () => { expect(renderer.generator.emission.bursts).to.have.length(1); expect(renderer.generator.emission.bursts[0].time).to.equal(0.5); expect(renderer.generator.emission.bursts[0].count.constant).to.equal(8); + expect(renderer.generator.emission.bursts[0].cycles).to.equal(Infinity); + await expect( + parser.parseMutationBlock(renderer, { + calls: [ + { + target: ["generator", "emission"], + method: "addBurst", + args: [ + { + $type: "Burst", + $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, "NaN", 0.1] + } + ] + } + ] + }) + ).rejects.toThrow('Burst $args[2] must be a positive integer or "Infinity"'); entity.destroy(); }); @@ -931,25 +948,6 @@ describe("ReflectionParser $type resolution", () => { expect(target.value.label).to.equal("ready"); }); - it("should resolve JSON-safe positive infinity values", async () => { - const scene = new Scene(engine); - const context = new ParserContext(engine, ParserType.Scene, scene); - const parser = new ReflectionParser(context, []); - const target: any = {}; - - await parser.parseProps(target, { - value: { - $type: "ConstructorValueType", - $args: [{ $number: "Infinity" }, { $type: "TestValueType" }] - } - }); - - expect(target.value.seed).to.equal(Infinity); - await expect(parser.parseProps({}, { value: { $number: "NaN" } })).rejects.toThrow( - '$number must be exactly "Infinity"' - ); - }); - it("should reject $args without an array-valued $type constructor contract", async () => { const scene = new Scene(engine); const context = new ParserContext(engine, ParserType.Scene, scene); From 1307f43d81727b71930fff8b88df2a0c8b83398c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Jul 2026 18:30:55 +0800 Subject: [PATCH 3/8] fix(asset): make virtual sub-asset loading reliable --- ...-resource-package-virtual-subasset-race.md | 21 ++++++++ packages/core/src/asset/ResourceManager.ts | 54 ++++++++++++------- packages/core/src/index.ts | 2 +- .../src/core/resource/ResourceManager.test.ts | 29 +++++++--- 4 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 notes/loader/2026-07-15-resource-package-virtual-subasset-race.md diff --git a/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md b/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md new file mode 100644 index 0000000000..cfa64eb971 --- /dev/null +++ b/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md @@ -0,0 +1,21 @@ +# Virtual resource sub-asset loading race + +## Symptom + +A runtime-v2 Prefab compiled from a resource package stopped at `Loading Prefab` even though every blob request completed. The unresolved load key was the second primitive of a package-local glTF: `meshes[0][1]`. + +## Root cause + +Sub-asset queries rely on eager callbacks emitted by loaders. When the glTF main asset completed, `ResourceManager` released the callback table. A concurrent sub-asset query could be registered around that cleanup boundary and remain pending forever even though the completed main resource already contained the requested mesh. + +## Resolution + +The eager callback remains the fast path. Every sub-asset promise now also follows the authoritative main-asset promise and resolves the query path from the completed resource. Callback cleanup can no longer strand a request. + +Resource-package consumers also need a supported way to map stable package paths to generated blob URLs, so `registerVirtualResources` is the public runtime boundary while the old Editor initialization method delegates to it for compatibility. + +## Verification + +- Browser `ResourceManager` suite: 16 tests pass, including a loader that completes without emitting an eager sub-asset callback. +- Real package: `/Meshes/FX_MS_ExtraShapes_02.glb?q=meshes[0][1]` resolves during Prefab loading without preloading dependencies. +- The independent demo mounts 27 virtual resources and renders the blue cosmic-flame Prefab from a single `.package`. diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 54b8309589..49f904f85e 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -44,7 +44,7 @@ export class ResourceManager { /** Asset path pool, key is the `instanceID` of resource, value is asset path. */ private _assetPool: Record = Object.create(null); /** Asset url pool, key is the asset path and the value is the asset. */ - private _assetUrlPool: Record = Object.create(null); + private _assetUrlPool: Record = Object.create(null); /** Referable resource pool, key is the `instanceID` of resource. */ private _referResourcePool: Record = Object.create(null); @@ -88,7 +88,7 @@ export class ResourceManager { */ load(path: string): AssetPromise; - load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { + load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { // single item if (!Array.isArray(assetInfo)) { return this._loadSingleItem(assetInfo); @@ -339,7 +339,7 @@ export class ResourceManager { this._loadingPromises = null; } - private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: EditorResourceItem): void { + private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: VirtualResource): void { assetInfo.type = virtualResourceEntry?.type ?? assetInfo.type ?? ResourceManager._getTypeByUrl(assetInfo.url); if (assetInfo.type === undefined) { throw `asset type should be specified: ${assetInfo.url}`; @@ -420,7 +420,7 @@ export class ResourceManager { this._onSubAssetFail(remoteAssetBaseURL, queryPath, e); }); - return this._createSubAssetPromiseCallback(remoteAssetBaseURL, remoteAssetURL, queryPath); + return this._createSubAssetPromiseCallback(remoteAssetBaseURL, remoteAssetURL, queryPath, mainPromise); } return this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName); @@ -461,7 +461,8 @@ export class ResourceManager { private _createSubAssetPromiseCallback( remoteAssetBaseURL: string, remoteAssetURL: string, - assetSubPath: string + assetSubPath: string, + mainPromise: AssetPromise ): AssetPromise { const loadingPromises = this._loadingPromises; const subPromiseCallback = this._subAssetPromiseCallbacks[remoteAssetBaseURL]?.[assetSubPath]; @@ -480,11 +481,22 @@ export class ResourceManager { } // Pending - const promise = new AssetPromise((resolve, reject) => { + const promise = new AssetPromise((resolve, reject, setTaskCompleteProgress, setTaskDetailProgress) => { (this._subAssetPromiseCallbacks[remoteAssetBaseURL] ||= {})[assetSubPath] = { resolve, reject }; + + // A loader may finish the main asset before its eager sub-asset notification reaches this callback. + // Always resolve from the completed main asset as the authoritative fallback so callback cleanup cannot + // strand a sub-asset request. + mainPromise.onProgress(setTaskCompleteProgress, setTaskDetailProgress).then((resource) => { + try { + resolve(this._getResolveResource(resource, this._parseQueryPath(assetSubPath)) as T); + } catch (error) { + reject(error); + } + }, reject); }); loadingPromises[remoteAssetURL] = promise; @@ -560,12 +572,23 @@ export class ResourceManager { delete this._subAssetPromiseCallbacks[assetBaseURL]; } - //-----------------Editor temp solution----------------- + // Virtual resource mapping /** @internal */ _objectPool: { [key: string]: any } = Object.create(null); /** @internal */ - _virtualPathResourceMap: Record = Object.create(null); + _virtualPathResourceMap: Record = Object.create(null); + + /** + * Register virtual asset paths and their load descriptors. + * @remarks References inside runtime scenes and Prefabs can keep stable virtual paths while the backing URLs + * are generated dynamically, such as object URLs created from a resource package. + */ + registerVirtualResources(resources: readonly VirtualResource[]): void { + resources.forEach((resource) => { + this._virtualPathResourceMap[resource.virtualPath] = resource; + }); + } /** * @internal @@ -599,16 +622,9 @@ export class ResourceManager { * @internal * @beta Just for internal editor, not recommended for developers. */ - initVirtualResources(config: EditorResourceItem[]): void { - config.forEach((element) => { - this._virtualPathResourceMap[element.virtualPath] = element; - if (element.dependentAssetMap) { - this._virtualPathResourceMap[element.virtualPath].dependentAssetMap = element.dependentAssetMap; - } - }); + initVirtualResources(config: VirtualResource[]): void { + this.registerVirtualResources(config); } - - //-----------------Editor temp solution----------------- } /** @@ -644,14 +660,14 @@ const rePropName = RegExp( ); type VirtualPath = string; -type EditorResourceItem = { +export interface VirtualResource { virtualPath: string; path: string; type: string; dependentAssetMap?: { [key: string]: string }; subpackageName?: string; params?: Record; -}; +} type SubAssetPromiseCallbacks = Record< // main asset url, ie. "https://***.glb" string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be483eccbd..dbb7b3efd9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export type { RequestConfig } from "./asset/request"; export { Loader } from "./asset/Loader"; export { ContentRestorer } from "./asset/ContentRestorer"; export { RenderingStatistics } from "./asset/RenderingStatistics"; -export { ResourceManager, resourceLoader } from "./asset/ResourceManager"; +export { ResourceManager, resourceLoader, type VirtualResource } from "./asset/ResourceManager"; export { AssetPromise } from "./asset/AssetPromise"; export type { LoadItem } from "./asset/LoadItem"; export { AssetType } from "./asset/AssetType"; diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index b0fea63848..79399e9b59 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -91,7 +91,7 @@ describe("ResourceManager", () => { describe("virtualPath loading", () => { it("infers loader type from virtualPathResourceMap when type is omitted", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/extensionless", path: "https://cdn.ali.com/a.json", type: AssetType.Texture } ]); // @ts-ignore @@ -108,7 +108,7 @@ describe("ResourceManager", () => { it("fills params from virtualPathResourceMap when params is omitted", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/withParams", path: "https://cdn.ali.com/p.json", @@ -130,7 +130,7 @@ describe("ResourceManager", () => { it("prefers explicit params over the virtualPath map params", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/overrideParams", path: "https://cdn.ali.com/o.json", @@ -165,9 +165,26 @@ describe("ResourceManager", () => { loaderSpy.mockRestore(); }); + it("resolves a sub-asset from the completed main asset when no eager callback arrives", async () => { + const resourceManager = engine.resourceManager; + const material = { name: "material" }; + const mainAsset = { instanceId: 987654321, materials: [material] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValue(AssetPromise.resolve(mainAsset) as any); + + try { + const loaded = await resourceManager.load("https://cdn.ali.com/sub-asset-fallback.glb?q=materials[0]"); + expect(loaded).equal(material); + } finally { + loaderSpy.mockRestore(); + } + }); + it("prefers the virtualPath map type over an explicit type", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/explicit", path: "https://cdn.ali.com/x.json", type: AssetType.Texture } ]); // @ts-ignore @@ -186,7 +203,7 @@ describe("ResourceManager", () => { it("getResourceByRef resolves the type from the map without passing it explicitly", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/byRef", path: "https://cdn.ali.com/r.json", type: AssetType.Texture } ]); // @ts-ignore @@ -204,7 +221,7 @@ describe("ResourceManager", () => { it("resolves virtualPath via map even when baseUrl is set", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/withBaseUrl", path: "https://cdn.ali.com/real.json", type: AssetType.Texture } ]); // @ts-ignore From be65ff7ebed7fd86198d504b29d78ca246a61029 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Jul 2026 19:40:43 +0800 Subject: [PATCH 4/8] refactor(loader): make special number decoding generic --- ...6-07-15-runtime-special-number-contract.md | 26 +++++++++++++ packages/core/src/asset/ResourceManager.ts | 2 +- .../resources/parser/ReflectionParser.ts | 38 +++++++++---------- packages/loader/src/schema/CommonSchema.ts | 5 +++ tests/src/loader/SceneFormatV2.test.ts | 28 +++++--------- 5 files changed, 60 insertions(+), 39 deletions(-) create mode 100644 notes/loader/2026-07-15-runtime-special-number-contract.md diff --git a/notes/loader/2026-07-15-runtime-special-number-contract.md b/notes/loader/2026-07-15-runtime-special-number-contract.md new file mode 100644 index 0000000000..3f11ebb04c --- /dev/null +++ b/notes/loader/2026-07-15-runtime-special-number-contract.md @@ -0,0 +1,26 @@ +# Runtime v2 special-number contract + +## Problem + +JSON cannot represent `Infinity`, but runtime-v2 values need it for data such as particle burst cycles. Decoding the string only when `$type === "Burst"` coupled the generic reflection parser to one Engine class and one constructor position. + +## Decision + +Runtime v2 represents positive infinity as `{ "$number": "Infinity" }`. `ReflectionParser` resolves that value recursively in props, arrays, call arguments, and constructor arguments without knowing the consuming class. Builder/compiler code owns conversion from Editor source data into this runtime sentinel. + +`$args` is valid only beside `$type`; mixed discriminator objects are rejected before any other sentinel branch can silently ignore it. + +## Boundary + +Only positive infinity is supported because it is the only current producer requirement. `NaN` and negative infinity remain invalid until a real format requirement exists. + +## Rejected alternative + +A `Burst.$args[2] === "Infinity"` parser branch was removed because it leaked particle constructor semantics into the format resolver and required parser changes for every future infinite numeric field. + +## Verification + +- `pnpm vitest run tests/src/core/resource/ResourceManager.test.ts tests/src/loader/SceneFormatV2.test.ts`: 70 passed. +- `pnpm build`: passed, including every package type declaration build. +- Engine ESLint: 0 errors; formatting and `git diff --check`: passed. +- Builder lowering tests: 45 passed; runtime schema-v2 typecheck and resource-package compiler build passed. diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 49f904f85e..44f7773aa6 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -88,7 +88,7 @@ export class ResourceManager { */ load(path: string): AssetPromise; - load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { + load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { // single item if (!Array.isArray(assetInfo)) { return this._loadSingleItem(assetInfo); diff --git a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts index dde0a61d41..68ff6a3531 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts @@ -14,7 +14,7 @@ export class ReflectionParser { /** * Apply v2 props to a component/object instance. - * Each prop value is resolved recursively (handling $ref, $type, $class, $entity, $component, $signal). + * Each prop value is resolved recursively (handling $ref, $number, $type, $class, $entity, $component, $signal). */ parseProps(instance: any, props?: Record): Promise { const promises: Promise[] = []; @@ -87,12 +87,13 @@ export class ReflectionParser { * 1. null/undefined/primitive → passthrough * 2. Array → recurse each element * 3. { $ref } → asset reference - * 4. { $type, $args? } → polymorphic type construct - * 5. { $class } → registered class constructor - * 6. { $entity } → entity reference by path (flat index + optional children descent) - * 7. { $component } → component reference - * 8. { $signal } → signal binding - * 9. plain object → recurse values (modify originValue in place if exists) + * 4. { $number } → JSON-safe special number + * 5. { $type, $args? } → polymorphic type construct + * 6. { $class } → registered class constructor + * 7. { $entity } → entity reference by path (flat index + optional children descent) + * 8. { $component } → component reference + * 9. { $signal } → signal binding + * 10. plain object → recurse values (modify originValue in place if exists) */ private _resolveValue(value: unknown, originValue?: any): Promise { if (value == null || typeof value !== "object") return Promise.resolve(value); @@ -100,6 +101,10 @@ export class ReflectionParser { const obj = value as Record; + if ("$args" in obj && !("$type" in obj)) { + return Promise.reject(new Error("$args requires $type")); + } + // $ref — asset reference (index into refs array) if ("$ref" in obj) { const { _context: context } = this; @@ -119,6 +124,13 @@ export class ReflectionParser { }); } + if ("$number" in obj) { + if (Object.keys(obj).length !== 1 || obj.$number !== "Infinity") { + return Promise.reject(new Error('$number must be exactly "Infinity"')); + } + return Promise.resolve(Infinity); + } + // $type — polymorphic type: resolve constructor args, construct instance, then apply remaining props if ("$type" in obj) { const { $type, $args, ...rest } = obj; @@ -128,14 +140,6 @@ export class ReflectionParser { const constructorArgs = Array.isArray($args) ? $args : []; return this._resolveRegisteredClass($type, "$type").then((Class) => { return Promise.all(constructorArgs.map((arg) => this._resolveValue(arg))).then((args) => { - if ($type === "Burst" && args.length > 2) { - const cycles = args[2]; - if (cycles === "Infinity") { - args[2] = Infinity; - } else if (cycles != null && (typeof cycles !== "number" || !Number.isInteger(cycles) || cycles < 1)) { - throw new Error('Burst $args[2] must be a positive integer or "Infinity"'); - } - } const instance = new Class(...args); return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; }); @@ -162,10 +166,6 @@ export class ReflectionParser { return this._resolveSignal(originValue, obj.$signal as SignalListener[]); } - if ("$args" in obj) { - return Promise.reject(new Error("$args requires $type")); - } - // Plain object — recurse each value, modifying originValue in place or building a new object const target = originValue && typeof originValue === "object" && !Array.isArray(originValue) diff --git a/packages/loader/src/schema/CommonSchema.ts b/packages/loader/src/schema/CommonSchema.ts index b162231768..83fb55c3c0 100644 --- a/packages/loader/src/schema/CommonSchema.ts +++ b/packages/loader/src/schema/CommonSchema.ts @@ -27,6 +27,11 @@ export interface TypeValue { [key: string]: unknown; } +/** JSON-safe encoding for positive infinity. */ +export interface SpecialNumberValue { + $number: "Infinity"; +} + export interface SignalListener { target: { $component: ComponentRef }; methodName: string; diff --git a/tests/src/loader/SceneFormatV2.test.ts b/tests/src/loader/SceneFormatV2.test.ts index 0d54f9cfae..afcade0d29 100644 --- a/tests/src/loader/SceneFormatV2.test.ts +++ b/tests/src/loader/SceneFormatV2.test.ts @@ -425,7 +425,7 @@ describe("ReflectionParser calls resolution", () => { args: [ { $type: "Burst", - $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, "Infinity", 0.1] + $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, { $number: "Infinity" }, 0.1] } ] } @@ -442,22 +442,6 @@ describe("ReflectionParser calls resolution", () => { expect(renderer.generator.emission.bursts[0].time).to.equal(0.5); expect(renderer.generator.emission.bursts[0].count.constant).to.equal(8); expect(renderer.generator.emission.bursts[0].cycles).to.equal(Infinity); - await expect( - parser.parseMutationBlock(renderer, { - calls: [ - { - target: ["generator", "emission"], - method: "addBurst", - args: [ - { - $type: "Burst", - $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, "NaN", 0.1] - } - ] - } - ] - }) - ).rejects.toThrow('Burst $args[2] must be a positive integer or "Infinity"'); entity.destroy(); }); @@ -937,12 +921,12 @@ describe("ReflectionParser $type resolution", () => { await parser.parseProps(target, { value: { $type: "ConstructorValueType", - $args: ["seed", { $type: "TestValueType", x: 3, y: 4 }], + $args: [{ $number: "Infinity" }, { $type: "TestValueType", x: 3, y: 4 }], label: "ready" } }); expect(target.value).to.be.instanceOf(ConstructorValueType); - expect(target.value.seed).to.equal("seed"); + expect(target.value.seed).to.equal(Infinity); expect(target.value.child).to.be.instanceOf(TestValueType); expect(target.value.child).to.deep.include({ x: 3, y: 4 }); expect(target.value.label).to.equal("ready"); @@ -960,6 +944,12 @@ describe("ReflectionParser $type resolution", () => { }) ).rejects.toThrow("$args must be an array when used with $type"); await expect(parser.parseProps(target, { value: { $args: [] } })).rejects.toThrow("$args requires $type"); + await expect(parser.parseProps(target, { value: { $class: "TestValueType", $args: [] } })).rejects.toThrow( + "$args requires $type" + ); + await expect(parser.parseProps(target, { value: { $number: "NaN" } })).rejects.toThrow( + '$number must be exactly "Infinity"' + ); }); it("should throw a clear error when $type references an unregistered class", async () => { From 37a32520b47426fcaf213882cad8a51d36608923 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Jul 2026 20:13:55 +0800 Subject: [PATCH 5/8] fix(asset): reject missing sub-asset paths --- ...15-resource-package-virtual-subasset-race.md | 4 +++- ...026-07-15-runtime-special-number-contract.md | 2 +- packages/core/src/asset/ResourceManager.ts | 5 ++++- tests/src/core/resource/ResourceManager.test.ts | 17 +++++++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md b/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md index cfa64eb971..b12d91af83 100644 --- a/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md +++ b/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md @@ -12,10 +12,12 @@ Sub-asset queries rely on eager callbacks emitted by loaders. When the glTF main The eager callback remains the fast path. Every sub-asset promise now also follows the authoritative main-asset promise and resolves the query path from the completed resource. Callback cleanup can no longer strand a request. +A query path absent from the completed resource rejects instead of resolving `undefined`, preserving the `load` success contract. + Resource-package consumers also need a supported way to map stable package paths to generated blob URLs, so `registerVirtualResources` is the public runtime boundary while the old Editor initialization method delegates to it for compatibility. ## Verification -- Browser `ResourceManager` suite: 16 tests pass, including a loader that completes without emitting an eager sub-asset callback. +- Browser `ResourceManager` suite: 17 tests pass, including a loader that completes without emitting an eager sub-asset callback and a missing-path rejection. - Real package: `/Meshes/FX_MS_ExtraShapes_02.glb?q=meshes[0][1]` resolves during Prefab loading without preloading dependencies. - The independent demo mounts 27 virtual resources and renders the blue cosmic-flame Prefab from a single `.package`. diff --git a/notes/loader/2026-07-15-runtime-special-number-contract.md b/notes/loader/2026-07-15-runtime-special-number-contract.md index 3f11ebb04c..fac29a283a 100644 --- a/notes/loader/2026-07-15-runtime-special-number-contract.md +++ b/notes/loader/2026-07-15-runtime-special-number-contract.md @@ -20,7 +20,7 @@ A `Burst.$args[2] === "Infinity"` parser branch was removed because it leaked pa ## Verification -- `pnpm vitest run tests/src/core/resource/ResourceManager.test.ts tests/src/loader/SceneFormatV2.test.ts`: 70 passed. +- `pnpm vitest run tests/src/core/resource/ResourceManager.test.ts tests/src/loader/SceneFormatV2.test.ts`: 71 passed. - `pnpm build`: passed, including every package type declaration build. - Engine ESLint: 0 errors; formatting and `git diff --check`: passed. - Builder lowering tests: 45 passed; runtime schema-v2 typecheck and resource-package compiler build passed. diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 44f7773aa6..3a45d8e013 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -526,7 +526,10 @@ export class ResourceManager { if (paths) { for (let i = 0, n = paths.length; i < n; i++) { const path = paths[i]; - subResource = subResource[path]; + subResource = subResource?.[path]; + if (subResource === undefined) { + throw new Error(`Sub-asset path does not exist: ${paths.join(".")}`); + } } } return subResource; diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index 79399e9b59..2135587f44 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -182,6 +182,23 @@ describe("ResourceManager", () => { } }); + it("rejects a missing sub-asset path from the completed main asset", async () => { + const resourceManager = engine.resourceManager; + const mainAsset = { instanceId: 987654322, materials: [] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValue(AssetPromise.resolve(mainAsset) as any); + + try { + await expect( + resourceManager.load("https://cdn.ali.com/missing-sub-asset.glb?q=materials[0]") + ).rejects.toThrow(); + } finally { + loaderSpy.mockRestore(); + } + }); + it("prefers the virtualPath map type over an explicit type", () => { const resourceManager = engine.resourceManager; resourceManager.registerVirtualResources([ From b8ce4dfa322f1d9457ee0398e6960661b2c99316 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Jul 2026 20:22:06 +0800 Subject: [PATCH 6/8] chore: remove development notes --- ...-resource-package-virtual-subasset-race.md | 23 ---------------- ...6-07-15-runtime-special-number-contract.md | 26 ------------------- 2 files changed, 49 deletions(-) delete mode 100644 notes/loader/2026-07-15-resource-package-virtual-subasset-race.md delete mode 100644 notes/loader/2026-07-15-runtime-special-number-contract.md diff --git a/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md b/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md deleted file mode 100644 index b12d91af83..0000000000 --- a/notes/loader/2026-07-15-resource-package-virtual-subasset-race.md +++ /dev/null @@ -1,23 +0,0 @@ -# Virtual resource sub-asset loading race - -## Symptom - -A runtime-v2 Prefab compiled from a resource package stopped at `Loading Prefab` even though every blob request completed. The unresolved load key was the second primitive of a package-local glTF: `meshes[0][1]`. - -## Root cause - -Sub-asset queries rely on eager callbacks emitted by loaders. When the glTF main asset completed, `ResourceManager` released the callback table. A concurrent sub-asset query could be registered around that cleanup boundary and remain pending forever even though the completed main resource already contained the requested mesh. - -## Resolution - -The eager callback remains the fast path. Every sub-asset promise now also follows the authoritative main-asset promise and resolves the query path from the completed resource. Callback cleanup can no longer strand a request. - -A query path absent from the completed resource rejects instead of resolving `undefined`, preserving the `load` success contract. - -Resource-package consumers also need a supported way to map stable package paths to generated blob URLs, so `registerVirtualResources` is the public runtime boundary while the old Editor initialization method delegates to it for compatibility. - -## Verification - -- Browser `ResourceManager` suite: 17 tests pass, including a loader that completes without emitting an eager sub-asset callback and a missing-path rejection. -- Real package: `/Meshes/FX_MS_ExtraShapes_02.glb?q=meshes[0][1]` resolves during Prefab loading without preloading dependencies. -- The independent demo mounts 27 virtual resources and renders the blue cosmic-flame Prefab from a single `.package`. diff --git a/notes/loader/2026-07-15-runtime-special-number-contract.md b/notes/loader/2026-07-15-runtime-special-number-contract.md deleted file mode 100644 index fac29a283a..0000000000 --- a/notes/loader/2026-07-15-runtime-special-number-contract.md +++ /dev/null @@ -1,26 +0,0 @@ -# Runtime v2 special-number contract - -## Problem - -JSON cannot represent `Infinity`, but runtime-v2 values need it for data such as particle burst cycles. Decoding the string only when `$type === "Burst"` coupled the generic reflection parser to one Engine class and one constructor position. - -## Decision - -Runtime v2 represents positive infinity as `{ "$number": "Infinity" }`. `ReflectionParser` resolves that value recursively in props, arrays, call arguments, and constructor arguments without knowing the consuming class. Builder/compiler code owns conversion from Editor source data into this runtime sentinel. - -`$args` is valid only beside `$type`; mixed discriminator objects are rejected before any other sentinel branch can silently ignore it. - -## Boundary - -Only positive infinity is supported because it is the only current producer requirement. `NaN` and negative infinity remain invalid until a real format requirement exists. - -## Rejected alternative - -A `Burst.$args[2] === "Infinity"` parser branch was removed because it leaked particle constructor semantics into the format resolver and required parser changes for every future infinite numeric field. - -## Verification - -- `pnpm vitest run tests/src/core/resource/ResourceManager.test.ts tests/src/loader/SceneFormatV2.test.ts`: 71 passed. -- `pnpm build`: passed, including every package type declaration build. -- Engine ESLint: 0 errors; formatting and `git diff --check`: passed. -- Builder lowering tests: 45 passed; runtime schema-v2 typecheck and resource-package compiler build passed. From 7f2bcbaa43f72021e49cee709c1bd8d74da1e3d8 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Thu, 16 Jul 2026 12:15:15 +0800 Subject: [PATCH 7/8] fix(asset): allow sub-asset retries after failure --- packages/core/src/asset/ResourceManager.ts | 38 +++---------------- packages/loader/src/ProjectLoader.ts | 3 +- .../src/core/resource/ResourceManager.test.ts | 20 ++++++++++ 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 3a45d8e013..ab9d61fd00 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -221,21 +221,6 @@ export class ResourceManager { } } - /** - * @internal - */ - _onSubAssetFail(assetBaseURL: string, assetSubPath: string, value: Error): void { - const subPromiseCallback = this._subAssetPromiseCallbacks[assetBaseURL]?.[assetSubPath]; - if (subPromiseCallback) { - subPromiseCallback.reject(value); - } else { - // Pending - (this._subAssetPromiseCallbacks[assetBaseURL] ||= {})[assetSubPath] = { - rejectedValue: value - }; - } - } - /** * @internal */ @@ -416,9 +401,6 @@ export class ResourceManager { const mainPromise = loadingPromises[remoteAssetBaseURL] || this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName); - mainPromise.catch((e) => { - this._onSubAssetFail(remoteAssetBaseURL, queryPath, e); - }); return this._createSubAssetPromiseCallback(remoteAssetBaseURL, remoteAssetURL, queryPath, mainPromise); } @@ -467,17 +449,10 @@ export class ResourceManager { const loadingPromises = this._loadingPromises; const subPromiseCallback = this._subAssetPromiseCallbacks[remoteAssetBaseURL]?.[assetSubPath]; const resolvedValue = subPromiseCallback?.resolvedValue; - const rejectedValue = subPromiseCallback?.rejectedValue; - - // Already resolved or rejected - if (resolvedValue || rejectedValue) { - return new AssetPromise((resolve, reject) => { - if (resolvedValue) { - resolve(resolvedValue); - } else if (rejectedValue) { - reject(rejectedValue); - } - }); + + // Already resolved + if (resolvedValue) { + return AssetPromise.resolve(resolvedValue); } // Pending @@ -623,7 +598,7 @@ export class ResourceManager { /** * @internal - * @beta Just for internal editor, not recommended for developers. + * @deprecated Use {@link registerVirtualResources}. */ initVirtualResources(config: VirtualResource[]): void { this.registerVirtualResources(config); @@ -678,9 +653,8 @@ type SubAssetPromiseCallbacks = Record< // sub asset url, ie. "textures[0]" string, { - // Already resolved or rejected + // Already resolved resolvedValue?: T; - rejectedValue?: Error; // Pending resolve?: (value: T) => void; reject?: (reason: any) => void; diff --git a/packages/loader/src/ProjectLoader.ts b/packages/loader/src/ProjectLoader.ts index de0ce3807b..9bfd6aa335 100644 --- a/packages/loader/src/ProjectLoader.ts +++ b/packages/loader/src/ProjectLoader.ts @@ -18,8 +18,7 @@ class ProjectLoader extends Loader { // @ts-ignore ._request(item.url, { ...item, type: "json" }) .then((data) => { - // @ts-ignore - engine.resourceManager.initVirtualResources(data.files); + engine.resourceManager.registerVirtualResources(data.files); return resourceManager .load({ type: AssetType.Scene, url: data.scene }) .onProgress(onTaskCompeteProgress) diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index 2135587f44..eb7b66121c 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -199,6 +199,26 @@ describe("ResourceManager", () => { } }); + it("retries a sub-asset after its main asset initially fails", async () => { + const resourceManager = engine.resourceManager; + const material = { name: "material" }; + const mainAsset = { instanceId: 987654323, materials: [material] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValueOnce(new AssetPromise((_resolve, reject) => reject(new Error())) as any) + .mockReturnValueOnce(AssetPromise.resolve(mainAsset) as any); + const url = "https://cdn.ali.com/retry-sub-asset.glb?q=materials[0]"; + + try { + await expect(resourceManager.load(url)).rejects.toThrow(); + expect(await resourceManager.load(url)).equal(material); + expect(loaderSpy).toHaveBeenCalledTimes(2); + } finally { + loaderSpy.mockRestore(); + } + }); + it("prefers the virtualPath map type over an explicit type", () => { const resourceManager = engine.resourceManager; resourceManager.registerVirtualResources([ From aa62327208d0345d11284a3b02f8d984d9ad28af Mon Sep 17 00:00:00 2001 From: luzhuang Date: Thu, 16 Jul 2026 14:08:45 +0800 Subject: [PATCH 8/8] refactor(asset): remove legacy virtual resource API --- packages/core/src/asset/ResourceManager.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index ab9d61fd00..e20e5fd139 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -595,14 +595,6 @@ export class ResourceManager { const promise = this.load({ url: loadUrl }); return isClone ? promise.then((item) => ((item)).clone()) : promise; } - - /** - * @internal - * @deprecated Use {@link registerVirtualResources}. - */ - initVirtualResources(config: VirtualResource[]): void { - this.registerVirtualResources(config); - } } /**