Skip to content

Migrate com.reactunity.quickjs to quickjs-ng - #137

Merged
gkurt merged 58 commits into
mainfrom
quickjs-ng-migration
Aug 24, 2026
Merged

Migrate com.reactunity.quickjs to quickjs-ng#137
gkurt merged 58 commits into
mainfrom
quickjs-ng-migration

Conversation

@gkurt

@gkurt gkurt commented Aug 24, 2026

Copy link
Copy Markdown
Member

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 import of an http URL — and therefore a dynamic import() — 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 run import or export at all. It now shares the host half (QuickJSModuleLoader resolves and fetches, so import './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-level await and the module cache come from the engine the page already runs. EngineCapabilities.ModuleResolution is now claimed everywhere.

State

Green on everything there is a runner for. The one gap is named in the last row and again below.

Unity suites Green on CI. A PR run is one editor by design (6000.1.9f1); the two-editor matrix needs a push to main or a manual dispatch, and was green on both when last run that way.
Unity suites, locally EditMode 342/350, zero failures (four fewer tests than before: the import-hook rewriter's). PlayMode has 15 failures on 6000.5.9f1 — ButtonTests and InputTests, 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 artifacts 11 of 11 built by native-quickjs.yml and installed, each verified at 105 of 105 live P/Invoke names
rendering snapshots the linux/ set compares clean against the new engine
IL2CPP pnpm unity player tests --backend il2cpp passes every probe check on Windows x64
WebGL modules scanner and graph loader green on 45 tests against the generated jslib; not run in a player

native/quickjs is 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/quickjs 30ceffe, tagged v0.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.RewriteDynamicImports and its string-literal scanner are deleted. Keeping it as an extension point did not survive checking how an engine is chosen — ScriptContext builds its factory from an internal switch over a closed enum, so a third party cannot supply an engine at all. ModuleCompat is down to NeedsModuleScope, and MakeExecutable, which had come to return its argument unchanged, is now DocumentTypeOf.

Also here: an IL2CPP player probe (pnpm unity player), which is the only check in the repo that covers AOT at all; the AgentBridge deleted in favour of Unity's own CLI and com.unity.pipeline; and tests/ running green on the 6000.5 line, which an earlier note claimed was impossible.

Known limits, none blocking

  • The fork is not upstream. JS_SetModuleMetaFunc (ECMA-262's HostGetImportMetaProperties, 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) live in gkurt/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.
  • The WebGL module support has not run in a player. The WebGL build module is not installed on the machine this was written on. What is verified is the scanner and the graph loader — 45 tests in .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 passes JSModuleLoadHandle as a flattened i32 the way clang's wasm ABI says, and whether Unity's WebGL output permits new Function and blob: 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.
  • Circular imports are refused on WebGL, with an error naming the cycle. A blob URL needs final text and a cycle's is not — each side needs the other's URL first. Bundler output has no ESM cycles; hand-written graphs can.
  • A deeply recursive script could take the process, and now cannot be made to. quickjs-ng defaults its stack limit to 1 MB and measures it against the creating thread's stack — Unity's main thread, which has less than that left — so the guard never fired and Mono raised an uncatchable StackOverflowException instead. JS_SetMaxStackSize is now bound and ScriptRuntime.MaxStackSize exposes it. It defaults to off: measured, a 768 KB cap turns the overflow into a catchable RangeError but 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.
  • IL2CPP is verified on Windows x64 only. Android and iOS have their own stripping and P/Invoke conventions, and that is where AOT bites hardest.
  • check-jslib.py compares names, not signatures. Arities and tag values across the two implementations are still a reading exercise; the jslib has no header to diff against.
  • Two targets dropped: WSA ARM (the Windows SDK ended 32-bit ARM support) and Android 32-bit x86 (not a Unity 6 build option). Android x86_64, which Unity 6 does target, is added in their place.
  • No sanitizer coverage off desktop, and the manual kitchen-sink walkthrough has not been run.

gkurt added 30 commits August 23, 2026 17:42
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.
gkurt added 12 commits August 24, 2026 01:47
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.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Tegami

This repository uses Tegami to manage releases. When your changes affect published packages, add a changelog file under .tegami/ before merging.

Create a changelog → · Changelog format

Changelogs in this PR

Changelog Title
2026-08-24-4e91cb.md QuickJS is now quickjs-ng, and modules load without blocking a frame

Run pnpm run tegami locally to create a changelog interactively.

Managed by Tegami.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Unit Tests 6000.1.9f1

    2 files  ±0    205 suites  +3   17m 12s ⏱️ -15s
  642 tests  - 2    631 ✅  - 2  11 💤 ±0  0 ❌ ±0 
1 056 runs  +2  1 037 ✅ +3  19 💤  - 1  0 ❌ ±0 

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.
          #708090 21px,
          #d9ecff 22px,
          #d9ecff 24px,
          #d9ecff 67px,
          #d9ecff 69px,
          225deg,
          circle at 0% 50%,
          circle at 100% 50%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
…
ReactUnity.Tests.Editor.StringMarshallingTests ‑ AnEmptyStringReachesCsharpAsEmpty
ReactUnity.Tests.Editor.StringMarshallingTests ‑ AnEmptyStringReachesScriptAsEmpty
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((blueprint, 
    background-color: #269;
    background-image: linear-gradient(white 2px, transparent 2px), linear-gradient(90deg, white 2px, transparent 2px),
      linear-gradient(rgba(255, 255, 255, 0.3) 1px, transparent 1px),
      linear-gradient(90deg, rgba(255, 255, 255, 0.3) 1px, transparent 1px);
    background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px;
    background-position: -2px -2px, -2px -2px, -1px -1px, -1px -1px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((japanese-cube, 
    background-color: #556;
    background-image: linear-gradient(30deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(150deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(30deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(150deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(60deg, #99a 25%, transparent 25.5%, transparent 75%, #99a 75%, #99a),
      linear-gradient(60deg, #99a 25%, transparent 25.5%, transparent 75%, #99a 75%, #99a);
    background-size: 80px 140px;
    background-position: 0 0, 0 0, 40px 70px, 40px 70px, 0 0, 40px 70px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((stars, 
    background: linear-gradient(324deg, #232927 4%, transparent 4%) -70px 43px,
      linear-gradient(36deg, #232927 4%, transparent 4%) 30px 43px,
      linear-gradient(72deg, #e3d7bf 8.5%, transparent 8.5%) 30px 43px,
      linear-gradient(288deg, #e3d7bf 8.5%, transparent 8.5%) -70px 43px,
      linear-gradient(216deg, #e3d7bf 7.5%, transparent 7.5%) -70px 23px,
      linear-gradient(144deg, #e3d7bf 7.5%, transparent 7.5%) 30px 23px,
      linear-gradient(324deg, #232927 4%, transparent 4%) -20px 93px,
      linear-gradient(36deg, #232927 4%, transparent 4%) 80px 93px,
      linear-gradient(72deg, #e3d7bf 8.5%, transparent 8.5%) 80px 93px,
      linear-gradient(288deg, #e3d7bf 8.5%, transparent 8.5%) -20px 93px,
      linear-gradient(216deg, #e3d7bf 7.5%, transparent 7.5%) -20px 73px,
      linear-gradient(144deg, #e3d7bf 7.5%, transparent 7.5%) 80px 73px;
    background-color: #232927;
    background-size: 100px 100px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((steps, 
    background-color: #ff7d9d;
    background-size: 58px 58px;
    background-position: 0px 2px, 4px 35px, 29px 31px, 33px 6px, 0px 36px, 4px 2px, 29px 6px, 33px 30px;
    background-image: linear-gradient(335deg, #c90032 23px, transparent 23px),
      linear-gradient(155deg, #c90032 23px, transparent 23px), linear-gradient(335deg, #c90032 23px, transparent 23px),
      linear-gradient(155deg, #c90032 23px, transparent 23px), linear-gradient(335deg, #c90032 10px, transparent 10px),
      linear-gradient(155deg, #c90032 10px, transparent 10px), linear-gradient(335deg, #c90032 10px, transparent 10px),
      linear-gradient(155deg, #c90032 10px, transparent 10px);
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((waves, 
    background: radial-gradient(
          circle at 100% 50%,
          transparent 20%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
          transparent 35%,
          transparent
        )
        0 0,
      radial-gradient(
          circle at 0% 50%,
          transparent 20%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
          transparent 35%,
          transparent
        )
        0 -50px;
    background-color: slategray;
    background-size: 75px 100px;
    background-position: 0 0, 0 -50px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((weave, 
    background: linear-gradient(
        135deg,
        #708090 21px,
        #d9ecff 22px,
        #d9ecff 24px,
        transparent 24px,
        transparent 67px,
        #d9ecff 67px,
        #d9ecff 69px,
        transparent 69px
      ),
      linear-gradient(
          225deg,
          #708090 21px,
          #d9ecff 22px,
          #d9ecff 24px,
          transparent 24px,
          transparent 67px,
          #d9ecff 67px,
          #d9ecff 69px,
          transparent 69px
        )
        0 64px;
    background-color: #708090;
    background-size: 64px 128px;
))
ReactUnity.Tests.ModuleSyntaxTests ‑ EveryEngineResolvesSpecifiersItself
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
…
This pull request removes 14 skipped tests and adds 8 skipped tests. Note that renamed tests count towards both.
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
))
</svg>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((01, 
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((00, 
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((01, 
…
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ InlineSvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ SvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ SvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))

♻️ This comment has been updated with latest results.

gkurt added 3 commits August 24, 2026 04:28
… 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.
@github-actions

Copy link
Copy Markdown
Contributor

Unit Tests 6000.0.51f1

    2 files  ±0    205 suites  +3   15m 11s ⏱️ - 2m 41s
  646 tests +2    635 ✅ +2  11 💤 ±0  0 ❌ ±0 
1 060 runs  +6  1 041 ✅ +7  19 💤  - 1  0 ❌ ±0 

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.
          #708090 21px,
          #d9ecff 22px,
          #d9ecff 24px,
          #d9ecff 67px,
          #d9ecff 69px,
          225deg,
          circle at 0% 50%,
          circle at 100% 50%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
…
ReactUnity.Tests.Editor.StringMarshallingTests ‑ AnEmptyStringReachesCsharpAsEmpty
ReactUnity.Tests.Editor.StringMarshallingTests ‑ AnEmptyStringReachesScriptAsEmpty
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((blueprint, 
    background-color: #269;
    background-image: linear-gradient(white 2px, transparent 2px), linear-gradient(90deg, white 2px, transparent 2px),
      linear-gradient(rgba(255, 255, 255, 0.3) 1px, transparent 1px),
      linear-gradient(90deg, rgba(255, 255, 255, 0.3) 1px, transparent 1px);
    background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px;
    background-position: -2px -2px, -2px -2px, -1px -1px, -1px -1px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((japanese-cube, 
    background-color: #556;
    background-image: linear-gradient(30deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(150deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(30deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(150deg, #445 12%, transparent 12.5%, transparent 87%, #445 87.5%, #445),
      linear-gradient(60deg, #99a 25%, transparent 25.5%, transparent 75%, #99a 75%, #99a),
      linear-gradient(60deg, #99a 25%, transparent 25.5%, transparent 75%, #99a 75%, #99a);
    background-size: 80px 140px;
    background-position: 0 0, 0 0, 40px 70px, 40px 70px, 0 0, 40px 70px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((stars, 
    background: linear-gradient(324deg, #232927 4%, transparent 4%) -70px 43px,
      linear-gradient(36deg, #232927 4%, transparent 4%) 30px 43px,
      linear-gradient(72deg, #e3d7bf 8.5%, transparent 8.5%) 30px 43px,
      linear-gradient(288deg, #e3d7bf 8.5%, transparent 8.5%) -70px 43px,
      linear-gradient(216deg, #e3d7bf 7.5%, transparent 7.5%) -70px 23px,
      linear-gradient(144deg, #e3d7bf 7.5%, transparent 7.5%) 30px 23px,
      linear-gradient(324deg, #232927 4%, transparent 4%) -20px 93px,
      linear-gradient(36deg, #232927 4%, transparent 4%) 80px 93px,
      linear-gradient(72deg, #e3d7bf 8.5%, transparent 8.5%) 80px 93px,
      linear-gradient(288deg, #e3d7bf 8.5%, transparent 8.5%) -20px 93px,
      linear-gradient(216deg, #e3d7bf 7.5%, transparent 7.5%) -20px 73px,
      linear-gradient(144deg, #e3d7bf 7.5%, transparent 7.5%) 80px 73px;
    background-color: #232927;
    background-size: 100px 100px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((steps, 
    background-color: #ff7d9d;
    background-size: 58px 58px;
    background-position: 0px 2px, 4px 35px, 29px 31px, 33px 6px, 0px 36px, 4px 2px, 29px 6px, 33px 30px;
    background-image: linear-gradient(335deg, #c90032 23px, transparent 23px),
      linear-gradient(155deg, #c90032 23px, transparent 23px), linear-gradient(335deg, #c90032 23px, transparent 23px),
      linear-gradient(155deg, #c90032 23px, transparent 23px), linear-gradient(335deg, #c90032 10px, transparent 10px),
      linear-gradient(155deg, #c90032 10px, transparent 10px), linear-gradient(335deg, #c90032 10px, transparent 10px),
      linear-gradient(155deg, #c90032 10px, transparent 10px);
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((waves, 
    background: radial-gradient(
          circle at 100% 50%,
          transparent 20%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
          transparent 35%,
          transparent
        )
        0 0,
      radial-gradient(
          circle at 0% 50%,
          transparent 20%,
          rgba(255, 255, 255, 0.3) 21%,
          rgba(255, 255, 255, 0.3) 34%,
          transparent 35%,
          transparent
        )
        0 -50px;
    background-color: slategray;
    background-size: 75px 100px;
    background-position: 0 0, 0 -50px;
))
ReactUnity.Tests.GraphicalTests ‑ AdvancedGradientSnapshots((weave, 
    background: linear-gradient(
        135deg,
        #708090 21px,
        #d9ecff 22px,
        #d9ecff 24px,
        transparent 24px,
        transparent 67px,
        #d9ecff 67px,
        #d9ecff 69px,
        transparent 69px
      ),
      linear-gradient(
          225deg,
          #708090 21px,
          #d9ecff 22px,
          #d9ecff 24px,
          transparent 24px,
          transparent 67px,
          #d9ecff 67px,
          #d9ecff 69px,
          transparent 69px
        )
        0 64px;
    background-color: #708090;
    background-size: 64px 128px;
))
ReactUnity.Tests.ModuleSyntaxTests ‑ EveryEngineResolvesSpecifiersItself
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
…
This pull request removes 14 skipped tests and adds 8 skipped tests. Note that renamed tests count towards both.
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
))
</svg>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((01, 
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((00, 
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((01, 
…
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.SvgTests ‑ InlineSvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.SvgTests ‑ SvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ InlineSvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ InlineSvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ SvgSnapshots((00, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 93 107' id='svg'>
  <path d='M74,74a42,42 0,1,0-57,0l28,29a42,41 0,0,0 0-57' fill='#00a3dc' fill-rule='evenodd'/>
</svg>
))
ReactUnity.Tests.UIToolkit.UIToolkitGraphicalTests ‑ SvgSnapshots((01, 
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='black' id='svg'>
  <path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' />
</svg>
))

gkurt added 3 commits August 24, 2026 11:24
…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.
@gkurt
gkurt marked this pull request as ready for review August 24, 2026 09:09
gkurt added 6 commits August 24, 2026 13:03
…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.
@gkurt
gkurt merged commit 08193cf into main Aug 24, 2026
17 checks passed
@gkurt
gkurt deleted the quickjs-ng-migration branch August 24, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant