Migrate com.reactunity.quickjs to quickjs-ng - #137
Conversation
com.reactunity.quickjs binds unity-jsb's fork of Bellard-era QuickJS. The shipped DLL exports JS_NewBigDecimal and JSB_ATOM_BigFloatEnv and has no JS_SetModuleLoaderFunc2; quickjs-ng deleted BigDecimal and BigFloat outright. They are different engines, not two versions of one, and both of our upstreams are dormant. The driver is asynchronous module loading - the spec's HostLoadImportedModule, which lets a host fetch modules over a network without blocking a frame. It is implemented on gkurt/quickjs and cannot reach Unity until this migration lands. MIGRATION.md records the measured surface rather than an estimate: which of the 137 P/Invoke names survive as-is, which changed signature in ways that compile clean and return garbage, and which subsystems can simply go. Also records the one question grep could not settle, and how to settle it with a single build.
unity-jsb could build against either QuickJS or its own v8-bridge, selected by JSB_WITH_V8_BACKEND. No v8-bridge binary has ever shipped in this package - Plugins/QuickJS holds only libquickjs - so every one of those branches referenced a DLL that does not exist. ClearScript is how ReactUnity offers V8, and it is a maintained upstream rather than a fork we would have to carry. Resolving the symbol to false throughout, which also removes the engine picker in ScriptEngineStatsWindow: it had one option left.
The five JS_*Debugger* entry points and JS_SetLogFunc only ever existed in the v8-bridge build; the QuickJS build got no-op stubs. Two further layers made them unreachable even in principle: ScriptRuntime.Initialize hardcoded args.withDebugServer = false before anything read it, so the "waiting for debugger" branch could not be taken, and OnDebuggerConnected had no subscribers. So this removes no behaviour. It does remove the impression that QuickJS can be debugged - ScriptRuntimeArgs advertised withDebugServer, waitingForDebugger and debugServerPort, and QuickJSEngine dutifully set all three. Debugging is what ClearScript is for. RaiseDebuggerConnectedEvent is renamed RaiseInitialized, which is what it actually did: fire OnInitializing and OnInitialized. QuickJSEngine still accepts debug and awaitDebugger, since IJavaScriptEngineFactory.Create passes them for every engine, and now ignores them.
JSB_GetBridgeClassID, jsb_get_int_4 and jsb_set_int_4 have no callers - JSB_GetBridgeClassID appears only inside a commented-out line. Three fewer functions to port to quickjs-ng. Also corrects two claims in MIGRATION.md. JSB_Init and the two *PropertyInternal wrappers looked dead and are not: the search that found them excluded Runtime/Source/Native, which is where their callers live. JSB_Init is the DLL version handshake, and phase 3 has to map *PropertyInternal onto ng'"'"'s JS_GetProperty/JS_SetProperty rather than deleting them. Noted in the doc so the next pass does not repeat the mistake.
`Runtime/Source/Unity/` was compiled out in its entirety. Every file in it opens with `#if !JSB_UNITYLESS`, and `JSB_UNITYLESS` is defined for all five jsb assemblies by a `versionDefines` entry with an empty expression, on every platform. Verified by injecting a syntax error inside one of the guards and watching the compile pass. That takes two whole assemblies with it -- jsb.editor.unity (25 files) and jsb.editor.hotfix (1 file, plus 468 KB of bundled Unity.Cecil DLLs) -- neither of which is referenced by anything outside the tree being removed. ReactUnity supplies its own Unity layer, so jsb's JSBehaviour, inspectors, editor windows and Prefs GUI were never reachable. The hard type references from live files (DefaultAsyncManager, ScriptContext, ScriptRuntime) all sit inside dead blocks; those blocks go in a later commit.
Settles the open question in MIGRATION.md, and more cheaply than the experiment it proposed. Fifteen of the sixteen `Binding/ValueTypes/Values_*.cs` files are whole-file `#if !JSB_UNITYLESS`, so the Vector/Color/Matrix fast paths that account for most of the shim call-sites were never compiled. No experiment needed -- the preprocessor had already answered it. `Values_DateTime.cs` stays: System.DateTime needs no Unity, and it is the only live file in the directory. `Values_inject.cs` goes with them, being dead for the same reason and the only other consumer of JSBehaviour. This is what shrinks phase 2. jsb_get/set_float_4, _floats, _int_1..3, _byte_4 and jsb_get_payload_header lose every call-site here.
With the Unity struct marshalling gone, the JSB_*/jsb_* surface measures 59 declarations, 41 with live callers and 18 with none. The 18 are the struct fast paths (jsb_get/set_float_2..4, _int_1..3, _byte_4, _floats) plus JSB_FreePayload and jsb_construct_bridge_object, which had no C# caller even before this branch. That sizes phase 2 exactly: 25 shim functions plus 16 JSB_ATOM_* to port, not the "18 to 41" the plan guessed from grep. The jslib still implements all 18 -- Plugins/QuickJS/WebGL/.source/jsbplugin.ts is the source of truth there and regenerating it belongs with the rest of the phase 4 WebGL work. Unused exports are harmless in the meantime.
The symbol was supplied unconditionally by a `versionDefines` entry in each asmdef, so `#if !JSB_UNITYLESS` was permanently false and `#if JSB_UNITYLESS` permanently true. Substituting it and constant-folding removes 407 lines and, more usefully, 22 conditionals wrapped around the P/Invoke calling-convention attributes and delegate rooting in JSApi.cs -- exactly the declarations phase 3 has to rewrite one by one against the ng header. Compound conditions were simplified rather than resolved where the symbol was not decisive: `JSB_UNITYLESS && !UNITY_2019_1_OR_NEWER` becomes `!UNITY_2019_1_OR_NEWER`. Conditions not mentioning the symbol are left byte-for -byte alone, so the diff carries no incidental reformatting. Also drops the now-inert versionDefines entries. JSB_RUNTIME_REFLECT_BINDING stays: the asmdef defineConstraints still test it. Two mentions survive, both in comments, one of them inside a commented-out block that also names a declaration deleted earlier on this branch.
`QuickJSEngine.InvokeReflectBinding` called `bm.Generate(TypeBindingFlags.None)`, which constructed a `CodeGenerator`, called `cg.Begin()`, and ran `cg.Generate()` per exported type into buffers nothing ever read -- no `codegenCallback` is passed, so every write was guarded off. The reflect binding needs none of it. `Bind()` is that path with the codegen removed: the IBindingCallback hooks, OnPre/OnPostGenerateType, the delegates, and the static modules. The log tail both paths shared is now `SubmitLog()`. `Generate(TypeBindingFlags)` is untouched, so this is not the codegen deletion -- see "The codegen is not separable" in MIGRATION.md for why that one is a product decision rather than a subtraction. Suite is byte-identical to the run before the change: EditMode 340/348 passed, PlayMode 689/701, 0 failed either side.
Gate 0. The repo had no way to reproduce any of the twelve shipped binaries -- they are unity-jsb prebuilts. This builds one of them from source. CMake fetches quickjs-ng pinned to a commit (a fork, since the async module loader is not upstream), builds it static with BUILDING_QJS_SHARED so JS_EXTERN becomes dllexport, and links it into a single `quickjs` shared library that re-exports ng's whole JS_* API alongside the JSB_*/jsb_* shim. The shim is vendored from unity-jsb (MIT) and ported in four changes -- the JS_BOOL alias, JS_NewClassID gaining a JSRuntime*, JS_FreeRuntime becoming void, and JSB_Init losing the runtime it needed -- plus two additions. All of them, and the one deliberate behaviour regression (the "gc object leaks" diagnostic, which ng reports by asserting instead of by return value), are written up in the README. Measured against the C# layer rather than assumed: of the 105 P/Invoke names live in `Runtime/Source/Native`, this DLL satisfies 99. The 6 gaps are all C#-side work for phase 3 and all enumerated in MIGRATION.md. It exports the four async-loader entry points and does not export JS_NewBigDecimal -- the exact inverse of the fingerprint that identified the old binary as Bellard-era. Windows x64 / MSVC only. unity-jsb built Windows with MinGW; both follow the Win64 ABI for the 16-byte JSValue return, but the other eleven artifacts are untested and there is no CI matrix yet.
Most of this replaces guesses with numbers, and two sections replace claims that were wrong. The plan said `Binding/ValueTypes/` was an open question needing a build to settle, and that the shim was somewhere between 18 and 41 functions. Both were answered by noticing that JSB_UNITYLESS is defined unconditionally: 28.9% of the package was never compiled, the struct fast paths among it, and the real number is 25 functions plus 16 atoms. It also called `Binding/Editor/` pure codegen with no native dependency and treated deleting it as subtraction. It is reachable at runtime from BindingManager._EmitDelegateMethod, so that is now written up as a feature decision rather than a pending chore. New: the four atoms ng does not have and what each costs, the six names phase 3 must fix in C#, and where gate 0 stands. Plus check-exports.py, so the gate 0 claim is a command rather than a paragraph.
The debugger paragraph still described the five JS_*Debugger* entry points and JS_SetLogFunc as living behind JSB_WITH_V8_BACKEND -- they were deleted earlier on this branch. And JS_NewFloat64 was listed as needing a shim alongside JS_NewString. It already has one: the C# declaration carries EntryPoint = "JSB_NewFloat64". Only JS_NewString is unshimmed. Noted, because an audit of this surface that ignores EntryPoint reports gaps that are not there.
Gate 0 got the shim compiling against ng. Compiling is not porting: two of the differences between ng and Bellard-era QuickJS change behaviour rather than break the build, and the vendored source handled neither. JS_SetOpaque is the important one. Bellard's wrote the pointer into whatever object it was handed; ng returns -1 for anything that is not an object of a registered class. Left unchecked, a failure leaks the payload and hands C# a bridge object whose id reads back as 0, with nothing reported anywhere. Both live constructors now go through one helper that checks it. js_malloc's return was never checked either, so an allocation failure dereferenced NULL instead of propagating the exception ng had already thrown. Same for the runtime payload in JSB_NewRuntime, where registering the class before allocating it leaves neither failure path anything to unwind. Two smaller ones: JS_NewClass's return was ignored, so a failed registration produced a runtime whose bridge objects silently had no class; and the atom accessors were declared K&R `()` rather than `(void)`, which C23 redefines. Then subtraction. 26 shim functions nothing names are gone -- JSB_Eval, JSB_FreePayload, JSB_GetClassID, JSB_GetBridgeClassID, jsb_construct_bridge_object, jsb_get_payload, and the 20 struct accessors phase 1 orphaned when it deleted the Unity value-type marshalling. The UNITY_WEBGL and EMSCRIPTEN guards go too: on WebGL JSApi.JSBDLL is "__Internal" and this library is never loaded, so they guarded a configuration that cannot occur -- and incoherently, skipping quickjs.h and then using JSAtom. JS_NewString is deleted rather than shimmed. ng made it `static inline`, so the plan was to give it a JSB_ shim the way JS_NewFloat64 already has one; grepping first showed the C# declaration has no callers at all. Suite unchanged: 340/348 EditMode, 689/701 PlayMode, 0 failed.
A successful link proves nothing about the atom table, which is the shim's riskiest part. The accessors are generated from ng's own quickjs-atom.h with the same DEF trick quickjs.c uses, so the numbering is right by construction -- but that is an argument, and ng went from 224 atoms to 241. Bad numbering would not crash; every atom-keyed property lookup would silently address a different name. shim-test links the shim against ng in-process, so it runs before anything is copied into Unity, and asserts all 241 ids and their strings, that ng still lacks the four atoms it is missing, and that the bridge payloads round-trip with the class finalizer firing. It is registered with CTest too. Worth running in Debug as well: ng reports a non-empty GC object list by asserting in JS_FreeRuntime rather than returning a value the way unity-jsb's patched Bellard did, so a Debug pass is what is left of the leak diagnostic this port gave up. check-exports.py now diffs both directions. It only reported names C# calls that we do not export, which let the 26 uncalled shim functions sit there unnoticed; a stale export is cheap but it is also how the surface grows back.
The shim is 27 functions, not the 25 this plan claimed. The old count went by C# member name, so it missed the three entry points reached through EntryPoint aliases on JS_*-named declarations -- JSB_DupValue, JSB_FreeValue and JSB_NewFloat64. The plan warned that any audit of this surface has to honour EntryPoint and then got it wrong anyway, which is the argument for re-running check-exports.py over reading the tables. Re-measured the whole surface while correcting it: 104 live entry points, 59 JS_* and 27 shim and 16 atoms and 2 allocator, against 137 declarations before phase 1 started deleting. Phase 3's list drops to five names, and its two property remaps are now verified rather than proposed: ng's JS_GetProperty and JS_SetProperty are exact identities of the wrapper bodies in JSApi.cs. Its JS_AddIntrinsicOperators entry is restated -- making the no-bignum branch permanent is the fix, and it also retires the two operator-overloading atom stubs in unity_qjs.c.
check-exports.py answers "does the symbol exist", which is the failure that throws on first call. It cannot see the failure that doesn't: a signature ng changed still links, and just returns the wrong answer. check-signatures.py parses every prototype in quickjs.h, parses every live P/Invoke, and diffs return width, arity, discarded returns and bool parameter width. Run against the tree as it stands it reports eleven mismatches -- the five MIGRATION.md predicted, plus three it did not have. The next commit fixes them. Sabotaged before being trusted, the same way shim-test was: four doctored copies of quickjs.h, one per check, each of which it caught. Its blind spot is deliberate and recorded in the docstring -- it reads DllImport declarations, so callback delegates, enums and struct layouts stay hand-checked. Both scripts now read the live P/Invoke set through pinvoke.py rather than each carrying its own copy of the C# preprocessor evaluator. They need the same two things, and duplicating them is how the two checks would come to disagree about what "live" means.
Every live P/Invoke now matches its prototype in quickjs.h, and check-signatures.py reports 0 mismatches across all 60. The five names MIGRATION.md predicted are resolved: - JS_GetProperty/JS_SetProperty bind straight to ng's exports. Their bodies are exact identities of the wrapper methods they replace (quickjs.c:9266, :10807), and the *Internal functions they called are no longer public. - IsOperatorOverloadingSupported is permanently false, since ng removed the engine feature. That takes JS_AddIntrinsicOperators and both Operators atoms off the surface. The cost is bounded: the only consumers are two checks in TypeBindingInfo, so what goes is codegen emitting operator overloads, on a path ReactUnity does not use. jsb.isOperatorOverloadingSupported still answers correctly. - fileName/lineNumber are gone from ng's Error objects, which keeps only the Function.prototype getters. FormatException drops both reads: ng's stack already opens with " at <file>:<line>:<col>" in exactly the shape the deleted code assembled by hand (quickjs.c:8256). Verified on a real parse error from the suite, which now reports the same location plus a column and the frames above it. Three the plan had wrong or missing, all found by running the check: - JS_IsJobPending carried an `out JSContext pctx` that no QuickJS header has ever declared, Bellard's included. The callee ignored the second register; the caller read back an uninitialised local. - JS_SetConstructor returns int in ng and was declared void, discarding a failure. - JS_ToCStringLen2's cesu8 was marshalled as UnmanagedType.Bool, the four-byte Win32 BOOL, against a one-byte C bool. Two more from auditing what the check cannot see: - JSHostPromiseRejectionTracker's is_handled narrowed to a C bool. This is a reverse P/Invoke, so the managed signature decides how many bytes come off the register, and reading four where ng wrote one takes three undefined bytes with it. It guards the "Unhandled promise rejection" log, so the symptom would have been handled rejections reported as unhandled. Correct before ng: Bellard's really did pass JS_BOOL. - JS_EVAL_FLAG_STRIP is JS_EVAL_FLAG_ASYNC_LOAD in ng -- same bit, unrelated meaning. Nothing passed it, but it is bit 4 of the flag word phase 4's async loader has to set, so the old name was a trap laid in the next phase's path. Constants and layouts were re-derived rather than carried forward, all of them and not just the tags. SHORT_BIG_INT occupies 7 in ng, the slot FLOAT64 used to hold, so a stale tag block reads every double as a bigint. JSPropFlags, JSGPNFlags, JSCFunctionEnum, JSMemoryUsage and ten of the eleven delegates already agreed; JSEvalFlags and JSPropertyEnum.is_enumerable did not. The one bug here that no signature check would have caught: JSValue.IsString() tested only JS_TAG_STRING. ng represents `a + b` as an unflattened rope with its own tag and hands it out as an ordinary value -- which is why ng's own JS_IsString accepts both tags -- so every concatenated string reaching a binding was classified as a non-string. Reachable from any script, with no error to notice. Call sites follow from the declarations: 25 dropped JSContext arguments, 42 comparisons against 1/0 turned into boolean expressions, and 18 unreachable `isArray == -1` blocks removed, ng's bool having no room for the tri-state Bellard's returned for the proxy case.
Phase 2 hand-wrote JSB_ATOM_Operators and JSB_ATOM_Symbol_operatorSet to return JS_ATOM_NULL, because ng removed operator overloading and the atom table has no entry to generate an accessor from. It also predicted they would go once the C# stopped asking, and that check-exports.py would say so. It did, reporting both as stale. Every atom accessor the shim exports now comes from the one macro over quickjs-atom.h. shim-test keeps asserting ng defines none of the four atoms unity-jsb named, so the reasoning written against their absence fails loudly if a future ng brings one back -- the two CHECKs it loses here covered the stubs specifically, which no longer exist. shim-test still passes in Release and Debug; Debug is the one that runs ng's assert on a non-empty GC object list.
unity/quickjs/.gitignore is a Visual Studio template inherited from the merged quickjs repo, and two of its build-output rules, [Xx]64/ and [Xx]86/, also match Plugins/QuickJS/x64 and x86 -- the directories the native binaries actually ship from. The four files already in x64 and the two in x86 are tracked, so they survive the rule and everything looked fine. What does not work is adding a new one: it is silently untracked, and `git add` on it fails outright, which takes lint-staged and the pre-commit hook down with it. That is how this surfaced -- installing the quickjs-ng build could not be committed at all. Negates the two paths rather than deleting the rules, so a genuine Debug/Release/x64 build directory elsewhere in the package is still ignored. Verified both ways: a new file under Plugins/QuickJS/x64 and x86 now shows up, and unity/quickjs/x64/junk.obj is still matched by line 18. Gate 0 has eleven more binaries to install, two of them in these two directories.
Replaces the unity-jsb prebuilt with what native/quickjs produces: ng plus the ported shim. First time the engine this migration is about has actually been loaded by Unity. A passing suite would not on its own prove ng is what ran, so the evidence is structural: ng exports JS_GetProperty and JS_SetProperty, which are static inline in Bellard's and absent from the old DLL's exports, and the C# commit binds both directly. The old binary would have thrown EntryPointNotFoundException on the first property read. The suite is unchanged from the pre-migration baseline -- EditMode 340/348, PlayMode 689/701, 0 failed in both. This pairs with the C# commit and only works with it: either half alone is a broken tree, because ng and Bellard do not export the same names. That is inherent to the change rather than sloppy sequencing. The other eleven artifacts are still unity-jsb prebuilts of Bellard-era QuickJS, so every platform other than Windows x64 is now inconsistent with this C#. Gate 0 already tracks them. Nothing regressed, but nothing outside Windows x64 works on this branch either. Copied by hand, as gate 0 does for now.
Phase 3 marked done, with its exit criterion split honestly: the Unity suite passes against the ng DLL on Windows, and the IL2CPP player build the criterion also asks for cannot run here at all -- no installed editor has the IL2CPP player variation, so it needs a Hub module and a batch-mode build entry point scripts/unity does not have. Recorded as open rather than waved through, along with what it would have covered that the Editor run does not. The measured surface is re-counted at 99 live entry points, down from 104 by subtraction: seven declarations deleted, two added. Both checks now exit 0. "What fails silently" is rewritten around the seven rows phase 3 fixed, three of which are corrections to what this document claimed. The JS_IsJobPending row said width-only; the parameter it actually carried was in no QuickJS header ever. That is the second time an audit of this surface has been wrong in a way running the check would have caught, so the table now says where it comes from. New sections cover what phase 3 found -- the string-rope IsString bug, the is_handled callback width, the JS_EVAL_FLAG_STRIP bit that now means ASYNC_LOAD -- and what check-signatures.py does and cannot see. Also notes the gitignore trap in gate 0, since two of the eleven remaining artifacts land in the directories it silently swallowed, and records that operator overloading is now permanently unsupported, with the cost bounded to codegen emitting operator overloads.
quickjs-ng has no operator overloading -- the engine feature is gone, not
disabled -- so phase 3 pinned IsOperatorOverloadingSupported to false and
left the machinery behind the guard. This deletes it.
Gone from the runtime: OperatorDecl and its two defs, TypeRegister's six
RegisterOperator overloads with SubmitOperators and GetOperatorDecl,
ClassDecl's Add{Self,Left,Right}Operator, ScriptContext's Operators.create
lookup and CreateDefaultOperators, and JS_AddIntrinsicOperators with both
atoms. Gone from the binding generator: Prefs.enableOperatorOverloading and
alwaysEmitOperatorMethod, TypeTransform.EnableOperatorOverloading,
OperatorBindingInfo, the op_* switch in AddMethod, CodeGenHelper_Operator,
and the two places codegen emitted an AddSelfOperator call.
`op_*` methods still bind as ordinary static methods under those names,
which is what they already did with the guard false -- IsSupportedOperators
survives as IsOperatorMethod, whose one remaining caller is the
special-name filter in CollectMethods and has nothing to do with
overloading.
Three consequences worth naming:
- `jsb.isOperatorOverloadingSupported` is no longer defined. It read false,
and reads undefined now, so anything gating on it still takes the same
branch.
- TypeBindingInfo.preload was `operators.Count != 0` and nothing else ever
set it, so both call sites now pass false.
ScriptRuntime.AddTypeReference keeps the parameter: generated bindings
pass it and eager binding is not an operator concept.
- JSB_ATOM_Function went with _functionConstructor, whose only reader was
TypeRegister.GetConstructor(typeof(JSFunction)) on the operator path.
check-exports.py is 98 of 98 now, still 0 stale.
The WebGL jslib loses the same three entry points. Its build instructions
now pin typescript@5: TS 7 removed every option this tsconfig needs and has
no ES5 emit at all, so `npx -p typescript tsc` fails on the config rather
than emitting anything.
Compiles clean and the suite is unmoved -- EditMode 340/348, PlayMode
689/701, 0 failed.
gkurt/quickjs 30ceffe adds JS_SetModuleMetaFunc -- ECMA-262's HostGetImportMetaProperties -- and implements the JS_LoadModuleAsync that quickjs.h already named twice without declaring. The async loader is handed source text and compiles it itself, so unlike the synchronous loaders it never gives the host a JSModuleDef, and populating import.meta has always been host policy. Without the hook every module loaded asynchronously reports `import.meta.url` as undefined, which two tests assert on for every engine. Measured, not assumed: with the hook's call removed the fork's own suite reports 'undefined|undefined'. shim-test passes 241/241 in both configurations, ctest passes, and the DLL exports all seven module entry points.
The jslib reimplements the whole JSBDLL surface on the browser's engine, and nothing kept it in agreement with the C# that calls it. check-exports.py answers "does the library export this" for the native backend; this asks the same question of the other one, with dumpbin replaced by reading the generated jslib, and reports both directions. Both directions had something on the first run: - JS_GetProperty and JS_SetProperty are missing. Phase 3 deleted the two *Internal wrappers and bound ng's exports directly; the jslib never grew them, so on WebGL this branch does not link -- an undefined symbol on the first property read, which is every property read. - 26 entries are named by nothing: the 18 jsb_get_*/jsb_set_* declarations phase 1 removed, the two *Internal functions, JS_NewString, JSB_ATOM_fileName and lineNumber, and three bridge functions. Liveness is re-evaluated with a WebGL define set rather than the Editor one, which is not cosmetic: JS_SetBaseUrl is a real P/Invoke exactly where the jslib is, and an Editor-define reading would report it as an unused entry. It checks names only. The jslib has no header to compare signatures against, so arity and tag values stay a reading exercise -- which is how it came to hold Bellard's tag numbers and the pctx argument on JS_IsJobPending that no QuickJS header has ever declared. Fixed in the next commit; this one is the check, and it exits 1 as it stands.
The synchronous loader has to return a JSModuleDef there and then, so a host that fetches over a network cannot satisfy it without blocking - which is the whole reason for this migration. This binds the async one. JSApi+AsyncModules.cs declares the six entry points and the two callbacks; AsyncModuleLoader owns the trampolines, the GCHandle the engine carries as its opaque pointer, and a path-style normalizer for anything a subclass declines to resolve. ModuleLoadCompletion is deliberately the same shape as Jint's, so the two loaders read alike. ScriptContext.EvalModuleAsync starts a graph and returns; the six declarations were checked against quickjs.h by check-signatures.py along with the other 60. Four things in it are load-bearing: - Nothing has evaluated when EvalModuleAsync returns. Draining the job queue afterwards finishes a graph that needs nothing from the loader, which is every bundle; one waiting on a request finishes over later updates. - A rejected graph is reported once. Attaching the handler also marks the rejection handled, so this replaces the tracker's "unhandled promise rejection" rather than adding a second line. The handler is created once per context: JSB_NewCFunction roots its delegate for good, so a fresh one per graph would leak a GCHandle on every hot reload. - No exception may cross back into C. Unwinding through the engine's frames would leave the load handle unsettled and hang the graph for good. - The delegates stay rooted and the GCHandle is freed only after the runtime, which still holds the pointer it backs. Two fixes came out of writing it. JSModuleNormalizeFunc and JSModuleLoaderFunc declared their module names as [MarshalAs(UnmanagedType.LPStr)] string - the ANSI code page against a const char* the engine encodes as UTF-8, so any module path outside ASCII arrived mangled. Both now take IntPtr and decode through a new JSApi.GetString(IntPtr). And import.meta no longer needs the compile-then-set-then-eval dance: the engine calls the host back instead.
check-jslib.py is green: 105 of 105 with nothing unused. The two directions it reported are both fixed. JS_GetProperty and JS_SetProperty are implemented, which is what stopped this branch linking for WebGL at all. JS_SetProperty takes ng's shape: no flags argument, since JS_PROP_THROW is the only combination the C# ever passed, and an int return rather than a bool. And 24 entries nothing declares are gone - the 18 jsb_get_*/jsb_set_* struct accessors phase 1 deleted, JS_NewString, JSB_ATOM_fileName, JSB_ATOM_lineNumber and three bridge functions - with the two *Internal property functions renamed rather than dropped. The signature work the check cannot do was read off the C#: the tag block re-derived from ng's enum, the JS_WRITE_OBJ/JS_READ_OBJ flags, JSEvalFlags with bit 4 renamed to ASYNC_LOAD, JSPropFlags, and the arities of JS_IsArray, JS_IsError, JS_IsJobPending and JS_SetConstructor. Widths do not matter on this side - wasm passes a C bool as an i32, so nothing like phase 3's is_handled exists here - but arity does, and silently: JS_IsArray(ctx, val) against a caller passing one argument reads val out of the ctx slot. ng dropped the JSContext from JS_IsArray and JS_IsError while references are kept per runtime, so there is nothing left to resolve a JSValue against; getAnyValue searches the live runtimes, and two of them would be ambiguous, which needs JSWorker, which needs threads WebGL does not have. The six async module entry points are here because the Emscripten link needs them, not because they work. This backend has no module scope at all: context.evaluate is an eval inside the sandbox iframe, wrapped in with(globals) so a bundle sees ReactUnity's globals, and eval cannot run import or export. ES module syntax has never worked on WebGL and this does not change that - each entry point reports a clear failure instead of pretending. Giving the iframe a module realm and reconciling it with a globals proxy a module cannot see is its own piece of work. The build instructions now pin typescript@5: TS 7 removed every option this tsconfig needs and has no ES5 emit at all, which Emscripten still requires, so `npx -p typescript tsc` fails on the config. That the pipeline was otherwise intact was checked first - rebuilding the untouched source reproduced the committed jslib byte for byte.
QuickJSModuleLoader resolves an import specifier against the module importing it and fetches each one with UnityWebRequest over Dispatcher.StartDeferred, the way JintModuleLoader does - the two are deliberately the same shape, because they answer the same question and a divergence between them should be visible by reading them side by side. QuickJSEngine installs it before anything executes, and now claims EngineCapabilities.ModuleResolution, which retires ModuleCompat's dynamic-import rewrite and the host import hook for this engine. Not on WebGL: that backend has no QuickJS and evaluates through eval, which cannot run module syntax at all, so it keeps the hook and RewriteDynamicImports stays alive for it. AStaticImportGraphLoadsAsynchronously no longer skips QuickJS, which is the evidence that this works and that it works asynchronously: the test asserts nothing has evaluated when ExecuteScript returns, and the synchronous loader would have resolved and run the whole two-hop graph inline. It also asserts a fetched module keeps a whole url in import.meta.url, which is what the relative import below it resolves against. The suite is 690/701 in PlayMode against a 689/701 baseline - one more test, not one fewer skip. OnlyQuickJSCannotResolveSpecifiersItself becomes EveryEngineResolvesSpecifiersItself, since that is now the invariant the import hook's existence is measured against. One unrelated quirk had to be worked around rather than fixed: QuickJS marshals '' back as null, JSApi.GetString returning null for any zero-length string, so the test's probe coalesces. Naming it here because the probe now hides it.
The measured surface is 104 live P/Invokes on the native backend and 105 on WebGL, both fully satisfied, with all 66 JS_* declarations matching quickjs.h. Four corrections to what this document claimed: - It said the async loader would be *easier* on WebGL, the browser having real promises and real import(). That backend has no module scope to put one in - context.evaluate is an eval in a sandbox iframe wrapped in with(globals) - so ES module syntax has never worked there at all. - "Two implementations drifting" is half closed rather than open: check-jslib.py holds the names in agreement both ways. Signatures are still unenforced. - Gate 0's export list and the fork's assertion count were both stale. - The four-atoms section described operator overloading as guarded, not gone. Plus what phase 4 changed: the module loader and the four things about it that are load-bearing, the two marshalling fixes writing it turned up, the jslib reconciliation and what its check can and cannot see, the operator-overloading removal and the exact capability it costs, and the jslib build failing under TypeScript 7.
TypeDB._DynamicOperatorInvoke was a [MonoPInvokeCallback] whose entire body was `throw new NotImplementedException()`, referenced by nothing. It is the only thing the operator-overloading removal left behind, and dropping it takes one more reverse-P/Invoke off the IL2CPP surface. Two operator-named things stay, and both are load-bearing rather than residue. The op_* switch in CodeGenHelper_Method emits `a + b` for a bound operator method, which is why one compiles at all -- C# refuses to call op_Addition by name -- and IsOperatorMethod is the filter that lets it past the special-name check. main did the same with phase 3's guard already false, so keeping them preserves that behaviour rather than adding to it.
JSApi.GetString(ctx, ptr, len) returned null for len == 0, so QuickJS was the one engine that handed C# null for a JS empty string -- and since that is the marshaller every JS-to-C# string goes through, including the object path in Values.cs, it made '' unrepresentable. Not an ng regression: main has the same line. A zero length is now the empty string, and null means only what it should, that there is no buffer. Nothing depended on the old behaviour. The two callers that read that null as a signal both get more correct: JSContext.ToStringSafe used it to detect a toString that threw and would fire its take-and-drop on a legitimate '', and js_get_classvalue(out Type) fed it to TypeDB.GetType, which answers the same for null and "". Characterised before fixing, which narrowed it: JS null and undefined already arrived as null despite js_get_primitive carrying a bare `// no check`, and non-ASCII already round-tripped, Marshal.PtrToStringAnsi resolving to UTF-8 under Mono and IL2CPP. The empty string was the whole divergence, and the C#-to-script direction was already correct -- so that half of the new test is a regression guard rather than a fix. One behavioural consequence worth knowing: InlineStyles.SaveValue treats null as "remove this property", so `style.x = ''` from script used to clear a declaration on QuickJS and now sets it to empty. That is convergence -- Jint and ClearScript always delivered "" there -- and the suite covers it. StringMarshallingTests pins both directions for all three engines; reverting the fix fails exactly one of them on exactly one engine. ModuleSyntaxTests drops the `?? ""` this quirk forced on its probe. EditMode 346/354 (was 340/348, all six new tests passing), PlayMode 690/701, 0 failed.
Reported as a crash on the second play-mode run after loading a Vite graph, and the crash log has the detail that makes it lethal: "Entering Playmode with Reload Domain disabled", twice. With domain reload off, managed closures survive play-mode exit, so a callback from the previous run can still fire. ModuleLoadCompletion captured a raw JSContext and JSModuleLoadHandle and was handed to a UnityWebRequest callback, with nothing checking whether the runtime still existed. Two failure modes, both real: - Use-after-free. A response arriving after teardown calls JS_FulfillModuleLoad on a freed context. That matches the crash: native, inside JS_ExecutePendingJob. - A leak. An in-flight load never settles, so its handle and the graph's promise are still live when JSB_FreeRuntime runs -- the "gc object leaks" path. The completion is now a ticket: the engine state lives in the loader, keyed by id, so settling is idempotent and a ticket held past the runtime's lifetime settles nothing. Teardown is two phases and the order is load-bearing both ways: Close() runs before Runtime.Shutdown() and rejects what is still in flight while the context is alive, Dispose() runs after and frees the GCHandle the engine was holding, as before. Also corrects a comment the log disproved. It claimed attaching a rejection handler replaces the tracker's "unhandled promise rejection"; a rejected Vite graph logs both, because the promises the graph rejects inside itself are separate from the one attached to. Compile-verified only. The suite cannot run on this machine: tests/ needs the 6000.1 line and no 6000.1 editor is installed, while the 6000.5 line fails obsolete-as-error inside com.unity.inputsystem and test-framework.performance before any test starts. Every error in that run was in a Unity package, none in this code.
Consequence of reading the editor version from ProjectVersion.txt: Unity rewrites that file on open and the churn list deliberately left it alone, so one `UNITY_VERSION=6000.5.9f1` run to check something would silently make 6000.5 the project's version from then on -- and for tests/ that is an editor which cannot run its suite at all. The file is now snapshotted and restored, but only when the override is what asked for a different editor; an ordinary run still leaves the stamp tracking whatever actually ran, which is what keeps the GUI off the modal "Project Upgrade Required" dialog. CLAUDE.md gets the sharper version of the 6000.5 trap, including the part that misled me: `compile` passes there because it only builds the project's own assemblies, while `test` pulls in the package editor assemblies that fail.
The Editor is always Mono, so nothing `compile` or `test` reports says anything about the backend that ships. A P/Invoke stub the AOT compiler had to generate from a signature alone, a reverse callback it never saw, a type the managed stripper deleted -- that is most of what the QuickJS binding is made of, and a player is the only place any of it appears. `pnpm unity player tests --backend il2cpp` builds a development standalone player and runs EngineProbe in it. The probe creates every engine in the build and runs seven checks each: evaluate, strings (including '', which QuickJS got wrong until 9ac748b), a Func and an Action called from JS, a type reference through the reflect binder, a global round trip, and a module. Both halves are gated on REACT_UNITY_DEVELOPER, so none of it ships. Measured on 6000.5.9f1, tests/: il2cpp 309 s build QuickJS 7/7, Jint 7/7 mono 15 s build QuickJS 7/7, Jint 7/7, ClearScript 7/7 Two engines against three is the gating working: ClearScript is compiled out under IL2CPP by design, and the probe is what shows you that rather than assuming it. Both guards were checked by making them fail. Expecting 3 from `1 + 1` reported `evaluate: expected 3, got 2` on all three engines and exited 1. Pointing a `--backend mono` run at the IL2CPP player reported the mismatch and exited 1 -- the probe prints the backend the *player* was compiled with, not the one the run asked for, because a run that quietly fell back to Mono is not a pass. Not on CI: it needs a C++ toolchain and the IL2CPP module on the runner. Snapshot asserts now go Inconclusive when the .snapshots folder is not there instead of laying down a fresh baseline beside the executable and passing -- a player has no project folder, so that path was a false green waiting to happen. UnityConnectSettings.asset joins the restore list; a player build reserialises it into a 6000.5-only shape.
…ed sample `com.unity.pipeline` is agent tooling of this checkout -- it is what lets Unity`s CLI drive an Editor we have open -- and b822544 added it to the sample that ships to users, along with an EditorPipelineManager asset under Assets/Settings/Pipeline. Dropping the package while copying the asset would have been worse than either: Unity reports a dangling script reference as a broken component, not as a missing package. So both go, and verify() asserts both, since the whole point of prepare.mts is that the export cannot break in a way nothing here catches. Also unpins biome.jsonc`s $schema from 2.5.5, which the 2.5.6 pin in package.json made an error rather than a warning -- `pnpm check` is what CI runs.
Two things, and the first is what exposed the second. `com.unity.pipeline` in tests/ too, so Unity`s CLI can drive an Editor open on it the way it already can for kitchen-sink. Verified end to end: the Editor comes up ready on port 7801, exposes 142 commands, and `unity command run_tests -- --mode EditMode --filter <name>` returned 6/6 across all three engines as structured results, no results file involved. Adding it also gave the resolver a reason to re-resolve from scratch, which dropped every package back to the manifest`s minimums -- and `com.unity.inputsystem` 1.14.2 does not compile on 6000.5: nine CS0619s in its editor assemblies from the EntityId migration (`GetInstanceID`, `GetAssetPath(int)`, `InstanceIDToObject(int)`), which report zero tests rather than a red suite. That is the whole of the "tests/ cannot run on 6000.5" story. 1.14.2 was only ever a floor, and 6000.5 was stepping over it to 1.20.0 whenever the lockfile let it, so whether a run worked depended on whether anything had invalidated the lock. The floors are now raised past it -- inputsystem 1.20.0, test-framework.performance 3.5.0, testtools.codecoverage 1.3.0 -- each still declaring unity 6000.0 or older, so CI can resolve them. test-framework, ugui and ext.nunit are left alone: they are builtin, the editor supplies its own, and pinning the local answer is exactly how you get the 6000-only manifest that will not resolve on 6000.1. Green on 6000.5.9f1 with no UNITY_VERSION, which is why the version stamp moves there -- 6000.1.4f1 is not installed on this machine, so the old stamp made every command need an override: EditMode 346/354 (8 skipped, 0 failed) PlayMode 690/701 (11 skipped, 0 failed) Both match the last known-good numbers exactly. CI at 6000.0.51f1/6000.1.9f1 is still the only proof of the matrix, and the raised floors have only been measured on 6000.5. One flake worth knowing: the first run after the package graph changed died with 0xC0000005 inside Unity`s own EditorWindow.Close -> DockArea.RemoveTab -> ContainerWindow.Close teardown, while it was also re-importing. The identical run straight after was green and it has not come back. Restore now reaches package assets, not just project ones. A PlayMode run empties the glyph table of unity/core/Assets/Material Icons/Material Icons SDF - TMP.asset -- 375 lines of metrics -- on its way to repopulating the dynamic atlas at runtime. It belongs to no project, so nothing project-relative was reaching it, and it *ships*: committing it publishes a font with no glyphs. Both suite runs above reported restoring it.
686 lines of C# in their own asmdef plus a 219-line client, replaced by a package dependency both projects now have. Every action had an equivalent, verified against a live Editor rather than assumed: status -> editor_status, logs -> console, refresh -> recompile, test -> run_tests, play/stop -> editor_play/editor_stop, screenshot -> screenshot + capture_game_view/capture_scene_view, menu -> menu. The Pipeline package goes far past that -- build, eval, player settings, target switching, package add/remove, the scene and asset surface, and a --runtime mode that attaches to a running development player, which is a thing the bridge could never have done. run_tests even returns a structured summary plus a row per test, so it needs no results file: 6/6 across all three engines on a filtered EditMode run through the open tests/ Editor. Two things genuinely lost, both small and both now documented. `quit` had deferred its response a few frames so the client got an answer before the process went away; `eval "EditorApplication.Exit(0);"` does quit the Editor but reports COMMAND_FAILED because the server dies mid-reply, so confirm with `unity status`. And a repo-specific action in a live Editor is now a [CliCommand] static method rather than a handler here -- which is the better shape anyway, since `unity list` discovers it with no release of anything. What did *not* move is batch mode. `unity test` and `unity build` open the Editor without snapshotting the files it rewrites for having been opened, and on this repo that upgrades tests/Packages/manifest.json into a shape CI cannot resolve -- zero tests reported as a pass. compile, test and player stay here for that reason alone. `pnpm unity compile tests` is clean with the asmdef gone. The generated ReactUnity.Editor.AgentBridge.csproj and the .slnx that referenced it are deleted too; both are gitignored and Unity regenerates them.
Phase 3 asked for a desktop IL2CPP player build and got "blocked on tooling" -- no installed editor had the IL2CPP player variation, and scripts/unity had no batch-mode build entry point. Both are gone: the module is installed, and `pnpm unity player` builds the player and probes the engines inside it. QuickJS and Jint pass all seven probe checks under IL2CPP on Windows x64, and the same player built as Mono passes with ClearScript alongside them. The section that argued no new stripping surface was introduced now reports a measurement instead. Left open deliberately, and said so in both the phase note and the risk: this covers Windows x64 only. Android and iOS have their own stripping and their own P/Invoke conventions, and nothing has built those artifacts yet.
CI is ubuntu-latest, so it loads Plugins/QuickJS/x64/libquickjs.so -- which was still the Bellard-era binary from unity-jsb. Every QuickJS fixture died at BeforeTest with `EntryPointNotFoundException: JS_SetProperty`, on all four matrix jobs, which is the same reasoning phase 3 used in reverse: JS_GetProperty and JS_SetProperty are `static inline` in Bellard`s and absent from its exports, the C# binds both directly, so the old binary cannot satisfy it. Built from native/quickjs in WSL: 241 atoms and 0 failures from shim-test, and 104 of 104 live P/Invoke names satisfied against the ELF. Nine of the eleven shipped artifacts are still the old engine and will fail the same way on their platforms -- Windows x86, four WSA, three Android, iOS, macOS. check-exports.py could not report that honestly until now. `have_projects()` asked whether Unity had generated the .csproj, and Unity writes an absolute path for every `<Compile Include>` outside the project -- which is all of the quickjs C#. Read the same tree through /mnt and none of those resolve, so the live set came back missing most of the surface and the drift guard named every function in it: a report that reads like a regression and is not one. It now also requires that at least one of those paths resolve, and falls back to the committed pinvoke-native.txt when they do not. Both paths agree at 104/104 -- the live one against the Windows DLL, the committed one against the Linux .so.
TegamiThis repository uses Tegami to manage releases. When your changes affect published packages, add a changelog file under Create a changelog → · Changelog format Changelogs in this PR
Run Managed by Tegami. |
Unit Tests 6000.1.9f1 2 files ±0 205 suites +3 17m 12s ⏱️ -15s Results for commit 969af6d. ± Comparison against base commit 1dc5436. This pull request removes 100 and adds 17 tests. Note that renamed tests count towards both.This pull request removes 14 skipped tests and adds 8 skipped tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
… legs it could not
First real run of native-quickjs.yml. Six of eleven legs green, and the failures
were all in the workflow rather than the port.
Installed, each verified at 104 of 104 live P/Invoke names:
Android arm64-v8a, armeabi-v7a built by the NDK, no glibc dependency
macOS universal bundle 241 atoms and 104/104 checked on the Mac itself
Not installed, and why:
linux-x64 built fine, records a GLIBC_2.38 floor, and the Unity test
container is older -- so Unity could not load it at all. That is
what turned the earlier EntryPointNotFoundException into
DllNotFoundException; right engine, unloadable binary. The .so in
the tree is my WSL build with the same problem, left there rather
than reverted to the old engine, because the next run replaces it.
Android x86 still the old engine. Unity 6 does not offer 32-bit x86 for
Android and the matrix builds x86_64 instead, but that is a new
path needing a .meta Unity has to generate, so it is a separate
step rather than a hand-written importer file.
Three workflow fixes:
- **The Windows and WSA legs never configured.** `-G "Visual Studio 17 2022"`,
and windows-latest ships VS 2026 now: "could not find any instance of Visual
Studio". The generator is gone entirely; `-A <arch>` is all those legs need and
CMake picks the newest it finds, which does not go stale again.
- **linux-x64 now builds in an ubuntu:22.04 container.** glibc is backward
compatible and never forward, so the floor a binary records is the oldest system
it loads on, and a runner-native build on 24.04 records 2.39. 22.04 gives 2.35.
Worth knowing: the binary this replaces asked for 2.17, so users on anything
older than 22.04 lose support -- an older base is the fix if that matters.
- **iOS reached libtool with `${EFFECTIVE_PLATFORM_NAME}` still in the path.**
Under the Xcode generator TARGET_FILE expands to a path holding that
placeholder, which Xcode substitutes in the script phase`s shell -- and VERBATIM
escapes the `$` so it never gets the chance. The merge step is no longer
VERBATIM. This is the step that stops iOS shipping an archive with the shim
alone and no engine in it, so it stays untested until that leg goes green.
Ten of twelve legs green after the last round. Both stragglers were tooling. **iOS built, and the merge worked** -- 591 symbols in the archive, where an unmerged one would hold only the shim`s handful. The exports check then matched none of the 104 wanted names, because the underscore strip was keyed on the detected format being MACHO and a Mach-O *static archive* detects as ARCHIVE. Mach-O prefixes every C symbol with an underscore, so 104 of 104 were being compared against `_JS_NewRuntime` and friends. Both spellings are now recorded, which is additive: the PE, ELF and Mach-O readers all still report 104/104. That check is the only thing standing between us and an iOS archive holding the shim and no engine, so it mattered that it was failing loudly rather than passing -- a silent version of this bug would have shipped. **linux-x64 got as far as the exports check and found no `python`.** A bare ubuntu:22.04 has neither, and the shared step calls `python`, so the container now installs python3 and python-is-python3. Unity Tests is still red, still 1662 DllNotFoundException and nothing else: the Linux .so in the tree is the GLIBC_2.38 build. The container leg replaces it once that run goes green.
…inux native-quickjs.yml is green on all twelve legs, so these come from CI rather than from someone`s machine. Every one checked at 104 of 104 live P/Invoke names, on a platform that can read its format: PE here, ELF in WSL, Mach-O and the archive on the macOS runners. iOS is the one worth pointing at. 1182 symbols in the archive and 104 of 104 satisfied -- the first time that artifact has been verified rather than assumed. An unmerged archive holds the shim alone, and the check now catches that. Linux comes back at GLIBC_2.35 instead of 2.38, which is the container leg doing its job. That is the file Unity Tests has been failing on: 1662 DllNotFoundException with no other cause behind it. Committing this needed the .gitignore fix that came with it. The Visual Studio template`s `[Xx]64/` and `[Xx]86/` rules match *four* directories under Plugins/QuickJS, not the two phase 3 negated -- WSA/x64 and WSA/x86 as well as the desktop pair. Tracked files survive an ignored directory, so nothing looked wrong until `git add` was handed the directory itself, which is what lint-staged does: "The following paths are ignored", hook fails, commit refused, and biome had already passed. The negations use `**/` now. Worth knowing while diagnosing the next one: `git check-ignore <dir>` reports "not ignored" for these while `git add` refuses them; only `--no-index` with a trailing slash tells the truth. Two things this does not cover. `x64/quickjs.dll` is still the locally-built ng DLL rather than the CI one -- same source, verified 104/104, but the Editor open on kitchen-sink holds the file and Windows will not let it be replaced. One `cp` once that Editor is closed; until then the Windows binary is the only one whose provenance is a laptop. Two old-engine binaries remain, both for targets Unity 6 no longer offers: WSA/ARM (the Windows SDK dropped 32-bit ARM) and Android/libs/x86 (the matrix builds x86_64 instead). Removing those and adding x86_64 is a separate change: a new Android plugin needs `CPU: X86_64` on its importer, which is Unity`s to generate rather than mine to hand-write.
Unit Tests 6000.0.51f1 2 files ±0 205 suites +3 15m 11s ⏱️ - 2m 41s Results for commit 3221dda. ± Comparison against base commit 1dc5436. This pull request removes 96 and adds 17 tests. Note that renamed tests count towards both.This pull request removes 14 skipped tests and adds 8 skipped tests. Note that renamed tests count towards both. |
…dead targets out Closes the three gaps the last artifact commit listed. `x64/quickjs.dll` now comes from CI like the other ten. Same source as the local build it replaces and the same 104 of 104, but the provenance is a workflow run rather than a laptop, which is what the exit criterion asks for. It only needed the Editor holding the file to be closed. **Android x86_64 added**, at 104 of 104. Unity 6 targets that ABI; it was missing because unity-jsb shipped 32-bit `x86` instead, which Unity 6 does not offer at all. Adding a native plugin is not a file copy -- it needs `CPU: X86_64` on the importer, and a batch import writes only a stub .meta with a guid and no PluginImporter block. So this went through a live Editor: `unity command eval_file` against PluginImporter, reporting back `android=True cpu=X86_64 any=False`. Hand-writing that .meta would have been the wrong tool; note the generated one uses 6000.5`s newer platformData shape rather than the older list form the arm64 and armeabi metas still carry, which Unity migrates on its own whenever those are reimported. **WSA/ARM and Android/libs/x86 deleted.** Both were still Bellard-era binaries, and both are for targets Unity 6 no longer offers -- the Windows SDK dropped 32-bit ARM (MSB8087) and Android x86 is not a build option. Leaving an old-engine binary in the package is worse than shipping none: it loads and then throws EntryPointNotFoundException on the first property read. Eleven artifacts, as MIGRATION.md predicted when it cut twelve down. Every binary in Plugins/QuickJS is now quickjs-ng, from CI, and verified.
`gkurt/quickjs` `30ceffe` is now also `v0.16.2-reactunity.1` -- an annotated tag whose message says what the fork adds on top of quickjs-ng 0.16.2 and why: JS_SetModuleMetaFunc, which is ECMA-262`s HostGetImportMetaProperties and the only way a host using the async loader can populate import.meta.url, and JS_LoadModuleAsync, which quickjs.h named twice and never declared. It points at the pinned commit, verified: object=30ceffe, type=commit. The CMakeLists still pins the SHA, not the tag name. That is deliberate and the comment says so -- a tag can be moved and a SHA cannot, so the tag is for identity and permanence while the SHA stays the thing resolution uses. What the tag buys is that the commit can no longer be lost to a rebase or GC, and that anyone reading the pin can find out what it is without spelunking a diff. Closes half the standing risk. The upstream PR is still open work, and still wants coordinating with quickjs-ng#1522.
Three documents still described the state before this branch, and CLAUDE.md`s
version asserted the exact inverse of what is now true -- "binds unity-jsb`s fork
of Bellard-era QuickJS, **not** quickjs-ng". Anyone trusting that would have
reasoned about the wrong engine.
What each now says:
- **CLAUDE.md** -- binds quickjs-ng; the C# is still unity-jsb`s design and says
so (namespace, four jsb asmdefs, JSB_* shim), but nothing is fetched from or
linked against unity-jsb any more. Names the pin, and the WebGL exception.
- **unity/quickjs/README.md**, which is what users read on OpenUPM -- separates
the two claims that were tangled into one sentence: the *binding* is a fork of
unity-jsb, the *engine* is quickjs-ng, and they are different engines rather
than two versions of one. Leads with what that buys, since a user cares about
non-blocking `import` and not about the lineage.
- **native/quickjs/README.md** -- all eleven artifacts built by CI and installed,
so the platform table loses its "not yet -- needs a Mac" column. The `-G
"Visual Studio 17 2022"` in the quickstart is gone, which is more than a
refresh: that is the flag that broke five CI legs when windows-latest moved to
VS 2026, and the README was still handing it to readers.
Also captured there, because both cost a CI round trip to find and neither is
guessable: why Linux is built in an ubuntu:22.04 container (glibc records a
floor, not a ceiling), and why the iOS libtool merge must not be VERBATIM (Xcode
substitutes ${EFFECTIVE_PLATFORM_NAME} in the script phase, VERBATIM escapes the
`$` before it can). Plus what it takes to add an Android ABI -- an importer
setting through a live Editor, not a file copy.
…esktop
ES module syntax had never worked on that backend. There is no QuickJS in a
WebGL build - jsbplugin.jslib reimplements the whole JSBDLL surface on the
browser's own engine - and its `evaluate` is an `eval` wrapped in `with
(globals)`, which cannot run `import` or `export` at all. So the six async
loader entry points existed only because the Emscripten link needs them, and
ModuleResolution was claimed everywhere except there.
This document's own note said fixing it needed "a module realm in the iframe",
and that was the wrong shape - it is what made the work look bigger than it is.
The realm was never the problem; the globals were.
The host half is unchanged and shared with desktop. QuickJSModuleLoader resolves
and fetches, JS_FulfillModuleLoad settles each load, so `import './x'` obeys
ReactUnity's own paths on both. What differs is who links and evaluates: each
module is assembled into a blob url with its specifiers rewritten to its
dependencies' urls, and the root is imported through `new Function('url',
'return import(url);')` - the page's realm, the same one `evaluate` runs in, so
a module and a script produce objects of one realm and the `instanceof Error`
checks all through the jslib keep working. Live bindings, top-level await and
the module cache come from the browser for free.
A module cannot see the globals proxy the rest of the backend runs inside, so
the proxy is published on the page under one key and every module opens with a
generated prelude of the host globals it mentions. Four rules make that hold up,
and each was a bug before it was a rule:
- `var`, not `const`. A bundle may declare `var URL` itself, and two `var`
declarations of one name are legal where two lexical ones are a SyntaxError.
The scanner collects let/const/class/function and import bindings so the
prelude skips those.
- A bare `__reactunity_jsb__[id]`, not `globalThis[...]`. `globalThis` is itself
one of the host globals, so a module that mentions it gets a `var` for it -
which hoists over the whole module and would leave the prelude reading a
property of undefined. Found by re-reading, not by a test; there is one now.
- Only the names the module mentions as free identifiers, collected by the same
pass that finds the specifiers rather than a regex per global over the whole
bundle. A name never declared can never collide.
- The prelude rides on the source's own first line, with no newline of its own,
so no line number moves. That matters most for the source map the bundle
arrived with, which cannot be corrected from here once it is off by one.
Dynamic `import()` is rewritten too, in modules and in scripts: left alone the
browser resolves it against the blob url and fetches it itself, which is not
where this backend's modules live. In a module the hook carries that module's
url as the referrer, so a relative specifier resolves against it.
One deliberate divergence from desktop: a cycle is refused, naming the path. A
blob url can only be minted for text that is already final and a cycle's is not
- each side needs the other's url first. Bundler output has no ESM cycles;
hand-written graphs can.
Also here, because the reject path needs it: JSB_ThrowError and its four
siblings now record the error as the context's pending exception, and the four
that were discarding their message now use it. AsyncModuleLoader rejects a load
by throwing and taking straight back through JS_GetException, which returned
whatever was thrown before it.
Tests: 45, in .source/jsbplugin.test.mjs, run by CI. They exercise the
*generated* jslib - extract.mjs cuts the members out by brace matching and
substitutes the {{{ makeDynCall }}} macros for a direct call, because an
Emscripten library object cannot be imported - and drive the graph loader
through the platform's own dynamic import, with data urls standing in for blob
urls since Node's loader refuses those. Five real bugs came out of writing them.
CI also rebuilds the jslib and diffs it, which nothing checked before: it is
generated from .source, and a hand-edit or a forgotten rebuild would have
shipped.
Not verified in a player: the WebGL build module is not installed here. What
that leaves open is the C boundary - the two new dyncall signatures, whether
IL2CPP passes JSModuleLoadHandle as a flattened i32 the way clang's wasm ABI
says, and whether Unity's WebGL output permits `new Function` and blob-url
imports. The docs site's player is a hand-built artifact hosted outside this
repo, so rebuilding it is both how that site picks this up and the obvious place
to confirm it.
Consequence worth knowing: with all three engines now claiming
ModuleResolution, ScriptContext.CreateImportHook and
ModuleCompat.RewriteDynamicImports are unreachable. Left in place as the
extension point for an engine that executes modules without resolving
specifiers, and still covered by ModuleCompatTests, but they are dead code
today.
Measured on 6000.5.9f1 while checking the WebGL module work for regressions: EditMode still 346/354 with zero failures, but PlayMode is 675/701 with 15 failures - ButtonTests and InputTests across all three engines, each a StackOverflowException in BeforeTest. It is not from this branch's local changes: it reproduces with every one of them stashed, and CI's 6000.1.9f1 passes the same commit. So it reads as specific to the 6000.5 line, which is exactly the editor these notes told you was safe to trust. Both notes had already been rewritten once, from 'the 6000.5 line cannot run tests/' to 'both suites are green there'. The truth is in between and the useful form of it is operational: 6000.5 is fine for compile and EditMode, and a PlayMode failure in those two fixtures there is the editor rather than your change.
It stood in for dynamic import on engines that could execute a module but not resolve a specifier. QuickJS was the last one, and only on WebGL; that backend now drives the same asynchronous loader as the rest, so all three engines claim EngineCapabilities.ModuleResolution and every branch guarded on its absence is dead. Gone: ScriptContext.CreateImportHook, the __reactunity_load_script global it installed and the LoadScript behind it, ModuleCompat.RewriteDynamicImports with its DynamicImport regex and IsInsideStringLiteral scanner, and the ImportHook constant. Four tests went with the rewriter. Kept as an extension point is the alternative, and it did not survive looking at how an engine is chosen: ScriptContext builds its factory from an `internal` switch over a closed enum, with no injection point, so a third party cannot supply an engine at all. Whoever adds the next one is editing these files anyway, and would write the hook against what that engine actually needs rather than inherit one shaped around a QuickJS-on-WebGL constraint that no longer exists. Git history is the better home for that. ModuleCompat is down to NeedsModuleScope, which is still load-bearing: MakeExecutable used it to decide whether a chunk needs module scope, and that was always separate from rewriting. Which is why MakeExecutable is now DocumentTypeOf. With the rewrite gone it returned its own argument unchanged, so a name promising to make something executable was describing the one thing it no longer did. It takes the requested document type and returns it, promoted to Module if the code needs module scope, and both call sites pass the original string through themselves. Also dropped: an unused `const string Url` that predates this change and was the last field in ModuleCompatTests. Measured on 6000.5.9f1. EditMode 350/350 counting skips (342 passed, 8 skipped, zero failures) - four fewer tests than before, which is exactly the four removed. PlayMode unchanged at 675/701 with the same 15 pre-existing failures in ButtonTests and InputTests, none of them touched either way.
…re here quickjs-ng defaults its stack limit to 1 MB (JS_DEFAULT_STACK_SIZE) and measures against the stack of whichever thread created the runtime. Ours is Unity's main thread - already deep in Unity's own frames, and deeper inside a coroutine - where less than 1 MB is left. So the guard never fires. A deeply recursive script exhausts the real stack instead; Mono notices at the managed-to-native boundary and raises a StackOverflowException, which cannot be caught and takes the run with it. Nothing in this repo had ever called JS_SetMaxStackSize. It is exported by the shipped library already, so binding it needs no native rebuild. ScriptRuntime.MaxStackSize exposes it and defaults to 0, which means "leave ng's limit alone" and is deliberately not forwarded: ng reads a zero stack_size as *unlimited*, which is worse than the default. Off by default because the safe values are not free. Measured on 6000.5.9f1, main thread inside a PlayMode coroutine: a 768 KB cap turns the overflow into an ordinary catchable RangeError, a 1 MB cap does not raise at all. So the headroom there is between the two - and the suite's own JSX transform needs more than that, because it runs Babel through QuickJS and Babel's parser is far deeper than anything a built bundle does. Capping by default would trade an uncatchable crash on one editor for 15 red tests on the editors where Babel currently fits. The cap is there for user scripts, which is where it is worth having. Found while chasing the 15 PlayMode failures on 6000.5.9f1. Those are not fixed and are not a bug: CodeTransformer is a static singleton built with JavascriptEngineType.Auto, so every fixture's JSX transform runs through QuickJS whatever engine parameterises the test - which is why the Jint and ClearScript rows report a QuickJS stack, and it is not contamination either, since running InputTests alone fails all 9 including the first. Babel simply needs marginally more main-thread stack than 6000.5 leaves it, and marginally less than 6000.1 does. The skill note now carries the measurements and says why capping is the wrong fix. Surface: 104 -> 105 native names, 105 -> 106 for WebGL, both regenerated. check-signatures now checks 67 declarations against quickjs.h with no mismatch, so the new one's width and arity are verified against the header. The jslib entry is a no-op with the reason: scripts there run on the browser's engine, which already enforces its own limit and raises a catchable RangeError. Behaviour is unchanged with the default off, and measured to be: EditMode 342/350 with zero failures and PlayMode 675/701 with the same 15, identical to the run before this change.
… Babel did not The suite transpiles each fixture's JSX snippet at runtime, inside the engine under test, on Unity's main thread inside a coroutine. That charges the transpiler's own call depth to a C stack that is already deep, and Babel did not fit: on 6000.5.9f1 every ButtonTests and InputTests case died in BeforeTest with an uncatchable StackOverflowException, 15 in all, across all three engines - CodeTransformer is a static singleton built with JavascriptEngineType.Auto, so every fixture's transform runs through QuickJS whatever engine parameterises the test, which is why the Jint and ClearScript rows reported a QuickJS stack. Pruning Babel's presets changed nothing. Cutting preset-es2015 down to the one plugin the suite actually needs - transform-modules-commonjs, for the fixtures that use import/export - still failed 9/9 on InputTests, because the depth is Babel's parse-then-traverse floor rather than the plugins above it. A newer Babel would not have helped either; 8.x is not shallower. Sucrase rewrites a token stream and never builds an AST, and fits. The other modern answers do not apply here: esbuild, SWC and oxc are WASM or native, and neither QuickJS nor Jint has a WASM runtime, so this has to be pure JS. disableESTransforms is correctness, not tuning. Sucrase lowers optional chaining into a helper that calls value.call(...), and a C# method handle is not a JS function under ClearScript, so Globals.list?.Add(x) threw a TypeError. All three engines run the modern syntax natively, so leaving it alone is both shallower and correct - it is what took InputTests from 7/9 to 9/9. The trade is that fixture output now assumes engine-level support for what the snippet used, where preset-es2015 used to flatten it to ES5. The bundle is generated rather than vendored: `pnpm build:test-transformer` bundles it with esbuild and stamps the Sucrase version into a header comment. The 3.7 MiB @babel/standalone it replaces was five years old (7.14.7) with no recorded provenance, and was excluded from Biome for being over the 1 MiB per-file ceiling, where being unable to read a file is an error rather than a skip; the 724 KiB replacement is under that and is excluded as generated output instead. docs/ still uses @babel/standalone 8.x for its Sandpack examples, which is fine - that one runs in the browser, where stack is not scarce. Verified on 6000.5.9f1: EditMode 342/350 and PlayMode 690/701, zero failures in either, against 675/701 with 15 failures before. Every one of the 113 script snippets in the fixtures was also run through both transpilers offline, and they disagree on nothing.
…rows JS_SetMaxStackSize was bound in 4a26107 but left switched off, because the test suite still ran Babel through this engine to transform its JSX and Babel needed more depth than any useful cap allowed. The suite uses Sucrase now, so that constraint is gone and the cap can carry its intended default of 768 KB. Why on rather than off: the failure it prevents is not recoverable. ng's own 1 MB default (JS_DEFAULT_STACK_SIZE) is measured against the stack of whichever thread created the runtime, and ours is Unity's main thread - already deep in Unity's frames, deeper inside a coroutine, with less than 1 MB left. So the guard never fires, a deeply recursive script exhausts the real stack first, Mono notices at the managed-to-native boundary, and the StackOverflowException that follows cannot be caught: it takes the player or the Editor with it. Under a limit the thread can actually reach, the same script raises a RangeError that a caller can catch and a console can show. The asymmetry settles the value. A cap too high for its platform simply never fires, which is no worse than having none. A cap too low costs recursion that would have completed, but only in the window between the cap and the stack that was really there - 768 KB to about 1 MB on the editors measured. Trading that window for a crash that cannot be caught or reported is the right trade for a UI framework, and 768 KB is the measured point where an overflow raises cleanly on 6000.5.9f1 on the main thread inside a PlayMode coroutine. Zero still means "leave ng's limit alone" and is never forwarded, since ng reads a zero stack_size as unlimited. The doc comment says which direction to move it and why: raise it for deliberately deep code, lower it on a platform whose main thread has a smaller stack, where 768 KB may sit above the real headroom and so never trip. Verified on 6000.5.9f1 with the cap active: EditMode 342/350 and PlayMode 690/701, zero failures in either - identical to the run with it off, so nothing in the suite comes near the limit. That the limit itself works was measured when it was bound: at 768 KB an overflow raises as an ordinary RangeError, at 1 MB it does not raise at all.
Replaces unity-jsb's fork of Bellard-era QuickJS with quickjs-ng. These are two different engines rather than two versions of one, so the native binaries, the C shim over them and every P/Invoke declaration were rebuilt rather than upgraded. The measured plan, and everything learned doing it, is in unity/quickjs/MIGRATION.md.
What it buys: ng has an asynchronous module loader, so an
importof an http URL — and therefore a dynamicimport()— resolves without a blocking frame. That is the thing the old fork could not do.WebGL has ES modules now too, which it never had — not a regression fixed, a capability added. That backend has no QuickJS in it, and the JavaScript standing in for one evaluates through
eval, which cannot runimportorexportat all. It now shares the host half (QuickJSModuleLoaderresolves and fetches, soimport './x'obeys ReactUnity's paths on every target) and hands the linking to the browser: each module is assembled into a blob URL with its specifiers rewritten to its dependencies' URLs, and the root is imported. Live bindings, top-levelawaitand the module cache come from the engine the page already runs.EngineCapabilities.ModuleResolutionis now claimed everywhere.State
Green on everything there is a runner for. The one gap is named in the last row and again below.
mainor a manual dispatch, and was green on both when last run that way.ButtonTestsandInputTests, all three engines, a native stack overflow in setup. Pre-existing and not from this branch's WebGL work: it reproduces on a clean checkout with every local change stashed, and CI's 6000.1.9f1 run of the same commit is green, so it looks specific to the 6000.5 line. Tracked separately.native-quickjs.ymland installed, each verified at 105 of 105 live P/Invoke nameslinux/set compares clean against the new enginepnpm unity player tests --backend il2cpppasses every probe check on Windows x64native/quickjsis new: the binaries used to arrive as unity-jsb prebuilts with nothing in this repo able to reproduce them. They are now built from source, in CI, from a pinned commit —gkurt/quickjs30ceffe, taggedv0.16.2-reactunity.1.The host import hook is gone, which is the knock-on: it stood in for dynamic import on engines that could execute a module but not resolve a specifier, and WebGL was the last one.
CreateImportHook,LoadScript,ModuleCompat.RewriteDynamicImportsand its string-literal scanner are deleted. Keeping it as an extension point did not survive checking how an engine is chosen —ScriptContextbuilds its factory from aninternalswitch over a closed enum, so a third party cannot supply an engine at all.ModuleCompatis down toNeedsModuleScope, andMakeExecutable, which had come to return its argument unchanged, is nowDocumentTypeOf.Also here: an IL2CPP player probe (
pnpm unity player), which is the only check in the repo that covers AOT at all; theAgentBridgedeleted in favour of Unity's own CLI andcom.unity.pipeline; andtests/running green on the 6000.5 line, which an earlier note claimed was impossible.Known limits, none blocking
JS_SetModuleMetaFunc(ECMA-262'sHostGetImportMetaProperties, the only way a host using the async loader can populateimport.meta.url) andJS_LoadModuleAsync(whichquickjs.hnamed twice and never declared) live ingkurt/quickjs. Coordinate the upstream PR with quickjs-ng#1522, whose author proposed a dynamic-import-only version of the same feature. Until it lands, the build depends on the fork..source/jsbplugin.test.mjs, run against the generated jslib and the platform's own dynamic import, in CI. What that cannot reach is the C boundary: the two new dyncall signatures, whether IL2CPP passesJSModuleLoadHandleas a flattenedi32the way clang's wasm ABI says, and whether Unity's WebGL output permitsnew Functionandblob:imports. The docs site's player is a hand-built artifact hosted outside this repo, so rebuilding it is both how the site picks this up and the obvious place to confirm it.StackOverflowExceptioninstead.JS_SetMaxStackSizeis now bound andScriptRuntime.MaxStackSizeexposes it. It defaults to off: measured, a 768 KB cap turns the overflow into a catchableRangeErrorbut is below what the suite's Babel-based JSX transform needs, so capping by default would trade a crash on one editor for red tests on the others.check-jslib.pycompares names, not signatures. Arities and tag values across the two implementations are still a reading exercise; the jslib has no header to diff against.x86(not a Unity 6 build option). Androidx86_64, which Unity 6 does target, is added in their place.